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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ npm run typecheck # tsc --noEmit,同时检查 src/ 与 test/

本包声明了 `"type": "module"` 并使用 `rootDir: "."` 编译,因此**所有 import 都使用 `.js` 后缀,且所有路径以仓库根目录为基准**(例如 `import { loadConfig } from "./config.js"`,测试位于 `test/` 下,导入如 `"../src/catalog.js"`)。

`src/` 中不使用 `any`:客户端请求、Provider 响应与 SSE 事件这类无法预先验证的 JSON 一律建模为 `src/json.ts` 的 `JsonRecord`,通过 `recStr`/`recNum`/`recCount`/`recObj`/`recObjs`/`parse` 读取。字段名写错或漏掉非对象检查会直接编译失败,而不是变成一次运行时 undefined。tsconfig 开启了 `noUncheckedIndexedAccess` 等严格标志,ESLint 只保留 tsc 看不见的规则(见 `eslint.config.js`)。
`src/` 中不使用 `any`:客户端请求、Provider 响应与 SSE 事件这类无法预先验证的 JSON 一律建模为 `src/json.ts` 的 `JsonRecord`,通过 `recStr`/`recNum`/`recCount`/`recObj`/`recObjs`/`parseJson` 读取。字段名写错或漏掉非对象检查会直接编译失败,而不是变成一次运行时 undefined。tsconfig 开启了 `noUncheckedIndexedAccess` 等严格标志,ESLint 只保留 tsc 看不见的规则(见 `eslint.config.js`)。

### 两套协议转换路径

Expand All @@ -38,7 +38,7 @@ npm run typecheck # tsc --noEmit,同时检查 src/ 与 test/

转换函数集中在 `src/convert/`,按**上游协议**(而非转换方向)拆分为四个文件:

- `shared.ts` — 跨方向 helper:图片/effort/thinking/三套 tool-choice 转换、采样参数(JSON 字段的读取统一走 `src/json.ts`)
- `shared.ts` — 跨方向 helper:图片/effort/thinking/三套 tool-choice 转换、采样参数、tool_result 媒体的双向拆分/回填、`count_tokens` 的本地 token 估算(JSON 字段的读取统一走 `src/json.ts`)
- `chat.ts` — 上游 = Chat Completions 的全部方向:`toChatRequest` / `fromChatResponse`(Anthropic ↔ Chat Completions)、`toChatCompletionsRequest` / `fromChatResponseToResponses`(Responses ↔ Chat Completions)
- `responses.ts` — 上游 = Responses:`toResponsesRequest` / `fromResponsesResponse`(Anthropic ↔ Responses);`toResponsesRequestFromChat` / `fromResponsesResponseToChat`(本地 Chat Completions ↔ Responses;分别复用 `toResponsesRequest`/`fromResponsesResponse` 加 `anthropic.ts` 的两个新函数拼出,不重复写转换逻辑)
- `anthropic.ts` — 上游 = Anthropic(自定义 Provider 声明 `protocol: "anthropic"` 时专属):`toAnthropicRequest` / `fromAnthropicResponse`(Responses ↔ Anthropic;Codex 只会看到本地 Responses 端点,仍需经此转换才能到达一个原生 Anthropic 上游);`toAnthropicRequestFromChat` / `fromAnthropicResponseToChat`(本地 Chat Completions ↔ Anthropic,供 `/v1/chat/completions` 使用,只映射主流字段,不映射 DeepSeek 的 `thinking`/`reasoning_effort` 扩展)
Expand Down
14 changes: 7 additions & 7 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ cli.ts (claude/codex/proxy/exec)
│
├─ server.ts startAdapter() → 生成本地随机 token,绑定回环端口
│ ├─ /health
│ ├─ /v1/models
│ ├─ /v1/messages (Anthropic)
│ ├─ /v1/models 与 /v1/models/{id}
│ ├─ /v1/messages (Anthropic;含 POST /v1/messages/count_tokens)
│ ├─ /v1/responses (OpenAI Responses)
│ └─ /v1/chat/completions (OpenAI Chat Completions)
│
Expand All @@ -54,7 +54,7 @@ cli.ts (claude/codex/proxy/exec)
Claude Code (Anthropic Messages API)
│ POST /v1/messages Bearer <本地随机 token>
▼
server.ts (校验本地 token,不转发给上游;按 config.retry 对 429/502/503/504 做指数退避重试)
server.ts (校验本地 token,不转发给上游;按 config.retry 对 408/429/502/503/504 做指数退避重试;`--max-concurrency` 限制在途上游请求数)
│
├─ catalog.ts providerFor(按 model 选路由;纯路由模块,不含转换函数)
│
Expand All @@ -73,9 +73,9 @@ Claude Code (Anthropic Messages API)
└─ 流式: streaming/anthropic-passthrough.ts, streaming/anthropic-to-responses.ts
```

`src/convert/` 是转换函数所在的模块化目录:`shared.ts`(跨方向 helper:图片/effort/thinking/tool-choice 映射、采样参数)、`chat.ts`(上游 = Chat Completions 的全部方向)、`responses.ts`(上游 = Responses)、`anthropic.ts`(上游 = Anthropic,自定义 Provider 专属)、`index.ts`(barrel 导出,公共函数名不变)。`src/catalog.ts` 只保留 `providers`/`providerFor`/`honorRequestedModel` 路由函数。
`src/convert/` 是转换函数所在的模块化目录:`shared.ts`(跨方向 helper:图片/effort/thinking/tool-choice 映射、采样参数、tool_result 媒体的双向拆分/回填、`count_tokens` 的本地 token 估算)、`chat.ts`(上游 = Chat Completions 的全部方向)、`responses.ts`(上游 = Responses)、`anthropic.ts`(上游 = Anthropic,自定义 Provider 专属)、`index.ts`(barrel 导出,公共函数名不变)。`src/catalog.ts` 只保留 `providers`/`providerFor`/`honorRequestedModel` 路由函数。

所有 wire payload(客户端请求、Provider 响应、SSE 事件)经 `src/json.ts` 的 `JsonRecord` + 字段访问器(`recStr`/`recNum`/`recCount`/`recObj`/`recObjs`/`parse`)读取,代码库里不使用 `any`:字段名写错或漏掉非对象检查由编译器拦下,而不是留成一次 undefined 运行时错误。
所有 wire payload(客户端请求、Provider 响应、SSE 事件)经 `src/json.ts` 的 `JsonRecord` + 字段访问器(`recStr`/`recNum`/`recCount`/`recObj`/`recObjs`/`parseJson`)读取,代码库里不使用 `any`:字段名写错或漏掉非对象检查由编译器拦下,而不是留成一次 undefined 运行时错误。

`src/streaming/` 同样是拆分后的模块化目录(`common.ts` 收敛公共 SSE 写入/heartbeat/usage capture 逻辑,每条协议转换路径各占一个文件),不是单一的 `streaming.ts`。

Expand Down Expand Up @@ -108,7 +108,7 @@ Claude Code (Anthropic Messages API)
- **凭据隔离**:真实 Key 只留在 Adapter 内,客户端只拿到每次启动随机生成的本地 token
- **工具调用**:只做协议转换(`tool_use`↔`function_call`、`tool_result`↔`function_call_output`),不在 Adapter 内执行
- **模型路由**:没有隐式 `auto` 路由(已移除);运行时必须解析为具体模型,解析优先级见 README「Configuration」
- **上游重试**:`server.ts` 的 `forwardWithRetry` 对网络失败和 429/502/503/504 做指数退避重试(`--retry`/`AGENTX_RETRY`,默认 3,0 禁用),仅发生在流式传输开始之前
- **上游重试**:`server.ts` 的 `forwardWithRetry` 对网络失败和 408/429/502/503/504 做指数退避重试(`--retry`/`AGENTX_RETRY`,默认 3,0 禁用),仅发生在流式传输开始之前
- **命令覆盖**:`claude`/`codex`/`proxy`/`exec`/`auth`/`usage`/`quota`/`doctor`/`forget`/`version`/`help`
- **可选集成**:`agentx quota --provider <id>` 查询 DeepSeek/OpenRouter 额度(OpenCode 返回"不支持";`usage --provider` 为过渡期别名);凭据只来自环境变量(`AGENTX_<PROVIDER>_API_KEY`,旧的无前缀变量兼容)

Expand All @@ -118,7 +118,7 @@ Claude Code (Anthropic Messages API)
|---|---|
| `src/cli.ts` | 命令分发、参数解析、编排(`runAuthCommand`/`runDoctorCommand`/`runClientLaunch` 等具名函数) |
| `src/config.ts` | 配置加载与优先级 |
| `src/server.ts` | HTTP Adapter、认证、路由、重试、端口回退 |
| `src/server.ts` | HTTP Adapter、认证、路由、重试、并发闸(`--max-concurrency`)、端口回退 |
| `src/catalog.ts` | 模型路由(`providers`/`providerFor`/`honorRequestedModel`),不含转换函数 |
| `src/convert/` | 协议转换函数,按上游协议拆分:`shared.ts`(跨方向 helper)、`chat.ts`(上游 = Chat Completions)、`responses.ts`(上游 = Responses)、`anthropic.ts`(上游 = Anthropic,自定义 Provider 专属)、`index.ts`(barrel) |
| `src/json.ts` | wire payload 的类型词汇表:`JsonRecord` 与字段访问器,替代 `any` |
Expand Down
12 changes: 6 additions & 6 deletions src/convert/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
*/
import type { AnthropicMessage, AnthropicRequest, AnthropicThinking } from "./shared.js";
import type { JsonRecord, JsonValue } from "../json.js";
import { asRecords, isRecord, parse, recNum, recObj, recObjs, recStr } from "../json.js";
import { anthropicThinking, anthropicToolChoice, collapseAnthropicContent, textOfBlocks, toAnthropicImageSource } from "./shared.js";
import { asRecords, isRecord, parseJson, recNum, recObj, recObjs, recStr } from "../json.js";
import { anthropicThinking, anthropicToolChoice, collapseAnthropicContent, textOfBlocks, toAnthropicImageSource, toolResultBlocks } from "./shared.js";

/** Responses message content (string or input_text/input_image parts) to Anthropic message content. */
function toAnthropicContent(content: JsonValue): JsonValue {
Expand Down Expand Up @@ -44,10 +44,10 @@ export function toAnthropicRequest(input: JsonRecord, model: string): AnthropicR
const flushAssistant = () => { if (pendingAssistantBlocks.length) { messages.push({ role: "assistant", content: pendingAssistantBlocks }); pendingAssistantBlocks = []; } };
for (const item of items) {
if (item.type === "reasoning") continue; // no signature to echo upstream; dropped like other local-only reasoning echoes
if (item.type === "function_call") { pendingAssistantBlocks.push({ type: "tool_use", id: recStr(item, "call_id") ?? recStr(item, "id"), name: recStr(item, "name"), input: parse(item.arguments) }); continue; }
if (item.type === "function_call") { pendingAssistantBlocks.push({ type: "tool_use", id: recStr(item, "call_id") ?? recStr(item, "id"), name: recStr(item, "name"), input: parseJson(item.arguments) }); continue; }
if (item.type === "function_call_output") {
flushAssistant();
messages.push({ role: "user", content: [{ type: "tool_result", tool_use_id: item.call_id, content: typeof item.output === "string" ? item.output : JSON.stringify(item.output ?? "") }] });
messages.push({ role: "user", content: [{ type: "tool_result", tool_use_id: item.call_id, content: toolResultBlocks(item.output) }] });
continue;
}
if (item.role === "assistant") { const text = responsesItemText(item.content); if (text) pendingAssistantBlocks.push({ type: "text", text }); continue; }
Expand Down Expand Up @@ -160,15 +160,15 @@ export function toAnthropicRequestFromChat(input: JsonRecord, model: string): An
const role = recStr(message, "role");
if (role === "system") { const content = recStr(message, "content"); if (content) systemParts.push(content); continue; }
if (role === "tool") {
messages.push({ role: "user", content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? "") }] });
messages.push({ role: "user", content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: toolResultBlocks(message.content) }] });
continue;
}
if (typeof role !== "string") continue;
const blocks: JsonRecord[] = [];
const converted = chatContentToAnthropic(message.content);
if (Array.isArray(converted)) blocks.push(...asRecords(converted));
else if (converted) blocks.push({ type: "text", text: converted });
for (const call of recObjs(message, "tool_calls")) blocks.push({ type: "tool_use", id: recStr(call, "id"), name: recStr(recObj(call, "function"), "name"), input: parse(recStr(recObj(call, "function"), "arguments")) });
for (const call of recObjs(message, "tool_calls")) blocks.push({ type: "tool_use", id: recStr(call, "id"), name: recStr(recObj(call, "function"), "name"), input: parseJson(recStr(recObj(call, "function"), "arguments")) });
if (blocks.length) messages.push({ role, content: blocks });
}
const rawStop = input.stop;
Expand Down
4 changes: 2 additions & 2 deletions src/convert/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/
import type { AnthropicMessage, AnthropicRequest } from "./shared.js";
import type { JsonRecord, JsonValue } from "../json.js";
import { isRecord, parse, recNum, recObj, recObjs, recStr } from "../json.js";
import { isRecord, parseJson, recNum, recObj, recObjs, recStr } from "../json.js";
import { acceptsImageInput, chatControlParams, collapseAnthropicContent, imageDataUri, samplingParams, toolResultContent, toolResultText, TOOL_RESULT_MEDIA_PROMPT } from "./shared.js";
import type { ProviderModel } from "../providers/types.js";
import { isDeepSeekLongContextModel } from "../providers/registry.js";
Expand Down Expand Up @@ -112,7 +112,7 @@ export function fromChatResponse(response: JsonRecord, model: string): JsonRecor
const text = chatText(message.content);
if (text) content.push({ type: "text", text });
const toolCalls = recObjs(message, "tool_calls");
for (const call of toolCalls) content.push({ type: "tool_use", id: recStr(call, "id"), name: recStr(recObj(call, "function"), "name"), input: parse(recStr(recObj(call, "function"), "arguments")) });
for (const call of toolCalls) content.push({ type: "tool_use", id: recStr(call, "id"), name: recStr(recObj(call, "function"), "name"), input: parseJson(recStr(recObj(call, "function"), "arguments")) });
const usage = recObj(response, "usage");
return { id: recStr(response, "id") ?? `msg_${crypto.randomUUID()}`, type: "message", role: "assistant", model, content, stop_reason: anthropicStopReason(choice.finish_reason, toolCalls.length > 0), stop_sequence: null, usage: { input_tokens: recNum(usage, "prompt_tokens") ?? 0, output_tokens: recNum(usage, "completion_tokens") ?? 0, cache_creation_input_tokens: 0, cache_read_input_tokens: chatUsageDetails(response, "usage", "prompt_tokens_details", "cached_tokens") ?? 0 } };
}
Expand Down
4 changes: 2 additions & 2 deletions src/convert/responses.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** Anthropic Messages API <-> Responses API: the direction whose upstream speaks the Responses protocol. */
import type { AnthropicMessage, AnthropicRequest, ResponsesItem } from "./shared.js";
import type { JsonRecord, JsonValue } from "../json.js";
import { asRecords, isRecord, parse, recNum, recObj, recObjs, recStr } from "../json.js";
import { asRecords, isRecord, parseJson, recNum, recObj, recObjs, recStr } from "../json.js";
import { acceptsImageInput, chatThinking, imageDataUri, reasoningEffort, responsesToolChoice, toolResultContent, toolResultText } from "./shared.js";
import type { ProviderModel } from "../providers/types.js";
import { fromAnthropicResponseToChat, toAnthropicRequestFromChat } from "./anthropic.js";
Expand Down Expand Up @@ -101,7 +101,7 @@ export function fromResponsesResponse(response: JsonRecord, model: string): Json
.flatMap((item) => asRecords(item.content))
.filter((part) => part.type === "output_text")
.map((part) => recStr(part, "text") ?? "").join("");
const toolUses = output.filter((item) => item.type === "function_call").map((item) => ({ type: "tool_use", id: recStr(item, "call_id") ?? recStr(item, "id"), name: recStr(item, "name"), input: parse(recStr(item, "arguments")) }));
const toolUses = output.filter((item) => item.type === "function_call").map((item) => ({ type: "tool_use", id: recStr(item, "call_id") ?? recStr(item, "id"), name: recStr(item, "name"), input: parseJson(recStr(item, "arguments")) }));
const thinking = reasoningText(output);
const usage = recObj(response, "usage");
const cached = recNum(recObj(usage, "input_tokens_details"), "cached_tokens");
Expand Down
68 changes: 68 additions & 0 deletions src/convert/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,71 @@ export function toolResultText(text: string, imageCount: number, forwarded: bool
export function acceptsImageInput(provider?: { modalities?: string[] }): boolean {
return provider?.modalities === undefined || provider.modalities.includes("image");
}

/**
* Anthropic `tool_result` content built from a Chat Completions or Responses
* tool-output value — the inbound counterpart of `toolResultContent()`. Both
* dialects can carry images in a tool output (`image_url` / `input_image`
* parts), and Anthropic accepts image blocks inside a tool_result directly,
* so they are mapped to blocks instead of stringified into the text — the
* exact mistake `toolResultContent()` documents for the other direction.
* Text-only results collapse back to a plain string.
*/
export function toolResultBlocks(value: unknown): JsonValue {
if (typeof value === "string") return value;
if (value === undefined || value === null) return "";
if (!Array.isArray(value)) return JSON.stringify(value);
const parts: JsonRecord[] = [];
for (const raw of value as JsonValue[]) {
if (isRecord(raw)) {
if (raw.type === "text" || raw.type === "input_text" || raw.type === "output_text") {
const text = recStr(raw, "text");
if (text !== undefined) parts.push({ type: "text", text });
continue;
}
const url = recStr(raw, "image_url") ?? recStr(recObj(raw, "image_url"), "url");
if ((raw.type === "input_image" || raw.type === "image_url") && url) {
const source = toAnthropicImageSource(url);
if (source) parts.push({ type: "image", source });
continue;
}
}
// Anything Anthropic does not accept as a tool_result block is kept as
// text rather than dropped, so an unknown part still reaches the model.
if (raw !== undefined && raw !== null) parts.push({ type: "text", text: typeof raw === "string" ? raw : JSON.stringify(raw) });
}
// Text-only results collapse to a string, newline-joined like
// `toolResultContent()` does for the other direction.
if (parts.length && parts.every((part) => part.type === "text")) return parts.map((part) => recStr(part, "text") ?? "").join("\n");
return collapseAnthropicContent(parts);
}

/**
* Flat per-image token allowance for the estimator below. Anthropic bills an
* image by its pixel area, which a request body does not carry, so a single
* mid-sized-screenshot figure stands in for it — far closer than counting the
* base64 payload as if it were prose.
*/
const IMAGE_TOKEN_ESTIMATE = 1_600;

/**
* Approximate the input tokens of an Anthropic Messages request. Used by
* `/v1/messages/count_tokens` when the upstream protocol has no equivalent
* endpoint to ask. Four characters per token is the conventional English
* approximation; this is explicitly an estimate, but one made from the actual
* request, unlike the blind fallback a 404 used to force on the client.
*/
export function estimateInputTokens(input: JsonRecord): number {
let chars = 0;
const walk = (value: unknown): void => {
if (typeof value === "string") { chars += value.length; return; }
if (Array.isArray(value)) { value.forEach(walk); return; }
if (!isRecord(value)) return;
// An image block's base64 payload is not prose; charge it a flat rate
// instead of walking into the data string.
if (value.type === "image") { chars += IMAGE_TOKEN_ESTIMATE * 4; return; }
Object.values(value).forEach(walk);
};
walk(input.system); walk(input.messages); walk(input.tools);
return Math.max(1, Math.ceil(chars / 4));
}
2 changes: 1 addition & 1 deletion src/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,6 @@ export function recCount(obj: JsonRecord | undefined, key: string): number | und
}

/** Best-effort JSON parse of a wire payload; malformed or absent input becomes `{}` rather than throwing. */
export function parse(value: unknown): JsonValue {
export function parseJson(value: unknown): JsonValue {
try { return (typeof value === "string" ? JSON.parse(value) : value ?? {}) as JsonValue; } catch { return {}; }
}
Loading
Loading