@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.
Files changed (33) hide show
  1. package/README.md +152 -65
  2. package/aeye-openrouter.md +175 -161
  3. package/dist/__integration__/openrouter.integration.test.js +122 -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 +185 -0
  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 +118 -41
  24. package/dist/openrouter.d.ts.map +1 -1
  25. package/dist/openrouter.js +407 -210
  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 +71 -71
@@ -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,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
- // 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
191
342
  if (request.reason) {
192
- params.reasoning = {
193
- enabled: true,
194
- effort: request.reason.effort,
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
- const dp = config.defaultParams;
199
- // Merge provider parameters from config defaults (preserve any existing
200
- // params.provider already set upstream).
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
- ...params.provider,
228
- require_parameters: true,
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
- if (dp?.transforms) {
232
- params.transforms = dp.transforms;
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
- if (message.reasoning) {
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
- const usage = expected.usage;
257
- if (usage) {
258
- if (!response.usage) {
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 = accumulateReasoning(chunk.reasoning, {
297
- content: delta.reasoning,
298
- details: delta.reasoning_details,
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
- augmentChatMessage(chatMessage, message, config) {
303
- if (message.reasoning?.content) {
304
- 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' };
305
434
  }
306
- if (message.reasoning?.details) {
307
- 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);
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
- for (let i = messages.length - 1; i >= 0; i--) {
313
- const message = messages[i];
314
- // If the message has content & reasoning, move details into a separate field
315
- // Annoying OpenRouter doesn't transform it correctly for us
316
- if (message.role === 'assistant' &&
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
- return messages;
454
+ replayed.add(reasoning);
455
+ return { ...message, reasoning: reasoning.content ?? undefined, reasoning_details: reasoning.details };
456
+ });
333
457
  }
334
458
  /**
335
- * 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.
336
461
  */
337
- 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);
338
482
  /**
339
- * OpenRouter does not support image generation streaming
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
- * OpenRouter does not support image editing
489
+ * Transcription streaming is not offered; transcription falls back to
490
+ * the non-streaming method.
344
491
  */
345
- editImage = undefined;
492
+ transcribeStream = undefined;
346
493
  /**
347
- * 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
348
500
  */
349
- 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
+ };
350
511
  /**
351
- * 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
352
520
  */
353
- 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
+ };
354
537
  /**
355
- * OpenRouter does not support audio transcription streaming
538
+ * Start a batch: OpenRouter takes the requests inline.
356
539
  */
357
- 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
+ }
358
545
  /**
359
- * OpenRouter does not support speech synthesis
546
+ * Read a batch: OpenRouter returns the results inline once it completes.
360
547
  */
361
- 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
+ }
362
553
  /**
363
- * OpenRouter does not support embeddings
554
+ * A batch as @aeye reports it.
364
555
  */
365
- 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
+ }
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(config.apiKey),
374
- fetchZDRModels(config.apiKey),
570
+ fetchModels(apiKey),
571
+ fetchZDRModels(apiKey),
375
572
  ]);
376
573
  return models.map((model) => convertOpenRouterModel(model, zdrModels));
377
574
  }