@oh-my-pi/pi-ai 16.5.2 → 17.0.1
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 +31 -0
- package/dist/types/auth-storage.d.ts +2 -0
- package/dist/types/error/auth-classify.d.ts +2 -0
- package/dist/types/providers/openai-shared.d.ts +5 -1
- package/dist/types/types.d.ts +6 -1
- package/dist/types/utils/empty-completion-retry.d.ts +3 -3
- package/dist/types/utils/schema/normalize.d.ts +3 -1
- package/package.json +4 -4
- package/src/auth-retry.ts +2 -2
- package/src/auth-storage.ts +120 -79
- package/src/dialect/owned-stream.ts +11 -0
- package/src/error/auth-classify.ts +13 -0
- package/src/error/rate-limit.ts +16 -1
- package/src/providers/amazon-bedrock.ts +6 -1
- package/src/providers/anthropic.ts +82 -25
- package/src/providers/cursor.ts +46 -49
- package/src/providers/ollama.ts +2 -32
- package/src/providers/openai-codex-responses.ts +18 -1
- package/src/providers/openai-completions.ts +26 -20
- package/src/providers/openai-shared.ts +51 -7
- package/src/providers/transform-messages.ts +7 -0
- package/src/stream.ts +2 -0
- package/src/types.ts +9 -1
- package/src/utils/empty-completion-retry.ts +6 -3
- package/src/utils/leaked-thinking-stream.ts +19 -1
- package/src/utils/proxy.ts +1 -1
- package/src/utils/schema/CONSTRAINTS.md +1 -0
- package/src/utils/schema/fields.ts +3 -0
- package/src/utils/schema/normalize.ts +94 -24
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Automatically invalidate and rotate OAuth credentials when an "invalidated oauth token" error occurs
|
|
8
|
+
|
|
9
|
+
## [17.0.1] - 2026-07-16
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed OpenRouter cost reporting to use the provider's authoritative account charge instead of catalog token-price estimates on both Responses and Chat Completions streams.
|
|
14
|
+
- Fixed OpenAI Responses and Chat Completions requests forwarding unsupported sampling parameters such as `temperature` to o-series and GPT-5+ models, preventing 400 errors for mnemopi memory calls through GitHub Copilot GPT-5.6 Luna. ([#5606](https://github.com/can1357/oh-my-pi/issues/5606))
|
|
15
|
+
- Fixed boolean JSON Schema subschemas (`true`/`false`) in MCP tool inputs triggering `400 INVALID_ARGUMENT` on the Google/Cloud Code Assist (Antigravity) transport by coercing them to their object equivalents (`true` → `{}`, `false` → `{ not: {} }`) before sending ([#5604](https://github.com/can1357/oh-my-pi/issues/5604)).
|
|
16
|
+
- Fixed thinking-enabled Claude requests routed to `google-vertex` sending the `effort-2025-11-24` beta as an `anthropic-beta` HTTP header, which Vertex rawPredict rejects with a 400. The effort beta and the `output_config.effort` field are now gated off the Vertex path the same way `context-management-2025-06-27` already is ([#5614](https://github.com/can1357/oh-my-pi/issues/5614)).
|
|
17
|
+
- Fixed custom and Foundry-routed Anthropic endpoints receiving first-party eager/legacy tool-streaming controls ([#5572](https://github.com/can1357/oh-my-pi/issues/5572)).
|
|
18
|
+
- Parsed Ollama NDJSON response bytes directly instead of decoding and buffering every network chunk as text. ([#5542](https://github.com/can1357/oh-my-pi/issues/5542))
|
|
19
|
+
- Fixed Amazon Bedrock stream error handling for non-`Error` values that `JSON.stringify` cannot serialize ([#5539](https://github.com/can1357/oh-my-pi/issues/5539)).
|
|
20
|
+
- Fixed concurrent provider OAuth refreshes by serializing rotating-token updates across processes, fencing stale writes, and preventing background usage probes from disabling otherwise usable credentials ([#5396](https://github.com/can1357/oh-my-pi/issues/5396)).
|
|
21
|
+
- Fixed OpenAI Codex WebSocket connections ignoring `PI_PROXY`, provider-specific proxy settings, and standard HTTPS/ALL proxy variables ([#5384](https://github.com/can1357/oh-my-pi/issues/5384)).
|
|
22
|
+
- Fixed Anthropic account quota exhaustion (`This request would exceed your account's monthly spend limit`) hanging until the local deadline instead of surfacing the error: the `rate_limit_error` "spend limit" wording is now classified as a persistent usage limit, so it fails fast and rotates to a sibling credential rather than looping in the provider retry backoff. ([#4787](https://github.com/can1357/oh-my-pi/issues/4787))
|
|
23
|
+
- Fixed OpenRouter daily free-model allowance errors (`free-models-per-day`) being treated as transient rate limits, so requests rotate from an exhausted API key to a healthy sibling credential. ([#4832](https://github.com/can1357/oh-my-pi/issues/4832))
|
|
24
|
+
|
|
25
|
+
## [17.0.0] - 2026-07-15
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Improved Ollama streaming performance by parsing NDJSON response bytes directly instead of decoding and buffering network chunks as text.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Fixed Cursor TLS connection resets causing process-fatal uncaught exceptions, allowing the active turn to fail or retry gracefully without terminating the session.
|
|
34
|
+
- Fixed Amazon Bedrock stream error handling to correctly handle non-Error values that cannot be serialized by JSON.stringify.
|
|
35
|
+
|
|
5
36
|
## [16.5.2] - 2026-07-14
|
|
6
37
|
|
|
7
38
|
### Added
|
|
@@ -559,6 +559,8 @@ export interface InvalidateCredentialMatchingOptions {
|
|
|
559
559
|
}
|
|
560
560
|
/** Options for refreshing one stored OAuth row through durable ownership. */
|
|
561
561
|
export interface StoredOAuthRefreshOptions<T extends OAuthCredential = OAuthCredential> {
|
|
562
|
+
/** Stable row id when a provider has multiple OAuth credentials. */
|
|
563
|
+
credentialId?: number;
|
|
562
564
|
observedCredential?: T;
|
|
563
565
|
credentialFromRow: (credential: OAuthCredential) => T | undefined;
|
|
564
566
|
forceRefresh?: boolean;
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* `@oh-my-pi/pi-ai` entrypoint name used by the coding agent and auth-broker.
|
|
6
6
|
*/
|
|
7
7
|
export declare function isDefinitiveOAuthFailure(errorMsg: string): boolean;
|
|
8
|
+
/** Whether an upstream response explicitly says the supplied OAuth bearer was invalidated. */
|
|
9
|
+
export declare function isInvalidatedOAuthTokenError(error: unknown): boolean;
|
|
8
10
|
/**
|
|
9
11
|
* Whether an upstream failure should rotate to a sibling credential: a hard
|
|
10
12
|
* `401`, a body-classified usage limit (Codex `usage_limit_reached`, Anthropic
|
|
@@ -68,6 +68,8 @@ export declare function applyOpenAIServiceTier(params: {
|
|
|
68
68
|
* proxy can never skew those costs.
|
|
69
69
|
*/
|
|
70
70
|
export declare function applyOpenAIResponsesServiceTierCost(model: Pick<Model, "provider">, usage: AssistantMessage["usage"], responseServiceTier: unknown, requestServiceTier: ServiceTier | null | undefined): void;
|
|
71
|
+
/** Reconcile token-price estimates with OpenRouter's authoritative account charge. */
|
|
72
|
+
export declare function applyOpenRouterReportedCost(model: Pick<Model, "provider">, usage: Usage, rawUsage: unknown): void;
|
|
71
73
|
export interface OpenAIUsageAccountingInput {
|
|
72
74
|
promptTokens: number;
|
|
73
75
|
outputTokens: number;
|
|
@@ -500,7 +502,9 @@ type CommonSamplingOptions = Pick<StreamOptions, "temperature" | "topP" | "topK"
|
|
|
500
502
|
* can let the upstream apply its own default instead of 400-ing on `maxTokens` values that
|
|
501
503
|
* reflect the model's context window rather than the upstream output limit.
|
|
502
504
|
*/
|
|
503
|
-
export declare function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(params: P, options: CommonSamplingOptions | undefined, model: Pick<Model, "provider" | "api" | "id" | "omitMaxOutputTokens" | "maxTokens">
|
|
505
|
+
export declare function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(params: P, options: CommonSamplingOptions | undefined, model: Pick<Model, "provider" | "api" | "id" | "omitMaxOutputTokens" | "maxTokens"> & {
|
|
506
|
+
compat: Pick<ResolvedOpenAISharedCompat, "supportsSamplingParams">;
|
|
507
|
+
}): void;
|
|
504
508
|
type ReasoningOptions = {
|
|
505
509
|
reasoning?: string;
|
|
506
510
|
reasoningSummary?: "auto" | "detailed" | "concise" | null;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -525,7 +525,7 @@ export interface ContextSnapshot {
|
|
|
525
525
|
}
|
|
526
526
|
export interface AssistantMessage {
|
|
527
527
|
role: "assistant";
|
|
528
|
-
content: (TextContent | ThinkingContent | RedactedThinkingContent | AnthropicFallbackContent | ToolCall)[];
|
|
528
|
+
content: (TextContent | ThinkingContent | RedactedThinkingContent | AnthropicFallbackContent | ImageContent | ToolCall)[];
|
|
529
529
|
api: Api;
|
|
530
530
|
provider: Provider;
|
|
531
531
|
model: string;
|
|
@@ -716,6 +716,11 @@ export type AssistantMessageEvent = {
|
|
|
716
716
|
contentIndex: number;
|
|
717
717
|
content: string;
|
|
718
718
|
partial: AssistantMessage;
|
|
719
|
+
} | {
|
|
720
|
+
type: "image_end";
|
|
721
|
+
contentIndex: number;
|
|
722
|
+
content: ImageContent;
|
|
723
|
+
partial: AssistantMessage;
|
|
719
724
|
} | {
|
|
720
725
|
type: "toolcall_start";
|
|
721
726
|
contentIndex: number;
|
|
@@ -3,9 +3,9 @@ import { AssistantMessageEventStream } from "./event-stream.js";
|
|
|
3
3
|
export declare const MAX_EMPTY_COMPLETION_RETRIES = 2;
|
|
4
4
|
export declare const EMPTY_COMPLETION_BASE_DELAY_MS = 500;
|
|
5
5
|
/**
|
|
6
|
-
* Whether a completed assistant message carries content worth delivering:
|
|
7
|
-
* call or any non-whitespace text. An empty/whitespace-only message
|
|
8
|
-
* that only ever produced thinking — is the "empty response" failure.
|
|
6
|
+
* Whether a completed assistant message carries content worth delivering: an
|
|
7
|
+
* image, tool call, or any non-whitespace text. An empty/whitespace-only message
|
|
8
|
+
* — or one that only ever produced thinking — is the "empty response" failure.
|
|
9
9
|
*/
|
|
10
10
|
export declare function hasVisibleAssistantContent(message: AssistantMessage): boolean;
|
|
11
11
|
interface EmptyCompletionRetryOptions {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { type DescriptionSpillFormat } from "./spill.js";
|
|
2
2
|
import { type JsonObject } from "./types.js";
|
|
3
|
-
export type ResidualSchemaIncompatibility = "type-array" | "type-null" | "nullable" | "combiners";
|
|
3
|
+
export type ResidualSchemaIncompatibility = "type-array" | "type-null" | "nullable" | "combiners" | "not";
|
|
4
4
|
export interface NormalizeSchemaOptions {
|
|
5
|
+
/** Coerce JSON Schema boolean subschemas for providers whose wire cannot encode them. */
|
|
6
|
+
coerceBooleanSubschemas?: boolean;
|
|
5
7
|
unsupportedFields: (key: string) => boolean;
|
|
6
8
|
normalizeFieldNames: boolean;
|
|
7
9
|
collapseNullFields: boolean;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "17.0.1",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.1",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "
|
|
42
|
-
"@oh-my-pi/pi-utils": "
|
|
43
|
-
"@oh-my-pi/pi-wire": "
|
|
41
|
+
"@oh-my-pi/pi-catalog": "17.0.1",
|
|
42
|
+
"@oh-my-pi/pi-utils": "17.0.1",
|
|
43
|
+
"@oh-my-pi/pi-wire": "17.0.1",
|
|
44
44
|
"arktype": "2.2.3",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
package/src/auth-retry.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { OAuthAccess } from "./auth-storage";
|
|
2
2
|
import * as AIError from "./error";
|
|
3
|
-
import { isAuthRetryableError } from "./error/auth-classify";
|
|
3
|
+
import { isAuthRetryableError, isInvalidatedOAuthTokenError } from "./error/auth-classify";
|
|
4
4
|
import { isUsageLimit } from "./error/flags";
|
|
5
5
|
import { isUsageLimitOutcome } from "./error/rate-limit";
|
|
6
6
|
|
|
@@ -90,7 +90,7 @@ export const AUTH_RETRY_STEPS: readonly boolean[] = [false, true];
|
|
|
90
90
|
export const AUTH_RETRY_MAX_ATTEMPTS = 64;
|
|
91
91
|
|
|
92
92
|
function isDirectCredentialRotationError(error: unknown): boolean {
|
|
93
|
-
if (isUsageLimit(error)) return true;
|
|
93
|
+
if (isUsageLimit(error) || isInvalidatedOAuthTokenError(error)) return true;
|
|
94
94
|
const status = AIError.status(error);
|
|
95
95
|
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
|
96
96
|
return isUsageLimitOutcome(status, message);
|
package/src/auth-storage.ts
CHANGED
|
@@ -797,6 +797,8 @@ export interface InvalidateCredentialMatchingOptions {
|
|
|
797
797
|
|
|
798
798
|
/** Options for refreshing one stored OAuth row through durable ownership. */
|
|
799
799
|
export interface StoredOAuthRefreshOptions<T extends OAuthCredential = OAuthCredential> {
|
|
800
|
+
/** Stable row id when a provider has multiple OAuth credentials. */
|
|
801
|
+
credentialId?: number;
|
|
800
802
|
observedCredential?: T;
|
|
801
803
|
credentialFromRow: (credential: OAuthCredential) => T | undefined;
|
|
802
804
|
forceRefresh?: boolean;
|
|
@@ -2007,17 +2009,32 @@ export class AuthStorage {
|
|
|
2007
2009
|
}
|
|
2008
2010
|
|
|
2009
2011
|
/**
|
|
2010
|
-
* Persist a refreshed credential
|
|
2011
|
-
* A
|
|
2012
|
-
*
|
|
2013
|
-
*
|
|
2014
|
-
* the row's current index, or -1 when it was disabled
|
|
2012
|
+
* Persist a refreshed credential by id only while the row still matches this
|
|
2013
|
+
* process's snapshot. A peer rotation wins the CAS and is reloaded instead of
|
|
2014
|
+
* being overwritten after this process releases its refresh lease.
|
|
2015
|
+
*
|
|
2016
|
+
* Returns the row's current index, or -1 when it was disabled or removed.
|
|
2015
2017
|
*/
|
|
2016
2018
|
#replaceCredentialById(provider: string, id: number, credential: AuthCredential): number {
|
|
2017
2019
|
const entries = this.#getStoredCredentials(provider);
|
|
2018
2020
|
const index = entries.findIndex(entry => entry.id === id);
|
|
2019
2021
|
if (index === -1) return -1;
|
|
2020
|
-
|
|
2022
|
+
const expected = serializeCredential(provider, entries[index]!.credential);
|
|
2023
|
+
if (
|
|
2024
|
+
expected &&
|
|
2025
|
+
this.#store.tryUpdateAuthCredentialIfMatches &&
|
|
2026
|
+
!this.#store.tryUpdateAuthCredentialIfMatches(id, expected.data, credential)
|
|
2027
|
+
) {
|
|
2028
|
+
const latest = this.#store.listAuthCredentials(provider);
|
|
2029
|
+
this.#setStoredCredentials(
|
|
2030
|
+
provider,
|
|
2031
|
+
latest.map(row => ({ id: row.id, credential: row.credential })),
|
|
2032
|
+
);
|
|
2033
|
+
return latest.findIndex(row => row.id === id);
|
|
2034
|
+
}
|
|
2035
|
+
if (!expected || !this.#store.tryUpdateAuthCredentialIfMatches) {
|
|
2036
|
+
this.#store.updateAuthCredential(id, credential);
|
|
2037
|
+
}
|
|
2021
2038
|
const updated = [...entries];
|
|
2022
2039
|
updated[index] = { id, credential };
|
|
2023
2040
|
this.#setStoredCredentials(provider, updated);
|
|
@@ -2150,7 +2167,11 @@ export class AuthStorage {
|
|
|
2150
2167
|
provider,
|
|
2151
2168
|
rows.map(row => ({ id: row.id, credential: row.credential })),
|
|
2152
2169
|
);
|
|
2153
|
-
const row = rows.find(
|
|
2170
|
+
const row = rows.find(
|
|
2171
|
+
entry =>
|
|
2172
|
+
entry.credential.type === "oauth" &&
|
|
2173
|
+
(options.credentialId === undefined || entry.id === options.credentialId),
|
|
2174
|
+
);
|
|
2154
2175
|
if (row?.credential.type !== "oauth") {
|
|
2155
2176
|
return { credential: undefined, refreshed: false, removed: false };
|
|
2156
2177
|
}
|
|
@@ -2189,7 +2210,11 @@ export class AuthStorage {
|
|
|
2189
2210
|
provider,
|
|
2190
2211
|
rows.map(row => ({ id: row.id, credential: row.credential })),
|
|
2191
2212
|
);
|
|
2192
|
-
const row = rows.find(
|
|
2213
|
+
const row = rows.find(
|
|
2214
|
+
entry =>
|
|
2215
|
+
entry.credential.type === "oauth" &&
|
|
2216
|
+
(options.credentialId === undefined || entry.id === options.credentialId),
|
|
2217
|
+
);
|
|
2193
2218
|
if (row?.credential.type !== "oauth") {
|
|
2194
2219
|
return { credential: undefined, refreshed: false, removed: false };
|
|
2195
2220
|
}
|
|
@@ -2266,7 +2291,7 @@ export class AuthStorage {
|
|
|
2266
2291
|
return { credential: undefined, refreshed: false, removed: true };
|
|
2267
2292
|
}
|
|
2268
2293
|
await this.reload();
|
|
2269
|
-
const latest = this
|
|
2294
|
+
const latest = this.#getStoredCredentials(provider).find(entry => entry.id === row.id)?.credential;
|
|
2270
2295
|
return {
|
|
2271
2296
|
credential: latest?.type === "oauth" ? options.credentialFromRow(latest) : undefined,
|
|
2272
2297
|
refreshed: false,
|
|
@@ -2316,7 +2341,7 @@ export class AuthStorage {
|
|
|
2316
2341
|
)
|
|
2317
2342
|
) {
|
|
2318
2343
|
await this.reload();
|
|
2319
|
-
const latest = this
|
|
2344
|
+
const latest = this.#getStoredCredentials(provider).find(entry => entry.id === row.id)?.credential;
|
|
2320
2345
|
return {
|
|
2321
2346
|
credential: latest?.type === "oauth" ? options.credentialFromRow(latest) : undefined,
|
|
2322
2347
|
refreshed: false,
|
|
@@ -2808,37 +2833,27 @@ export class AuthStorage {
|
|
|
2808
2833
|
return match?.id;
|
|
2809
2834
|
}
|
|
2810
2835
|
|
|
2811
|
-
#persistRefreshedUsageCredential(
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
entry.credential.accountId === previous.accountId &&
|
|
2822
|
-
entry.credential.email === previous.email &&
|
|
2823
|
-
entry.credential.projectId === previous.projectId &&
|
|
2824
|
-
entry.credential.orgId === previous.orgId
|
|
2825
|
-
);
|
|
2826
|
-
});
|
|
2827
|
-
if (index === -1) return;
|
|
2828
|
-
const existing = entries[index]!.credential;
|
|
2829
|
-
if (existing.type !== "oauth") return;
|
|
2830
|
-
this.#replaceCredentialAt(provider, index, {
|
|
2836
|
+
#persistRefreshedUsageCredential(
|
|
2837
|
+
provider: Provider,
|
|
2838
|
+
previous: UsageCredential,
|
|
2839
|
+
next: UsageCredential,
|
|
2840
|
+
credentialId = this.#findStoredCredentialIdForUsageCredential(provider, previous),
|
|
2841
|
+
): void {
|
|
2842
|
+
if (credentialId === undefined) return;
|
|
2843
|
+
const entry = this.#getStoredCredentials(provider).find(candidate => candidate.id === credentialId);
|
|
2844
|
+
if (entry?.credential.type !== "oauth") return;
|
|
2845
|
+
this.#replaceCredentialById(provider, credentialId, {
|
|
2831
2846
|
type: "oauth",
|
|
2832
|
-
access: next.accessToken ??
|
|
2833
|
-
refresh: next.refreshToken ??
|
|
2834
|
-
expires: next.expiresAt ??
|
|
2847
|
+
access: next.accessToken ?? entry.credential.access,
|
|
2848
|
+
refresh: next.refreshToken ?? entry.credential.refresh,
|
|
2849
|
+
expires: next.expiresAt ?? entry.credential.expires,
|
|
2835
2850
|
accountId: next.accountId,
|
|
2836
2851
|
projectId: next.projectId,
|
|
2837
2852
|
email: next.email,
|
|
2838
2853
|
enterpriseUrl: next.enterpriseUrl,
|
|
2839
2854
|
apiEndpoint: next.apiEndpoint,
|
|
2840
|
-
orgId: next.orgId ??
|
|
2841
|
-
orgName: next.orgName ??
|
|
2855
|
+
orgId: next.orgId ?? entry.credential.orgId,
|
|
2856
|
+
orgName: next.orgName ?? entry.credential.orgName,
|
|
2842
2857
|
});
|
|
2843
2858
|
}
|
|
2844
2859
|
|
|
@@ -2878,7 +2893,12 @@ export class AuthStorage {
|
|
|
2878
2893
|
timeoutSignal,
|
|
2879
2894
|
);
|
|
2880
2895
|
const refreshedCredential = this.#mergeRefreshedUsageCredential(request.credential, refreshed);
|
|
2881
|
-
this.#persistRefreshedUsageCredential(
|
|
2896
|
+
this.#persistRefreshedUsageCredential(
|
|
2897
|
+
request.provider,
|
|
2898
|
+
request.credential,
|
|
2899
|
+
refreshedCredential,
|
|
2900
|
+
refreshableCredentialId,
|
|
2901
|
+
);
|
|
2882
2902
|
params = {
|
|
2883
2903
|
...request,
|
|
2884
2904
|
credential: refreshedCredential,
|
|
@@ -2887,46 +2907,16 @@ export class AuthStorage {
|
|
|
2887
2907
|
};
|
|
2888
2908
|
} catch (error) {
|
|
2889
2909
|
const errorMsg = String(error);
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
// freezing in place until the user notices and re-logs in.
|
|
2897
|
-
if (AIError.isDefinitiveOAuthFailure(errorMsg)) {
|
|
2898
|
-
const credentialId = this.#findStoredCredentialIdForUsageCredential(
|
|
2899
|
-
request.provider,
|
|
2900
|
-
request.credential,
|
|
2901
|
-
);
|
|
2902
|
-
if (credentialId !== undefined) {
|
|
2903
|
-
const entries = this.#getStoredCredentials(request.provider);
|
|
2904
|
-
const index = entries.findIndex(entry => entry.id === credentialId);
|
|
2905
|
-
if (index !== -1) {
|
|
2906
|
-
const disabled = this.#tryDisableCredentialAtIfMatches(
|
|
2907
|
-
request.provider,
|
|
2908
|
-
index,
|
|
2909
|
-
refreshableCredential,
|
|
2910
|
-
`oauth refresh failed during usage probe: ${errorMsg}`,
|
|
2911
|
-
);
|
|
2912
|
-
if (disabled) {
|
|
2913
|
-
this.#usageLogger?.warn(
|
|
2914
|
-
"Usage credential refresh failed definitively; credential disabled",
|
|
2915
|
-
{ provider: request.provider, credentialId, error: errorMsg },
|
|
2916
|
-
);
|
|
2917
|
-
// Neutralize last-good for this cache key: write a null
|
|
2918
|
-
// entry with an immediately-elapsed expiry so a future
|
|
2919
|
-
// getStale lookup (e.g. on re-login under the same
|
|
2920
|
-
// account identity) can't replay the stale report.
|
|
2921
|
-
this.#usageCache.set(this.#buildUsageReportCacheKey(request), {
|
|
2922
|
-
value: null,
|
|
2923
|
-
expiresAt: 0,
|
|
2924
|
-
});
|
|
2925
|
-
return null;
|
|
2926
|
-
}
|
|
2927
|
-
}
|
|
2928
|
-
}
|
|
2910
|
+
if (request.credential.expiresAt <= Date.now() && AIError.isDefinitiveOAuthFailure(errorMsg)) {
|
|
2911
|
+
// The current access token is unusable, so don't replay an
|
|
2912
|
+
// old usage report after its rotating refresh token is revoked.
|
|
2913
|
+
// This changes cache state only; usage polling remains
|
|
2914
|
+
// non-authoritative about the credential lifecycle.
|
|
2915
|
+
this.#usageCache.set(this.#buildUsageReportCacheKey(request), { value: null, expiresAt: 0 });
|
|
2929
2916
|
}
|
|
2917
|
+
// Usage polling is advisory. A refresh can fail while the current
|
|
2918
|
+
// access token remains valid inside the refresh skew, so probe with
|
|
2919
|
+
// that token and never mutate credential state from this path.
|
|
2930
2920
|
this.#usageLogger?.debug("Usage credential refresh failed, using original credential", {
|
|
2931
2921
|
provider: request.provider,
|
|
2932
2922
|
error: errorMsg,
|
|
@@ -3667,6 +3657,7 @@ export class AuthStorage {
|
|
|
3667
3657
|
row.provider as Provider,
|
|
3668
3658
|
initialRequest.credential,
|
|
3669
3659
|
refreshedCredential,
|
|
3660
|
+
row.id,
|
|
3670
3661
|
);
|
|
3671
3662
|
params = {
|
|
3672
3663
|
...params,
|
|
@@ -4317,6 +4308,42 @@ export class AuthStorage {
|
|
|
4317
4308
|
credential: OAuthCredential,
|
|
4318
4309
|
credentialId: number | undefined,
|
|
4319
4310
|
signal?: AbortSignal,
|
|
4311
|
+
): Promise<OAuthCredentials> {
|
|
4312
|
+
const hasDurableLease =
|
|
4313
|
+
!!this.#store.tryAcquireCredentialRefreshLease &&
|
|
4314
|
+
!!this.#store.getCredentialRefreshLeaseExpiresAt &&
|
|
4315
|
+
!!this.#store.releaseCredentialRefreshLease &&
|
|
4316
|
+
!!this.#store.renewCredentialRefreshLease;
|
|
4317
|
+
if (credentialId !== undefined && hasDurableLease) {
|
|
4318
|
+
const forceRefresh = credential.expires === 0;
|
|
4319
|
+
const result = await this.refreshStoredOAuthCredential(provider, {
|
|
4320
|
+
credentialId,
|
|
4321
|
+
observedCredential: forceRefresh ? undefined : credential,
|
|
4322
|
+
credentialFromRow: row => row,
|
|
4323
|
+
forceRefresh,
|
|
4324
|
+
signal,
|
|
4325
|
+
refresh: (current, refreshSignal) =>
|
|
4326
|
+
this.#requestOAuthCredentialRefresh(
|
|
4327
|
+
provider,
|
|
4328
|
+
current,
|
|
4329
|
+
credentialId,
|
|
4330
|
+
signal && refreshSignal ? AbortSignal.any([signal, refreshSignal]) : (signal ?? refreshSignal),
|
|
4331
|
+
),
|
|
4332
|
+
});
|
|
4333
|
+
if (result.credential) return result.credential;
|
|
4334
|
+
throw new AIError.OAuthError(`OAuth credential no longer exists for provider: ${provider}`, {
|
|
4335
|
+
kind: "token-refresh",
|
|
4336
|
+
provider,
|
|
4337
|
+
});
|
|
4338
|
+
}
|
|
4339
|
+
return this.#requestOAuthCredentialRefresh(provider, credential, credentialId, signal);
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4342
|
+
async #requestOAuthCredentialRefresh(
|
|
4343
|
+
provider: Provider,
|
|
4344
|
+
credential: OAuthCredential,
|
|
4345
|
+
credentialId: number | undefined,
|
|
4346
|
+
signal?: AbortSignal,
|
|
4320
4347
|
): Promise<OAuthCredentials> {
|
|
4321
4348
|
let refreshPromise: Promise<OAuthCredentials>;
|
|
4322
4349
|
// Caller override > store-level hook > local per-provider refresh.
|
|
@@ -4343,10 +4370,9 @@ export class AuthStorage {
|
|
|
4343
4370
|
// Bound the refresh so a slow/hanging token endpoint cannot stall credential selection.
|
|
4344
4371
|
// Caller-driven abort jumps the gun on the timeout — the agent's ESC must
|
|
4345
4372
|
// take priority over the floor timeout.
|
|
4346
|
-
let timeout: NodeJS.Timeout | undefined;
|
|
4347
|
-
let onAbort: (() => void) | undefined;
|
|
4348
4373
|
const cancellation = Promise.withResolvers<never>();
|
|
4349
|
-
|
|
4374
|
+
let onAbort: (() => void) | undefined;
|
|
4375
|
+
const timeout = setTimeout(
|
|
4350
4376
|
() =>
|
|
4351
4377
|
cancellation.reject(
|
|
4352
4378
|
new AIError.OAuthError(`OAuth token refresh timed out for provider: ${provider}`, {
|
|
@@ -4367,7 +4393,7 @@ export class AuthStorage {
|
|
|
4367
4393
|
try {
|
|
4368
4394
|
return await Promise.race([refreshPromise, cancellation.promise]);
|
|
4369
4395
|
} finally {
|
|
4370
|
-
|
|
4396
|
+
clearTimeout(timeout);
|
|
4371
4397
|
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
4372
4398
|
}
|
|
4373
4399
|
}
|
|
@@ -5359,6 +5385,21 @@ export class AuthStorage {
|
|
|
5359
5385
|
Date.now() + AuthStorage.#defaultBackoffMs,
|
|
5360
5386
|
);
|
|
5361
5387
|
|
|
5388
|
+
if (target && AIError.isInvalidatedOAuthTokenError(error)) {
|
|
5389
|
+
const disabledCause = message ?? "upstream reported invalidated OAuth token";
|
|
5390
|
+
const deleted = this.#store.deleteAuthCredentialRemote
|
|
5391
|
+
? await this.#store.deleteAuthCredentialRemote(target.id, disabledCause)
|
|
5392
|
+
: this.disableCredentialById(target.id, disabledCause);
|
|
5393
|
+
if (deleted) {
|
|
5394
|
+
const latestRows = this.#store.listAuthCredentials(provider);
|
|
5395
|
+
this.#setStoredCredentials(
|
|
5396
|
+
provider,
|
|
5397
|
+
latestRows.map(row => ({ id: row.id, credential: row.credential })),
|
|
5398
|
+
);
|
|
5399
|
+
}
|
|
5400
|
+
return deleted && hasSibling;
|
|
5401
|
+
}
|
|
5402
|
+
|
|
5362
5403
|
if (target) {
|
|
5363
5404
|
const markSuspect = this.#store.markCredentialSuspect?.bind(this.#store);
|
|
5364
5405
|
if (markSuspect) {
|
|
@@ -109,6 +109,9 @@ export function wrapInbandToolStream(
|
|
|
109
109
|
case "thinking_end":
|
|
110
110
|
projector?.thinkingEnd();
|
|
111
111
|
break;
|
|
112
|
+
case "image_end":
|
|
113
|
+
projector?.keep(event.content);
|
|
114
|
+
break;
|
|
112
115
|
case "text_delta":
|
|
113
116
|
// `text()` returns true once the model starts fabricating its own
|
|
114
117
|
// tool result. In abort mode we cut the turn immediately so the
|
|
@@ -206,6 +209,14 @@ class InbandStreamProjector {
|
|
|
206
209
|
this.#closeText();
|
|
207
210
|
this.#closeThinking();
|
|
208
211
|
this.#partial.content.push(block);
|
|
212
|
+
if (this.#emitEvents && block.type === "image") {
|
|
213
|
+
this.#out.push({
|
|
214
|
+
type: "image_end",
|
|
215
|
+
contentIndex: this.#partial.content.length - 1,
|
|
216
|
+
content: block,
|
|
217
|
+
partial: this.#partial,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
209
220
|
}
|
|
210
221
|
|
|
211
222
|
// Forward a native tool call's lifecycle live. `source` comes from the inner
|
|
@@ -12,6 +12,18 @@ export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
|
|
|
12
12
|
return isOAuthExpiry(errorMsg);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
const INVALIDATED_OAUTH_TOKEN_PATTERN = /\binvalidated oauth token\b/i;
|
|
16
|
+
|
|
17
|
+
/** Whether an upstream response explicitly says the supplied OAuth bearer was invalidated. */
|
|
18
|
+
export function isInvalidatedOAuthTokenError(error: unknown): boolean {
|
|
19
|
+
if (typeof error === "object" && error !== null && "errorMessage" in error) {
|
|
20
|
+
const errorMessage = error.errorMessage;
|
|
21
|
+
if (typeof errorMessage === "string" && INVALIDATED_OAUTH_TOKEN_PATTERN.test(errorMessage)) return true;
|
|
22
|
+
}
|
|
23
|
+
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
|
24
|
+
return message !== undefined && INVALIDATED_OAUTH_TOKEN_PATTERN.test(message);
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
/**
|
|
16
28
|
* Whether an upstream failure should rotate to a sibling credential: a hard
|
|
17
29
|
* `401`, a body-classified usage limit (Codex `usage_limit_reached`, Anthropic
|
|
@@ -22,6 +34,7 @@ export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
|
|
|
22
34
|
*/
|
|
23
35
|
export function isAuthRetryableError(error: unknown): boolean {
|
|
24
36
|
if (isUsageLimit(error)) return true;
|
|
37
|
+
if (isInvalidatedOAuthTokenError(error)) return true;
|
|
25
38
|
const httpStatus = extractHttpStatusFromError(error);
|
|
26
39
|
if (httpStatus === 401) return true;
|
|
27
40
|
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
package/src/error/rate-limit.ts
CHANGED
|
@@ -19,6 +19,8 @@ const SERVER_ERROR_BACKOFF_MS = 20 * 1000; // 20s
|
|
|
19
19
|
const ACCOUNT_RATE_LIMIT_PATTERN =
|
|
20
20
|
/\baccount(?:'s)?\b[^\n]{0,80}\brate.?limit\b|\brate.?limit\b[^\n]{0,80}\baccount\b/i;
|
|
21
21
|
const INSUFFICIENT_BALANCE_PATTERN = /insufficient.?balance/i;
|
|
22
|
+
const SPEND_LIMIT_PATTERN = /spend.?limit/i;
|
|
23
|
+
const OPENROUTER_DAILY_FREE_LIMIT_PATTERN = /\bfree[-_ ]models[-_ ]per[-_ ]day\b/i;
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Classify a rate-limit error message into a reason category.
|
|
@@ -54,6 +56,14 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
|
|
|
54
56
|
return "QUOTA_EXHAUSTED";
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
if (SPEND_LIMIT_PATTERN.test(errorMessage)) {
|
|
60
|
+
return "QUOTA_EXHAUSTED";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (OPENROUTER_DAILY_FREE_LIMIT_PATTERN.test(errorMessage)) {
|
|
64
|
+
return "QUOTA_EXHAUSTED";
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
if (
|
|
58
68
|
lower.includes("per minute") ||
|
|
59
69
|
lower.includes("rate limit") ||
|
|
@@ -163,5 +173,10 @@ export function isOpaqueStatusBody(message: string): boolean {
|
|
|
163
173
|
* {@link isUsageLimitOutcome} uses it for the account-rotation decision.
|
|
164
174
|
*/
|
|
165
175
|
export function matchesUsageLimitText(errorMessage: string): boolean {
|
|
166
|
-
return
|
|
176
|
+
return (
|
|
177
|
+
USAGE_LIMIT_PATTERN.test(errorMessage) ||
|
|
178
|
+
SPEND_LIMIT_PATTERN.test(errorMessage) ||
|
|
179
|
+
ACCOUNT_RATE_LIMIT_PATTERN.test(errorMessage) ||
|
|
180
|
+
OPENROUTER_DAILY_FREE_LIMIT_PATTERN.test(errorMessage)
|
|
181
|
+
);
|
|
167
182
|
}
|
|
@@ -510,7 +510,12 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
|
|
510
510
|
for (const block of output.content) {
|
|
511
511
|
if (block.type === "toolCall") clearStreamingPartialJson(block);
|
|
512
512
|
}
|
|
513
|
-
|
|
513
|
+
let baseMessage: string;
|
|
514
|
+
try {
|
|
515
|
+
baseMessage = error instanceof Error ? error.message : (JSON.stringify(error) ?? String(error));
|
|
516
|
+
} catch {
|
|
517
|
+
baseMessage = String(error);
|
|
518
|
+
}
|
|
514
519
|
// Enrich error with thinking block diagnostics for signature-related failures
|
|
515
520
|
let diagnostics = "";
|
|
516
521
|
if (baseMessage.includes("signature") || baseMessage.includes("thinking")) {
|