@code-yeongyu/senpi-ai 2026.10.4 → 2026.10.6

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.
@@ -67,6 +67,7 @@ export declare function isRecoverableLength(message: AssistantMessage, desiredMa
67
67
  */
68
68
  export declare function getOverflowPatterns(): RegExp[];
69
69
  export declare function isCursorPayloadResourceExhausted(message: {
70
+ provider?: string;
70
71
  stopReason?: string;
71
72
  errorMessage?: string;
72
73
  usage?: {
@@ -83,6 +84,7 @@ export declare function isCursorPayloadResourceExhausted(message: {
83
84
  * context window.
84
85
  */
85
86
  export declare function isCursorQuotaResourceExhausted(message: {
87
+ provider?: string;
86
88
  stopReason?: string;
87
89
  errorMessage?: string;
88
90
  usage?: {
@@ -94,6 +96,7 @@ export declare function isCursorQuotaResourceExhausted(message: {
94
96
  };
95
97
  }, contextWindow: number): boolean;
96
98
  export declare function isCursorZeroTokenResourceExhausted(message: {
99
+ provider?: string;
97
100
  stopReason?: string;
98
101
  errorMessage?: string;
99
102
  usage?: {
@@ -215,6 +215,15 @@ export function isRecoverableLength(message, desiredMaxOutput) {
215
215
  export function getOverflowPatterns() {
216
216
  return [...OVERFLOW_PATTERNS];
217
217
  }
218
+ const CURSOR_PROVIDERS = new Set(["cursor", "cursor-cli-oauth"]);
219
+ /**
220
+ * The zero-token / quota `resource_exhausted` signatures below describe Cursor's backend only.
221
+ * Other providers send `resource_exhausted` for their own rate and usage limits (senpi#2660), and
222
+ * reading those as a Cursor payload overflow or re-mint skips the fallback chain.
223
+ */
224
+ function isCursorProviderMessage(message) {
225
+ return message.provider === undefined || CURSOR_PROVIDERS.has(message.provider);
226
+ }
218
227
  function cursorZeroTokenCount(message) {
219
228
  const usage = message.usage;
220
229
  if (!usage)
@@ -222,6 +231,8 @@ function cursorZeroTokenCount(message) {
222
231
  return (usage.totalTokens || (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0));
223
232
  }
224
233
  export function isCursorPayloadResourceExhausted(message, _estimateTokens) {
234
+ if (!isCursorProviderMessage(message))
235
+ return false;
225
236
  if (message.stopReason !== "error" || !/resource.?exhausted/i.test(message.errorMessage || "")) {
226
237
  return false;
227
238
  }
@@ -234,13 +245,16 @@ export function isCursorPayloadResourceExhausted(message, _estimateTokens) {
234
245
  */
235
246
  export function isCursorQuotaResourceExhausted(message, contextWindow) {
236
247
  const tokens = cursorZeroTokenCount(message);
237
- return (message.stopReason === "error" &&
248
+ return (isCursorProviderMessage(message) &&
249
+ message.stopReason === "error" &&
238
250
  RESOURCE_EXHAUSTED_PATTERN.test(message.errorMessage || "") &&
239
251
  contextWindow > 0 &&
240
252
  tokens > 0 &&
241
253
  tokens < contextWindow * 0.5);
242
254
  }
243
255
  export function isCursorZeroTokenResourceExhausted(message) {
256
+ if (!isCursorProviderMessage(message))
257
+ return false;
244
258
  if (message.stopReason !== "error" || !/resource.?exhausted/i.test(message.errorMessage || "")) {
245
259
  return false;
246
260
  }
@@ -6,7 +6,7 @@
6
6
  * 2. retry-after header (strict integer delta-seconds `^\d+$`, else HTTP-date)
7
7
  * 3. x-ratelimit-reset{,-requests,-tokens} (epoch seconds, max-wins)
8
8
  * 4. JSON retryDelay strings (ms/s/m units, recursive, max-wins; numeric rejected)
9
- * 5. body prose (ms-marker, seconds-marker, relative, resets-at ISO8601)
9
+ * 5. body prose (ms-marker, seconds-marker, relative, resets-in, resets-at ISO8601)
10
10
  *
11
11
  * Eligibility gate: status 429 OR body marks rate-limit error.
12
12
  * Explicit zero = 0 (beats lower-precedence positives).
@@ -6,7 +6,7 @@
6
6
  * 2. retry-after header (strict integer delta-seconds `^\d+$`, else HTTP-date)
7
7
  * 3. x-ratelimit-reset{,-requests,-tokens} (epoch seconds, max-wins)
8
8
  * 4. JSON retryDelay strings (ms/s/m units, recursive, max-wins; numeric rejected)
9
- * 5. body prose (ms-marker, seconds-marker, relative, resets-at ISO8601)
9
+ * 5. body prose (ms-marker, seconds-marker, relative, resets-in, resets-at ISO8601)
10
10
  *
11
11
  * Eligibility gate: status 429 OR body marks rate-limit error.
12
12
  * Explicit zero = 0 (beats lower-precedence positives).
@@ -132,6 +132,10 @@ function fromBodyProse(bodyText, nowMs) {
132
132
  return Number.parseInt(m[1], 10) * 1000;
133
133
  // relative prose: try again | retry | wait after ... [in] N <unit>
134
134
  m = bodyText.match(/(?:try again|retry|wait after).*?(?:in\s+)?(\d+(?:\.\d+)?)\s*(ms|s|sec|seconds?|m|min|minutes?)\b/i);
135
+ if (m)
136
+ return convertUnit(Number.parseFloat(m[1]), m[2]);
137
+ // limit prose: [limit will] reset(s) in N <unit>
138
+ m = bodyText.match(/\bresets?\s+in\s+(\d+(?:\.\d+)?)\s*(ms|s|sec|seconds?|m|min|minutes?)\b/i);
135
139
  if (m)
136
140
  return convertUnit(Number.parseFloat(m[1]), m[2]);
137
141
  // resets at <ISO8601-with-tz>
@@ -2,6 +2,16 @@ import type { Api, Model } from "../types.ts";
2
2
  /** The wire identity a forced tool_choice refusal is remembered under. */
3
3
  export type ForcedToolChoiceTarget = Pick<Model<Api>, "api" | "provider" | "baseUrl" | "id">;
4
4
  export declare function isForcedToolChoiceUnsupportedError(error: unknown, sentForcedToolChoice: boolean): boolean;
5
+ /**
6
+ * A 400 to a request that forced a tool and whose message names that very tool: its `tools.N` /
7
+ * `tools[N]` position or its quoted name. Strict-schema gateways refuse the forced tool's schema this
8
+ * way (senpi#2648); the same request without the forced choice is accepted. A 400 that names
9
+ * no tool, or another one, is not this refusal.
10
+ */
11
+ export declare function refusalNamesForcedTool(error: unknown, params: {
12
+ readonly tool_choice?: unknown;
13
+ readonly tools?: unknown;
14
+ }): boolean;
5
15
  export declare function omitToolChoiceParam<TParams extends {
6
16
  tool_choice?: unknown;
7
17
  }>(params: TParams): TParams;
@@ -50,6 +50,44 @@ export function isForcedToolChoiceUnsupportedError(error, sentForcedToolChoice)
50
50
  // cannot force tool use. Use tool_choice 'auto' or 'none'."
51
51
  /tool[_\s-]?choice\s+cannot\s+force\s+tool\s+use/is.test(message));
52
52
  }
53
+ /** The tool a `tool_choice` forces, in the wire shapes senpi sends (Chat Completions, Responses, Messages). */
54
+ function forcedToolName(toolChoice) {
55
+ if (!isRecord(toolChoice))
56
+ return undefined;
57
+ const named = isRecord(toolChoice.function) ? toolChoice.function.name : toolChoice.name;
58
+ return typeof named === "string" && named.length > 0 ? named : undefined;
59
+ }
60
+ function toolNames(tools) {
61
+ if (!Array.isArray(tools))
62
+ return [];
63
+ return tools.map((tool) => {
64
+ if (!isRecord(tool))
65
+ return undefined;
66
+ const named = isRecord(tool.function) ? tool.function.name : tool.name;
67
+ return typeof named === "string" ? named : undefined;
68
+ });
69
+ }
70
+ function escapeRegExp(text) {
71
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
72
+ }
73
+ /**
74
+ * A 400 to a request that forced a tool and whose message names that very tool: its `tools.N` /
75
+ * `tools[N]` position or its quoted name. Strict-schema gateways refuse the forced tool's schema this
76
+ * way (senpi#2648); the same request without the forced choice is accepted. A 400 that names
77
+ * no tool, or another one, is not this refusal.
78
+ */
79
+ export function refusalNamesForcedTool(error, params) {
80
+ if (extractHttpStatus(error) !== 400)
81
+ return false;
82
+ const name = forcedToolName(params.tool_choice);
83
+ if (name === undefined)
84
+ return false;
85
+ const message = errorMessage(error);
86
+ const index = toolNames(params.tools).indexOf(name);
87
+ if (index >= 0 && new RegExp(`\\btools(?:\\.${index}|\\[${index}\\])(?!\\d)`).test(message))
88
+ return true;
89
+ return new RegExp(`['"\`]${escapeRegExp(name)}['"\`]`).test(message);
90
+ }
53
91
  export function omitToolChoiceParam(params) {
54
92
  const nextParams = { ...params };
55
93
  delete nextParams.tool_choice;
@@ -82,7 +120,8 @@ export async function sendWithForcedToolChoiceFallback(request) {
82
120
  return { params: request.params, result: await request.send(request.params) };
83
121
  }
84
122
  catch (error) {
85
- if (!isForcedToolChoiceUnsupportedError(error, forced))
123
+ if (!isForcedToolChoiceUnsupportedError(error, forced) &&
124
+ !(forced && refusalNamesForcedTool(error, request.params)))
86
125
  throw error;
87
126
  const params = omitToolChoiceParam(request.params);
88
127
  const result = await request.send(params);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-ai",
3
- "version": "2026.10.4",
3
+ "version": "2026.10.6",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -88,7 +88,7 @@
88
88
  "dependencies": {
89
89
  "@anthropic-ai/sdk": "0.127.0",
90
90
  "@aws-sdk/client-bedrock-runtime": "3.1136.0",
91
- "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.4",
91
+ "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.6",
92
92
  "@google/genai": "2.23.0",
93
93
  "@smithy/node-http-handler": "4.12.1",
94
94
  "http-proxy-agent": "9.1.0",