@wacry/codeks-bridge 0.0.0-stage → 0.0.1
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/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +40 -2
- package/catalog/README.md +63 -0
- package/catalog/claude-opus-5-5.json +194 -0
- package/catalog/claude-sonnet-5-5.json +194 -0
- package/catalog/deepseek-flash.json +203 -0
- package/catalog/gpt-5.6-sol.json +152 -0
- package/catalog/gpt-6-astra.json +184 -0
- package/catalog/gpt-6-luna.json +178 -0
- package/catalog/gpt-6-sol.json +182 -0
- package/catalog/gpt-6.1-sol.json +187 -0
- package/dist/auth.d.ts +2 -0
- package/dist/auth.js +42 -0
- package/dist/backends/anthropic.d.ts +8 -0
- package/dist/backends/anthropic.js +309 -0
- package/dist/backends/chat.d.ts +16 -0
- package/dist/backends/chat.js +284 -0
- package/dist/backends/deepseek.d.ts +6 -0
- package/dist/backends/deepseek.js +37 -0
- package/dist/backends/responses.d.ts +5 -0
- package/dist/backends/responses.js +205 -0
- package/dist/carrier.d.ts +22 -0
- package/dist/carrier.js +61 -0
- package/dist/catalog.d.ts +8 -0
- package/dist/catalog.js +40 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +64 -0
- package/dist/compaction-handoff.d.ts +13 -0
- package/dist/compaction-handoff.js +126 -0
- package/dist/compaction-history.d.ts +7 -0
- package/dist/compaction-history.js +20 -0
- package/dist/compaction.d.ts +20 -0
- package/dist/compaction.js +134 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +167 -0
- package/dist/errors.d.ts +43 -0
- package/dist/errors.js +141 -0
- package/dist/headers.d.ts +6 -0
- package/dist/headers.js +39 -0
- package/dist/history.d.ts +7 -0
- package/dist/history.js +85 -0
- package/dist/ids.d.ts +1 -0
- package/dist/ids.js +4 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/inspect/capture.d.ts +119 -0
- package/dist/inspect/capture.js +370 -0
- package/dist/inspect/http.d.ts +7 -0
- package/dist/inspect/http.js +149 -0
- package/dist/inspect/view.d.ts +239 -0
- package/dist/inspect/view.js +388 -0
- package/dist/model-catalog.d.ts +18 -0
- package/dist/model-catalog.js +16 -0
- package/dist/profiles.d.ts +6 -0
- package/dist/profiles.js +63 -0
- package/dist/prompts.d.ts +5 -0
- package/dist/prompts.js +118 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +232 -0
- package/dist/sse.d.ts +12 -0
- package/dist/sse.js +93 -0
- package/dist/tool-history.d.ts +4 -0
- package/dist/tool-history.js +72 -0
- package/dist/tools.d.ts +33 -0
- package/dist/tools.js +182 -0
- package/dist/types.d.ts +115 -0
- package/dist/types.js +1 -0
- package/dist/upstream.d.ts +3 -0
- package/dist/upstream.js +84 -0
- package/dist/writer.d.ts +95 -0
- package/dist/writer.js +430 -0
- package/docs/backends-anthropic.md +61 -0
- package/docs/backends-deepseek-chat.md +75 -0
- package/docs/backends-responses.md +33 -0
- package/docs/compaction.md +176 -0
- package/docs/config.md +231 -0
- package/docs/design.md +93 -0
- package/docs/inspect.md +138 -0
- package/docs/reasoning.md +72 -0
- package/docs/releasing.md +50 -0
- package/docs/responses-stream.md +90 -0
- package/docs/testing.md +177 -0
- package/docs/tools.md +55 -0
- package/examples/config.json +35 -0
- package/package.json +46 -4
- package/ui/api.js +64 -0
- package/ui/app.css +375 -0
- package/ui/app.js +489 -0
- package/ui/detail.js +682 -0
- package/ui/diff.js +281 -0
- package/ui/dom.js +258 -0
- package/ui/events.js +351 -0
- package/ui/favicon.svg +1 -0
- package/ui/index.html +58 -0
- package/ui/labels.js +101 -0
- package/ui/list.js +180 -0
- package/ui/structure.js +285 -0
- package/ui/tree.js +158 -0
package/dist/auth.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { BridgeError } from "./errors.js";
|
|
4
|
+
import { forwardRequestHeaders } from "./headers.js";
|
|
5
|
+
const execute = promisify(execFile);
|
|
6
|
+
export async function upstreamHeaders(config, api, signal, clientHeaders) {
|
|
7
|
+
const headers = forwardRequestHeaders(clientHeaders, config.auth_header);
|
|
8
|
+
for (const [name, value] of Object.entries(config.headers ?? {}))
|
|
9
|
+
headers.set(name, value);
|
|
10
|
+
if (!headers.has("content-type"))
|
|
11
|
+
headers.set("content-type", "application/json");
|
|
12
|
+
if (api === "anthropic" && !headers.has("anthropic-version"))
|
|
13
|
+
headers.set("anthropic-version", "2023-06-01");
|
|
14
|
+
let credential;
|
|
15
|
+
if (config.api_key_env) {
|
|
16
|
+
credential = process.env[config.api_key_env]?.trim();
|
|
17
|
+
if (!credential)
|
|
18
|
+
throw new BridgeError(`Missing credential environment variable: ${config.api_key_env}`);
|
|
19
|
+
}
|
|
20
|
+
else if (config.api_key_command) {
|
|
21
|
+
const [command, ...args] = config.api_key_command;
|
|
22
|
+
try {
|
|
23
|
+
const result = await execute(command, args, { encoding: "utf8", timeout: 30_000, maxBuffer: 64 * 1024, signal });
|
|
24
|
+
credential = result.stdout.trim();
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
if (signal.aborted)
|
|
28
|
+
throw signal.reason;
|
|
29
|
+
// execFile errors include stdout/stderr. Never forward those to a client.
|
|
30
|
+
throw new BridgeError("Credential command failed");
|
|
31
|
+
}
|
|
32
|
+
if (!credential)
|
|
33
|
+
throw new BridgeError("Credential command returned an empty credential");
|
|
34
|
+
}
|
|
35
|
+
if (credential) {
|
|
36
|
+
if (/[\r\n]/.test(credential))
|
|
37
|
+
throw new BridgeError("Credential must be a single line");
|
|
38
|
+
const header = config.auth_header ?? (api === "anthropic" ? "x-api-key" : "authorization");
|
|
39
|
+
headers.set(header, header.toLowerCase() === "authorization" ? `Bearer ${credential}` : credential);
|
|
40
|
+
}
|
|
41
|
+
return headers;
|
|
42
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ToolSet } from "../tools.ts";
|
|
2
|
+
import type { Backend, BackendContext } from "../types.ts";
|
|
3
|
+
/** Convert only request-owned copies; stored history remains unchanged. */
|
|
4
|
+
export declare function buildAnthropicRequest(context: BackendContext): {
|
|
5
|
+
body: Record<string, any>;
|
|
6
|
+
tools: ToolSet;
|
|
7
|
+
};
|
|
8
|
+
export declare const runAnthropic: Backend;
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { encodeReasoning } from "../carrier.js";
|
|
2
|
+
import { BridgeError, upstreamError } from "../errors.js";
|
|
3
|
+
import { prepareHistory } from "../history.js";
|
|
4
|
+
import { reasoningEffort, reasoningSummaryRequested } from "../profiles.js";
|
|
5
|
+
import { parseJsonSse } from "../sse.js";
|
|
6
|
+
import { buildToolSet, normalizeToolDeclarations, safeCallId } from "../tools.js";
|
|
7
|
+
import { upstreamFetch } from "../upstream.js";
|
|
8
|
+
function contentBlocks(content) {
|
|
9
|
+
if (typeof content === "string")
|
|
10
|
+
return content ? [{ type: "text", text: content }] : [];
|
|
11
|
+
if (!Array.isArray(content))
|
|
12
|
+
throw new BridgeError("Messages content must be text or an array.");
|
|
13
|
+
return content.flatMap((part) => {
|
|
14
|
+
if (["input_text", "output_text", "text"].includes(part.type)) {
|
|
15
|
+
return part.text ? [{ type: "text", text: part.text }] : [];
|
|
16
|
+
}
|
|
17
|
+
if (part.type === "input_image" || part.type === "image_url") {
|
|
18
|
+
const url = typeof part.image_url === "string" ? part.image_url : part.image_url?.url;
|
|
19
|
+
if (typeof url !== "string")
|
|
20
|
+
throw new BridgeError("An image needs an image_url.");
|
|
21
|
+
const data = /^data:(image\/(?:jpeg|png|gif|webp));base64,([\s\S]+)$/.exec(url);
|
|
22
|
+
if (data)
|
|
23
|
+
return [{ type: "image", source: { type: "base64", media_type: data[1], data: data[2] } }];
|
|
24
|
+
if (/^https?:\/\//.test(url))
|
|
25
|
+
return [{ type: "image", source: { type: "url", url } }];
|
|
26
|
+
throw new BridgeError("Messages images must use an HTTP URL or a JPEG, PNG, GIF, or WebP base64 data URL.");
|
|
27
|
+
}
|
|
28
|
+
if (part.type === "refusal")
|
|
29
|
+
return [{ type: "text", text: String(part.refusal ?? "") }];
|
|
30
|
+
throw new BridgeError(`Unsupported Messages content type: ${String(part.type)}.`);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function parseArguments(value) {
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = typeof value === "string" ? JSON.parse(value) : value;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new BridgeError("A tool call contains invalid JSON arguments.");
|
|
40
|
+
}
|
|
41
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
42
|
+
throw new BridgeError("Messages tool arguments must be an object.");
|
|
43
|
+
}
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
function toolChoice(value, tools) {
|
|
47
|
+
if (value === undefined)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (value === "auto")
|
|
50
|
+
return { type: "auto" };
|
|
51
|
+
if (value === "none")
|
|
52
|
+
return { type: "none" };
|
|
53
|
+
if (value === "required")
|
|
54
|
+
return { type: "any" };
|
|
55
|
+
if (value && ["function", "custom"].includes(value.type) && typeof value.name === "string") {
|
|
56
|
+
const selected = tools.get(value.namespace, value.name);
|
|
57
|
+
if (!selected || selected.kind !== value.type) {
|
|
58
|
+
throw new BridgeError("The selected tool is not available on the Messages backend.");
|
|
59
|
+
}
|
|
60
|
+
return { type: "tool", name: selected.flatName };
|
|
61
|
+
}
|
|
62
|
+
throw new BridgeError("Unsupported Messages tool_choice.");
|
|
63
|
+
}
|
|
64
|
+
/** Convert only request-owned copies; stored history remains unchanged. */
|
|
65
|
+
export function buildAnthropicRequest(context) {
|
|
66
|
+
const { route } = context;
|
|
67
|
+
const request = normalizeToolDeclarations(context.request);
|
|
68
|
+
const tools = buildToolSet(request.tools);
|
|
69
|
+
const system = request.instructions ? [{ type: "text", text: request.instructions }] : [];
|
|
70
|
+
const messages = [];
|
|
71
|
+
const append = (role, blocks) => {
|
|
72
|
+
if (!blocks.length)
|
|
73
|
+
return;
|
|
74
|
+
const last = messages.at(-1);
|
|
75
|
+
if (last?.role === role)
|
|
76
|
+
last.content.push(...blocks);
|
|
77
|
+
else
|
|
78
|
+
messages.push({ role, content: blocks });
|
|
79
|
+
};
|
|
80
|
+
for (const item of prepareHistory(request.input, route)) {
|
|
81
|
+
if (item.type === "bridge_reasoning") {
|
|
82
|
+
const block = item.state.data;
|
|
83
|
+
if (!block || !["thinking", "redacted_thinking"].includes(block.type)) {
|
|
84
|
+
throw new BridgeError("Invalid Messages reasoning carrier.");
|
|
85
|
+
}
|
|
86
|
+
append("assistant", [block]);
|
|
87
|
+
}
|
|
88
|
+
else if (item.type === "function_call" || item.type === "custom_tool_call") {
|
|
89
|
+
append("assistant", [{
|
|
90
|
+
type: "tool_use", id: safeCallId(item.call_id), name: tools.flatFor(item.namespace, item.name),
|
|
91
|
+
input: item.type === "custom_tool_call" ? { input: item.input } : parseArguments(item.arguments),
|
|
92
|
+
}]);
|
|
93
|
+
}
|
|
94
|
+
else if (item.type === "function_call_output" || item.type === "custom_tool_call_output") {
|
|
95
|
+
const content = contentBlocks(item.output);
|
|
96
|
+
append("user", [{ type: "tool_result", tool_use_id: safeCallId(item.call_id), ...(content.length ? { content } : {}) }]);
|
|
97
|
+
}
|
|
98
|
+
else if (item.type === "message" || item.role) {
|
|
99
|
+
const blocks = contentBlocks(item.content);
|
|
100
|
+
if (item.role === "developer" || item.role === "system") {
|
|
101
|
+
if (blocks.some((block) => block.type !== "text"))
|
|
102
|
+
throw new BridgeError("System messages must contain text.");
|
|
103
|
+
if (!messages.length)
|
|
104
|
+
system.push(...blocks);
|
|
105
|
+
// Inline system roles have placement restrictions that arbitrary Responses
|
|
106
|
+
// history cannot satisfy. Keep reminders in order without rewriting a
|
|
107
|
+
// signed assistant prefix or moving new instructions to the beginning.
|
|
108
|
+
else
|
|
109
|
+
append("user", [{ type: "text", text: `<system-reminder>\n${blocks.map((block) => block.text).join("\n\n")}\n</system-reminder>` }]);
|
|
110
|
+
}
|
|
111
|
+
else if (item.role === "assistant" || item.role === "user") {
|
|
112
|
+
append(item.role, blocks);
|
|
113
|
+
}
|
|
114
|
+
else
|
|
115
|
+
throw new BridgeError(`Unsupported Messages role: ${String(item.role)}.`);
|
|
116
|
+
}
|
|
117
|
+
else
|
|
118
|
+
throw new BridgeError(`Unsupported Messages history item: ${String(item.type)}.`);
|
|
119
|
+
}
|
|
120
|
+
const definitions = tools.entries.map((tool) => ({
|
|
121
|
+
name: tool.flatName, description: tool.description, input_schema: tool.parameters,
|
|
122
|
+
}));
|
|
123
|
+
// Keep stable prefixes at 1h. The one-off compaction suffix uses 5m.
|
|
124
|
+
const cache = (block, ttl = "1h") => {
|
|
125
|
+
if (block)
|
|
126
|
+
block.cache_control = { type: "ephemeral", ttl };
|
|
127
|
+
};
|
|
128
|
+
cache(definitions.at(-1));
|
|
129
|
+
cache(system.at(-1));
|
|
130
|
+
const userMessages = messages.filter((message) => message.role === "user");
|
|
131
|
+
for (const message of userMessages.slice(-2)) {
|
|
132
|
+
cache(message.content.at(-1), context.compacting && message === userMessages.at(-1) ? "5m" : "1h");
|
|
133
|
+
}
|
|
134
|
+
const effort = reasoningEffort(route, request.reasoning?.effort);
|
|
135
|
+
const body = {
|
|
136
|
+
thinking: effort === "none" ? { type: "disabled" } : {
|
|
137
|
+
type: "adaptive", display: reasoningSummaryRequested(request) ? "summarized" : "omitted",
|
|
138
|
+
},
|
|
139
|
+
...(effort !== "none" ? { output_config: { effort } } : {}),
|
|
140
|
+
max_tokens: request.max_output_tokens ?? route.profile.max_output_tokens,
|
|
141
|
+
};
|
|
142
|
+
for (const name of ["temperature", "top_p"]) {
|
|
143
|
+
if (request[name] !== undefined)
|
|
144
|
+
body[name] = request[name];
|
|
145
|
+
}
|
|
146
|
+
let choice = toolChoice(request.tool_choice, tools);
|
|
147
|
+
if (tools.entries.length && typeof request.parallel_tool_calls === "boolean" && choice?.type !== "none") {
|
|
148
|
+
choice = { ...(choice ?? { type: "auto" }), disable_parallel_tool_use: !request.parallel_tool_calls };
|
|
149
|
+
}
|
|
150
|
+
if (choice)
|
|
151
|
+
body.tool_choice = choice;
|
|
152
|
+
const format = request.text?.format;
|
|
153
|
+
if (format !== undefined && format.type !== "text") {
|
|
154
|
+
if (format.type !== "json_schema")
|
|
155
|
+
throw new BridgeError(`Messages does not support text format ${String(format.type)}.`);
|
|
156
|
+
body.output_config = { ...body.output_config, format: { type: "json_schema", schema: format.schema } };
|
|
157
|
+
}
|
|
158
|
+
Object.assign(body, route.upstream.options, route.config.options, {
|
|
159
|
+
model: route.config.model, messages, stream: true,
|
|
160
|
+
});
|
|
161
|
+
if (system.length)
|
|
162
|
+
body.system = system;
|
|
163
|
+
else
|
|
164
|
+
delete body.system;
|
|
165
|
+
if (definitions.length)
|
|
166
|
+
body.tools = definitions;
|
|
167
|
+
else
|
|
168
|
+
delete body.tools;
|
|
169
|
+
if (context.compacting) {
|
|
170
|
+
if (body.output_config) {
|
|
171
|
+
body.output_config = { ...body.output_config };
|
|
172
|
+
delete body.output_config.format;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return { body, tools };
|
|
176
|
+
}
|
|
177
|
+
function tokenCount(value) {
|
|
178
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
179
|
+
throw new BridgeError("Messages stream has invalid or missing token usage.", "upstream_error", 502);
|
|
180
|
+
}
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
function validateUsage(value, required) {
|
|
184
|
+
for (const field of required)
|
|
185
|
+
tokenCount(value?.[field]);
|
|
186
|
+
for (const field of ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"]) {
|
|
187
|
+
if (value?.[field] !== undefined)
|
|
188
|
+
tokenCount(value[field]);
|
|
189
|
+
}
|
|
190
|
+
if (value?.output_tokens_details?.thinking_tokens !== undefined)
|
|
191
|
+
tokenCount(value.output_tokens_details.thinking_tokens);
|
|
192
|
+
}
|
|
193
|
+
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);
|
|
196
|
+
context.onAccepted?.(response.headers);
|
|
197
|
+
if (!response.body)
|
|
198
|
+
throw new BridgeError("Messages returned an empty stream.", "upstream_error", 502);
|
|
199
|
+
const blocks = new Map();
|
|
200
|
+
let rawUsage = {};
|
|
201
|
+
let started = false;
|
|
202
|
+
let finalUsage = false;
|
|
203
|
+
let stopReason;
|
|
204
|
+
let stopped = false;
|
|
205
|
+
for await (const { json: event } of parseJsonSse(response.body)) {
|
|
206
|
+
if (event.type === "error")
|
|
207
|
+
throw upstreamError(undefined, event, response.headers);
|
|
208
|
+
if (event.type === "ping") {
|
|
209
|
+
writer.keepalive();
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (event.type === "message_start") {
|
|
213
|
+
validateUsage(event.message?.usage, ["input_tokens", "output_tokens"]);
|
|
214
|
+
started = true;
|
|
215
|
+
rawUsage = { ...rawUsage, ...event.message?.usage };
|
|
216
|
+
}
|
|
217
|
+
else if (event.type === "content_block_start") {
|
|
218
|
+
const value = { ...event.content_block };
|
|
219
|
+
if (!["text", "thinking", "redacted_thinking", "tool_use"].includes(value.type)) {
|
|
220
|
+
throw new BridgeError(`Unsupported Messages output block: ${String(value.type)}.`);
|
|
221
|
+
}
|
|
222
|
+
blocks.set(event.index, { value, json: "" });
|
|
223
|
+
if (value.type === "text" && value.text)
|
|
224
|
+
writer.textDelta(value.text);
|
|
225
|
+
if (value.type === "thinking")
|
|
226
|
+
writer.reasoningDelta(value.thinking ?? "");
|
|
227
|
+
}
|
|
228
|
+
else if (event.type === "content_block_delta") {
|
|
229
|
+
const block = blocks.get(event.index);
|
|
230
|
+
if (!block)
|
|
231
|
+
throw new BridgeError("Messages sent a delta before its block.", "upstream_error", 502);
|
|
232
|
+
const delta = event.delta;
|
|
233
|
+
if (delta.type === "text_delta") {
|
|
234
|
+
block.value.text = (block.value.text ?? "") + delta.text;
|
|
235
|
+
writer.textDelta(delta.text);
|
|
236
|
+
}
|
|
237
|
+
else if (delta.type === "thinking_delta") {
|
|
238
|
+
block.value.thinking = (block.value.thinking ?? "") + delta.thinking;
|
|
239
|
+
writer.reasoningDelta(delta.thinking);
|
|
240
|
+
}
|
|
241
|
+
else if (delta.type === "signature_delta") {
|
|
242
|
+
block.value.signature = (block.value.signature ?? "") + delta.signature;
|
|
243
|
+
}
|
|
244
|
+
else if (delta.type === "input_json_delta")
|
|
245
|
+
block.json += delta.partial_json;
|
|
246
|
+
else if (delta.type !== "citations_delta")
|
|
247
|
+
throw new BridgeError(`Unsupported Messages delta: ${String(delta.type)}.`);
|
|
248
|
+
}
|
|
249
|
+
else if (event.type === "content_block_stop") {
|
|
250
|
+
const block = blocks.get(event.index);
|
|
251
|
+
if (!block)
|
|
252
|
+
throw new BridgeError("Messages stopped an unknown block.", "upstream_error", 502);
|
|
253
|
+
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 }));
|
|
255
|
+
}
|
|
256
|
+
else if (block.value.type === "tool_use") {
|
|
257
|
+
if (block.json)
|
|
258
|
+
block.value.input = parseArguments(block.json);
|
|
259
|
+
const call = block.value;
|
|
260
|
+
const tool = tools.resolve(call.name);
|
|
261
|
+
if (!tool)
|
|
262
|
+
throw new BridgeError(`Messages called an unknown tool: ${String(call.name)}.`);
|
|
263
|
+
if (tool.kind === "custom") {
|
|
264
|
+
if (typeof call.input?.input !== "string")
|
|
265
|
+
throw new BridgeError("A custom tool call needs a string input.");
|
|
266
|
+
writer.addCustomToolCall({ call_id: call.id, name: tool.name, namespace: tool.namespace, input: call.input.input });
|
|
267
|
+
}
|
|
268
|
+
else
|
|
269
|
+
writer.addFunctionCall({ call_id: call.id, name: tool.name, namespace: tool.namespace, arguments: JSON.stringify(call.input ?? {}) });
|
|
270
|
+
}
|
|
271
|
+
else if (block.value.type === "text")
|
|
272
|
+
writer.closeMessage();
|
|
273
|
+
blocks.delete(event.index);
|
|
274
|
+
}
|
|
275
|
+
else if (event.type === "message_delta") {
|
|
276
|
+
stopReason = event.delta?.stop_reason ?? stopReason;
|
|
277
|
+
validateUsage(event.usage, ["output_tokens"]);
|
|
278
|
+
finalUsage = true;
|
|
279
|
+
rawUsage = { ...rawUsage, ...event.usage };
|
|
280
|
+
}
|
|
281
|
+
else if (event.type === "message_stop")
|
|
282
|
+
stopped = true;
|
|
283
|
+
}
|
|
284
|
+
if (!started || !stopped || blocks.size || !stopReason) {
|
|
285
|
+
throw new BridgeError("Messages stream ended before the message was complete.", "upstream_error", 502);
|
|
286
|
+
}
|
|
287
|
+
if (stopReason === "model_context_window_exceeded") {
|
|
288
|
+
throw new BridgeError("Messages reached the model context window before completing the response.", "context_length_exceeded");
|
|
289
|
+
}
|
|
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.");
|
|
292
|
+
}
|
|
293
|
+
if (!["end_turn", "stop_sequence", "tool_use", "pause_turn"].includes(stopReason)) {
|
|
294
|
+
throw new BridgeError(`Unsupported Messages stop reason: ${stopReason}.`);
|
|
295
|
+
}
|
|
296
|
+
if (!finalUsage)
|
|
297
|
+
throw new BridgeError("Messages stream ended without final token usage.", "upstream_error", 502);
|
|
298
|
+
const usage = {
|
|
299
|
+
input_tokens: tokenCount(rawUsage.input_tokens + (rawUsage.cache_read_input_tokens ?? 0) + (rawUsage.cache_creation_input_tokens ?? 0)),
|
|
300
|
+
cached_tokens: rawUsage.cache_read_input_tokens ?? 0,
|
|
301
|
+
cache_write_tokens: rawUsage.cache_creation_input_tokens ?? 0,
|
|
302
|
+
output_tokens: tokenCount(rawUsage.output_tokens),
|
|
303
|
+
...(rawUsage.output_tokens_details?.thinking_tokens != null ? { reasoning_tokens: rawUsage.output_tokens_details.thinking_tokens } : {}),
|
|
304
|
+
};
|
|
305
|
+
return {
|
|
306
|
+
usage,
|
|
307
|
+
endTurn: stopReason !== "tool_use" && stopReason !== "pause_turn",
|
|
308
|
+
};
|
|
309
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { BridgeError } from "../errors.ts";
|
|
2
|
+
import type { ToolSet } from "../tools.ts";
|
|
3
|
+
import type { BackendContext, BackendResult } from "../types.ts";
|
|
4
|
+
import type { ResponseWriter, Usage } from "../writer.ts";
|
|
5
|
+
/** A wire dialect supplies only the parts that differ from Chat Completions. */
|
|
6
|
+
export interface ChatDialect {
|
|
7
|
+
reasoningField: string;
|
|
8
|
+
requestParameters(context: BackendContext): Record<string, unknown>;
|
|
9
|
+
readUsage(value: any): Usage;
|
|
10
|
+
failureReason(reason: string): BridgeError | undefined;
|
|
11
|
+
}
|
|
12
|
+
export declare function buildChatRequest(context: BackendContext, dialect: ChatDialect): {
|
|
13
|
+
body: Record<string, any>;
|
|
14
|
+
tools: ToolSet;
|
|
15
|
+
};
|
|
16
|
+
export declare function runChat(context: BackendContext, writer: ResponseWriter, dialect: ChatDialect): Promise<BackendResult>;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { encodeReasoning } from "../carrier.js";
|
|
2
|
+
import { BridgeError, transportError, upstreamError } from "../errors.js";
|
|
3
|
+
import { prepareHistory } from "../history.js";
|
|
4
|
+
import { parseJsonSse } from "../sse.js";
|
|
5
|
+
import { buildToolSet, normalizeToolDeclarations } from "../tools.js";
|
|
6
|
+
import { upstreamFetch } from "../upstream.js";
|
|
7
|
+
function contentParts(content) {
|
|
8
|
+
if (typeof content === "string")
|
|
9
|
+
return [{ type: "text", text: content }];
|
|
10
|
+
if (!Array.isArray(content))
|
|
11
|
+
throw new BridgeError("Chat message content must be text or a content array.");
|
|
12
|
+
return content.map((part) => {
|
|
13
|
+
if (["input_text", "output_text", "text"].includes(part?.type) && typeof part.text === "string") {
|
|
14
|
+
return { type: "text", text: part.text };
|
|
15
|
+
}
|
|
16
|
+
if (part?.type === "refusal" && typeof part.refusal === "string")
|
|
17
|
+
return { type: "text", text: part.refusal };
|
|
18
|
+
if (part?.type === "input_image" && typeof part.image_url === "string") {
|
|
19
|
+
if (!/^(https?:\/\/|data:image\/)/.test(part.image_url)) {
|
|
20
|
+
throw new BridgeError("Chat images require an HTTP(S) URL or an image data URL.");
|
|
21
|
+
}
|
|
22
|
+
return { type: "image_url", image_url: {
|
|
23
|
+
url: part.image_url,
|
|
24
|
+
...(part.detail ? { detail: part.detail === "original" ? "high" : part.detail } : {}),
|
|
25
|
+
} };
|
|
26
|
+
}
|
|
27
|
+
throw new BridgeError(`Chat does not support content part ${String(part?.type)}.`);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function messageContent(parts) {
|
|
31
|
+
return parts.every((p) => p.type === "text") ? parts.map((p) => p.text).join("\n") : parts;
|
|
32
|
+
}
|
|
33
|
+
function convertHistory(context, tools, reasoningField) {
|
|
34
|
+
const messages = [];
|
|
35
|
+
let assistant;
|
|
36
|
+
let pendingImages = [];
|
|
37
|
+
const flushAssistant = () => {
|
|
38
|
+
if (assistant)
|
|
39
|
+
messages.push(assistant);
|
|
40
|
+
assistant = undefined;
|
|
41
|
+
};
|
|
42
|
+
const flushImages = () => {
|
|
43
|
+
if (pendingImages.length)
|
|
44
|
+
messages.push({ role: "user", content: pendingImages });
|
|
45
|
+
pendingImages = [];
|
|
46
|
+
};
|
|
47
|
+
const getAssistant = () => assistant ??= { role: "assistant", content: "", [reasoningField]: "" };
|
|
48
|
+
if (context.request.instructions)
|
|
49
|
+
messages.push({ role: "system", content: context.request.instructions });
|
|
50
|
+
for (const item of prepareHistory(context.request.input, context.route)) {
|
|
51
|
+
if (item.type === "bridge_reasoning") {
|
|
52
|
+
flushImages();
|
|
53
|
+
// A reasoning item starts the next assistant response, even if the
|
|
54
|
+
// previous response ended without a tool or user message.
|
|
55
|
+
flushAssistant();
|
|
56
|
+
if (typeof item.state.data !== "string")
|
|
57
|
+
throw new BridgeError("Invalid Chat reasoning data.");
|
|
58
|
+
getAssistant()[reasoningField] = item.state.data;
|
|
59
|
+
}
|
|
60
|
+
else if (item.type === "message" || (!item.type && item.role)) {
|
|
61
|
+
const parts = contentParts(item.content);
|
|
62
|
+
if (item.role === "assistant") {
|
|
63
|
+
flushImages();
|
|
64
|
+
if (parts.some((p) => p.type !== "text"))
|
|
65
|
+
throw new BridgeError("Chat assistant history cannot contain images.");
|
|
66
|
+
const a = getAssistant();
|
|
67
|
+
const text = messageContent(parts);
|
|
68
|
+
a.content += (a.content && text ? "\n" : "") + text;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
flushAssistant();
|
|
72
|
+
flushImages();
|
|
73
|
+
if (!["system", "developer", "user"].includes(item.role)) {
|
|
74
|
+
throw new BridgeError(`Chat does not support message role ${String(item.role)}.`);
|
|
75
|
+
}
|
|
76
|
+
if (item.role !== "user" && parts.some((p) => p.type !== "text")) {
|
|
77
|
+
throw new BridgeError("Chat system and developer messages must contain text only.");
|
|
78
|
+
}
|
|
79
|
+
messages.push({ role: item.role === "developer" ? "system" : item.role, content: messageContent(parts) });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else if (item.type === "function_call" || item.type === "custom_tool_call") {
|
|
83
|
+
flushImages();
|
|
84
|
+
if (typeof item.call_id !== "string" || typeof item.name !== "string")
|
|
85
|
+
throw new BridgeError("Invalid Chat tool call history.");
|
|
86
|
+
const a = getAssistant();
|
|
87
|
+
a.tool_calls ??= [];
|
|
88
|
+
a.tool_calls.push({
|
|
89
|
+
id: item.call_id, type: "function", function: {
|
|
90
|
+
name: tools.flatFor(item.namespace, item.name),
|
|
91
|
+
arguments: item.type === "custom_tool_call" ? JSON.stringify({ input: item.input }) : item.arguments,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
else if (item.type === "function_call_output" || item.type === "custom_tool_call_output") {
|
|
96
|
+
flushAssistant();
|
|
97
|
+
if (typeof item.call_id !== "string")
|
|
98
|
+
throw new BridgeError("Chat tool output has no call_id.");
|
|
99
|
+
const parts = contentParts(item.output);
|
|
100
|
+
const images = parts.filter((p) => p.type === "image_url");
|
|
101
|
+
messages.push({ role: "tool", tool_call_id: item.call_id, content: messageContent(parts.filter((p) => p.type === "text")) });
|
|
102
|
+
if (images.length) {
|
|
103
|
+
pendingImages.push({ type: "text", text: `Images from tool call ${item.call_id}:` }, ...images);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
throw new BridgeError(`Chat does not support history item ${String(item.type)}.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
flushAssistant();
|
|
111
|
+
flushImages();
|
|
112
|
+
return messages;
|
|
113
|
+
}
|
|
114
|
+
function toolChoice(choice, tools) {
|
|
115
|
+
if (choice === undefined)
|
|
116
|
+
return undefined;
|
|
117
|
+
if (["auto", "none", "required"].includes(choice))
|
|
118
|
+
return choice;
|
|
119
|
+
if (choice && typeof choice === "object") {
|
|
120
|
+
const selected = choice;
|
|
121
|
+
if (["function", "custom"].includes(selected.type) && typeof selected.name === "string") {
|
|
122
|
+
const tool = tools.get(selected.namespace, selected.name);
|
|
123
|
+
if (!tool || tool.kind !== selected.type) {
|
|
124
|
+
throw new BridgeError("The selected tool is not available on the Chat backend.");
|
|
125
|
+
}
|
|
126
|
+
return { type: "function", function: { name: tool.flatName } };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
throw new BridgeError("Chat does not support this tool_choice.");
|
|
130
|
+
}
|
|
131
|
+
export function buildChatRequest(context, dialect) {
|
|
132
|
+
context = { ...context, request: normalizeToolDeclarations(context.request) };
|
|
133
|
+
const { request, route } = context;
|
|
134
|
+
const tools = buildToolSet(request.tools);
|
|
135
|
+
const body = { ...dialect.requestParameters(context) };
|
|
136
|
+
const limit = request.max_output_tokens ?? route.profile.max_output_tokens;
|
|
137
|
+
if (limit !== undefined)
|
|
138
|
+
body.max_tokens = limit;
|
|
139
|
+
for (const name of ["temperature", "top_p", "parallel_tool_calls"]) {
|
|
140
|
+
if (request[name] !== undefined)
|
|
141
|
+
body[name] = request[name];
|
|
142
|
+
}
|
|
143
|
+
const choice = toolChoice(request.tool_choice, tools);
|
|
144
|
+
if (choice !== undefined)
|
|
145
|
+
body.tool_choice = choice;
|
|
146
|
+
const format = request.text?.format;
|
|
147
|
+
if (format?.type === "json_object")
|
|
148
|
+
body.response_format = { type: "json_object" };
|
|
149
|
+
else if (format?.type === "json_schema") {
|
|
150
|
+
body.response_format = { type: "json_schema", json_schema: { name: format.name, schema: format.schema, strict: format.strict } };
|
|
151
|
+
}
|
|
152
|
+
else if (format && format.type !== "text")
|
|
153
|
+
throw new BridgeError(`Chat does not support text format ${String(format.type)}.`);
|
|
154
|
+
Object.assign(body, route.upstream.options, route.config.options, {
|
|
155
|
+
model: route.config.model,
|
|
156
|
+
messages: convertHistory(context, tools, dialect.reasoningField),
|
|
157
|
+
stream: true,
|
|
158
|
+
});
|
|
159
|
+
body.stream_options = { ...body.stream_options, include_usage: true };
|
|
160
|
+
if (tools.entries.length)
|
|
161
|
+
body.tools = tools.entries.map((tool) => ({
|
|
162
|
+
type: "function", function: { name: tool.flatName, description: tool.description, parameters: tool.parameters },
|
|
163
|
+
}));
|
|
164
|
+
else
|
|
165
|
+
delete body.tools;
|
|
166
|
+
if (context.compacting) {
|
|
167
|
+
delete body.response_format;
|
|
168
|
+
}
|
|
169
|
+
return { body, tools };
|
|
170
|
+
}
|
|
171
|
+
async function* chatEvents(body, signal) {
|
|
172
|
+
try {
|
|
173
|
+
yield* parseJsonSse(body);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (signal.aborted)
|
|
177
|
+
throw signal.reason;
|
|
178
|
+
throw transportError(error);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export async function runChat(context, writer, dialect) {
|
|
182
|
+
const { body, tools } = buildChatRequest(context, dialect);
|
|
183
|
+
const { reasoningField } = dialect;
|
|
184
|
+
const response = await upstreamFetch(context.route, "chat/completions", body, context.signal, context.requestHeaders);
|
|
185
|
+
context.onAccepted?.(response.headers);
|
|
186
|
+
if (!response.body)
|
|
187
|
+
throw new BridgeError("Chat upstream returned no stream.", "upstream_error", 502);
|
|
188
|
+
let reasoning = "";
|
|
189
|
+
let reasoningSeen = false;
|
|
190
|
+
let reasoningClosed = false;
|
|
191
|
+
let finish;
|
|
192
|
+
let usage;
|
|
193
|
+
let visible = false;
|
|
194
|
+
const calls = new Map();
|
|
195
|
+
const closeReasoning = () => {
|
|
196
|
+
if (reasoningSeen && !reasoningClosed) {
|
|
197
|
+
writer.endReasoning(encodeReasoning({ source: context.route.origin, format: context.route.profile.reasoning_format, data: reasoning }));
|
|
198
|
+
reasoningClosed = true;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
for await (const { json } of chatEvents(response.body, context.signal)) {
|
|
202
|
+
if (json.error)
|
|
203
|
+
throw upstreamError(undefined, json, response.headers);
|
|
204
|
+
if (json.usage)
|
|
205
|
+
usage = dialect.readUsage(json.usage);
|
|
206
|
+
for (const choice of json.choices ?? []) {
|
|
207
|
+
if (choice.index !== 0)
|
|
208
|
+
throw new BridgeError("Chat returned more than one completion.", "upstream_error", 502);
|
|
209
|
+
if (choice.finish_reason)
|
|
210
|
+
finish = choice.finish_reason;
|
|
211
|
+
const delta = choice.delta ?? {};
|
|
212
|
+
if (delta.refusal)
|
|
213
|
+
throw new BridgeError(String(delta.refusal));
|
|
214
|
+
if (delta[reasoningField] !== undefined && delta[reasoningField] !== null) {
|
|
215
|
+
if (typeof delta[reasoningField] !== "string" || reasoningClosed)
|
|
216
|
+
throw new BridgeError("Chat returned unsupported reasoning ordering or content.", "upstream_error", 502);
|
|
217
|
+
reasoningSeen = true;
|
|
218
|
+
reasoning += delta[reasoningField];
|
|
219
|
+
writer.reasoningDelta(delta[reasoningField]);
|
|
220
|
+
}
|
|
221
|
+
if (delta.content !== undefined && delta.content !== null && delta.content !== "") {
|
|
222
|
+
if (typeof delta.content !== "string")
|
|
223
|
+
throw new BridgeError("Chat returned non-text output.", "upstream_error", 502);
|
|
224
|
+
closeReasoning();
|
|
225
|
+
writer.textDelta(delta.content);
|
|
226
|
+
visible = true;
|
|
227
|
+
}
|
|
228
|
+
for (const part of delta.tool_calls ?? []) {
|
|
229
|
+
if (!Number.isInteger(part.index) || part.index < 0 || (part.type && part.type !== "function")) {
|
|
230
|
+
throw new BridgeError("Chat returned an invalid tool call.", "upstream_error", 502);
|
|
231
|
+
}
|
|
232
|
+
const call = calls.get(part.index) ?? { id: "", name: "", arguments: "" };
|
|
233
|
+
if (part.id)
|
|
234
|
+
call.id += part.id;
|
|
235
|
+
if (part.function?.name)
|
|
236
|
+
call.name += part.function.name;
|
|
237
|
+
if (part.function?.arguments)
|
|
238
|
+
call.arguments += part.function.arguments;
|
|
239
|
+
calls.set(part.index, call);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (!finish)
|
|
244
|
+
throw new BridgeError("Chat stream ended before a finish_reason.", "upstream_error", 502);
|
|
245
|
+
if (finish === "length")
|
|
246
|
+
throw new BridgeError("Chat output reached its token limit; increase max_output_tokens.");
|
|
247
|
+
const failure = dialect.failureReason(finish);
|
|
248
|
+
if (failure)
|
|
249
|
+
throw failure;
|
|
250
|
+
if (finish !== "stop" && finish !== "tool_calls")
|
|
251
|
+
throw new BridgeError(`Chat stopped with ${finish}.`);
|
|
252
|
+
if (!usage)
|
|
253
|
+
throw new BridgeError("Chat stream ended without token usage.", "upstream_error", 502);
|
|
254
|
+
if (finish === "tool_calls" && !calls.size)
|
|
255
|
+
throw new BridgeError("Chat ended with tool_calls but returned no calls.", "upstream_error", 502);
|
|
256
|
+
if (!visible && !calls.size)
|
|
257
|
+
throw new BridgeError("Chat returned no text or tool calls.", "upstream_error", 502);
|
|
258
|
+
closeReasoning();
|
|
259
|
+
// Validate every call before exposing any of them for execution.
|
|
260
|
+
const completed = [...calls.entries()].sort(([a], [b]) => a - b).map(([, call]) => {
|
|
261
|
+
const tool = tools.resolve(call.name);
|
|
262
|
+
if (!tool || !call.id)
|
|
263
|
+
throw new BridgeError("Chat returned an unknown or incomplete tool call.", "upstream_error", 502);
|
|
264
|
+
let args;
|
|
265
|
+
try {
|
|
266
|
+
args = JSON.parse(call.arguments);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
throw new BridgeError("Chat returned invalid tool arguments.", "upstream_error", 502);
|
|
270
|
+
}
|
|
271
|
+
if (!args || typeof args !== "object" || Array.isArray(args))
|
|
272
|
+
throw new BridgeError("Chat tool arguments must be an object.", "upstream_error", 502);
|
|
273
|
+
if (tool.kind === "custom" && typeof args.input !== "string")
|
|
274
|
+
throw new BridgeError("Chat custom tool call has no string input.", "upstream_error", 502);
|
|
275
|
+
return { call, tool, args };
|
|
276
|
+
});
|
|
277
|
+
for (const { call, tool, args } of completed) {
|
|
278
|
+
if (tool.kind === "custom")
|
|
279
|
+
writer.addCustomToolCall({ call_id: call.id, name: tool.name, namespace: tool.namespace, input: args.input });
|
|
280
|
+
else
|
|
281
|
+
writer.addFunctionCall({ call_id: call.id, name: tool.name, namespace: tool.namespace, arguments: call.arguments });
|
|
282
|
+
}
|
|
283
|
+
return { usage, endTurn: calls.size === 0 };
|
|
284
|
+
}
|