feat: add aliyun qwen backup provider
This commit is contained in:
@@ -110,12 +110,12 @@ Android APK:
|
||||
- 当前原生聊天页已把待审批推荐前移到主消息流:`ProjectDetailActivity` 会直接显示 `确认下发 / 拒绝`,刷新后也能恢复最近一条待确认推荐
|
||||
- 当前 `approval_required` 群聊在已有待确认推荐时,会拒绝继续生成新的推荐,并提示用户先确认或拒绝当前推荐,避免审批消息叠加
|
||||
- 当前三条聊天主链都已接入真实等待链路:`主 Agent 单聊 / 普通线程单聊 / 群聊确认下发` 当前都会返回任务信息,原生 Android 会保持等待直到收到真实回写或明确超时提示
|
||||
- 当前 `我的 > AI 账号` 已补 `登录 OpenAI 平台账号` 与 `绑定 Master Codex Node` 两条显式入口;OpenAI API 登录成功后会立即设为当前主控
|
||||
- 当前 `我的 > AI 账号` 已补 `登录 OpenAI 平台账号`、`接入阿里百炼备用账号` 与 `绑定 Master Codex Node` 三条显式入口;OpenAI API 登录成功后会立即设为当前主控,阿里百炼账号会作为备用链路保存
|
||||
- 当前 `登录 OpenAI 平台账号` 已升级成浏览器辅助登录流:会先进入原生引导页,再自动打开 `OpenAI Platform` 登录页;用户登录后可直接跳到 `API Keys` 页面,回 APP 粘贴 key 完成接入
|
||||
- 当前 `AI 账号` 页顶部会显式展示“当前主控身份”,并提供 `校验主控 / 测试主 Agent 对话` 两个动作,切换主控后可直接验证聊天通路
|
||||
- 当前 `我的 > 主 Agent 提示词 / 记忆` 页面已补:管理员全局主提示词只读展示、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 跨项目项目记忆的新增、编辑、删除接口;当前对话设置按登录账号隔离,管理员全局主提示词不可覆盖
|
||||
- 当前 `OpenAiOnboardingActivity` 在登录成功后会直接给出 `测试主 Agent 对话` 入口,可一键跳到 `master-agent` 聊天页
|
||||
- 当前主控若还是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API` 备用账号,避免聊天直接掉成失败日志
|
||||
- 当前主控若还是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API / 阿里百炼 Qwen` 备用账号,避免聊天直接掉成失败日志
|
||||
- 当前原生 Android 的聊天发送已改成更短的客户端等待窗口;`master-agent` 单聊依赖服务端快速入队和消息流里的“思考中 / 超时 / 重试等待”状态,不再要求客户端长时间同步阻塞
|
||||
- 当前群资料页已经支持“修复群成员”:如果历史脏群里混入了 `master-agent` 或失效线程引用,前台会明确提示并允许重新选择真实线程成员,修复后会正式写回群成员账本
|
||||
- 当前原生聊天页也会直接提示“修复群成员”:当群里存在失效线程或不可下发成员时,`ProjectDetailActivity` 会在消息流上方直接给出 `去修复` 入口,并跳到群资料页完成修复
|
||||
|
||||
@@ -19,8 +19,8 @@ import org.json.JSONObject;
|
||||
public class AiAccountsActivity extends BossScreenActivity {
|
||||
private static final String[] ROLE_VALUES = {"primary", "backup", "api_fallback"};
|
||||
private static final String[] ROLE_LABELS = {"主 GPT", "备用 GPT", "API 容灾"};
|
||||
private static final String[] PROVIDER_VALUES = {"master_codex_node", "openai_api"};
|
||||
private static final String[] PROVIDER_LABELS = {"Master Codex Node", "OpenAI API"};
|
||||
private static final String[] PROVIDER_VALUES = {"master_codex_node", "openai_api", "aliyun_qwen_api"};
|
||||
private static final String[] PROVIDER_LABELS = {"Master Codex Node", "OpenAI API", "阿里百炼 Qwen"};
|
||||
private boolean refreshOnResume;
|
||||
|
||||
@Override
|
||||
@@ -174,6 +174,15 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
v -> openOpenAiOnboardingScreen()
|
||||
));
|
||||
|
||||
section.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"接入阿里百炼备用账号",
|
||||
"把阿里百炼 Qwen 兼容接口接成主 Agent 的备用链路。",
|
||||
"建议模型:qwen3.5-plus 或 qwen3.5-flash。",
|
||||
null,
|
||||
v -> openAliyunQwenOnboardingDialog()
|
||||
));
|
||||
|
||||
section.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"绑定电脑上的 Codex 节点",
|
||||
@@ -330,6 +339,40 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
.show();
|
||||
}
|
||||
|
||||
private void openAliyunQwenOnboardingDialog() {
|
||||
final EditText labelInput = BossUi.buildInput(this, "标签,例如 备用 GPT", false);
|
||||
labelInput.setText("备用 GPT");
|
||||
final EditText displayNameInput = BossUi.buildInput(this, "显示名称", false);
|
||||
displayNameInput.setText("阿里百炼备用账号");
|
||||
final EditText accountIdentifierInput = BossUi.buildInput(this, "账号标识 / 备注", false);
|
||||
final EditText modelInput = BossUi.buildInput(this, "模型,例如 qwen3.5-plus", false);
|
||||
modelInput.setText("qwen3.5-plus");
|
||||
final EditText apiKeyInput = BossUi.buildInput(this, "阿里百炼 API Key", false);
|
||||
apiKeyInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
|
||||
LinearLayout form = new LinearLayout(this);
|
||||
form.setOrientation(LinearLayout.VERTICAL);
|
||||
form.addView(BossUi.buildFormCell(this, "标签", "建议使用 备用 GPT", labelInput));
|
||||
form.addView(BossUi.buildFormCell(this, "显示名称", "会展示在账号列表中", displayNameInput));
|
||||
form.addView(BossUi.buildFormCell(this, "账号标识", "可填账号名或自定义备注", accountIdentifierInput));
|
||||
form.addView(BossUi.buildFormCell(this, "模型", "例如 qwen3.5-plus", modelInput));
|
||||
form.addView(BossUi.buildFormCell(this, "API Key", "填写后会保存为备用链路,不会抢占当前主控", apiKeyInput));
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("接入阿里百炼备用账号")
|
||||
.setMessage("接入成功后,这个账号会作为主 Agent 的备用模型链路,在主节点离线或失败时自动接管。")
|
||||
.setView(form)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("接入", (dialog, which) -> submitAliyunQwenOnboarding(
|
||||
labelInput.getText().toString().trim(),
|
||||
displayNameInput.getText().toString().trim(),
|
||||
accountIdentifierInput.getText().toString().trim(),
|
||||
modelInput.getText().toString().trim(),
|
||||
apiKeyInput.getText().toString().trim()
|
||||
))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void submitOpenAiOnboarding(
|
||||
String label,
|
||||
String displayName,
|
||||
@@ -428,6 +471,45 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void submitAliyunQwenOnboarding(
|
||||
String label,
|
||||
String displayName,
|
||||
String accountIdentifier,
|
||||
String model,
|
||||
String apiKey
|
||||
) {
|
||||
if (label.isEmpty() || displayName.isEmpty() || apiKey.isEmpty()) {
|
||||
showMessage("标签、显示名称和 API Key 不能为空");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("label", label);
|
||||
payload.put("displayName", displayName);
|
||||
payload.put("accountIdentifier", accountIdentifier);
|
||||
payload.put("model", model);
|
||||
payload.put("apiKey", apiKey);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.onboardAliyunQwenAccount(payload);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("阿里百炼备用账号已接入。");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
String detail = error.getMessage();
|
||||
showMessage(detail == null || detail.trim().isEmpty()
|
||||
? "阿里百炼备用账号接入失败,请稍后重试。"
|
||||
: "阿里百炼备用账号接入失败:" + detail);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String extractAccountId(JSONObject json) {
|
||||
if (json == null) {
|
||||
return "";
|
||||
@@ -486,7 +568,7 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
form.addView(BossUi.buildFormCell(this, "节点 ID", "Master Codex Node 的唯一标识", nodeIdInput));
|
||||
form.addView(BossUi.buildFormCell(this, "节点名称", "用于快速识别节点", nodeLabelInput));
|
||||
form.addView(BossUi.buildFormCell(this, "模型", "例如 gpt-5.4", modelInput));
|
||||
form.addView(BossUi.buildFormCell(this, "API Key", "仅 OpenAI API 模式需要", apiKeyInput));
|
||||
form.addView(BossUi.buildFormCell(this, "API Key", "OpenAI API / 阿里百炼 Qwen 模式需要", apiKeyInput));
|
||||
form.addView(BossUi.buildFormCell(this, "登录状态备注", "可记录 Plus、有无风控等状态", loginStatusInput));
|
||||
form.addView(BossUi.buildFormCell(this, "账号角色", null, roleSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "提供方", null, providerSpinner));
|
||||
|
||||
@@ -396,6 +396,10 @@ public class BossApiClient {
|
||||
return onboardAccount("/api/v1/accounts/onboard/openai-api", payload);
|
||||
}
|
||||
|
||||
public ApiResponse onboardAliyunQwenAccount(JSONObject payload) throws IOException, JSONException {
|
||||
return onboardAccount("/api/v1/accounts/onboard/aliyun-qwen", payload);
|
||||
}
|
||||
|
||||
public ApiResponse onboardMasterNodeAccount(JSONObject payload) throws IOException, JSONException {
|
||||
return onboardAccount("/api/v1/accounts/onboard/master-node", payload);
|
||||
}
|
||||
|
||||
@@ -344,8 +344,8 @@
|
||||
- 返回体会附带 `task.taskId / taskType / status`,给 Web 和原生 Android 保持等待真实回写使用
|
||||
- `projectId=master-agent` 且 `kind=text` 时,会先返回 `masterReplyState + task`,真实回复随后异步回写到账本
|
||||
- 当前主链路优先走 `Master Codex Node`:`task queue -> local-agent -> codex exec -> complete`
|
||||
- 如果当前主控是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 当前会优先尝试已配置的 `OpenAI API` 账号,避免聊天直接只剩失败日志
|
||||
- 如本机节点未接通,可切到 `OpenAI API` 容灾账号
|
||||
- 如果当前主控是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 当前会优先尝试已配置的 `OpenAI API / 阿里百炼 Qwen` 账号,避免聊天直接只剩失败日志
|
||||
- 如本机节点未接通,可切到 `OpenAI API` 或 `阿里百炼 Qwen` 备用账号
|
||||
- 群聊项目当前会带上 `collaborationGate`,用于标明当前是否需要先经主 Agent / 用户审批
|
||||
- 群聊文本消息当前还会返回 `dispatchPlan / dispatchRecommendation`,用于展示主 Agent 推荐的线程下发方案
|
||||
- 如果群里已经有一条待确认推荐,接口会直接返回 `409`,要求先确认或拒绝当前推荐,避免审批消息叠加
|
||||
@@ -467,6 +467,21 @@
|
||||
- 返回结果会带当前主控状态摘要,供原生 Android 直接弹出“测试主 Agent 对话”
|
||||
- 若服务器当前无法访问 `api.openai.com`,会直接返回明确中文网络错误,而不是只返回 `fetch failed`
|
||||
|
||||
#### `POST /api/v1/accounts/onboard/aliyun-qwen`
|
||||
|
||||
- 用途:通过阿里百炼兼容接口接入 `Qwen` 备用账号
|
||||
- 输入:
|
||||
- `label`
|
||||
- `displayName`
|
||||
- `accountIdentifier`
|
||||
- `model`
|
||||
- `apiKey`
|
||||
- 当前行为:
|
||||
- 先对候选 `API Key` 做真实阿里百炼兼容 `responses` 探针校验
|
||||
- 校验成功后创建或更新 `aliyun_qwen_api` 备用账号
|
||||
- 默认不抢占当前主控,只作为 fallback 链路保存
|
||||
- 返回当前主控摘要,方便前台立刻刷新账号状态
|
||||
|
||||
#### `POST /api/v1/accounts/onboard/master-node`
|
||||
|
||||
- 用途:显式绑定一台电脑上的 `Master Codex Node`
|
||||
@@ -489,9 +504,11 @@
|
||||
- 当前 provider:
|
||||
- `master_codex_node`
|
||||
- `openai_api`
|
||||
- `aliyun_qwen_api`
|
||||
- 当前产品语义:
|
||||
- `master_codex_node` 表示已经在绑定电脑上登录 `ChatGPT Plus / Codex` 的执行节点
|
||||
- `openai_api` 只作为用户自行配置的 API 容灾
|
||||
- `openai_api` 作为用户自行配置的 OpenAI 主控或容灾
|
||||
- `aliyun_qwen_api` 作为用户自行配置的阿里百炼 Qwen 兼容备用链路
|
||||
|
||||
#### `GET /api/v1/accounts/[accountId]`
|
||||
|
||||
@@ -515,6 +532,7 @@
|
||||
- 当前行为:
|
||||
- `master_codex_node`:明确提示“主 GPT 不在手机里直接登录”,并同时校验绑定设备是否在线;在线时返回 `ready`,离线时返回 `degraded`
|
||||
- `openai_api`:实际调用模型返回探针结果
|
||||
- `aliyun_qwen_api`:实际调用阿里百炼兼容接口返回探针结果
|
||||
- 当前约束:
|
||||
- `openai_api` 的容灾 Key 由用户在 APP 内配置,不走服务器默认预置
|
||||
- 手机端当前没有直接的 ChatGPT OAuth 登录流程;主 GPT 必须先在绑定电脑上的 Codex / ChatGPT Plus 会话里完成登录
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- AI 账号摘要接口:`http://127.0.0.1:3000/api/v1/accounts`
|
||||
- AI 账号校验接口:`POST http://127.0.0.1:3000/api/v1/accounts/[accountId]/validate`
|
||||
- AI 账号 OpenAI 登录接口:`POST http://127.0.0.1:3000/api/v1/accounts/onboard/openai-api`
|
||||
- AI 账号阿里备用接入接口:`POST http://127.0.0.1:3000/api/v1/accounts/onboard/aliyun-qwen`
|
||||
- AI 账号 Master Node 绑定接口:`POST http://127.0.0.1:3000/api/v1/accounts/onboard/master-node`
|
||||
- 设备 Skill 同步接口:`http://127.0.0.1:3000/api/v1/devices/mac-studio/skills`
|
||||
- 登录接口:`POST http://127.0.0.1:3000/api/auth/login`
|
||||
@@ -107,12 +108,12 @@ cd /Users/kris/code/boss
|
||||
- 当前 `approval_required` 群聊在已经存在一条 `pending_user_confirmation` 推荐时,会拒绝继续创建新的推荐并返回 `409`,前台会提示用户先确认或拒绝当前推荐
|
||||
- 当前普通单线程聊天也已补上真实执行链:`POST /api/v1/projects/[projectId]/messages` 不再只写用户消息,而是会追加 `conversation_reply` 任务;绑定设备上的 `local-agent` 认领后会继续恢复到真实 Codex 线程,再把线程原始回复回写到该聊天窗口
|
||||
- 当前 Web 群聊详情页也已补上待确认推荐的刷新恢复:服务端会在页面渲染时读取最近一条 `pending_user_confirmation` 的 dispatch plan,聊天输入区会继续显示“等待你确认主 Agent 推荐”,不再因刷新丢失确认入口
|
||||
- 当前 `AI 账号` 页面已分成两条显式接入链:`登录 OpenAI 平台账号(API Key)` 和 `绑定 Master Codex Node`;OpenAI API 登录成功后会立即切成当前主控
|
||||
- 当前 `AI 账号` 页面已分成三条显式接入链:`登录 OpenAI 平台账号(API Key)`、`接入阿里百炼备用账号` 和 `绑定 Master Codex Node`;OpenAI API 登录成功后会立即切成当前主控,阿里百炼账号会作为备用链路保存
|
||||
- 当前 `登录 OpenAI 平台账号` 已升级成浏览器辅助登录流:原生 Android 会先进入 `OpenAiOnboardingActivity`,自动打开 `OpenAI Platform` 登录页;用户登录后可直接跳到 `API Keys` 页面,回 APP 粘贴 key 完成接入
|
||||
- 当前 `OpenAiOnboardingActivity` 在登录成功后会直接弹出 `测试主 Agent 对话`,可一键进入 `master-agent` 聊天页验证主控链路
|
||||
- 当前 `AI 账号` 页顶部会直接展示“当前主控身份”,并提供 `校验主控 / 测试主 Agent 对话` 两个入口,切换主控后不必再手动退回会话页验证
|
||||
- 当前 `我的 > 主 Agent 提示词 / 记忆` 页面已接通:管理员全局主提示词只读展示、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 跨项目项目记忆都可以在 Web 端查看和编辑;当前对话设置按登录账号隔离,管理员全局主提示词不可覆盖
|
||||
- 当前如果主控身份还是 `Master Codex Node`,但该节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API` 备用账号,不再把失败日志直接原样回给用户
|
||||
- 当前如果主控身份还是 `Master Codex Node`,但该节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API / 阿里百炼 Qwen` 备用账号,不再把失败日志直接原样回给用户
|
||||
- 当前原生 Android 的聊天发送已收短客户端等待窗口;`master-agent` 单聊依赖服务端快速入队和消息流里的“主 Agent 思考中 / 回复超时 / 重试等待”状态,不再要求客户端长时间同步阻塞
|
||||
- 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口
|
||||
- Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链
|
||||
|
||||
@@ -6,8 +6,8 @@ function isValidRole(value: string): value is "primary" | "backup" | "api_fallba
|
||||
return value === "primary" || value === "backup" || value === "api_fallback";
|
||||
}
|
||||
|
||||
function isValidProvider(value: string): value is "master_codex_node" | "openai_api" {
|
||||
return value === "master_codex_node" || value === "openai_api";
|
||||
function isValidProvider(value: string): value is "master_codex_node" | "openai_api" | "aliyun_qwen_api" {
|
||||
return value === "master_codex_node" || value === "openai_api" || value === "aliyun_qwen_api";
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
|
||||
82
src/app/api/v1/accounts/onboard/aliyun-qwen/route.ts
Normal file
82
src/app/api/v1/accounts/onboard/aliyun-qwen/route.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getMasterIdentitySummaryFromState, readState, saveAiAccount, updateAiAccountHealth } from "@/lib/boss-data";
|
||||
import { probeApiCompatibleAccount } from "@/lib/boss-master-agent";
|
||||
|
||||
function chooseAliyunBackupAccountId(state: Awaited<ReturnType<typeof readState>>) {
|
||||
return (
|
||||
state.aiAccounts.find((item) => item.provider === "aliyun_qwen_api" && item.role === "backup")?.accountId ||
|
||||
"aliyun-qwen-backup"
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
accountIdentifier?: string;
|
||||
model?: string;
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
if (!body.displayName?.trim()) {
|
||||
return NextResponse.json({ ok: false, message: "显示名称不能为空。" }, { status: 400 });
|
||||
}
|
||||
if (!body.apiKey?.trim()) {
|
||||
return NextResponse.json({ ok: false, message: "请先填写阿里百炼 API Key。" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const probe = await probeApiCompatibleAccount({
|
||||
provider: "aliyun_qwen_api",
|
||||
apiKey: body.apiKey,
|
||||
model: body.model,
|
||||
});
|
||||
|
||||
const state = await readState();
|
||||
const accountId = chooseAliyunBackupAccountId(state);
|
||||
const account = await saveAiAccount({
|
||||
accountId,
|
||||
label: body.label?.trim() || "备用 GPT",
|
||||
role: "backup",
|
||||
provider: "aliyun_qwen_api",
|
||||
displayName: body.displayName.trim(),
|
||||
accountIdentifier: body.accountIdentifier?.trim() || undefined,
|
||||
model: probe.model,
|
||||
apiKey: body.apiKey.trim(),
|
||||
enabled: true,
|
||||
setActive: false,
|
||||
loginStatusNote: "已接入阿里百炼 Qwen 兼容接口,可作为主 Agent 的备用模型链路。",
|
||||
});
|
||||
|
||||
await updateAiAccountHealth({
|
||||
accountId: account.accountId,
|
||||
status: "ready",
|
||||
lastError: undefined,
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const nextState = await readState();
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
accountId: account.accountId,
|
||||
account,
|
||||
activeIdentity: getMasterIdentitySummaryFromState(nextState),
|
||||
requestId: probe.requestId,
|
||||
message: "阿里百炼备用账号已接入,可作为主 Agent 的备用链路。",
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "ALIYUN_QWEN_ONBOARD_FAILED" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@ function isValidRole(value: string): value is "primary" | "backup" | "api_fallba
|
||||
return value === "primary" || value === "backup" || value === "api_fallback";
|
||||
}
|
||||
|
||||
function isValidProvider(value: string): value is "master_codex_node" | "openai_api" {
|
||||
return value === "master_codex_node" || value === "openai_api";
|
||||
function isValidProvider(value: string): value is "master_codex_node" | "openai_api" | "aliyun_qwen_api" {
|
||||
return value === "master_codex_node" || value === "openai_api" || value === "aliyun_qwen_api";
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
|
||||
@@ -26,7 +26,7 @@ type AccountDraft = {
|
||||
loginStatusNote: string;
|
||||
};
|
||||
|
||||
type OnboardingMode = "openai_api" | "master_codex_node" | null;
|
||||
type OnboardingMode = "openai_api" | "aliyun_qwen_api" | "master_codex_node" | null;
|
||||
|
||||
type OpenAiOnboardDraft = {
|
||||
label: string;
|
||||
@@ -36,6 +36,14 @@ type OpenAiOnboardDraft = {
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
type AliyunQwenOnboardDraft = {
|
||||
label: string;
|
||||
displayName: string;
|
||||
accountIdentifier: string;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
type MasterNodeOnboardDraft = {
|
||||
label: string;
|
||||
displayName: string;
|
||||
@@ -56,6 +64,7 @@ function roleOptions() {
|
||||
function providerOptions() {
|
||||
return [
|
||||
{ value: "openai_api", label: "OpenAI API" },
|
||||
{ value: "aliyun_qwen_api", label: "阿里百炼 Qwen" },
|
||||
{ value: "master_codex_node", label: "Master Codex Node / ChatGPT Plus 节点" },
|
||||
] as const;
|
||||
}
|
||||
@@ -70,6 +79,16 @@ function defaultOpenAiOnboardDraft(): OpenAiOnboardDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function defaultAliyunQwenOnboardDraft(): AliyunQwenOnboardDraft {
|
||||
return {
|
||||
label: "备用 GPT",
|
||||
displayName: "阿里百炼备用账号",
|
||||
accountIdentifier: "",
|
||||
model: "qwen3.5-plus",
|
||||
apiKey: "",
|
||||
};
|
||||
}
|
||||
|
||||
function defaultMasterNodeOnboardDraft(): MasterNodeOnboardDraft {
|
||||
return {
|
||||
label: "主 GPT",
|
||||
@@ -184,6 +203,8 @@ export function AiAccountsClient({
|
||||
const [guideAccountId, setGuideAccountId] = useState<string | null>(null);
|
||||
const [onboardingMode, setOnboardingMode] = useState<OnboardingMode>(null);
|
||||
const [openAiOnboardDraft, setOpenAiOnboardDraft] = useState<OpenAiOnboardDraft>(defaultOpenAiOnboardDraft());
|
||||
const [aliyunQwenOnboardDraft, setAliyunQwenOnboardDraft] =
|
||||
useState<AliyunQwenOnboardDraft>(defaultAliyunQwenOnboardDraft());
|
||||
const [masterNodeOnboardDraft, setMasterNodeOnboardDraft] = useState<MasterNodeOnboardDraft>(
|
||||
defaultMasterNodeOnboardDraft(),
|
||||
);
|
||||
@@ -296,6 +317,37 @@ export function AiAccountsClient({
|
||||
setMessage(result.message || "OpenAI 平台账号登录失败。");
|
||||
}
|
||||
|
||||
async function submitAliyunQwenOnboarding() {
|
||||
const draft = aliyunQwenOnboardDraft;
|
||||
if (!draft.displayName.trim() || !draft.apiKey.trim()) {
|
||||
setMessage("请先填写阿里百炼备用账号名称和 API Key。");
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyKey("onboard:aliyun_qwen_api");
|
||||
const response = await fetch("/api/v1/accounts/onboard/aliyun-qwen", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
label: draft.label.trim() || "备用 GPT",
|
||||
displayName: draft.displayName.trim(),
|
||||
accountIdentifier: draft.accountIdentifier.trim(),
|
||||
model: draft.model.trim() || "qwen3.5-plus",
|
||||
apiKey: draft.apiKey.trim(),
|
||||
}),
|
||||
});
|
||||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||||
setBusyKey(null);
|
||||
if (result.ok) {
|
||||
setMessage(result.message || "阿里百炼备用账号已接入。");
|
||||
setAliyunQwenOnboardDraft(defaultAliyunQwenOnboardDraft());
|
||||
closeOnboarding();
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
setMessage(result.message || "阿里百炼备用账号接入失败。");
|
||||
}
|
||||
|
||||
async function submitMasterNodeOnboarding() {
|
||||
const draft = masterNodeOnboardDraft;
|
||||
if (!draft.displayName.trim() || !draft.nodeId.trim()) {
|
||||
@@ -330,7 +382,9 @@ export function AiAccountsClient({
|
||||
}
|
||||
|
||||
function onboardingTitle() {
|
||||
return onboardingMode === "openai_api" ? "登录 OpenAI 平台账号" : "绑定电脑上的 Codex 节点";
|
||||
if (onboardingMode === "openai_api") return "登录 OpenAI 平台账号";
|
||||
if (onboardingMode === "aliyun_qwen_api") return "接入阿里百炼备用账号";
|
||||
return "绑定电脑上的 Codex 节点";
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -399,6 +453,30 @@ export function AiAccountsClient({
|
||||
入口字段:显示名称、账号标识、模型、API Key。
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOnboardingMode("aliyun_qwen_api");
|
||||
setMessage("");
|
||||
}}
|
||||
disabled={!canManage}
|
||||
className="rounded-3xl border border-[#E5E5EA] bg-gradient-to-br from-[#FFF8EE] to-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[16px] font-semibold text-[#111111]">接入阿里百炼备用账号</div>
|
||||
<div className="mt-2 text-[12px] leading-6 text-[#57606A]">
|
||||
作为主 Agent 的备用模型链路,默认建议用 qwen3.5-plus。
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full bg-[#FFF5E8] px-3 py-1 text-[11px] font-semibold text-[#B54708]">
|
||||
备用链路
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 rounded-2xl bg-white/80 px-3 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
入口字段:显示名称、模型、API Key。
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -545,7 +623,7 @@ export function AiAccountsClient({
|
||||
onChange={(value) => updateDraft(account.accountId, (current) => ({ ...current, model: value }))}
|
||||
placeholder="例如:gpt-5.4"
|
||||
/>
|
||||
{draft.provider === "openai_api" ? (
|
||||
{draft.provider === "openai_api" || draft.provider === "aliyun_qwen_api" ? (
|
||||
<div className="col-span-2">
|
||||
<AccountField
|
||||
label={`API Key${account.apiKeyMasked ? `(已配置 ${account.apiKeyMasked})` : ""}`}
|
||||
@@ -553,7 +631,13 @@ export function AiAccountsClient({
|
||||
onChange={(value) =>
|
||||
updateDraft(account.accountId, (current) => ({ ...current, apiKey: value }))
|
||||
}
|
||||
placeholder={account.apiKeyConfigured ? "留空则保持现有 Key" : "输入 OpenAI API Key"}
|
||||
placeholder={
|
||||
account.apiKeyConfigured
|
||||
? "留空则保持现有 Key"
|
||||
: draft.provider === "aliyun_qwen_api"
|
||||
? "输入阿里百炼 API Key"
|
||||
: "输入 OpenAI API Key"
|
||||
}
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
@@ -679,6 +763,8 @@ export function AiAccountsClient({
|
||||
<div className="mt-1 text-[12px] leading-6 text-[#8C8C8C]">
|
||||
{onboardingMode === "openai_api"
|
||||
? "填写 API Key 后会立即校验,并将这个账号设为当前主控。"
|
||||
: onboardingMode === "aliyun_qwen_api"
|
||||
? "填写阿里百炼 API Key 后会立即校验,并保存为备用模型链路。"
|
||||
: "这个入口只负责绑定电脑节点,真正登录仍发生在那台电脑上。"}
|
||||
</div>
|
||||
</div>
|
||||
@@ -746,6 +832,63 @@ export function AiAccountsClient({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : onboardingMode === "aliyun_qwen_api" ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<AccountField
|
||||
label="标签"
|
||||
value={aliyunQwenOnboardDraft.label}
|
||||
onChange={(value) => setAliyunQwenOnboardDraft((current) => ({ ...current, label: value }))}
|
||||
placeholder="例如:备用 GPT"
|
||||
/>
|
||||
<AccountField
|
||||
label="显示名称"
|
||||
value={aliyunQwenOnboardDraft.displayName}
|
||||
onChange={(value) =>
|
||||
setAliyunQwenOnboardDraft((current) => ({ ...current, displayName: value }))
|
||||
}
|
||||
placeholder="例如:阿里百炼备用账号"
|
||||
/>
|
||||
<AccountField
|
||||
label="账号标识"
|
||||
value={aliyunQwenOnboardDraft.accountIdentifier}
|
||||
onChange={(value) =>
|
||||
setAliyunQwenOnboardDraft((current) => ({ ...current, accountIdentifier: value }))
|
||||
}
|
||||
placeholder="例如:dashscope 账号备注"
|
||||
/>
|
||||
<AccountField
|
||||
label="模型"
|
||||
value={aliyunQwenOnboardDraft.model}
|
||||
onChange={(value) =>
|
||||
setAliyunQwenOnboardDraft((current) => ({ ...current, model: value }))
|
||||
}
|
||||
placeholder="例如:qwen3.5-plus"
|
||||
/>
|
||||
<AccountField
|
||||
label="API Key"
|
||||
value={aliyunQwenOnboardDraft.apiKey}
|
||||
onChange={(value) => setAliyunQwenOnboardDraft((current) => ({ ...current, apiKey: value }))}
|
||||
placeholder="输入阿里百炼 API Key"
|
||||
type="password"
|
||||
/>
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitAliyunQwenOnboarding}
|
||||
disabled={!canManage || busyKey === "onboard:aliyun_qwen_api"}
|
||||
className="flex-1 rounded-full bg-[#111111] px-4 py-3 text-[13px] font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
{busyKey === "onboard:aliyun_qwen_api" ? "接入中" : "接入备用账号"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeOnboarding}
|
||||
className="rounded-full border border-[#E5E5EA] px-4 py-3 text-[13px] font-semibold text-[#57606A]"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 space-y-3">
|
||||
<AccountField
|
||||
|
||||
@@ -127,7 +127,7 @@ export type LoginMethod = "password" | "code";
|
||||
export type OtaUpdateStatus = "available" | "scheduled" | "applied" | "skipped";
|
||||
export type OtaLogStatus = "checked" | "applied" | "skipped";
|
||||
export type AppLogLevel = "info" | "warn" | "error";
|
||||
export type AiProvider = "master_codex_node" | "openai_api";
|
||||
export type AiProvider = "master_codex_node" | "openai_api" | "aliyun_qwen_api";
|
||||
export type AiAccountRole = "primary" | "backup" | "api_fallback";
|
||||
export type AiAccountStatus = "ready" | "needs_login" | "needs_api_key" | "degraded" | "disabled";
|
||||
export type MasterAgentTaskStatus = "queued" | "running" | "completed" | "failed";
|
||||
@@ -2254,6 +2254,8 @@ export function aiProviderLabel(provider: AiProvider) {
|
||||
return "Master Codex Node / ChatGPT Plus 节点";
|
||||
case "openai_api":
|
||||
return "OpenAI API";
|
||||
case "aliyun_qwen_api":
|
||||
return "阿里百炼 Qwen";
|
||||
default:
|
||||
return provider;
|
||||
}
|
||||
@@ -2283,9 +2285,13 @@ function maskApiKey(value?: string) {
|
||||
return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}`;
|
||||
}
|
||||
|
||||
function isApiKeyProvider(provider: AiProvider) {
|
||||
return provider === "openai_api" || provider === "aliyun_qwen_api";
|
||||
}
|
||||
|
||||
function deriveAiAccountStatus(account: AiAccount): AiAccountStatus {
|
||||
if (!account.enabled) return "disabled";
|
||||
if (account.provider === "openai_api") {
|
||||
if (isApiKeyProvider(account.provider)) {
|
||||
if (!account.apiKey?.trim()) return "needs_api_key";
|
||||
return account.status === "disabled" ? "ready" : account.status;
|
||||
}
|
||||
@@ -2458,7 +2464,7 @@ export function getMasterIdentitySummaryFromState(state: BossState): MasterIdent
|
||||
status: "needs_api_key",
|
||||
statusLabel: aiStatusLabel("needs_api_key"),
|
||||
canGenerate: false,
|
||||
note: "请到“我的 > AI 账号”至少配置一个可用的 Master Codex Node 或 OpenAI API 账号。",
|
||||
note: "请到“我的 > AI 账号”至少配置一个可用的 Master Codex Node、OpenAI API 或阿里百炼 Qwen 账号。",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4662,6 +4668,12 @@ export async function saveAiAccount(payload: {
|
||||
existing?.accountId ??
|
||||
payload.accountId?.trim() ??
|
||||
`ai-${slugify(`${payload.label}-${payload.displayName}`)}`;
|
||||
const defaultModel =
|
||||
payload.provider === "aliyun_qwen_api"
|
||||
? "qwen3.5-plus"
|
||||
: payload.provider === "openai_api"
|
||||
? "gpt-5.4"
|
||||
: undefined;
|
||||
const next: AiAccount = normalizeAiAccount({
|
||||
accountId,
|
||||
label: payload.label.trim() || aiRoleLabel(payload.role),
|
||||
@@ -4671,21 +4683,21 @@ export async function saveAiAccount(payload: {
|
||||
accountIdentifier: payload.accountIdentifier?.trim() || undefined,
|
||||
nodeId: payload.nodeId?.trim() || undefined,
|
||||
nodeLabel: payload.nodeLabel?.trim() || undefined,
|
||||
model: payload.model?.trim() || (payload.provider === "openai_api" ? "gpt-5.4" : undefined),
|
||||
model: payload.model?.trim() || defaultModel,
|
||||
apiKey:
|
||||
payload.provider === "openai_api"
|
||||
isApiKeyProvider(payload.provider)
|
||||
? payload.apiKey?.trim()
|
||||
? payload.apiKey.trim()
|
||||
: existing?.apiKey
|
||||
: undefined,
|
||||
apiKeyMasked:
|
||||
payload.provider === "openai_api"
|
||||
isApiKeyProvider(payload.provider)
|
||||
? maskApiKey(payload.apiKey?.trim() || existing?.apiKey)
|
||||
: undefined,
|
||||
enabled: payload.enabled ?? existing?.enabled ?? true,
|
||||
isActive: existing?.isActive ?? false,
|
||||
status:
|
||||
payload.provider === "openai_api"
|
||||
isApiKeyProvider(payload.provider)
|
||||
? payload.apiKey?.trim() || existing?.apiKey
|
||||
? existing?.status === "degraded"
|
||||
? "degraded"
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
updateAttachmentAnalysisResult,
|
||||
updateAiAccountHealth,
|
||||
} from "@/lib/boss-data";
|
||||
import type { DispatchPlanTarget, Project, ProjectAgentControls, ReasoningEffort } from "@/lib/boss-data";
|
||||
import type { AiProvider, DispatchPlanTarget, Project, ProjectAgentControls, ReasoningEffort } from "@/lib/boss-data";
|
||||
import { canInlineAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments";
|
||||
import { readAliyunOssObjectBuffer } from "@/lib/boss-storage-aliyun-oss";
|
||||
import { readServerFileAttachmentBuffer } from "@/lib/boss-storage-server-file";
|
||||
@@ -31,6 +31,32 @@ import {
|
||||
|
||||
type MasterAgentReplyState = "queued" | "running" | "completed";
|
||||
const OPENAI_MASTER_AGENT_DEVICE_ID = "master-agent-openai";
|
||||
const ALIYUN_QWEN_DEVICE_ID = "master-agent-aliyun-qwen";
|
||||
|
||||
type ApiCompatibleProvider = Extract<AiProvider, "openai_api" | "aliyun_qwen_api">;
|
||||
|
||||
const API_PROVIDER_CONFIG: Record<
|
||||
ApiCompatibleProvider,
|
||||
{
|
||||
label: string;
|
||||
endpoint: string;
|
||||
defaultModel: string;
|
||||
loginLabel: string;
|
||||
}
|
||||
> = {
|
||||
openai_api: {
|
||||
label: "OpenAI API",
|
||||
endpoint: "https://api.openai.com/v1/responses",
|
||||
defaultModel: "gpt-5.4",
|
||||
loginLabel: "OpenAI API Key",
|
||||
},
|
||||
aliyun_qwen_api: {
|
||||
label: "阿里百炼 Qwen",
|
||||
endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1/responses",
|
||||
defaultModel: "qwen3.5-plus",
|
||||
loginLabel: "阿里百炼 API Key",
|
||||
},
|
||||
};
|
||||
|
||||
type QueuedMasterAgentReplyEnvelope = {
|
||||
ok: true;
|
||||
@@ -378,6 +404,51 @@ function normalizeOpenAiFetchFailure(error: unknown) {
|
||||
return normalizeOpenAiError(String(error));
|
||||
}
|
||||
|
||||
function isApiCompatibleProvider(provider: AiProvider): provider is ApiCompatibleProvider {
|
||||
return provider === "openai_api" || provider === "aliyun_qwen_api";
|
||||
}
|
||||
|
||||
function apiProviderConfig(provider: ApiCompatibleProvider) {
|
||||
return API_PROVIDER_CONFIG[provider];
|
||||
}
|
||||
|
||||
function normalizeApiProviderError(provider: ApiCompatibleProvider, message: string) {
|
||||
if (provider === "openai_api") {
|
||||
return normalizeOpenAiError(message);
|
||||
}
|
||||
|
||||
const trimmed = message.trim();
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (
|
||||
lowered.includes("network is unreachable") ||
|
||||
lowered.includes("enetunreach") ||
|
||||
lowered.includes("timed out") ||
|
||||
lowered.includes("fetch failed") ||
|
||||
lowered.includes("connect timeout")
|
||||
) {
|
||||
return "服务器当前无法连接阿里百炼兼容接口,请检查出网、代理或防火墙配置。";
|
||||
}
|
||||
if (!trimmed) return "主 Agent 当前调用阿里百炼模型失败。";
|
||||
if (trimmed.length <= 240) return trimmed;
|
||||
return `${trimmed.slice(0, 237)}...`;
|
||||
}
|
||||
|
||||
function normalizeApiProviderFetchFailure(provider: ApiCompatibleProvider, error: unknown) {
|
||||
if (provider === "openai_api") {
|
||||
return normalizeOpenAiFetchFailure(error);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const causeCode =
|
||||
typeof (error as Error & { cause?: { code?: string } }).cause?.code === "string"
|
||||
? (error as Error & { cause?: { code?: string } }).cause?.code
|
||||
: "";
|
||||
const causeMessage =
|
||||
(error as Error & { cause?: { message?: string } }).cause?.message?.trim() || "";
|
||||
return normalizeApiProviderError(provider, [error.message, causeCode, causeMessage].filter(Boolean).join(" "));
|
||||
}
|
||||
return normalizeApiProviderError(provider, String(error));
|
||||
}
|
||||
|
||||
function fallbackAiRolePriority(role: "primary" | "backup" | "api_fallback") {
|
||||
switch (role) {
|
||||
case "primary":
|
||||
@@ -391,14 +462,14 @@ function fallbackAiRolePriority(role: "primary" | "backup" | "api_fallback") {
|
||||
}
|
||||
}
|
||||
|
||||
async function findFallbackOpenAiAccount(excludedAccountId?: string) {
|
||||
async function findFallbackApiAccount(excludedAccountId?: string) {
|
||||
const state = await readState();
|
||||
return [...state.aiAccounts]
|
||||
.filter(
|
||||
(account) =>
|
||||
account.accountId !== excludedAccountId &&
|
||||
account.enabled &&
|
||||
account.provider === "openai_api" &&
|
||||
isApiCompatibleProvider(account.provider) &&
|
||||
Boolean(account.apiKey?.trim()),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
@@ -409,7 +480,7 @@ async function findFallbackOpenAiAccount(excludedAccountId?: string) {
|
||||
}
|
||||
|
||||
async function replyViaOpenAiAccount(params: {
|
||||
account: Awaited<ReturnType<typeof findFallbackOpenAiAccount>>;
|
||||
account: Awaited<ReturnType<typeof findFallbackApiAccount>>;
|
||||
requestText: string;
|
||||
currentSessionExpiresAt?: string;
|
||||
senderLabel: string;
|
||||
@@ -419,13 +490,17 @@ async function replyViaOpenAiAccount(params: {
|
||||
projectMemories?: Awaited<ReturnType<typeof listUserMasterMemoriesView>>;
|
||||
userMemories?: Awaited<ReturnType<typeof listUserMasterMemoriesView>>;
|
||||
}) {
|
||||
if (!params.account?.apiKey?.trim()) {
|
||||
if (!params.account?.apiKey?.trim() || !isApiCompatibleProvider(params.account.provider)) {
|
||||
throw new Error("OPENAI_ACCOUNT_NOT_CONFIGURED");
|
||||
}
|
||||
|
||||
const generated = await generateOpenAiReply({
|
||||
const generated = await generateApiProviderReply({
|
||||
provider: params.account.provider,
|
||||
apiKey: params.account.apiKey,
|
||||
model: params.agentControls?.modelOverride || params.account.model || "gpt-5.4",
|
||||
model:
|
||||
params.agentControls?.modelOverride ||
|
||||
params.account.model ||
|
||||
apiProviderConfig(params.account.provider).defaultModel,
|
||||
reasoningEffort: params.agentControls?.reasoningEffortOverride || "medium",
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
@@ -451,7 +526,8 @@ async function replyViaOpenAiAccount(params: {
|
||||
};
|
||||
}
|
||||
|
||||
async function generateOpenAiReply(params: {
|
||||
async function generateApiProviderReply(params: {
|
||||
provider: ApiCompatibleProvider;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
@@ -475,34 +551,38 @@ async function generateOpenAiReply(params: {
|
||||
params.requestText,
|
||||
);
|
||||
let response: Response;
|
||||
const config = apiProviderConfig(params.provider);
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: params.model,
|
||||
instructions: buildMasterAgentExecutionPrompt({
|
||||
state,
|
||||
projectId: "master-agent",
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
agentControls: params.agentControls,
|
||||
accountId: "master-agent",
|
||||
promptPolicy: params.promptPolicy ?? null,
|
||||
userPrompt: params.userPrompt ?? null,
|
||||
projectMemories: effectiveProjectMemories,
|
||||
userMemories: params.userMemories ?? [],
|
||||
}),
|
||||
input: params.requestText,
|
||||
};
|
||||
if (params.provider === "openai_api") {
|
||||
requestBody.reasoning = { effort: params.reasoningEffort };
|
||||
}
|
||||
try {
|
||||
response = await fetch("https://api.openai.com/v1/responses", {
|
||||
response = await fetch(config.endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
reasoning: { effort: params.reasoningEffort },
|
||||
instructions: buildMasterAgentExecutionPrompt({
|
||||
state,
|
||||
projectId: "master-agent",
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
agentControls: params.agentControls,
|
||||
accountId: "master-agent",
|
||||
promptPolicy: params.promptPolicy ?? null,
|
||||
userPrompt: params.userPrompt ?? null,
|
||||
projectMemories: effectiveProjectMemories,
|
||||
userMemories: params.userMemories ?? [],
|
||||
}),
|
||||
input: params.requestText,
|
||||
}),
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(normalizeOpenAiFetchFailure(error));
|
||||
throw new Error(normalizeApiProviderFetchFailure(params.provider, error));
|
||||
}
|
||||
|
||||
const requestId = response.headers.get("x-request-id") ?? undefined;
|
||||
@@ -516,8 +596,9 @@ async function generateOpenAiReply(params: {
|
||||
? payload.error?.message
|
||||
: undefined;
|
||||
throw new Error(
|
||||
normalizeOpenAiError(
|
||||
`${apiError ?? `OpenAI API ${response.status}`}${requestId ? ` (request_id=${requestId})` : ""}`,
|
||||
normalizeApiProviderError(
|
||||
params.provider,
|
||||
`${apiError ?? `${config.label} ${response.status}`}${requestId ? ` (request_id=${requestId})` : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -525,7 +606,8 @@ async function generateOpenAiReply(params: {
|
||||
const content = extractResponseText(payload);
|
||||
if (!content) {
|
||||
throw new Error(
|
||||
normalizeOpenAiError(
|
||||
normalizeApiProviderError(
|
||||
params.provider,
|
||||
`模型已返回成功状态,但没有可用文本输出${requestId ? ` (request_id=${requestId})` : ""}`,
|
||||
),
|
||||
);
|
||||
@@ -562,6 +644,7 @@ function buildMasterOpenAiReplyPrompt(
|
||||
}
|
||||
|
||||
async function queueAndStartOpenAiMasterAgentReply(params: {
|
||||
provider: ApiCompatibleProvider;
|
||||
taskId: string;
|
||||
deviceId: string;
|
||||
requestText: string;
|
||||
@@ -583,7 +666,8 @@ async function queueAndStartOpenAiMasterAgentReply(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
const generated = await generateOpenAiReply({
|
||||
const generated = await generateApiProviderReply({
|
||||
provider: params.provider,
|
||||
apiKey: params.apiKey,
|
||||
model: params.model,
|
||||
reasoningEffort: params.reasoningEffort,
|
||||
@@ -617,6 +701,7 @@ async function queueAndStartOpenAiMasterAgentReply(params: {
|
||||
}
|
||||
|
||||
async function enqueueOpenAiMasterAgentReply(params: {
|
||||
provider: ApiCompatibleProvider;
|
||||
accountId: string;
|
||||
accountLabel: string;
|
||||
requestMessageId?: string;
|
||||
@@ -649,13 +734,14 @@ async function enqueueOpenAiMasterAgentReply(params: {
|
||||
),
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
deviceId: OPENAI_MASTER_AGENT_DEVICE_ID,
|
||||
deviceId: params.provider === "aliyun_qwen_api" ? ALIYUN_QWEN_DEVICE_ID : OPENAI_MASTER_AGENT_DEVICE_ID,
|
||||
accountId: params.accountId,
|
||||
accountLabel: params.accountLabel,
|
||||
});
|
||||
void queueAndStartOpenAiMasterAgentReply({
|
||||
provider: params.provider,
|
||||
taskId: task.taskId,
|
||||
deviceId: OPENAI_MASTER_AGENT_DEVICE_ID,
|
||||
deviceId: params.provider === "aliyun_qwen_api" ? ALIYUN_QWEN_DEVICE_ID : OPENAI_MASTER_AGENT_DEVICE_ID,
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
apiKey: params.apiKey,
|
||||
@@ -682,34 +768,39 @@ async function enqueueOpenAiMasterAgentReply(params: {
|
||||
return queuedReply;
|
||||
}
|
||||
|
||||
export async function probeOpenAiApiAccount(params: {
|
||||
export async function probeApiCompatibleAccount(params: {
|
||||
provider: ApiCompatibleProvider;
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
}) {
|
||||
const apiKey = params.apiKey.trim();
|
||||
if (!apiKey) {
|
||||
throw new Error("当前账号还没有可用的 OpenAI API Key。");
|
||||
throw new Error(`当前账号还没有可用的 ${apiProviderConfig(params.provider).loginLabel}。`);
|
||||
}
|
||||
|
||||
const model = params.model?.trim() || "gpt-5.4";
|
||||
const config = apiProviderConfig(params.provider);
|
||||
const model = params.model?.trim() || config.defaultModel;
|
||||
let response: Response;
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
instructions: `你正在执行${config.label}连接自检。请只回复“连接正常”。`,
|
||||
input: "请只回复“连接正常”。",
|
||||
};
|
||||
if (params.provider === "openai_api") {
|
||||
body.reasoning = { effort: "low" };
|
||||
}
|
||||
try {
|
||||
response = await fetch("https://api.openai.com/v1/responses", {
|
||||
response = await fetch(config.endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
reasoning: { effort: "low" },
|
||||
instructions: "你正在执行 OpenAI API 连接自检。请只回复“连接正常”。",
|
||||
input: "请只回复“连接正常”。",
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(normalizeOpenAiFetchFailure(error));
|
||||
throw new Error(normalizeApiProviderFetchFailure(params.provider, error));
|
||||
}
|
||||
|
||||
const requestId = response.headers.get("x-request-id") ?? undefined;
|
||||
@@ -723,8 +814,9 @@ export async function probeOpenAiApiAccount(params: {
|
||||
? payload.error?.message
|
||||
: undefined;
|
||||
throw new Error(
|
||||
normalizeOpenAiError(
|
||||
`${apiError ?? `OpenAI API ${response.status}`}${requestId ? ` (request_id=${requestId})` : ""}`,
|
||||
normalizeApiProviderError(
|
||||
params.provider,
|
||||
`${apiError ?? `${config.label} ${response.status}`}${requestId ? ` (request_id=${requestId})` : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -738,6 +830,13 @@ export async function probeOpenAiApiAccount(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function probeOpenAiApiAccount(params: { apiKey: string; model?: string }) {
|
||||
return probeApiCompatibleAccount({
|
||||
provider: "openai_api",
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
async function appendMasterAgentSystemReply(body: string, senderLabel = "主 Agent") {
|
||||
return appendProjectMessage({
|
||||
projectId: "master-agent",
|
||||
@@ -1332,17 +1431,18 @@ export async function validateAiAccountConnection(accountId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
if (account.provider !== "openai_api" || !account.apiKey?.trim()) {
|
||||
if (!isApiCompatibleProvider(account.provider) || !account.apiKey?.trim()) {
|
||||
return {
|
||||
ok: false as const,
|
||||
status: "needs_api_key",
|
||||
message: "当前账号还没有可用的 OpenAI API Key。",
|
||||
message: `当前账号还没有可用的${isApiCompatibleProvider(account.provider) ? apiProviderConfig(account.provider).loginLabel : " API Key"}。`,
|
||||
};
|
||||
}
|
||||
|
||||
const generated = await probeOpenAiApiAccount({
|
||||
const generated = await probeApiCompatibleAccount({
|
||||
provider: account.provider,
|
||||
apiKey: account.apiKey,
|
||||
model: account.model || "gpt-5.4",
|
||||
model: account.model || apiProviderConfig(account.provider).defaultModel,
|
||||
});
|
||||
|
||||
await updateAiAccountHealth({
|
||||
@@ -1372,7 +1472,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
|
||||
if (!runtime?.account) {
|
||||
await appendMasterAgentSystemReply(
|
||||
"我已经收到你的消息,但当前没有可用的主控 AI 账号。请到“我的 > AI 账号”至少配置一个可用的 OpenAI API 账号,再继续对话。",
|
||||
"我已经收到你的消息,但当前没有可用的主控 AI 账号。请到“我的 > AI 账号”至少配置一个可用的 OpenAI API、阿里百炼 Qwen,或接回 Master Codex Node 后,再继续对话。",
|
||||
);
|
||||
return { ok: false as const, reason: "NO_AI_ACCOUNT" };
|
||||
}
|
||||
@@ -1403,9 +1503,10 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const fallbackAccount = await findFallbackOpenAiAccount(runtime.account.accountId);
|
||||
if (fallbackAccount?.apiKey?.trim()) {
|
||||
const fallbackAccount = await findFallbackApiAccount(runtime.account.accountId);
|
||||
if (fallbackAccount?.apiKey?.trim() && isApiCompatibleProvider(fallbackAccount.provider)) {
|
||||
return enqueueOpenAiMasterAgentReply({
|
||||
provider: fallbackAccount.provider,
|
||||
accountId: fallbackAccount.accountId,
|
||||
accountLabel: fallbackAccount.label || aiRoleLabel(fallbackAccount.role),
|
||||
requestMessageId: params.requestMessageId,
|
||||
@@ -1414,7 +1515,10 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
apiKey: fallbackAccount.apiKey,
|
||||
model: agentControls?.modelOverride || fallbackAccount.model || "gpt-5.4",
|
||||
model:
|
||||
agentControls?.modelOverride ||
|
||||
fallbackAccount.model ||
|
||||
apiProviderConfig(fallbackAccount.provider).defaultModel,
|
||||
reasoningEffort: agentControls?.reasoningEffortOverride || "medium",
|
||||
agentControls,
|
||||
promptPolicy: executionConfig.promptPolicy,
|
||||
@@ -1465,8 +1569,9 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
return queuedReply;
|
||||
}
|
||||
|
||||
if (runtime.account.provider === "openai_api" && runtime.account.apiKey?.trim()) {
|
||||
if (isApiCompatibleProvider(runtime.account.provider) && runtime.account.apiKey?.trim()) {
|
||||
return enqueueOpenAiMasterAgentReply({
|
||||
provider: runtime.account.provider,
|
||||
accountId: runtime.account.accountId,
|
||||
accountLabel: runtime.account.label || runtime.summary.roleLabel,
|
||||
requestMessageId: params.requestMessageId,
|
||||
@@ -1503,7 +1608,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
lastError: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE",
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
});
|
||||
const fallbackAccount = await findFallbackOpenAiAccount(runtime.account.accountId);
|
||||
const fallbackAccount = await findFallbackApiAccount(runtime.account.accountId);
|
||||
if (fallbackAccount) {
|
||||
try {
|
||||
return await replyViaOpenAiAccount({
|
||||
@@ -1557,7 +1662,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
};
|
||||
}
|
||||
if (completedTask?.status === "failed") {
|
||||
const fallbackAccount = await findFallbackOpenAiAccount(runtime.account.accountId);
|
||||
const fallbackAccount = await findFallbackApiAccount(runtime.account.accountId);
|
||||
if (fallbackAccount) {
|
||||
try {
|
||||
return await replyViaOpenAiAccount({
|
||||
@@ -1593,12 +1698,12 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
return { ok: true as const, accountId: runtime.account.accountId, taskId: task.taskId };
|
||||
}
|
||||
|
||||
if (runtime.account.provider !== "openai_api" || !runtime.account.apiKey?.trim()) {
|
||||
if (!isApiCompatibleProvider(runtime.account.provider) || !runtime.account.apiKey?.trim()) {
|
||||
await appendMasterAgentSystemReply(
|
||||
[
|
||||
`当前主控身份是 ${runtime.summary.roleLabel},来源 ${aiProviderLabel(runtime.account.provider)}。`,
|
||||
"当前账号既没有接入 Master Codex Node 执行器,也没有可用的 OpenAI API Key。",
|
||||
"请到“我的 > AI 账号”补一个可用的 OpenAI API 账号,或者把当前节点接回 Master Codex Node relay。",
|
||||
"当前账号既没有接入 Master Codex Node 执行器,也没有可用的 API 兼容账号。",
|
||||
"请到“我的 > AI 账号”补一个可用的 OpenAI API 或阿里百炼 Qwen 账号,或者把当前节点接回 Master Codex Node relay。",
|
||||
].join(""),
|
||||
`主 Agent · ${runtime.summary.roleLabel}`,
|
||||
);
|
||||
@@ -1606,7 +1711,8 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
const generated = await generateOpenAiReply({
|
||||
const generated = await generateApiProviderReply({
|
||||
provider: runtime.account.provider,
|
||||
apiKey: runtime.account.apiKey,
|
||||
model: executionConfig.model,
|
||||
reasoningEffort: executionConfig.reasoningEffort,
|
||||
|
||||
@@ -234,6 +234,8 @@ function aiProviderLabel(provider: AiProvider) {
|
||||
return "Master Codex Node";
|
||||
case "openai_api":
|
||||
return "OpenAI API";
|
||||
case "aliyun_qwen_api":
|
||||
return "阿里百炼 Qwen";
|
||||
default:
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let openAiOnboardRoute: (typeof import("../src/app/api/v1/accounts/onboard/openai-api/route"))["POST"];
|
||||
let aliyunQwenOnboardRoute: (typeof import("../src/app/api/v1/accounts/onboard/aliyun-qwen/route"))["POST"];
|
||||
let masterNodeOnboardRoute: (typeof import("../src/app/api/v1/accounts/onboard/master-node/route"))["POST"];
|
||||
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
@@ -19,14 +20,16 @@ async function setup() {
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [openAiModule, masterNodeModule, data, auth] = await Promise.all([
|
||||
const [openAiModule, aliyunModule, masterNodeModule, data, auth] = await Promise.all([
|
||||
import("../src/app/api/v1/accounts/onboard/openai-api/route.ts"),
|
||||
import("../src/app/api/v1/accounts/onboard/aliyun-qwen/route.ts"),
|
||||
import("../src/app/api/v1/accounts/onboard/master-node/route.ts"),
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
]);
|
||||
|
||||
openAiOnboardRoute = openAiModule.POST;
|
||||
aliyunQwenOnboardRoute = aliyunModule.POST;
|
||||
masterNodeOnboardRoute = masterNodeModule.POST;
|
||||
createAuthSession = data.createAuthSession;
|
||||
readState = data.readState;
|
||||
@@ -188,3 +191,61 @@ test("POST /api/v1/accounts/onboard/openai-api returns a clear network guidance
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("POST /api/v1/accounts/onboard/aliyun-qwen creates a backup aliyun qwen account", async () => {
|
||||
await setup();
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
if (typeof input === "string" && input === "https://dashscope.aliyuncs.com/compatible-mode/v1/responses") {
|
||||
assert.equal(init?.method, "POST");
|
||||
return new Response(JSON.stringify({ output_text: "连接正常" }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-request-id": "req-onboard-aliyun",
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${String(input)}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const response = await aliyunQwenOnboardRoute(
|
||||
await createAuthedJsonRequest("http://127.0.0.1:3000/api/v1/accounts/onboard/aliyun-qwen", {
|
||||
label: "阿里备用",
|
||||
displayName: "阿里百炼备用账号",
|
||||
accountIdentifier: "dashscope-demo",
|
||||
model: "qwen3.5-plus",
|
||||
apiKey: "sk-aliyun-demo-123456",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
accountId: string;
|
||||
message: string;
|
||||
account: { provider: string; role: string; model: string; isActive: boolean };
|
||||
};
|
||||
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.accountId, "aliyun-qwen-backup");
|
||||
assert.equal(payload.account.provider, "aliyun_qwen_api");
|
||||
assert.equal(payload.account.role, "backup");
|
||||
assert.equal(payload.account.model, "qwen3.5-plus");
|
||||
assert.equal(payload.account.isActive, false);
|
||||
assert.match(payload.message, /备用/);
|
||||
assert.match(payload.message, /阿里|百炼/);
|
||||
|
||||
const state = await readState();
|
||||
const account = state.aiAccounts.find((item) => item.accountId === "aliyun-qwen-backup");
|
||||
assert.ok(account, "expected aliyun backup account to be created");
|
||||
assert.equal(account?.provider, "aliyun_qwen_api");
|
||||
assert.equal(account?.role, "backup");
|
||||
assert.equal(account?.model, "qwen3.5-plus");
|
||||
assert.equal(account?.apiKey, "sk-aliyun-demo-123456");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ let runtimeRoot = "";
|
||||
let validateAiAccountConnection: (typeof import("../src/lib/boss-master-agent"))["validateAiAccountConnection"];
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let deviceHeartbeatRoute: (typeof import("../src/app/api/device-heartbeat/route"))["POST"];
|
||||
let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
@@ -25,6 +26,7 @@ async function setup() {
|
||||
|
||||
validateAiAccountConnection = masterAgent.validateAiAccountConnection;
|
||||
readState = data.readState;
|
||||
saveAiAccount = data.saveAiAccount;
|
||||
deviceHeartbeatRoute = heartbeatModule.POST;
|
||||
}
|
||||
|
||||
@@ -77,3 +79,42 @@ test("validateAiAccountConnection reports degraded when the bound master node de
|
||||
assert.equal(result.status, "degraded");
|
||||
assert.match(result.message, /当前不在线|不在线/);
|
||||
});
|
||||
|
||||
test("validateAiAccountConnection probes aliyun qwen backup accounts through the compatible endpoint", async () => {
|
||||
await setup();
|
||||
|
||||
await saveAiAccount({
|
||||
accountId: "aliyun-qwen-backup",
|
||||
label: "阿里备用",
|
||||
role: "backup",
|
||||
provider: "aliyun_qwen_api",
|
||||
displayName: "阿里百炼备用账号",
|
||||
accountIdentifier: "dashscope-demo",
|
||||
model: "qwen3.5-plus",
|
||||
apiKey: "sk-aliyun-demo-123456",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input) => {
|
||||
if (typeof input === "string" && input === "https://dashscope.aliyuncs.com/compatible-mode/v1/responses") {
|
||||
return new Response(JSON.stringify({ output_text: "连接正常" }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-request-id": "req-aliyun-validate",
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${String(input)}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await validateAiAccountConnection("aliyun-qwen-backup");
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.status, "ready");
|
||||
assert.match(result.message, /连接正常/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -100,3 +100,72 @@ test("replyToMasterAgentUserMessage falls back to a runnable OpenAI API account
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("replyToMasterAgentUserMessage falls back to a runnable aliyun qwen backup account when the master node is offline", async () => {
|
||||
await setup();
|
||||
|
||||
await saveAiAccount({
|
||||
accountId: "master-codex-primary",
|
||||
label: "主 GPT",
|
||||
role: "primary",
|
||||
provider: "master_codex_node",
|
||||
displayName: "Mac 上的 Master Codex Node",
|
||||
nodeId: "offline-node",
|
||||
nodeLabel: "离线节点",
|
||||
model: "gpt-5.4",
|
||||
enabled: true,
|
||||
setActive: true,
|
||||
loginStatusNote: "通过绑定的 Master Codex Node 对话。",
|
||||
});
|
||||
|
||||
await saveAiAccount({
|
||||
accountId: "aliyun-qwen-backup",
|
||||
label: "阿里备用",
|
||||
role: "backup",
|
||||
provider: "aliyun_qwen_api",
|
||||
displayName: "阿里百炼备用账号",
|
||||
accountIdentifier: "dashscope-demo",
|
||||
model: "qwen3.5-plus",
|
||||
apiKey: "sk-aliyun-demo-123456",
|
||||
enabled: true,
|
||||
setActive: false,
|
||||
loginStatusNote: "阿里百炼 Qwen 备用账号。",
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input) => {
|
||||
if (typeof input === "string" && input === "https://dashscope.aliyuncs.com/compatible-mode/v1/responses") {
|
||||
return new Response(JSON.stringify({ output_text: "阿里备用链路正常。" }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-request-id": "req-master-aliyun-fallback",
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${String(input)}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await replyToMasterAgentUserMessage({
|
||||
requestMessageId: "msg-master-aliyun-fallback",
|
||||
requestText: "请只回复:阿里备用链路正常。",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "17600003315",
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.accountId, "aliyun-qwen-backup");
|
||||
assert.equal(result.requestId, "req-master-aliyun-fallback");
|
||||
|
||||
const state = await readState();
|
||||
const masterProject = state.projects.find((project) => project.id === "master-agent");
|
||||
const reply = masterProject?.messages.at(-1);
|
||||
assert.ok(reply, "expected a master-agent reply to be appended");
|
||||
assert.equal(reply?.sender, "master");
|
||||
assert.equal(reply?.senderLabel, "主 Agent · 阿里备用");
|
||||
assert.match(reply?.body ?? "", /阿里备用链路正常/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user