@wacry/codeks-bridge 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,7 @@ codeks-bridge config-snippet --config /absolute/path/bridge.json
30
30
 
31
31
  - 上游错误保留原始详情交给客户端;bridge 不自动换端点、重试或更换压缩方式。
32
32
  - Reasoning 默认只回放给原目标。原生压缩与 reasoning 的跨目标复用分别配置,实际兼容性由上游决定。
33
- - GPT 默认使用 `compaction.mode: "native"`:先做原生加密压缩,需要文字表示时再向原来源复述并缓存。其他模型默认使用标准文字压缩,也可通过 [`compaction.model`](docs/config.md#压缩模式与执行模型) 委托 GPT 执行原生压缩。
33
+ - GPT 默认使用 `compaction.mode: "native"`:先做原生加密压缩,需要文字表示时再向原来源复述并缓存。其他模型默认使用标准文字压缩,也可通过 [`compaction.model`](docs/config.md#压缩模式与执行模型) 委托常用 GPT 执行原生压缩,直接复用已有模型配置;见 [GPT+Claude 双模型示例](examples/gpt-claude.json)。
34
34
  - 历史由 codeks 保存;按需复述的成功结果另存本地缓存。封装只是编码,文字 reasoning、摘要及复述配方可读。
35
35
  - Lark/regex 转为工具描述约束。图片理解由模型支持,客户端配置关闭网页搜索和图片生成。
36
36
 
@@ -0,0 +1,5 @@
1
+ import type { AdapterId, ClaudeMessagesOptions, ResolvedModel } from "./types.ts";
2
+ /** Validate bridge-owned settings; native options stay open to upstream extensions. */
3
+ export declare function validateAdapterConfig(value: unknown, location: string, adapter?: AdapterId): void;
4
+ /** Model leaves override connection leaves; neither is part of state provenance. */
5
+ export declare function resolveClaudeOptions(route: Pick<ResolvedModel, "upstream" | "config">): ClaudeMessagesOptions;
@@ -0,0 +1,41 @@
1
+ import { BridgeError } from "./errors.js";
2
+ function object(value) {
3
+ return !!value && typeof value === "object" && !Array.isArray(value);
4
+ }
5
+ /** Validate bridge-owned settings; native options stay open to upstream extensions. */
6
+ export function validateAdapterConfig(value, location, adapter) {
7
+ if (value === undefined)
8
+ return;
9
+ function fail(message) {
10
+ throw new BridgeError(`Invalid configuration: ${location}.adapters: ${message}`);
11
+ }
12
+ if (!object(value))
13
+ fail("must be an object");
14
+ for (const [name, settings] of Object.entries(value)) {
15
+ if (name !== "claude-messages")
16
+ fail(`unknown adapter settings ${name}`);
17
+ if (adapter !== undefined && name !== adapter)
18
+ fail(`${name} does not match the model adapter ${adapter}`);
19
+ if (!object(settings))
20
+ fail(`${name} must be an object`);
21
+ for (const field of Object.keys(settings)) {
22
+ if (field !== "tool_updates" && field !== "thinking_mismatch")
23
+ fail(`unknown ${name} setting ${field}`);
24
+ }
25
+ if (settings.tool_updates !== undefined && !["inline", "top_level"].includes(settings.tool_updates)) {
26
+ fail(`${name}.tool_updates must be inline or top_level`);
27
+ }
28
+ if (settings.thinking_mismatch !== undefined && !["drop_block", "error", "upstream_default"].includes(settings.thinking_mismatch)) {
29
+ fail(`${name}.thinking_mismatch must be drop_block, error, or upstream_default`);
30
+ }
31
+ }
32
+ }
33
+ /** Model leaves override connection leaves; neither is part of state provenance. */
34
+ export function resolveClaudeOptions(route) {
35
+ const upstream = route.upstream.adapters?.["claude-messages"];
36
+ const model = route.config.adapters?.["claude-messages"];
37
+ return {
38
+ tool_updates: model?.tool_updates ?? upstream?.tool_updates ?? "inline",
39
+ thinking_mismatch: model?.thinking_mismatch ?? upstream?.thinking_mismatch ?? "drop_block",
40
+ };
41
+ }
@@ -0,0 +1,13 @@
1
+ import { type ToolSet } from "../tools.ts";
2
+ import type { ResponsesRequest } from "../types.ts";
3
+ export interface AnthropicToolDefinition {
4
+ name: string;
5
+ description: string;
6
+ input_schema: Record<string, unknown>;
7
+ }
8
+ /** Keep later definitions at their original conversation boundary. */
9
+ export declare function buildAnthropicTools(request: ResponsesRequest, mode: "inline" | "top_level"): {
10
+ tools: ToolSet;
11
+ definitions: AnthropicToolDefinition[];
12
+ input: ResponsesRequest["input"];
13
+ };
@@ -0,0 +1,41 @@
1
+ import { BridgeError } from "../errors.js";
2
+ import { buildToolSet, normalizeToolDeclarations } from "../tools.js";
3
+ function definition(tool) {
4
+ return { name: tool.flatName, description: tool.description, input_schema: tool.parameters };
5
+ }
6
+ /** Keep later definitions at their original conversation boundary. */
7
+ export function buildAnthropicTools(request, mode) {
8
+ if (mode === "top_level") {
9
+ const normalized = normalizeToolDeclarations(request);
10
+ const tools = buildToolSet(normalized.tools);
11
+ return { tools, definitions: tools.entries.map(definition), input: normalized.input };
12
+ }
13
+ const tools = buildToolSet(request.tools);
14
+ const initial = new Map(tools.entries.map((tool) => [tool.flatName, definition(tool)]));
15
+ if (!Array.isArray(request.input)) {
16
+ return { tools, definitions: [...initial.values()], input: request.input };
17
+ }
18
+ const input = [];
19
+ let prefix = true;
20
+ for (const item of request.input) {
21
+ if (item?.type === "additional_tools") {
22
+ if (!Array.isArray(item.tools))
23
+ throw new BridgeError("additional_tools requires a tools array.");
24
+ const definitions = tools.add(item.tools).map(definition);
25
+ if (prefix) {
26
+ for (const tool of definitions)
27
+ initial.set(tool.name, tool);
28
+ }
29
+ else if (definitions.length) {
30
+ input.push({ ...item, tools: definitions });
31
+ }
32
+ }
33
+ else {
34
+ input.push(item);
35
+ if (!item || !["system", "developer"].includes(item.role) ||
36
+ (item.type !== undefined && item.type !== "message"))
37
+ prefix = false;
38
+ }
39
+ }
40
+ return { tools, definitions: [...initial.values()], input };
41
+ }
@@ -4,5 +4,6 @@ import type { Backend, BackendContext } from "../types.ts";
4
4
  export declare function buildAnthropicRequest(context: BackendContext): {
5
5
  body: Record<string, any>;
6
6
  tools: ToolSet;
7
+ betas: string[];
7
8
  };
8
9
  export declare const runAnthropic: Backend;
@@ -1,10 +1,14 @@
1
1
  import { encodeReasoning } from "../carrier.js";
2
+ import { resolveClaudeOptions } from "../adapter-options.js";
2
3
  import { BridgeError, upstreamError } from "../errors.js";
3
4
  import { prepareHistory } from "../history.js";
4
5
  import { reasoningEffort, reasoningSummaryRequested } from "../profiles.js";
5
6
  import { parseJsonSse } from "../sse.js";
6
- import { buildToolSet, normalizeToolDeclarations, safeCallId } from "../tools.js";
7
+ import { safeCallId } from "../tools.js";
7
8
  import { upstreamFetch } from "../upstream.js";
9
+ import { buildAnthropicTools } from "./anthropic-tools.js";
10
+ const THINKING_BINDING_BETA = "thinking-binding-controls-2026-08-01";
11
+ const INLINE_TOOLS_BETA = "inline-tools-2026-09-15";
8
12
  function contentBlocks(content) {
9
13
  if (typeof content === "string")
10
14
  return content ? [{ type: "text", text: content }] : [];
@@ -64,21 +68,51 @@ function toolChoice(value, tools) {
64
68
  /** Convert only request-owned copies; stored history remains unchanged. */
65
69
  export function buildAnthropicRequest(context) {
66
70
  const { route } = context;
67
- const request = normalizeToolDeclarations(context.request);
68
- const tools = buildToolSet(request.tools);
71
+ const request = context.request;
72
+ const settings = resolveClaudeOptions(route);
73
+ const { tools, definitions, input } = buildAnthropicTools(request, settings.tool_updates);
69
74
  const system = request.instructions ? [{ type: "text", text: request.instructions }] : [];
70
75
  const messages = [];
76
+ let additions = [];
77
+ let hasInlineTools = false;
78
+ const continueAssistant = () => {
79
+ const tail = messages.at(-1);
80
+ if (tail?.role === "assistant" && !tail.content.some(block => block.type === "tool_use")) {
81
+ messages.push({ role: "user", content: [{ type: "text", text: "Continue." }] });
82
+ }
83
+ };
84
+ const flushAdditions = () => {
85
+ if (!additions.length)
86
+ return;
87
+ // A persisted declaration also anchors this boundary on later replays.
88
+ continueAssistant();
89
+ if (messages.at(-1)?.role !== "user") {
90
+ throw new BridgeError("Inline tool declarations require a user or completed tool-result boundary.");
91
+ }
92
+ messages.push({ role: "system", content: additions });
93
+ additions = [];
94
+ hasInlineTools = true;
95
+ };
71
96
  const append = (role, blocks) => {
72
97
  if (!blocks.length)
73
98
  return;
99
+ // Several reminders/results may share a user turn. Tool declarations take
100
+ // effect after that complete boundary, before the next assistant step.
101
+ if (role === "assistant")
102
+ flushAdditions();
74
103
  const last = messages.at(-1);
75
104
  if (last?.role === role)
76
105
  last.content.push(...blocks);
77
106
  else
78
107
  messages.push({ role, content: blocks });
79
108
  };
80
- for (const item of prepareHistory(request.input, route)) {
81
- if (item.type === "bridge_reasoning") {
109
+ for (const item of prepareHistory(input, route)) {
110
+ if (item.type === "additional_tools") {
111
+ additions.push(...item.tools.map((definition) => ({
112
+ type: "tool_addition", tool: { type: "tool_definition", definition },
113
+ })));
114
+ }
115
+ else if (item.type === "bridge_reasoning") {
82
116
  const block = item.state.data;
83
117
  if (!block || !["thinking", "redacted_thinking"].includes(block.type)) {
84
118
  throw new BridgeError("Invalid Messages reasoning carrier.");
@@ -117,9 +151,10 @@ export function buildAnthropicRequest(context) {
117
151
  else
118
152
  throw new BridgeError(`Unsupported Messages history item: ${String(item.type)}.`);
119
153
  }
120
- const definitions = tools.entries.map((tool) => ({
121
- name: tool.flatName, description: tool.description, input_schema: tool.parameters,
122
- }));
154
+ // A failed response can leave a complete thinking/text item in codeks history.
155
+ // Claude requires a user boundary to resume. Do not mask a missing tool result.
156
+ continueAssistant();
157
+ flushAdditions();
123
158
  // Keep stable prefixes at 1h. The one-off compaction suffix uses 5m.
124
159
  const cache = (block, ttl = "1h") => {
125
160
  if (block)
@@ -158,6 +193,18 @@ export function buildAnthropicRequest(context) {
158
193
  Object.assign(body, route.upstream.options, route.config.options, {
159
194
  model: route.config.model, messages, stream: true,
160
195
  });
196
+ // Native options remain authoritative. Modes such as between_tools do not
197
+ // support binding controls; do not change the caller's chosen thinking mode.
198
+ if (["adaptive", "enabled"].includes(body.thinking?.type) &&
199
+ settings.thinking_mismatch !== "upstream_default") {
200
+ body.thinking = {
201
+ ...body.thinking,
202
+ block_binding: {
203
+ prefix_mismatch_behavior: settings.thinking_mismatch,
204
+ ...body.thinking.block_binding,
205
+ },
206
+ };
207
+ }
161
208
  if (system.length)
162
209
  body.system = system;
163
210
  else
@@ -172,7 +219,7 @@ export function buildAnthropicRequest(context) {
172
219
  delete body.output_config.format;
173
220
  }
174
221
  }
175
- return { body, tools };
222
+ return { body, tools, betas: [THINKING_BINDING_BETA, ...(hasInlineTools ? [INLINE_TOOLS_BETA] : [])] };
176
223
  }
177
224
  function tokenCount(value) {
178
225
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
@@ -191,8 +238,8 @@ function validateUsage(value, required) {
191
238
  tokenCount(value.output_tokens_details.thinking_tokens);
192
239
  }
193
240
  export const runAnthropic = async (context, writer) => {
194
- const { body, tools } = buildAnthropicRequest(context);
195
- const response = await upstreamFetch(context.route, "/messages", body, context.signal, context.requestHeaders);
241
+ const { body, tools, betas } = buildAnthropicRequest(context);
242
+ const response = await upstreamFetch(context.route, "/messages", body, context.signal, context.requestHeaders, { "anthropic-beta": betas });
196
243
  context.onAccepted?.(response.headers);
197
244
  if (!response.body)
198
245
  throw new BridgeError("Messages returned an empty stream.", "upstream_error", 502);
@@ -51,16 +51,41 @@ function usageFrom(value) {
51
51
  }
52
52
  return value;
53
53
  };
54
- const cached = value?.input_tokens_details?.cached_tokens;
55
- const reasoning = value?.output_tokens_details?.reasoning_tokens;
54
+ // These fields are required by codeks when their enclosing objects exist.
55
+ count(value?.total_tokens, "total_tokens");
56
+ const cached = value?.input_tokens_details == null ? undefined
57
+ : count(value.input_tokens_details.cached_tokens, "input_tokens_details.cached_tokens");
58
+ const reasoning = value?.output_tokens_details == null ? undefined
59
+ : count(value.output_tokens_details.reasoning_tokens, "output_tokens_details.reasoning_tokens");
56
60
  const cacheWrite = value?.input_tokens_details?.cache_write_tokens;
61
+ if (value?.codex_rollout_budget_units != null && !Number.isFinite(value.codex_rollout_budget_units)) {
62
+ throw new BridgeError("Invalid Responses usage field: codex_rollout_budget_units", "upstream_error", 502);
63
+ }
57
64
  return {
58
65
  input_tokens: count(value?.input_tokens, "input_tokens"),
59
66
  output_tokens: count(value?.output_tokens, "output_tokens"),
60
- ...(cached !== undefined ? { cached_tokens: count(cached, "input_tokens_details.cached_tokens") } : {}),
67
+ ...(cached !== undefined ? { cached_tokens: cached } : {}),
61
68
  ...(cacheWrite !== undefined ? { cache_write_tokens: count(cacheWrite, "input_tokens_details.cache_write_tokens") } : {}),
62
- ...(reasoning !== undefined ? { reasoning_tokens: count(reasoning, "output_tokens_details.reasoning_tokens") } : {}),
69
+ ...(reasoning !== undefined ? { reasoning_tokens: reasoning } : {}),
70
+ };
71
+ }
72
+ /** Validate the client terminal contract before any buffered tools can run. */
73
+ function completedUsage(response) {
74
+ const invalid = (field) => {
75
+ throw new BridgeError(`Missing or invalid Responses completed field: ${field}`, "upstream_error", 502);
63
76
  };
77
+ if (typeof response.id !== "string")
78
+ invalid("id");
79
+ if (response.end_turn != null && typeof response.end_turn !== "boolean")
80
+ invalid("end_turn");
81
+ const metadata = response.usage_metadata;
82
+ if (metadata != null) {
83
+ if (typeof metadata !== "object" || Array.isArray(metadata))
84
+ invalid("usage_metadata");
85
+ if (metadata.amount != null && typeof metadata.amount !== "string")
86
+ invalid("usage_metadata.amount");
87
+ }
88
+ return usageFrom(response.usage);
64
89
  }
65
90
  /** Transform only bridge-owned fields; native events keep their original shape. */
66
91
  export const runResponses = async (context, writer) => {
@@ -136,6 +161,14 @@ export const runResponses = async (context, writer) => {
136
161
  states.set(index, state);
137
162
  emit({ ...event, item });
138
163
  }
164
+ function reconcileOutput(result) {
165
+ // codeks persists item events, not the terminal response.output. This also
166
+ // applies to partial history returned only by an incomplete terminal event.
167
+ for (const [index, item] of (result.output ?? []).entries()) {
168
+ if (!states.get(index)?.done)
169
+ outputEvent({ type: "response.output_item.done", output_index: index, item });
170
+ }
171
+ }
139
172
  function terminal(event) {
140
173
  const result = event.response;
141
174
  if (event.type === "response.failed" || result?.status === "failed" || result?.error) {
@@ -143,19 +176,15 @@ export const runResponses = async (context, writer) => {
143
176
  throw responsesStreamError(responseFor(result), response.headers, event);
144
177
  }
145
178
  if (event.type === "response.incomplete" || result?.status === "incomplete") {
179
+ reconcileOutput(result);
146
180
  releasePhase();
147
181
  throw new ResponsesIncompleteError(responseFor(result), event);
148
182
  }
149
183
  if (result?.status !== "completed") {
150
184
  throw new BridgeError("The upstream did not return a completed response", "stream_error", 502);
151
185
  }
152
- const usage = usageFrom(result.usage);
153
- // A whole JSON response, or a terminal-only stream, still needs item events
154
- // because codeks persists the history from output_item.done.
155
- for (const [index, item] of (result.output ?? []).entries()) {
156
- if (!states.get(index)?.done)
157
- outputEvent({ type: "response.output_item.done", output_index: index, item });
158
- }
186
+ const usage = completedUsage(result);
187
+ reconcileOutput(result);
159
188
  for (const state of states.values()) {
160
189
  if (!state.done)
161
190
  throw new BridgeError("The upstream completed with an unfinished output item", "stream_error", 502);
@@ -173,13 +202,6 @@ export const runResponses = async (context, writer) => {
173
202
  if (result.status === "completed" || result.status === "incomplete") {
174
203
  writer.nativeEvent({ type: "response.created", response: { ...result, model: context.request.model, status: "in_progress", output: [] } });
175
204
  }
176
- if (result.status === "incomplete") {
177
- // An interrupted whole response has no earlier item events. Publish its
178
- // partial history before the terminal event, still withholding any tool suffix.
179
- for (const [index, item] of (result.output ?? []).entries()) {
180
- outputEvent({ type: "response.output_item.done", output_index: index, item });
181
- }
182
- }
183
205
  return terminal({ type: result.status === "incomplete" ? "response.incomplete" : "response.completed", response: result });
184
206
  }
185
207
  if (!response.body)
package/dist/config.js CHANGED
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { modelProfile } from "./profiles.js";
4
4
  import { getCatalogEntry } from "./model-catalog.js";
5
5
  import { BridgeError } from "./errors.js";
6
+ import { validateAdapterConfig } from "./adapter-options.js";
6
7
  function object(value) {
7
8
  return !!value && typeof value === "object" && !Array.isArray(value);
8
9
  }
@@ -65,6 +66,7 @@ export function validateConfig(value) {
65
66
  fail(`upstream ${name}: invalid headers`);
66
67
  if (up.options !== undefined && !object(up.options))
67
68
  fail(`upstream ${name}: options must be an object`);
69
+ validateAdapterConfig(up.adapters, `upstream ${name}`);
68
70
  }
69
71
  for (const [slug, model] of Object.entries(value.models)) {
70
72
  if (!slug || !object(model) || typeof model.model !== "string" || !model.model || typeof model.upstream !== "string" || !Object.hasOwn(value.upstreams, model.upstream))
@@ -91,6 +93,7 @@ export function validateConfig(value) {
91
93
  fail(`model ${slug}: effort_map must map effort names to upstream values`);
92
94
  }
93
95
  const profile = modelProfile(model);
96
+ validateAdapterConfig(model.adapters, `model ${slug}`, profile.id);
94
97
  if (Object.hasOwn(profile.effort_map, "ultra") && !profile.multi_agent_effort)
95
98
  fail(`model ${slug}: ultra must map to the same upstream effort as an ordinary level`);
96
99
  if (!Object.hasOwn(profile.effort_map, profile.default_effort))
@@ -1,7 +1,8 @@
1
1
  import { BridgeError } from "./errors.js";
2
2
  const isCall = (item) => ["function_call", "custom_tool_call"].includes(item.type);
3
3
  const isResult = (item) => ["function_call_output", "custom_tool_call_output"].includes(item.type);
4
- const isReminder = (item) => (item.type === "message" || !item.type) && ["user", "developer", "system"].includes(item.role);
4
+ const isReminder = (item) => item.type === "additional_tools" ||
5
+ ((item.type === "message" || !item.type) && ["user", "developer", "system"].includes(item.role));
5
6
  function toolName(item) {
6
7
  return JSON.stringify({ ...(item.namespace ? { namespace: item.namespace } : {}), name: item.name });
7
8
  }
package/dist/tools.d.ts CHANGED
@@ -11,6 +11,8 @@ export interface ToolEntry {
11
11
  }
12
12
  export interface ToolSet {
13
13
  entries: ToolEntry[];
14
+ /** Applies a declaration batch and returns detached snapshots of its definitions. */
15
+ add(tools: unknown[]): ToolEntry[];
14
16
  /** Upstream name -> tool. */
15
17
  byFlat: Map<string, ToolEntry>;
16
18
  /** Upstream name for a call found in history. */
package/dist/tools.js CHANGED
@@ -76,13 +76,13 @@ export function cleanSchema(schema) {
76
76
  return out;
77
77
  }
78
78
  function objectSchema(parameters) {
79
+ if (parameters == null)
80
+ return { type: "object", properties: {} };
79
81
  const cleaned = cleanSchema(parameters);
80
82
  if (cleaned && typeof cleaned === "object" && !Array.isArray(cleaned)) {
81
- const schema = cleaned;
82
- if (schema.type === "object" || schema.properties)
83
- return { type: "object", ...schema };
83
+ return { type: "object", ...cleaned };
84
84
  }
85
- return { type: "object", properties: {} };
85
+ throw new BridgeError("Function tool parameters must be a JSON Schema object.");
86
86
  }
87
87
  /** Describes a custom tool's input format in words, for function-only upstreams. */
88
88
  export function customToolDescription(description, format) {
@@ -116,7 +116,7 @@ export function buildToolSet(tools) {
116
116
  // Lite updates contain only changed members. Replace that definition
117
117
  // without losing siblings or changing its established upstream name.
118
118
  Object.assign(previous, entry);
119
- return;
119
+ return previous;
120
120
  }
121
121
  let flatName = baseFlatName(entry.namespace, entry.name);
122
122
  // Add a suffix only when two different tools would get the same name.
@@ -128,39 +128,50 @@ export function buildToolSet(tools) {
128
128
  entries.push(full);
129
129
  byFlat.set(flatName, full);
130
130
  byIdentity.set(id, full);
131
+ return full;
131
132
  };
132
133
  const visit = (tool, namespace) => {
133
134
  if (!tool || typeof tool !== "object")
134
- return;
135
+ return [];
135
136
  if (tool.type === "function") {
136
- add({
137
- kind: "function",
138
- name: String(tool.name),
139
- namespace,
140
- description: String(tool.description ?? ""),
141
- parameters: objectSchema(tool.parameters),
142
- });
137
+ return [add({
138
+ kind: "function",
139
+ name: String(tool.name),
140
+ namespace,
141
+ description: String(tool.description ?? ""),
142
+ parameters: objectSchema(tool.parameters),
143
+ })];
143
144
  }
144
145
  else if (tool.type === "custom") {
145
- add({
146
- kind: "custom",
147
- name: String(tool.name),
148
- namespace,
149
- description: customToolDescription(String(tool.description ?? ""), tool.format),
150
- parameters: CUSTOM_PARAMETERS,
151
- });
146
+ return [add({
147
+ kind: "custom",
148
+ name: String(tool.name),
149
+ namespace,
150
+ description: customToolDescription(String(tool.description ?? ""), tool.format),
151
+ parameters: CUSTOM_PARAMETERS,
152
+ })];
152
153
  }
153
154
  else if (tool.type === "namespace" && Array.isArray(tool.tools)) {
154
- for (const inner of tool.tools)
155
- visit(inner, String(tool.name));
155
+ return tool.tools.flatMap((inner) => visit(inner, String(tool.name)));
156
156
  }
157
157
  // Hosted tools (web_search, image_generation, tool_search, ...) have no
158
158
  // equivalent on these upstreams and are left out.
159
+ return [];
159
160
  };
160
- for (const tool of tools ?? [])
161
- visit(tool, undefined);
161
+ const addTools = (declarations) => {
162
+ const changed = new Map();
163
+ for (const declaration of declarations) {
164
+ for (const entry of visit(declaration, undefined))
165
+ changed.set(entry.flatName, entry);
166
+ }
167
+ // Later updates may mutate the registry's current definition. Earlier
168
+ // inline definitions must retain the exact payload used at that point.
169
+ return structuredClone([...changed.values()]);
170
+ };
171
+ addTools(tools ?? []);
162
172
  return {
163
173
  entries,
174
+ add: addTools,
164
175
  byFlat,
165
176
  flatFor(namespace, name) {
166
177
  return byIdentity.get(identity(namespace, name))?.flatName ?? baseFlatName(namespace, name);
package/dist/types.d.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import type { ResponseWriter, Usage } from "./writer.ts";
2
2
  export type Api = "responses" | "anthropic" | "chat";
3
3
  export type AdapterId = "gpt-responses" | "claude-messages" | "deepseek-chat";
4
+ export interface ClaudeMessagesOptions {
5
+ tool_updates: "inline" | "top_level";
6
+ thinking_mismatch: "drop_block" | "error" | "upstream_default";
7
+ }
8
+ /** Bridge behavior, distinct from native request-body options. */
9
+ export interface AdapterConfig {
10
+ "claude-messages"?: Partial<ClaudeMessagesOptions>;
11
+ }
4
12
  export interface UpstreamConfig {
5
13
  base_url: string;
6
14
  api_key_env?: string;
@@ -10,6 +18,7 @@ export interface UpstreamConfig {
10
18
  stream_idle_timeout_ms?: number;
11
19
  headers?: Record<string, string>;
12
20
  options?: Record<string, unknown>;
21
+ adapters?: AdapterConfig;
13
22
  }
14
23
  export interface ModelConfig {
15
24
  upstream: string;
@@ -34,6 +43,7 @@ export interface ModelConfig {
34
43
  native_replay?: boolean;
35
44
  };
36
45
  options?: Record<string, unknown>;
46
+ adapters?: AdapterConfig;
37
47
  }
38
48
  export interface BridgeConfig {
39
49
  host?: string;
@@ -1,3 +1,3 @@
1
1
  import type { ResolvedModel } from "./types.ts";
2
2
  /** base_url includes any API version prefix; path is appended, never resolved from /. */
3
- export declare function upstreamFetch(route: ResolvedModel, path: string, body: unknown, signal: AbortSignal, requestHeaders?: Headers, protocolHeaders?: Record<string, string>): Promise<Response>;
3
+ export declare function upstreamFetch(route: ResolvedModel, path: string, body: unknown, signal: AbortSignal, requestHeaders?: Headers, protocolHeaders?: Record<string, string | string[]>): Promise<Response>;
package/dist/upstream.js CHANGED
@@ -5,8 +5,13 @@ import { captureUpstream } from "./inspect/capture.js";
5
5
  export async function upstreamFetch(route, path, body, signal, requestHeaders, protocolHeaders) {
6
6
  const url = route.upstream.base_url.replace(/\/+$/, "") + "/" + path.replace(/^\/+/, "");
7
7
  const headers = await upstreamHeaders(route.upstream, route.profile.api, signal, requestHeaders);
8
- for (const [name, value] of Object.entries(protocolHeaders ?? {}))
9
- headers.set(name, value);
8
+ for (const [name, value] of Object.entries(protocolHeaders ?? {})) {
9
+ // A protocol may require additional comma-separated feature flags without
10
+ // replacing the endpoint's configured flags. Scalar headers still replace.
11
+ headers.set(name, Array.isArray(value)
12
+ ? [...new Set([...(headers.get(name)?.split(",") ?? []), ...value].map(v => v.trim()).filter(Boolean))].join(",")
13
+ : value);
14
+ }
10
15
  const payload = JSON.stringify(body);
11
16
  const call = captureUpstream(url, headers, payload);
12
17
  const idle = new AbortController();
@@ -34,12 +34,39 @@ Opus / Sonnet 5.5 目录的默认及最大上下文窗口为 `1000000`,Message
34
34
 
35
35
  完整 `thinking`/`redacted_thinking` block,包括签名或隐藏数据,存入 `anthropic.thinking.v1` carrier。请求按 `replay.reasoning_from` 恢复允许来源,签名完整保留;其他来源只在当前请求过滤。允许配对不意味着上游一定接受其签名。[Reasoning 策略](reasoning.md)
36
36
 
37
+ ## 失配策略与故障续接
38
+
39
+ 默认 `adapters.claude-messages.thinking_mismatch: "drop_block"`,在 `adaptive`、`enabled` 模式的 `thinking.block_binding.prefix_mismatch_behavior` 中声明。服务端可以丢弃前缀失配的块,继续使用其余可接受的 thinking;bridge 不自行验签或删除已保存的载荷。可配置 `error` 或 `upstream_default`,原生 options 中明确的绑定策略仍优先,完整配置与优先级见 [Adapter 专用行为](config.md#adapter-专用行为)。
40
+
41
+ 所有 Messages 请求都附带 `thinking-binding-controls-2026-08-01` beta,与现有 beta header 合并,以取得 `input_transformations` 诊断;声明诊断 beta 本身不等于选择某种失配策略。服务端报告与实际支持取决于端点,未知能力或请求错误直接返回,不自动更换策略。[官方 preserved thinking 规则](https://platform.claude.com/docs/en/build-with-claude/preserved-thinking)
42
+
43
+ 流式响应可能已交付完整 thinking 或文字项,随后才过载或断流。codeks 保存这些已完成项并负责重试,因此重试历史可能以 assistant 结束。若最后的 assistant 不含 `tool_use`,bridge 仅在本次出站请求追加 user `Continue.`,提供合法续接边界;含工具调用时不补这一句,以免掩盖缺失工具结果。
44
+
45
+ 若这个 assistant 后紧跟持久化的增量工具声明,同样在声明前补续接边界;后续回放时,声明的位置让 bridge 能重新构造相同边界,不需要另存隐藏会话状态。
46
+
47
+ 这句合成提示不写入 codeks 历史。假设失败前已保存 T1,重试生成 T2,下一轮历史会出现以下差别:
48
+
49
+ ```text
50
+ 重试请求:H → assistant[T1] → user[Continue.] → assistant[T2, 正文]
51
+ 下一轮: H → assistant[T1, T2, 正文] → user[新问题]
52
+ ```
53
+
54
+ 因此 T2 的签名前缀可能不再匹配;默认 `drop_block` 让服务端在该轮报告并丢弃失配块。只保证 Continue 那次请求成功并不够,后续回放也需要考虑这个变化。`error` 或 `upstream_default` 下,服务端仍可能拒绝后续请求。
55
+
56
+ 选择这种恢复方式是为了保留正常 thinking、正文的流式显示,不让每轮正文都等待整个响应成功,也不引入隐藏会话状态。工具调用仍按现有规则等完整成功后交付。失配块及依赖该前缀的后续 thinking 都可能被丢弃,造成推理上下文损失或增加重新推理成本;这不是质量不变的承诺,也不能修复损坏签名或其他所有非法输入。具体发生了什么以[检查器中的服务端报告](inspect.md#claude-服务端输入处理)为准。
57
+
58
+ 一次失配不会令今后生成的 thinking 永久失效。后续请求保持前缀稳定时,新生成的块仍可正常复用;原始历史里的旧失配块可能在多轮请求中被重复报告,重复报告不代表每轮都丢弃新的 thinking。实际保留与丢弃以各次上游报告为准。
59
+
37
60
  ## 输出格式、工具与图片
38
61
 
39
62
  Responses `text.format: {type: "json_schema", schema: ...}` 转为 `output_config.format: {type: "json_schema", schema: ...}`;name/strict 不转发。schema 内容直接交给上游,随后 options 仍可覆盖原生格式。普通 `text` 不添加约束;无法表达为 Messages 的未知格式报错。
40
63
 
41
64
  namespace 和 custom tool 使用共享映射。自由文本工具转换成 `{input: string}`,Lark/regex 放入描述,返回时恢复原始字符串;不添加工具专属提示词。指定工具必须能在当前工具表中解析,`tool_choice: "required"` 转为原生 `any`,不会改成 `auto`。显式 `temperature`、`top_p` 也继续发送;这些参数与具体模型、thinking 的组合是否受支持由上游判断。静默删除参数或关闭 thinking 会改变用户请求,bridge 不做这类修正。
42
65
 
66
+ 工具更新默认使用完整 inline 定义,保持已有顶部工具与历史前缀稳定;仅实际包含中途工具更新的请求添加 `inline-tools-2026-09-15` beta。初始声明、更新位置与兼容模式见 [Lite 工具声明](tools.md#lite-工具声明)。端点不支持时可在连接配置中显式选择 `tool_updates: "top_level"`;不会在错误后自动探测或降级。
67
+
68
+ 从 `0.0.1` 的顶部合并方式切到默认 inline 时,已有增量声明历史会改变出站工具前缀,可能触发旧 thinking 丢弃或缓存重建;可显式设为 `top_level` 保持原工具策略。切换模式不改变状态来源身份,也不改写客户端保存的历史。
69
+
43
70
  有工具且未选择 `none` 时,显式 `parallel_tool_calls: false` 转为 `tool_choice.disable_parallel_tool_use: true`,显式 `true` 则转为 `false`;未指定 choice 时采用 `auto`,原生 options 仍可覆盖。这保留 Codex Lite 的外层工具调用限制,不限制一次 `exec` 内 JavaScript 的并发执行。[Claude 并行工具调用](https://platform.claude.com/docs/en/agents-and-tools/tool-use/parallel-tool-use)
44
71
 
45
72
  用户与工具结果都可带图片。HTTP(S) URL 和 JPEG、PNG、GIF、WebP 的 base64 data URL 转为原生 image block,bridge 不下载图片。图片能力、尺寸和 schema 限制交给上游,不能转换的内容类型明确报错。空工具结果仍发送 `tool_result` 及原调用 ID,只省略空的 `content` 数组,不添加占位文字。
@@ -48,7 +75,7 @@ namespace 和 custom tool 使用共享映射。自由文本工具转换成 `{inp
48
75
 
49
76
  ## 缓存与压缩
50
77
 
51
- 最多四个显式缓存断点:最后一个工具、最后一段顶层 system、最近两条 user 消息的末块。普通请求 TTL 为 `1h`。压缩保留公共前缀的 `1h`,只将最后的摘要请求末块设为 `5m`,使长 TTL 位于短 TTL 之前。thinking 块不直接设置缓存断点。
78
+ 最多四个显式缓存断点:最后一个顶层工具、最后一段顶层 system、最近两条 user 消息的末块。普通请求 TTL 为 `1h`。压缩保留公共前缀的 `1h`,只将最后的摘要请求末块设为 `5m`,使长 TTL 位于短 TTL 之前。thinking 块不直接设置缓存断点。inline 模式不为新增定义重写顶部 tools;兼容模式修改顶部定义可能导致缓存未命中及 thinking 前缀失配,两者最终由上游判断。
52
79
 
53
80
  压缩只生成文字摘要,保留工具定义及普通请求的工具选择,移除 `output_config.format`,其他参数保留。摘要封装为客户端 V2 compaction item,可供其他模型恢复。保留工具选择的缓存依据及意外工具调用的处理见 [压缩](compaction.md#工具选择与缓存)。
54
81
 
@@ -92,7 +92,11 @@ Claude 接收 U + S + 后续内容
92
92
 
93
93
  GPT 返回 `K` 时,客户端尚未选定最终的 `U`。让 codeks 先安装检查点,再从下一条真实请求取得 `U`,可以复用现有按需复述流程,无需复制客户端的保留算法。自动压缩后正常继续的请求即可触发,不需要用户再发一轮消息。
94
94
 
95
- 同样适用于多次切换:GPT 生成 `K1`,Claude 首次接续时使用 `S1`;Claude 的新工作使上下文再次达到阈值后,兼容的 GPT 执行模型接收 `U1 + K1 + 新工作` 并生成 `K2`。Claude 随后取得 `S2`;切回允许该来源的 GPT 时仍可恢复 `K2`。历史中的外来 reasoning 继续由执行模型的独立来源策略筛选,不因压缩委托而共享。
95
+ 同样适用于多次切换:GPT 生成 `K1`,Claude 首次接续时使用 `S1`;Claude 的新工作使上下文再次达到阈值后,兼容的 GPT 执行模型接收 `U1 + K1 + 新工作` 并生成 `K2`。Claude 随后取得 `S2`;切回允许该来源的 GPT 时仍可恢复 `K2`。
96
+
97
+ 经常在同一个 GPT 与 Claude 之间切换时,直接让 `claude.compaction.model` 指向常用的 `gpt`,并设置 `mode: "native"` 即可。两者产生的 `K` 都属于该 GPT,默认就能恢复。Claude 使用过 `S1` 后,下一次 GPT 压缩仍读取 `K1`,无需先把 `S1` 再压缩;切回 GPT 也无需为了转换摘要而发起压缩。达到客户端窗口阈值时仍会正常生成新的检查点。
98
+
99
+ Reasoning 与检查点分别处理:仍在客户端历史中的 GPT reasoning,在 Claude 普通请求中被过滤,在 GPT 普通请求和受托压缩中默认恢复;Claude reasoning 仍由 GPT 的来源策略过滤。压缩后客户端已从活动历史移除的 reasoning 不会作为独立项找回,任务状态由新检查点承接。这是复用已有 GPT 别名的常用场景,无需专用压缩别名或额外来源授权,见[双模型配置](config.md#压缩模式与执行模型)。
96
100
 
97
101
  用户选择这一可选路径,是希望即使主要使用文字模型,也能利用原生压缩整理任务状态的能力。已有 GPT 复述实验支持该设计,但未证明每种 Claude 历史都优于标准摘要。委托会增加实际调用与等待,跨模型输入也可能无法复用 prompt cache;不承诺固定成本增幅。标准 `summary` 保持可选。压缩阈值仍由工作模型决定,bridge 不换算不同模型的 token 比例、不裁剪超限输入,实际容量错误直接返回。
98
102
 
package/docs/config.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 目录、别名与连接
6
6
 
7
- `upstreams` 只描述连接。`models` 的键是 codeks 显示和请求的别名,每个别名由三项配置组成:
7
+ `upstreams` 描述连接、鉴权及该连接共用的 adapter 设置。`models` 的键是 codeks 显示和请求的别名,每个别名由三项配置组成:
8
8
 
9
9
  | 字段 | 示例 | 用途 |
10
10
  | --- | --- | --- |
@@ -64,6 +64,7 @@
64
64
  | `stream_idle_timeout_ms` | 等待上游 headers 或后续字节的最长空闲时间,默认 300000 ms |
65
65
  | `headers` | 静态 header,覆盖同名客户端 header,例如 API 版本 |
66
66
  | `options` | 原生请求参数,覆盖 adapter 推导出的参数 |
67
+ | `adapters` | 按 adapter ID 分组的桥接行为;供使用该连接的模型继承 |
67
68
 
68
69
  `api_key_command` 不经过 shell,每条上游请求执行一次,最多 30 秒,适合短期凭据。命令错误不会把 stdout/stderr 回传客户端。连接无需 key 的服务时可省略凭据设置。Claude 默认版本 header 为 `anthropic-version: 2023-06-01`。客户端 metadata 默认透传,鉴权使用上游配置;覆盖顺序及必要过滤见 [header 边界](responses-stream.md#header-与转发边界)。
69
70
 
@@ -82,6 +83,7 @@
82
83
  | `replay` | 分别允许哪些来源的 reasoning、原生压缩 |
83
84
  | `compaction` | 压缩方式、执行模型,以及本目标是否接收原生压缩 |
84
85
  | `options` | 原生请求参数,覆盖上游级 options |
86
+ | `adapters` | 当前模型的桥接行为;逐字段覆盖上游级同名 adapter 设置 |
85
87
 
86
88
  `expose` 控制目录展示,不改变别名的路由、来源身份或访问权限。隐藏模型仍可用于压缩、handoff 和状态复用,显式按别名发送请求也仍可调用;inspector 保留其诊断记录。`config-snippet` 选择首个公开别名作为默认模型;全部隐藏时目录为空,该命令明确报错。
87
89
 
@@ -91,6 +93,38 @@
91
93
 
92
94
  最终由 bridge 固定模型路由、转换后的历史、工具定义、流模式和必要协议字段。摘要请求保留普通工具选择并移除结构化输出约束;原生压缩移除普通回答的输出上限。必须重建的字段承载桥接契约,不由 options 改写。详见上表链接的后端文档,以及[摘要的工具选择](compaction.md#工具选择与缓存)。
93
95
 
96
+ ## Adapter 专用行为
97
+
98
+ `adapters` 与 `options` 分工不同:前者控制 bridge 怎样转换协议,后者是发送给上游的原生请求参数。命名空间使用目录已有的 adapter ID;以后新增协议专用行为时,在对应命名空间增加字段,无需向模型目录或连接顶层添加协议特例。目前有专用设置的是 `claude-messages`:
99
+
100
+ | 字段 | 值与默认行为 |
101
+ | --- | --- |
102
+ | `tool_updates` | 默认 `inline`,把中途工具声明转换为原生完整 `tool_addition`;兼容模式 `top_level` 将所有定义合并到顶部 |
103
+ | `thinking_mismatch` | 默认 `drop_block`,请服务端丢弃前缀失配的 thinking;`error` 请求报错;`upstream_default` 不补充该策略 |
104
+
105
+ 连接级设置适合端点差异,模型级设置适合同一连接下的模型差异。每个叶字段依次取模型设置、连接设置、adapter 默认值;模型只覆盖一个字段时,仍继承连接里的其他字段。下面的连接显式使用兼容工具模式,模型再单独调整 thinking 策略:
106
+
107
+ ```json
108
+ {
109
+ "upstreams": {
110
+ "compatible": {
111
+ "base_url": "https://example.com/v1",
112
+ "adapters": { "claude-messages": { "tool_updates": "top_level" } }
113
+ }
114
+ },
115
+ "models": {
116
+ "sonnet": {
117
+ "upstream": "compatible", "catalog": "claude-sonnet-5-5", "model": "deployment-a",
118
+ "adapters": { "claude-messages": { "thinking_mismatch": "error" } }
119
+ }
120
+ }
121
+ }
122
+ ```
123
+
124
+ 省略 `thinking_mismatch` 表示 bridge 默认的 `drop_block`,与显式 `upstream_default` 不同。对最终 `thinking.type` 为 `adaptive` 或 `enabled` 的请求,bridge 补充 `block_binding.prefix_mismatch_behavior`;原生 `options.thinking.block_binding` 中明确设置的同名值优先。`between_tools` 等不支持该字段的模式不补充策略,不擅自换 thinking 模式。`options` 本身仍按前述浅层规则合并,不进行通用递归合并。
125
+
126
+ 专用设置不作为请求参数发给上游,不改变状态来源身份或客户端目录。共享连接可以配置供不同模型使用的 adapter;模型级只接受自身 adapter 的设置。未知命名空间、字段及枚举会报配置错误;尚无专用字段的 adapter 无需配置空对象。`inline` 需要上游支持相关协议,不兼容时直接返回错误,应在该连接显式选择 `top_level`。恢复代价及端点行为见 [Claude Messages](backends-anthropic.md#失配策略与故障续接)。
127
+
94
128
  ## Reasoning 与模型目录
95
129
 
96
130
  ```json
@@ -131,28 +165,34 @@ GPT 的默认配置等价于:
131
165
 
132
166
  `native` 初始只生成原生检查点;目标无法按来源策略回放它时,先向生成检查点的模型和连接复述为文字。原来源须继续在配置中可用。成功摘要及辅助 usage 存入顶层 `compaction_cache_dir`,重启后可复用;它不是上游 prompt cache。`summary` 继续使用标准 prompt 直接压缩原历史。
133
167
 
134
- 例如让 Claude 的历史由 GPT 原生压缩,再按需复述给 Claude:
168
+ 经常在 GPT 与 Claude 之间切换时,建议直接复用日常使用的 GPT 别名执行压缩。只需两个模型,Claude 的历史由同一个 GPT 原生压缩,再按需复述给 Claude。以下为模型部分,含连接的完整配置见 [examples/gpt-claude.json](../examples/gpt-claude.json):
135
169
 
136
170
  ```json
137
171
  {
138
172
  "models": {
173
+ "gpt": {
174
+ "catalog": "gpt-6.1-sol", "upstream": "openai", "model": "gpt-6.1-sol"
175
+ },
139
176
  "claude": {
140
177
  "catalog": "claude-opus-5-5", "upstream": "anthropic", "model": "claude-opus-5-5",
141
- "compaction": { "mode": "native", "model": "gpt-compactor" }
142
- },
143
- "gpt-compactor": {
144
- "catalog": "gpt-6.1-sol", "upstream": "openai", "model": "gpt-6.1-sol",
145
- "expose": false,
146
- "reasoning": { "default_effort": "xhigh" }
178
+ "compaction": { "mode": "native", "model": "gpt" }
147
179
  }
148
180
  }
149
181
  }
150
182
  ```
151
183
 
152
- 这里的 `anthropic` 和 `openai` 使用已有 `upstreams` 连接。`gpt-compactor` 通过 `expose: false` 隐藏于客户端目录,仍复用普通模型配置,不需要另一套压缩器注册表。压缩只跟随一次 `compaction.model`,不继续读取执行模型自己的委托配置或生成模式。工作模型的窗口与压缩阈值仍决定何时压缩,执行模型须能接收实际历史;超限由上游报错。
184
+ 这里的 `anthropic` 和 `openai` 使用已有 `upstreams` 连接,客户端目录只展示 `gpt` 和 `claude`。Claude 必须同时设置 `mode: "native"`;只设置 `model: "gpt"` 会让 GPT 执行 Claude 默认的标准文字压缩。
185
+
186
+ 两条压缩路径都由 `gpt` 执行,因此生成的原生检查点与日常 GPT 的来源身份相同。GPT 默认就能恢复它们及历史中仍保存的自身 reasoning,无需添加 `replay.reasoning_from` 或 `replay.native_compaction_from`。Claude 使用文字 handoff;切换到 Claude 不会改写客户端保存的 GPT 原生状态。具体切换时序见[委托压缩与切换模型](compaction.md#委托压缩与切换模型)。
187
+
188
+ 只有需要独立的压缩参数或不同部署时,才有必要增加专用模型别名;可用 `expose: false` 隐藏于客户端目录。同一 upstream ID、URL、adapter 和实际部署的别名本就共享来源身份,即使别名不同也不需要额外的 replay 配置。
189
+
190
+ 压缩只跟随一次 `compaction.model`,不继续读取执行模型自己的委托配置或生成模式。工作模型的窗口与压缩阈值仍决定何时压缩,执行模型须能接收实际历史;超限由上游报错。
153
191
 
154
192
  未设置 `compaction.model` 或显式引用自身别名时,沿用请求的 reasoning effort;引用其他别名时使用执行别名的 `reasoning.default_effort`,未设置则使用它的目录默认值,再按该模型的映射转换。即使两个别名指向同一部署,也可用专用压缩别名选择不同默认 effort。复述配方保留选定的请求 effort,后续切换工作模型不改变它。显式原生 options 仍按普通请求的规则覆盖推导参数;后续修改原来源的 options 也会作用于复述,它们不作为快照固化。
155
193
 
194
+ 因此,Claude 委托 `gpt` 压缩不会记住之前 GPT 轮次在客户端临时选择的 effort。若希望受托压缩固定使用某档位,直接在现有 `gpt` 条目设置 `reasoning.default_effort`;它也会成为该别名的普通请求默认值,普通请求仍可显式覆盖。
195
+
156
196
  Claude 的 `native_replay` 仍默认为 `false`:委托生成不会让 Claude 获得读取 GPT 密文的能力。`native` 要求执行模型的 adapter 支持原生压缩格式;`native_replay` 则要求工作模型能恢复该格式。它们与[状态复用](#状态复用)的来源策略独立。完整时序、失败及用量规则见[压缩](compaction.md)。
157
197
 
158
198
  ## 状态复用
package/docs/design.md CHANGED
@@ -22,7 +22,7 @@ flowchart LR
22
22
 
23
23
  优先匹配最新 Codex 的 GPT 默认行为,以 OpenAI 订阅、AWS 和 Azure 的使用场景为基准。当前已审阅模型目录使用 Responses Lite,保留 `use_responses_lite: true`、Code Mode Only 和多代理字段。目录固定到明确的 Codex 源码版本,不随用户本机二进制或订阅账号变化。
24
24
 
25
- Lite 仍调用 `/responses` 并返回 Responses SSE。完整工具 schema 位于历史中的 `additional_tools` 项,普通函数和 custom 工具归入默认 `functions` namespace。GPT 路径保留这些声明的位置、顺序、schema 和 grammar,并发送 `x-openai-internal-codex-responses-lite: true`。Claude/DeepSeek 只在各自协议转换入口提取声明,组合为原生工具表;声明不会变成 user reminder。中途提醒保留所在对话位置;若插在一次完整的工具交换内,则移至该批结果之后,以满足目标协议的配对要求。
25
+ Lite 仍调用 `/responses` 并返回 Responses SSE。完整工具 schema 位于历史中的 `additional_tools` 项,普通函数和 custom 工具归入默认 `functions` namespace。GPT 路径保留这些声明的位置、顺序、schema 和 grammar,并发送 `x-openai-internal-codex-responses-lite: true`。Claude 默认将初始声明放到顶部,增量声明转换为原生中途工具更新;DeepSeek 及 Claude 的显式兼容模式将各批定义合并到顶部。声明不会变成 user reminder。中途提醒保留所在对话位置;若插在一次完整的工具交换内,则移至该批结果之后,以满足目标协议的配对要求。
26
26
 
27
27
  已核对 Codex `0.163.0`:Lite 设置 `reasoning.context: "all_turns"` 和 `parallel_tool_calls: false`。bridge 保留客户端传入值,原生 options 仍可按配置覆盖普通参数。reasoning 的来源筛选与服务端 context 语义是两件事:前者决定发送哪些载荷,后者决定 GPT 如何使用它们。原生压缩与随后的复述保留源 Lite 工具声明及 transport 标记。
28
28
 
@@ -36,7 +36,9 @@ Lite 仍调用 `/responses` 并返回 Responses SSE。完整工具 schema 位于
36
36
 
37
37
  ## 配置表达策略,adapter 负责协议
38
38
 
39
- `models.catalog` 选择条目;`upstreams` 只包含连接与鉴权;别名和真实部署名独立。目录条目决定 adapter,实际模型名不参与方言推断。模型窗口、输入模态、effort 映射、状态复用范围、压缩模式及执行模型可由配置覆盖。
39
+ `models.catalog` 选择条目;`upstreams` 包含连接、鉴权及共用的 adapter 设置;别名和真实部署名独立。目录条目决定 adapter,实际模型名不参与方言推断。模型窗口、输入模态、effort 映射、状态复用范围、压缩模式及执行模型可由配置覆盖。
40
+
41
+ 协议专用桥接行为放在 `adapters.<adapter ID>`,模型逐字段覆盖连接,再取对应 adapter 默认值;原生请求参数仍通过开放的 `options` 浅层合并。这样的边界让未来 GPT、Messages 或 Chat 方言可以添加各自确有需求的设置,而不用在连接顶层堆积特例,也不提前维护空的配置对象或通用插件框架。配置行为不改变来源身份和模型目录。当前 Claude 的工具更新方式与 thinking 失配策略分别配置,因为前者保持工具前缀,后者还影响故障续接等其他历史变化;见 [Adapter 专用行为](config.md#adapter-专用行为)。
40
42
 
41
43
  `src/profiles.ts` 将目录与配置组合为运行时 profile,同时用于客户端目录和出站请求,避免界面显示一套 effort、实际发送另一套。未覆盖字段保留原模型差异,不能用一套通用默认值改写所有 GPT。来源身份继续取连接、adapter 与实际部署,不因提示词更新或窗口预设改变。
42
44
 
@@ -62,6 +64,8 @@ bridge 检查自身必须维持的不变量:配置引用存在、历史能转
62
64
 
63
65
  上游负责判定模型能力:effort、thinking、强制工具选择、schema、图片以及允许复用的密文是否可接受。bridge 不预先维护这些能力的限制列表,也不在失败后自动移除 reasoning、关闭 thinking、换端点或改压缩模式。原生参数可通过 options 显式覆盖。
64
66
 
67
+ Claude 默认主动请求服务端对前缀失配 thinking 使用 `drop_block`,保留其余可接受的块。这是已配置的请求策略,不是在收到错误后重试或改写已存历史。它允许流式故障后的部分回答继续工作,但丢失 reasoning 仍可能影响质量或增加重新推理的开销,不能视作无损。检查器展示上游实际报告的丢弃位置和原因,区分 bridge 的来源过滤与服务端判断。协议与恢复时序见 [Claude Messages](backends-anthropic.md#失配策略与故障续接)。
68
+
65
69
  GPT 原生 HTTP 失败保留 status、body 和诊断 header,流内失败保留 error 对象。下游 HTTP 200 推迟到上游接受请求后,避免把 HTTP 失败变成 SSE 错误而改变 codeks 的重试判断;等待响应头期间没有 keepalive,是这一选择的明确代价。Claude/DeepSeek 只在协议转换边界映射错误分类。重试和退避由客户端控制,bridge 不叠加第二层重试。详见 [Responses 流](responses-stream.md)。
66
70
 
67
71
  ## 压缩的生成和恢复分开
package/docs/inspect.md CHANGED
@@ -112,6 +112,16 @@ codeks-bridge serve --config /absolute/path/bridge.json --capture
112
112
 
113
113
  每条标注附来源别名、部署名、adapter、来源指纹、载荷格式和解码后的 carrier。`cxbr1:` 与 `cxbc1:` 只是编码,解码后可以看到文字 reasoning、压缩摘要和原生载荷;GPT 原生 `encrypted_content` 本身仍是密文,只能看到原样字符串。
114
114
 
115
+ ## Claude 服务端输入处理
116
+
117
+ 上游响应详情单独显示服务端原始 `input_transformations` 报告。它与 bridge 根据来源策略做的本地回放过滤不同:本地决定回放的 thinking,仍可能被 Claude 拒绝或丢弃。
118
+
119
+ - `thinking_dropped` 表示服务端报告已丢弃对应 thinking。
120
+ - `thinking_mismatch_allowed` 表示允许失配并保留,不代表丢弃。
121
+ - 每项保留原始 `path`、`reason` 与未知字段;`path` 指向该次实际发出的上游请求,不是 codeks 原始输入的位置。
122
+
123
+ 页面保留报告所在 SSE 事件及字段路径,可回到原始内容核对。流里重复出现的报告不会累加为“丢弃总数”;显式空列表显示“服务端报告为空”,没有报告则不展示这个区块,不能解读为零丢弃或全部验签通过。丢弃可能减少可用的推理上下文,检查器只展示上游报告,不推断模型质量或自行改写历史。策略与故障恢复见 [Claude Messages](backends-anthropic.md#失配策略与故障续接)。
124
+
115
125
  ## 内存、清空与导出
116
126
 
117
127
  记录只在内存里,不写磁盘,进程退出即清空。超过 `max_mib` 时淘汰最早的已结束交换;仍在传输的交换不淘汰,所以占用可能短时超过上限。页面显示已淘汰的数量。
@@ -28,7 +28,7 @@ GPT 同一 reasoning item 内的摘要保留各段 `summary_index`,对应的 p
28
28
 
29
29
  末尾 `response.completed` 带 ID、完整 output 和 usage。`end_turn: false` 保留需要继续生成的语义,例如 Claude `pause_turn`。缺少终止信号或无效工具结果返回错误,不伪装成成功。
30
30
 
31
- GPT 的 `response.incomplete` 保留原类型、原因和 usage,不改成通用错误。已核对 Codex `0.163.0`:`interrupted` 表示继续当前 turn;`content_filter` 使用内容过滤提示;`max_output_tokens` 等其他原因结束当前生成并记录已有用量。未完成的工具后缀仍不交付。Claude/DeepSeek 没有同样的 Responses 事件时,继续按各自停止原因转换。
31
+ GPT 的 `response.incomplete` 保留原类型、原因和 usage,不改成通用错误。已核对 Codex `0.163.0`:`interrupted` 表示继续当前 turn;`content_filter` 使用内容过滤提示;`max_output_tokens` 等其他原因结束当前生成并记录已有用量。完整 JSON 与 SSE 终止事件中的 `output` 都会补齐尚未交付的 item 事件,已有 done 不重复发送,因为客户端不会从终止事件的 `output` 重建历史。未完成的工具后缀仍不交付。Claude/DeepSeek 没有同样的 Responses 事件时,继续按各自停止原因转换。
32
32
 
33
33
  ## 上游错误
34
34
 
@@ -75,7 +75,7 @@ bridge 不重试、不替换端点,也不因错误移除 reasoning、关闭 th
75
75
 
76
76
  GPT 保留原始 usage,包括提供的 cache write 等分项;DeepSeek 使用 prompt/completion 与 cache hit;Claude 将普通 input、cache read、cache creation 相加得到完整 input。转换后的总数只计算 input + output;reasoning 作为输出分项,不重复叠加。`native` 初始只返回原生压缩用量;后续辅助复述的 usage 独立保存在本地缓存,不加入目标回答的 usage。
77
77
 
78
- 成功响应的必需 usage 缺失或无效时返回错误,不使用零值影响客户端压缩判断。原生失败或 incomplete 保留上游提供的用量,不以 usage 校验覆盖原始终止原因。
78
+ 成功响应的必需 usage 缺失或无效时返回错误,不使用零值影响客户端压缩判断。释放工具前还会按当前客户端的终止解码要求检查 response ID、可选 `end_turn` 与 usage metadata 的类型,以及 `total_tokens` 和已提供的 usage 分项。保留原生 envelope 与未知字段,不补造缺失总数;否则工具可能已经执行,客户端才发现成功终止事件无法解码。这里核对的是 Codex `0.163.0` 的 `sse/responses.rs` 与 `protocol/src/response_usage.rs`,升级客户端时复核这些少量必需字段。原生失败或 incomplete 保留上游提供的用量,不以 usage 校验覆盖原始终止原因。
79
79
 
80
80
  ## Keepalive 与超时
81
81
 
package/docs/tools.md CHANGED
@@ -4,10 +4,21 @@
4
4
 
5
5
  ## Lite 工具声明
6
6
 
7
- 客户端默认把完整工具 schema 放在 `input[].additional_tools`。GPT 保留原始声明项;Messages/Chat 共用提取逻辑,在转换消息前消费声明,避免产生空消息或 reminder。顶层 `tools` 也可参与转换,随后按历史顺序合并各批声明。
7
+ 客户端默认把完整工具 schema 放在 `input[].additional_tools`。GPT 保留原始声明项。DeepSeek 及 Claude 的 `top_level` 模式在转换消息前提取声明,与顶层 `tools` 按历史顺序合并;Claude 默认 `inline` 模式保留增量定义生效的位置。声明不会变成普通 reminder。
8
8
 
9
9
  增量声明只包含新增或变更的成员,按 namespace 与工具名覆盖旧定义,保留未变的其他成员和已分配的上游名字。工具移除的 developer 通知保留原位置,不从提示词文字推断删工具。codeks 将默认 `functions` namespace、空 namespace 和省略 namespace 视为同一身份,因此历史调用和显式工具选择均按此规则查找;返回调用保留声明中的原 namespace。
10
10
 
11
+ Claude 的 `adapters.claude-messages.tool_updates` 有两种明确模式:
12
+
13
+ | 模式 | 初始定义 | 中途新增与同名更新 |
14
+ | --- | --- | --- |
15
+ | `inline`(默认) | 顶层 `tools` 及会话开始前的声明组成原生顶部工具表 | 在原生中途 system 消息中发送 `tool_addition`,携带完整 `tool_definition`;此前定义不变 |
16
+ | `top_level` | 各批定义累积成同一顶部工具表 | 用最新定义覆盖同身份工具,适用于尚未支持完整 inline 的端点 |
17
+
18
+ 完整 inline 定义能同时表达新工具和同名工具更新,避免为了更新定义重写早先的工具前缀;这是选择它作为默认方式的理由。bridge 在 user 或整批工具结果之后、下一条 assistant 之前发送中途 system 工具声明;工具交换内的声明先等待结果完整,无法形成合法边界则报错。普通 system/developer 提醒仍转为原位 user reminder,不因工具使用原生中途 system 而改变角色。
19
+
20
+ 端点需要支持 `inline-tools-2026-09-15` beta;bridge 在请求包含中途更新时自动追加,保留已有 beta。不兼容时直接交付错误,不自动切换模式。`top_level` 可能改变缓存前缀和旧 thinking 的绑定;失配处理由独立的 [thinking 策略](backends-anthropic.md#失配策略与故障续接)决定。Code Mode Only 下多数工具位于 `exec` 内的目录,因此外层更新较少,但仍按协议完整处理。
21
+
11
22
  ## 工具发现与网页搜索
12
23
 
13
24
  `supports_search_tool` 表示客户端的工具发现能力,与网页搜索不同。当前源目录的 GPT 条目显式设为 `true`,bridge 保留它。是否出现独立 `tool_search` 还取决于客户端工具模式和功能开关,不能只从目录字段推断。
@@ -36,6 +47,8 @@ Messages 对不符合端点格式的 call ID 采用确定性映射,并添加
36
47
 
37
48
  普通 JSON Schema 清理 Responses 专用的布尔 `encrypted` 关键字,不删除名为 `encrypted` 的普通属性或数据。客户端使用明文多代理工具;返回的函数项显式携带 `encrypted_function_args: []`。
38
49
 
50
+ 根 schema 的 `$ref`、`$defs`、`allOf`、`anyOf`、`oneOf` 及其他约束均保留;缺少根 `type` 时补为 `object`,已有类型不覆盖,由上游判断是否支持。codeks 可以发送没有根 `type` 的引用或组合 schema,不能因此把参数清空。未提供参数 schema 时使用空对象定义;显式提供非对象 schema 值则报错,不在 bridge 内解析引用或删减约束。
51
+
39
52
  codeks V2 多代理工具的 task/message 是明文,历史中的 `agent_message` 对 Messages/Chat 转成 user 消息,保留 author、recipient 和内容顺序。它不会成为模型 reasoning。不能解密的工具或 agent message 会明确报错。
40
53
 
41
54
  ## 结果与范围
@@ -0,0 +1,27 @@
1
+ {
2
+ "host": "127.0.0.1",
3
+ "port": 8787,
4
+ "upstreams": {
5
+ "openai": {
6
+ "base_url": "https://api.openai.com/v1",
7
+ "api_key_env": "OPENAI_API_KEY"
8
+ },
9
+ "anthropic": {
10
+ "base_url": "https://api.anthropic.com/v1",
11
+ "api_key_env": "ANTHROPIC_API_KEY"
12
+ }
13
+ },
14
+ "models": {
15
+ "gpt": {
16
+ "catalog": "gpt-6.1-sol",
17
+ "upstream": "openai",
18
+ "model": "gpt-6.1-sol"
19
+ },
20
+ "claude": {
21
+ "catalog": "claude-opus-5-5",
22
+ "upstream": "anthropic",
23
+ "model": "claude-opus-5-5",
24
+ "compaction": { "mode": "native", "model": "gpt" }
25
+ }
26
+ }
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wacry/codeks-bridge",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Configurable local Responses API bridge for codeks with GPT, Claude Messages, and DeepSeek Chat adapters.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "David Zhang",
package/ui/app.css CHANGED
@@ -44,6 +44,10 @@ h3, h4 { margin: 0; }
44
44
  .err { color: var(--bad); }
45
45
  .grow { flex: 1 1 auto; }
46
46
  .note { padding: 4px 8px; margin: 0 0 8px; background: var(--info-bg); border-left: 3px solid var(--info); border-radius: 3px; font-size: 12px; }
47
+ .server-transformations { margin-bottom: 12px; }
48
+ .transformation-report { margin: 4px 0; }
49
+ .transformation-entry { padding: 3px 0; border-bottom: 1px solid var(--line2); }
50
+ .transformation-entry > summary { cursor: pointer; overflow-wrap: anywhere; }
47
51
  .empty { padding: 48px 24px; text-align: center; color: var(--fg2); }
48
52
  .empty.small { padding: 14px 4px; text-align: left; }
49
53
  .link { color: var(--accent); text-decoration: none; }
package/ui/detail.js CHANGED
@@ -3,9 +3,9 @@
3
3
  import { h, btn, fill, copyText, download, fmtBytes, fmtDateTime, fmtDur, fmtInt, fmtTime, kvTable, oneLine, safeName, shortId, strBlock, toast, tryJson, put } from './dom.js';
4
4
  import { jsonTree } from './tree.js';
5
5
  import { structureView, newStructState, focusPointer } from './structure.js';
6
- import { eventsView, newEventsState, finalObject, parseEvents, usageText } from './events.js';
6
+ import { eventsView, newEventsState, finalObject, inputTransformationReports, parseEvents, usageText } from './events.js';
7
7
  import { diffView } from './diff.js';
8
- import { annChip, annDetail, annIndex, callLabel, decisionClass, decisionText, END_LABEL, KIND_LABEL, KIND_TEXT, OUTCOME_LABEL, reasonText, sourceText } from './labels.js';
8
+ import { annChip, annDetail, annIndex, callLabel, decisionClass, decisionText, END_LABEL, INPUT_TRANSFORMATION_LABEL, KIND_LABEL, KIND_TEXT, OUTCOME_LABEL, reasonText, sourceText } from './labels.js';
9
9
  import { OUTCOME_CLASS, threadKey, byNewest } from './list.js';
10
10
  import { exportDoc } from './api.js';
11
11
 
@@ -93,6 +93,28 @@ function fieldsTable(obj, hints, skip = []) {
93
93
  return kvTable(rows, 'fields-kv');
94
94
  }
95
95
 
96
+ /** Renders server-reported input changes separately from local replay annotations. */
97
+ function serverTransformations(resp) {
98
+ const reports = inputTransformationReports(resp);
99
+ if (!reports.length) return null;
100
+ return h('section', { class: 'server-transformations' },
101
+ h('div', { class: 'note' },
102
+ '上游服务端报告 · input_transformations。与 bridge 本地回放过滤分开记录;path 指向本次上游请求。丢弃 thinking 可能损失推理上下文,允许失配不代表已丢弃。'),
103
+ reports.map(report => {
104
+ const where = `${report.event === null ? 'JSON 正文' : `SSE 事件 #${report.event}`} ${report.pointer}`;
105
+ const entries = Array.isArray(report.value) ? report.value : null;
106
+ return h('div', { class: 'transformation-report' },
107
+ h('div', { class: 'sub-title mono' }, where),
108
+ entries?.length === 0 ? h('div', { class: 'dim' }, '服务端报告为空 []') : null,
109
+ entries ? entries.map(entry => h('details', { class: 'transformation-entry' },
110
+ h('summary', null,
111
+ h('span', { class: `chip ${entry?.type === 'thinking_dropped' ? 'warn' : 'info'}` },
112
+ INPUT_TRANSFORMATION_LABEL[entry?.type] ?? entry?.type ?? '未知变换'),
113
+ ' ', h('span', { class: 'mono' }, [entry?.path, entry?.reason].filter(x => x != null).map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' · '))),
114
+ jsonTree(entry, { auto: 2 }))) : jsonTree(report.value, { auto: 2 }));
115
+ }));
116
+ }
117
+
96
118
  const ROUTE_HINTS = {
97
119
  alias: '别名', catalog: '目录模型', adapter: '适配器', api: '上游 API', upstream: '上游配置', base_url: 'base_url', upstream_model: '上游模型',
98
120
  origin_id: '来源指纹', reasoning_format: 'reasoning 格式', native_compaction_format: '原生压缩格式', compaction_mode: '压缩模式',
@@ -442,7 +464,9 @@ export class DetailView {
442
464
  return structureView(r.value, { state: st, ann, annChip: chip });
443
465
  }
444
466
  const fo = finalObject(resp);
445
- return structureView(fo.value, { state: st, ann, annChip: chip, note: fo.note });
467
+ return h('div', null,
468
+ stage.call ? serverTransformations(resp) : null,
469
+ structureView(fo.value, { state: st, ann, annChip: chip, note: fo.note }));
446
470
  }
447
471
 
448
472
  // ---------- overview ----------
@@ -475,6 +499,13 @@ export class DetailView {
475
499
  if (d.annotations?.length) {
476
500
  put(box, h('h4', null, '标注'), h('div', null, h('span', { class: 'chip info' }, annSummary(d.annotations)), ' ', btn('查看全部标注', () => this.open('ann'))));
477
501
  }
502
+ for (const [i, call] of (d.upstream ?? []).entries()) {
503
+ const reports = serverTransformations(call.response);
504
+ if (reports) {
505
+ put(box, h('h4', null, `服务端输入变换 · ${callLabel(call.label)} (${keys[i]})`),
506
+ btn('查看上游响应', () => this.open(`upres:${keys[i]}`)), reports);
507
+ }
508
+ }
478
509
  put(box, h('h4', null, '记录字段'), fieldsTable(d, SUMMARY_HINTS, ['route', 'client', 'upstream', 'annotations']));
479
510
  return box;
480
511
  }
package/ui/events.js CHANGED
@@ -22,6 +22,30 @@ export function parseEvents(resp) {
22
22
  return list;
23
23
  }
24
24
 
25
+ /** Keeps each server report with its source; repeated SSE reports are not totals. */
26
+ export function inputTransformationReports(resp) {
27
+ const reports = [];
28
+ const add = (value, pointer, event = null) => {
29
+ if (value != null && Object.hasOwn(value, 'input_transformations')) {
30
+ reports.push({ event, pointer: `${pointer}/input_transformations`, value: value.input_transformations });
31
+ }
32
+ };
33
+ const events = parseEvents(resp);
34
+ if (events) {
35
+ for (const ev of events) {
36
+ if (ev.json?.type === 'message_start') add(ev.json.message, '/message', ev.i);
37
+ else if (ev.json?.type === 'message_delta') {
38
+ add(ev.json, '', ev.i);
39
+ add(ev.json.delta, '/delta', ev.i);
40
+ }
41
+ }
42
+ } else {
43
+ const parsed = tryJson(resp?.body?.text);
44
+ if (parsed.ok) add(parsed.value, '');
45
+ }
46
+ return reports;
47
+ }
48
+
25
49
  /** Returns {key, text, label} for streaming delta events of Responses, Messages and Chat. */
26
50
  function deltaOf(ev) {
27
51
  const j = ev.json;
@@ -311,6 +335,7 @@ function fromMessages(data) {
311
335
  } else if (j.type === 'message_delta') {
312
336
  Object.assign(msg, j.delta ?? {});
313
337
  if (j.usage) msg.usage = { ...(msg.usage ?? {}), ...j.usage };
338
+ if (Object.hasOwn(j, 'input_transformations')) msg.input_transformations = j.input_transformations;
314
339
  } else if (!['message_stop', 'ping'].includes(j.type)) extra.push(j);
315
340
  }
316
341
  for (const [i, s] of partial) if (blocks[i] && blocks[i].input === undefined) blocks[i].input = s;
package/ui/labels.js CHANGED
@@ -5,6 +5,11 @@ import { jsonTree } from './tree.js';
5
5
  export const CALL_LABEL = { main: '主请求', summary: '压缩·文字摘要', native: '压缩·原生', handoff: '压缩·交接复述' };
6
6
  export const callLabel = (l) => CALL_LABEL[l] ?? l;
7
7
 
8
+ export const INPUT_TRANSFORMATION_LABEL = {
9
+ thinking_dropped: '已丢弃 thinking',
10
+ thinking_mismatch_allowed: '允许失配,保留 thinking',
11
+ };
12
+
8
13
  export const KIND_LABEL = { turn: '轮次', compaction: '压缩', invalid: '无效' };
9
14
  export const OUTCOME_LABEL = { active: '进行中', completed: '完成', incomplete: '未完成', failed: '失败', error: '错误', aborted: '中止' };
10
15
  export const END_LABEL = { active: '传输中', complete: '完整', cancelled: '已取消', error: '出错', unread: '终止事件后停止读取' };