@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.
- package/dist/api/anthropic-messages.js +101 -38
- package/dist/api/anthropic-tool-references.js +24 -18
- package/dist/api/bedrock-converse-stream.js +5 -1
- package/dist/api/google-shared.js +101 -21
- package/dist/api/lazy.js +13 -0
- package/dist/api/mistral-conversations.js +18 -3
- package/dist/api/openai-codex-responses.js +2 -2
- package/dist/api/openai-completions.js +26 -2
- package/dist/api/openai-responses-allowed-tools.d.ts +33 -0
- package/dist/api/openai-responses-allowed-tools.js +108 -0
- package/dist/api/openai-responses.js +43 -58
- package/dist/auth/oauth/anthropic.js +4 -3
- package/dist/auth/oauth/chatgpt-subscription.js +3 -2
- package/dist/auth/oauth/cursor.js +3 -2
- package/dist/auth/oauth/devin-token.js +2 -1
- package/dist/auth/oauth/github-copilot.js +3 -2
- package/dist/auth/oauth/kimi-coding.js +5 -4
- package/dist/auth/oauth/openai-chatgpt.js +2 -1
- package/dist/auth/oauth/openrouter.js +2 -1
- package/dist/auth/oauth/radius.js +5 -5
- package/dist/auth/oauth/xai.js +7 -4
- package/dist/auth/oauth-refresh.js +10 -0
- package/dist/auth/resolve.js +5 -0
- package/dist/openai-responses-compat.d.ts +7 -2
- package/dist/openai-responses-compat.js +15 -2
- package/dist/providers/all.d.ts +1 -1
- package/dist/providers/data/.manifest.json +1 -1
- package/dist/providers/data/amazon-bedrock.json +1 -1
- package/dist/providers/data/anthropic.json +1 -1
- package/dist/providers/data/baseten.json +1 -1
- package/dist/providers/data/chatgpt-subscription.json +1 -1
- package/dist/providers/data/cloudflare-ai-gateway.json +1 -1
- package/dist/providers/data/cloudflare-workers-ai.json +1 -1
- package/dist/providers/data/github-copilot.json +1 -1
- package/dist/providers/data/google-vertex.json +1 -1
- package/dist/providers/data/huggingface.json +1 -1
- package/dist/providers/data/mistral.json +1 -1
- package/dist/providers/data/moonshotai-cn.json +1 -1
- package/dist/providers/data/moonshotai.json +1 -1
- package/dist/providers/data/opencode-go.json +1 -1
- package/dist/providers/data/opencode.json +1 -1
- package/dist/providers/data/opengateway.json +1 -1
- package/dist/providers/data/openrouter.json +1 -1
- package/dist/providers/data/radius.json +1 -1
- package/dist/providers/data/venice.json +1 -1
- package/dist/providers/data/vercel-ai-gateway.json +1 -1
- package/dist/providers/kimi-coding.models.d.ts +1 -1
- package/dist/providers/kimi-coding.models.js +1 -1
- package/dist/providers/opengateway-refresh.js +2 -1
- package/dist/types.d.ts +3 -2
- package/dist/utils/oauth-refresh-error.d.ts +16 -0
- package/dist/utils/oauth-refresh-error.js +67 -0
- package/dist/utils/prompt-cache-ttl.js +3 -1
- package/dist/utils/retry.d.ts +6 -0
- package/dist/utils/retry.js +10 -0
- package/dist/utils/tool-choice-fallback.d.ts +11 -0
- package/dist/utils/tool-choice-fallback.js +69 -2
- package/package.json +2 -2
|
@@ -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
|
|
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
|
|
694
|
-
* byte-stable and restricts callability through `tool_choice`
|
|
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"))
|
package/dist/utils/retry.d.ts
CHANGED
|
@@ -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;
|
package/dist/utils/retry.js
CHANGED
|
@@ -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 ||
|
|
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 (
|
|
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.
|
|
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.
|
|
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",
|