@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.
Files changed (33) hide show
  1. package/README.md +652 -556
  2. package/aeye-openrouter.md +39 -21
  3. package/dist/__integration__/openrouter.integration.test.js +6 -6
  4. package/dist/__integration__/openrouter.integration.test.js.map +1 -1
  5. package/dist/__tests__/batch.test.d.ts +5 -0
  6. package/dist/__tests__/batch.test.d.ts.map +1 -0
  7. package/dist/__tests__/batch.test.js +41 -0
  8. package/dist/__tests__/batch.test.js.map +1 -0
  9. package/dist/__tests__/features.test.d.ts +5 -0
  10. package/dist/__tests__/features.test.d.ts.map +1 -0
  11. package/dist/__tests__/features.test.js +206 -0
  12. package/dist/__tests__/features.test.js.map +1 -0
  13. package/dist/__tests__/openrouter.test.js +38 -1
  14. package/dist/__tests__/openrouter.test.js.map +1 -1
  15. package/dist/__tests__/rerank-decide.test.d.ts +5 -0
  16. package/dist/__tests__/rerank-decide.test.d.ts.map +1 -0
  17. package/dist/__tests__/rerank-decide.test.js +75 -0
  18. package/dist/__tests__/rerank-decide.test.js.map +1 -0
  19. package/dist/__tests__/schema-rejection.test.d.ts +6 -0
  20. package/dist/__tests__/schema-rejection.test.d.ts.map +1 -0
  21. package/dist/__tests__/schema-rejection.test.js +39 -0
  22. package/dist/__tests__/schema-rejection.test.js.map +1 -0
  23. package/dist/openrouter.d.ts +100 -41
  24. package/dist/openrouter.d.ts.map +1 -1
  25. package/dist/openrouter.js +390 -211
  26. package/dist/openrouter.js.map +1 -1
  27. package/dist/source.d.ts +9 -4
  28. package/dist/source.d.ts.map +1 -1
  29. package/dist/source.js +45 -28
  30. package/dist/source.js.map +1 -1
  31. package/dist/types.d.ts +244 -81
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +6 -6
@@ -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 { accumulateReasoning } from '@aeye/core';
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
- * Convert OpenRouter parameter names to our ModelParameter format
23
+ * OpenRouter parameter names to our ModelParameter names
24
24
  */
25
- function convertSupportedParameters(openRouterParams) {
26
- const paramMap = {
27
- 'max_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
- 'include_reasoning': 'reason',
43
- };
44
- const converted = new Set();
45
- for (const param of openRouterParams) {
46
- const mapped = paramMap[param];
47
- if (mapped) {
48
- converted.add(mapped);
49
- }
50
- }
51
- return Array.from(converted);
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
- * Detect capabilities from input/output modalities
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
- // Chat capability - if model outputs text
59
- if (model.architecture.output_modalities.includes('text')) {
60
- capabilities.add('chat');
61
- }
62
- // Image generation - if model outputs images
63
- if (model.architecture.output_modalities.includes('image')) {
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
- // Vision capability - if model accepts images as input
67
- if (model.architecture.input_modalities.includes('image')) {
68
- capabilities.add('vision');
69
- }
70
- // Audio/hearing capability - if model accepts audio as input
71
- if (model.architecture.input_modalities.includes('audio')) {
72
- capabilities.add('hearing');
73
- }
74
- // File handling capability
75
- if (model.architecture.input_modalities.includes('file')) {
76
- capabilities.add('vision'); // Files often imply document/vision capabilities
77
- }
78
- // Tools/function calling
79
- if (model.supported_parameters.includes('tools') || model.supported_parameters.includes('tool_choice')) {
80
- capabilities.add('tools');
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.push('zdr');
128
+ capabilities.add('zdr');
108
129
  }
109
- const hasValue = (x) => {
110
- return x !== undefined && x !== null && x !== '' && x !== '0';
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: new Set(capabilities), // Will be serialized as array
117
- tier,
137
+ capabilities,
138
+ tier: detectTier(model.name),
118
139
  pricing: {
119
- text: hasValue(model.pricing.prompt) || hasValue(model.pricing.completion) ? {
120
- input: hasValue(model.pricing.prompt) ? parseFloat(model.pricing.prompt) * 1_000_000 : undefined,
121
- output: hasValue(model.pricing.completion) ? parseFloat(model.pricing.completion) * 1_000_000 : undefined,
122
- } : undefined,
123
- image: hasValue(model.pricing.image) ? {
124
- input: parseFloat(model.pricing.image) * 1_000_000,
125
- } : undefined,
126
- reasoning: hasValue(model.pricing.internal_reasoning) ? {
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(supportedParameters), // Will be serialized as array
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
- // Add reasoning if specified
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
- params.reasoning = {
211
- enabled: true,
212
- effort: request.reason.effort,
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
- const dp = config.defaultParams;
217
- // Merge provider parameters from config defaults (preserve any existing
218
- // params.provider already set upstream).
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
- ...params.provider,
246
- require_parameters: true,
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?.transforms) {
250
- params.transforms = dp.transforms;
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
- if (message.reasoning) {
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
- const usage = expected.usage;
275
- if (usage) {
276
- if (!response.usage) {
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 = accumulateReasoning(chunk.reasoning, {
315
- content: delta.reasoning,
316
- details: delta.reasoning_details,
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
- augmentChatMessage(chatMessage, message, config) {
321
- if (message.reasoning?.content) {
322
- chatMessage.reasoning = message.reasoning.content;
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
- if (message.reasoning?.details) {
325
- chatMessage.reasoning_details = message.reasoning.details;
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
- for (let i = messages.length - 1; i >= 0; i--) {
331
- const message = messages[i];
332
- // If the message has content & reasoning, move details into a separate field
333
- // Annoying OpenRouter doesn't transform it correctly for us
334
- if (message.role === 'assistant' &&
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
- return messages;
454
+ replayed.add(reasoning);
455
+ return { ...message, reasoning: reasoning.content ?? undefined, reasoning_details: reasoning.details };
456
+ });
351
457
  }
352
458
  /**
353
- * OpenRouter does not support image generation
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
- generateImage = undefined;
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
- * OpenRouter does not support image generation streaming
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
- * OpenRouter does not support image editing
489
+ * Transcription streaming is not offered; transcription falls back to
490
+ * the non-streaming method.
362
491
  */
363
- editImage = undefined;
492
+ transcribeStream = undefined;
364
493
  /**
365
- * OpenRouter does not support image editing
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
- editImageStream = undefined;
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
- * OpenRouter does not support audio transcription
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
- transcribe = undefined;
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
- * OpenRouter does not support audio transcription streaming
538
+ * Start a batch: OpenRouter takes the requests inline.
374
539
  */
375
- transcribeStream = undefined;
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 does not support speech synthesis
546
+ * Read a batch: OpenRouter returns the results inline once it completes.
378
547
  */
379
- speech = undefined;
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
- * OpenRouter does not support embeddings
554
+ * A batch as @aeye reports it.
382
555
  */
383
- embed = undefined;
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(config.apiKey),
392
- fetchZDRModels(config.apiKey),
570
+ fetchModels(apiKey),
571
+ fetchZDRModels(apiKey),
393
572
  ]);
394
573
  return models.map((model) => convertOpenRouterModel(model, zdrModels));
395
574
  }