@code-yeongyu/senpi-ai 2026.10.10-6 → 2026.10.10-8

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.
Files changed (42) hide show
  1. package/dist/api/anthropic-messages.js +40 -9
  2. package/dist/api/anthropic-tool-references.js +24 -18
  3. package/dist/api/bedrock-converse-stream.js +5 -1
  4. package/dist/api/lazy.js +13 -0
  5. package/dist/api/mistral-conversations.js +18 -3
  6. package/dist/auth/oauth/anthropic.js +4 -3
  7. package/dist/auth/oauth/chatgpt-subscription.js +3 -2
  8. package/dist/auth/oauth/cursor.js +3 -2
  9. package/dist/auth/oauth/devin-token.js +2 -1
  10. package/dist/auth/oauth/github-copilot.js +3 -2
  11. package/dist/auth/oauth/kimi-coding.js +5 -4
  12. package/dist/auth/oauth/openai-chatgpt.js +2 -1
  13. package/dist/auth/oauth/openrouter.js +2 -1
  14. package/dist/auth/oauth/radius.js +5 -5
  15. package/dist/auth/oauth/xai.js +7 -4
  16. package/dist/auth/oauth-refresh.js +10 -0
  17. package/dist/auth/resolve.js +5 -0
  18. package/dist/providers/all.d.ts +1 -1
  19. package/dist/providers/data/.manifest.json +1 -1
  20. package/dist/providers/data/amazon-bedrock.json +1 -1
  21. package/dist/providers/data/anthropic.json +1 -1
  22. package/dist/providers/data/cloudflare-ai-gateway.json +1 -1
  23. package/dist/providers/data/github-copilot.json +1 -1
  24. package/dist/providers/data/mistral.json +1 -1
  25. package/dist/providers/data/moonshotai-cn.json +1 -1
  26. package/dist/providers/data/moonshotai.json +1 -1
  27. package/dist/providers/data/opencode-go.json +1 -1
  28. package/dist/providers/data/opencode.json +1 -1
  29. package/dist/providers/data/opengateway.json +1 -1
  30. package/dist/providers/data/openrouter.json +1 -1
  31. package/dist/providers/data/radius.json +1 -1
  32. package/dist/providers/data/venice.json +1 -1
  33. package/dist/providers/data/vercel-ai-gateway.json +1 -1
  34. package/dist/providers/kimi-coding.models.d.ts +1 -1
  35. package/dist/providers/kimi-coding.models.js +1 -1
  36. package/dist/providers/opengateway-refresh.js +2 -1
  37. package/dist/utils/oauth-refresh-error.d.ts +16 -0
  38. package/dist/utils/oauth-refresh-error.js +67 -0
  39. package/dist/utils/prompt-cache-ttl.js +3 -1
  40. package/dist/utils/retry.d.ts +6 -0
  41. package/dist/utils/retry.js +10 -0
  42. package/package.json +2 -2
@@ -71,7 +71,7 @@ function getCacheControl(model, cacheRetention, env) {
71
71
  // The bundled Claude Code version and the floor of the advertised `claude-cli/<version>`
72
72
  // (see utils/claude-code-version.ts). Keep this exact declaration: a downstream installer
73
73
  // (oh-my-openagent) rewrites it byte-for-byte in the installed dist and bundle.
74
- const claudeCodeVersion = "2.1.289";
74
+ const claudeCodeVersion = "2.1.292";
75
75
  // Claude Code 2.x tool names (canonical casing)
76
76
  // Source: https://cchistory.mariozechner.at/data/prompts-2.1.11.md
77
77
  // To update: https://github.com/badlogic/cchistory
@@ -164,6 +164,7 @@ const ADAPTIVE_THINKING_MODEL_MARKERS = [
164
164
  "opus-5",
165
165
  "sonnet-4-6",
166
166
  "sonnet-5",
167
+ "haiku-5-5",
167
168
  "fable-5",
168
169
  "mythos-5",
169
170
  ];
@@ -176,14 +177,20 @@ const NATIVE_XHIGH_EFFORT_MODEL_MARKERS = [
176
177
  "opus-4-8",
177
178
  "opus-5",
178
179
  "sonnet-5",
180
+ "haiku-5-5",
179
181
  "fable-5",
180
182
  "mythos-5",
181
183
  ];
182
184
  /**
183
- * Adaptive families that reject `thinking: {type: "disabled"}` outright (verified 400:
184
- * `"thinking.type.disabled" is not supported for this model`). The generated catalog also encodes
185
- * this as `compat.supportsDisabledThinking: false`, but `models.json` entries and third-party
186
- * gateway rows carry no generated compat, so the family fact has to live here as well.
185
+ * Families senpi never sends `thinking: {type: "disabled"}` to. Fable 5, Opus 5.5 and Sonnet 5.5 reject it
186
+ * outright (verified live 400: `"thinking.type.disabled" is not supported for this model`); Mythos 5 is
187
+ * listed by family with Fable. Claude Haiku 5.5 is listed by choice, not because it rejects `disabled`: its
188
+ * effort docs accept `disabled` at effort `high` or below (400 only at `xhigh` / `max`) and reject a
189
+ * differing per-message effort while disabled, so a real thinking-off needs marker handling first
190
+ * (senpi#2927). Until then a thinking-off turn sends no `thinking` and effort `low`, which is valid on all of
191
+ * them. The generated catalog also encodes this as `compat.supportsDisabledThinking: false`, but
192
+ * `models.json` entries and third-party gateway rows carry no generated compat, so the family fact has to
193
+ * live here as well.
187
194
  */
188
195
  const DISABLED_THINKING_REJECTING_MODEL_MARKERS = [
189
196
  "fable-5",
@@ -192,6 +199,8 @@ const DISABLED_THINKING_REJECTING_MODEL_MARKERS = [
192
199
  "opus-5.5",
193
200
  "sonnet-5-5",
194
201
  "sonnet-5.5",
202
+ "haiku-5-5",
203
+ "haiku-5.5",
195
204
  ];
196
205
  const UNSUPPORTED_NATIVE_COMPUTER_TOOL_MODEL_MARKERS = [
197
206
  "opus-4-6",
@@ -922,7 +931,7 @@ export const stream = (model, context, options) => {
922
931
  const normalizedContext = resolveTranscript(context, supportsMidConvoSystemMessages(model));
923
932
  const currentTools = getCurrentTools(normalizedContext.messages);
924
933
  (async () => {
925
- const providerThinkingLevel = model.compat?.supportsMidConvoEffort ? (options?.effort ?? "high") : undefined;
934
+ const providerThinkingLevel = managedEffortForRequest(model, options);
926
935
  const output = {
927
936
  role: "assistant",
928
937
  content: [],
@@ -1459,6 +1468,25 @@ function cannotDisableThinking(model, compat) {
1459
1468
  return true;
1460
1469
  return matchesModelMarker(model, DISABLED_THINKING_REJECTING_MODEL_MARKERS);
1461
1470
  }
1471
+ /**
1472
+ * Effort a per-message-effort request runs at, which is also the active marker and the level recorded
1473
+ * on the reply: the caller's effort with thinking on, `low` for a thinking-off turn on a family that
1474
+ * cannot disable thinking, and none when thinking is actually disabled (senpi#2912).
1475
+ */
1476
+ function managedEffortForRequest(model, options) {
1477
+ if (model.compat?.supportsMidConvoEffort !== true)
1478
+ return undefined;
1479
+ if (options?.thinkingEnabled === false) {
1480
+ return cannotDisableThinking(model, getAnthropicCompat(model)) ? "low" : undefined;
1481
+ }
1482
+ return options?.effort ?? "high";
1483
+ }
1484
+ function isEffortMarker(message) {
1485
+ return (message.role === "system" &&
1486
+ Array.isArray(message.content) &&
1487
+ message.content.length === 0 &&
1488
+ message.output_config?.effort !== undefined);
1489
+ }
1462
1490
  function disableThinkingForRequest(params, model, compat) {
1463
1491
  // A degraded/disabled turn must not retain the caller's higher effort.
1464
1492
  delete params.output_config;
@@ -1470,6 +1498,8 @@ function disableThinkingForRequest(params, model, compat) {
1470
1498
  return;
1471
1499
  }
1472
1500
  params.thinking = { type: "disabled" };
1501
+ // Per-message effort is rejected alongside disabled thinking (senpi#1399).
1502
+ params.messages = params.messages.filter((message) => !isEffortMarker(message));
1473
1503
  }
1474
1504
  function supportsAdaptiveThinking(model) {
1475
1505
  if (model.compat?.forceAdaptiveThinking !== undefined) {
@@ -1741,7 +1771,7 @@ function buildParams(model, context, isOAuthToken, options, unsignedThinkingRepl
1741
1771
  deferredTools = [];
1742
1772
  }
1743
1773
  const deferredToolNames = new Set(deferredTools.map((tool) => normalizeToolName(tool.name)));
1744
- const activeEffort = options?.effort ?? "high";
1774
+ const activeEffort = managedEffortForRequest(model, options) ?? "high";
1745
1775
  const betaFeatures = getBetaFeatures(model, context, isOAuthToken, nativeToolChanges, options);
1746
1776
  const convertedMessages = convertMessages(conversationMessages, model, isOAuthToken, cacheControl, unsignedThinkingReplay, deferredToolNames, normalizeToolName, model.compat?.supportsMidConvoEffort === true ? model.provider : undefined, nativeToolChanges);
1747
1777
  const messages = model.compat?.supportsMidConvoEffort === true
@@ -1826,8 +1856,9 @@ function buildParams(model, context, isOAuthToken, options, unsignedThinkingRepl
1826
1856
  ];
1827
1857
  }
1828
1858
  // Managed effort models always use adaptive thinking so prefix mismatches can
1829
- // be dropped instead of surfacing as persistent 400 responses. Thinking-off is
1830
- // handled before this managed branch because these models accept `disabled`.
1859
+ // be dropped instead of surfacing as persistent 400 responses. A thinking-off turn
1860
+ // skips this branch: `disableThinkingForRequest` sends `disabled` where the family
1861
+ // accepts it and pins effort `low` where it does not (Opus/Sonnet/Haiku 5.5, Fable, Mythos).
1831
1862
  if (model.compat?.supportsMidConvoEffort === true && options?.thinkingEnabled !== false) {
1832
1863
  params.thinking = {
1833
1864
  type: "adaptive",
@@ -73,6 +73,27 @@ export function demoteUnavailableToolReferences(params) {
73
73
  }
74
74
  let changed = false;
75
75
  const availableToolNames = [...available];
76
+ // A native search pair whose every reference stopped resolving is demoted as a
77
+ // unit: the result decides, and its `server_tool_use` follows. The pair can span
78
+ // two assistant messages (a deferred server tool resumes in the continuation), so
79
+ // the decision is collected over the whole request before any message is rewritten.
80
+ const droppedSearchUseIds = new Set();
81
+ const droppedSearchNames = new Map();
82
+ for (const message of messages) {
83
+ if (message.role !== "assistant" || !Array.isArray(message.content))
84
+ continue;
85
+ for (const block of message.content) {
86
+ if (!isNativeToolSearchResultBlock(block))
87
+ continue;
88
+ const names = block.content.tool_references
89
+ .filter((item) => isRecord(item) && item.type === "tool_reference")
90
+ .map((item) => (typeof item.tool_name === "string" ? item.tool_name : ""));
91
+ if (names.length > 0 && names.every((name) => resolve(name) === undefined)) {
92
+ droppedSearchUseIds.add(block.tool_use_id);
93
+ droppedSearchNames.set(block.tool_use_id, names);
94
+ }
95
+ }
96
+ }
76
97
  const seenDemotedCallNames = new Set();
77
98
  const rewrittenMessages = [];
78
99
  for (const message of messages) {
@@ -81,23 +102,6 @@ export function demoteUnavailableToolReferences(params) {
81
102
  continue;
82
103
  }
83
104
  let messageChanged = false;
84
- // A native search pair whose every reference stopped resolving is demoted
85
- // as a unit: the result decides, and its `server_tool_use` follows.
86
- const droppedSearchUseIds = new Set();
87
- const droppedSearchNames = new Map();
88
- if (message.role === "assistant") {
89
- for (const block of message.content) {
90
- if (!isNativeToolSearchResultBlock(block))
91
- continue;
92
- const names = block.content.tool_references
93
- .filter((item) => isRecord(item) && item.type === "tool_reference")
94
- .map((item) => (typeof item.tool_name === "string" ? item.tool_name : ""));
95
- if (names.length > 0 && names.every((name) => resolve(name) === undefined)) {
96
- droppedSearchUseIds.add(block.tool_use_id);
97
- droppedSearchNames.set(block.tool_use_id, names);
98
- }
99
- }
100
- }
101
105
  const content = [];
102
106
  for (const block of message.content) {
103
107
  if (message.role === "assistant" && isRecord(block) && block.type === "tool_use") {
@@ -168,7 +172,9 @@ export function demoteUnavailableToolReferences(params) {
168
172
  }
169
173
  content.push(block);
170
174
  }
171
- if (content.length === 0) {
175
+ // Only a message this pass emptied is dropped; a message that arrived empty (a per-message
176
+ // effort marker carries `content: []` by design) passes through untouched (senpi#2912).
177
+ if (messageChanged && content.length === 0) {
172
178
  changed = true;
173
179
  continue;
174
180
  }
@@ -590,6 +590,7 @@ function supportsAdaptiveThinking(modelId, modelName) {
590
590
  s.includes("opus-5") ||
591
591
  s.includes("sonnet-4-6") ||
592
592
  s.includes("sonnet-5") ||
593
+ s.includes("haiku-5-5") ||
593
594
  s.includes("fable-5") ||
594
595
  s.includes("mythos-5"));
595
596
  }
@@ -599,6 +600,7 @@ function supportsNativeXhighEffort(model) {
599
600
  s.includes("opus-4-8") ||
600
601
  s.includes("opus-5") ||
601
602
  s.includes("sonnet-5") ||
603
+ s.includes("haiku-5-5") ||
602
604
  s.includes("fable-5") ||
603
605
  s.includes("mythos-5"));
604
606
  }
@@ -616,7 +618,9 @@ function rejectsDisabledThinking(model) {
616
618
  s.includes("opus-5-5") ||
617
619
  s.includes("opus-5.5") ||
618
620
  s.includes("sonnet-5-5") ||
619
- s.includes("sonnet-5.5"));
621
+ s.includes("sonnet-5.5") ||
622
+ s.includes("haiku-5-5") ||
623
+ s.includes("haiku-5.5"));
620
624
  }
621
625
  function mapThinkingLevelToEffort(model, level) {
622
626
  if (level === "xhigh" && supportsNativeXhighEffort(model))
package/dist/api/lazy.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { AssistantMessageEventStream } from "../utils/event-stream.js";
2
+ import { isOAuthRefreshUnavailableError } from "../utils/oauth-refresh-error.js";
2
3
  import { describeReplacedInstall } from "../utils/provider-failure-description.js";
4
+ import { OAUTH_REFRESH_UNAVAILABLE_DIAGNOSTIC } from "../utils/retry.js";
3
5
  function createSetupErrorMessage(model, error) {
4
6
  return {
5
7
  role: "assistant",
@@ -17,6 +19,17 @@ function createSetupErrorMessage(model, error) {
17
19
  },
18
20
  stopReason: "error",
19
21
  errorMessage: describeReplacedInstall(error) ?? (error instanceof Error ? error.message : String(error)),
22
+ ...(isOAuthRefreshUnavailableError(error)
23
+ ? {
24
+ diagnostics: [
25
+ {
26
+ type: OAUTH_REFRESH_UNAVAILABLE_DIAGNOSTIC,
27
+ timestamp: Date.now(),
28
+ details: { provider: model.provider },
29
+ },
30
+ ],
31
+ }
32
+ : {}),
20
33
  timestamp: Date.now(),
21
34
  };
22
35
  }
@@ -612,6 +612,21 @@ function stripSymbolKeys(value) {
612
612
  }
613
613
  return value;
614
614
  }
615
+ /**
616
+ * Consecutive user turns become one user message, as the OpenAI Chat converter does for
617
+ * non-OpenAI endpoints: a word message rebuilt after tool results (senpi#2920) is often followed
618
+ * by a steer or the next prompt, and Mistral's role-order validation is not documented to accept
619
+ * two user messages in a row.
620
+ */
621
+ function appendUserMessage(result, content) {
622
+ const previous = result[result.length - 1];
623
+ if (previous?.role !== "user") {
624
+ result.push({ role: "user", content });
625
+ return;
626
+ }
627
+ const chunks = (value) => typeof value === "string" ? [{ type: "text", text: value }] : (value ?? []);
628
+ previous.content = [...chunks(previous.content), ...chunks(content)];
629
+ }
615
630
  function toChatMessages(messages, supportsImages) {
616
631
  const result = [];
617
632
  for (const [index, msg] of messages.entries()) {
@@ -625,7 +640,7 @@ function toChatMessages(messages, supportsImages) {
625
640
  }
626
641
  if (msg.role === "user") {
627
642
  if (typeof msg.content === "string") {
628
- result.push({ role: "user", content: sanitizeSurrogates(msg.content) });
643
+ appendUserMessage(result, sanitizeSurrogates(msg.content));
629
644
  continue;
630
645
  }
631
646
  const hadImages = msg.content.some((item) => item.type === "image");
@@ -637,11 +652,11 @@ function toChatMessages(messages, supportsImages) {
637
652
  return { type: "image_url", imageUrl: `data:${item.mimeType};base64,${item.data}` };
638
653
  });
639
654
  if (content.length > 0) {
640
- result.push({ role: "user", content });
655
+ appendUserMessage(result, content);
641
656
  continue;
642
657
  }
643
658
  if (hadImages && !supportsImages) {
644
- result.push({ role: "user", content: "(image omitted: model does not support images)" });
659
+ appendUserMessage(result, "(image omitted: model does not support images)");
645
660
  }
646
661
  continue;
647
662
  }
@@ -4,6 +4,7 @@
4
4
  * NOTE: This module uses Node.js http.createServer for the OAuth callback server.
5
5
  * It is only intended for CLI use, not browser environments.
6
6
  */
7
+ import { OAuthTokenEndpointError } from "../../utils/oauth-refresh-error.js";
7
8
  import { getProviderEnvValue } from "../../utils/provider-env.js";
8
9
  import { PREFERRED_CALLBACK_PORT, startCallbackListener, } from "./anthropic-callback-listener.js";
9
10
  import { parseAuthorizationInput } from "./authorization-input.js";
@@ -57,7 +58,7 @@ async function postJson(url, body, signal) {
57
58
  });
58
59
  const responseBody = await response.text();
59
60
  if (!response.ok) {
60
- throw new Error(`HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`);
61
+ throw new OAuthTokenEndpointError(`HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`, response.status);
61
62
  }
62
63
  return responseBody;
63
64
  }
@@ -74,7 +75,7 @@ async function exchangeAuthorizationCode(code, state, verifier, redirectUri, sig
74
75
  }, signal);
75
76
  }
76
77
  catch (error) {
77
- throw new Error(`Token exchange request failed. url=${TOKEN_URL}; redirect_uri=${redirectUri}; response_type=authorization_code; details=${formatErrorDetails(error)}`);
78
+ throw new Error(`Token exchange request failed. url=${TOKEN_URL}; redirect_uri=${redirectUri}; response_type=authorization_code; details=${formatErrorDetails(error)}`, { cause: error });
78
79
  }
79
80
  let tokenData;
80
81
  try {
@@ -240,7 +241,7 @@ async function refreshAnthropicToken(refreshToken, signal) {
240
241
  }, signal);
241
242
  }
242
243
  catch (error) {
243
- throw new Error(`Anthropic token refresh request failed. url=${TOKEN_URL}; details=${formatErrorDetails(error)}`);
244
+ throw new Error(`Anthropic token refresh request failed. url=${TOKEN_URL}; details=${formatErrorDetails(error)}`, { cause: error });
244
245
  }
245
246
  let data;
246
247
  try {
@@ -12,6 +12,7 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
12
12
  });
13
13
  }
14
14
  import { extractChatGptSubscriptionAccountId } from "../../utils/chatgpt-subscription-auth.js";
15
+ import { OAuthTokenEndpointError } from "../../utils/oauth-refresh-error.js";
15
16
  import { getProviderEnvValue } from "../../utils/provider-env.js";
16
17
  import { getWireIdentity } from "../../wire-identity.js";
17
18
  import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.js";
@@ -80,7 +81,7 @@ async function fetchWithLoginCancellation(input, init) {
80
81
  async function readTokenResponse(response, operation) {
81
82
  if (!response.ok) {
82
83
  const text = await response.text().catch(() => "");
83
- throw new Error(`ChatGPT Subscription token ${operation} failed (${response.status}): ${text || response.statusText}`);
84
+ throw new OAuthTokenEndpointError(`ChatGPT Subscription token ${operation} failed (${response.status}): ${text || response.statusText}`, response.status);
84
85
  }
85
86
  const rawJson = await response.json();
86
87
  const json = rawJson;
@@ -123,7 +124,7 @@ async function refreshAccessToken(refreshToken, signal) {
123
124
  });
124
125
  }
125
126
  catch (error) {
126
- throw new Error(`ChatGPT Subscription token refresh error: ${error instanceof Error ? error.message : String(error)}`);
127
+ throw new Error(`ChatGPT Subscription token refresh error: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
127
128
  }
128
129
  return readTokenResponse(response, "refresh");
129
130
  }
@@ -10,6 +10,7 @@
10
10
  * (or a dashboard user API key) at `auth/exchange_user_api_key` for a fresh
11
11
  * session JWT.
12
12
  */
13
+ import { OAuthTokenEndpointError } from "../../utils/oauth-refresh-error.js";
13
14
  import { generatePKCE } from "./pkce.js";
14
15
  const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl";
15
16
  const CURSOR_POLL_URL = "https://api2.cursor.sh/auth/poll";
@@ -205,12 +206,12 @@ async function refreshCursorCredential(credential, signal) {
205
206
  }
206
207
  catch (error) {
207
208
  if (signal.aborted)
208
- throw new Error("Cursor token refresh cancelled");
209
+ throw new Error("Cursor token refresh cancelled", { cause: signal.reason });
209
210
  throw error;
210
211
  }
211
212
  const body = await readJsonBody(response);
212
213
  if (!response.ok) {
213
- throw new Error(`Cursor token refresh failed (${describeFailure(response.status, body)})`);
214
+ throw new OAuthTokenEndpointError(`Cursor token refresh failed (${describeFailure(response.status, body)})`, response.status);
214
215
  }
215
216
  const access = requiredString(body ?? {}, "accessToken");
216
217
  const rotated = body?.refreshToken;
@@ -7,6 +7,7 @@
7
7
  * "token" that serves as both the access and the refresh credential. Expiry is
8
8
  * read from the JWT itself because the response carries no expires_in.
9
9
  */
10
+ import { OAuthTokenEndpointError } from "../../utils/oauth-refresh-error.js";
10
11
  export const DEVIN_TOKEN_URL = "https://api.devin.ai/auth/cli/token";
11
12
  export const DEVIN_API_ENDPOINT = "https://api.devin.ai";
12
13
  /** Devin issues long-lived CLI tokens; used when the JWT carries no usable exp. */
@@ -91,7 +92,7 @@ export async function exchangeDevinAuthorizationCode(code, verifier, signal) {
91
92
  }
92
93
  if (!response.ok) {
93
94
  const detail = errorDetail(body);
94
- throw new Error(`Devin OAuth token exchange failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`);
95
+ throw new OAuthTokenEndpointError(`Devin OAuth token exchange failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`, response.status);
95
96
  }
96
97
  if (typeof body.token !== "string" || body.token.length === 0) {
97
98
  throw new Error('Devin OAuth response carries no "token"');
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { GITHUB_COPILOT_INDIVIDUAL_BASE_URL, githubCopilotTokenField, parseGitHubCopilotApiEndpoint, resolveGitHubCopilotBaseUrl, } from "../../api/github-copilot-endpoint.js";
5
5
  import { GITHUB_COPILOT_REJECTED_TOKEN_STATUSES } from "../../api/github-copilot-headers.js";
6
+ import { OAuthTokenEndpointError } from "../../utils/oauth-refresh-error.js";
6
7
  import { sleep } from "../../utils/sleep.js";
7
8
  import { pollOAuthDeviceCodeFlow } from "./device-code.js";
8
9
  import { parseGitHubCopilotModelCatalog } from "./github-copilot-model-catalog.js";
@@ -79,7 +80,7 @@ async function fetchGitHubCopilotModels(copilotToken, enterpriseDomain, signal,
79
80
  },
80
81
  }, signal, retryPolicy);
81
82
  if (!response.ok) {
82
- throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
83
+ throw new OAuthTokenEndpointError(`${response.status} ${response.statusText}: ${await response.text()}`, response.status);
83
84
  }
84
85
  return parseGitHubCopilotModelCatalog(await response.json(), allowPolicyFallback);
85
86
  }
@@ -87,7 +88,7 @@ async function fetchJson(url, init) {
87
88
  const response = await fetch(url, init);
88
89
  if (!response.ok) {
89
90
  const text = await response.text();
90
- throw new Error(`${response.status} ${response.statusText}: ${text}`);
91
+ throw new OAuthTokenEndpointError(`${response.status} ${response.statusText}: ${text}`, response.status);
91
92
  }
92
93
  return response.json();
93
94
  }
@@ -6,6 +6,7 @@
6
6
  * access token authenticates requests to that region's coding API as an
7
7
  * `Authorization: Bearer` header; see kimi-region.ts for the host table.
8
8
  */
9
+ import { OAuthTokenEndpointError } from "../../utils/oauth-refresh-error.js";
9
10
  import { sleep } from "../../utils/sleep.js";
10
11
  import { pollOAuthDeviceCodeFlow } from "./device-code.js";
11
12
  import { kimiCodeIdentityHeaders } from "./kimi-identity.js";
@@ -178,7 +179,7 @@ async function refreshToken(oauthHost, refreshTokenValue, signal) {
178
179
  await sleep(1000 * 2 ** (attempt - 1), signal);
179
180
  }
180
181
  if (signal.aborted) {
181
- throw new Error("Kimi Code token refresh aborted");
182
+ throw new Error("Kimi Code token refresh aborted", { cause: signal.reason });
182
183
  }
183
184
  let response;
184
185
  try {
@@ -208,14 +209,14 @@ async function refreshToken(oauthHost, refreshTokenValue, signal) {
208
209
  // Unauthorized: the stored credential is dead; Models clears it and prompts re-login.
209
210
  if (response.status === 401 || response.status === 403 || json?.error === "invalid_grant") {
210
211
  const description = typeof json?.error_description === "string" ? `: ${json.error_description}` : "";
211
- throw new Error(`Kimi Code token refresh unauthorized (status ${response.status})${description}`);
212
+ throw new OAuthTokenEndpointError(`Kimi Code token refresh unauthorized (status ${response.status})${description}`, response.status);
212
213
  }
213
214
  if (isRetryableRefreshFailure(response) && attempt < REFRESH_MAX_RETRIES) {
214
- lastError = new Error(`Kimi Code token refresh failed with status ${response.status}`);
215
+ lastError = new OAuthTokenEndpointError(`Kimi Code token refresh failed with status ${response.status}`, response.status);
215
216
  continue;
216
217
  }
217
218
  const text = JSON.stringify(json);
218
- throw new Error(`Kimi Code token refresh failed with status ${response.status}${text ? `: ${text}` : ""}`);
219
+ throw new OAuthTokenEndpointError(`Kimi Code token refresh failed with status ${response.status}${text ? `: ${text}` : ""}`, response.status);
219
220
  }
220
221
  throw lastError ?? new Error("Kimi Code token refresh failed");
221
222
  }
@@ -7,6 +7,7 @@
7
7
  import { randomBytes } from "node:crypto";
8
8
  import { createServer } from "node:http";
9
9
  import { oauthErrorHtml, oauthSuccessHtml } from "../../utils/oauth-page.js";
10
+ import { OAuthTokenEndpointError } from "../../utils/oauth-refresh-error.js";
10
11
  import { getProviderEnvValue } from "../../utils/provider-env.js";
11
12
  import { generatePKCE } from "./pkce.js";
12
13
  // every login registers a new client with this ID; OpenAI returns the issued client ID in the callback
@@ -119,7 +120,7 @@ async function requestToken(body, signal) {
119
120
  });
120
121
  if (!response.ok) {
121
122
  const responseBody = await response.text().catch(() => "");
122
- throw new Error(`OpenAI OAuth token request failed (${response.status}): ${responseBody || response.statusText}`);
123
+ throw new OAuthTokenEndpointError(`OpenAI OAuth token request failed (${response.status}): ${responseBody || response.statusText}`, response.status);
123
124
  }
124
125
  const data = await response.json();
125
126
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
@@ -10,6 +10,7 @@
10
10
  * NOTE: This module uses node:http (via callback-server.ts) for the OAuth callback server.
11
11
  * It is only intended for CLI use, not browser environments.
12
12
  */
13
+ import { OAuthTokenEndpointError } from "../../utils/oauth-refresh-error.js";
13
14
  import { getProviderEnvValue } from "../../utils/provider-env.js";
14
15
  import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.js";
15
16
  import { generatePKCE } from "./pkce.js";
@@ -88,7 +89,7 @@ async function exchangeAuthorizationCode(code, verifier, signal) {
88
89
  }
89
90
  if (!response.ok) {
90
91
  const detail = errorDetail(body);
91
- throw new Error(`OpenRouter OAuth key exchange failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`);
92
+ throw new OAuthTokenEndpointError(`OpenRouter OAuth key exchange failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`, response.status);
92
93
  }
93
94
  if (typeof body.key !== "string" || body.key.length === 0) {
94
95
  throw new Error('OpenRouter OAuth response carries no "key"');
@@ -9,6 +9,7 @@
9
9
  * It is only intended for CLI use, not browser environments.
10
10
  */
11
11
  import { normalizeRadiusGatewayUrl } from "../../providers/radius-config.js";
12
+ import { OAuthTokenEndpointError } from "../../utils/oauth-refresh-error.js";
12
13
  import { startOAuthCallbackServer } from "./callback-server.js";
13
14
  import { pollOAuthDeviceCodeFlow } from "./device-code.js";
14
15
  import { generatePKCE } from "./pkce.js";
@@ -36,15 +37,14 @@ async function loadRadiusOAuthDiscovery(gateway, signal) {
36
37
  }
37
38
  return { authorizationEndpoint: discovery.authorizationEndpoint };
38
39
  }
39
- class OAuthResponseError extends Error {
40
+ class OAuthResponseError extends OAuthTokenEndpointError {
40
41
  constructor(status, oauthError, description, message) {
41
42
  const detail = oauthError
42
43
  ? description
43
44
  ? `${oauthError}: ${description}`
44
45
  : oauthError
45
46
  : description || String(status);
46
- super(`${message}: ${detail}`);
47
- this.status = status;
47
+ super(`${message}: ${detail}`, status);
48
48
  this.oauthError = oauthError;
49
49
  }
50
50
  }
@@ -76,7 +76,7 @@ async function requestOAuthToken(gateway, body, signal) {
76
76
  }
77
77
  catch (error) {
78
78
  if (signal.aborted) {
79
- throw new Error("Login cancelled");
79
+ throw new Error("Login cancelled", { cause: signal.reason });
80
80
  }
81
81
  throw error;
82
82
  }
@@ -149,7 +149,7 @@ async function requestDeviceAuthorization(gateway, signal) {
149
149
  }
150
150
  catch (error) {
151
151
  if (signal.aborted) {
152
- throw new Error("Login cancelled");
152
+ throw new Error("Login cancelled", { cause: signal.reason });
153
153
  }
154
154
  throw error;
155
155
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * xAI OAuth device-code flow.
3
3
  */
4
+ import { OAuthTokenEndpointError } from "../../utils/oauth-refresh-error.js";
4
5
  import { pollOAuthDeviceCodeFlow } from "./device-code.js";
5
6
  const XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
6
7
  const XAI_SCOPE = "openid profile email offline_access grok-cli:access api:access";
@@ -53,7 +54,7 @@ async function postForm(url, fields, signal) {
53
54
  }
54
55
  catch (error) {
55
56
  if (signal.aborted) {
56
- throw new Error("Login cancelled");
57
+ throw new Error("Login cancelled", { cause: signal.reason });
57
58
  }
58
59
  throw error;
59
60
  }
@@ -64,9 +65,11 @@ async function postForm(url, fields, signal) {
64
65
  }
65
66
  catch {
66
67
  if (signal.aborted) {
67
- throw new Error("Login cancelled");
68
+ throw new Error("Login cancelled", { cause: signal.reason });
68
69
  }
69
- throw new Error(`xAI OAuth returned invalid JSON (HTTP ${response.status})`);
70
+ const message = `xAI OAuth returned invalid JSON (HTTP ${response.status})`;
71
+ // An error page (an HTML 503 from a proxy, say) still carries the status the retry decision needs.
72
+ throw response.ok ? new Error(message) : new OAuthTokenEndpointError(message, response.status);
70
73
  }
71
74
  return {
72
75
  ok: response.ok,
@@ -78,7 +81,7 @@ function requestFailure(action, response) {
78
81
  const error = typeof response.body.error === "string" ? response.body.error : undefined;
79
82
  const description = typeof response.body.error_description === "string" ? response.body.error_description : undefined;
80
83
  const detail = [error, description].filter(Boolean).join(": ");
81
- return new Error(`xAI OAuth ${action} failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`);
84
+ return new OAuthTokenEndpointError(`xAI OAuth ${action} failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`, response.status);
82
85
  }
83
86
  function parseDeviceCode(body) {
84
87
  // RFC 8628 allows interval 0 (no minimum wait); fall back to the poller's
@@ -15,6 +15,7 @@
15
15
  * superseded catalog refresh stops waiting but never aborts the exchange.
16
16
  */
17
17
  import { raceWithAbortSignal } from "../utils/abort.js";
18
+ import { oauthRefreshFailureCause } from "../utils/oauth-refresh-error.js";
18
19
  import { mergeRefreshed, mergeRefreshedSlot, projectSlot } from "./pool/slots.js";
19
20
  export const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 15_000;
20
21
  /** The provider rejected or timed out the token exchange. */
@@ -103,6 +104,15 @@ async function exchangeAndStore(request, signal) {
103
104
  signal.throwIfAborted();
104
105
  }
105
106
  catch (error) {
107
+ signal.throwIfAborted();
108
+ const causeClass = oauthRefreshFailureCause(error);
109
+ if (causeClass !== undefined) {
110
+ console.warn("OAuth refresh unavailable", JSON.stringify({
111
+ provider: providerId,
112
+ ...(slotName ? { slot: slotName } : {}),
113
+ cause: causeClass,
114
+ }));
115
+ }
106
116
  throw new OAuthRefreshExchangeError(error);
107
117
  }
108
118
  try {
@@ -1,5 +1,6 @@
1
1
  import { operationSignal, raceWithAbortSignal } from "../utils/abort.js";
2
2
  import { ModelsError } from "../utils/models-error.js";
3
+ import { classifyOAuthRefreshFailure, OAuthRefreshUnavailableError } from "../utils/oauth-refresh-error.js";
3
4
  import { OAuthRefreshExchangeError, OAuthRefreshStoreError, projectOAuthSlot, refreshOAuthCredential, } from "./oauth-refresh.js";
4
5
  import { projectSlot } from "./pool/slots.js";
5
6
  export { ModelsError } from "../utils/models-error.js";
@@ -82,6 +83,9 @@ export function oauthRefreshModelsError(error, providerId) {
82
83
  if (error instanceof ModelsError)
83
84
  return error;
84
85
  if (error instanceof OAuthRefreshExchangeError) {
86
+ if (classifyOAuthRefreshFailure(error.cause) === "transient") {
87
+ return new OAuthRefreshUnavailableError(providerId, error.cause);
88
+ }
85
89
  return new ModelsError("oauth", `OAuth refresh failed for ${providerId}`, { cause: error.cause });
86
90
  }
87
91
  const cause = error instanceof OAuthRefreshStoreError ? error.cause : error;
@@ -112,6 +116,7 @@ async function resolveStoredOAuth(credentials, providerId, oauth, stored, authCo
112
116
  });
113
117
  }
114
118
  catch (error) {
119
+ signal.throwIfAborted();
115
120
  throw oauthRefreshModelsError(error, providerId);
116
121
  }
117
122
  if (post?.type !== "oauth")
@@ -50,7 +50,7 @@ declare const FORK_OWNED_CATALOGS: {
50
50
  readonly input: 3;
51
51
  readonly output: 15;
52
52
  readonly cacheRead: 0.3;
53
- readonly cacheWrite: 0;
53
+ readonly cacheWrite: 3;
54
54
  };
55
55
  readonly contextWindow: 1048576;
56
56
  readonly maxTokens: 131072;