@oh-my-pi/pi-ai 17.1.5 → 17.1.7
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 +15 -0
- package/dist/types/auth-retry.d.ts +4 -4
- package/dist/types/error/auth-classify.d.ts +5 -3
- package/dist/types/registry/registry.d.ts +8 -0
- package/dist/types/registry/siliconflow-cn.d.ts +7 -0
- package/dist/types/registry/siliconflow.d.ts +7 -0
- package/dist/types/utils/proxy.d.ts +2 -0
- package/package.json +4 -4
- package/src/auth-broker/remote-store.ts +32 -7
- package/src/auth-retry.ts +9 -4
- package/src/error/auth-classify.ts +7 -5
- package/src/providers/cursor.ts +4 -2
- package/src/providers/devin.ts +6 -1
- package/src/providers/openai-codex-responses.ts +2 -10
- package/src/registry/registry.ts +4 -0
- package/src/registry/siliconflow-cn.ts +22 -0
- package/src/registry/siliconflow.ts +22 -0
- package/src/stream.ts +4 -2
- package/src/utils/proxy.ts +10 -0
- package/dist/types/providers/__tests__/google-auth.test.d.ts +0 -1
- package/dist/types/providers/__tests__/keyless-auth-header.test.d.ts +0 -1
- package/dist/types/providers/__tests__/kimi-code-thinking.test.d.ts +0 -1
- package/dist/types/providers/__tests__/openai-codex-error.test.d.ts +0 -1
- package/dist/types/registry/oauth/__tests__/xai-oauth.test.d.ts +0 -1
- package/src/providers/__tests__/google-auth.test.ts +0 -144
- package/src/providers/__tests__/keyless-auth-header.test.ts +0 -39
- package/src/providers/__tests__/kimi-code-thinking.test.ts +0 -505
- package/src/providers/__tests__/openai-codex-error.test.ts +0 -84
- package/src/registry/oauth/__tests__/xai-oauth.test.ts +0 -479
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.1.7] - 2026-07-27
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Upstream `403 Forbidden` responses (e.g. Anthropic `permission_error` plan/model denials, Copilot model-policy rejections) now rotate through sibling credentials like usage limits do, instead of failing the session on the first denied account. The denied credential is soft-blocked for 60s and re-validated — never removed — and the original 403 surfaces only once every sibling has been tried.
|
|
10
|
+
- Usage report filtering in the auth-broker remote store is memoized per (reports, snapshot) with a precomputed per-provider OAuth credential map, replacing an O(reports × credentials) scan on every credential-selection and status refresh
|
|
11
|
+
- Cursor and Devin Connect-frame readers no longer copy every stream chunk through `Buffer.concat` when the pending buffer is empty
|
|
12
|
+
|
|
13
|
+
## [17.1.6] - 2026-07-27
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Added `getProxyForUrl()` for transports that need provider-specific and standard proxy environment resolution with `NO_PROXY` support ([#6770](https://github.com/can1357/oh-my-pi/issues/6770)).
|
|
18
|
+
- Added SiliconFlow and SiliconFlow (China) to the built-in API-key login provider catalog so `omp login siliconflow` / `omp login siliconflow-cn` stores a reusable credential validated against each region's `/v1/models` endpoint.
|
|
19
|
+
|
|
5
20
|
## [17.1.5] - 2026-07-27
|
|
6
21
|
|
|
7
22
|
### Fixed
|
|
@@ -14,7 +14,7 @@ import { isAuthRetryableError } from "./error/auth-classify.js";
|
|
|
14
14
|
* (invalidate/usage-limit the current credential and rotate to a sibling).
|
|
15
15
|
*
|
|
16
16
|
* Current drivers preserve that bounded a/b/c sequence for ordinary 401/auth
|
|
17
|
-
* failures.
|
|
17
|
+
* failures. 403/usage-limit failures skip refresh and may repeat step (c)
|
|
18
18
|
* until the resolver returns `undefined`, cycles, or hits
|
|
19
19
|
* {@link AUTH_RETRY_MAX_ATTEMPTS}.
|
|
20
20
|
*/
|
|
@@ -84,8 +84,8 @@ export declare function resolveNextAuthRetryKey(state: AuthRetryKeyState, resolv
|
|
|
84
84
|
* - A resolver → initial `attempt`, then resolver-driven retries until the
|
|
85
85
|
* applicable policy is exhausted, the resolver declines or cycles, or the
|
|
86
86
|
* operation reaches {@link AUTH_RETRY_MAX_ATTEMPTS}. Ordinary 401/auth
|
|
87
|
-
* failures retain one refresh-same plus one sibling switch; usage
|
|
88
|
-
*
|
|
87
|
+
* failures retain one refresh-same plus one sibling switch; 403/usage-limit
|
|
88
|
+
* failures rotate directly through distinct siblings.
|
|
89
89
|
*
|
|
90
90
|
* Used by non-streaming consumers (image generation, web search, completion
|
|
91
91
|
* helpers). The streaming driver in `stream.ts` implements the same policy with
|
|
@@ -136,7 +136,7 @@ export interface WithOAuthAccessOptions {
|
|
|
136
136
|
* - initial → `getOAuthAccess` (or `opts.seed`).
|
|
137
137
|
* - 401/auth failure → one `getOAuthAccess` with `forceRefresh: true` for the
|
|
138
138
|
* current account, then sibling rotation.
|
|
139
|
-
* - usage-limit failure → `rotateSessionCredential` directly, without a
|
|
139
|
+
* - 403/usage-limit failure → `rotateSessionCredential` directly, without a
|
|
140
140
|
* force-refresh detour.
|
|
141
141
|
*
|
|
142
142
|
* A refresh-same step may retry a new bearer for the same credential identity;
|
|
@@ -9,9 +9,11 @@ export declare function isDefinitiveOAuthFailure(errorMsg: string): boolean;
|
|
|
9
9
|
export declare function isInvalidatedOAuthTokenError(error: unknown): boolean;
|
|
10
10
|
/**
|
|
11
11
|
* Whether an upstream failure should rotate to a sibling credential: a hard
|
|
12
|
-
* `401`, a
|
|
13
|
-
* account
|
|
14
|
-
*
|
|
12
|
+
* `401`, a `403` (token valid but access denied — plan, model policy, or org
|
|
13
|
+
* restriction a sibling account may not share), a body-classified usage limit
|
|
14
|
+
* (Codex `usage_limit_reached`, Anthropic account rate-limit, Google
|
|
15
|
+
* `resource_exhausted`, OpenAI `insufficient_quota`, …), or a bare `429`
|
|
16
|
+
* whose payload did not preserve a richer quota code.
|
|
15
17
|
* Transient 429s (`Too many requests`, per-minute caps) stay in the
|
|
16
18
|
* upstream-backoff lane.
|
|
17
19
|
*/
|
|
@@ -239,6 +239,14 @@ declare const ALL: ({
|
|
|
239
239
|
readonly id: "sakana";
|
|
240
240
|
readonly name: "Sakana AI";
|
|
241
241
|
readonly login: (cb: import("./oauth/index.js").OAuthLoginCallbacks) => Promise<string>;
|
|
242
|
+
} | {
|
|
243
|
+
readonly id: "siliconflow";
|
|
244
|
+
readonly name: "SiliconFlow";
|
|
245
|
+
readonly login: (cb: import("./oauth/index.js").OAuthLoginCallbacks) => Promise<string>;
|
|
246
|
+
} | {
|
|
247
|
+
readonly id: "siliconflow-cn";
|
|
248
|
+
readonly name: "SiliconFlow (China)";
|
|
249
|
+
readonly login: (cb: import("./oauth/index.js").OAuthLoginCallbacks) => Promise<string>;
|
|
242
250
|
} | {
|
|
243
251
|
readonly id: "synthetic";
|
|
244
252
|
readonly name: "Synthetic";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { OAuthLoginCallbacks } from "./oauth/types.js";
|
|
2
|
+
export declare const loginSiliconFlowCn: (options: import("./oauth/index.js").OAuthController) => Promise<string>;
|
|
3
|
+
export declare const siliconflowCnProvider: {
|
|
4
|
+
readonly id: "siliconflow-cn";
|
|
5
|
+
readonly name: "SiliconFlow (China)";
|
|
6
|
+
readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
|
|
7
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { OAuthLoginCallbacks } from "./oauth/types.js";
|
|
2
|
+
export declare const loginSiliconFlow: (options: import("./oauth/index.js").OAuthController) => Promise<string>;
|
|
3
|
+
export declare const siliconflowProvider: {
|
|
4
|
+
readonly id: "siliconflow";
|
|
5
|
+
readonly name: "SiliconFlow";
|
|
6
|
+
readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
|
|
7
|
+
};
|
|
@@ -18,6 +18,8 @@ export declare function __resetProxyCache(): void;
|
|
|
18
18
|
* for the lifetime of the process and this function is called for every outgoing request.
|
|
19
19
|
*/
|
|
20
20
|
export declare function getProxyForProvider(provider: string): string | undefined;
|
|
21
|
+
/** Resolves provider-specific and standard proxy variables for a target URL, honoring NO_PROXY. */
|
|
22
|
+
export declare function getProxyForUrl(provider: string, url: URL): string | undefined;
|
|
21
23
|
/**
|
|
22
24
|
* Wraps a fetch implementation to inject proxy options for non-local hosts.
|
|
23
25
|
*/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "17.1.
|
|
4
|
+
"version": "17.1.7",
|
|
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.1",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "17.1.
|
|
42
|
-
"@oh-my-pi/pi-utils": "17.1.
|
|
43
|
-
"@oh-my-pi/pi-wire": "17.1.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "17.1.7",
|
|
42
|
+
"@oh-my-pi/pi-utils": "17.1.7",
|
|
43
|
+
"@oh-my-pi/pi-wire": "17.1.7",
|
|
44
44
|
"arktype": "2.2.3",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
@@ -253,6 +253,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
253
253
|
#usageInflight?: Promise<UsageReport[] | null>;
|
|
254
254
|
#credentialBlockReconcileAfter: Map<string, number> = new Map();
|
|
255
255
|
#usageCacheEpoch = 0;
|
|
256
|
+
/** Per-snapshot lookup of oauth credentials by provider; rebuilt when `#snapshot` is replaced. */
|
|
257
|
+
#usageFilterLookup?: { snapshot: SnapshotResponse; byProvider: Map<Provider, OAuthCredential[]> };
|
|
258
|
+
/** Memoized `#filterUsageReports` output, keyed on (input identity, lookup identity). */
|
|
259
|
+
#usageFilterResult?: { input: UsageReport[]; byProvider: Map<Provider, OAuthCredential[]>; output: UsageReport[] };
|
|
256
260
|
#closed = false;
|
|
257
261
|
/**
|
|
258
262
|
* `true` once the SSE consumer received its first frame and hasn't dropped
|
|
@@ -962,18 +966,39 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
962
966
|
return overlay ?? matched;
|
|
963
967
|
}
|
|
964
968
|
|
|
969
|
+
/**
|
|
970
|
+
* Hot path — called per `getUsageReport()`/`fetchUsageReports()` (status-line
|
|
971
|
+
* refresh cadence). The oauth-credential lookup is memoized on `#snapshot`
|
|
972
|
+
* identity (every update site replaces the reference), and the filtered
|
|
973
|
+
* output on (reports identity, lookup identity) — `#loadUsageReports`
|
|
974
|
+
* serves the same array for 15s, so steady-state calls are O(1).
|
|
975
|
+
*/
|
|
965
976
|
#filterUsageReports(reports: UsageReport[]): UsageReport[] {
|
|
966
977
|
const accountPool = this.#accountPool;
|
|
967
978
|
if (!accountPool) return reports;
|
|
968
|
-
|
|
979
|
+
let lookup = this.#usageFilterLookup;
|
|
980
|
+
if (!lookup || lookup.snapshot !== this.#snapshot) {
|
|
981
|
+
const byProvider = new Map<Provider, OAuthCredential[]>();
|
|
982
|
+
for (const entry of this.#snapshot.credentials) {
|
|
983
|
+
if (entry.credential.type !== "oauth") continue;
|
|
984
|
+
const list = byProvider.get(entry.provider);
|
|
985
|
+
if (list) list.push(entry.credential);
|
|
986
|
+
else byProvider.set(entry.provider, [entry.credential]);
|
|
987
|
+
}
|
|
988
|
+
lookup = { snapshot: this.#snapshot, byProvider };
|
|
989
|
+
this.#usageFilterLookup = lookup;
|
|
990
|
+
}
|
|
991
|
+
const memo = this.#usageFilterResult;
|
|
992
|
+
if (memo && memo.input === reports && memo.byProvider === lookup.byProvider) return memo.output;
|
|
993
|
+
const byProvider = lookup.byProvider;
|
|
994
|
+
const output = reports.filter(report => {
|
|
969
995
|
if (!accountPool.has(report.provider)) return true;
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
entry.credential.type === "oauth" &&
|
|
974
|
-
usageReportMatchesCredential(report, entry.credential),
|
|
975
|
-
);
|
|
996
|
+
const credentials = byProvider.get(report.provider);
|
|
997
|
+
if (!credentials) return false;
|
|
998
|
+
return credentials.some(credential => usageReportMatchesCredential(report, credential));
|
|
976
999
|
});
|
|
1000
|
+
this.#usageFilterResult = { input: reports, byProvider, output };
|
|
1001
|
+
return output;
|
|
977
1002
|
}
|
|
978
1003
|
|
|
979
1004
|
ingestUsageReport(provider: Provider, credential: OAuthCredential, report: UsageReport): boolean {
|
package/src/auth-retry.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
|
|
1
2
|
import type { OAuthAccess } from "./auth-storage";
|
|
2
3
|
import * as AIError from "./error";
|
|
3
4
|
import { isAuthRetryableError, isInvalidatedOAuthTokenError } from "./error/auth-classify";
|
|
@@ -18,7 +19,7 @@ import { isUsageLimitOutcome } from "./error/rate-limit";
|
|
|
18
19
|
* (invalidate/usage-limit the current credential and rotate to a sibling).
|
|
19
20
|
*
|
|
20
21
|
* Current drivers preserve that bounded a/b/c sequence for ordinary 401/auth
|
|
21
|
-
* failures.
|
|
22
|
+
* failures. 403/usage-limit failures skip refresh and may repeat step (c)
|
|
22
23
|
* until the resolver returns `undefined`, cycles, or hits
|
|
23
24
|
* {@link AUTH_RETRY_MAX_ATTEMPTS}.
|
|
24
25
|
*/
|
|
@@ -92,7 +93,11 @@ export const AUTH_RETRY_MAX_ATTEMPTS = 64;
|
|
|
92
93
|
function isDirectCredentialRotationError(error: unknown): boolean {
|
|
93
94
|
if (isUsageLimit(error) || isInvalidatedOAuthTokenError(error)) return true;
|
|
94
95
|
const status = AIError.status(error);
|
|
96
|
+
// 403: the token is valid but access was denied, so refreshing the same
|
|
97
|
+
// credential can't help — rotate straight through the sibling pool.
|
|
98
|
+
if (status === 403) return true;
|
|
95
99
|
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
|
100
|
+
if (status === undefined && message !== undefined && extractHttpStatusFromError({ message }) === 403) return true;
|
|
96
101
|
return isUsageLimitOutcome(status, message);
|
|
97
102
|
}
|
|
98
103
|
|
|
@@ -199,8 +204,8 @@ async function runOAuthAttempt<T>(
|
|
|
199
204
|
* - A resolver → initial `attempt`, then resolver-driven retries until the
|
|
200
205
|
* applicable policy is exhausted, the resolver declines or cycles, or the
|
|
201
206
|
* operation reaches {@link AUTH_RETRY_MAX_ATTEMPTS}. Ordinary 401/auth
|
|
202
|
-
* failures retain one refresh-same plus one sibling switch; usage
|
|
203
|
-
*
|
|
207
|
+
* failures retain one refresh-same plus one sibling switch; 403/usage-limit
|
|
208
|
+
* failures rotate directly through distinct siblings.
|
|
204
209
|
*
|
|
205
210
|
* Used by non-streaming consumers (image generation, web search, completion
|
|
206
211
|
* helpers). The streaming driver in `stream.ts` implements the same policy with
|
|
@@ -289,7 +294,7 @@ export interface WithOAuthAccessOptions {
|
|
|
289
294
|
* - initial → `getOAuthAccess` (or `opts.seed`).
|
|
290
295
|
* - 401/auth failure → one `getOAuthAccess` with `forceRefresh: true` for the
|
|
291
296
|
* current account, then sibling rotation.
|
|
292
|
-
* - usage-limit failure → `rotateSessionCredential` directly, without a
|
|
297
|
+
* - 403/usage-limit failure → `rotateSessionCredential` directly, without a
|
|
293
298
|
* force-refresh detour.
|
|
294
299
|
*
|
|
295
300
|
* A refresh-same step may retry a new bearer for the same credential identity;
|
|
@@ -26,9 +26,11 @@ export function isInvalidatedOAuthTokenError(error: unknown): boolean {
|
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Whether an upstream failure should rotate to a sibling credential: a hard
|
|
29
|
-
* `401`, a
|
|
30
|
-
* account
|
|
31
|
-
*
|
|
29
|
+
* `401`, a `403` (token valid but access denied — plan, model policy, or org
|
|
30
|
+
* restriction a sibling account may not share), a body-classified usage limit
|
|
31
|
+
* (Codex `usage_limit_reached`, Anthropic account rate-limit, Google
|
|
32
|
+
* `resource_exhausted`, OpenAI `insufficient_quota`, …), or a bare `429`
|
|
33
|
+
* whose payload did not preserve a richer quota code.
|
|
32
34
|
* Transient 429s (`Too many requests`, per-minute caps) stay in the
|
|
33
35
|
* upstream-backoff lane.
|
|
34
36
|
*/
|
|
@@ -36,9 +38,9 @@ export function isAuthRetryableError(error: unknown): boolean {
|
|
|
36
38
|
if (isUsageLimit(error)) return true;
|
|
37
39
|
if (isInvalidatedOAuthTokenError(error)) return true;
|
|
38
40
|
const httpStatus = extractHttpStatusFromError(error);
|
|
39
|
-
if (httpStatus === 401) return true;
|
|
41
|
+
if (httpStatus === 401 || httpStatus === 403) return true;
|
|
40
42
|
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
|
41
43
|
const embeddedStatus = message ? extractHttpStatusFromError({ message }) : undefined;
|
|
42
|
-
if (embeddedStatus === 401) return true;
|
|
44
|
+
if (embeddedStatus === 401 || embeddedStatus === 403) return true;
|
|
43
45
|
return isUsageLimitOutcome(httpStatus ?? embeddedStatus, message);
|
|
44
46
|
}
|
package/src/providers/cursor.ts
CHANGED
|
@@ -503,7 +503,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
503
503
|
|
|
504
504
|
stream.push({ type: "start", partial: output });
|
|
505
505
|
|
|
506
|
-
let pendingBuffer = Buffer.alloc(0);
|
|
506
|
+
let pendingBuffer: Buffer = Buffer.alloc(0);
|
|
507
507
|
let currentTextBlock: (TextContent & { [kStreamingBlockIndex]: number }) | null = null;
|
|
508
508
|
let currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null = null;
|
|
509
509
|
let currentToolCall: ToolCallState | null = null;
|
|
@@ -557,7 +557,9 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
557
557
|
log?.write(chunk);
|
|
558
558
|
});
|
|
559
559
|
}
|
|
560
|
-
|
|
560
|
+
// Steady state drains fully per chunk; alias the fresh h2 chunk instead
|
|
561
|
+
// of copying it through Buffer.concat (see aws-eventstream.ts).
|
|
562
|
+
pendingBuffer = pendingBuffer.length === 0 ? chunk : Buffer.concat([pendingBuffer, chunk]);
|
|
561
563
|
|
|
562
564
|
while (pendingBuffer.length >= 5) {
|
|
563
565
|
const flags = pendingBuffer[0];
|
package/src/providers/devin.ts
CHANGED
|
@@ -217,7 +217,12 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
|
|
|
217
217
|
for (;;) {
|
|
218
218
|
const { done, value } = await reader.read();
|
|
219
219
|
if (value && value.length > 0) {
|
|
220
|
-
|
|
220
|
+
// Steady state drains fully per chunk; view the fresh reader chunk
|
|
221
|
+
// instead of copying it through Buffer.concat (see aws-eventstream.ts).
|
|
222
|
+
pending =
|
|
223
|
+
pending.length === 0
|
|
224
|
+
? Buffer.from(value.buffer, value.byteOffset, value.byteLength)
|
|
225
|
+
: Buffer.concat([pending, value]);
|
|
221
226
|
}
|
|
222
227
|
|
|
223
228
|
while (pending.length >= 5) {
|
|
@@ -61,7 +61,7 @@ import {
|
|
|
61
61
|
getOpenAIStreamIdleTimeoutMs,
|
|
62
62
|
iterateWithIdleTimeout,
|
|
63
63
|
} from "../utils/idle-iterator";
|
|
64
|
-
import {
|
|
64
|
+
import { getProxyForUrl } from "../utils/proxy";
|
|
65
65
|
import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
|
|
66
66
|
import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
|
|
67
67
|
import { notifyRawSseEvent } from "../utils/sse-debug";
|
|
@@ -3855,15 +3855,7 @@ async function getOrCreateCodexWebSocketConnection(
|
|
|
3855
3855
|
provider: string,
|
|
3856
3856
|
signal?: AbortSignal,
|
|
3857
3857
|
): Promise<CodexWebSocketConnection> {
|
|
3858
|
-
const
|
|
3859
|
-
const proxy = shouldBypassProxy(targetUrl)
|
|
3860
|
-
? undefined
|
|
3861
|
-
: (getProxyForProvider(provider) ??
|
|
3862
|
-
(targetUrl.protocol === "wss:"
|
|
3863
|
-
? Bun.env.HTTPS_PROXY || Bun.env.https_proxy
|
|
3864
|
-
: Bun.env.HTTP_PROXY || Bun.env.http_proxy) ??
|
|
3865
|
-
Bun.env.ALL_PROXY ??
|
|
3866
|
-
Bun.env.all_proxy);
|
|
3858
|
+
const proxy = getProxyForUrl(provider, new URL(url));
|
|
3867
3859
|
const headerRecord = headersToRecord(headers);
|
|
3868
3860
|
// Join an in-flight handshake instead of tearing it down: closing a
|
|
3869
3861
|
// CONNECTING socket rejects the concurrent caller (prewarm racing the first
|
package/src/registry/registry.ts
CHANGED
|
@@ -51,6 +51,8 @@ import { perplexityProvider } from "./perplexity";
|
|
|
51
51
|
import { qianfanProvider } from "./qianfan";
|
|
52
52
|
import { qwenPortalProvider } from "./qwen-portal";
|
|
53
53
|
import { sakanaProvider } from "./sakana";
|
|
54
|
+
import { siliconflowProvider } from "./siliconflow";
|
|
55
|
+
import { siliconflowCnProvider } from "./siliconflow-cn";
|
|
54
56
|
import { syntheticProvider } from "./synthetic";
|
|
55
57
|
import { tavilyProvider } from "./tavily";
|
|
56
58
|
import { togetherProvider } from "./together";
|
|
@@ -121,6 +123,8 @@ const ALL = [
|
|
|
121
123
|
perplexityProvider,
|
|
122
124
|
qianfanProvider,
|
|
123
125
|
veniceProvider,
|
|
126
|
+
siliconflowProvider,
|
|
127
|
+
siliconflowCnProvider,
|
|
124
128
|
syntheticProvider,
|
|
125
129
|
nanogptProvider,
|
|
126
130
|
waferServerlessProvider,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createApiKeyLogin } from "./api-key-login";
|
|
2
|
+
import type { OAuthLoginCallbacks } from "./oauth/types";
|
|
3
|
+
import type { ProviderDefinition } from "./types";
|
|
4
|
+
|
|
5
|
+
export const loginSiliconFlowCn = createApiKeyLogin({
|
|
6
|
+
providerLabel: "SiliconFlow (China)",
|
|
7
|
+
authUrl: "https://cloud.siliconflow.cn/account/ak",
|
|
8
|
+
instructions: "Create or copy your API key from the SiliconFlow console",
|
|
9
|
+
promptMessage: "Paste your SiliconFlow API key",
|
|
10
|
+
placeholder: "sk-...",
|
|
11
|
+
validation: {
|
|
12
|
+
kind: "models-endpoint",
|
|
13
|
+
provider: "siliconflow-cn",
|
|
14
|
+
modelsUrl: "https://api.siliconflow.cn/v1/models",
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const siliconflowCnProvider = {
|
|
19
|
+
id: "siliconflow-cn",
|
|
20
|
+
name: "SiliconFlow (China)",
|
|
21
|
+
login: (cb: OAuthLoginCallbacks) => loginSiliconFlowCn(cb),
|
|
22
|
+
} as const satisfies ProviderDefinition;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createApiKeyLogin } from "./api-key-login";
|
|
2
|
+
import type { OAuthLoginCallbacks } from "./oauth/types";
|
|
3
|
+
import type { ProviderDefinition } from "./types";
|
|
4
|
+
|
|
5
|
+
export const loginSiliconFlow = createApiKeyLogin({
|
|
6
|
+
providerLabel: "SiliconFlow",
|
|
7
|
+
authUrl: "https://cloud.siliconflow.com/account/ak",
|
|
8
|
+
instructions: "Create or copy your API key from the SiliconFlow console",
|
|
9
|
+
promptMessage: "Paste your SiliconFlow API key",
|
|
10
|
+
placeholder: "sk-...",
|
|
11
|
+
validation: {
|
|
12
|
+
kind: "models-endpoint",
|
|
13
|
+
provider: "siliconflow",
|
|
14
|
+
modelsUrl: "https://api.siliconflow.com/v1/models",
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const siliconflowProvider = {
|
|
19
|
+
id: "siliconflow",
|
|
20
|
+
name: "SiliconFlow",
|
|
21
|
+
login: (cb: OAuthLoginCallbacks) => loginSiliconFlow(cb),
|
|
22
|
+
} as const satisfies ProviderDefinition;
|
package/src/stream.ts
CHANGED
|
@@ -970,7 +970,9 @@ function extractStatusFromAssistantError(message: AssistantMessage): number | un
|
|
|
970
970
|
}
|
|
971
971
|
|
|
972
972
|
function isRetryableUpstreamError(error: unknown, status: number | undefined, message: string | undefined): boolean {
|
|
973
|
-
// 401 means the credential is bad
|
|
973
|
+
// 401 means the credential is bad; 403 is its valid-token twin (access
|
|
974
|
+
// denied by plan, model policy, or org restriction — a sibling account may
|
|
975
|
+
// not share it). Usage-limit phrasing (Codex's
|
|
974
976
|
// "You have hit your ChatGPT usage limit", Anthropic's "usage_limit_reached",
|
|
975
977
|
// Google's "resource_exhausted", OpenAI's "insufficient_quota") and 429s
|
|
976
978
|
// without transient rate-limit wording mean this account is parked but a
|
|
@@ -983,7 +985,7 @@ function isRetryableUpstreamError(error: unknown, status: number | undefined, me
|
|
|
983
985
|
// instead of burning siblings.
|
|
984
986
|
if (AIError.isUsageLimit(error)) return true;
|
|
985
987
|
if (isInvalidatedOAuthTokenError(error)) return true;
|
|
986
|
-
if (status === 401) return true;
|
|
988
|
+
if (status === 401 || status === 403) return true;
|
|
987
989
|
return isUsageLimitOutcome(status, message);
|
|
988
990
|
}
|
|
989
991
|
|
package/src/utils/proxy.ts
CHANGED
|
@@ -129,6 +129,16 @@ export function getProxyForProvider(provider: string): string | undefined {
|
|
|
129
129
|
return value;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/** Resolves provider-specific and standard proxy variables for a target URL, honoring NO_PROXY. */
|
|
133
|
+
export function getProxyForUrl(provider: string, url: URL): string | undefined {
|
|
134
|
+
if (shouldBypassProxy(url)) return undefined;
|
|
135
|
+
const protocolProxy =
|
|
136
|
+
url.protocol === "https:" || url.protocol === "wss:"
|
|
137
|
+
? Bun.env.HTTPS_PROXY || Bun.env.https_proxy
|
|
138
|
+
: Bun.env.HTTP_PROXY || Bun.env.http_proxy;
|
|
139
|
+
return getProxyForProvider(provider) || protocolProxy || Bun.env.ALL_PROXY || Bun.env.all_proxy || undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
132
142
|
/**
|
|
133
143
|
* Wraps a fetch implementation to inject proxy options for non-local hosts.
|
|
134
144
|
*/
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import { Buffer } from "node:buffer";
|
|
3
|
-
import * as fs from "node:fs/promises";
|
|
4
|
-
import * as os from "node:os";
|
|
5
|
-
import * as path from "node:path";
|
|
6
|
-
import type { FetchImpl } from "../../types";
|
|
7
|
-
import { __resetVertexTokenCache, getVertexAccessToken } from "../google-auth";
|
|
8
|
-
|
|
9
|
-
const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
|
|
10
|
-
const JWT_BEARER_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
|
11
|
-
|
|
12
|
-
/** Generate a real RS256 private key so signJwtRs256 / pemToPkcs8 run for real. */
|
|
13
|
-
async function generateServiceAccountPem(): Promise<string> {
|
|
14
|
-
const keyPair = (await globalThis.crypto.subtle.generateKey(
|
|
15
|
-
{ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
|
|
16
|
-
true,
|
|
17
|
-
["sign", "verify"],
|
|
18
|
-
)) as CryptoKeyPair;
|
|
19
|
-
const pkcs8 = new Uint8Array(await globalThis.crypto.subtle.exportKey("pkcs8", keyPair.privateKey));
|
|
20
|
-
const body = (
|
|
21
|
-
Buffer.from(pkcs8)
|
|
22
|
-
.toString("base64")
|
|
23
|
-
.match(/.{1,64}/g) ?? []
|
|
24
|
-
).join("\n");
|
|
25
|
-
return `-----BEGIN PRIVATE KEY-----\n${body}\n-----END PRIVATE KEY-----\n`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function urlOf(input: string | URL | Request): string {
|
|
29
|
-
if (typeof input === "string") return input;
|
|
30
|
-
if (input instanceof URL) return input.toString();
|
|
31
|
-
return input.url;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
describe("getVertexAccessToken impersonated_service_account ADC", () => {
|
|
35
|
-
let tmpDir: string;
|
|
36
|
-
let originalGac: string | undefined;
|
|
37
|
-
|
|
38
|
-
beforeEach(async () => {
|
|
39
|
-
__resetVertexTokenCache();
|
|
40
|
-
originalGac = Bun.env.GOOGLE_APPLICATION_CREDENTIALS;
|
|
41
|
-
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-vertex-adc-"));
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
afterEach(async () => {
|
|
45
|
-
__resetVertexTokenCache();
|
|
46
|
-
if (originalGac === undefined) delete Bun.env.GOOGLE_APPLICATION_CREDENTIALS;
|
|
47
|
-
else Bun.env.GOOGLE_APPLICATION_CREDENTIALS = originalGac;
|
|
48
|
-
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("rejects a malformed service_account_impersonation_url before any network call", async () => {
|
|
52
|
-
const adcPath = path.join(tmpDir, "impersonated-bad-url.json");
|
|
53
|
-
await Bun.write(
|
|
54
|
-
adcPath,
|
|
55
|
-
JSON.stringify({
|
|
56
|
-
type: "impersonated_service_account",
|
|
57
|
-
// Missing the trailing ":generateAccessToken" the principal parser requires.
|
|
58
|
-
service_account_impersonation_url:
|
|
59
|
-
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/target@project.iam.gserviceaccount.com",
|
|
60
|
-
source_credentials: {
|
|
61
|
-
type: "authorized_user",
|
|
62
|
-
client_id: "client-id",
|
|
63
|
-
client_secret: "client-secret",
|
|
64
|
-
refresh_token: "refresh-token",
|
|
65
|
-
},
|
|
66
|
-
}),
|
|
67
|
-
);
|
|
68
|
-
Bun.env.GOOGLE_APPLICATION_CREDENTIALS = adcPath;
|
|
69
|
-
|
|
70
|
-
const calls: string[] = [];
|
|
71
|
-
const fetchImpl: FetchImpl = async input => {
|
|
72
|
-
calls.push(urlOf(input));
|
|
73
|
-
return new Response("{}");
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
// The principal is parsed before the source exchange, so a bad URL must fail
|
|
77
|
-
// up front rather than after burning a source-token round trip.
|
|
78
|
-
await expect(getVertexAccessToken({ fetch: fetchImpl })).rejects.toBeInstanceOf(RangeError);
|
|
79
|
-
expect(calls).toEqual([]);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("signs an RS256 JWT for a service_account source and reconstructs the IAM URL", async () => {
|
|
83
|
-
const pem = await generateServiceAccountPem();
|
|
84
|
-
const adcPath = path.join(tmpDir, "impersonated-sa.json");
|
|
85
|
-
await Bun.write(
|
|
86
|
-
adcPath,
|
|
87
|
-
JSON.stringify({
|
|
88
|
-
type: "impersonated_service_account",
|
|
89
|
-
// Non-canonical project segment proves the request URL is rebuilt, not echoed.
|
|
90
|
-
service_account_impersonation_url:
|
|
91
|
-
"https://iamcredentials.googleapis.com/v1/projects/explicit-proj/serviceAccounts/target@project.iam.gserviceaccount.com:generateAccessToken",
|
|
92
|
-
source_credentials: {
|
|
93
|
-
type: "service_account",
|
|
94
|
-
client_email: "source@project.iam.gserviceaccount.com",
|
|
95
|
-
private_key: pem,
|
|
96
|
-
private_key_id: "key-1",
|
|
97
|
-
},
|
|
98
|
-
// delegates intentionally omitted — the IAM body must default to [].
|
|
99
|
-
}),
|
|
100
|
-
);
|
|
101
|
-
Bun.env.GOOGLE_APPLICATION_CREDENTIALS = adcPath;
|
|
102
|
-
|
|
103
|
-
const calls: { url: string; init?: RequestInit }[] = [];
|
|
104
|
-
const fetchImpl: FetchImpl = async (input, init) => {
|
|
105
|
-
const url = urlOf(input);
|
|
106
|
-
calls.push({ url, init });
|
|
107
|
-
if (url === "https://oauth2.googleapis.com/token") {
|
|
108
|
-
return new Response(JSON.stringify({ access_token: "sa-source-token", expires_in: 3600 }));
|
|
109
|
-
}
|
|
110
|
-
if (url.startsWith("https://iamcredentials.googleapis.com/")) {
|
|
111
|
-
return new Response(
|
|
112
|
-
JSON.stringify({
|
|
113
|
-
accessToken: "impersonated-token",
|
|
114
|
-
expireTime: new Date(Date.now() + 3_600_000).toISOString(),
|
|
115
|
-
}),
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
return new Response("unexpected", { status: 404 });
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
const token = await getVertexAccessToken({ fetch: fetchImpl });
|
|
122
|
-
expect(token).toBe("impersonated-token");
|
|
123
|
-
|
|
124
|
-
// Source JWT exchange happens first, then the impersonation exchange.
|
|
125
|
-
expect(calls.map(c => c.url)).toEqual([
|
|
126
|
-
"https://oauth2.googleapis.com/token",
|
|
127
|
-
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/target@project.iam.gserviceaccount.com:generateAccessToken",
|
|
128
|
-
]);
|
|
129
|
-
|
|
130
|
-
// Source credential is exchanged via a signed JWT bearer assertion, not a refresh grant.
|
|
131
|
-
const sourceBody = new URLSearchParams(String(calls[0].init?.body));
|
|
132
|
-
expect(sourceBody.get("grant_type")).toBe(JWT_BEARER_GRANT);
|
|
133
|
-
expect((sourceBody.get("assertion") ?? "").split(".")).toHaveLength(3);
|
|
134
|
-
|
|
135
|
-
// The IAM call carries the source-derived bearer token and defaults delegates to [].
|
|
136
|
-
const iamHeaders = calls[1].init?.headers as Record<string, string>;
|
|
137
|
-
expect(iamHeaders.Authorization).toBe("Bearer sa-source-token");
|
|
138
|
-
expect(JSON.parse(String(calls[1].init?.body))).toEqual({
|
|
139
|
-
delegates: [],
|
|
140
|
-
scope: [CLOUD_PLATFORM_SCOPE],
|
|
141
|
-
lifetime: "3600s",
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
});
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { NO_AUTH_SENTINEL, resolveOpenAIRequestSetup } from "../openai-shared";
|
|
3
|
-
|
|
4
|
-
describe("resolveOpenAIRequestSetup keyless auth", () => {
|
|
5
|
-
test("omits Authorization for the keyless (auth: none) sentinel, keeping custom headers", () => {
|
|
6
|
-
const setup = resolveOpenAIRequestSetup(
|
|
7
|
-
{
|
|
8
|
-
provider: "qwen",
|
|
9
|
-
id: "Qwen3.6-35B-A3B",
|
|
10
|
-
baseUrl: "http://localhost:8788",
|
|
11
|
-
headers: { "x-api-key": "real-key" },
|
|
12
|
-
},
|
|
13
|
-
{ apiKey: NO_AUTH_SENTINEL, messages: [] },
|
|
14
|
-
);
|
|
15
|
-
expect(setup.headers.Authorization).toBeUndefined();
|
|
16
|
-
expect(setup.headers["x-api-key"]).toBe("real-key");
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test("still injects Bearer for a real key", () => {
|
|
20
|
-
const setup = resolveOpenAIRequestSetup(
|
|
21
|
-
{ provider: "custom", id: "m", baseUrl: "http://localhost:8788" },
|
|
22
|
-
{ apiKey: "sk-real", messages: [] },
|
|
23
|
-
);
|
|
24
|
-
expect(setup.headers.Authorization).toBe("Bearer sk-real");
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test("caller-supplied Authorization in model.headers is preserved even when keyless", () => {
|
|
28
|
-
const setup = resolveOpenAIRequestSetup(
|
|
29
|
-
{
|
|
30
|
-
provider: "qwen",
|
|
31
|
-
id: "m",
|
|
32
|
-
baseUrl: "http://localhost:8788",
|
|
33
|
-
headers: { Authorization: "Bearer custom-token" },
|
|
34
|
-
},
|
|
35
|
-
{ apiKey: NO_AUTH_SENTINEL, messages: [] },
|
|
36
|
-
);
|
|
37
|
-
expect(setup.headers.Authorization).toBe("Bearer custom-token");
|
|
38
|
-
});
|
|
39
|
-
});
|