@linxiraos/pi-ai 1.1.6 → 1.1.7

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/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.1.7] - 2026-09-01
6
+
7
+ - 同步上游 OMP v18.0.11(`b8ce33a58911c26bed1d84f0db9a5e2e727c49a2`)。
8
+
5
9
  ## [1.1.6] - 2026-08-30
6
10
 
7
11
  - 同步上游 OMP v18.0.9(`cc14e04f075d`)。
@@ -24,13 +24,15 @@ export declare function calculateRateLimitBackoffMs(reason: RateLimitReason): nu
24
24
  /**
25
25
  * HTTP status codes that, absent richer body classification, represent an
26
26
  * account-local usage cap rather than a bad credential or a transient blip.
27
- * HTTP 402 Payment Required is categorically an account-billing cap (xAI
27
+ * HTTP 402 Payment Required represents an account-billing cap (xAI
28
28
  * Grok Build "usage balance exhausted", DeepSeek "Insufficient Balance",
29
- * OpenRouter credit exhaustion) never a transient blip or bad credential.
30
- * Always combine with {@link isUsageLimitOutcome} when a message is available
31
- * a 429 carrying transient rate-limit wording is NOT a usage cap.
29
+ * OpenRouter credit exhaustion) when opaque, payment/deactivation/balance-worded,
30
+ * or QUOTA_EXHAUSTED/CONCURRENT_LIMIT, while informative non-quota 402s (e.g.
31
+ * endpoint subscription requirements) remain non-usage-limits. Always combine
32
+ * with {@link isUsageLimitOutcome} when a message is available.
32
33
  */
33
34
  export declare function isUsageLimitStatus(status: number | undefined): boolean;
35
+ export declare function is402BillingCapBody(message: string | undefined): boolean;
34
36
  /**
35
37
  * Returns true for failures that should burn one credential and rotate to a
36
38
  * sibling account. Decision tree:
@@ -43,13 +45,13 @@ export declare function isUsageLimitStatus(status: number | undefined): boolean;
43
45
  * empty JSON, HTTP framing only) → rotate conservatively: the server
44
46
  * gave us nothing else to go on.
45
47
  * 4. Body has content → defer to {@link parseRateLimitReason}. `QUOTA_EXHAUSTED`
46
- * rotates; for the categorical 402 billing cap a `CONCURRENT_LIMIT` body
47
- * also rotates (the cap is concurrent-worded but the status is still an
48
- * exhausted billing cap). `RATE_LIMIT_EXCEEDED` (`Too many requests`,
49
- * per-minute caps), `MODEL_CAPACITY_EXHAUSTED` (`Service overloaded`),
50
- * `SERVER_ERROR`, and `UNKNOWN` (`Please retry in 5s`) stay in the
51
- * provider's own backoff layer so transient 429s don't burn sibling
52
- * credentials.
48
+ * rotates; for a 402 status a `CONCURRENT_LIMIT` body also rotates (the cap
49
+ * is concurrent-worded but the status is an exhausted billing cap).
50
+ * `RATE_LIMIT_EXCEEDED` (`Too many requests`, per-minute caps),
51
+ * `MODEL_CAPACITY_EXHAUSTED` (`Service overloaded`), `SERVER_ERROR`, and
52
+ * `UNKNOWN` (e.g. "A subscription is required for this endpoint" or
53
+ * "Please retry in 5s") stay in the provider's own backoff / failure
54
+ * layer so transient or non-quota responses don't burn sibling credentials.
53
55
  */
54
56
  export declare function isUsageLimitOutcome(status: number | undefined, message: string | undefined): boolean;
55
57
  /**
@@ -78,6 +80,6 @@ export declare function isAccountScopedCapText(message: string): boolean;
78
80
  /**
79
81
  * A concurrency cap on a non-billing status is shed-and-backoff, not
80
82
  * credential-rotatable. This mirrors the exclusion in {@link isUsageLimitOutcome}
81
- * for the 403 auth-retry entry points. A 402 remains a categorical billing cap.
83
+ * for the 403 auth-retry entry points. A 402 remains an account-billing cap.
82
84
  */
83
85
  export declare function isConcurrencyCapExclusion(status: number | undefined, message: string | undefined): boolean;
@@ -11,6 +11,8 @@ type ChatCompletionsValidation = {
11
11
  provider: string;
12
12
  baseUrl: string;
13
13
  model: string;
14
+ /** Treat an authenticated 401 (`invalid_model`) as a valid key. */
15
+ tolerateModelDenied?: boolean;
14
16
  };
15
17
  type AnthropicMessagesValidation = {
16
18
  kind: "anthropic-messages";
@@ -6,6 +6,7 @@ type OpenAICompatibleValidationOptions = {
6
6
  model: string;
7
7
  signal?: AbortSignal;
8
8
  fetch?: FetchImpl;
9
+ tolerateModelDenied?: boolean;
9
10
  };
10
11
  type AnthropicCompatibleValidationOptions = {
11
12
  provider: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@linxiraos/pi-ai",
4
- "version": "1.1.6",
4
+ "version": "1.1.7",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://linxira-os.github.io/zeta/",
7
7
  "author": "Can Boluk",
@@ -37,10 +37,10 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@linxiraos/pi-omptype": "1.1.6",
41
- "@linxiraos/pi-catalog": "1.1.6",
42
- "@linxiraos/pi-utils": "1.1.6",
43
- "@linxiraos/pi-wire": "1.1.6"
40
+ "@linxiraos/pi-omptype": "1.1.7",
41
+ "@linxiraos/pi-catalog": "1.1.7",
42
+ "@linxiraos/pi-utils": "1.1.7",
43
+ "@linxiraos/pi-wire": "1.1.7"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/bun": "^1.3.14"
@@ -8,6 +8,7 @@ import {
8
8
  STREAM_ENVELOPE_ERROR_PREFIX,
9
9
  } from "./classes";
10
10
  import {
11
+ is402BillingCapBody,
11
12
  isAccountScopedCapText,
12
13
  isDashScopeTokenLimitText,
13
14
  isOpaqueStatusBody,
@@ -153,7 +154,7 @@ function matchesPayloadRejectionText(text: string): boolean {
153
154
 
154
155
  const TIMEOUT_PATTERN = /\b(?:operation\s+)?timed?\s*out\b|\btimeout\b|\bstream stall\b/i;
155
156
  const TRANSIENT_ENVELOPE_PATTERN = /anthropic stream envelope error:/i;
156
- const TRANSIENT_ENVELOPE_BEFORE_START_PATTERN = /before message_start/i;
157
+ const TRANSIENT_ENVELOPE_TRUNCATION_PATTERN = /before message_(?:start|stop)/i;
157
158
  export const STREAM_READ_ERROR_PATTERN = /stream[_ -]?read[_ -]?error/i;
158
159
  export const TRANSIENT_TRANSPORT_PATTERN =
159
160
  /\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|\b(?:429|500|502|503|504)\b|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|nghttp2_(?:internal_error|refused_stream)|stream closed with error code nghttp2_(?:internal_error|refused_stream)|malformed.?function.?call/i;
@@ -404,7 +405,7 @@ function isTransientErrorText(text: string): boolean {
404
405
  return (
405
406
  isUnexpectedSocketCloseMessage(text) ||
406
407
  isStreamReadErrorText(text) ||
407
- (TRANSIENT_ENVELOPE_PATTERN.test(text) && TRANSIENT_ENVELOPE_BEFORE_START_PATTERN.test(text)) ||
408
+ (TRANSIENT_ENVELOPE_PATTERN.test(text) && TRANSIENT_ENVELOPE_TRUNCATION_PATTERN.test(text)) ||
408
409
  TRANSIENT_TRANSPORT_PATTERN.test(text)
409
410
  );
410
411
  }
@@ -471,26 +472,24 @@ function classifyText(
471
472
 
472
473
  const isLimitStatus = isUsageLimitStatus(statusClean);
473
474
  const reason = parseRateLimitReason(cleanMessage);
475
+ const is402BillingCap = statusClean === 402 && is402BillingCapBody(cleanMessage);
474
476
  // Concurrency caps (e.g. Vertex "Online prediction concurrent requests
475
477
  // quota exceeded") are shed-and-backoff, not credential-rotatable —
476
478
  // exclude them even when the quota-worded phrasing matches the generic
477
479
  // usage-limit text matcher, whose `quota.?exceeded` arm would otherwise
478
480
  // set Flag.UsageLimit and burn a healthy sibling credential. HTTP 402 is
479
- // excluded from this gate: it is categorically an account-billing cap, so
480
- // a 402 whose body merely mentions concurrency still classifies as a
481
- // usage limit, mirroring isUsageLimitOutcome.
482
- const isBillingCapStatus = statusClean === 402;
483
- const concurrencyExcluded = reason === "CONCURRENT_LIMIT" && !isBillingCapStatus;
481
+ // excluded from this gate: a 402 whose body merely mentions concurrency
482
+ // still classifies as a usage limit, mirroring isUsageLimitOutcome.
483
+ const concurrencyExcluded = reason === "CONCURRENT_LIMIT" && statusClean !== 402;
484
484
  if (
485
485
  !concurrencyExcluded &&
486
- (matchesUsageLimitText(cleanMessage) ||
486
+ (is402BillingCap ||
487
+ matchesUsageLimitText(cleanMessage) ||
487
488
  ((statusClean === 403 || statusClean === undefined) && isAccountScopedCapText(cleanMessage)) ||
488
- (isLimitStatus &&
489
- (isOpaque || reason === "QUOTA_EXHAUSTED" || (isBillingCapStatus && reason === "CONCURRENT_LIMIT"))))
489
+ (isLimitStatus && (isOpaque || reason === "QUOTA_EXHAUSTED")))
490
490
  ) {
491
491
  kinds |= Flag.UsageLimit;
492
492
  }
493
-
494
493
  if (isTimeoutText(errorMessage)) kinds |= Flag.Transient | Flag.Timeout;
495
494
  else if (isTransientErrorText(errorMessage)) kinds |= Flag.Transient;
496
495
  // A concurrency cap (e.g. Vertex "Online prediction concurrent requests
@@ -519,6 +518,9 @@ function classifyText(
519
518
  ) {
520
519
  kinds |= Flag.PayloadRejected;
521
520
  }
521
+ if (statusEvidence === 402 && (errorMessage === undefined || isOpaqueStatusBody(errorMessage))) {
522
+ kinds |= Flag.UsageLimit;
523
+ }
522
524
  if (kinds !== 0) return create(kinds);
523
525
  const fallbackStatus = errorStatus ?? (errorMessage ? status({ message: errorMessage }) : undefined);
524
526
  if (fallbackStatus === 401 || fallbackStatus === 403) return create(Flag.AuthFailed);
@@ -567,7 +569,9 @@ export function classify(error: unknown, api?: Api): number {
567
569
  const { status: codeStatus, code } = link;
568
570
  if (
569
571
  code === "usage_limit_reached" ||
570
- (code === "insufficient_quota" && !isDashScopeTokenLimitText(link.message))
572
+ (code === "insufficient_quota" && !isDashScopeTokenLimitText(link.message)) ||
573
+ (codeStatus === 402 &&
574
+ (code === "payment_required" || code === "deactivated_workspace" || is402BillingCapBody(link.message)))
571
575
  ) {
572
576
  linkKinds |= Flag.UsageLimit;
573
577
  }
@@ -278,15 +278,25 @@ const USAGE_LIMIT_PATTERN =
278
278
  /**
279
279
  * HTTP status codes that, absent richer body classification, represent an
280
280
  * account-local usage cap rather than a bad credential or a transient blip.
281
- * HTTP 402 Payment Required is categorically an account-billing cap (xAI
281
+ * HTTP 402 Payment Required represents an account-billing cap (xAI
282
282
  * Grok Build "usage balance exhausted", DeepSeek "Insufficient Balance",
283
- * OpenRouter credit exhaustion) never a transient blip or bad credential.
284
- * Always combine with {@link isUsageLimitOutcome} when a message is available
285
- * a 429 carrying transient rate-limit wording is NOT a usage cap.
283
+ * OpenRouter credit exhaustion) when opaque, payment/deactivation/balance-worded,
284
+ * or QUOTA_EXHAUSTED/CONCURRENT_LIMIT, while informative non-quota 402s (e.g.
285
+ * endpoint subscription requirements) remain non-usage-limits. Always combine
286
+ * with {@link isUsageLimitOutcome} when a message is available.
286
287
  */
287
288
  export function isUsageLimitStatus(status: number | undefined): boolean {
288
289
  return status === 429 || status === 402;
289
290
  }
291
+ const STATUS_402_QUOTA_PATTERN =
292
+ /\b(?:payment(?:\s+is)?[-_.\s]*required|deactivated_workspace|insufficient.?balance)\b/i;
293
+
294
+ export function is402BillingCapBody(message: string | undefined): boolean {
295
+ if (message === undefined || isOpaqueStatusBody(message)) return true;
296
+ if (STATUS_402_QUOTA_PATTERN.test(message)) return true;
297
+ const reason = parseRateLimitReason(message);
298
+ return isQuotaExhaustedReason(reason) || reason === "CONCURRENT_LIMIT";
299
+ }
290
300
 
291
301
  /**
292
302
  * Returns true for failures that should burn one credential and rotate to a
@@ -300,23 +310,17 @@ export function isUsageLimitStatus(status: number | undefined): boolean {
300
310
  * empty JSON, HTTP framing only) → rotate conservatively: the server
301
311
  * gave us nothing else to go on.
302
312
  * 4. Body has content → defer to {@link parseRateLimitReason}. `QUOTA_EXHAUSTED`
303
- * rotates; for the categorical 402 billing cap a `CONCURRENT_LIMIT` body
304
- * also rotates (the cap is concurrent-worded but the status is still an
305
- * exhausted billing cap). `RATE_LIMIT_EXCEEDED` (`Too many requests`,
306
- * per-minute caps), `MODEL_CAPACITY_EXHAUSTED` (`Service overloaded`),
307
- * `SERVER_ERROR`, and `UNKNOWN` (`Please retry in 5s`) stay in the
308
- * provider's own backoff layer so transient 429s don't burn sibling
309
- * credentials.
313
+ * rotates; for a 402 status a `CONCURRENT_LIMIT` body also rotates (the cap
314
+ * is concurrent-worded but the status is an exhausted billing cap).
315
+ * `RATE_LIMIT_EXCEEDED` (`Too many requests`, per-minute caps),
316
+ * `MODEL_CAPACITY_EXHAUSTED` (`Service overloaded`), `SERVER_ERROR`, and
317
+ * `UNKNOWN` (e.g. "A subscription is required for this endpoint" or
318
+ * "Please retry in 5s") stay in the provider's own backoff / failure
319
+ * layer so transient or non-quota responses don't burn sibling credentials.
310
320
  */
311
321
  export function isUsageLimitOutcome(status: number | undefined, message: string | undefined): boolean {
312
322
  const structuredReason = message ? parseGoogleRpcRateLimitReason(message) : undefined;
313
323
  if (structuredReason !== undefined) return isQuotaExhaustedReason(structuredReason);
314
- // Concurrency caps are shed-and-backoff, not credential-rotatable — but only
315
- // for quota-worded 429 / other statuses. HTTP 402 is categorically an
316
- // account-billing cap, so a 402 whose body happens to mention concurrency is
317
- // still an exhausted billing cap and must rotate; gate the exclusion on the
318
- // status not being that categorical billing cap.
319
- const isBillingCapStatus = status === 402;
320
324
  if (isConcurrencyCapExclusion(status, message)) return false;
321
325
  if (message && matchesUsageLimitText(message)) return true;
322
326
  // A 403 is normally an auth failure, but several providers deliver an
@@ -326,14 +330,12 @@ export function isUsageLimitOutcome(status: number | undefined, message: string
326
330
  // accept an undefined status too — but only when the body names a cap that
327
331
  // resets, never on a bare 403, which stays an auth failure.
328
332
  if ((status === 403 || status === undefined) && message && isAccountScopedCapText(message)) return true;
333
+ if (status === 402 && is402BillingCapBody(message)) return true;
329
334
  if (!isUsageLimitStatus(status)) return false;
330
335
  if (!message || isOpaqueStatusBody(message)) return true;
331
336
  const reason = parseRateLimitReason(message);
332
- // For the categorical 402 billing cap a concurrency-worded body is still an
333
- // exhausted cap (rotate); for 429 / other only QUOTA_EXHAUSTED rotates.
334
- return isQuotaExhaustedReason(reason) || (isBillingCapStatus && reason === "CONCURRENT_LIMIT");
337
+ return isQuotaExhaustedReason(reason);
335
338
  }
336
-
337
339
  /**
338
340
  * A usage-limit status body is opaque when it carries no signal beyond the
339
341
  * status itself — empty, whitespace-only, the status digits with HTTP/JSON
@@ -344,7 +346,8 @@ export function isUsageLimitOutcome(status: number | undefined, message: string
344
346
  export function isOpaqueStatusBody(message: string): boolean {
345
347
  const cleaned = message
346
348
  .replace(/\b(?:429|402)\b/g, "")
347
- .replace(/\b(?:http|https|status|error|code|response|message)\b/gi, "");
349
+ .replace(/\b(?:http|https|status|error|code|response|message)\b/gi, "")
350
+ .replace(/\(?\bno body\b\)?/gi, "");
348
351
  // A body is informative when the text classifier can act on it. Any Latin
349
352
  // word or Simplified Chinese phrasing the classifier recognizes (quota
350
353
  // exhaustion or a throttle) defers to parseRateLimitReason; a body that
@@ -395,7 +398,7 @@ export function isAccountScopedCapText(message: string): boolean {
395
398
  /**
396
399
  * A concurrency cap on a non-billing status is shed-and-backoff, not
397
400
  * credential-rotatable. This mirrors the exclusion in {@link isUsageLimitOutcome}
398
- * for the 403 auth-retry entry points. A 402 remains a categorical billing cap.
401
+ * for the 403 auth-retry entry points. A 402 remains an account-billing cap.
399
402
  */
400
403
  export function isConcurrencyCapExclusion(status: number | undefined, message: string | undefined): boolean {
401
404
  return message !== undefined && parseRateLimitReason(message) === "CONCURRENT_LIMIT" && status !== 402;
@@ -63,6 +63,7 @@ import type {
63
63
  ChatCompletionContentPart,
64
64
  ChatCompletionContentPartImage,
65
65
  ChatCompletionContentPartText,
66
+ ChatCompletionMessageFunctionToolCall,
66
67
  ChatCompletionMessageParam,
67
68
  ChatCompletionTool,
68
69
  ChatCompletionToolMessageParam,
@@ -132,6 +133,44 @@ type OpenAICompletionsDeltaWithReasoningDetails = ChatCompletionChunk.Choice["de
132
133
  reasoning_details?: unknown;
133
134
  };
134
135
 
136
+ type GeminiThoughtSignatureNamespace = "google" | "vertex";
137
+
138
+ type GeminiThoughtSignatureExtraContent = Partial<
139
+ Record<GeminiThoughtSignatureNamespace, { thought_signature: string }>
140
+ >;
141
+
142
+ type OpenAICompletionsFunctionToolCall = ChatCompletionMessageFunctionToolCall & {
143
+ extra_content?: GeminiThoughtSignatureExtraContent;
144
+ };
145
+
146
+ const GEMINI_THOUGHT_SIGNATURE_NAMESPACES: readonly GeminiThoughtSignatureNamespace[] = ["google", "vertex"];
147
+
148
+ function getGeminiThoughtSignatureExtraContent(value: unknown): GeminiThoughtSignatureExtraContent | undefined {
149
+ if (typeof value !== "object" || value === null) return undefined;
150
+ for (const namespace of GEMINI_THOUGHT_SIGNATURE_NAMESPACES) {
151
+ const providerContent = Reflect.get(value, namespace);
152
+ if (typeof providerContent !== "object" || providerContent === null) continue;
153
+ const thoughtSignature = Reflect.get(providerContent, "thought_signature");
154
+ if (typeof thoughtSignature !== "string" || thoughtSignature.length === 0) continue;
155
+ return namespace === "google"
156
+ ? { google: { thought_signature: thoughtSignature } }
157
+ : { vertex: { thought_signature: thoughtSignature } };
158
+ }
159
+ return undefined;
160
+ }
161
+
162
+ function parseGeminiThoughtSignatureExtraContent(
163
+ thoughtSignature: string | undefined,
164
+ ): GeminiThoughtSignatureExtraContent | undefined {
165
+ if (!thoughtSignature) return undefined;
166
+ try {
167
+ const parsed: unknown = JSON.parse(thoughtSignature);
168
+ return getGeminiThoughtSignatureExtraContent(parsed);
169
+ } catch {
170
+ return undefined;
171
+ }
172
+ }
173
+
135
174
  type OpenAICompletionsAssistantMessageParam = ChatCompletionAssistantMessageParam &
136
175
  Partial<Record<OpenAICompletionsReasoningField, string>> & {
137
176
  reasoning_details?: unknown[];
@@ -1257,6 +1296,8 @@ const streamOpenAICompletionsOnce = (
1257
1296
 
1258
1297
  if (toolCall.id) block.id = toolCall.id;
1259
1298
  if (incomingName) block.name = incomingName;
1299
+ const extraContent = getGeminiThoughtSignatureExtraContent(Reflect.get(toolCall, "extra_content"));
1300
+ if (extraContent) block.thoughtSignature = JSON.stringify(extraContent);
1260
1301
  let delta = "";
1261
1302
  // The OpenAI SDK types `function.arguments` as a JSON string, but MiniMax-compatible
1262
1303
  // hosts stream a fully-formed object instead. Model both shapes so the branches below
@@ -2235,26 +2276,28 @@ export function convertMessages(
2235
2276
  assistantMsg.tool_calls = toolCalls.map((tc, toolCallIndex) => {
2236
2277
  const toolCallId = ensureToolCallId(tc.id, `${i}:${toolCallIndex}:${tc.name}`, msg);
2237
2278
  rememberToolCallId(tc.id, toolCallId);
2238
- return {
2279
+ const replayedToolCall: OpenAICompletionsFunctionToolCall = {
2239
2280
  id: normalizeMistralToolId(toolCallId, compat.requiresMistralToolIds),
2240
- type: "function" as const,
2281
+ type: "function",
2241
2282
  function: {
2242
2283
  name: tc.name,
2243
2284
  arguments: serializeToolArguments(tc.arguments),
2244
2285
  },
2245
2286
  };
2287
+ const extraContent = parseGeminiThoughtSignatureExtraContent(tc.thoughtSignature);
2288
+ if (extraContent) replayedToolCall.extra_content = extraContent;
2289
+ return replayedToolCall;
2290
+ });
2291
+ const reasoningDetails = toolCalls.flatMap(tc => {
2292
+ const thoughtSignature = tc.thoughtSignature;
2293
+ if (!thoughtSignature) return [];
2294
+ try {
2295
+ const parsed: unknown = JSON.parse(thoughtSignature);
2296
+ return getGeminiThoughtSignatureExtraContent(parsed) ? [] : [parsed];
2297
+ } catch {
2298
+ return [];
2299
+ }
2246
2300
  });
2247
- const reasoningDetails = toolCalls
2248
- .filter(tc => tc.thoughtSignature)
2249
- .map(tc => {
2250
- try {
2251
- const parsed: unknown = JSON.parse(tc.thoughtSignature!);
2252
- return parsed;
2253
- } catch {
2254
- return null;
2255
- }
2256
- })
2257
- .filter(Boolean);
2258
2301
  if (reasoningDetails.length > 0) {
2259
2302
  assistantMsg.reasoning_details = reasoningDetails;
2260
2303
  }
@@ -4,19 +4,48 @@ export interface DecodedDataUri {
4
4
  mimeType: string;
5
5
  }
6
6
 
7
+ function hexValue(code: number): number {
8
+ if (code >= 0x30 && code <= 0x39) return code - 0x30;
9
+ if (code >= 0x41 && code <= 0x46) return code - 0x41 + 10;
10
+ if (code >= 0x61 && code <= 0x66) return code - 0x61 + 10;
11
+ return -1;
12
+ }
13
+
14
+ /** Decode percent escapes as bytes; image data is not necessarily valid UTF-8. */
15
+ function decodePercentEncodedBytes(payload: string): Buffer {
16
+ const output = Buffer.allocUnsafe(Buffer.byteLength(payload, "utf8"));
17
+ let sourceOffset = 0;
18
+ let outputOffset = 0;
19
+ while (sourceOffset < payload.length) {
20
+ if (payload.charCodeAt(sourceOffset) !== 0x25) {
21
+ const nextEscape = payload.indexOf("%", sourceOffset);
22
+ const end = nextEscape < 0 ? payload.length : nextEscape;
23
+ outputOffset += output.write(payload.slice(sourceOffset, end), outputOffset, "utf8");
24
+ sourceOffset = end;
25
+ continue;
26
+ }
27
+ const high = hexValue(payload.charCodeAt(sourceOffset + 1));
28
+ const low = hexValue(payload.charCodeAt(sourceOffset + 2));
29
+ if (high < 0 || low < 0) throw new URIError("URI malformed");
30
+ output[outputOffset++] = (high << 4) | low;
31
+ sourceOffset += 3;
32
+ }
33
+ return output.subarray(0, outputOffset);
34
+ }
35
+
7
36
  /**
8
37
  * Decodes base64 and percent-encoded `data:` URIs.
9
38
  *
10
39
  * Returns `undefined` for non-data URLs and data URIs without a comma separator.
11
40
  */
12
41
  export function decodeDataUri(url: string): DecodedDataUri | undefined {
13
- if (!url.startsWith("data:")) return undefined;
42
+ if (url.slice(0, 5).toLowerCase() !== "data:") return undefined;
14
43
  const comma = url.indexOf(",");
15
44
  if (comma < 0) return undefined;
16
45
  const header = url.slice(5, comma);
17
46
  const payload = url.slice(comma + 1);
18
- const isBase64 = header.endsWith(";base64");
47
+ const isBase64 = header.toLowerCase().endsWith(";base64");
19
48
  const mimeType = (isBase64 ? header.slice(0, -";base64".length) : header) || "application/octet-stream";
20
- const data = isBase64 ? payload : Buffer.from(decodeURIComponent(payload), "utf8").toString("base64");
49
+ const data = isBase64 ? payload : decodePercentEncodedBytes(payload).toString("base64");
21
50
  return { data, mimeType };
22
51
  }
@@ -19,8 +19,9 @@ type ChatCompletionsValidation = {
19
19
  provider: string;
20
20
  baseUrl: string;
21
21
  model: string;
22
+ /** Treat an authenticated 401 (`invalid_model`) as a valid key. */
23
+ tolerateModelDenied?: boolean;
22
24
  };
23
-
24
25
  type AnthropicMessagesValidation = {
25
26
  kind: "anthropic-messages";
26
27
  provider: string;
@@ -99,6 +100,7 @@ export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthCon
99
100
  model: config.validation.model,
100
101
  signal: options.signal,
101
102
  fetch: options.fetch,
103
+ tolerateModelDenied: config.validation.tolerateModelDenied,
102
104
  });
103
105
  } else if (config.validation.kind === "anthropic-messages") {
104
106
  await validateAnthropicCompatibleApiKey({
@@ -1,4 +1,4 @@
1
- import { ProviderHttpError } from "../error/classes";
1
+ import { OpenAIHttpError, ProviderHttpError } from "../error/classes";
2
2
  import type { FetchImpl } from "../types";
3
3
 
4
4
  type OpenAICompatibleValidationOptions = {
@@ -8,6 +8,7 @@ type OpenAICompatibleValidationOptions = {
8
8
  model: string;
9
9
  signal?: AbortSignal;
10
10
  fetch?: FetchImpl;
11
+ tolerateModelDenied?: boolean;
11
12
  };
12
13
  type AnthropicCompatibleValidationOptions = {
13
14
  provider: string;
@@ -27,6 +28,11 @@ type ModelListValidationOptions = {
27
28
  fetch?: FetchImpl;
28
29
  };
29
30
 
31
+ type ErrorEnvelope = {
32
+ details: string;
33
+ code: string | undefined;
34
+ };
35
+
30
36
  const VALIDATION_TIMEOUT_MS = 15_000;
31
37
 
32
38
  function normalizeAnthropicCompatibleBaseUrl(baseUrl: string): string {
@@ -40,7 +46,7 @@ function resolveValidationHeaders(
40
46
  return typeof headers === "function" ? headers() : headers;
41
47
  }
42
48
 
43
- async function createApiKeyValidationError(provider: string, response: Response): Promise<ProviderHttpError> {
49
+ async function readErrorEnvelope(response: Response): Promise<ErrorEnvelope> {
44
50
  let details = "";
45
51
  try {
46
52
  details = (await response.text()).trim();
@@ -48,10 +54,27 @@ async function createApiKeyValidationError(provider: string, response: Response)
48
54
  // Ignore body read errors; the HTTP status still preserves the failure category.
49
55
  }
50
56
 
57
+ let bodyJson: unknown;
58
+ try {
59
+ bodyJson = details ? JSON.parse(details) : undefined;
60
+ } catch {
61
+ bodyJson = undefined;
62
+ }
63
+ const { code } = OpenAIHttpError.parseEnvelope(bodyJson, details);
64
+ return { details, code };
65
+ }
66
+
67
+ async function createApiKeyValidationError(
68
+ provider: string,
69
+ response: Response,
70
+ envelope?: ErrorEnvelope,
71
+ ): Promise<ProviderHttpError> {
72
+ const { details, code } = envelope ?? (await readErrorEnvelope(response));
73
+
51
74
  const message = details
52
75
  ? `${provider} API key validation failed (${response.status}): ${details}`
53
76
  : `${provider} API key validation failed (${response.status})`;
54
- return new ProviderHttpError(message, response.status, { headers: response.headers });
77
+ return new ProviderHttpError(message, response.status, { headers: response.headers, code });
55
78
  }
56
79
 
57
80
  /**
@@ -83,7 +106,12 @@ export async function validateOpenAICompatibleApiKey(options: OpenAICompatibleVa
83
106
  return;
84
107
  }
85
108
 
86
- throw await createApiKeyValidationError(options.provider, response);
109
+ const envelope = await readErrorEnvelope(response);
110
+ if (options.tolerateModelDenied && response.status === 401 && envelope.code === "invalid_model") {
111
+ return;
112
+ }
113
+
114
+ throw await createApiKeyValidationError(options.provider, response, envelope);
87
115
  }
88
116
 
89
117
  /**
@@ -199,10 +199,11 @@ async function httpEmailLogin(ctrl: OAuthController): Promise<OAuthCredentials>
199
199
  signal: ctrl.signal,
200
200
  });
201
201
 
202
- const verifyData = (await verifyResponse.json()) as {
202
+ let verifyData = (await verifyResponse.json()) as {
203
203
  token?: string;
204
204
  challenge_token?: string;
205
205
  status?: string;
206
+ error?: string;
206
207
  error_code?: string;
207
208
  text?: string;
208
209
  };
@@ -216,7 +217,52 @@ async function httpEmailLogin(ctrl: OAuthController): Promise<OAuthCredentials>
216
217
  });
217
218
  }
218
219
 
219
- const token = verifyData.challenge_token || verifyData.token;
220
+ if (verifyData.status === "totp_challenge_required" && verifyData.challenge_token) {
221
+ const totp = await ctrl.onPrompt({
222
+ message: "Enter the code from your authenticator app",
223
+ placeholder: "123456",
224
+ });
225
+ if (ctrl.signal?.aborted) throw new AIError.LoginCancelledError();
226
+ const trimmedTotp = totp.trim();
227
+ if (!trimmedTotp) {
228
+ throw new AIError.OAuthError("Authenticator code is required", {
229
+ kind: "validation",
230
+ provider: "perplexity",
231
+ });
232
+ }
233
+ ctrl.onProgress?.("Verifying authenticator code...");
234
+ const totpResponse = await request("https://www.perplexity.ai/api/auth/totp/challenge-verify", {
235
+ method: "POST",
236
+ headers: {
237
+ "Content-Type": "application/json",
238
+ "User-Agent": APP_USER_AGENT,
239
+ "X-App-ApiVersion": API_VERSION,
240
+ },
241
+ body: JSON.stringify({
242
+ token: verifyData.challenge_token,
243
+ code: trimmedTotp,
244
+ }),
245
+ signal: ctrl.signal,
246
+ });
247
+ verifyData = (await totpResponse.json()) as typeof verifyData;
248
+ if (!totpResponse.ok) {
249
+ const reason = verifyData.text ?? verifyData.error_code ?? verifyData.status ?? "TOTP verification failed";
250
+ throw new AIError.OAuthError(`Perplexity authenticator verification failed: ${reason}`, {
251
+ kind: "validation",
252
+ provider: "perplexity",
253
+ status: totpResponse.status,
254
+ });
255
+ }
256
+ if (!verifyData.token) {
257
+ verifyData = {
258
+ token:
259
+ cookies.get("__Secure-next-auth.session-token") ?? cookies.get("next-auth.session-token") ?? undefined,
260
+ status: "success",
261
+ };
262
+ }
263
+ }
264
+
265
+ const token = verifyData.token ?? verifyData.challenge_token;
220
266
  if (!token || verifyData.error_code || (verifyData.status && verifyData.status !== "success")) {
221
267
  const reason = verifyData.text ?? verifyData.error_code ?? verifyData.status ?? "missing token";
222
268
  throw new AIError.OAuthError(`Perplexity OTP verification response rejected: ${reason}`, {
@@ -217,7 +217,11 @@ export class ZaiOAuthFlow extends OAuthCallbackFlow {
217
217
  #fetch: FetchImpl;
218
218
 
219
219
  constructor(ctrl: OAuthController) {
220
- super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
220
+ super(ctrl, {
221
+ preferredPort: CALLBACK_PORT,
222
+ callbackPath: CALLBACK_PATH,
223
+ allowPortFallback: false,
224
+ });
221
225
  this.#fetch = ctrl.fetch ?? fetch;
222
226
  }
223
227
 
@@ -17,6 +17,7 @@ export const loginQianfan = createApiKeyLogin({
17
17
  provider: "qianfan",
18
18
  baseUrl: API_BASE_URL,
19
19
  model: VALIDATION_MODEL,
20
+ tolerateModelDenied: true,
20
21
  },
21
22
  });
22
23