@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 +4 -0
- package/dist/types/error/rate-limit.d.ts +14 -12
- package/dist/types/registry/api-key-login.d.ts +2 -0
- package/dist/types/registry/api-key-validation.d.ts +1 -0
- package/package.json +5 -5
- package/src/error/flags.ts +16 -12
- package/src/error/rate-limit.ts +26 -23
- package/src/providers/openai-completions.ts +56 -13
- package/src/providers/openai-data-uri.ts +32 -3
- package/src/registry/api-key-login.ts +3 -1
- package/src/registry/api-key-validation.ts +32 -4
- package/src/registry/oauth/perplexity.ts +48 -2
- package/src/registry/oauth/zai.ts +5 -1
- package/src/registry/qianfan.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -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
|
|
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)
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* `
|
|
51
|
-
*
|
|
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
|
|
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";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@linxiraos/pi-ai",
|
|
4
|
-
"version": "1.1.
|
|
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.
|
|
41
|
-
"@linxiraos/pi-catalog": "1.1.
|
|
42
|
-
"@linxiraos/pi-utils": "1.1.
|
|
43
|
-
"@linxiraos/pi-wire": "1.1.
|
|
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"
|
package/src/error/flags.ts
CHANGED
|
@@ -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
|
|
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) &&
|
|
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:
|
|
480
|
-
//
|
|
481
|
-
|
|
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
|
-
(
|
|
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
|
}
|
package/src/error/rate-limit.ts
CHANGED
|
@@ -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
|
|
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)
|
|
284
|
-
*
|
|
285
|
-
*
|
|
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
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
* `
|
|
308
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2279
|
+
const replayedToolCall: OpenAICompletionsFunctionToolCall = {
|
|
2239
2280
|
id: normalizeMistralToolId(toolCallId, compat.requiresMistralToolIds),
|
|
2240
|
-
type: "function"
|
|
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 (
|
|
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 :
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|