@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 +12 -0
- package/README.md +1 -1
- package/dist/adapter-options.d.ts +5 -0
- package/dist/adapter-options.js +41 -0
- package/dist/backends/anthropic-tools.d.ts +14 -0
- package/dist/backends/anthropic-tools.js +44 -0
- package/dist/backends/anthropic.d.ts +2 -0
- package/dist/backends/anthropic.js +92 -16
- package/dist/backends/chat.js +4 -1
- package/dist/backends/responses.js +40 -18
- package/dist/config.js +5 -0
- package/dist/inspect/view.d.ts +1 -1
- package/dist/inspect/view.js +9 -7
- package/dist/server.js +7 -6
- package/dist/tool-history.js +2 -1
- package/dist/tools.d.ts +3 -0
- package/dist/tools.js +38 -24
- package/dist/types.d.ts +11 -0
- package/dist/upstream.d.ts +1 -1
- package/dist/upstream.js +7 -2
- package/docs/backends-anthropic.md +37 -1
- package/docs/compaction.md +5 -1
- package/docs/config.md +53 -10
- package/docs/design.md +6 -2
- package/docs/inspect.md +20 -0
- package/docs/reasoning.md +2 -0
- package/docs/responses-stream.md +2 -2
- package/docs/testing.md +6 -0
- package/docs/tools.md +18 -1
- package/examples/gpt-claude.json +27 -0
- package/package.json +1 -1
- package/ui/app.css +4 -0
- package/ui/detail.js +57 -7
- package/ui/events.js +25 -0
- package/ui/labels.js +5 -0
- package/ui/latency.js +88 -0
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#压缩模式与执行模型)
|
|
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 {
|
|
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 =
|
|
68
|
-
const
|
|
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(
|
|
81
|
-
if (item.type === "
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
291
|
-
|
|
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}.`);
|
package/dist/backends/chat.js
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
55
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
153
|
-
|
|
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))
|
package/dist/inspect/view.d.ts
CHANGED
|
@@ -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;
|
package/dist/inspect/view.js
CHANGED
|
@@ -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 (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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 >
|
|
36
|
-
throw new BridgeError(
|
|
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/tool-history.js
CHANGED
|
@@ -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) =>
|
|
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. */
|