@framers/agentos 0.10.30 → 0.10.32

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.
@@ -11,6 +11,7 @@ import { OpenRouterProviderError } from '../errors/OpenRouterProviderError.js';
11
11
  import { ApiKeyPool } from '../../../providers/ApiKeyPool.js';
12
12
  import { createGMIErrorFromError, GMIErrorCode } from '../../../utils/errors.js'; // Corrected import path
13
13
  import { clampMaxOutputTokens } from '../model-output-limits.js';
14
+ import { CONTEXT_WINDOW_EXCEEDED_CODE } from '../errors/errorCodes.js';
14
15
  import { stripGeminiOnlyParams } from '../openrouter-only-params.js';
15
16
  import { baseUrlCredentials, redactUrlSecrets } from '../url-secrets.js';
16
17
  /**
@@ -161,6 +162,68 @@ function parseErrorBody(text) {
161
162
  return text;
162
163
  }
163
164
  }
165
+ /**
166
+ * Whether an OpenRouter error is a request larger than the model's context
167
+ * window. Once a provider took the request, OpenRouter types it
168
+ * `context_length_exceeded`. Its own check before routing answers HTTP 400
169
+ * with no type, only the message: "This endpoint's maximum context length is
170
+ * 16384 tokens. However, you requested about 33760 tokens ..." (observed
171
+ * 2026-10-05, for streamed and plain requests alike).
172
+ *
173
+ * @param errorType - The typed code, when the error carries one.
174
+ * @param code - The HTTP status, or the numeric code inside a 200 body or event.
175
+ * @param message - The error's message as OpenRouter sent it.
176
+ */
177
+ function isContextWindowRejection(errorType, code, message) {
178
+ if (errorType === 'context_length_exceeded')
179
+ return true;
180
+ return code === 400 && /maximum context length is \d+ tokens/i.test(message);
181
+ }
182
+ /**
183
+ * A string error code that names a class (`server_error`), kept as the error's
184
+ * code. A number in a string (`'502'`) is not one: it is neither a status the
185
+ * registry should read nor a class the retry classifier knows.
186
+ */
187
+ function namedErrorCode(code) {
188
+ return typeof code === 'string' && /^[a-z][a-z0-9_]*$/i.test(code) ? code : undefined;
189
+ }
190
+ /**
191
+ * Classify an OpenRouter error envelope as a content decline, or not.
192
+ *
193
+ * - `metadata.error_type` `refusal`: the model refused (code `content_filter`).
194
+ * - `metadata.error_type` `content_policy_violation`: a filter flagged the
195
+ * input or output.
196
+ * - The documented moderation metadata (`reasons` + `flagged_input`) with no
197
+ * `error_type`.
198
+ * - With `inBody`, a numeric code 403 and no `error_type`: the error reference
199
+ * gives every in-body policy decline the code 403 once the HTTP line was
200
+ * already committed. Never applied to an HTTP 403 response, which without a
201
+ * decline type is a guardrail or permission block (`permission_denied`).
202
+ *
203
+ * @param error - The `error` object from an OpenRouter body, choice or event.
204
+ * @param opts.inBody - True when the error sits inside an HTTP 200 body.
205
+ * @returns The decline, or `undefined` when the error is not one.
206
+ */
207
+ export function classifyOpenRouterDecline(error, opts = {}) {
208
+ if (!error || typeof error !== 'object')
209
+ return undefined;
210
+ const env = error;
211
+ const meta = env.metadata && typeof env.metadata === 'object' ? env.metadata : undefined;
212
+ const errorType = typeof meta?.error_type === 'string' ? meta.error_type : undefined;
213
+ if (errorType === 'refusal')
214
+ return { code: 'content_filter', nativeType: 'refusal' };
215
+ if (errorType === 'content_policy_violation') {
216
+ return { code: 'content_policy_violation', nativeType: 'content_policy_violation' };
217
+ }
218
+ if (errorType !== undefined)
219
+ return undefined;
220
+ if (Array.isArray(meta?.reasons) && typeof meta?.flagged_input === 'string') {
221
+ return { code: 'content_policy_violation', nativeType: 'moderation' };
222
+ }
223
+ if (opts.inBody && env.code === 403)
224
+ return { code: 'content_policy_violation', nativeType: 'in_body_403' };
225
+ return undefined;
226
+ }
164
227
  export class OpenRouterProvider {
165
228
  constructor() {
166
229
  this.providerId = 'openrouter';
@@ -451,6 +514,7 @@ export class OpenRouterProvider {
451
514
  throw error;
452
515
  apiResponseData = await this.makeApiRequest('/chat/completions', 'POST', options.requestTimeout ?? this.config.requestTimeout, payload);
453
516
  }
517
+ this.throwOnInBodyError(apiResponseData, modelId);
454
518
  return this.mapApiToCompletionResponse(apiResponseData, modelId);
455
519
  }
456
520
  /**
@@ -578,40 +642,116 @@ export class OpenRouterProvider {
578
642
  }
579
643
  const accumulatedToolCalls = new Map();
580
644
  const abortSignal = options.abortSignal;
645
+ // `usage` is what a refused turn billed before the abort; a consumer
646
+ // metering final chunks would otherwise lose it.
647
+ const abortChunk = (message, usage) => ({
648
+ id: `openrouter-abort-${Date.now()}`,
649
+ object: 'chat.completion.chunk',
650
+ created: Math.floor(Date.now() / 1000),
651
+ modelId,
652
+ choices: [],
653
+ error: { message, type: 'abort' },
654
+ isFinal: true,
655
+ ...(usage ? { usage } : {}),
656
+ });
581
657
  if (abortSignal?.aborted) {
582
- yield { id: `openrouter-abort-${Date.now()}`, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), modelId, choices: [], error: { message: 'Stream aborted prior to first chunk', type: 'abort' }, isFinal: true };
658
+ // The response is already open, and parseSseStream, which closes it,
659
+ // never runs on this path.
660
+ stream.destroy?.();
661
+ yield abortChunk('Stream aborted prior to first chunk');
583
662
  return;
584
663
  }
585
664
  const abortHandler = () => { };
586
665
  abortSignal?.addEventListener('abort', abortHandler, { once: true });
587
- for await (const rawChunk of this.parseSseStream(stream)) {
588
- if (abortSignal?.aborted) {
589
- yield { id: `openrouter-abort-${Date.now()}`, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), modelId, choices: [], error: { message: 'Stream aborted by caller', type: 'abort' }, isFinal: true };
590
- break;
591
- }
592
- if (rawChunk.startsWith('data: ') && rawChunk.includes('[DONE]')) {
593
- const doneData = rawChunk.substring('data: '.length).trim();
594
- if (doneData === '[DONE]')
595
- break;
596
- }
597
- if (rawChunk === 'data: [DONE]') {
598
- break;
599
- }
600
- if (rawChunk.startsWith('data: ')) {
601
- const jsonData = rawChunk.substring('data: '.length);
602
- try {
603
- const apiChunk = JSON.parse(jsonData);
666
+ // Text yielded so far (for the decline's partialText); the
667
+ // decline held back when a content_filter finish arrives, while the
668
+ // trailing usage chunk is read; a read error seen during that wait.
669
+ let yieldedText = '';
670
+ let held = null;
671
+ let readError;
672
+ const isDecline = (e) => e instanceof OpenRouterProviderError && (e.code === 'content_filter' || e.code === 'content_policy_violation');
673
+ try {
674
+ try {
675
+ for await (const rawChunk of this.parseSseStream(stream)) {
676
+ if (abortSignal?.aborted) {
677
+ // The line that shows the abort is usually the usage line a held
678
+ // decline was waiting for: read what it reports before leaving.
679
+ if (held)
680
+ held.usage = this.usageOfSseLine(rawChunk) ?? held.usage;
681
+ yield abortChunk('Stream aborted by caller', held?.usage);
682
+ return;
683
+ }
684
+ if (!rawChunk.startsWith('data: '))
685
+ continue;
686
+ const jsonData = rawChunk.substring('data: '.length);
687
+ if (jsonData.trim() === '[DONE]')
688
+ break;
689
+ // The parse has its own catch, so a decline thrown while HANDLING
690
+ // a chunk is never swallowed as a parse failure. A line that is not
691
+ // JSON, or is JSON but not an object, is skipped as before.
692
+ let apiChunk;
693
+ try {
694
+ apiChunk = JSON.parse(jsonData);
695
+ }
696
+ catch (error) {
697
+ console.warn('OpenRouterProvider: Failed to parse stream chunk JSON, skipping chunk. Data:', jsonData, 'Error:', error);
698
+ continue;
699
+ }
700
+ if (!apiChunk || typeof apiChunk !== 'object') {
701
+ console.warn('OpenRouterProvider: Stream chunk is not a JSON object, skipping chunk. Data:', jsonData);
702
+ continue;
703
+ }
604
704
  // OpenRouter reports upstream failures MID-STREAM as an SSE data
605
- // event carrying an `error` object and no choices. Previously this
606
- // fell into the empty-choices branch and surfaced as a generic
607
- // "Stream chunk contained no choices" — the real upstream reason
608
- // (provider outage, moderation, context overflow) was discarded,
609
- // which made every mid-stream failure look identical to callers'
610
- // retry/fallback routing. Surface the actual message + code and
611
- // terminate the stream.
705
+ // event carrying an `error` object. A content decline throws typed
706
+ // (the walkers move on before any output; after output the stream
707
+ // ends with an error part). Any other error still surfaces as an
708
+ // upstream_error chunk and ends the stream.
612
709
  if (apiChunk.error && typeof apiChunk.error === 'object') {
710
+ // The stream is an HTTP 200, so the in-body 403 row applies here too.
711
+ const decline = classifyOpenRouterDecline(apiChunk.error, { inBody: true });
712
+ if (decline) {
713
+ throw this.declineError(modelId, decline, {
714
+ httpStatus: typeof apiChunk.error.code === 'number' ? apiChunk.error.code : undefined,
715
+ error: apiChunk.error,
716
+ partialText: yieldedText,
717
+ // The event's own usage when it reports one, else what the
718
+ // held finish or the usage chunk reported.
719
+ usage: mapOpenRouterUsage(apiChunk.usage) ?? held?.usage,
720
+ });
721
+ }
613
722
  const errMessage = apiChunk.error.message || 'OpenRouter mid-stream error';
614
723
  const errCode = apiChunk.error.code;
724
+ // The typed code: metadata.error_type, else the envelope's own
725
+ // `type`, in the order the HTTP error path reads them.
726
+ const envelopeType = apiChunk.error.type;
727
+ const errType = typeof apiChunk.error.metadata?.error_type === 'string'
728
+ ? apiChunk.error.metadata.error_type
729
+ : typeof envelopeType === 'string'
730
+ ? envelopeType
731
+ : undefined;
732
+ // A 401 or 403 inside a 200 stream describes an upstream attempt,
733
+ // not this key: without the `[NNN]` prefix the health registry
734
+ // counts a transient failure, not its auth policy (as inBodyError
735
+ // does for a 200 body).
736
+ const decorated = errCode === 401 || errCode === 403
737
+ ? `OpenRouter in-body error ${errCode}: ${errMessage}`
738
+ : errCode !== undefined
739
+ ? `[${errCode}] ${errMessage}`
740
+ : errMessage;
741
+ // A context-window rejection, or a string code such as
742
+ // `server_error`, rides the chunk's `code` for the walkers.
743
+ const chunkCode = isContextWindowRejection(errType, typeof errCode === 'number' ? errCode : undefined, errMessage)
744
+ ? CONTEXT_WINDOW_EXCEEDED_CODE
745
+ : namedErrorCode(errCode);
746
+ if (held) {
747
+ // The answer already ended on a content_filter finish. A later
748
+ // upstream failure is the end of the read: it neither replaces
749
+ // the held decline nor loses the usage read so far.
750
+ if (apiChunk.usage)
751
+ held.usage = mapOpenRouterUsage(apiChunk.usage);
752
+ readError = new Error(decorated);
753
+ break;
754
+ }
615
755
  yield {
616
756
  id: apiChunk.id ?? `openrouter-error-${Date.now()}`,
617
757
  object: 'chat.completion.chunk',
@@ -620,25 +760,79 @@ export class OpenRouterProvider {
620
760
  choices: [],
621
761
  isFinal: true,
622
762
  error: {
623
- message: errCode !== undefined ? `[${errCode}] ${errMessage}` : errMessage,
763
+ message: decorated,
624
764
  type: 'upstream_error',
765
+ ...(chunkCode !== undefined ? { code: chunkCode } : {}),
625
766
  },
626
767
  };
627
768
  break;
628
769
  }
629
- yield this.mapApiToStreamChunkResponse(apiChunk, modelId, accumulatedToolCalls);
770
+ if (held) {
771
+ // After a content_filter finish only the trailing usage-only
772
+ // chunk is wanted; nothing more is yielded.
773
+ if (apiChunk.usage)
774
+ held.usage = mapOpenRouterUsage(apiChunk.usage);
775
+ continue;
776
+ }
777
+ const choice = apiChunk.choices?.[0];
778
+ if (choice && choice.finish_reason === 'content_filter') {
779
+ held = {
780
+ decline: { code: 'content_filter', nativeType: 'content_filter_finish' },
781
+ refusal: choice.delta?.refusal ?? choice.message?.refusal ?? null,
782
+ usage: mapOpenRouterUsage(apiChunk.usage),
783
+ };
784
+ continue;
785
+ }
786
+ // A chunk of an unexpected shape (no choices array, a malformed
787
+ // tool call) is logged and skipped, as it was when the parse and
788
+ // the mapping shared one catch.
789
+ let mapped;
790
+ try {
791
+ mapped = this.mapApiToStreamChunkResponse(apiChunk, modelId, accumulatedToolCalls);
792
+ }
793
+ catch (error) {
794
+ console.warn('OpenRouterProvider: Failed to map stream chunk, skipping chunk. Data:', jsonData, 'Error:', this.describeError(error));
795
+ continue;
796
+ }
797
+ if (mapped.responseTextDelta)
798
+ yieldedText += mapped.responseTextDelta;
799
+ yield mapped;
630
800
  // Don't break on finish_reason: with stream_options.include_usage,
631
801
  // OpenRouter (like OpenAI) emits a trailing usage-only chunk AFTER
632
- // the finish_reason chunk and BEFORE [DONE]. Breaking here would
633
- // skip the usage chunk and zero out the caller's token totals. The
634
- // [DONE] marker check above is the right termination signal.
802
+ // the finish_reason chunk and BEFORE [DONE]. The [DONE] marker
803
+ // check above is the right termination signal.
635
804
  }
636
- catch (error) {
637
- console.warn('OpenRouterProvider: Failed to parse stream chunk JSON, skipping chunk. Data:', jsonData, 'Error:', error);
805
+ }
806
+ catch (error) {
807
+ if (isDecline(error))
808
+ throw error;
809
+ // A read error after a content_filter finish does not lose the
810
+ // decline: it is thrown below with the usage held so far.
811
+ if (held)
812
+ readError = error;
813
+ else
814
+ throw error;
815
+ }
816
+ if (held) {
817
+ // An abort during the wait wins over the decline. The loop only
818
+ // checks the signal when a line arrives, so a read that ended
819
+ // without one is checked here.
820
+ if (abortSignal?.aborted) {
821
+ yield abortChunk('Stream aborted by caller', held.usage);
822
+ return;
638
823
  }
824
+ throw this.declineError(modelId, held.decline, {
825
+ httpStatus: 200,
826
+ refusal: held.refusal,
827
+ partialText: yieldedText,
828
+ usage: held.usage,
829
+ readError,
830
+ });
639
831
  }
640
832
  }
641
- abortSignal?.removeEventListener('abort', abortHandler);
833
+ finally {
834
+ abortSignal?.removeEventListener('abort', abortHandler);
835
+ }
642
836
  }
643
837
  async generateEmbeddings(modelId, texts, options) {
644
838
  this.ensureInitialized();
@@ -947,7 +1141,8 @@ export class OpenRouterProvider {
947
1141
  }
948
1142
  if (errorData?.error && typeof errorData.error === 'object') {
949
1143
  errorMessage = errorData.error.message || errorMessage;
950
- errorType = errorData.error.type || errorType;
1144
+ const metaType = errorData.error.metadata?.error_type;
1145
+ errorType = (typeof metaType === 'string' ? metaType : undefined) || errorData.error.type || errorType;
951
1146
  }
952
1147
  else if (typeof errorData === 'string' && errorData.trim()) {
953
1148
  errorMessage = errorData;
@@ -959,6 +1154,16 @@ export class OpenRouterProvider {
959
1154
  else if (error instanceof Error) {
960
1155
  errorMessage = error.message;
961
1156
  }
1157
+ // A content decline is a verdict on the request, not provider
1158
+ // health: throw it typed at once (no in-provider retry, no key
1159
+ // cooldown) so the walkers move on and the breaker stays closed.
1160
+ const decline = classifyOpenRouterDecline(errorData?.error);
1161
+ if (decline) {
1162
+ throw this.declineError(String(body?.model ?? ''), decline, {
1163
+ httpStatus: statusCode,
1164
+ error: errorData.error,
1165
+ });
1166
+ }
962
1167
  // A throttled (429) or credit-exhausted (402) key must not be
963
1168
  // reused by the next attempt/request — cool it down in the pool.
964
1169
  if ((statusCode === 402 || statusCode === 429) && this.keyPool?.hasKeys) {
@@ -968,7 +1173,7 @@ export class OpenRouterProvider {
968
1173
  // logic (e.g. isRetryableError, which greps for \b402\b) can route on it
969
1174
  // even when the OR API body provides a friendlier description.
970
1175
  const decoratedMessage = this.redactSecrets(statusCode ? `[${statusCode}] ${errorMessage}` : errorMessage);
971
- lastError = new OpenRouterProviderError(decoratedMessage, 'API_REQUEST_FAILED', statusCode, errorType, {
1176
+ lastError = new OpenRouterProviderError(decoratedMessage, isContextWindowRejection(errorType, statusCode, errorMessage) ? CONTEXT_WINDOW_EXCEEDED_CODE : 'API_REQUEST_FAILED', statusCode, errorType, {
972
1177
  requestEndpoint: endpoint,
973
1178
  requestBodyPreview: body ? JSON.stringify(body).substring(0, 200) + '...' : undefined,
974
1179
  responseData: this.redactResponseData(errorData),
@@ -996,6 +1201,110 @@ export class OpenRouterProvider {
996
1201
  // the compiler + any future refactor honest.
997
1202
  throw lastError ?? new OpenRouterProviderError('OpenRouter request failed.', 'API_REQUEST_FAILED');
998
1203
  }
1204
+ /**
1205
+ * The error a declined request raises. Code `content_filter` or
1206
+ * `content_policy_violation` is what `isContentPolicyRefusal` and the
1207
+ * health registry's exemption read, so the walkers move on and no breaker
1208
+ * opens. The message is fixed: no HTTP status digits and no upstream text,
1209
+ * which retry classifiers grep for. The response status, the upstream
1210
+ * message (redacted, 300 chars), the provider, the refusal text (300), the
1211
+ * partial text (2,000) and the billed usage ride `details`.
1212
+ */
1213
+ declineError(modelId, decline, details) {
1214
+ const meta = details.error?.metadata;
1215
+ const bounded = (value, max) => typeof value === 'string' && value.length > 0 ? this.redactSecrets(value).slice(0, max) : undefined;
1216
+ return new OpenRouterProviderError(`OpenRouter declined the request on ${modelId || 'the requested model'} (${decline.nativeType}).`, decline.code, undefined, decline.nativeType, {
1217
+ httpStatus: details.httpStatus,
1218
+ upstreamMessage: bounded(details.error?.message, 300),
1219
+ providerName: typeof meta?.provider_name === 'string' ? meta.provider_name : undefined,
1220
+ providerCode: typeof meta?.provider_code === 'string' ? meta.provider_code : undefined,
1221
+ refusal: bounded(details.refusal, 300),
1222
+ partialText: bounded(details.partialText, 2000),
1223
+ usage: details.usage,
1224
+ ...(details.readError !== undefined ? { readError: this.describeError(details.readError) } : {}),
1225
+ });
1226
+ }
1227
+ /**
1228
+ * A 200 body that reports a failure instead of an answer throws before
1229
+ * mapping, in this order: a body holding only `error` and no
1230
+ * choice; a choice ended by `finish_reason: 'error'` with its own error
1231
+ * object; a choice ended by `finish_reason: 'content_filter'` (the model
1232
+ * declined as output). A choice with `finish_reason: 'error'` and no error
1233
+ * object is not a documented shape and is mapped as before.
1234
+ */
1235
+ throwOnInBodyError(body, modelId) {
1236
+ const choices = Array.isArray(body.choices) ? body.choices : [];
1237
+ const first = choices[0];
1238
+ const usage = mapOpenRouterUsage(body.usage);
1239
+ if (body.error && typeof body.error === 'object' && choices.length === 0) {
1240
+ throw this.inBodyError(body.error, modelId, { responseId: body.id, usage });
1241
+ }
1242
+ if (first && first.finish_reason === 'error' && first.error && typeof first.error === 'object') {
1243
+ throw this.inBodyError(first.error, modelId, {
1244
+ responseId: body.id,
1245
+ usage,
1246
+ partialText: first.message?.content ?? null,
1247
+ });
1248
+ }
1249
+ if (first && first.finish_reason === 'content_filter') {
1250
+ throw this.declineError(modelId, { code: 'content_filter', nativeType: 'content_filter_finish' }, {
1251
+ httpStatus: 200,
1252
+ refusal: first.message?.refusal ?? null,
1253
+ partialText: first.message?.content ?? null,
1254
+ usage,
1255
+ });
1256
+ }
1257
+ }
1258
+ /**
1259
+ * An error reported inside an HTTP 200 body: a decline becomes
1260
+ * the decline error; anything else becomes the error an HTTP response with
1261
+ * that code produces today, so `isRetryableError` and the breaker treat it
1262
+ * by its code. The exception is a 401 or 403 that is not a decline: it is
1263
+ * still retryable, but it carries no status, so it counts as a transient
1264
+ * failure and not as a rejected key.
1265
+ */
1266
+ inBodyError(error, modelId, extra) {
1267
+ const code = typeof error.code === 'number' ? error.code : undefined;
1268
+ // A named string code (`server_error`) is kept as the error's code, as a
1269
+ // stream error event's is, so the retry classifier reads it.
1270
+ const stringCode = namedErrorCode(error.code);
1271
+ const decline = classifyOpenRouterDecline(error, { inBody: true });
1272
+ if (decline) {
1273
+ return this.declineError(modelId, decline, {
1274
+ httpStatus: code,
1275
+ error,
1276
+ partialText: extra.partialText,
1277
+ usage: extra.usage,
1278
+ });
1279
+ }
1280
+ // The body is untyped network input: a message that is not a string
1281
+ // must not reach the redaction's string calls.
1282
+ const message = this.redactSecrets(typeof error.message === 'string' && error.message ? error.message : 'OpenRouter reported an error in a 200 response');
1283
+ // The typed code: metadata.error_type, else the envelope's own `type`, in
1284
+ // the order the HTTP error path reads them.
1285
+ const envelopeType = error.type;
1286
+ const errorType = typeof error.metadata?.error_type === 'string'
1287
+ ? error.metadata.error_type
1288
+ : typeof envelopeType === 'string'
1289
+ ? envelopeType
1290
+ : undefined;
1291
+ // The 200 means the configured key was accepted, so a 401 or 403 inside
1292
+ // the body describes an upstream attempt (a BYOK key, a failover leg),
1293
+ // not this account. Its code stays in the message, where the retry
1294
+ // classifiers read it, and in details; the error carries no status and
1295
+ // no `[NNN]` prefix, so the breaker counts one transient failure instead
1296
+ // of opening its auth policy (one failure, 30 minutes) on one response.
1297
+ const inBodyAuth = code === 401 || code === 403;
1298
+ return new OpenRouterProviderError(code === undefined ? message : inBodyAuth ? `OpenRouter in-body error ${code}: ${message}` : `[${code}] ${message}`, isContextWindowRejection(errorType, code, message)
1299
+ ? CONTEXT_WINDOW_EXCEEDED_CODE
1300
+ : (stringCode ?? 'API_REQUEST_FAILED'), inBodyAuth ? undefined : code, errorType ?? 'UNKNOWN_API_ERROR', {
1301
+ responseId: extra.responseId,
1302
+ ...(inBodyAuth ? { httpStatus: code } : {}),
1303
+ responseData: this.redactResponseData(error),
1304
+ partialText: typeof extra.partialText === 'string' ? this.redactSecrets(extra.partialText).slice(0, 2000) : undefined,
1305
+ usage: extra.usage,
1306
+ });
1307
+ }
999
1308
  /**
1000
1309
  * Masks the configured API keys (the raw config value and each key in a
1001
1310
  * comma-separated pool) and any base-URL credentials out of text bound
@@ -1078,6 +1387,20 @@ export class OpenRouterProvider {
1078
1387
  }
1079
1388
  return { message: this.redactSecrets(String(error)) };
1080
1389
  }
1390
+ /** The usage a raw SSE line reports, when it is a data line that carries one. */
1391
+ usageOfSseLine(rawChunk) {
1392
+ if (!rawChunk.startsWith('data: '))
1393
+ return undefined;
1394
+ try {
1395
+ const parsed = JSON.parse(rawChunk.substring('data: '.length));
1396
+ return parsed && typeof parsed === 'object'
1397
+ ? mapOpenRouterUsage(parsed.usage)
1398
+ : undefined;
1399
+ }
1400
+ catch {
1401
+ return undefined;
1402
+ }
1403
+ }
1081
1404
  async *parseSseStream(stream) {
1082
1405
  let buffer = '';
1083
1406
  const readableStream = stream;