@wacry/codeks-bridge 0.0.3 → 0.0.4

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/NOTICE CHANGED
@@ -11,3 +11,15 @@ licensed under the Apache License, Version 2.0:
11
11
  - catalog/*.json contains model metadata and prompts copied or adapted from:
12
12
  codex-rs/models-manager/models.json
13
13
  Each catalog entry records its source tag, commit, and changes.
14
+ - benchmarks/compaction/prompts/ contains frozen or adapted compact prompts,
15
+ the summary prefix, and model instructions from OpenAI Codex, together with
16
+ user-supplied experiment prompts. File hashes and source notes are recorded
17
+ in benchmarks/compaction/prompts/manifest.json.
18
+ - benchmarks/compaction/retention.py adapts the text-only user-message retention
19
+ and middle-truncation logic from codex-rs/core/src/compact_remote_v2.rs and
20
+ codex-rs/utils/string/src/truncate.rs at rust-v0.162.1.
21
+
22
+ benchmarks/compaction/fixture.py adapts the product-defaults fixture generator
23
+ from algal/pi-openai-server-compaction, Copyright (c) 2026 Alexis Gallagher,
24
+ licensed under the MIT License. The source commit and complete license are
25
+ included in that file.
@@ -4,6 +4,7 @@ export interface AnthropicToolDefinition {
4
4
  name: string;
5
5
  description: string;
6
6
  input_schema: Record<string, unknown>;
7
+ strict?: boolean;
7
8
  }
8
9
  /** Keep later definitions at their original conversation boundary. */
9
10
  export declare function buildAnthropicTools(request: ResponsesRequest, mode: "inline" | "top_level"): {
@@ -1,7 +1,10 @@
1
1
  import { BridgeError } from "../errors.js";
2
2
  import { buildToolSet, normalizeToolDeclarations } from "../tools.js";
3
3
  function definition(tool) {
4
- return { name: tool.flatName, description: tool.description, input_schema: tool.parameters };
4
+ return {
5
+ name: tool.flatName, description: tool.description, input_schema: tool.parameters,
6
+ ...(tool.strict === undefined ? {} : { strict: tool.strict }),
7
+ };
5
8
  }
6
9
  /** Keep later definitions at their original conversation boundary. */
7
10
  export function buildAnthropicTools(request, mode) {
@@ -5,5 +5,6 @@ export declare function buildAnthropicRequest(context: BackendContext): {
5
5
  body: Record<string, any>;
6
6
  tools: ToolSet;
7
7
  betas: string[];
8
+ continuationBefore?: string;
8
9
  };
9
10
  export declare const runAnthropic: Backend;
@@ -78,7 +78,9 @@ export function buildAnthropicRequest(context) {
78
78
  const continueAssistant = () => {
79
79
  const tail = messages.at(-1);
80
80
  if (tail?.role === "assistant" && !tail.content.some(block => block.type === "tool_use")) {
81
- messages.push({ role: "user", content: [{ type: "text", text: "Continue." }] });
81
+ const text = "Continue.";
82
+ messages.push({ role: "user", content: [{ type: "text", text }] });
83
+ return text;
82
84
  }
83
85
  };
84
86
  const flushAdditions = () => {
@@ -117,6 +119,13 @@ export function buildAnthropicRequest(context) {
117
119
  if (!block || !["thinking", "redacted_thinking"].includes(block.type)) {
118
120
  throw new BridgeError("Invalid Messages reasoning carrier.");
119
121
  }
122
+ const continuation = item.state.continuation_before;
123
+ if (continuation !== undefined) {
124
+ if (typeof continuation !== "string" || !continuation.length) {
125
+ throw new BridgeError("Invalid Messages reasoning continuation boundary.");
126
+ }
127
+ append("user", [{ type: "text", text: continuation }]);
128
+ }
120
129
  append("assistant", [block]);
121
130
  }
122
131
  else if (item.type === "function_call" || item.type === "custom_tool_call") {
@@ -153,7 +162,8 @@ export function buildAnthropicRequest(context) {
153
162
  }
154
163
  // A failed response can leave a complete thinking/text item in codeks history.
155
164
  // Claude requires a user boundary to resume. Do not mask a missing tool result.
156
- continueAssistant();
165
+ // Pending declarations already persist their own continuation boundary.
166
+ const continuationBefore = additions.length ? undefined : continueAssistant();
157
167
  flushAdditions();
158
168
  // Keep stable prefixes at 1h. The one-off compaction suffix uses 5m.
159
169
  const cache = (block, ttl = "1h") => {
@@ -219,7 +229,7 @@ export function buildAnthropicRequest(context) {
219
229
  delete body.output_config.format;
220
230
  }
221
231
  }
222
- return { body, tools, betas: [THINKING_BINDING_BETA, ...(hasInlineTools ? [INLINE_TOOLS_BETA] : [])] };
232
+ return { body, tools, betas: [THINKING_BINDING_BETA, ...(hasInlineTools ? [INLINE_TOOLS_BETA] : [])], continuationBefore };
223
233
  }
224
234
  function tokenCount(value) {
225
235
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
@@ -238,16 +248,18 @@ function validateUsage(value, required) {
238
248
  tokenCount(value.output_tokens_details.thinking_tokens);
239
249
  }
240
250
  export const runAnthropic = async (context, writer) => {
241
- const { body, tools, betas } = buildAnthropicRequest(context);
251
+ const { body, tools, betas, continuationBefore } = buildAnthropicRequest(context);
242
252
  const response = await upstreamFetch(context.route, "/messages", body, context.signal, context.requestHeaders, { "anthropic-beta": betas });
243
253
  context.onAccepted?.(response.headers);
244
254
  if (!response.body)
245
255
  throw new BridgeError("Messages returned an empty stream.", "upstream_error", 502);
246
256
  const blocks = new Map();
257
+ let pendingContinuation = continuationBefore;
247
258
  let rawUsage = {};
248
259
  let started = false;
249
260
  let finalUsage = false;
250
261
  let stopReason;
262
+ let stopEvent;
251
263
  let stopped = false;
252
264
  for await (const { json: event } of parseJsonSse(response.body)) {
253
265
  if (event.type === "error")
@@ -266,7 +278,9 @@ export const runAnthropic = async (context, writer) => {
266
278
  if (!["text", "thinking", "redacted_thinking", "tool_use"].includes(value.type)) {
267
279
  throw new BridgeError(`Unsupported Messages output block: ${String(value.type)}.`);
268
280
  }
269
- blocks.set(event.index, { value, json: "" });
281
+ // Only the first native block can anchor the response's leading boundary.
282
+ blocks.set(event.index, { value, json: "", continuationBefore: pendingContinuation });
283
+ pendingContinuation = undefined;
270
284
  if (value.type === "text" && value.text)
271
285
  writer.textDelta(value.text);
272
286
  if (value.type === "thinking")
@@ -298,7 +312,13 @@ export const runAnthropic = async (context, writer) => {
298
312
  if (!block)
299
313
  throw new BridgeError("Messages stopped an unknown block.", "upstream_error", 502);
300
314
  if (block.value.type === "thinking" || block.value.type === "redacted_thinking") {
301
- writer.endReasoning(encodeReasoning({ source: context.route.origin, format: context.route.profile.reasoning_format, data: block.value }));
315
+ const state = {
316
+ source: context.route.origin,
317
+ format: context.route.profile.reasoning_format,
318
+ data: block.value,
319
+ ...(block.continuationBefore !== undefined ? { continuation_before: block.continuationBefore } : {}),
320
+ };
321
+ writer.endReasoning(encodeReasoning(state));
302
322
  }
303
323
  else if (block.value.type === "tool_use") {
304
324
  if (block.json)
@@ -320,7 +340,10 @@ export const runAnthropic = async (context, writer) => {
320
340
  blocks.delete(event.index);
321
341
  }
322
342
  else if (event.type === "message_delta") {
323
- stopReason = event.delta?.stop_reason ?? stopReason;
343
+ if (event.delta?.stop_reason != null) {
344
+ stopReason = event.delta.stop_reason;
345
+ stopEvent = event;
346
+ }
324
347
  validateUsage(event.usage, ["output_tokens"]);
325
348
  finalUsage = true;
326
349
  rawUsage = { ...rawUsage, ...event.usage };
@@ -334,8 +357,14 @@ export const runAnthropic = async (context, writer) => {
334
357
  if (stopReason === "model_context_window_exceeded") {
335
358
  throw new BridgeError("Messages reached the model context window before completing the response.", "context_length_exceeded");
336
359
  }
337
- if (stopReason === "max_tokens" || stopReason === "refusal") {
338
- throw new BridgeError(stopReason === "max_tokens" ? "Messages reached max_tokens before completing the response." : "Messages refused the request.");
360
+ if (stopReason === "refusal") {
361
+ const details = stopEvent?.delta?.stop_details;
362
+ const diagnostic = [details?.category, details?.explanation]
363
+ .filter((value) => typeof value === "string" && value.trim().length > 0);
364
+ throw new BridgeError(`Messages refused the request.${diagnostic.length ? ` ${diagnostic.join(": ")}` : ""}`, "invalid_prompt", 400, undefined, { upstream: { body: stopEvent } });
365
+ }
366
+ if (stopReason === "max_tokens") {
367
+ throw new BridgeError("Messages reached max_tokens before completing the response.");
339
368
  }
340
369
  if (!["end_turn", "stop_sequence", "tool_use", "pause_turn"].includes(stopReason)) {
341
370
  throw new BridgeError(`Unsupported Messages stop reason: ${stopReason}.`);
@@ -159,7 +159,10 @@ export function buildChatRequest(context, dialect) {
159
159
  body.stream_options = { ...body.stream_options, include_usage: true };
160
160
  if (tools.entries.length)
161
161
  body.tools = tools.entries.map((tool) => ({
162
- type: "function", function: { name: tool.flatName, description: tool.description, parameters: tool.parameters },
162
+ type: "function", function: {
163
+ name: tool.flatName, description: tool.description, parameters: tool.parameters,
164
+ ...(tool.strict === undefined ? {} : { strict: tool.strict }),
165
+ },
163
166
  }));
164
167
  else
165
168
  delete body.tools;
package/dist/config.js CHANGED
@@ -29,6 +29,8 @@ export function validateConfig(value) {
29
29
  fail("port must be 0..65535");
30
30
  if (value.token_env !== undefined && (typeof value.token_env !== "string" || !value.token_env))
31
31
  fail("token_env must name an environment variable");
32
+ if (value.request_max_mib !== undefined && (!Number.isSafeInteger(value.request_max_mib) || value.request_max_mib <= 0 || !Number.isSafeInteger(value.request_max_mib * 1024 * 1024)))
33
+ fail("request_max_mib must be a positive integer with a safe byte count");
32
34
  if (value.compaction_cache_dir !== undefined && (typeof value.compaction_cache_dir !== "string" || !value.compaction_cache_dir.trim()))
33
35
  fail("compaction_cache_dir must be a nonempty path");
34
36
  if (value.inspect !== undefined) {
@@ -23,7 +23,7 @@ export interface Annotation {
23
23
  decoded: unknown;
24
24
  }
25
25
  /** Splits timed text into SSE events. An event takes the time of the part that completed it. */
26
- export declare function sseEvents(parts: readonly Part[]): SseRecord[];
26
+ export declare function sseEvents(parts: readonly Part[], finished?: boolean): SseRecord[];
27
27
  export declare function summary(exchange: Exchange): {
28
28
  id: string;
29
29
  seq: number;
@@ -2,7 +2,7 @@ import { decodeCompaction, decodeReasoning } from "../carrier.js";
2
2
  import { resolveModel } from "../config.js";
3
3
  import { replaysReasoning, restoresNativeCompaction } from "../history.js";
4
4
  /** Splits timed text into SSE events. An event takes the time of the part that completed it. */
5
- export function sseEvents(parts) {
5
+ export function sseEvents(parts, finished = true) {
6
6
  const events = [];
7
7
  let event = null;
8
8
  let data = [];
@@ -43,11 +43,13 @@ export function sseEvents(parts) {
43
43
  }
44
44
  buffer = buffer.slice(start);
45
45
  }
46
- if (buffer.endsWith("\r"))
47
- buffer = buffer.slice(0, -1);
48
- if (buffer)
49
- line(buffer, t);
50
- line("", t);
46
+ if (finished) {
47
+ if (buffer.endsWith("\r"))
48
+ buffer = buffer.slice(0, -1);
49
+ if (buffer)
50
+ line(buffer, t);
51
+ line("", t);
52
+ }
51
53
  return events;
52
54
  }
53
55
  function isSse(message) {
@@ -210,7 +212,7 @@ function messageView(message) {
210
212
  end: message.end,
211
213
  error: message.error ?? null,
212
214
  body: { text: message.text(), bytes: message.bytes },
213
- events: isSse(message) ? sseEvents(message.parts) : null,
215
+ events: isSse(message) ? sseEvents(message.parts, message.end !== "active") : null,
214
216
  };
215
217
  }
216
218
  function reasoningInput(inspector, route, item, pointer) {
package/dist/server.js CHANGED
@@ -14,7 +14,6 @@ import { Inspector } from "./inspect/capture.js";
14
14
  import { isInspectorPath, serveInspector } from "./inspect/http.js";
15
15
  import { forwardResponseHeaders, incomingHeaders } from "./headers.js";
16
16
  const BACKENDS = { "gpt-responses": runResponses, "claude-messages": runAnthropic, "deepseek-chat": runDeepSeek };
17
- const MAX_BODY_BYTES = 64 * 1024 * 1024;
18
17
  function json(res, status, body, headers = {}) {
19
18
  for (const [name, value] of Object.entries(headers))
20
19
  res.setHeader(name, value);
@@ -27,13 +26,15 @@ function upstreamHttpFailure(res, error) {
27
26
  res.writeHead(error.status, forwardResponseHeaders(error.headers, false));
28
27
  res.end(error.body);
29
28
  }
30
- async function readRequest(req, exchange) {
29
+ async function readRequest(req, maxMiB, exchange) {
30
+ const maxBytes = maxMiB * 1024 * 1024;
31
31
  let size = 0;
32
32
  const chunks = [];
33
- for await (const chunk of req) {
33
+ // Keep the socket alive long enough to deliver HTTP 413, then close it.
34
+ for await (const chunk of req.iterator({ destroyOnReturn: false })) {
34
35
  size += chunk.length;
35
- if (size > MAX_BODY_BYTES)
36
- throw new BridgeError("Request exceeds 64 MiB", "invalid_prompt", 413);
36
+ if (size > maxBytes)
37
+ throw new BridgeError(`Request exceeds ${maxMiB} MiB`, "invalid_prompt", 413, { connection: "close" });
37
38
  chunks.push(chunk);
38
39
  }
39
40
  const text = Buffer.concat(chunks).toString("utf8");
@@ -123,7 +124,7 @@ export function createBridgeServer(value) {
123
124
  }
124
125
  res.setHeader("x-models-etag", catalogEtag);
125
126
  const exchange = inspector.begin(req, res);
126
- const request = await readRequest(req, exchange);
127
+ const request = await readRequest(req, config.request_max_mib ?? 512, exchange);
127
128
  const caller = resolveModel(config, request.model);
128
129
  const compacting = isCompactionRequest(request);
129
130
  // Restore history for the actual consumer before materializing any handoffs.
package/dist/tools.d.ts CHANGED
@@ -8,6 +8,7 @@ export interface ToolEntry {
8
8
  namespace?: string;
9
9
  description: string;
10
10
  parameters: Record<string, unknown>;
11
+ strict?: boolean;
11
12
  }
12
13
  export interface ToolSet {
13
14
  entries: ToolEntry[];
package/dist/tools.js CHANGED
@@ -116,6 +116,8 @@ 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
+ if (entry.strict === undefined)
120
+ delete previous.strict;
119
121
  return previous;
120
122
  }
121
123
  let flatName = baseFlatName(entry.namespace, entry.name);
@@ -140,6 +142,7 @@ export function buildToolSet(tools) {
140
142
  namespace,
141
143
  description: String(tool.description ?? ""),
142
144
  parameters: objectSchema(tool.parameters),
145
+ ...(typeof tool.strict === "boolean" ? { strict: tool.strict } : {}),
143
146
  })];
144
147
  }
145
148
  else if (tool.type === "custom") {
package/dist/types.d.ts CHANGED
@@ -49,6 +49,7 @@ export interface BridgeConfig {
49
49
  host?: string;
50
50
  port?: number;
51
51
  token_env?: string;
52
+ request_max_mib?: number;
52
53
  compaction_cache_dir?: string;
53
54
  /** Debug capture for the /inspect/ page. */
54
55
  inspect?: {
@@ -40,20 +40,27 @@ Opus / Sonnet 5.5 目录的默认及最大上下文窗口为 `1000000`,Message
40
40
 
41
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
42
 
43
- 流式响应可能已交付完整 thinking 或文字项,随后才过载或断流。codeks 保存这些已完成项并负责重试,因此重试历史可能以 assistant 结束。若最后的 assistant 不含 `tool_use`,bridge 仅在本次出站请求追加 user `Continue.`,提供合法续接边界;含工具调用时不补这一句,以免掩盖缺失工具结果。
43
+ 流式响应可能已交付完整 thinking 或文字项,随后才过载或断流。codeks 保存这些已完成项并负责重试,因此重试历史可能以 assistant 结束。若最后的 assistant 不含 `tool_use`,bridge 在出站请求追加 user `Continue.`,提供合法续接边界;含工具调用时不补这一句,以免掩盖缺失工具结果。
44
44
 
45
45
  若这个 assistant 后紧跟持久化的增量工具声明,同样在声明前补续接边界;后续回放时,声明的位置让 bridge 能重新构造相同边界,不需要另存隐藏会话状态。
46
46
 
47
- 这句合成提示不写入 codeks 历史。假设失败前已保存 T1,重试生成 T2,下一轮历史会出现以下差别:
47
+ 对于没有增量工具声明锚定的尾部 `Continue.`,若本次响应的第一个原生内容块是 `thinking` 或 `redacted_thinking`,bridge 在该块的 `cxbr1:` 外层记录可选字段 `continuation_before`,值为实际补入的文本。原生 thinking、签名和隐藏数据保持不变;字段随原 reasoning item 的 `output_item.done` 进入 codeks 历史,不产生独立的控制项或用户消息。
48
+
49
+ 以后恢复这块 reasoning 时,先在同一位置补回记录的 user 文本。假设失败前已保存 T1,重试的首块为 T2:
48
50
 
49
51
  ```text
50
- 重试请求:H → assistant[T1] → user[Continue.] → assistant[T2, 正文]
51
- 下一轮: H → assistant[T1, T2, 正文] → user[新问题]
52
+ 重试请求:H → assistant[T1] → user[Continue.]
53
+ 重试输出:T2{continuation_before: "Continue."} → 正文
54
+ 下轮请求:H → assistant[T1] → user[Continue.] → assistant[T2, 正文] → user[新问题]
52
55
  ```
53
56
 
54
- 因此 T2 的签名前缀可能不再匹配;默认 `drop_block` 让服务端在该轮报告并丢弃失配块。只保证 Continue 那次请求成功并不够,后续回放也需要考虑这个变化。`error` 或 `upstream_default` 下,服务端仍可能拒绝后续请求。
57
+ 这样 T2 不会仅因续接边界消失而失配。同一响应只标记首块,后续 thinking、正文和工具继续保持原顺序;如果 T2 完整交付后又发生故障,这个边界已随 T2 保存,下一次重试可恢复它并记录新的续接边界。首块尚未完整交付时没有新载荷,下次仍按已有历史重试。字段跟随 reasoning 的来源与格式筛选:本次不恢复该载荷时,也不恢复其中的边界;以后允许恢复时,两者一起生效。
58
+
59
+ 覆盖范围按实际输出顺序决定。Adaptive thinking 不保证第一个内容块就是 thinking;若先输出正文或工具,再输出 thinking,bridge 不给后来的块添加这个字段,否则会把 `Continue.` 恢复到错误的位置。这类响应及没有字段的既有历史仍沿用当前失配策略;没有 thinking 的响应则没有新签名需要通过这个字段保护。由增量工具声明锚定的边界不重复记录。[官方 thinking 规则](https://platform.claude.com/docs/en/build-with-claude/thinking)
55
60
 
56
- 选择这种恢复方式是为了保留正常 thinking、正文的流式显示,不让每轮正文都等待整个响应成功,也不引入隐藏会话状态。工具调用仍按现有规则等完整成功后交付。失配块及依赖该前缀的后续 thinking 都可能被丢弃,造成推理上下文损失或增加重新推理成本;这不是质量不变的承诺,也不能修复损坏签名或其他所有非法输入。具体发生了什么以[检查器中的服务端报告](inspect.md#claude-服务端输入处理)为准。
61
+ 用户选择保留正常 thinking、正文的流式显示,并用少量自包含状态恢复边界。因此只记录位置确定的首块,不缓存整轮正文,也不引入会话索引、独立控制载荷或向前查找历史位置的规则。工具调用仍按现有规则等完整成功后交付。`continuation_before` 记录已发生的请求事实,不改变来源复用配置,也不替代 `thinking_mismatch`。
62
+
63
+ 工具定义等其他前缀变化仍可能导致失配,未覆盖的续接次序也可能丢失 thinking。默认 `drop_block` 让服务端报告并丢弃失配块;`error` 或 `upstream_default` 下,服务端仍可能拒绝请求。丢失推理上下文可能影响质量或增加重新推理成本,不能把边界恢复视为所有签名和输入均可接受的保证。具体发生了什么以[检查器中的服务端报告](inspect.md#claude-服务端输入处理)为准。
57
64
 
58
65
  一次失配不会令今后生成的 thinking 永久失效。后续请求保持前缀稳定时,新生成的块仍可正常复用;原始历史里的旧失配块可能在多轮请求中被重复报告,重复报告不代表每轮都丢弃新的 thinking。实际保留与丢弃以各次上游报告为准。
59
66
 
@@ -85,4 +92,6 @@ input 为 `input_tokens + cache_read_input_tokens + cache_creation_input_tokens`
85
92
 
86
93
  HTTP 与流内错误经共享层分类,同时保留上游原始错误体;未完整结束的流返回错误。bridge 不改参数重试,容量与限流退避由 codeks 决定。[错误结构](responses-stream.md)
87
94
 
95
+ `stop_reason: "refusal"` 保持不可重试的 `invalid_prompt`。若上游提供 `stop_details.category` 或 `explanation`,错误消息附上这些原文,便于直接在 codeks 看到拒绝原因;未提供时保留通用提示,不猜测分类。完整的终止 `message_delta` 放入 `error.upstream.body`,包含未知字段及上游 usage,便于诊断,不计入普通成功响应的 usage。工具后缀仍整体丢弃,不因保留详情而交付工具或自动重试。
96
+
88
97
  实现与测试:[`src/backends/anthropic.ts`](https://github.com/WAcry/codeks-bridge/blob/main/src/backends/anthropic.ts)、[`test/anthropic.test.ts`](https://github.com/WAcry/codeks-bridge/blob/main/test/anthropic.test.ts)。协议参考:[thinking](https://platform.claude.com/docs/en/build-with-claude/thinking)、[结构化输出](https://platform.claude.com/docs/en/build-with-claude/structured-outputs)、[缓存](https://platform.claude.com/docs/en/build-with-claude/prompt-caching)。
package/docs/config.md CHANGED
@@ -46,12 +46,15 @@
46
46
  | `host` | 默认 `127.0.0.1` |
47
47
  | `port` | 默认 `8787`;程序集成测试可用 `0` 分配端口 |
48
48
  | `token_env` | 保存 bridge 入口 Bearer token 的环境变量名称 |
49
+ | `request_max_mib` | `POST /v1/responses` 请求体上限,单位 MiB,默认 `512`;必须是正整数且换算后的字节数可安全表示 |
49
50
  | `compaction_cache_dir` | 按需复述缓存目录;默认 `$XDG_CACHE_HOME/codeks-bridge/compaction`,未设置 XDG 时为 `~/.cache/codeks-bridge/compaction` |
50
51
  | `upstreams` | 上游连接配置表 |
51
52
  | `models` | 模型别名表,是否进入客户端目录由各自的 `expose` 控制 |
52
53
  | `inspect` | 可选;`capture` 决定启动时是否捕获,`max_mib` 是内存软上限,见[调试检查器](inspect.md) |
53
54
 
54
- 监听非 loopback 地址时必须配置 `token_env`,客户端也要设置同一个变量。它是 bridge 入口鉴权,不是上游 API key。HTTP 请求体最多 64 MiB,包含所有图片编码;入口没有 CORS。携带 `Origin` 的请求必须使用配置好的 bridge token,即使监听 loopback 也一样,避免网页的简单 POST 在无法读取响应时仍触发上游调用。没有 `Origin` 的 codeks 请求保持原行为。`GET /health`、`GET /v1/models` 和 `POST /v1/responses` 使用同一鉴权。`/inspect/` 页面与接口使用[自己的规则](inspect.md#访问与鉴权)。
55
+ 监听非 loopback 地址时必须配置 `token_env`,客户端也要设置同一个变量。它是 bridge 入口鉴权,不是上游 API key。入口没有 CORS。携带 `Origin` 的请求必须使用配置好的 bridge token,即使监听 loopback 也一样,避免网页的简单 POST 在无法读取响应时仍触发上游调用。没有 `Origin` 的 codeks 请求保持原行为。`GET /health`、`GET /v1/models` 和 `POST /v1/responses` 使用同一鉴权。`/inspect/` 页面与接口使用[自己的规则](inspect.md#访问与鉴权)。
56
+
57
+ 请求体上限按实际收到的字节计算,包含 JSON 和图片编码,不按字符数或声明的 `Content-Length` 判断。恰好达到上限的请求可接受,超限立即返回 HTTP 413 并关闭连接,不调用上游。默认 `512` MiB 为长截图历史留出空间;可按会话需要及可用内存调高或调低。保留上限是为了约束完整缓冲、JSON 解析和转换的内存开销,启用检查器时还会保留捕获副本;它不是进程总内存上限,也不等于模型 token 窗口。
55
58
 
56
59
  ## 上游连接
57
60
 
package/docs/inspect.md CHANGED
@@ -59,6 +59,16 @@ codeks-bridge serve --config /absolute/path/bridge.json --capture
59
59
 
60
60
  “概要”给出实际执行模型的路由配置、每个调用的时间线和上游调用表;客户端模型字段仍保留用户请求的工作别名。“标注”列出全部回放决定,点击可定位到对应项。“对比”选择任意两次交换的任意一段做结构比较,常用的是和同一线程上一轮的客户端请求或上游请求比较;数组按类型和角色对齐,新增、删除的项单独标出。
61
61
 
62
+ 概要中的“流式内容延迟”直接从已有 SSE 捕获派生,不增加请求或修改原始记录:
63
+
64
+ - 每条 `main`、`native`、`summary`、`handoff` 从该次上游调用开始计时。`bridge → codeks` 从客户端请求开始计时,包含必要的前置 handoff 和 bridge 缓冲时间。
65
+ - “首内容”是首次实际文字、reasoning 或工具内容;“首文字”“首 reasoning”“首工具”分别记录各类首次到达。工具以首次出现名称或非空参数为准,不表示工具已经执行。空 delta、响应头、created、keepalive、签名更新和压缩密文不算内容。
66
+ - 上游 reasoning 可以是文字或不透明载荷;写回 codeks 一侧只计可展示的 reasoning 摘要,不把隐藏载荷当成可见输出。这里测量 bridge 收到或写出的时刻,不是模型内部生成时间或 codeks TUI 实际渲染时间。
67
+ - SSE 事件按完成该事件的数据块计时;若上游只在终止事件里交付完整 output,就记该事件到达时刻。仍未结束的事件不会提前计入。
68
+ - `—` 表示尚未观测到该类内容或无法测量,不等于零延迟。JSON 响应、未完成的 JSON 事件及缺少时间戳的导入记录不估算首 token 时间;原生压缩只有密文而没有上述内容时,这些指标也为空。已观测的延迟不代表整条响应成功,需同时看调用状态。
69
+
70
+ 时间线中的“响应头”保留其独立含义,不作为首内容时间。选择逐调用记录,是为了区分上游等待与客户端实际等待,避免把辅助压缩的时间混成普通模型的首 token 延迟。
71
+
62
72
  “导出全部”和详情里的“导出此条”下载导出文件。“导入”(或把文件拖到页面上)离线查看导出文件,“返回实时”回到 bridge 的记录。浏览器不允许从本地文件加载页面脚本,离线查看也要通过 HTTP 打开页面,例如任意运行中的 bridge 的 `/inspect/`。
63
73
 
64
74
  ## 记录规则
package/docs/reasoning.md CHANGED
@@ -39,6 +39,8 @@ JSON 的结构如下,示例指纹为占位值:
39
39
 
40
40
  一个 carrier 对应一项 reasoning;格式可以保存字符串或结构化数据,不限于可见文字。外层只是编码,不是加密。GPT 密文本身不变;Claude 可见 thinking 和 DeepSeek 文本仍可解码。
41
41
 
42
+ Claude 的外层还可包含 `continuation_before: "Continue."`,记录生成该块前实际补入的 user 续接边界。它只用于响应首块为 thinking/redacted 的情况,原生 `data` 和签名不变,前缀仍为 `cxbr1:`。字段随载荷保存、筛选和恢复,不改变复用策略;具体时序与覆盖范围见 [Claude 故障续接](backends-anthropic.md#失配策略与故障续接)。
43
+
42
44
  codeks 会再次发送 `encrypted_content`,因此载荷可以跨请求、会话恢复、服务重启和子代理 fork 保留。只放在 reasoning 的可见 `content` 中不足以完成这种回放。
43
45
 
44
46
  ## 来源身份
package/docs/testing.md CHANGED
@@ -29,6 +29,12 @@ npm run check
29
29
 
30
30
  这些测试检查协议和客户端行为;假上游不会验证模型是否理解提示词,也不会验证真实服务的缓存命中率、容量和区域之间的密文兼容性。
31
31
 
32
+ 压缩后的信息保留与 handoff 质量另有[手动 benchmark](../benchmarks/compaction/README.md):使用公开合成材料,对比两种文字 prompt、原生压缩和原生压缩后 handoff。它独立于 CI,提供复跑方法、输入、产物和逐题结果;真实模型请求需要显式执行。
33
+
34
+ Claude 续接边界的回归同时检查生成和下一轮回放:首块 thinking/redacted 的 `continuation_before` 只记录一次,原生载荷不变;连续失败、多个 thinking、关闭摘要显示时仍保持顺序;来源过滤与切回按同一载荷生效;增量工具声明已有边界时不重复插入。正文或工具先于 thinking、没有 thinking,以及没有该字段的既有载荷,继续使用原有失配策略。`test/claude-continuation.test.ts` 检查转换,`test/claude-recovery-e2e.test.ts` 使用真实 codeks 和假上游检查持久化与重试;签名是否被真实上游接受须另行验证,不能由合成签名测试推断。
35
+
36
+ 2026-10-20(America/Los_Angeles),以 `@wacry/codeks@0.163.0-codeks.5` 启用全部客户端测试,286 项通过、0 跳过,类型检查、构建和私有内容检查通过。续接场景覆盖 overload、EOF、连续两次失败及进程重启后的 resume。另复用真实 T1/T2 签名直接请求官方 Sonnet 5.5:恢复原位 `user: Continue.` 后返回 HTTP 200,`input_transformations` 明确为空数组。这验证边界恢复的协议依据;carrier 的生成、保存与重放由真实客户端加本地假上游验证,两类证据分别成立。
37
+
32
38
  `test/delegated-compaction-e2e.test.ts` 通过 bridge 的真实 HTTP 入口连接本地假上游,覆盖 GPT 原生续聊 → Claude 按需复述 → Claude 委托 GPT 再压缩 → Claude 复述新检查点 → GPT 恢复新检查点。另检查 native 失败不交付部分检查点、handoff 失败不调用工作模型、工作模型失败后跨服务重启复用成功缓存,以及 inspector 的独立 handoff usage 不进入客户端响应。它不启动 codeks,也不调用真实模型;委托压缩的质量与成本不由这些测试推断。
33
39
 
34
40
  ## 使用真实 codeks 二进制
package/docs/tools.md CHANGED
@@ -49,6 +49,10 @@ Messages 对不符合端点格式的 call ID 采用确定性映射,并添加
49
49
 
50
50
  根 schema 的 `$ref`、`$defs`、`allOf`、`anyOf`、`oneOf` 及其他约束均保留;缺少根 `type` 时补为 `object`,已有类型不覆盖,由上游判断是否支持。codeks 可以发送没有根 `type` 的引用或组合 schema,不能因此把参数清空。未提供参数 schema 时使用空对象定义;显式提供非对象 schema 值则报错,不在 bridge 内解析引用或删减约束。
51
51
 
52
+ 普通函数显式给出的 `strict: true` 或 `strict: false` 原样保留:Messages 放在工具定义顶层,Chat 放在 `function` 内;省略时不添加默认值。Lite 同名更新替换完整定义,因此新定义省略 `strict` 时也不继承旧值;Claude inline 的早先版本保持不变。custom 工具的 JSON 外壳不因此自动启用 strict。
53
+
54
+ [Claude strict tool use](https://platform.claude.com/docs/en/agents-and-tools/tool-use/strict-tool-use) 与 [DeepSeek strict mode](https://api-docs.deepseek.com/guides/tool_calls/#strict-mode-beta) 各有支持的 schema 范围;DeepSeek 官方文档还要求 beta 端点及所有函数都设为 `strict: true`。bridge 只转换调用方明确提供的字段,不切换端点、不改其他工具或删减 schema 来满足 strict。需要该能力时由用户配置支持的端点,上游不接受则保留错误。
55
+
52
56
  codeks V2 多代理工具的 task/message 是明文,历史中的 `agent_message` 对 Messages/Chat 转成 user 消息,保留 author、recipient 和内容顺序。它不会成为模型 reasoning。不能解密的工具或 agent message 会明确报错。
53
57
 
54
58
  ## 结果与范围
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wacry/codeks-bridge",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
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/detail.js CHANGED
@@ -8,6 +8,7 @@ import { diffView } from './diff.js';
8
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
+ import { responseLatency } from './latency.js';
11
12
 
12
13
  const RAW_LIMIT = 200_000;
13
14
  const VIEW_STATES = 80;
@@ -402,7 +403,7 @@ export class DetailView {
402
403
  else {
403
404
  put(out, h('span', { class: `chip ${respClass(r)}` }, `${r.status ?? '–'} ${r.status_text ?? ''}`.trim()),
404
405
  h('span', { class: `chip ${r.end === 'active' ? 'info' : 'dim'}`, title: `end: ${r.end}` }, END_LABEL[r.end] ?? r.end ?? '–'),
405
- r.headers_ms != null ? h('span', { class: 'dim', title: '自请求开始到响应头的时间' }, `首包 ${fmtDur(r.headers_ms)}`) : null,
406
+ r.headers_ms != null ? h('span', { class: 'dim', title: '自请求开始到响应头的时间' }, `响应头 ${fmtDur(r.headers_ms)}`) : null,
406
407
  r.ended_ms != null ? h('span', { class: 'dim', title: '自请求开始到结束的时间' }, `结束 ${fmtDur(r.ended_ms)}`) : null,
407
408
  r.events ? h('span', { class: 'dim' }, `${fmtInt(r.events.length)} 事件`) : h('span', { class: 'dim' }, '非 SSE'),
408
409
  r.error ? h('span', { class: 'chip bad', title: r.error }, oneLine(`错误:${r.error}`, 80)) : null);
@@ -476,10 +477,11 @@ export class DetailView {
476
477
  const box = h('div', { class: 'overview' });
477
478
  put(box, h('h4', null, '路由'), d.route ? fieldsTable(d.route, ROUTE_HINTS) : h('div', { class: 'dim' }, '没有路由信息(请求无法解析或路由失败)'));
478
479
  put(box, h('h4', null, '时间线'), this.timeline());
480
+ put(box, h('h4', null, '流式内容延迟'), this.latency());
479
481
  const keys = callKeys(d.upstream);
480
482
  if (d.upstream?.length) {
481
483
  put(box, h('h4', null, `上游调用(${d.upstream.length})`), h('table', { class: 'calls' },
482
- h('thead', null, h('tr', null, ['标签', '请求', '状态', '结束', '开始', '首包', '结束于', '请求大小', '响应大小', '错误'].map((x) => h('th', null, x)))),
484
+ h('thead', null, h('tr', null, ['标签', '请求', '状态', '结束', '开始', '响应头于', '结束于', '请求大小', '响应大小', '错误'].map((x) => h('th', null, x)))),
483
485
  h('tbody', null, d.upstream.map((c, i) => {
484
486
  const r = c.response;
485
487
  const tr = h('tr', { class: 'clickable', title: '打开该调用的上游响应', onclick: () => this.open(`upres:${keys[i]}`) },
@@ -525,13 +527,30 @@ export class DetailView {
525
527
  const bar = h('span', { class: `tl-bar ${r.cls}` });
526
528
  bar.style.left = pct(r.start);
527
529
  bar.style.width = pct(Math.max(end - r.start, total / 400));
528
- const mark = r.head != null ? h('span', { class: 'tl-head', title: `首包 ${fmtDur(r.head)}` }) : null;
530
+ const mark = r.head != null ? h('span', { class: 'tl-head', title: `响应头 ${fmtDur(r.head)}` }) : null;
529
531
  if (mark) mark.style.left = pct(r.head);
530
532
  return h('div', { class: 'tl-row clickable', onclick: () => this.open(r.key) },
531
533
  h('span', { class: 'tl-label' }, r.label),
532
534
  h('span', { class: 'tl-track' }, bar, mark),
533
535
  h('span', { class: 'tl-num mono' }, `${fmtDur(r.start)} → ${r.head != null ? fmtDur(r.head) : '–'} → ${r.end != null ? fmtDur(r.end) : d.active ? '…' : '–'}`));
534
- }), h('div', { class: 'dim tl-axis' }, `0 → ${fmtDur(total)}(开始 → 首包 → 结束,相对请求开始)`));
536
+ }), h('div', { class: 'dim tl-axis' }, `0 → ${fmtDur(total)}(开始 → 响应头 → 结束,相对请求开始)`));
537
+ }
538
+
539
+ latency() {
540
+ const d = this.detail;
541
+ const rows = (d.upstream ?? []).map(c => ({
542
+ label: `${callLabel(c.label)} → bridge`,
543
+ value: responseLatency(c.response, c.started_ms),
544
+ }));
545
+ rows.push({ label: 'bridge → codeks', value: responseLatency(d.client?.response, 0, true) });
546
+ return h('div', null,
547
+ h('div', { class: 'note' }, '上游从各自调用开始计时;写回 codeks 从客户端请求开始计时,包含前置 handoff。只计文字、reasoning 或工具内容,响应头和 keepalive 不计。这里是到达或写出时间,不是模型生成或 TUI 渲染时间。'),
548
+ h('table', { class: 'calls' },
549
+ h('thead', null, h('tr', null, ['方向', '首内容', '首文字', '首 reasoning', '首工具'].map(x => h('th', null, x)))),
550
+ h('tbody', null, rows.map(({ label, value }) => h('tr', null,
551
+ h('td', null, label),
552
+ ['content_ms', 'text_ms', 'reasoning_ms', 'tool_ms'].map(key => h('td', { class: 'mono' }, value?.[key] != null ? fmtDur(value[key]) : '—')))))),
553
+ h('div', { class: 'dim' }, '— 表示未观测到或无法测量;JSON 响应和缺少时间戳的导入不估算。上游 reasoning 可包含不透明载荷;写回 codeks 仅计可见摘要。工具以首次名称或参数为准,压缩密文不计入这些指标。'));
535
554
  }
536
555
 
537
556
  // ---------- annotations ----------
package/ui/latency.js ADDED
@@ -0,0 +1,88 @@
1
+ // Delivery times derived from captured SSE events, never from headers or usage.
2
+ const present = (value) => typeof value === 'string' && value.length > 0;
3
+ const array = (value) => Array.isArray(value) ? value : [];
4
+
5
+ function contentKinds(block, visibleOnly) {
6
+ if (!block || typeof block !== 'object') return [];
7
+ switch (block.type) {
8
+ case 'text': case 'output_text': case 'refusal':
9
+ return present(block.text ?? block.refusal) ? ['text'] : [];
10
+ case 'thinking':
11
+ return present(block.thinking) ? ['reasoning'] : [];
12
+ case 'summary_text': case 'reasoning_text':
13
+ return present(block.text) ? ['reasoning'] : [];
14
+ case 'redacted_thinking':
15
+ return !visibleOnly && present(block.data) ? ['reasoning'] : [];
16
+ case 'reasoning':
17
+ return [
18
+ ...array(block.summary).flatMap(part => contentKinds(part, visibleOnly)),
19
+ ...array(block.content).flatMap(part => contentKinds(part, visibleOnly)),
20
+ ...(!visibleOnly && present(block.encrypted_content) ? ['reasoning'] : []),
21
+ ];
22
+ case 'message':
23
+ return Array.isArray(block.content) ? block.content.flatMap(part => contentKinds(part, visibleOnly)) : [];
24
+ case 'function_call': case 'custom_tool_call': case 'tool_use':
25
+ return present(block.name) || present(block.arguments) || present(block.input) ? ['tool'] : [];
26
+ default: return [];
27
+ }
28
+ }
29
+
30
+ function eventKinds(data, visibleOnly) {
31
+ switch (data?.type) {
32
+ case 'response.output_text.delta': case 'response.output_text.done':
33
+ return present(data.delta ?? data.text) ? ['text'] : [];
34
+ case 'response.refusal.delta': case 'response.refusal.done':
35
+ return present(data.delta ?? data.refusal) ? ['text'] : [];
36
+ case 'response.reasoning_summary_text.delta': case 'response.reasoning_summary_text.done':
37
+ case 'response.reasoning_text.delta': case 'response.reasoning_text.done':
38
+ return present(data.delta ?? data.text) ? ['reasoning'] : [];
39
+ case 'response.function_call_arguments.delta': case 'response.function_call_arguments.done':
40
+ case 'response.custom_tool_call_input.delta': case 'response.custom_tool_call_input.done':
41
+ return present(data.delta ?? data.arguments ?? data.input) ? ['tool'] : [];
42
+ case 'response.output_item.added': case 'response.output_item.done':
43
+ return contentKinds(data.item, visibleOnly);
44
+ case 'response.content_part.added': case 'response.content_part.done':
45
+ case 'response.reasoning_summary_part.added': case 'response.reasoning_summary_part.done':
46
+ return contentKinds(data.part, visibleOnly);
47
+ case 'response.completed': case 'response.incomplete': case 'response.failed':
48
+ return array(data.response?.output).flatMap(item => contentKinds(item, visibleOnly));
49
+ case 'message_start':
50
+ return array(data.message?.content).flatMap(item => contentKinds(item, visibleOnly));
51
+ case 'content_block_start':
52
+ return contentKinds(data.content_block, visibleOnly);
53
+ case 'content_block_delta':
54
+ switch (data.delta?.type) {
55
+ case 'text_delta': return present(data.delta.text) ? ['text'] : [];
56
+ case 'thinking_delta': return present(data.delta.thinking) ? ['reasoning'] : [];
57
+ case 'input_json_delta': return present(data.delta.partial_json) ? ['tool'] : [];
58
+ default: return [];
59
+ }
60
+ default:
61
+ return array(data?.choices).flatMap(choice => {
62
+ const delta = choice?.delta ?? choice?.message;
63
+ if (!delta) return [];
64
+ return [
65
+ ...(present(delta.content) || present(delta.refusal) ? ['text'] : []),
66
+ ...(present(delta.reasoning_content) ? ['reasoning'] : []),
67
+ ...(array(delta.tool_calls).some(call => present(call.function?.name) || present(call.function?.arguments)) ? ['tool'] : []),
68
+ ];
69
+ });
70
+ }
71
+ }
72
+
73
+ /** Null means no timed SSE capture; individual null fields mean not observed. */
74
+ export function responseLatency(response, startedMs, visibleOnly = false) {
75
+ if (!Array.isArray(response?.events) || !Number.isFinite(startedMs)) return null;
76
+ const first = { content_ms: null, text_ms: null, reasoning_ms: null, tool_ms: null };
77
+ for (const event of response.events) {
78
+ if (!Number.isFinite(event.t) || event.t < startedMs) continue;
79
+ let data;
80
+ try { data = JSON.parse(event.data); } catch { continue; }
81
+ for (const kind of eventKinds(data, visibleOnly)) {
82
+ const elapsed = Math.round((event.t - startedMs) * 10) / 10;
83
+ first.content_ms ??= elapsed;
84
+ first[`${kind}_ms`] ??= elapsed;
85
+ }
86
+ }
87
+ return first;
88
+ }