llm-relay 0.77.0 → 0.78.0
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/dist/accounting-store-schema.js +5 -6
- package/dist/accounting-store-schema.js.map +1 -1
- package/dist/accounting-store.d.ts +10 -0
- package/dist/accounting-store.js +60 -6
- package/dist/accounting-store.js.map +1 -1
- package/dist/backend/envelope-validator.d.ts +1 -1
- package/dist/backend/envelope-validator.js +89 -52
- package/dist/backend/envelope-validator.js.map +1 -1
- package/dist/backend/health-prober.js +3 -1
- package/dist/backend/health-prober.js.map +1 -1
- package/dist/backend.d.ts +48 -0
- package/dist/backend.js +831 -7
- package/dist/backend.js.map +1 -1
- package/dist/candidate-runner.d.ts +135 -50
- package/dist/candidate-runner.js +160 -55
- package/dist/candidate-runner.js.map +1 -1
- package/dist/cli.d.ts +24 -2
- package/dist/cli.js +122 -20
- package/dist/cli.js.map +1 -1
- package/dist/config/routing-parser.d.ts +13 -8
- package/dist/config/routing-parser.js +369 -183
- package/dist/config/routing-parser.js.map +1 -1
- package/dist/config-types.d.ts +202 -0
- package/dist/config-types.js +2 -0
- package/dist/config-types.js.map +1 -1
- package/dist/config.d.ts +45 -1
- package/dist/config.js +87 -22
- package/dist/config.js.map +1 -1
- package/dist/dashboard-contract.d.ts +38 -0
- package/dist/dashboard-contract.js +19 -3
- package/dist/dashboard-contract.js.map +1 -1
- package/dist/dispatch-lane-stats.d.ts +33 -4
- package/dist/dispatch-lane-stats.js +65 -16
- package/dist/dispatch-lane-stats.js.map +1 -1
- package/dist/dispatch.d.ts +9 -0
- package/dist/dispatch.js +36 -4
- package/dist/dispatch.js.map +1 -1
- package/dist/key-checker.d.ts +21 -1
- package/dist/key-checker.js +38 -12
- package/dist/key-checker.js.map +1 -1
- package/dist/lane-affinity.d.ts +94 -1
- package/dist/lane-affinity.js +52 -4
- package/dist/lane-affinity.js.map +1 -1
- package/dist/mcp/lane-runner.d.ts +69 -16
- package/dist/mcp/lane-runner.js +30 -1
- package/dist/mcp/lane-runner.js.map +1 -1
- package/dist/mcp/server.d.ts +39 -4
- package/dist/mcp/server.js +70 -13
- package/dist/mcp/server.js.map +1 -1
- package/dist/openai-request.d.ts +21 -0
- package/dist/openai-request.js +1 -1
- package/dist/openai-request.js.map +1 -1
- package/dist/ping/cadence.js +9 -1
- package/dist/ping/cadence.js.map +1 -1
- package/dist/ping/probe-cache.d.ts +13 -0
- package/dist/ping/probe-cache.js +12 -0
- package/dist/ping/probe-cache.js.map +1 -1
- package/dist/responses-request.d.ts +20 -0
- package/dist/responses-request.js +7 -7
- package/dist/responses-request.js.map +1 -1
- package/dist/routes/admin.d.ts +8 -1
- package/dist/routes/admin.js +42 -6
- package/dist/routes/admin.js.map +1 -1
- package/dist/routes/messages.d.ts +1 -0
- package/dist/routes/messages.js +13 -8
- package/dist/routes/messages.js.map +1 -1
- package/dist/routes/openai-front.js +20 -7
- package/dist/routes/openai-front.js.map +1 -1
- package/dist/server.d.ts +9 -1
- package/dist/server.js +21 -3
- package/dist/server.js.map +1 -1
- package/dist/setup-claude.d.ts +2 -2
- package/dist/setup-claude.js +9 -7
- package/dist/setup-claude.js.map +1 -1
- package/dist/stream-pipeline.d.ts +116 -0
- package/dist/stream-pipeline.js +151 -0
- package/dist/stream-pipeline.js.map +1 -1
- package/dist/target-facts.d.ts +5 -4
- package/dist/target-facts.js +5 -0
- package/dist/target-facts.js.map +1 -1
- package/dist/telemetry.d.ts +15 -2
- package/dist/telemetry.js +4 -1
- package/dist/telemetry.js.map +1 -1
- package/dist/tier-data.d.ts +22 -0
- package/dist/tier-data.js +12 -2
- package/dist/tier-data.js.map +1 -1
- package/package.json +2 -1
- package/skills/llm-relay/SKILL.md +8 -3
- package/skills/llm-relay/references/dispatch-lanes.md +2 -1
- package/skills/llm-relay/references/operations.md +1 -1
package/dist/backend.js
CHANGED
|
@@ -2,8 +2,9 @@ import { translateBetweenProviders, handleUniversalStreamRequest } from "llm-bri
|
|
|
2
2
|
import { buildAuthHeaders } from "./authEnv.js";
|
|
3
3
|
import { isRecord } from "./json-shape.js";
|
|
4
4
|
import { DocumentError, transcodeDocuments } from "./documents.js";
|
|
5
|
-
import { anthropicRequestToOpenAi, RequestMappingError } from "./openai-request.js";
|
|
6
|
-
import { openaiResponsesRequestToAnthropic } from "./responses-request.js";
|
|
5
|
+
import { anthropicRequestToOpenAi, RequestMappingError, ToolCallIds, ThoughtSignatures } from "./openai-request.js";
|
|
6
|
+
import { openaiResponsesRequestToAnthropic, DEFAULT_RESPONSES_MAX_TOKENS } from "./responses-request.js";
|
|
7
|
+
import { observedMaxOutput } from "./context-limits.js";
|
|
7
8
|
import { DIALECT_REFUSED_DESTRUCTIVE_CODE, describeRefused, dialectRefusalSignal, recoverToolCalls } from "./tool-dialects.js";
|
|
8
9
|
import { recoverDialectInStream } from "./dialect-stream.js";
|
|
9
10
|
import { toolSchemaMap } from "./anthropic.js";
|
|
@@ -13,6 +14,8 @@ import { invalidEnvelopeReason } from "./backend/envelope-validator.js";
|
|
|
13
14
|
import { captureReportedModel, preflightResponseStream } from "./backend/health-prober.js";
|
|
14
15
|
import { inspectDialectInOpenAiChat, recoverDialectInOpenAiChatStream, } from "./openai-dialect.js";
|
|
15
16
|
import { observeUsage } from "./usage-observer.js";
|
|
17
|
+
import { createSseTransformStream, parseSseEvent, BufferedSseFrames } from "./sse-frames.js";
|
|
18
|
+
import { syntheticMessageId } from "./emitSse.js";
|
|
16
19
|
export class DialectUnparseableError extends Error {
|
|
17
20
|
dialect;
|
|
18
21
|
constructor(dialect) {
|
|
@@ -57,6 +60,7 @@ function oneShotFetch(fetchFn, signal, onEgress) {
|
|
|
57
60
|
export const DEGRADED_HEADER = "x-llm-relay-degraded";
|
|
58
61
|
export const QUOTA_DEMOTED_HEADER = "x-llm-relay-quota-demoted";
|
|
59
62
|
export const LATENCY_DEMOTED_HEADER = "x-llm-relay-latency-demoted";
|
|
63
|
+
export const PROBATION_HEADER = "x-llm-relay-probation";
|
|
60
64
|
export const HEDGED_HEADER = "x-llm-relay-hedged";
|
|
61
65
|
export const HARD_CAP_HEADER = "x-llm-relay-capped";
|
|
62
66
|
export const PAID_HEADER = "x-llm-relay-paid";
|
|
@@ -165,6 +169,9 @@ async function fetchAnthropicBackend(attempt, args, invokeFetch) {
|
|
|
165
169
|
return attachUpstreamMetadata(res, metadata);
|
|
166
170
|
}
|
|
167
171
|
async function fetchOpenAiBackend(attempt, args, invokeFetch) {
|
|
172
|
+
if (attempt.target.wire === "responses") {
|
|
173
|
+
return fetchOpenAiResponsesBackend(attempt, args, invokeFetch);
|
|
174
|
+
}
|
|
168
175
|
const target = attempt.target;
|
|
169
176
|
let reqJson = args.reqJson;
|
|
170
177
|
try {
|
|
@@ -324,6 +331,747 @@ async function fetchOpenAiBackend(attempt, args, invokeFetch) {
|
|
|
324
331
|
metadata.thoughtSignatureSentinels = sentinelsStamped;
|
|
325
332
|
return attachUpstreamMetadata(response, metadata);
|
|
326
333
|
}
|
|
334
|
+
function describeResponsesType(t) {
|
|
335
|
+
return typeof t === "string" && t.length > 0 ? `"${t.slice(0, 40)}"` : "(missing type)";
|
|
336
|
+
}
|
|
337
|
+
function responsesSystemText(system) {
|
|
338
|
+
if (typeof system === "string")
|
|
339
|
+
return system;
|
|
340
|
+
if (!Array.isArray(system))
|
|
341
|
+
return "";
|
|
342
|
+
const parts = [];
|
|
343
|
+
for (const block of system) {
|
|
344
|
+
if (isRecord(block) && block.type === "text" && typeof block.text === "string")
|
|
345
|
+
parts.push(block.text);
|
|
346
|
+
}
|
|
347
|
+
return parts.join("\n\n");
|
|
348
|
+
}
|
|
349
|
+
function responsesInputImagePart(block) {
|
|
350
|
+
const source = isRecord(block.source) ? block.source : {};
|
|
351
|
+
if (source.type === "url" && typeof source.url === "string")
|
|
352
|
+
return { type: "input_image", image_url: source.url };
|
|
353
|
+
if (source.type === "base64" && typeof source.data === "string" && typeof source.media_type === "string") {
|
|
354
|
+
return { type: "input_image", image_url: `data:${source.media_type};base64,${source.data}` };
|
|
355
|
+
}
|
|
356
|
+
throw new RequestMappingError("image block needs a base64 or url source");
|
|
357
|
+
}
|
|
358
|
+
function responsesToolResultParts(content) {
|
|
359
|
+
if (content === undefined || content === null)
|
|
360
|
+
return { text: "", images: [] };
|
|
361
|
+
if (typeof content === "string")
|
|
362
|
+
return { text: content, images: [] };
|
|
363
|
+
if (!Array.isArray(content)) {
|
|
364
|
+
throw new RequestMappingError("tool_result content must be text or a list of blocks");
|
|
365
|
+
}
|
|
366
|
+
const texts = [];
|
|
367
|
+
const images = [];
|
|
368
|
+
for (const raw of content) {
|
|
369
|
+
if (!isRecord(raw))
|
|
370
|
+
throw new RequestMappingError("tool_result content block is not an object");
|
|
371
|
+
switch (raw.type) {
|
|
372
|
+
case "text":
|
|
373
|
+
texts.push(typeof raw.text === "string" ? raw.text : "");
|
|
374
|
+
break;
|
|
375
|
+
case "image":
|
|
376
|
+
images.push(responsesInputImagePart(raw));
|
|
377
|
+
break;
|
|
378
|
+
default:
|
|
379
|
+
throw new RequestMappingError(`tool_result carries an unsupported ${describeResponsesType(raw.type)} block; a Responses function_call_output carries text and images only`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return { text: texts.join("\n"), images };
|
|
383
|
+
}
|
|
384
|
+
function responsesToolResultItem(block, ids) {
|
|
385
|
+
if (typeof block.tool_use_id !== "string" || block.tool_use_id.length === 0) {
|
|
386
|
+
throw new RequestMappingError("tool_result block without a tool_use_id");
|
|
387
|
+
}
|
|
388
|
+
const callId = ids === null ? block.tool_use_id : ids.map(block.tool_use_id);
|
|
389
|
+
const { text, images } = responsesToolResultParts(block.content);
|
|
390
|
+
return { item: { type: "function_call_output", call_id: callId, output: text }, images };
|
|
391
|
+
}
|
|
392
|
+
function responsesFunctionCallItem(block, ids, sigs) {
|
|
393
|
+
if (typeof block.id !== "string" || block.id.length === 0) {
|
|
394
|
+
throw new RequestMappingError("tool_use block without an id");
|
|
395
|
+
}
|
|
396
|
+
if (typeof block.name !== "string" || block.name.length === 0) {
|
|
397
|
+
throw new RequestMappingError("tool_use block without a name");
|
|
398
|
+
}
|
|
399
|
+
const input = block.input ?? {};
|
|
400
|
+
const item = {
|
|
401
|
+
type: "function_call",
|
|
402
|
+
call_id: ids === null ? block.id : ids.map(block.id),
|
|
403
|
+
name: block.name,
|
|
404
|
+
arguments: typeof input === "string" ? input : JSON.stringify(input),
|
|
405
|
+
};
|
|
406
|
+
return sigs === null ? item : sigs.stamp(item);
|
|
407
|
+
}
|
|
408
|
+
function responsesAssistantItems(turn, ids, sigs) {
|
|
409
|
+
const content = turn.content;
|
|
410
|
+
if (typeof content === "string") {
|
|
411
|
+
return content.length > 0 ? [{ role: "assistant", content: [{ type: "output_text", text: content }] }] : [];
|
|
412
|
+
}
|
|
413
|
+
const out = [];
|
|
414
|
+
for (const raw of Array.isArray(content) ? content : []) {
|
|
415
|
+
if (!isRecord(raw))
|
|
416
|
+
throw new RequestMappingError("assistant content block is not an object");
|
|
417
|
+
switch (raw.type) {
|
|
418
|
+
case "text": {
|
|
419
|
+
const text = typeof raw.text === "string" ? raw.text : "";
|
|
420
|
+
if (text.length > 0)
|
|
421
|
+
out.push({ role: "assistant", content: [{ type: "output_text", text }] });
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
case "tool_use":
|
|
425
|
+
out.push(responsesFunctionCallItem(raw, ids, sigs));
|
|
426
|
+
break;
|
|
427
|
+
case "thinking":
|
|
428
|
+
case "redacted_thinking":
|
|
429
|
+
break;
|
|
430
|
+
default:
|
|
431
|
+
throw new RequestMappingError(`unsupported assistant content block ${describeResponsesType(raw.type)}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return out;
|
|
435
|
+
}
|
|
436
|
+
function responsesUserItems(turn, ids) {
|
|
437
|
+
const content = turn.content;
|
|
438
|
+
if (typeof content === "string") {
|
|
439
|
+
return content.length > 0 ? [{ role: "user", content: [{ type: "input_text", text: content }] }] : [];
|
|
440
|
+
}
|
|
441
|
+
const toolResultItems = [];
|
|
442
|
+
const trailingImages = [];
|
|
443
|
+
const contentParts = [];
|
|
444
|
+
for (const raw of Array.isArray(content) ? content : []) {
|
|
445
|
+
if (!isRecord(raw))
|
|
446
|
+
throw new RequestMappingError("user content block is not an object");
|
|
447
|
+
switch (raw.type) {
|
|
448
|
+
case "text": {
|
|
449
|
+
const text = typeof raw.text === "string" ? raw.text : "";
|
|
450
|
+
if (text.length > 0)
|
|
451
|
+
contentParts.push({ type: "input_text", text });
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
case "image":
|
|
455
|
+
contentParts.push(responsesInputImagePart(raw));
|
|
456
|
+
break;
|
|
457
|
+
case "tool_result": {
|
|
458
|
+
const { item, images } = responsesToolResultItem(raw, ids);
|
|
459
|
+
toolResultItems.push(item);
|
|
460
|
+
trailingImages.push(...images);
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
case "thinking":
|
|
464
|
+
case "redacted_thinking":
|
|
465
|
+
break;
|
|
466
|
+
default:
|
|
467
|
+
throw new RequestMappingError(`unsupported user content block ${describeResponsesType(raw.type)}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const out = [...toolResultItems];
|
|
471
|
+
if (trailingImages.length > 0)
|
|
472
|
+
out.push({ role: "user", content: trailingImages });
|
|
473
|
+
if (contentParts.length > 0)
|
|
474
|
+
out.push({ role: "user", content: contentParts });
|
|
475
|
+
return out;
|
|
476
|
+
}
|
|
477
|
+
function mapAnthropicToolsToResponses(tools) {
|
|
478
|
+
if (!Array.isArray(tools) || tools.length === 0)
|
|
479
|
+
return null;
|
|
480
|
+
const out = [];
|
|
481
|
+
for (const raw of tools) {
|
|
482
|
+
if (!isRecord(raw))
|
|
483
|
+
throw new RequestMappingError("tool declaration is not an object");
|
|
484
|
+
if (typeof raw.name !== "string" || raw.name.length === 0) {
|
|
485
|
+
throw new RequestMappingError("tool declaration without a name");
|
|
486
|
+
}
|
|
487
|
+
const fn = { type: "function", name: raw.name };
|
|
488
|
+
if (typeof raw.description === "string" && raw.description.length > 0)
|
|
489
|
+
fn.description = raw.description;
|
|
490
|
+
fn.parameters = isRecord(raw.input_schema) ? raw.input_schema : { type: "object", properties: {} };
|
|
491
|
+
out.push(fn);
|
|
492
|
+
}
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
495
|
+
function mapAnthropicToolChoiceToResponses(choice) {
|
|
496
|
+
if (!isRecord(choice))
|
|
497
|
+
return {};
|
|
498
|
+
let toolChoice;
|
|
499
|
+
switch (choice.type) {
|
|
500
|
+
case "auto":
|
|
501
|
+
toolChoice = "auto";
|
|
502
|
+
break;
|
|
503
|
+
case "any":
|
|
504
|
+
toolChoice = "required";
|
|
505
|
+
break;
|
|
506
|
+
case "none":
|
|
507
|
+
toolChoice = "none";
|
|
508
|
+
break;
|
|
509
|
+
case "tool":
|
|
510
|
+
toolChoice = typeof choice.name === "string" && choice.name.length > 0
|
|
511
|
+
? { type: "function", name: choice.name }
|
|
512
|
+
: undefined;
|
|
513
|
+
break;
|
|
514
|
+
default:
|
|
515
|
+
toolChoice = undefined;
|
|
516
|
+
}
|
|
517
|
+
if (toolChoice === undefined)
|
|
518
|
+
return {};
|
|
519
|
+
const parallelToolCalls = choice.disable_parallel_tool_use === true && choice.type !== "none" ? false : undefined;
|
|
520
|
+
return parallelToolCalls === undefined ? { toolChoice } : { toolChoice, parallelToolCalls };
|
|
521
|
+
}
|
|
522
|
+
function responsesRequestOptions(body, opts, out) {
|
|
523
|
+
if (opts.model !== undefined)
|
|
524
|
+
out.model = opts.model;
|
|
525
|
+
const stream = opts.stream ?? (typeof body.stream === "boolean" ? body.stream : undefined);
|
|
526
|
+
if (stream !== undefined)
|
|
527
|
+
out.stream = stream;
|
|
528
|
+
const instructions = responsesSystemText(body.system);
|
|
529
|
+
if (instructions.length > 0)
|
|
530
|
+
out.instructions = instructions;
|
|
531
|
+
if (typeof body.max_tokens === "number")
|
|
532
|
+
out.max_output_tokens = body.max_tokens;
|
|
533
|
+
if (typeof body.temperature === "number")
|
|
534
|
+
out.temperature = body.temperature;
|
|
535
|
+
if (typeof body.top_p === "number")
|
|
536
|
+
out.top_p = body.top_p;
|
|
537
|
+
const tools = mapAnthropicToolsToResponses(body.tools);
|
|
538
|
+
if (!tools)
|
|
539
|
+
return;
|
|
540
|
+
out.tools = tools;
|
|
541
|
+
const { toolChoice, parallelToolCalls } = mapAnthropicToolChoiceToResponses(body.tool_choice);
|
|
542
|
+
if (toolChoice !== undefined)
|
|
543
|
+
out.tool_choice = toolChoice;
|
|
544
|
+
if (parallelToolCalls !== undefined)
|
|
545
|
+
out.parallel_tool_calls = parallelToolCalls;
|
|
546
|
+
}
|
|
547
|
+
export function anthropicRequestToOpenAiResponses(reqJson, opts = {}) {
|
|
548
|
+
const body = isRecord(reqJson) ? reqJson : {};
|
|
549
|
+
const input = [];
|
|
550
|
+
const ids = opts.toolCallIds === "strict9" ? new ToolCallIds() : null;
|
|
551
|
+
const sigs = opts.thoughtSignature === "sentinel" ? new ThoughtSignatures() : null;
|
|
552
|
+
for (const raw of Array.isArray(body.messages) ? body.messages : []) {
|
|
553
|
+
if (!isRecord(raw))
|
|
554
|
+
throw new RequestMappingError("message is not an object");
|
|
555
|
+
if (raw.role === "assistant")
|
|
556
|
+
input.push(...responsesAssistantItems(raw, ids, sigs));
|
|
557
|
+
else
|
|
558
|
+
input.push(...responsesUserItems(raw, ids));
|
|
559
|
+
}
|
|
560
|
+
if (ids !== null)
|
|
561
|
+
opts.onToolCallIdsRewritten?.(ids.count());
|
|
562
|
+
if (sigs !== null)
|
|
563
|
+
opts.onThoughtSignatureSentinels?.(sigs.count());
|
|
564
|
+
const out = { input };
|
|
565
|
+
responsesRequestOptions(body, opts, out);
|
|
566
|
+
return out;
|
|
567
|
+
}
|
|
568
|
+
function parseResponsesFunctionCallOutputItem(raw, ordinal) {
|
|
569
|
+
const id = typeof raw.call_id === "string" ? raw.call_id
|
|
570
|
+
: typeof raw.id === "string" ? raw.id : `tu_${ordinal}`;
|
|
571
|
+
const name = typeof raw.name === "string" ? raw.name : "";
|
|
572
|
+
const rawArgs = typeof raw.arguments === "string" ? raw.arguments : "";
|
|
573
|
+
let input;
|
|
574
|
+
try {
|
|
575
|
+
input = rawArgs.length > 0 ? JSON.parse(rawArgs) : {};
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
input = rawArgs;
|
|
579
|
+
}
|
|
580
|
+
return { id, name, input };
|
|
581
|
+
}
|
|
582
|
+
function parseResponsesMessageOutputItem(raw) {
|
|
583
|
+
const textParts = [];
|
|
584
|
+
if (!Array.isArray(raw.content))
|
|
585
|
+
return textParts;
|
|
586
|
+
for (const part of raw.content) {
|
|
587
|
+
if (!isRecord(part))
|
|
588
|
+
continue;
|
|
589
|
+
if (part.type === "output_text" && typeof part.text === "string" && part.text.length > 0) {
|
|
590
|
+
textParts.push(part.text);
|
|
591
|
+
}
|
|
592
|
+
else if (part.type === "refusal" && typeof part.refusal === "string" && part.refusal.length > 0) {
|
|
593
|
+
textParts.push(part.refusal);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return textParts;
|
|
597
|
+
}
|
|
598
|
+
function parseOpenAiResponsesOutput(j) {
|
|
599
|
+
const outputs = Array.isArray(j.output) ? j.output : [];
|
|
600
|
+
const textParts = [];
|
|
601
|
+
const functionCalls = [];
|
|
602
|
+
for (const raw of outputs) {
|
|
603
|
+
if (!isRecord(raw))
|
|
604
|
+
continue;
|
|
605
|
+
const type = typeof raw.type === "string" ? raw.type : "";
|
|
606
|
+
if (type === "function_call") {
|
|
607
|
+
functionCalls.push(parseResponsesFunctionCallOutputItem(raw, functionCalls.length));
|
|
608
|
+
}
|
|
609
|
+
else if (type === "message") {
|
|
610
|
+
textParts.push(...parseResponsesMessageOutputItem(raw));
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return { text: textParts.join(""), functionCalls };
|
|
614
|
+
}
|
|
615
|
+
function mapResponsesStopReason(j, hasToolCalls) {
|
|
616
|
+
if (hasToolCalls)
|
|
617
|
+
return "tool_use";
|
|
618
|
+
const status = typeof j.status === "string" ? j.status : undefined;
|
|
619
|
+
const incompleteReason = isRecord(j.incomplete_details) && typeof j.incomplete_details.reason === "string"
|
|
620
|
+
? j.incomplete_details.reason
|
|
621
|
+
: undefined;
|
|
622
|
+
if (status === "incomplete" && incompleteReason === "max_output_tokens")
|
|
623
|
+
return "max_tokens";
|
|
624
|
+
return "end_turn";
|
|
625
|
+
}
|
|
626
|
+
function measuredResponsesCacheTokens(v) {
|
|
627
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : undefined;
|
|
628
|
+
}
|
|
629
|
+
function extractAnthropicUsageFromResponses(rawUsage) {
|
|
630
|
+
if (!isRecord(rawUsage))
|
|
631
|
+
return null;
|
|
632
|
+
if (typeof rawUsage.input_tokens !== "number" && typeof rawUsage.output_tokens !== "number")
|
|
633
|
+
return null;
|
|
634
|
+
const out = {};
|
|
635
|
+
if (typeof rawUsage.input_tokens === "number") {
|
|
636
|
+
const details = isRecord(rawUsage.input_tokens_details) ? rawUsage.input_tokens_details : undefined;
|
|
637
|
+
const cached = details ? measuredResponsesCacheTokens(details.cached_tokens) : undefined;
|
|
638
|
+
if (cached !== undefined && cached <= rawUsage.input_tokens) {
|
|
639
|
+
out.input_tokens = rawUsage.input_tokens - cached;
|
|
640
|
+
out.cache_read_input_tokens = cached;
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
out.input_tokens = rawUsage.input_tokens;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (typeof rawUsage.output_tokens === "number")
|
|
647
|
+
out.output_tokens = rawUsage.output_tokens;
|
|
648
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
649
|
+
}
|
|
650
|
+
function applyResponsesUsage(accumulator, usage) {
|
|
651
|
+
if (!usage)
|
|
652
|
+
return;
|
|
653
|
+
if (typeof usage.input_tokens === "number")
|
|
654
|
+
accumulator.inputTokens = usage.input_tokens;
|
|
655
|
+
if (typeof usage.output_tokens === "number") {
|
|
656
|
+
accumulator.outputTokens = usage.output_tokens;
|
|
657
|
+
accumulator.completionTokens = usage.output_tokens;
|
|
658
|
+
}
|
|
659
|
+
if (typeof usage.cache_read_input_tokens === "number")
|
|
660
|
+
accumulator.cacheReadInputTokens = usage.cache_read_input_tokens;
|
|
661
|
+
}
|
|
662
|
+
function openAiResponsesToAnthropicMessage(j, model, schemas, isDestructive) {
|
|
663
|
+
const parsed = parseOpenAiResponsesOutput(j);
|
|
664
|
+
const content = [];
|
|
665
|
+
let functionCalls = parsed.functionCalls;
|
|
666
|
+
if (functionCalls.length === 0 && parsed.text.length > 0) {
|
|
667
|
+
const { recoveredCalls, textParts } = recoverOpenAiTextDialect(parsed.text, schemas, isDestructive);
|
|
668
|
+
content.push(...textParts);
|
|
669
|
+
if (recoveredCalls.length > 0) {
|
|
670
|
+
functionCalls = recoveredCalls.map((c, i) => ({ id: `tu_recovered_${i}`, name: c.name, input: c.input }));
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
else if (parsed.text.length > 0) {
|
|
674
|
+
content.push({ type: "text", text: parsed.text });
|
|
675
|
+
}
|
|
676
|
+
for (const fc of functionCalls) {
|
|
677
|
+
content.push({ type: "tool_use", id: fc.id, name: fc.name, input: fc.input });
|
|
678
|
+
}
|
|
679
|
+
return {
|
|
680
|
+
id: typeof j.id === "string" ? j.id : "msg_translated",
|
|
681
|
+
type: "message",
|
|
682
|
+
role: "assistant",
|
|
683
|
+
model: model || (typeof j.model === "string" ? j.model : ""),
|
|
684
|
+
content,
|
|
685
|
+
stop_reason: mapResponsesStopReason(j, functionCalls.length > 0),
|
|
686
|
+
stop_sequence: null,
|
|
687
|
+
...(extractAnthropicUsageFromResponses(j.usage) ? { usage: extractAnthropicUsageFromResponses(j.usage) } : {}),
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
function anthropicSseEvent(type, data) {
|
|
691
|
+
return `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`;
|
|
692
|
+
}
|
|
693
|
+
function anthropicSseError(message) {
|
|
694
|
+
return `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: `llm-relay: ${message}` } })}\n\n`;
|
|
695
|
+
}
|
|
696
|
+
function newResponsesStreamState() {
|
|
697
|
+
return {
|
|
698
|
+
messageStarted: false,
|
|
699
|
+
messageStopped: false,
|
|
700
|
+
nextBlockIndex: 0,
|
|
701
|
+
textBlocks: new Map(),
|
|
702
|
+
toolBlocks: new Map(),
|
|
703
|
+
openBlocksByItem: new Map(),
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
function ensureResponsesMessageStarted(state, push, response) {
|
|
707
|
+
if (state.messageStarted)
|
|
708
|
+
return;
|
|
709
|
+
state.messageStarted = true;
|
|
710
|
+
push(anthropicSseEvent("message_start", {
|
|
711
|
+
message: {
|
|
712
|
+
id: typeof response?.id === "string" ? response.id : syntheticMessageId(),
|
|
713
|
+
type: "message",
|
|
714
|
+
role: "assistant",
|
|
715
|
+
model: typeof response?.model === "string" ? response.model : "",
|
|
716
|
+
content: [],
|
|
717
|
+
stop_reason: null,
|
|
718
|
+
stop_sequence: null,
|
|
719
|
+
usage: {},
|
|
720
|
+
},
|
|
721
|
+
}));
|
|
722
|
+
}
|
|
723
|
+
function openResponsesTextBlock(state, push, outputIndex, contentIndex) {
|
|
724
|
+
const key = `${outputIndex}:${contentIndex}`;
|
|
725
|
+
const existing = state.textBlocks.get(key);
|
|
726
|
+
if (existing !== undefined)
|
|
727
|
+
return existing;
|
|
728
|
+
const index = state.nextBlockIndex++;
|
|
729
|
+
state.textBlocks.set(key, index);
|
|
730
|
+
const opened = state.openBlocksByItem.get(outputIndex) ?? [];
|
|
731
|
+
opened.push(index);
|
|
732
|
+
state.openBlocksByItem.set(outputIndex, opened);
|
|
733
|
+
push(anthropicSseEvent("content_block_start", { index, content_block: { type: "text", text: "" } }));
|
|
734
|
+
return index;
|
|
735
|
+
}
|
|
736
|
+
function openResponsesToolBlock(state, push, outputIndex, callId, name) {
|
|
737
|
+
const existing = state.toolBlocks.get(outputIndex);
|
|
738
|
+
if (existing !== undefined)
|
|
739
|
+
return existing;
|
|
740
|
+
const index = state.nextBlockIndex++;
|
|
741
|
+
state.toolBlocks.set(outputIndex, index);
|
|
742
|
+
state.openBlocksByItem.set(outputIndex, [index]);
|
|
743
|
+
push(anthropicSseEvent("content_block_start", {
|
|
744
|
+
index, content_block: { type: "tool_use", id: callId, name, input: {} },
|
|
745
|
+
}));
|
|
746
|
+
return index;
|
|
747
|
+
}
|
|
748
|
+
function closeResponsesItemBlocks(state, push, outputIndex) {
|
|
749
|
+
const opened = state.openBlocksByItem.get(outputIndex);
|
|
750
|
+
if (!opened)
|
|
751
|
+
return;
|
|
752
|
+
for (const index of opened)
|
|
753
|
+
push(anthropicSseEvent("content_block_stop", { index }));
|
|
754
|
+
state.openBlocksByItem.delete(outputIndex);
|
|
755
|
+
}
|
|
756
|
+
function synthesizeResponsesFunctionCallBlock(state, push, outputIndex, item) {
|
|
757
|
+
if (state.toolBlocks.has(outputIndex))
|
|
758
|
+
return;
|
|
759
|
+
const callId = typeof item.call_id === "string" ? item.call_id
|
|
760
|
+
: typeof item.id === "string" ? item.id : `tu_${outputIndex}`;
|
|
761
|
+
const index = openResponsesToolBlock(state, push, outputIndex, callId, typeof item.name === "string" ? item.name : "");
|
|
762
|
+
const args = typeof item.arguments === "string" ? item.arguments : "";
|
|
763
|
+
if (args.length > 0) {
|
|
764
|
+
push(anthropicSseEvent("content_block_delta", { index, delta: { type: "input_json_delta", partial_json: args } }));
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
function synthesizeResponsesMessageBlocks(state, push, outputIndex, item) {
|
|
768
|
+
if (!Array.isArray(item.content))
|
|
769
|
+
return;
|
|
770
|
+
item.content.forEach((part, contentIndex) => {
|
|
771
|
+
if (!isRecord(part) || (part.type !== "output_text" && part.type !== "refusal"))
|
|
772
|
+
return;
|
|
773
|
+
if (state.textBlocks.has(`${outputIndex}:${contentIndex}`))
|
|
774
|
+
return;
|
|
775
|
+
const text = typeof part.text === "string" ? part.text : typeof part.refusal === "string" ? part.refusal : "";
|
|
776
|
+
const index = openResponsesTextBlock(state, push, outputIndex, contentIndex);
|
|
777
|
+
if (text.length > 0) {
|
|
778
|
+
push(anthropicSseEvent("content_block_delta", { index, delta: { type: "text_delta", text } }));
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
function synthesizeMissingResponsesBlocks(state, push, response) {
|
|
783
|
+
if (!Array.isArray(response.output))
|
|
784
|
+
return;
|
|
785
|
+
response.output.forEach((raw, outputIndex) => {
|
|
786
|
+
if (!isRecord(raw))
|
|
787
|
+
return;
|
|
788
|
+
if (raw.type === "function_call")
|
|
789
|
+
synthesizeResponsesFunctionCallBlock(state, push, outputIndex, raw);
|
|
790
|
+
else if (raw.type === "message")
|
|
791
|
+
synthesizeResponsesMessageBlocks(state, push, outputIndex, raw);
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
function finishResponsesStream(state, push, response) {
|
|
795
|
+
if (state.messageStopped)
|
|
796
|
+
return;
|
|
797
|
+
state.messageStopped = true;
|
|
798
|
+
ensureResponsesMessageStarted(state, push, response);
|
|
799
|
+
if (response)
|
|
800
|
+
synthesizeMissingResponsesBlocks(state, push, response);
|
|
801
|
+
for (const outputIndex of [...state.openBlocksByItem.keys()])
|
|
802
|
+
closeResponsesItemBlocks(state, push, outputIndex);
|
|
803
|
+
const hasToolCalls = state.toolBlocks.size > 0;
|
|
804
|
+
const stopReason = response ? mapResponsesStopReason(response, hasToolCalls) : (hasToolCalls ? "tool_use" : "end_turn");
|
|
805
|
+
const usage = response ? extractAnthropicUsageFromResponses(response.usage) : null;
|
|
806
|
+
push(anthropicSseEvent("message_delta", {
|
|
807
|
+
delta: { stop_reason: stopReason, stop_sequence: null },
|
|
808
|
+
...(usage ? { usage } : {}),
|
|
809
|
+
}));
|
|
810
|
+
push(anthropicSseEvent("message_stop", {}));
|
|
811
|
+
}
|
|
812
|
+
function handleResponsesOutputItemAdded(state, push, data) {
|
|
813
|
+
ensureResponsesMessageStarted(state, push, undefined);
|
|
814
|
+
const item = isRecord(data.item) ? data.item : undefined;
|
|
815
|
+
const outputIndex = typeof data.output_index === "number" ? data.output_index : -1;
|
|
816
|
+
if (item?.type === "function_call" && outputIndex >= 0) {
|
|
817
|
+
const callId = typeof item.call_id === "string" ? item.call_id
|
|
818
|
+
: typeof item.id === "string" ? item.id : `tu_${outputIndex}`;
|
|
819
|
+
openResponsesToolBlock(state, push, outputIndex, callId, typeof item.name === "string" ? item.name : "");
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
function handleResponsesContentPartAdded(state, push, data) {
|
|
823
|
+
const outputIndex = typeof data.output_index === "number" ? data.output_index : -1;
|
|
824
|
+
const contentIndex = typeof data.content_index === "number" ? data.content_index : 0;
|
|
825
|
+
const part = isRecord(data.part) ? data.part : undefined;
|
|
826
|
+
if (outputIndex >= 0 && (part?.type === "output_text" || part?.type === "refusal")) {
|
|
827
|
+
ensureResponsesMessageStarted(state, push, undefined);
|
|
828
|
+
openResponsesTextBlock(state, push, outputIndex, contentIndex);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
function handleResponsesTextDelta(state, push, data) {
|
|
832
|
+
const outputIndex = typeof data.output_index === "number" ? data.output_index : -1;
|
|
833
|
+
const contentIndex = typeof data.content_index === "number" ? data.content_index : 0;
|
|
834
|
+
const delta = typeof data.delta === "string" ? data.delta : "";
|
|
835
|
+
const index = state.textBlocks.get(`${outputIndex}:${contentIndex}`);
|
|
836
|
+
if (index !== undefined && delta.length > 0) {
|
|
837
|
+
push(anthropicSseEvent("content_block_delta", { index, delta: { type: "text_delta", text: delta } }));
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
function handleResponsesToolArgumentsDelta(state, push, data) {
|
|
841
|
+
const outputIndex = typeof data.output_index === "number" ? data.output_index : -1;
|
|
842
|
+
const delta = typeof data.delta === "string" ? data.delta : "";
|
|
843
|
+
const index = state.toolBlocks.get(outputIndex);
|
|
844
|
+
if (index !== undefined && delta.length > 0) {
|
|
845
|
+
push(anthropicSseEvent("content_block_delta", { index, delta: { type: "input_json_delta", partial_json: delta } }));
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
function handleResponsesOutputItemDone(state, push, data) {
|
|
849
|
+
const outputIndex = typeof data.output_index === "number" ? data.output_index : -1;
|
|
850
|
+
if (outputIndex >= 0)
|
|
851
|
+
closeResponsesItemBlocks(state, push, outputIndex);
|
|
852
|
+
}
|
|
853
|
+
function handleResponsesFailure(state, push, type, data) {
|
|
854
|
+
const response = isRecord(data.response) ? data.response : undefined;
|
|
855
|
+
ensureResponsesMessageStarted(state, push, response);
|
|
856
|
+
const message = isRecord(response?.error) && typeof response.error.message === "string"
|
|
857
|
+
? response.error.message
|
|
858
|
+
: typeof data.message === "string" ? data.message
|
|
859
|
+
: isRecord(data.error) && typeof data.error.message === "string" ? data.error.message
|
|
860
|
+
: `stream ended: ${type}`;
|
|
861
|
+
push(anthropicSseError(message));
|
|
862
|
+
state.messageStopped = true;
|
|
863
|
+
}
|
|
864
|
+
const RESPONSES_STREAM_HANDLERS = {
|
|
865
|
+
"response.created": (state, push, data) => ensureResponsesMessageStarted(state, push, isRecord(data.response) ? data.response : undefined),
|
|
866
|
+
"response.in_progress": (state, push, data) => ensureResponsesMessageStarted(state, push, isRecord(data.response) ? data.response : undefined),
|
|
867
|
+
"response.output_item.added": handleResponsesOutputItemAdded,
|
|
868
|
+
"response.content_part.added": handleResponsesContentPartAdded,
|
|
869
|
+
"response.output_text.delta": handleResponsesTextDelta,
|
|
870
|
+
"response.refusal.delta": handleResponsesTextDelta,
|
|
871
|
+
"response.function_call_arguments.delta": handleResponsesToolArgumentsDelta,
|
|
872
|
+
"response.output_item.done": handleResponsesOutputItemDone,
|
|
873
|
+
"response.completed": (state, push, data) => finishResponsesStream(state, push, isRecord(data.response) ? data.response : undefined),
|
|
874
|
+
"response.incomplete": (state, push, data) => finishResponsesStream(state, push, isRecord(data.response) ? data.response : undefined),
|
|
875
|
+
"response.failed": (state, push, data) => handleResponsesFailure(state, push, "response.failed", data),
|
|
876
|
+
"response.cancelled": (state, push, data) => handleResponsesFailure(state, push, "response.cancelled", data),
|
|
877
|
+
error: (state, push, data) => handleResponsesFailure(state, push, "error", data),
|
|
878
|
+
ping: (_state, push) => push(`event: ping\ndata: {"type":"ping"}\n\n`),
|
|
879
|
+
};
|
|
880
|
+
function processResponsesStreamEvent(state, push, type, data) {
|
|
881
|
+
RESPONSES_STREAM_HANDLERS[type]?.(state, push, data);
|
|
882
|
+
}
|
|
883
|
+
function translateResponsesStreamToAnthropic(upstream) {
|
|
884
|
+
return createSseTransformStream(upstream, ({ push, frames }) => {
|
|
885
|
+
const state = newResponsesStreamState();
|
|
886
|
+
return {
|
|
887
|
+
flushHeld: () => finishResponsesStream(state, push, undefined),
|
|
888
|
+
processFrames: () => {
|
|
889
|
+
for (const { frame } of frames) {
|
|
890
|
+
const ev = parseSseEvent(frame);
|
|
891
|
+
if (!ev || !ev.data)
|
|
892
|
+
continue;
|
|
893
|
+
const type = typeof ev.data.type === "string" ? ev.data.type : ev.type;
|
|
894
|
+
processResponsesStreamEvent(state, push, type, ev.data);
|
|
895
|
+
}
|
|
896
|
+
},
|
|
897
|
+
};
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
async function postResponsesRequest(attempt, args, reqJson, invokeFetch) {
|
|
901
|
+
const target = attempt.target;
|
|
902
|
+
let responsesBody;
|
|
903
|
+
let toolCallIdsRewritten = 0;
|
|
904
|
+
let sentinelsStamped = 0;
|
|
905
|
+
try {
|
|
906
|
+
responsesBody = anthropicRequestToOpenAiResponses(reqJson, {
|
|
907
|
+
model: target.model,
|
|
908
|
+
stream: args.wantsStream,
|
|
909
|
+
...(target.toolCallIds !== undefined ? { toolCallIds: target.toolCallIds } : {}),
|
|
910
|
+
onToolCallIdsRewritten: (n) => { toolCallIdsRewritten = n; },
|
|
911
|
+
...(target.thoughtSignature !== undefined ? { thoughtSignature: target.thoughtSignature } : {}),
|
|
912
|
+
onThoughtSignatureSentinels: (n) => { sentinelsStamped = n; },
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
catch (e) {
|
|
916
|
+
if (e instanceof RequestMappingError) {
|
|
917
|
+
return { ok: false, response: anthropicError(400, `llm-relay: ${e.message}`, "local") };
|
|
918
|
+
}
|
|
919
|
+
return { ok: false, response: anthropicError(502, `request translation failed: ${e.message}`, "local") };
|
|
920
|
+
}
|
|
921
|
+
const res = await invokeFetch(target.base + "/responses", {
|
|
922
|
+
method: "POST",
|
|
923
|
+
headers: buildTargetHeaders(attempt),
|
|
924
|
+
body: JSON.stringify(responsesBody),
|
|
925
|
+
signal: args.signal,
|
|
926
|
+
});
|
|
927
|
+
if (!res.ok) {
|
|
928
|
+
let body;
|
|
929
|
+
try {
|
|
930
|
+
body = await res.text();
|
|
931
|
+
}
|
|
932
|
+
catch (cause) {
|
|
933
|
+
return { ok: false, response: attachPostHeaderBodyFailure(res, cause) };
|
|
934
|
+
}
|
|
935
|
+
const hint = res.status === 404
|
|
936
|
+
? ` — model "${target.model}" is not served by provider "${target.provider}" (a model can be listed in /models and still 404 here)`
|
|
937
|
+
: "";
|
|
938
|
+
return {
|
|
939
|
+
ok: false,
|
|
940
|
+
response: anthropicError(res.status, `openai backend HTTP ${res.status}${hint}: ${body.slice(0, 300)}`, "upstream", {
|
|
941
|
+
...retryAfterHeader(res.headers),
|
|
942
|
+
}),
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
return { ok: true, res, toolCallIdsRewritten, sentinelsStamped };
|
|
946
|
+
}
|
|
947
|
+
async function fetchResponsesStreamed(args, res, toolCallIdsRewritten, sentinelsStamped) {
|
|
948
|
+
if (!res.body) {
|
|
949
|
+
return anthropicError(502, "llm-relay: invalid OpenAI upstream envelope: empty stream", "upstream", {
|
|
950
|
+
...retryAfterHeader(res.headers),
|
|
951
|
+
}, "invalid_upstream_envelope");
|
|
952
|
+
}
|
|
953
|
+
const preflight = await preflightResponseStream(res.body, "openai-responses");
|
|
954
|
+
if (!preflight.ok) {
|
|
955
|
+
return anthropicError(502, `llm-relay: invalid OpenAI upstream envelope: ${preflight.reason}`, "upstream", {
|
|
956
|
+
...retryAfterHeader(res.headers),
|
|
957
|
+
}, "invalid_upstream_envelope");
|
|
958
|
+
}
|
|
959
|
+
try {
|
|
960
|
+
const anthStream = translateResponsesStreamToAnthropic(preflight.body);
|
|
961
|
+
const observedStream = args.usage
|
|
962
|
+
? observeUsage(new Response(anthStream), "anthropic-messages", args.usage, { streamed: true }).body ?? anthStream
|
|
963
|
+
: anthStream;
|
|
964
|
+
const strippedStream = stripThinkTagsInStream(observedStream);
|
|
965
|
+
const schemas = toolSchemaMap(args.reqJson);
|
|
966
|
+
const refusalSignal = dialectRefusalSignal();
|
|
967
|
+
const recovered = schemas.size > 0
|
|
968
|
+
? recoverDialectInStream(strippedStream, schemas, args.isDestructive, refusalSignal)
|
|
969
|
+
: strippedStream;
|
|
970
|
+
const metadata = preflight.metadata;
|
|
971
|
+
if (toolCallIdsRewritten > 0)
|
|
972
|
+
metadata.toolCallIdRewrites = toolCallIdsRewritten;
|
|
973
|
+
if (sentinelsStamped > 0)
|
|
974
|
+
metadata.thoughtSignatureSentinels = sentinelsStamped;
|
|
975
|
+
const body = rewriteToolUseIdsInStream(recovered, () => knownToolUseIds(args.reqJson), (count) => { metadata.toolUseIdRewrites = count; });
|
|
976
|
+
const streamResponse = attachUpstreamMetadata(new Response(body, {
|
|
977
|
+
status: res.status,
|
|
978
|
+
headers: {
|
|
979
|
+
"content-type": "text/event-stream",
|
|
980
|
+
...(toolCallIdsRewritten > 0 ? { [TOOL_CALL_IDS_HEADER]: `${toolCallIdsRewritten} rewritten` } : {}),
|
|
981
|
+
},
|
|
982
|
+
}), metadata);
|
|
983
|
+
dialectRefusalSignals.set(streamResponse, refusalSignal);
|
|
984
|
+
return streamResponse;
|
|
985
|
+
}
|
|
986
|
+
catch (e) {
|
|
987
|
+
return anthropicError(502, `llm-relay: response translation failed: ${e.message}`, "local", {}, "relay_mapper_defect");
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
function mapResponsesJsonToAnthropicOrError(upstreamJson, target, args) {
|
|
991
|
+
try {
|
|
992
|
+
const schemas = toolSchemaMap(args.reqJson);
|
|
993
|
+
const message = openAiResponsesToAnthropicMessage(upstreamJson, target.model ?? "", schemas, args.isDestructive);
|
|
994
|
+
return { ok: true, message };
|
|
995
|
+
}
|
|
996
|
+
catch (e) {
|
|
997
|
+
if (e instanceof DialectUnparseableError) {
|
|
998
|
+
return { ok: false, response: anthropicError(502, `llm-relay: ${e.message}`, "upstream", {}, "tool_dialect_unparseable") };
|
|
999
|
+
}
|
|
1000
|
+
if (e instanceof DialectDestructiveError) {
|
|
1001
|
+
return {
|
|
1002
|
+
ok: false,
|
|
1003
|
+
response: anthropicError(502, `llm-relay: ${e.message}`, "local", { [TOOL_DIALECT_HEADER]: "refused-destructive" }, DIALECT_REFUSED_DESTRUCTIVE_CODE),
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
return { ok: false, response: anthropicError(502, `llm-relay: response translation failed: ${e.message}`, "local", {}, "relay_mapper_defect") };
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
async function fetchResponsesBuffered(args, target, res, toolCallIdsRewritten, sentinelsStamped) {
|
|
1010
|
+
let upstreamJson;
|
|
1011
|
+
try {
|
|
1012
|
+
upstreamJson = await res.json();
|
|
1013
|
+
}
|
|
1014
|
+
catch (cause) {
|
|
1015
|
+
if (!(cause instanceof SyntaxError))
|
|
1016
|
+
return attachPostHeaderBodyFailure(res, cause);
|
|
1017
|
+
return anthropicError(502, "llm-relay: invalid OpenAI upstream envelope: body is not valid JSON", "upstream", {
|
|
1018
|
+
...retryAfterHeader(res.headers),
|
|
1019
|
+
}, "invalid_upstream_envelope");
|
|
1020
|
+
}
|
|
1021
|
+
const invalidReason = invalidEnvelopeReason(upstreamJson, "openai-responses", false);
|
|
1022
|
+
if (invalidReason) {
|
|
1023
|
+
return anthropicError(502, `llm-relay: invalid OpenAI upstream envelope: ${invalidReason}`, "upstream", {
|
|
1024
|
+
...retryAfterHeader(res.headers),
|
|
1025
|
+
}, "invalid_upstream_envelope");
|
|
1026
|
+
}
|
|
1027
|
+
const mapped = mapResponsesJsonToAnthropicOrError(upstreamJson, target, args);
|
|
1028
|
+
if (!mapped.ok)
|
|
1029
|
+
return mapped.response;
|
|
1030
|
+
let anthropicJson = mapped.message;
|
|
1031
|
+
const mintedIds = mintUniqueToolUseIds(anthropicJson, args.reqJson);
|
|
1032
|
+
if (mintedIds.message)
|
|
1033
|
+
anthropicJson = mintedIds.message;
|
|
1034
|
+
const recoveredDialect = recoveredDialectOf(anthropicJson);
|
|
1035
|
+
if (args.usage) {
|
|
1036
|
+
applyResponsesUsage(args.usage, extractAnthropicUsageFromResponses(upstreamJson.usage));
|
|
1037
|
+
}
|
|
1038
|
+
const response = new Response(JSON.stringify(anthropicJson), {
|
|
1039
|
+
status: 200,
|
|
1040
|
+
headers: {
|
|
1041
|
+
"content-type": "application/json",
|
|
1042
|
+
...(recoveredDialect ? { [TOOL_DIALECT_HEADER]: recoveredDialect } : {}),
|
|
1043
|
+
...(mintedIds.rewritten > 0 ? { [TOOL_USE_IDS_HEADER]: `${mintedIds.rewritten} rewritten` } : {}),
|
|
1044
|
+
...(toolCallIdsRewritten > 0 ? { [TOOL_CALL_IDS_HEADER]: `${toolCallIdsRewritten} rewritten` } : {}),
|
|
1045
|
+
},
|
|
1046
|
+
});
|
|
1047
|
+
const metadata = {};
|
|
1048
|
+
captureReportedModel(metadata, upstreamJson, "openai-responses", false);
|
|
1049
|
+
if (mintedIds.rewritten > 0)
|
|
1050
|
+
metadata.toolUseIdRewrites = mintedIds.rewritten;
|
|
1051
|
+
if (toolCallIdsRewritten > 0)
|
|
1052
|
+
metadata.toolCallIdRewrites = toolCallIdsRewritten;
|
|
1053
|
+
if (sentinelsStamped > 0)
|
|
1054
|
+
metadata.thoughtSignatureSentinels = sentinelsStamped;
|
|
1055
|
+
return attachUpstreamMetadata(response, metadata);
|
|
1056
|
+
}
|
|
1057
|
+
async function fetchOpenAiResponsesBackend(attempt, args, invokeFetch) {
|
|
1058
|
+
let reqJson = args.reqJson;
|
|
1059
|
+
try {
|
|
1060
|
+
reqJson = await transcodeDocuments(reqJson);
|
|
1061
|
+
}
|
|
1062
|
+
catch (e) {
|
|
1063
|
+
if (e instanceof DocumentError)
|
|
1064
|
+
return anthropicError(400, `llm-relay: ${e.message}`, "local");
|
|
1065
|
+
return anthropicError(502, `document conversion failed: ${e.message}`, "local");
|
|
1066
|
+
}
|
|
1067
|
+
const posted = await postResponsesRequest(attempt, args, reqJson, invokeFetch);
|
|
1068
|
+
if (!posted.ok)
|
|
1069
|
+
return posted.response;
|
|
1070
|
+
if (args.wantsStream) {
|
|
1071
|
+
return fetchResponsesStreamed(args, posted.res, posted.toolCallIdsRewritten, posted.sentinelsStamped);
|
|
1072
|
+
}
|
|
1073
|
+
return fetchResponsesBuffered(args, attempt.target, posted.res, posted.toolCallIdsRewritten, posted.sentinelsStamped);
|
|
1074
|
+
}
|
|
327
1075
|
export async function fetchBackend(attempt, args, fetchFn = fetch) {
|
|
328
1076
|
const invokeFetch = oneShotFetch(fetchFn, args.signal, args.onEgress);
|
|
329
1077
|
if (attempt.target.kind === "anthropic") {
|
|
@@ -572,12 +1320,14 @@ function formatOpenAiChatCompletion(body, text, toolCalls, model, usage) {
|
|
|
572
1320
|
return out;
|
|
573
1321
|
}
|
|
574
1322
|
function formatOpenAiResponses(body, text, toolCalls, model, usage) {
|
|
1323
|
+
const capped = body.stop_reason === "max_tokens";
|
|
1324
|
+
const itemStatus = capped ? "incomplete" : "completed";
|
|
575
1325
|
const output = [];
|
|
576
1326
|
if (text) {
|
|
577
1327
|
output.push({
|
|
578
1328
|
type: "message",
|
|
579
1329
|
id: `msg_${typeof body.id === "string" ? body.id : "relay"}`,
|
|
580
|
-
status:
|
|
1330
|
+
status: itemStatus,
|
|
581
1331
|
role: "assistant",
|
|
582
1332
|
content: [{ type: "output_text", text, annotations: [] }],
|
|
583
1333
|
});
|
|
@@ -590,18 +1340,20 @@ function formatOpenAiResponses(body, text, toolCalls, model, usage) {
|
|
|
590
1340
|
call_id: call.id,
|
|
591
1341
|
name: fn.name,
|
|
592
1342
|
arguments: fn.arguments,
|
|
593
|
-
status:
|
|
1343
|
+
status: itemStatus,
|
|
594
1344
|
});
|
|
595
1345
|
}
|
|
596
1346
|
const out = {
|
|
597
1347
|
id: typeof body.id === "string" ? body.id : "resp_relay",
|
|
598
1348
|
object: "response",
|
|
599
1349
|
created_at: Math.floor(Date.now() / 1000),
|
|
600
|
-
status: "completed",
|
|
1350
|
+
status: capped ? "incomplete" : "completed",
|
|
601
1351
|
model,
|
|
602
1352
|
output,
|
|
603
1353
|
output_text: text,
|
|
604
1354
|
};
|
|
1355
|
+
if (capped)
|
|
1356
|
+
out.incomplete_details = { reason: "max_output_tokens" };
|
|
605
1357
|
if (usage)
|
|
606
1358
|
out.usage = withOpenAiTotal(usage);
|
|
607
1359
|
return out;
|
|
@@ -720,6 +1472,17 @@ function buildTargetHeaders(attempt) {
|
|
|
720
1472
|
...buildAuthHeaders(credential.value, target.authHeader),
|
|
721
1473
|
};
|
|
722
1474
|
}
|
|
1475
|
+
function resolveAnthropicResponsesMaxTokens(target, catalogLimits) {
|
|
1476
|
+
if (target.model) {
|
|
1477
|
+
const learned = observedMaxOutput(target.provider, target.model);
|
|
1478
|
+
if (typeof learned === "number" && learned > 0)
|
|
1479
|
+
return learned;
|
|
1480
|
+
const published = catalogLimits?.(target.provider, target.model)?.maxOutputTokens;
|
|
1481
|
+
if (typeof published === "number" && published > 0)
|
|
1482
|
+
return published;
|
|
1483
|
+
}
|
|
1484
|
+
return DEFAULT_RESPONSES_MAX_TOKENS;
|
|
1485
|
+
}
|
|
723
1486
|
async function fetchDirectOpenAiChat(attempt, args, invokeFetch) {
|
|
724
1487
|
const target = attempt.target;
|
|
725
1488
|
const base = (args.reqJson ?? {});
|
|
@@ -814,6 +1577,58 @@ async function fetchDirectOpenAiChat(attempt, args, invokeFetch) {
|
|
|
814
1577
|
}
|
|
815
1578
|
return attachUpstreamMetadata(res, metadata);
|
|
816
1579
|
}
|
|
1580
|
+
function tapAnthropicStopReason(source, onStopReason) {
|
|
1581
|
+
const decoder = new TextDecoder();
|
|
1582
|
+
const frames = new BufferedSseFrames();
|
|
1583
|
+
let seen = false;
|
|
1584
|
+
return source.pipeThrough(new TransformStream({
|
|
1585
|
+
transform(chunk, controller) {
|
|
1586
|
+
controller.enqueue(chunk);
|
|
1587
|
+
if (seen)
|
|
1588
|
+
return;
|
|
1589
|
+
frames.append(decoder.decode(chunk, { stream: true }));
|
|
1590
|
+
for (const { frame } of frames) {
|
|
1591
|
+
if (seen)
|
|
1592
|
+
break;
|
|
1593
|
+
const ev = parseSseEvent(frame);
|
|
1594
|
+
if (ev?.type !== "message_delta" || !isRecord(ev.data))
|
|
1595
|
+
continue;
|
|
1596
|
+
const delta = ev.data.delta;
|
|
1597
|
+
if (isRecord(delta) && typeof delta.stop_reason === "string") {
|
|
1598
|
+
seen = true;
|
|
1599
|
+
onStopReason(delta.stop_reason);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
},
|
|
1603
|
+
}));
|
|
1604
|
+
}
|
|
1605
|
+
function responsesSseEvent(type, data) {
|
|
1606
|
+
return `event: ${type}\ndata: ${JSON.stringify({ ...data, type })}\n\n`;
|
|
1607
|
+
}
|
|
1608
|
+
function markResponsesIncompleteOnMaxTokens(source, stopReason) {
|
|
1609
|
+
return createSseTransformStream(source, ({ push, frames }) => ({
|
|
1610
|
+
processFrames: () => {
|
|
1611
|
+
for (const { frame, raw } of frames) {
|
|
1612
|
+
const ev = parseSseEvent(frame);
|
|
1613
|
+
if (stopReason.value === "max_tokens" &&
|
|
1614
|
+
ev?.type === "response.completed" &&
|
|
1615
|
+
isRecord(ev.data) &&
|
|
1616
|
+
isRecord(ev.data.response)) {
|
|
1617
|
+
push(responsesSseEvent("response.incomplete", {
|
|
1618
|
+
...ev.data,
|
|
1619
|
+
response: {
|
|
1620
|
+
...ev.data.response,
|
|
1621
|
+
status: "incomplete",
|
|
1622
|
+
incomplete_details: { reason: "max_output_tokens" },
|
|
1623
|
+
},
|
|
1624
|
+
}));
|
|
1625
|
+
continue;
|
|
1626
|
+
}
|
|
1627
|
+
push(raw);
|
|
1628
|
+
}
|
|
1629
|
+
},
|
|
1630
|
+
}));
|
|
1631
|
+
}
|
|
817
1632
|
async function fetchTranslatedOpenAiFront(attempt, args, fetchFn) {
|
|
818
1633
|
const target = attempt.target;
|
|
819
1634
|
const protocol = args.protocol ?? "chat";
|
|
@@ -826,6 +1641,9 @@ async function fetchTranslatedOpenAiFront(attempt, args, fetchFn) {
|
|
|
826
1641
|
if (target.model !== undefined)
|
|
827
1642
|
anthropicBody.model = target.model;
|
|
828
1643
|
anthropicBody.stream = args.wantsStream;
|
|
1644
|
+
if (protocol === "responses" && target.kind === "anthropic" && typeof anthropicBody.max_tokens !== "number") {
|
|
1645
|
+
anthropicBody.max_tokens = resolveAnthropicResponsesMaxTokens(target, args.catalogLimits);
|
|
1646
|
+
}
|
|
829
1647
|
}
|
|
830
1648
|
catch (e) {
|
|
831
1649
|
if (e instanceof RequestMappingError)
|
|
@@ -881,7 +1699,13 @@ async function fetchTranslatedOpenAiFront(attempt, args, fetchFn) {
|
|
|
881
1699
|
}
|
|
882
1700
|
const targetProtocol = protocol === "responses" ? "openai-responses" : "openai";
|
|
883
1701
|
try {
|
|
884
|
-
const
|
|
1702
|
+
const stopReason = { value: null };
|
|
1703
|
+
const anthropicSource = protocol === "responses"
|
|
1704
|
+
? tapAnthropicStopReason(preflight.body, (r) => { stopReason.value = r; })
|
|
1705
|
+
: preflight.body;
|
|
1706
|
+
let output = handleUniversalStreamRequest(anthropicSource, "anthropic", targetProtocol);
|
|
1707
|
+
if (protocol === "responses")
|
|
1708
|
+
output = markResponsesIncompleteOnMaxTokens(output, stopReason);
|
|
885
1709
|
const rewritten = backendRes.headers.get(TOOL_CALL_IDS_HEADER);
|
|
886
1710
|
const response = new Response(output, {
|
|
887
1711
|
status: backendRes.status,
|
|
@@ -935,7 +1759,7 @@ async function fetchTranslatedOpenAiFront(attempt, args, fetchFn) {
|
|
|
935
1759
|
}
|
|
936
1760
|
export async function fetchOpenAiFront(attempt, args, fetchFn = fetch) {
|
|
937
1761
|
const protocol = args.protocol ?? "chat";
|
|
938
|
-
if (attempt.target.kind === "openai" && protocol === "chat") {
|
|
1762
|
+
if (attempt.target.kind === "openai" && protocol === "chat" && attempt.target.wire !== "responses") {
|
|
939
1763
|
const invokeFetch = oneShotFetch(fetchFn, args.signal, args.onEgress);
|
|
940
1764
|
return fetchDirectOpenAiChat(attempt, args, invokeFetch);
|
|
941
1765
|
}
|