@oh-my-pi/pi-ai 18.1.13 → 18.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,12 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.1.15] - 2026-09-08
6
+
7
+ ### Fixed
8
+
9
+ - GitHub Copilot sign-in keeps the GitHub-owned Copilot CLI OAuth app on Enterprise domains: private instances run their own OAuth registry and reject the github.com-registered OpenCode client, which is now used only for public github.com sign-ins ([#11285](https://github.com/can1357/oh-my-pi/pull/11285) by [@H4vC](https://github.com/H4vC))
10
+ - GitHub Copilot sign-in uses the minimal-grant OpenCode OAuth app again (`read:user` only): GitHub renders each app's existing per-user grant on the consent page, so Enterprise organizations that block the Copilot CLI app's broad historic grant can log in as on 18.1.4. API request identity still mimics the Copilot CLI, and tokens minted by either app keep working ([#11280](https://github.com/can1357/oh-my-pi/pull/11280) by [@H4vC](https://github.com/H4vC)).
11
+ - GitHub Copilot plan/model-policy 403s no longer count as credential failures for credential-lifetime decisions: the token is valid, so stored credentials are preserved instead of wiped ([#11280](https://github.com/can1357/oh-my-pi/pull/11280) by [@H4vC](https://github.com/H4vC)).
12
+ - Fixed custom `google-generative-ai` providers failing mid-turn model fallback when Gemini 3 tool calls are replayed without their original thought signature ([#11270](https://github.com/can1357/oh-my-pi/issues/11270)).
13
+
14
+ ## [18.1.14] - 2026-09-07
15
+
16
+ ### Fixed
17
+
18
+ - Fixed reasoning-off requests (e.g. GitHub Copilot `gpt-6-astra`) surfacing `400 Unsupported value: 'none' … Supported values are: …` instead of retrying at the lowest allowed effort: the reasoning-effort fallback now recognizes `Supported values` phrasing ([#11128](https://github.com/can1357/oh-my-pi/pull/11128) by [@H4vC](https://github.com/H4vC)).
19
+ - Fixed Cursor GPT off-tier requests sending raw `-none` sibling ids (e.g. `gpt-5.6-sol-none-fast`), which the Run endpoint rejects; they now normalize to the base model id with no reasoning parameter, matching every other effort tier ([#11128](https://github.com/can1357/oh-my-pi/pull/11128) by [@H4vC](https://github.com/H4vC)).
20
+
5
21
  ## [18.1.12] - 2026-09-06
6
22
 
7
23
  ### Added
8
24
 
9
25
  - Added 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
26
 
27
+ ### Fixed
28
+
29
+ - Fixed Codex compaction timeouts triggering prolonged retries instead of advancing to the next compaction method.
30
+
11
31
  ## [18.1.11] - 2026-09-05
12
32
 
13
33
  ### Fixed
@@ -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": "@oh-my-pi/pi-ai",
3
- "version": "18.1.13",
3
+ "version": "18.1.15",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -124,11 +124,11 @@
124
124
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
125
125
  },
126
126
  "dependencies": {
127
- "@oh-my-pi/omptype": "18.1.13",
128
- "@oh-my-pi/pi-catalog": "18.1.13",
129
- "@oh-my-pi/pi-natives": "18.1.13",
130
- "@oh-my-pi/pi-utils": "18.1.13",
131
- "@oh-my-pi/pi-wire": "18.1.13"
127
+ "@oh-my-pi/omptype": "18.1.15",
128
+ "@oh-my-pi/pi-catalog": "18.1.15",
129
+ "@oh-my-pi/pi-natives": "18.1.15",
130
+ "@oh-my-pi/pi-utils": "18.1.15",
131
+ "@oh-my-pi/pi-wire": "18.1.15"
132
132
  },
133
133
  "devDependencies": {
134
134
  "@types/bun": "^1.3.14"
@@ -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;
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import http2 from "node:http2";
4
- import { classifyModel } from "@oh-my-pi/pi-catalog/compat/taxonomy";
4
+ import { classifyModel, collapseVariantId } from "@oh-my-pi/pi-catalog/compat/taxonomy";
5
5
  import type {
6
6
  ConversationStep,
7
7
  CursorRule,
@@ -5236,8 +5236,11 @@ function extractImages(content: (TextContent | ImageContent)[]) {
5236
5236
  * The Run endpoint rejects a sibling slug as the wire `model_id` with
5237
5237
  * `resource_exhausted` (errorId 528384); the official `cursor-agent` splits the
5238
5238
  * slug into its base model id plus a `reasoning` effort parameter. Mirror that
5239
- * for OpenAI-family ids: strip a trailing effort tier and emit
5240
- * `{ id: "reasoning", value: <effort> }`.
5239
+ * for OpenAI-family ids: split the tier with the compiled catalog policy
5240
+ * (`collapseVariantId`, KDL suffix/lane rules) and emit
5241
+ * `{ id: "reasoning", value: <effort> }`. The off tier (`-none`) is a sibling
5242
+ * slug too, so it normalizes to the bare (lane-preserving) base with no
5243
+ * reasoning parameter instead of going out raw.
5241
5244
  *
5242
5245
  * Non-OpenAI ids pass through unchanged — Cursor-native ids (`composer-*`,
5243
5246
  * `cursor-grok-*`, `default`) carry no effort suffix, and Claude/other siblings
@@ -5255,21 +5258,23 @@ function resolveCursorWireModel(
5255
5258
  } {
5256
5259
  const wireModelId = requestModelId ?? model.requestModelId ?? model.id;
5257
5260
  if (wireMode === "discovered") return { modelId: wireModelId, parameters: [] };
5258
- // Cursor's fast lane follows the effort token (`-high-fast`), while the
5259
- // standard lane ends at it (`-high`). Preserve the lane in the base id.
5260
- const match = /^(.*)-(minimal|low|medium|high|xhigh|max)(-fast)?$/.exec(wireModelId);
5261
- const base = match?.[1];
5262
- const effort = match?.[2];
5263
- if (
5264
- base &&
5265
- effort &&
5266
- (THINKING_EFFORTS as readonly string[]).includes(effort) &&
5267
- classifyModel("cursor", base).class === "openai"
5268
- ) {
5269
- return {
5270
- modelId: `${base}${match[3] ?? ""}`,
5271
- parameters: [create(RequestedModel_ModelParameterbytesSchema, { id: "reasoning", value: effort })],
5272
- };
5261
+ // `collapseVariantId` keeps the lane in the logical id (`-high-fast`
5262
+ // base `-fast`) and decodes the KDL effort (`-none` `off`).
5263
+ const collapsed = collapseVariantId("cursor", wireModelId);
5264
+ const effort = collapsed.effort;
5265
+ const base = effort !== undefined ? collapsed.logicalId : undefined;
5266
+ if (effort !== undefined && base && classifyModel("cursor", base).class === "openai") {
5267
+ if (effort === "off") {
5268
+ return { modelId: base, parameters: [] };
5269
+ }
5270
+ if ((THINKING_EFFORTS as readonly string[]).includes(effort)) {
5271
+ return {
5272
+ modelId: base,
5273
+ parameters: [
5274
+ create(RequestedModel_ModelParameterbytesSchema, { id: "reasoning", value: collapsed.effort }),
5275
+ ],
5276
+ };
5277
+ }
5273
5278
  }
5274
5279
  // A bare `composer-2.5` id resolves to the Fast variant server-side
5275
5280
  // (can1357/oh-my-pi#9012). Pin the Standard tier explicitly; `-fast`
@@ -3734,12 +3734,12 @@ class CodexWebSocketConnection {
3734
3734
  if (signal) signal.removeEventListener("abort", onAbort);
3735
3735
  };
3736
3736
  const onAbort = () => {
3737
- this.close("aborted");
3738
3737
  if (!settled) {
3739
3738
  settled = true;
3740
3739
  clearPending();
3741
- reject(new CodexWebSocketTransportError(`request was aborted`));
3740
+ reject(new CodexWebSocketTransportError(`request was aborted`, { cause: signal?.reason }));
3742
3741
  }
3742
+ this.close("aborted");
3743
3743
  };
3744
3744
  if (signal) {
3745
3745
  if (signal.aborted) {
@@ -3844,7 +3844,7 @@ class CodexWebSocketConnection {
3844
3844
  throw new CodexWebSocketTransportError(`websocket request already in progress`);
3845
3845
  }
3846
3846
  if (signal?.aborted) {
3847
- throw new CodexWebSocketTransportError(`request was aborted`);
3847
+ throw new CodexWebSocketTransportError(`request was aborted`, { cause: signal.reason });
3848
3848
  }
3849
3849
  this.#activeRequest = true;
3850
3850
  this.#streamObserver = onSseEvent;
@@ -3860,8 +3860,9 @@ class CodexWebSocketConnection {
3860
3860
  // the death signal instead of writing into a dead socket.
3861
3861
  this.#dropStaleFrames();
3862
3862
  const onAbort = () => {
3863
+ this.#push(new CodexWebSocketTransportError(`request was aborted`, { cause: signal?.reason }));
3864
+ // Closing can synchronously enqueue a generic onclose error.
3863
3865
  this.close("aborted");
3864
- this.#push(new CodexWebSocketTransportError(`request was aborted`));
3865
3866
  };
3866
3867
  if (signal) signal.addEventListener("abort", onAbort, { once: true });
3867
3868
 
@@ -3882,8 +3883,15 @@ class CodexWebSocketConnection {
3882
3883
  const requestPayload = JSON.stringify(request);
3883
3884
  notifyCodexWebSocketOutbound(onSseEvent, request, requestPayload);
3884
3885
  // Re-check liveness: the debug-session await above can outlive the socket.
3886
+ // Preserve the abort cause: onAbort already queued a caused error, but this
3887
+ // throw would otherwise mask it before #nextMessage() drains the queue.
3885
3888
  const socket = this.#socket;
3886
3889
  if (!socket || socket.readyState !== WebSocket.OPEN) {
3890
+ if (signal?.aborted) {
3891
+ throw new CodexWebSocketTransportError(`websocket connection is unavailable`, {
3892
+ cause: signal.reason,
3893
+ });
3894
+ }
3887
3895
  throw new CodexWebSocketTransportError(`websocket connection is unavailable`);
3888
3896
  }
3889
3897
  try {
@@ -4752,8 +4760,8 @@ export function convertOpenAICodexResponsesTools(
4752
4760
  }
4753
4761
 
4754
4762
  export class CodexWebSocketTransportError extends Error {
4755
- constructor(detail: string) {
4756
- super(`${CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX}: ${detail}`);
4763
+ constructor(detail: string, options?: ErrorOptions) {
4764
+ super(`${CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX}: ${detail}`, options);
4757
4765
  this.name = "CodexWebSocketTransportError";
4758
4766
  }
4759
4767
  }
@@ -816,10 +816,15 @@ const streamOpenAICompletionsOnce = (
816
816
  openaiStream = await createCompletionsStream();
817
817
  } catch (error) {
818
818
  const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined;
819
+ // A caller disable with a retained effort preference is still an
820
+ // explicit disable: without this, a fieldless rejection of the
821
+ // resulting `none` resolves to a delete-effort retry that gets
822
+ // cached and strips later enabled turns.
823
+ const isExplicitDisable = options?.disableReasoning === true;
819
824
  const reasoningEffortFallback =
820
825
  activeReasoningEffortFallbackKey && activeRequestParams && !requestSignal.aborted
821
826
  ? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, activeRequestParams, {
822
- explicitDisable: options?.disableReasoning === true && options.reasoning === undefined,
827
+ explicitDisable: isExplicitDisable,
823
828
  })
824
829
  : undefined;
825
830
  if (reasoningEffortFallback !== undefined && activeReasoningEffortFallbackKey) {
@@ -828,11 +833,16 @@ const streamOpenAICompletionsOnce = (
828
833
  attemptedReasoningEffortFallbacks.add(retryMarker);
829
834
  requestReasoningEffortFallbacks.set(activeReasoningEffortFallbackKey, reasoningEffortFallback);
830
835
  openaiStream = await createCompletionsStream();
831
- rememberOpenAIReasoningEffortFallback(
832
- providerSessionState,
833
- activeReasoningEffortFallbackKey,
834
- reasoningEffortFallback,
835
- );
836
+ // Explicit-disable fallbacks stay per-request so a reasoning-off
837
+ // side request cannot downgrade later normal turns sharing
838
+ // the session state.
839
+ if (!isExplicitDisable) {
840
+ rememberOpenAIReasoningEffortFallback(
841
+ providerSessionState,
842
+ activeReasoningEffortFallbackKey,
843
+ reasoningEffortFallback,
844
+ );
845
+ }
836
846
  } else if (
837
847
  model.compat.retryWithoutStrictOnGrammarError &&
838
848
  !disableStrictTools &&
@@ -183,9 +183,11 @@ function collectMessageParts(error: unknown, captured: CapturedHttpErrorResponse
183
183
  * Text that identifies a 400 as being about the reasoning-effort field.
184
184
  * OpenAI-compatible gateways (cliproxy, …) never name the field — they reject
185
185
  * the value alone with `level "none" not supported, valid levels: low, …` — so
186
- * the allowed-level phrasing counts as a mention too.
186
+ * the allowed-level phrasing counts as a mention too. GitHub Copilot phrases
187
+ * the same rejection with `Supported values are: …`, so value lists count too.
187
188
  */
188
- const REASONING_EFFORT_FIELD_PATTERN = /reasoning[_. ]effort|reasoning value|(?:valid|supported|allowed) levels?/i;
189
+ const REASONING_EFFORT_FIELD_PATTERN =
190
+ /reasoning[_. ]effort|reasoning value|(?:valid|supported|allowed) (?:levels?|values?)/i;
189
191
 
190
192
  function mentionsReasoningEffort(error: unknown, captured: CapturedHttpErrorResponse | undefined): boolean {
191
193
  const param = capturedStringField(captured, "param");
@@ -200,6 +202,68 @@ function mentionsReasoningEffort(error: unknown, captured: CapturedHttpErrorResp
200
202
  );
201
203
  }
202
204
 
205
+ /** Which request field a rejection attributes itself to, parsed once. */
206
+ type EffortRejectionField = "reasoning-effort" | "other" | "unknown";
207
+
208
+ /**
209
+ * Parsed attribution of a 400/422, in precedence order: the structured error
210
+ * field first, the message verdict second, bare vocabulary last. New server
211
+ * wordings extend these parsers; the decision in
212
+ * {@link isInvalidReasoningEffortError} stays fixed.
213
+ */
214
+ interface EffortRejectionSignal {
215
+ /** Authoritative attribution: explicit `param`, else message content, else unknown. */
216
+ field: EffortRejectionField;
217
+ /** The message names the reasoning-effort option without a verdict. */
218
+ namesFieldInMessage: boolean;
219
+ /** The message carries a fielded rejection verdict (any word order). */
220
+ messageVerdict: boolean;
221
+ /** The message lists allowed tiers in gateway levels vocabulary. */
222
+ listsLevels: boolean;
223
+ /** The rejected effort is quoted next to a rejection verdict. */
224
+ rejectedMatches: boolean;
225
+ }
226
+
227
+ const EFFORT_FIELD_PATTERN = /reasoning[_. ]effort|reasoning value/i;
228
+ const ALLOWED_LEVELS_PATTERN = /(?:valid|supported|allowed) levels?/i;
229
+
230
+ /** Fielded rejection verdicts in any word order: verdict-first, field-first, or bare mention plus verdict. */
231
+ function messageCarriesEffortVerdict(message: string): boolean {
232
+ return (
233
+ /invalid[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(message) ||
234
+ /(?:reasoning[_. ]effort|reasoning value)[^\n]*(?:invalid|unsupported|not supported|not permitted|must be|expected|unknown|unexpected|unrecognized)/i.test(
235
+ message,
236
+ ) ||
237
+ /(?:unsupported|not supported|not permitted|unknown|unexpected|unrecognized|extra)[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(
238
+ message,
239
+ )
240
+ );
241
+ }
242
+
243
+ function parseEffortRejectionSignal(
244
+ message: string,
245
+ captured: CapturedHttpErrorResponse | undefined,
246
+ currentEffort: string,
247
+ ): EffortRejectionSignal {
248
+ const namesFieldInMessage = EFFORT_FIELD_PATTERN.test(message);
249
+ const param = capturedStringField(captured, "param") ?? "";
250
+ const quoted = `["'\`]${escapeRegExp(currentEffort)}["'\`]`;
251
+ return {
252
+ field:
253
+ namesFieldInMessage || EFFORT_FIELD_PATTERN.test(param)
254
+ ? "reasoning-effort"
255
+ : param.trim() !== ""
256
+ ? "other"
257
+ : "unknown",
258
+ namesFieldInMessage,
259
+ messageVerdict: messageCarriesEffortVerdict(message),
260
+ listsLevels: ALLOWED_LEVELS_PATTERN.test(message),
261
+ rejectedMatches:
262
+ new RegExp(`(?:invalid|unsupported|not supported)[^\\n]*${quoted}`, "i").test(message) ||
263
+ new RegExp(`${quoted}[^\\n]*(?:invalid|unsupported|not supported)`, "i").test(message),
264
+ };
265
+ }
266
+
203
267
  function isInvalidReasoningEffortError(
204
268
  error: unknown,
205
269
  captured: CapturedHttpErrorResponse | undefined,
@@ -210,28 +274,15 @@ function isInvalidReasoningEffortError(
210
274
  if (!mentionsReasoningEffort(error, captured)) return false;
211
275
  const message = collectMessageParts(error, captured);
212
276
  if (/reasoning[_ ]content/i.test(message) && !REASONING_EFFORT_FIELD_PATTERN.test(message)) return false;
213
- if (/invalid[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(message)) return true;
214
- if (
215
- /(?:reasoning[_. ]effort|reasoning value)[^\n]*(?:invalid|unsupported|not supported|not permitted|must be|expected|unknown|unexpected|unrecognized)/i.test(
216
- message,
217
- )
218
- ) {
219
- return true;
220
- }
221
- if (
222
- /(?:unsupported|not supported|not permitted|unknown|unexpected|unrecognized|extra)[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(
223
- message,
224
- )
225
- ) {
226
- return true;
227
- }
228
- // Gateways put the rejected value first (`level "none" not supported`), the
229
- // official API puts the verdict first (`Unsupported value: 'none'`).
230
- const quoted = `["'\`]${escapeRegExp(currentEffort)}["'\`]`;
231
- return (
232
- new RegExp(`(?:invalid|unsupported|not supported)[^\\n]*${quoted}`, "i").test(message) ||
233
- new RegExp(`${quoted}[^\\n]*(?:invalid|unsupported|not supported)`, "i").test(message)
234
- );
277
+ const signal = parseEffortRejectionSignal(message, captured, currentEffort);
278
+ // Precedence is fixed: an explicit foreign field defeats the heuristic,
279
+ // a fielded verdict always qualifies, and fieldless values-lists are
280
+ // trusted only for the reasoning-off value (levels vocabulary is
281
+ // gateway-effort dialect, so it stays trusted for every tier).
282
+ if (signal.field === "other" && !signal.namesFieldInMessage) return false;
283
+ if (signal.messageVerdict) return true;
284
+ if (currentEffort.toLowerCase() !== "none" && !signal.namesFieldInMessage && !signal.listsLevels) return false;
285
+ return signal.rejectedMatches;
235
286
  }
236
287
 
237
288
  function escapeRegExp(value: string): string {
@@ -591,11 +591,21 @@ const streamOpenAIResponsesOnce = (
591
591
  try {
592
592
  openaiStream = await openResponsesStream(chained.params);
593
593
  if (pendingReasoningEffortFallback) {
594
- rememberOpenAIReasoningEffortFallback(
595
- providerSessionState,
596
- pendingReasoningEffortFallback.key,
597
- pendingReasoningEffortFallback.fallback,
598
- );
594
+ // Explicit-disable fallbacks (none -> lowest allowed) are
595
+ // per-request: persisting them under the model key would
596
+ // silently downgrade later normal turns sharing the
597
+ // session state. A retained effort preference does not
598
+ // make the disable less explicit. Keep them in the
599
+ // per-request map only.
600
+ const isExplicitDisable =
601
+ options?.forceReasoningOff === true || options?.disableReasoning === true;
602
+ if (!isExplicitDisable) {
603
+ rememberOpenAIReasoningEffortFallback(
604
+ providerSessionState,
605
+ pendingReasoningEffortFallback.key,
606
+ pendingReasoningEffortFallback.fallback,
607
+ );
608
+ }
599
609
  pendingReasoningEffortFallback = undefined;
600
610
  }
601
611
  break;
@@ -605,8 +615,7 @@ const streamOpenAIResponsesOnce = (
605
615
  activeReasoningEffortFallbackKey && activeRequestParams && !requestSignal.aborted
606
616
  ? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, activeRequestParams, {
607
617
  explicitDisable:
608
- options?.forceReasoningOff === true ||
609
- (options?.disableReasoning === true && options.reasoning === undefined),
618
+ options?.forceReasoningOff === true || options?.disableReasoning === true,
610
619
  })
611
620
  : undefined;
612
621
  if (reasoningEffortFallback !== undefined && activeReasoningEffortFallbackKey) {
@@ -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 "@oh-my-pi/pi-catalog/models";
@@ -15,7 +25,18 @@ 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";
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
+ }
19
40
  const OAUTH_SCOPE = "read:user";
20
41
  const OAUTH_HEADERS = {
21
42
  Accept: "application/json",
@@ -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,