@linxiraos/pi-ai 1.1.12 → 1.1.14

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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.1.14] - 2026-09-12
6
+
7
+ - 随 1.1.14 版本线发布:bazel 构建面(crates/*/BUILD.bazel)版本号纳入一致性检查,CI 原生构建与桌面冒烟守卫修复。
8
+
9
+ ## [1.1.13] - 2026-09-10
10
+
11
+ - 上游 v18.1.16 同步:AuthStorage 合并封锁契约——凭据级 `blockedUntilMs` 与 `providerTimed` 时间线合并,先到的更长封锁在短提示到来时保持有效;GitHub Copilot OAuth 拆分公共 GitHub / GHE 双 client-id;Codex WebSocket 传输 abort 携带 cause 链。
12
+
5
13
  ## [1.1.12] - 2026-09-10
6
14
 
7
15
  - 品牌与合并工具链维护版本;无本包用户可见变更。
@@ -12,12 +20,6 @@
12
20
 
13
21
  - 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.
14
22
 
15
- ### Fixed
16
-
17
- - 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.
18
- - 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.
19
- - Fixed Codex compaction timeouts triggering prolonged retries instead of advancing to the next compaction method.
20
-
21
23
  ## [1.1.10-omp18.1.12] - 2026-09-06
22
24
 
23
25
  ### 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 former OpenCode app and the Copilot CLI app
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.12",
3
+ "version": "1.1.14",
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": "tsgo -p tsconfig.json --noEmit",
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
- "test": "bun test --parallel"
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.12",
125
- "@linxiraos/pi-catalog": "1.1.12",
126
- "@linxiraos/pi-natives": "1.1.12",
127
- "@linxiraos/pi-utils": "1.1.12",
128
- "@linxiraos/pi-wire": "1.1.12"
127
+ "@linxiraos/pi-omptype": "1.1.14",
128
+ "@linxiraos/pi-catalog": "1.1.14",
129
+ "@linxiraos/pi-natives": "1.1.14",
130
+ "@linxiraos/pi-utils": "1.1.14",
131
+ "@linxiraos/pi-wire": "1.1.14"
129
132
  },
130
133
  "devDependencies": {
131
134
  "@types/bun": "^1.3.14"
@@ -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
- /** Marks a credential as blocked until the specified time. */
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(provider, routing.providerKey, targetIndex, blockedUntil, routing.blockScope);
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) return { switched: true };
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 { switched: false, retryAtMs };
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
- return this.#blockCredentialForRotation(provider, credentialType, targetIndex, blockedUntil, routing);
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
 
@@ -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);
@@ -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));
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -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();
@@ -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): AnthropicToolSchemaPlan => ({
5010
- inputSchema: buildAnthropicBaseToolInputSchema(tool) as AnthropicToolInputSchema,
5011
- strict: false,
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
- 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),
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
- WebSocket as unknown as new (
1851
- url: string,
1852
- options: Bun.WebSocketOptions,
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 using the official Copilot CLI app.
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 CLIENT_ID = "Ov23ctDVkRmgkPke0Mmm";
19
- const OAUTH_SCOPE = "read:user,read:org,repo,gist,codespace";
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: 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: 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 former OpenCode app and the Copilot CLI app
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 former
339
- // OpenCode OAuth app remain valid alongside new Copilot CLI app logins.
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);