Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions reference/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
158 changes: 158 additions & 0 deletions reference/harness/ark-models.md
Original file line number Diff line number Diff line change
@@ -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/<model-id-from-list-models>",
"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": "<model-id-from-list-models>", // 见下一节怎么查
"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-<uuid>` 这种
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))。
6 changes: 6 additions & 0 deletions reference/harness/invoke-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 之前先读这一份。
148 changes: 148 additions & 0 deletions scripts/cli/bridge/commands/send.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}

const fetchMock = vi.fn<typeof fetch>();

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<string, string> = {};
for (const [k, v] of Object.entries((init?.headers ?? {}) as Record<string, string>)) {
headers[k.toLowerCase()] = v;
}
return {
url: String(url),
method: (init?.method ?? "GET").toString(),
body: readJsonBody(init),
headers,
};
}

function makeInput(overrides: Partial<SendInput> = {}): 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<string, unknown>;
expect(body).toMatchObject({
mode: "prompt",
message: {text: "用一句话介绍你自己"},
});
// 必须不存在旧的 Anthropic 风格 content 数组——DTO strict() 会拒它
const message = body.message as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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");
});
});
8 changes: 7 additions & 1 deletion scripts/cli/bridge/commands/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<InvokeResult> {
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<InvokeResult>({
Expand Down
Loading
Loading