@oh-my-pi/pi-ai 15.10.0 → 15.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/dist/types/auth-retry.d.ts +72 -0
- package/dist/types/auth-storage.d.ts +43 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/model-thinking.d.ts +0 -7
- package/dist/types/provider-models/openai-compat.d.ts +5 -0
- package/dist/types/types.d.ts +10 -7
- package/dist/types/utils/schema/json-schema-validator.d.ts +8 -0
- package/package.json +2 -2
- package/src/auth-gateway/server.ts +73 -25
- package/src/auth-retry.ts +141 -0
- package/src/auth-storage.ts +115 -3
- package/src/index.ts +1 -0
- package/src/model-thinking.ts +0 -12
- package/src/models.json +402 -30
- package/src/provider-models/openai-compat.ts +27 -1
- package/src/providers/anthropic.ts +0 -16
- package/src/providers/gitlab-duo.ts +3 -2
- package/src/providers/openai-anthropic-shim.ts +5 -2
- package/src/providers/openai-completions.ts +5 -0
- package/src/providers/openai-responses-shared.ts +19 -5
- package/src/providers/pi-native-client.ts +4 -1
- package/src/stream.ts +36 -16
- package/src/types.ts +10 -7
- package/src/utils/http-inspector.ts +4 -2
- package/src/utils/request-debug.ts +12 -7
- package/src/utils/schema/json-schema-validator.ts +19 -1
- package/src/utils/validation.ts +26 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.10.1] - 2026-06-07
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- Removed the `onAuthError` option from stream request options and shifted auth retry handling to resolver-based `apiKey` behavior, requiring callers using custom auth-retry hooks to migrate
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added `ApiKeyResolver` and `ApiKey` auth helpers, including `isApiKeyResolver`, `isAuthRetryableError`, `resolveApiKeyOnce`, and `withAuth`, and exported them from the package root
|
|
14
|
+
- Added support for a function-valued `apiKey` in `SimpleStreamOptions` so a single stream request can refresh or rotate credentials during retry
|
|
15
|
+
- Added `forceRefresh` credential option to `AuthStorage.getApiKey` and `rotateSessionCredential` support for session-level credential rotation after auth failures
|
|
16
|
+
- Added `AuthStorage.resolver(provider, options)` method that builds an `ApiKeyResolver` implementing the a/b/c auth-retry policy directly on the storage instance
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Changed gateway and stream auth flows to share the a/b/c retry policy, refreshing the same session credential first and then switching to a sibling credential on repeated auth failures
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Fixed streaming auth retries to handle `401` and usage-limit errors before replay-unsafe content is emitted, including failures surfaced only via `errorStatus`
|
|
25
|
+
- Fixed tool argument validation to coerce singleton non-string values into arrays when the schema expects an array, preventing Anthropic-compatible models that emit `todo.ops` as an object from getting stuck in repeated validation-error loops. ([#2026](https://github.com/can1357/oh-my-pi/issues/2026))
|
|
26
|
+
- Fixed streaming retries to buffer and suppress partial `start` events from failed auth attempts so only clean retried events are delivered
|
|
27
|
+
- Fixed the HTTP 400 raw-request dumper (`appendRawHttpRequestDumpFor400`) littering the real `~/.omp/logs/http-400-requests` directory during tests. Provider suites exercise the 400 error path with mocked `fetch` responses, which the dumper could not distinguish from genuine failures; it now skips persistence under the Bun test runner (`isBunTestRuntime()`).
|
|
28
|
+
- Fixed Anthropic Opus requests unnecessarily forcing `tool_choice.disable_parallel_tool_use`, allowing Claude Opus to use the provider's default parallel tool-calling behavior again.
|
|
29
|
+
- Fixed parallel `function_call` items losing arguments against llama.cpp's OpenAI Responses endpoint (`/v1/responses`), where every call but the last finalized with `{}` and the agent rejected them with `path: Invalid input: expected string, received undefined`. llama.cpp's `to_json_oaicompat_resp` emits `output_item.added` with only `item.call_id` (no `item.id`, no `output_index`) while the matching `function_call_arguments.delta` carries `item_id: "fc_<call_id>"`. `processResponsesStream` now registers function-call and custom-tool-call items under `item.call_id` as a secondary lookup key (alongside `item.id`/`output_index`) so identifier-deviant hosts route deltas and done events to the right block. ([#2015](https://github.com/can1357/oh-my-pi/issues/2015))
|
|
30
|
+
- Fixed `PI_REQ_DEBUG` response recording truncating the captured body when a streamed response was cancelled mid-flight. The response tee in `wrapResponse` could call `FileRequestDebugResponseLog.close()` from both the `cancel` callback and the resumed `pull` (which observes `done` once the source reader is cancelled); the second caller saw the handle already nulled and returned before the first caller's pending write flushed, so the `.res.log` lost the already-buffered chunk. `close()` now memoizes its flush-and-close promise so every caller awaits the same completion.
|
|
31
|
+
|
|
5
32
|
## [15.10.0] - 2026-06-06
|
|
6
33
|
|
|
7
34
|
### Added
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context passed to an {@link ApiKeyResolver} on each resolution attempt.
|
|
3
|
+
*
|
|
4
|
+
* The `error`/`lastChance` pair drives the central a/b/c retry policy shared by
|
|
5
|
+
* the streaming ({@link streamSimple}) and non-streaming ({@link withAuth})
|
|
6
|
+
* drivers:
|
|
7
|
+
* - `error === undefined` → **initial resolve** (no force-refresh; cheap, may
|
|
8
|
+
* return a locally-cached not-yet-expired token).
|
|
9
|
+
* - `error !== undefined && !lastChance` → **step (b): refresh the SAME
|
|
10
|
+
* account** (force a token re-mint / await an in-flight broker refresh).
|
|
11
|
+
* - `error !== undefined && lastChance` → **step (c): switch account**
|
|
12
|
+
* (invalidate/usage-limit the current credential and rotate to a sibling).
|
|
13
|
+
*
|
|
14
|
+
* The resolver returns the bearer to send, or `undefined` to stop retrying and
|
|
15
|
+
* surface the last error to the caller.
|
|
16
|
+
*/
|
|
17
|
+
export interface ApiKeyResolveContext {
|
|
18
|
+
/** True on the final retry step — the resolver should rotate to a sibling credential. */
|
|
19
|
+
lastChance: boolean;
|
|
20
|
+
/** The auth error that triggered this re-resolution, or `undefined` on the initial resolve. */
|
|
21
|
+
error: unknown;
|
|
22
|
+
/** Caller cancel signal, threaded into any credential refresh / rotation work. */
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Resolves the API key to send for a request, retried through the a/b/c policy
|
|
27
|
+
* described on {@link ApiKeyResolveContext}.
|
|
28
|
+
*/
|
|
29
|
+
export type ApiKeyResolver = (ctx: ApiKeyResolveContext) => Promise<string | undefined> | string | undefined;
|
|
30
|
+
/** A static bearer string, or a {@link ApiKeyResolver} that mints/rotates one. */
|
|
31
|
+
export type ApiKey = string | ApiKeyResolver;
|
|
32
|
+
/** Narrows {@link ApiKey} to its resolver form. */
|
|
33
|
+
export declare function isApiKeyResolver(key: ApiKey | undefined): key is ApiKeyResolver;
|
|
34
|
+
/**
|
|
35
|
+
* Performs the initial resolve of an {@link ApiKey} (`error: undefined`,
|
|
36
|
+
* `lastChance: false`). Static keys pass through unchanged.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveApiKeyOnce(key: ApiKey | undefined, signal?: AbortSignal): Promise<string | undefined>;
|
|
39
|
+
/**
|
|
40
|
+
* Classifies whether an error should trigger a credential refresh/rotation
|
|
41
|
+
* retry: a hard `401`, or a rotatable usage-limit ("usage_limit_reached",
|
|
42
|
+
* Codex's "you have hit your ChatGPT usage limit", etc.).
|
|
43
|
+
*/
|
|
44
|
+
export declare function isAuthRetryableError(error: unknown): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* The ordered `lastChance` values for the retry steps after the initial
|
|
47
|
+
* attempt fails: `false` → step (b) refresh-same, `true` → step (c) switch.
|
|
48
|
+
* Shared by {@link withAuth} and the streaming retry driver so both run the
|
|
49
|
+
* same policy.
|
|
50
|
+
*/
|
|
51
|
+
export declare const AUTH_RETRY_STEPS: readonly boolean[];
|
|
52
|
+
/** Resolve a single retry step, swallowing resolver failures into `undefined`. */
|
|
53
|
+
export declare function resolveRetryKey(resolver: ApiKeyResolver, lastChance: boolean, error: unknown, signal?: AbortSignal): Promise<string | undefined>;
|
|
54
|
+
/**
|
|
55
|
+
* Runs an auth-protected operation through the central a/b/c retry policy.
|
|
56
|
+
*
|
|
57
|
+
* - A static string key (or any non-resolver) → a single `attempt` with no
|
|
58
|
+
* retry (identical to the legacy static-key path).
|
|
59
|
+
* - A resolver → initial `attempt`, then on a retryable auth error up to two
|
|
60
|
+
* more attempts (refresh-same, then switch). A step is skipped when the
|
|
61
|
+
* resolver returns the same key it just tried or `undefined`; non-auth errors
|
|
62
|
+
* propagate immediately.
|
|
63
|
+
*
|
|
64
|
+
* Used by non-streaming consumers (image generation, web search, completion
|
|
65
|
+
* helpers). The streaming driver in `stream.ts` implements the same policy with
|
|
66
|
+
* its replay-safe buffering machinery.
|
|
67
|
+
*/
|
|
68
|
+
export declare function withAuth<T>(key: ApiKey | undefined, attempt: (key: string) => Promise<T>, opts?: {
|
|
69
|
+
isAuthError?: (error: unknown) => boolean;
|
|
70
|
+
signal?: AbortSignal;
|
|
71
|
+
missingKeyMessage?: string;
|
|
72
|
+
}): Promise<T>;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - `SqliteAuthCredentialStore`: concrete SQLite-backed implementation
|
|
9
9
|
*/
|
|
10
10
|
import { Database } from "bun:sqlite";
|
|
11
|
+
import type { ApiKeyResolver } from "./auth-retry";
|
|
11
12
|
import type { Provider } from "./types";
|
|
12
13
|
import type { CredentialRankingStrategy, UsageLogger, UsageProvider, UsageReport } from "./usage";
|
|
13
14
|
import type { OAuthController, OAuthCredentials, OAuthProviderId } from "./utils/oauth/types";
|
|
@@ -366,6 +367,13 @@ type AuthApiKeyOptions = {
|
|
|
366
367
|
* stranding the caller for `timeoutMs * (maxRetries + 1)`.
|
|
367
368
|
*/
|
|
368
369
|
signal?: AbortSignal;
|
|
370
|
+
/**
|
|
371
|
+
* Force a re-mint of the session-preferred OAuth credential's access token,
|
|
372
|
+
* bypassing the not-yet-expired short-circuit. Powers step (b) of the
|
|
373
|
+
* auth-retry policy ("refresh the SAME account") so a locally-cached token
|
|
374
|
+
* that a peer/broker rotated out from under us is replaced before retrying.
|
|
375
|
+
*/
|
|
376
|
+
forceRefresh?: boolean;
|
|
369
377
|
};
|
|
370
378
|
/**
|
|
371
379
|
* Refreshed OAuth access plus identity metadata returned by
|
|
@@ -647,6 +655,41 @@ export declare class AuthStorage {
|
|
|
647
655
|
getOAuthAccesses(provider: string, options?: AuthApiKeyOptions): Promise<OAuthAccessResolution[]>;
|
|
648
656
|
invalidateCredentialMatching(provider: string, apiKey: string, options?: InvalidateCredentialMatchingOptions): Promise<boolean>;
|
|
649
657
|
invalidateCredentialMatching(provider: string, apiKey: string, signal?: AbortSignal): Promise<boolean>;
|
|
658
|
+
/**
|
|
659
|
+
* Rotate away from the session's current credential after a retryable auth
|
|
660
|
+
* error — step (c) of the auth-retry policy. Stateless: looks up the
|
|
661
|
+
* session-sticky credential (no API-key matching needed), applies the
|
|
662
|
+
* storage action for the error class, then clears the sticky so the next
|
|
663
|
+
* {@link AuthStorage.getApiKey} for this session picks a sibling.
|
|
664
|
+
*
|
|
665
|
+
* - usage-limit / account-rate-limit error → {@link AuthStorage.markUsageLimitReached}
|
|
666
|
+
* (temporary block via its own backoff — default plus server usage-report
|
|
667
|
+
* reset; sticky left intact so the next resolve re-ranks around the block).
|
|
668
|
+
* - otherwise (hard 401 / auth failure) → mark the credential suspect (or
|
|
669
|
+
* reload when no broker hook is wired) and block it, then drop the sticky.
|
|
670
|
+
*
|
|
671
|
+
* Returns whether another usable credential of the same type remains.
|
|
672
|
+
*/
|
|
673
|
+
rotateSessionCredential(provider: string, sessionId: string | undefined, options?: {
|
|
674
|
+
error?: unknown;
|
|
675
|
+
signal?: AbortSignal;
|
|
676
|
+
}): Promise<boolean>;
|
|
677
|
+
/**
|
|
678
|
+
* Build an {@link ApiKeyResolver} backed by this storage, implementing the
|
|
679
|
+
* central a/b/c auth-retry policy:
|
|
680
|
+
*
|
|
681
|
+
* - initial (`error: undefined`) → resolve the session credential.
|
|
682
|
+
* - step (b) `!lastChance` → force-refresh the SAME session-sticky credential.
|
|
683
|
+
* - step (c) `lastChance` → rotate to a sibling credential, then re-resolve.
|
|
684
|
+
*
|
|
685
|
+
* Used by web-search providers and other consumers that hold an AuthStorage
|
|
686
|
+
* directly (no ModelRegistry in scope).
|
|
687
|
+
*/
|
|
688
|
+
resolver(provider: string, options?: {
|
|
689
|
+
sessionId?: string;
|
|
690
|
+
baseUrl?: string;
|
|
691
|
+
modelId?: string;
|
|
692
|
+
}): ApiKeyResolver;
|
|
650
693
|
/**
|
|
651
694
|
* Build a redacted snapshot of all loaded credentials for the auth-broker
|
|
652
695
|
* wire. OAuth refresh tokens are replaced with {@link REMOTE_REFRESH_SENTINEL}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from "./api-registry";
|
|
|
3
3
|
export * from "./auth-broker";
|
|
4
4
|
export { type AuthGatewayBootOptions, type ModelResolver, startAuthGateway } from "./auth-gateway/server";
|
|
5
5
|
export * from "./auth-gateway/types";
|
|
6
|
+
export * from "./auth-retry";
|
|
6
7
|
export * from "./auth-storage";
|
|
7
8
|
export * from "./effort";
|
|
8
9
|
export * from "./model-cache";
|
|
@@ -83,10 +83,3 @@ export declare function hasOpus47ApiRestrictions(modelId: string): boolean;
|
|
|
83
83
|
* @see https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages
|
|
84
84
|
*/
|
|
85
85
|
export declare function supportsMidConversationSystemMessages(modelId: string): boolean;
|
|
86
|
-
/**
|
|
87
|
-
* Claude Opus 4.8 must emit at most one tool call per turn: the Anthropic
|
|
88
|
-
* Messages provider sends `tool_choice.disable_parallel_tool_use = true` for
|
|
89
|
-
* this model. Scoped to exactly 4.8 — earlier and later Opus versions keep
|
|
90
|
-
* Anthropic's default parallel tool-calling.
|
|
91
|
-
*/
|
|
92
|
-
export declare function disablesParallelToolUse(modelId: string): boolean;
|
|
@@ -131,6 +131,11 @@ export declare function isFireworksKimiK2ModelId(modelId: string): boolean;
|
|
|
131
131
|
* on Fireworks-backed providers, leaving every other model untouched.
|
|
132
132
|
*/
|
|
133
133
|
export declare function clampFireworksKimiMaxTokens(modelId: string, candidate: number): number;
|
|
134
|
+
/**
|
|
135
|
+
* Fireworks DeepSeek V4 accepts effort via `reasoning_effort` but rejects the
|
|
136
|
+
* DeepSeek-native binary `thinking` toggle when both are present.
|
|
137
|
+
*/
|
|
138
|
+
export declare function stripFireworksDeepSeekThinkingToggle(model: Model<"openai-completions">, publicModelId: string): Model<"openai-completions">;
|
|
134
139
|
export interface FireworksModelManagerConfig {
|
|
135
140
|
apiKey?: string;
|
|
136
141
|
baseUrl?: string;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ZodType, z } from "zod/v4";
|
|
2
|
+
import type { ApiKey } from "./auth-retry";
|
|
2
3
|
import type { BedrockOptions } from "./providers/amazon-bedrock";
|
|
3
4
|
import type { AnthropicOptions } from "./providers/anthropic";
|
|
4
5
|
import type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses";
|
|
@@ -159,12 +160,6 @@ export interface StreamOptions {
|
|
|
159
160
|
maxTokens?: number;
|
|
160
161
|
signal?: AbortSignal;
|
|
161
162
|
apiKey?: string;
|
|
162
|
-
/**
|
|
163
|
-
* Called when a provider returns 401 before any replay-unsafe assistant
|
|
164
|
-
* event has been emitted. Returning a different key retries the provider
|
|
165
|
-
* request once.
|
|
166
|
-
*/
|
|
167
|
-
onAuthError?: (provider: string, apiKey: string, error: unknown) => Promise<string | undefined>;
|
|
168
163
|
cacheRetention?: CacheRetention;
|
|
169
164
|
/**
|
|
170
165
|
* Additional headers to include in provider requests.
|
|
@@ -274,7 +269,15 @@ export interface StreamOptions {
|
|
|
274
269
|
/** Cursor exec/MCP tool handlers (cursor-agent only). */
|
|
275
270
|
execHandlers?: CursorExecHandlers;
|
|
276
271
|
}
|
|
277
|
-
export interface SimpleStreamOptions extends StreamOptions {
|
|
272
|
+
export interface SimpleStreamOptions extends Omit<StreamOptions, "apiKey"> {
|
|
273
|
+
/**
|
|
274
|
+
* API key for the request: either a static bearer string, or an
|
|
275
|
+
* {@link ApiKeyResolver} that mints/rotates the key across the central
|
|
276
|
+
* a/b/c auth-retry policy. `streamSimple`/`completeSimple` resolve a
|
|
277
|
+
* resolver to a string before per-provider dispatch, so providers only
|
|
278
|
+
* ever see the resolved {@link StreamOptions.apiKey} string.
|
|
279
|
+
*/
|
|
280
|
+
apiKey?: ApiKey;
|
|
278
281
|
reasoning?: Effort;
|
|
279
282
|
/**
|
|
280
283
|
* Force-disable reasoning for the request even when the model supports it.
|
|
@@ -3,6 +3,14 @@ export interface JsonSchemaValidationIssue {
|
|
|
3
3
|
message: string;
|
|
4
4
|
expectedTypes?: string[];
|
|
5
5
|
keyword?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Marks issues that originate inside a failed `anyOf` / `oneOf` branch.
|
|
8
|
+
* Consumers such as the tool-argument coercion layer use this to avoid
|
|
9
|
+
* applying type repairs (e.g. singleton-array wrapping) that would be
|
|
10
|
+
* authoritative outside of a combinator but are only one candidate
|
|
11
|
+
* branch's expectation here.
|
|
12
|
+
*/
|
|
13
|
+
fromUnionBranch?: boolean;
|
|
6
14
|
}
|
|
7
15
|
export interface JsonSchemaValidationResult {
|
|
8
16
|
success: boolean;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "15.10.
|
|
4
|
+
"version": "15.10.1",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@bufbuild/protobuf": "^2.12.0",
|
|
42
|
-
"@oh-my-pi/pi-utils": "15.10.
|
|
42
|
+
"@oh-my-pi/pi-utils": "15.10.1",
|
|
43
43
|
"openai": "^6.39.0",
|
|
44
44
|
"partial-json": "^0.1.7",
|
|
45
45
|
"zod": "4.4.3"
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* POST /v1/responses → OpenAI Responses in/out
|
|
19
19
|
*/
|
|
20
20
|
import { extractRetryHint, logger } from "@oh-my-pi/pi-utils";
|
|
21
|
+
import type { ApiKeyResolver } from "../auth-retry";
|
|
21
22
|
import type { AuthStorage } from "../auth-storage";
|
|
22
23
|
import { Effort } from "../effort";
|
|
23
24
|
import * as anthropicMessages from "../providers/anthropic-messages-server";
|
|
@@ -340,6 +341,60 @@ async function refreshGatewayApiKeyAfterAuthError(
|
|
|
340
341
|
return storage.getApiKey(provider, sessionId, { modelId: model.id, signal });
|
|
341
342
|
}
|
|
342
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Build the {@link ApiKeyResolver} handed to `streamSimple` for a gateway
|
|
346
|
+
* request. Drives the central a/b/c auth-retry policy server-side:
|
|
347
|
+
*
|
|
348
|
+
* - initial resolve → the credential already resolved for this request.
|
|
349
|
+
* - step (b) `!lastChance` → force-refresh the SAME session-sticky credential
|
|
350
|
+
* (a peer/broker may have rotated its token out from under our cached copy).
|
|
351
|
+
* - step (c) `lastChance` → {@link refreshGatewayApiKeyAfterAuthError} switches
|
|
352
|
+
* to a sibling (usage-limit block vs credential invalidation by error class).
|
|
353
|
+
*
|
|
354
|
+
* `lastKey` tracks the most recent bearer so the switch step invalidates the
|
|
355
|
+
* credential that actually failed.
|
|
356
|
+
*/
|
|
357
|
+
function buildGatewayApiKeyResolver(
|
|
358
|
+
storage: AuthStorage,
|
|
359
|
+
model: Model<Api>,
|
|
360
|
+
sessionId: string,
|
|
361
|
+
initialKey: string,
|
|
362
|
+
requestSignal: AbortSignal,
|
|
363
|
+
format: string,
|
|
364
|
+
peer: string,
|
|
365
|
+
): ApiKeyResolver {
|
|
366
|
+
let lastKey = initialKey;
|
|
367
|
+
return async ({ lastChance, error, signal }) => {
|
|
368
|
+
const sig = signal ?? requestSignal;
|
|
369
|
+
if (error === undefined) {
|
|
370
|
+
lastKey = initialKey;
|
|
371
|
+
return initialKey;
|
|
372
|
+
}
|
|
373
|
+
if (!lastChance) {
|
|
374
|
+
const refreshed = await storage.getApiKey(model.provider, sessionId, {
|
|
375
|
+
modelId: model.id,
|
|
376
|
+
signal: sig,
|
|
377
|
+
forceRefresh: true,
|
|
378
|
+
});
|
|
379
|
+
lastKey = refreshed ?? lastKey;
|
|
380
|
+
return refreshed;
|
|
381
|
+
}
|
|
382
|
+
const next = await refreshGatewayApiKeyAfterAuthError(
|
|
383
|
+
storage,
|
|
384
|
+
model,
|
|
385
|
+
sessionId,
|
|
386
|
+
model.provider,
|
|
387
|
+
lastKey,
|
|
388
|
+
error,
|
|
389
|
+
sig,
|
|
390
|
+
format,
|
|
391
|
+
peer,
|
|
392
|
+
);
|
|
393
|
+
lastKey = next ?? lastKey;
|
|
394
|
+
return next;
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
343
398
|
function clientClosedResponse(route: { module: FormatModule }): Response {
|
|
344
399
|
return route.module.formatError(499, "request_aborted", "client closed request");
|
|
345
400
|
}
|
|
@@ -447,19 +502,15 @@ async function handleFormatEndpoint(
|
|
|
447
502
|
}
|
|
448
503
|
|
|
449
504
|
const streamOpts = buildStreamOptions(parsed, model.api, controller.signal);
|
|
450
|
-
streamOpts.apiKey =
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
controller.signal,
|
|
460
|
-
route.label,
|
|
461
|
-
peer,
|
|
462
|
-
);
|
|
505
|
+
streamOpts.apiKey = buildGatewayApiKeyResolver(
|
|
506
|
+
bootOpts.storage,
|
|
507
|
+
model,
|
|
508
|
+
sessionId,
|
|
509
|
+
apiKey,
|
|
510
|
+
controller.signal,
|
|
511
|
+
route.label,
|
|
512
|
+
peer,
|
|
513
|
+
);
|
|
463
514
|
|
|
464
515
|
logger.info("auth-gateway request", {
|
|
465
516
|
format: route.label,
|
|
@@ -604,18 +655,15 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
|
|
604
655
|
// only inject server-controlled fields. The codex temperature/topP strip
|
|
605
656
|
// matches `buildStreamOptions` — Codex rejects them with a 400.
|
|
606
657
|
const streamOpts: SimpleStreamOptions = { ...parsed.options, apiKey, signal: controller.signal };
|
|
607
|
-
streamOpts.
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
"pi-native",
|
|
617
|
-
peer,
|
|
618
|
-
);
|
|
658
|
+
streamOpts.apiKey = buildGatewayApiKeyResolver(
|
|
659
|
+
bootOpts.storage,
|
|
660
|
+
model,
|
|
661
|
+
sessionId,
|
|
662
|
+
apiKey,
|
|
663
|
+
controller.signal,
|
|
664
|
+
"pi-native",
|
|
665
|
+
peer,
|
|
666
|
+
);
|
|
619
667
|
if (model.api === "openai-codex-responses") {
|
|
620
668
|
delete streamOpts.temperature;
|
|
621
669
|
delete streamOpts.topP;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import { isUsageLimitError } from "./rate-limit-utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Context passed to an {@link ApiKeyResolver} on each resolution attempt.
|
|
6
|
+
*
|
|
7
|
+
* The `error`/`lastChance` pair drives the central a/b/c retry policy shared by
|
|
8
|
+
* the streaming ({@link streamSimple}) and non-streaming ({@link withAuth})
|
|
9
|
+
* drivers:
|
|
10
|
+
* - `error === undefined` → **initial resolve** (no force-refresh; cheap, may
|
|
11
|
+
* return a locally-cached not-yet-expired token).
|
|
12
|
+
* - `error !== undefined && !lastChance` → **step (b): refresh the SAME
|
|
13
|
+
* account** (force a token re-mint / await an in-flight broker refresh).
|
|
14
|
+
* - `error !== undefined && lastChance` → **step (c): switch account**
|
|
15
|
+
* (invalidate/usage-limit the current credential and rotate to a sibling).
|
|
16
|
+
*
|
|
17
|
+
* The resolver returns the bearer to send, or `undefined` to stop retrying and
|
|
18
|
+
* surface the last error to the caller.
|
|
19
|
+
*/
|
|
20
|
+
export interface ApiKeyResolveContext {
|
|
21
|
+
/** True on the final retry step — the resolver should rotate to a sibling credential. */
|
|
22
|
+
lastChance: boolean;
|
|
23
|
+
/** The auth error that triggered this re-resolution, or `undefined` on the initial resolve. */
|
|
24
|
+
error: unknown;
|
|
25
|
+
/** Caller cancel signal, threaded into any credential refresh / rotation work. */
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolves the API key to send for a request, retried through the a/b/c policy
|
|
31
|
+
* described on {@link ApiKeyResolveContext}.
|
|
32
|
+
*/
|
|
33
|
+
export type ApiKeyResolver = (ctx: ApiKeyResolveContext) => Promise<string | undefined> | string | undefined;
|
|
34
|
+
|
|
35
|
+
/** A static bearer string, or a {@link ApiKeyResolver} that mints/rotates one. */
|
|
36
|
+
export type ApiKey = string | ApiKeyResolver;
|
|
37
|
+
|
|
38
|
+
/** Narrows {@link ApiKey} to its resolver form. */
|
|
39
|
+
export function isApiKeyResolver(key: ApiKey | undefined): key is ApiKeyResolver {
|
|
40
|
+
return typeof key === "function";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Performs the initial resolve of an {@link ApiKey} (`error: undefined`,
|
|
45
|
+
* `lastChance: false`). Static keys pass through unchanged.
|
|
46
|
+
*/
|
|
47
|
+
export async function resolveApiKeyOnce(key: ApiKey | undefined, signal?: AbortSignal): Promise<string | undefined> {
|
|
48
|
+
if (key === undefined) return undefined;
|
|
49
|
+
if (isApiKeyResolver(key)) return (await key({ lastChance: false, error: undefined, signal })) || undefined;
|
|
50
|
+
return key;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Classifies whether an error should trigger a credential refresh/rotation
|
|
55
|
+
* retry: a hard `401`, or a rotatable usage-limit ("usage_limit_reached",
|
|
56
|
+
* Codex's "you have hit your ChatGPT usage limit", etc.).
|
|
57
|
+
*/
|
|
58
|
+
export function isAuthRetryableError(error: unknown): boolean {
|
|
59
|
+
if (extractHttpStatusFromError(error) === 401) return true;
|
|
60
|
+
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
|
61
|
+
if (!message) return false;
|
|
62
|
+
if (extractHttpStatusFromError({ message }) === 401) return true;
|
|
63
|
+
return isUsageLimitError(message);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The ordered `lastChance` values for the retry steps after the initial
|
|
68
|
+
* attempt fails: `false` → step (b) refresh-same, `true` → step (c) switch.
|
|
69
|
+
* Shared by {@link withAuth} and the streaming retry driver so both run the
|
|
70
|
+
* same policy.
|
|
71
|
+
*/
|
|
72
|
+
export const AUTH_RETRY_STEPS: readonly boolean[] = [false, true];
|
|
73
|
+
|
|
74
|
+
/** Resolve a single retry step, swallowing resolver failures into `undefined`. */
|
|
75
|
+
export async function resolveRetryKey(
|
|
76
|
+
resolver: ApiKeyResolver,
|
|
77
|
+
lastChance: boolean,
|
|
78
|
+
error: unknown,
|
|
79
|
+
signal?: AbortSignal,
|
|
80
|
+
): Promise<string | undefined> {
|
|
81
|
+
try {
|
|
82
|
+
return (await resolver({ lastChance, error, signal })) || undefined;
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Runs an auth-protected operation through the central a/b/c retry policy.
|
|
90
|
+
*
|
|
91
|
+
* - A static string key (or any non-resolver) → a single `attempt` with no
|
|
92
|
+
* retry (identical to the legacy static-key path).
|
|
93
|
+
* - A resolver → initial `attempt`, then on a retryable auth error up to two
|
|
94
|
+
* more attempts (refresh-same, then switch). A step is skipped when the
|
|
95
|
+
* resolver returns the same key it just tried or `undefined`; non-auth errors
|
|
96
|
+
* propagate immediately.
|
|
97
|
+
*
|
|
98
|
+
* Used by non-streaming consumers (image generation, web search, completion
|
|
99
|
+
* helpers). The streaming driver in `stream.ts` implements the same policy with
|
|
100
|
+
* its replay-safe buffering machinery.
|
|
101
|
+
*/
|
|
102
|
+
export async function withAuth<T>(
|
|
103
|
+
key: ApiKey | undefined,
|
|
104
|
+
attempt: (key: string) => Promise<T>,
|
|
105
|
+
opts?: { isAuthError?: (error: unknown) => boolean; signal?: AbortSignal; missingKeyMessage?: string },
|
|
106
|
+
): Promise<T> {
|
|
107
|
+
const isAuthError = opts?.isAuthError ?? isAuthRetryableError;
|
|
108
|
+
const missingKey = (): Error => new Error(opts?.missingKeyMessage ?? "No API key available");
|
|
109
|
+
|
|
110
|
+
if (!isApiKeyResolver(key)) {
|
|
111
|
+
if (key === undefined) throw missingKey();
|
|
112
|
+
return attempt(key);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const resolver = key;
|
|
116
|
+
const signal = opts?.signal;
|
|
117
|
+
let lastKey = await resolveRetryKey(resolver, false, undefined, signal);
|
|
118
|
+
if (lastKey === undefined) throw missingKey();
|
|
119
|
+
|
|
120
|
+
let lastError: unknown;
|
|
121
|
+
try {
|
|
122
|
+
return await attempt(lastKey);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (!isAuthError(error)) throw error;
|
|
125
|
+
lastError = error;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (let i = 0; i < AUTH_RETRY_STEPS.length; i++) {
|
|
129
|
+
const nextKey = await resolveRetryKey(resolver, AUTH_RETRY_STEPS[i]!, lastError, signal);
|
|
130
|
+
if (nextKey === undefined || nextKey === lastKey) continue;
|
|
131
|
+
lastKey = nextKey;
|
|
132
|
+
try {
|
|
133
|
+
return await attempt(nextKey);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (!isAuthError(error)) throw error;
|
|
136
|
+
lastError = error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
throw lastError;
|
|
141
|
+
}
|