From 5976e72836f56d8286b89118bf42e2a20138e747 Mon Sep 17 00:00:00 2001 From: Neo Date: Thu, 13 Aug 2026 01:17:33 +0800 Subject: [PATCH] fix(bridge): align CLI send body with AgentUserMessageInputDto + add ARK compat warn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues surfaced from end-to-end bridge testing against a real Volcengine Ark (Coding Plan / Doubao) deployment. Two of them are user- facing configuration gotchas captured in reference/harness/ark-models.md; the third is a real wire-contract bug in scripts/cli/bridge/commands/send.ts that the 28 server-side tests did not catch because they hit the route directly, never going through the CLI. Fixes 1. scripts/cli/bridge/commands/send.ts - The CLI historically sent `message: {content: [{type:"text",text}]}` (Anthropic-style), but the DTO `AgentUserMessageInputDtoSchema = z.object({text: z.string()}).strict()` only accepts `{text: string}`. Server replies 400 with "Invalid input: expected string, received undefined" because the strict zod object has no `text` field. Server-side tests mock harness directly so they never observe the wire body the CLI writes. - Switch the body to `message: {text: input.message}` and add a comment pinning the DTO contract as the single source of truth. 2. scripts/cli/bridge/commands/send.test.ts (new) - Regression guard: capture the request body via stubbed `fetch` and assert the shape (mode, message.text, clientMessageId UUID, no caller/block/queueIfBusy leakage, followup mode, title passthrough, non-2xx → BridgeHttpError with response body). Prevents future drift between CLI and DTO. 3. server/agent/harness/model-resolver.ts + model-resolver.test.ts - Add `warnArkDeveloperRoleCompatIfNeeded` heuristic and `warnProviderCompatIssues` scan. Detects reasoning+openai-completions models whose `baseURL` matches `ark..volces.com` and lack `compat.supportsDeveloperRole: false` — emits `appLogger.warn` once per (provider, model) per process. pi-ai 0.80.6 openai-completions adapter defaults to `developer` role when `model.reasoning && compat.supportsDeveloperRole`; ARK rejects `developer` with 400 InvalidParameter. Warning fires best-effort, never blocks harness startup; users can then patch compat and restart session. Docs - reference/harness/ark-models.md (new): config.json template, the /v3/models lookup workflow for resolving `ark-code-latest`-style UI aliases to actual model ids, region baseURL table, timeout/retry recommendations, and a link back to the startup warn. - reference/harness/invoke-http.md: cross-reference to ark-models.md. - reference/README.md: add ark-models.md to the bookshelf and reading order. Test infra - vitest.config.ts: include `scripts/cli/**/*.test.ts` so CLI tests run in the standard `bunx vitest run` invocation (previously excluded). Verification - `bunx vitest run scripts/cli/bridge server/agent/bridge server/agent/harness/pi-runtime-resolver` → 53/53 tests pass (5 new send + 5 new model-resolver compat + 43 existing) - `bunx tsc --noEmit -p tsconfig.json` → 7 pre-existing Prisma client errors, 0 new errors from this change Refs: end-to-end verification record in ~/.claude/projects/-www-wwwroot-book-neoshen-dpdns-org/memory/bridge-ark-e2e-verified.md --- reference/README.md | 1 + reference/harness/ark-models.md | 158 ++++++++++++++++++++ reference/harness/invoke-http.md | 6 + scripts/cli/bridge/commands/send.test.ts | 148 ++++++++++++++++++ scripts/cli/bridge/commands/send.ts | 8 +- server/agent/harness/model-resolver.test.ts | 140 ++++++++++++++++- server/agent/harness/model-resolver.ts | 65 +++++++- vitest.config.ts | 1 + 8 files changed, 522 insertions(+), 5 deletions(-) create mode 100644 reference/harness/ark-models.md create mode 100644 scripts/cli/bridge/commands/send.test.ts diff --git a/reference/README.md b/reference/README.md index fc6c8ffe..644bfa5a 100644 --- a/reference/README.md +++ b/reference/README.md @@ -20,6 +20,7 @@ - [editor/](editor/):Markdown Studio 富文本 / 源码模式稳定规则。 - [theme/](theme/):主题系统规则。 - [harness/invoke-http.md](harness/invoke-http.md):Agent Bridge HTTP 合同——外部 CLI 调 leader 的端点、鉴权、caller kind、并发限流、CLI sidecar。 +- [harness/ark-models.md](harness/ark-models.md):Volcengine Ark(方舟 Coding Plan / Doubao / DeepSeek 系列)配置模板、模型名查询、developer role 兼容性与启动期 warn 入口。 - [media/image-variants.md](media/image-variants.md):图片原图所有权、授权 Adapter、变体参数、有界缓存和 Project 封面合同。 ## Reading Order diff --git a/reference/harness/ark-models.md b/reference/harness/ark-models.md new file mode 100644 index 00000000..a0629bc5 --- /dev/null +++ b/reference/harness/ark-models.md @@ -0,0 +1,158 @@ +# Volcengine Ark 模型配置与已知兼容性约束 + +> 与 [invoke-http.md](./invoke-http.md) 配套:使用 bridge 或内置 Agent 调方舟 +> Coding Plan / Doubao / DeepSeek 系列时,先读完本节再写 `config.json` 的 +> `models.providers`,否则第一次 invoke 会以 4xx 失败收场。 + +## TL;DR 配置模板 + +把 `config.json` 写成下面这样基本就能跑通。`apiKey` 通过 runtime config +注入(**不要**写进源)。 + +```jsonc +{ + "models": { + "default": "volcengine-ark/", + "providers": [{ + "id": "volcengine-ark", + "name": "Volcengine Ark", + "enabled": true, + "modelApi": "openai-completions", + "options": { + "apiKey": "ark-...", + "baseURL": "https://ark.cn-beijing.volces.com/api/v3", + "proxy": "", + "timeoutMs": 60000, + "requestOptions": {"maxRetries": 2} + }, + "models": [{ + "id": "", // 见下一节怎么查 + "name": "...", + "enabled": true, + "api": "openai-completions", + "reasoning": true, // coding plan 系列通常支持 + "input": ["text"], + "maxTokens": 32768, + "contextWindowTokens": 256000, + "compat": { "supportsDeveloperRole": false } // 关键!见下 + }] + }] + } +} +``` + +## 第一关:模型名不能猜 + +方舟控制台的 UI 别名(`ark-code-latest` 之类)**不是** API 接受的 `model` 值。 +在拿到 API key 后,先用 key 列出当前账号能用的模型清单: + +```bash +curl https://ark.cn-beijing.volces.com/api/v3/models \ + -H "Authorization: Bearer $ARK_API_KEY" +``` + +返回 `data[]`,过滤掉 `status === "Shutdown"` 或 `status === "Retiring"`, +剩下的 `id` 才是真正能填进 `models.providers[*].models[*].id` 的值。常见 +"代码"类(验证于 2026-08-13 的 Beijing 区域): + +- `doubao-seed-2-0-code-preview-260215`(Doubao seed code preview) +- `doubao-seed-2-0-pro-260215` +- `doubao-seed-2-1-pro-260628` +- `deepseek-v4-pro-260425` +- `deepseek-v4-flash-260425` + +如果你的 key 走 Coding Plan 商品,`/v3/models` 还会出现 `ep-` 这种 +endpoint id——把 endpoint id 当 `model` 字段值即可,效果一样。 + +**症状**:直接写 `ark-code-latest` 这种 UI 别名,ARK 返回 +`404 InvalidEndpointOrModel.NotFound`,bridge 上会冒 +`errorPhase: "model"` 的 invocation error。 + +## 第二关:拒收 `developer` role + +方舟的 OpenAI-compat 端点只接受 `messages.role ∈ {system, assistant, user, tool}`, +**不**接受 `developer`。而 pi-ai 0.80.6 的 openai-completions adapter 默认 +逻辑是: + +```ts +const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole; +``` + +——只要模型 `reasoning: true` 且 `compat.supportsDeveloperRole` 没显式设 +`false`,system prompt 就会以 `developer` role 发出,ARK 立刻 400。 + +**症状**: + +``` +400: {"code":"InvalidParameter", + "message":"The parameter `messages.role` specified in the request are not valid: + invalid value: `developer`, supported values are: `system`, `assistant`, `user`, `tool`."} +``` + +**修复**:在 model 配置里加: + +```jsonc +"compat": { "supportsDeveloperRole": false } +``` + +这条 fix 在 NeuroBook 启动期也会被检测并 warn(见 +`server/agent/harness/model-resolver.ts`),但**不**阻塞启动——用户可以在 +设置页先确认再补 compat。 + +## 第三关:`baseURL` 与 region + +不同 region 域名不同: + +| Region | baseURL | +| --- | --- | +| 北京(cn-beijing) | `https://ark.cn-beijing.volces.com/api/v3` | +| 上海(cn-shanghai) | `https://ark.cn-shanghai.volces.com/api/v3` | +| 哥本哈根(eu-copenhagen) | `https://ark.eu-copenhagen.volces.com/api/v3` | + +只有北京区域提供 Coding Plan 商品;其它区域用通用 Doubao / DeepSeek 模型。 +控制台顶部"地域"切换时 key 不会自动迁移,混用会持续 401。 + +## 第四关:超时与限流 + +方舟对单请求的 `timeoutMs` 没有强制,但 SSE 流式输出 coding plan 模型时 +平均 5–15 秒。`config.json` 推荐: + +```jsonc +"options": { + "timeoutMs": 60000, + "requestOptions": { "maxRetries": 2 } +} +``` + +bridge 端的 `send` 默认 10 分钟 timeout(`scripts/cli/bridge/util/http.ts`), +够用。 + +## 启动期自动检测 + +NeuroBook 启动时 `resolvePiModelFromConfig` 会扫所有启用的 model provider, +对命中下列**启发式**条件的,输出 `appLogger.warn` 提醒: + +- `api === "openai-completions"` +- `baseURL` 匹配 `ark\.[a-z0-9-]+\.volces\.com` +- `reasoning === true` +- `compat.supportsDeveloperRole !== false` + +warn 内容: + +``` +[agent.model.arkCompat.developerRoleNotDisabled] + provider: volcengine-ark + model: doubao-seed-2-0-code-preview-260215 + hint: ARK OpenAI 端点拒 developer role,需在 model.compat 设 supportsDeveloperRole:false +``` + +warn 是 best-effort,**不**阻塞 harness 启动。用户在 settings 页看到 warn 后 +再补 compat,重启会话即可生效(registry 标 `next-run` 生效周期)。 + +## 与本仓其他文件的关系 + +- [invoke-http.md](./invoke-http.md):bridge 端点、鉴权、caller kind。 +- `server/agent/harness/model-resolver.ts`:compat 解析 + 启发式 warn。 +- `shared/dto/agent-session.dto.ts`:`AgentUserMessageInputDtoSchema` 是 + `{text: string}`——bridge CLI `send` 必须按这个形状发(已修,见 + [bridge-ark-e2e-verified](../../.claude/projects/-www-wwwroot-book-neoshen-dpdns-org/memory/bridge-ark-e2e-verified.md))。 diff --git a/reference/harness/invoke-http.md b/reference/harness/invoke-http.md index 36d5268b..5fa8af38 100644 --- a/reference/harness/invoke-http.md +++ b/reference/harness/invoke-http.md @@ -143,3 +143,9 @@ dev 模式直连 `/api/workspace-files/read` 也行;bridge 文件读端点作 - 多会话 daemon / 远程 session 池 - `nb-history` actor 扩展(要改 sibling 仓) - 远端(跨机)调用(ssh -L 隧道是用户责任) + +## 关联 + +- [ark-models.md](./ark-models.md):方舟 Coding Plan / Doubao / DeepSeek 模型的 + `config.json` 模板、`/v3/models` 模型名查询、developer role 兼容性与启动期 + warn。bridge 调用 ARK 之前先读这一份。 diff --git a/scripts/cli/bridge/commands/send.test.ts b/scripts/cli/bridge/commands/send.test.ts new file mode 100644 index 00000000..de51cb5c --- /dev/null +++ b/scripts/cli/bridge/commands/send.test.ts @@ -0,0 +1,148 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {randomUUID} from "node:crypto"; +import {sendCommand, type SendInput} from "nbook/scripts/cli/bridge/commands/send"; +import {BridgeHttpError} from "nbook/scripts/cli/bridge/util/http"; + +/** + * Regression guard for the bridge `send` CLI request body shape. + * + * The DTO `AgentUserMessageInputDtoSchema` (`shared/dto/agent-session.dto.ts`) is + * `z.object({text: z.string()}).strict()`. The CLI historically sent Anthropic-style + * `message: {content: [{type: "text", text}]}`, which 400s on the server as + * `Invalid input: expected string, received undefined` because the strict zod + * object has no `text` field. + * + * The server-side `invoke.post.test.ts` covers the happy path but mocks harness + * directly, so it never observes the wire body the CLI sends. This test pins the + * CLI-side contract by capturing the body the CLI writes to `fetch` and asserting + * the shape the server expects. + */ + +interface CapturedRequest { + url: string; + method: string; + body: unknown; + headers: Record; +} + +const fetchMock = vi.fn(); + +function readJsonBody(init: RequestInit | undefined): unknown { + const raw = init?.body; + if (typeof raw !== "string") { + throw new Error("expected fetch body to be a JSON string"); + } + return JSON.parse(raw) as unknown; +} + +function captureFetchRequest(): CapturedRequest { + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]!; + const headers: Record = {}; + for (const [k, v] of Object.entries((init?.headers ?? {}) as Record)) { + headers[k.toLowerCase()] = v; + } + return { + url: String(url), + method: (init?.method ?? "GET").toString(), + body: readJsonBody(init), + headers, + }; +} + +function makeInput(overrides: Partial = {}): SendInput { + return { + sessionId: 42, + message: "hello leader", + token: "test-bridge-token", + baseUrl: "http://127.0.0.1:3010", + ...overrides, + }; +} + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("sendCommand request body", () => { + it("sends message as {text: string} matching AgentUserMessageInputDtoSchema", async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({status: "completed", finalMessage: "ok"}), { + status: 200, + headers: {"Content-Type": "application/json"}, + })); + + await sendCommand(makeInput({message: "用一句话介绍你自己"})); + + const sent = captureFetchRequest(); + expect(sent.method).toBe("POST"); + expect(sent.url).toBe("http://127.0.0.1:3010/api/agent/bridge/sessions/42/invoke"); + expect(sent.headers["authorization"]).toBe("Bearer test-bridge-token"); + expect(sent.headers["content-type"]).toBe("application/json"); + + const body = sent.body as Record; + expect(body).toMatchObject({ + mode: "prompt", + message: {text: "用一句话介绍你自己"}, + }); + // 必须不存在旧的 Anthropic 风格 content 数组——DTO strict() 会拒它 + const message = body.message as Record; + expect(message).not.toHaveProperty("content"); + }); + + it("generates a UUID-shaped clientMessageId and pins mode=prompt by default", async () => { + fetchMock.mockResolvedValueOnce(new Response("{}", {status: 200})); + + await sendCommand(makeInput()); + + const body = (captureFetchRequest().body) as Record; + expect(body.mode).toBe("prompt"); + expect(typeof body.clientMessageId).toBe("string"); + expect(body.clientMessageId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u); + // 验证就是有效的 UUID v4 + const parsed = randomUUID.call(null); + expect(parsed).toMatch(/^[0-9a-f-]+$/u); + }); + + it("switches to mode=followup when followup=true", async () => { + fetchMock.mockResolvedValueOnce(new Response("{}", {status: 200})); + + await sendCommand(makeInput({followup: true, message: "继续"})); + + const body = (captureFetchRequest().body) as Record; + expect(body.mode).toBe("followup"); + }); + + it("forwards title when supplied, never adds caller/signal/queueIfBusy", async () => { + fetchMock.mockResolvedValueOnce(new Response("{}", {status: 200})); + + await sendCommand(makeInput({title: "第一章 800 字"})); + + const body = (captureFetchRequest().body) as Record; + expect(body.title).toBe("第一章 800 字"); + // 桥 DTO 拒 caller,服务端强制 external-cli;CLI 不应越权注入 + expect(body).not.toHaveProperty("caller"); + expect(body).not.toHaveProperty("block"); + expect(body).not.toHaveProperty("queueIfBusy"); + }); + + it("propagates non-2xx responses as BridgeHttpError with body", async () => { + const errorBody = JSON.stringify({ + error: true, + message: "Invalid input: expected string, received undefined", + statusCode: 400, + }); + fetchMock.mockResolvedValueOnce(new Response(errorBody, {status: 400, statusText: "Bad Request"})); + + const error = await sendCommand(makeInput()).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(BridgeHttpError); + const httpError = error as BridgeHttpError; + expect(httpError.statusCode).toBe(400); + expect(httpError.responseBody).toContain("Invalid input"); + }); +}); diff --git a/scripts/cli/bridge/commands/send.ts b/scripts/cli/bridge/commands/send.ts index 21578591..6de4bdf8 100644 --- a/scripts/cli/bridge/commands/send.ts +++ b/scripts/cli/bridge/commands/send.ts @@ -24,13 +24,19 @@ export interface InvokeResult { * 阻塞式 invoke。自动生成 clientMessageId(prompt/followup 模式必传)。 * * 桥 DTO 不允许 caller 字段——caller 由服务端强制为 external-cli。CLI 不传 caller。 + * + * `message` 形状必须匹配 `AgentUserMessageInputDtoSchema`(见 + * `shared/dto/agent-session.dto.ts`):`{ text: string }`。CLI 不能发 Anthropic 风格 + * 的 `content: [{type:"text", text}]` 数组——server 会 400 `Invalid input: + * expected string, received undefined`,因为 DTO 走的是 `.strict()` 的 zod + * object,缺失 `text` 字段直接 fail。CLI 这一处必须与 DTO 严格对齐。 */ export async function sendCommand(input: SendInput): Promise { const mode = input.followup ? "followup" : "prompt"; const body = { mode, clientMessageId: randomUUID(), - message: {content: [{type: "text", text: input.message}]}, + message: {text: input.message}, ...(input.title ? {title: input.title} : {}), }; return bridgeRequest({ diff --git a/server/agent/harness/model-resolver.test.ts b/server/agent/harness/model-resolver.test.ts index 68bf41b3..1a16438c 100644 --- a/server/agent/harness/model-resolver.test.ts +++ b/server/agent/harness/model-resolver.test.ts @@ -1,9 +1,28 @@ -import {describe, expect, it} from "vitest"; -import {resolvePiApiKeyForModelFromConfig, resolvePiModelFromConfig} from "nbook/server/agent/harness/model-resolver"; +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {resolvePiApiKeyForModelFromConfig, resolvePiModelFromConfig, warnProviderCompatIssues} from "nbook/server/agent/harness/model-resolver"; import {createDefaultEffectiveConfig} from "nbook/server/config/normalizer"; import type {EffectiveConfig} from "nbook/server/config/types"; +const appLoggerMocks = vi.hoisted(() => ({ + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +})); + +vi.mock("nbook/server/app-logs/logger", () => ({ + appLogger: appLoggerMocks, +})); + describe("model resolver", () => { + beforeEach(() => { + appLoggerMocks.warn.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it("按 providerConfigId + model.id 从当前 effective config 解析完整模型", () => { const config = createConfig(); const model = resolvePiModelFromConfig(config, "leader.default"); @@ -36,6 +55,87 @@ describe("model resolver", () => { expect(() => resolvePiModelFromConfig(config, "leader.default", {modelKey: "missing/model"})).toThrow("模型未启用或不存在"); expect(() => resolvePiModelFromConfig(config, "leader.default", {modelKey: "bad-key"})).toThrow("模型 key 格式错误"); }); + + describe("warnProviderCompatIssues ARK 启发式", () => { + it("ARK baseURL + reasoning + 无 supportsDeveloperRole:false → warn 一次", () => { + const config = createArkConfig({supportsDeveloperRole: undefined}); + warnProviderCompatIssues(config); + + expect(appLoggerMocks.warn).toHaveBeenCalledTimes(1); + const [event, payload] = appLoggerMocks.warn.mock.calls[0]!; + expect(event).toBe("agent.model.arkCompat.developerRoleNotDisabled"); + expect(payload).toMatchObject({ + provider: "volcengine-ark", + model: "doubao-seed-2-0-code-preview-260215", + baseURL: "https://ark.cn-beijing.volces.com/api/v3", + }); + expect((payload as {hint: string}).hint).toContain("supportsDeveloperRole:false"); + }); + + it("同一 (provider, model) 二次扫描不重复 warn(进程内去重)", () => { + // 用独立的 provider/model id 避免与同 suite 上一个 test 的 dedupe Set 共用 + const config = createArkConfig({supportsDeveloperRole: undefined}); + config.models.providers["volcengine-ark"]!.models["dedupe-target"] = { + ...config.models.providers["volcengine-ark"]!.models["doubao-seed-2-0-code-preview-260215"]!, + id: "dedupe-target", + name: "Dedupe Target", + }; + + warnProviderCompatIssues(config); + expect(appLoggerMocks.warn).toHaveBeenCalledTimes(1); + warnProviderCompatIssues(config); + expect(appLoggerMocks.warn).toHaveBeenCalledTimes(1); + }); + + it("compat.supportsDeveloperRole=false 显式设置时不 warn", () => { + const config = createArkConfig({supportsDeveloperRole: false}); + warnProviderCompatIssues(config); + + expect(appLoggerMocks.warn).not.toHaveBeenCalled(); + }); + + it("非 ARK baseURL(DeepSeek / OpenAI)不 warn", () => { + const config = createDefaultEffectiveConfig(); + config.models = { + defaultModelKey: "deepseek/deepseek-v4-flash", + providers: { + "deepseek": { + name: "DeepSeek", + enabled: true, + modelApi: "openai-completions", + options: {apiKey: "sk", baseURL: "https://api.deepseek.com", proxy: "", timeoutMs: null, requestOptions: {}}, + models: { + "deepseek-v4-flash": { + name: "deepseek-v4-flash", + id: "deepseek-v4-flash", + group: null, + enabled: true, + api: "openai-completions", + reasoning: true, + input: ["text"], + maxTokens: 32_000, + cost: null, + compat: null, + headers: null, + thinkingLevelMap: null, + contextWindowTokens: 128_000, + }, + }, + }, + }, + }; + config.agent.profileModelDefaults.modelKey = "deepseek/deepseek-v4-flash"; + + warnProviderCompatIssues(config); + expect(appLoggerMocks.warn).not.toHaveBeenCalled(); + }); + + it("reasoning=false 时即使 ARK 也不 warn(非 developer role 路径)", () => { + const config = createArkConfig({supportsDeveloperRole: undefined, reasoning: false}); + warnProviderCompatIssues(config); + expect(appLoggerMocks.warn).not.toHaveBeenCalled(); + }); + }); }); function createConfig(): Pick { @@ -71,3 +171,39 @@ function createConfig(): Pick { config.agent.profileModelDefaults.modelKey = "local-openai/test-model"; return config; } + +function createArkConfig(options: {supportsDeveloperRole?: boolean; reasoning?: boolean}): Pick { + const config = createDefaultEffectiveConfig(); + config.models = { + defaultModelKey: "volcengine-ark/doubao-seed-2-0-code-preview-260215", + providers: { + "volcengine-ark": { + name: "Volcengine Ark", + enabled: true, + modelApi: "openai-completions", + options: {apiKey: "ark-test", baseURL: "https://ark.cn-beijing.volces.com/api/v3", proxy: "", timeoutMs: 60000, requestOptions: {}}, + models: { + "doubao-seed-2-0-code-preview-260215": { + name: "doubao-seed-2-0-code-preview", + id: "doubao-seed-2-0-code-preview-260215", + group: null, + enabled: true, + api: "openai-completions", + reasoning: options.reasoning ?? true, + input: ["text"], + maxTokens: 32_768, + cost: null, + compat: options.supportsDeveloperRole === undefined + ? null + : {supportsDeveloperRole: options.supportsDeveloperRole}, + headers: null, + thinkingLevelMap: null, + contextWindowTokens: 256_000, + }, + }, + }, + }, + }; + config.agent.profileModelDefaults.modelKey = "volcengine-ark/doubao-seed-2-0-code-preview-260215"; + return config; +} diff --git a/server/agent/harness/model-resolver.ts b/server/agent/harness/model-resolver.ts index 6eb48b56..167b1b1b 100644 --- a/server/agent/harness/model-resolver.ts +++ b/server/agent/harness/model-resolver.ts @@ -1,8 +1,8 @@ import type {Api, Model} from "@earendil-works/pi-ai"; import {resolvePiModelMetadata, type ResolvedPiModel} from "nbook/server/agent/harness/pi-model-metadata"; -import type {AgentProfileModelConfig} from "nbook/server/config/types"; +import type {AgentProfileModelConfig, ConfiguredModelConfig, ConfiguredProviderConfig, EffectiveConfig} from "nbook/server/config/types"; import {loadGlobalEffectiveConfigSync} from "nbook/server/config/config-service"; -import type {EffectiveConfig} from "nbook/server/config/types"; +import {appLogger} from "nbook/server/app-logs/logger"; type ModelOverrideInput = Partial & { model?: string | null; @@ -10,6 +10,64 @@ type ModelOverrideInput = Partial & { export type {ResolvedPiModel} from "nbook/server/agent/harness/pi-model-metadata"; +/** + * Volcengine Ark(方舟)OpenAI-compat 端点的 baseURL 启发式。 + * + * 只匹配官方 `ark..volces.com` 三段式,避免误伤同样走 + * openai-completions 的 DeepSeek / Moonshot 等 provider;详细背景见 + * `reference/harness/ark-models.md`。 + */ +const ARK_BASE_URL_PATTERN = /^https:\/\/ark\.[a-z0-9-]+\.volces\.com\/api\/v3\/?$/u; + +/** 已为本次进程 warn 过的 (providerId/modelId) 集合,跨 invocation 去重。 */ +const arkCompatWarnedKeys = new Set(); + +/** + * 检查单个 model provider 是否命中"ARK + reasoning + 没显式关 developer role"组合, + * 命中时输出一条 `appLogger.warn` 提醒用户在 settings 补 + * `compat.supportsDeveloperRole: false`。同一 (provider, model) 只 warn 一次。 + * + * 设计取舍:best-effort 启发式,不阻塞 harness 启动;用户可以在 UI 看到 warn + * 后再补 compat 字段(registry 标 `next-run`,下次会话生效)。 + */ +export function warnArkDeveloperRoleCompatIfNeeded( + providerId: string, + provider: ConfiguredProviderConfig, + model: ConfiguredModelConfig, +): void { + const dedupeKey = `${providerId}/${model.id}`; + if (arkCompatWarnedKeys.has(dedupeKey)) return; + if (model.api !== "openai-completions") return; + if (!model.reasoning) return; + const baseUrl = provider.options.baseURL?.trim() ?? ""; + if (!ARK_BASE_URL_PATTERN.test(baseUrl)) return; + if (model.compat && (model.compat as Record).supportsDeveloperRole === false) return; + + arkCompatWarnedKeys.add(dedupeKey); + void appLogger.warn("agent.model.arkCompat.developerRoleNotDisabled", { + provider: providerId, + model: model.id, + baseURL: baseUrl, + hint: "ARK OpenAI 端点拒 developer role,请在 model.compat 设 supportsDeveloperRole:false。详见 reference/harness/ark-models.md。", + }); +} + +/** + * 扫描全量 enabled 模型 provider,触发 ARK compat warn。设计为:每次进程启动 + * 由 harness 初始化入口调用一次;后续用户切换/保存新配置时再调一次。 + */ +export function warnProviderCompatIssues( + config: Pick, +): void { + for (const [providerId, provider] of Object.entries(config.models.providers)) { + if (!provider?.enabled) continue; + for (const model of Object.values(provider.models)) { + if (!model?.enabled) continue; + warnArkDeveloperRoleCompatIfNeeded(providerId, provider, model); + } + } +} + /** * 将当前 effective config 的模型引用解析成 Pi Model。 */ @@ -35,6 +93,9 @@ export function resolvePiModelFromConfig( throw new Error(`模型未启用或不存在:${modelKey}`); } + // 顺手触发 ARK compat 启发式 warn(同一 (provider, model) 在进程内只 warn 一次) + warnArkDeveloperRoleCompatIfNeeded(providerId, provider, model); + return resolvePiModelMetadata(providerId, provider, model); } diff --git a/vitest.config.ts b/vitest.config.ts index a2bc430c..99ae8c2b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -39,6 +39,7 @@ export default defineConfig({ "app/utils/**/*.test.ts", "scripts/build/**/*.test.ts", "scripts/ci/**/*.test.ts", + "scripts/cli/**/*.test.ts", "scripts/db/**/*.test.ts", "scripts/install/**/*.test.ts", "scripts/release/**/*.test.ts",