@wacry/codeks-bridge 0.0.1 → 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.
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,14 @@
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
+ strict?: boolean;
8
+ }
9
+ /** Keep later definitions at their original conversation boundary. */
10
+ export declare function buildAnthropicTools(request: ResponsesRequest, mode: "inline" | "top_level"): {
11
+ tools: ToolSet;
12
+ definitions: AnthropicToolDefinition[];
13
+ input: ResponsesRequest["input"];
14
+ };
@@ -0,0 +1,44 @@
1
+ import { BridgeError } from "../errors.js";
2
+ import { buildToolSet, normalizeToolDeclarations } from "../tools.js";
3
+ function definition(tool) {
4
+ return {
5
+ name: tool.flatName, description: tool.description, input_schema: tool.parameters,
6
+ ...(tool.strict === undefined ? {} : { strict: tool.strict }),
7
+ };
8
+ }
9
+ /** Keep later definitions at their original conversation boundary. */
10
+ export function buildAnthropicTools(request, mode) {
11
+ if (mode === "top_level") {
12
+ const normalized = normalizeToolDeclarations(request);
13
+ const tools = buildToolSet(normalized.tools);
14
+ return { tools, definitions: tools.entries.map(definition), input: normalized.input };
15
+ }
16
+ const tools = buildToolSet(request.tools);
17
+ const initial = new Map(tools.entries.map((tool) => [tool.flatName, definition(tool)]));
18
+ if (!Array.isArray(request.input)) {
19
+ return { tools, definitions: [...initial.values()], input: request.input };
20
+ }
21
+ const input = [];
22
+ let prefix = true;
23
+ for (const item of request.input) {
24
+ if (item?.type === "additional_tools") {
25
+ if (!Array.isArray(item.tools))
26
+ throw new BridgeError("additional_tools requires a tools array.");
27
+ const definitions = tools.add(item.tools).map(definition);
28
+ if (prefix) {
29
+ for (const tool of definitions)
30
+ initial.set(tool.name, tool);
31
+ }
32
+ else if (definitions.length) {
33
+ input.push({ ...item, tools: definitions });
34
+ }
35
+ }
36
+ else {
37
+ input.push(item);
38
+ if (!item || !["system", "developer"].includes(item.role) ||
39
+ (item.type !== undefined && item.type !== "message"))
40
+ prefix = false;
41
+ }
42
+ }
43
+ return { tools, definitions: [...initial.values()], input };
44
+ }
@@ -4,5 +4,7 @@ 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[];
8
+ continuationBefore?: string;
7
9
  };
8
10
  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,25 +68,64 @@ 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
+ const text = "Continue.";
82
+ messages.push({ role: "user", content: [{ type: "text", text }] });
83
+ return text;
84
+ }
85
+ };
86
+ const flushAdditions = () => {
87
+ if (!additions.length)
88
+ return;
89
+ // A persisted declaration also anchors this boundary on later replays.
90
+ continueAssistant();
91
+ if (messages.at(-1)?.role !== "user") {
92
+ throw new BridgeError("Inline tool declarations require a user or completed tool-result boundary.");
93
+ }
94
+ messages.push({ role: "system", content: additions });
95
+ additions = [];
96
+ hasInlineTools = true;
97
+ };
71
98
  const append = (role, blocks) => {
72
99
  if (!blocks.length)
73
100
  return;
101
+ // Several reminders/results may share a user turn. Tool declarations take
102
+ // effect after that complete boundary, before the next assistant step.
103
+ if (role === "assistant")
104
+ flushAdditions();
74
105
  const last = messages.at(-1);
75
106
  if (last?.role === role)
76
107
  last.content.push(...blocks);
77
108
  else
78
109
  messages.push({ role, content: blocks });
79
110
  };
80
- for (const item of prepareHistory(request.input, route)) {
81
- if (item.type === "bridge_reasoning") {
111
+ for (const item of prepareHistory(input, route)) {
112
+ if (item.type === "additional_tools") {
113
+ additions.push(...item.tools.map((definition) => ({
114
+ type: "tool_addition", tool: { type: "tool_definition", definition },
115
+ })));
116
+ }
117
+ else if (item.type === "bridge_reasoning") {
82
118
  const block = item.state.data;
83
119
  if (!block || !["thinking", "redacted_thinking"].includes(block.type)) {
84
120
  throw new BridgeError("Invalid Messages reasoning carrier.");
85
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
+ }
86
129
  append("assistant", [block]);
87
130
  }
88
131
  else if (item.type === "function_call" || item.type === "custom_tool_call") {
@@ -117,9 +160,11 @@ export function buildAnthropicRequest(context) {
117
160
  else
118
161
  throw new BridgeError(`Unsupported Messages history item: ${String(item.type)}.`);
119
162
  }
120
- const definitions = tools.entries.map((tool) => ({
121
- name: tool.flatName, description: tool.description, input_schema: tool.parameters,
122
- }));
163
+ // A failed response can leave a complete thinking/text item in codeks history.
164
+ // Claude requires a user boundary to resume. Do not mask a missing tool result.
165
+ // Pending declarations already persist their own continuation boundary.
166
+ const continuationBefore = additions.length ? undefined : continueAssistant();
167
+ flushAdditions();
123
168
  // Keep stable prefixes at 1h. The one-off compaction suffix uses 5m.
124
169
  const cache = (block, ttl = "1h") => {
125
170
  if (block)
@@ -158,6 +203,18 @@ export function buildAnthropicRequest(context) {
158
203
  Object.assign(body, route.upstream.options, route.config.options, {
159
204
  model: route.config.model, messages, stream: true,
160
205
  });
206
+ // Native options remain authoritative. Modes such as between_tools do not
207
+ // support binding controls; do not change the caller's chosen thinking mode.
208
+ if (["adaptive", "enabled"].includes(body.thinking?.type) &&
209
+ settings.thinking_mismatch !== "upstream_default") {
210
+ body.thinking = {
211
+ ...body.thinking,
212
+ block_binding: {
213
+ prefix_mismatch_behavior: settings.thinking_mismatch,
214
+ ...body.thinking.block_binding,
215
+ },
216
+ };
217
+ }
161
218
  if (system.length)
162
219
  body.system = system;
163
220
  else
@@ -172,7 +229,7 @@ export function buildAnthropicRequest(context) {
172
229
  delete body.output_config.format;
173
230
  }
174
231
  }
175
- return { body, tools };
232
+ return { body, tools, betas: [THINKING_BINDING_BETA, ...(hasInlineTools ? [INLINE_TOOLS_BETA] : [])], continuationBefore };
176
233
  }
177
234
  function tokenCount(value) {
178
235
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
@@ -191,16 +248,18 @@ function validateUsage(value, required) {
191
248
  tokenCount(value.output_tokens_details.thinking_tokens);
192
249
  }
193
250
  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);
251
+ const { body, tools, betas, continuationBefore } = buildAnthropicRequest(context);
252
+ const response = await upstreamFetch(context.route, "/messages", body, context.signal, context.requestHeaders, { "anthropic-beta": betas });
196
253
  context.onAccepted?.(response.headers);
197
254
  if (!response.body)
198
255
  throw new BridgeError("Messages returned an empty stream.", "upstream_error", 502);
199
256
  const blocks = new Map();
257
+ let pendingContinuation = continuationBefore;
200
258
  let rawUsage = {};
201
259
  let started = false;
202
260
  let finalUsage = false;
203
261
  let stopReason;
262
+ let stopEvent;
204
263
  let stopped = false;
205
264
  for await (const { json: event } of parseJsonSse(response.body)) {
206
265
  if (event.type === "error")
@@ -219,7 +278,9 @@ export const runAnthropic = async (context, writer) => {
219
278
  if (!["text", "thinking", "redacted_thinking", "tool_use"].includes(value.type)) {
220
279
  throw new BridgeError(`Unsupported Messages output block: ${String(value.type)}.`);
221
280
  }
222
- 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;
223
284
  if (value.type === "text" && value.text)
224
285
  writer.textDelta(value.text);
225
286
  if (value.type === "thinking")
@@ -251,7 +312,13 @@ export const runAnthropic = async (context, writer) => {
251
312
  if (!block)
252
313
  throw new BridgeError("Messages stopped an unknown block.", "upstream_error", 502);
253
314
  if (block.value.type === "thinking" || block.value.type === "redacted_thinking") {
254
- 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));
255
322
  }
256
323
  else if (block.value.type === "tool_use") {
257
324
  if (block.json)
@@ -273,7 +340,10 @@ export const runAnthropic = async (context, writer) => {
273
340
  blocks.delete(event.index);
274
341
  }
275
342
  else if (event.type === "message_delta") {
276
- stopReason = event.delta?.stop_reason ?? stopReason;
343
+ if (event.delta?.stop_reason != null) {
344
+ stopReason = event.delta.stop_reason;
345
+ stopEvent = event;
346
+ }
277
347
  validateUsage(event.usage, ["output_tokens"]);
278
348
  finalUsage = true;
279
349
  rawUsage = { ...rawUsage, ...event.usage };
@@ -287,8 +357,14 @@ export const runAnthropic = async (context, writer) => {
287
357
  if (stopReason === "model_context_window_exceeded") {
288
358
  throw new BridgeError("Messages reached the model context window before completing the response.", "context_length_exceeded");
289
359
  }
290
- if (stopReason === "max_tokens" || stopReason === "refusal") {
291
- 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.");
292
368
  }
293
369
  if (!["end_turn", "stop_sequence", "tool_use", "pause_turn"].includes(stopReason)) {
294
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;
@@ -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
  }
@@ -28,6 +29,8 @@ export function validateConfig(value) {
28
29
  fail("port must be 0..65535");
29
30
  if (value.token_env !== undefined && (typeof value.token_env !== "string" || !value.token_env))
30
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");
31
34
  if (value.compaction_cache_dir !== undefined && (typeof value.compaction_cache_dir !== "string" || !value.compaction_cache_dir.trim()))
32
35
  fail("compaction_cache_dir must be a nonempty path");
33
36
  if (value.inspect !== undefined) {
@@ -65,6 +68,7 @@ export function validateConfig(value) {
65
68
  fail(`upstream ${name}: invalid headers`);
66
69
  if (up.options !== undefined && !object(up.options))
67
70
  fail(`upstream ${name}: options must be an object`);
71
+ validateAdapterConfig(up.adapters, `upstream ${name}`);
68
72
  }
69
73
  for (const [slug, model] of Object.entries(value.models)) {
70
74
  if (!slug || !object(model) || typeof model.model !== "string" || !model.model || typeof model.upstream !== "string" || !Object.hasOwn(value.upstreams, model.upstream))
@@ -91,6 +95,7 @@ export function validateConfig(value) {
91
95
  fail(`model ${slug}: effort_map must map effort names to upstream values`);
92
96
  }
93
97
  const profile = modelProfile(model);
98
+ validateAdapterConfig(model.adapters, `model ${slug}`, profile.id);
94
99
  if (Object.hasOwn(profile.effort_map, "ultra") && !profile.multi_agent_effort)
95
100
  fail(`model ${slug}: ultra must map to the same upstream effort as an ordinary level`);
96
101
  if (!Object.hasOwn(profile.effort_map, profile.default_effort))
@@ -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.
@@ -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
@@ -8,9 +8,12 @@ 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[];
15
+ /** Applies a declaration batch and returns detached snapshots of its definitions. */
16
+ add(tools: unknown[]): ToolEntry[];
14
17
  /** Upstream name -> tool. */
15
18
  byFlat: Map<string, ToolEntry>;
16
19
  /** Upstream name for a call found in history. */