@juspay/neurolink 9.68.18 → 9.68.19

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.
@@ -1,60 +1,75 @@
1
- import type { ZodType } from "zod";
2
1
  import { AIProviderName } from "../constants/enums.js";
3
- import { BaseProvider } from "../core/baseProvider.js";
4
- import type { StreamOptions, StreamResult } from "../types/index.js";
5
- import type { LanguageModel, Schema } from "../types/index.js";
2
+ import type { NeurolinkCredentials } from "../types/index.js";
3
+ import { OpenAIChatCompletionsProvider } from "./openaiChatCompletionsBase.js";
6
4
  /**
7
- * OpenRouter Provider - BaseProvider Implementation
8
- * Provides access to 300+ models from 60+ providers via OpenRouter unified gateway
5
+ * OpenRouter Provider direct HTTP, no AI SDK.
6
+ *
7
+ * OpenAI-compatible unified gateway to 300+ models from 60+ providers. All
8
+ * request/stream/tool-loop orchestration lives in
9
+ * `OpenAIChatCompletionsProvider`; this class declares configuration plus the
10
+ * OpenRouter-specific behaviour:
11
+ *
12
+ * 1. Attribution headers — optional `HTTP-Referer` / `X-Title` (from
13
+ * `OPENROUTER_REFERER` / `OPENROUTER_APP_NAME`) are merged into every
14
+ * request via `getAuthHeaders` so usage shows up on the openrouter.ai
15
+ * activity dashboard.
16
+ * 2. Per-model tool gating — OpenRouter proxies many models with varying
17
+ * tool support, so `supportsTools()` consults a cached capability set
18
+ * (populated by `cacheModelCapabilities()`) and falls back to a
19
+ * conservative known-capable pattern list.
20
+ * 3. Dynamic model discovery — `getAvailableModels()` fetches the live
21
+ * `/models` list (10-minute cache) with a hardcoded fallback.
22
+ *
23
+ * @see https://openrouter.ai/docs
9
24
  */
10
- export declare class OpenRouterProvider extends BaseProvider {
11
- private model;
12
- private openRouterClient;
13
- private config;
25
+ export declare class OpenRouterProvider extends OpenAIChatCompletionsProvider {
26
+ private readonly referer?;
27
+ private readonly appName?;
14
28
  private static modelsCache;
15
29
  private static modelsCacheTime;
16
30
  private static readonly MODELS_CACHE_DURATION;
17
31
  private static toolCapableModels;
18
32
  private static capabilitiesCached;
19
- constructor(modelName?: string, sdk?: unknown, _region?: string, credentials?: {
20
- apiKey?: string;
21
- baseURL?: string;
22
- });
33
+ constructor(modelName?: string, sdk?: unknown, _region?: string, credentials?: NeurolinkCredentials["openrouter"]);
23
34
  protected getProviderName(): AIProviderName;
24
35
  protected getDefaultModel(): string;
36
+ protected formatProviderError(error: unknown): Error;
37
+ protected getFallbackModelName(): string;
25
38
  /**
26
- * Returns the Vercel AI SDK model instance for OpenRouter
39
+ * Attribution headers are merged into every request alongside the bearer
40
+ * token so OpenRouter can attribute usage on its activity dashboard.
27
41
  */
28
- protected getAISDKModel(): LanguageModel;
29
- formatProviderError(error: unknown): Error;
42
+ protected getAuthHeaders(): Record<string, string>;
30
43
  /**
31
- * OpenRouter supports tools for compatible models
32
- * Checks cached model capabilities or uses known patterns as fallback
44
+ * OpenRouter proxies models with varying tool support. Use cached
45
+ * capabilities when available (populated by `cacheModelCapabilities()`),
46
+ * otherwise fall back to a conservative known-capable pattern list and
47
+ * disable tools for unknown models.
33
48
  */
34
49
  supportsTools(): boolean;
35
50
  /**
36
- * Provider-specific streaming implementation
37
- * Note: This is only used when tools are disabled
51
+ * Models/capabilities endpoint, derived from the configured `baseURL` so a
52
+ * custom OpenRouter-compatible gateway is honoured for discovery too.
38
53
  */
39
- protected executeStream(options: StreamOptions, analysisSchema?: ZodType | Schema<unknown>): Promise<StreamResult>;
54
+ private getModelsUrl;
40
55
  /**
41
- * Get available models from OpenRouter API
42
- * Dynamically fetches from /api/v1/models endpoint with caching and fallback
56
+ * Get available models from the OpenRouter `/models` endpoint, with a
57
+ * 10-minute cache and a hardcoded fallback when the fetch fails.
43
58
  */
44
59
  getAvailableModels(): Promise<string[]>;
45
60
  /**
46
- * Fetch available models from OpenRouter API /api/v1/models endpoint
61
+ * Fetch available models from the OpenRouter `/models` endpoint.
47
62
  * @private
48
63
  */
49
64
  private fetchModelsFromAPI;
50
65
  /**
51
- * Type guard to validate the models API response structure
66
+ * Type guard to validate the models API response structure.
52
67
  * @private
53
68
  */
54
69
  private isValidModelsResponse;
55
70
  /**
56
- * Fetch and cache model capabilities from OpenRouter API
57
- * Call this to enable accurate tool support detection
71
+ * Fetch and cache model capabilities from the OpenRouter `/models` endpoint.
72
+ * Call this to enable accurate per-model tool support detection.
58
73
  */
59
74
  cacheModelCapabilities(): Promise<void>;
60
75
  }
@@ -1,44 +1,30 @@
1
- import { createOpenRouter } from "@openrouter/ai-sdk-provider";
2
1
  import { AIProviderName } from "../constants/enums.js";
3
- import { BaseProvider } from "../core/baseProvider.js";
4
- import { DEFAULT_MAX_STEPS } from "../core/constants.js";
5
- import { streamAnalyticsCollector } from "../core/streamAnalytics.js";
6
- import { createProxyFetch } from "../proxy/proxyFetch.js";
7
2
  import { AuthenticationError, InvalidModelError, NetworkError, ProviderError, RateLimitError, } from "../types/index.js";
3
+ import { createProxyFetch } from "../proxy/proxyFetch.js";
8
4
  import { isAbortError } from "../utils/errorHandling.js";
9
- import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js";
10
5
  import { logger } from "../utils/logger.js";
11
- import { buildNoOutputSentinel, detectPostStreamNoOutput, stampNoOutputSpan, } from "../utils/noOutputSentinel.js";
6
+ import { redactUrlCredentials } from "../utils/logSanitize.js";
12
7
  import { getProviderModel } from "../utils/providerConfig.js";
13
- import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
14
- import { resolveToolChoice } from "../utils/toolChoice.js";
15
- import { NoOutputGeneratedError } from "../utils/generationErrors.js";
16
- import { Output, stepCountIs } from "../utils/tool.js";
17
- import { streamText } from "../utils/generation.js";
18
- // Constants
8
+ import { TimeoutError } from "../utils/timeout.js";
9
+ import { OpenAIChatCompletionsProvider } from "./openaiChatCompletionsBase.js";
10
+ import { stripTrailingSlash } from "./openaiChatCompletionsClient.js";
11
+ // OpenRouter's OpenAI-compatible gateway. `${baseURL}/chat/completions` and
12
+ // `${baseURL}/models` both resolve correctly off this root.
13
+ const OPENROUTER_DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
19
14
  const MODELS_DISCOVERY_TIMEOUT_MS = 5000; // 5 seconds for model discovery
20
- // Configuration helpers
21
- const getOpenRouterConfig = () => {
15
+ const getOpenRouterApiKey = () => {
22
16
  const apiKey = process.env.OPENROUTER_API_KEY;
23
17
  if (!apiKey) {
24
18
  throw new Error("OPENROUTER_API_KEY environment variable is required. " +
25
19
  "Get your API key at https://openrouter.ai/keys");
26
20
  }
27
- return {
28
- apiKey,
29
- referer: process.env.OPENROUTER_REFERER,
30
- appName: process.env.OPENROUTER_APP_NAME,
31
- };
21
+ return apiKey;
32
22
  };
33
23
  /**
34
24
  * Returns the default model name for OpenRouter.
35
25
  *
36
- * OpenRouter uses a 'provider/model' format for model names.
37
- * For example:
38
- * - 'anthropic/claude-sonnet-4.5'
39
- * - 'openai/gpt-4o'
40
- * - 'google/gemini-2.5-flash'
41
- * - 'meta-llama/llama-3-70b-instruct'
26
+ * OpenRouter uses a 'provider/model' format for model names (e.g.
27
+ * 'anthropic/claude-sonnet-4.5', 'openai/gpt-4o', 'google/gemini-2.5-flash').
42
28
  *
43
29
  * The previous default `anthropic/claude-3-5-sonnet` was retired by OpenRouter
44
30
  * in late 2025 and now returns "No endpoints found for model" for every
@@ -47,20 +33,34 @@ const getOpenRouterConfig = () => {
47
33
  * model. Must stay aligned with the registry default in
48
34
  * `src/lib/factories/providerRegistry.ts` and `PROVIDER_DEFAULTS` in
49
35
  * `src/lib/utils/modelChoices.ts`.
50
- *
51
- * You can override the default by setting the OPENROUTER_MODEL environment variable.
52
36
  */
53
37
  const getDefaultOpenRouterModel = () => {
54
38
  return getProviderModel("OPENROUTER_MODEL", "anthropic/claude-sonnet-4.5");
55
39
  };
56
40
  /**
57
- * OpenRouter Provider - BaseProvider Implementation
58
- * Provides access to 300+ models from 60+ providers via OpenRouter unified gateway
41
+ * OpenRouter Provider direct HTTP, no AI SDK.
42
+ *
43
+ * OpenAI-compatible unified gateway to 300+ models from 60+ providers. All
44
+ * request/stream/tool-loop orchestration lives in
45
+ * `OpenAIChatCompletionsProvider`; this class declares configuration plus the
46
+ * OpenRouter-specific behaviour:
47
+ *
48
+ * 1. Attribution headers — optional `HTTP-Referer` / `X-Title` (from
49
+ * `OPENROUTER_REFERER` / `OPENROUTER_APP_NAME`) are merged into every
50
+ * request via `getAuthHeaders` so usage shows up on the openrouter.ai
51
+ * activity dashboard.
52
+ * 2. Per-model tool gating — OpenRouter proxies many models with varying
53
+ * tool support, so `supportsTools()` consults a cached capability set
54
+ * (populated by `cacheModelCapabilities()`) and falls back to a
55
+ * conservative known-capable pattern list.
56
+ * 3. Dynamic model discovery — `getAvailableModels()` fetches the live
57
+ * `/models` list (10-minute cache) with a hardcoded fallback.
58
+ *
59
+ * @see https://openrouter.ai/docs
59
60
  */
60
- export class OpenRouterProvider extends BaseProvider {
61
- model;
62
- openRouterClient;
63
- config;
61
+ export class OpenRouterProvider extends OpenAIChatCompletionsProvider {
62
+ referer;
63
+ appName;
64
64
  // Cache for available models to avoid repeated API calls
65
65
  static modelsCache = [];
66
66
  static modelsCacheTime = 0;
@@ -69,53 +69,40 @@ export class OpenRouterProvider extends BaseProvider {
69
69
  static toolCapableModels = new Set();
70
70
  static capabilitiesCached = false;
71
71
  constructor(modelName, sdk, _region, credentials) {
72
- super(modelName, AIProviderName.OPENROUTER, sdk);
73
- // Build config: prefer credentials over env vars to avoid throwing when env vars are absent
74
- if (credentials?.apiKey) {
75
- this.config = {
76
- apiKey: credentials.apiKey,
77
- referer: process.env.OPENROUTER_REFERER,
78
- appName: process.env.OPENROUTER_APP_NAME,
79
- };
80
- }
81
- else {
82
- this.config = getOpenRouterConfig(); // throws if OPENROUTER_API_KEY missing
83
- }
84
- const config = this.config;
85
- // Build headers for attribution on openrouter.ai/activity dashboard
86
- const headers = {};
87
- if (config.referer) {
88
- headers["HTTP-Referer"] = config.referer;
89
- }
90
- if (config.appName) {
91
- headers["X-Title"] = config.appName;
92
- }
93
- // Create OpenRouter client with optional attribution headers
94
- this.openRouterClient = createOpenRouter({
95
- apiKey: config.apiKey,
96
- ...(credentials?.baseURL ? { baseURL: credentials.baseURL } : {}),
97
- ...(Object.keys(headers).length > 0 && { headers }),
98
- });
99
- // Initialize model with OpenRouter client
100
- // OpenRouterChatLanguageModel implements LanguageModelV3 which is part of the LanguageModel union
101
- this.model = this.openRouterClient(this.modelName || getDefaultOpenRouterModel());
72
+ // Trim the override before applying precedence. A blank/whitespace
73
+ // `credentials.apiKey` must NOT bypass `getOpenRouterApiKey()` that
74
+ // would build a client with an unusable bearer token and fail at request
75
+ // time with a confusing 401 instead of at construction time.
76
+ const overrideApiKey = credentials?.apiKey?.trim();
77
+ const apiKey = overrideApiKey && overrideApiKey.length > 0
78
+ ? overrideApiKey
79
+ : getOpenRouterApiKey();
80
+ // Treat blank/whitespace overrides as unset so an empty
81
+ // `credentials.baseURL` or `OPENROUTER_BASE_URL=` cannot silently override
82
+ // the default with "" (mirrors the apiKey precedence above).
83
+ const baseURL = credentials?.baseURL?.trim() ||
84
+ process.env.OPENROUTER_BASE_URL?.trim() ||
85
+ OPENROUTER_DEFAULT_BASE_URL;
86
+ super(AIProviderName.OPENROUTER, modelName, sdk, { baseURL, apiKey });
87
+ // Trim attribution values so whitespace-only env vars are not sent as
88
+ // empty HTTP-Referer / X-Title headers.
89
+ this.referer = process.env.OPENROUTER_REFERER?.trim() || undefined;
90
+ this.appName = process.env.OPENROUTER_APP_NAME?.trim() || undefined;
102
91
  logger.debug("OpenRouter Provider initialized", {
103
92
  modelName: this.modelName,
104
- provider: this.providerName,
93
+ providerName: this.providerName,
94
+ baseURL: redactUrlCredentials(this.config.baseURL),
105
95
  });
106
96
  }
97
+ // ===========================================================================
98
+ // Abstract hooks (required)
99
+ // ===========================================================================
107
100
  getProviderName() {
108
101
  return AIProviderName.OPENROUTER;
109
102
  }
110
103
  getDefaultModel() {
111
104
  return getDefaultOpenRouterModel();
112
105
  }
113
- /**
114
- * Returns the Vercel AI SDK model instance for OpenRouter
115
- */
116
- getAISDKModel() {
117
- return this.model;
118
- }
119
106
  formatProviderError(error) {
120
107
  if (error instanceof TimeoutError) {
121
108
  return new NetworkError(`Request timed out: ${error.message}`, "openrouter");
@@ -150,9 +137,9 @@ export class OpenRouterProvider extends BaseProvider {
150
137
  if (errorRecord.message.includes("insufficient_credits")) {
151
138
  return new ProviderError("Insufficient OpenRouter credits. Add credits at https://openrouter.ai/credits", "openrouter");
152
139
  }
153
- // "No endpoints found" — model temporarily unavailable or unsupported parameters
154
- // This is distinct from tool errors: it can happen on any request when the
155
- // model has no available providers on OpenRouter (e.g., free-tier model down).
140
+ // "No endpoints found" — model temporarily unavailable or unsupported
141
+ // parameters. Distinct from tool errors: it can happen on any request
142
+ // when the model has no available providers on OpenRouter.
156
143
  if (errorRecord.message.includes("No endpoints found")) {
157
144
  return new InvalidModelError(`No endpoints found for model '${this.modelName}' on OpenRouter. ` +
158
145
  "The model may be temporarily unavailable or does not support the requested parameters. " +
@@ -175,9 +162,31 @@ export class OpenRouterProvider extends BaseProvider {
175
162
  }
176
163
  return new ProviderError(`OpenRouter error: ${errorRecord?.message || "Unknown error"}`, "openrouter");
177
164
  }
165
+ // ===========================================================================
166
+ // Optional hooks — provider-specific quirks
167
+ // ===========================================================================
168
+ getFallbackModelName() {
169
+ return getDefaultOpenRouterModel();
170
+ }
171
+ /**
172
+ * Attribution headers are merged into every request alongside the bearer
173
+ * token so OpenRouter can attribute usage on its activity dashboard.
174
+ */
175
+ getAuthHeaders() {
176
+ const headers = { ...super.getAuthHeaders() };
177
+ if (this.referer) {
178
+ headers["HTTP-Referer"] = this.referer;
179
+ }
180
+ if (this.appName) {
181
+ headers["X-Title"] = this.appName;
182
+ }
183
+ return headers;
184
+ }
178
185
  /**
179
- * OpenRouter supports tools for compatible models
180
- * Checks cached model capabilities or uses known patterns as fallback
186
+ * OpenRouter proxies models with varying tool support. Use cached
187
+ * capabilities when available (populated by `cacheModelCapabilities()`),
188
+ * otherwise fall back to a conservative known-capable pattern list and
189
+ * disable tools for unknown models.
181
190
  */
182
191
  supportsTools() {
183
192
  const modelName = this.modelName || getDefaultOpenRouterModel();
@@ -222,220 +231,19 @@ export class OpenRouterProvider extends BaseProvider {
222
231
  });
223
232
  return false;
224
233
  }
234
+ // ===========================================================================
235
+ // Model discovery (OpenRouter /models endpoint)
236
+ // ===========================================================================
225
237
  /**
226
- * Provider-specific streaming implementation
227
- * Note: This is only used when tools are disabled
238
+ * Models/capabilities endpoint, derived from the configured `baseURL` so a
239
+ * custom OpenRouter-compatible gateway is honoured for discovery too.
228
240
  */
229
- async executeStream(options, analysisSchema) {
230
- this.validateStreamOptions(options);
231
- const startTime = Date.now();
232
- let chunkCount = 0; // Track chunk count for debugging
233
- // Reviewer follow-up: capture upstream provider errors via onError so
234
- // the post-stream NoOutput detect can propagate the *real* cause
235
- // (e.g. content_filter, provider crash) into the sentinel's
236
- // providerError / modelResponseRaw instead of the AI SDK's generic
237
- // "No output generated" message.
238
- let capturedProviderError;
239
- const timeout = this.getTimeout(options);
240
- const timeoutController = createTimeoutController(timeout, this.providerName, "stream");
241
- try {
242
- // Build message array from options with multimodal support
243
- // Using protected helper from BaseProvider to eliminate code duplication
244
- const messages = await this.buildMessagesForStream(options);
245
- const model = await this.getAISDKModelWithMiddleware(options);
246
- // Get all available tools (direct + MCP + external) for streaming
247
- // BaseProvider.stream() pre-merges base tools + external tools into options.tools
248
- const shouldUseTools = !options.disableTools && this.supportsTools();
249
- const tools = shouldUseTools
250
- ? options.tools || (await this.getAllTools())
251
- : {};
252
- logger.debug(`OpenRouter: Tools for streaming`, {
253
- shouldUseTools,
254
- toolCount: Object.keys(tools).length,
255
- toolNames: Object.keys(tools),
256
- });
257
- // Build complete stream options with proper typing
258
- // Note: maxRetries set to 0 for OpenRouter free tier to prevent SDK's quick retries
259
- // from consuming rate limits. Our test suite handles retries with appropriate delays.
260
- let streamOptions = {
261
- model: model,
262
- messages: messages,
263
- temperature: options.temperature,
264
- maxRetries: 0, // Disable SDK retries - let caller handle rate limit retries with delays
265
- // AI SDK v6 renamed `maxTokens` to `maxOutputTokens` — using the old
266
- // name here is a silent no-op, so OpenRouter sees no cap and applies
267
- // the model's full output max (typically 64K+ tokens) to its pre-bill
268
- // affordability check. That trips "This request requires more credits"
269
- // even on cheap models when the account balance is low.
270
- ...(options.maxTokens && { maxOutputTokens: options.maxTokens }),
271
- ...(shouldUseTools &&
272
- Object.keys(tools).length > 0 && {
273
- tools,
274
- toolChoice: resolveToolChoice(options, tools, shouldUseTools),
275
- stopWhen: stepCountIs(options.maxSteps || DEFAULT_MAX_STEPS),
276
- }),
277
- abortSignal: composeAbortSignals(options.abortSignal, timeoutController?.controller.signal),
278
- experimental_telemetry: this.telemetryHandler.getTelemetryConfig(options),
279
- experimental_repairToolCall: this.getToolCallRepairFn(options),
280
- onError: (event) => {
281
- const error = event.error;
282
- const errorMessage = error instanceof Error ? error.message : String(error);
283
- // Reviewer follow-up: propagate the captured error to the
284
- // post-stream NoOutput sentinel so telemetry sees the real
285
- // provider cause instead of "No output generated".
286
- capturedProviderError = error;
287
- logger.error(`OpenRouter: Stream error`, {
288
- provider: this.providerName,
289
- modelName: this.modelName,
290
- error: errorMessage,
291
- chunkCount,
292
- });
293
- },
294
- onFinish: (event) => {
295
- logger.debug(`OpenRouter: Stream finished`, {
296
- finishReason: event.finishReason,
297
- totalChunks: chunkCount,
298
- });
299
- },
300
- onChunk: () => {
301
- chunkCount++;
302
- },
303
- onStepFinish: ({ toolCalls, toolResults }) => {
304
- emitToolEndFromStepFinish(this.neurolink?.getEventEmitter(), toolResults);
305
- logger.info("Tool execution completed", {
306
- toolCallCount: toolCalls?.length || 0,
307
- toolResultCount: toolResults?.length || 0,
308
- toolNames: toolCalls?.map((tc) => tc.toolName),
309
- });
310
- this.handleToolExecutionStorage(toolCalls, toolResults, options, new Date()).catch((error) => {
311
- logger.warn("OpenRouterProvider: Failed to store tool executions", {
312
- provider: this.providerName,
313
- error: error instanceof Error ? error.message : String(error),
314
- });
315
- });
316
- },
317
- };
318
- // Add analysisSchema support if provided
319
- if (analysisSchema) {
320
- try {
321
- streamOptions = {
322
- ...streamOptions,
323
- experimental_output: Output.object({
324
- schema: analysisSchema,
325
- }),
326
- };
327
- }
328
- catch (error) {
329
- logger.warn("Schema application failed, continuing without schema", {
330
- error: String(error),
331
- });
332
- }
333
- }
334
- const result = await streamText(streamOptions);
335
- // Guard against NoOutputGeneratedError becoming an unhandled rejection.
336
- Promise.resolve(result.text)
337
- .catch((err) => {
338
- logger.debug("Stream text promise rejected (expected for empty streams)", {
339
- error: err instanceof Error ? err.message : String(err),
340
- });
341
- })
342
- .finally(() => timeoutController?.cleanup());
343
- // Transform stream to content object stream using fullStream (handles both text and tool calls)
344
- const transformedStream = (async function* () {
345
- // Reviewer follow-up: gate the post-stream NoOutput detect on
346
- // *content yielded*, not raw chunk count. AI SDK fullStream emits
347
- // control events ({ type: "start" }, "step-start", etc.) before
348
- // any text-delta — those incremented `chunkCount` and made the
349
- // post-stream check dead even when zero text was produced.
350
- let contentYielded = 0;
351
- try {
352
- // Try fullStream first (handles both text and tool calls), fallback to textStream
353
- const streamToUse = result.fullStream || result.textStream;
354
- for await (const chunk of streamToUse) {
355
- // Handle different chunk types from fullStream
356
- if (chunk && typeof chunk === "object") {
357
- // Check for error chunks first (critical error handling)
358
- if ("type" in chunk && chunk.type === "error") {
359
- const errorChunk = chunk;
360
- logger.error(`OpenRouter: Error chunk received:`, {
361
- errorType: errorChunk.type,
362
- errorDetails: errorChunk.error,
363
- });
364
- throw new Error(`OpenRouter streaming error: ${errorChunk.error?.message ||
365
- "Unknown error"}`);
366
- }
367
- if ("textDelta" in chunk) {
368
- // Text delta from fullStream
369
- const textDelta = chunk.textDelta;
370
- if (textDelta) {
371
- contentYielded++;
372
- yield { content: textDelta };
373
- }
374
- }
375
- else if ("type" in chunk &&
376
- chunk.type === "tool-call" &&
377
- "toolCallId" in chunk) {
378
- // Tool call event - log for debugging
379
- const toolCallId = String(chunk.toolCallId);
380
- const toolName = "toolName" in chunk ? String(chunk.toolName) : "unknown";
381
- logger.debug("OpenRouter: Tool call", {
382
- toolCallId,
383
- toolName,
384
- });
385
- }
386
- }
387
- else if (typeof chunk === "string") {
388
- // Direct string chunk from textStream fallback
389
- contentYielded++;
390
- yield { content: chunk };
391
- }
392
- }
393
- }
394
- catch (streamError) {
395
- if (NoOutputGeneratedError.isInstance(streamError)) {
396
- logger.warn("OpenRouter: Stream produced no output (NoOutputGeneratedError) — caught from textStream");
397
- const sentinel = await buildNoOutputSentinel(streamError, result, capturedProviderError);
398
- stampNoOutputSpan(sentinel);
399
- yield sentinel;
400
- return;
401
- }
402
- throw streamError;
403
- }
404
- // Curator P3-6 (round-2 fix): production trigger comes through
405
- // result.finishReason rejection, not textStream throws.
406
- if (contentYielded === 0) {
407
- const detected = await detectPostStreamNoOutput(result, capturedProviderError);
408
- if (detected) {
409
- logger.warn("OpenRouter: Stream produced no output (NoOutputGeneratedError) — caught from finishReason rejection");
410
- stampNoOutputSpan(detected.sentinel);
411
- yield detected.sentinel;
412
- }
413
- }
414
- })();
415
- // Create analytics promise that resolves after stream completion
416
- const analyticsPromise = streamAnalyticsCollector.createAnalytics(this.providerName, this.modelName, result, Date.now() - startTime, {
417
- requestId: `openrouter-stream-${Date.now()}`,
418
- streamingMode: true,
419
- });
420
- return {
421
- stream: transformedStream,
422
- provider: this.providerName,
423
- model: this.modelName,
424
- analytics: analyticsPromise,
425
- metadata: {
426
- startTime,
427
- streamId: `openrouter-${Date.now()}`,
428
- },
429
- };
430
- }
431
- catch (error) {
432
- timeoutController?.cleanup();
433
- throw this.handleProviderError(error);
434
- }
241
+ getModelsUrl() {
242
+ return `${stripTrailingSlash(this.config.baseURL)}/models`;
435
243
  }
436
244
  /**
437
- * Get available models from OpenRouter API
438
- * Dynamically fetches from /api/v1/models endpoint with caching and fallback
245
+ * Get available models from the OpenRouter `/models` endpoint, with a
246
+ * 10-minute cache and a hardcoded fallback when the fetch fails.
439
247
  */
440
248
  async getAvailableModels() {
441
249
  const functionTag = "OpenRouterProvider.getAvailableModels";
@@ -454,7 +262,6 @@ export class OpenRouterProvider extends BaseProvider {
454
262
  try {
455
263
  const dynamicModels = await this.fetchModelsFromAPI();
456
264
  if (dynamicModels.length > 0) {
457
- // Cache successful result
458
265
  OpenRouterProvider.modelsCache = dynamicModels;
459
266
  OpenRouterProvider.modelsCacheTime = now;
460
267
  logger.debug(`[${functionTag}] Successfully fetched models from API`, {
@@ -496,22 +303,21 @@ export class OpenRouterProvider extends BaseProvider {
496
303
  return fallbackModels;
497
304
  }
498
305
  /**
499
- * Fetch available models from OpenRouter API /api/v1/models endpoint
306
+ * Fetch available models from the OpenRouter `/models` endpoint.
500
307
  * @private
501
308
  */
502
309
  async fetchModelsFromAPI() {
503
310
  const functionTag = "OpenRouterProvider.fetchModelsFromAPI";
504
- const config = this.config;
505
- const modelsUrl = "https://openrouter.ai/api/v1/models";
506
311
  const controller = new AbortController();
507
312
  const timeoutId = setTimeout(() => controller.abort(), MODELS_DISCOVERY_TIMEOUT_MS);
508
313
  try {
314
+ const modelsUrl = this.getModelsUrl();
509
315
  logger.debug(`[${functionTag}] Fetching models from ${modelsUrl}`);
510
316
  const proxyFetch = createProxyFetch();
511
317
  const response = await proxyFetch(modelsUrl, {
512
318
  method: "GET",
513
319
  headers: {
514
- Authorization: `Bearer ${config.apiKey}`,
320
+ ...this.getAuthHeaders(),
515
321
  "Content-Type": "application/json",
516
322
  },
517
323
  signal: controller.signal,
@@ -544,7 +350,7 @@ export class OpenRouterProvider extends BaseProvider {
544
350
  }
545
351
  }
546
352
  /**
547
- * Type guard to validate the models API response structure
353
+ * Type guard to validate the models API response structure.
548
354
  * @private
549
355
  */
550
356
  isValidModelsResponse(data) {
@@ -554,8 +360,8 @@ export class OpenRouterProvider extends BaseProvider {
554
360
  Array.isArray(data.data));
555
361
  }
556
362
  /**
557
- * Fetch and cache model capabilities from OpenRouter API
558
- * Call this to enable accurate tool support detection
363
+ * Fetch and cache model capabilities from the OpenRouter `/models` endpoint.
364
+ * Call this to enable accurate per-model tool support detection.
559
365
  */
560
366
  async cacheModelCapabilities() {
561
367
  const functionTag = "OpenRouterProvider.cacheModelCapabilities";
@@ -563,15 +369,13 @@ export class OpenRouterProvider extends BaseProvider {
563
369
  return; // Already cached
564
370
  }
565
371
  try {
566
- const config = this.config;
567
- const modelsUrl = "https://openrouter.ai/api/v1/models";
568
372
  const controller = new AbortController();
569
373
  const timeoutId = setTimeout(() => controller.abort(), MODELS_DISCOVERY_TIMEOUT_MS);
570
374
  const proxyFetch = createProxyFetch();
571
- const response = await proxyFetch(modelsUrl, {
375
+ const response = await proxyFetch(this.getModelsUrl(), {
572
376
  method: "GET",
573
377
  headers: {
574
- Authorization: `Bearer ${config.apiKey}`,
378
+ ...this.getAuthHeaders(),
575
379
  "Content-Type": "application/json",
576
380
  },
577
381
  signal: controller.signal,