@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
@@ -45,7 +45,7 @@ declare const values: {
45
45
  readonly input: 3;
46
46
  readonly output: 15;
47
47
  readonly cacheRead: 0.3;
48
- readonly cacheWrite: 0;
48
+ readonly cacheWrite: 3;
49
49
  };
50
50
  readonly contextWindow: 1048576;
51
51
  readonly maxTokens: 131072;
@@ -44,7 +44,7 @@ const values = {
44
44
  "input": 3,
45
45
  "output": 15,
46
46
  "cacheRead": 0.3,
47
- "cacheWrite": 0
47
+ "cacheWrite": 3
48
48
  },
49
49
  "contextWindow": 1048576,
50
50
  "maxTokens": 131072
@@ -10,6 +10,7 @@
10
10
  //
11
11
  // Any fetch or parse failure keeps the last good list and surfaces as a refresh error.
12
12
  import { isModelType } from "../utils/model-operations.js";
13
+ import { OAuthTokenEndpointError } from "../utils/oauth-refresh-error.js";
13
14
  import { applyOpenAiInputCap } from "../utils/openai-input-cap.js";
14
15
  import { isServableChatModel, OPENGATEWAY_BASE_URL, OPENGATEWAY_MODELS_URL, OPENGATEWAY_PRICES_URL, openGatewayPrice, parseOpenGatewayListing, parseOpenGatewayPriceTable, servingTierBase, withGatewayPrice, } from "./opengateway-catalog.js";
15
16
  export const OPENGATEWAY_REFRESH_INTERVAL_MS = 60 * 60 * 1000;
@@ -67,7 +68,7 @@ export function overlayOpenGatewayCatalog(shipped, listing, prices) {
67
68
  async function fetchJson(url, signal) {
68
69
  const response = await fetch(url, { headers: { accept: "application/json" }, signal });
69
70
  if (!response.ok)
70
- throw new Error(`OpenGateway catalog request failed: ${url} returned ${response.status}`);
71
+ throw new OAuthTokenEndpointError(`OpenGateway catalog request failed: ${url} returned ${response.status}`, response.status);
71
72
  return response.json();
72
73
  }
73
74
  async function fetchRefreshedCatalog(shipped, signal) {
package/dist/types.d.ts CHANGED
@@ -690,8 +690,9 @@ export interface Context {
690
690
  tools?: Tool[];
691
691
  /**
692
692
  * Names from `tools` the model may call on this request; absent means every tool is callable.
693
- * Set only for models whose compat declares `supportsAllowedTools`: the adapter keeps `tools`
694
- * byte-stable and restricts callability through `tool_choice` (senpi#2095).
693
+ * Set only for models that `supportsAllowedToolChoice` accepts (the compat flag on a native OpenAI API
694
+ * host): the adapter keeps `tools` byte-stable and restricts callability through `tool_choice`
695
+ * (senpi#2095, senpi#3080).
695
696
  */
696
697
  activeToolNames?: string[];
697
698
  }
@@ -0,0 +1,16 @@
1
+ import { ModelsError } from "./models-error.ts";
2
+ export declare class OAuthTokenEndpointError extends Error {
3
+ readonly status: number;
4
+ constructor(message: string, status: number);
5
+ }
6
+ /** Closed, log-safe facts only; never inspect the message or response body. */
7
+ export declare function oauthRefreshFailureCause(error: unknown): string | undefined;
8
+ export declare function classifyOAuthRefreshFailure(error: unknown): "transient" | "permanent";
9
+ declare const UNAVAILABLE: unique symbol;
10
+ export declare class OAuthRefreshUnavailableError extends ModelsError {
11
+ readonly [UNAVAILABLE] = true;
12
+ constructor(provider: string, cause: unknown);
13
+ }
14
+ export declare function isOAuthRefreshUnavailableError(error: unknown): error is OAuthRefreshUnavailableError;
15
+ export {};
16
+ //# sourceMappingURL=oauth-refresh-error.d.ts.map
@@ -0,0 +1,67 @@
1
+ var _a;
2
+ import { ModelsError } from "./models-error.js";
3
+ export class OAuthTokenEndpointError extends Error {
4
+ constructor(message, status) {
5
+ super(message);
6
+ this.status = status;
7
+ }
8
+ }
9
+ const TRANSPORT_CODES = new Set([
10
+ "ConnectionRefused",
11
+ "FailedToOpenSocket",
12
+ "ConnectionClosed",
13
+ "ECONNREFUSED",
14
+ "ECONNRESET",
15
+ "ENOTFOUND",
16
+ "EAI_AGAIN",
17
+ "ETIMEDOUT",
18
+ "EPIPE",
19
+ "UND_ERR_CONNECT_TIMEOUT",
20
+ "UND_ERR_HEADERS_TIMEOUT",
21
+ "UND_ERR_BODY_TIMEOUT",
22
+ "UND_ERR_SOCKET",
23
+ ]);
24
+ /** Both cause walks are bounded as well as cycle-safe: a refresh failure's cause chain is a few links deep. */
25
+ const MAX_CAUSE_LINKS = 16;
26
+ /** Closed, log-safe facts only; never inspect the message or response body. */
27
+ export function oauthRefreshFailureCause(error) {
28
+ const seen = new Set();
29
+ while (typeof error === "object" && error !== null && !seen.has(error) && seen.size < MAX_CAUSE_LINKS) {
30
+ seen.add(error);
31
+ if (Reflect.get(error, "name") === "TimeoutError")
32
+ return "timeout";
33
+ const code = Reflect.get(error, "code");
34
+ if (typeof code === "string" && TRANSPORT_CODES.has(code)) {
35
+ return code === "ConnectionRefused" || code === "ECONNREFUSED" ? "connection_refused" : code.toLowerCase();
36
+ }
37
+ const status = Reflect.get(error, "status");
38
+ if (typeof status === "number" && (status === 408 || status === 429 || (status >= 500 && status < 600))) {
39
+ return `http_${status}`;
40
+ }
41
+ error = Reflect.get(error, "cause");
42
+ }
43
+ return undefined;
44
+ }
45
+ export function classifyOAuthRefreshFailure(error) {
46
+ return oauthRefreshFailureCause(error) === undefined ? "permanent" : "transient";
47
+ }
48
+ const UNAVAILABLE = Symbol.for("senpi.oauthRefreshUnavailable");
49
+ export class OAuthRefreshUnavailableError extends ModelsError {
50
+ static { _a = UNAVAILABLE; }
51
+ constructor(provider, cause) {
52
+ super("oauth", `OAuth refresh failed for ${provider}`, { cause });
53
+ this[_a] = true;
54
+ this.name = "OAuthRefreshUnavailableError";
55
+ }
56
+ }
57
+ export function isOAuthRefreshUnavailableError(error) {
58
+ const seen = new Set();
59
+ while (typeof error === "object" && error !== null && !seen.has(error) && seen.size < MAX_CAUSE_LINKS) {
60
+ seen.add(error);
61
+ if (Reflect.get(error, UNAVAILABLE) === true)
62
+ return true;
63
+ error = Reflect.get(error, "cause");
64
+ }
65
+ return false;
66
+ }
67
+ //# sourceMappingURL=oauth-refresh-error.js.map
@@ -36,7 +36,9 @@ const FORCED_TOOL_CHOICE_REJECTING_MODEL_ID = /^claude-(?:(?:fable|mythos)(?:-|$
36
36
  /**
37
37
  * Default for `supportsToolReferences`: first-party Anthropic models except
38
38
  * Haiku (rejects client-side tool_reference blocks) and models that predate
39
- * tool search (Claude 3.x, Opus/Sonnet 4.0, Opus 4.1).
39
+ * tool search (Claude 3.x, Opus/Sonnet 4.0, Opus 4.1). Haiku 5.5 is listed in
40
+ * Anthropic's tool-search table, as Haiku 4.5 is, but stays off until a live
41
+ * probe confirms it (senpi#2914).
40
42
  */
41
43
  function defaultSupportsToolReferences(model) {
42
44
  if (model.provider !== "anthropic" || model.id.includes("haiku"))
@@ -1,4 +1,10 @@
1
1
  import type { AssistantMessage } from "../types.ts";
2
+ /**
3
+ * Diagnostic type on a terminal assistant message whose request failed because an OAuth refresh failed
4
+ * transiently (senpi#2893). Defined here, beside the classifier that reads it, so the retry entry graph
5
+ * stays free of the OAuth error types.
6
+ */
7
+ export declare const OAUTH_REFRESH_UNAVAILABLE_DIAGNOSTIC = "oauth_refresh_unavailable";
2
8
  export declare const PROVIDER_REQUEST_ID_MARKER = "request id:";
3
9
  export declare function formatProviderRequestId(label: string, id: string): string;
4
10
  export declare function stripProviderRequestIds(text: string): string;
@@ -1,4 +1,10 @@
1
1
  import { FORWARDED_EMPTY_RESPONSE_ERROR, FORWARDED_EMPTY_TOOL_USE_ERROR } from "./empty-response-errors.js";
2
+ /**
3
+ * Diagnostic type on a terminal assistant message whose request failed because an OAuth refresh failed
4
+ * transiently (senpi#2893). Defined here, beside the classifier that reads it, so the retry entry graph
5
+ * stays free of the OAuth error types.
6
+ */
7
+ export const OAUTH_REFRESH_UNAVAILABLE_DIAGNOSTIC = "oauth_refresh_unavailable";
2
8
  // A provider's support request id is opaque hex like `C6FD:AB660:AEB5548:6ABA4D81`.
3
9
  // It can contain `429` or `500`, which message classifiers read as HTTP statuses,
4
10
  // so every id is rendered behind this marker and removed before classification.
@@ -371,6 +377,10 @@ export async function retryAssistantCall(produce, policy, signal, callbacks) {
371
377
  * before restarting the assistant turn.
372
378
  */
373
379
  export function isRetryableAssistantError(message) {
380
+ if (message.stopReason === "error" &&
381
+ message.diagnostics?.some((diagnostic) => diagnostic.type === OAUTH_REFRESH_UNAVAILABLE_DIAGNOSTIC)) {
382
+ return true;
383
+ }
374
384
  if (message.stopReason !== "error" ||
375
385
  message.stopDetails?.type === "refusal" ||
376
386
  message.stopDetails?.type === "sensitive" ||
@@ -1,6 +1,17 @@
1
1
  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
+ /**
5
+ * A request refusal: the HTTP 400 that rejects the request, or the same refusal a gateway that has
6
+ * already answered 200 (a keepalive stream) sends in-band before any content (senpi#2801).
7
+ */
8
+ export declare function isRequestRefusal(error: unknown): boolean;
9
+ /**
10
+ * Reads `stream` until it has produced content (or ended, or `maxBuffered` contentless events went
11
+ * by), so a refusal a gateway sends in-band after answering 200 is raised while the request can still
12
+ * be retried. Such an error is marked as a request refusal; the events read are replayed in order.
13
+ */
14
+ export declare function primeStreamUntilContent<T>(stream: AsyncIterable<T>, hasContent: (event: T) => boolean, maxBuffered?: number): Promise<AsyncIterable<T>>;
4
15
  export declare function isForcedToolChoiceUnsupportedError(error: unknown, sentForcedToolChoice: boolean): boolean;
5
16
  /**
6
17
  * A 400 to a request that forced a tool and whose message names that very tool: its `tools.N` /
@@ -30,8 +30,75 @@ function errorMessage(error) {
30
30
  }
31
31
  return String(error);
32
32
  }
33
+ /** Marks an error a stream raised in-band, after its 200 response but before any content (senpi#2801). */
34
+ const IN_BAND_BEFORE_CONTENT = Symbol.for("senpi.forcedToolChoice.inBandBeforeContent");
35
+ function isInBandBeforeContent(error) {
36
+ return isRecord(error) && error[IN_BAND_BEFORE_CONTENT] === true;
37
+ }
38
+ function markInBandBeforeContent(error) {
39
+ const marked = isRecord(error) ? error : new Error(errorMessage(error));
40
+ try {
41
+ Object.defineProperty(marked, IN_BAND_BEFORE_CONTENT, { value: true });
42
+ }
43
+ catch {
44
+ // A frozen error keeps its own identity; it is then judged like any other in-band error.
45
+ }
46
+ return marked;
47
+ }
48
+ /**
49
+ * A request refusal: the HTTP 400 that rejects the request, or the same refusal a gateway that has
50
+ * already answered 200 (a keepalive stream) sends in-band before any content (senpi#2801).
51
+ */
52
+ export function isRequestRefusal(error) {
53
+ const status = extractHttpStatus(error);
54
+ return status === 400 || (status === undefined && isInBandBeforeContent(error));
55
+ }
56
+ /**
57
+ * Reads `stream` until it has produced content (or ended, or `maxBuffered` contentless events went
58
+ * by), so a refusal a gateway sends in-band after answering 200 is raised while the request can still
59
+ * be retried. Such an error is marked as a request refusal; the events read are replayed in order.
60
+ */
61
+ export async function primeStreamUntilContent(stream, hasContent, maxBuffered = 64) {
62
+ const iterator = stream[Symbol.asyncIterator]();
63
+ const buffered = [];
64
+ let ended = false;
65
+ try {
66
+ while (buffered.length < maxBuffered) {
67
+ const next = await iterator.next();
68
+ if (next.done) {
69
+ ended = true;
70
+ break;
71
+ }
72
+ buffered.push(next.value);
73
+ if (hasContent(next.value))
74
+ break;
75
+ }
76
+ }
77
+ catch (error) {
78
+ throw markInBandBeforeContent(error);
79
+ }
80
+ return {
81
+ async *[Symbol.asyncIterator]() {
82
+ try {
83
+ yield* buffered;
84
+ if (ended)
85
+ return;
86
+ while (true) {
87
+ const next = await iterator.next();
88
+ if (next.done)
89
+ return;
90
+ yield next.value;
91
+ }
92
+ }
93
+ finally {
94
+ if (!ended)
95
+ await iterator.return?.();
96
+ }
97
+ },
98
+ };
99
+ }
33
100
  export function isForcedToolChoiceUnsupportedError(error, sentForcedToolChoice) {
34
- if (!sentForcedToolChoice || extractHttpStatus(error) !== 400) {
101
+ if (!sentForcedToolChoice || !isRequestRefusal(error)) {
35
102
  return false;
36
103
  }
37
104
  const message = errorMessage(error);
@@ -77,7 +144,7 @@ function escapeRegExp(text) {
77
144
  * no tool, or another one, is not this refusal.
78
145
  */
79
146
  export function refusalNamesForcedTool(error, params) {
80
- if (extractHttpStatus(error) !== 400)
147
+ if (!isRequestRefusal(error))
81
148
  return false;
82
149
  const name = forcedToolName(params.tool_choice);
83
150
  if (name === undefined)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-ai",
3
- "version": "2026.10.10",
3
+ "version": "2026.10.11",
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.10",
91
+ "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.11",
92
92
  "@google/genai": "2.23.0",
93
93
  "@smithy/node-http-handler": "4.12.1",
94
94
  "http-proxy-agent": "9.1.0",