agent-accelerator 0.5.9 → 0.6.5
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 +64 -16
- 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 +447 -172
- package/src/agent/architecture.md +16 -12
- package/src/agent/config-blocks.ts +39 -12
- package/src/agent/context.ts +9 -3
- package/src/agent/defaults.ts +88 -0
- package/src/agent/delegation.ts +86 -28
- package/src/agent/loop.ts +474 -374
- package/src/agent/subagent.ts +1 -3
- package/src/agent/subagents-tool.ts +50 -19
- package/src/agent/turns.ts +3 -1
- package/src/agent/usage-limit.ts +92 -0
- package/src/agent/worker.ts +73 -30
- package/src/agent/workers.ts +126 -21
- package/src/architecture.md +17 -15
- package/src/index.ts +43 -19
- package/src/models/architecture.md +4 -3
- package/src/models/catalog-cache.ts +6 -5
- package/src/models/catalog.ts +39 -30
- package/src/prompts/additional-context.md +2 -0
- package/src/prompts/architecture.md +19 -0
- package/src/prompts/prompts.ts +81 -0
- package/src/prompts/reasoning-directive.md +5 -0
- package/src/{scheduler/scheduled-actions.md → prompts/scheduled-actions-tool.md} +4 -0
- package/src/prompts/structured-output.md +2 -0
- package/src/{agent/subagents.md → prompts/subagents-tool.md} +3 -0
- package/src/prompts/tool-bridge.md +1 -0
- package/src/prompts/tool-interrupted.md +1 -0
- package/src/prompts/tool-not-found.md +1 -0
- package/src/prompts/tool-repeat-blocked.md +1 -0
- package/src/prompts/tool-schema-error.md +1 -0
- package/src/prompts/tool-truncated-arguments.md +1 -0
- package/src/prompts/worker-failed.md +1 -0
- package/src/prompts/worker-replied.md +2 -0
- package/src/providers/architecture.md +10 -8
- package/src/providers/google.ts +228 -69
- package/src/providers/openai-compat.ts +74 -56
- package/src/providers/openai.ts +64 -41
- package/src/providers/openrouter.ts +270 -48
- package/src/providers/registry.ts +6 -21
- package/src/providers/shared.ts +87 -33
- package/src/providers.ts +115 -124
- package/src/scheduler/architecture.md +8 -7
- package/src/scheduler/scheduler.ts +226 -60
- package/src/scheduler/store.ts +23 -10
- package/src/scheduler/time.ts +17 -3
- package/src/scheduler/tool.ts +38 -16
- package/src/session/architecture.md +7 -6
- package/src/session/journal.ts +39 -12
- package/src/session/store.ts +126 -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 +9 -7
- package/src/tools/executor.ts +109 -62
- package/src/tools/schema.ts +10 -6
- package/src/tools/subagent-result.ts +6 -1
- package/src/tools/tool-content.ts +32 -0
- package/src/tools/tool.ts +9 -11
- package/src/types/agent.ts +37 -22
- package/src/types/architecture.md +11 -10
- package/src/types/core.ts +5 -9
- package/src/types/errors.ts +26 -4
- package/src/types/message.ts +7 -0
- package/src/types/model.ts +4 -5
- package/src/types/scheduler.ts +4 -4
- package/src/types/tool.ts +14 -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 +9 -17
- 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
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* never in the canonical layer (`src/providers.ts`).
|
|
7
7
|
*
|
|
8
8
|
* Wire contract: `references/testings/openrouter/CONTRACT.md` (docs + live
|
|
9
|
-
* captures
|
|
9
|
+
* captures, 2026-09-24). Capability matrix:
|
|
10
10
|
* `references/testings/openrouter/CAPABILITY-MATRIX.md`.
|
|
11
11
|
*
|
|
12
12
|
* This is the STABLE transport. The beta Responses skin
|
|
@@ -24,9 +24,10 @@
|
|
|
24
24
|
* - IDs are provider-generated (`gen-…` generations, `call_…`/uuid tool ids)
|
|
25
25
|
* and echoed verbatim (`tool_call_id`). The `call_${random}` fallback in
|
|
26
26
|
* parsers is a local canonical correlation id only; it is never sent.
|
|
27
|
-
* - Prior-turn reasoning is
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* - Prior-turn reasoning is resent as `reasoning_details` exactly as returned (kept on the assistant message as
|
|
28
|
+
* `providerSteps`), which tool turns need (reasoning-tokens.md, "Preserving Reasoning"). Plain `reasoning` text alone is
|
|
29
|
+
* never resent. Parsed-PDF `file` annotations ride the same way and go back as `annotations`, so the router does not parse
|
|
30
|
+
* the same PDF again (pdf-inputs.md, "Skip Parsing Costs").
|
|
30
31
|
*/
|
|
31
32
|
import type {
|
|
32
33
|
Provider,
|
|
@@ -47,11 +48,11 @@ import { normalizeMediaInput } from "../utils/media.ts";
|
|
|
47
48
|
import { safeStringify } from "../utils/serialization.ts";
|
|
48
49
|
import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
|
|
49
50
|
import { ConfigError, isAbortError } from "../types/errors.ts";
|
|
51
|
+
import { DEFAULT_PROMPTS } from "../prompts/prompts.ts";
|
|
50
52
|
import {
|
|
51
53
|
newToolCallId,
|
|
52
54
|
redactedHeaders,
|
|
53
55
|
readErrorPayload,
|
|
54
|
-
incompleteStreamError,
|
|
55
56
|
hasUnfinishedToolCall,
|
|
56
57
|
linkStreamAbort,
|
|
57
58
|
withAbortTimer,
|
|
@@ -85,6 +86,8 @@ import {
|
|
|
85
86
|
augmentSystemPromptWithStructuredOutput,
|
|
86
87
|
emitProviderWarning,
|
|
87
88
|
warnIgnoredMediaResolution,
|
|
89
|
+
warnIgnoredVideoProcessing,
|
|
90
|
+
warnIgnoredToolMedia,
|
|
88
91
|
} from "../providers.ts";
|
|
89
92
|
|
|
90
93
|
// ---------------------------------------------------------------------------
|
|
@@ -109,6 +112,8 @@ type ChatMessage =
|
|
|
109
112
|
type: "function";
|
|
110
113
|
function: { name: string; arguments: string };
|
|
111
114
|
}>;
|
|
115
|
+
reasoning_details?: unknown[];
|
|
116
|
+
annotations?: unknown[];
|
|
112
117
|
name?: string;
|
|
113
118
|
}
|
|
114
119
|
| { role: "tool"; content: string; tool_call_id: string; name?: string }
|
|
@@ -119,11 +124,17 @@ interface ChatRequestBody {
|
|
|
119
124
|
messages: ChatMessage[];
|
|
120
125
|
tools?: Array<Record<string, unknown>>;
|
|
121
126
|
tool_choice?: string | { type: "function"; function: { name: string } };
|
|
122
|
-
reasoning?: { effort
|
|
127
|
+
reasoning?: { effort?: string; exclude?: boolean };
|
|
128
|
+
provider?: Record<string, unknown>;
|
|
129
|
+
parallel_tool_calls?: boolean;
|
|
130
|
+
user?: string;
|
|
123
131
|
service_tier?: string;
|
|
124
132
|
session_id?: string;
|
|
125
133
|
cache_control?: { type: "ephemeral"; ttl: "5m" | "1h" };
|
|
126
|
-
response_format?: {
|
|
134
|
+
response_format?: {
|
|
135
|
+
type: "json_schema";
|
|
136
|
+
json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string };
|
|
137
|
+
};
|
|
127
138
|
stream?: boolean;
|
|
128
139
|
[k: string]: unknown;
|
|
129
140
|
}
|
|
@@ -133,8 +144,13 @@ interface ChatUsage {
|
|
|
133
144
|
prompt_tokens?: number;
|
|
134
145
|
completion_tokens?: number;
|
|
135
146
|
total_tokens?: number;
|
|
136
|
-
prompt_tokens_details?: {
|
|
137
|
-
|
|
147
|
+
prompt_tokens_details?: {
|
|
148
|
+
cached_tokens?: number;
|
|
149
|
+
cache_write_tokens?: number;
|
|
150
|
+
audio_tokens?: number;
|
|
151
|
+
video_tokens?: number;
|
|
152
|
+
};
|
|
153
|
+
completion_tokens_details?: { reasoning_tokens?: number; audio_tokens?: number; image_tokens?: number };
|
|
138
154
|
cost?: number;
|
|
139
155
|
[k: string]: unknown;
|
|
140
156
|
}
|
|
@@ -173,13 +189,84 @@ function cleanModelId(model: string | ModelSpec): string {
|
|
|
173
189
|
}
|
|
174
190
|
|
|
175
191
|
// 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> = {
|
|
192
|
+
const RETRYABLE_ERROR_TYPE_STATUS: Record<string, number> = {
|
|
193
|
+
rate_limit_exceeded: 429,
|
|
194
|
+
provider_overloaded: 503,
|
|
195
|
+
provider_unavailable: 502,
|
|
196
|
+
timeout: 504,
|
|
197
|
+
server: 500,
|
|
198
|
+
};
|
|
177
199
|
// 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
200
|
function midStreamStatus(code: unknown, errorType: unknown): number | undefined {
|
|
179
201
|
if (typeof code === "number" && Number.isInteger(code) && code >= 100 && code <= 599) return code;
|
|
180
202
|
return typeof errorType === "string" ? RETRYABLE_ERROR_TYPE_STATUS[errorType] : undefined;
|
|
181
203
|
}
|
|
182
204
|
|
|
205
|
+
// OpenRouter options that come from env vars, never from AgentConfig (provider routing, `parallel_tool_calls`, `reasoning.exclude`, `user`). A per-Agent `env` wins over the process env; nothing is sent when no variable is set; a bad value is a ConfigError before any network call. NOT supported on purpose (the owner does not want them for now): `reasoning.max_tokens` and `trace`; in: buildBody, out: the extra body fields.
|
|
206
|
+
const ROUTING_LISTS = { OPENROUTER_ORDER: "order", OPENROUTER_ONLY: "only", OPENROUTER_IGNORE: "ignore" } as const;
|
|
207
|
+
const ROUTING_FLAGS = {
|
|
208
|
+
OPENROUTER_ALLOW_FALLBACKS: "allow_fallbacks",
|
|
209
|
+
OPENROUTER_REQUIRE_PARAMETERS: "require_parameters",
|
|
210
|
+
OPENROUTER_ZDR: "zdr",
|
|
211
|
+
} as const;
|
|
212
|
+
function openRouterEnvOptions(options?: ProviderRequestOptions): {
|
|
213
|
+
provider?: Record<string, unknown>;
|
|
214
|
+
parallel_tool_calls?: boolean;
|
|
215
|
+
reasoning_exclude?: boolean;
|
|
216
|
+
user?: string;
|
|
217
|
+
} {
|
|
218
|
+
const read = (name: string): string | undefined => {
|
|
219
|
+
const own = options?.env?.[name]?.trim();
|
|
220
|
+
return own ? own : getEnv(name);
|
|
221
|
+
};
|
|
222
|
+
const bad = (name: string, value: string, expected: string): never => {
|
|
223
|
+
throw new ConfigError(`[Agent Accelerator] Invalid ${name} ${JSON.stringify(value)}: expected ${expected}.`);
|
|
224
|
+
};
|
|
225
|
+
const flag = (name: string): boolean | undefined => {
|
|
226
|
+
const v = read(name);
|
|
227
|
+
if (v === undefined) return undefined;
|
|
228
|
+
const t = v.toLowerCase();
|
|
229
|
+
if (t === "true" || t === "1") return true;
|
|
230
|
+
if (t === "false" || t === "0") return false;
|
|
231
|
+
return bad(name, v, "true or false");
|
|
232
|
+
};
|
|
233
|
+
const provider: Record<string, unknown> = {};
|
|
234
|
+
for (const [name, field] of Object.entries(ROUTING_LISTS)) {
|
|
235
|
+
const v = read(name);
|
|
236
|
+
if (v === undefined) continue;
|
|
237
|
+
const list = v
|
|
238
|
+
.split(",")
|
|
239
|
+
.map((x) => x.trim())
|
|
240
|
+
.filter(Boolean);
|
|
241
|
+
if (list.length === 0) bad(name, v, "a comma-separated list of provider slugs");
|
|
242
|
+
provider[field] = list;
|
|
243
|
+
}
|
|
244
|
+
for (const [name, field] of Object.entries(ROUTING_FLAGS)) {
|
|
245
|
+
const v = flag(name);
|
|
246
|
+
if (v !== undefined) provider[field] = v;
|
|
247
|
+
}
|
|
248
|
+
const collection = read("OPENROUTER_DATA_COLLECTION");
|
|
249
|
+
if (collection !== undefined) {
|
|
250
|
+
if (collection !== "allow" && collection !== "deny") bad("OPENROUTER_DATA_COLLECTION", collection, "allow or deny");
|
|
251
|
+
provider["data_collection"] = collection;
|
|
252
|
+
}
|
|
253
|
+
const sort = read("OPENROUTER_SORT");
|
|
254
|
+
if (sort !== undefined) {
|
|
255
|
+
if (sort !== "price" && sort !== "throughput" && sort !== "latency")
|
|
256
|
+
bad("OPENROUTER_SORT", sort, "price, throughput or latency");
|
|
257
|
+
provider["sort"] = sort;
|
|
258
|
+
}
|
|
259
|
+
const parallel = flag("OPENROUTER_PARALLEL_TOOL_CALLS");
|
|
260
|
+
const exclude = flag("OPENROUTER_REASONING_EXCLUDE");
|
|
261
|
+
const user = read("OPENROUTER_USER");
|
|
262
|
+
return {
|
|
263
|
+
...(Object.keys(provider).length > 0 ? { provider } : {}),
|
|
264
|
+
...(parallel !== undefined ? { parallel_tool_calls: parallel } : {}),
|
|
265
|
+
...(exclude !== undefined ? { reasoning_exclude: exclude } : {}),
|
|
266
|
+
...(user ? { user } : {}),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
183
270
|
// Canonical tool declarations as Chat Completions function tools; in: options.tools, out: request `tools`.
|
|
184
271
|
function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<string, unknown>> | undefined {
|
|
185
272
|
if (!tools || tools.length === 0) return undefined;
|
|
@@ -195,7 +282,18 @@ function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<stri
|
|
|
195
282
|
}
|
|
196
283
|
|
|
197
284
|
// 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> = {
|
|
285
|
+
const AUDIO_FORMAT: Record<string, string> = {
|
|
286
|
+
wav: "wav",
|
|
287
|
+
wave: "wav",
|
|
288
|
+
mpeg: "mp3",
|
|
289
|
+
mp3: "mp3",
|
|
290
|
+
mp4: "m4a",
|
|
291
|
+
m4a: "m4a",
|
|
292
|
+
aac: "aac",
|
|
293
|
+
ogg: "ogg",
|
|
294
|
+
flac: "flac",
|
|
295
|
+
aiff: "aiff",
|
|
296
|
+
};
|
|
199
297
|
// The `input_audio.format` for an audio MIME type (see AUDIO_FORMAT above); in: contentPartsToBlocks, out: the format string.
|
|
200
298
|
function audioFormatFor(mimeType?: string): string {
|
|
201
299
|
const subtype = (mimeType || "").toLowerCase().split(";")[0]!.split("/")[1]?.replace(/^x-/, "") ?? "";
|
|
@@ -203,7 +301,12 @@ function audioFormatFor(mimeType?: string): string {
|
|
|
203
301
|
}
|
|
204
302
|
|
|
205
303
|
// `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"> = {
|
|
304
|
+
const IMAGE_DETAIL: Record<string, "low" | "auto" | "high" | "original"> = {
|
|
305
|
+
low: "low",
|
|
306
|
+
medium: "auto",
|
|
307
|
+
high: "high",
|
|
308
|
+
ultra_high: "original",
|
|
309
|
+
};
|
|
207
310
|
|
|
208
311
|
// 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
312
|
function hasRemoteMedia(messages: ChatMessage[]): boolean {
|
|
@@ -211,7 +314,9 @@ function hasRemoteMedia(messages: ChatMessage[]): boolean {
|
|
|
211
314
|
(m) =>
|
|
212
315
|
Array.isArray(m.content) &&
|
|
213
316
|
(m.content as ChatContentPart[]).some((p) => {
|
|
214
|
-
const url =
|
|
317
|
+
const url =
|
|
318
|
+
(p as { image_url?: { url?: string }; video_url?: { url?: string } }).image_url?.url ??
|
|
319
|
+
(p as { video_url?: { url?: string } }).video_url?.url;
|
|
215
320
|
return typeof url === "string" && /^https?:\/\//.test(url);
|
|
216
321
|
})
|
|
217
322
|
);
|
|
@@ -258,10 +363,7 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPa
|
|
|
258
363
|
const mimeType = (part as { mimeType?: string }).mimeType;
|
|
259
364
|
// OpenAI chat audio shape (router parsed it live; capability routing
|
|
260
365
|
// decides per model). Always inline — `input_audio` carries data only.
|
|
261
|
-
const norm = await normalizeMediaInput(
|
|
262
|
-
raw as string | Uint8Array | ArrayBuffer,
|
|
263
|
-
mimeType
|
|
264
|
-
);
|
|
366
|
+
const norm = await normalizeMediaInput(raw as string | Uint8Array | ArrayBuffer, mimeType);
|
|
265
367
|
blocks.push({
|
|
266
368
|
type: "input_audio",
|
|
267
369
|
input_audio: { data: norm.base64Data, format: audioFormatFor(norm.mimeType) },
|
|
@@ -273,7 +375,12 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPa
|
|
|
273
375
|
const fileData =
|
|
274
376
|
typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))
|
|
275
377
|
? raw
|
|
276
|
-
: (
|
|
378
|
+
: (
|
|
379
|
+
await normalizeMediaInput(
|
|
380
|
+
raw as string | Uint8Array | ArrayBuffer,
|
|
381
|
+
(part as { mimeType?: string }).mimeType
|
|
382
|
+
)
|
|
383
|
+
).dataUrl;
|
|
277
384
|
blocks.push({
|
|
278
385
|
type: "file",
|
|
279
386
|
file: { ...(typeof filename === "string" && filename ? { filename } : {}), file_data: fileData },
|
|
@@ -283,6 +390,48 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPa
|
|
|
283
390
|
return blocks;
|
|
284
391
|
}
|
|
285
392
|
|
|
393
|
+
// Folds one streamed `reasoning_details` chunk into the turn's blocks: chunks with the same index (else id) are one block whose text, summary, data and signature are concatenated or filled in, in order; in: the stream handler, out: the blocks replayed on the next request.
|
|
394
|
+
function mergeReasoningDetails(blocks: Array<Record<string, unknown>>, chunk: unknown): void {
|
|
395
|
+
if (!chunk || typeof chunk !== "object" || Array.isArray(chunk)) return;
|
|
396
|
+
const c = chunk as Record<string, unknown>;
|
|
397
|
+
const same = blocks.find((b) =>
|
|
398
|
+
typeof c["index"] === "number" ? b["index"] === c["index"] : c["id"] != null && b["id"] === c["id"]
|
|
399
|
+
);
|
|
400
|
+
if (!same) {
|
|
401
|
+
blocks.push({ ...c });
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
for (const [k, v] of Object.entries(c)) {
|
|
405
|
+
if (v === null || v === undefined) continue;
|
|
406
|
+
if ((k === "text" || k === "summary" || k === "data") && typeof v === "string" && typeof same[k] === "string")
|
|
407
|
+
same[k] = (same[k] as string) + v;
|
|
408
|
+
else same[k] = v;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Adds a streamed `file` annotation (a parsed PDF) once per `file.hash`; in: the stream handler, out: the annotations replayed on the next request.
|
|
413
|
+
function mergeAnnotation(blocks: Array<Record<string, unknown>>, chunk: unknown): void {
|
|
414
|
+
if (!chunk || typeof chunk !== "object" || (chunk as { type?: unknown }).type !== "file") return;
|
|
415
|
+
const hash = (chunk as { file?: { hash?: unknown } }).file?.hash;
|
|
416
|
+
if (typeof hash === "string" && blocks.some((b) => (b as { file?: { hash?: unknown } }).file?.hash === hash)) return;
|
|
417
|
+
blocks.push(chunk as Record<string, unknown>);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// The reasoning blocks and the parsed-PDF annotations of an assistant turn as OpenRouter returned them (both live in `providerSteps`, told apart by `type: "file"`); in: a stored assistant message, out: fullHistoryMessages.
|
|
421
|
+
function storedReplay(m: { providerSteps?: { provider: string; steps: unknown[] } }): {
|
|
422
|
+
reasoning_details?: unknown[];
|
|
423
|
+
annotations?: unknown[];
|
|
424
|
+
} {
|
|
425
|
+
if (m.providerSteps?.provider !== "openrouter") return {};
|
|
426
|
+
const isFile = (x: unknown): boolean => (x as { type?: unknown } | null)?.type === "file";
|
|
427
|
+
const annotations = m.providerSteps.steps.filter(isFile);
|
|
428
|
+
const details = m.providerSteps.steps.filter((x) => !isFile(x));
|
|
429
|
+
return {
|
|
430
|
+
...(details.length > 0 ? { reasoning_details: details } : {}),
|
|
431
|
+
...(annotations.length > 0 ? { annotations } : {}),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
286
435
|
// A tool result as the text of a tool message; in: fullHistoryMessages, out: a string.
|
|
287
436
|
function resultToOutput(result: unknown): string {
|
|
288
437
|
if (typeof result === "string") return result;
|
|
@@ -295,7 +444,10 @@ function resultToOutput(result: unknown): string {
|
|
|
295
444
|
* carries everything. Assistant items carry no provider `id`/`status`
|
|
296
445
|
* requirements (unlike the discontinued Responses skin).
|
|
297
446
|
*/
|
|
298
|
-
async function fullHistoryMessages(
|
|
447
|
+
async function fullHistoryMessages(
|
|
448
|
+
context: ProviderContext,
|
|
449
|
+
bridge = DEFAULT_PROMPTS.toolBridge
|
|
450
|
+
): Promise<ChatMessage[]> {
|
|
299
451
|
// Map assistant tool_call ids so tool results pair even though the
|
|
300
452
|
// executor keys results by the call id.
|
|
301
453
|
const pairing = new Map<string, string>();
|
|
@@ -326,18 +478,25 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
|
|
|
326
478
|
// Strict routes reject `user` directly after `tool`: bridge with a
|
|
327
479
|
// synthetic assistant turn (pi api/openai-completions.ts:1233).
|
|
328
480
|
if (lastRole === "tool") {
|
|
329
|
-
messages.push({ role: "assistant", content:
|
|
481
|
+
messages.push({ role: "assistant", content: bridge });
|
|
330
482
|
}
|
|
331
483
|
if (typeof m.content === "string") {
|
|
332
|
-
if (m.content) {
|
|
484
|
+
if (m.content) {
|
|
485
|
+
messages.push({ role: "user", content: m.content });
|
|
486
|
+
lastRole = "user";
|
|
487
|
+
}
|
|
333
488
|
} else {
|
|
334
489
|
const blocks = await contentPartsToBlocks(m.content);
|
|
335
|
-
if (blocks.length > 0) {
|
|
490
|
+
if (blocks.length > 0) {
|
|
491
|
+
messages.push({ role: "user", content: blocks });
|
|
492
|
+
lastRole = "user";
|
|
493
|
+
}
|
|
336
494
|
}
|
|
337
495
|
} else if (m.role === "assistant") {
|
|
496
|
+
const replay = storedReplay(m);
|
|
338
497
|
if (typeof m.content === "string") {
|
|
339
498
|
if (m.content) {
|
|
340
|
-
messages.push({ role: "assistant", content: m.content });
|
|
499
|
+
messages.push({ role: "assistant", content: m.content, ...replay });
|
|
341
500
|
}
|
|
342
501
|
continue;
|
|
343
502
|
}
|
|
@@ -353,8 +512,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
|
|
|
353
512
|
} else if (part.type === "text" && part.text) {
|
|
354
513
|
texts.push(part.text);
|
|
355
514
|
}
|
|
356
|
-
//
|
|
357
|
-
// tool calls only (documented decision; avoids strict-route 400s).
|
|
515
|
+
// Reasoning is not rebuilt from text: the stored `reasoning_details` blocks are replayed verbatim below.
|
|
358
516
|
}
|
|
359
517
|
// Skip empty assistant turns (no text + no tool calls): strict
|
|
360
518
|
// routes reject them, and they carry no signal (pi drop rule).
|
|
@@ -363,6 +521,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
|
|
|
363
521
|
role: "assistant",
|
|
364
522
|
content: texts.length > 0 ? texts.join("\n") : null,
|
|
365
523
|
...(calls.length > 0 ? { tool_calls: calls } : {}),
|
|
524
|
+
...replay,
|
|
366
525
|
});
|
|
367
526
|
lastRole = "assistant";
|
|
368
527
|
} else if (m.role === "tool") {
|
|
@@ -414,7 +573,27 @@ function mapUsage(raw?: ChatUsage): TokenUsage {
|
|
|
414
573
|
cacheWriteTokens: raw?.prompt_tokens_details?.cache_write_tokens ?? 0,
|
|
415
574
|
thinkingTokens: raw?.completion_tokens_details?.reasoning_tokens ?? 0,
|
|
416
575
|
};
|
|
417
|
-
|
|
576
|
+
// Media tokens are listed only when the provider reported more than zero of them.
|
|
577
|
+
const count = (n: unknown): number | undefined =>
|
|
578
|
+
typeof n === "number" && Number.isFinite(n) && n > 0 ? n : undefined;
|
|
579
|
+
const inDet = {
|
|
580
|
+
audioTokens: count(raw?.prompt_tokens_details?.audio_tokens),
|
|
581
|
+
videoTokens: count(raw?.prompt_tokens_details?.video_tokens),
|
|
582
|
+
};
|
|
583
|
+
const outDet = {
|
|
584
|
+
audioTokens: count(raw?.completion_tokens_details?.audio_tokens),
|
|
585
|
+
imageTokens: count(raw?.completion_tokens_details?.image_tokens),
|
|
586
|
+
};
|
|
587
|
+
const keep = (d: Record<string, number | undefined>): Record<string, number> | undefined => {
|
|
588
|
+
const kept = Object.fromEntries(Object.entries(d).filter(([, v]) => v !== undefined)) as Record<string, number>;
|
|
589
|
+
return Object.keys(kept).length > 0 ? kept : undefined;
|
|
590
|
+
};
|
|
591
|
+
const inKept = keep(inDet);
|
|
592
|
+
const outKept = keep(outDet);
|
|
593
|
+
if (inKept) usage.inputDetails = inKept;
|
|
594
|
+
if (outKept) usage.outputDetails = outKept;
|
|
595
|
+
if (typeof raw?.cost === "number" && raw.cost >= 0) {
|
|
596
|
+
// an explicit 0 (a free model, a response-cache hit) is a known cost, not a missing one
|
|
418
597
|
usage.cost = { totalCost: raw.cost };
|
|
419
598
|
}
|
|
420
599
|
return usage;
|
|
@@ -460,8 +639,15 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
460
639
|
const { effort } = mapThinkingLevelToOpenRouterChat(options?.thinking?.level);
|
|
461
640
|
const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
|
|
462
641
|
// Anthropic models take a top-level `cache_control` TTL; every other route caches implicitly (docs: prompt-caching.md).
|
|
463
|
-
const cache = resolveCache(
|
|
642
|
+
const cache = resolveCache(
|
|
643
|
+
"openrouter",
|
|
644
|
+
options?.cache,
|
|
645
|
+
/^~?anthropic\//.test(modelId) ? ["implicit", "5m", "1h"] : ["implicit"],
|
|
646
|
+
`openrouter/${modelId}`
|
|
647
|
+
);
|
|
464
648
|
warnIgnoredMediaResolution("openrouter", context.messages, `openrouter/${modelId}`, ["video"]);
|
|
649
|
+
warnIgnoredVideoProcessing("openrouter", context.messages, `openrouter/${modelId}`);
|
|
650
|
+
warnIgnoredToolMedia("openrouter", context.messages, `openrouter/${modelId}`);
|
|
465
651
|
|
|
466
652
|
// Structured outputs: native `response_format` when supported, else
|
|
467
653
|
// emulated via system-prompt instruction + client validation (catalog
|
|
@@ -479,17 +665,22 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
479
665
|
outputEmulated = true;
|
|
480
666
|
effectiveContext = {
|
|
481
667
|
...context,
|
|
482
|
-
systemPrompt: augmentSystemPromptWithStructuredOutput(
|
|
668
|
+
systemPrompt: augmentSystemPromptWithStructuredOutput(
|
|
669
|
+
context.systemPrompt,
|
|
670
|
+
outputSpec,
|
|
671
|
+
options?.prompts?.structuredOutput
|
|
672
|
+
),
|
|
483
673
|
};
|
|
484
674
|
emitProviderWarning({
|
|
485
675
|
provider: "openrouter",
|
|
486
676
|
capability: "structured output",
|
|
487
677
|
requested: `${outputSpec.name} (openrouter/${modelId})`,
|
|
488
|
-
reason:
|
|
678
|
+
reason:
|
|
679
|
+
"the catalog reports structured_output: false for this model, so native response_format is unsupported.",
|
|
489
680
|
fallback: "emulated JSON instruction + client validation (no response_format is sent)",
|
|
490
681
|
});
|
|
491
682
|
}
|
|
492
|
-
const messages = await fullHistoryMessages(effectiveContext);
|
|
683
|
+
const messages = await fullHistoryMessages(effectiveContext, options?.prompts?.toolBridge);
|
|
493
684
|
const body: ChatRequestBody = {
|
|
494
685
|
model: modelId,
|
|
495
686
|
messages,
|
|
@@ -497,15 +688,21 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
497
688
|
};
|
|
498
689
|
const orTools = toOpenRouterTools(tools);
|
|
499
690
|
if (orTools) body.tools = orTools;
|
|
500
|
-
const toolChoice = mapToolChoiceToOpenRouterChat(
|
|
501
|
-
options?.toolChoice as "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
|
|
502
|
-
);
|
|
691
|
+
const toolChoice = mapToolChoiceToOpenRouterChat(options?.toolChoice);
|
|
503
692
|
if (toolChoice !== undefined) body.tool_choice = toolChoice;
|
|
504
|
-
|
|
693
|
+
const env = openRouterEnvOptions(options);
|
|
694
|
+
if (effort || env.reasoning_exclude !== undefined)
|
|
695
|
+
body.reasoning = {
|
|
696
|
+
...(effort ? { effort } : {}),
|
|
697
|
+
...(env.reasoning_exclude !== undefined ? { exclude: env.reasoning_exclude } : {}),
|
|
698
|
+
};
|
|
699
|
+
if (env.provider) body.provider = env.provider;
|
|
700
|
+
if (env.parallel_tool_calls !== undefined && orTools) body.parallel_tool_calls = env.parallel_tool_calls;
|
|
701
|
+
if (env.user) body.user = env.user;
|
|
505
702
|
if (serviceTier) body.service_tier = serviceTier;
|
|
506
703
|
if (cache === "5m" || cache === "1h") body.cache_control = { type: "ephemeral", ttl: cache };
|
|
507
704
|
// Documented Chat Completions generation controls (only those the caller set).
|
|
508
|
-
Object.assign(body, mapGenerationParamsToChat(options
|
|
705
|
+
Object.assign(body, mapGenerationParamsToChat(options));
|
|
509
706
|
// Structured outputs: Chat Completions `response_format{type:json_schema,json_schema}`.
|
|
510
707
|
// Skipped when emulated (unsupported model): instruction already in messages.
|
|
511
708
|
if (outputSpec && !outputEmulated) {
|
|
@@ -537,11 +734,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
537
734
|
): RequestInit {
|
|
538
735
|
// Attribution + session headers mirror the previous transport exactly
|
|
539
736
|
// (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
|
-
);
|
|
737
|
+
const headers: Record<string, string> = buildSessionHeaders("openrouter", options?.headers, sessionId);
|
|
545
738
|
setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
|
|
546
739
|
if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
|
|
547
740
|
return { method: "POST", headers, body: JSON.stringify(body), signal };
|
|
@@ -593,10 +786,16 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
593
786
|
options?: ProviderRequestOptions
|
|
594
787
|
): Promise<ProviderGenerateResult> {
|
|
595
788
|
// 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(
|
|
789
|
+
const { signal, cleanup } = combinedSignal(
|
|
790
|
+
options?.signal,
|
|
791
|
+
timeoutFor(options as { timeoutMs?: number } | undefined)
|
|
792
|
+
);
|
|
597
793
|
try {
|
|
598
794
|
// 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, {
|
|
795
|
+
const events = this.streamInto(new AssistantMessageEventStream({ buffer: false }), model, context, {
|
|
796
|
+
...options,
|
|
797
|
+
signal,
|
|
798
|
+
});
|
|
600
799
|
const res = await events.result();
|
|
601
800
|
return {
|
|
602
801
|
text: res.text,
|
|
@@ -610,6 +809,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
610
809
|
provider: "openrouter",
|
|
611
810
|
raw: res.raw,
|
|
612
811
|
durationMs: res.durationMs,
|
|
812
|
+
providerSteps: res.providerSteps,
|
|
613
813
|
};
|
|
614
814
|
} catch (err) {
|
|
615
815
|
// A stream reports its own generic abort; the caller's reason (`interrupt(new Error("..."))`) is what the run should carry.
|
|
@@ -675,9 +875,14 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
675
875
|
// Transient failures (429/5xx/network) are retried here, before the
|
|
676
876
|
// first byte; once the stream is live it is never retried.
|
|
677
877
|
const res = await openStreamResponse({
|
|
678
|
-
fetchOnce: () =>
|
|
679
|
-
|
|
680
|
-
|
|
878
|
+
fetchOnce: () =>
|
|
879
|
+
withAbortTimer(linked, streamTimeoutMs, () =>
|
|
880
|
+
(options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal))
|
|
881
|
+
),
|
|
882
|
+
onHttpError: (r, text) =>
|
|
883
|
+
this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers), hasRemoteMedia(body.messages)),
|
|
884
|
+
noBodyError: () =>
|
|
885
|
+
toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean),
|
|
681
886
|
retry: retryOptionsFrom(options),
|
|
682
887
|
signal: linked.signal,
|
|
683
888
|
label: { providerId: "openrouter", modelId: clean },
|
|
@@ -686,7 +891,11 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
686
891
|
res.headers.forEach((v, k) => {
|
|
687
892
|
responseHeaders[k] = v;
|
|
688
893
|
});
|
|
689
|
-
const responseMeta: Record<string, unknown> & {
|
|
894
|
+
const responseMeta: Record<string, unknown> & {
|
|
895
|
+
status: number;
|
|
896
|
+
statusText: string;
|
|
897
|
+
headers: Record<string, string>;
|
|
898
|
+
} = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
690
899
|
eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
|
|
691
900
|
|
|
692
901
|
let text = "";
|
|
@@ -696,6 +905,10 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
696
905
|
// Index of the most recent tool-call delta: unindexed chunks continue
|
|
697
906
|
// it (falling back to calls.size would fork a phantom call per chunk).
|
|
698
907
|
let lastToolIndex = 0;
|
|
908
|
+
// The turn's reasoning blocks exactly as returned, replayed on the next request (tool turns need them).
|
|
909
|
+
const reasoningBlocks: Array<Record<string, unknown>> = [];
|
|
910
|
+
// Parsed PDFs (`file` annotations) of the turn: sent back so OpenRouter does not parse the same file again.
|
|
911
|
+
const annotationBlocks: Array<Record<string, unknown>> = [];
|
|
699
912
|
let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
700
913
|
let finishReason = "stop";
|
|
701
914
|
let sawFinishReason = false;
|
|
@@ -721,7 +934,9 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
721
934
|
}
|
|
722
935
|
// Mid-stream provider error: top-level `error`, HTTP stays 200.
|
|
723
936
|
// Must surface — resolving empty success hides it.
|
|
724
|
-
const topError = msg["error"] as
|
|
937
|
+
const topError = msg["error"] as
|
|
938
|
+
| { message?: string; code?: string | number; metadata?: { error_type?: string } }
|
|
939
|
+
| undefined;
|
|
725
940
|
if (topError && typeof topError === "object") {
|
|
726
941
|
const message =
|
|
727
942
|
typeof topError.message === "string" && topError.message
|
|
@@ -738,7 +953,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
738
953
|
if (typeof msg["id"] === "string" && !responseId) {
|
|
739
954
|
responseId = msg["id"] as string;
|
|
740
955
|
}
|
|
741
|
-
const choice =
|
|
956
|
+
const choice =
|
|
957
|
+
(Array.isArray(msg["choices"]) ? (msg["choices"] as Record<string, unknown>[])[0] : undefined) ?? {};
|
|
742
958
|
const delta = (choice["delta"] as Record<string, unknown>) ?? {};
|
|
743
959
|
// Text delta (content-free accounting frames carry "" — ignored).
|
|
744
960
|
// Refusal deltas surface as text with a `refusal` finish.
|
|
@@ -757,6 +973,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
757
973
|
const details = delta["reasoning_details"];
|
|
758
974
|
if (Array.isArray(details)) {
|
|
759
975
|
for (const block of details) {
|
|
976
|
+
mergeReasoningDetails(reasoningBlocks, block);
|
|
760
977
|
const t = (block as { text?: string })?.text;
|
|
761
978
|
if (typeof t === "string" && t) {
|
|
762
979
|
thinking += t;
|
|
@@ -778,6 +995,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
778
995
|
partialThinking: thinking,
|
|
779
996
|
});
|
|
780
997
|
}
|
|
998
|
+
if (Array.isArray(delta["annotations"]))
|
|
999
|
+
for (const a of delta["annotations"]) mergeAnnotation(annotationBlocks, a);
|
|
781
1000
|
// Tool-call deltas accumulate per choice index.
|
|
782
1001
|
const deltaCalls = delta["tool_calls"];
|
|
783
1002
|
if (Array.isArray(deltaCalls)) {
|
|
@@ -887,6 +1106,9 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
887
1106
|
response: { ...responseMeta, body: completedBody, badFrames } as unknown as ProviderRawData["response"],
|
|
888
1107
|
},
|
|
889
1108
|
durationMs: Date.now() - startTime,
|
|
1109
|
+
...(reasoningBlocks.length + annotationBlocks.length > 0
|
|
1110
|
+
? { providerSteps: { provider: "openrouter", steps: [...reasoningBlocks, ...annotationBlocks] } }
|
|
1111
|
+
: {}),
|
|
890
1112
|
});
|
|
891
1113
|
eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
|
|
892
1114
|
eventStream.end(finalResponse);
|
|
@@ -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,
|