@aeye/openrouter 0.4.1 → 0.7.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 +652 -556
- package/aeye-openrouter.md +39 -21
- package/dist/__integration__/openrouter.integration.test.js +6 -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 +38 -1
- 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 +100 -41
- package/dist/openrouter.d.ts.map +1 -1
- package/dist/openrouter.js +390 -211
- 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 +6 -6
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,83 @@ 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;
|
|
160
253
|
/**
|
|
161
254
|
* OpenRouter is a router, not a model host: it speaks an OpenAI-shaped API
|
|
162
255
|
* but forwards each request to the upstream provider that actually serves
|
|
@@ -174,7 +267,10 @@ export class OpenRouterProvider extends OpenAIProvider {
|
|
|
174
267
|
* the family itself and only covers `response_format`, not tools — which is
|
|
175
268
|
* why structured output looked family-aware while tools were not.
|
|
176
269
|
*/
|
|
177
|
-
supportedStrictFamilies = new Set(['openai', 'anthropic', 'google']);
|
|
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();
|
|
178
274
|
createClient(config) {
|
|
179
275
|
return new OpenAI({
|
|
180
276
|
apiKey: config.apiKey,
|
|
@@ -201,28 +297,66 @@ export class OpenRouterProvider extends OpenAIProvider {
|
|
|
201
297
|
metadata: {},
|
|
202
298
|
};
|
|
203
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
|
+
}
|
|
204
334
|
/**
|
|
205
335
|
* Augment chat request with OpenRouter-specific parameters
|
|
206
336
|
*/
|
|
207
337
|
augmentChatRequest(params, request, config) {
|
|
208
|
-
|
|
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
|
|
209
342
|
if (request.reason) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
max_tokens: request.reason.maxTokens,
|
|
214
|
-
};
|
|
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 };
|
|
215
346
|
}
|
|
216
|
-
|
|
217
|
-
//
|
|
218
|
-
|
|
347
|
+
delete params.reasoning_effort;
|
|
348
|
+
// OpenRouter ignores OpenAI's inline moderation
|
|
349
|
+
delete params.moderation;
|
|
219
350
|
if (dp?.providers) {
|
|
220
|
-
const { allowFallbacks, requireParameters, dataCollection, maxPrice, ...provider } = dp.providers;
|
|
351
|
+
const { allowFallbacks, requireParameters, dataCollection, enforceDistillableText, preferredMinThroughput, preferredMaxLatency, maxPrice, ...provider } = dp.providers;
|
|
221
352
|
params.provider = {
|
|
222
353
|
...params.provider,
|
|
223
354
|
allow_fallbacks: allowFallbacks,
|
|
224
355
|
require_parameters: requireParameters,
|
|
225
356
|
data_collection: dataCollection,
|
|
357
|
+
enforce_distillable_text: enforceDistillableText,
|
|
358
|
+
preferred_min_throughput: preferredMinThroughput,
|
|
359
|
+
preferred_max_latency: preferredMaxLatency,
|
|
226
360
|
max_price: maxPrice,
|
|
227
361
|
...provider,
|
|
228
362
|
};
|
|
@@ -241,155 +375,200 @@ export class OpenRouterProvider extends OpenAIProvider {
|
|
|
241
375
|
const isStrictStructured = params.response_format?.type === 'json_schema' &&
|
|
242
376
|
params.response_format.json_schema?.strict === true;
|
|
243
377
|
if (isStrictStructured && dp?.providers?.requireParameters === undefined) {
|
|
244
|
-
params.provider = {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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];
|
|
387
|
+
}
|
|
388
|
+
const plugins = [...dp?.plugins ?? []];
|
|
389
|
+
if (dp?.transforms?.includes('middle-out')) {
|
|
390
|
+
plugins.push({ id: 'context-compression', engine: 'middle-out' });
|
|
391
|
+
}
|
|
392
|
+
if (plugins.length) {
|
|
393
|
+
params.plugins = plugins;
|
|
248
394
|
}
|
|
249
|
-
if (dp?.
|
|
250
|
-
params.
|
|
395
|
+
if (dp?.fallbackModels?.length) {
|
|
396
|
+
params.models = dp.fallbackModels;
|
|
251
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;
|
|
252
405
|
}
|
|
253
|
-
/**
|
|
254
|
-
*
|
|
255
|
-
* @param expected
|
|
256
|
-
* @param response
|
|
257
|
-
*/
|
|
258
406
|
augmentChatResponse(expected, response, config) {
|
|
259
407
|
const message = expected.choices?.[0]?.message;
|
|
260
|
-
if (message) {
|
|
261
|
-
|
|
262
|
-
if (!response.reasoning) {
|
|
263
|
-
response.reasoning = {};
|
|
264
|
-
}
|
|
265
|
-
response.reasoning.content = message.reasoning;
|
|
266
|
-
}
|
|
267
|
-
if (message.reasoning_details) {
|
|
268
|
-
if (!response.reasoning) {
|
|
269
|
-
response.reasoning = {};
|
|
270
|
-
}
|
|
271
|
-
response.reasoning.details = message.reasoning_details;
|
|
272
|
-
}
|
|
408
|
+
if (message?.reasoning || message?.reasoning_details) {
|
|
409
|
+
response.reasoning = { content: message.reasoning, details: message.reasoning_details, provider: this.name };
|
|
273
410
|
}
|
|
274
|
-
|
|
275
|
-
if (usage) {
|
|
276
|
-
|
|
277
|
-
response.usage = {};
|
|
278
|
-
}
|
|
279
|
-
if (!response.usage.text) {
|
|
280
|
-
response.usage.text = {};
|
|
281
|
-
}
|
|
282
|
-
if (usage.completion_tokens) {
|
|
283
|
-
response.usage.text.output = usage.completion_tokens;
|
|
284
|
-
}
|
|
285
|
-
if (usage.prompt_tokens) {
|
|
286
|
-
response.usage.text.input = usage.prompt_tokens;
|
|
287
|
-
}
|
|
288
|
-
if (usage.cost) {
|
|
289
|
-
response.usage.cost = usage.cost;
|
|
290
|
-
}
|
|
411
|
+
response.images = toImages(message?.images);
|
|
412
|
+
if (expected.usage) {
|
|
413
|
+
augmentUsage(response.usage ??= {}, expected.usage);
|
|
291
414
|
}
|
|
292
415
|
}
|
|
293
416
|
augmentChatChunk(expected, chunk, config) {
|
|
294
|
-
const usage = expected.usage;
|
|
295
|
-
if (usage) {
|
|
296
|
-
if (!chunk.usage) {
|
|
297
|
-
chunk.usage = {};
|
|
298
|
-
}
|
|
299
|
-
if (!chunk.usage.text) {
|
|
300
|
-
chunk.usage.text = {};
|
|
301
|
-
}
|
|
302
|
-
if (usage.completion_tokens) {
|
|
303
|
-
chunk.usage.text.output = usage.completion_tokens;
|
|
304
|
-
}
|
|
305
|
-
if (usage.prompt_tokens) {
|
|
306
|
-
chunk.usage.text.input = usage.prompt_tokens;
|
|
307
|
-
}
|
|
308
|
-
if (usage.cost) {
|
|
309
|
-
chunk.usage.cost = usage.cost;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
417
|
const delta = expected.choices[0]?.delta;
|
|
313
418
|
if (delta?.reasoning || delta?.reasoning_details?.length) {
|
|
314
|
-
chunk.reasoning =
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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);
|
|
318
424
|
}
|
|
319
425
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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' };
|
|
323
434
|
}
|
|
324
|
-
|
|
325
|
-
|
|
435
|
+
}
|
|
436
|
+
augmentChatMessage(chatMessage, message) {
|
|
437
|
+
if (chatMessage.role === 'assistant' && replaysTo(message.reasoning, this.name)) {
|
|
438
|
+
this.reasoningOf.set(chatMessage, message.reasoning);
|
|
326
439
|
}
|
|
327
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
|
+
*/
|
|
328
446
|
async convertMessages(request, config) {
|
|
329
447
|
const messages = await super.convertMessages(request, config);
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
(message.content?.length) &&
|
|
336
|
-
(message.reasoning?.length || message.reasoning_details?.length)) {
|
|
337
|
-
// Remove reasoning details from content
|
|
338
|
-
const { reasoning, reasoning_details } = message;
|
|
339
|
-
delete message.reasoning;
|
|
340
|
-
delete message.reasoning_details;
|
|
341
|
-
// Insert a new message before this one with the reasoning info
|
|
342
|
-
messages.splice(i, 0, {
|
|
343
|
-
role: 'assistant',
|
|
344
|
-
name: message.name,
|
|
345
|
-
reasoning,
|
|
346
|
-
reasoning_details,
|
|
347
|
-
});
|
|
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;
|
|
348
453
|
}
|
|
349
|
-
|
|
350
|
-
|
|
454
|
+
replayed.add(reasoning);
|
|
455
|
+
return { ...message, reasoning: reasoning.content ?? undefined, reasoning_details: reasoning.details };
|
|
456
|
+
});
|
|
351
457
|
}
|
|
352
458
|
/**
|
|
353
|
-
*
|
|
459
|
+
* Produce images through chat: image models on OpenRouter answer with
|
|
460
|
+
* images when asked for the image modality. Runs through the chat hooks.
|
|
354
461
|
*/
|
|
355
|
-
|
|
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);
|
|
356
482
|
/**
|
|
357
|
-
*
|
|
483
|
+
* Image streaming is not offered; image requests fall back to the
|
|
484
|
+
* non-streaming methods.
|
|
358
485
|
*/
|
|
359
486
|
generateImageStream = undefined;
|
|
487
|
+
editImageStream = undefined;
|
|
360
488
|
/**
|
|
361
|
-
*
|
|
489
|
+
* Transcription streaming is not offered; transcription falls back to
|
|
490
|
+
* the non-streaming method.
|
|
362
491
|
*/
|
|
363
|
-
|
|
492
|
+
transcribeStream = undefined;
|
|
364
493
|
/**
|
|
365
|
-
*
|
|
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
|
|
366
500
|
*/
|
|
367
|
-
|
|
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
|
+
};
|
|
368
511
|
/**
|
|
369
|
-
*
|
|
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
|
|
370
520
|
*/
|
|
371
|
-
|
|
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
|
+
};
|
|
372
537
|
/**
|
|
373
|
-
*
|
|
538
|
+
* Start a batch: OpenRouter takes the requests inline.
|
|
374
539
|
*/
|
|
375
|
-
|
|
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
|
+
}
|
|
376
545
|
/**
|
|
377
|
-
* OpenRouter
|
|
546
|
+
* Read a batch: OpenRouter returns the results inline once it completes.
|
|
378
547
|
*/
|
|
379
|
-
|
|
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
|
+
}
|
|
380
553
|
/**
|
|
381
|
-
*
|
|
554
|
+
* A batch as @aeye reports it.
|
|
382
555
|
*/
|
|
383
|
-
|
|
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
|
+
}
|
|
384
563
|
/**
|
|
385
564
|
* Override listModels to use OpenRouter's model API with ZDR support
|
|
386
565
|
*/
|
|
387
566
|
async listModels(config) {
|
|
567
|
+
const { apiKey } = { ...this.config, ...config };
|
|
388
568
|
try {
|
|
389
|
-
// Fetch both models and ZDR models in parallel
|
|
390
569
|
const [models, zdrModels] = await Promise.all([
|
|
391
|
-
fetchModels(
|
|
392
|
-
fetchZDRModels(
|
|
570
|
+
fetchModels(apiKey),
|
|
571
|
+
fetchZDRModels(apiKey),
|
|
393
572
|
]);
|
|
394
573
|
return models.map((model) => convertOpenRouterModel(model, zdrModels));
|
|
395
574
|
}
|