@oh-my-pi/pi-ai 18.2.0 → 18.2.2
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 +53 -0
- package/README.md +2 -0
- package/dist/types/auth/sqlite-credential-store.d.ts +2 -1
- package/dist/types/auth-broker/remote-store.d.ts +17 -0
- package/dist/types/auth-gateway/index.d.ts +1 -0
- package/dist/types/auth-gateway/session-state.d.ts +118 -0
- package/dist/types/auth-storage.d.ts +17 -0
- package/dist/types/error/body-error.d.ts +15 -0
- package/dist/types/error/flags.d.ts +16 -0
- package/dist/types/error/index.d.ts +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/oneshot-retry.d.ts +6 -0
- package/dist/types/provider-session-state.d.ts +46 -0
- package/dist/types/providers/amazon-bedrock.d.ts +3 -0
- package/dist/types/providers/aws-sigv4.d.ts +12 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +27 -0
- package/dist/types/providers/openai-responses.d.ts +15 -0
- package/dist/types/providers/openai-shared.d.ts +20 -3
- package/dist/types/registry/oauth/perplexity.d.ts +1 -7
- package/dist/types/registry/oauth/types.d.ts +8 -0
- package/dist/types/stream.d.ts +2 -0
- package/dist/types/types.d.ts +3 -1
- package/dist/types/usage/openai-codex.d.ts +3 -1
- package/dist/types/usage.d.ts +11 -1
- package/dist/types/utils/block-symbols.d.ts +36 -0
- package/dist/types/utils/openai-http.d.ts +2 -0
- package/dist/types/utils/retry-after.d.ts +2 -0
- package/dist/types/utils/schema/wire.d.ts +4 -5
- package/dist/types/utils.d.ts +9 -0
- package/package.json +6 -6
- package/src/auth/sqlite-credential-store.ts +8 -33
- package/src/auth-broker/remote-store.ts +73 -8
- package/src/auth-broker/wire-schemas.ts +1 -0
- package/src/auth-gateway/index.ts +1 -0
- package/src/auth-gateway/server.ts +186 -74
- package/src/auth-gateway/session-state.ts +312 -0
- package/src/auth-storage.ts +146 -15
- package/src/error/body-error.ts +310 -0
- package/src/error/flags.ts +63 -13
- package/src/error/index.ts +1 -0
- package/src/error/retryable.ts +2 -0
- package/src/index.ts +1 -0
- package/src/oneshot-retry.ts +13 -3
- package/src/provider-session-state.ts +56 -0
- package/src/providers/amazon-bedrock.ts +20 -3
- package/src/providers/anthropic-messages-server.ts +104 -23
- package/src/providers/anthropic-signature.ts +5 -2
- package/src/providers/anthropic.ts +101 -15
- package/src/providers/aws-sigv4.ts +16 -5
- package/src/providers/cursor.ts +60 -10
- package/src/providers/devin.ts +82 -28
- package/src/providers/openai-chat-server.ts +4 -0
- package/src/providers/openai-codex/request-transformer.ts +36 -0
- package/src/providers/openai-codex-responses.ts +35 -12
- package/src/providers/openai-completions.ts +49 -12
- package/src/providers/openai-reasoning-fallback.ts +6 -6
- package/src/providers/openai-responses-server.ts +2 -1
- package/src/providers/openai-responses.ts +52 -4
- package/src/providers/openai-shared.ts +199 -51
- package/src/registry/oauth/perplexity.ts +94 -28
- package/src/registry/oauth/types.ts +9 -0
- package/src/stream.ts +23 -2
- package/src/types.ts +3 -0
- package/src/usage/claude.ts +33 -0
- package/src/usage/google-antigravity.ts +8 -2
- package/src/usage/openai-codex.ts +94 -11
- package/src/usage.ts +8 -1
- package/src/utils/block-symbols.ts +57 -0
- package/src/utils/http-inspector.ts +20 -0
- package/src/utils/openai-http.ts +39 -3
- package/src/utils/retry-after.ts +12 -0
- package/src/utils/schema/normalize.ts +3 -3
- package/src/utils/schema/stamps.ts +33 -45
- package/src/utils/schema/wire.ts +9 -7
- package/src/utils.ts +67 -22
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-owned provider session state for the auth-gateway.
|
|
3
|
+
*
|
|
4
|
+
* `SimpleStreamOptions.providerSessionState` is how a provider keeps what it
|
|
5
|
+
* learned about an endpoint across turns of one conversation: Anthropic's
|
|
6
|
+
* sticky `strictToolsDisabled` / `fastModeDisabled` /
|
|
7
|
+
* `replayUnsignedThinkingDisabled` flags and dropped-thinking-prefix set,
|
|
8
|
+
* OpenAI's strict-tools and reasoning-effort fallbacks, Codex's WebSocket and
|
|
9
|
+
* turn-state sessions. An in-process omp session owns that `Map` for its whole
|
|
10
|
+
* lifetime, so a grammar-too-large 400 or a fast-mode rejection costs one
|
|
11
|
+
* wasted round-trip per session rather than one per turn.
|
|
12
|
+
*
|
|
13
|
+
* The map is deliberately non-serializable — `Set`/`Map` fields, live sockets,
|
|
14
|
+
* a `close()` method — so `pi-native-client` strips it from the wire and
|
|
15
|
+
* `pi-native-server` never accepts it. Gateway clients therefore cannot bring
|
|
16
|
+
* their own, and without a server-side owner every containerized / robomp turn
|
|
17
|
+
* re-learns every lesson from a fresh upstream rejection.
|
|
18
|
+
*
|
|
19
|
+
* A plain `Map<sessionId, …>` in a long-lived server process is a leak: nothing
|
|
20
|
+
* ever reclaims an entry, and the entries own timers and sockets. This store is
|
|
21
|
+
* an LRU with a hard entry ceiling that calls `close()` on everything it drops
|
|
22
|
+
* and on everything it still holds at shutdown — but it only ever drops an
|
|
23
|
+
* entry no request is holding, because `close()` on a live entry tears down
|
|
24
|
+
* state an in-flight stream is still streaming through.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
28
|
+
import { resetAccountScopedProviderSessionState } from "../provider-session-state";
|
|
29
|
+
import type { Api, Context, Model, ProviderSessionState } from "../types";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Retained logical sessions. Each entry is a handful of small provider records
|
|
33
|
+
* plus, for Codex, a WebSocket session — cheap to keep, but not free, so the
|
|
34
|
+
* ceiling is what turns "one entry per session id forever" into a bounded cost.
|
|
35
|
+
* Eviction is least-recently-used, so the ceiling only ever drops sessions that
|
|
36
|
+
* have been quiet longer than the 256 most recent ones.
|
|
37
|
+
*/
|
|
38
|
+
export const AUTH_GATEWAY_MAX_SESSION_STATES = 256;
|
|
39
|
+
|
|
40
|
+
/** Why an entry's provider records were closed. Logged on teardown failure. */
|
|
41
|
+
type SessionDisposeReason = "evict" | "shutdown";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One request's claim on a retained session.
|
|
45
|
+
*
|
|
46
|
+
* `release()` is what makes the entry evictable again, so it MUST run for every
|
|
47
|
+
* outcome of the request — a `finally` at the call site for the synchronous
|
|
48
|
+
* paths, stream completion for the streaming ones. It is idempotent, so the
|
|
49
|
+
* two can overlap.
|
|
50
|
+
*/
|
|
51
|
+
export interface AuthGatewaySessionStateLease {
|
|
52
|
+
/** The map to hand to `streamSimple` as `providerSessionState`. */
|
|
53
|
+
readonly states: Map<string, ProviderSessionState>;
|
|
54
|
+
/** Reset account-scoped records if an in-request auth retry switches accounts. */
|
|
55
|
+
updateAccount(account: string): void;
|
|
56
|
+
/** Give up this request's claim. Idempotent. */
|
|
57
|
+
release(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Everything the store needs to place one request on a retained session. */
|
|
61
|
+
export interface AuthGatewaySessionStateRequest {
|
|
62
|
+
/**
|
|
63
|
+
* The client's own session key (`prompt_cache_key` / `sessionId`), or
|
|
64
|
+
* `undefined` when it sent none — blank counts as none. A supplied key is
|
|
65
|
+
* authoritative: the client is telling us which conversation this is.
|
|
66
|
+
*/
|
|
67
|
+
clientKey: string | undefined;
|
|
68
|
+
model: Model<Api>;
|
|
69
|
+
/**
|
|
70
|
+
* System prompt, tools and message history of this request. Used only when
|
|
71
|
+
* `clientKey` is absent, to place the request on the conversation it
|
|
72
|
+
* continues.
|
|
73
|
+
*/
|
|
74
|
+
context: Context;
|
|
75
|
+
/**
|
|
76
|
+
* Stable identity of the account this request's credential resolved to.
|
|
77
|
+
* A change means the gateway switched the session to a sibling credential,
|
|
78
|
+
* so the account-dependent lessons in the retained map are re-probed. The
|
|
79
|
+
* comparison happens on acquire and whenever an in-request auth retry
|
|
80
|
+
* resolves a sibling credential.
|
|
81
|
+
*/
|
|
82
|
+
account: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface RetainedSession {
|
|
86
|
+
/** Current index key. Advances as a keyless conversation grows. */
|
|
87
|
+
key: string;
|
|
88
|
+
states: Map<string, ProviderSessionState>;
|
|
89
|
+
/** Account identity of the most recent request placed on this entry. */
|
|
90
|
+
account: string;
|
|
91
|
+
/** Requests currently holding this entry. Eviction never takes one of these. */
|
|
92
|
+
leases: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Close every provider record an evicted (or drained) session held.
|
|
97
|
+
*
|
|
98
|
+
* Anthropic's `close()` resets its sticky flags, Codex's tears down WebSockets
|
|
99
|
+
* and GitLab Duo's stops the server-side workflow — so dropping an entry
|
|
100
|
+
* without closing it leaks exactly the resources the bound exists to cap.
|
|
101
|
+
*/
|
|
102
|
+
function closeSessionState(
|
|
103
|
+
states: Map<string, ProviderSessionState>,
|
|
104
|
+
sessionKey: string,
|
|
105
|
+
reason: SessionDisposeReason,
|
|
106
|
+
): void {
|
|
107
|
+
for (const [providerKey, state] of states) {
|
|
108
|
+
try {
|
|
109
|
+
state.close();
|
|
110
|
+
} catch (error) {
|
|
111
|
+
// One provider's teardown must not abort the rest: a throw here
|
|
112
|
+
// propagates out of the eviction into whichever request happened to
|
|
113
|
+
// trigger it, or abandons the remainder of the shutdown drain.
|
|
114
|
+
logger.warn("auth-gateway provider session state close failed", {
|
|
115
|
+
sessionKey,
|
|
116
|
+
providerKey,
|
|
117
|
+
reason,
|
|
118
|
+
error: String(error),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
states.clear();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Index keys this request may be placed on, most specific first.
|
|
127
|
+
*
|
|
128
|
+
* With a client key there is exactly one: the client named its conversation, so
|
|
129
|
+
* provider + model + that key is the identity.
|
|
130
|
+
*
|
|
131
|
+
* Without one the gateway has to infer the conversation, and the request's
|
|
132
|
+
* message history is the only thing that can distinguish two of them. The
|
|
133
|
+
* derived `sessionId` used for prefix caching and credential stickiness hashes
|
|
134
|
+
* the model, system prompt, tools and *first* message, which is deliberately
|
|
135
|
+
* prefix-shaped — two chats that open the same way share a cache bucket, which
|
|
136
|
+
* is a cache hit rather than a leak, and share a sticky account, which is a
|
|
137
|
+
* load-balancing hint. Retained provider state is neither: sharing it means one
|
|
138
|
+
* chat's rejection silences another chat's request, and one chat's Codex
|
|
139
|
+
* transport session answers another chat's turn. So provider state gets its own
|
|
140
|
+
* key, and only provider state: `deriveSessionId` keeps its two other jobs.
|
|
141
|
+
*
|
|
142
|
+
* The key is therefore a running hash over (provider, model, system, tools) and
|
|
143
|
+
* then every message, one key per message — the last of which identifies the
|
|
144
|
+
* exact history this request presented. Turn N+1 of a conversation extends turn
|
|
145
|
+
* N's history, so turn N's key is one of the earlier entries in turn N+1's
|
|
146
|
+
* chain: the store finds the nearest ancestor and moves that entry forward onto
|
|
147
|
+
* the new key. Two conversations that share an opening therefore share an entry
|
|
148
|
+
* only until they diverge; after that the first branch to arrive keeps the
|
|
149
|
+
* ancestor and the other starts clean. That is the most a stateless wire can
|
|
150
|
+
* tell us — before divergence the two requests are byte-identical.
|
|
151
|
+
*
|
|
152
|
+
* System prompt and tools sit in the root rather than per-message because they
|
|
153
|
+
* are not history: a client that re-stamps its system prompt every turn (a
|
|
154
|
+
* date, a cwd) starts a new lineage, exactly as it already starts a new derived
|
|
155
|
+
* `sessionId` today.
|
|
156
|
+
*/
|
|
157
|
+
function sessionKeys(request: AuthGatewaySessionStateRequest): string[] {
|
|
158
|
+
const { model } = request;
|
|
159
|
+
const scope = `${model.provider}\u0000${model.id}`;
|
|
160
|
+
if (request.clientKey !== undefined) return [`c\u0000${scope}\u0000${request.clientKey}`];
|
|
161
|
+
const { context } = request;
|
|
162
|
+
// NUL separates the components so none of them can forge the boundary.
|
|
163
|
+
let hash = Bun.hash(
|
|
164
|
+
`${scope}\u0000${context.systemPrompt?.join("\n\n") ?? ""}\u0000${context.tools ? JSON.stringify(context.tools) : ""}`,
|
|
165
|
+
);
|
|
166
|
+
const keys: string[] = [];
|
|
167
|
+
for (const message of context.messages) {
|
|
168
|
+
// Role + content only: omp re-stamps `timestamp` and provider metadata on
|
|
169
|
+
// every parsed message, so hashing those would break the chain on turn
|
|
170
|
+
// two of every conversation.
|
|
171
|
+
hash = Bun.hash(JSON.stringify({ role: message.role, content: message.content }), hash);
|
|
172
|
+
keys.push(`h\u0000${scope}\u0000${hash.toString(36)}`);
|
|
173
|
+
}
|
|
174
|
+
// A request with no messages has no history to place; its root is the key.
|
|
175
|
+
if (keys.length === 0) return [`h\u0000${scope}\u0000${hash.toString(36)}`];
|
|
176
|
+
keys.reverse();
|
|
177
|
+
return keys;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Bounded per-session provider state, owned by one gateway server instance.
|
|
182
|
+
*
|
|
183
|
+
* Two gateways in the same process get separate stores, so neither can hand a
|
|
184
|
+
* request another gateway's learned state or close it out from under one.
|
|
185
|
+
*
|
|
186
|
+
* The recency order is this class's own (a `Map` iterates in insertion order,
|
|
187
|
+
* and every acquire re-inserts) rather than `LRUCache`'s, because the policy
|
|
188
|
+
* needs two things a general cache cannot express: an entry that a request is
|
|
189
|
+
* still holding must be skipped when picking a victim, and an entry must be
|
|
190
|
+
* able to change key — `LRUCache` disposes on every removal, which is precisely
|
|
191
|
+
* the `close()` we must not run here.
|
|
192
|
+
*/
|
|
193
|
+
export class AuthGatewaySessionStateStore {
|
|
194
|
+
/** Least recently acquired first — insertion order is the LRU order. */
|
|
195
|
+
readonly #sessions = new Map<string, RetainedSession>();
|
|
196
|
+
readonly #max: number;
|
|
197
|
+
|
|
198
|
+
constructor(max: number = AUTH_GATEWAY_MAX_SESSION_STATES) {
|
|
199
|
+
if (!Number.isInteger(max) || max < 1) throw new TypeError("max must be a positive integer");
|
|
200
|
+
this.#max = max;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Retained logical sessions. */
|
|
204
|
+
get size(): number {
|
|
205
|
+
return this.#sessions.size;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Claim the provider-session map for one request, created on first use and
|
|
210
|
+
* returned by reference so provider mutations persist into the next request.
|
|
211
|
+
*
|
|
212
|
+
* Keyed by provider + model + conversation (see {@link sessionKeys}). A
|
|
213
|
+
* client is free to reuse one session id across models, and the coarsest
|
|
214
|
+
* provider entries do not separate models themselves (`openai-responses`
|
|
215
|
+
* keys its strict-tools / history-replay record by provider alone,
|
|
216
|
+
* Antigravity by a single constant), so the model belongs in the key here.
|
|
217
|
+
* Endpoint is deliberately absent: every provider whose learning is
|
|
218
|
+
* endpoint-specific already sub-keys it internally
|
|
219
|
+
* (`anthropic-messages:${baseUrl}\0${modelId}`,
|
|
220
|
+
* `openai-completions:${provider}:${baseUrl}:${modelId}`), and repeating it
|
|
221
|
+
* would only fragment the map. The credential is absent for the same reason
|
|
222
|
+
* — most of what is retained is true of the endpoint whoever calls it, and
|
|
223
|
+
* Codex already sub-keys its transport by account and bearer — so a
|
|
224
|
+
* credential switch resets the account-dependent subset instead of
|
|
225
|
+
* splitting the entry (see `resetAccountScopedProviderSessionState`).
|
|
226
|
+
*
|
|
227
|
+
* The returned lease MUST be released; until then the entry cannot be
|
|
228
|
+
* evicted.
|
|
229
|
+
*/
|
|
230
|
+
acquire(request: AuthGatewaySessionStateRequest): AuthGatewaySessionStateLease {
|
|
231
|
+
const session = this.#claim(sessionKeys(request), request.account);
|
|
232
|
+
let released = false;
|
|
233
|
+
return {
|
|
234
|
+
states: session.states,
|
|
235
|
+
updateAccount: (account: string): void => {
|
|
236
|
+
if (session.account === account) return;
|
|
237
|
+
resetAccountScopedProviderSessionState(session.states);
|
|
238
|
+
session.account = account;
|
|
239
|
+
},
|
|
240
|
+
release: (): void => {
|
|
241
|
+
if (released) return;
|
|
242
|
+
released = true;
|
|
243
|
+
session.leases--;
|
|
244
|
+
// This entry may be the victim the bound has been waiting for.
|
|
245
|
+
if (session.leases === 0) this.#evict();
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Close and drop every retained state. Called when the gateway shuts down. */
|
|
251
|
+
close(): void {
|
|
252
|
+
// The only place a leased entry is torn down: the listener is already
|
|
253
|
+
// down, every in-flight stream is being cancelled with it, and the
|
|
254
|
+
// process cannot settle while a Codex WebSocket or Duo workflow is open.
|
|
255
|
+
for (const session of this.#sessions.values()) closeSessionState(session.states, session.key, "shutdown");
|
|
256
|
+
this.#sessions.clear();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Resolve `keys` to an entry — reusing the nearest ancestor when a keyless
|
|
261
|
+
* conversation has grown — mark it most recently used, and hand the caller
|
|
262
|
+
* the claim. The claim is taken before the ceiling is enforced: a brand-new
|
|
263
|
+
* entry belongs to the request that just created it, and is not a candidate
|
|
264
|
+
* for making room for itself.
|
|
265
|
+
*/
|
|
266
|
+
#claim(keys: readonly string[], account: string): RetainedSession {
|
|
267
|
+
const key = keys[0] ?? "";
|
|
268
|
+
for (const candidate of keys) {
|
|
269
|
+
const session = this.#sessions.get(candidate);
|
|
270
|
+
if (session === undefined) continue;
|
|
271
|
+
// Re-insert at the tail for recency, under this request's own key so
|
|
272
|
+
// the next turn of this conversation finds it as its ancestor. A
|
|
273
|
+
// sibling branch of the same ancestor no longer matches, which is the
|
|
274
|
+
// point: it gets an entry of its own.
|
|
275
|
+
this.#sessions.delete(candidate);
|
|
276
|
+
session.key = key;
|
|
277
|
+
this.#sessions.set(key, session);
|
|
278
|
+
session.leases++;
|
|
279
|
+
if (session.account !== account) {
|
|
280
|
+
resetAccountScopedProviderSessionState(session.states);
|
|
281
|
+
session.account = account;
|
|
282
|
+
}
|
|
283
|
+
return session;
|
|
284
|
+
}
|
|
285
|
+
const created: RetainedSession = { key, states: new Map(), account, leases: 1 };
|
|
286
|
+
this.#sessions.set(key, created);
|
|
287
|
+
this.#evict();
|
|
288
|
+
return created;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Enforce the ceiling against entries no request is holding.
|
|
293
|
+
*
|
|
294
|
+
* A long-running stream is exactly the entry LRU order would pick — it was
|
|
295
|
+
* acquired when the stream opened and not touched since — so blind eviction
|
|
296
|
+
* would `close()` the sockets and flags that stream is still using. Live
|
|
297
|
+
* entries are skipped instead, and the store sits above its bound until
|
|
298
|
+
* their requests release; the excess is therefore capped by the number of
|
|
299
|
+
* concurrent requests, each of which holds a client connection.
|
|
300
|
+
*/
|
|
301
|
+
#evict(): void {
|
|
302
|
+
if (this.#sessions.size <= this.#max) return;
|
|
303
|
+
// Deleting during Map iteration is well-defined: the current and later
|
|
304
|
+
// keys stay consistent, so this walks least-recently-acquired first.
|
|
305
|
+
for (const session of this.#sessions.values()) {
|
|
306
|
+
if (this.#sessions.size <= this.#max) return;
|
|
307
|
+
if (session.leases > 0) continue;
|
|
308
|
+
this.#sessions.delete(session.key);
|
|
309
|
+
closeSessionState(session.states, session.key, "evict");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
package/src/auth-storage.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { planRequirementFor } from "@oh-my-pi/pi-catalog/compat/behavior";
|
|
12
|
-
import { $env, $envExact,
|
|
12
|
+
import { $env, $envExact, getAgentDbPath, logger, untilAborted } from "@oh-my-pi/pi-utils";
|
|
13
13
|
import {
|
|
14
14
|
isSqliteCorruptionError,
|
|
15
15
|
resolveCredentialIdentityKey,
|
|
@@ -31,6 +31,7 @@ import type {
|
|
|
31
31
|
} from "./registry/oauth/types";
|
|
32
32
|
import { AUTHENTICATED_SENTINEL } from "./registry/types";
|
|
33
33
|
import { getEnvApiKey, getEnvApiKeyName } from "./stream";
|
|
34
|
+
import { extractProviderRetryHint } from "./utils/retry-after";
|
|
34
35
|
import type { Provider } from "./types";
|
|
35
36
|
import type {
|
|
36
37
|
ClientUsageIdentity,
|
|
@@ -765,6 +766,11 @@ export { isDefinitiveOAuthFailure } from "./error/auth-classify";
|
|
|
765
766
|
* the usage report reveals. Callers that wait the account out (instead of
|
|
766
767
|
* rotating) must sleep until this, not the error-text hint alone.
|
|
767
768
|
*
|
|
769
|
+
* `requestedBlockedUntilMs` (epoch ms) is this mark call's initial deadline,
|
|
770
|
+
* before usage-report correction and longest-wins merging. Callers use it to
|
|
771
|
+
* distinguish the call's replaceable heuristic from a longer merged block
|
|
772
|
+
* that credential selection will continue enforcing.
|
|
773
|
+
*
|
|
768
774
|
* `priorBlockedUntilMs` (epoch ms) is the live block deadline the map already
|
|
769
775
|
* stored for this credential before this call. The merged `blockedUntilMs`
|
|
770
776
|
* masks a pre-existing block shorter than this call's own heuristic
|
|
@@ -790,6 +796,8 @@ export interface UsageLimitMarkResult {
|
|
|
790
796
|
switched: boolean;
|
|
791
797
|
retryAtMs?: number;
|
|
792
798
|
blockedUntilMs?: number;
|
|
799
|
+
/** This mark call's initial deadline, before report correction and merging. */
|
|
800
|
+
requestedBlockedUntilMs?: number;
|
|
793
801
|
priorBlockedUntilMs?: number;
|
|
794
802
|
priorBlockedUntilTimed?: boolean;
|
|
795
803
|
reportResetAtMs?: number;
|
|
@@ -1353,7 +1361,7 @@ export class AuthStorage {
|
|
|
1353
1361
|
/** Tracks the last used credential per provider for a session (used for rate-limit switching). */
|
|
1354
1362
|
#sessionLastCredential: Map<
|
|
1355
1363
|
string,
|
|
1356
|
-
Map<string, { type: AuthCredential["type"]; index: number; lastUsedAtMs?: number }>
|
|
1364
|
+
Map<string, { type: AuthCredential["type"]; index: number; credentialId?: number; lastUsedAtMs?: number }>
|
|
1357
1365
|
> = new Map();
|
|
1358
1366
|
/** Recent bearer fingerprints resolved for each durable OAuth row; used only for delayed usage-limit attribution. */
|
|
1359
1367
|
#oauthBearerFingerprints: Map<string, Map<number, string[]>> = new Map();
|
|
@@ -1490,6 +1498,36 @@ export class AuthStorage {
|
|
|
1490
1498
|
return true;
|
|
1491
1499
|
}
|
|
1492
1500
|
|
|
1501
|
+
/**
|
|
1502
|
+
* Adopt credentials another process committed before selecting or rotating.
|
|
1503
|
+
*
|
|
1504
|
+
* The store is shared across every omp process, but the pool is an
|
|
1505
|
+
* in-process cache refreshed only by this process's own writes. Without
|
|
1506
|
+
* this a long-running session ranks a stale pool for its whole lifetime:
|
|
1507
|
+
* `omp auth` in another terminal is invisible, rotation reports no usable
|
|
1508
|
+
* sibling while a freshly added account sits unblocked in SQLite, and the
|
|
1509
|
+
* turn degrades to the fallback chain. The auth-broker path already polls;
|
|
1510
|
+
* direct-store sessions had no equivalent.
|
|
1511
|
+
*
|
|
1512
|
+
* A poll is two cheap reads (`PRAGMA data_version` plus the auth revision)
|
|
1513
|
+
* and re-lists credentials only when another connection committed, so it
|
|
1514
|
+
* runs on every resolution rather than on a timer that would make recovery
|
|
1515
|
+
* depend on wall-clock spacing. It sits on the paths that read the pool —
|
|
1516
|
+
* OAuth selection, and the two public usage-limit entry points — and is
|
|
1517
|
+
* idempotent, so a rotation reached through `markUsageLimitReached` costs
|
|
1518
|
+
* one extra `data_version` read and no second reload.
|
|
1519
|
+
*/
|
|
1520
|
+
async #adoptExternalCredentialChanges(): Promise<void> {
|
|
1521
|
+
if (this.#closed || this.#store.pollExternalChanges === undefined) return;
|
|
1522
|
+
try {
|
|
1523
|
+
await this.pollExternalChanges();
|
|
1524
|
+
} catch (error) {
|
|
1525
|
+
// A failed poll must not fail credential resolution: the in-memory
|
|
1526
|
+
// pool is still serviceable, just possibly stale.
|
|
1527
|
+
logger.debug("External credential poll failed", { error: String(error) });
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1493
1531
|
onGenerationChanged(listener: (generation: number) => void): () => void {
|
|
1494
1532
|
this.#generationListeners.add(listener);
|
|
1495
1533
|
return () => {
|
|
@@ -2084,12 +2122,12 @@ export class AuthStorage {
|
|
|
2084
2122
|
): void {
|
|
2085
2123
|
if (!sessionId) return;
|
|
2086
2124
|
const nowMs = lastUsedAtMs ?? Date.now();
|
|
2125
|
+
const credentialId = this.#getStoredCredentials(provider)[index]?.id;
|
|
2087
2126
|
const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map();
|
|
2088
|
-
sessionMap.set(sessionId, { type, index, lastUsedAtMs: nowMs });
|
|
2127
|
+
sessionMap.set(sessionId, { type, index, credentialId, lastUsedAtMs: nowMs });
|
|
2089
2128
|
this.#sessionLastCredential.set(provider, sessionMap);
|
|
2090
2129
|
|
|
2091
2130
|
try {
|
|
2092
|
-
const credentialId = this.#getStoredCredentials(provider)[index]?.id;
|
|
2093
2131
|
if (credentialId !== undefined) {
|
|
2094
2132
|
const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
|
|
2095
2133
|
const cacheValue = JSON.stringify({
|
|
@@ -2111,11 +2149,24 @@ export class AuthStorage {
|
|
|
2111
2149
|
#getSessionCredential(
|
|
2112
2150
|
provider: string,
|
|
2113
2151
|
sessionId: string | undefined,
|
|
2114
|
-
): { type: AuthCredential["type"]; index: number; lastUsedAtMs?: number } | undefined {
|
|
2152
|
+
): { type: AuthCredential["type"]; index: number; credentialId?: number; lastUsedAtMs?: number } | undefined {
|
|
2115
2153
|
if (!sessionId) return undefined;
|
|
2116
2154
|
let sessionMap = this.#sessionLastCredential.get(provider);
|
|
2117
|
-
|
|
2118
|
-
|
|
2155
|
+
const live = sessionMap?.get(sessionId);
|
|
2156
|
+
if (live) {
|
|
2157
|
+
// Another process can add or drop rows mid-session and the pool is an
|
|
2158
|
+
// index-ordered snapshot, so re-resolve the pin through its durable row
|
|
2159
|
+
// id: a compacted array must not point the session at a different
|
|
2160
|
+
// account, and a deleted account must not hand its slot to a sibling.
|
|
2161
|
+
if (live.credentialId === undefined) return live;
|
|
2162
|
+
const stored = this.#getStoredCredentials(provider);
|
|
2163
|
+
const actualIndex = stored.findIndex(entry => entry.id === live.credentialId);
|
|
2164
|
+
if (actualIndex === -1 || stored[actualIndex]?.credential.type !== live.type) {
|
|
2165
|
+
sessionMap?.delete(sessionId);
|
|
2166
|
+
return undefined;
|
|
2167
|
+
}
|
|
2168
|
+
live.index = actualIndex;
|
|
2169
|
+
return live;
|
|
2119
2170
|
}
|
|
2120
2171
|
try {
|
|
2121
2172
|
const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
|
|
@@ -2149,6 +2200,7 @@ export class AuthStorage {
|
|
|
2149
2200
|
const sessionVal = {
|
|
2150
2201
|
type: val.type,
|
|
2151
2202
|
index: val.index,
|
|
2203
|
+
credentialId: val.credentialId,
|
|
2152
2204
|
lastUsedAtMs: val.lastUsedAtMs,
|
|
2153
2205
|
};
|
|
2154
2206
|
sessionMap.set(sessionId, sessionVal);
|
|
@@ -3145,6 +3197,7 @@ export class AuthStorage {
|
|
|
3145
3197
|
onProgress: ctrl.onProgress,
|
|
3146
3198
|
onPrompt: ctrl.onPrompt,
|
|
3147
3199
|
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
|
|
3200
|
+
onBrowserSession: ctrl.onBrowserSession,
|
|
3148
3201
|
signal: ctrl.signal,
|
|
3149
3202
|
fetch: ctrl.fetch,
|
|
3150
3203
|
});
|
|
@@ -3730,7 +3783,7 @@ export class AuthStorage {
|
|
|
3730
3783
|
ingestUsageHeaders(
|
|
3731
3784
|
provider: Provider,
|
|
3732
3785
|
headers: Record<string, string>,
|
|
3733
|
-
options?: { sessionId?: string; baseUrl?: string },
|
|
3786
|
+
options?: { sessionId?: string; baseUrl?: string; responseStatus?: number },
|
|
3734
3787
|
): boolean {
|
|
3735
3788
|
if (this.#fetchUsageReportsOverride) return false;
|
|
3736
3789
|
const parseHeaders = this.#resolveUsageProvider(provider)?.parseRateLimitHeaders;
|
|
@@ -3743,7 +3796,7 @@ export class AuthStorage {
|
|
|
3743
3796
|
this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
|
|
3744
3797
|
);
|
|
3745
3798
|
const now = Date.now();
|
|
3746
|
-
const parsedReport = parseHeaders(headers, now);
|
|
3799
|
+
const parsedReport = parseHeaders(headers, now, { responseStatus: options?.responseStatus });
|
|
3747
3800
|
if (!parsedReport) return false;
|
|
3748
3801
|
// Throttled to one ingest per interval — except when a window reads
|
|
3749
3802
|
// exhausted: persist that snapshot immediately. A full-backed cache can
|
|
@@ -4204,7 +4257,14 @@ export class AuthStorage {
|
|
|
4204
4257
|
const credentialType = entry.credential.type;
|
|
4205
4258
|
const providerKey = this.#getProviderTypeKey(provider, credentialType);
|
|
4206
4259
|
let blockedUntil = this.#getCredentialBlockedUntil(provider, providerKey, index, blockScopes);
|
|
4207
|
-
|
|
4260
|
+
// A block under a scope the strategy can vouch for must still fetch
|
|
4261
|
+
// a probe report, or it outlives the recovery that report would
|
|
4262
|
+
// prove: no report means no reconciliation, so the credential idles
|
|
4263
|
+
// until the clock runs out even after quota is restored.
|
|
4264
|
+
if (
|
|
4265
|
+
blockedUntil !== undefined &&
|
|
4266
|
+
!this.#blockedCredentialCanHeal(provider, providerKey, index, blockScopes)
|
|
4267
|
+
) {
|
|
4208
4268
|
return {
|
|
4209
4269
|
credentialId: entry.id,
|
|
4210
4270
|
credentialType,
|
|
@@ -4231,7 +4291,7 @@ export class AuthStorage {
|
|
|
4231
4291
|
planEligibilityByCredential.set(entry.id, getOpenAICodexPlanEligibility(report, planRequirement));
|
|
4232
4292
|
}
|
|
4233
4293
|
|
|
4234
|
-
if (provider
|
|
4294
|
+
if (this.#supportsUsageBlockHealing(provider)) {
|
|
4235
4295
|
blockedUntil = this.#getCredentialBlockedUntil(provider, providerKey, index, blockScopes);
|
|
4236
4296
|
}
|
|
4237
4297
|
if (blockedUntil !== undefined) {
|
|
@@ -4795,6 +4855,7 @@ export class AuthStorage {
|
|
|
4795
4855
|
signal?: AbortSignal;
|
|
4796
4856
|
},
|
|
4797
4857
|
): Promise<UsageLimitMarkResult> {
|
|
4858
|
+
await this.#adoptExternalCredentialChanges();
|
|
4798
4859
|
let sessionCredential = await this.#resolveCredentialTarget(provider, sessionId, {
|
|
4799
4860
|
credentialId: options?.credentialId,
|
|
4800
4861
|
apiKey: options?.apiKey,
|
|
@@ -4820,7 +4881,8 @@ export class AuthStorage {
|
|
|
4820
4881
|
|
|
4821
4882
|
const routing = this.#credentialBlockRouting(provider, credentialType, options?.modelId);
|
|
4822
4883
|
const now = Date.now();
|
|
4823
|
-
|
|
4884
|
+
const requestedBlockedUntilMs = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
|
|
4885
|
+
let blockedUntil = requestedBlockedUntilMs;
|
|
4824
4886
|
// Heuristic/default fallbacks are guesses; provider-stated hints and
|
|
4825
4887
|
// report-derived extensions are timed.
|
|
4826
4888
|
let providerTimed = options?.providerTimed === true;
|
|
@@ -4871,7 +4933,11 @@ export class AuthStorage {
|
|
|
4871
4933
|
routing,
|
|
4872
4934
|
providerTimed,
|
|
4873
4935
|
);
|
|
4874
|
-
return
|
|
4936
|
+
return {
|
|
4937
|
+
...rotation,
|
|
4938
|
+
requestedBlockedUntilMs,
|
|
4939
|
+
...(reportResetAtMs === undefined ? {} : { reportResetAtMs }),
|
|
4940
|
+
};
|
|
4875
4941
|
}
|
|
4876
4942
|
|
|
4877
4943
|
#resolveWindowResetAt(window: UsageLimit["window"]): number | undefined {
|
|
@@ -5011,7 +5077,15 @@ export class AuthStorage {
|
|
|
5011
5077
|
);
|
|
5012
5078
|
let usage: UsageReport | null = null;
|
|
5013
5079
|
let usageChecked = false;
|
|
5014
|
-
if (
|
|
5080
|
+
if (
|
|
5081
|
+
blockedUntil !== undefined &&
|
|
5082
|
+
this.#blockedCredentialCanHeal(
|
|
5083
|
+
args.provider,
|
|
5084
|
+
args.providerKey,
|
|
5085
|
+
selection.index,
|
|
5086
|
+
args.blockScopes ?? args.blockScope,
|
|
5087
|
+
)
|
|
5088
|
+
) {
|
|
5015
5089
|
usage = await this.#getUsageReport(args.provider, selection.credential, {
|
|
5016
5090
|
...args.options,
|
|
5017
5091
|
timeoutMs: this.#usageRequestTimeoutMs,
|
|
@@ -5136,6 +5210,7 @@ export class AuthStorage {
|
|
|
5136
5210
|
sessionId?: string,
|
|
5137
5211
|
options?: AuthApiKeyOptions,
|
|
5138
5212
|
): Promise<OAuthResolutionResult | undefined> {
|
|
5213
|
+
await this.#adoptExternalCredentialChanges();
|
|
5139
5214
|
const credentials = this.#getCredentialsForProvider(provider)
|
|
5140
5215
|
.map((credential, index) => ({ credential, index }))
|
|
5141
5216
|
.filter((entry): entry is { credential: OAuthCredential; index: number } => entry.credential.type === "oauth");
|
|
@@ -5889,6 +5964,8 @@ export class AuthStorage {
|
|
|
5889
5964
|
return configKey;
|
|
5890
5965
|
}
|
|
5891
5966
|
|
|
5967
|
+
await this.#adoptExternalCredentialChanges();
|
|
5968
|
+
|
|
5892
5969
|
// Precedence: a deliberate OAuth/login credential wins, then an explicit env var,
|
|
5893
5970
|
// then a stored static api_key (which may be a stale broker-migrated copy) as a last resort.
|
|
5894
5971
|
const oauthSelection = this.#selectCredentialByType(provider, "oauth");
|
|
@@ -6164,6 +6241,32 @@ export class AuthStorage {
|
|
|
6164
6241
|
return true;
|
|
6165
6242
|
}
|
|
6166
6243
|
|
|
6244
|
+
/**
|
|
6245
|
+
* Copy every stored credential affinity from one live session to another.
|
|
6246
|
+
*
|
|
6247
|
+
* The target receives its own sticky entries, so request resolution, usage
|
|
6248
|
+
* blocking, credential rotation, metadata, and persisted pins all continue
|
|
6249
|
+
* through the target session id without retaining a live dependency on the
|
|
6250
|
+
* source session.
|
|
6251
|
+
*/
|
|
6252
|
+
inheritSessionCredentials(sourceSessionId: string, targetSessionId: string): number {
|
|
6253
|
+
if (!sourceSessionId || !targetSessionId || sourceSessionId === targetSessionId) return 0;
|
|
6254
|
+
let inherited = 0;
|
|
6255
|
+
for (const provider of this.#data.keys()) {
|
|
6256
|
+
const credential = this.#getSessionCredential(provider, sourceSessionId);
|
|
6257
|
+
if (!credential) continue;
|
|
6258
|
+
this.#recordSessionCredential(
|
|
6259
|
+
provider,
|
|
6260
|
+
targetSessionId,
|
|
6261
|
+
credential.type,
|
|
6262
|
+
credential.index,
|
|
6263
|
+
credential.lastUsedAtMs,
|
|
6264
|
+
);
|
|
6265
|
+
inherited += 1;
|
|
6266
|
+
}
|
|
6267
|
+
return inherited;
|
|
6268
|
+
}
|
|
6269
|
+
|
|
6167
6270
|
/**
|
|
6168
6271
|
* Resolve every stored OAuth credential for `provider` independently.
|
|
6169
6272
|
*
|
|
@@ -6612,6 +6715,29 @@ export class AuthStorage {
|
|
|
6612
6715
|
);
|
|
6613
6716
|
}
|
|
6614
6717
|
|
|
6718
|
+
/**
|
|
6719
|
+
* Whether a fresh report could lift what currently blocks this credential.
|
|
6720
|
+
*
|
|
6721
|
+
* A strategy that names healable scopes can only vouch for those scopes, so
|
|
6722
|
+
* a live unscoped block — an Opus/Sonnet usage limit, a refresh failure —
|
|
6723
|
+
* keeps the credential unusable whatever the report says about a tier. A
|
|
6724
|
+
* probe then cannot change the outcome and must not be spent; the tier scope
|
|
6725
|
+
* heals on a later pass, once the block that actually holds the credential
|
|
6726
|
+
* has lifted. Codex heals through its meter metadata rather than named
|
|
6727
|
+
* scopes, so its blocks always qualify.
|
|
6728
|
+
*/
|
|
6729
|
+
#blockedCredentialCanHeal(
|
|
6730
|
+
provider: Provider,
|
|
6731
|
+
providerKey: string,
|
|
6732
|
+
credentialIndex: number,
|
|
6733
|
+
blockScopeOrScopes: string | readonly string[] | undefined,
|
|
6734
|
+
): boolean {
|
|
6735
|
+
if (!this.#supportsUsageBlockHealing(provider)) return false;
|
|
6736
|
+
if (this.#rankingStrategyResolver?.(provider)?.healableBlockScopes === undefined) return true;
|
|
6737
|
+
if (this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex) !== undefined) return false;
|
|
6738
|
+
return this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex, blockScopeOrScopes) !== undefined;
|
|
6739
|
+
}
|
|
6740
|
+
|
|
6615
6741
|
/**
|
|
6616
6742
|
* Self-heal stale usage-limit blocks: when a fresh live usage report says a
|
|
6617
6743
|
* scope is below every limit gating it, drop its persisted and in-memory
|
|
@@ -6626,6 +6752,10 @@ export class AuthStorage {
|
|
|
6626
6752
|
if (credentialIndex < 0) return;
|
|
6627
6753
|
const strategy = this.#rankingStrategyResolver?.(provider);
|
|
6628
6754
|
if (provider !== "openai-codex") {
|
|
6755
|
+
// Only a live report proves recovery. A broker can serve its retained
|
|
6756
|
+
// last-good report for hours after `/usage` starts failing, and those
|
|
6757
|
+
// healthy limits describe the account before the 429 that blocked it.
|
|
6758
|
+
if (!Number.isFinite(report.fetchedAt) || Date.now() - report.fetchedAt > USAGE_REPORT_TTL_MS) return;
|
|
6629
6759
|
for (const { blockScope, limits } of strategy?.healableBlockScopes?.(report) ?? []) {
|
|
6630
6760
|
if (limits.length === 0 || this.#isUsageLimitReached(limits)) continue;
|
|
6631
6761
|
this.#clearHealedBlockScope(provider, providerKey, credentialId, credentialIndex, blockScope);
|
|
@@ -6861,6 +6991,7 @@ export class AuthStorage {
|
|
|
6861
6991
|
signal?: AbortSignal;
|
|
6862
6992
|
},
|
|
6863
6993
|
): Promise<boolean> {
|
|
6994
|
+
await this.#adoptExternalCredentialChanges();
|
|
6864
6995
|
const error = options?.error;
|
|
6865
6996
|
const status = AIError.status(error);
|
|
6866
6997
|
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
|
@@ -6870,7 +7001,7 @@ export class AuthStorage {
|
|
|
6870
7001
|
// Thread the provider-specified reset window (e.g. Devin "Your limit
|
|
6871
7002
|
// will reset in 13 minutes") into the block duration so the credential
|
|
6872
7003
|
// is not reselected and hammered while the cap remains active.
|
|
6873
|
-
const retryAfterMs =
|
|
7004
|
+
const retryAfterMs = extractProviderRetryHint(provider, message);
|
|
6874
7005
|
return (
|
|
6875
7006
|
await this.markUsageLimitReached(provider, sessionId, {
|
|
6876
7007
|
retryAfterMs,
|