agent-accelerator 0.5.9 → 0.6.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/README.md +3 -1
- package/examples/prompts/SYSTEM_PROMPT.md +2 -0
- package/examples/prompts/SYSTEM_PROMPT_AGENT.md +4 -0
- package/examples/prompts/SYSTEM_PROMPT_TOOLS.md +1 -0
- package/package.json +4 -1
- package/src/agent/agent.ts +366 -161
- package/src/agent/architecture.md +13 -12
- package/src/agent/config-blocks.ts +39 -12
- package/src/agent/context.ts +8 -3
- package/src/agent/delegation.ts +82 -29
- package/src/agent/loop.ts +444 -365
- package/src/agent/subagent.ts +1 -3
- package/src/agent/subagents-tool.ts +45 -7
- package/src/agent/subagents.md +3 -0
- package/src/agent/turns.ts +3 -1
- package/src/agent/worker.ts +56 -27
- package/src/agent/workers.ts +113 -20
- package/src/architecture.md +16 -15
- package/src/index.ts +34 -10
- package/src/models/architecture.md +4 -3
- package/src/models/catalog-cache.ts +6 -5
- package/src/models/catalog.ts +39 -30
- package/src/providers/architecture.md +10 -8
- package/src/providers/google.ts +147 -59
- package/src/providers/openai-compat.ts +57 -49
- package/src/providers/openai.ts +54 -37
- package/src/providers/openrouter.ts +85 -30
- package/src/providers/registry.ts +6 -21
- package/src/providers/shared.ts +87 -33
- package/src/providers.ts +56 -115
- package/src/scheduler/architecture.md +7 -6
- package/src/scheduler/scheduled-actions.md +4 -0
- package/src/scheduler/scheduler.ts +186 -60
- package/src/scheduler/store.ts +23 -10
- package/src/scheduler/time.ts +17 -3
- package/src/scheduler/tool.ts +33 -7
- package/src/session/architecture.md +7 -6
- package/src/session/journal.ts +38 -12
- package/src/session/store.ts +122 -61
- package/src/session/usage.ts +58 -9
- package/src/streaming/architecture.md +4 -3
- package/src/streaming/event-stream.ts +26 -12
- package/src/telemetry.ts +6 -2
- package/src/tools/architecture.md +8 -7
- package/src/tools/executor.ts +50 -48
- package/src/tools/schema.ts +10 -6
- package/src/tools/subagent-result.ts +6 -1
- package/src/tools/tool.ts +9 -11
- package/src/types/agent.ts +2 -21
- package/src/types/architecture.md +11 -10
- package/src/types/core.ts +1 -9
- package/src/types/errors.ts +26 -4
- package/src/types/model.ts +0 -4
- package/src/types/tool.ts +12 -2
- package/src/update-models.ts +5 -5
- package/src/utils/architecture.md +16 -15
- package/src/utils/cache.ts +1 -3
- package/src/utils/durable.ts +17 -5
- package/src/utils/env.ts +9 -7
- package/src/utils/errors.ts +6 -14
- package/src/utils/headers.ts +3 -10
- package/src/utils/media.ts +23 -8
- package/src/utils/raw-capture.ts +9 -2
- package/src/utils/retry.ts +15 -3
- package/src/utils/safe-io.ts +29 -7
- package/src/utils/serialization.ts +1 -4
- package/src/utils/session.ts +1 -2
package/src/providers/openai.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* effort, service_tier flex/priority, prompt_cache_key affinity, usage,
|
|
13
13
|
* finish reasons, switching, abort, errors.
|
|
14
14
|
*
|
|
15
|
-
* Also implemented: `text.format` structured output, `max_output_tokens
|
|
15
|
+
* Also implemented: `text.format` structured output, `max_output_tokens`.
|
|
16
16
|
*
|
|
17
17
|
* Out of scope (never sent): background, conversation, previous_response_id
|
|
18
18
|
* chaining, include, metadata, truncation, safety_identifier,
|
|
@@ -59,7 +59,6 @@ import {
|
|
|
59
59
|
newToolCallId,
|
|
60
60
|
redactedHeaders,
|
|
61
61
|
readErrorPayload,
|
|
62
|
-
incompleteStreamError,
|
|
63
62
|
hasUnfinishedToolCall,
|
|
64
63
|
linkStreamAbort,
|
|
65
64
|
withAbortTimer,
|
|
@@ -71,7 +70,6 @@ import {
|
|
|
71
70
|
combinedSignal,
|
|
72
71
|
timeoutFor,
|
|
73
72
|
hasAuthHeader,
|
|
74
|
-
clampToolCallId,
|
|
75
73
|
openStreamResponse,
|
|
76
74
|
retryOptionsFrom,
|
|
77
75
|
headersToRecord,
|
|
@@ -124,7 +122,15 @@ interface ResponsesRequestBody {
|
|
|
124
122
|
prompt_cache_key?: string;
|
|
125
123
|
store?: boolean;
|
|
126
124
|
stream?: boolean;
|
|
127
|
-
text?: {
|
|
125
|
+
text?: {
|
|
126
|
+
format: {
|
|
127
|
+
type: "json_schema";
|
|
128
|
+
name: string;
|
|
129
|
+
schema: Record<string, unknown>;
|
|
130
|
+
strict: boolean;
|
|
131
|
+
description?: string;
|
|
132
|
+
};
|
|
133
|
+
};
|
|
128
134
|
[k: string]: unknown;
|
|
129
135
|
}
|
|
130
136
|
|
|
@@ -212,13 +218,9 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ResponseConte
|
|
|
212
218
|
for (const part of parts) {
|
|
213
219
|
if (part.type === "text" && part.text) {
|
|
214
220
|
blocks.push({ type: "input_text", text: part.text });
|
|
215
|
-
} else if (
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
part.type === "video" ||
|
|
219
|
-
part.type === "file"
|
|
220
|
-
) {
|
|
221
|
-
const raw = (part as { image?: unknown; audio?: unknown; video?: unknown; file?: unknown }).image ??
|
|
221
|
+
} else if (part.type === "image" || part.type === "audio" || part.type === "video" || part.type === "file") {
|
|
222
|
+
const raw =
|
|
223
|
+
(part as { image?: unknown; audio?: unknown; video?: unknown; file?: unknown }).image ??
|
|
222
224
|
(part as { audio?: unknown }).audio ??
|
|
223
225
|
(part as { video?: unknown }).video ??
|
|
224
226
|
(part as { file?: unknown }).file;
|
|
@@ -319,7 +321,8 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
|
|
|
319
321
|
if (m.role === "system") continue;
|
|
320
322
|
if (m.role === "user") {
|
|
321
323
|
if (typeof m.content === "string") {
|
|
322
|
-
if (m.content)
|
|
324
|
+
if (m.content)
|
|
325
|
+
items.push({ type: "message", role: "user", content: [{ type: "input_text", text: m.content }] });
|
|
323
326
|
} else {
|
|
324
327
|
const blocks = await contentPartsToBlocks(m.content);
|
|
325
328
|
if (blocks.length > 0) items.push({ type: "message", role: "user", content: blocks });
|
|
@@ -363,7 +366,11 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
|
|
|
363
366
|
// provider-minted ids that canonical history does not retain.
|
|
364
367
|
}
|
|
365
368
|
if (texts.length > 0) {
|
|
366
|
-
items.push({
|
|
369
|
+
items.push({
|
|
370
|
+
type: "message",
|
|
371
|
+
role: "assistant",
|
|
372
|
+
content: texts.map((t) => ({ type: "output_text", text: t })),
|
|
373
|
+
});
|
|
367
374
|
}
|
|
368
375
|
} else if (m.role === "tool") {
|
|
369
376
|
if (typeof m.content === "string") {
|
|
@@ -585,8 +592,8 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
585
592
|
if (toolChoice !== undefined) body.tool_choice = toolChoice;
|
|
586
593
|
if (effort) body.reasoning = { effort };
|
|
587
594
|
if (serviceTier) body.service_tier = serviceTier;
|
|
588
|
-
// max_output_tokens
|
|
589
|
-
Object.assign(body, mapGenerationParamsToOpenAI(options
|
|
595
|
+
// max_output_tokens.
|
|
596
|
+
Object.assign(body, mapGenerationParamsToOpenAI(options));
|
|
590
597
|
// Structured outputs: Responses `text.format{type:json_schema,name,schema,strict}`.
|
|
591
598
|
// Skipped when emulated (instruction already in `instructions`).
|
|
592
599
|
if (outputSpec && !outputEmulated) {
|
|
@@ -640,11 +647,7 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
640
647
|
sessionId: string | undefined,
|
|
641
648
|
signal?: AbortSignal
|
|
642
649
|
): RequestInit {
|
|
643
|
-
const headers: Record<string, string> = buildSessionHeaders(
|
|
644
|
-
"openai",
|
|
645
|
-
options?.headers,
|
|
646
|
-
sessionId
|
|
647
|
-
);
|
|
650
|
+
const headers: Record<string, string> = buildSessionHeaders("openai", options?.headers, sessionId);
|
|
648
651
|
// Case-insensitive merge so a lowercase `authorization` custom header
|
|
649
652
|
// never coexists with the canonical bearer. Proxy flows
|
|
650
653
|
// (`apiKey === "unused"`) preserve custom auth instead of overwriting.
|
|
@@ -662,7 +665,10 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
662
665
|
sessionId: string | undefined,
|
|
663
666
|
signal?: AbortSignal
|
|
664
667
|
): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
|
|
665
|
-
const { signal: effective, cleanup } = combinedSignal(
|
|
668
|
+
const { signal: effective, cleanup } = combinedSignal(
|
|
669
|
+
signal,
|
|
670
|
+
timeoutFor(options as { timeoutMs?: number } | undefined)
|
|
671
|
+
);
|
|
666
672
|
try {
|
|
667
673
|
const res = await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, effective));
|
|
668
674
|
const text = await res.text();
|
|
@@ -769,12 +775,10 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
769
775
|
// Stateless turns never establish chains, but the session store still
|
|
770
776
|
// records the turn so other providers' switch detection keeps working.
|
|
771
777
|
noteProviderTurn(sessionId, "openai");
|
|
772
|
-
return parseResponse(
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
{ request: rawRequest, response: { status: res.status, statusText: res.statusText, headers: res.headers, body: response } }
|
|
777
|
-
);
|
|
778
|
+
return parseResponse(response, clean, Date.now() - startTime, {
|
|
779
|
+
request: rawRequest,
|
|
780
|
+
response: { status: res.status, statusText: res.statusText, headers: res.headers, body: response },
|
|
781
|
+
});
|
|
778
782
|
};
|
|
779
783
|
|
|
780
784
|
try {
|
|
@@ -834,9 +838,13 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
834
838
|
// Transient failures (429/5xx/network) are retried here, before the
|
|
835
839
|
// first byte; once the stream is live it is never retried.
|
|
836
840
|
const res = await openStreamResponse({
|
|
837
|
-
fetchOnce: () =>
|
|
841
|
+
fetchOnce: () =>
|
|
842
|
+
withAbortTimer(linked, streamTimeoutMs, () =>
|
|
843
|
+
(options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal))
|
|
844
|
+
),
|
|
838
845
|
onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers)),
|
|
839
|
-
noBodyError: () =>
|
|
846
|
+
noBodyError: () =>
|
|
847
|
+
toConciseProviderError(new Error("OpenAI streaming response had no body"), "openai", clean),
|
|
840
848
|
retry: retryOptionsFrom(options),
|
|
841
849
|
signal: linked.signal,
|
|
842
850
|
label: { providerId: "openai", modelId: clean },
|
|
@@ -845,13 +853,20 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
845
853
|
res.headers.forEach((v, k) => {
|
|
846
854
|
responseHeaders[k] = v;
|
|
847
855
|
});
|
|
848
|
-
const responseMeta: Record<string, unknown> & {
|
|
856
|
+
const responseMeta: Record<string, unknown> & {
|
|
857
|
+
status: number;
|
|
858
|
+
statusText: string;
|
|
859
|
+
headers: Record<string, string>;
|
|
860
|
+
} = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
849
861
|
eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
|
|
850
862
|
|
|
851
863
|
let text = "";
|
|
852
864
|
let thinking = "";
|
|
853
865
|
// Tool calls keyed by output_index: { itemId, callId, name, startArgs, deltaArgs }.
|
|
854
|
-
const calls = new Map<
|
|
866
|
+
const calls = new Map<
|
|
867
|
+
number,
|
|
868
|
+
{ itemId: string; callId: string; name: string; startArgs: string; deltaArgs: string }
|
|
869
|
+
>();
|
|
855
870
|
let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
856
871
|
let finishReason = "stop";
|
|
857
872
|
let sawFinishReason = false;
|
|
@@ -892,10 +907,7 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
892
907
|
} else if (type === "response.output_text.delta" && typeof msg["delta"] === "string") {
|
|
893
908
|
text += msg["delta"] as string;
|
|
894
909
|
eventStream.push({ type: "text_delta", delta: msg["delta"] as string, partialText: text });
|
|
895
|
-
} else if (
|
|
896
|
-
type === "response.refusal.delta" ||
|
|
897
|
-
type === "response.output_refusal.delta"
|
|
898
|
-
) {
|
|
910
|
+
} else if (type === "response.refusal.delta" || type === "response.output_refusal.delta") {
|
|
899
911
|
// Refusal deltas carry the filter text; surface as text and
|
|
900
912
|
// remember for a `refusal` finish reason.
|
|
901
913
|
if (typeof msg["delta"] === "string" && msg["delta"]) {
|
|
@@ -908,7 +920,11 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
908
920
|
typeof msg["delta"] === "string"
|
|
909
921
|
) {
|
|
910
922
|
thinking += msg["delta"] as string;
|
|
911
|
-
eventStream.push({
|
|
923
|
+
eventStream.push({
|
|
924
|
+
type: "thinking_delta",
|
|
925
|
+
thinkingDelta: msg["delta"] as string,
|
|
926
|
+
partialThinking: thinking,
|
|
927
|
+
});
|
|
912
928
|
} else if (type === "response.output_item.added") {
|
|
913
929
|
const index = (msg["output_index"] as number) ?? 0;
|
|
914
930
|
const item = (msg["item"] as Record<string, unknown>) ?? {};
|
|
@@ -957,7 +973,8 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
957
973
|
// Non-streaming maps `incomplete` → `length` (max tokens). Streaming
|
|
958
974
|
// must do the same — otherwise truncated turns misreport `stop`.
|
|
959
975
|
// Content-filter incompletes surface as `refusal`.
|
|
960
|
-
const incompleteReason = (response as { incomplete_details?: { reason?: string } }).incomplete_details
|
|
976
|
+
const incompleteReason = (response as { incomplete_details?: { reason?: string } }).incomplete_details
|
|
977
|
+
?.reason;
|
|
961
978
|
if (response.status === "incomplete") {
|
|
962
979
|
finishReason = incompleteReason === "content_filter" ? "refusal" : "length";
|
|
963
980
|
sawFinishReason = true;
|
|
@@ -51,7 +51,6 @@ import {
|
|
|
51
51
|
newToolCallId,
|
|
52
52
|
redactedHeaders,
|
|
53
53
|
readErrorPayload,
|
|
54
|
-
incompleteStreamError,
|
|
55
54
|
hasUnfinishedToolCall,
|
|
56
55
|
linkStreamAbort,
|
|
57
56
|
withAbortTimer,
|
|
@@ -123,7 +122,10 @@ interface ChatRequestBody {
|
|
|
123
122
|
service_tier?: string;
|
|
124
123
|
session_id?: string;
|
|
125
124
|
cache_control?: { type: "ephemeral"; ttl: "5m" | "1h" };
|
|
126
|
-
response_format?: {
|
|
125
|
+
response_format?: {
|
|
126
|
+
type: "json_schema";
|
|
127
|
+
json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string };
|
|
128
|
+
};
|
|
127
129
|
stream?: boolean;
|
|
128
130
|
[k: string]: unknown;
|
|
129
131
|
}
|
|
@@ -173,7 +175,13 @@ function cleanModelId(model: string | ModelSpec): string {
|
|
|
173
175
|
}
|
|
174
176
|
|
|
175
177
|
// The HTTP status of an error chunk inside a stream (the HTTP status itself is 200): its numeric `code`, else the status of its retryable `error_type` (errors-and-debugging.md, "Typed Error Codes"); in: the chunk's error, out: streamInto, where it makes the error retryable.
|
|
176
|
-
const RETRYABLE_ERROR_TYPE_STATUS: Record<string, number> = {
|
|
178
|
+
const RETRYABLE_ERROR_TYPE_STATUS: Record<string, number> = {
|
|
179
|
+
rate_limit_exceeded: 429,
|
|
180
|
+
provider_overloaded: 503,
|
|
181
|
+
provider_unavailable: 502,
|
|
182
|
+
timeout: 504,
|
|
183
|
+
server: 500,
|
|
184
|
+
};
|
|
177
185
|
// HTTP-like status of an error chunk inside a stream, or undefined when unknown; in: streamInto's error branch, out: the status that makes the error retryable.
|
|
178
186
|
function midStreamStatus(code: unknown, errorType: unknown): number | undefined {
|
|
179
187
|
if (typeof code === "number" && Number.isInteger(code) && code >= 100 && code <= 599) return code;
|
|
@@ -195,7 +203,18 @@ function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<stri
|
|
|
195
203
|
}
|
|
196
204
|
|
|
197
205
|
// The `input_audio.format` for a MIME type: Chat Completions takes any format string the provider supports (audio.md: wav, mp3, aiff, aac, ogg, flac, m4a, pcm16 ...), so the real format is sent, never a guess; in: an audio part's MIME type, out: contentPartsToBlocks.
|
|
198
|
-
const AUDIO_FORMAT: Record<string, string> = {
|
|
206
|
+
const AUDIO_FORMAT: Record<string, string> = {
|
|
207
|
+
wav: "wav",
|
|
208
|
+
wave: "wav",
|
|
209
|
+
mpeg: "mp3",
|
|
210
|
+
mp3: "mp3",
|
|
211
|
+
mp4: "m4a",
|
|
212
|
+
m4a: "m4a",
|
|
213
|
+
aac: "aac",
|
|
214
|
+
ogg: "ogg",
|
|
215
|
+
flac: "flac",
|
|
216
|
+
aiff: "aiff",
|
|
217
|
+
};
|
|
199
218
|
// The `input_audio.format` for an audio MIME type (see AUDIO_FORMAT above); in: contentPartsToBlocks, out: the format string.
|
|
200
219
|
function audioFormatFor(mimeType?: string): string {
|
|
201
220
|
const subtype = (mimeType || "").toLowerCase().split(";")[0]!.split("/")[1]?.replace(/^x-/, "") ?? "";
|
|
@@ -203,7 +222,12 @@ function audioFormatFor(mimeType?: string): string {
|
|
|
203
222
|
}
|
|
204
223
|
|
|
205
224
|
// `ImagePart.resolution` as OpenRouter's `image_url.detail` (api-ref ChatContentImage: auto, low, high, original; `original` is downgraded to `high` where unsupported); videos have no such field; in: an image part, out: contentPartsToBlocks.
|
|
206
|
-
const IMAGE_DETAIL: Record<string, "low" | "auto" | "high" | "original"> = {
|
|
225
|
+
const IMAGE_DETAIL: Record<string, "low" | "auto" | "high" | "original"> = {
|
|
226
|
+
low: "low",
|
|
227
|
+
medium: "auto",
|
|
228
|
+
high: "high",
|
|
229
|
+
ultra_high: "original",
|
|
230
|
+
};
|
|
207
231
|
|
|
208
232
|
// True when a request's messages carry an image or video by web link (the router fetches those itself); in: the built messages, out: throwIfError's hint.
|
|
209
233
|
function hasRemoteMedia(messages: ChatMessage[]): boolean {
|
|
@@ -211,7 +235,9 @@ function hasRemoteMedia(messages: ChatMessage[]): boolean {
|
|
|
211
235
|
(m) =>
|
|
212
236
|
Array.isArray(m.content) &&
|
|
213
237
|
(m.content as ChatContentPart[]).some((p) => {
|
|
214
|
-
const url =
|
|
238
|
+
const url =
|
|
239
|
+
(p as { image_url?: { url?: string }; video_url?: { url?: string } }).image_url?.url ??
|
|
240
|
+
(p as { video_url?: { url?: string } }).video_url?.url;
|
|
215
241
|
return typeof url === "string" && /^https?:\/\//.test(url);
|
|
216
242
|
})
|
|
217
243
|
);
|
|
@@ -258,10 +284,7 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPa
|
|
|
258
284
|
const mimeType = (part as { mimeType?: string }).mimeType;
|
|
259
285
|
// OpenAI chat audio shape (router parsed it live; capability routing
|
|
260
286
|
// decides per model). Always inline — `input_audio` carries data only.
|
|
261
|
-
const norm = await normalizeMediaInput(
|
|
262
|
-
raw as string | Uint8Array | ArrayBuffer,
|
|
263
|
-
mimeType
|
|
264
|
-
);
|
|
287
|
+
const norm = await normalizeMediaInput(raw as string | Uint8Array | ArrayBuffer, mimeType);
|
|
265
288
|
blocks.push({
|
|
266
289
|
type: "input_audio",
|
|
267
290
|
input_audio: { data: norm.base64Data, format: audioFormatFor(norm.mimeType) },
|
|
@@ -273,7 +296,12 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPa
|
|
|
273
296
|
const fileData =
|
|
274
297
|
typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))
|
|
275
298
|
? raw
|
|
276
|
-
: (
|
|
299
|
+
: (
|
|
300
|
+
await normalizeMediaInput(
|
|
301
|
+
raw as string | Uint8Array | ArrayBuffer,
|
|
302
|
+
(part as { mimeType?: string }).mimeType
|
|
303
|
+
)
|
|
304
|
+
).dataUrl;
|
|
277
305
|
blocks.push({
|
|
278
306
|
type: "file",
|
|
279
307
|
file: { ...(typeof filename === "string" && filename ? { filename } : {}), file_data: fileData },
|
|
@@ -329,10 +357,16 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
|
|
|
329
357
|
messages.push({ role: "assistant", content: "I have processed the tool results." });
|
|
330
358
|
}
|
|
331
359
|
if (typeof m.content === "string") {
|
|
332
|
-
if (m.content) {
|
|
360
|
+
if (m.content) {
|
|
361
|
+
messages.push({ role: "user", content: m.content });
|
|
362
|
+
lastRole = "user";
|
|
363
|
+
}
|
|
333
364
|
} else {
|
|
334
365
|
const blocks = await contentPartsToBlocks(m.content);
|
|
335
|
-
if (blocks.length > 0) {
|
|
366
|
+
if (blocks.length > 0) {
|
|
367
|
+
messages.push({ role: "user", content: blocks });
|
|
368
|
+
lastRole = "user";
|
|
369
|
+
}
|
|
336
370
|
}
|
|
337
371
|
} else if (m.role === "assistant") {
|
|
338
372
|
if (typeof m.content === "string") {
|
|
@@ -414,7 +448,8 @@ function mapUsage(raw?: ChatUsage): TokenUsage {
|
|
|
414
448
|
cacheWriteTokens: raw?.prompt_tokens_details?.cache_write_tokens ?? 0,
|
|
415
449
|
thinkingTokens: raw?.completion_tokens_details?.reasoning_tokens ?? 0,
|
|
416
450
|
};
|
|
417
|
-
if (typeof raw?.cost === "number" && raw.cost >= 0) {
|
|
451
|
+
if (typeof raw?.cost === "number" && raw.cost >= 0) {
|
|
452
|
+
// an explicit 0 (a free model, a response-cache hit) is a known cost, not a missing one
|
|
418
453
|
usage.cost = { totalCost: raw.cost };
|
|
419
454
|
}
|
|
420
455
|
return usage;
|
|
@@ -460,7 +495,12 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
460
495
|
const { effort } = mapThinkingLevelToOpenRouterChat(options?.thinking?.level);
|
|
461
496
|
const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
|
|
462
497
|
// Anthropic models take a top-level `cache_control` TTL; every other route caches implicitly (docs: prompt-caching.md).
|
|
463
|
-
const cache = resolveCache(
|
|
498
|
+
const cache = resolveCache(
|
|
499
|
+
"openrouter",
|
|
500
|
+
options?.cache,
|
|
501
|
+
/^~?anthropic\//.test(modelId) ? ["implicit", "5m", "1h"] : ["implicit"],
|
|
502
|
+
`openrouter/${modelId}`
|
|
503
|
+
);
|
|
464
504
|
warnIgnoredMediaResolution("openrouter", context.messages, `openrouter/${modelId}`, ["video"]);
|
|
465
505
|
|
|
466
506
|
// Structured outputs: native `response_format` when supported, else
|
|
@@ -485,7 +525,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
485
525
|
provider: "openrouter",
|
|
486
526
|
capability: "structured output",
|
|
487
527
|
requested: `${outputSpec.name} (openrouter/${modelId})`,
|
|
488
|
-
reason:
|
|
528
|
+
reason:
|
|
529
|
+
"the catalog reports structured_output: false for this model, so native response_format is unsupported.",
|
|
489
530
|
fallback: "emulated JSON instruction + client validation (no response_format is sent)",
|
|
490
531
|
});
|
|
491
532
|
}
|
|
@@ -505,7 +546,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
505
546
|
if (serviceTier) body.service_tier = serviceTier;
|
|
506
547
|
if (cache === "5m" || cache === "1h") body.cache_control = { type: "ephemeral", ttl: cache };
|
|
507
548
|
// Documented Chat Completions generation controls (only those the caller set).
|
|
508
|
-
Object.assign(body, mapGenerationParamsToChat(options
|
|
549
|
+
Object.assign(body, mapGenerationParamsToChat(options));
|
|
509
550
|
// Structured outputs: Chat Completions `response_format{type:json_schema,json_schema}`.
|
|
510
551
|
// Skipped when emulated (unsupported model): instruction already in messages.
|
|
511
552
|
if (outputSpec && !outputEmulated) {
|
|
@@ -537,11 +578,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
537
578
|
): RequestInit {
|
|
538
579
|
// Attribution + session headers mirror the previous transport exactly
|
|
539
580
|
// (HTTP-Referer / X-Title / x-session-id / x-client-request-id).
|
|
540
|
-
const headers: Record<string, string> = buildSessionHeaders(
|
|
541
|
-
"openrouter",
|
|
542
|
-
options?.headers,
|
|
543
|
-
sessionId
|
|
544
|
-
);
|
|
581
|
+
const headers: Record<string, string> = buildSessionHeaders("openrouter", options?.headers, sessionId);
|
|
545
582
|
setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
|
|
546
583
|
if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
|
|
547
584
|
return { method: "POST", headers, body: JSON.stringify(body), signal };
|
|
@@ -593,10 +630,16 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
593
630
|
options?: ProviderRequestOptions
|
|
594
631
|
): Promise<ProviderGenerateResult> {
|
|
595
632
|
// The whole call stays bounded by `timeoutMs`; the stream's own timer only covers the wait for its first byte.
|
|
596
|
-
const { signal, cleanup } = combinedSignal(
|
|
633
|
+
const { signal, cleanup } = combinedSignal(
|
|
634
|
+
options?.signal,
|
|
635
|
+
timeoutFor(options as { timeoutMs?: number } | undefined)
|
|
636
|
+
);
|
|
597
637
|
try {
|
|
598
638
|
// Nobody reads the deltas here, so the stream keeps none (a burst of frames would overflow its buffer for nothing).
|
|
599
|
-
const events = this.streamInto(new AssistantMessageEventStream({ buffer: false }), model, context, {
|
|
639
|
+
const events = this.streamInto(new AssistantMessageEventStream({ buffer: false }), model, context, {
|
|
640
|
+
...options,
|
|
641
|
+
signal,
|
|
642
|
+
});
|
|
600
643
|
const res = await events.result();
|
|
601
644
|
return {
|
|
602
645
|
text: res.text,
|
|
@@ -675,9 +718,14 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
675
718
|
// Transient failures (429/5xx/network) are retried here, before the
|
|
676
719
|
// first byte; once the stream is live it is never retried.
|
|
677
720
|
const res = await openStreamResponse({
|
|
678
|
-
fetchOnce: () =>
|
|
679
|
-
|
|
680
|
-
|
|
721
|
+
fetchOnce: () =>
|
|
722
|
+
withAbortTimer(linked, streamTimeoutMs, () =>
|
|
723
|
+
(options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal))
|
|
724
|
+
),
|
|
725
|
+
onHttpError: (r, text) =>
|
|
726
|
+
this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers), hasRemoteMedia(body.messages)),
|
|
727
|
+
noBodyError: () =>
|
|
728
|
+
toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean),
|
|
681
729
|
retry: retryOptionsFrom(options),
|
|
682
730
|
signal: linked.signal,
|
|
683
731
|
label: { providerId: "openrouter", modelId: clean },
|
|
@@ -686,7 +734,11 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
686
734
|
res.headers.forEach((v, k) => {
|
|
687
735
|
responseHeaders[k] = v;
|
|
688
736
|
});
|
|
689
|
-
const responseMeta: Record<string, unknown> & {
|
|
737
|
+
const responseMeta: Record<string, unknown> & {
|
|
738
|
+
status: number;
|
|
739
|
+
statusText: string;
|
|
740
|
+
headers: Record<string, string>;
|
|
741
|
+
} = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
690
742
|
eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
|
|
691
743
|
|
|
692
744
|
let text = "";
|
|
@@ -721,7 +773,9 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
721
773
|
}
|
|
722
774
|
// Mid-stream provider error: top-level `error`, HTTP stays 200.
|
|
723
775
|
// Must surface — resolving empty success hides it.
|
|
724
|
-
const topError = msg["error"] as
|
|
776
|
+
const topError = msg["error"] as
|
|
777
|
+
| { message?: string; code?: string | number; metadata?: { error_type?: string } }
|
|
778
|
+
| undefined;
|
|
725
779
|
if (topError && typeof topError === "object") {
|
|
726
780
|
const message =
|
|
727
781
|
typeof topError.message === "string" && topError.message
|
|
@@ -738,7 +792,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
738
792
|
if (typeof msg["id"] === "string" && !responseId) {
|
|
739
793
|
responseId = msg["id"] as string;
|
|
740
794
|
}
|
|
741
|
-
const choice =
|
|
795
|
+
const choice =
|
|
796
|
+
(Array.isArray(msg["choices"]) ? (msg["choices"] as Record<string, unknown>[])[0] : undefined) ?? {};
|
|
742
797
|
const delta = (choice["delta"] as Record<string, unknown>) ?? {};
|
|
743
798
|
// Text delta (content-free accounting frames carry "" — ignored).
|
|
744
799
|
// Refusal deltas surface as text with a `refusal` finish.
|
|
@@ -31,12 +31,7 @@ providerRegistry.set("openai", openaiProvider);
|
|
|
31
31
|
// MODEL="cerebras/gpt-oss-120b" + CEREBRAS_API_KEY + CEREBRAS_BASE_URL
|
|
32
32
|
// MODEL="ollama/qwen2.5-coder" + OLLAMA_BASE_URL (key optional for local)
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
34
|
-
const FIRST_CLASS = new Set([
|
|
35
|
-
"google",
|
|
36
|
-
"gemini",
|
|
37
|
-
"openrouter",
|
|
38
|
-
"openai",
|
|
39
|
-
]);
|
|
34
|
+
const FIRST_CLASS = new Set(["google", "gemini", "openrouter", "openai"]);
|
|
40
35
|
|
|
41
36
|
// Removed providers: fail loudly instead of misrouting. OpenCode free models
|
|
42
37
|
// are harness-only since 2026, so the opencode/opencode-go prefixes no longer
|
|
@@ -168,9 +163,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
|
|
|
168
163
|
// Try catalog first for any provider/model
|
|
169
164
|
const tryCatalogLookup = (prov: string, mid: string): ModelSpec | undefined => {
|
|
170
165
|
return (
|
|
171
|
-
getModelFromCatalog(prov, mid) ||
|
|
172
|
-
getModelFromCatalog(prov.toLowerCase(), mid) ||
|
|
173
|
-
getModelFromCatalog(mid, mid)
|
|
166
|
+
getModelFromCatalog(prov, mid) || getModelFromCatalog(prov.toLowerCase(), mid) || getModelFromCatalog(mid, mid)
|
|
174
167
|
);
|
|
175
168
|
};
|
|
176
169
|
|
|
@@ -194,11 +187,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
|
|
|
194
187
|
}
|
|
195
188
|
|
|
196
189
|
// 2. Unified layer: any non-first-class prefix routes to auto-created custom provider
|
|
197
|
-
const isExplicitOther = [
|
|
198
|
-
"google",
|
|
199
|
-
"gemini",
|
|
200
|
-
"openrouter",
|
|
201
|
-
].includes(providerPrefix);
|
|
190
|
+
const isExplicitOther = ["google", "gemini", "openrouter"].includes(providerPrefix);
|
|
202
191
|
|
|
203
192
|
if (!isExplicitOther && providerPrefix !== "openai") {
|
|
204
193
|
const envName = providerPrefix.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
@@ -210,8 +199,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
|
|
|
210
199
|
getEnv(`${envName}_BASE_API_KEY`)
|
|
211
200
|
);
|
|
212
201
|
|
|
213
|
-
const looksLikeOpenRouterScope =
|
|
214
|
-
parts.length === 2 && modelStr.includes(":") && !hasDedicatedEnv;
|
|
202
|
+
const looksLikeOpenRouterScope = parts.length === 2 && modelStr.includes(":") && !hasDedicatedEnv;
|
|
215
203
|
|
|
216
204
|
if (!looksLikeOpenRouterScope) {
|
|
217
205
|
const custom = ensureCustomProvider(providerPrefix);
|
|
@@ -251,9 +239,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
|
|
|
251
239
|
}
|
|
252
240
|
|
|
253
241
|
// Check catalog for this exact provider/model
|
|
254
|
-
const catalogSpec =
|
|
255
|
-
tryCatalogLookup(providerPrefix, remainingModel) ||
|
|
256
|
-
tryCatalogLookup(providerPrefix, modelStr);
|
|
242
|
+
const catalogSpec = tryCatalogLookup(providerPrefix, remainingModel) || tryCatalogLookup(providerPrefix, modelStr);
|
|
257
243
|
if (catalogSpec) {
|
|
258
244
|
const internalProvider = getProvider(catalogSpec.provider);
|
|
259
245
|
return { provider: internalProvider, modelId: catalogSpec.id, modelSpec: catalogSpec };
|
|
@@ -277,8 +263,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
|
|
|
277
263
|
|
|
278
264
|
// Fallback custom provider
|
|
279
265
|
const fallbackCustom = ensureCustomProvider(providerPrefix);
|
|
280
|
-
const fallbackSpec =
|
|
281
|
-
fallbackCustom.getModel(remainingModel) || fallbackCustom.getModel(modelStr);
|
|
266
|
+
const fallbackSpec = fallbackCustom.getModel(remainingModel) || fallbackCustom.getModel(modelStr);
|
|
282
267
|
return {
|
|
283
268
|
provider: fallbackCustom,
|
|
284
269
|
modelId: remainingModel,
|