@oh-my-pi/pi-ai 16.1.13 → 16.1.15
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 +26 -0
- package/dist/types/auth-retry.d.ts +6 -2
- package/dist/types/providers/aws-credentials.d.ts +2 -0
- package/dist/types/providers/openai-shared.d.ts +3 -0
- package/dist/types/rate-limit-utils.d.ts +26 -0
- package/dist/types/registry/huggingface.d.ts +2 -2
- package/dist/types/registry/nvidia.d.ts +0 -1
- package/dist/types/registry/oauth/minimax-code.d.ts +2 -3
- package/dist/types/registry/qianfan.d.ts +2 -2
- package/dist/types/registry/registry.d.ts +4 -1
- package/dist/types/registry/sakana.d.ts +7 -0
- package/dist/types/registry/types.d.ts +0 -2
- package/dist/types/registry/venice.d.ts +2 -2
- package/dist/types/registry/zai.d.ts +2 -2
- package/dist/types/registry/zhipu-coding-plan.d.ts +2 -2
- package/dist/types/utils/deterministic-id.d.ts +16 -0
- package/dist/types/utils/proxy.d.ts +29 -0
- package/package.json +4 -4
- package/src/auth-gateway/server.ts +7 -8
- package/src/auth-retry.ts +12 -7
- package/src/auth-storage.ts +7 -11
- package/src/providers/amazon-bedrock.ts +1 -0
- package/src/providers/aws-credentials.ts +23 -10
- package/src/providers/cursor.ts +12 -15
- package/src/providers/devin.ts +7 -14
- package/src/providers/openai-responses.ts +2 -1
- package/src/providers/openai-shared.ts +33 -6
- package/src/rate-limit-utils.ts +49 -1
- package/src/registry/huggingface.ts +13 -35
- package/src/registry/nvidia.ts +0 -1
- package/src/registry/oauth/minimax-code.ts +19 -48
- package/src/registry/qianfan.ts +12 -34
- package/src/registry/registry.ts +2 -0
- package/src/registry/sakana.ts +22 -0
- package/src/registry/types.ts +0 -2
- package/src/registry/venice.ts +12 -34
- package/src/registry/zai.ts +12 -33
- package/src/registry/zhipu-coding-plan.ts +12 -33
- package/src/stream.ts +29 -19
- package/src/utils/deterministic-id.ts +20 -0
- package/src/utils/proxy.ts +239 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.1.15] - 2026-06-22
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed API-key `/login` providers replacing sibling credentials instead of appending new keys for the same provider. ([#3265](https://github.com/can1357/oh-my-pi/issues/3265))
|
|
10
|
+
- Fixed OpenAI Codex OAuth account rotation for quota failures that surface as bare HTTP 429 or `insufficient_quota`, so pre-content failures temporarily block only the exhausted credential and retry a healthy sibling. The 429 status-only fallback applies only to absent/opaque bodies; informative transient bodies (`Too many requests`, `Service overloaded 529`, `Please retry in 5s`, …) defer to `parseRateLimitReason` and stay in the provider's own backoff layer instead of burning sibling credentials. ([#3231](https://github.com/can1357/oh-my-pi/issues/3231))
|
|
11
|
+
|
|
12
|
+
## [16.1.14] - 2026-06-22
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Added proxy support for model providers via `PI_PROXY` and `PI_PROXY_<PROVIDER>` variables
|
|
17
|
+
- Added `NO_PROXY` environment variable support for bypassing proxy configuration
|
|
18
|
+
- Added support for Sakana AI provider
|
|
19
|
+
- Added Sakana AI login and request base URL support for `SAKANA_*` / `FUGU_*` environment variables
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Consolidated API key authentication logic across registry providers
|
|
24
|
+
- Disabled parallel tool calls for Devin provider requests
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Improved proxy bypass logic to correctly handle private IP ranges and local metadata services
|
|
29
|
+
- Enhanced memoization for proxy environment variable lookups to improve performance
|
|
30
|
+
|
|
5
31
|
## [16.1.13] - 2026-06-22
|
|
6
32
|
|
|
7
33
|
### Added
|
|
@@ -48,8 +48,12 @@ export declare function resolveApiKeyOnce(key: ApiKey | undefined, signal?: Abor
|
|
|
48
48
|
export declare function seedApiKeyResolver(seed: string | undefined, resolver: ApiKeyResolver): ApiKeyResolver;
|
|
49
49
|
/**
|
|
50
50
|
* Classifies whether an error should trigger a credential refresh/rotation
|
|
51
|
-
* retry: a hard `401`,
|
|
52
|
-
*
|
|
51
|
+
* retry: a hard `401`, body-classified usage limit (Codex
|
|
52
|
+
* `usage_limit_reached`, Anthropic account rate-limit, Google
|
|
53
|
+
* `resource_exhausted`, OpenAI `insufficient_quota`, …), or a bare `429`
|
|
54
|
+
* whose payload did not preserve a richer quota code. Transient 429s
|
|
55
|
+
* (`Too many requests`, per-minute caps) classify as `RATE_LIMIT_EXCEEDED`
|
|
56
|
+
* via {@link parseRateLimitReason} and stay in the upstream-backoff lane.
|
|
53
57
|
*/
|
|
54
58
|
export declare function isAuthRetryableError(error: unknown): boolean;
|
|
55
59
|
/**
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* Resolved credentials are cached process-wide per profile and refreshed
|
|
19
19
|
* 60 s before `Expiration` to absorb clock skew.
|
|
20
20
|
*/
|
|
21
|
+
import type { FetchImpl } from "../types";
|
|
21
22
|
import type { AwsCredentials } from "./aws-sigv4";
|
|
22
23
|
export interface ResolvedCredentials extends AwsCredentials {
|
|
23
24
|
/** Absolute expiration timestamp in ms. `undefined` for non-expiring static creds. */
|
|
@@ -29,6 +30,7 @@ export interface CredentialResolveOptions {
|
|
|
29
30
|
/** Falls back to env (`AWS_REGION` / `AWS_DEFAULT_REGION`) and finally `us-east-1`. */
|
|
30
31
|
region?: string;
|
|
31
32
|
signal?: AbortSignal;
|
|
33
|
+
fetch?: FetchImpl;
|
|
32
34
|
}
|
|
33
35
|
export declare function resolveAwsCredentials(opts?: CredentialResolveOptions): Promise<ResolvedCredentials>;
|
|
34
36
|
/** POSIX-shell-style tokenizer used by the AWS CLI for `credential_process`.
|
|
@@ -475,9 +475,12 @@ export declare function populateResponsesUsageFromResponse(output: AssistantMess
|
|
|
475
475
|
input_tokens_details?: {
|
|
476
476
|
cached_tokens?: number | null;
|
|
477
477
|
cache_write_tokens?: number | null;
|
|
478
|
+
orchestration_input_tokens?: number | null;
|
|
479
|
+
orchestration_input_cached_tokens?: number | null;
|
|
478
480
|
} | null;
|
|
479
481
|
output_tokens_details?: {
|
|
480
482
|
reasoning_tokens?: number | null;
|
|
483
|
+
orchestration_output_tokens?: number | null;
|
|
481
484
|
} | null;
|
|
482
485
|
} | null | undefined): void;
|
|
483
486
|
/**
|
|
@@ -17,4 +17,30 @@ export declare function parseRateLimitReason(errorMessage: string): RateLimitRea
|
|
|
17
17
|
* MODEL_CAPACITY gets jitter to prevent thundering herd.
|
|
18
18
|
*/
|
|
19
19
|
export declare function calculateRateLimitBackoffMs(reason: RateLimitReason): number;
|
|
20
|
+
/**
|
|
21
|
+
* HTTP status codes that, absent richer body classification, represent an
|
|
22
|
+
* account-local usage cap rather than a bad credential or a transient blip.
|
|
23
|
+
* Always combine with {@link isUsageLimitOutcome} when a message is available
|
|
24
|
+
* — a 429 carrying transient rate-limit wording is NOT a usage cap.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isUsageLimitStatus(status: number | undefined): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Returns true for failures that should burn one credential and rotate to a
|
|
29
|
+
* sibling account. Decision tree:
|
|
30
|
+
*
|
|
31
|
+
* 1. Body matches {@link isUsageLimitError} (Codex `usage_limit_reached`,
|
|
32
|
+
* Anthropic account rate-limit, Google `resource_exhausted`, OpenAI
|
|
33
|
+
* `insufficient_quota`, …) → rotate.
|
|
34
|
+
* 2. Status is not 429 → backoff (caller's domain).
|
|
35
|
+
* 3. Body is absent or {@link isOpaqueStatusBody opaque} (just the status,
|
|
36
|
+
* empty JSON, HTTP framing only) → rotate conservatively: the server
|
|
37
|
+
* gave us nothing else to go on.
|
|
38
|
+
* 4. Body has content → defer to {@link parseRateLimitReason}. Only
|
|
39
|
+
* `QUOTA_EXHAUSTED` rotates; `RATE_LIMIT_EXCEEDED` (`Too many requests`,
|
|
40
|
+
* per-minute caps), `MODEL_CAPACITY_EXHAUSTED` (`Service overloaded`),
|
|
41
|
+
* `SERVER_ERROR`, and `UNKNOWN` (`Please retry in 5s`) stay in the
|
|
42
|
+
* provider's own backoff layer so transient 429s don't burn sibling
|
|
43
|
+
* credentials.
|
|
44
|
+
*/
|
|
45
|
+
export declare function isUsageLimitOutcome(status: number | undefined, message: string | undefined): boolean;
|
|
20
46
|
export declare function isUsageLimitError(errorMessage: string): boolean;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare
|
|
1
|
+
import type { OAuthLoginCallbacks } from "./oauth/types";
|
|
2
|
+
export declare const loginHuggingface: (options: import("./oauth").OAuthController) => Promise<string>;
|
|
3
3
|
export declare const huggingfaceProvider: {
|
|
4
4
|
readonly id: "huggingface";
|
|
5
5
|
readonly name: "Hugging Face Inference";
|
|
@@ -12,17 +12,16 @@
|
|
|
12
12
|
* International: https://api.minimax.io/v1
|
|
13
13
|
* China: https://api.minimaxi.com/v1
|
|
14
14
|
*/
|
|
15
|
-
import type { OAuthController } from "./types";
|
|
16
15
|
/**
|
|
17
16
|
* Login to MiniMax Token Plan (international).
|
|
18
17
|
*
|
|
19
18
|
* Opens browser to subscription page, prompts user to paste their API key.
|
|
20
19
|
* Returns the API key directly (not OAuthCredentials - this isn't OAuth).
|
|
21
20
|
*/
|
|
22
|
-
export declare
|
|
21
|
+
export declare const loginMiniMaxCode: (options: import("./types").OAuthController) => Promise<string>;
|
|
23
22
|
/**
|
|
24
23
|
* Login to MiniMax Token Plan (China).
|
|
25
24
|
*
|
|
26
25
|
* Same flow as international but uses China endpoint.
|
|
27
26
|
*/
|
|
28
|
-
export declare
|
|
27
|
+
export declare const loginMiniMaxCodeCn: (options: import("./types").OAuthController) => Promise<string>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare
|
|
1
|
+
import type { OAuthLoginCallbacks } from "./oauth/types";
|
|
2
|
+
export declare const loginQianfan: (options: import("./oauth").OAuthController) => Promise<string>;
|
|
3
3
|
export declare const qianfanProvider: {
|
|
4
4
|
readonly id: "qianfan";
|
|
5
5
|
readonly name: "Qianfan";
|
|
@@ -153,7 +153,6 @@ declare const ALL: ({
|
|
|
153
153
|
readonly id: "nvidia";
|
|
154
154
|
readonly name: "NVIDIA";
|
|
155
155
|
readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
|
|
156
|
-
readonly appendApiKeyLogin: true;
|
|
157
156
|
} | {
|
|
158
157
|
readonly id: "ollama";
|
|
159
158
|
readonly name: "Ollama (Local OpenAI-compatible)";
|
|
@@ -208,6 +207,10 @@ declare const ALL: ({
|
|
|
208
207
|
readonly id: "qwen-portal";
|
|
209
208
|
readonly name: "Qwen Portal";
|
|
210
209
|
readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
|
|
210
|
+
} | {
|
|
211
|
+
readonly id: "sakana";
|
|
212
|
+
readonly name: "Sakana AI";
|
|
213
|
+
readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
|
|
211
214
|
} | {
|
|
212
215
|
readonly id: "synthetic";
|
|
213
216
|
readonly name: "Synthetic";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { OAuthLoginCallbacks } from "./oauth/types";
|
|
2
|
+
export declare const loginSakana: (options: import("./oauth").OAuthController) => Promise<string>;
|
|
3
|
+
export declare const sakanaProvider: {
|
|
4
|
+
readonly id: "sakana";
|
|
5
|
+
readonly name: "Sakana AI";
|
|
6
|
+
readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
|
|
7
|
+
};
|
|
@@ -40,8 +40,6 @@ export interface ProviderDefinition {
|
|
|
40
40
|
readonly showInLoginList?: boolean;
|
|
41
41
|
readonly envKeys?: KeyResolver;
|
|
42
42
|
readonly login?: (callbacks: OAuthLoginCallbacks) => Promise<OAuthCredentials | string>;
|
|
43
|
-
/** String-returning login appends API keys instead of replacing the provider's existing key. */
|
|
44
|
-
readonly appendApiKeyLogin?: boolean;
|
|
45
43
|
readonly refreshToken?: (credentials: OAuthCredentials) => Promise<OAuthCredentials>;
|
|
46
44
|
readonly getApiKey?: (credentials: OAuthCredentials) => string;
|
|
47
45
|
/** Store OAuth credentials under a different provider id (e.g. `openai-codex-device` ⇒ `openai-codex`). */
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { OAuthLoginCallbacks } from "./oauth/types";
|
|
2
2
|
/**
|
|
3
3
|
* Login to Venice.
|
|
4
4
|
*
|
|
5
5
|
* Opens browser to API keys page, prompts user to paste their API key.
|
|
6
6
|
* Returns the API key directly (not OAuthCredentials - this isn't OAuth).
|
|
7
7
|
*/
|
|
8
|
-
export declare
|
|
8
|
+
export declare const loginVenice: (options: import("./oauth").OAuthController) => Promise<string>;
|
|
9
9
|
export declare const veniceProvider: {
|
|
10
10
|
readonly id: "venice";
|
|
11
11
|
readonly name: "Venice";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare
|
|
1
|
+
import type { OAuthLoginCallbacks } from "./oauth/types";
|
|
2
|
+
export declare const loginZai: (options: import("./oauth").OAuthController) => Promise<string>;
|
|
3
3
|
export declare const zaiProvider: {
|
|
4
4
|
readonly id: "zai";
|
|
5
5
|
readonly name: "Z.AI (GLM Coding Plan)";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare
|
|
1
|
+
import type { OAuthLoginCallbacks } from "./oauth/types";
|
|
2
|
+
export declare const loginZhipuCodingPlan: (options: import("./oauth").OAuthController) => Promise<string>;
|
|
3
3
|
export declare const zhipuCodingPlanProvider: {
|
|
4
4
|
readonly id: "zhipu-coding-plan";
|
|
5
5
|
readonly name: "Zhipu Coding Plan (智谱)";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A UUID-shaped string: five hex groups in the canonical 8-4-4-4-12 layout.
|
|
3
|
+
*
|
|
4
|
+
* NOT a spec-compliant RFC 4122 UUID — the version/variant nibbles are left as
|
|
5
|
+
* raw hash output — but the shape passes everywhere a UUID string is expected.
|
|
6
|
+
*/
|
|
7
|
+
export type DeterministicUuid = `${string}-${string}-${string}-${string}-${string}`;
|
|
8
|
+
/**
|
|
9
|
+
* Format the leading 128 bits of `seed`'s SHA-256 digest as a v4-shape UUID
|
|
10
|
+
* (8-4-4-4-12 hex groups).
|
|
11
|
+
*
|
|
12
|
+
* Deterministic: identical seeds always map to the same id, so callers get
|
|
13
|
+
* stable ids across requests / conversation turns (reusing message-blob ids,
|
|
14
|
+
* keying prompt caches) without persisting a seed→id mapping.
|
|
15
|
+
*/
|
|
16
|
+
export declare function deterministicUuid(seed: string): DeterministicUuid;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as tls from "node:tls";
|
|
2
|
+
import type { FetchImpl } from "../types";
|
|
3
|
+
/**
|
|
4
|
+
* Checks if a host is local or cloud metadata, which should always bypass the proxy
|
|
5
|
+
* (e.g. localhost, 127/8, ::1, 169.254.169.254, metadata.google.internal).
|
|
6
|
+
*/
|
|
7
|
+
export declare function isLocalOrMetadataHost(host: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Check if the url should bypass the proxy due to hard-coded localhost/metadata checks
|
|
10
|
+
* or custom NO_PROXY/no_proxy environment variables rules.
|
|
11
|
+
*/
|
|
12
|
+
export declare function shouldBypassProxy(urlObj: URL): boolean;
|
|
13
|
+
/** Test seam: clears the provider proxy cache. */
|
|
14
|
+
export declare function __resetProxyCache(): void;
|
|
15
|
+
/**
|
|
16
|
+
* Normalizes provider id (e.g. github-copilot -> PI_PROXY_GITHUB_COPILOT) and looks it up.
|
|
17
|
+
* If not found, falls back to PI_PROXY. Results are memoized because env values are static
|
|
18
|
+
* for the lifetime of the process and this function is called for every outgoing request.
|
|
19
|
+
*/
|
|
20
|
+
export declare function getProxyForProvider(provider: string): string | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Wraps a fetch implementation to inject proxy options for non-local hosts.
|
|
23
|
+
*/
|
|
24
|
+
export declare function wrapFetchForProxy(fetchImpl: FetchImpl, provider: string): FetchImpl;
|
|
25
|
+
/**
|
|
26
|
+
* Tunnel a socket connection through an HTTP CONNECT proxy.
|
|
27
|
+
* This is used specifically to wrap Node's `http2.connect(baseUrl, { createConnection })` for Cursor.
|
|
28
|
+
*/
|
|
29
|
+
export declare function connectProxiedSocket(proxyUrlStr: string, targetUrlStr: string): Promise<tls.TLSSocket>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "16.1.
|
|
4
|
+
"version": "16.1.15",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "16.1.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.1.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.1.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "16.1.15",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.1.15",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.1.15",
|
|
44
44
|
"arktype": "^2.2.0",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
@@ -19,16 +19,17 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { Effort } from "@oh-my-pi/pi-catalog/effort";
|
|
22
|
-
import { extractRetryHint, logger } from "@oh-my-pi/pi-utils";
|
|
22
|
+
import { extractHttpStatusFromError, extractRetryHint, logger } from "@oh-my-pi/pi-utils";
|
|
23
23
|
import type { ApiKeyResolver } from "../auth-retry";
|
|
24
24
|
import type { AuthStorage } from "../auth-storage";
|
|
25
25
|
import * as anthropicMessages from "../providers/anthropic-messages-server";
|
|
26
26
|
import * as openaiChat from "../providers/openai-chat-server";
|
|
27
27
|
import * as openaiResponses from "../providers/openai-responses-server";
|
|
28
28
|
import * as piNative from "../providers/pi-native-server";
|
|
29
|
-
import { isUsageLimitError } from "../rate-limit-utils";
|
|
29
|
+
import { isUsageLimitError, isUsageLimitOutcome } from "../rate-limit-utils";
|
|
30
30
|
import { streamSimple } from "../stream";
|
|
31
31
|
import type { Api, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "../types";
|
|
32
|
+
import { deterministicUuid } from "../utils/deterministic-id";
|
|
32
33
|
import { parseBind } from "../utils/parse-bind";
|
|
33
34
|
import { captureRequestHeaders, corsHeaders, isAuthorized, json, resolvePeer, withCors } from "./http";
|
|
34
35
|
import type {
|
|
@@ -110,11 +111,9 @@ function deriveSessionId(modelId: string, context: Context): string {
|
|
|
110
111
|
parts.push(JSON.stringify({ role: first.role, content: first.content }));
|
|
111
112
|
}
|
|
112
113
|
const seed = parts.join("\u0000");
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
// the 36-char UUID flows through unchanged.
|
|
117
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
114
|
+
// The 36-char UUID flows through unchanged: Codex's
|
|
115
|
+
// `normalizeOpenAIResponsesPromptCacheKey` accepts ≤64 chars verbatim.
|
|
116
|
+
return deterministicUuid(seed);
|
|
118
117
|
}
|
|
119
118
|
|
|
120
119
|
function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: AbortSignal): SimpleStreamOptions {
|
|
@@ -316,7 +315,7 @@ async function refreshGatewayApiKeyAfterAuthError(
|
|
|
316
315
|
peer: string,
|
|
317
316
|
): Promise<string | undefined> {
|
|
318
317
|
const message = error instanceof Error ? error.message : String(error);
|
|
319
|
-
if (
|
|
318
|
+
if (isUsageLimitOutcome(extractHttpStatusFromError(error), message)) {
|
|
320
319
|
const retryAfterMs = extractRetryHint(undefined, message);
|
|
321
320
|
const { switched, retryAtMs } = await storage.markUsageLimitReached(provider, sessionId, {
|
|
322
321
|
retryAfterMs,
|
package/src/auth-retry.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
|
|
2
2
|
import type { OAuthAccess } from "./auth-storage";
|
|
3
|
-
import {
|
|
3
|
+
import { isUsageLimitOutcome } from "./rate-limit-utils";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Context passed to an {@link ApiKeyResolver} on each resolution attempt.
|
|
@@ -72,15 +72,20 @@ export function seedApiKeyResolver(seed: string | undefined, resolver: ApiKeyRes
|
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
74
|
* Classifies whether an error should trigger a credential refresh/rotation
|
|
75
|
-
* retry: a hard `401`,
|
|
76
|
-
*
|
|
75
|
+
* retry: a hard `401`, body-classified usage limit (Codex
|
|
76
|
+
* `usage_limit_reached`, Anthropic account rate-limit, Google
|
|
77
|
+
* `resource_exhausted`, OpenAI `insufficient_quota`, …), or a bare `429`
|
|
78
|
+
* whose payload did not preserve a richer quota code. Transient 429s
|
|
79
|
+
* (`Too many requests`, per-minute caps) classify as `RATE_LIMIT_EXCEEDED`
|
|
80
|
+
* via {@link parseRateLimitReason} and stay in the upstream-backoff lane.
|
|
77
81
|
*/
|
|
78
82
|
export function isAuthRetryableError(error: unknown): boolean {
|
|
79
|
-
|
|
83
|
+
const status = extractHttpStatusFromError(error);
|
|
84
|
+
if (status === 401) return true;
|
|
80
85
|
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
return
|
|
86
|
+
const embeddedStatus = message ? extractHttpStatusFromError({ message }) : undefined;
|
|
87
|
+
if (embeddedStatus === 401) return true;
|
|
88
|
+
return isUsageLimitOutcome(status ?? embeddedStatus, message);
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
/**
|
package/src/auth-storage.ts
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
import { Database, type Statement } from "bun:sqlite";
|
|
11
11
|
import * as fs from "node:fs/promises";
|
|
12
12
|
import * as path from "node:path";
|
|
13
|
-
import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
|
|
13
|
+
import { extractHttpStatusFromError, getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
|
|
14
14
|
import type { ApiKeyResolver } from "./auth-retry";
|
|
15
|
-
import {
|
|
15
|
+
import { isUsageLimitOutcome } from "./rate-limit-utils";
|
|
16
16
|
import { getProviderDefinition } from "./registry";
|
|
17
17
|
import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./registry/oauth";
|
|
18
18
|
import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./registry/oauth/types";
|
|
@@ -1845,14 +1845,9 @@ export class AuthStorage {
|
|
|
1845
1845
|
return;
|
|
1846
1846
|
}
|
|
1847
1847
|
const newCredential: ApiKeyCredential = { type: "api_key", key: result };
|
|
1848
|
-
const
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
? await this.#store.upsertAuthCredentialRemote(provider, newCredential)
|
|
1852
|
-
: this.#store.upsertAuthCredentialForProvider(provider, newCredential)
|
|
1853
|
-
: this.#store.replaceAuthCredentialsRemote
|
|
1854
|
-
? await this.#store.replaceAuthCredentialsRemote(provider, [newCredential])
|
|
1855
|
-
: this.#store.replaceAuthCredentialsForProvider(provider, [newCredential]);
|
|
1848
|
+
const stored = this.#store.upsertAuthCredentialRemote
|
|
1849
|
+
? await this.#store.upsertAuthCredentialRemote(provider, newCredential)
|
|
1850
|
+
: this.#store.upsertAuthCredentialForProvider(provider, newCredential);
|
|
1856
1851
|
this.#setStoredCredentials(
|
|
1857
1852
|
provider,
|
|
1858
1853
|
stored.map(entry => ({ id: entry.id, credential: entry.credential })),
|
|
@@ -4089,8 +4084,9 @@ export class AuthStorage {
|
|
|
4089
4084
|
if (!sessionCredential) return false;
|
|
4090
4085
|
|
|
4091
4086
|
const error = options?.error;
|
|
4087
|
+
const status = extractHttpStatusFromError(error);
|
|
4092
4088
|
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
|
4093
|
-
if (
|
|
4089
|
+
if (isUsageLimitOutcome(status, message)) {
|
|
4094
4090
|
return (
|
|
4095
4091
|
await this.markUsageLimitReached(provider, sessionId, {
|
|
4096
4092
|
modelId: options?.modelId,
|
|
@@ -23,6 +23,7 @@ import * as fs from "node:fs";
|
|
|
23
23
|
import * as os from "node:os";
|
|
24
24
|
import * as path from "node:path";
|
|
25
25
|
import { $env, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
26
|
+
import type { FetchImpl } from "../types";
|
|
26
27
|
import { raceWithSignal } from "../utils/abort";
|
|
27
28
|
import type { AwsCredentials } from "./aws-sigv4";
|
|
28
29
|
|
|
@@ -37,6 +38,7 @@ export interface CredentialResolveOptions {
|
|
|
37
38
|
/** Falls back to env (`AWS_REGION` / `AWS_DEFAULT_REGION`) and finally `us-east-1`. */
|
|
38
39
|
region?: string;
|
|
39
40
|
signal?: AbortSignal;
|
|
41
|
+
fetch?: FetchImpl;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
const REFRESH_SKEW_MS = 60_000;
|
|
@@ -75,9 +77,10 @@ export async function resolveAwsCredentials(opts: CredentialResolveOptions = {})
|
|
|
75
77
|
const existing = inflight.get(cacheKey);
|
|
76
78
|
if (existing) return raceWithSignal(existing, opts.signal);
|
|
77
79
|
|
|
80
|
+
const fetchImpl = opts.fetch ?? (globalThis.fetch as FetchImpl);
|
|
78
81
|
const promise = (async () => {
|
|
79
82
|
try {
|
|
80
|
-
const creds = await resolveFresh(profile, region, AbortSignal.timeout(SHARED_RESOLVE_TIMEOUT_MS));
|
|
83
|
+
const creds = await resolveFresh(profile, region, AbortSignal.timeout(SHARED_RESOLVE_TIMEOUT_MS), fetchImpl);
|
|
81
84
|
cache.set(cacheKey, { creds, expiresAt: creds.expiresAt ?? Number.POSITIVE_INFINITY });
|
|
82
85
|
return creds;
|
|
83
86
|
} finally {
|
|
@@ -88,18 +91,23 @@ export async function resolveAwsCredentials(opts: CredentialResolveOptions = {})
|
|
|
88
91
|
return raceWithSignal(promise, opts.signal);
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
async function resolveFresh(
|
|
94
|
+
async function resolveFresh(
|
|
95
|
+
profile: string,
|
|
96
|
+
region: string,
|
|
97
|
+
signal?: AbortSignal,
|
|
98
|
+
fetchImpl: FetchImpl = globalThis.fetch as FetchImpl,
|
|
99
|
+
): Promise<ResolvedCredentials> {
|
|
92
100
|
// 1. Environment first — matches the AWS SDK chain order.
|
|
93
101
|
const envCreds = readEnvCredentials();
|
|
94
102
|
if (envCreds) return envCreds;
|
|
95
103
|
|
|
96
104
|
// 2. Profile (static or SSO).
|
|
97
|
-
const profileCreds = await readProfileCredentials(profile, region, signal);
|
|
105
|
+
const profileCreds = await readProfileCredentials(profile, region, signal, fetchImpl);
|
|
98
106
|
if (profileCreds) return profileCreds;
|
|
99
107
|
|
|
100
108
|
// 3. EC2 IMDSv2.
|
|
101
109
|
if ($env.AWS_EC2_METADATA_DISABLED?.toLowerCase() !== "true") {
|
|
102
|
-
const imdsCreds = await readImdsCredentials(signal);
|
|
110
|
+
const imdsCreds = await readImdsCredentials(signal, fetchImpl);
|
|
103
111
|
if (imdsCreds) return imdsCreds;
|
|
104
112
|
}
|
|
105
113
|
|
|
@@ -167,6 +175,7 @@ async function readProfileCredentials(
|
|
|
167
175
|
profile: string,
|
|
168
176
|
region: string,
|
|
169
177
|
signal: AbortSignal | undefined,
|
|
178
|
+
fetchImpl: FetchImpl,
|
|
170
179
|
): Promise<ResolvedCredentials | undefined> {
|
|
171
180
|
const home = os.homedir();
|
|
172
181
|
const credentialsPath = $env.AWS_SHARED_CREDENTIALS_FILE || path.join(home, ".aws", "credentials");
|
|
@@ -195,7 +204,7 @@ async function readProfileCredentials(
|
|
|
195
204
|
}
|
|
196
205
|
|
|
197
206
|
if (merged.sso_account_id && merged.sso_role_name) {
|
|
198
|
-
return readSsoCredentials(merged, configIni, region, signal);
|
|
207
|
+
return readSsoCredentials(merged, configIni, region, signal, fetchImpl);
|
|
199
208
|
}
|
|
200
209
|
|
|
201
210
|
if (merged.credential_process) {
|
|
@@ -217,6 +226,7 @@ async function readSsoCredentials(
|
|
|
217
226
|
configIni: IniFile | undefined,
|
|
218
227
|
defaultRegion: string,
|
|
219
228
|
signal: AbortSignal | undefined,
|
|
229
|
+
fetchImpl: FetchImpl,
|
|
220
230
|
): Promise<ResolvedCredentials | undefined> {
|
|
221
231
|
// Two SSO profile shapes:
|
|
222
232
|
// - legacy: `sso_start_url` + `sso_region` directly on the profile
|
|
@@ -246,7 +256,7 @@ async function readSsoCredentials(
|
|
|
246
256
|
`https://portal.sso.${ssoRegion}.amazonaws.com/federation/credentials` +
|
|
247
257
|
`?account_id=${encodeURIComponent(profileCfg.sso_account_id)}` +
|
|
248
258
|
`&role_name=${encodeURIComponent(profileCfg.sso_role_name)}`;
|
|
249
|
-
const response = await
|
|
259
|
+
const response = await fetchImpl(url, {
|
|
250
260
|
method: "GET",
|
|
251
261
|
headers: { "x-amz-sso_bearer_token": token.accessToken },
|
|
252
262
|
signal,
|
|
@@ -480,11 +490,14 @@ export function tokenizeCredentialProcessCommand(cmd: string): string[] {
|
|
|
480
490
|
const IMDS_HOST = "169.254.169.254";
|
|
481
491
|
const IMDS_TIMEOUT_MS = 1000;
|
|
482
492
|
|
|
483
|
-
async function readImdsCredentials(
|
|
493
|
+
async function readImdsCredentials(
|
|
494
|
+
parentSignal: AbortSignal | undefined,
|
|
495
|
+
fetchImpl: FetchImpl,
|
|
496
|
+
): Promise<ResolvedCredentials | undefined> {
|
|
484
497
|
const timeout = AbortSignal.timeout(IMDS_TIMEOUT_MS);
|
|
485
498
|
const signal = parentSignal ? AbortSignal.any([parentSignal, timeout]) : timeout;
|
|
486
499
|
try {
|
|
487
|
-
const tokenRes = await
|
|
500
|
+
const tokenRes = await fetchImpl(`http://${IMDS_HOST}/latest/api/token`, {
|
|
488
501
|
method: "PUT",
|
|
489
502
|
headers: { "x-aws-ec2-metadata-token-ttl-seconds": "21600" },
|
|
490
503
|
signal,
|
|
@@ -492,7 +505,7 @@ async function readImdsCredentials(parentSignal: AbortSignal | undefined): Promi
|
|
|
492
505
|
if (!tokenRes.ok) return undefined;
|
|
493
506
|
const token = await tokenRes.text();
|
|
494
507
|
|
|
495
|
-
const roleRes = await
|
|
508
|
+
const roleRes = await fetchImpl(`http://${IMDS_HOST}/latest/meta-data/iam/security-credentials/`, {
|
|
496
509
|
headers: { "x-aws-ec2-metadata-token": token },
|
|
497
510
|
signal,
|
|
498
511
|
});
|
|
@@ -500,7 +513,7 @@ async function readImdsCredentials(parentSignal: AbortSignal | undefined): Promi
|
|
|
500
513
|
const role = (await roleRes.text()).trim();
|
|
501
514
|
if (!role) return undefined;
|
|
502
515
|
|
|
503
|
-
const credsRes = await
|
|
516
|
+
const credsRes = await fetchImpl(
|
|
504
517
|
`http://${IMDS_HOST}/latest/meta-data/iam/security-credentials/${encodeURIComponent(role)}`,
|
|
505
518
|
{
|
|
506
519
|
headers: { "x-aws-ec2-metadata-token": token },
|
package/src/providers/cursor.ts
CHANGED
|
@@ -124,8 +124,10 @@ import type {
|
|
|
124
124
|
ToolResultMessage,
|
|
125
125
|
} from "../types";
|
|
126
126
|
import { normalizeSystemPrompts } from "../utils";
|
|
127
|
+
import { deterministicUuid } from "../utils/deterministic-id";
|
|
127
128
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
128
129
|
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse";
|
|
130
|
+
import { connectProxiedSocket, getProxyForProvider, shouldBypassProxy } from "../utils/proxy";
|
|
129
131
|
import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
|
|
130
132
|
import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
|
|
131
133
|
import { toolWireSchema } from "../utils/schema/wire";
|
|
@@ -376,7 +378,15 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
376
378
|
})
|
|
377
379
|
: undefined;
|
|
378
380
|
|
|
379
|
-
|
|
381
|
+
const proxyUrl = shouldBypassProxy(new URL(baseUrl)) ? undefined : getProxyForProvider(model.provider);
|
|
382
|
+
if (proxyUrl) {
|
|
383
|
+
const tlsSocket = await connectProxiedSocket(proxyUrl, baseUrl);
|
|
384
|
+
h2Client = http2.connect(baseUrl, {
|
|
385
|
+
createConnection: () => tlsSocket,
|
|
386
|
+
});
|
|
387
|
+
} else {
|
|
388
|
+
h2Client = http2.connect(baseUrl);
|
|
389
|
+
}
|
|
380
390
|
|
|
381
391
|
h2Request = h2Client.request(requestHeaders);
|
|
382
392
|
|
|
@@ -2258,19 +2268,6 @@ function extractAssistantMessageText(msg: Message): string {
|
|
|
2258
2268
|
.join("\n");
|
|
2259
2269
|
}
|
|
2260
2270
|
|
|
2261
|
-
/**
|
|
2262
|
-
* Derive a stable, UUID-formatted `message_id` from a content key.
|
|
2263
|
-
* Ensures identical historical messages hash to the same blob IDs across
|
|
2264
|
-
* requests, so `conversationBlobStores` does not grow unboundedly and
|
|
2265
|
-
* unchanged history reuses existing blob IDs.
|
|
2266
|
-
*/
|
|
2267
|
-
type CursorMessageId = `${string}-${string}-${string}-${string}-${string}`;
|
|
2268
|
-
|
|
2269
|
-
function deterministicMessageId(key: string): CursorMessageId {
|
|
2270
|
-
const hex = createHash("sha256").update(key).digest("hex");
|
|
2271
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2274
2271
|
/**
|
|
2275
2272
|
* Index of the last user/developer message in `messages`, or -1 if none.
|
|
2276
2273
|
* Used to exclude the current user turn from history builders — it goes in
|
|
@@ -2394,7 +2391,7 @@ function buildConversationTurns(
|
|
|
2394
2391
|
const userMessage = createCursorUserMessage(
|
|
2395
2392
|
msg.content,
|
|
2396
2393
|
userText,
|
|
2397
|
-
|
|
2394
|
+
deterministicUuid(`u:${turns.length}:${cursorUserContentKey(msg.content)}`),
|
|
2398
2395
|
);
|
|
2399
2396
|
const userMessageBytes = toBinary(UserMessageSchema, userMessage);
|
|
2400
2397
|
const userMessageBlobId = storeCursorBlob(blobStore, userMessageBytes);
|