@code-yeongyu/senpi-ai 2026.10.10 → 2026.10.11

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 (58) hide show
  1. package/dist/api/anthropic-messages.js +101 -38
  2. package/dist/api/anthropic-tool-references.js +24 -18
  3. package/dist/api/bedrock-converse-stream.js +5 -1
  4. package/dist/api/google-shared.js +101 -21
  5. package/dist/api/lazy.js +13 -0
  6. package/dist/api/mistral-conversations.js +18 -3
  7. package/dist/api/openai-codex-responses.js +2 -2
  8. package/dist/api/openai-completions.js +26 -2
  9. package/dist/api/openai-responses-allowed-tools.d.ts +33 -0
  10. package/dist/api/openai-responses-allowed-tools.js +108 -0
  11. package/dist/api/openai-responses.js +43 -58
  12. package/dist/auth/oauth/anthropic.js +4 -3
  13. package/dist/auth/oauth/chatgpt-subscription.js +3 -2
  14. package/dist/auth/oauth/cursor.js +3 -2
  15. package/dist/auth/oauth/devin-token.js +2 -1
  16. package/dist/auth/oauth/github-copilot.js +3 -2
  17. package/dist/auth/oauth/kimi-coding.js +5 -4
  18. package/dist/auth/oauth/openai-chatgpt.js +2 -1
  19. package/dist/auth/oauth/openrouter.js +2 -1
  20. package/dist/auth/oauth/radius.js +5 -5
  21. package/dist/auth/oauth/xai.js +7 -4
  22. package/dist/auth/oauth-refresh.js +10 -0
  23. package/dist/auth/resolve.js +5 -0
  24. package/dist/openai-responses-compat.d.ts +7 -2
  25. package/dist/openai-responses-compat.js +15 -2
  26. package/dist/providers/all.d.ts +1 -1
  27. package/dist/providers/data/.manifest.json +1 -1
  28. package/dist/providers/data/amazon-bedrock.json +1 -1
  29. package/dist/providers/data/anthropic.json +1 -1
  30. package/dist/providers/data/baseten.json +1 -1
  31. package/dist/providers/data/chatgpt-subscription.json +1 -1
  32. package/dist/providers/data/cloudflare-ai-gateway.json +1 -1
  33. package/dist/providers/data/cloudflare-workers-ai.json +1 -1
  34. package/dist/providers/data/github-copilot.json +1 -1
  35. package/dist/providers/data/google-vertex.json +1 -1
  36. package/dist/providers/data/huggingface.json +1 -1
  37. package/dist/providers/data/mistral.json +1 -1
  38. package/dist/providers/data/moonshotai-cn.json +1 -1
  39. package/dist/providers/data/moonshotai.json +1 -1
  40. package/dist/providers/data/opencode-go.json +1 -1
  41. package/dist/providers/data/opencode.json +1 -1
  42. package/dist/providers/data/opengateway.json +1 -1
  43. package/dist/providers/data/openrouter.json +1 -1
  44. package/dist/providers/data/radius.json +1 -1
  45. package/dist/providers/data/venice.json +1 -1
  46. package/dist/providers/data/vercel-ai-gateway.json +1 -1
  47. package/dist/providers/kimi-coding.models.d.ts +1 -1
  48. package/dist/providers/kimi-coding.models.js +1 -1
  49. package/dist/providers/opengateway-refresh.js +2 -1
  50. package/dist/types.d.ts +3 -2
  51. package/dist/utils/oauth-refresh-error.d.ts +16 -0
  52. package/dist/utils/oauth-refresh-error.js +67 -0
  53. package/dist/utils/prompt-cache-ttl.js +3 -1
  54. package/dist/utils/retry.d.ts +6 -0
  55. package/dist/utils/retry.js +10 -0
  56. package/dist/utils/tool-choice-fallback.d.ts +11 -0
  57. package/dist/utils/tool-choice-fallback.js +69 -2
  58. package/package.json +2 -2
@@ -14,7 +14,7 @@ import { getProviderEnvValue } from "../utils/provider-env.js";
14
14
  import { retryProviderStreamRequest } from "../utils/provider-retry.js";
15
15
  import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
16
16
  import { getSystemMessageText, renderSystemMessageUpdate } from "../utils/text.js";
17
- import { sendWithForcedToolChoiceFallback } from "../utils/tool-choice-fallback.js";
17
+ import { primeStreamUntilContent, sendWithForcedToolChoiceFallback } from "../utils/tool-choice-fallback.js";
18
18
  import { normalizeToolParametersForMoonshot, normalizeToolParametersForOpenAICompat, } from "../utils/tool-schema-compat.js";
19
19
  import { getCurrentTools, getDeclaredTools, resolveTranscript, resolveTranscriptTools, } from "../utils/transcript.js";
20
20
  import { appendGrammarToolInputJsonDelta, createGrammarToolInputProperties, getGrammarToolInput, getJsonSchemaToolParameters, resolveGrammarConstrainedSampling, resolveJsonSchemaStrictSampling, } from "./constrained-sampling.js";
@@ -169,6 +169,23 @@ function getOpenRouterRawMetadata(error) {
169
169
  function isForcedOpenAICompletionsToolChoice(toolChoice) {
170
170
  return toolChoice !== undefined && toolChoice !== "auto" && toolChoice !== "none";
171
171
  }
172
+ /** A chunk that carries model output or a finish: text, reasoning, a tool call, or a finish_reason. */
173
+ function chatChunkHasContent(chunk) {
174
+ if (!isRecord(chunk) || !Array.isArray(chunk.choices))
175
+ return false;
176
+ return chunk.choices.some((choice) => {
177
+ if (!isRecord(choice))
178
+ return false;
179
+ if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0)
180
+ return true;
181
+ const delta = choice.delta;
182
+ if (!isRecord(delta))
183
+ return false;
184
+ if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0)
185
+ return true;
186
+ return ["content", "reasoning_content", "reasoning", "reasoning_text"].some((field) => typeof delta[field] === "string" && delta[field].length > 0);
187
+ });
188
+ }
172
189
  function isReasoningDetailObject(detail) {
173
190
  return typeof detail === "object" && detail !== null && !Array.isArray(detail);
174
191
  }
@@ -362,7 +379,14 @@ export const stream = (model, context, options) => {
362
379
  params,
363
380
  acceptsForcedToolChoice: compat.supportsForcedToolChoice !== false,
364
381
  isForced: isForcedOpenAICompletionsToolChoice,
365
- send: createStream,
382
+ // A keepalive gateway answers 200 and refuses a forced choice in-band (senpi#2801): a forced
383
+ // request is read up to its first content so that refusal can still be retried.
384
+ send: async (sentParams) => {
385
+ const created = await createStream(sentParams);
386
+ if (!isForcedOpenAICompletionsToolChoice(sentParams.tool_choice))
387
+ return created;
388
+ return { ...created, data: await primeStreamUntilContent(created.data, chatChunkHasContent) };
389
+ },
366
390
  });
367
391
  params = sent.params;
368
392
  return sent.result;
@@ -0,0 +1,33 @@
1
+ import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
2
+ import type { Api, Model, OpenAIResponsesCompat, TranscriptContext } from "../types.ts";
3
+ export type AllowedToolsTarget = Pick<Model<Api>, "api" | "provider" | "baseUrl" | "id">;
4
+ export declare function hasRefusedAllowedToolsChoice(target: AllowedToolsTarget): boolean;
5
+ export declare function rememberAllowedToolsChoiceRefusal(target: AllowedToolsTarget): void;
6
+ export declare function clearAllowedToolsChoiceRefusals(): void;
7
+ /**
8
+ * A refusal of `allowed_tools` as a `tool_choice` type, sent to a request that carried one. Over HTTP it is
9
+ * a 400; a WebSocket error event before any content carries no status, so the caller vouches for it with
10
+ * `beforeContent`. Observed on a Responses-compatible gateway: "Invalid value: 'allowed_tools'. Supported
11
+ * values are: ..." with param `tool_choice.type`.
12
+ */
13
+ export declare function isAllowedToolsChoiceRefusal(error: unknown, params: {
14
+ readonly tool_choice?: unknown;
15
+ }, options?: {
16
+ readonly beforeContent?: boolean;
17
+ }): boolean;
18
+ /**
19
+ * The pre-senpi#2095 request shape for an `allowed_tools` request: top-level function and custom tools
20
+ * outside the allowed list leave `tools`, hosted tools stay, and `tool_choice` goes. Tools that transcript
21
+ * items load in place are not filtered; a call to an inactive one is still refused by the session's active
22
+ * tool set. Any other request is unchanged.
23
+ */
24
+ export declare function restrictToAllowedTools<TParams extends ResponseCreateParamsStreaming>(params: TParams): TParams;
25
+ /**
26
+ * senpi#2095: `tools` carries every tool declared this session, so removing a tool never rewrites the
27
+ * cached prefix; the callable subset rides `tool_choice: allowed_tools` instead. Hosted tools a payload
28
+ * hook added stay callable, and deferred tools (declared by transcript items rather than `tools`) are
29
+ * referenced by name. A declared tool a payload hook removed from `tools` is not referenced (senpi#2234).
30
+ * An empty subset forbids tool calls. An explicit `tool_choice` always wins.
31
+ */
32
+ export declare function applyAllowedToolsChoice<TParams extends ResponseCreateParamsStreaming>(params: TParams, context: TranscriptContext, activeToolNames: readonly string[] | undefined, compat: Required<OpenAIResponsesCompat>): TParams;
33
+ //# sourceMappingURL=openai-responses-allowed-tools.d.ts.map
@@ -0,0 +1,108 @@
1
+ import { isRequestRefusal } from "../utils/tool-choice-fallback.js";
2
+ import { getCurrentTools } from "../utils/transcript.js";
3
+ import { convertResponsesTools, resolveResponsesDeferredToolsMode, resolveResponsesToolPlacement, } from "./openai-responses-shared.js";
4
+ /**
5
+ * Models whose endpoint refused `tool_choice: allowed_tools` and then accepted the same request
6
+ * restricted to the active tools, for the life of the process (senpi#3080).
7
+ */
8
+ const allowedToolsRefusals = new Set();
9
+ function refusalKey(target) {
10
+ return JSON.stringify([target.api, target.provider, target.baseUrl, target.id]);
11
+ }
12
+ export function hasRefusedAllowedToolsChoice(target) {
13
+ return allowedToolsRefusals.has(refusalKey(target));
14
+ }
15
+ export function rememberAllowedToolsChoiceRefusal(target) {
16
+ allowedToolsRefusals.add(refusalKey(target));
17
+ }
18
+ export function clearAllowedToolsChoiceRefusals() {
19
+ allowedToolsRefusals.clear();
20
+ }
21
+ function allowedReference(tool) {
22
+ if (tool.type === "function" || tool.type === "custom")
23
+ return { type: tool.type, name: tool.name };
24
+ if (tool.type === "mcp")
25
+ return { type: "mcp", server_label: tool.server_label };
26
+ return { type: tool.type };
27
+ }
28
+ function allowedToolsChoice(params) {
29
+ const choice = params.tool_choice;
30
+ if (typeof choice !== "object" || choice === null)
31
+ return undefined;
32
+ const { type, tools } = choice;
33
+ return type === "allowed_tools" && Array.isArray(tools) ? tools : undefined;
34
+ }
35
+ function errorMessage(error) {
36
+ return error instanceof Error ? error.message : String(error);
37
+ }
38
+ /**
39
+ * A refusal of `allowed_tools` as a `tool_choice` type, sent to a request that carried one. Over HTTP it is
40
+ * a 400; a WebSocket error event before any content carries no status, so the caller vouches for it with
41
+ * `beforeContent`. Observed on a Responses-compatible gateway: "Invalid value: 'allowed_tools'. Supported
42
+ * values are: ..." with param `tool_choice.type`.
43
+ */
44
+ export function isAllowedToolsChoiceRefusal(error, params, options = {}) {
45
+ if (allowedToolsChoice(params) === undefined)
46
+ return false;
47
+ if (!options.beforeContent && !isRequestRefusal(error))
48
+ return false;
49
+ const message = errorMessage(error);
50
+ return /\ballowed_tools\b/.test(message) && /invalid|unsupported|not\s+supported/i.test(message);
51
+ }
52
+ /**
53
+ * The pre-senpi#2095 request shape for an `allowed_tools` request: top-level function and custom tools
54
+ * outside the allowed list leave `tools`, hosted tools stay, and `tool_choice` goes. Tools that transcript
55
+ * items load in place are not filtered; a call to an inactive one is still refused by the session's active
56
+ * tool set. Any other request is unchanged.
57
+ */
58
+ export function restrictToAllowedTools(params) {
59
+ const allowed = allowedToolsChoice(params);
60
+ if (allowed === undefined)
61
+ return params;
62
+ const allowedNames = new Set(allowed.map((tool) => tool.name).filter((name) => typeof name === "string"));
63
+ const tools = (params.tools ?? []).filter((tool) => (tool.type !== "function" && tool.type !== "custom") || allowedNames.has(tool.name));
64
+ const restricted = { ...params, tools };
65
+ delete restricted.tool_choice;
66
+ return restricted;
67
+ }
68
+ /**
69
+ * senpi#2095: `tools` carries every tool declared this session, so removing a tool never rewrites the
70
+ * cached prefix; the callable subset rides `tool_choice: allowed_tools` instead. Hosted tools a payload
71
+ * hook added stay callable, and deferred tools (declared by transcript items rather than `tools`) are
72
+ * referenced by name. A declared tool a payload hook removed from `tools` is not referenced (senpi#2234).
73
+ * An empty subset forbids tool calls. An explicit `tool_choice` always wins.
74
+ */
75
+ export function applyAllowedToolsChoice(params, context, activeToolNames, compat) {
76
+ if (!compat.supportsAllowedTools || activeToolNames === undefined || params.tool_choice !== undefined) {
77
+ return params;
78
+ }
79
+ const active = new Set(activeToolNames);
80
+ const declaredTools = getCurrentTools(context.messages);
81
+ if (declaredTools.every((tool) => active.has(tool.name)))
82
+ return params;
83
+ const allowed = [];
84
+ const namedInTools = new Set();
85
+ for (const tool of params.tools ?? []) {
86
+ if (tool.type === "function" || tool.type === "custom") {
87
+ namedInTools.add(tool.name);
88
+ if (!active.has(tool.name))
89
+ continue;
90
+ }
91
+ allowed.push(allowedReference(tool));
92
+ }
93
+ // Tools missing from the request-level `tools` placement are the ones transcript items load in place.
94
+ const requestToolNames = new Set(resolveResponsesToolPlacement(context.messages, resolveResponsesDeferredToolsMode(compat) !== undefined).requestTools.map((tool) => tool.name));
95
+ for (const tool of declaredTools) {
96
+ if (namedInTools.has(tool.name) || !active.has(tool.name) || requestToolNames.has(tool.name))
97
+ continue;
98
+ const [converted] = convertResponsesTools([tool], {
99
+ supportsStrictMode: compat.supportsStrictMode,
100
+ supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools,
101
+ });
102
+ if (converted)
103
+ allowed.push(allowedReference(converted));
104
+ }
105
+ const toolChoice = allowed.length > 0 ? { type: "allowed_tools", mode: "auto", tools: allowed } : "none";
106
+ return { ...params, tool_choice: toolChoice };
107
+ }
108
+ //# sourceMappingURL=openai-responses-allowed-tools.js.map
@@ -1,5 +1,6 @@
1
1
  import OpenAI from "openai";
2
2
  import { calculateCost, clampThinkingLevel, inferOpenAIThinkingLevelMap, supportsMax, supportsXhigh, } from "../models.js";
3
+ import { supportsAllowedToolChoice } from "../openai-responses-compat.js";
3
4
  import { readProviderDiagnostic } from "../provider-diagnostic.js";
4
5
  import { formatProviderError, normalizeProviderError } from "../utils/error-body.js";
5
6
  import { AssistantMessageEventStream } from "../utils/event-stream.js";
@@ -10,13 +11,14 @@ import { awaitProviderTransport, openAICompatibleProviderDiagnosticFromError, }
10
11
  import { getProviderEnvValue } from "../utils/provider-env.js";
11
12
  import { retryProviderRequest } from "../utils/provider-retry.js";
12
13
  import { sendWithForcedToolChoiceFallback } from "../utils/tool-choice-fallback.js";
13
- import { getCurrentTools, getDeclaredTools, normalizeContext, resolveTranscript } from "../utils/transcript.js";
14
+ import { getDeclaredTools, normalizeContext, resolveTranscript } from "../utils/transcript.js";
14
15
  import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js";
15
16
  import { createGrammarToolInputProperties } from "./constrained-sampling.js";
16
17
  import { withGitHubCopilotFailureNote } from "./github-copilot-errors.js";
17
18
  import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
18
19
  import { resolveOpenAIClientAuth } from "./openai-client-auth.js";
19
20
  import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js";
21
+ import { applyAllowedToolsChoice, hasRefusedAllowedToolsChoice, isAllowedToolsChoiceRefusal, rememberAllowedToolsChoiceRefusal, restrictToAllowedTools, } from "./openai-responses-allowed-tools.js";
20
22
  import { findPromptCacheComparisonResponseId, withPromptCacheComparison, } from "./openai-responses-prompt-cache.js";
21
23
  import { convertResponsesMessages, convertResponsesTools, processResponsesStream, resolveResponsesDeferredToolsMode, resolveResponsesToolPlacement, } from "./openai-responses-shared.js";
22
24
  import { buildBaseOptions, clampMaxForOpenAI, OPENAI_RESPONSES_RESERVED_BODY_KEYS } from "./simple-options.js";
@@ -70,7 +72,7 @@ function getCompat(model, env) {
70
72
  supportsConfigurationUpdate: model.compat?.supportsConfigurationUpdate ?? false,
71
73
  supportsMaxOutputTokens: model.compat?.supportsMaxOutputTokens ?? true,
72
74
  supportsForcedToolChoice: model.compat?.supportsForcedToolChoice ?? true,
73
- supportsAllowedTools: model.compat?.supportsAllowedTools ?? false,
75
+ supportsAllowedTools: supportsAllowedToolChoice(model),
74
76
  };
75
77
  }
76
78
  function isOpenAIResponsesNativeEndpoint(model, env) {
@@ -141,53 +143,6 @@ function sanitizeUnsupportedNativeTools(params, compat) {
141
143
  }
142
144
  return sanitized ? sanitized : params;
143
145
  }
144
- function allowedReference(tool) {
145
- if (tool.type === "function" || tool.type === "custom")
146
- return { type: tool.type, name: tool.name };
147
- if (tool.type === "mcp")
148
- return { type: "mcp", server_label: tool.server_label };
149
- return { type: tool.type };
150
- }
151
- /**
152
- * senpi#2095: `tools` carries every tool declared this session, so removing a tool never rewrites the
153
- * cached prefix; the callable subset rides `tool_choice: allowed_tools` instead. Hosted tools a payload
154
- * hook added stay callable, and deferred tools (declared by transcript items rather than `tools`) are
155
- * referenced by name. A declared tool a payload hook removed from `tools` is not referenced (senpi#2234).
156
- * An empty subset forbids tool calls. An explicit `tool_choice` always wins.
157
- */
158
- function applyAllowedToolsChoice(params, context, activeToolNames, compat) {
159
- if (!compat.supportsAllowedTools || activeToolNames === undefined || params.tool_choice !== undefined) {
160
- return params;
161
- }
162
- const active = new Set(activeToolNames);
163
- const declaredTools = getCurrentTools(context.messages);
164
- if (declaredTools.every((tool) => active.has(tool.name)))
165
- return params;
166
- const allowed = [];
167
- const namedInTools = new Set();
168
- for (const tool of params.tools ?? []) {
169
- if (tool.type === "function" || tool.type === "custom") {
170
- namedInTools.add(tool.name);
171
- if (!active.has(tool.name))
172
- continue;
173
- }
174
- allowed.push(allowedReference(tool));
175
- }
176
- // Tools missing from the request-level `tools` placement are the ones transcript items load in place.
177
- const requestToolNames = new Set(resolveResponsesToolPlacement(context.messages, resolveResponsesDeferredToolsMode(compat) !== undefined).requestTools.map((tool) => tool.name));
178
- for (const tool of declaredTools) {
179
- if (namedInTools.has(tool.name) || !active.has(tool.name) || requestToolNames.has(tool.name))
180
- continue;
181
- const [converted] = convertResponsesTools([tool], {
182
- supportsStrictMode: compat.supportsStrictMode,
183
- supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools,
184
- });
185
- if (converted)
186
- allowed.push(allowedReference(converted));
187
- }
188
- const toolChoice = allowed.length > 0 ? { type: "allowed_tools", mode: "auto", tools: allowed } : "none";
189
- return { ...params, tool_choice: toolChoice };
190
- }
191
146
  function formatOpenAIResponsesError(error, provider) {
192
147
  const errorMessage = formatProviderError(normalizeProviderError(error), `${provider === "openai" ? "OpenAI" : provider} API error`);
193
148
  // Sign in with ChatGPT shares the subscription's usage limit with other apps.
@@ -234,12 +189,15 @@ export const stream = (model, context, options) => {
234
189
  }
235
190
  params = sanitizeUnsupportedNativeTools(params, compat);
236
191
  params = applyAllowedToolsChoice(params, normalizedContext, context.activeToolNames, compat);
192
+ if (hasRefusedAllowedToolsChoice(model))
193
+ params = restrictToAllowedTools(params);
237
194
  const limitedTools = limitGitHubCopilotTools(model.provider, params.tools, params.tool_choice);
238
195
  if (limitedTools.omittedCount > 0) {
239
196
  params = { ...params, tools: limitedTools.tools };
240
197
  recordGitHubCopilotToolLimit(output, limitedTools.omittedCount);
241
198
  }
242
199
  const transport = options?.transport ?? "sse";
200
+ let refusedAllowedToolsOverWebSocket = false;
243
201
  if (transport !== "sse" && compat.supportsWebSocket) {
244
202
  let websocketStarted = false;
245
203
  try {
@@ -254,9 +212,16 @@ export const stream = (model, context, options) => {
254
212
  return;
255
213
  }
256
214
  catch (error) {
257
- if (transport === "websocket" || websocketStarted) {
215
+ const refusedAllowedTools = output.content.length === 0 && isAllowedToolsChoiceRefusal(error, params, { beforeContent: true });
216
+ if (!refusedAllowedTools && (transport === "websocket" || websocketStarted)) {
258
217
  throw error;
259
218
  }
219
+ if (refusedAllowedTools) {
220
+ params = restrictToAllowedTools(params);
221
+ refusedAllowedToolsOverWebSocket = true;
222
+ output.stopReason = "pending";
223
+ delete output.errorMessage;
224
+ }
260
225
  }
261
226
  }
262
227
  const requestOptions = {
@@ -264,26 +229,46 @@ export const stream = (model, context, options) => {
264
229
  ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
265
230
  maxRetries: 0,
266
231
  };
267
- const createRequest = async () => {
232
+ const sendOnce = async (body) => {
268
233
  const sent = await sendWithForcedToolChoiceFallback({
269
234
  target: model,
270
- params,
235
+ params: body,
271
236
  acceptsForcedToolChoice: compat.supportsForcedToolChoice,
272
237
  isForced: isForcedOpenAIResponsesToolChoice,
273
- send: (body) => model.provider === "github-copilot"
274
- ? awaitProviderTransport(() => client.responses.create(body, requestOptions).withResponse(), openAICompatibleProviderDiagnosticFromError)
275
- : client.responses.create(body, requestOptions).withResponse(),
238
+ send: (request) => model.provider === "github-copilot"
239
+ ? awaitProviderTransport(() => client.responses.create(request, requestOptions).withResponse(), openAICompatibleProviderDiagnosticFromError)
240
+ : client.responses.create(request, requestOptions).withResponse(),
276
241
  });
277
242
  params = sent.params;
278
243
  return sent.result;
279
244
  };
245
+ // senpi#3080: an endpoint that refuses `allowed_tools` gets the request restricted to the active
246
+ // tools; the model is remembered once that restricted request is accepted.
247
+ const createRequest = async () => {
248
+ try {
249
+ const result = await sendOnce(params);
250
+ if (refusedAllowedToolsOverWebSocket)
251
+ rememberAllowedToolsChoiceRefusal(model);
252
+ return result;
253
+ }
254
+ catch (error) {
255
+ if (!isAllowedToolsChoiceRefusal(error, params))
256
+ throw error;
257
+ params = restrictToAllowedTools(params);
258
+ const result = await sendOnce(params);
259
+ rememberAllowedToolsChoiceRefusal(model);
260
+ return result;
261
+ }
262
+ };
280
263
  const { data: openaiStream, response } = await retryProviderRequest(() => createRequest(), {
281
264
  maxRetries: options?.maxRetries,
282
265
  maxRetryDelayMs: options?.maxRetryDelayMs,
283
266
  signal: options?.signal,
284
267
  });
285
268
  await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
286
- stream.push({ type: "start", partial: output });
269
+ // A WebSocket attempt that fell through after its refusal already pushed `start`.
270
+ if (!refusedAllowedToolsOverWebSocket)
271
+ stream.push({ type: "start", partial: output });
287
272
  await processResponsesStream(openaiStream, output, stream, model, {
288
273
  onProviderStreamEvent: options?.onProviderStreamEvent,
289
274
  serviceTier: options?.serviceTier,
@@ -861,9 +846,9 @@ function buildWebSocketHeaders(model, context, apiKey, optionsHeaders, sessionId
861
846
  function getServiceTierCostMultiplier(model, serviceTier) {
862
847
  switch (serviceTier) {
863
848
  case "ultrafast":
864
- // OpenAI publishes an Ultrafast price for GPT-6 Astra only: 6x Standard on every
849
+ // OpenAI prices GPT-6 Astra and GPT-6.1 Sol Ultrafast at 6x Standard on every
865
850
  // token class and context tier. Any other model keeps its base rate.
866
- return model.id === "gpt-6-astra" ? 6 : 1;
851
+ return ["gpt-6-astra", "gpt-6.1-sol"].includes(model.upstreamModelId ?? model.id) ? 6 : 1;
867
852
  case "flex":
868
853
  return 0.5;
869
854
  case "priority":
@@ -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
  }