@linxiraos/pi-ai 1.1.11 → 1.1.13
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 +8 -6
- package/dist/types/auth-storage.d.ts +36 -0
- package/dist/types/error/flags.d.ts +9 -0
- package/dist/types/providers/openai-codex-responses.d.ts +1 -1
- package/dist/types/registry/oauth/github-copilot.d.ts +1 -1
- package/package.json +11 -8
- package/src/auth-storage.ts +189 -6
- package/src/dialect/deepseek.ts +1 -1
- package/src/dialect/gemini.ts +1 -1
- package/src/dialect/gemma.ts +1 -1
- package/src/dialect/glm.ts +1 -1
- package/src/dialect/harmony.ts +1 -1
- package/src/dialect/kimi.ts +1 -1
- package/src/dialect/rendering.ts +2 -2
- package/src/dialect/thinking.ts +1 -1
- package/src/error/flags.ts +21 -1
- package/src/providers/anthropic.ts +4 -6
- package/src/providers/gitlab-duo-workflow.ts +10 -12
- package/src/providers/openai-codex-responses.ts +25 -21
- package/src/providers/openai-responses.ts +1 -3
- package/src/registry/oauth/github-copilot.ts +29 -8
- package/src/utils/harmony-leak.ts +1 -1
- package/src/utils/thinking-loop.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,18 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.1.13] - 2026-09-10
|
|
6
|
+
|
|
7
|
+
- 上游 v18.1.16 同步:AuthStorage 合并封锁契约——凭据级 `blockedUntilMs` 与 `providerTimed` 时间线合并,先到的更长封锁在短提示到来时保持有效;GitHub Copilot OAuth 拆分公共 GitHub / GHE 双 client-id;Codex WebSocket 传输 abort 携带 cause 链。
|
|
8
|
+
|
|
9
|
+
## [1.1.12] - 2026-09-10
|
|
10
|
+
|
|
11
|
+
- 品牌与合并工具链维护版本;无本包用户可见变更。
|
|
12
|
+
|
|
5
13
|
## [1.1.11] - 2026-09-08
|
|
6
14
|
|
|
7
15
|
### Added
|
|
8
16
|
|
|
9
17
|
- Muse Code subscription sign-in, credential refresh, inference, and quota reporting in `/usage`, with durable rate-limit backoff so quota refresh recovers instead of repeatedly retrying.
|
|
10
18
|
|
|
11
|
-
### Fixed
|
|
12
|
-
|
|
13
|
-
- Reasoning-off requests (e.g. GitHub Copilot `gpt-6-astra`) no longer surface `400 Unsupported value: 'none'`: the reasoning-effort fallback now recognizes `Supported values` phrasing and retries at the lowest allowed effort.
|
|
14
|
-
- Cursor GPT off-tier requests no longer send raw `-none` sibling ids (e.g. `gpt-5.6-sol-none-fast`), which the Run endpoint rejects; they normalize to the base model id with no reasoning parameter.
|
|
15
|
-
- Fixed Codex compaction timeouts triggering prolonged retries instead of advancing to the next compaction method.
|
|
16
|
-
|
|
17
19
|
## [1.1.10-omp18.1.12] - 2026-09-06
|
|
18
20
|
|
|
19
21
|
### Added
|
|
@@ -496,10 +496,40 @@ export { isDefinitiveOAuthFailure } from "./error/auth-classify.js";
|
|
|
496
496
|
* multi-hour) retry-after when it is sooner. `retryAtMs` is `undefined` when
|
|
497
497
|
* no sibling credentials exist at all, or when the session has no tracked
|
|
498
498
|
* credential to rotate away from.
|
|
499
|
+
*
|
|
500
|
+
* `blockedUntilMs` (epoch ms) is the just-blocked credential's own unblock
|
|
501
|
+
* deadline — the later of the caller's retry-after and any exhausted window
|
|
502
|
+
* the usage report reveals. Callers that wait the account out (instead of
|
|
503
|
+
* rotating) must sleep until this, not the error-text hint alone.
|
|
504
|
+
*
|
|
505
|
+
* `priorBlockedUntilMs` (epoch ms) is the live block deadline the map already
|
|
506
|
+
* stored for this credential before this call. The merged `blockedUntilMs`
|
|
507
|
+
* masks a pre-existing block shorter than this call's own heuristic
|
|
508
|
+
* fallback (`Math.max` in the mark), so callers that replace that heuristic
|
|
509
|
+
* with an authoritative report window must consult the prior deadline to
|
|
510
|
+
* keep honoring the earlier response's provider-stated block.
|
|
511
|
+
*
|
|
512
|
+
* `priorBlockedUntilTimed` is `true` when that prior deadline came from
|
|
513
|
+
* provider-stated timing (a parsed hint or usage-report reset) rather than
|
|
514
|
+
* another session's heuristic guess — only timed priors may extend a wait
|
|
515
|
+
* past an authoritative report window. Persisted blocks carry no provenance
|
|
516
|
+
* and count as untimed: a stale persisted heuristic must not outrank a
|
|
517
|
+
* fresh complete report (longer persisted deadlines still win through the
|
|
518
|
+
* merged `blockedUntilMs`).
|
|
519
|
+
*
|
|
520
|
+
* `reportResetAtMs` (epoch ms) is present only when the usage report is a
|
|
521
|
+
* complete authority for the wait: every exhausted window carries a future
|
|
522
|
+
* reset, so sleeping until the latest one can actually clear the account. A
|
|
523
|
+
* permanent cap alongside a timed window (or no report at all) leaves it
|
|
524
|
+
* unset, and the heuristic fallback alone must never authorize a wait.
|
|
499
525
|
*/
|
|
500
526
|
export interface UsageLimitMarkResult {
|
|
501
527
|
switched: boolean;
|
|
502
528
|
retryAtMs?: number;
|
|
529
|
+
blockedUntilMs?: number;
|
|
530
|
+
priorBlockedUntilMs?: number;
|
|
531
|
+
priorBlockedUntilTimed?: boolean;
|
|
532
|
+
reportResetAtMs?: number;
|
|
503
533
|
}
|
|
504
534
|
export type ModelUsageHealthState = "healthy" | "reserve" | "depleted" | "unknown";
|
|
505
535
|
export interface ModelUsageAccountHealth {
|
|
@@ -1013,6 +1043,12 @@ export declare class AuthStorage {
|
|
|
1013
1043
|
*/
|
|
1014
1044
|
markUsageLimitReached(provider: string, sessionId: string | undefined, options?: {
|
|
1015
1045
|
retryAfterMs?: number;
|
|
1046
|
+
/**
|
|
1047
|
+
* Whether `retryAfterMs` came from provider-stated timing (a parsed
|
|
1048
|
+
* retry hint) rather than a heuristic/default guess. A report reset
|
|
1049
|
+
* extending the block counts as provider timing regardless.
|
|
1050
|
+
*/
|
|
1051
|
+
providerTimed?: boolean;
|
|
1016
1052
|
baseUrl?: string;
|
|
1017
1053
|
modelId?: string;
|
|
1018
1054
|
apiKey?: string;
|
|
@@ -87,6 +87,15 @@ export declare function isFastModeUnsupported(error: unknown): boolean;
|
|
|
87
87
|
* fail identically) nor surface as an auth failure.
|
|
88
88
|
*/
|
|
89
89
|
export declare function isClinePassSurfaceGateMessage(errorMessage: string | undefined): boolean;
|
|
90
|
+
/**
|
|
91
|
+
* GitHub Copilot 403s are plan/model-policy/org denials against a valid token —
|
|
92
|
+
* never a revoked credential (those arrive as 401). Wiping stored credentials
|
|
93
|
+
* on them hides the whole provider from `/model` and forces a pointless
|
|
94
|
+
* re-login, so credential-lifetime decisions must exempt them even though
|
|
95
|
+
* they still classify as `Flag.AuthFailed` (issue #11275). Mirrors the 403
|
|
96
|
+
* contract in `rewriteCopilotError` (`utils/http-inspector.ts`).
|
|
97
|
+
*/
|
|
98
|
+
export declare function isGitHubCopilotPolicyDenial(provider: string | undefined, status: number | undefined, errorMessage: string | undefined): boolean;
|
|
90
99
|
export declare function classifyMessage(message: {
|
|
91
100
|
api?: Api;
|
|
92
101
|
provider?: string;
|
|
@@ -223,7 +223,7 @@ type CodexToolPayload = {
|
|
|
223
223
|
/** @internal Exported for tests. */
|
|
224
224
|
export declare function convertOpenAICodexResponsesTools(tools: Tool[], model: Model<"openai-codex-responses">): CodexToolPayload[];
|
|
225
225
|
export declare class CodexWebSocketTransportError extends Error {
|
|
226
|
-
constructor(detail: string);
|
|
226
|
+
constructor(detail: string, options?: ErrorOptions);
|
|
227
227
|
}
|
|
228
228
|
declare class CodexProviderStreamError extends Error {
|
|
229
229
|
readonly retryable: boolean;
|
|
@@ -15,7 +15,7 @@ type GitHubCopilotLoginOptions = {
|
|
|
15
15
|
};
|
|
16
16
|
/**
|
|
17
17
|
* Refresh GitHub Copilot token.
|
|
18
|
-
* GitHub OAuth tokens from both the
|
|
18
|
+
* GitHub OAuth tokens from both the OpenCode app and the Copilot CLI app
|
|
19
19
|
* remain directly usable, so existing logins need no token exchange or migration.
|
|
20
20
|
*/
|
|
21
21
|
export declare function refreshGitHubCopilotToken(refreshToken: string, enterpriseDomain?: string, apiEndpoint?: string): OAuthCredentials;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxiraos/pi-ai",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.13",
|
|
4
4
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -116,16 +116,19 @@
|
|
|
116
116
|
"./*.js": "./src/*.ts"
|
|
117
117
|
},
|
|
118
118
|
"scripts": {
|
|
119
|
-
"check": "
|
|
119
|
+
"check": "oxlint . && oxfmt --check --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts' && bun run check:types",
|
|
120
120
|
"check:types": "tsgo -p tsconfig.json --noEmit",
|
|
121
|
-
"
|
|
121
|
+
"lint": "oxlint .",
|
|
122
|
+
"test": "bun test --parallel",
|
|
123
|
+
"fix": "oxlint --fix --fix-suggestions . && bun run fmt",
|
|
124
|
+
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
122
125
|
},
|
|
123
126
|
"dependencies": {
|
|
124
|
-
"@linxiraos/pi-omptype": "1.1.
|
|
125
|
-
"@linxiraos/pi-catalog": "1.1.
|
|
126
|
-
"@linxiraos/pi-natives": "1.1.
|
|
127
|
-
"@linxiraos/pi-utils": "1.1.
|
|
128
|
-
"@linxiraos/pi-wire": "1.1.
|
|
127
|
+
"@linxiraos/pi-omptype": "1.1.13",
|
|
128
|
+
"@linxiraos/pi-catalog": "1.1.13",
|
|
129
|
+
"@linxiraos/pi-natives": "1.1.13",
|
|
130
|
+
"@linxiraos/pi-utils": "1.1.13",
|
|
131
|
+
"@linxiraos/pi-wire": "1.1.13"
|
|
129
132
|
},
|
|
130
133
|
"devDependencies": {
|
|
131
134
|
"@types/bun": "^1.3.14"
|
package/src/auth-storage.ts
CHANGED
|
@@ -757,10 +757,40 @@ export { isDefinitiveOAuthFailure } from "./error/auth-classify";
|
|
|
757
757
|
* multi-hour) retry-after when it is sooner. `retryAtMs` is `undefined` when
|
|
758
758
|
* no sibling credentials exist at all, or when the session has no tracked
|
|
759
759
|
* credential to rotate away from.
|
|
760
|
+
*
|
|
761
|
+
* `blockedUntilMs` (epoch ms) is the just-blocked credential's own unblock
|
|
762
|
+
* deadline — the later of the caller's retry-after and any exhausted window
|
|
763
|
+
* the usage report reveals. Callers that wait the account out (instead of
|
|
764
|
+
* rotating) must sleep until this, not the error-text hint alone.
|
|
765
|
+
*
|
|
766
|
+
* `priorBlockedUntilMs` (epoch ms) is the live block deadline the map already
|
|
767
|
+
* stored for this credential before this call. The merged `blockedUntilMs`
|
|
768
|
+
* masks a pre-existing block shorter than this call's own heuristic
|
|
769
|
+
* fallback (`Math.max` in the mark), so callers that replace that heuristic
|
|
770
|
+
* with an authoritative report window must consult the prior deadline to
|
|
771
|
+
* keep honoring the earlier response's provider-stated block.
|
|
772
|
+
*
|
|
773
|
+
* `priorBlockedUntilTimed` is `true` when that prior deadline came from
|
|
774
|
+
* provider-stated timing (a parsed hint or usage-report reset) rather than
|
|
775
|
+
* another session's heuristic guess — only timed priors may extend a wait
|
|
776
|
+
* past an authoritative report window. Persisted blocks carry no provenance
|
|
777
|
+
* and count as untimed: a stale persisted heuristic must not outrank a
|
|
778
|
+
* fresh complete report (longer persisted deadlines still win through the
|
|
779
|
+
* merged `blockedUntilMs`).
|
|
780
|
+
*
|
|
781
|
+
* `reportResetAtMs` (epoch ms) is present only when the usage report is a
|
|
782
|
+
* complete authority for the wait: every exhausted window carries a future
|
|
783
|
+
* reset, so sleeping until the latest one can actually clear the account. A
|
|
784
|
+
* permanent cap alongside a timed window (or no report at all) leaves it
|
|
785
|
+
* unset, and the heuristic fallback alone must never authorize a wait.
|
|
760
786
|
*/
|
|
761
787
|
export interface UsageLimitMarkResult {
|
|
762
788
|
switched: boolean;
|
|
763
789
|
retryAtMs?: number;
|
|
790
|
+
blockedUntilMs?: number;
|
|
791
|
+
priorBlockedUntilMs?: number;
|
|
792
|
+
priorBlockedUntilTimed?: boolean;
|
|
793
|
+
reportResetAtMs?: number;
|
|
764
794
|
}
|
|
765
795
|
|
|
766
796
|
export type ModelUsageHealthState = "healthy" | "reserve" | "depleted" | "unknown";
|
|
@@ -1327,6 +1357,14 @@ export class AuthStorage {
|
|
|
1327
1357
|
#oauthBearerFingerprints: Map<string, Map<number, string[]>> = new Map();
|
|
1328
1358
|
/** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
|
|
1329
1359
|
#credentialBackoff: Map<string, Map<number, number>> = new Map();
|
|
1360
|
+
/**
|
|
1361
|
+
* Provenance of each in-memory block: `true` when the deadline came from
|
|
1362
|
+
* provider-stated timing (a parsed retry hint or a usage-report reset),
|
|
1363
|
+
* `false` when it is a heuristic/default guess. Mirrors
|
|
1364
|
+
* {@link AuthStorage.#credentialBackoff} longest-wins semantics, tracking
|
|
1365
|
+
* the flag of whichever deadline currently wins.
|
|
1366
|
+
*/
|
|
1367
|
+
#credentialBackoffProviderTimed: Map<string, Map<number, boolean>> = new Map();
|
|
1330
1368
|
/** Earliest time a freshly-set in-memory block may be cleared by live usage reconciliation. */
|
|
1331
1369
|
#credentialBackoffProbeAfter: Map<string, Map<number, number>> = new Map();
|
|
1332
1370
|
/**
|
|
@@ -1802,6 +1840,10 @@ export class AuthStorage {
|
|
|
1802
1840
|
if (backoffMap.size === 0) {
|
|
1803
1841
|
this.#credentialBackoff.delete(backoffKey);
|
|
1804
1842
|
}
|
|
1843
|
+
this.#credentialBackoffProviderTimed.get(backoffKey)?.delete(credentialIndex);
|
|
1844
|
+
if (this.#credentialBackoffProviderTimed.get(backoffKey)?.size === 0) {
|
|
1845
|
+
this.#credentialBackoffProviderTimed.delete(backoffKey);
|
|
1846
|
+
}
|
|
1805
1847
|
const probeAfterMap = this.#credentialBackoffProbeAfter.get(backoffKey);
|
|
1806
1848
|
probeAfterMap?.delete(credentialIndex);
|
|
1807
1849
|
if (probeAfterMap?.size === 0) this.#credentialBackoffProbeAfter.delete(backoffKey);
|
|
@@ -1908,13 +1950,48 @@ export class AuthStorage {
|
|
|
1908
1950
|
return this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex, blockScope) !== undefined;
|
|
1909
1951
|
}
|
|
1910
1952
|
|
|
1911
|
-
/**
|
|
1953
|
+
/**
|
|
1954
|
+
* Whether the in-memory block currently sitting at exactly `deadline` for
|
|
1955
|
+
* this credential was written with provider-stated timing. Mirrors the
|
|
1956
|
+
* scope enumeration of {@link AuthStorage.#getCredentialBlockedUntil}.
|
|
1957
|
+
* A deadline with no matching in-memory entry came from the persisted
|
|
1958
|
+
* store, which carries no provenance — a stale persisted heuristic guess
|
|
1959
|
+
* (pre-restart hintless response) must not outrank a fresh complete usage
|
|
1960
|
+
* report, so persisted-only deadlines count as untimed. Persisted
|
|
1961
|
+
* deadlines longer than this call's own request still win through the
|
|
1962
|
+
* merged `blockedUntilMs` comparison, which needs no provenance.
|
|
1963
|
+
*/
|
|
1964
|
+
#isProviderTimedBlock(
|
|
1965
|
+
providerKey: string,
|
|
1966
|
+
blockScopeOrScopes: string | readonly string[] | undefined,
|
|
1967
|
+
credentialIndex: number,
|
|
1968
|
+
deadline: number,
|
|
1969
|
+
): boolean {
|
|
1970
|
+
const scopes = (
|
|
1971
|
+
typeof blockScopeOrScopes === "string" ? [blockScopeOrScopes] : (blockScopeOrScopes ?? [])
|
|
1972
|
+
).filter(scope => scope.length > 0);
|
|
1973
|
+
for (const key of [providerKey, ...scopes.map(scope => this.#toScopedBackoffKey(providerKey, scope))]) {
|
|
1974
|
+
const entry = this.#credentialBackoff.get(key)?.get(credentialIndex);
|
|
1975
|
+
if (entry === undefined || entry !== deadline) continue;
|
|
1976
|
+
if (this.#credentialBackoffProviderTimed.get(key)?.get(credentialIndex) === true) return true;
|
|
1977
|
+
}
|
|
1978
|
+
return false;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/**
|
|
1982
|
+
* Marks a credential as blocked until the specified time. `providerTimed`
|
|
1983
|
+
* records whether the requested deadline comes from provider-stated
|
|
1984
|
+
* timing (a parsed retry hint or a usage-report reset) rather than a
|
|
1985
|
+
* heuristic/default guess; the stored block keeps the provenance of
|
|
1986
|
+
* whichever deadline wins the longest-wins merge.
|
|
1987
|
+
*/
|
|
1912
1988
|
#markCredentialBlocked(
|
|
1913
1989
|
provider: string,
|
|
1914
1990
|
providerKey: string,
|
|
1915
1991
|
credentialIndex: number,
|
|
1916
1992
|
blockedUntilMs: number,
|
|
1917
1993
|
blockScope: string | undefined = undefined,
|
|
1994
|
+
providerTimed = false,
|
|
1918
1995
|
): void {
|
|
1919
1996
|
const backoffKey = this.#toScopedBackoffKey(providerKey, blockScope);
|
|
1920
1997
|
const backoffMap = this.#credentialBackoff.get(backoffKey) ?? new Map<number, number>();
|
|
@@ -1922,6 +1999,16 @@ export class AuthStorage {
|
|
|
1922
1999
|
const nextBlockedUntil = Math.max(existing, blockedUntilMs);
|
|
1923
2000
|
backoffMap.set(credentialIndex, nextBlockedUntil);
|
|
1924
2001
|
this.#credentialBackoff.set(backoffKey, backoffMap);
|
|
2002
|
+
const timedMap = this.#credentialBackoffProviderTimed.get(backoffKey) ?? new Map<number, boolean>();
|
|
2003
|
+
const existingTimed = timedMap.get(credentialIndex) ?? false;
|
|
2004
|
+
const nextTimed =
|
|
2005
|
+
blockedUntilMs > existing
|
|
2006
|
+
? providerTimed
|
|
2007
|
+
: blockedUntilMs === existing
|
|
2008
|
+
? existingTimed || providerTimed
|
|
2009
|
+
: existingTimed;
|
|
2010
|
+
timedMap.set(credentialIndex, nextTimed);
|
|
2011
|
+
this.#credentialBackoffProviderTimed.set(backoffKey, timedMap);
|
|
1925
2012
|
const probeAfterMap = this.#credentialBackoffProbeAfter.get(backoffKey) ?? new Map<number, number>();
|
|
1926
2013
|
probeAfterMap.set(credentialIndex, Math.min(nextBlockedUntil, Date.now() + USAGE_REPORT_TTL_MS));
|
|
1927
2014
|
this.#credentialBackoffProbeAfter.set(backoffKey, probeAfterMap);
|
|
@@ -2207,6 +2294,7 @@ export class AuthStorage {
|
|
|
2207
2294
|
selection.index,
|
|
2208
2295
|
blockedUntil,
|
|
2209
2296
|
args.blockScope,
|
|
2297
|
+
resetAtMs !== undefined,
|
|
2210
2298
|
);
|
|
2211
2299
|
blocked = true;
|
|
2212
2300
|
}
|
|
@@ -2312,6 +2400,11 @@ export class AuthStorage {
|
|
|
2312
2400
|
this.#credentialBackoff.delete(key);
|
|
2313
2401
|
}
|
|
2314
2402
|
}
|
|
2403
|
+
for (const key of this.#credentialBackoffProviderTimed.keys()) {
|
|
2404
|
+
if (key.startsWith(`${provider}:`)) {
|
|
2405
|
+
this.#credentialBackoffProviderTimed.delete(key);
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2315
2408
|
}
|
|
2316
2409
|
|
|
2317
2410
|
/** Updates credential at index in-place (used for OAuth token refresh) */
|
|
@@ -4603,11 +4696,43 @@ export class AuthStorage {
|
|
|
4603
4696
|
targetIndex: number,
|
|
4604
4697
|
blockedUntil: number,
|
|
4605
4698
|
routing: CredentialBlockRouting,
|
|
4699
|
+
providerTimed: boolean,
|
|
4606
4700
|
): UsageLimitMarkResult {
|
|
4701
|
+
// Snapshot the live block deadline BEFORE this call's mark: the merged
|
|
4702
|
+
// value below masks a pre-existing block shorter than this call's own
|
|
4703
|
+
// request (longest-wins), and recovery that replaces this call's
|
|
4704
|
+
// heuristic fallback with an authoritative report window still needs
|
|
4705
|
+
// the prior response's provider-stated deadline.
|
|
4706
|
+
const priorBlockedUntilMs =
|
|
4707
|
+
targetIndex >= 0
|
|
4708
|
+
? this.#getCredentialBlockedUntil(provider, routing.providerKey, targetIndex, routing.blockScope)
|
|
4709
|
+
: undefined;
|
|
4710
|
+
const priorBlockedUntilTimed =
|
|
4711
|
+
targetIndex >= 0 && priorBlockedUntilMs !== undefined
|
|
4712
|
+
? this.#isProviderTimedBlock(routing.providerKey, routing.blockScope, targetIndex, priorBlockedUntilMs)
|
|
4713
|
+
: false;
|
|
4607
4714
|
if (targetIndex >= 0) {
|
|
4608
|
-
this.#markCredentialBlocked(
|
|
4715
|
+
this.#markCredentialBlocked(
|
|
4716
|
+
provider,
|
|
4717
|
+
routing.providerKey,
|
|
4718
|
+
targetIndex,
|
|
4719
|
+
blockedUntil,
|
|
4720
|
+
routing.blockScope,
|
|
4721
|
+
providerTimed,
|
|
4722
|
+
);
|
|
4609
4723
|
}
|
|
4610
4724
|
|
|
4725
|
+
// Report the merged deadline the block map actually stores, not this
|
|
4726
|
+
// call's input: a sibling session may have established a longer block
|
|
4727
|
+
// for the same credential (out-of-order usage-limit responses), and
|
|
4728
|
+
// #markCredentialBlocked keeps the longest. Waiting on the shorter
|
|
4729
|
+
// value would retry before the credential is actually usable.
|
|
4730
|
+
const mergedBlockedUntil =
|
|
4731
|
+
targetIndex >= 0
|
|
4732
|
+
? (this.#getCredentialBlockedUntil(provider, routing.providerKey, targetIndex, routing.blockScope) ??
|
|
4733
|
+
blockedUntil)
|
|
4734
|
+
: blockedUntil;
|
|
4735
|
+
|
|
4611
4736
|
const remainingCredentials = this.#getCredentialsForProvider(provider)
|
|
4612
4737
|
.map((credential, index) => ({ credential, index }))
|
|
4613
4738
|
.filter(
|
|
@@ -4625,10 +4750,22 @@ export class AuthStorage {
|
|
|
4625
4750
|
candidate.index,
|
|
4626
4751
|
routing.siblingBlockScopes,
|
|
4627
4752
|
);
|
|
4628
|
-
if (candidateBlockedUntil === undefined)
|
|
4753
|
+
if (candidateBlockedUntil === undefined)
|
|
4754
|
+
return {
|
|
4755
|
+
switched: true,
|
|
4756
|
+
blockedUntilMs: mergedBlockedUntil,
|
|
4757
|
+
priorBlockedUntilMs,
|
|
4758
|
+
priorBlockedUntilTimed,
|
|
4759
|
+
};
|
|
4629
4760
|
if (retryAtMs === undefined || candidateBlockedUntil < retryAtMs) retryAtMs = candidateBlockedUntil;
|
|
4630
4761
|
}
|
|
4631
|
-
return {
|
|
4762
|
+
return {
|
|
4763
|
+
switched: false,
|
|
4764
|
+
retryAtMs,
|
|
4765
|
+
blockedUntilMs: mergedBlockedUntil,
|
|
4766
|
+
priorBlockedUntilMs,
|
|
4767
|
+
priorBlockedUntilTimed,
|
|
4768
|
+
};
|
|
4632
4769
|
}
|
|
4633
4770
|
|
|
4634
4771
|
/**
|
|
@@ -4643,6 +4780,12 @@ export class AuthStorage {
|
|
|
4643
4780
|
sessionId: string | undefined,
|
|
4644
4781
|
options?: {
|
|
4645
4782
|
retryAfterMs?: number;
|
|
4783
|
+
/**
|
|
4784
|
+
* Whether `retryAfterMs` came from provider-stated timing (a parsed
|
|
4785
|
+
* retry hint) rather than a heuristic/default guess. A report reset
|
|
4786
|
+
* extending the block counts as provider timing regardless.
|
|
4787
|
+
*/
|
|
4788
|
+
providerTimed?: boolean;
|
|
4646
4789
|
baseUrl?: string;
|
|
4647
4790
|
modelId?: string;
|
|
4648
4791
|
apiKey?: string;
|
|
@@ -4676,7 +4819,17 @@ export class AuthStorage {
|
|
|
4676
4819
|
const routing = this.#credentialBlockRouting(provider, credentialType, options?.modelId);
|
|
4677
4820
|
const now = Date.now();
|
|
4678
4821
|
let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
|
|
4679
|
-
|
|
4822
|
+
// Heuristic/default fallbacks are guesses; provider-stated hints and
|
|
4823
|
+
// report-derived extensions are timed.
|
|
4824
|
+
let providerTimed = options?.providerTimed === true;
|
|
4825
|
+
|
|
4826
|
+
// Epoch ms of the usage-report-derived reset. Authoritative only when
|
|
4827
|
+
// EVERY exhausted window carries a future reset: a permanent cap
|
|
4828
|
+
// alongside a timed window must not authorize a wait that can never
|
|
4829
|
+
// clear it, and a bare heuristic must not sleep alone. Set
|
|
4830
|
+
// independently of whether the report extends the hint so a shorter
|
|
4831
|
+
// authoritative window still counts as provider timing.
|
|
4832
|
+
let reportResetAtMs: number | undefined;
|
|
4680
4833
|
if (target && routing.strategy) {
|
|
4681
4834
|
const report = await raceUsageWithSignal(
|
|
4682
4835
|
this.#getUsageReport(provider, target.credential, options),
|
|
@@ -4688,6 +4841,15 @@ export class AuthStorage {
|
|
|
4688
4841
|
const resetAtMs = this.#getUsageResetAtMs(scopedLimits, Date.now());
|
|
4689
4842
|
if (resetAtMs && resetAtMs > blockedUntil) {
|
|
4690
4843
|
blockedUntil = resetAtMs;
|
|
4844
|
+
providerTimed = true;
|
|
4845
|
+
}
|
|
4846
|
+
const nowMs = Date.now();
|
|
4847
|
+
const exhaustedLimits = scopedLimits.filter(limit => this.#isUsageLimitExhausted(limit));
|
|
4848
|
+
const futureResets = exhaustedLimits
|
|
4849
|
+
.map(limit => this.#resolveWindowResetAt(limit.window))
|
|
4850
|
+
.filter((reset): reset is number => reset !== undefined && reset > nowMs);
|
|
4851
|
+
if (exhaustedLimits.length > 0 && futureResets.length === exhaustedLimits.length) {
|
|
4852
|
+
reportResetAtMs = Math.max(...futureResets);
|
|
4691
4853
|
}
|
|
4692
4854
|
}
|
|
4693
4855
|
}
|
|
@@ -4699,7 +4861,15 @@ export class AuthStorage {
|
|
|
4699
4861
|
const targetIndex = this.#getStoredCredentials(provider).findIndex(
|
|
4700
4862
|
entry => entry.id === targetCredentialId && entry.credential.type === credentialType,
|
|
4701
4863
|
);
|
|
4702
|
-
|
|
4864
|
+
const rotation = this.#blockCredentialForRotation(
|
|
4865
|
+
provider,
|
|
4866
|
+
credentialType,
|
|
4867
|
+
targetIndex,
|
|
4868
|
+
blockedUntil,
|
|
4869
|
+
routing,
|
|
4870
|
+
providerTimed,
|
|
4871
|
+
);
|
|
4872
|
+
return reportResetAtMs === undefined ? rotation : { ...rotation, reportResetAtMs };
|
|
4703
4873
|
}
|
|
4704
4874
|
|
|
4705
4875
|
#resolveWindowResetAt(window: UsageLimit["window"]): number | undefined {
|
|
@@ -4909,6 +5079,7 @@ export class AuthStorage {
|
|
|
4909
5079
|
selection.index,
|
|
4910
5080
|
blockedUntil,
|
|
4911
5081
|
args.blockScope,
|
|
5082
|
+
resetAtMs !== undefined,
|
|
4912
5083
|
);
|
|
4913
5084
|
blocked = true;
|
|
4914
5085
|
}
|
|
@@ -5566,6 +5737,7 @@ export class AuthStorage {
|
|
|
5566
5737
|
selection.index,
|
|
5567
5738
|
resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs,
|
|
5568
5739
|
blockScope,
|
|
5740
|
+
resetAtMs !== undefined,
|
|
5569
5741
|
);
|
|
5570
5742
|
return undefined;
|
|
5571
5743
|
}
|
|
@@ -5646,6 +5818,7 @@ export class AuthStorage {
|
|
|
5646
5818
|
selection.index,
|
|
5647
5819
|
resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs,
|
|
5648
5820
|
blockScope,
|
|
5821
|
+
resetAtMs !== undefined,
|
|
5649
5822
|
);
|
|
5650
5823
|
return undefined;
|
|
5651
5824
|
}
|
|
@@ -6339,6 +6512,11 @@ export class AuthStorage {
|
|
|
6339
6512
|
backoffMap.delete(index);
|
|
6340
6513
|
if (backoffMap.size === 0) this.#credentialBackoff.delete(key);
|
|
6341
6514
|
}
|
|
6515
|
+
for (const [key, timedMap] of this.#credentialBackoffProviderTimed) {
|
|
6516
|
+
if (key !== providerKey && !key.startsWith(scopedPrefix)) continue;
|
|
6517
|
+
timedMap.delete(index);
|
|
6518
|
+
if (timedMap.size === 0) this.#credentialBackoffProviderTimed.delete(key);
|
|
6519
|
+
}
|
|
6342
6520
|
for (const [key, probeAfterMap] of this.#credentialBackoffProbeAfter) {
|
|
6343
6521
|
if (key !== providerKey && !key.startsWith(scopedPrefix)) continue;
|
|
6344
6522
|
probeAfterMap.delete(index);
|
|
@@ -6400,6 +6578,9 @@ export class AuthStorage {
|
|
|
6400
6578
|
const backoffMap = this.#credentialBackoff.get(key);
|
|
6401
6579
|
backoffMap?.delete(credentialIndex);
|
|
6402
6580
|
if (backoffMap?.size === 0) this.#credentialBackoff.delete(key);
|
|
6581
|
+
const timedMap = this.#credentialBackoffProviderTimed.get(key);
|
|
6582
|
+
timedMap?.delete(credentialIndex);
|
|
6583
|
+
if (timedMap?.size === 0) this.#credentialBackoffProviderTimed.delete(key);
|
|
6403
6584
|
const probeAfterMap = this.#credentialBackoffProbeAfter.get(key);
|
|
6404
6585
|
probeAfterMap?.delete(credentialIndex);
|
|
6405
6586
|
if (probeAfterMap?.size === 0) this.#credentialBackoffProbeAfter.delete(key);
|
|
@@ -6691,6 +6872,7 @@ export class AuthStorage {
|
|
|
6691
6872
|
return (
|
|
6692
6873
|
await this.markUsageLimitReached(provider, sessionId, {
|
|
6693
6874
|
retryAfterMs,
|
|
6875
|
+
providerTimed: retryAfterMs !== undefined,
|
|
6694
6876
|
modelId: options?.modelId,
|
|
6695
6877
|
apiKey: options?.apiKey,
|
|
6696
6878
|
credentialId: options?.credentialId,
|
|
@@ -6730,6 +6912,7 @@ export class AuthStorage {
|
|
|
6730
6912
|
sessionCredential.index,
|
|
6731
6913
|
Date.now() + AuthStorage.#defaultBackoffMs,
|
|
6732
6914
|
routing,
|
|
6915
|
+
false,
|
|
6733
6916
|
).switched;
|
|
6734
6917
|
}
|
|
6735
6918
|
|
package/src/dialect/deepseek.ts
CHANGED
|
@@ -608,7 +608,7 @@ function renderThinking(text: string): string {
|
|
|
608
608
|
function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string {
|
|
609
609
|
if (messages.length === 0) return "";
|
|
610
610
|
let out = DEEPSEEK_BOS;
|
|
611
|
-
for (let i = 0; i < messages.length;
|
|
611
|
+
for (let i = 0; i < messages.length;) {
|
|
612
612
|
const message = messages[i]!;
|
|
613
613
|
if (message.role === "assistant") {
|
|
614
614
|
const parts = assistantTranscriptParts(message);
|
package/src/dialect/gemini.ts
CHANGED
|
@@ -533,7 +533,7 @@ function renderTranscript(messages: readonly Message[], options: DialectRenderOp
|
|
|
533
533
|
if (messages.length === 0) return "";
|
|
534
534
|
let out = "<bos>";
|
|
535
535
|
let pendingUserPreamble = "";
|
|
536
|
-
for (let i = 0; i < messages.length;
|
|
536
|
+
for (let i = 0; i < messages.length;) {
|
|
537
537
|
const message = messages[i]!;
|
|
538
538
|
if (message.role === "developer") {
|
|
539
539
|
pendingUserPreamble = joinUserBodies(pendingUserPreamble, messageContentText(message.content));
|
package/src/dialect/gemma.ts
CHANGED
|
@@ -320,7 +320,7 @@ function renderThinking(text: string): string {
|
|
|
320
320
|
function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string {
|
|
321
321
|
if (messages.length === 0) return "";
|
|
322
322
|
let out = "<bos>";
|
|
323
|
-
for (let i = 0; i < messages.length;
|
|
323
|
+
for (let i = 0; i < messages.length;) {
|
|
324
324
|
const message = messages[i]!;
|
|
325
325
|
if (message.role === "assistant") {
|
|
326
326
|
const parts = assistantTranscriptParts(message);
|
package/src/dialect/glm.ts
CHANGED
|
@@ -543,7 +543,7 @@ function renderThinking(text: string): string {
|
|
|
543
543
|
function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string {
|
|
544
544
|
if (messages.length === 0) return "";
|
|
545
545
|
let out = "[gMASK]<sop>";
|
|
546
|
-
for (let i = 0; i < messages.length;
|
|
546
|
+
for (let i = 0; i < messages.length;) {
|
|
547
547
|
const message = messages[i]!;
|
|
548
548
|
if (message.role === "assistant") {
|
|
549
549
|
const parts = assistantTranscriptParts(message);
|
package/src/dialect/harmony.ts
CHANGED
|
@@ -298,7 +298,7 @@ function renderThinking(text: string): string {
|
|
|
298
298
|
|
|
299
299
|
function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string {
|
|
300
300
|
let out = "";
|
|
301
|
-
for (let i = 0; i < messages.length;
|
|
301
|
+
for (let i = 0; i < messages.length;) {
|
|
302
302
|
const message = messages[i]!;
|
|
303
303
|
if (message.role === "assistant") {
|
|
304
304
|
const parts = assistantTranscriptParts(message);
|
package/src/dialect/kimi.ts
CHANGED
|
@@ -291,7 +291,7 @@ function renderThinking(text: string): string {
|
|
|
291
291
|
|
|
292
292
|
function renderTranscript(messages: readonly Message[], _options?: DialectRenderOptions): string {
|
|
293
293
|
let out = "";
|
|
294
|
-
for (let i = 0; i < messages.length;
|
|
294
|
+
for (let i = 0; i < messages.length;) {
|
|
295
295
|
const message = messages[i]!;
|
|
296
296
|
if (message.role === "assistant") {
|
|
297
297
|
const parts = assistantTranscriptParts(message);
|
package/src/dialect/rendering.ts
CHANGED
|
@@ -111,7 +111,7 @@ export function renderChatMlTranscript(
|
|
|
111
111
|
): string {
|
|
112
112
|
if (messages.length === 0) return "";
|
|
113
113
|
let out = config.bos ?? "";
|
|
114
|
-
for (let i = 0; i < messages.length;
|
|
114
|
+
for (let i = 0; i < messages.length;) {
|
|
115
115
|
const message = messages[i]!;
|
|
116
116
|
if (message.role === "assistant") {
|
|
117
117
|
const parts = assistantTranscriptParts(message);
|
|
@@ -141,7 +141,7 @@ export function renderLegacyTextTranscript(
|
|
|
141
141
|
config: LegacyTextTranscriptConfig,
|
|
142
142
|
): string {
|
|
143
143
|
let out = "";
|
|
144
|
-
for (let i = 0; i < messages.length;
|
|
144
|
+
for (let i = 0; i < messages.length;) {
|
|
145
145
|
const message = messages[i]!;
|
|
146
146
|
if (message.role === "assistant") {
|
|
147
147
|
const parts = assistantTranscriptParts(message);
|
package/src/dialect/thinking.ts
CHANGED
|
@@ -309,7 +309,7 @@ function trailingLineIndent(text: string, prior: number): number {
|
|
|
309
309
|
* extend it into a non-fence line).
|
|
310
310
|
*/
|
|
311
311
|
function findFenceCloseEnd(buffer: string, ticks: number, final: boolean): number {
|
|
312
|
-
for (let start = 0; start <= buffer.length;
|
|
312
|
+
for (let start = 0; start <= buffer.length;) {
|
|
313
313
|
const nl = buffer.indexOf("\n", start);
|
|
314
314
|
const terminated = nl !== -1;
|
|
315
315
|
const line = buffer.slice(start, terminated ? nl : buffer.length).trim();
|
package/src/error/flags.ts
CHANGED
|
@@ -157,7 +157,7 @@ const TRANSIENT_ENVELOPE_PATTERN = /anthropic stream envelope error:/i;
|
|
|
157
157
|
const TRANSIENT_ENVELOPE_TRUNCATION_PATTERN = /before message_(?:start|stop)/i;
|
|
158
158
|
export const STREAM_READ_ERROR_PATTERN = /stream[_ -]?read[_ -]?error/i;
|
|
159
159
|
export const TRANSIENT_TRANSPORT_PATTERN =
|
|
160
|
-
/\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|\b(?:429|500|502|503|504)\b|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|nghttp2_(?:internal_error|refused_stream)|stream closed with error code nghttp2_(?:internal_error|refused_stream)|malformed.?function.?call/i;
|
|
160
|
+
/\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|auth-gateway\s+5\d{2}(?=[:\s]|$)|\b(?:429|500|502|503|504)\b|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|nghttp2_(?:internal_error|refused_stream)|stream closed with error code nghttp2_(?:internal_error|refused_stream)|malformed.?function.?call/i;
|
|
161
161
|
const AUTH_FAILURE_PATTERN =
|
|
162
162
|
/\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
|
|
163
163
|
const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
|
|
@@ -694,6 +694,26 @@ export function isClinePassSurfaceGateMessage(errorMessage: string | undefined):
|
|
|
694
694
|
return errorMessage !== undefined && CLINE_PASS_SURFACE_GATE_PATTERN.test(errorMessage);
|
|
695
695
|
}
|
|
696
696
|
|
|
697
|
+
const GITHUB_COPILOT_POLICY_DENIAL_PATTERN = /GitHub Copilot access denied \(HTTP 403\)/;
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* GitHub Copilot 403s are plan/model-policy/org denials against a valid token —
|
|
701
|
+
* never a revoked credential (those arrive as 401). Wiping stored credentials
|
|
702
|
+
* on them hides the whole provider from `/model` and forces a pointless
|
|
703
|
+
* re-login, so credential-lifetime decisions must exempt them even though
|
|
704
|
+
* they still classify as `Flag.AuthFailed` (issue #11275). Mirrors the 403
|
|
705
|
+
* contract in `rewriteCopilotError` (`utils/http-inspector.ts`).
|
|
706
|
+
*/
|
|
707
|
+
export function isGitHubCopilotPolicyDenial(
|
|
708
|
+
provider: string | undefined,
|
|
709
|
+
status: number | undefined,
|
|
710
|
+
errorMessage: string | undefined,
|
|
711
|
+
): boolean {
|
|
712
|
+
if (provider !== "github-copilot") return false;
|
|
713
|
+
if (status === 403) return true;
|
|
714
|
+
return errorMessage !== undefined && GITHUB_COPILOT_POLICY_DENIAL_PATTERN.test(errorMessage);
|
|
715
|
+
}
|
|
716
|
+
|
|
697
717
|
export function classifyMessage(message: {
|
|
698
718
|
api?: Api;
|
|
699
719
|
provider?: string;
|
|
@@ -5005,12 +5005,10 @@ function buildAnthropicBaseToolInputSchema(tool: Tool): Record<string, unknown>
|
|
|
5005
5005
|
}
|
|
5006
5006
|
|
|
5007
5007
|
function buildAnthropicToolSchemaPlans(tools: Tool[], disableStrictTools = false): AnthropicToolSchemaPlan[] {
|
|
5008
|
-
const plans = tools.map(
|
|
5009
|
-
(tool)
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
}),
|
|
5013
|
-
);
|
|
5008
|
+
const plans = tools.map((tool): AnthropicToolSchemaPlan => ({
|
|
5009
|
+
inputSchema: buildAnthropicBaseToolInputSchema(tool) as AnthropicToolInputSchema,
|
|
5010
|
+
strict: false,
|
|
5011
|
+
}));
|
|
5014
5012
|
if (NO_STRICT || disableStrictTools) return plans;
|
|
5015
5013
|
|
|
5016
5014
|
const candidateIndexes = tools.flatMap((tool, index) => {
|
|
@@ -915,12 +915,12 @@ function markGitLabDuoWorkflowSettingsEnsured(apiKey: string, baseUrl: string, c
|
|
|
915
915
|
function hasGitLabDuoWorkflowExplicitNamespace(options: GitLabDuoWorkflowOptions): boolean {
|
|
916
916
|
return Boolean(
|
|
917
917
|
nonEmptyString(options.rootNamespaceId) ??
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
918
|
+
nonEmptyString(options.namespaceId) ??
|
|
919
|
+
nonEmptyString(Bun.env.GITLAB_DUO_NAMESPACE_ID) ??
|
|
920
|
+
nonEmptyString(options.projectId) ??
|
|
921
|
+
nonEmptyString(options.projectPath) ??
|
|
922
|
+
nonEmptyString(Bun.env.GITLAB_DUO_PROJECT_ID) ??
|
|
923
|
+
nonEmptyString(Bun.env.GITLAB_DUO_PROJECT_PATH),
|
|
924
924
|
);
|
|
925
925
|
}
|
|
926
926
|
|
|
@@ -1846,12 +1846,10 @@ function defaultGitLabDuoWorkflowWebSocketFactory(
|
|
|
1846
1846
|
url: string,
|
|
1847
1847
|
options: GitLabDuoWorkflowWebSocketFactoryOptions,
|
|
1848
1848
|
): GitLabDuoWorkflowWebSocketLike {
|
|
1849
|
-
return new (
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
) => GitLabDuoWorkflowWebSocketLike
|
|
1854
|
-
)(url, { headers: options.headers });
|
|
1849
|
+
return new (WebSocket as unknown as new (
|
|
1850
|
+
url: string,
|
|
1851
|
+
options: Bun.WebSocketOptions,
|
|
1852
|
+
) => GitLabDuoWorkflowWebSocketLike)(url, { headers: options.headers });
|
|
1855
1853
|
}
|
|
1856
1854
|
|
|
1857
1855
|
export function runGitLabDuoWorkflowSocket(
|
|
@@ -2147,6 +2147,17 @@ class CodexStreamProcessor {
|
|
|
2147
2147
|
firstTokenTime = this.#handleStreamEvent(rawEvent, firstTokenTime);
|
|
2148
2148
|
if (this.runtime.sawTerminalEvent) break;
|
|
2149
2149
|
}
|
|
2150
|
+
if (!this.runtime.sawTerminalEvent) {
|
|
2151
|
+
CODEX_DEBUG &&
|
|
2152
|
+
logger.debug("[codex] codex stream ended unexpectedly", {
|
|
2153
|
+
transport: this.runtime.transport,
|
|
2154
|
+
terminalEventSeen: false,
|
|
2155
|
+
unexpectedStreamEnd: true,
|
|
2156
|
+
sentTurnStateHeader: Boolean(this.requestContext.turnState.value),
|
|
2157
|
+
sentModelsEtagHeader: Boolean(this.requestContext.websocketState?.modelsEtag),
|
|
2158
|
+
});
|
|
2159
|
+
throw new CodexProviderStreamError("Codex stream ended before terminal completion event", true);
|
|
2160
|
+
}
|
|
2150
2161
|
return { firstTokenTime };
|
|
2151
2162
|
} catch (error) {
|
|
2152
2163
|
const recovered = await this.#recoverStreamError(error);
|
|
@@ -2952,21 +2963,6 @@ class CodexStreamProcessor {
|
|
|
2952
2963
|
if (this.options?.signal?.aborted) {
|
|
2953
2964
|
throw new AIError.AbortError();
|
|
2954
2965
|
}
|
|
2955
|
-
if (!this.runtime.sawTerminalEvent) {
|
|
2956
|
-
if (this.requestContext.websocketState) {
|
|
2957
|
-
resetCodexWebSocketAppendState(this.requestContext.websocketState);
|
|
2958
|
-
this.requestContext.websocketState.modelsEtag = undefined;
|
|
2959
|
-
}
|
|
2960
|
-
CODEX_DEBUG &&
|
|
2961
|
-
logger.debug("[codex] codex stream ended unexpectedly", {
|
|
2962
|
-
transport: this.runtime.transport,
|
|
2963
|
-
terminalEventSeen: this.runtime.sawTerminalEvent,
|
|
2964
|
-
unexpectedStreamEnd: true,
|
|
2965
|
-
sentTurnStateHeader: Boolean(this.requestContext.turnState.value),
|
|
2966
|
-
sentModelsEtagHeader: Boolean(this.requestContext.websocketState?.modelsEtag),
|
|
2967
|
-
});
|
|
2968
|
-
throw new CodexProviderStreamError("Codex stream ended before terminal completion event", false);
|
|
2969
|
-
}
|
|
2970
2966
|
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
|
2971
2967
|
throw new CodexProviderStreamError("Codex response failed", false);
|
|
2972
2968
|
}
|
|
@@ -3734,12 +3730,12 @@ class CodexWebSocketConnection {
|
|
|
3734
3730
|
if (signal) signal.removeEventListener("abort", onAbort);
|
|
3735
3731
|
};
|
|
3736
3732
|
const onAbort = () => {
|
|
3737
|
-
this.close("aborted");
|
|
3738
3733
|
if (!settled) {
|
|
3739
3734
|
settled = true;
|
|
3740
3735
|
clearPending();
|
|
3741
|
-
reject(new CodexWebSocketTransportError(`request was aborted
|
|
3736
|
+
reject(new CodexWebSocketTransportError(`request was aborted`, { cause: signal?.reason }));
|
|
3742
3737
|
}
|
|
3738
|
+
this.close("aborted");
|
|
3743
3739
|
};
|
|
3744
3740
|
if (signal) {
|
|
3745
3741
|
if (signal.aborted) {
|
|
@@ -3844,7 +3840,7 @@ class CodexWebSocketConnection {
|
|
|
3844
3840
|
throw new CodexWebSocketTransportError(`websocket request already in progress`);
|
|
3845
3841
|
}
|
|
3846
3842
|
if (signal?.aborted) {
|
|
3847
|
-
throw new CodexWebSocketTransportError(`request was aborted
|
|
3843
|
+
throw new CodexWebSocketTransportError(`request was aborted`, { cause: signal.reason });
|
|
3848
3844
|
}
|
|
3849
3845
|
this.#activeRequest = true;
|
|
3850
3846
|
this.#streamObserver = onSseEvent;
|
|
@@ -3860,8 +3856,9 @@ class CodexWebSocketConnection {
|
|
|
3860
3856
|
// the death signal instead of writing into a dead socket.
|
|
3861
3857
|
this.#dropStaleFrames();
|
|
3862
3858
|
const onAbort = () => {
|
|
3859
|
+
this.#push(new CodexWebSocketTransportError(`request was aborted`, { cause: signal?.reason }));
|
|
3860
|
+
// Closing can synchronously enqueue a generic onclose error.
|
|
3863
3861
|
this.close("aborted");
|
|
3864
|
-
this.#push(new CodexWebSocketTransportError(`request was aborted`));
|
|
3865
3862
|
};
|
|
3866
3863
|
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
3867
3864
|
|
|
@@ -3882,8 +3879,15 @@ class CodexWebSocketConnection {
|
|
|
3882
3879
|
const requestPayload = JSON.stringify(request);
|
|
3883
3880
|
notifyCodexWebSocketOutbound(onSseEvent, request, requestPayload);
|
|
3884
3881
|
// Re-check liveness: the debug-session await above can outlive the socket.
|
|
3882
|
+
// Preserve the abort cause: onAbort already queued a caused error, but this
|
|
3883
|
+
// throw would otherwise mask it before #nextMessage() drains the queue.
|
|
3885
3884
|
const socket = this.#socket;
|
|
3886
3885
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
3886
|
+
if (signal?.aborted) {
|
|
3887
|
+
throw new CodexWebSocketTransportError(`websocket connection is unavailable`, {
|
|
3888
|
+
cause: signal.reason,
|
|
3889
|
+
});
|
|
3890
|
+
}
|
|
3887
3891
|
throw new CodexWebSocketTransportError(`websocket connection is unavailable`);
|
|
3888
3892
|
}
|
|
3889
3893
|
try {
|
|
@@ -4752,8 +4756,8 @@ export function convertOpenAICodexResponsesTools(
|
|
|
4752
4756
|
}
|
|
4753
4757
|
|
|
4754
4758
|
export class CodexWebSocketTransportError extends Error {
|
|
4755
|
-
constructor(detail: string) {
|
|
4756
|
-
super(`${CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX}: ${detail}
|
|
4759
|
+
constructor(detail: string, options?: ErrorOptions) {
|
|
4760
|
+
super(`${CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX}: ${detail}`, options);
|
|
4757
4761
|
this.name = "CodexWebSocketTransportError";
|
|
4758
4762
|
}
|
|
4759
4763
|
}
|
|
@@ -199,9 +199,7 @@ function isRetryableOpenAIResponsesStreamFailure(error: unknown): boolean {
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
interface OpenAIResponsesProviderSessionState
|
|
202
|
-
extends ProviderSessionState,
|
|
203
|
-
OpenAIStrictToolsState,
|
|
204
|
-
OpenAIReasoningEffortFallbackState {
|
|
202
|
+
extends ProviderSessionState, OpenAIStrictToolsState, OpenAIReasoningEffortFallbackState {
|
|
205
203
|
nativeHistoryReplayWarmed: boolean;
|
|
206
204
|
/** Stateful `previous_response_id` chain baselines, keyed by baseUrl/model/session. */
|
|
207
205
|
chains: Map<string, OpenAIResponsesChainState>;
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GitHub Copilot OAuth flow
|
|
2
|
+
* GitHub Copilot OAuth flow (OpenCode OAuth app, minimal read:user grant).
|
|
3
|
+
*
|
|
4
|
+
* The device flow intentionally uses the OpenCode app identity rather than
|
|
5
|
+
* the official Copilot CLI app: GitHub renders each app's existing per-user
|
|
6
|
+
* grant (Existing access) on the consent page, and Enterprise orgs that
|
|
7
|
+
* restrict OAuth apps block the CLI app's broad historic grant
|
|
8
|
+
* (repo/gist/codespace) no matter what scope this request asks for
|
|
9
|
+
* (issue #11275). API request identity still mimics the Copilot CLI via
|
|
10
|
+
* COPILOT_API_HEADERS. Enterprise domains keep the GitHub-owned Copilot CLI
|
|
11
|
+
* client: private instances run their own OAuth registry and reject the
|
|
12
|
+
* github.com-registered OpenCode client.
|
|
3
13
|
*/
|
|
4
14
|
import { scheduler } from "node:timers/promises";
|
|
5
15
|
import { getBundledModels } from "@linxiraos/pi-catalog/models";
|
|
@@ -15,8 +25,19 @@ import * as AIError from "../../error";
|
|
|
15
25
|
import type { FetchImpl } from "../../types";
|
|
16
26
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
17
27
|
|
|
18
|
-
const
|
|
19
|
-
const
|
|
28
|
+
const OPENCODE_CLIENT_ID = "Ov23li8tweQw6odWQebz";
|
|
29
|
+
const COPILOT_CLI_CLIENT_ID = "Ov23ctDVkRmgkPke0Mmm";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* OAuth client for the device flow. Public github.com uses the minimal-grant
|
|
33
|
+
* OpenCode app (narrow consent, issue #11275); private GitHub Enterprise
|
|
34
|
+
* instances run their own OAuth registry, which does not know that github.com
|
|
35
|
+
* registration, so they keep the GitHub-owned Copilot CLI client.
|
|
36
|
+
*/
|
|
37
|
+
function resolveOAuthClientId(domain: string): string {
|
|
38
|
+
return isPublicGitHubHost(domain) ? OPENCODE_CLIENT_ID : COPILOT_CLI_CLIENT_ID;
|
|
39
|
+
}
|
|
40
|
+
const OAUTH_SCOPE = "read:user";
|
|
20
41
|
const OAUTH_HEADERS = {
|
|
21
42
|
Accept: "application/json",
|
|
22
43
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
@@ -82,7 +103,7 @@ async function startDeviceFlow(domain: string, fetchImpl: FetchImpl): Promise<De
|
|
|
82
103
|
method: "POST",
|
|
83
104
|
headers: OAUTH_HEADERS,
|
|
84
105
|
body: new URLSearchParams({
|
|
85
|
-
client_id:
|
|
106
|
+
client_id: resolveOAuthClientId(domain),
|
|
86
107
|
scope: OAUTH_SCOPE,
|
|
87
108
|
}),
|
|
88
109
|
},
|
|
@@ -156,7 +177,7 @@ async function pollForGitHubAccessToken(
|
|
|
156
177
|
method: "POST",
|
|
157
178
|
headers: OAUTH_HEADERS,
|
|
158
179
|
body: new URLSearchParams({
|
|
159
|
-
client_id:
|
|
180
|
+
client_id: resolveOAuthClientId(domain),
|
|
160
181
|
device_code: deviceCode,
|
|
161
182
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
162
183
|
}),
|
|
@@ -207,7 +228,7 @@ const FAR_FUTURE_MS = Date.now() + 10 * 365.25 * 24 * 60 * 60 * 1000;
|
|
|
207
228
|
|
|
208
229
|
/**
|
|
209
230
|
* Refresh GitHub Copilot token.
|
|
210
|
-
* GitHub OAuth tokens from both the
|
|
231
|
+
* GitHub OAuth tokens from both the OpenCode app and the Copilot CLI app
|
|
211
232
|
* remain directly usable, so existing logins need no token exchange or migration.
|
|
212
233
|
*/
|
|
213
234
|
export function refreshGitHubCopilotToken(
|
|
@@ -335,8 +356,8 @@ export async function loginGitHubCopilot(options: GitHubCopilotLoginOptions): Pr
|
|
|
335
356
|
|
|
336
357
|
const apiEndpoint = await discoverGitHubCopilotApiEndpoint(githubAccessToken, fetchImpl);
|
|
337
358
|
|
|
338
|
-
// Keep storing the GitHub token directly so credentials minted by the
|
|
339
|
-
//
|
|
359
|
+
// Keep storing the GitHub token directly so credentials minted by the
|
|
360
|
+
// Copilot CLI OAuth app remain valid alongside new OpenCode app logins.
|
|
340
361
|
const credentials: OAuthCredentials = {
|
|
341
362
|
refresh: githubAccessToken,
|
|
342
363
|
access: githubAccessToken,
|
|
@@ -485,7 +485,7 @@ const PREVIEW_TOKEN_RE =
|
|
|
485
485
|
function redactedJunkPreview(text: string): string {
|
|
486
486
|
const source = text.slice(0, 64);
|
|
487
487
|
let out = "";
|
|
488
|
-
for (let i = 0; i < source.length;
|
|
488
|
+
for (let i = 0; i < source.length;) {
|
|
489
489
|
const tok = PREVIEW_TOKEN_RE.exec(source.slice(i));
|
|
490
490
|
if (tok) {
|
|
491
491
|
out += tok[0];
|
|
@@ -187,7 +187,7 @@ export class ThinkingLoopDetector {
|
|
|
187
187
|
return null;
|
|
188
188
|
}
|
|
189
189
|
// An over-long segment is chunked so each piece stays comparable.
|
|
190
|
-
for (let rest = raw; rest.length > 0;
|
|
190
|
+
for (let rest = raw; rest.length > 0;) {
|
|
191
191
|
const chunk = rest.length > SEGMENT_CHAR_CAP ? rest.slice(0, SEGMENT_CHAR_CAP) : rest;
|
|
192
192
|
rest = rest.slice(chunk.length);
|
|
193
193
|
const hit = this.#consumeSegment(chunk);
|