@aeye/openrouter 0.4.0 → 0.5.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 +152 -65
- package/aeye-openrouter.md +175 -161
- package/dist/__integration__/openrouter.integration.test.js +122 -6
- package/dist/__integration__/openrouter.integration.test.js.map +1 -1
- package/dist/__tests__/batch.test.d.ts +5 -0
- package/dist/__tests__/batch.test.d.ts.map +1 -0
- package/dist/__tests__/batch.test.js +41 -0
- package/dist/__tests__/batch.test.js.map +1 -0
- package/dist/__tests__/features.test.d.ts +5 -0
- package/dist/__tests__/features.test.d.ts.map +1 -0
- package/dist/__tests__/features.test.js +206 -0
- package/dist/__tests__/features.test.js.map +1 -0
- package/dist/__tests__/openrouter.test.js +185 -0
- package/dist/__tests__/openrouter.test.js.map +1 -1
- package/dist/__tests__/rerank-decide.test.d.ts +5 -0
- package/dist/__tests__/rerank-decide.test.d.ts.map +1 -0
- package/dist/__tests__/rerank-decide.test.js +75 -0
- package/dist/__tests__/rerank-decide.test.js.map +1 -0
- package/dist/__tests__/schema-rejection.test.d.ts +6 -0
- package/dist/__tests__/schema-rejection.test.d.ts.map +1 -0
- package/dist/__tests__/schema-rejection.test.js +39 -0
- package/dist/__tests__/schema-rejection.test.js.map +1 -0
- package/dist/openrouter.d.ts +118 -41
- package/dist/openrouter.d.ts.map +1 -1
- package/dist/openrouter.js +407 -210
- package/dist/openrouter.js.map +1 -1
- package/dist/source.d.ts +9 -4
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +45 -28
- package/dist/source.js.map +1 -1
- package/dist/types.d.ts +244 -81
- package/dist/types.d.ts.map +1 -1
- package/package.json +71 -71
package/dist/openrouter.js
CHANGED
|
@@ -14,133 +14,158 @@
|
|
|
14
14
|
* `defaultParams.providers.requireParameters` explicitly (`false` disables, `true`
|
|
15
15
|
* forces regardless of strictness). See {@link OpenRouterConfig.defaultParams}.
|
|
16
16
|
*/
|
|
17
|
-
import { detectTier } from '@aeye/ai';
|
|
18
|
-
import {
|
|
19
|
-
import { OpenAIProvider } from '@aeye/openai';
|
|
17
|
+
import { detectTier, INPUT_CAPABILITIES, OUTPUT_CAPABILITIES } from '@aeye/ai';
|
|
18
|
+
import { REASONING_EFFORTS, replaysTo, schemaRejection, withAllowedTools } from '@aeye/core';
|
|
19
|
+
import { OpenAIProvider, ProviderError, toSystemOneQuestion } from '@aeye/openai';
|
|
20
20
|
import OpenAI from 'openai';
|
|
21
21
|
import { fetchModels, fetchZDRModels } from './source';
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
23
|
+
* OpenRouter parameter names to our ModelParameter names
|
|
24
24
|
*/
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
25
|
+
const PARAMETERS = {
|
|
26
|
+
max_tokens: 'maxTokens',
|
|
27
|
+
max_completion_tokens: 'maxTokens',
|
|
28
|
+
temperature: 'temperature',
|
|
29
|
+
top_p: 'topP',
|
|
30
|
+
frequency_penalty: 'frequencyPenalty',
|
|
31
|
+
presence_penalty: 'presencePenalty',
|
|
32
|
+
stop: 'stop',
|
|
33
|
+
seed: 'seed',
|
|
34
|
+
response_format: 'responseFormat',
|
|
35
|
+
structured_outputs: 'structuredOutput',
|
|
36
|
+
tools: 'tools',
|
|
37
|
+
tool_choice: 'toolChoice',
|
|
38
|
+
logit_bias: 'logitBias',
|
|
39
|
+
logprobs: 'logProbabilities',
|
|
40
|
+
top_logprobs: 'logProbabilities',
|
|
41
|
+
reasoning: 'reason',
|
|
42
|
+
reasoning_effort: 'reason',
|
|
43
|
+
include_reasoning: 'reason',
|
|
44
|
+
verbosity: 'verbosity',
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Capabilities a model has for each modality it produces: chat output, plus
|
|
48
|
+
* OpenRouter's dedicated media and embedding endpoints.
|
|
49
|
+
*/
|
|
50
|
+
const PRODUCES = {
|
|
51
|
+
...OUTPUT_CAPABILITIES,
|
|
52
|
+
text: 'chat',
|
|
53
|
+
speech: 'audio',
|
|
54
|
+
transcription: 'hearing',
|
|
55
|
+
embeddings: 'embedding',
|
|
56
|
+
rerank: 'rerank',
|
|
57
|
+
decisions: 'decision',
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Capabilities a model has for each parameter it accepts.
|
|
61
|
+
*/
|
|
62
|
+
const PARAMETER_CAPABILITIES = {
|
|
63
|
+
tools: 'tools',
|
|
64
|
+
tool_choice: 'tools',
|
|
65
|
+
reasoning: 'reasoning',
|
|
66
|
+
include_reasoning: 'reasoning',
|
|
67
|
+
response_format: 'json',
|
|
68
|
+
structured_outputs: 'structured',
|
|
69
|
+
};
|
|
53
70
|
/**
|
|
54
|
-
*
|
|
71
|
+
* Server tool call counts in usage, by the hosted tool they belong to.
|
|
72
|
+
*/
|
|
73
|
+
const SERVER_TOOL_USAGE = {
|
|
74
|
+
web_search_requests: 'web_search',
|
|
75
|
+
web_fetch_requests: 'web_fetch',
|
|
76
|
+
image_generation_requests: 'image_generation',
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Aspect ratios image models accept.
|
|
80
|
+
*/
|
|
81
|
+
const ASPECT_RATIOS = ['1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9'];
|
|
82
|
+
/**
|
|
83
|
+
* Models used when neither the request, the context, nor the config names one.
|
|
84
|
+
*/
|
|
85
|
+
const DEFAULT_MODELS = {
|
|
86
|
+
imageGenerate: 'google/gemini-2.5-flash-image',
|
|
87
|
+
imageEdit: 'google/gemini-2.5-flash-image',
|
|
88
|
+
imageAnalyze: 'openai/gpt-5.4-mini',
|
|
89
|
+
transcription: 'openai/gpt-transcribe',
|
|
90
|
+
embedding: 'openai/text-embedding-3-small',
|
|
91
|
+
rerank: 'cohere/rerank-v3.5',
|
|
92
|
+
decision: 'typesafe/jev-1.14',
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Detect capabilities from modalities and supported parameters
|
|
55
96
|
*/
|
|
56
97
|
function detectCapabilities(model) {
|
|
57
98
|
const capabilities = new Set();
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
capabilities.add('image');
|
|
99
|
+
const add = (capability) => capability && capabilities.add(capability);
|
|
100
|
+
model.architecture.input_modalities.forEach(modality => add(INPUT_CAPABILITIES[modality]));
|
|
101
|
+
model.architecture.output_modalities.forEach(modality => add(PRODUCES[modality]));
|
|
102
|
+
model.supported_parameters.forEach(param => add(PARAMETER_CAPABILITIES[param]));
|
|
103
|
+
if (capabilities.has('chat')) {
|
|
104
|
+
capabilities.add('streaming');
|
|
65
105
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
// Reasoning capability
|
|
83
|
-
if (model.supported_parameters.includes('reasoning') || model.supported_parameters.includes('include_reasoning')) {
|
|
84
|
-
capabilities.add('reasoning');
|
|
85
|
-
}
|
|
86
|
-
// JSON output capability
|
|
87
|
-
if (model.supported_parameters.includes('response_format')) {
|
|
88
|
-
capabilities.add('json');
|
|
89
|
-
}
|
|
90
|
-
// Structured output capability
|
|
91
|
-
if (model.supported_parameters.includes('structured_outputs')) {
|
|
92
|
-
capabilities.add('structured');
|
|
93
|
-
}
|
|
94
|
-
// Streaming capability (most models support this)
|
|
95
|
-
capabilities.add('streaming');
|
|
96
|
-
return Array.from(capabilities);
|
|
106
|
+
return capabilities;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* A price string as a number, or undefined when free or absent.
|
|
110
|
+
*/
|
|
111
|
+
function price(value) {
|
|
112
|
+
const parsed = Number(value);
|
|
113
|
+
return parsed > 0 ? parsed : undefined;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* A per-token price string as dollars per million tokens.
|
|
117
|
+
*/
|
|
118
|
+
function perMillion(value) {
|
|
119
|
+
const parsed = price(value);
|
|
120
|
+
return parsed && parsed * 1_000_000;
|
|
97
121
|
}
|
|
98
122
|
/**
|
|
99
123
|
* Convert OpenRouter model to ModelInfo with full details
|
|
100
124
|
*/
|
|
101
125
|
export function convertOpenRouterModel(model, zdrModelIds, metrics) {
|
|
102
126
|
const capabilities = detectCapabilities(model);
|
|
103
|
-
const supportedParameters = convertSupportedParameters(model.supported_parameters);
|
|
104
|
-
const tier = detectTier(model.name);
|
|
105
|
-
// Update ZDR support from ZDR endpoint
|
|
106
127
|
if (zdrModelIds.has(model.id)) {
|
|
107
|
-
capabilities.
|
|
128
|
+
capabilities.add('zdr');
|
|
108
129
|
}
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
130
|
+
const { pricing } = model;
|
|
131
|
+
const embeds = capabilities.has('embedding');
|
|
132
|
+
const supportedEfforts = model.reasoning?.supported_efforts;
|
|
112
133
|
return {
|
|
113
134
|
provider: 'openrouter',
|
|
114
135
|
id: model.id,
|
|
115
136
|
name: model.name,
|
|
116
|
-
capabilities
|
|
117
|
-
tier,
|
|
137
|
+
capabilities,
|
|
138
|
+
tier: detectTier(model.name),
|
|
118
139
|
pricing: {
|
|
119
|
-
text:
|
|
120
|
-
input:
|
|
121
|
-
output:
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
output: parseFloat(model.pricing.internal_reasoning) * 1_000_000,
|
|
128
|
-
} : undefined,
|
|
129
|
-
perRequest: hasValue(model.pricing.request)
|
|
130
|
-
? parseFloat(model.pricing.request)
|
|
140
|
+
text: embeds ? undefined : {
|
|
141
|
+
input: perMillion(pricing.prompt),
|
|
142
|
+
output: perMillion(pricing.completion),
|
|
143
|
+
cached: perMillion(pricing.input_cache_read),
|
|
144
|
+
cacheWrite: perMillion(pricing.input_cache_write),
|
|
145
|
+
},
|
|
146
|
+
image: price(pricing.image_token) || price(pricing.image_output)
|
|
147
|
+
? { input: perMillion(pricing.image_token), tokens: perMillion(pricing.image_output) }
|
|
131
148
|
: undefined,
|
|
149
|
+
audio: price(pricing.audio) || price(pricing.audio_output)
|
|
150
|
+
? { input: perMillion(pricing.audio), output: perMillion(pricing.audio_output) }
|
|
151
|
+
: undefined,
|
|
152
|
+
reasoning: price(pricing.internal_reasoning) ? { output: perMillion(pricing.internal_reasoning) } : undefined,
|
|
153
|
+
embeddings: embeds ? { cost: perMillion(pricing.prompt) } : undefined,
|
|
154
|
+
tools: price(pricing.web_search) ? { web_search: price(pricing.web_search) } : undefined,
|
|
155
|
+
perRequest: price(pricing.request),
|
|
132
156
|
},
|
|
133
157
|
contextWindow: model.context_length,
|
|
134
158
|
maxOutputTokens: model.top_provider.max_completion_tokens ?? undefined,
|
|
135
159
|
tokenizer: model.architecture.tokenizer,
|
|
136
|
-
supportedParameters: new Set(
|
|
160
|
+
supportedParameters: new Set(model.supported_parameters.flatMap(param => PARAMETERS[param] ?? [])),
|
|
161
|
+
reasoningEfforts: supportedEfforts && REASONING_EFFORTS.filter(effort => supportedEfforts.includes(effort)),
|
|
162
|
+
retiresAt: model.expiration_date?.slice(0, 10),
|
|
137
163
|
// Only emit metrics for FINITE values. A scraped page value that fails to
|
|
138
164
|
// parse yields NaN, which `JSON.stringify` turns into `null` — poisoning the
|
|
139
165
|
// generated `ModelInfo` (whose metric fields are `number | undefined`).
|
|
140
166
|
metrics: metrics && (Number.isFinite(metrics.latency) || Number.isFinite(metrics.throughput)) ? {
|
|
141
167
|
timeToFirstToken: Number.isFinite(metrics.latency) ? metrics.latency : undefined,
|
|
142
168
|
tokensPerSecond: Number.isFinite(metrics.throughput) ? metrics.throughput : undefined,
|
|
143
|
-
// Store uptime in metadata since it's not a standard metric
|
|
144
169
|
} : undefined,
|
|
145
170
|
metadata: {
|
|
146
171
|
description: model.description,
|
|
@@ -148,15 +173,104 @@ export function convertOpenRouterModel(model, zdrModelIds, metrics) {
|
|
|
148
173
|
canonicalSlug: model.canonical_slug,
|
|
149
174
|
huggingFaceId: model.hugging_face_id,
|
|
150
175
|
created: model.created,
|
|
176
|
+
knowledgeCutoff: model.knowledge_cutoff ?? undefined,
|
|
177
|
+
voices: model.supported_voices ?? undefined,
|
|
151
178
|
uptime: Number.isFinite(metrics?.uptime) ? metrics?.uptime : undefined,
|
|
152
179
|
},
|
|
153
180
|
};
|
|
154
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* A hosted tool as an OpenRouter server tool.
|
|
184
|
+
*/
|
|
185
|
+
function toServerTool(tool) {
|
|
186
|
+
switch (tool.type) {
|
|
187
|
+
case 'web_search':
|
|
188
|
+
return {
|
|
189
|
+
type: 'openrouter:web_search',
|
|
190
|
+
parameters: { max_results: tool.maxResults, allowed_domains: tool.allowedDomains, excluded_domains: tool.blockedDomains },
|
|
191
|
+
};
|
|
192
|
+
case 'web_fetch':
|
|
193
|
+
return { type: 'openrouter:web_fetch' };
|
|
194
|
+
case 'image_generation':
|
|
195
|
+
return { type: 'openrouter:image_generation' };
|
|
196
|
+
default:
|
|
197
|
+
throw new ProviderError('openrouter', `The ${tool.type} hosted tool is not available on OpenRouter`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The supported aspect ratio closest to a WIDTHxHEIGHT size.
|
|
202
|
+
*/
|
|
203
|
+
function aspectRatio(size) {
|
|
204
|
+
const [width, height] = size.split('x').map(Number);
|
|
205
|
+
const ratio = (r) => { const [w, h] = r.split(':').map(Number); return w / h; };
|
|
206
|
+
return ASPECT_RATIOS.reduce((best, r) => Math.abs(ratio(r) - width / height) < Math.abs(ratio(best) - width / height) ? r : best);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* An answer in @aeye's shape, with score probabilities in level order.
|
|
210
|
+
*/
|
|
211
|
+
function toDecisionAnswer(answer) {
|
|
212
|
+
switch (answer.type) {
|
|
213
|
+
case 'noul':
|
|
214
|
+
return { type: 'predicate', probability: answer.noul };
|
|
215
|
+
case 'choice':
|
|
216
|
+
return answer;
|
|
217
|
+
case 'score':
|
|
218
|
+
return {
|
|
219
|
+
type: 'score',
|
|
220
|
+
score: answer.score,
|
|
221
|
+
probabilities: Object.entries(answer.probabilities).sort(([a], [b]) => Number(a) - Number(b)).map(([, probability]) => probability),
|
|
222
|
+
confidence: answer.confidence,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Images a chat model produced.
|
|
228
|
+
*/
|
|
229
|
+
function toImages(images) {
|
|
230
|
+
return images?.length ? images.map(image => ({ url: image.image_url.url })) : undefined;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Usage beyond what the OpenAI shape carries: server tool calls, and the
|
|
234
|
+
* full cost, which with BYOK is OpenRouter's fee plus the provider's bill.
|
|
235
|
+
*/
|
|
236
|
+
function augmentUsage(target, usage) {
|
|
237
|
+
for (const [key, calls] of Object.entries(usage.server_tool_use_details ?? {})) {
|
|
238
|
+
const tool = SERVER_TOOL_USAGE[key];
|
|
239
|
+
if (tool && calls) {
|
|
240
|
+
target.tools = { ...target.tools, [tool]: calls };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (usage.cost !== undefined) {
|
|
244
|
+
target.cost = usage.cost + (usage.is_byok ? usage.cost_details?.upstream_inference_cost ?? 0 : 0);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
155
247
|
/**
|
|
156
248
|
* OpenRouter provider implementation extending base OpenAI-compatible provider
|
|
157
249
|
*/
|
|
158
250
|
export class OpenRouterProvider extends OpenAIProvider {
|
|
159
251
|
name = 'openrouter';
|
|
252
|
+
defaults = DEFAULT_MODELS;
|
|
253
|
+
/**
|
|
254
|
+
* OpenRouter is a router, not a model host: it speaks an OpenAI-shaped API
|
|
255
|
+
* but forwards each request to the upstream provider that actually serves
|
|
256
|
+
* the model, translating the tool/response schemas on the way. So the wire
|
|
257
|
+
* dialect that matters is the UPSTREAM model's, which
|
|
258
|
+
* `resolveStrictFormat` already derives from the `[family]/...` id prefix
|
|
259
|
+
* (`google/gemini-3-flash-preview` → `'google'`).
|
|
260
|
+
*
|
|
261
|
+
* The inherited default is `['openai']` — correct for api.openai.com, wrong
|
|
262
|
+
* here, and it silently forced EVERY tool schema through LENIENT no matter
|
|
263
|
+
* which family the model belonged to. LENIENT encodes `z.any()` as a
|
|
264
|
+
* self-referencing `$defs/Any`, which Gemini's function-calling grammar
|
|
265
|
+
* compiler rejects with `400 INVALID_ARGUMENT` as soon as a tool call is
|
|
266
|
+
* forced. `applySchemaDeliveryFallback` never hid this because it resolves
|
|
267
|
+
* the family itself and only covers `response_format`, not tools — which is
|
|
268
|
+
* why structured output looked family-aware while tools were not.
|
|
269
|
+
*/
|
|
270
|
+
supportedStrictFamilies = new Set(['openai', 'anthropic', 'google', 'xai']);
|
|
271
|
+
// Reasoning of each converted assistant message, re-attached when the
|
|
272
|
+
// messages are assembled
|
|
273
|
+
reasoningOf = new WeakMap();
|
|
160
274
|
createClient(config) {
|
|
161
275
|
return new OpenAI({
|
|
162
276
|
apiKey: config.apiKey,
|
|
@@ -183,28 +297,66 @@ export class OpenRouterProvider extends OpenAIProvider {
|
|
|
183
297
|
metadata: {},
|
|
184
298
|
};
|
|
185
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* OpenRouter serves hosted tools and reasoning options through chat
|
|
302
|
+
* completions, so the Responses API is never needed.
|
|
303
|
+
*/
|
|
304
|
+
usesResponses() {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* OpenRouter doesn't honor `allowed_tools` (models still call the others,
|
|
309
|
+
* and some upstreams reject it), so only the allowed tools are sent.
|
|
310
|
+
*/
|
|
311
|
+
buildChatParams(request, run, streaming) {
|
|
312
|
+
return super.buildChatParams(withAllowedTools(request), run, streaming);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* OpenRouter reports an upstream's rejection as "Provider returned error",
|
|
316
|
+
* with the upstream's own message in `metadata.raw`.
|
|
317
|
+
*/
|
|
318
|
+
rejectedSchema(request, error) {
|
|
319
|
+
const body = 'error' in error ? error.error : undefined;
|
|
320
|
+
const metadata = body && typeof body === 'object' && 'metadata' in body ? body.metadata : undefined;
|
|
321
|
+
const raw = metadata && typeof metadata === 'object' && 'raw' in metadata && typeof metadata.raw === 'string' ? metadata.raw : '';
|
|
322
|
+
return schemaRejection(request, error, `${error.message} ${raw}`);
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* OpenRouter strips `strict` from tools bound for Anthropic unless the
|
|
326
|
+
* request carries Anthropic's structured-outputs beta flag, so a strict tool
|
|
327
|
+
* would silently go unenforced (openrouter.ai/docs/guides/routing/provider-selection,
|
|
328
|
+
* read 2026-11-04). Other upstreams ignore the header.
|
|
329
|
+
*/
|
|
330
|
+
chatHeaders(params) {
|
|
331
|
+
const strictTool = params.tools?.some(tool => tool.type === 'function' && tool.function.strict);
|
|
332
|
+
return strictTool ? { 'x-anthropic-beta': 'structured-outputs-2025-11-13' } : undefined;
|
|
333
|
+
}
|
|
186
334
|
/**
|
|
187
335
|
* Augment chat request with OpenRouter-specific parameters
|
|
188
336
|
*/
|
|
189
337
|
augmentChatRequest(params, request, config) {
|
|
190
|
-
|
|
338
|
+
const dp = config.defaultParams;
|
|
339
|
+
// OpenRouter takes reasoning as one object with either a token budget or
|
|
340
|
+
// an effort, not both; the effort arrives already clamped to what the
|
|
341
|
+
// model accepts
|
|
191
342
|
if (request.reason) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
max_tokens: request.reason.maxTokens,
|
|
196
|
-
};
|
|
343
|
+
const { maxTokens, exclude, summary, mode, context } = request.reason;
|
|
344
|
+
const budget = maxTokens ? { max_tokens: maxTokens } : { effort: params.reasoning_effort ?? undefined };
|
|
345
|
+
params.reasoning = { enabled: true, ...budget, exclude, summary, mode, context };
|
|
197
346
|
}
|
|
198
|
-
|
|
199
|
-
//
|
|
200
|
-
|
|
347
|
+
delete params.reasoning_effort;
|
|
348
|
+
// OpenRouter ignores OpenAI's inline moderation
|
|
349
|
+
delete params.moderation;
|
|
201
350
|
if (dp?.providers) {
|
|
202
|
-
const { allowFallbacks, requireParameters, dataCollection, maxPrice, ...provider } = dp.providers;
|
|
351
|
+
const { allowFallbacks, requireParameters, dataCollection, enforceDistillableText, preferredMinThroughput, preferredMaxLatency, maxPrice, ...provider } = dp.providers;
|
|
203
352
|
params.provider = {
|
|
204
353
|
...params.provider,
|
|
205
354
|
allow_fallbacks: allowFallbacks,
|
|
206
355
|
require_parameters: requireParameters,
|
|
207
356
|
data_collection: dataCollection,
|
|
357
|
+
enforce_distillable_text: enforceDistillableText,
|
|
358
|
+
preferred_min_throughput: preferredMinThroughput,
|
|
359
|
+
preferred_max_latency: preferredMaxLatency,
|
|
208
360
|
max_price: maxPrice,
|
|
209
361
|
...provider,
|
|
210
362
|
};
|
|
@@ -223,155 +375,200 @@ export class OpenRouterProvider extends OpenAIProvider {
|
|
|
223
375
|
const isStrictStructured = params.response_format?.type === 'json_schema' &&
|
|
224
376
|
params.response_format.json_schema?.strict === true;
|
|
225
377
|
if (isStrictStructured && dp?.providers?.requireParameters === undefined) {
|
|
226
|
-
params.provider = {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
378
|
+
params.provider = { ...params.provider, require_parameters: true };
|
|
379
|
+
}
|
|
380
|
+
// Some upstreams serve any response format as JSON mode, which refuses a
|
|
381
|
+
// conversation that never says "json" (Alibaba for Qwen: "'messages' must
|
|
382
|
+
// contain the word 'json' in some form", even for a strict json_schema,
|
|
383
|
+
// 2026-11-04). Say it once when nothing else does.
|
|
384
|
+
const saysJson = params.messages.some(message => /json/i.test(JSON.stringify(message.content ?? '')));
|
|
385
|
+
if (params.response_format && params.response_format.type !== 'text' && !saysJson) {
|
|
386
|
+
params.messages = [{ role: 'system', content: 'Respond in JSON.' }, ...params.messages];
|
|
230
387
|
}
|
|
231
|
-
|
|
232
|
-
|
|
388
|
+
const plugins = [...dp?.plugins ?? []];
|
|
389
|
+
if (dp?.transforms?.includes('middle-out')) {
|
|
390
|
+
plugins.push({ id: 'context-compression', engine: 'middle-out' });
|
|
233
391
|
}
|
|
392
|
+
if (plugins.length) {
|
|
393
|
+
params.plugins = plugins;
|
|
394
|
+
}
|
|
395
|
+
if (dp?.fallbackModels?.length) {
|
|
396
|
+
params.models = dp.fallbackModels;
|
|
397
|
+
}
|
|
398
|
+
if (request.hostedTools?.length) {
|
|
399
|
+
params.tools = [...params.tools ?? [], ...request.hostedTools.map(toServerTool)];
|
|
400
|
+
}
|
|
401
|
+
if (request.modalities?.includes('image')) {
|
|
402
|
+
params.modalities = request.modalities;
|
|
403
|
+
}
|
|
404
|
+
params.user = request.userKey;
|
|
234
405
|
}
|
|
235
|
-
/**
|
|
236
|
-
*
|
|
237
|
-
* @param expected
|
|
238
|
-
* @param response
|
|
239
|
-
*/
|
|
240
406
|
augmentChatResponse(expected, response, config) {
|
|
241
407
|
const message = expected.choices?.[0]?.message;
|
|
242
|
-
if (message) {
|
|
243
|
-
|
|
244
|
-
if (!response.reasoning) {
|
|
245
|
-
response.reasoning = {};
|
|
246
|
-
}
|
|
247
|
-
response.reasoning.content = message.reasoning;
|
|
248
|
-
}
|
|
249
|
-
if (message.reasoning_details) {
|
|
250
|
-
if (!response.reasoning) {
|
|
251
|
-
response.reasoning = {};
|
|
252
|
-
}
|
|
253
|
-
response.reasoning.details = message.reasoning_details;
|
|
254
|
-
}
|
|
408
|
+
if (message?.reasoning || message?.reasoning_details) {
|
|
409
|
+
response.reasoning = { content: message.reasoning, details: message.reasoning_details, provider: this.name };
|
|
255
410
|
}
|
|
256
|
-
|
|
257
|
-
if (usage) {
|
|
258
|
-
|
|
259
|
-
response.usage = {};
|
|
260
|
-
}
|
|
261
|
-
if (!response.usage.text) {
|
|
262
|
-
response.usage.text = {};
|
|
263
|
-
}
|
|
264
|
-
if (usage.completion_tokens) {
|
|
265
|
-
response.usage.text.output = usage.completion_tokens;
|
|
266
|
-
}
|
|
267
|
-
if (usage.prompt_tokens) {
|
|
268
|
-
response.usage.text.input = usage.prompt_tokens;
|
|
269
|
-
}
|
|
270
|
-
if (usage.cost) {
|
|
271
|
-
response.usage.cost = usage.cost;
|
|
272
|
-
}
|
|
411
|
+
response.images = toImages(message?.images);
|
|
412
|
+
if (expected.usage) {
|
|
413
|
+
augmentUsage(response.usage ??= {}, expected.usage);
|
|
273
414
|
}
|
|
274
415
|
}
|
|
275
416
|
augmentChatChunk(expected, chunk, config) {
|
|
276
|
-
const usage = expected.usage;
|
|
277
|
-
if (usage) {
|
|
278
|
-
if (!chunk.usage) {
|
|
279
|
-
chunk.usage = {};
|
|
280
|
-
}
|
|
281
|
-
if (!chunk.usage.text) {
|
|
282
|
-
chunk.usage.text = {};
|
|
283
|
-
}
|
|
284
|
-
if (usage.completion_tokens) {
|
|
285
|
-
chunk.usage.text.output = usage.completion_tokens;
|
|
286
|
-
}
|
|
287
|
-
if (usage.prompt_tokens) {
|
|
288
|
-
chunk.usage.text.input = usage.prompt_tokens;
|
|
289
|
-
}
|
|
290
|
-
if (usage.cost) {
|
|
291
|
-
chunk.usage.cost = usage.cost;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
417
|
const delta = expected.choices[0]?.delta;
|
|
295
418
|
if (delta?.reasoning || delta?.reasoning_details?.length) {
|
|
296
|
-
chunk.reasoning =
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
419
|
+
chunk.reasoning = { content: delta.reasoning, details: delta.reasoning_details, provider: this.name };
|
|
420
|
+
}
|
|
421
|
+
chunk.images = toImages(delta?.images);
|
|
422
|
+
if (expected.usage) {
|
|
423
|
+
augmentUsage(chunk.usage ??= {}, expected.usage);
|
|
300
424
|
}
|
|
301
425
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
426
|
+
/**
|
|
427
|
+
* OpenRouter marks cache breakpoints Anthropic-style and translates them
|
|
428
|
+
* for each upstream provider.
|
|
429
|
+
*/
|
|
430
|
+
augmentChatContent(chatContent) {
|
|
431
|
+
if (chatContent.prompt_cache_breakpoint) {
|
|
432
|
+
delete chatContent.prompt_cache_breakpoint;
|
|
433
|
+
chatContent.cache_control = { type: 'ephemeral' };
|
|
305
434
|
}
|
|
306
|
-
|
|
307
|
-
|
|
435
|
+
}
|
|
436
|
+
augmentChatMessage(chatMessage, message) {
|
|
437
|
+
if (chatMessage.role === 'assistant' && replaysTo(message.reasoning, this.name)) {
|
|
438
|
+
this.reasoningOf.set(chatMessage, message.reasoning);
|
|
308
439
|
}
|
|
309
440
|
}
|
|
441
|
+
/**
|
|
442
|
+
* Assistant messages carry their reasoning back on the same message, which
|
|
443
|
+
* OpenRouter relays with the turn's text and tool calls, once even when a
|
|
444
|
+
* message was split into parts.
|
|
445
|
+
*/
|
|
310
446
|
async convertMessages(request, config) {
|
|
311
447
|
const messages = await super.convertMessages(request, config);
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
(message.content?.length) &&
|
|
318
|
-
(message.reasoning?.length || message.reasoning_details?.length)) {
|
|
319
|
-
// Remove reasoning details from content
|
|
320
|
-
const { reasoning, reasoning_details } = message;
|
|
321
|
-
delete message.reasoning;
|
|
322
|
-
delete message.reasoning_details;
|
|
323
|
-
// Insert a new message before this one with the reasoning info
|
|
324
|
-
messages.splice(i, 0, {
|
|
325
|
-
role: 'assistant',
|
|
326
|
-
name: message.name,
|
|
327
|
-
reasoning,
|
|
328
|
-
reasoning_details,
|
|
329
|
-
});
|
|
448
|
+
const replayed = new Set();
|
|
449
|
+
return messages.map((message) => {
|
|
450
|
+
const reasoning = this.reasoningOf.get(message);
|
|
451
|
+
if (!reasoning || replayed.has(reasoning)) {
|
|
452
|
+
return message;
|
|
330
453
|
}
|
|
331
|
-
|
|
332
|
-
|
|
454
|
+
replayed.add(reasoning);
|
|
455
|
+
return { ...message, reasoning: reasoning.content ?? undefined, reasoning_details: reasoning.details };
|
|
456
|
+
});
|
|
333
457
|
}
|
|
334
458
|
/**
|
|
335
|
-
*
|
|
459
|
+
* Produce images through chat: image models on OpenRouter answer with
|
|
460
|
+
* images when asked for the image modality. Runs through the chat hooks.
|
|
336
461
|
*/
|
|
337
|
-
|
|
462
|
+
async chatImages(request, images, ctx, defaultModel, config) {
|
|
463
|
+
const response = await this.createExecutor(config)({
|
|
464
|
+
model: request.model,
|
|
465
|
+
messages: [{
|
|
466
|
+
role: 'user',
|
|
467
|
+
content: [
|
|
468
|
+
{ type: 'text', content: request.prompt },
|
|
469
|
+
...images.map((image) => ({ type: 'image', content: image })),
|
|
470
|
+
],
|
|
471
|
+
}],
|
|
472
|
+
modalities: ['image', 'text'],
|
|
473
|
+
extra: {
|
|
474
|
+
image_config: request.size && request.size !== 'auto' ? { aspect_ratio: aspectRatio(request.size) } : undefined,
|
|
475
|
+
...request.extra,
|
|
476
|
+
},
|
|
477
|
+
}, ctx, { model: config?.defaultModels?.[defaultModel] ?? this.config.defaultModels?.[defaultModel] ?? this.defaults[defaultModel] });
|
|
478
|
+
return { images: response.images ?? [], model: response.model, usage: response.usage };
|
|
479
|
+
}
|
|
480
|
+
generateImage = (request, ctx, config) => this.chatImages(request, [], ctx, 'imageGenerate', config);
|
|
481
|
+
editImage = (request, ctx, config) => this.chatImages(request, [request.image, ...request.references ?? []], ctx, 'imageEdit', config);
|
|
338
482
|
/**
|
|
339
|
-
*
|
|
483
|
+
* Image streaming is not offered; image requests fall back to the
|
|
484
|
+
* non-streaming methods.
|
|
340
485
|
*/
|
|
341
486
|
generateImageStream = undefined;
|
|
487
|
+
editImageStream = undefined;
|
|
342
488
|
/**
|
|
343
|
-
*
|
|
489
|
+
* Transcription streaming is not offered; transcription falls back to
|
|
490
|
+
* the non-streaming method.
|
|
344
491
|
*/
|
|
345
|
-
|
|
492
|
+
transcribeStream = undefined;
|
|
346
493
|
/**
|
|
347
|
-
*
|
|
494
|
+
* Order documents by relevance to a query.
|
|
495
|
+
*
|
|
496
|
+
* @param request Rerank request parameters
|
|
497
|
+
* @param ctx Context object
|
|
498
|
+
* @param config Optional configuration override
|
|
499
|
+
* @returns Documents by relevance, most relevant first
|
|
348
500
|
*/
|
|
349
|
-
|
|
501
|
+
rerank = async (request, ctx, config) => {
|
|
502
|
+
const run = this.prepare('rerank', request, ctx, undefined, config, undefined, 'rerank');
|
|
503
|
+
const params = { model: run.model.id, query: request.query, documents: request.documents, top_n: request.topN, ...request.extra };
|
|
504
|
+
const result = await this.send('rerank', run, request, params, (signal) => run.client.post('/rerank', { body: params, signal }));
|
|
505
|
+
return this.finish('rerank', run, request, params, {
|
|
506
|
+
results: result.results.map(({ index, relevance_score }) => ({ index, score: relevance_score })),
|
|
507
|
+
model: run.model,
|
|
508
|
+
usage: { cost: result.usage?.cost },
|
|
509
|
+
});
|
|
510
|
+
};
|
|
350
511
|
/**
|
|
351
|
-
*
|
|
512
|
+
* Typed answers about an input, from System One models such as Jev and
|
|
513
|
+
* Kev or other decision models. A conversation goes in as chat messages.
|
|
514
|
+
* Decisions are an alpha endpoint, outside the versioned API.
|
|
515
|
+
*
|
|
516
|
+
* @param request Decision request parameters
|
|
517
|
+
* @param ctx Context object
|
|
518
|
+
* @param config Optional configuration override
|
|
519
|
+
* @returns Answers by question name
|
|
352
520
|
*/
|
|
353
|
-
|
|
521
|
+
decide = async (request, ctx, config) => {
|
|
522
|
+
const run = this.prepare('decide', request, ctx, undefined, config, undefined, 'decision');
|
|
523
|
+
const params = {
|
|
524
|
+
model: run.model.id,
|
|
525
|
+
state: typeof request.input === 'string' ? request.input : await this.convertMessages({ messages: request.input }, run.config),
|
|
526
|
+
questions: Object.fromEntries(Object.entries(request.questions).map(([name, question]) => [name, toSystemOneQuestion(question)])),
|
|
527
|
+
...request.extra,
|
|
528
|
+
};
|
|
529
|
+
const url = new URL('../alpha/decisions', `${run.client.baseURL}/`).href;
|
|
530
|
+
const result = await this.send('decide', run, request, params, (signal) => run.client.post(url, { body: params, signal }));
|
|
531
|
+
return this.finish('decide', run, request, params, {
|
|
532
|
+
answers: Object.fromEntries(Object.entries(result.answers).map(([name, answer]) => [name, toDecisionAnswer(answer)])),
|
|
533
|
+
model: run.model,
|
|
534
|
+
usage: { text: { input: result.usage.input_tokens, output: result.usage.output_tokens || undefined }, cost: result.usage.cost },
|
|
535
|
+
});
|
|
536
|
+
};
|
|
354
537
|
/**
|
|
355
|
-
*
|
|
538
|
+
* Start a batch: OpenRouter takes the requests inline.
|
|
356
539
|
*/
|
|
357
|
-
|
|
540
|
+
async createBatch(batch, run) {
|
|
541
|
+
const body = { endpoint: batch.endpoint, model: batch.model, requests: batch.lines };
|
|
542
|
+
const created = await run.retry((signal) => run.client.post('/batches', { body, signal }));
|
|
543
|
+
return this.toBatchJob(created, run);
|
|
544
|
+
}
|
|
358
545
|
/**
|
|
359
|
-
* OpenRouter
|
|
546
|
+
* Read a batch: OpenRouter returns the results inline once it completes.
|
|
360
547
|
*/
|
|
361
|
-
|
|
548
|
+
async retrieveBatch(job, run) {
|
|
549
|
+
const batch = await run.retry((signal) => run.client.get(`/batches/${job.id}`, { signal }));
|
|
550
|
+
const results = batch.results && this.batchResults(batch.results, batch.request_counts.total, run);
|
|
551
|
+
return { ...this.toBatchJob(batch, run), results: results ?? undefined };
|
|
552
|
+
}
|
|
362
553
|
/**
|
|
363
|
-
*
|
|
554
|
+
* A batch as @aeye reports it.
|
|
364
555
|
*/
|
|
365
|
-
|
|
556
|
+
toBatchJob(batch, run) {
|
|
557
|
+
const usage = {};
|
|
558
|
+
if (batch.usage) {
|
|
559
|
+
augmentUsage(usage, batch.usage);
|
|
560
|
+
}
|
|
561
|
+
return { id: batch.id, provider: this.name, model: run.model.id, status: batch.status, counts: batch.request_counts, error: batch.error?.message, cost: usage.cost };
|
|
562
|
+
}
|
|
366
563
|
/**
|
|
367
564
|
* Override listModels to use OpenRouter's model API with ZDR support
|
|
368
565
|
*/
|
|
369
566
|
async listModels(config) {
|
|
567
|
+
const { apiKey } = { ...this.config, ...config };
|
|
370
568
|
try {
|
|
371
|
-
// Fetch both models and ZDR models in parallel
|
|
372
569
|
const [models, zdrModels] = await Promise.all([
|
|
373
|
-
fetchModels(
|
|
374
|
-
fetchZDRModels(
|
|
570
|
+
fetchModels(apiKey),
|
|
571
|
+
fetchZDRModels(apiKey),
|
|
375
572
|
]);
|
|
376
573
|
return models.map((model) => convertOpenRouterModel(model, zdrModels));
|
|
377
574
|
}
|