@linxiraos/pi-ai 1.1.26 → 1.2.0
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 +9 -8
- package/README.md +15 -0
- package/dist/types/auth/affinity.d.ts +33 -6
- package/dist/types/auth/cascade.d.ts +44 -7
- package/dist/types/auth/oauth.d.ts +9 -6
- package/dist/types/auth/policy.d.ts +1 -0
- package/dist/types/auth/refresh.d.ts +1 -2
- package/dist/types/auth/select.d.ts +6 -4
- package/dist/types/auth/sqlite-credential-store.d.ts +21 -1
- package/dist/types/auth/store.d.ts +8 -2
- package/dist/types/auth/types.d.ts +99 -15
- package/dist/types/auth-broker/client.d.ts +3 -3
- package/dist/types/auth-broker/discover.d.ts +1 -1
- package/dist/types/auth-broker/refresher.d.ts +6 -1
- package/dist/types/auth-broker/remote-store.d.ts +6 -4
- package/dist/types/auth-broker/server.d.ts +2 -0
- package/dist/types/auth-broker/snapshot-cache.d.ts +12 -0
- package/dist/types/auth-gateway/dispatch.d.ts +13 -5
- package/dist/types/auth-gateway/http.d.ts +0 -2
- package/dist/types/auth-gateway/index.d.ts +1 -0
- package/dist/types/auth-gateway/routes/embeddings.d.ts +2 -2
- package/dist/types/auth-gateway/routes/images.d.ts +3 -3
- package/dist/types/auth-gateway/routes/rerank.d.ts +2 -2
- package/dist/types/auth-gateway/routes/speech.d.ts +2 -2
- package/dist/types/auth-gateway/routes/systemone.d.ts +2 -2
- package/dist/types/auth-gateway/routes/transcriptions.d.ts +2 -2
- package/dist/types/auth-gateway/routes/video.d.ts +4 -4
- package/dist/types/auth-gateway/server.d.ts +22 -5
- package/dist/types/auth-gateway/session-state.d.ts +6 -0
- package/dist/types/auth-gateway/stdio.d.ts +12 -0
- package/dist/types/auth-storage.d.ts +7 -6
- package/dist/types/dialect/terminator-wait.d.ts +19 -0
- package/dist/types/env-api-key.d.ts +21 -0
- package/dist/types/error/auth.d.ts +10 -0
- package/dist/types/error/flags.d.ts +38 -0
- package/dist/types/images/index.d.ts +2 -3
- package/dist/types/images/shared.d.ts +6 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/judgment/index.d.ts +2 -1
- package/dist/types/judgment/native.d.ts +80 -0
- package/dist/types/judgment/openai-decisions.d.ts +3 -0
- package/dist/types/judgment/question-file.d.ts +52 -0
- package/dist/types/judgment/systemone.d.ts +7 -0
- package/dist/types/judgment/types.d.ts +2 -2
- package/dist/types/judgment/wire.d.ts +36 -0
- package/dist/types/provider-details.d.ts +1 -1
- package/dist/types/provider-session-state.d.ts +2 -2
- package/dist/types/providers/anthropic-client.d.ts +10 -0
- package/dist/types/providers/anthropic-state.d.ts +2 -0
- package/dist/types/providers/anthropic.d.ts +38 -3
- package/dist/types/providers/aws-credentials.d.ts +4 -8
- package/dist/types/providers/connect-error-detail.d.ts +9 -0
- package/dist/types/providers/connect-frame.d.ts +44 -0
- package/dist/types/providers/cursor/interaction-query.d.ts +0 -2
- package/dist/types/providers/cursor.d.ts +8 -8
- package/dist/types/providers/google-auth.d.ts +4 -1
- package/dist/types/providers/google-shared.d.ts +24 -4
- package/dist/types/providers/inference-headers.d.ts +6 -0
- package/dist/types/providers/mock.d.ts +3 -1
- package/dist/types/providers/openai-codex/request-transformer.d.ts +2 -0
- package/dist/types/providers/openai-codex-responses.d.ts +1 -1
- package/dist/types/providers/openai-configuration-update.d.ts +20 -11
- package/dist/types/providers/openai-responses-wire.d.ts +1 -55
- package/dist/types/providers/openai-responses.d.ts +40 -3
- package/dist/types/providers/openai-shared.d.ts +30 -5
- package/dist/types/providers/pi-native-client.d.ts +1 -1
- package/dist/types/providers/transform-messages.d.ts +25 -0
- package/dist/types/providers/vision-guard.d.ts +1 -1
- package/dist/types/registry/oauth/cloudflare-workers-ai.d.ts +3 -0
- package/dist/types/registry/oauth/cursor.d.ts +10 -2
- package/dist/types/registry/oauth/index.d.ts +2 -0
- package/dist/types/registry/oauth/snowflake.d.ts +3 -0
- package/dist/types/registry/snowflake.d.ts +4 -0
- package/dist/types/stream.d.ts +0 -21
- package/dist/types/transcription/types.d.ts +2 -1
- package/dist/types/types.d.ts +116 -14
- package/dist/types/usage.d.ts +2 -0
- package/dist/types/utils/anthropic-auth.d.ts +21 -7
- package/dist/types/utils/aws-profile.d.ts +5 -0
- package/dist/types/utils/block-symbols.d.ts +14 -0
- package/dist/types/utils/dsml-leak.d.ts +29 -0
- package/dist/types/utils/glyph-codec.d.ts +1 -1
- package/dist/types/utils/idle-iterator.d.ts +11 -0
- package/dist/types/utils/leaked-thinking-stream.d.ts +1 -1
- package/dist/types/utils/resolve-peer.d.ts +2 -0
- package/dist/types/utils/retry-wait.d.ts +9 -0
- package/dist/types/utils/schema/normalize.d.ts +8 -3
- package/dist/types/utils/stream-markup-healing.d.ts +4 -4
- package/dist/types/utils/tool-call-arguments.d.ts +8 -0
- package/dist/types/utils.d.ts +6 -0
- package/package.json +6 -6
- package/src/auth/affinity.ts +130 -37
- package/src/auth/blocks.ts +94 -15
- package/src/auth/cascade.ts +112 -17
- package/src/auth/health.ts +3 -2
- package/src/auth/oauth.ts +25 -10
- package/src/auth/policy.ts +10 -1
- package/src/auth/pool.ts +0 -5
- package/src/auth/refresh.ts +17 -12
- package/src/auth/resets.ts +1 -1
- package/src/auth/rotation.ts +8 -2
- package/src/auth/select.ts +102 -30
- package/src/auth/sqlite-credential-store.ts +119 -49
- package/src/auth/store.ts +8 -2
- package/src/auth/types.ts +108 -17
- package/src/auth/usage-cache.ts +16 -5
- package/src/auth/usage.ts +2 -2
- package/src/auth-broker/client.ts +7 -4
- package/src/auth-broker/discover.ts +6 -5
- package/src/auth-broker/refresher.ts +8 -1
- package/src/auth-broker/remote-store.ts +200 -62
- package/src/auth-broker/server.ts +316 -130
- package/src/auth-broker/snapshot-cache.ts +142 -2
- package/src/auth-gateway/dispatch.ts +15 -7
- package/src/auth-gateway/http.ts +0 -8
- package/src/auth-gateway/index.ts +1 -0
- package/src/auth-gateway/routes/embeddings.ts +2 -2
- package/src/auth-gateway/routes/images.ts +4 -4
- package/src/auth-gateway/routes/rerank.ts +2 -2
- package/src/auth-gateway/routes/speech.ts +2 -2
- package/src/auth-gateway/routes/systemone.ts +22 -14
- package/src/auth-gateway/routes/transcriptions.ts +2 -2
- package/src/auth-gateway/routes/video.ts +7 -7
- package/src/auth-gateway/server.ts +175 -134
- package/src/auth-gateway/session-state.ts +51 -9
- package/src/auth-gateway/stdio.ts +132 -0
- package/src/auth-storage.ts +23 -9
- package/src/dialect/deepseek.ts +67 -8
- package/src/dialect/gemini.ts +8 -2
- package/src/dialect/hermes.ts +8 -2
- package/src/dialect/kimi.ts +8 -2
- package/src/dialect/qwen-xml.ts +8 -2
- package/src/dialect/qwen3.ts +8 -2
- package/src/dialect/terminator-wait.ts +42 -0
- package/src/env-api-key.ts +78 -0
- package/src/error/auth.ts +20 -0
- package/src/error/flags.ts +143 -24
- package/src/error/rate-limit.ts +8 -6
- package/src/images/google-antigravity.ts +0 -1
- package/src/images/index.ts +3 -17
- package/src/images/openai-hosted.ts +13 -4
- package/src/images/openai-images.ts +19 -13
- package/src/images/shared.ts +10 -2
- package/src/index.ts +2 -1
- package/src/judgment/index.ts +2 -1
- package/src/judgment/native.ts +333 -0
- package/src/judgment/openai-decisions-predicate.md +9 -0
- package/src/judgment/openai-decisions.ts +129 -0
- package/src/judgment/question-file.ts +90 -0
- package/src/judgment/systemone.ts +65 -0
- package/src/judgment/text.ts +9 -1
- package/src/judgment/types.ts +2 -2
- package/src/judgment/wire.ts +42 -0
- package/src/provider-details.ts +1 -1
- package/src/provider-session-state.ts +2 -2
- package/src/providers/amazon-bedrock.ts +19 -8
- package/src/providers/anthropic-client.ts +16 -1
- package/src/providers/anthropic-state.ts +6 -0
- package/src/providers/anthropic.ts +962 -238
- package/src/providers/apple-foundation-models.ts +10 -4
- package/src/providers/aws-credentials.ts +65 -5
- package/src/providers/azure-openai-responses.ts +1 -1
- package/src/providers/connect-error-detail.ts +21 -0
- package/src/providers/connect-frame.ts +107 -0
- package/src/providers/cursor/interaction-query.ts +1 -9
- package/src/providers/cursor.ts +590 -133
- package/src/providers/devin.ts +68 -58
- package/src/providers/factory-droid/gemini.ts +38 -51
- package/src/providers/factory-droid.ts +2 -0
- package/src/providers/gitlab-duo-workflow.ts +5 -2
- package/src/providers/gitlab-duo.ts +1 -0
- package/src/providers/google-auth.ts +10 -3
- package/src/providers/google-gemini-cli.ts +4 -0
- package/src/providers/google-shared.ts +134 -105
- package/src/providers/google-vertex.ts +0 -1
- package/src/providers/google.ts +1 -1
- package/src/providers/inference-headers.ts +19 -0
- package/src/providers/mock.ts +4 -0
- package/src/providers/ollama.ts +1 -1
- package/src/providers/openai-anthropic-shim.ts +1 -0
- package/src/providers/openai-codex/request-transformer.ts +16 -11
- package/src/providers/openai-codex-responses.ts +201 -76
- package/src/providers/openai-completions.ts +12 -2
- package/src/providers/openai-configuration-update.ts +54 -15
- package/src/providers/openai-responses-wire.ts +1 -55
- package/src/providers/openai-responses.ts +437 -33
- package/src/providers/openai-shared.ts +267 -72
- package/src/providers/pi-native-client.ts +2 -2
- package/src/providers/pi-native-server.ts +1 -0
- package/src/providers/systemone-server.ts +1 -1
- package/src/providers/transcriptions-server.ts +1 -1
- package/src/providers/transform-messages.ts +240 -84
- package/src/providers/vision-guard.ts +1 -1
- package/src/registry/aws.ts +51 -14
- package/src/registry/cloudflare-ai-gateway.ts +26 -3
- package/src/registry/hooks/api-key.ts +1 -0
- package/src/registry/hooks/custom.ts +2 -0
- package/src/registry/hooks/env.ts +2 -5
- package/src/registry/oauth/callback-server.ts +6 -1
- package/src/registry/oauth/cloudflare-ai-gateway.ts +2 -2
- package/src/registry/oauth/cloudflare-workers-ai.ts +32 -0
- package/src/registry/oauth/cursor.ts +80 -17
- package/src/registry/oauth/index.ts +5 -0
- package/src/registry/oauth/snowflake.ts +183 -0
- package/src/registry/registry.ts +2 -0
- package/src/registry/snowflake.ts +149 -0
- package/src/stream.ts +85 -107
- package/src/transcription/types.ts +2 -1
- package/src/types.ts +142 -15
- package/src/usage/claude.ts +19 -12
- package/src/usage/commandcode.ts +1 -1
- package/src/usage/cursor.ts +14 -25
- package/src/usage/github-copilot.ts +1 -1
- package/src/usage/kimi.ts +1 -1
- package/src/usage/minimax-code.ts +8 -1
- package/src/usage.ts +8 -0
- package/src/utils/anthropic-auth.ts +34 -10
- package/src/utils/aws-profile.ts +10 -4
- package/src/utils/block-symbols.ts +17 -0
- package/src/utils/dsml-leak.ts +109 -0
- package/src/utils/empty-completion-retry.ts +2 -3
- package/src/utils/glyph-codec.ts +200 -7
- package/src/utils/idle-iterator.ts +80 -35
- package/src/utils/leaked-thinking-stream.ts +47 -25
- package/src/utils/resolve-peer.ts +7 -0
- package/src/utils/retry-wait.ts +19 -0
- package/src/utils/schema/CONSTRAINTS.md +9 -7
- package/src/utils/schema/fields.ts +20 -0
- package/src/utils/schema/normalize.ts +65 -23
- package/src/utils/schema/wire.ts +3 -3
- package/src/utils/stream-markup-healing.ts +4 -4
- package/src/utils/thinking-loop.ts +36 -13
- package/src/utils/tool-call-arguments.ts +16 -0
- package/src/utils/transport-fetch.ts +1 -1
- package/src/utils.ts +41 -10
- package/dist/types/judgment/typesafe.d.ts +0 -49
- package/src/judgment/typesafe.ts +0 -183
package/src/auth/blocks.ts
CHANGED
|
@@ -64,6 +64,14 @@ export function credentialBlockScopesForRequest(
|
|
|
64
64
|
*/
|
|
65
65
|
type CredentialBackoff = { until: number; timed: boolean; probeAfter: number; persisted?: boolean };
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* One read of a credential's persisted blocks under a providerKey, keyed by
|
|
69
|
+
* scope (`""` is unscoped). `undefined`: the store has no bulk read, so use
|
|
70
|
+
* per-scope point reads. `null`: unreadable (damaged store or failed read), so
|
|
71
|
+
* it neither contributes deadlines nor retires mirrored in-memory blocks.
|
|
72
|
+
*/
|
|
73
|
+
type PersistedBlockScopes = ReadonlyMap<string, number> | null | undefined;
|
|
74
|
+
|
|
67
75
|
/** Latch for unrecoverably corrupt persisted block stores, shared with the credential pool. */
|
|
68
76
|
export class BlockStoreHealth {
|
|
69
77
|
readonly #sourceLabel: string | undefined;
|
|
@@ -134,19 +142,37 @@ export class CredentialBlocks implements BlocksApi {
|
|
|
134
142
|
}
|
|
135
143
|
}
|
|
136
144
|
|
|
137
|
-
/**
|
|
138
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Returns in-memory block expiry timestamp for a credential/key pair, cleaning up expired entries.
|
|
147
|
+
* `persistedScopes` is a bulk read for the key's providerKey; omitted, the store is point-read.
|
|
148
|
+
*/
|
|
149
|
+
#getCredentialBlockedUntilForKey(
|
|
150
|
+
backoffKey: string,
|
|
151
|
+
credentialId: number,
|
|
152
|
+
nowMs: number,
|
|
153
|
+
persistedScopes: PersistedBlockScopes = undefined,
|
|
154
|
+
): number | undefined {
|
|
139
155
|
const block = this.#credentialBackoff.get(backoffKey)?.get(credentialId);
|
|
140
156
|
if (!block) return undefined;
|
|
141
157
|
// Once mirrored successfully, the store is authoritative for cross-process
|
|
142
158
|
// deletion. A reset in a sibling must also retire this process's old map entry.
|
|
143
|
-
|
|
159
|
+
// Upserts are longest-wins, so a persisted row shorter than the deadline this
|
|
160
|
+
// process mirrored means the row was deleted and replaced: the old deadline
|
|
161
|
+
// was superseded and must not win the next longest-wins merge.
|
|
162
|
+
if (
|
|
163
|
+
block.persisted &&
|
|
164
|
+
!this.#deps.health.damaged &&
|
|
165
|
+
persistedScopes !== null &&
|
|
166
|
+
(persistedScopes !== undefined || this.#deps.store.getCredentialBlock)
|
|
167
|
+
) {
|
|
144
168
|
const separator = backoffKey.indexOf("\0");
|
|
145
169
|
const providerKey = separator < 0 ? backoffKey : backoffKey.slice(0, separator);
|
|
146
170
|
const scope = separator < 0 ? "" : backoffKey.slice(separator + 1);
|
|
147
171
|
try {
|
|
148
|
-
const persisted =
|
|
149
|
-
|
|
172
|
+
const persisted = persistedScopes
|
|
173
|
+
? persistedScopes.get(scope)
|
|
174
|
+
: this.#deps.store.getCredentialBlock?.(credentialId, providerKey, scope);
|
|
175
|
+
if (persisted === undefined || persisted < block.until) {
|
|
150
176
|
this.#deleteCredentialBackoff(backoffKey, credentialId);
|
|
151
177
|
return undefined;
|
|
152
178
|
}
|
|
@@ -172,7 +198,9 @@ export class CredentialBlocks implements BlocksApi {
|
|
|
172
198
|
credentialId: number,
|
|
173
199
|
providerKey: string,
|
|
174
200
|
blockScope: string | undefined,
|
|
201
|
+
persistedScopes: PersistedBlockScopes = undefined,
|
|
175
202
|
): number | undefined {
|
|
203
|
+
if (persistedScopes !== undefined) return persistedScopes?.get(blockScope ?? "");
|
|
176
204
|
if (this.#deps.health.damaged) return undefined;
|
|
177
205
|
const getCredentialBlock = this.#deps.store.getCredentialBlock?.bind(this.#deps.store);
|
|
178
206
|
if (!getCredentialBlock) return undefined;
|
|
@@ -190,6 +218,20 @@ export class CredentialBlocks implements BlocksApi {
|
|
|
190
218
|
}
|
|
191
219
|
}
|
|
192
220
|
|
|
221
|
+
/** Every persisted scope of one credential/providerKey in a single store read, when supported. */
|
|
222
|
+
#readPersistedCredentialBlockScopes(credentialId: number, providerKey: string): PersistedBlockScopes {
|
|
223
|
+
const store = this.#deps.store;
|
|
224
|
+
if (!store.getCredentialBlockScopes) return undefined;
|
|
225
|
+
if (this.#deps.health.damaged) return null;
|
|
226
|
+
try {
|
|
227
|
+
return store.getCredentialBlockScopes(credentialId, providerKey);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
if (this.#deps.health.handle(err)) return null;
|
|
230
|
+
logger.debug("Failed to read credential blocks from persistent store", { err, credentialId, providerKey });
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
193
235
|
#readPersistedCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number {
|
|
194
236
|
if (this.#deps.health.damaged) return 0;
|
|
195
237
|
const getCredentialBlockReconcileAfter = this.#deps.store.getCredentialBlockReconcileAfter?.bind(
|
|
@@ -212,13 +254,19 @@ export class CredentialBlocks implements BlocksApi {
|
|
|
212
254
|
}
|
|
213
255
|
}
|
|
214
256
|
|
|
215
|
-
#exactBlockedUntil(
|
|
257
|
+
#exactBlockedUntil(
|
|
258
|
+
credentialId: number,
|
|
259
|
+
providerKey: string,
|
|
260
|
+
scope: string | undefined,
|
|
261
|
+
persistedScopes: PersistedBlockScopes = this.#readPersistedCredentialBlockScopes(credentialId, providerKey),
|
|
262
|
+
): number | undefined {
|
|
216
263
|
const memory = this.#getCredentialBlockedUntilForKey(
|
|
217
264
|
scopedBackoffKey(providerKey, scope),
|
|
218
265
|
credentialId,
|
|
219
266
|
Date.now(),
|
|
267
|
+
persistedScopes,
|
|
220
268
|
);
|
|
221
|
-
const persisted = this.#readPersistedCredentialBlock(credentialId, providerKey, scope);
|
|
269
|
+
const persisted = this.#readPersistedCredentialBlock(credentialId, providerKey, scope, persistedScopes);
|
|
222
270
|
return memory === undefined ? persisted : persisted === undefined ? memory : Math.max(memory, persisted);
|
|
223
271
|
}
|
|
224
272
|
|
|
@@ -228,6 +276,22 @@ export class CredentialBlocks implements BlocksApi {
|
|
|
228
276
|
providerKey: string,
|
|
229
277
|
credentialIndex: number,
|
|
230
278
|
blockScopeOrScopes: string | readonly string[] | undefined = undefined,
|
|
279
|
+
): number | undefined {
|
|
280
|
+
const credentialId = this.#deps.pool.entries(provider)[credentialIndex]?.id;
|
|
281
|
+
if (credentialId === undefined) return undefined;
|
|
282
|
+
return this.#blockedUntilById(
|
|
283
|
+
credentialId,
|
|
284
|
+
providerKey,
|
|
285
|
+
blockScopeOrScopes,
|
|
286
|
+
this.#readPersistedCredentialBlockScopes(credentialId, providerKey),
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#blockedUntilById(
|
|
291
|
+
credentialId: number,
|
|
292
|
+
providerKey: string,
|
|
293
|
+
blockScopeOrScopes: string | readonly string[] | undefined,
|
|
294
|
+
persistedScopes: PersistedBlockScopes,
|
|
231
295
|
): number | undefined {
|
|
232
296
|
const nowMs = Date.now();
|
|
233
297
|
// A request honours its own scope plus any legacy catch-all scope, so a
|
|
@@ -236,21 +300,25 @@ export class CredentialBlocks implements BlocksApi {
|
|
|
236
300
|
...PROTECTED_BLOCK_SCOPES,
|
|
237
301
|
...(typeof blockScopeOrScopes === "string" ? [blockScopeOrScopes] : (blockScopeOrScopes ?? [])),
|
|
238
302
|
].filter(scope => scope.length > 0);
|
|
239
|
-
|
|
240
|
-
if (credentialId === undefined) return undefined;
|
|
241
|
-
let blockedUntil = this.#getCredentialBlockedUntilForKey(providerKey, credentialId, nowMs);
|
|
303
|
+
let blockedUntil = this.#getCredentialBlockedUntilForKey(providerKey, credentialId, nowMs, persistedScopes);
|
|
242
304
|
for (const blockScope of scopes) {
|
|
243
305
|
const scopedBlockedUntil = this.#getCredentialBlockedUntilForKey(
|
|
244
306
|
scopedBackoffKey(providerKey, blockScope),
|
|
245
307
|
credentialId,
|
|
246
308
|
nowMs,
|
|
309
|
+
persistedScopes,
|
|
247
310
|
);
|
|
248
311
|
if (scopedBlockedUntil !== undefined && (blockedUntil === undefined || scopedBlockedUntil > blockedUntil)) {
|
|
249
312
|
blockedUntil = scopedBlockedUntil;
|
|
250
313
|
}
|
|
251
314
|
}
|
|
252
315
|
|
|
253
|
-
const persistedGlobalBlockedUntil = this.#readPersistedCredentialBlock(
|
|
316
|
+
const persistedGlobalBlockedUntil = this.#readPersistedCredentialBlock(
|
|
317
|
+
credentialId,
|
|
318
|
+
providerKey,
|
|
319
|
+
"",
|
|
320
|
+
persistedScopes,
|
|
321
|
+
);
|
|
254
322
|
if (
|
|
255
323
|
persistedGlobalBlockedUntil !== undefined &&
|
|
256
324
|
(blockedUntil === undefined || persistedGlobalBlockedUntil > blockedUntil)
|
|
@@ -258,7 +326,12 @@ export class CredentialBlocks implements BlocksApi {
|
|
|
258
326
|
blockedUntil = persistedGlobalBlockedUntil;
|
|
259
327
|
}
|
|
260
328
|
for (const blockScope of scopes) {
|
|
261
|
-
const persistedScopedBlockedUntil = this.#readPersistedCredentialBlock(
|
|
329
|
+
const persistedScopedBlockedUntil = this.#readPersistedCredentialBlock(
|
|
330
|
+
credentialId,
|
|
331
|
+
providerKey,
|
|
332
|
+
blockScope,
|
|
333
|
+
persistedScopes,
|
|
334
|
+
);
|
|
262
335
|
if (
|
|
263
336
|
persistedScopedBlockedUntil !== undefined &&
|
|
264
337
|
(blockedUntil === undefined || persistedScopedBlockedUntil > blockedUntil)
|
|
@@ -437,14 +510,20 @@ export class CredentialBlocks implements BlocksApi {
|
|
|
437
510
|
if (!this.supportsHealing(provider)) return false;
|
|
438
511
|
const credentialId = this.#deps.pool.entries(provider)[credentialIndex]?.id;
|
|
439
512
|
if (credentialId === undefined) return false;
|
|
440
|
-
|
|
513
|
+
// One persisted read serves the protected-scope, global, and requested checks.
|
|
514
|
+
const persistedScopes = this.#readPersistedCredentialBlockScopes(credentialId, providerKey);
|
|
515
|
+
if (
|
|
516
|
+
PROTECTED_BLOCK_SCOPES.some(
|
|
517
|
+
scope => this.#exactBlockedUntil(credentialId, providerKey, scope, persistedScopes) !== undefined,
|
|
518
|
+
)
|
|
519
|
+
)
|
|
441
520
|
return false;
|
|
442
521
|
if (
|
|
443
522
|
!this.#deps.strategies(provider)?.healsGlobalBlocks &&
|
|
444
|
-
this
|
|
523
|
+
this.#blockedUntilById(credentialId, providerKey, undefined, persistedScopes) !== undefined
|
|
445
524
|
)
|
|
446
525
|
return false;
|
|
447
|
-
return this
|
|
526
|
+
return this.#blockedUntilById(credentialId, providerKey, blockScopeOrScopes, persistedScopes) !== undefined;
|
|
448
527
|
}
|
|
449
528
|
|
|
450
529
|
/**
|
package/src/auth/cascade.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { authPolicyFor } from "@linxiraos/pi-catalog/compat/auth";
|
|
2
|
-
import { $env, $envExact } from "@linxiraos/pi-utils";
|
|
2
|
+
import { $env, $envExact, logger } from "@linxiraos/pi-utils";
|
|
3
3
|
import { type ApiKeyResolver, markAfterSiblingWait, type ResolvedApiKey } from "../auth-retry";
|
|
4
|
+
import { getEnvApiKey, getEnvApiKeyName } from "../env-api-key";
|
|
4
5
|
import * as AIError from "../error";
|
|
5
6
|
import { isUsageLimitOutcome } from "../error/rate-limit";
|
|
6
7
|
import { AUTHENTICATED_SENTINEL } from "../registry/types";
|
|
7
|
-
import { getEnvApiKey, getEnvApiKeyName } from "../stream";
|
|
8
8
|
import type { SessionAffinity } from "./affinity";
|
|
9
9
|
import type { CredentialPool } from "./pool";
|
|
10
|
-
import type { CredentialSelector } from "./select";
|
|
10
|
+
import type { CredentialSelector, OAuthResolutionResult } from "./select";
|
|
11
11
|
import type {
|
|
12
12
|
AuthApiKeyOptions,
|
|
13
13
|
AuthCredential,
|
|
@@ -32,6 +32,7 @@ export class KeyOverrides {
|
|
|
32
32
|
#runtimeOverrides: Map<string, string> = new Map();
|
|
33
33
|
#configOverrides: Map<string, string> = new Map();
|
|
34
34
|
#configFallbacks: Map<string, string> = new Map();
|
|
35
|
+
#mirrorKeys: Map<string, string> = new Map();
|
|
35
36
|
#configValueResolver: (config: string) => Promise<string | undefined>;
|
|
36
37
|
|
|
37
38
|
constructor(resolver?: (config: string) => Promise<string | undefined>) {
|
|
@@ -42,6 +43,17 @@ export class KeyOverrides {
|
|
|
42
43
|
return this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider);
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Whether an override replaces OAuth for a session. Any override does for an
|
|
48
|
+
* unrestricted session. A restricted session skips the runtime key, but a
|
|
49
|
+
* config key still blocks OAuth: it marks the provider's endpoint (often a
|
|
50
|
+
* proxy) as taking that key, so the session fails closed instead of sending
|
|
51
|
+
* a pooled OAuth token there (see `KeyCascade.get`).
|
|
52
|
+
*/
|
|
53
|
+
suppressesOAuth(provider: string, restricted: boolean): boolean {
|
|
54
|
+
return restricted ? this.#configOverrides.has(provider) : this.has(provider);
|
|
55
|
+
}
|
|
56
|
+
|
|
45
57
|
runtimeKey(provider: string): string | undefined {
|
|
46
58
|
return this.#runtimeOverrides.get(provider);
|
|
47
59
|
}
|
|
@@ -55,6 +67,16 @@ export class KeyOverrides {
|
|
|
55
67
|
return this.#configFallbacks.get(provider);
|
|
56
68
|
}
|
|
57
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Config value consulted only after every other source, including stored
|
|
72
|
+
* api_key credentials — the lowest cascade tier. The OMP compatibility
|
|
73
|
+
* overlay uses it so an upstream key is a pure fallback: any credential
|
|
74
|
+
* the user later stores locally wins without needing to evict the mirror.
|
|
75
|
+
*/
|
|
76
|
+
mirrorKey(provider: string): string | undefined {
|
|
77
|
+
return this.#mirrorKeys.get(provider);
|
|
78
|
+
}
|
|
79
|
+
|
|
58
80
|
/** Resolve a config value (env var name, "!command", literal) to the secret. */
|
|
59
81
|
resolve(config: string): Promise<string | undefined> {
|
|
60
82
|
return this.#configValueResolver(config);
|
|
@@ -89,8 +111,19 @@ export class KeyOverrides {
|
|
|
89
111
|
* credentials (at the env-var tier). Providers that own a `/login` flow use
|
|
90
112
|
* this so their default key reference (e.g. an unset env-var name, which
|
|
91
113
|
* resolves to its literal text) cannot shadow the key the user logged in with.
|
|
114
|
+
*
|
|
115
|
+
* `mirror: true` ranks the value below everything else, under stored
|
|
116
|
+
* api_key credentials — a read-only compatibility mirror (upstream OMP
|
|
117
|
+
* agent.db key) that must never shadow a key the user stores locally.
|
|
92
118
|
*/
|
|
93
|
-
setConfig(provider: string, apiKeyConfig: string, options?: { fallback?: boolean }): void {
|
|
119
|
+
setConfig(provider: string, apiKeyConfig: string, options?: { fallback?: boolean; mirror?: boolean }): void {
|
|
120
|
+
if (options?.mirror) {
|
|
121
|
+
this.#configOverrides.delete(provider);
|
|
122
|
+
this.#configFallbacks.delete(provider);
|
|
123
|
+
this.#mirrorKeys.set(provider, apiKeyConfig);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
this.#mirrorKeys.delete(provider);
|
|
94
127
|
if (options?.fallback) {
|
|
95
128
|
this.#configOverrides.delete(provider);
|
|
96
129
|
this.#configFallbacks.set(provider, apiKeyConfig);
|
|
@@ -101,11 +134,12 @@ export class KeyOverrides {
|
|
|
101
134
|
}
|
|
102
135
|
|
|
103
136
|
/**
|
|
104
|
-
* Remove a single config-sourced API key (override or
|
|
137
|
+
* Remove a single config-sourced API key (override, fallback, or mirror).
|
|
105
138
|
*/
|
|
106
139
|
removeConfig(provider: string): void {
|
|
107
140
|
this.#configOverrides.delete(provider);
|
|
108
141
|
this.#configFallbacks.delete(provider);
|
|
142
|
+
this.#mirrorKeys.delete(provider);
|
|
109
143
|
}
|
|
110
144
|
|
|
111
145
|
/**
|
|
@@ -115,6 +149,7 @@ export class KeyOverrides {
|
|
|
115
149
|
clearConfig(): void {
|
|
116
150
|
this.#configOverrides.clear();
|
|
117
151
|
this.#configFallbacks.clear();
|
|
152
|
+
this.#mirrorKeys.clear();
|
|
118
153
|
}
|
|
119
154
|
|
|
120
155
|
/**
|
|
@@ -139,7 +174,7 @@ export interface KeyCascadeDeps {
|
|
|
139
174
|
sourceLabel?: string;
|
|
140
175
|
}
|
|
141
176
|
|
|
142
|
-
/** The provider auth precedence cascade (runtime → config → OAuth → login key → env → stored key). */
|
|
177
|
+
/** The provider auth precedence cascade (runtime → config → OAuth → login key → env → stored key → compat mirror). */
|
|
143
178
|
export class KeyCascade implements KeysApi {
|
|
144
179
|
#deps: KeyCascadeDeps;
|
|
145
180
|
|
|
@@ -196,8 +231,8 @@ export class KeyCascade implements KeysApi {
|
|
|
196
231
|
/**
|
|
197
232
|
* Classify where a provider's auth comes from, following the same precedence
|
|
198
233
|
* as {@link KeyCascade.get}: runtime override → config override →
|
|
199
|
-
* stored OAuth → login-stored api_key → config fallback → env var →
|
|
200
|
-
* Returns undefined when no auth is configured.
|
|
234
|
+
* stored OAuth → login-stored api_key → config fallback → env var →
|
|
235
|
+
* stored api_key → compat mirror. Returns undefined when no auth is configured.
|
|
201
236
|
*
|
|
202
237
|
* Compact, structured counterpart to {@link KeyCascade.describe}; `env`
|
|
203
238
|
* selects dedicated-only, alias-aware, or no environment fallback.
|
|
@@ -219,6 +254,7 @@ export class KeyCascade implements KeysApi {
|
|
|
219
254
|
return { kind: "env", envVar: getEnvApiKeyName(provider), concrete };
|
|
220
255
|
}
|
|
221
256
|
if (bearing.some(credential => credential.type === "api_key")) return { kind: "api_key", concrete: true };
|
|
257
|
+
if (this.#deps.overrides.mirrorKey(provider) !== undefined) return { kind: "config", concrete: true };
|
|
222
258
|
return undefined;
|
|
223
259
|
}
|
|
224
260
|
|
|
@@ -294,10 +330,18 @@ export class KeyCascade implements KeysApi {
|
|
|
294
330
|
if (apiKeySelection) {
|
|
295
331
|
return this.#deps.overrides.resolve(apiKeySelection.credential.key);
|
|
296
332
|
}
|
|
333
|
+
const mirrorKey = this.#deps.overrides.mirrorKey(provider);
|
|
334
|
+
if (mirrorKey !== undefined) return this.#deps.overrides.resolve(mirrorKey);
|
|
297
335
|
return undefined;
|
|
298
336
|
}
|
|
299
337
|
|
|
300
|
-
/**
|
|
338
|
+
/**
|
|
339
|
+
* Resolve a bearer together with the stored row that supplied it, for a request.
|
|
340
|
+
* Same precedence as {@link KeyCascade.get}, but rejects with
|
|
341
|
+
* {@link AIError.OAuthRefreshUnavailableError} (transient, retryable) when OAuth
|
|
342
|
+
* refresh failed with a retryable error and no other permitted source could serve
|
|
343
|
+
* the request, so request paths retry instead of reporting a missing key.
|
|
344
|
+
*/
|
|
301
345
|
async getWithCredential(
|
|
302
346
|
provider: string,
|
|
303
347
|
sessionId?: string,
|
|
@@ -305,7 +349,7 @@ export class KeyCascade implements KeysApi {
|
|
|
305
349
|
): Promise<ResolvedApiKey | undefined> {
|
|
306
350
|
let credentialId: number | undefined;
|
|
307
351
|
let oauthIdentity: OAuthRequestIdentity | undefined;
|
|
308
|
-
const apiKey = await this
|
|
352
|
+
const apiKey = await this.#resolve(provider, sessionId, options, (id, identity) => {
|
|
309
353
|
credentialId = id;
|
|
310
354
|
oauthIdentity = identity;
|
|
311
355
|
});
|
|
@@ -315,16 +359,36 @@ export class KeyCascade implements KeysApi {
|
|
|
315
359
|
/**
|
|
316
360
|
* Get API key for a provider.
|
|
317
361
|
* Priority (first match wins): runtime override, config override, OAuth,
|
|
362
|
+
* login API key, environment variable, another stored API key, then the
|
|
363
|
+
* lowest-tier OMP compat mirror.
|
|
318
364
|
* login API key, environment variable, then another stored API key.
|
|
365
|
+
* A session restricted by `sessions.restrict` resolves only its allowed
|
|
366
|
+
* OAuth accounts and throws {@link AIError.MissingApiKeyError} when none
|
|
367
|
+
* can serve or a config key (a models.yml `apiKey`, often for a proxy
|
|
368
|
+
* `baseUrl`) owns the provider, rather than using any other credential.
|
|
369
|
+
* A transient OAuth refresh failure resolves `undefined`, so availability
|
|
370
|
+
* probes move on to their next candidate; request paths use
|
|
371
|
+
* {@link KeyCascade.getWithCredential}, which surfaces it.
|
|
319
372
|
*/
|
|
320
|
-
async get(
|
|
373
|
+
async get(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
|
|
374
|
+
try {
|
|
375
|
+
return await this.#resolve(provider, sessionId, options);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
if (!(error instanceof AIError.OAuthRefreshUnavailableError)) throw error;
|
|
378
|
+
logger.debug("API key probe skipped transient OAuth refresh failure", { provider, error: error.message });
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async #resolve(
|
|
321
384
|
provider: string,
|
|
322
385
|
sessionId?: string,
|
|
323
386
|
options?: AuthApiKeyOptions,
|
|
324
387
|
onCredentialId?: (id: number, identity?: OAuthRequestIdentity) => void,
|
|
325
388
|
): Promise<string | undefined> {
|
|
389
|
+
const restricted = this.#deps.affinity.isRestricted(provider, sessionId);
|
|
326
390
|
// Runtime override takes highest priority
|
|
327
|
-
const runtimeKey = this.#deps.overrides.runtimeKey(provider);
|
|
391
|
+
const runtimeKey = restricted ? undefined : this.#deps.overrides.runtimeKey(provider);
|
|
328
392
|
if (runtimeKey) {
|
|
329
393
|
return runtimeKey;
|
|
330
394
|
}
|
|
@@ -334,14 +398,29 @@ export class KeyCascade implements KeysApi {
|
|
|
334
398
|
// (e.g. an auth-gateway) and supplied the bearer for that endpoint —
|
|
335
399
|
// honor it instead of forwarding an upstream OAuth token that the proxy
|
|
336
400
|
// won't accept.
|
|
337
|
-
|
|
401
|
+
if (restricted && this.#deps.overrides.configKey(provider) !== undefined) {
|
|
402
|
+
throw new AIError.MissingApiKeyError(
|
|
403
|
+
provider,
|
|
404
|
+
`No API key for provider: ${provider} (session ${sessionId} is restricted to its OAuth account pool, but models.yml configures an apiKey for this provider, and pooled OAuth tokens are never sent past it)`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
const configKey = restricted ? undefined : this.#deps.overrides.configKey(provider);
|
|
338
408
|
if (configKey !== undefined) {
|
|
339
409
|
return this.#deps.overrides.resolve(configKey);
|
|
340
410
|
}
|
|
341
411
|
|
|
342
412
|
// Precedence: a deliberate OAuth/login credential wins, then an explicit env var,
|
|
343
413
|
// then a stored static api_key (which may be a stale broker-migrated copy) as a last resort.
|
|
344
|
-
|
|
414
|
+
// A transient OAuth refresh failure still lets later sources serve the request; it is
|
|
415
|
+
// rethrown only when none can, so callers retry instead of reporting a missing key.
|
|
416
|
+
let oauthResolved: OAuthResolutionResult | undefined;
|
|
417
|
+
let oauthRefreshFailure: AIError.OAuthRefreshUnavailableError | undefined;
|
|
418
|
+
try {
|
|
419
|
+
oauthResolved = await this.#deps.selector.resolveOAuth(provider, sessionId, options);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
if (!(error instanceof AIError.OAuthRefreshUnavailableError)) throw error;
|
|
422
|
+
oauthRefreshFailure = error;
|
|
423
|
+
}
|
|
345
424
|
if (oauthResolved) {
|
|
346
425
|
if (onCredentialId && oauthResolved.credentialId !== undefined) {
|
|
347
426
|
const { orgId, region, inferenceRegion } = oauthResolved.credential;
|
|
@@ -349,6 +428,13 @@ export class KeyCascade implements KeysApi {
|
|
|
349
428
|
}
|
|
350
429
|
return oauthResolved.apiKey;
|
|
351
430
|
}
|
|
431
|
+
if (restricted) {
|
|
432
|
+
if (oauthRefreshFailure) throw oauthRefreshFailure;
|
|
433
|
+
throw new AIError.MissingApiKeyError(
|
|
434
|
+
provider,
|
|
435
|
+
`No API key for provider: ${provider} (session ${sessionId} is restricted to its OAuth account pool and none of those accounts is available)`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
352
438
|
const loginApiKeySelection = await this.#deps.selector.selectApiKey(
|
|
353
439
|
provider,
|
|
354
440
|
sessionId,
|
|
@@ -356,7 +442,9 @@ export class KeyCascade implements KeysApi {
|
|
|
356
442
|
credential => credential.source === "login" && !this.isKeylessFallback(provider, credential),
|
|
357
443
|
);
|
|
358
444
|
if (loginApiKeySelection) {
|
|
359
|
-
|
|
445
|
+
if (options?.recordAffinity !== false) {
|
|
446
|
+
this.#deps.affinity.record(provider, sessionId, "api_key", loginApiKeySelection.index);
|
|
447
|
+
}
|
|
360
448
|
const credentialId = onCredentialId
|
|
361
449
|
? this.#deps.pool.entries(provider)[loginApiKeySelection.index]?.id
|
|
362
450
|
: undefined;
|
|
@@ -382,12 +470,17 @@ export class KeyCascade implements KeysApi {
|
|
|
382
470
|
credential => credential.source !== "login",
|
|
383
471
|
);
|
|
384
472
|
if (apiKeySelection) {
|
|
385
|
-
|
|
473
|
+
if (options?.recordAffinity !== false) {
|
|
474
|
+
this.#deps.affinity.record(provider, sessionId, "api_key", apiKeySelection.index);
|
|
475
|
+
}
|
|
386
476
|
const credentialId = onCredentialId ? this.#deps.pool.entries(provider)[apiKeySelection.index]?.id : undefined;
|
|
387
477
|
const apiKey = await this.#deps.overrides.resolve(apiKeySelection.credential.key);
|
|
388
478
|
if (apiKey !== undefined && credentialId !== undefined) onCredentialId?.(credentialId);
|
|
389
479
|
return apiKey;
|
|
390
480
|
}
|
|
481
|
+
const mirrorKey = this.#deps.overrides.mirrorKey(provider);
|
|
482
|
+
if (mirrorKey !== undefined) return this.#deps.overrides.resolve(mirrorKey);
|
|
483
|
+
if (oauthRefreshFailure) throw oauthRefreshFailure;
|
|
391
484
|
return undefined;
|
|
392
485
|
}
|
|
393
486
|
|
|
@@ -454,6 +547,7 @@ export class KeyCascade implements KeysApi {
|
|
|
454
547
|
* 4. API key persisted by a successful `/login`.
|
|
455
548
|
* 5. Env var — overrides a stored static api_key (e.g. a stale broker copy).
|
|
456
549
|
* 6. Stored api_key credential.
|
|
550
|
+
* 7. OMP compatibility mirror (upstream agent.db fallback key).
|
|
457
551
|
*
|
|
458
552
|
* The string is purely informational; consumers must not parse it.
|
|
459
553
|
*/
|
|
@@ -504,6 +598,7 @@ export class KeyCascade implements KeysApi {
|
|
|
504
598
|
credential => credential.type !== "api_key" || credential.source !== "login",
|
|
505
599
|
);
|
|
506
600
|
if (apiKeySource) return apiKeySource;
|
|
601
|
+
if (this.#deps.overrides.mirrorKey(provider) !== undefined) return "OMP compatibility mirror (upstream agent.db)";
|
|
507
602
|
return undefined;
|
|
508
603
|
}
|
|
509
604
|
|
|
@@ -515,7 +610,7 @@ export class KeyCascade implements KeysApi {
|
|
|
515
610
|
this.#deps.overrides.removeRuntime(provider);
|
|
516
611
|
}
|
|
517
612
|
|
|
518
|
-
setConfig(provider: string, apiKeyConfig: string, options?: { fallback?: boolean }): void {
|
|
613
|
+
setConfig(provider: string, apiKeyConfig: string, options?: { fallback?: boolean; mirror?: boolean }): void {
|
|
519
614
|
this.#deps.overrides.setConfig(provider, apiKeyConfig, options);
|
|
520
615
|
}
|
|
521
616
|
|
package/src/auth/health.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Provider } from "../types";
|
|
2
|
-
import { resolveUsedFraction } from "../usage";
|
|
2
|
+
import { isWithinUsageReserve, resolveUsedFraction } from "../usage";
|
|
3
3
|
import type {
|
|
4
4
|
CredentialRankingContext,
|
|
5
5
|
UsageCredential,
|
|
@@ -224,7 +224,7 @@ export class CredentialHealth implements HealthApi {
|
|
|
224
224
|
return {
|
|
225
225
|
credentialId: entry.id,
|
|
226
226
|
credentialType,
|
|
227
|
-
state: remainingFraction
|
|
227
|
+
state: isWithinUsageReserve(remainingFraction, resolveReserveFraction(entry)) ? "reserve" : "healthy",
|
|
228
228
|
remainingFraction,
|
|
229
229
|
};
|
|
230
230
|
}),
|
|
@@ -283,6 +283,7 @@ export class CredentialHealth implements HealthApi {
|
|
|
283
283
|
const results: CredentialHealthResult[] = [];
|
|
284
284
|
for (const row of stored) {
|
|
285
285
|
options?.signal?.throwIfAborted();
|
|
286
|
+
if (options?.excludeProviders?.has(row.provider)) continue;
|
|
286
287
|
const base: CredentialHealthResult = {
|
|
287
288
|
id: row.id,
|
|
288
289
|
provider: row.provider,
|
package/src/auth/oauth.ts
CHANGED
|
@@ -127,15 +127,18 @@ export class OAuthAccounts implements OAuthApi {
|
|
|
127
127
|
* `enterpriseUrl`). For pure "give me the bytes for `Authorization`"
|
|
128
128
|
* scenarios, prefer API-key resolution.
|
|
129
129
|
*
|
|
130
|
-
* Returns `undefined` when no OAuth credential is available
|
|
131
|
-
*
|
|
132
|
-
* OAuth with an explicit API key.
|
|
130
|
+
* Returns `undefined` when no usable OAuth credential is available
|
|
131
|
+
* (none stored, or every one definitively failed to refresh) or
|
|
132
|
+
* runtime/config overrides have replaced OAuth with an explicit API key.
|
|
133
|
+
* Rejects with {@link AIError.OAuthRefreshUnavailableError} (transient,
|
|
134
|
+
* retryable) when a retryable refresh failure left no usable credential.
|
|
133
135
|
*/
|
|
134
136
|
async access(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<OAuthAccess | undefined> {
|
|
135
137
|
// Runtime / config overrides intentionally short-circuit OAuth: when the
|
|
136
138
|
// user has pinned an API key, they expect the OAuth identity to be
|
|
137
|
-
// suppressed (same contract as account identity lookup).
|
|
138
|
-
|
|
139
|
+
// suppressed (same contract as account identity lookup). A session
|
|
140
|
+
// restricted to an account pool never uses a runtime key.
|
|
141
|
+
if (this.#deps.overrides.suppressesOAuth(provider, this.#deps.affinity.isRestricted(provider, sessionId))) {
|
|
139
142
|
return undefined;
|
|
140
143
|
}
|
|
141
144
|
const resolved = await this.#deps.selector.resolveOAuth(provider, sessionId, options);
|
|
@@ -176,9 +179,20 @@ export class OAuthAccounts implements OAuthApi {
|
|
|
176
179
|
options: AuthApiKeyOptions | undefined,
|
|
177
180
|
): Promise<OAuthAccessResolution> {
|
|
178
181
|
try {
|
|
182
|
+
// tryOAuth refreshes only expired tokens; it resyncs this row from the store after a forced re-mint.
|
|
183
|
+
let index = selection.index;
|
|
184
|
+
if (options?.forceRefresh) {
|
|
185
|
+
await this.refresh(selection.credentialId, options.signal, {
|
|
186
|
+
reuseRecentMint: options.refreshReason === "auth-recovery",
|
|
187
|
+
reason: options.refreshReason,
|
|
188
|
+
});
|
|
189
|
+
// The refresh re-lists rows, so a concurrently removed row can shift this one's position.
|
|
190
|
+
index = this.#deps.pool.entries(provider).findIndex(entry => entry.id === selection.credentialId);
|
|
191
|
+
if (index === -1) throw new Error(`OAuth credential ${selection.credentialId} was removed during refresh`);
|
|
192
|
+
}
|
|
179
193
|
const resolved = await this.#deps.selector.tryOAuth(
|
|
180
194
|
provider,
|
|
181
|
-
{ credential: selection.credential, index
|
|
195
|
+
{ credential: selection.credential, index },
|
|
182
196
|
providerKey,
|
|
183
197
|
undefined,
|
|
184
198
|
options,
|
|
@@ -236,7 +250,7 @@ export class OAuthAccounts implements OAuthApi {
|
|
|
236
250
|
* credential.
|
|
237
251
|
*/
|
|
238
252
|
accounts(provider: string, sessionId?: string): OAuthAccountSummary[] {
|
|
239
|
-
if (this.#deps.overrides.
|
|
253
|
+
if (this.#deps.overrides.suppressesOAuth(provider, this.#deps.affinity.isRestricted(provider, sessionId))) {
|
|
240
254
|
return [];
|
|
241
255
|
}
|
|
242
256
|
const sessionCredential = this.#deps.affinity.get(provider, sessionId);
|
|
@@ -286,9 +300,10 @@ export class OAuthAccounts implements OAuthApi {
|
|
|
286
300
|
* Resolve one stored OAuth credential by its durable storage row id.
|
|
287
301
|
*
|
|
288
302
|
* Unlike the normal session resolver, this method never ranks, rotates, or
|
|
289
|
-
* falls back to sibling credentials. A forced refresh re-mints only the
|
|
290
|
-
* requested row
|
|
291
|
-
*
|
|
303
|
+
* falls back to sibling credentials. A generic forced refresh re-mints only the
|
|
304
|
+
* requested row; with `refreshReason: "auth-recovery"` it may instead reuse a
|
|
305
|
+
* still-usable recent mint of that row. Either way exact-account affinity holds
|
|
306
|
+
* for operations whose provenance and policy boundary are tied to one workspace.
|
|
292
307
|
*
|
|
293
308
|
* Returns `undefined` when the row does not exist for `provider` or an
|
|
294
309
|
* explicit runtime/config API-key override suppresses OAuth.
|
package/src/auth/policy.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { logger } from "@linxiraos/pi-utils";
|
|
1
2
|
import * as AIError from "../error";
|
|
2
3
|
import type {
|
|
3
4
|
AuthAccountPolicies,
|
|
@@ -23,6 +24,8 @@ export function matchesAuthAccountSelector(selector: AuthAccountSelector, identi
|
|
|
23
24
|
export class AccountPolicies {
|
|
24
25
|
#accountPolicies: AuthAccountPolicies;
|
|
25
26
|
#defaultReservePct: number;
|
|
27
|
+
/** Indexes of policies already warned as unmatched; validation runs on every selection. */
|
|
28
|
+
#warnedUnmatched = new Set<number>();
|
|
26
29
|
|
|
27
30
|
constructor(policies: AuthAccountPolicies, defaultReservePct: number | undefined) {
|
|
28
31
|
AccountPolicies.#validateAccountPolicyConfiguration(policies);
|
|
@@ -52,6 +55,7 @@ export class AccountPolicies {
|
|
|
52
55
|
for (const [provider, credentials] of storedCredentials) next.validateFor(provider, credentials);
|
|
53
56
|
this.#accountPolicies = next.#accountPolicies;
|
|
54
57
|
this.#defaultReservePct = next.#defaultReservePct;
|
|
58
|
+
this.#warnedUnmatched = next.#warnedUnmatched;
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
static #validateAccountPolicyConfiguration(accountPolicies: AuthAccountPolicies): void {
|
|
@@ -105,6 +109,7 @@ export class AccountPolicies {
|
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
/** Throw on ambiguous selectors for `provider`; a selector whose account is absent is inert and warns once. */
|
|
108
113
|
validateFor(provider: string, credentials: readonly AuthCredential[]): void {
|
|
109
114
|
const policies = this.#accountPolicies
|
|
110
115
|
.map((policy, index) => ({ policy, index }))
|
|
@@ -125,7 +130,11 @@ export class AccountPolicies {
|
|
|
125
130
|
}
|
|
126
131
|
const path = `auth.accountPolicies[${index}].account`;
|
|
127
132
|
if (matches.length === 0) {
|
|
128
|
-
|
|
133
|
+
if (!this.#warnedUnmatched.has(index)) {
|
|
134
|
+
this.#warnedUnmatched.add(index);
|
|
135
|
+
logger.warn("Account policy matches no stored OAuth account; ignoring it", { provider, path });
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
129
138
|
}
|
|
130
139
|
if (matches.length > 1) {
|
|
131
140
|
throw new AIError.ConfigurationError(
|
package/src/auth/pool.ts
CHANGED
|
@@ -642,11 +642,6 @@ export class CredentialPool implements CredentialsApi {
|
|
|
642
642
|
const index = entries.findIndex(entry => entry.id === credentialId);
|
|
643
643
|
if (index === -1) return false;
|
|
644
644
|
const remainingEntries = entries.filter((_entry, entryIndex) => entryIndex !== index);
|
|
645
|
-
this.#options.policies.validateFor(
|
|
646
|
-
provider,
|
|
647
|
-
remainingEntries.map(entry => entry.credential),
|
|
648
|
-
);
|
|
649
|
-
|
|
650
645
|
const deleted = await this.#store.deleteAuthCredential(credentialId, "deleted by user");
|
|
651
646
|
if (!deleted) return false;
|
|
652
647
|
this.replace(provider, remainingEntries);
|