@skydiveai/pi-server 0.1.0-beta.6
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 +21 -0
- package/README.md +26 -0
- package/dist/index.d.mts +545 -0
- package/dist/index.mjs +2854 -0
- package/package.json +59 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2854 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { pino } from "pino";
|
|
3
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
+
import { getModel } from "@earendil-works/pi-ai";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
//#region src/session-registry.ts
|
|
9
|
+
function createMapSessionRegistry() {
|
|
10
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
11
|
+
return {
|
|
12
|
+
get(id) {
|
|
13
|
+
return sessions.get(id) ?? null;
|
|
14
|
+
},
|
|
15
|
+
set(id, session) {
|
|
16
|
+
sessions.set(id, session);
|
|
17
|
+
},
|
|
18
|
+
delete(id) {
|
|
19
|
+
sessions.delete(id);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/trace-context.ts
|
|
25
|
+
/**
|
|
26
|
+
* Per-request trace context. Protocol entry points wrap their handlers
|
|
27
|
+
* in `runInTraceContext` and `requestLogger` records the request's
|
|
28
|
+
* traceparent here, so anything downstream (the self-trace extension,
|
|
29
|
+
* response headers) can read it without threading it through every
|
|
30
|
+
* call. Lives in its own module so both `logger.ts` and `tracing.ts`
|
|
31
|
+
* can import it without a cycle.
|
|
32
|
+
*/
|
|
33
|
+
const traceStorage = new AsyncLocalStorage();
|
|
34
|
+
function runInTraceContext(fn) {
|
|
35
|
+
return traceStorage.run({ traceparent: null }, fn);
|
|
36
|
+
}
|
|
37
|
+
/** No-op when called outside `runInTraceContext`. */
|
|
38
|
+
function setCurrentTraceparent(tp) {
|
|
39
|
+
const store = traceStorage.getStore();
|
|
40
|
+
if (store) store.traceparent = tp;
|
|
41
|
+
}
|
|
42
|
+
function getCurrentTraceparent() {
|
|
43
|
+
return traceStorage.getStore()?.traceparent ?? null;
|
|
44
|
+
}
|
|
45
|
+
/** Extract the trace id (second segment) from a `traceparent` value. */
|
|
46
|
+
function parseTraceId(traceparent) {
|
|
47
|
+
if (!traceparent) return null;
|
|
48
|
+
const parts = traceparent.split("-");
|
|
49
|
+
return parts.length >= 2 ? parts[1] ?? null : null;
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/logger.ts
|
|
53
|
+
/**
|
|
54
|
+
* Base logger for the harness. Defaults to JSON line output (one event per
|
|
55
|
+
* line, machine-greppable) so logs from inside the e2b sandbox can be
|
|
56
|
+
* tailed and joined with api/proxy logs by `trace_id`. Set
|
|
57
|
+
* `HARNESS_LOG_FORMAT=pretty` for human-readable output during local dev.
|
|
58
|
+
*/
|
|
59
|
+
const isPretty = process.env.HARNESS_LOG_FORMAT === "pretty";
|
|
60
|
+
const logger = pino({
|
|
61
|
+
level: process.env.HARNESS_LOG_LEVEL ?? "info",
|
|
62
|
+
base: { service: process.env.HARNESS_LOG_SERVICE ?? "pi-server" },
|
|
63
|
+
...isPretty ? { transport: {
|
|
64
|
+
target: "pino-pretty",
|
|
65
|
+
options: {
|
|
66
|
+
colorize: true,
|
|
67
|
+
translateTime: "HH:MM:ss.l"
|
|
68
|
+
}
|
|
69
|
+
} } : {}
|
|
70
|
+
});
|
|
71
|
+
/**
|
|
72
|
+
* W3C Trace Context: `traceparent: 00-<32 hex>-<16 hex>-<2 hex>`.
|
|
73
|
+
* https://www.w3.org/TR/trace-context/#traceparent-header
|
|
74
|
+
*
|
|
75
|
+
* We only support version 00. Anything malformed → mint a fresh root.
|
|
76
|
+
*/
|
|
77
|
+
const TRACEPARENT_RE = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/i;
|
|
78
|
+
function hex(bytes) {
|
|
79
|
+
return randomBytes(bytes).toString("hex");
|
|
80
|
+
}
|
|
81
|
+
function newSpanId() {
|
|
82
|
+
return hex(8);
|
|
83
|
+
}
|
|
84
|
+
function newTraceContext() {
|
|
85
|
+
const traceId = hex(16);
|
|
86
|
+
const spanId = newSpanId();
|
|
87
|
+
return {
|
|
88
|
+
traceId,
|
|
89
|
+
spanId,
|
|
90
|
+
parentSpanId: null,
|
|
91
|
+
flags: "01",
|
|
92
|
+
traceparent: `00-${traceId}-${spanId}-01`
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Parse an incoming `traceparent` and mint a child span id under the same
|
|
97
|
+
* trace id. The parent span id is preserved so logs can stitch caller and
|
|
98
|
+
* callee. Returns a fresh root context if the header is missing or malformed.
|
|
99
|
+
*/
|
|
100
|
+
function deriveTraceContext(headerValue) {
|
|
101
|
+
if (typeof headerValue !== "string") return newTraceContext();
|
|
102
|
+
const m = headerValue.match(TRACEPARENT_RE);
|
|
103
|
+
if (!m) return newTraceContext();
|
|
104
|
+
const [, , traceId, parentSpanId, flags] = m;
|
|
105
|
+
const spanId = newSpanId();
|
|
106
|
+
return {
|
|
107
|
+
traceId,
|
|
108
|
+
spanId,
|
|
109
|
+
parentSpanId,
|
|
110
|
+
flags,
|
|
111
|
+
traceparent: `00-${traceId}-${spanId}-${flags}`
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Build a per-request child logger pre-bound with trace context. Every event
|
|
116
|
+
* emitted from `req` should go through this logger so it auto-inherits
|
|
117
|
+
* `trace_id`/`span_id`/`parent_span_id`.
|
|
118
|
+
*/
|
|
119
|
+
function requestLogger(request) {
|
|
120
|
+
const trace = deriveTraceContext(request.headers.get("traceparent"));
|
|
121
|
+
setCurrentTraceparent(trace.traceparent);
|
|
122
|
+
return {
|
|
123
|
+
log: logger.child({
|
|
124
|
+
trace_id: trace.traceId,
|
|
125
|
+
span_id: trace.spanId,
|
|
126
|
+
parent_span_id: trace.parentSpanId
|
|
127
|
+
}),
|
|
128
|
+
trace
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/protocols/prewarm.ts
|
|
133
|
+
/**
|
|
134
|
+
* Prewarm endpoint — POST to a configured path runs a 1-token chat
|
|
135
|
+
* completion so the first real request doesn't pay session/model
|
|
136
|
+
* cold-start. Invokes the chat-completions handler in-process (no HTTP
|
|
137
|
+
* loopback).
|
|
138
|
+
*/
|
|
139
|
+
function createPrewarm({ paths, chatCompletions }) {
|
|
140
|
+
const prewarmPaths = new Set(paths);
|
|
141
|
+
return async (request) => {
|
|
142
|
+
if (prewarmPaths.size === 0) return null;
|
|
143
|
+
const url = new URL(request.url);
|
|
144
|
+
if (request.method !== "POST" || !prewarmPaths.has(url.pathname)) return null;
|
|
145
|
+
const start = performance.now();
|
|
146
|
+
try {
|
|
147
|
+
const warmHeaders = { "content-type": "application/json" };
|
|
148
|
+
const envdToken = request.headers.get("x-e2b-envd-token");
|
|
149
|
+
if (envdToken) warmHeaders["x-e2b-envd-token"] = envdToken;
|
|
150
|
+
await (await chatCompletions(new Request(new URL("/v1/chat/completions", url.origin), {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: warmHeaders,
|
|
153
|
+
body: JSON.stringify({
|
|
154
|
+
max_tokens: 1,
|
|
155
|
+
stream: false,
|
|
156
|
+
messages: [{
|
|
157
|
+
role: "user",
|
|
158
|
+
content: "hi"
|
|
159
|
+
}]
|
|
160
|
+
})
|
|
161
|
+
})))?.text();
|
|
162
|
+
const ms = Math.round(performance.now() - start);
|
|
163
|
+
logger.info({
|
|
164
|
+
event: "prewarm_done",
|
|
165
|
+
ms
|
|
166
|
+
}, "harness prewarm complete");
|
|
167
|
+
return Response.json({
|
|
168
|
+
ok: true,
|
|
169
|
+
ms
|
|
170
|
+
});
|
|
171
|
+
} catch (err) {
|
|
172
|
+
logger.error({
|
|
173
|
+
err,
|
|
174
|
+
event: "prewarm_failed"
|
|
175
|
+
}, "harness prewarm failed");
|
|
176
|
+
return Response.json({ error: err instanceof Error ? err.message : "prewarm failed" }, { status: 500 });
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region src/constants.ts
|
|
182
|
+
const DEFAULT_PROVIDER = "anthropic";
|
|
183
|
+
const DEFAULT_MODEL = "claude-opus-4-7";
|
|
184
|
+
const DEFAULT_THINKING_LEVEL = "medium";
|
|
185
|
+
const VALID_THINKING_LEVELS = [
|
|
186
|
+
"off",
|
|
187
|
+
"minimal",
|
|
188
|
+
"low",
|
|
189
|
+
"medium",
|
|
190
|
+
"high",
|
|
191
|
+
"xhigh"
|
|
192
|
+
];
|
|
193
|
+
const PI_AGENT_DIR = join(homedir(), ".pi", "agent");
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/protocols/shared.ts
|
|
196
|
+
/**
|
|
197
|
+
* Provider usage off an `agent_end` event, from the last assistant message that
|
|
198
|
+
* actually carries it — the final billed call saw the full accumulated context,
|
|
199
|
+
* so its counts are the turn's context size. A trailing assistant message
|
|
200
|
+
* without usage (e.g. a tool-only turn) isn't a report, so skip past it rather
|
|
201
|
+
* than give up. Null when no assistant message reported usage, or the report
|
|
202
|
+
* lacks the core counts: we don't fabricate zeros, so sizing falls back to the
|
|
203
|
+
* estimate instead of trusting a fake 0.
|
|
204
|
+
*/
|
|
205
|
+
function extractAgentEndUsage(event) {
|
|
206
|
+
const u = [...event.messages ?? []].reverse().find((m) => m?.role === "assistant" && m?.usage)?.usage;
|
|
207
|
+
if (!u || u.input == null || u.output == null) return null;
|
|
208
|
+
return {
|
|
209
|
+
input: u.input,
|
|
210
|
+
output: u.output,
|
|
211
|
+
cacheRead: u.cacheRead ?? 0,
|
|
212
|
+
cacheWrite: u.cacheWrite ?? 0,
|
|
213
|
+
totalTokens: u.totalTokens ?? 0
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const PER_REQUEST_API_KEY_HEADERS = [{
|
|
217
|
+
header: "x-anthropic-api-key",
|
|
218
|
+
provider: "anthropic"
|
|
219
|
+
}, {
|
|
220
|
+
header: "x-openai-api-key",
|
|
221
|
+
provider: "openai"
|
|
222
|
+
}];
|
|
223
|
+
function resolvePerRequestApiKeys(request) {
|
|
224
|
+
const out = {};
|
|
225
|
+
for (const { header, provider } of PER_REQUEST_API_KEY_HEADERS) {
|
|
226
|
+
const v = request.headers.get(header);
|
|
227
|
+
if (v) out[provider] = v;
|
|
228
|
+
}
|
|
229
|
+
if (!out.anthropic) {
|
|
230
|
+
const auth = request.headers.get("authorization");
|
|
231
|
+
if (auth && /^Bearer\s+/i.test(auth)) out.anthropic = auth.replace(/^Bearer\s+/i, "");
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
function parseSessionId(request) {
|
|
236
|
+
const raw = request.headers.get("x-session-id");
|
|
237
|
+
if (!raw) return {
|
|
238
|
+
sessionId: randomUUID(),
|
|
239
|
+
source: "generated",
|
|
240
|
+
invalid: false
|
|
241
|
+
};
|
|
242
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) return {
|
|
243
|
+
sessionId: raw,
|
|
244
|
+
source: "header",
|
|
245
|
+
invalid: false
|
|
246
|
+
};
|
|
247
|
+
return {
|
|
248
|
+
sessionId: randomUUID(),
|
|
249
|
+
source: "generated",
|
|
250
|
+
invalid: true
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function parseShellEnv(request) {
|
|
254
|
+
const raw = request.headers.get("x-shell-env");
|
|
255
|
+
if (!raw) return null;
|
|
256
|
+
try {
|
|
257
|
+
const parsed = JSON.parse(raw);
|
|
258
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
259
|
+
const env = {};
|
|
260
|
+
for (const [k, v] of Object.entries(parsed)) if (typeof v === "string") env[k] = v;
|
|
261
|
+
return Object.keys(env).length > 0 ? env : null;
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function jsonResponse(body, status = 200) {
|
|
267
|
+
return new Response(JSON.stringify(body), {
|
|
268
|
+
status,
|
|
269
|
+
headers: { "Content-Type": "application/json" }
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
function jsonError(status, message) {
|
|
273
|
+
return jsonResponse({ error: { message } }, status);
|
|
274
|
+
}
|
|
275
|
+
const SSE_KEEPALIVE_INTERVAL_MS = 15e3;
|
|
276
|
+
function createSSEResponse(handler) {
|
|
277
|
+
const encoder = new TextEncoder();
|
|
278
|
+
let controller;
|
|
279
|
+
const stream = new ReadableStream({ start(ctrl) {
|
|
280
|
+
controller = ctrl;
|
|
281
|
+
} });
|
|
282
|
+
const writer = {
|
|
283
|
+
write(chunk) {
|
|
284
|
+
try {
|
|
285
|
+
controller.enqueue(encoder.encode(chunk));
|
|
286
|
+
} catch {}
|
|
287
|
+
},
|
|
288
|
+
close() {
|
|
289
|
+
try {
|
|
290
|
+
controller.close();
|
|
291
|
+
} catch {}
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
const keepalive = setInterval(() => {
|
|
295
|
+
writer.write(": keepalive\n\n");
|
|
296
|
+
}, SSE_KEEPALIVE_INTERVAL_MS);
|
|
297
|
+
handler(writer).then(() => {
|
|
298
|
+
clearInterval(keepalive);
|
|
299
|
+
try {
|
|
300
|
+
controller.close();
|
|
301
|
+
} catch {}
|
|
302
|
+
}, (err) => {
|
|
303
|
+
logger.error({ err }, "SSE handler crashed — stream closed without response");
|
|
304
|
+
clearInterval(keepalive);
|
|
305
|
+
try {
|
|
306
|
+
controller.close();
|
|
307
|
+
} catch {}
|
|
308
|
+
});
|
|
309
|
+
return new Response(stream, {
|
|
310
|
+
status: 200,
|
|
311
|
+
headers: {
|
|
312
|
+
"Content-Type": "text/event-stream",
|
|
313
|
+
"Cache-Control": "no-cache, no-transform",
|
|
314
|
+
Connection: "keep-alive",
|
|
315
|
+
"X-Accel-Buffering": "no"
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
function resolveModel(modelString, log) {
|
|
320
|
+
const raw = typeof modelString === "string" && modelString.trim() ? modelString.trim() : DEFAULT_MODEL;
|
|
321
|
+
let provider = DEFAULT_PROVIDER;
|
|
322
|
+
let name = raw;
|
|
323
|
+
const sep = raw.match(/[:/]/);
|
|
324
|
+
if (sep) [provider, name] = raw.split(sep[0], 2);
|
|
325
|
+
const found = getModel(provider, name);
|
|
326
|
+
if (found) return found;
|
|
327
|
+
log.warn({
|
|
328
|
+
event: "model_unknown",
|
|
329
|
+
requested_provider: provider,
|
|
330
|
+
requested_model: name,
|
|
331
|
+
fallback_provider: DEFAULT_PROVIDER,
|
|
332
|
+
fallback_model: DEFAULT_MODEL
|
|
333
|
+
}, "unknown model, falling back to default");
|
|
334
|
+
return getModel(DEFAULT_PROVIDER, DEFAULT_MODEL);
|
|
335
|
+
}
|
|
336
|
+
const contextWindowSchema = z.number().int().positive();
|
|
337
|
+
function applyContextWindowOverride(model, contextWindow) {
|
|
338
|
+
const parsed = contextWindowSchema.safeParse(contextWindow);
|
|
339
|
+
if (!parsed.success) return model;
|
|
340
|
+
return {
|
|
341
|
+
...model,
|
|
342
|
+
contextWindow: parsed.data
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
function resolveThinkingLevel(input) {
|
|
346
|
+
if (typeof input === "string" && VALID_THINKING_LEVELS.includes(input)) return input;
|
|
347
|
+
return DEFAULT_THINKING_LEVEL;
|
|
348
|
+
}
|
|
349
|
+
async function fetchImageAsBase64({ url, userAgent }) {
|
|
350
|
+
const ctrl = new AbortController();
|
|
351
|
+
const timeout = setTimeout(() => ctrl.abort(), 15e3);
|
|
352
|
+
try {
|
|
353
|
+
const r = await fetch(url, {
|
|
354
|
+
headers: { "user-agent": userAgent || "pi-server/0.1" },
|
|
355
|
+
redirect: "follow",
|
|
356
|
+
signal: ctrl.signal
|
|
357
|
+
});
|
|
358
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
359
|
+
return {
|
|
360
|
+
type: "image",
|
|
361
|
+
mimeType: r.headers.get("content-type")?.split(";")[0]?.trim() || "image/png",
|
|
362
|
+
data: Buffer.from(await r.arrayBuffer()).toString("base64")
|
|
363
|
+
};
|
|
364
|
+
} finally {
|
|
365
|
+
clearTimeout(timeout);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async function runConversation({ session, prompt, images, log, postPrompt }) {
|
|
369
|
+
await session.prompt(prompt, images.length ? { images } : void 0);
|
|
370
|
+
if (postPrompt) await postPrompt({
|
|
371
|
+
session,
|
|
372
|
+
log
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Hard-stop the in-flight turn for a session. Shared by every protocol's
|
|
377
|
+
* `/:id/abort` route: a cancel signals the stop explicitly instead of relying
|
|
378
|
+
* on a dropped connection. `session.abort()` interrupts the turn and resolves
|
|
379
|
+
* once the agent is idle, so a 200 means the agent has actually stopped (404 =
|
|
380
|
+
* nothing running to stop).
|
|
381
|
+
*/
|
|
382
|
+
async function handleAbort({ request, sessionId, registry }) {
|
|
383
|
+
const { log } = requestLogger(request);
|
|
384
|
+
const session = registry.get(sessionId);
|
|
385
|
+
if (!session) return jsonError(404, "session not found or already completed");
|
|
386
|
+
await session.abort();
|
|
387
|
+
log.info({
|
|
388
|
+
event: "abort_accepted",
|
|
389
|
+
session_id: sessionId
|
|
390
|
+
}, "session aborted");
|
|
391
|
+
return jsonResponse({ ok: true });
|
|
392
|
+
}
|
|
393
|
+
//#endregion
|
|
394
|
+
//#region src/protocols/a2a.ts
|
|
395
|
+
/**
|
|
396
|
+
* A2A (Agent-to-Agent) protocol handler.
|
|
397
|
+
*
|
|
398
|
+
* Exports an AgentExecutor + agent card builder for use with the
|
|
399
|
+
* @a2a-js/sdk server. The SDK's Express restHandler handles all
|
|
400
|
+
* routing and proto-JSON serialization.
|
|
401
|
+
*
|
|
402
|
+
* @see https://google.github.io/A2A/specification/
|
|
403
|
+
*/
|
|
404
|
+
async function extractParts(parts, log) {
|
|
405
|
+
let text = "";
|
|
406
|
+
const images = [];
|
|
407
|
+
for (const part of parts) {
|
|
408
|
+
if (part.text != null) {
|
|
409
|
+
text += part.text;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (part.raw != null && part.mediaType?.startsWith("image/")) {
|
|
413
|
+
images.push({
|
|
414
|
+
type: "image",
|
|
415
|
+
mimeType: part.mediaType,
|
|
416
|
+
data: part.raw
|
|
417
|
+
});
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (part.url != null && part.mediaType?.startsWith("image/")) {
|
|
421
|
+
try {
|
|
422
|
+
images.push(await fetchImageAsBase64({ url: part.url }));
|
|
423
|
+
} catch (err) {
|
|
424
|
+
log.warn({
|
|
425
|
+
event: "a2a_image_fetch_failed",
|
|
426
|
+
url: part.url,
|
|
427
|
+
err
|
|
428
|
+
}, "image fetch failed");
|
|
429
|
+
text += `\n[image fetch failed: ${part.url}]\n`;
|
|
430
|
+
}
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
if (part.data != null) text += `\n${JSON.stringify(part.data)}\n`;
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
text: text.trim(),
|
|
437
|
+
images
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function createAgentExecutor(options) {
|
|
441
|
+
const onSessionSetup = options.onSessionSetup ?? (() => {});
|
|
442
|
+
const postPrompt = options.postPrompt;
|
|
443
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
444
|
+
const taskSessions = /* @__PURE__ */ new Map();
|
|
445
|
+
const executor = {
|
|
446
|
+
async execute(requestContext, eventBus) {
|
|
447
|
+
const { log } = requestLogger(new Request("http://localhost/a2a"));
|
|
448
|
+
const userMessage = requestContext.userMessage;
|
|
449
|
+
const { text: prompt, images } = await extractParts(userMessage.parts ?? userMessage.content ?? [], log);
|
|
450
|
+
const taskId = requestContext.taskId;
|
|
451
|
+
const contextId = requestContext.contextId;
|
|
452
|
+
eventBus.publish({
|
|
453
|
+
kind: "task",
|
|
454
|
+
id: taskId,
|
|
455
|
+
contextId,
|
|
456
|
+
status: { state: "working" }
|
|
457
|
+
});
|
|
458
|
+
if (!prompt && images.length === 0) {
|
|
459
|
+
eventBus.publish({
|
|
460
|
+
kind: "status-update",
|
|
461
|
+
taskId,
|
|
462
|
+
contextId,
|
|
463
|
+
status: {
|
|
464
|
+
state: "failed",
|
|
465
|
+
message: {
|
|
466
|
+
kind: "message",
|
|
467
|
+
messageId: randomUUID(),
|
|
468
|
+
role: "agent",
|
|
469
|
+
parts: [{
|
|
470
|
+
kind: "text",
|
|
471
|
+
text: "No extractable content"
|
|
472
|
+
}]
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
eventBus.finished();
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const existing = sessions.get(contextId);
|
|
480
|
+
if (existing) {
|
|
481
|
+
log.info({
|
|
482
|
+
event: "a2a_steer",
|
|
483
|
+
contextId,
|
|
484
|
+
taskId
|
|
485
|
+
}, "steering existing session");
|
|
486
|
+
await existing.session.steer(prompt, images.length > 0 ? images : void 0);
|
|
487
|
+
eventBus.finished();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const cwd = options.cwd ?? process.cwd();
|
|
491
|
+
const sessionResult = await options.createSession({
|
|
492
|
+
cwd,
|
|
493
|
+
sessionId: randomUUID(),
|
|
494
|
+
perRequestApiKeys: {},
|
|
495
|
+
systemPromptOverride: null,
|
|
496
|
+
shellEnv: null,
|
|
497
|
+
model: resolveModel(void 0, log),
|
|
498
|
+
thinkingLevel: null,
|
|
499
|
+
log,
|
|
500
|
+
onSessionSetup,
|
|
501
|
+
prepare: null
|
|
502
|
+
});
|
|
503
|
+
const { session } = sessionResult;
|
|
504
|
+
sessions.set(contextId, sessionResult);
|
|
505
|
+
taskSessions.set(taskId, sessionResult);
|
|
506
|
+
let responseText = "";
|
|
507
|
+
session.subscribe((event) => {
|
|
508
|
+
const ev = event;
|
|
509
|
+
if (ev.type === "message_update" && ev.assistantMessageEvent?.type === "text_delta") {
|
|
510
|
+
responseText += ev.assistantMessageEvent.delta ?? "";
|
|
511
|
+
eventBus.publish({
|
|
512
|
+
kind: "status-update",
|
|
513
|
+
taskId,
|
|
514
|
+
contextId,
|
|
515
|
+
status: {
|
|
516
|
+
state: "working",
|
|
517
|
+
message: {
|
|
518
|
+
kind: "message",
|
|
519
|
+
messageId: randomUUID(),
|
|
520
|
+
role: "agent",
|
|
521
|
+
parts: [{
|
|
522
|
+
kind: "text",
|
|
523
|
+
text: responseText
|
|
524
|
+
}]
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
if (ev.type === "tool_execution_end") {
|
|
530
|
+
const resultText = Array.isArray(ev.result?.content) ? ev.result.content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("") : typeof ev.result === "string" ? ev.result : "";
|
|
531
|
+
eventBus.publish({
|
|
532
|
+
kind: "artifact-update",
|
|
533
|
+
taskId,
|
|
534
|
+
contextId,
|
|
535
|
+
artifact: {
|
|
536
|
+
artifactId: randomUUID(),
|
|
537
|
+
name: String(ev.toolName ?? "tool_result"),
|
|
538
|
+
parts: [{
|
|
539
|
+
kind: "data",
|
|
540
|
+
data: {
|
|
541
|
+
toolCallId: ev.toolCallId,
|
|
542
|
+
toolName: ev.toolName,
|
|
543
|
+
result: resultText,
|
|
544
|
+
isError: ev.isError ?? false
|
|
545
|
+
}
|
|
546
|
+
}]
|
|
547
|
+
},
|
|
548
|
+
lastChunk: true
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
try {
|
|
553
|
+
await runConversation({
|
|
554
|
+
session,
|
|
555
|
+
prompt,
|
|
556
|
+
images,
|
|
557
|
+
log,
|
|
558
|
+
postPrompt
|
|
559
|
+
});
|
|
560
|
+
} catch (err) {
|
|
561
|
+
log.error({
|
|
562
|
+
event: "a2a_executor_error",
|
|
563
|
+
err
|
|
564
|
+
}, "agent execution error");
|
|
565
|
+
} finally {
|
|
566
|
+
sessions.delete(contextId);
|
|
567
|
+
taskSessions.delete(taskId);
|
|
568
|
+
}
|
|
569
|
+
eventBus.publish({
|
|
570
|
+
kind: "status-update",
|
|
571
|
+
taskId,
|
|
572
|
+
contextId,
|
|
573
|
+
status: {
|
|
574
|
+
state: "completed",
|
|
575
|
+
message: {
|
|
576
|
+
kind: "message",
|
|
577
|
+
messageId: randomUUID(),
|
|
578
|
+
role: "agent",
|
|
579
|
+
parts: [{
|
|
580
|
+
kind: "text",
|
|
581
|
+
text: responseText || "(no text output)"
|
|
582
|
+
}]
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
eventBus.finished();
|
|
587
|
+
},
|
|
588
|
+
async cancelTask(taskId, eventBus) {
|
|
589
|
+
await taskSessions.get(taskId)?.session.abort();
|
|
590
|
+
eventBus.finished();
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
return {
|
|
594
|
+
...executor,
|
|
595
|
+
execute: (requestContext, eventBus) => runInTraceContext(() => executor.execute(requestContext, eventBus))
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
function buildAgentCard(baseUrl, overrides) {
|
|
599
|
+
const o = overrides ?? {};
|
|
600
|
+
return {
|
|
601
|
+
name: o.name ?? "Pi Agent",
|
|
602
|
+
description: o.description ?? "A pi coding agent.",
|
|
603
|
+
url: baseUrl,
|
|
604
|
+
version: o.version ?? "0.1.0",
|
|
605
|
+
protocolVersion: "1.0",
|
|
606
|
+
preferredTransport: "HTTP+JSON",
|
|
607
|
+
additionalInterfaces: [{
|
|
608
|
+
url: baseUrl,
|
|
609
|
+
transport: "HTTP+JSON"
|
|
610
|
+
}],
|
|
611
|
+
capabilities: {
|
|
612
|
+
streaming: true,
|
|
613
|
+
pushNotifications: false
|
|
614
|
+
},
|
|
615
|
+
defaultInputModes: ["text/plain"],
|
|
616
|
+
defaultOutputModes: ["text/plain"],
|
|
617
|
+
skills: o.skills ?? [{
|
|
618
|
+
id: "code-generation",
|
|
619
|
+
name: "Code Generation",
|
|
620
|
+
description: "Generate, edit, and refactor code."
|
|
621
|
+
}, {
|
|
622
|
+
id: "tool-use",
|
|
623
|
+
name: "Tool Use",
|
|
624
|
+
description: "Execute shell commands, read/write files, and use MCP tools."
|
|
625
|
+
}]
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
//#endregion
|
|
629
|
+
//#region src/model-spec.ts
|
|
630
|
+
/**
|
|
631
|
+
* Runtime mirror of pi-ai's `KnownProvider` union (which is type-only). The
|
|
632
|
+
* `satisfies` keeps every member valid against the type; new pi-ai providers
|
|
633
|
+
* are admitted by adding them here.
|
|
634
|
+
*/
|
|
635
|
+
const KNOWN_PI_PROVIDERS = [
|
|
636
|
+
"amazon-bedrock",
|
|
637
|
+
"anthropic",
|
|
638
|
+
"google",
|
|
639
|
+
"google-vertex",
|
|
640
|
+
"openai",
|
|
641
|
+
"azure-openai-responses",
|
|
642
|
+
"openai-codex",
|
|
643
|
+
"deepseek",
|
|
644
|
+
"github-copilot",
|
|
645
|
+
"xai",
|
|
646
|
+
"groq",
|
|
647
|
+
"cerebras",
|
|
648
|
+
"openrouter",
|
|
649
|
+
"vercel-ai-gateway",
|
|
650
|
+
"zai",
|
|
651
|
+
"mistral",
|
|
652
|
+
"minimax",
|
|
653
|
+
"minimax-cn",
|
|
654
|
+
"moonshotai",
|
|
655
|
+
"moonshotai-cn",
|
|
656
|
+
"huggingface",
|
|
657
|
+
"fireworks",
|
|
658
|
+
"opencode",
|
|
659
|
+
"opencode-go",
|
|
660
|
+
"kimi-coding",
|
|
661
|
+
"cloudflare-workers-ai",
|
|
662
|
+
"cloudflare-ai-gateway",
|
|
663
|
+
"xiaomi",
|
|
664
|
+
"xiaomi-token-plan-cn",
|
|
665
|
+
"xiaomi-token-plan-ams",
|
|
666
|
+
"xiaomi-token-plan-sgp"
|
|
667
|
+
];
|
|
668
|
+
/**
|
|
669
|
+
* Mirrors pi-ai's `ThinkingLevelMap`. Each key is genuinely tri-state:
|
|
670
|
+
* absent = provider default, `null` = level unsupported, string = the
|
|
671
|
+
* provider-specific value for that level.
|
|
672
|
+
*/
|
|
673
|
+
const thinkingLevelMapSchema = z.object({
|
|
674
|
+
off: z.string().nullable().optional(),
|
|
675
|
+
minimal: z.string().nullable().optional(),
|
|
676
|
+
low: z.string().nullable().optional(),
|
|
677
|
+
medium: z.string().nullable().optional(),
|
|
678
|
+
high: z.string().nullable().optional(),
|
|
679
|
+
xhigh: z.string().nullable().optional()
|
|
680
|
+
});
|
|
681
|
+
/**
|
|
682
|
+
* Per-API compat overrides, mirroring pi-ai's compat interfaces. Every field
|
|
683
|
+
* is genuinely tri-state: absent defers to pi-ai's baseUrl auto-detection
|
|
684
|
+
* (`detectCompat`), present pins the knob. Unknown keys are stripped rather
|
|
685
|
+
* than rejected so an already-shipped server tolerates knobs added for newer
|
|
686
|
+
* models instead of kicking the whole spec back to registry resolution.
|
|
687
|
+
*/
|
|
688
|
+
const openaiCompletionsCompatSchema = z.object({
|
|
689
|
+
supportsReasoningEffort: z.boolean().optional(),
|
|
690
|
+
requiresThinkingAsText: z.boolean().optional(),
|
|
691
|
+
thinkingFormat: z.enum([
|
|
692
|
+
"openai",
|
|
693
|
+
"openrouter",
|
|
694
|
+
"deepseek",
|
|
695
|
+
"zai",
|
|
696
|
+
"qwen",
|
|
697
|
+
"qwen-chat-template"
|
|
698
|
+
]).optional(),
|
|
699
|
+
supportsStrictMode: z.boolean().optional(),
|
|
700
|
+
maxTokensField: z.enum(["max_completion_tokens", "max_tokens"]).optional(),
|
|
701
|
+
cacheControlFormat: z.literal("anthropic").optional()
|
|
702
|
+
}).strip();
|
|
703
|
+
const anthropicMessagesCompatSchema = z.object({
|
|
704
|
+
supportsEagerToolInputStreaming: z.boolean().optional(),
|
|
705
|
+
supportsLongCacheRetention: z.boolean().optional()
|
|
706
|
+
}).strip();
|
|
707
|
+
const baseSpecShape = {
|
|
708
|
+
/** Exact model id the serving endpoint expects. */
|
|
709
|
+
id: z.string().min(1),
|
|
710
|
+
/** Display name shown in pi surfaces and session logs. */
|
|
711
|
+
name: z.string().min(1),
|
|
712
|
+
/** pi-ai provider key — also the auth-storage key for `apiKey`. */
|
|
713
|
+
provider: z.enum(KNOWN_PI_PROVIDERS),
|
|
714
|
+
/** Provider API root, e.g. "https://openrouter.ai/api/v1". */
|
|
715
|
+
baseUrl: z.string().url(),
|
|
716
|
+
reasoning: z.boolean(),
|
|
717
|
+
thinkingLevelMap: thinkingLevelMapSchema.optional(),
|
|
718
|
+
input: z.array(z.enum(["text", "image"])).nonempty(),
|
|
719
|
+
contextWindow: z.number().int().positive(),
|
|
720
|
+
maxTokens: z.number().int().positive(),
|
|
721
|
+
/**
|
|
722
|
+
* Optional runtime API key for `provider`, installed as a per-request key
|
|
723
|
+
* so the model registry's auth lookup succeeds. Deployments that resolve
|
|
724
|
+
* real credentials elsewhere (e.g. at an egress proxy) can pass whatever
|
|
725
|
+
* placeholder that layer recognizes — the value is opaque to this server.
|
|
726
|
+
*/
|
|
727
|
+
apiKey: z.string().min(1).optional()
|
|
728
|
+
};
|
|
729
|
+
const modelSpecSchema = z.discriminatedUnion("api", [z.object({
|
|
730
|
+
...baseSpecShape,
|
|
731
|
+
api: z.literal("openai-completions"),
|
|
732
|
+
compat: openaiCompletionsCompatSchema.optional()
|
|
733
|
+
}), z.object({
|
|
734
|
+
...baseSpecShape,
|
|
735
|
+
api: z.literal("anthropic-messages"),
|
|
736
|
+
compat: anthropicMessagesCompatSchema.optional()
|
|
737
|
+
})]);
|
|
738
|
+
/**
|
|
739
|
+
* Parse a request-body `x_model`. Null when absent or invalid — never a
|
|
740
|
+
* request rejection, so a malformed spec degrades to the legacy body-model
|
|
741
|
+
* resolution instead of failing the turn.
|
|
742
|
+
*/
|
|
743
|
+
function parseModelSpec(input, log) {
|
|
744
|
+
if (input == null) return null;
|
|
745
|
+
const parsed = modelSpecSchema.safeParse(input);
|
|
746
|
+
if (!parsed.success) {
|
|
747
|
+
log.warn({
|
|
748
|
+
event: "x_model_invalid",
|
|
749
|
+
issues: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
750
|
+
}, "ignoring invalid x_model, falling back to body model");
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
753
|
+
return parsed.data;
|
|
754
|
+
}
|
|
755
|
+
function buildModelFromSpec(spec) {
|
|
756
|
+
const base = {
|
|
757
|
+
id: spec.id,
|
|
758
|
+
name: spec.name,
|
|
759
|
+
provider: spec.provider,
|
|
760
|
+
baseUrl: spec.baseUrl,
|
|
761
|
+
reasoning: spec.reasoning,
|
|
762
|
+
...spec.thinkingLevelMap ? { thinkingLevelMap: spec.thinkingLevelMap } : {},
|
|
763
|
+
input: spec.input,
|
|
764
|
+
cost: {
|
|
765
|
+
input: 0,
|
|
766
|
+
output: 0,
|
|
767
|
+
cacheRead: 0,
|
|
768
|
+
cacheWrite: 0
|
|
769
|
+
},
|
|
770
|
+
contextWindow: spec.contextWindow,
|
|
771
|
+
maxTokens: spec.maxTokens
|
|
772
|
+
};
|
|
773
|
+
switch (spec.api) {
|
|
774
|
+
case "openai-completions": return {
|
|
775
|
+
...base,
|
|
776
|
+
api: spec.api,
|
|
777
|
+
...spec.compat ? { compat: spec.compat } : {}
|
|
778
|
+
};
|
|
779
|
+
case "anthropic-messages": return {
|
|
780
|
+
...base,
|
|
781
|
+
api: spec.api,
|
|
782
|
+
...spec.compat ? { compat: spec.compat } : {}
|
|
783
|
+
};
|
|
784
|
+
default: throw new Error(`Unhandled x_model api: ${String(spec)}`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Resolve the model a request should run on: a valid `x_model` wins,
|
|
789
|
+
* anything else falls back to registry resolution of the body model. Shared
|
|
790
|
+
* by every transport so they honor the spec identically.
|
|
791
|
+
*/
|
|
792
|
+
function resolveRequestModel({ modelInput, modelSpecInput, defaultModel, log }) {
|
|
793
|
+
const modelSpec = parseModelSpec(modelSpecInput, log);
|
|
794
|
+
return {
|
|
795
|
+
modelSpec,
|
|
796
|
+
model: modelSpec ? buildModelFromSpec(modelSpec) : resolveModel(modelInput ?? defaultModel, log)
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Install the spec's runtime API key as the per-request key for its
|
|
801
|
+
* provider. A caller-supplied key for the same provider (request headers)
|
|
802
|
+
* always wins.
|
|
803
|
+
*/
|
|
804
|
+
function withSpecApiKey(keys, modelSpec) {
|
|
805
|
+
if (!modelSpec?.apiKey || keys[modelSpec.provider]) return keys;
|
|
806
|
+
return {
|
|
807
|
+
...keys,
|
|
808
|
+
[modelSpec.provider]: modelSpec.apiKey
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
//#endregion
|
|
812
|
+
//#region src/protocols/model-provider-error.ts
|
|
813
|
+
function numericStatus(err) {
|
|
814
|
+
const s = err.status ?? err.statusCode;
|
|
815
|
+
return typeof s === "number" ? s : null;
|
|
816
|
+
}
|
|
817
|
+
function messageText(err) {
|
|
818
|
+
if (err instanceof Error) return err.message;
|
|
819
|
+
if (typeof err === "string") return err;
|
|
820
|
+
const m = err?.message;
|
|
821
|
+
return typeof m === "string" ? m : String(err);
|
|
822
|
+
}
|
|
823
|
+
function providerErrorType(err) {
|
|
824
|
+
const e = err.error;
|
|
825
|
+
if (e && typeof e === "object" && "type" in e) {
|
|
826
|
+
const t = e.type;
|
|
827
|
+
if (typeof t === "string") return t;
|
|
828
|
+
}
|
|
829
|
+
return null;
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* A stable, machine-readable code when the provider supplies one — preferred
|
|
833
|
+
* over message text since wording drifts. Covers OpenAI / Vercel AI Gateway
|
|
834
|
+
* (`error.code`, e.g. `context_length_exceeded`, `rate_limit_exceeded`) and
|
|
835
|
+
* OpenRouter (`error.metadata.error_type`, its canonical vocabulary). Anthropic
|
|
836
|
+
* and Gemini have no per-overflow code, so they fall through to the message
|
|
837
|
+
* regex; Gemini's `error.status` (e.g. `INVALID_ARGUMENT`) is too generic to map
|
|
838
|
+
* on its own.
|
|
839
|
+
*/
|
|
840
|
+
function machineCode(err) {
|
|
841
|
+
const e = err.error;
|
|
842
|
+
if (!e || typeof e !== "object") return null;
|
|
843
|
+
const code = e.code;
|
|
844
|
+
if (typeof code === "string") return code;
|
|
845
|
+
const meta = e.metadata;
|
|
846
|
+
if (meta && typeof meta === "object") {
|
|
847
|
+
const errorType = meta.error_type;
|
|
848
|
+
if (typeof errorType === "string") return errorType;
|
|
849
|
+
}
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
function providerId(err, message) {
|
|
853
|
+
if (typeof err.provider === "string") return err.provider;
|
|
854
|
+
if (/anthropic|claude/i.test(message)) return "anthropic";
|
|
855
|
+
if (/openai|gpt-/i.test(message)) return "openai";
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* A context-overflow message from any provider. Verified shapes (see research):
|
|
860
|
+
* - Anthropic: "prompt is too long: 1001955 tokens > 1000000 maximum"
|
|
861
|
+
* - Anthropic (1M beta): "Prompt exceeds maximum token limit (191989 > 180000)"
|
|
862
|
+
* - OpenAI / Vercel AI Gateway: "This model's maximum context length is N tokens..."
|
|
863
|
+
* - OpenRouter: "This endpoint's maximum context length is N tokens..."
|
|
864
|
+
* - Gemini: "The input token count (N) exceeds the maximum number of tokens allowed (M)"
|
|
865
|
+
*/
|
|
866
|
+
const CONTEXT_OVERFLOW_MESSAGE = /prompt is too long|context[_ ]length[_ ]exceeded|maximum context length|exceeds maximum token limit|input token count|too many tokens|exceeds the maximum/i;
|
|
867
|
+
/**
|
|
868
|
+
* Map a provider error to a stable code. Prefers the provider's machine code
|
|
869
|
+
* (OpenAI `error.code`, OpenRouter `error.metadata.error_type`) over message
|
|
870
|
+
* text, then falls back to status + message regex for providers that don't
|
|
871
|
+
* carry one (Anthropic, Gemini). Context overflow drives reactive compaction,
|
|
872
|
+
* so it's matched first and most specifically.
|
|
873
|
+
*/
|
|
874
|
+
function classifyCode(status, message, code) {
|
|
875
|
+
if (code === "context_length_exceeded" || CONTEXT_OVERFLOW_MESSAGE.test(message)) return "context_length_exceeded";
|
|
876
|
+
if (status === 402 || code === "insufficient_credits" || code === "insufficient_quota" || /insufficient credits|requires more credits|credit balance is too low|payment required|purchase more credits/i.test(message)) return "provider_out_of_credits";
|
|
877
|
+
if (status === 429 || code === "rate_limit_exceeded" || /rate[_ ]limit|too many requests|resource[_ ]exhausted/i.test(message)) return "rate_limited";
|
|
878
|
+
if (status === 529 || code === "provider_overloaded" || /overloaded/i.test(message)) return "provider_overloaded";
|
|
879
|
+
if (status === 503 || status === 504 || code === "provider_unavailable" || /no healthy upstream|upstream request timeout|stream timeout|service unavailable|unavailable|gateway/i.test(message)) return "provider_unavailable";
|
|
880
|
+
return "provider_error";
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* True when the thrown error looks like it originated from a model-provider
|
|
884
|
+
* call (it carries an HTTP status or a provider-shaped error body) rather than
|
|
885
|
+
* an arbitrary harness-internal throw. Conservative: when unsure, returns false
|
|
886
|
+
* so the caller keeps the existing generic handling.
|
|
887
|
+
*/
|
|
888
|
+
function isModelProviderError(err) {
|
|
889
|
+
if (err === null || typeof err !== "object") return false;
|
|
890
|
+
const e = err;
|
|
891
|
+
if (numericStatus(e) !== null) return true;
|
|
892
|
+
if (e.error && typeof e.error === "object") return true;
|
|
893
|
+
const msg = messageText(err);
|
|
894
|
+
return /prompt is too long|context[_ ]length[_ ]exceeded|exceeds maximum token limit|input token count|rate[_ ]limit|overloaded|no healthy upstream|upstream request timeout|insufficient credits|requires more credits|credit balance is too low|payment required|purchase more credits/i.test(msg);
|
|
895
|
+
}
|
|
896
|
+
/** Build the structured, forwardable error from a thrown model-provider error. */
|
|
897
|
+
function toModelProviderError(err) {
|
|
898
|
+
const e = err ?? {};
|
|
899
|
+
const status = numericStatus(e);
|
|
900
|
+
const message = messageText(err);
|
|
901
|
+
return {
|
|
902
|
+
code: classifyCode(status, message, machineCode(e)),
|
|
903
|
+
message,
|
|
904
|
+
provider: providerId(e, message),
|
|
905
|
+
type: providerErrorType(e),
|
|
906
|
+
upstreamStatus: status
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
//#endregion
|
|
910
|
+
//#region src/protocols/anthropic-messages.ts
|
|
911
|
+
/**
|
|
912
|
+
* Anthropic Messages API–compatible protocol handler.
|
|
913
|
+
*
|
|
914
|
+
* Catch-all: returns Response on match, null on no match.
|
|
915
|
+
* Zero server-framework deps — just Web Standard Request/Response.
|
|
916
|
+
*
|
|
917
|
+
* Routes:
|
|
918
|
+
* POST /v1/messages
|
|
919
|
+
* POST /v1/messages/:id/abort
|
|
920
|
+
*
|
|
921
|
+
* @see https://docs.anthropic.com/en/api/messages
|
|
922
|
+
*/
|
|
923
|
+
function sseEvent(writer, event, data) {
|
|
924
|
+
writer.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
925
|
+
}
|
|
926
|
+
async function extractContent$1(content, log) {
|
|
927
|
+
if (typeof content === "string") return {
|
|
928
|
+
text: content,
|
|
929
|
+
images: []
|
|
930
|
+
};
|
|
931
|
+
if (!Array.isArray(content)) return {
|
|
932
|
+
text: "",
|
|
933
|
+
images: []
|
|
934
|
+
};
|
|
935
|
+
let text = "";
|
|
936
|
+
const images = [];
|
|
937
|
+
for (const block of content) {
|
|
938
|
+
if (block?.type === "text") {
|
|
939
|
+
text += block.text ?? "";
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
if (block?.type === "image") {
|
|
943
|
+
const src = block.source;
|
|
944
|
+
if (src?.type === "base64" && src.media_type && src.data) images.push({
|
|
945
|
+
type: "image",
|
|
946
|
+
mimeType: src.media_type,
|
|
947
|
+
data: src.data
|
|
948
|
+
});
|
|
949
|
+
else if (src?.type === "url" && src.url) try {
|
|
950
|
+
images.push(await fetchImageAsBase64({ url: src.url }));
|
|
951
|
+
} catch (err) {
|
|
952
|
+
log.warn({
|
|
953
|
+
event: "image_fetch_failed",
|
|
954
|
+
url: src.url,
|
|
955
|
+
err
|
|
956
|
+
}, "image fetch failed");
|
|
957
|
+
text += `\n[image fetch failed: ${src.url}]\n`;
|
|
958
|
+
}
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
return {
|
|
963
|
+
text,
|
|
964
|
+
images
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
function extractSystemPrompt(system) {
|
|
968
|
+
if (typeof system === "string") return system;
|
|
969
|
+
if (!Array.isArray(system)) return "";
|
|
970
|
+
return system.filter((b) => b?.type === "text").map((b) => b.text ?? "").join("\n\n");
|
|
971
|
+
}
|
|
972
|
+
async function loadHistoryIntoSession$1({ sm, messages, upToIdxExclusive, modelName, log }) {
|
|
973
|
+
const toolCallNames = /* @__PURE__ */ new Map();
|
|
974
|
+
for (let i = 0; i < upToIdxExclusive; i++) {
|
|
975
|
+
const m = messages[i];
|
|
976
|
+
if (!m) continue;
|
|
977
|
+
if (m.role === "user") {
|
|
978
|
+
const { text, images } = await extractContent$1(m.content, log);
|
|
979
|
+
const content = images.length > 0 ? [...text ? [{
|
|
980
|
+
type: "text",
|
|
981
|
+
text
|
|
982
|
+
}] : [], ...images] : text;
|
|
983
|
+
if (typeof content === "string" && content.length > 0 || Array.isArray(content) && content.length > 0) sm.appendMessage({
|
|
984
|
+
role: "user",
|
|
985
|
+
content,
|
|
986
|
+
timestamp: Date.now()
|
|
987
|
+
});
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
if (m.role === "assistant") {
|
|
991
|
+
const contentArr = [];
|
|
992
|
+
const blocks = Array.isArray(m.content) ? m.content : [];
|
|
993
|
+
for (const b of blocks) if (b?.type === "text" && b.text) contentArr.push({
|
|
994
|
+
type: "text",
|
|
995
|
+
text: b.text
|
|
996
|
+
});
|
|
997
|
+
else if (b?.type === "tool_use") {
|
|
998
|
+
contentArr.push({
|
|
999
|
+
type: "toolCall",
|
|
1000
|
+
id: b.id,
|
|
1001
|
+
name: b.name,
|
|
1002
|
+
arguments: b.input ?? {}
|
|
1003
|
+
});
|
|
1004
|
+
toolCallNames.set(b.id, b.name);
|
|
1005
|
+
}
|
|
1006
|
+
if (contentArr.length === 0) continue;
|
|
1007
|
+
sm.appendMessage({
|
|
1008
|
+
role: "assistant",
|
|
1009
|
+
content: contentArr,
|
|
1010
|
+
api: "anthropic-messages",
|
|
1011
|
+
provider: "anthropic",
|
|
1012
|
+
model: modelName,
|
|
1013
|
+
usage: {
|
|
1014
|
+
input: 0,
|
|
1015
|
+
output: 0,
|
|
1016
|
+
cacheRead: 0,
|
|
1017
|
+
cacheWrite: 0,
|
|
1018
|
+
totalTokens: 0,
|
|
1019
|
+
cost: {
|
|
1020
|
+
input: 0,
|
|
1021
|
+
output: 0,
|
|
1022
|
+
cacheRead: 0,
|
|
1023
|
+
cacheWrite: 0,
|
|
1024
|
+
total: 0
|
|
1025
|
+
}
|
|
1026
|
+
},
|
|
1027
|
+
stopReason: contentArr.some((c) => c.type === "toolCall") ? "toolUse" : "stop",
|
|
1028
|
+
timestamp: Date.now()
|
|
1029
|
+
});
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
async function handleMessages(request, ctx, registry) {
|
|
1035
|
+
const { log, trace } = requestLogger(request);
|
|
1036
|
+
let parsed;
|
|
1037
|
+
try {
|
|
1038
|
+
parsed = JSON.parse(await request.text());
|
|
1039
|
+
} catch {
|
|
1040
|
+
return jsonError(400, "invalid json");
|
|
1041
|
+
}
|
|
1042
|
+
const { messages, stream = false, model: modelInput, system, x_model: modelSpecInput } = parsed ?? {};
|
|
1043
|
+
if (!Array.isArray(messages) || messages.length === 0) return jsonError(400, "messages array required");
|
|
1044
|
+
let lastUserIdx = -1;
|
|
1045
|
+
for (let i = messages.length - 1; i >= 0; i--) if (messages[i]?.role === "user") {
|
|
1046
|
+
lastUserIdx = i;
|
|
1047
|
+
break;
|
|
1048
|
+
}
|
|
1049
|
+
if (lastUserIdx < 0) return jsonError(400, "no user message");
|
|
1050
|
+
const { text: prompt, images } = await extractContent$1(messages[lastUserIdx].content, log);
|
|
1051
|
+
if (!prompt && images.length === 0) return jsonError(400, "user message has no content");
|
|
1052
|
+
const { model, modelSpec } = resolveRequestModel({
|
|
1053
|
+
modelInput,
|
|
1054
|
+
modelSpecInput,
|
|
1055
|
+
defaultModel: ctx.defaultModel,
|
|
1056
|
+
log
|
|
1057
|
+
});
|
|
1058
|
+
const modelName = model.id ?? "claude-opus-4-7";
|
|
1059
|
+
const baseSystemPrompt = extractSystemPrompt(system);
|
|
1060
|
+
const { sessionId } = parseSessionId(request);
|
|
1061
|
+
const shellEnv = parseShellEnv(request);
|
|
1062
|
+
const { session, sessionManager: sm } = await ctx.createSession({
|
|
1063
|
+
cwd: ctx.cwd,
|
|
1064
|
+
sessionId,
|
|
1065
|
+
perRequestApiKeys: withSpecApiKey(resolvePerRequestApiKeys(request), modelSpec),
|
|
1066
|
+
systemPromptOverride: () => baseSystemPrompt.length > 0 ? baseSystemPrompt : void 0,
|
|
1067
|
+
shellEnv,
|
|
1068
|
+
model,
|
|
1069
|
+
thinkingLevel: null,
|
|
1070
|
+
log,
|
|
1071
|
+
onSessionSetup: (args) => ctx.onSessionSetup?.(args),
|
|
1072
|
+
prepare: ({ sessionManager }) => loadHistoryIntoSession$1({
|
|
1073
|
+
sm: sessionManager,
|
|
1074
|
+
messages,
|
|
1075
|
+
upToIdxExclusive: lastUserIdx,
|
|
1076
|
+
modelName,
|
|
1077
|
+
log
|
|
1078
|
+
})
|
|
1079
|
+
});
|
|
1080
|
+
registry.set(sessionId, session);
|
|
1081
|
+
const id = `msg_${randomUUID().replace(/-/g, "").slice(0, 20)}`;
|
|
1082
|
+
if (stream) {
|
|
1083
|
+
const response = runStream$2({
|
|
1084
|
+
session,
|
|
1085
|
+
sm,
|
|
1086
|
+
prompt,
|
|
1087
|
+
images,
|
|
1088
|
+
id,
|
|
1089
|
+
sessionId,
|
|
1090
|
+
modelName,
|
|
1091
|
+
log,
|
|
1092
|
+
postPrompt: ctx.postPrompt,
|
|
1093
|
+
registry
|
|
1094
|
+
});
|
|
1095
|
+
const traceId = parseTraceId(trace.traceparent);
|
|
1096
|
+
if (traceId) response.headers.set("x-trace-id", traceId);
|
|
1097
|
+
return response;
|
|
1098
|
+
}
|
|
1099
|
+
const response = await runBlocking$2({
|
|
1100
|
+
session,
|
|
1101
|
+
sm,
|
|
1102
|
+
prompt,
|
|
1103
|
+
images,
|
|
1104
|
+
id,
|
|
1105
|
+
sessionId,
|
|
1106
|
+
modelName,
|
|
1107
|
+
log,
|
|
1108
|
+
postPrompt: ctx.postPrompt,
|
|
1109
|
+
registry
|
|
1110
|
+
});
|
|
1111
|
+
const traceId = parseTraceId(trace.traceparent);
|
|
1112
|
+
if (traceId) response.headers.set("x-trace-id", traceId);
|
|
1113
|
+
return response;
|
|
1114
|
+
}
|
|
1115
|
+
function runStream$2({ session, sm, prompt, images, id, sessionId, modelName, log, postPrompt, registry }) {
|
|
1116
|
+
return createSSEResponse(async (writer) => {
|
|
1117
|
+
let contentBlockIndex = 0;
|
|
1118
|
+
let textBlockOpen = false;
|
|
1119
|
+
const tcByContentIdx = /* @__PURE__ */ new Map();
|
|
1120
|
+
sseEvent(writer, "message_start", {
|
|
1121
|
+
type: "message_start",
|
|
1122
|
+
message: {
|
|
1123
|
+
id,
|
|
1124
|
+
type: "message",
|
|
1125
|
+
role: "assistant",
|
|
1126
|
+
model: modelName,
|
|
1127
|
+
content: [],
|
|
1128
|
+
stop_reason: null,
|
|
1129
|
+
stop_sequence: null,
|
|
1130
|
+
usage: {
|
|
1131
|
+
input_tokens: 0,
|
|
1132
|
+
cache_creation_input_tokens: 0,
|
|
1133
|
+
cache_read_input_tokens: 0,
|
|
1134
|
+
output_tokens: 0
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
sseEvent(writer, "ping", { type: "ping" });
|
|
1139
|
+
const ensureTextBlock = () => {
|
|
1140
|
+
if (textBlockOpen) return;
|
|
1141
|
+
sseEvent(writer, "content_block_start", {
|
|
1142
|
+
type: "content_block_start",
|
|
1143
|
+
index: contentBlockIndex,
|
|
1144
|
+
content_block: {
|
|
1145
|
+
type: "text",
|
|
1146
|
+
text: ""
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
textBlockOpen = true;
|
|
1150
|
+
};
|
|
1151
|
+
const closeTextBlock = () => {
|
|
1152
|
+
if (!textBlockOpen) return;
|
|
1153
|
+
sseEvent(writer, "content_block_stop", {
|
|
1154
|
+
type: "content_block_stop",
|
|
1155
|
+
index: contentBlockIndex
|
|
1156
|
+
});
|
|
1157
|
+
contentBlockIndex++;
|
|
1158
|
+
textBlockOpen = false;
|
|
1159
|
+
};
|
|
1160
|
+
let capturedModelError = null;
|
|
1161
|
+
const emitProviderError = (providerError) => {
|
|
1162
|
+
log.warn({
|
|
1163
|
+
event: "model_provider_error",
|
|
1164
|
+
code: providerError.code,
|
|
1165
|
+
upstream_status: providerError.upstreamStatus,
|
|
1166
|
+
provider: providerError.provider
|
|
1167
|
+
}, "forwarding model-provider error to client");
|
|
1168
|
+
sseEvent(writer, "error", {
|
|
1169
|
+
type: "error",
|
|
1170
|
+
error: {
|
|
1171
|
+
type: providerError.type ?? "api_error",
|
|
1172
|
+
message: providerError.message,
|
|
1173
|
+
x_model_provider_error: {
|
|
1174
|
+
code: providerError.code,
|
|
1175
|
+
provider: providerError.provider,
|
|
1176
|
+
upstream_status: providerError.upstreamStatus
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
};
|
|
1181
|
+
session.subscribe((event) => {
|
|
1182
|
+
const ev = event;
|
|
1183
|
+
const endedMessage = ev.message ?? (Array.isArray(ev.messages) ? ev.messages[ev.messages.length - 1] : null);
|
|
1184
|
+
if (endedMessage?.stopReason === "error" && typeof endedMessage.errorMessage === "string" && endedMessage.errorMessage.length > 0) capturedModelError = endedMessage.errorMessage;
|
|
1185
|
+
if (ev.type !== "message_update") return;
|
|
1186
|
+
const inner = ev.assistantMessageEvent;
|
|
1187
|
+
if (!inner) return;
|
|
1188
|
+
if (inner.type === "text_delta") {
|
|
1189
|
+
ensureTextBlock();
|
|
1190
|
+
sseEvent(writer, "content_block_delta", {
|
|
1191
|
+
type: "content_block_delta",
|
|
1192
|
+
index: contentBlockIndex,
|
|
1193
|
+
delta: {
|
|
1194
|
+
type: "text_delta",
|
|
1195
|
+
text: inner.delta ?? ""
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
if (inner.type === "toolcall_start") {
|
|
1201
|
+
closeTextBlock();
|
|
1202
|
+
const idx = inner.contentIndex;
|
|
1203
|
+
const item = inner.partial?.content?.[idx];
|
|
1204
|
+
const tc = item?.type === "toolCall" ? item : void 0;
|
|
1205
|
+
const blockIdx = contentBlockIndex++;
|
|
1206
|
+
tcByContentIdx.set(idx, blockIdx);
|
|
1207
|
+
sseEvent(writer, "content_block_start", {
|
|
1208
|
+
type: "content_block_start",
|
|
1209
|
+
index: blockIdx,
|
|
1210
|
+
content_block: {
|
|
1211
|
+
type: "tool_use",
|
|
1212
|
+
id: tc?.id ?? `toolu_${blockIdx}`,
|
|
1213
|
+
name: tc?.name ?? ""
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
if (inner.type === "toolcall_delta") {
|
|
1219
|
+
const blockIdx = tcByContentIdx.get(inner.contentIndex);
|
|
1220
|
+
if (blockIdx === void 0) return;
|
|
1221
|
+
sseEvent(writer, "content_block_delta", {
|
|
1222
|
+
type: "content_block_delta",
|
|
1223
|
+
index: blockIdx,
|
|
1224
|
+
delta: {
|
|
1225
|
+
type: "input_json_delta",
|
|
1226
|
+
partial_json: inner.delta ?? ""
|
|
1227
|
+
}
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
let stopReason = "end_turn";
|
|
1232
|
+
const outputTokens = 0;
|
|
1233
|
+
try {
|
|
1234
|
+
await runConversation({
|
|
1235
|
+
session,
|
|
1236
|
+
prompt,
|
|
1237
|
+
images,
|
|
1238
|
+
log,
|
|
1239
|
+
postPrompt
|
|
1240
|
+
});
|
|
1241
|
+
if (capturedModelError !== null) emitProviderError(toModelProviderError(new Error(capturedModelError)));
|
|
1242
|
+
} catch (err) {
|
|
1243
|
+
log.error({
|
|
1244
|
+
err,
|
|
1245
|
+
event: "anthropic_stream_error"
|
|
1246
|
+
}, "stream error");
|
|
1247
|
+
stopReason = "end_turn";
|
|
1248
|
+
if (isModelProviderError(err)) emitProviderError(toModelProviderError(err));
|
|
1249
|
+
} finally {
|
|
1250
|
+
registry.delete(sessionId);
|
|
1251
|
+
}
|
|
1252
|
+
closeTextBlock();
|
|
1253
|
+
for (const blockIdx of tcByContentIdx.values()) sseEvent(writer, "content_block_stop", {
|
|
1254
|
+
type: "content_block_stop",
|
|
1255
|
+
index: blockIdx
|
|
1256
|
+
});
|
|
1257
|
+
if (tcByContentIdx.size > 0) stopReason = "tool_use";
|
|
1258
|
+
sseEvent(writer, "message_delta", {
|
|
1259
|
+
type: "message_delta",
|
|
1260
|
+
delta: {
|
|
1261
|
+
stop_reason: stopReason,
|
|
1262
|
+
stop_sequence: null
|
|
1263
|
+
},
|
|
1264
|
+
usage: { output_tokens: outputTokens }
|
|
1265
|
+
});
|
|
1266
|
+
sseEvent(writer, "message_stop", { type: "message_stop" });
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
async function runBlocking$2({ session, sm, prompt, images, id, sessionId, modelName, log, postPrompt, registry }) {
|
|
1270
|
+
let text = "";
|
|
1271
|
+
const toolUseBlocks = [];
|
|
1272
|
+
const tcByContentIdx = /* @__PURE__ */ new Map();
|
|
1273
|
+
session.subscribe((event) => {
|
|
1274
|
+
if (event.type === "message_update") {
|
|
1275
|
+
const inner = event.assistantMessageEvent;
|
|
1276
|
+
if (inner?.type === "text_delta") {
|
|
1277
|
+
text += inner.delta ?? "";
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
if (inner?.type === "toolcall_start") {
|
|
1281
|
+
const idx = inner.contentIndex;
|
|
1282
|
+
const item = inner.partial?.content?.[idx];
|
|
1283
|
+
tcByContentIdx.set(idx, toolUseBlocks.length);
|
|
1284
|
+
toolUseBlocks.push({
|
|
1285
|
+
type: "tool_use",
|
|
1286
|
+
id: item?.id ?? `toolu_${toolUseBlocks.length}`,
|
|
1287
|
+
name: item?.name ?? "",
|
|
1288
|
+
input: {}
|
|
1289
|
+
});
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
if (inner?.type === "toolcall_delta") {
|
|
1293
|
+
const i = tcByContentIdx.get(inner.contentIndex);
|
|
1294
|
+
if (i !== void 0) {
|
|
1295
|
+
const block = toolUseBlocks[i];
|
|
1296
|
+
if (block && block.type === "tool_use") block.input = (typeof block.input === "string" ? block.input : "") + (inner.delta ?? "");
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
try {
|
|
1302
|
+
await runConversation({
|
|
1303
|
+
session,
|
|
1304
|
+
prompt,
|
|
1305
|
+
images,
|
|
1306
|
+
log,
|
|
1307
|
+
postPrompt
|
|
1308
|
+
});
|
|
1309
|
+
} catch (err) {
|
|
1310
|
+
log.error({
|
|
1311
|
+
err,
|
|
1312
|
+
event: "anthropic_blocking_error"
|
|
1313
|
+
}, "blocking error");
|
|
1314
|
+
return jsonError(500, err?.message ?? String(err));
|
|
1315
|
+
} finally {
|
|
1316
|
+
registry.delete(sessionId);
|
|
1317
|
+
}
|
|
1318
|
+
for (const block of toolUseBlocks) if (block.type === "tool_use" && typeof block.input === "string") try {
|
|
1319
|
+
block.input = JSON.parse(block.input);
|
|
1320
|
+
} catch {
|
|
1321
|
+
block.input = {};
|
|
1322
|
+
}
|
|
1323
|
+
const content = [];
|
|
1324
|
+
if (text) content.push({
|
|
1325
|
+
type: "text",
|
|
1326
|
+
text
|
|
1327
|
+
});
|
|
1328
|
+
content.push(...toolUseBlocks);
|
|
1329
|
+
return jsonResponse({
|
|
1330
|
+
id,
|
|
1331
|
+
type: "message",
|
|
1332
|
+
role: "assistant",
|
|
1333
|
+
content,
|
|
1334
|
+
model: modelName,
|
|
1335
|
+
stop_reason: toolUseBlocks.length > 0 ? "tool_use" : "end_turn",
|
|
1336
|
+
stop_sequence: null,
|
|
1337
|
+
usage: {
|
|
1338
|
+
input_tokens: 0,
|
|
1339
|
+
output_tokens: 0
|
|
1340
|
+
}
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
function create$2(options) {
|
|
1344
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1345
|
+
const registry = options.sessionRegistry ?? createMapSessionRegistry();
|
|
1346
|
+
const ctx = {
|
|
1347
|
+
...options,
|
|
1348
|
+
cwd
|
|
1349
|
+
};
|
|
1350
|
+
return async (request) => {
|
|
1351
|
+
const { pathname } = new URL(request.url);
|
|
1352
|
+
if (request.method !== "POST") return null;
|
|
1353
|
+
const abortSessionId = pathname.match(/^\/v1\/messages\/([^/]+)\/abort$/)?.[1];
|
|
1354
|
+
if (abortSessionId) try {
|
|
1355
|
+
return await runInTraceContext(() => handleAbort({
|
|
1356
|
+
request,
|
|
1357
|
+
sessionId: abortSessionId,
|
|
1358
|
+
registry
|
|
1359
|
+
}));
|
|
1360
|
+
} catch (err) {
|
|
1361
|
+
const { log } = requestLogger(request);
|
|
1362
|
+
log.error({
|
|
1363
|
+
err,
|
|
1364
|
+
event: "anthropic_messages_abort_unhandled_error"
|
|
1365
|
+
}, "unhandled error");
|
|
1366
|
+
return jsonError(500, "internal error");
|
|
1367
|
+
}
|
|
1368
|
+
if (pathname !== "/v1/messages") return null;
|
|
1369
|
+
try {
|
|
1370
|
+
return await runInTraceContext(() => handleMessages(request, ctx, registry));
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
const { log } = requestLogger(request);
|
|
1373
|
+
log.error({
|
|
1374
|
+
err,
|
|
1375
|
+
event: "anthropic_messages_unhandled_error"
|
|
1376
|
+
}, "unhandled error");
|
|
1377
|
+
return jsonError(500, "internal error");
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
//#endregion
|
|
1382
|
+
//#region src/protocols/chat-completions.ts
|
|
1383
|
+
/**
|
|
1384
|
+
* OpenAI Chat Completions–compatible protocol handler.
|
|
1385
|
+
*
|
|
1386
|
+
* Catch-all: returns Response on match, null on no match.
|
|
1387
|
+
* Zero framework deps — just Web Standard Request/Response.
|
|
1388
|
+
*
|
|
1389
|
+
* Routes:
|
|
1390
|
+
* POST /v1/chat/completions
|
|
1391
|
+
* POST /v1/chat/completions/:id/steer
|
|
1392
|
+
* POST /v1/chat/completions/:id/abort
|
|
1393
|
+
*
|
|
1394
|
+
* @see https://platform.openai.com/docs/api-reference/chat/create
|
|
1395
|
+
* @see PROTOCOL.md for custom extensions
|
|
1396
|
+
*/
|
|
1397
|
+
const ZERO_USAGE = {
|
|
1398
|
+
input: 0,
|
|
1399
|
+
output: 0,
|
|
1400
|
+
cacheRead: 0,
|
|
1401
|
+
cacheWrite: 0,
|
|
1402
|
+
totalTokens: 0,
|
|
1403
|
+
cost: {
|
|
1404
|
+
input: 0,
|
|
1405
|
+
output: 0,
|
|
1406
|
+
cacheRead: 0,
|
|
1407
|
+
cacheWrite: 0,
|
|
1408
|
+
total: 0
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
function extractUsageFromAgentEnd(event) {
|
|
1412
|
+
const u = extractAgentEndUsage(event);
|
|
1413
|
+
if (!u) return null;
|
|
1414
|
+
return {
|
|
1415
|
+
prompt_tokens: u.input,
|
|
1416
|
+
completion_tokens: u.output,
|
|
1417
|
+
total_tokens: u.totalTokens,
|
|
1418
|
+
cache_read_tokens: u.cacheRead,
|
|
1419
|
+
cache_creation_tokens: u.cacheWrite
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
function isDebug(request) {
|
|
1423
|
+
const h = request.headers.get("x-debug");
|
|
1424
|
+
return h === "1" || h === "true";
|
|
1425
|
+
}
|
|
1426
|
+
function parseRequestStartMs(request) {
|
|
1427
|
+
const h = request.headers.get("x-request-start-ms");
|
|
1428
|
+
if (h == null) return null;
|
|
1429
|
+
const n = Number(h);
|
|
1430
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
1431
|
+
}
|
|
1432
|
+
function withPassthroughHeaders({ model, request, prefixes }) {
|
|
1433
|
+
if (prefixes.length === 0) return model;
|
|
1434
|
+
const forwarded = {};
|
|
1435
|
+
request.headers.forEach((value, key) => {
|
|
1436
|
+
if (prefixes.some((p) => key.startsWith(p))) forwarded[key] = value;
|
|
1437
|
+
});
|
|
1438
|
+
if (Object.keys(forwarded).length === 0) return model;
|
|
1439
|
+
const existing = "headers" in model && model.headers != null && typeof model.headers === "object" && !Array.isArray(model.headers) ? model.headers : {};
|
|
1440
|
+
return {
|
|
1441
|
+
...model,
|
|
1442
|
+
headers: {
|
|
1443
|
+
...existing,
|
|
1444
|
+
...forwarded
|
|
1445
|
+
}
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
async function extractContent(content, { userAgent, log }) {
|
|
1449
|
+
if (typeof content === "string") return {
|
|
1450
|
+
text: content,
|
|
1451
|
+
images: []
|
|
1452
|
+
};
|
|
1453
|
+
if (!Array.isArray(content)) return {
|
|
1454
|
+
text: "",
|
|
1455
|
+
images: []
|
|
1456
|
+
};
|
|
1457
|
+
let text = "";
|
|
1458
|
+
const images = [];
|
|
1459
|
+
for (const part of content) {
|
|
1460
|
+
if (typeof part === "string") {
|
|
1461
|
+
text += part;
|
|
1462
|
+
continue;
|
|
1463
|
+
}
|
|
1464
|
+
if (part?.type === "text") {
|
|
1465
|
+
text += part.text ?? "";
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1468
|
+
if (part?.type === "image_url") {
|
|
1469
|
+
const url = String(part.image_url?.url ?? "");
|
|
1470
|
+
const dataUrl = url.match(/^data:([^;]+);base64,(.+)$/);
|
|
1471
|
+
if (dataUrl) images.push({
|
|
1472
|
+
type: "image",
|
|
1473
|
+
mimeType: dataUrl[1],
|
|
1474
|
+
data: dataUrl[2]
|
|
1475
|
+
});
|
|
1476
|
+
else if (/^https?:\/\//i.test(url)) try {
|
|
1477
|
+
images.push(await fetchImageAsBase64({
|
|
1478
|
+
url,
|
|
1479
|
+
userAgent
|
|
1480
|
+
}));
|
|
1481
|
+
} catch (err) {
|
|
1482
|
+
log.warn({
|
|
1483
|
+
event: "image_fetch_failed",
|
|
1484
|
+
url,
|
|
1485
|
+
err
|
|
1486
|
+
}, "image fetch failed");
|
|
1487
|
+
text += `\n[image fetch failed: ${url}: ${err?.message ?? err}]\n`;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
return {
|
|
1492
|
+
text,
|
|
1493
|
+
images
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
function extractAssistantText(content) {
|
|
1497
|
+
if (typeof content === "string") return content;
|
|
1498
|
+
if (!Array.isArray(content)) return "";
|
|
1499
|
+
return content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("");
|
|
1500
|
+
}
|
|
1501
|
+
function countMessageStats(messages) {
|
|
1502
|
+
let chars = 0;
|
|
1503
|
+
let images = 0;
|
|
1504
|
+
for (const m of messages || []) {
|
|
1505
|
+
const c = m?.content;
|
|
1506
|
+
if (typeof c === "string") chars += c.length;
|
|
1507
|
+
else if (Array.isArray(c)) {
|
|
1508
|
+
for (const p of c) if (typeof p === "string") chars += p.length;
|
|
1509
|
+
else if (p?.type === "text" && typeof p.text === "string") chars += p.text.length;
|
|
1510
|
+
else if (p?.type === "image_url") images += 1;
|
|
1511
|
+
}
|
|
1512
|
+
if (Array.isArray(m?.tool_calls)) for (const tc of m.tool_calls) {
|
|
1513
|
+
const args = tc?.function?.arguments;
|
|
1514
|
+
if (typeof args === "string") chars += args.length;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
return {
|
|
1518
|
+
chars,
|
|
1519
|
+
images
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
async function loadHistoryIntoSession({ sm, messages, upToIdxExclusive, modelName, userAgent, log }) {
|
|
1523
|
+
const toolCallNames = /* @__PURE__ */ new Map();
|
|
1524
|
+
for (let i = 0; i < upToIdxExclusive; i++) {
|
|
1525
|
+
const m = messages[i];
|
|
1526
|
+
if (!m) continue;
|
|
1527
|
+
if (m.role === "user") {
|
|
1528
|
+
const { text, images } = await extractContent(m.content, {
|
|
1529
|
+
userAgent,
|
|
1530
|
+
log
|
|
1531
|
+
});
|
|
1532
|
+
const content = images.length > 0 ? [...text ? [{
|
|
1533
|
+
type: "text",
|
|
1534
|
+
text
|
|
1535
|
+
}] : [], ...images] : text;
|
|
1536
|
+
if (typeof content === "string" && content.length > 0 || Array.isArray(content) && content.length > 0) sm.appendMessage({
|
|
1537
|
+
role: "user",
|
|
1538
|
+
content,
|
|
1539
|
+
timestamp: Date.now()
|
|
1540
|
+
});
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
if (m.role === "assistant") {
|
|
1544
|
+
const text = extractAssistantText(m.content);
|
|
1545
|
+
const contentArr = [];
|
|
1546
|
+
if (Array.isArray(m.x_thinking)) for (const t of m.x_thinking) {
|
|
1547
|
+
const tt = String(t?.text ?? "");
|
|
1548
|
+
if (!tt) continue;
|
|
1549
|
+
contentArr.push({
|
|
1550
|
+
type: "thinking",
|
|
1551
|
+
thinking: tt,
|
|
1552
|
+
...t?.signature ? { thinkingSignature: t.signature } : {},
|
|
1553
|
+
...t?.redacted ? { redacted: true } : {}
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
if (text) contentArr.push({
|
|
1557
|
+
type: "text",
|
|
1558
|
+
text
|
|
1559
|
+
});
|
|
1560
|
+
if (Array.isArray(m.tool_calls)) for (const tc of m.tool_calls) {
|
|
1561
|
+
const name = tc?.function?.name;
|
|
1562
|
+
if (!tc?.id || !name) continue;
|
|
1563
|
+
let args = {};
|
|
1564
|
+
try {
|
|
1565
|
+
args = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
|
|
1566
|
+
} catch {
|
|
1567
|
+
args = { _raw: tc.function.arguments };
|
|
1568
|
+
}
|
|
1569
|
+
contentArr.push({
|
|
1570
|
+
type: "toolCall",
|
|
1571
|
+
id: tc.id,
|
|
1572
|
+
name,
|
|
1573
|
+
arguments: args
|
|
1574
|
+
});
|
|
1575
|
+
toolCallNames.set(tc.id, name);
|
|
1576
|
+
}
|
|
1577
|
+
if (contentArr.length === 0) continue;
|
|
1578
|
+
sm.appendMessage({
|
|
1579
|
+
role: "assistant",
|
|
1580
|
+
content: contentArr,
|
|
1581
|
+
api: "anthropic-messages",
|
|
1582
|
+
provider: "anthropic",
|
|
1583
|
+
model: modelName,
|
|
1584
|
+
usage: ZERO_USAGE,
|
|
1585
|
+
stopReason: contentArr.some((c) => c.type === "toolCall") ? "toolUse" : "stop",
|
|
1586
|
+
timestamp: Date.now()
|
|
1587
|
+
});
|
|
1588
|
+
continue;
|
|
1589
|
+
}
|
|
1590
|
+
if (m.role === "tool") {
|
|
1591
|
+
const text = typeof m.content === "string" ? m.content : Array.isArray(m.content) ? m.content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("") : "";
|
|
1592
|
+
const toolCallId = String(m.tool_call_id ?? "");
|
|
1593
|
+
sm.appendMessage({
|
|
1594
|
+
role: "toolResult",
|
|
1595
|
+
toolCallId,
|
|
1596
|
+
toolName: toolCallNames.get(toolCallId) ?? "",
|
|
1597
|
+
content: [{
|
|
1598
|
+
type: "text",
|
|
1599
|
+
text
|
|
1600
|
+
}],
|
|
1601
|
+
isError: Boolean(m.x_is_error),
|
|
1602
|
+
timestamp: Date.now()
|
|
1603
|
+
});
|
|
1604
|
+
continue;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
function piMessagesToOpenAI(messages) {
|
|
1609
|
+
const out = [];
|
|
1610
|
+
for (const m of messages) {
|
|
1611
|
+
if (m?.role === "assistant") {
|
|
1612
|
+
let textContent = "";
|
|
1613
|
+
const toolCalls = [];
|
|
1614
|
+
const thinking = [];
|
|
1615
|
+
const content = Array.isArray(m.content) ? m.content : [];
|
|
1616
|
+
for (const c of content) if (c?.type === "text") textContent += c.text ?? "";
|
|
1617
|
+
else if (c?.type === "thinking") thinking.push({
|
|
1618
|
+
text: String(c.thinking ?? ""),
|
|
1619
|
+
...c.thinkingSignature ? { signature: String(c.thinkingSignature) } : {},
|
|
1620
|
+
...c.redacted ? { redacted: true } : {}
|
|
1621
|
+
});
|
|
1622
|
+
else if (c?.type === "toolCall") toolCalls.push({
|
|
1623
|
+
id: String(c.id),
|
|
1624
|
+
type: "function",
|
|
1625
|
+
function: {
|
|
1626
|
+
name: String(c.name ?? ""),
|
|
1627
|
+
arguments: JSON.stringify(c.arguments ?? {})
|
|
1628
|
+
}
|
|
1629
|
+
});
|
|
1630
|
+
const msg = {
|
|
1631
|
+
role: "assistant",
|
|
1632
|
+
content: textContent || null
|
|
1633
|
+
};
|
|
1634
|
+
if (toolCalls.length) msg.tool_calls = toolCalls;
|
|
1635
|
+
if (thinking.length) msg.x_thinking = thinking;
|
|
1636
|
+
out.push(msg);
|
|
1637
|
+
continue;
|
|
1638
|
+
}
|
|
1639
|
+
if (m?.role === "toolResult") {
|
|
1640
|
+
const text = Array.isArray(m.content) ? m.content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("") : "";
|
|
1641
|
+
const tm = {
|
|
1642
|
+
role: "tool",
|
|
1643
|
+
tool_call_id: String(m.toolCallId ?? ""),
|
|
1644
|
+
content: text
|
|
1645
|
+
};
|
|
1646
|
+
if (m.isError) tm.x_is_error = true;
|
|
1647
|
+
out.push(tm);
|
|
1648
|
+
continue;
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
return out;
|
|
1652
|
+
}
|
|
1653
|
+
async function handleChatCompletions(request, ctx, registry, pendingSteerIds) {
|
|
1654
|
+
const reqStart = performance.now();
|
|
1655
|
+
const upstreamStartMs = parseRequestStartMs(request);
|
|
1656
|
+
const { log: baseLog, trace } = requestLogger(request);
|
|
1657
|
+
const log = isDebug(request) ? baseLog.child({}, { level: "debug" }) : baseLog;
|
|
1658
|
+
let parsed;
|
|
1659
|
+
try {
|
|
1660
|
+
parsed = JSON.parse(await request.text());
|
|
1661
|
+
} catch {
|
|
1662
|
+
return jsonError(400, "invalid json");
|
|
1663
|
+
}
|
|
1664
|
+
const { messages, stream = false, model: modelInput, thinkingLevel: thinkingInput, context_window: contextWindowInput, x_model: modelSpecInput } = parsed ?? {};
|
|
1665
|
+
if (!Array.isArray(messages) || messages.length === 0) return jsonError(400, "messages array required");
|
|
1666
|
+
let lastUserIdx = -1;
|
|
1667
|
+
for (let i = messages.length - 1; i >= 0; i--) if (messages[i]?.role === "user") {
|
|
1668
|
+
lastUserIdx = i;
|
|
1669
|
+
break;
|
|
1670
|
+
}
|
|
1671
|
+
if (lastUserIdx < 0) return jsonError(400, "no user message in history");
|
|
1672
|
+
const lastUser = messages[lastUserIdx];
|
|
1673
|
+
const reqUA = request.headers.get("user-agent") ?? void 0;
|
|
1674
|
+
const { text: prompt, images } = await extractContent(lastUser.content, {
|
|
1675
|
+
userAgent: reqUA,
|
|
1676
|
+
log
|
|
1677
|
+
});
|
|
1678
|
+
if (!prompt && images.length === 0) return jsonError(400, "user message has no content");
|
|
1679
|
+
const { model: requestModel, modelSpec } = resolveRequestModel({
|
|
1680
|
+
modelInput,
|
|
1681
|
+
modelSpecInput,
|
|
1682
|
+
defaultModel: ctx.defaultModel,
|
|
1683
|
+
log
|
|
1684
|
+
});
|
|
1685
|
+
const resolvedModel = applyContextWindowOverride(requestModel, contextWindowInput);
|
|
1686
|
+
const model = withPassthroughHeaders({
|
|
1687
|
+
model: resolvedModel,
|
|
1688
|
+
request,
|
|
1689
|
+
prefixes: ctx.passthroughHeaderPrefixes ?? []
|
|
1690
|
+
});
|
|
1691
|
+
const thinkingLevel = resolveThinkingLevel(thinkingInput);
|
|
1692
|
+
const modelName = model.id ?? "claude-opus-4-7";
|
|
1693
|
+
const requestSystemPrompts = [];
|
|
1694
|
+
for (const m of messages) if (m?.role === "system") {
|
|
1695
|
+
const text = extractAssistantText(m.content);
|
|
1696
|
+
if (text) requestSystemPrompts.push(text);
|
|
1697
|
+
}
|
|
1698
|
+
const baseSystemPrompt = requestSystemPrompts.filter((p) => p.length > 0).join("\n\n");
|
|
1699
|
+
const parsedSession = parseSessionId(request);
|
|
1700
|
+
if (parsedSession.invalid) return jsonError(400, "x-session-id must be a valid UUID");
|
|
1701
|
+
const sessionId = parsedSession.source === "header" ? parsedSession.sessionId : `chatcmpl-${parsedSession.sessionId}`;
|
|
1702
|
+
const shellEnv = parseShellEnv(request);
|
|
1703
|
+
const sessionSetupStart = performance.now();
|
|
1704
|
+
const [{ session, sessionManager: sm }, customTools] = await Promise.all([ctx.createSession({
|
|
1705
|
+
cwd: ctx.cwd,
|
|
1706
|
+
sessionId,
|
|
1707
|
+
perRequestApiKeys: withSpecApiKey(resolvePerRequestApiKeys(request), modelSpec),
|
|
1708
|
+
systemPromptOverride: () => baseSystemPrompt.length > 0 ? baseSystemPrompt : void 0,
|
|
1709
|
+
shellEnv,
|
|
1710
|
+
model,
|
|
1711
|
+
thinkingLevel,
|
|
1712
|
+
log,
|
|
1713
|
+
onSessionSetup: (args) => ctx.onSessionSetup?.(args),
|
|
1714
|
+
prepare: ({ sessionManager }) => loadHistoryIntoSession({
|
|
1715
|
+
sm: sessionManager,
|
|
1716
|
+
messages,
|
|
1717
|
+
upToIdxExclusive: lastUserIdx,
|
|
1718
|
+
modelName,
|
|
1719
|
+
userAgent: reqUA,
|
|
1720
|
+
log
|
|
1721
|
+
})
|
|
1722
|
+
}), ctx.getTools ? ctx.getTools(request) : void 0]);
|
|
1723
|
+
const sessionSetupMs = Math.round(performance.now() - sessionSetupStart);
|
|
1724
|
+
const created = Math.floor(Date.now() / 1e3);
|
|
1725
|
+
registry.set(sessionId, session);
|
|
1726
|
+
const stats = countMessageStats(messages);
|
|
1727
|
+
log.info({
|
|
1728
|
+
event: "req_received",
|
|
1729
|
+
chatcmpl_id: sessionId,
|
|
1730
|
+
turns: messages.length,
|
|
1731
|
+
chars: stats.chars,
|
|
1732
|
+
images: stats.images,
|
|
1733
|
+
stream,
|
|
1734
|
+
model: modelName,
|
|
1735
|
+
x_model: modelSpec !== null,
|
|
1736
|
+
context_window: resolvedModel.contextWindow,
|
|
1737
|
+
setup_ms: Math.round(performance.now() - reqStart),
|
|
1738
|
+
session_setup_ms: sessionSetupMs,
|
|
1739
|
+
upstream_setup_ms: upstreamStartMs !== null ? Date.now() - upstreamStartMs : null
|
|
1740
|
+
}, "chat req received");
|
|
1741
|
+
if (stream) {
|
|
1742
|
+
const response = runStream$1({
|
|
1743
|
+
session,
|
|
1744
|
+
sm,
|
|
1745
|
+
prompt,
|
|
1746
|
+
images,
|
|
1747
|
+
sessionId,
|
|
1748
|
+
created,
|
|
1749
|
+
reqStart,
|
|
1750
|
+
upstreamStartMs,
|
|
1751
|
+
log,
|
|
1752
|
+
trace,
|
|
1753
|
+
postPrompt: ctx.postPrompt,
|
|
1754
|
+
registry,
|
|
1755
|
+
pendingSteerIds
|
|
1756
|
+
});
|
|
1757
|
+
const traceId = parseTraceId(trace.traceparent);
|
|
1758
|
+
if (traceId) response.headers.set("x-trace-id", traceId);
|
|
1759
|
+
return response;
|
|
1760
|
+
}
|
|
1761
|
+
const response = await runBlocking$1({
|
|
1762
|
+
session,
|
|
1763
|
+
sm,
|
|
1764
|
+
prompt,
|
|
1765
|
+
images,
|
|
1766
|
+
sessionId,
|
|
1767
|
+
created,
|
|
1768
|
+
reqStart,
|
|
1769
|
+
upstreamStartMs,
|
|
1770
|
+
log,
|
|
1771
|
+
postPrompt: ctx.postPrompt,
|
|
1772
|
+
registry,
|
|
1773
|
+
pendingSteerIds
|
|
1774
|
+
});
|
|
1775
|
+
const traceId = parseTraceId(trace.traceparent);
|
|
1776
|
+
if (traceId) response.headers.set("x-trace-id", traceId);
|
|
1777
|
+
return response;
|
|
1778
|
+
}
|
|
1779
|
+
function runStream$1({ session, sm, prompt, images, sessionId, created, reqStart, upstreamStartMs, log, trace, postPrompt, registry, pendingSteerIds }) {
|
|
1780
|
+
return createSSEResponse(async (writer) => {
|
|
1781
|
+
writer.write(": ready\n\n");
|
|
1782
|
+
const baselineMessageCount = sm.buildSessionContext().messages.length;
|
|
1783
|
+
let roleEmitted = false;
|
|
1784
|
+
let firstChunkAt = null;
|
|
1785
|
+
const tcByContentIdx = /* @__PURE__ */ new Map();
|
|
1786
|
+
let nextTcIdx = 0;
|
|
1787
|
+
let lastSteeringCount = 0;
|
|
1788
|
+
let usage = null;
|
|
1789
|
+
let lastFollowUpCount = 0;
|
|
1790
|
+
let capturedModelError = null;
|
|
1791
|
+
const sendChunk = (delta, finishReason) => {
|
|
1792
|
+
if (firstChunkAt === null) {
|
|
1793
|
+
firstChunkAt = performance.now();
|
|
1794
|
+
log.info({
|
|
1795
|
+
event: "ttft",
|
|
1796
|
+
chatcmpl_id: sessionId,
|
|
1797
|
+
ttft_ms: Math.round(firstChunkAt - reqStart),
|
|
1798
|
+
upstream_ttft_ms: upstreamStartMs !== null ? Date.now() - upstreamStartMs : null
|
|
1799
|
+
}, "time to first chunk");
|
|
1800
|
+
}
|
|
1801
|
+
const chunk = {
|
|
1802
|
+
id: sessionId,
|
|
1803
|
+
object: "chat.completion.chunk",
|
|
1804
|
+
created,
|
|
1805
|
+
choices: [{
|
|
1806
|
+
index: 0,
|
|
1807
|
+
delta,
|
|
1808
|
+
finish_reason: finishReason ?? null
|
|
1809
|
+
}]
|
|
1810
|
+
};
|
|
1811
|
+
writer.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
1812
|
+
};
|
|
1813
|
+
const ensureRole = () => {
|
|
1814
|
+
if (roleEmitted) return;
|
|
1815
|
+
sendChunk({ role: "assistant" });
|
|
1816
|
+
roleEmitted = true;
|
|
1817
|
+
};
|
|
1818
|
+
let _evCount = 0;
|
|
1819
|
+
session.subscribe((event) => {
|
|
1820
|
+
const ev = event;
|
|
1821
|
+
const endedMessage = ev.message ?? (Array.isArray(ev.messages) ? ev.messages[ev.messages.length - 1] : null);
|
|
1822
|
+
if (endedMessage?.stopReason === "error" && typeof endedMessage.errorMessage === "string" && endedMessage.errorMessage.length > 0) capturedModelError = endedMessage.errorMessage;
|
|
1823
|
+
if (ev.type === "queue_update") {
|
|
1824
|
+
const steering = Array.isArray(ev.steering) ? ev.steering.length : 0;
|
|
1825
|
+
const followUp = Array.isArray(ev.followUp) ? ev.followUp.length : 0;
|
|
1826
|
+
const queues = pendingSteerIds.get(sessionId);
|
|
1827
|
+
const consumedIds = [];
|
|
1828
|
+
const consumedSteer = lastSteeringCount - steering;
|
|
1829
|
+
if (consumedSteer > 0 && queues && queues.steer.length > 0) consumedIds.push(...queues.steer.splice(0, consumedSteer));
|
|
1830
|
+
const consumedFollowUp = lastFollowUpCount - followUp;
|
|
1831
|
+
if (consumedFollowUp > 0 && queues && queues.followUp.length > 0) consumedIds.push(...queues.followUp.splice(0, consumedFollowUp));
|
|
1832
|
+
lastSteeringCount = steering;
|
|
1833
|
+
lastFollowUpCount = followUp;
|
|
1834
|
+
sendChunk({ x_queue_update: {
|
|
1835
|
+
steering,
|
|
1836
|
+
follow_up: followUp,
|
|
1837
|
+
...consumedIds.length > 0 ? { consumed_steer_ids: consumedIds } : {}
|
|
1838
|
+
} });
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
_evCount++;
|
|
1842
|
+
const innerType = ev.type === "message_update" ? ev.assistantMessageEvent?.type ?? null : null;
|
|
1843
|
+
const messageContentSummary = ev.type === "message_update" || ev.type === "message_end" ? Array.isArray(ev.message?.content) ? ev.message.content.map((c) => ({
|
|
1844
|
+
type: c?.type,
|
|
1845
|
+
...c?.type === "text" ? { textLen: (c.text ?? "").length } : {},
|
|
1846
|
+
...c?.type === "thinking" ? { thinkingLen: (c.thinking ?? "").length } : {},
|
|
1847
|
+
...c?.type === "toolCall" ? { name: c.name } : {}
|
|
1848
|
+
})) : null : null;
|
|
1849
|
+
log.info({
|
|
1850
|
+
chatcmpl_id: sessionId,
|
|
1851
|
+
evIdx: _evCount,
|
|
1852
|
+
eventType: ev.type,
|
|
1853
|
+
messageRole: ev.message?.role ?? null,
|
|
1854
|
+
stopReason: ev.message?.stopReason ?? null,
|
|
1855
|
+
innerType,
|
|
1856
|
+
...innerType === "text_delta" || innerType === "thinking_delta" ? { delta: String(ev.assistantMessageEvent?.delta ?? "").slice(0, 200) } : {},
|
|
1857
|
+
...messageContentSummary ? { messageContent: messageContentSummary } : {},
|
|
1858
|
+
...ev.type === "message_start" || ev.type === "message_end" ? { messageJson: JSON.stringify(ev.message).slice(0, 500) } : {}
|
|
1859
|
+
}, "harness session event");
|
|
1860
|
+
if (ev.type === "tool_execution_start") {
|
|
1861
|
+
sendChunk({ x_tool_execution_start: { tool_call_id: String(ev.toolCallId ?? "") } });
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
if (ev.type === "tool_execution_end") {
|
|
1865
|
+
const text = Array.isArray(ev.result?.content) ? ev.result.content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("") : typeof ev.result === "string" ? ev.result : "";
|
|
1866
|
+
sendChunk({ x_tool_call_result: {
|
|
1867
|
+
tool_call_id: String(ev.toolCallId ?? ""),
|
|
1868
|
+
content: text,
|
|
1869
|
+
...ev.isError ? { is_error: true } : {}
|
|
1870
|
+
} });
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
if (ev.type === "agent_end") {
|
|
1874
|
+
usage = extractUsageFromAgentEnd(ev) ?? usage;
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
if (ev.type !== "message_update") return;
|
|
1878
|
+
const inner = ev.assistantMessageEvent;
|
|
1879
|
+
const t = inner?.type;
|
|
1880
|
+
if (t === "text_delta") {
|
|
1881
|
+
ensureRole();
|
|
1882
|
+
sendChunk({ content: inner.delta ?? "" });
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
if (t === "thinking_delta") {
|
|
1886
|
+
sendChunk({ x_thinking_delta: inner.delta ?? "" });
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
if (t === "toolcall_start") {
|
|
1890
|
+
const idx = inner.contentIndex;
|
|
1891
|
+
const item = inner.partial?.content?.[idx];
|
|
1892
|
+
const tc = item?.type === "toolCall" ? item : void 0;
|
|
1893
|
+
const openaiIdx = nextTcIdx++;
|
|
1894
|
+
tcByContentIdx.set(idx, openaiIdx);
|
|
1895
|
+
ensureRole();
|
|
1896
|
+
sendChunk({ tool_calls: [{
|
|
1897
|
+
index: openaiIdx,
|
|
1898
|
+
id: tc?.id ?? `call_${openaiIdx}`,
|
|
1899
|
+
type: "function",
|
|
1900
|
+
function: {
|
|
1901
|
+
name: tc?.name ?? "",
|
|
1902
|
+
arguments: ""
|
|
1903
|
+
}
|
|
1904
|
+
}] });
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
if (t === "toolcall_delta") {
|
|
1908
|
+
const openaiIdx = tcByContentIdx.get(inner.contentIndex);
|
|
1909
|
+
if (openaiIdx === void 0) return;
|
|
1910
|
+
sendChunk({ tool_calls: [{
|
|
1911
|
+
index: openaiIdx,
|
|
1912
|
+
function: { arguments: inner.delta ?? "" }
|
|
1913
|
+
}] });
|
|
1914
|
+
}
|
|
1915
|
+
});
|
|
1916
|
+
try {
|
|
1917
|
+
await runConversation({
|
|
1918
|
+
session,
|
|
1919
|
+
prompt,
|
|
1920
|
+
images,
|
|
1921
|
+
log,
|
|
1922
|
+
postPrompt
|
|
1923
|
+
});
|
|
1924
|
+
if (capturedModelError !== null) {
|
|
1925
|
+
const providerError = toModelProviderError(new Error(capturedModelError));
|
|
1926
|
+
log.warn({
|
|
1927
|
+
event: "model_provider_error",
|
|
1928
|
+
chatcmpl_id: sessionId,
|
|
1929
|
+
code: providerError.code,
|
|
1930
|
+
upstream_status: providerError.upstreamStatus,
|
|
1931
|
+
provider: providerError.provider,
|
|
1932
|
+
source: "stop_reason_error"
|
|
1933
|
+
}, "forwarding model-provider error to client");
|
|
1934
|
+
emitModelProviderError({
|
|
1935
|
+
writer,
|
|
1936
|
+
sessionId,
|
|
1937
|
+
created,
|
|
1938
|
+
providerError
|
|
1939
|
+
});
|
|
1940
|
+
writer.write("data: [DONE]\n\n");
|
|
1941
|
+
} else {
|
|
1942
|
+
emitSessionMessagesTrailer({
|
|
1943
|
+
writer,
|
|
1944
|
+
sm,
|
|
1945
|
+
baselineMessageCount,
|
|
1946
|
+
sessionId,
|
|
1947
|
+
created,
|
|
1948
|
+
usage
|
|
1949
|
+
});
|
|
1950
|
+
sendChunk({}, "stop");
|
|
1951
|
+
writer.write("data: [DONE]\n\n");
|
|
1952
|
+
}
|
|
1953
|
+
} catch (err) {
|
|
1954
|
+
log.error({
|
|
1955
|
+
event: "chat_error",
|
|
1956
|
+
chatcmpl_id: sessionId,
|
|
1957
|
+
err
|
|
1958
|
+
}, "chat error");
|
|
1959
|
+
if (isModelProviderError(err)) {
|
|
1960
|
+
const providerError = toModelProviderError(err);
|
|
1961
|
+
log.warn({
|
|
1962
|
+
event: "model_provider_error",
|
|
1963
|
+
chatcmpl_id: sessionId,
|
|
1964
|
+
code: providerError.code,
|
|
1965
|
+
upstream_status: providerError.upstreamStatus,
|
|
1966
|
+
provider: providerError.provider
|
|
1967
|
+
}, "forwarding model-provider error to client");
|
|
1968
|
+
emitModelProviderError({
|
|
1969
|
+
writer,
|
|
1970
|
+
sessionId,
|
|
1971
|
+
created,
|
|
1972
|
+
providerError
|
|
1973
|
+
});
|
|
1974
|
+
} else sendChunk({ content: `\n[error: ${err?.message ?? err}]` }, "stop");
|
|
1975
|
+
writer.write("data: [DONE]\n\n");
|
|
1976
|
+
} finally {
|
|
1977
|
+
registry.delete(sessionId);
|
|
1978
|
+
pendingSteerIds.delete(sessionId);
|
|
1979
|
+
}
|
|
1980
|
+
log.info({
|
|
1981
|
+
event: "stream_done",
|
|
1982
|
+
chatcmpl_id: sessionId,
|
|
1983
|
+
duration_ms: Math.round(performance.now() - reqStart),
|
|
1984
|
+
upstream_duration_ms: upstreamStartMs !== null ? Date.now() - upstreamStartMs : null
|
|
1985
|
+
}, "stream done");
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
function emitSessionMessagesTrailer({ writer, sm, baselineMessageCount, sessionId, created, usage }) {
|
|
1989
|
+
const sessionMessages = piMessagesToOpenAI(sm.buildSessionContext().messages.slice(baselineMessageCount));
|
|
1990
|
+
if (sessionMessages.length === 0 && !usage) return;
|
|
1991
|
+
const chunk = {
|
|
1992
|
+
id: sessionId,
|
|
1993
|
+
object: "chat.completion.chunk",
|
|
1994
|
+
created,
|
|
1995
|
+
choices: [{
|
|
1996
|
+
index: 0,
|
|
1997
|
+
delta: {},
|
|
1998
|
+
finish_reason: null
|
|
1999
|
+
}],
|
|
2000
|
+
...sessionMessages.length ? { x_session_messages: sessionMessages } : {},
|
|
2001
|
+
...usage ? { usage } : {}
|
|
2002
|
+
};
|
|
2003
|
+
writer.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* Emit a terminal chunk carrying a structured model-provider error under the
|
|
2007
|
+
* `x_model_provider_error` extension field, so the worker can react on the
|
|
2008
|
+
* forwarded provider error (e.g. compact + retry on `context_length_exceeded`)
|
|
2009
|
+
* rather than seeing an ambiguous empty stream. `finish_reason: 'stop'`
|
|
2010
|
+
* cleanly closes the stream for any OpenAI-shaped reader that ignores the
|
|
2011
|
+
* extension field.
|
|
2012
|
+
*/
|
|
2013
|
+
function emitModelProviderError({ writer, sessionId, created, providerError }) {
|
|
2014
|
+
const chunk = {
|
|
2015
|
+
id: sessionId,
|
|
2016
|
+
object: "chat.completion.chunk",
|
|
2017
|
+
created,
|
|
2018
|
+
choices: [{
|
|
2019
|
+
index: 0,
|
|
2020
|
+
delta: {},
|
|
2021
|
+
finish_reason: "stop"
|
|
2022
|
+
}],
|
|
2023
|
+
x_model_provider_error: {
|
|
2024
|
+
code: providerError.code,
|
|
2025
|
+
message: providerError.message,
|
|
2026
|
+
provider: providerError.provider,
|
|
2027
|
+
type: providerError.type,
|
|
2028
|
+
upstream_status: providerError.upstreamStatus
|
|
2029
|
+
}
|
|
2030
|
+
};
|
|
2031
|
+
writer.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
2032
|
+
}
|
|
2033
|
+
async function runBlocking$1({ session, sm, prompt, images, sessionId, created, reqStart, upstreamStartMs, log, postPrompt, registry, pendingSteerIds }) {
|
|
2034
|
+
const baselineMessageCount = sm.buildSessionContext().messages.length;
|
|
2035
|
+
let content = "";
|
|
2036
|
+
const toolCalls = [];
|
|
2037
|
+
const tcByContentIdx = /* @__PURE__ */ new Map();
|
|
2038
|
+
let usage = null;
|
|
2039
|
+
session.subscribe((event) => {
|
|
2040
|
+
if (event.type === "message_update") {
|
|
2041
|
+
const inner = event.assistantMessageEvent;
|
|
2042
|
+
const t = inner?.type;
|
|
2043
|
+
if (t === "text_delta") {
|
|
2044
|
+
content += inner.delta ?? "";
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
if (t === "toolcall_start") {
|
|
2048
|
+
const idx = inner.contentIndex;
|
|
2049
|
+
const item = inner.partial?.content?.[idx];
|
|
2050
|
+
tcByContentIdx.set(idx, toolCalls.length);
|
|
2051
|
+
toolCalls.push({
|
|
2052
|
+
id: item?.id ?? `call_${toolCalls.length}`,
|
|
2053
|
+
type: "function",
|
|
2054
|
+
function: {
|
|
2055
|
+
name: item?.name ?? "",
|
|
2056
|
+
arguments: ""
|
|
2057
|
+
}
|
|
2058
|
+
});
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
if (t === "toolcall_delta") {
|
|
2062
|
+
const i = tcByContentIdx.get(inner.contentIndex);
|
|
2063
|
+
if (i !== void 0 && toolCalls[i]) toolCalls[i].function.arguments += inner.delta ?? "";
|
|
2064
|
+
}
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
if (event.type === "agent_end") usage = extractUsageFromAgentEnd(event) ?? usage;
|
|
2068
|
+
});
|
|
2069
|
+
try {
|
|
2070
|
+
await runConversation({
|
|
2071
|
+
session,
|
|
2072
|
+
prompt,
|
|
2073
|
+
images,
|
|
2074
|
+
log,
|
|
2075
|
+
postPrompt
|
|
2076
|
+
});
|
|
2077
|
+
} catch (err) {
|
|
2078
|
+
log.error({
|
|
2079
|
+
event: "chat_error",
|
|
2080
|
+
chatcmpl_id: sessionId,
|
|
2081
|
+
err
|
|
2082
|
+
}, "chat error");
|
|
2083
|
+
return jsonError(500, err?.message ?? String(err));
|
|
2084
|
+
} finally {
|
|
2085
|
+
registry.delete(sessionId);
|
|
2086
|
+
pendingSteerIds.delete(sessionId);
|
|
2087
|
+
}
|
|
2088
|
+
const message = {
|
|
2089
|
+
role: "assistant",
|
|
2090
|
+
content: content || null
|
|
2091
|
+
};
|
|
2092
|
+
if (toolCalls.length) message.tool_calls = toolCalls;
|
|
2093
|
+
const all = sm.buildSessionContext().messages;
|
|
2094
|
+
const sessionMessages = piMessagesToOpenAI(all.slice(baselineMessageCount));
|
|
2095
|
+
const response = {
|
|
2096
|
+
id: sessionId,
|
|
2097
|
+
object: "chat.completion",
|
|
2098
|
+
created,
|
|
2099
|
+
choices: [{
|
|
2100
|
+
index: 0,
|
|
2101
|
+
message,
|
|
2102
|
+
finish_reason: "stop"
|
|
2103
|
+
}],
|
|
2104
|
+
...usage ? { usage } : {},
|
|
2105
|
+
...sessionMessages.length ? { x_session_messages: sessionMessages } : {}
|
|
2106
|
+
};
|
|
2107
|
+
log.info({
|
|
2108
|
+
event: "blocking_done",
|
|
2109
|
+
chatcmpl_id: sessionId,
|
|
2110
|
+
duration_ms: Math.round(performance.now() - reqStart),
|
|
2111
|
+
upstream_duration_ms: upstreamStartMs !== null ? Date.now() - upstreamStartMs : null
|
|
2112
|
+
}, "blocking done");
|
|
2113
|
+
return new Response(JSON.stringify(response), {
|
|
2114
|
+
status: 200,
|
|
2115
|
+
headers: { "Content-Type": "application/json" }
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
const steerContentPartSchema = z.union([z.object({
|
|
2119
|
+
type: z.literal("text"),
|
|
2120
|
+
text: z.string()
|
|
2121
|
+
}), z.object({
|
|
2122
|
+
type: z.literal("image_url"),
|
|
2123
|
+
image_url: z.object({ url: z.string() })
|
|
2124
|
+
})]);
|
|
2125
|
+
const steerRequestSchema = z.object({
|
|
2126
|
+
content: z.union([z.string().min(1), z.array(steerContentPartSchema).min(1)]).optional(),
|
|
2127
|
+
mode: z.enum(["steer", "follow-up"]).default("steer"),
|
|
2128
|
+
text: z.string().optional()
|
|
2129
|
+
}).refine((v) => v.content !== void 0 || v.text !== void 0, { message: "content or text is required" });
|
|
2130
|
+
z.string().uuid();
|
|
2131
|
+
async function handleSteer(request, sessionId, registry, pendingSteerIds) {
|
|
2132
|
+
const { log } = requestLogger(request);
|
|
2133
|
+
const session = registry.get(sessionId);
|
|
2134
|
+
if (!session) return jsonError(404, "session not found or already completed");
|
|
2135
|
+
let body;
|
|
2136
|
+
try {
|
|
2137
|
+
body = JSON.parse(await request.text());
|
|
2138
|
+
} catch {
|
|
2139
|
+
return jsonError(400, "invalid json");
|
|
2140
|
+
}
|
|
2141
|
+
const parsed = steerRequestSchema.safeParse(body);
|
|
2142
|
+
if (!parsed.success) return jsonError(400, parsed.error.message);
|
|
2143
|
+
const { mode } = parsed.data;
|
|
2144
|
+
const { text, images } = await extractContent(parsed.data.content ?? parsed.data.text ?? "", {
|
|
2145
|
+
userAgent: request.headers.get("user-agent") ?? void 0,
|
|
2146
|
+
log
|
|
2147
|
+
});
|
|
2148
|
+
if (!text && images.length === 0) return jsonError(400, "steer has no content");
|
|
2149
|
+
const steerId = randomUUID();
|
|
2150
|
+
let queues = pendingSteerIds.get(sessionId);
|
|
2151
|
+
if (!queues) {
|
|
2152
|
+
queues = {
|
|
2153
|
+
steer: [],
|
|
2154
|
+
followUp: []
|
|
2155
|
+
};
|
|
2156
|
+
pendingSteerIds.set(sessionId, queues);
|
|
2157
|
+
}
|
|
2158
|
+
if (mode === "follow-up") {
|
|
2159
|
+
queues.followUp.push(steerId);
|
|
2160
|
+
await session.followUp(text, images.length > 0 ? images : void 0);
|
|
2161
|
+
} else {
|
|
2162
|
+
queues.steer.push(steerId);
|
|
2163
|
+
await session.steer(text, images.length > 0 ? images : void 0);
|
|
2164
|
+
}
|
|
2165
|
+
log.info({
|
|
2166
|
+
event: "steer_accepted",
|
|
2167
|
+
session_id: sessionId,
|
|
2168
|
+
steer_id: steerId,
|
|
2169
|
+
mode
|
|
2170
|
+
}, "steering message queued");
|
|
2171
|
+
return jsonResponse({
|
|
2172
|
+
ok: true,
|
|
2173
|
+
id: steerId
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
function create$1(options) {
|
|
2177
|
+
const cwd = options.cwd ?? process.cwd();
|
|
2178
|
+
const registry = options.sessionRegistry ?? createMapSessionRegistry();
|
|
2179
|
+
const pendingSteerIds = /* @__PURE__ */ new Map();
|
|
2180
|
+
const ctx = {
|
|
2181
|
+
...options,
|
|
2182
|
+
cwd
|
|
2183
|
+
};
|
|
2184
|
+
return async (request) => {
|
|
2185
|
+
const { pathname } = new URL(request.url);
|
|
2186
|
+
if (request.method === "POST") {
|
|
2187
|
+
const steerSessionId = pathname.match(/^\/v1\/chat\/completions\/([^/]+)\/steer$/)?.[1];
|
|
2188
|
+
if (steerSessionId) try {
|
|
2189
|
+
return await runInTraceContext(() => handleSteer(request, steerSessionId, registry, pendingSteerIds));
|
|
2190
|
+
} catch (err) {
|
|
2191
|
+
const { log } = requestLogger(request);
|
|
2192
|
+
log.error({
|
|
2193
|
+
err,
|
|
2194
|
+
event: "steer_unhandled_error"
|
|
2195
|
+
}, "unhandled error");
|
|
2196
|
+
return jsonError(500, "internal error");
|
|
2197
|
+
}
|
|
2198
|
+
const abortSessionId = pathname.match(/^\/v1\/chat\/completions\/([^/]+)\/abort$/)?.[1];
|
|
2199
|
+
if (abortSessionId) try {
|
|
2200
|
+
return await runInTraceContext(() => handleAbort({
|
|
2201
|
+
request,
|
|
2202
|
+
sessionId: abortSessionId,
|
|
2203
|
+
registry
|
|
2204
|
+
}));
|
|
2205
|
+
} catch (err) {
|
|
2206
|
+
const { log } = requestLogger(request);
|
|
2207
|
+
log.error({
|
|
2208
|
+
err,
|
|
2209
|
+
event: "abort_unhandled_error"
|
|
2210
|
+
}, "unhandled error");
|
|
2211
|
+
return jsonError(500, "internal error");
|
|
2212
|
+
}
|
|
2213
|
+
if (pathname === "/v1/chat/completions") try {
|
|
2214
|
+
return await runInTraceContext(() => handleChatCompletions(request, ctx, registry, pendingSteerIds));
|
|
2215
|
+
} catch (err) {
|
|
2216
|
+
const { log } = requestLogger(request);
|
|
2217
|
+
log.error({
|
|
2218
|
+
err,
|
|
2219
|
+
event: "chat_completions_unhandled_error"
|
|
2220
|
+
}, "unhandled error");
|
|
2221
|
+
return jsonError(500, "internal error");
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
return null;
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
//#endregion
|
|
2228
|
+
//#region src/protocols/responses.ts
|
|
2229
|
+
/**
|
|
2230
|
+
* OpenAI Responses API–compatible protocol handler.
|
|
2231
|
+
*
|
|
2232
|
+
* Catch-all: returns Response on match, null on no match.
|
|
2233
|
+
* Zero server-framework deps — just Web Standard Request/Response.
|
|
2234
|
+
*
|
|
2235
|
+
* Routes:
|
|
2236
|
+
* POST /v1/responses
|
|
2237
|
+
* POST /v1/responses/:id/abort
|
|
2238
|
+
*
|
|
2239
|
+
* @see https://platform.openai.com/docs/api-reference/responses
|
|
2240
|
+
*/
|
|
2241
|
+
async function extractInputContent(input, log) {
|
|
2242
|
+
let systemPrompt = "";
|
|
2243
|
+
const history = [];
|
|
2244
|
+
let lastUserText = "";
|
|
2245
|
+
const images = [];
|
|
2246
|
+
if (typeof input === "string") return {
|
|
2247
|
+
prompt: input,
|
|
2248
|
+
images: [],
|
|
2249
|
+
systemPrompt: "",
|
|
2250
|
+
history: []
|
|
2251
|
+
};
|
|
2252
|
+
if (!Array.isArray(input)) return {
|
|
2253
|
+
prompt: "",
|
|
2254
|
+
images: [],
|
|
2255
|
+
systemPrompt: "",
|
|
2256
|
+
history: []
|
|
2257
|
+
};
|
|
2258
|
+
for (const item of input) {
|
|
2259
|
+
if (item?.type === "message") {
|
|
2260
|
+
if (item.role === "developer" || item.role === "system") {
|
|
2261
|
+
const text = extractTextFromContent(item.content);
|
|
2262
|
+
if (text) systemPrompt += (systemPrompt ? "\n\n" : "") + text;
|
|
2263
|
+
continue;
|
|
2264
|
+
}
|
|
2265
|
+
if (item.role === "user") {
|
|
2266
|
+
const text = extractTextFromContent(item.content);
|
|
2267
|
+
const imgs = await extractImagesFromContent(item.content, log);
|
|
2268
|
+
lastUserText = text;
|
|
2269
|
+
images.push(...imgs);
|
|
2270
|
+
history.push(item);
|
|
2271
|
+
continue;
|
|
2272
|
+
}
|
|
2273
|
+
if (item.role === "assistant") {
|
|
2274
|
+
history.push(item);
|
|
2275
|
+
continue;
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
if (item?.type === "function_call_output") history.push(item);
|
|
2279
|
+
}
|
|
2280
|
+
return {
|
|
2281
|
+
prompt: lastUserText,
|
|
2282
|
+
images,
|
|
2283
|
+
systemPrompt,
|
|
2284
|
+
history
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
function extractTextFromContent(content) {
|
|
2288
|
+
if (typeof content === "string") return content;
|
|
2289
|
+
if (!Array.isArray(content)) return "";
|
|
2290
|
+
return content.filter((p) => p?.type === "input_text" || p?.type === "text").map((p) => p.text ?? "").join("");
|
|
2291
|
+
}
|
|
2292
|
+
async function extractImagesFromContent(content, log) {
|
|
2293
|
+
if (!Array.isArray(content)) return [];
|
|
2294
|
+
const images = [];
|
|
2295
|
+
for (const part of content) if (part?.type === "input_image") {
|
|
2296
|
+
const url = part.image_url ?? part.url;
|
|
2297
|
+
if (typeof url === "string" && /^https?:\/\//i.test(url)) try {
|
|
2298
|
+
images.push(await fetchImageAsBase64({ url }));
|
|
2299
|
+
} catch (err) {
|
|
2300
|
+
log.warn({
|
|
2301
|
+
event: "image_fetch_failed",
|
|
2302
|
+
url,
|
|
2303
|
+
err
|
|
2304
|
+
}, "image fetch failed");
|
|
2305
|
+
}
|
|
2306
|
+
else if (part.data && part.media_type) images.push({
|
|
2307
|
+
type: "image",
|
|
2308
|
+
mimeType: part.media_type,
|
|
2309
|
+
data: part.data
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
return images;
|
|
2313
|
+
}
|
|
2314
|
+
async function handleResponses(request, ctx, registry) {
|
|
2315
|
+
const { log, trace } = requestLogger(request);
|
|
2316
|
+
let parsed;
|
|
2317
|
+
try {
|
|
2318
|
+
parsed = JSON.parse(await request.text());
|
|
2319
|
+
} catch {
|
|
2320
|
+
return jsonError(400, "invalid json");
|
|
2321
|
+
}
|
|
2322
|
+
const { input, stream = false, model: modelInput, instructions, x_model: modelSpecInput } = parsed ?? {};
|
|
2323
|
+
if (input == null) return jsonError(400, "input required");
|
|
2324
|
+
const extracted = await extractInputContent(input, log);
|
|
2325
|
+
const { prompt } = extracted;
|
|
2326
|
+
const { images, systemPrompt: inputSystemPrompt } = extracted;
|
|
2327
|
+
if (!prompt && images.length === 0) return jsonError(400, "no user content in input");
|
|
2328
|
+
const systemPrompt = [typeof instructions === "string" ? instructions : "", inputSystemPrompt].filter(Boolean).join("\n\n");
|
|
2329
|
+
const { model, modelSpec } = resolveRequestModel({
|
|
2330
|
+
modelInput,
|
|
2331
|
+
modelSpecInput,
|
|
2332
|
+
defaultModel: ctx.defaultModel,
|
|
2333
|
+
log
|
|
2334
|
+
});
|
|
2335
|
+
const modelName = model.id ?? "claude-opus-4-7";
|
|
2336
|
+
const { sessionId } = parseSessionId(request);
|
|
2337
|
+
const shellEnv = parseShellEnv(request);
|
|
2338
|
+
const { session } = await ctx.createSession({
|
|
2339
|
+
cwd: ctx.cwd,
|
|
2340
|
+
sessionId,
|
|
2341
|
+
perRequestApiKeys: withSpecApiKey(resolvePerRequestApiKeys(request), modelSpec),
|
|
2342
|
+
systemPromptOverride: () => systemPrompt.length > 0 ? systemPrompt : void 0,
|
|
2343
|
+
shellEnv,
|
|
2344
|
+
model,
|
|
2345
|
+
thinkingLevel: null,
|
|
2346
|
+
log,
|
|
2347
|
+
onSessionSetup: (args) => ctx.onSessionSetup?.(args),
|
|
2348
|
+
prepare: null
|
|
2349
|
+
});
|
|
2350
|
+
registry.set(sessionId, session);
|
|
2351
|
+
const responseId = `resp_${randomUUID().replace(/-/g, "").slice(0, 20)}`;
|
|
2352
|
+
if (stream) {
|
|
2353
|
+
const response = runStream({
|
|
2354
|
+
session,
|
|
2355
|
+
prompt,
|
|
2356
|
+
images,
|
|
2357
|
+
responseId,
|
|
2358
|
+
sessionId,
|
|
2359
|
+
modelName,
|
|
2360
|
+
log,
|
|
2361
|
+
postPrompt: ctx.postPrompt,
|
|
2362
|
+
registry
|
|
2363
|
+
});
|
|
2364
|
+
const traceId = parseTraceId(trace.traceparent);
|
|
2365
|
+
if (traceId) response.headers.set("x-trace-id", traceId);
|
|
2366
|
+
return response;
|
|
2367
|
+
}
|
|
2368
|
+
const response = await runBlocking({
|
|
2369
|
+
session,
|
|
2370
|
+
prompt,
|
|
2371
|
+
images,
|
|
2372
|
+
responseId,
|
|
2373
|
+
sessionId,
|
|
2374
|
+
modelName,
|
|
2375
|
+
log,
|
|
2376
|
+
postPrompt: ctx.postPrompt,
|
|
2377
|
+
registry
|
|
2378
|
+
});
|
|
2379
|
+
const traceId = parseTraceId(trace.traceparent);
|
|
2380
|
+
if (traceId) response.headers.set("x-trace-id", traceId);
|
|
2381
|
+
return response;
|
|
2382
|
+
}
|
|
2383
|
+
function runStream({ session, prompt, images, responseId, sessionId, modelName, log, postPrompt, registry }) {
|
|
2384
|
+
return createSSEResponse(async (writer) => {
|
|
2385
|
+
let seq = 0;
|
|
2386
|
+
let textContent = "";
|
|
2387
|
+
const outputItems = [];
|
|
2388
|
+
const tcByContentIdx = /* @__PURE__ */ new Map();
|
|
2389
|
+
let nextOutputIndex = 0;
|
|
2390
|
+
let capturedModelError = null;
|
|
2391
|
+
const send = (event) => {
|
|
2392
|
+
writer.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
2393
|
+
};
|
|
2394
|
+
const failWithProviderError = (providerError) => {
|
|
2395
|
+
log.warn({
|
|
2396
|
+
event: "model_provider_error",
|
|
2397
|
+
code: providerError.code,
|
|
2398
|
+
upstream_status: providerError.upstreamStatus,
|
|
2399
|
+
provider: providerError.provider
|
|
2400
|
+
}, "forwarding model-provider error to client");
|
|
2401
|
+
send({
|
|
2402
|
+
type: "response.failed",
|
|
2403
|
+
sequence_number: seq++,
|
|
2404
|
+
response: {
|
|
2405
|
+
id: responseId,
|
|
2406
|
+
status: "failed",
|
|
2407
|
+
error: {
|
|
2408
|
+
code: providerError.code,
|
|
2409
|
+
message: providerError.message,
|
|
2410
|
+
x_model_provider_error: {
|
|
2411
|
+
provider: providerError.provider,
|
|
2412
|
+
type: providerError.type,
|
|
2413
|
+
upstream_status: providerError.upstreamStatus
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
});
|
|
2418
|
+
};
|
|
2419
|
+
send({
|
|
2420
|
+
type: "response.created",
|
|
2421
|
+
sequence_number: seq++,
|
|
2422
|
+
response: {
|
|
2423
|
+
id: responseId,
|
|
2424
|
+
object: "response",
|
|
2425
|
+
status: "in_progress",
|
|
2426
|
+
output: [],
|
|
2427
|
+
model: modelName,
|
|
2428
|
+
created_at: Math.floor(Date.now() / 1e3)
|
|
2429
|
+
}
|
|
2430
|
+
});
|
|
2431
|
+
let textItemEmitted = false;
|
|
2432
|
+
const ensureTextItem = () => {
|
|
2433
|
+
if (textItemEmitted) return;
|
|
2434
|
+
textItemEmitted = true;
|
|
2435
|
+
send({
|
|
2436
|
+
type: "response.output_item.added",
|
|
2437
|
+
sequence_number: seq++,
|
|
2438
|
+
output_index: nextOutputIndex,
|
|
2439
|
+
item: {
|
|
2440
|
+
type: "message",
|
|
2441
|
+
id: `item_${randomUUID().slice(0, 8)}`,
|
|
2442
|
+
role: "assistant",
|
|
2443
|
+
content: []
|
|
2444
|
+
}
|
|
2445
|
+
});
|
|
2446
|
+
};
|
|
2447
|
+
session.subscribe((event) => {
|
|
2448
|
+
const ev = event;
|
|
2449
|
+
const endedMessage = ev.message ?? (Array.isArray(ev.messages) ? ev.messages[ev.messages.length - 1] : null);
|
|
2450
|
+
if (endedMessage?.stopReason === "error" && typeof endedMessage.errorMessage === "string" && endedMessage.errorMessage.length > 0) capturedModelError = endedMessage.errorMessage;
|
|
2451
|
+
if (ev.type !== "message_update") return;
|
|
2452
|
+
const inner = ev.assistantMessageEvent;
|
|
2453
|
+
if (!inner) return;
|
|
2454
|
+
if (inner.type === "text_delta") {
|
|
2455
|
+
ensureTextItem();
|
|
2456
|
+
textContent += inner.delta ?? "";
|
|
2457
|
+
send({
|
|
2458
|
+
type: "response.output_text.delta",
|
|
2459
|
+
sequence_number: seq++,
|
|
2460
|
+
output_index: textItemEmitted ? 0 : nextOutputIndex,
|
|
2461
|
+
delta: inner.delta ?? ""
|
|
2462
|
+
});
|
|
2463
|
+
return;
|
|
2464
|
+
}
|
|
2465
|
+
if (inner.type === "toolcall_start") {
|
|
2466
|
+
const idx = inner.contentIndex;
|
|
2467
|
+
const item = inner.partial?.content?.[idx];
|
|
2468
|
+
const tc = item?.type === "toolCall" ? item : void 0;
|
|
2469
|
+
const itemId = `item_${randomUUID().slice(0, 8)}`;
|
|
2470
|
+
const callId = tc?.id ?? `call_${nextOutputIndex}`;
|
|
2471
|
+
const outputIndex = nextOutputIndex++;
|
|
2472
|
+
tcByContentIdx.set(idx, {
|
|
2473
|
+
itemId,
|
|
2474
|
+
callId,
|
|
2475
|
+
outputIndex
|
|
2476
|
+
});
|
|
2477
|
+
send({
|
|
2478
|
+
type: "response.output_item.added",
|
|
2479
|
+
sequence_number: seq++,
|
|
2480
|
+
output_index: outputIndex,
|
|
2481
|
+
item: {
|
|
2482
|
+
type: "function_call",
|
|
2483
|
+
id: itemId,
|
|
2484
|
+
call_id: callId,
|
|
2485
|
+
name: tc?.name ?? "",
|
|
2486
|
+
arguments: "",
|
|
2487
|
+
status: "in_progress"
|
|
2488
|
+
}
|
|
2489
|
+
});
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
if (inner.type === "toolcall_delta") {
|
|
2493
|
+
const info = tcByContentIdx.get(inner.contentIndex);
|
|
2494
|
+
if (!info) return;
|
|
2495
|
+
send({
|
|
2496
|
+
type: "response.function_call_arguments.delta",
|
|
2497
|
+
sequence_number: seq++,
|
|
2498
|
+
output_index: info.outputIndex,
|
|
2499
|
+
call_id: info.callId,
|
|
2500
|
+
delta: inner.delta ?? ""
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
});
|
|
2504
|
+
try {
|
|
2505
|
+
await runConversation({
|
|
2506
|
+
session,
|
|
2507
|
+
prompt,
|
|
2508
|
+
images,
|
|
2509
|
+
log,
|
|
2510
|
+
postPrompt
|
|
2511
|
+
});
|
|
2512
|
+
if (capturedModelError !== null) failWithProviderError(toModelProviderError(new Error(capturedModelError)));
|
|
2513
|
+
} catch (err) {
|
|
2514
|
+
log.error({
|
|
2515
|
+
err,
|
|
2516
|
+
event: "responses_stream_error"
|
|
2517
|
+
}, "stream error");
|
|
2518
|
+
if (isModelProviderError(err)) failWithProviderError(toModelProviderError(err));
|
|
2519
|
+
} finally {
|
|
2520
|
+
registry.delete(sessionId);
|
|
2521
|
+
}
|
|
2522
|
+
if (textContent) {
|
|
2523
|
+
send({
|
|
2524
|
+
type: "response.output_text.done",
|
|
2525
|
+
sequence_number: seq++,
|
|
2526
|
+
output_index: 0,
|
|
2527
|
+
text: textContent
|
|
2528
|
+
});
|
|
2529
|
+
outputItems.push({
|
|
2530
|
+
type: "message",
|
|
2531
|
+
role: "assistant",
|
|
2532
|
+
content: [{
|
|
2533
|
+
type: "output_text",
|
|
2534
|
+
text: textContent
|
|
2535
|
+
}]
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
for (const [, info] of tcByContentIdx) send({
|
|
2539
|
+
type: "response.output_item.done",
|
|
2540
|
+
sequence_number: seq++,
|
|
2541
|
+
output_index: info.outputIndex,
|
|
2542
|
+
item: {
|
|
2543
|
+
type: "function_call",
|
|
2544
|
+
id: info.itemId,
|
|
2545
|
+
call_id: info.callId,
|
|
2546
|
+
status: "completed"
|
|
2547
|
+
}
|
|
2548
|
+
});
|
|
2549
|
+
send({
|
|
2550
|
+
type: "response.completed",
|
|
2551
|
+
sequence_number: seq++,
|
|
2552
|
+
response: {
|
|
2553
|
+
id: responseId,
|
|
2554
|
+
object: "response",
|
|
2555
|
+
status: "completed",
|
|
2556
|
+
output: outputItems,
|
|
2557
|
+
model: modelName,
|
|
2558
|
+
output_text: textContent,
|
|
2559
|
+
created_at: Math.floor(Date.now() / 1e3),
|
|
2560
|
+
usage: {
|
|
2561
|
+
input_tokens: 0,
|
|
2562
|
+
output_tokens: 0,
|
|
2563
|
+
total_tokens: 0,
|
|
2564
|
+
input_tokens_details: { cached_tokens: 0 },
|
|
2565
|
+
output_tokens_details: { reasoning_tokens: 0 }
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
});
|
|
2569
|
+
writer.write("data: [DONE]\n\n");
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2572
|
+
async function runBlocking({ session, prompt, images, responseId, sessionId, modelName, log, postPrompt, registry }) {
|
|
2573
|
+
let textContent = "";
|
|
2574
|
+
const functionCalls = [];
|
|
2575
|
+
const tcByContentIdx = /* @__PURE__ */ new Map();
|
|
2576
|
+
session.subscribe((event) => {
|
|
2577
|
+
if (event.type === "message_update") {
|
|
2578
|
+
const inner = event.assistantMessageEvent;
|
|
2579
|
+
if (inner?.type === "text_delta") {
|
|
2580
|
+
textContent += inner.delta ?? "";
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
if (inner?.type === "toolcall_start") {
|
|
2584
|
+
const idx = inner.contentIndex;
|
|
2585
|
+
const item = inner.partial?.content?.[idx];
|
|
2586
|
+
tcByContentIdx.set(idx, functionCalls.length);
|
|
2587
|
+
functionCalls.push({
|
|
2588
|
+
type: "function_call",
|
|
2589
|
+
id: `item_${randomUUID().slice(0, 8)}`,
|
|
2590
|
+
call_id: item?.id ?? `call_${functionCalls.length}`,
|
|
2591
|
+
name: item?.name ?? "",
|
|
2592
|
+
arguments: "",
|
|
2593
|
+
status: "completed"
|
|
2594
|
+
});
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
if (inner?.type === "toolcall_delta") {
|
|
2598
|
+
const i = tcByContentIdx.get(inner.contentIndex);
|
|
2599
|
+
if (i !== void 0 && functionCalls[i]) functionCalls[i].arguments += inner.delta ?? "";
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
});
|
|
2603
|
+
try {
|
|
2604
|
+
await runConversation({
|
|
2605
|
+
session,
|
|
2606
|
+
prompt,
|
|
2607
|
+
images,
|
|
2608
|
+
log,
|
|
2609
|
+
postPrompt
|
|
2610
|
+
});
|
|
2611
|
+
} catch (err) {
|
|
2612
|
+
log.error({
|
|
2613
|
+
err,
|
|
2614
|
+
event: "responses_blocking_error"
|
|
2615
|
+
}, "blocking error");
|
|
2616
|
+
return jsonError(500, err?.message ?? String(err));
|
|
2617
|
+
} finally {
|
|
2618
|
+
registry.delete(sessionId);
|
|
2619
|
+
}
|
|
2620
|
+
const output = [];
|
|
2621
|
+
if (textContent) output.push({
|
|
2622
|
+
type: "message",
|
|
2623
|
+
role: "assistant",
|
|
2624
|
+
content: [{
|
|
2625
|
+
type: "output_text",
|
|
2626
|
+
text: textContent
|
|
2627
|
+
}]
|
|
2628
|
+
});
|
|
2629
|
+
output.push(...functionCalls);
|
|
2630
|
+
return jsonResponse({
|
|
2631
|
+
id: responseId,
|
|
2632
|
+
object: "response",
|
|
2633
|
+
status: "completed",
|
|
2634
|
+
output,
|
|
2635
|
+
model: modelName,
|
|
2636
|
+
output_text: textContent,
|
|
2637
|
+
created_at: Math.floor(Date.now() / 1e3),
|
|
2638
|
+
usage: {
|
|
2639
|
+
input_tokens: 0,
|
|
2640
|
+
output_tokens: 0,
|
|
2641
|
+
total_tokens: 0,
|
|
2642
|
+
input_tokens_details: { cached_tokens: 0 },
|
|
2643
|
+
output_tokens_details: { reasoning_tokens: 0 }
|
|
2644
|
+
}
|
|
2645
|
+
});
|
|
2646
|
+
}
|
|
2647
|
+
function create(options) {
|
|
2648
|
+
const cwd = options.cwd ?? process.cwd();
|
|
2649
|
+
const registry = options.sessionRegistry ?? createMapSessionRegistry();
|
|
2650
|
+
const ctx = {
|
|
2651
|
+
...options,
|
|
2652
|
+
cwd
|
|
2653
|
+
};
|
|
2654
|
+
return async (request) => {
|
|
2655
|
+
const { pathname } = new URL(request.url);
|
|
2656
|
+
if (request.method !== "POST") return null;
|
|
2657
|
+
const abortSessionId = pathname.match(/^\/v1\/responses\/([^/]+)\/abort$/)?.[1];
|
|
2658
|
+
if (abortSessionId) try {
|
|
2659
|
+
return await runInTraceContext(() => handleAbort({
|
|
2660
|
+
request,
|
|
2661
|
+
sessionId: abortSessionId,
|
|
2662
|
+
registry
|
|
2663
|
+
}));
|
|
2664
|
+
} catch (err) {
|
|
2665
|
+
const { log } = requestLogger(request);
|
|
2666
|
+
log.error({
|
|
2667
|
+
err,
|
|
2668
|
+
event: "responses_abort_unhandled_error"
|
|
2669
|
+
}, "unhandled error");
|
|
2670
|
+
return jsonError(500, "internal error");
|
|
2671
|
+
}
|
|
2672
|
+
if (pathname !== "/v1/responses") return null;
|
|
2673
|
+
try {
|
|
2674
|
+
return await runInTraceContext(() => handleResponses(request, ctx, registry));
|
|
2675
|
+
} catch (err) {
|
|
2676
|
+
const { log } = requestLogger(request);
|
|
2677
|
+
log.error({
|
|
2678
|
+
err,
|
|
2679
|
+
event: "responses_unhandled_error"
|
|
2680
|
+
}, "unhandled error");
|
|
2681
|
+
return jsonError(500, "internal error");
|
|
2682
|
+
}
|
|
2683
|
+
};
|
|
2684
|
+
}
|
|
2685
|
+
//#endregion
|
|
2686
|
+
//#region src/protocols/index.ts
|
|
2687
|
+
/**
|
|
2688
|
+
* Build the individual protocol handlers, each returning null for
|
|
2689
|
+
* requests it doesn't recognize. An in-memory session registry backs
|
|
2690
|
+
* chat-completions steering unless deliberately overridden.
|
|
2691
|
+
*/
|
|
2692
|
+
function createProtocolHandlers(options) {
|
|
2693
|
+
const shared = {
|
|
2694
|
+
cwd: options.cwd,
|
|
2695
|
+
createSession: options.createSession,
|
|
2696
|
+
onSessionSetup: options.onSessionSetup,
|
|
2697
|
+
postPrompt: options.postPrompt,
|
|
2698
|
+
passthroughHeaderPrefixes: options.passthroughHeaderPrefixes
|
|
2699
|
+
};
|
|
2700
|
+
const chatCompletions = create$1({
|
|
2701
|
+
...shared,
|
|
2702
|
+
sessionRegistry: createMapSessionRegistry(),
|
|
2703
|
+
...options.chatCompletions
|
|
2704
|
+
});
|
|
2705
|
+
return {
|
|
2706
|
+
prewarm: createPrewarm({
|
|
2707
|
+
paths: options.prewarmPaths ?? [],
|
|
2708
|
+
chatCompletions
|
|
2709
|
+
}),
|
|
2710
|
+
chatCompletions,
|
|
2711
|
+
messages: create$2({
|
|
2712
|
+
...shared,
|
|
2713
|
+
...options.messages
|
|
2714
|
+
}),
|
|
2715
|
+
responses: create({
|
|
2716
|
+
...shared,
|
|
2717
|
+
...options.responses
|
|
2718
|
+
})
|
|
2719
|
+
};
|
|
2720
|
+
}
|
|
2721
|
+
/** Chain handlers; null when none matched. */
|
|
2722
|
+
function composeHandlers(handlers) {
|
|
2723
|
+
return async (request) => {
|
|
2724
|
+
for (const handler of handlers) {
|
|
2725
|
+
const response = await handler(request);
|
|
2726
|
+
if (response) return response;
|
|
2727
|
+
}
|
|
2728
|
+
return null;
|
|
2729
|
+
};
|
|
2730
|
+
}
|
|
2731
|
+
function createProtocols(options) {
|
|
2732
|
+
const handlers = createProtocolHandlers(options);
|
|
2733
|
+
const enabled = (name) => (!options.only || options.only.includes(name)) && (!options.exclude || !options.exclude.includes(name));
|
|
2734
|
+
const composed = composeHandlers([
|
|
2735
|
+
...enabled("chat-completions") ? [handlers.prewarm, handlers.chatCompletions] : [],
|
|
2736
|
+
...enabled("messages") ? [handlers.messages] : [],
|
|
2737
|
+
...enabled("responses") ? [handlers.responses] : []
|
|
2738
|
+
]);
|
|
2739
|
+
return async (request) => {
|
|
2740
|
+
const response = await composed(request);
|
|
2741
|
+
if (response) return response;
|
|
2742
|
+
return new Response(JSON.stringify({ error: { message: "not found" } }), {
|
|
2743
|
+
status: 404,
|
|
2744
|
+
headers: { "Content-Type": "application/json" }
|
|
2745
|
+
});
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
//#endregion
|
|
2749
|
+
//#region src/express.ts
|
|
2750
|
+
function requestUrl(req) {
|
|
2751
|
+
return `${req.headers["x-forwarded-proto"] ?? "http"}://${req.headers["x-forwarded-host"] ?? req.headers.host ?? "localhost"}${req.originalUrl ?? req.url ?? "/"}`;
|
|
2752
|
+
}
|
|
2753
|
+
function requestHeaders(req) {
|
|
2754
|
+
const headers = new Headers();
|
|
2755
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
2756
|
+
if (value == null) continue;
|
|
2757
|
+
if (Array.isArray(value)) for (const v of value) headers.append(key, v);
|
|
2758
|
+
else headers.set(key, value);
|
|
2759
|
+
}
|
|
2760
|
+
return headers;
|
|
2761
|
+
}
|
|
2762
|
+
async function toWebRequest(req) {
|
|
2763
|
+
const init = {
|
|
2764
|
+
method: req.method,
|
|
2765
|
+
headers: requestHeaders(req)
|
|
2766
|
+
};
|
|
2767
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2768
|
+
const { Readable } = await import("node:stream");
|
|
2769
|
+
init.body = Readable.toWeb(req);
|
|
2770
|
+
init.duplex = "half";
|
|
2771
|
+
}
|
|
2772
|
+
return new Request(requestUrl(req), init);
|
|
2773
|
+
}
|
|
2774
|
+
async function sendWebResponse(res, webResponse) {
|
|
2775
|
+
const headers = {};
|
|
2776
|
+
webResponse.headers.forEach((v, k) => {
|
|
2777
|
+
headers[k] = v;
|
|
2778
|
+
});
|
|
2779
|
+
res.writeHead(webResponse.status, headers);
|
|
2780
|
+
if (webResponse.body) {
|
|
2781
|
+
const { Readable } = await import("node:stream");
|
|
2782
|
+
Readable.fromWeb(webResponse.body).pipe(res);
|
|
2783
|
+
} else res.end();
|
|
2784
|
+
}
|
|
2785
|
+
/**
|
|
2786
|
+
* Mountable middleware for a web-standard handler. A null result calls
|
|
2787
|
+
* next() so unmatched requests fall through to whatever the caller
|
|
2788
|
+
* mounts after it.
|
|
2789
|
+
*/
|
|
2790
|
+
function webHandlerToMiddleware(handler) {
|
|
2791
|
+
return async (req, res, next) => {
|
|
2792
|
+
try {
|
|
2793
|
+
const webResponse = await handler(await toWebRequest(req));
|
|
2794
|
+
if (!webResponse) {
|
|
2795
|
+
next();
|
|
2796
|
+
return;
|
|
2797
|
+
}
|
|
2798
|
+
await sendWebResponse(res, webResponse);
|
|
2799
|
+
} catch (err) {
|
|
2800
|
+
logger.error({
|
|
2801
|
+
err,
|
|
2802
|
+
event: "server_error"
|
|
2803
|
+
}, "unhandled server error");
|
|
2804
|
+
if (!res.headersSent) {
|
|
2805
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
2806
|
+
res.end(JSON.stringify({ error: { message: "internal error" } }));
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
};
|
|
2810
|
+
}
|
|
2811
|
+
/**
|
|
2812
|
+
* Path-scope a middleware the way express mounting does: skip (next())
|
|
2813
|
+
* unless the request path matches the prefix, and present a
|
|
2814
|
+
* mount-relative req.url to the inner middleware. Lets express-style
|
|
2815
|
+
* handlers that expect `app.use('/a2a', h)` participate in a composed
|
|
2816
|
+
* catch-all.
|
|
2817
|
+
*/
|
|
2818
|
+
function mountAt(prefix, middleware) {
|
|
2819
|
+
return (req, res, next) => {
|
|
2820
|
+
const originalUrl = req.url ?? "/";
|
|
2821
|
+
const path = originalUrl.split("?")[0] ?? "";
|
|
2822
|
+
if (path !== prefix && !path.startsWith(`${prefix}/`)) {
|
|
2823
|
+
next();
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
const rest = originalUrl.slice(prefix.length);
|
|
2827
|
+
req.url = rest.startsWith("/") ? rest : `/${rest}`;
|
|
2828
|
+
middleware(req, res, (err) => {
|
|
2829
|
+
req.url = originalUrl;
|
|
2830
|
+
next(err);
|
|
2831
|
+
});
|
|
2832
|
+
};
|
|
2833
|
+
}
|
|
2834
|
+
/** Run middlewares in order; each next() advances to the following one. */
|
|
2835
|
+
function chainMiddleware(middlewares) {
|
|
2836
|
+
return (req, res, next) => {
|
|
2837
|
+
let i = 0;
|
|
2838
|
+
const run = (err) => {
|
|
2839
|
+
if (err) {
|
|
2840
|
+
next(err);
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
const middleware = middlewares[i++];
|
|
2844
|
+
if (!middleware) {
|
|
2845
|
+
next();
|
|
2846
|
+
return;
|
|
2847
|
+
}
|
|
2848
|
+
middleware(req, res, run);
|
|
2849
|
+
};
|
|
2850
|
+
run();
|
|
2851
|
+
};
|
|
2852
|
+
}
|
|
2853
|
+
//#endregion
|
|
2854
|
+
export { DEFAULT_MODEL, DEFAULT_PROVIDER, DEFAULT_THINKING_LEVEL, KNOWN_PI_PROVIDERS, PI_AGENT_DIR, VALID_THINKING_LEVELS, buildAgentCard, buildModelFromSpec, chainMiddleware, composeHandlers, createAgentExecutor, createMapSessionRegistry, createPrewarm, createProtocolHandlers, createProtocols, deriveTraceContext, getCurrentTraceparent, logger, modelSpecSchema, mountAt, newTraceContext, parseModelSpec, parseTraceId, requestHeaders, requestLogger, requestUrl, resolveRequestModel, runInTraceContext, setCurrentTraceparent, webHandlerToMiddleware, withSpecApiKey };
|