@oh-my-pi/pi-ai 16.3.11 → 16.3.12
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 +19 -0
- package/dist/types/auth-retry.d.ts +3 -1
- package/dist/types/auth-storage.d.ts +13 -8
- package/dist/types/providers/anthropic.d.ts +1 -0
- package/dist/types/providers/openai-shared.d.ts +4 -3
- package/dist/types/types.d.ts +2 -0
- package/package.json +4 -4
- package/src/auth-broker/remote-store.ts +55 -5
- package/src/auth-gateway/server.ts +1 -0
- package/src/auth-retry.ts +7 -2
- package/src/auth-storage.ts +210 -46
- package/src/providers/anthropic.ts +49 -3
- package/src/providers/openai-codex-responses.ts +19 -4
- package/src/providers/openai-responses.ts +1 -1
- package/src/providers/openai-shared.ts +20 -6
- package/src/registry/oauth/callback-server.ts +19 -7
- package/src/stream.ts +7 -1
- package/src/types.ts +2 -0
- package/src/usage/openai-codex.ts +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.3.12] - 2026-07-08
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `AssistantMessage.toolCallAbortMessages` for per-tool placeholder labels on aborted assistant turns ([#2783](https://github.com/can1357/oh-my-pi/issues/2783)).
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed Anthropic replay 400s (`tool_use ids were found without tool_result blocks immediately after`) when a persisted assistant turn carries content after a completed tool call — such as a mid-turn `server-side-fallback` handoff (fallback block plus continued text/tool calls after the primary model's `tool_use`) or trailing text from cross-provider replays — by stable-partitioning assistant content so all `tool_use` blocks trail the non-`tool_use` chain. ([#4781](https://github.com/can1357/oh-my-pi/issues/4781), [#544](https://github.com/can1357/oh-my-pi/issues/544))
|
|
14
|
+
- Fixed access-token-only OAuth credentials attempting token refresh with an empty refresh token after expiry.
|
|
15
|
+
- Fixed gateway usage-limit retries falling through to cross-provider model fallback before trying a sibling credential from the same provider.
|
|
16
|
+
- Fixed Codex usage-limit rotation treating Plus and K-12 accounts as separate quota groups for shared 5-hour/7-day windows.
|
|
17
|
+
- Fixed OpenAI Responses streams that end with `response.done` being misclassified as premature stream closures.
|
|
18
|
+
- Fixed OpenCode Go `/login` credentials being shadowed by an existing `OPENCODE_API_KEY` env fallback after switching accounts. ([#4688](https://github.com/can1357/oh-my-pi/issues/4688))
|
|
19
|
+
- Fixed OpenAI Codex WebSocket continuations to treat proxy stale-anchor codes such as `codex_previous_response_stale` as an expired `previous_response_id` chain — same recovery class as the OpenAI-standard `previous_response_not_found` — so the turn is retried with full context instead of surfacing the error to the user ([#4624](https://github.com/can1357/oh-my-pi/issues/4624)).
|
|
20
|
+
- Fixed Azure Foundry Anthropic utility requests to omit the structured-output beta whenever strict tools are disabled, preventing `structured_outputs not supported in your workspace` failures for Sonnet 5 compaction ([#4679](https://github.com/can1357/oh-my-pi/issues/4679)).
|
|
21
|
+
- Fixed OAuth `launchUrl` advertisement for flows whose redirect never returns to the local callback server: custom-scheme redirects (e.g. GitLab Duo's `vscode://` URI, which `new URL` parses without complaint) and fixed non-loopback hosts no longer receive a `http://localhost:<port>/launch` copy target that misrepresents the callback endpoint and resolves nowhere for remote users.
|
|
22
|
+
- Codex load balancing: clear stale persisted and in-memory usage-limit blocks for an `openai-codex` account when a fresh live usage report shows it is allowed and below all limits, including broker-backed gateway snapshots, so traffic returns to recovered accounts instead of funneling to one sibling.
|
|
23
|
+
|
|
5
24
|
## [16.3.11] - 2026-07-06
|
|
6
25
|
|
|
7
26
|
### Fixed
|
|
@@ -21,6 +21,8 @@ export interface ApiKeyResolveContext {
|
|
|
21
21
|
lastChance: boolean;
|
|
22
22
|
/** The auth error that triggered this re-resolution, or `undefined` on the initial resolve. */
|
|
23
23
|
error: unknown;
|
|
24
|
+
/** Bearer used by the failed attempt, when the caller can expose it. */
|
|
25
|
+
previousKey?: string;
|
|
24
26
|
/** Caller cancel signal, threaded into any credential refresh / rotation work. */
|
|
25
27
|
signal?: AbortSignal;
|
|
26
28
|
}
|
|
@@ -56,7 +58,7 @@ export { isAuthRetryableError };
|
|
|
56
58
|
*/
|
|
57
59
|
export declare const AUTH_RETRY_STEPS: readonly boolean[];
|
|
58
60
|
/** Resolve a single retry step, swallowing resolver failures into `undefined`. */
|
|
59
|
-
export declare function resolveRetryKey(resolver: ApiKeyResolver, lastChance: boolean, error: unknown, signal?: AbortSignal): Promise<string | undefined>;
|
|
61
|
+
export declare function resolveRetryKey(resolver: ApiKeyResolver, lastChance: boolean, error: unknown, signal?: AbortSignal, previousKey?: string): Promise<string | undefined>;
|
|
60
62
|
/**
|
|
61
63
|
* Runs an auth-protected operation through the central a/b/c retry policy.
|
|
62
64
|
*
|
|
@@ -16,6 +16,7 @@ import { type CodexResetConsumeCode, type CodexResetCredit } from "./usage/opena
|
|
|
16
16
|
export type ApiKeyCredential = {
|
|
17
17
|
type: "api_key";
|
|
18
18
|
key: string;
|
|
19
|
+
source?: "login";
|
|
19
20
|
};
|
|
20
21
|
export type OAuthCredential = {
|
|
21
22
|
type: "oauth";
|
|
@@ -678,8 +679,8 @@ export declare class AuthStorage {
|
|
|
678
679
|
/**
|
|
679
680
|
* Classify where a provider's auth comes from, following the same precedence
|
|
680
681
|
* as {@link AuthStorage.getApiKey}: runtime override → config override →
|
|
681
|
-
* stored OAuth → env var → stored api_key →
|
|
682
|
-
* undefined when no auth is configured.
|
|
682
|
+
* stored OAuth → login-stored api_key → env var → stored api_key →
|
|
683
|
+
* fallback resolver. Returns undefined when no auth is configured.
|
|
683
684
|
*
|
|
684
685
|
* Compact, structured counterpart to {@link describeCredentialSource}.
|
|
685
686
|
*/
|
|
@@ -792,6 +793,7 @@ export declare class AuthStorage {
|
|
|
792
793
|
retryAfterMs?: number;
|
|
793
794
|
baseUrl?: string;
|
|
794
795
|
modelId?: string;
|
|
796
|
+
apiKey?: string;
|
|
795
797
|
signal?: AbortSignal;
|
|
796
798
|
}): Promise<UsageLimitMarkResult>;
|
|
797
799
|
/**
|
|
@@ -807,9 +809,10 @@ export declare class AuthStorage {
|
|
|
807
809
|
* 1. Runtime override (CLI --api-key)
|
|
808
810
|
* 2. Config override (models.yml `providers.<name>.apiKey`)
|
|
809
811
|
* 3. OAuth token from storage (auto-refreshed)
|
|
810
|
-
* 4.
|
|
811
|
-
* 5.
|
|
812
|
-
* 6.
|
|
812
|
+
* 4. API key persisted by a successful `/login`
|
|
813
|
+
* 5. Environment variable
|
|
814
|
+
* 6. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
|
|
815
|
+
* 7. Fallback resolver (models.yml custom providers, last-resort)
|
|
813
816
|
*/
|
|
814
817
|
getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined>;
|
|
815
818
|
/**
|
|
@@ -908,6 +911,7 @@ export declare class AuthStorage {
|
|
|
908
911
|
rotateSessionCredential(provider: string, sessionId: string | undefined, options?: {
|
|
909
912
|
error?: unknown;
|
|
910
913
|
modelId?: string;
|
|
914
|
+
apiKey?: string;
|
|
911
915
|
signal?: AbortSignal;
|
|
912
916
|
}): Promise<boolean>;
|
|
913
917
|
/**
|
|
@@ -986,9 +990,10 @@ export declare class AuthStorage {
|
|
|
986
990
|
* 1. Runtime override (`--api-key`).
|
|
987
991
|
* 2. Config override (`models.yml` `providers.<name>.apiKey`).
|
|
988
992
|
* 3. Stored OAuth credential.
|
|
989
|
-
* 4.
|
|
990
|
-
* 5.
|
|
991
|
-
* 6.
|
|
993
|
+
* 4. API key persisted by a successful `/login`.
|
|
994
|
+
* 5. Env var — overrides a stored static api_key (e.g. a stale broker copy).
|
|
995
|
+
* 6. Stored api_key credential.
|
|
996
|
+
* 7. Fallback resolver.
|
|
992
997
|
*
|
|
993
998
|
* The string is purely informational; consumers must not parse it.
|
|
994
999
|
*/
|
|
@@ -400,9 +400,10 @@ export interface ProcessResponsesStreamOptions {
|
|
|
400
400
|
onFirstToken?: () => void;
|
|
401
401
|
onOutputItemDone?: (item: ResponseOutputItem) => void;
|
|
402
402
|
/**
|
|
403
|
-
* Called when a terminal `response.completed
|
|
404
|
-
* is successfully processed. Only invoked on the
|
|
405
|
-
* thrown failure (`response.failed`) and
|
|
403
|
+
* Called when a terminal `response.completed`, `response.incomplete`, or
|
|
404
|
+
* `response.done` event is successfully processed. Only invoked on the
|
|
405
|
+
* successful-completion path; thrown failure (`response.failed`) and
|
|
406
|
+
* cancellation paths never call this.
|
|
406
407
|
* Used by callers to detect premature stream closure (i.e. the stream ended
|
|
407
408
|
* without a recognized terminal event).
|
|
408
409
|
*/
|
package/dist/types/types.d.ts
CHANGED
|
@@ -537,6 +537,8 @@ export interface AssistantMessage {
|
|
|
537
537
|
stopReason: StopReason;
|
|
538
538
|
stopDetails?: StopDetails | null;
|
|
539
539
|
errorMessage?: string;
|
|
540
|
+
/** Per-tool abort messages used when an aborted assistant turn needs different placeholder results per tool call. */
|
|
541
|
+
toolCallAbortMessages?: Record<string, string>;
|
|
540
542
|
/** HTTP status surfaced by the provider when the request failed. Populated by every provider's catch block alongside `errorMessage` so consumers (auth retry, telemetry, UI) can branch without regex-scraping the message. */
|
|
541
543
|
errorStatus?: number;
|
|
542
544
|
/** Structured machine-readable error classifier; see `utils/error-id.ts` for bit layout and helpers. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "16.3.
|
|
4
|
+
"version": "16.3.12",
|
|
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.0",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "16.3.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.3.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.3.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "16.3.12",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.3.12",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.3.12",
|
|
44
44
|
"arktype": "^2.2.0",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
@@ -61,6 +61,41 @@ function toCredentialBlockSnapshot(block: StoredCredentialBlock): CredentialBloc
|
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
function credentialBlockSnapshotsEqual(
|
|
65
|
+
left: readonly CredentialBlockSnapshot[] | undefined,
|
|
66
|
+
right: readonly CredentialBlockSnapshot[] | undefined,
|
|
67
|
+
): boolean {
|
|
68
|
+
const leftBlocks = left ?? [];
|
|
69
|
+
const rightBlocks = right ?? [];
|
|
70
|
+
if (leftBlocks.length !== rightBlocks.length) return false;
|
|
71
|
+
for (let index = 0; index < leftBlocks.length; index += 1) {
|
|
72
|
+
const leftBlock = leftBlocks[index]!;
|
|
73
|
+
const rightBlock = rightBlocks[index]!;
|
|
74
|
+
if (
|
|
75
|
+
leftBlock.providerKey !== rightBlock.providerKey ||
|
|
76
|
+
leftBlock.blockScope !== rightBlock.blockScope ||
|
|
77
|
+
leftBlock.blockedUntilMs !== rightBlock.blockedUntilMs
|
|
78
|
+
) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function snapshotBlocksChanged(previous: readonly SnapshotEntry[], next: readonly SnapshotEntry[]): boolean {
|
|
86
|
+
const previousBlocksById = new Map<number, readonly CredentialBlockSnapshot[] | undefined>();
|
|
87
|
+
for (const entry of previous) previousBlocksById.set(entry.id, entry.blocks);
|
|
88
|
+
for (const entry of next) {
|
|
89
|
+
const previousBlocks = previousBlocksById.get(entry.id);
|
|
90
|
+
if (!credentialBlockSnapshotsEqual(previousBlocks, entry.blocks)) return true;
|
|
91
|
+
previousBlocksById.delete(entry.id);
|
|
92
|
+
}
|
|
93
|
+
for (const previousBlocks of previousBlocksById.values()) {
|
|
94
|
+
if (previousBlocks && previousBlocks.length > 0) return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
64
99
|
function credentialEntryWithBlocks(
|
|
65
100
|
entry: AuthCredentialSnapshotEntry,
|
|
66
101
|
blocks: readonly CredentialBlockSnapshot[] | undefined,
|
|
@@ -173,6 +208,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
173
208
|
#cache: Map<string, CacheEntry> = new Map();
|
|
174
209
|
#usageCache?: UsageCacheEntry;
|
|
175
210
|
#usageInflight?: Promise<UsageReport[] | null>;
|
|
211
|
+
#usageCacheEpoch = 0;
|
|
176
212
|
#closed = false;
|
|
177
213
|
/**
|
|
178
214
|
* `true` once the SSE consumer received its first frame and hasn't dropped
|
|
@@ -202,10 +238,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
202
238
|
|
|
203
239
|
#applySnapshot(snapshot: SnapshotResponse, generation: number): void {
|
|
204
240
|
const nowMs = Date.now();
|
|
205
|
-
this.#
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
};
|
|
241
|
+
const credentials = snapshot.credentials.map(entry => this.#normalizeSnapshotEntryBlocks(entry, nowMs));
|
|
242
|
+
if (snapshotBlocksChanged(this.#snapshot.credentials, credentials)) this.#invalidateUsageCache();
|
|
243
|
+
this.#snapshot = { ...snapshot, credentials };
|
|
209
244
|
this.#generation = generation;
|
|
210
245
|
this.#snapshotReceivedAt = nowMs;
|
|
211
246
|
const onSnapshot = this.#onSnapshot;
|
|
@@ -304,6 +339,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
304
339
|
): void {
|
|
305
340
|
const incoming = this.#normalizeSnapshotEntryBlocks(entry, Date.now());
|
|
306
341
|
const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === incoming.id);
|
|
342
|
+
const previousBlocks = index === -1 ? undefined : this.#snapshot.credentials[index]?.blocks;
|
|
343
|
+
if (!credentialBlockSnapshotsEqual(previousBlocks, incoming.blocks)) this.#invalidateUsageCache();
|
|
307
344
|
const credentials =
|
|
308
345
|
index === -1
|
|
309
346
|
? [...this.#snapshot.credentials, incoming]
|
|
@@ -314,6 +351,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
314
351
|
}
|
|
315
352
|
|
|
316
353
|
#removeStreamCredential(id: number, refresher: RefresherSchedule, generation: number, serverNowMs: number): void {
|
|
354
|
+
const removed = this.#snapshot.credentials.find(entry => entry.id === id);
|
|
355
|
+
if (removed?.blocks && removed.blocks.length > 0) this.#invalidateUsageCache();
|
|
317
356
|
const credentials = this.#snapshot.credentials.filter(entry => entry.id !== id);
|
|
318
357
|
this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
|
|
319
358
|
this.#generation = generation;
|
|
@@ -376,6 +415,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
376
415
|
|
|
377
416
|
upsertCredentialBlock(block: StoredCredentialBlock): void {
|
|
378
417
|
this.#upsertSnapshotBlock(block);
|
|
418
|
+
this.#invalidateUsageCache();
|
|
379
419
|
const body = toCredentialBlockSnapshot(block);
|
|
380
420
|
void this.#client
|
|
381
421
|
.upsertCredentialBlock(block.credentialId, body)
|
|
@@ -394,6 +434,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
394
434
|
|
|
395
435
|
deleteCredentialBlocks(credentialId: number): void {
|
|
396
436
|
this.#deleteSnapshotBlocks(credentialId);
|
|
437
|
+
this.#invalidateUsageCache();
|
|
397
438
|
void this.#client
|
|
398
439
|
.deleteCredentialBlocks(credentialId)
|
|
399
440
|
.then(() => {
|
|
@@ -698,6 +739,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
698
739
|
}
|
|
699
740
|
}
|
|
700
741
|
|
|
742
|
+
#invalidateUsageCache(): void {
|
|
743
|
+
this.#usageCache = undefined;
|
|
744
|
+
this.#usageInflight = undefined;
|
|
745
|
+
this.#usageCacheEpoch += 1;
|
|
746
|
+
}
|
|
747
|
+
|
|
701
748
|
/**
|
|
702
749
|
* Store-level hook consumed by `AuthStorage` — routes refresh through the
|
|
703
750
|
* broker so the actual refresh token never leaves the broker host. Returns
|
|
@@ -834,9 +881,11 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
834
881
|
return Promise.resolve(cached.reports);
|
|
835
882
|
}
|
|
836
883
|
if (this.#usageInflight) return this.#usageInflight;
|
|
884
|
+
const epoch = this.#usageCacheEpoch;
|
|
837
885
|
const inflight = this.#client
|
|
838
886
|
.fetchUsage()
|
|
839
887
|
.then(body => {
|
|
888
|
+
if (epoch !== this.#usageCacheEpoch) return this.#loadUsageReports();
|
|
840
889
|
this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
|
|
841
890
|
return body.reports;
|
|
842
891
|
})
|
|
@@ -845,11 +894,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
845
894
|
// Documented 15s TTL fallback: cache the null so sequential callers
|
|
846
895
|
// don't re-hit the broker while it's still down. See
|
|
847
896
|
// docs/auth-broker-gateway.md § "Client-side single-flight".
|
|
897
|
+
if (epoch !== this.#usageCacheEpoch) return this.#loadUsageReports();
|
|
848
898
|
this.#usageCache = { reports: null, fetchedAt: Date.now() };
|
|
849
899
|
return null;
|
|
850
900
|
})
|
|
851
901
|
.finally(() => {
|
|
852
|
-
this.#usageInflight = undefined;
|
|
902
|
+
if (this.#usageInflight === inflight) this.#usageInflight = undefined;
|
|
853
903
|
});
|
|
854
904
|
this.#usageInflight = inflight;
|
|
855
905
|
return inflight;
|
package/src/auth-retry.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OAuthAccess } from "./auth-storage";
|
|
2
2
|
import * as AIError from "./error";
|
|
3
3
|
import { isAuthRetryableError } from "./error/auth-classify";
|
|
4
|
+
import { isUsageLimit } from "./error/flags";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Context passed to an {@link ApiKeyResolver} on each resolution attempt.
|
|
@@ -23,6 +24,8 @@ export interface ApiKeyResolveContext {
|
|
|
23
24
|
lastChance: boolean;
|
|
24
25
|
/** The auth error that triggered this re-resolution, or `undefined` on the initial resolve. */
|
|
25
26
|
error: unknown;
|
|
27
|
+
/** Bearer used by the failed attempt, when the caller can expose it. */
|
|
28
|
+
previousKey?: string;
|
|
26
29
|
/** Caller cancel signal, threaded into any credential refresh / rotation work. */
|
|
27
30
|
signal?: AbortSignal;
|
|
28
31
|
}
|
|
@@ -87,9 +90,11 @@ export async function resolveRetryKey(
|
|
|
87
90
|
lastChance: boolean,
|
|
88
91
|
error: unknown,
|
|
89
92
|
signal?: AbortSignal,
|
|
93
|
+
previousKey?: string,
|
|
90
94
|
): Promise<string | undefined> {
|
|
91
95
|
try {
|
|
92
|
-
|
|
96
|
+
const rotateSibling = lastChance || (!lastChance && isUsageLimit(error));
|
|
97
|
+
return (await resolver({ lastChance: rotateSibling, error, signal, previousKey })) || undefined;
|
|
93
98
|
} catch {
|
|
94
99
|
return undefined;
|
|
95
100
|
}
|
|
@@ -136,7 +141,7 @@ export async function withAuth<T>(
|
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
for (let i = 0; i < AUTH_RETRY_STEPS.length; i++) {
|
|
139
|
-
const nextKey = await resolveRetryKey(resolver, AUTH_RETRY_STEPS[i]!, lastError, signal);
|
|
144
|
+
const nextKey = await resolveRetryKey(resolver, AUTH_RETRY_STEPS[i]!, lastError, signal, lastKey);
|
|
140
145
|
if (nextKey === undefined || nextKey === lastKey) continue;
|
|
141
146
|
lastKey = nextKey;
|
|
142
147
|
try {
|
package/src/auth-storage.ts
CHANGED
|
@@ -66,6 +66,7 @@ const USAGE_RANKING_METRIC_EPSILON = 1e-9;
|
|
|
66
66
|
export type ApiKeyCredential = {
|
|
67
67
|
type: "api_key";
|
|
68
68
|
key: string;
|
|
69
|
+
source?: "login";
|
|
69
70
|
};
|
|
70
71
|
|
|
71
72
|
export type OAuthCredential = {
|
|
@@ -967,6 +968,7 @@ export class AuthStorage {
|
|
|
967
968
|
#usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
|
|
968
969
|
#rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
|
|
969
970
|
#usageCache: UsageCache;
|
|
971
|
+
#usageCacheEpoch = 0;
|
|
970
972
|
#usageRequestInFlight: Map<string, Promise<UsageReport | null>> = new Map();
|
|
971
973
|
#usageHeaderIngestAt: Map<string, number> = new Map();
|
|
972
974
|
#usageReportsInFlight: Map<string, Promise<UsageReport[] | null>> = new Map();
|
|
@@ -1390,12 +1392,14 @@ export class AuthStorage {
|
|
|
1390
1392
|
|
|
1391
1393
|
const credentialId = this.#getStoredCredentials(provider)[credentialIndex]?.id;
|
|
1392
1394
|
if (credentialId === undefined) return blockedUntil;
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1395
|
+
if (!blockScope || provider !== "openai-codex") {
|
|
1396
|
+
const persistedGlobalBlockedUntil = this.#readPersistedCredentialBlock(credentialId, providerKey, "");
|
|
1397
|
+
if (
|
|
1398
|
+
persistedGlobalBlockedUntil !== undefined &&
|
|
1399
|
+
(blockedUntil === undefined || persistedGlobalBlockedUntil > blockedUntil)
|
|
1400
|
+
) {
|
|
1401
|
+
blockedUntil = persistedGlobalBlockedUntil;
|
|
1402
|
+
}
|
|
1399
1403
|
}
|
|
1400
1404
|
if (blockScope) {
|
|
1401
1405
|
const persistedScopedBlockedUntil = this.#readPersistedCredentialBlock(credentialId, providerKey, blockScope);
|
|
@@ -1433,6 +1437,7 @@ export class AuthStorage {
|
|
|
1433
1437
|
const nextBlockedUntil = Math.max(existing, blockedUntilMs);
|
|
1434
1438
|
backoffMap.set(credentialIndex, nextBlockedUntil);
|
|
1435
1439
|
this.#credentialBackoff.set(backoffKey, backoffMap);
|
|
1440
|
+
this.#invalidateUsageReportCache(provider);
|
|
1436
1441
|
|
|
1437
1442
|
const upsertCredentialBlock = this.#store.upsertCredentialBlock?.bind(this.#store);
|
|
1438
1443
|
if (!upsertCredentialBlock) return;
|
|
@@ -1554,13 +1559,14 @@ export class AuthStorage {
|
|
|
1554
1559
|
provider: string,
|
|
1555
1560
|
type: T,
|
|
1556
1561
|
sessionId?: string,
|
|
1562
|
+
filter?: (credential: AuthCredential) => boolean,
|
|
1557
1563
|
): { credential: Extract<AuthCredential, { type: T }>; index: number } | undefined {
|
|
1558
1564
|
const credentials = this.#getCredentialsForProvider(provider)
|
|
1559
1565
|
.map((credential, index) => ({ credential, index }))
|
|
1560
|
-
.filter(
|
|
1561
|
-
(entry
|
|
1562
|
-
|
|
1563
|
-
);
|
|
1566
|
+
.filter((entry): entry is { credential: Extract<AuthCredential, { type: T }>; index: number } => {
|
|
1567
|
+
if (entry.credential.type !== type) return false;
|
|
1568
|
+
return filter?.(entry.credential) ?? true;
|
|
1569
|
+
});
|
|
1564
1570
|
|
|
1565
1571
|
if (credentials.length === 0) return undefined;
|
|
1566
1572
|
if (credentials.length === 1) return credentials[0];
|
|
@@ -1851,8 +1857,8 @@ export class AuthStorage {
|
|
|
1851
1857
|
/**
|
|
1852
1858
|
* Classify where a provider's auth comes from, following the same precedence
|
|
1853
1859
|
* as {@link AuthStorage.getApiKey}: runtime override → config override →
|
|
1854
|
-
* stored OAuth → env var → stored api_key →
|
|
1855
|
-
* undefined when no auth is configured.
|
|
1860
|
+
* stored OAuth → login-stored api_key → env var → stored api_key →
|
|
1861
|
+
* fallback resolver. Returns undefined when no auth is configured.
|
|
1856
1862
|
*
|
|
1857
1863
|
* Compact, structured counterpart to {@link describeCredentialSource}.
|
|
1858
1864
|
*/
|
|
@@ -1861,6 +1867,9 @@ export class AuthStorage {
|
|
|
1861
1867
|
if (this.#configOverrides.has(provider)) return { kind: "config" };
|
|
1862
1868
|
const stored = this.#getCredentialsForProvider(provider);
|
|
1863
1869
|
if (stored.some(credential => credential.type === "oauth")) return { kind: "oauth" };
|
|
1870
|
+
if (stored.some(credential => credential.type === "api_key" && credential.source === "login")) {
|
|
1871
|
+
return { kind: "api_key" };
|
|
1872
|
+
}
|
|
1864
1873
|
if (getEnvApiKey(provider)) return { kind: "env", envVar: getEnvApiKeyName(provider) };
|
|
1865
1874
|
if (stored.some(credential => credential.type === "api_key")) return { kind: "api_key" };
|
|
1866
1875
|
if (this.#fallbackResolver?.(provider)) return { kind: "fallback" };
|
|
@@ -2005,7 +2014,7 @@ export class AuthStorage {
|
|
|
2005
2014
|
if (!result) {
|
|
2006
2015
|
return;
|
|
2007
2016
|
}
|
|
2008
|
-
const newCredential: ApiKeyCredential = { type: "api_key", key: result };
|
|
2017
|
+
const newCredential: ApiKeyCredential = { type: "api_key", key: result, source: "login" };
|
|
2009
2018
|
const stored = this.#store.upsertAuthCredentialRemote
|
|
2010
2019
|
? await this.#store.upsertAuthCredentialRemote(provider, newCredential)
|
|
2011
2020
|
: this.#store.upsertAuthCredentialForProvider(provider, newCredential);
|
|
@@ -2336,8 +2345,10 @@ export class AuthStorage {
|
|
|
2336
2345
|
const inFlight = this.#usageRequestInFlight.get(cacheKey);
|
|
2337
2346
|
if (inFlight) return inFlight;
|
|
2338
2347
|
|
|
2348
|
+
const usageCacheEpoch = this.#usageCacheEpoch;
|
|
2339
2349
|
const promise = (async () => {
|
|
2340
2350
|
const report = await this.#fetchUsageUncached(request, timeoutMs);
|
|
2351
|
+
if (usageCacheEpoch !== this.#usageCacheEpoch) return report;
|
|
2341
2352
|
const ttlJitter = USAGE_REPORT_TTL_MS * (Math.random() * 0.5 - 0.25);
|
|
2342
2353
|
if (report !== null) {
|
|
2343
2354
|
// Success: stagger per-credential cache expiry so all accounts don't
|
|
@@ -2347,6 +2358,7 @@ export class AuthStorage {
|
|
|
2347
2358
|
// times decorrelate within a few cycles.
|
|
2348
2359
|
this.#usageCache.set(cacheKey, { value: report, expiresAt: Date.now() + USAGE_REPORT_TTL_MS + ttlJitter });
|
|
2349
2360
|
this.#recordUsageHistory(request, report);
|
|
2361
|
+
this.#reconcileCodexUsageBlock(request, report);
|
|
2350
2362
|
return report;
|
|
2351
2363
|
}
|
|
2352
2364
|
// Failure: apply a short jittered cool-down so the credential doesn't
|
|
@@ -2761,7 +2773,14 @@ export class AuthStorage {
|
|
|
2761
2773
|
// whole point of routing through it.
|
|
2762
2774
|
const storeHook = this.#store.getUsageReport?.bind(this.#store);
|
|
2763
2775
|
if (storeHook) {
|
|
2764
|
-
|
|
2776
|
+
const report = await storeHook(provider, credential, options?.signal);
|
|
2777
|
+
if (report) {
|
|
2778
|
+
this.#reconcileCodexUsageBlock(
|
|
2779
|
+
this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
|
|
2780
|
+
report,
|
|
2781
|
+
);
|
|
2782
|
+
}
|
|
2783
|
+
return report;
|
|
2765
2784
|
}
|
|
2766
2785
|
return this.#fetchUsageCached(
|
|
2767
2786
|
this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
|
|
@@ -2789,7 +2808,10 @@ export class AuthStorage {
|
|
|
2789
2808
|
// `RemoteAuthCredentialStore` implements the store hook so a gateway
|
|
2790
2809
|
// backed by a broker automatically routes usage to the broker without
|
|
2791
2810
|
// needing the caller to wire it explicitly.
|
|
2792
|
-
const
|
|
2811
|
+
const storeOverride = this.#store.fetchUsageReports?.bind(this.#store);
|
|
2812
|
+
const override = this.#fetchUsageReportsOverride ?? storeOverride;
|
|
2813
|
+
const shouldReconcileStoreHookReports =
|
|
2814
|
+
this.#fetchUsageReportsOverride === undefined && storeOverride !== undefined;
|
|
2793
2815
|
if (override) {
|
|
2794
2816
|
// Reuse the in-flight map so concurrent callers (widget poll + format
|
|
2795
2817
|
// dispatch + credential selection) coalesce into one upstream call.
|
|
@@ -2805,7 +2827,9 @@ export class AuthStorage {
|
|
|
2805
2827
|
});
|
|
2806
2828
|
this.#usageReportsInFlight.set(OVERRIDE_KEY, shared);
|
|
2807
2829
|
}
|
|
2808
|
-
|
|
2830
|
+
const reports = await raceUsageWithSignal(shared, options?.signal);
|
|
2831
|
+
if (shouldReconcileStoreHookReports && reports) this.#reconcileCodexUsageBlocksFromReports(reports);
|
|
2832
|
+
return reports;
|
|
2809
2833
|
}
|
|
2810
2834
|
if (!this.#usageProviderResolver) return null;
|
|
2811
2835
|
|
|
@@ -3057,9 +3081,20 @@ export class AuthStorage {
|
|
|
3057
3081
|
async markUsageLimitReached(
|
|
3058
3082
|
provider: string,
|
|
3059
3083
|
sessionId: string | undefined,
|
|
3060
|
-
options?: { retryAfterMs?: number; baseUrl?: string; modelId?: string; signal?: AbortSignal },
|
|
3084
|
+
options?: { retryAfterMs?: number; baseUrl?: string; modelId?: string; apiKey?: string; signal?: AbortSignal },
|
|
3061
3085
|
): Promise<UsageLimitMarkResult> {
|
|
3062
|
-
|
|
3086
|
+
let sessionCredential: { type: AuthCredential["type"]; index: number } | undefined;
|
|
3087
|
+
if (options?.apiKey) {
|
|
3088
|
+
const stored = this.#getStoredCredentials(provider);
|
|
3089
|
+
for (let index = 0; index < stored.length; index++) {
|
|
3090
|
+
const entry = stored[index];
|
|
3091
|
+
if (entry && (await this.#credentialMatchesApiKey(entry.credential, options.apiKey))) {
|
|
3092
|
+
sessionCredential = { type: entry.credential.type, index };
|
|
3093
|
+
break;
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
sessionCredential ??= this.#getSessionCredential(provider, sessionId);
|
|
3063
3098
|
if (!sessionCredential) return { switched: false };
|
|
3064
3099
|
|
|
3065
3100
|
const providerKey = this.#getProviderTypeKey(provider, sessionCredential.type);
|
|
@@ -3387,12 +3422,21 @@ export class AuthStorage {
|
|
|
3387
3422
|
const checkUsage = strategy !== undefined && (credentials.length > 1 || requiresProModel);
|
|
3388
3423
|
const sessionCredential = this.#getSessionCredential(provider, sessionId);
|
|
3389
3424
|
const sessionPreferredIndex = sessionCredential?.type === "oauth" ? sessionCredential.index : undefined;
|
|
3425
|
+
const sessionPreferredCredential =
|
|
3426
|
+
sessionPreferredIndex !== undefined
|
|
3427
|
+
? credentials.find(entry => entry.index === sessionPreferredIndex)?.credential
|
|
3428
|
+
: undefined;
|
|
3429
|
+
const sessionPreferredCanRefreshOrUse =
|
|
3430
|
+
sessionPreferredCredential !== undefined &&
|
|
3431
|
+
(sessionPreferredCredential.refresh.trim().length > 0 ||
|
|
3432
|
+
Date.now() + OAUTH_REFRESH_SKEW_MS < sessionPreferredCredential.expires);
|
|
3390
3433
|
// Skip ranking only when the session already has a working preferred credential — re-ranking
|
|
3391
3434
|
// mid-session causes account switches that cold-start the server-side prompt cache. New sessions
|
|
3392
3435
|
// (no preference) and sessions whose preferred is blocked still rank, so we pick the account
|
|
3393
3436
|
// with the most headroom proactively and fall back intelligently when rate-limited.
|
|
3394
3437
|
const sessionPreferredIsAvailable =
|
|
3395
3438
|
sessionPreferredIndex !== undefined &&
|
|
3439
|
+
sessionPreferredCanRefreshOrUse &&
|
|
3396
3440
|
!this.#isCredentialBlocked(provider, providerKey, sessionPreferredIndex, blockScope);
|
|
3397
3441
|
const shouldRank = checkUsage && (!sessionPreferredIsAvailable || requiresProModel);
|
|
3398
3442
|
const rankingOrder = shouldRank && sessionId ? credentials.map((_credential, index) => index) : order;
|
|
@@ -3899,8 +3943,8 @@ export class AuthStorage {
|
|
|
3899
3943
|
return configKey;
|
|
3900
3944
|
}
|
|
3901
3945
|
|
|
3902
|
-
// Precedence: a deliberate OAuth
|
|
3903
|
-
// static api_key (which may be a stale broker-migrated copy) as a last resort.
|
|
3946
|
+
// Precedence: a deliberate OAuth/login credential wins, then an explicit env var,
|
|
3947
|
+
// then a stored static api_key (which may be a stale broker-migrated copy) as a last resort.
|
|
3904
3948
|
const oauthSelection = this.#selectCredentialByType(provider, "oauth");
|
|
3905
3949
|
if (oauthSelection) {
|
|
3906
3950
|
const expiresAt = oauthSelection.credential.expires;
|
|
@@ -3916,6 +3960,16 @@ export class AuthStorage {
|
|
|
3916
3960
|
}
|
|
3917
3961
|
}
|
|
3918
3962
|
|
|
3963
|
+
const loginApiKeySelection = this.#selectCredentialByType(
|
|
3964
|
+
provider,
|
|
3965
|
+
"api_key",
|
|
3966
|
+
undefined,
|
|
3967
|
+
credential => credential.type === "api_key" && credential.source === "login",
|
|
3968
|
+
);
|
|
3969
|
+
if (loginApiKeySelection) {
|
|
3970
|
+
return this.#configValueResolver(loginApiKeySelection.credential.key);
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3919
3973
|
const envKey = getEnvApiKey(provider);
|
|
3920
3974
|
if (envKey) return envKey;
|
|
3921
3975
|
|
|
@@ -3933,9 +3987,10 @@ export class AuthStorage {
|
|
|
3933
3987
|
* 1. Runtime override (CLI --api-key)
|
|
3934
3988
|
* 2. Config override (models.yml `providers.<name>.apiKey`)
|
|
3935
3989
|
* 3. OAuth token from storage (auto-refreshed)
|
|
3936
|
-
* 4.
|
|
3937
|
-
* 5.
|
|
3938
|
-
* 6.
|
|
3990
|
+
* 4. API key persisted by a successful `/login`
|
|
3991
|
+
* 5. Environment variable
|
|
3992
|
+
* 6. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
|
|
3993
|
+
* 7. Fallback resolver (models.yml custom providers, last-resort)
|
|
3939
3994
|
*/
|
|
3940
3995
|
async getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
|
|
3941
3996
|
// Runtime override takes highest priority
|
|
@@ -3954,13 +4009,24 @@ export class AuthStorage {
|
|
|
3954
4009
|
return configKey;
|
|
3955
4010
|
}
|
|
3956
4011
|
|
|
3957
|
-
// Precedence: a deliberate OAuth
|
|
3958
|
-
// static api_key (which may be a stale broker-migrated copy) as a last resort.
|
|
4012
|
+
// Precedence: a deliberate OAuth/login credential wins, then an explicit env var,
|
|
4013
|
+
// then a stored static api_key (which may be a stale broker-migrated copy) as a last resort.
|
|
3959
4014
|
const oauthResolved = await this.#resolveOAuthSelection(provider, sessionId, options);
|
|
3960
4015
|
if (oauthResolved) {
|
|
3961
4016
|
return oauthResolved.apiKey;
|
|
3962
4017
|
}
|
|
3963
4018
|
|
|
4019
|
+
const loginApiKeySelection = this.#selectCredentialByType(
|
|
4020
|
+
provider,
|
|
4021
|
+
"api_key",
|
|
4022
|
+
sessionId,
|
|
4023
|
+
credential => credential.type === "api_key" && credential.source === "login",
|
|
4024
|
+
);
|
|
4025
|
+
if (loginApiKeySelection) {
|
|
4026
|
+
this.#recordSessionCredential(provider, sessionId, "api_key", loginApiKeySelection.index);
|
|
4027
|
+
return this.#configValueResolver(loginApiKeySelection.credential.key);
|
|
4028
|
+
}
|
|
4029
|
+
|
|
3964
4030
|
// Past OAuth: the session sticky (if any) is stale — the request authenticates via
|
|
3965
4031
|
// env/api_key/fallback, not OAuth, so clear it now so getOAuthAccountId() correctly
|
|
3966
4032
|
// suppresses account_uuid for this session.
|
|
@@ -3969,7 +4035,12 @@ export class AuthStorage {
|
|
|
3969
4035
|
const envKey = getEnvApiKey(provider);
|
|
3970
4036
|
if (envKey) return envKey;
|
|
3971
4037
|
|
|
3972
|
-
const apiKeySelection = this.#selectCredentialByType(
|
|
4038
|
+
const apiKeySelection = this.#selectCredentialByType(
|
|
4039
|
+
provider,
|
|
4040
|
+
"api_key",
|
|
4041
|
+
sessionId,
|
|
4042
|
+
credential => credential.type !== "api_key" || credential.source !== "login",
|
|
4043
|
+
);
|
|
3973
4044
|
if (apiKeySelection) {
|
|
3974
4045
|
this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
|
|
3975
4046
|
return this.#configValueResolver(apiKeySelection.credential.key);
|
|
@@ -4255,6 +4326,7 @@ export class AuthStorage {
|
|
|
4255
4326
|
* `/usage` reflects a freshly-redeemed reset instead of stale numbers.
|
|
4256
4327
|
*/
|
|
4257
4328
|
#invalidateUsageReportCache(provider: string, baseUrl?: string): void {
|
|
4329
|
+
this.#usageCacheEpoch += 1;
|
|
4258
4330
|
const expired = Date.now() - 1;
|
|
4259
4331
|
for (const entry of this.#getStoredCredentials(provider)) {
|
|
4260
4332
|
if (entry.credential.type !== "oauth") continue;
|
|
@@ -4266,6 +4338,12 @@ export class AuthStorage {
|
|
|
4266
4338
|
}
|
|
4267
4339
|
}
|
|
4268
4340
|
|
|
4341
|
+
#invalidateUsageReportCacheForProviderKey(providerKey: string): void {
|
|
4342
|
+
const oauthSuffix = ":oauth";
|
|
4343
|
+
if (!providerKey.endsWith(oauthSuffix)) return;
|
|
4344
|
+
this.#invalidateUsageReportCache(providerKey.slice(0, -oauthSuffix.length));
|
|
4345
|
+
}
|
|
4346
|
+
|
|
4269
4347
|
/**
|
|
4270
4348
|
* Lift any temporary backoff blocks on one credential (across the bare
|
|
4271
4349
|
* `provider:oauth` key and its scoped `\0`-suffixed derivatives). Called
|
|
@@ -4274,13 +4352,10 @@ export class AuthStorage {
|
|
|
4274
4352
|
* that `markUsageLimitReached` set for the now-obsolete reset time.
|
|
4275
4353
|
*/
|
|
4276
4354
|
#clearCredentialBlocks(provider: string, credentialId: number): void {
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
} catch (err) {
|
|
4282
|
-
logger.debug("Failed to clear persisted credential blocks", { err, provider, credentialId });
|
|
4283
|
-
}
|
|
4355
|
+
try {
|
|
4356
|
+
this.deleteCredentialBlocks(credentialId);
|
|
4357
|
+
} catch (err) {
|
|
4358
|
+
logger.debug("Failed to clear persisted credential blocks", { err, provider, credentialId });
|
|
4284
4359
|
}
|
|
4285
4360
|
|
|
4286
4361
|
const index = this.#getStoredCredentials(provider).findIndex(entry => entry.id === credentialId);
|
|
@@ -4294,6 +4369,80 @@ export class AuthStorage {
|
|
|
4294
4369
|
}
|
|
4295
4370
|
}
|
|
4296
4371
|
|
|
4372
|
+
/**
|
|
4373
|
+
* Self-heal a stale Codex usage-limit block: when a fresh live usage report
|
|
4374
|
+
* shows the account is allowed and below every limit, drop the persisted and
|
|
4375
|
+
* in-memory `openai-codex:oauth` blocks so the balancer re-includes it. Only
|
|
4376
|
+
* Codex — its ranking strategy uses the single model-independent `"shared"`
|
|
4377
|
+
* scope, so clearing every block for the credential id is exact.
|
|
4378
|
+
*/
|
|
4379
|
+
#isHealthyCodexUsageReport(report: UsageReport): boolean {
|
|
4380
|
+
const metadata = report.metadata;
|
|
4381
|
+
return (
|
|
4382
|
+
report.provider === "openai-codex" &&
|
|
4383
|
+
metadata?.allowed === true &&
|
|
4384
|
+
metadata.limitReached === false &&
|
|
4385
|
+
!this.#isUsageLimitReached(report.limits)
|
|
4386
|
+
);
|
|
4387
|
+
}
|
|
4388
|
+
|
|
4389
|
+
#reconcileCodexUsageBlockForCredential(provider: Provider, credentialId: number, report: UsageReport): void {
|
|
4390
|
+
if (!this.#isHealthyCodexUsageReport(report)) return;
|
|
4391
|
+
const providerKey = this.#getProviderTypeKey(provider, "oauth");
|
|
4392
|
+
const credentialIndex = this.#getStoredCredentials(provider).findIndex(entry => entry.id === credentialId);
|
|
4393
|
+
if (credentialIndex < 0) return;
|
|
4394
|
+
// Mirror selection: consult the same strategy scope `markUsageLimitReached`
|
|
4395
|
+
// persists under, else a scoped block is invisible here and never healed.
|
|
4396
|
+
const blockScope = this.#rankingStrategyResolver?.(provider)?.blockScope?.({});
|
|
4397
|
+
const blockedUntilMs = this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex, blockScope);
|
|
4398
|
+
if (blockedUntilMs === undefined) return;
|
|
4399
|
+
this.#clearCredentialBlocks(provider, credentialId);
|
|
4400
|
+
logger.info("Cleared stale Codex usage-limit block after healthy live usage report", {
|
|
4401
|
+
credentialId,
|
|
4402
|
+
provider,
|
|
4403
|
+
clearedBlockedUntilMs: blockedUntilMs,
|
|
4404
|
+
});
|
|
4405
|
+
}
|
|
4406
|
+
|
|
4407
|
+
#reconcileCodexUsageBlock(request: UsageRequestDescriptor, report: UsageReport): void {
|
|
4408
|
+
if (request.provider !== "openai-codex") return;
|
|
4409
|
+
const credentialId = this.#findStoredCredentialIdForUsageCredential(request.provider, request.credential);
|
|
4410
|
+
if (credentialId === undefined) return;
|
|
4411
|
+
this.#reconcileCodexUsageBlockForCredential(request.provider, credentialId, report);
|
|
4412
|
+
}
|
|
4413
|
+
|
|
4414
|
+
#findStoredCredentialIdsForUsageReport(report: UsageReport): number[] {
|
|
4415
|
+
if (report.provider !== "openai-codex") return [];
|
|
4416
|
+
const email = this.#getUsageReportMetadataValue(report, "email")?.toLowerCase();
|
|
4417
|
+
const accountId = (
|
|
4418
|
+
this.#getUsageReportMetadataValue(report, "accountId") ?? this.#getUsageReportScopeAccountId(report)
|
|
4419
|
+
)?.toLowerCase();
|
|
4420
|
+
if (!email && !accountId) return [];
|
|
4421
|
+
const matches: number[] = [];
|
|
4422
|
+
for (const entry of this.#getStoredCredentials(report.provider)) {
|
|
4423
|
+
const credential = entry.credential;
|
|
4424
|
+
if (credential.type !== "oauth") continue;
|
|
4425
|
+
const credentialEmail = credential.email?.trim().toLowerCase();
|
|
4426
|
+
const credentialAccountId = credential.accountId?.trim().toLowerCase();
|
|
4427
|
+
if ((email && credentialEmail === email) || (accountId && credentialAccountId === accountId)) {
|
|
4428
|
+
matches.push(entry.id);
|
|
4429
|
+
}
|
|
4430
|
+
}
|
|
4431
|
+
return matches;
|
|
4432
|
+
}
|
|
4433
|
+
|
|
4434
|
+
#reconcileCodexUsageBlocksFromReports(reports: UsageReport[]): void {
|
|
4435
|
+
const reconciled = new Set<number>();
|
|
4436
|
+
for (const report of reports) {
|
|
4437
|
+
if (!this.#isHealthyCodexUsageReport(report)) continue;
|
|
4438
|
+
for (const credentialId of this.#findStoredCredentialIdsForUsageReport(report)) {
|
|
4439
|
+
if (reconciled.has(credentialId)) continue;
|
|
4440
|
+
reconciled.add(credentialId);
|
|
4441
|
+
this.#reconcileCodexUsageBlockForCredential(report.provider, credentialId, report);
|
|
4442
|
+
}
|
|
4443
|
+
}
|
|
4444
|
+
}
|
|
4445
|
+
|
|
4297
4446
|
#extractStructuredApiKeyToken(apiKey: string): string | undefined {
|
|
4298
4447
|
if (!apiKey.startsWith("{")) return undefined;
|
|
4299
4448
|
try {
|
|
@@ -4381,7 +4530,7 @@ export class AuthStorage {
|
|
|
4381
4530
|
async rotateSessionCredential(
|
|
4382
4531
|
provider: string,
|
|
4383
4532
|
sessionId: string | undefined,
|
|
4384
|
-
options?: { error?: unknown; modelId?: string; signal?: AbortSignal },
|
|
4533
|
+
options?: { error?: unknown; modelId?: string; apiKey?: string; signal?: AbortSignal },
|
|
4385
4534
|
): Promise<boolean> {
|
|
4386
4535
|
const sessionCredential = this.#getSessionCredential(provider, sessionId);
|
|
4387
4536
|
if (!sessionCredential) return false;
|
|
@@ -4393,6 +4542,7 @@ export class AuthStorage {
|
|
|
4393
4542
|
return (
|
|
4394
4543
|
await this.markUsageLimitReached(provider, sessionId, {
|
|
4395
4544
|
modelId: options?.modelId,
|
|
4545
|
+
apiKey: options?.apiKey,
|
|
4396
4546
|
signal: options?.signal,
|
|
4397
4547
|
})
|
|
4398
4548
|
).switched;
|
|
@@ -4654,6 +4804,7 @@ export class AuthStorage {
|
|
|
4654
4804
|
const upsertCredentialBlock = this.#store.upsertCredentialBlock?.bind(this.#store);
|
|
4655
4805
|
if (!upsertCredentialBlock) return;
|
|
4656
4806
|
upsertCredentialBlock(block);
|
|
4807
|
+
this.#invalidateUsageReportCacheForProviderKey(block.providerKey);
|
|
4657
4808
|
this.#bumpGeneration("credential-block");
|
|
4658
4809
|
}
|
|
4659
4810
|
|
|
@@ -4674,9 +4825,10 @@ export class AuthStorage {
|
|
|
4674
4825
|
* 1. Runtime override (`--api-key`).
|
|
4675
4826
|
* 2. Config override (`models.yml` `providers.<name>.apiKey`).
|
|
4676
4827
|
* 3. Stored OAuth credential.
|
|
4677
|
-
* 4.
|
|
4678
|
-
* 5.
|
|
4679
|
-
* 6.
|
|
4828
|
+
* 4. API key persisted by a successful `/login`.
|
|
4829
|
+
* 5. Env var — overrides a stored static api_key (e.g. a stale broker copy).
|
|
4830
|
+
* 6. Stored api_key credential.
|
|
4831
|
+
* 7. Fallback resolver.
|
|
4680
4832
|
*
|
|
4681
4833
|
* The string is purely informational; consumers must not parse it.
|
|
4682
4834
|
*/
|
|
@@ -4691,14 +4843,16 @@ export class AuthStorage {
|
|
|
4691
4843
|
const baseLabel = this.#sourceLabel ?? "local store";
|
|
4692
4844
|
const stored = this.#getStoredCredentials(provider);
|
|
4693
4845
|
const session = sessionId ? this.#sessionLastCredential.get(provider)?.get(sessionId) : undefined;
|
|
4694
|
-
|
|
4695
|
-
|
|
4846
|
+
const describeStored = (
|
|
4847
|
+
type: AuthCredential["type"],
|
|
4848
|
+
filter?: (credential: AuthCredential) => boolean,
|
|
4849
|
+
): string | undefined => {
|
|
4696
4850
|
const typed = stored
|
|
4697
4851
|
.map((entry, index) => ({ entry, index }))
|
|
4698
|
-
.filter(({ entry }) => entry.credential.type === type);
|
|
4852
|
+
.filter(({ entry }) => entry.credential.type === type && (filter?.(entry.credential) ?? true));
|
|
4699
4853
|
if (typed.length === 0) return undefined;
|
|
4700
|
-
const
|
|
4701
|
-
const chosen =
|
|
4854
|
+
const sticky = session?.type === type ? typed.find(entry => entry.index === session.index) : undefined;
|
|
4855
|
+
const chosen = sticky?.entry ?? typed[0].entry;
|
|
4702
4856
|
const credential = chosen.credential;
|
|
4703
4857
|
const identity =
|
|
4704
4858
|
credential.type === "oauth"
|
|
@@ -4707,11 +4861,19 @@ export class AuthStorage {
|
|
|
4707
4861
|
return `${baseLabel} · ${type} #${chosen.id} (${identity})`;
|
|
4708
4862
|
};
|
|
4709
4863
|
|
|
4710
|
-
//
|
|
4864
|
+
// Deliberate login credentials win; then an explicit env var; then a stored static api_key.
|
|
4711
4865
|
const oauthSource = describeStored("oauth");
|
|
4712
4866
|
if (oauthSource) return oauthSource;
|
|
4867
|
+
const loginApiKeySource = describeStored(
|
|
4868
|
+
"api_key",
|
|
4869
|
+
credential => credential.type === "api_key" && credential.source === "login",
|
|
4870
|
+
);
|
|
4871
|
+
if (loginApiKeySource) return loginApiKeySource;
|
|
4713
4872
|
if (getEnvApiKey(provider)) return `env (over ${baseLabel})`;
|
|
4714
|
-
const apiKeySource = describeStored(
|
|
4873
|
+
const apiKeySource = describeStored(
|
|
4874
|
+
"api_key",
|
|
4875
|
+
credential => credential.type !== "api_key" || credential.source !== "login",
|
|
4876
|
+
);
|
|
4715
4877
|
if (apiKeySource) return apiKeySource;
|
|
4716
4878
|
if (this.#fallbackResolver?.(provider) !== undefined) return "fallback resolver";
|
|
4717
4879
|
return undefined;
|
|
@@ -4776,9 +4938,10 @@ function normalizeStoredIdentityKey(identityKey: string | null | undefined): str
|
|
|
4776
4938
|
|
|
4777
4939
|
function serializeCredential(provider: string, credential: AuthCredential): SerializedCredentialRecord | null {
|
|
4778
4940
|
if (credential.type === "api_key") {
|
|
4941
|
+
const data = credential.source === "login" ? { key: credential.key, source: "login" } : { key: credential.key };
|
|
4779
4942
|
return {
|
|
4780
4943
|
credentialType: "api_key",
|
|
4781
|
-
data: JSON.stringify(
|
|
4944
|
+
data: JSON.stringify(data),
|
|
4782
4945
|
identityKey: null,
|
|
4783
4946
|
};
|
|
4784
4947
|
}
|
|
@@ -4806,7 +4969,8 @@ function deserializeCredential(row: AuthRow): AuthCredential | null {
|
|
|
4806
4969
|
if (row.credential_type === "api_key") {
|
|
4807
4970
|
const data = parsed as Record<string, unknown>;
|
|
4808
4971
|
if (typeof data.key === "string") {
|
|
4809
|
-
|
|
4972
|
+
const source = data.source === "login" ? "login" : undefined;
|
|
4973
|
+
return source ? { type: "api_key", key: data.key, source } : { type: "api_key", key: data.key };
|
|
4810
4974
|
}
|
|
4811
4975
|
}
|
|
4812
4976
|
if (row.credential_type === "oauth") {
|
|
@@ -127,12 +127,13 @@ export function buildBetaHeader(baseBetas: readonly string[], extraBetas: readon
|
|
|
127
127
|
|
|
128
128
|
const midConversationSystemBeta = "mid-conversation-system-2026-04-07";
|
|
129
129
|
const contextManagementBeta = "context-management-2025-06-27";
|
|
130
|
+
const structuredOutputsBeta = "structured-outputs-2025-12-15";
|
|
130
131
|
const claudeCodeUtilityBetaDefaults = [
|
|
131
132
|
"oauth-2025-04-20",
|
|
132
133
|
"interleaved-thinking-2025-05-14",
|
|
133
134
|
contextManagementBeta,
|
|
134
135
|
"prompt-caching-scope-2026-01-05",
|
|
135
|
-
|
|
136
|
+
structuredOutputsBeta,
|
|
136
137
|
] as const;
|
|
137
138
|
const claudeCodeAgentBetaDefaults = [
|
|
138
139
|
"claude-code-20250219",
|
|
@@ -159,10 +160,12 @@ function buildClaudeCodeBetas(
|
|
|
159
160
|
agentRequest: boolean,
|
|
160
161
|
thinkingRequest: boolean,
|
|
161
162
|
redactThinking: boolean,
|
|
163
|
+
disableStrictTools = false,
|
|
162
164
|
): readonly string[] {
|
|
163
|
-
if (!agentRequest && !redactThinking) return claudeCodeUtilityBetaDefaults;
|
|
165
|
+
if (!agentRequest && !redactThinking && !disableStrictTools) return claudeCodeUtilityBetaDefaults;
|
|
164
166
|
const betas: string[] = [];
|
|
165
167
|
for (const beta of agentRequest ? claudeCodeAgentBetaDefaults : claudeCodeUtilityBetaDefaults) {
|
|
168
|
+
if (disableStrictTools && beta === structuredOutputsBeta) continue;
|
|
166
169
|
betas.push(beta);
|
|
167
170
|
// Match CC's header order: redact-thinking immediately follows interleaved-thinking.
|
|
168
171
|
if (redactThinking && beta === interleavedThinkingBeta) betas.push(redactThinkingBeta);
|
|
@@ -1098,6 +1101,7 @@ export type AnthropicClientOptionsArgs = {
|
|
|
1098
1101
|
hasTools?: boolean;
|
|
1099
1102
|
thinkingEnabled?: boolean;
|
|
1100
1103
|
thinkingDisplay?: AnthropicThinkingDisplay;
|
|
1104
|
+
disableStrictTools?: boolean;
|
|
1101
1105
|
fetch?: FetchImpl;
|
|
1102
1106
|
claudeCodeSessionId?: string;
|
|
1103
1107
|
};
|
|
@@ -1833,6 +1837,7 @@ const streamAnthropicOnce = (
|
|
|
1833
1837
|
thinkingDisplay: options?.thinkingDisplay,
|
|
1834
1838
|
fetch: options?.fetch,
|
|
1835
1839
|
claudeCodeSessionId: options?.sessionId ?? extractClaudeMetadataSessionId(options?.metadata?.user_id),
|
|
1840
|
+
disableStrictTools,
|
|
1836
1841
|
});
|
|
1837
1842
|
client = created.client;
|
|
1838
1843
|
isOAuthToken = created.isOAuthToken;
|
|
@@ -2648,8 +2653,10 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
2648
2653
|
thinkingDisplay,
|
|
2649
2654
|
isOAuth,
|
|
2650
2655
|
claudeCodeSessionId,
|
|
2656
|
+
disableStrictTools: disableStrictToolsOverride,
|
|
2651
2657
|
} = args;
|
|
2652
2658
|
const compat = model.compat;
|
|
2659
|
+
const disableStrictTools = disableStrictToolsOverride ?? compat.disableStrictTools;
|
|
2653
2660
|
const needsInterleavedBeta = interleavedThinking && !model.thinking?.supportsDisplay;
|
|
2654
2661
|
const needsFineGrainedToolStreamingBeta = hasTools && !compat.supportsEagerToolInputStreaming;
|
|
2655
2662
|
const oauthToken = isOAuth ?? isAnthropicOAuthToken(apiKey);
|
|
@@ -2722,7 +2729,12 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
2722
2729
|
isCloudflareAiGateway: model.provider === "cloudflare-ai-gateway",
|
|
2723
2730
|
claudeCodeSessionId,
|
|
2724
2731
|
claudeCodeBetas: oauthToken
|
|
2725
|
-
? buildClaudeCodeBetas(
|
|
2732
|
+
? buildClaudeCodeBetas(
|
|
2733
|
+
hasTools || thinkingEnabled,
|
|
2734
|
+
thinkingEnabled,
|
|
2735
|
+
thinkingDisplay === "omitted",
|
|
2736
|
+
disableStrictTools,
|
|
2737
|
+
)
|
|
2726
2738
|
: [],
|
|
2727
2739
|
});
|
|
2728
2740
|
|
|
@@ -3529,6 +3541,40 @@ export function convertAnthropicMessages(
|
|
|
3529
3541
|
});
|
|
3530
3542
|
}
|
|
3531
3543
|
}
|
|
3544
|
+
// Anthropic's replay validator rejects any non-`tool_use` block that
|
|
3545
|
+
// appears after a `tool_use` inside an assistant turn (400:
|
|
3546
|
+
// "tool_use ids were found without tool_result blocks immediately
|
|
3547
|
+
// after: <id>"). A persisted turn can violate this when a mid-turn
|
|
3548
|
+
// server-side fallback handoff lands after the primary model already
|
|
3549
|
+
// emitted a tool_use — the replayed content is then e.g.
|
|
3550
|
+
// [thinking, text, tool_use, fallback, text, tool_use] — and also for
|
|
3551
|
+
// the older cross-provider [text, tool_use, text] shape (issue #544).
|
|
3552
|
+
// Stable-partition into [...non-tool_use, ...tool_use], preserving each
|
|
3553
|
+
// side's relative order: the non-tool_use chain (thinking → text →
|
|
3554
|
+
// fallback → text) carries thinking signatures and the fallback
|
|
3555
|
+
// boundary marker whose order Anthropic verifies, while tool_use blocks
|
|
3556
|
+
// are unsigned and safe to defer to the tail. Fast-path untouched when
|
|
3557
|
+
// already in order so prompt-cache prefixes stay byte-identical.
|
|
3558
|
+
let sawToolUse = false;
|
|
3559
|
+
let needsPartition = false;
|
|
3560
|
+
for (const block of blocks) {
|
|
3561
|
+
if (block.type === "tool_use") {
|
|
3562
|
+
sawToolUse = true;
|
|
3563
|
+
} else if (sawToolUse) {
|
|
3564
|
+
needsPartition = true;
|
|
3565
|
+
break;
|
|
3566
|
+
}
|
|
3567
|
+
}
|
|
3568
|
+
if (needsPartition) {
|
|
3569
|
+
const nonToolUse: ContentBlockParam[] = [];
|
|
3570
|
+
const toolUse: ContentBlockParam[] = [];
|
|
3571
|
+
for (const block of blocks) {
|
|
3572
|
+
if (block.type === "tool_use") toolUse.push(block);
|
|
3573
|
+
else nonToolUse.push(block);
|
|
3574
|
+
}
|
|
3575
|
+
blocks.length = 0;
|
|
3576
|
+
blocks.push(...nonToolUse, ...toolUse);
|
|
3577
|
+
}
|
|
3532
3578
|
if (blocks.length === 0) continue;
|
|
3533
3579
|
params.push({
|
|
3534
3580
|
role: "assistant",
|
|
@@ -1232,12 +1232,27 @@ function getOutputBlockStartEventType(block: CodexOutputBlock): "thinking_start"
|
|
|
1232
1232
|
return "toolcall_start";
|
|
1233
1233
|
}
|
|
1234
1234
|
|
|
1235
|
+
const CODEX_STALE_PREVIOUS_RESPONSE_CODES: Record<string, true> = {
|
|
1236
|
+
// OpenAI-standard code for an expired/missing `previous_response_id` chain.
|
|
1237
|
+
previous_response_not_found: true,
|
|
1238
|
+
// Proxy-specific: upstream response anchor expired. Same recovery class —
|
|
1239
|
+
// retry the turn with full context and no `previous_response_id`.
|
|
1240
|
+
codex_previous_response_stale: true,
|
|
1241
|
+
};
|
|
1242
|
+
|
|
1235
1243
|
function isCodexStalePreviousResponseError(error: unknown): boolean {
|
|
1236
|
-
if (error instanceof CodexProviderStreamError) return error.code === "previous_response_not_found";
|
|
1237
1244
|
if (!(error instanceof Error)) return false;
|
|
1238
|
-
if (
|
|
1239
|
-
|
|
1240
|
-
|
|
1245
|
+
if (
|
|
1246
|
+
"code" in error &&
|
|
1247
|
+
typeof error.code === "string" &&
|
|
1248
|
+
Object.hasOwn(CODEX_STALE_PREVIOUS_RESPONSE_CODES, error.code)
|
|
1249
|
+
) {
|
|
1250
|
+
return true;
|
|
1251
|
+
}
|
|
1252
|
+
// Message-based fallback for providers/proxies that report the condition
|
|
1253
|
+
// without a canonical code. Also covers "unsupported": the backend
|
|
1254
|
+
// intermittently rejects the parameter outright with
|
|
1255
|
+
// `{"detail":"Unsupported parameter: previous_response_id"}` (no
|
|
1241
1256
|
// `error.code`); treat it like a stale chain so the turn replays with full
|
|
1242
1257
|
// context instead of surfacing the 400.
|
|
1243
1258
|
return (
|
|
@@ -668,7 +668,7 @@ const streamOpenAIResponsesOnce = (
|
|
|
668
668
|
}
|
|
669
669
|
|
|
670
670
|
// Detect premature stream closure: the HTTP stream ended without the
|
|
671
|
-
// provider sending
|
|
671
|
+
// provider sending a recognized terminal response event.
|
|
672
672
|
// Custom/proxy providers may drop the connection mid-stream; without
|
|
673
673
|
// this guard the incomplete output is silently surfaced as a successful
|
|
674
674
|
// "stop".
|
|
@@ -80,6 +80,7 @@ import {
|
|
|
80
80
|
import type { ChatCompletionCreateParamsStreaming } from "./openai-chat-wire";
|
|
81
81
|
import type { InputItem } from "./openai-codex/request-transformer";
|
|
82
82
|
import type {
|
|
83
|
+
Response as OpenAIResponse,
|
|
83
84
|
ResponseContentPartAddedEvent,
|
|
84
85
|
ResponseCreateParamsStreaming,
|
|
85
86
|
ResponseCustomToolCall,
|
|
@@ -1798,13 +1799,25 @@ export function finalizeCustomToolCallInputDone(block: ResponsesToolCallBlock, i
|
|
|
1798
1799
|
block.arguments = { input };
|
|
1799
1800
|
}
|
|
1800
1801
|
|
|
1802
|
+
type OpenAIResponsesTerminalStreamEvent =
|
|
1803
|
+
| Extract<ResponseStreamEvent, { type: "response.completed" | "response.incomplete" }>
|
|
1804
|
+
| { type: "response.done"; response?: Partial<OpenAIResponse> };
|
|
1805
|
+
|
|
1806
|
+
function getOpenAIResponsesTerminalEvent(event: ResponseStreamEvent): OpenAIResponsesTerminalStreamEvent | undefined {
|
|
1807
|
+
const type = (event as { type?: unknown }).type;
|
|
1808
|
+
return type === "response.completed" || type === "response.incomplete" || type === "response.done"
|
|
1809
|
+
? (event as OpenAIResponsesTerminalStreamEvent)
|
|
1810
|
+
: undefined;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1801
1813
|
export interface ProcessResponsesStreamOptions {
|
|
1802
1814
|
onFirstToken?: () => void;
|
|
1803
1815
|
onOutputItemDone?: (item: ResponseOutputItem) => void;
|
|
1804
1816
|
/**
|
|
1805
|
-
* Called when a terminal `response.completed
|
|
1806
|
-
* is successfully processed. Only invoked on the
|
|
1807
|
-
* thrown failure (`response.failed`) and
|
|
1817
|
+
* Called when a terminal `response.completed`, `response.incomplete`, or
|
|
1818
|
+
* `response.done` event is successfully processed. Only invoked on the
|
|
1819
|
+
* successful-completion path; thrown failure (`response.failed`) and
|
|
1820
|
+
* cancellation paths never call this.
|
|
1808
1821
|
* Used by callers to detect premature stream closure (i.e. the stream ended
|
|
1809
1822
|
* without a recognized terminal event).
|
|
1810
1823
|
*/
|
|
@@ -2039,6 +2052,7 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
2039
2052
|
let sawFirstToken = false;
|
|
2040
2053
|
|
|
2041
2054
|
for await (const event of openaiStream) {
|
|
2055
|
+
const terminalEvent = getOpenAIResponsesTerminalEvent(event);
|
|
2042
2056
|
if (event.type === "response.created") {
|
|
2043
2057
|
output.responseId = event.response.id;
|
|
2044
2058
|
} else if (event.type === "response.output_item.added") {
|
|
@@ -2297,8 +2311,8 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
2297
2311
|
closeOpenItem(event.output_index, item.id, entry, item.call_id, prefixedFunctionCallItemKey(item.call_id));
|
|
2298
2312
|
stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
|
|
2299
2313
|
}
|
|
2300
|
-
} else if (
|
|
2301
|
-
const response =
|
|
2314
|
+
} else if (terminalEvent) {
|
|
2315
|
+
const response = terminalEvent.response;
|
|
2302
2316
|
finalizePendingResponsesToolCalls(output);
|
|
2303
2317
|
if (response?.id) {
|
|
2304
2318
|
output.responseId = response.id;
|
|
@@ -2336,7 +2350,7 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
2336
2350
|
}
|
|
2337
2351
|
promoteResponsesToolUseStopReason(output, (response as { end_turn?: boolean } | undefined)?.end_turn);
|
|
2338
2352
|
options?.onCompleted?.();
|
|
2339
|
-
// `response.completed`/`response.incomplete` is the last event of a
|
|
2353
|
+
// `response.completed`/`response.incomplete`/`response.done` is the last event of a
|
|
2340
2354
|
// Responses stream. Stop pulling instead of waiting for the server to
|
|
2341
2355
|
// close the connection: misbehaving providers keep the socket open
|
|
2342
2356
|
// after the terminal event, which would park this loop until the idle
|
|
@@ -229,20 +229,32 @@ export abstract class OAuthCallbackFlow {
|
|
|
229
229
|
|
|
230
230
|
/**
|
|
231
231
|
* Build the `/launch` URL served by the callback server bound to `port`, or
|
|
232
|
-
* `undefined` when
|
|
233
|
-
*
|
|
234
|
-
*
|
|
232
|
+
* `undefined` when it must not be advertised:
|
|
233
|
+
* - the configured `callbackPath` (or a `redirectUri` whose pathname
|
|
234
|
+
* resolves to {@link LAUNCH_PATH}) would collide with the launch route;
|
|
235
|
+
* - the flow's `redirectUri` never returns to this loopback server: fixed
|
|
236
|
+
* non-loopback hosts, or custom schemes like GitLab Duo's `vscode://`
|
|
237
|
+
* URI — which `new URL` parses without complaint, so a scheme/host check
|
|
238
|
+
* is required, not just the parse failure path. Advertising a localhost
|
|
239
|
+
* `/launch` target for such flows misrepresents the callback endpoint
|
|
240
|
+
* and hands remote users a URL that resolves nowhere.
|
|
241
|
+
* Kept short (~30 chars) so UIs can advertise it as a
|
|
235
242
|
* viewport-truncation-safe copy target for the full authorization URL.
|
|
236
243
|
*/
|
|
237
244
|
#launchUrlIfSafe(port: number): string | undefined {
|
|
238
245
|
if (this.callbackPath === LAUNCH_PATH) return undefined;
|
|
239
246
|
if (this.redirectUri) {
|
|
240
247
|
try {
|
|
241
|
-
|
|
248
|
+
const parsed = new URL(this.redirectUri);
|
|
249
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return undefined;
|
|
250
|
+
if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "[::1]") {
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
if (parsed.pathname === LAUNCH_PATH) return undefined;
|
|
242
254
|
} catch {
|
|
243
|
-
// A
|
|
244
|
-
//
|
|
245
|
-
|
|
255
|
+
// A redirectUri even WHATWG URL cannot parse certainly does not
|
|
256
|
+
// return to this server — never advertise a launch URL for it.
|
|
257
|
+
return undefined;
|
|
246
258
|
}
|
|
247
259
|
}
|
|
248
260
|
return `http://${this.callbackHostname}:${port}${LAUNCH_PATH}`;
|
package/src/stream.ts
CHANGED
|
@@ -1106,7 +1106,13 @@ export function streamSimple<TApi extends Api>(
|
|
|
1106
1106
|
// Caller aborted between attempts: don't mint a fresh token or fire
|
|
1107
1107
|
// another doomed request — emit the captured failure instead.
|
|
1108
1108
|
if (signal?.aborted) break;
|
|
1109
|
-
const nextKey = await resolveRetryKey(
|
|
1109
|
+
const nextKey = await resolveRetryKey(
|
|
1110
|
+
apiKeyResolver,
|
|
1111
|
+
AUTH_RETRY_STEPS[step]!,
|
|
1112
|
+
failure.error,
|
|
1113
|
+
signal,
|
|
1114
|
+
lastKey,
|
|
1115
|
+
);
|
|
1110
1116
|
if (nextKey === undefined || nextKey === lastKey) continue;
|
|
1111
1117
|
lastKey = nextKey;
|
|
1112
1118
|
const isLastStep = step === AUTH_RETRY_STEPS.length - 1;
|
package/src/types.ts
CHANGED
|
@@ -715,6 +715,8 @@ export interface AssistantMessage {
|
|
|
715
715
|
stopReason: StopReason;
|
|
716
716
|
stopDetails?: StopDetails | null;
|
|
717
717
|
errorMessage?: string;
|
|
718
|
+
/** Per-tool abort messages used when an aborted assistant turn needs different placeholder results per tool call. */
|
|
719
|
+
toolCallAbortMessages?: Record<string, string>;
|
|
718
720
|
/** HTTP status surfaced by the provider when the request failed. Populated by every provider's catch block alongside `errorMessage` so consumers (auth retry, telemetry, UI) can branch without regex-scraping the message. */
|
|
719
721
|
errorStatus?: number;
|
|
720
722
|
/** Structured machine-readable error classifier; see `utils/error-id.ts` for bit layout and helpers. */
|
|
@@ -285,8 +285,6 @@ function buildUsageLimit(args: {
|
|
|
285
285
|
label: usageWindow.label,
|
|
286
286
|
scope: {
|
|
287
287
|
provider: "openai-codex",
|
|
288
|
-
accountId: args.accountId,
|
|
289
|
-
tier: args.planType,
|
|
290
288
|
windowId: usageWindow.id,
|
|
291
289
|
shared: true,
|
|
292
290
|
},
|
|
@@ -507,6 +505,9 @@ export const openaiCodexUsageProvider: UsageProvider = {
|
|
|
507
505
|
const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
|
|
508
506
|
|
|
509
507
|
export const codexRankingStrategy: CredentialRankingStrategy = {
|
|
508
|
+
blockScope() {
|
|
509
|
+
return "shared";
|
|
510
|
+
},
|
|
510
511
|
findWindowLimits(report) {
|
|
511
512
|
const findLimit = (key: "primary" | "secondary"): UsageLimit | undefined => {
|
|
512
513
|
const direct = report.limits.find(l => l.id === `openai-codex:${key}`);
|