@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.
- package/dist/api/generateText.d.ts.map +1 -1
- package/dist/api/generateText.js +20 -4
- package/dist/api/generateText.js.map +1 -1
- package/dist/core/llm/providers/errors/errorCodes.d.ts +8 -0
- package/dist/core/llm/providers/errors/errorCodes.d.ts.map +1 -0
- package/dist/core/llm/providers/errors/errorCodes.js +8 -0
- package/dist/core/llm/providers/errors/errorCodes.js.map +1 -0
- package/dist/core/llm/providers/implementations/OpenRouterProvider.d.ts +80 -0
- package/dist/core/llm/providers/implementations/OpenRouterProvider.d.ts.map +1 -1
- package/dist/core/llm/providers/implementations/OpenRouterProvider.js +358 -35
- package/dist/core/llm/providers/implementations/OpenRouterProvider.js.map +1 -1
- package/dist/core/llm/routing/UncensoredModelCatalog.d.ts.map +1 -1
- package/dist/core/llm/routing/UncensoredModelCatalog.js +2 -1
- package/dist/core/llm/routing/UncensoredModelCatalog.js.map +1 -1
- package/dist/core/safety/LLMProviderHealthRegistry.d.ts +4 -3
- package/dist/core/safety/LLMProviderHealthRegistry.d.ts.map +1 -1
- package/dist/core/safety/LLMProviderHealthRegistry.js +17 -3
- package/dist/core/safety/LLMProviderHealthRegistry.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
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:
|
|
763
|
+
message: decorated,
|
|
624
764
|
type: 'upstream_error',
|
|
765
|
+
...(chunkCode !== undefined ? { code: chunkCode } : {}),
|
|
625
766
|
},
|
|
626
767
|
};
|
|
627
768
|
break;
|
|
628
769
|
}
|
|
629
|
-
|
|
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].
|
|
633
|
-
//
|
|
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
|
-
|
|
637
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|