@sayknow-cli/ai 0.5.1 → 0.5.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.
- package/CHANGELOG.md +13 -0
- package/dist/types/provider-models/openai-compat.d.ts +10 -0
- package/dist/types/providers/openai-responses-shared.d.ts +11 -9
- package/dist/types/types.d.ts +7 -1
- package/dist/types/utils/discovery/openai-compatible.d.ts +10 -0
- package/dist/types/utils/oauth/sglang.d.ts +12 -0
- package/dist/types/utils/oauth/types.d.ts +1 -1
- package/package.json +2 -2
- package/src/auth-storage.ts +32 -6
- package/src/model-thinking.ts +21 -1
- package/src/provider-models/descriptors.ts +12 -0
- package/src/provider-models/openai-compat.ts +152 -0
- package/src/providers/anthropic.ts +11 -0
- package/src/providers/openai-completions.ts +56 -14
- package/src/providers/openai-responses-shared.ts +19 -15
- package/src/stream.ts +15 -21
- package/src/types.ts +9 -1
- package/src/utils/discovery/openai-compatible.ts +106 -2
- package/src/utils/oauth/index.ts +6 -0
- package/src/utils/oauth/sglang.ts +39 -0
- package/src/utils/oauth/types.ts +1 -0
- package/src/utils/overflow.ts +64 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.5.6] - 2026-08-28
|
|
6
|
+
|
|
7
|
+
## [0.5.3] - 2026-08-28
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added first-class oMLX and SGLang OpenAI-compatible local providers. oMLX includes model capability enrichment and macOS profiles; SGLang discovery is credentialless only on normalized loopback origins and supports trusted `SGLANG_BASE_URL`/`SGLANG_API_KEY` configuration.
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Hardened local OpenAI-compatible discovery with bounded catalogs, redirect refusal, canonical loopback URLs, safe oMLX limits, and explicit-only SGLang login; deprecated `sglang-local` credentials no longer shadow real keys.
|
|
15
|
+
|
|
16
|
+
- Anthropic's measured `invalid_request_error` prompt-overflow envelope now triggers context compaction only when its reported token usage actually exceeds the reported maximum, while auth, quota, rate-limit, and unverified prose remain authoritative non-overflow failures.
|
|
17
|
+
|
|
5
18
|
## [0.7.5] - 2026-06-27
|
|
6
19
|
|
|
7
20
|
### Fixed
|
|
@@ -153,6 +153,11 @@ export interface LmStudioModelManagerConfig {
|
|
|
153
153
|
baseUrl?: string;
|
|
154
154
|
}
|
|
155
155
|
export declare function lmStudioModelManagerOptions(config?: LmStudioModelManagerConfig): ModelManagerOptions<"openai-completions">;
|
|
156
|
+
export interface OmlxModelManagerConfig {
|
|
157
|
+
apiKey?: string;
|
|
158
|
+
baseUrl?: string;
|
|
159
|
+
}
|
|
160
|
+
export declare function omlxModelManagerOptions(config?: OmlxModelManagerConfig): ModelManagerOptions<"openai-completions">;
|
|
156
161
|
export interface SyntheticModelManagerConfig {
|
|
157
162
|
apiKey?: string;
|
|
158
163
|
baseUrl?: string;
|
|
@@ -210,6 +215,11 @@ export interface VllmModelManagerConfig {
|
|
|
210
215
|
baseUrl?: string;
|
|
211
216
|
}
|
|
212
217
|
export declare function vllmModelManagerOptions(config?: VllmModelManagerConfig): ModelManagerOptions<"openai-completions">;
|
|
218
|
+
export interface SglangModelManagerConfig {
|
|
219
|
+
apiKey?: string;
|
|
220
|
+
baseUrl?: string;
|
|
221
|
+
}
|
|
222
|
+
export declare function sglangModelManagerOptions(config?: SglangModelManagerConfig): ModelManagerOptions<"openai-completions">;
|
|
213
223
|
export interface NanoGptModelManagerConfig {
|
|
214
224
|
apiKey?: string;
|
|
215
225
|
baseUrl?: string;
|
|
@@ -45,16 +45,18 @@ export interface ProcessResponsesStreamOptions {
|
|
|
45
45
|
}
|
|
46
46
|
export declare function processResponsesStream<TApi extends Api>(openaiStream: AsyncIterable<OpenAI.Responses.ResponseStreamEvent>, output: AssistantMessage, stream: AssistantMessageEventStream, model: Model<TApi>, options?: ProcessResponsesStreamOptions): Promise<void>;
|
|
47
47
|
/**
|
|
48
|
-
* Mark tool-call blocks
|
|
49
|
-
*
|
|
48
|
+
* Mark tool-call blocks whose arguments are structurally incomplete when a
|
|
49
|
+
* response stops for length.
|
|
50
50
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
51
|
+
* A missing `output_item.done` normally means the call was cut off. Responses
|
|
52
|
+
* can also hit the output-token limit after emitting a complete JSON object but
|
|
53
|
+
* before finalizing the item (for example, after a long whitespace tail). A
|
|
54
|
+
* non-finalized JSON call is therefore safe only when its exact buffered text
|
|
55
|
+
* parses as complete JSON. Custom tools carry raw input with no structural
|
|
56
|
+
* completion marker, so they still require finalization.
|
|
57
|
+
*
|
|
58
|
+
* Finalized JSON calls get the same defensive parse check for misbehaving
|
|
59
|
+
* relays. No-op unless the turn stopped for length.
|
|
58
60
|
*
|
|
59
61
|
* Shared by both Responses providers (`openai-responses`, `openai-codex-responses`).
|
|
60
62
|
*/
|
package/dist/types/types.d.ts
CHANGED
|
@@ -51,7 +51,7 @@ export interface ThinkingConfig {
|
|
|
51
51
|
/** Provider-specific transport used to encode the selected effort. */
|
|
52
52
|
mode: ThinkingControlMode;
|
|
53
53
|
}
|
|
54
|
-
export type KnownProvider = "alibaba-token-plan" | "amazon-bedrock" | "azure-openai" | "anthropic" | "google" | "google-gemini-cli" | "google-antigravity" | "google-vertex" | "openai" | "openai-codex" | "kimi-code" | "minimax-code" | "minimax-code-cn" | "github-copilot" | "fireworks" | "firepass" | "fugu" | "gitlab-duo" | "cursor" | "deepseek" | "deepinfra" | "xai" | "groq" | "cerebras" | "openrouter" | "kilo" | "vercel-ai-gateway" | "zai" | "glm-zcode" | "mistral" | "minimax" | "opencode-go" | "opencode-zen" | "opengateway" | "bizrouter" | "synthetic" | "cloudflare-ai-gateway" | "huggingface" | "litellm" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "qianfan" | "qwen-portal" | "together" | "venice" | "vllm" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "lm-studio";
|
|
54
|
+
export type KnownProvider = "alibaba-token-plan" | "amazon-bedrock" | "azure-openai" | "anthropic" | "google" | "google-gemini-cli" | "google-antigravity" | "google-vertex" | "openai" | "openai-codex" | "kimi-code" | "minimax-code" | "minimax-code-cn" | "github-copilot" | "fireworks" | "firepass" | "fugu" | "gitlab-duo" | "cursor" | "deepseek" | "deepinfra" | "xai" | "groq" | "cerebras" | "openrouter" | "kilo" | "vercel-ai-gateway" | "zai" | "glm-zcode" | "mistral" | "minimax" | "opencode-go" | "opencode-zen" | "opengateway" | "bizrouter" | "synthetic" | "cloudflare-ai-gateway" | "huggingface" | "litellm" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "qianfan" | "qwen-portal" | "together" | "venice" | "vllm" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "lm-studio" | "omlx" | "sglang";
|
|
55
55
|
export type Provider = KnownProvider | string;
|
|
56
56
|
import type { Effort } from "./model-thinking";
|
|
57
57
|
/** Token budgets for each thinking level (token-based providers only) */
|
|
@@ -355,6 +355,12 @@ export interface ToolCall {
|
|
|
355
355
|
* rejects the call with a retryable error instead.
|
|
356
356
|
*/
|
|
357
357
|
incompleteArguments?: boolean;
|
|
358
|
+
/**
|
|
359
|
+
* Transient raw JSON for a provider-detected `\uXXXX`-escaped non-ASCII
|
|
360
|
+
* tool payload. The agent loop validates and removes it before persistence.
|
|
361
|
+
*/
|
|
362
|
+
escapedNonAsciiArguments?: boolean;
|
|
363
|
+
escapedNonAsciiArgumentsRaw?: string;
|
|
358
364
|
}
|
|
359
365
|
export interface Usage {
|
|
360
366
|
/** Non-cached input tokens (matches the bucket the provider bills as new input). */
|
|
@@ -72,3 +72,13 @@ export interface FetchOpenAICompatibleModelsOptions<TApi extends Api> {
|
|
|
72
72
|
* Returns `[]` only when the endpoint responds successfully with no usable models.
|
|
73
73
|
*/
|
|
74
74
|
export declare function fetchOpenAICompatibleModels<TApi extends Api>(options: FetchOpenAICompatibleModelsOptions<TApi>): Promise<Model<TApi>[] | null>;
|
|
75
|
+
/**
|
|
76
|
+
* Returns a canonical HTTP(S) OpenAI-compatible base URL without embedded URL
|
|
77
|
+
* credentials, query parameters, or fragments.
|
|
78
|
+
*/
|
|
79
|
+
export declare function resolveCanonicalOpenAIBaseUrl(value: string | undefined): string | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* Returns a local OpenAI-compatible base URL only when it is an HTTP(S)
|
|
82
|
+
* loopback endpoint; otherwise returns the trusted fallback.
|
|
83
|
+
*/
|
|
84
|
+
export declare function resolveLoopbackOpenAIBaseUrl(value: string | undefined, fallback: string): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SGLang login flow.
|
|
3
|
+
*
|
|
4
|
+
* SGLang commonly exposes an OpenAI-compatible API on a local server. It may
|
|
5
|
+
* require a bearer token, but local servers commonly allow unauthenticated
|
|
6
|
+
* access. This flow stores an API-key-style credential for auth storage.
|
|
7
|
+
*/
|
|
8
|
+
import type { OAuthController } from "./types";
|
|
9
|
+
/**
|
|
10
|
+
* Login to SGLang with an explicit bearer token.
|
|
11
|
+
*/
|
|
12
|
+
export declare function loginSglang(options: OAuthController): Promise<string>;
|
|
@@ -7,7 +7,7 @@ export type OAuthCredentials = {
|
|
|
7
7
|
email?: string;
|
|
8
8
|
accountId?: string;
|
|
9
9
|
};
|
|
10
|
-
export type OAuthProvider = "alibaba-token-plan" | "anthropic" | "bizrouter" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "deepseek" | "deepinfra" | "fireworks" | "firepass" | "fugu" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "openai-codex-device" | "opencode-go" | "opencode-zen" | "opengateway" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "vllm" | "xai" | "glm-zcode" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "zai";
|
|
10
|
+
export type OAuthProvider = "alibaba-token-plan" | "anthropic" | "bizrouter" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "deepseek" | "deepinfra" | "fireworks" | "firepass" | "fugu" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "openai-codex-device" | "opencode-go" | "opencode-zen" | "opengateway" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "vllm" | "sglang" | "xai" | "glm-zcode" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "zai";
|
|
11
11
|
export type OAuthProviderId = OAuthProvider | (string & {});
|
|
12
12
|
export type OAuthPrompt = {
|
|
13
13
|
message: string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/ai",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.6",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://sayknow-cli.com",
|
|
7
7
|
"author": "jaybeyond",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@anthropic-ai/sdk": "^0.94.0",
|
|
45
45
|
"@bufbuild/protobuf": "^2.12.0",
|
|
46
|
-
"@sayknow-cli/utils": "0.5.
|
|
46
|
+
"@sayknow-cli/utils": "0.5.6",
|
|
47
47
|
"openai": "^6.36.0",
|
|
48
48
|
"partial-json": "^0.1.7",
|
|
49
49
|
"zod": "4.4.3"
|
package/src/auth-storage.ts
CHANGED
|
@@ -37,6 +37,12 @@ import { loginDeepSeek } from "./utils/oauth/deepseek";
|
|
|
37
37
|
import { loginOpenAICodexDevice } from "./utils/oauth/openai-codex";
|
|
38
38
|
import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./utils/oauth/types";
|
|
39
39
|
|
|
40
|
+
const DEPRECATED_SGLANG_NO_AUTH_TOKEN = "sglang-local";
|
|
41
|
+
|
|
42
|
+
function isDeprecatedSglangNoAuthToken(provider: string, apiKey: string | undefined): boolean {
|
|
43
|
+
return provider === "sglang" && apiKey === DEPRECATED_SGLANG_NO_AUTH_TOKEN;
|
|
44
|
+
}
|
|
45
|
+
|
|
40
46
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
41
47
|
// Credential Types
|
|
42
48
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1351,6 +1357,11 @@ export class AuthStorage {
|
|
|
1351
1357
|
.filter(
|
|
1352
1358
|
(entry): entry is { credential: Extract<AuthCredential, { type: T }>; index: number } =>
|
|
1353
1359
|
entry.credential.type === type,
|
|
1360
|
+
)
|
|
1361
|
+
.filter(
|
|
1362
|
+
entry =>
|
|
1363
|
+
type !== "api_key" ||
|
|
1364
|
+
!isDeprecatedSglangNoAuthToken(provider, (entry.credential as ApiKeyCredential).key),
|
|
1354
1365
|
);
|
|
1355
1366
|
|
|
1356
1367
|
if (credentials.length === 0) return undefined;
|
|
@@ -1950,6 +1961,12 @@ export class AuthStorage {
|
|
|
1950
1961
|
await saveApiKeyCredential(apiKey);
|
|
1951
1962
|
return;
|
|
1952
1963
|
}
|
|
1964
|
+
case "sglang": {
|
|
1965
|
+
const { loginSglang } = await import("./utils/oauth/sglang");
|
|
1966
|
+
const apiKey = await loginSglang(ctrl);
|
|
1967
|
+
await saveApiKeyCredential(apiKey);
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1953
1970
|
case "parallel": {
|
|
1954
1971
|
const { loginParallel } = await import("./utils/oauth/parallel");
|
|
1955
1972
|
const apiKey = await loginParallel(ctrl);
|
|
@@ -3434,7 +3451,8 @@ export class AuthStorage {
|
|
|
3434
3451
|
|
|
3435
3452
|
const apiKeySelection = this.#selectCredentialByType(provider, "api_key");
|
|
3436
3453
|
if (apiKeySelection) {
|
|
3437
|
-
|
|
3454
|
+
const apiKey = await this.#configValueResolver(apiKeySelection.credential.key);
|
|
3455
|
+
if (!isDeprecatedSglangNoAuthToken(provider, apiKey)) return apiKey;
|
|
3438
3456
|
}
|
|
3439
3457
|
|
|
3440
3458
|
// Return current OAuth access token only if it is not already expired.
|
|
@@ -3487,16 +3505,24 @@ export class AuthStorage {
|
|
|
3487
3505
|
return configKey;
|
|
3488
3506
|
}
|
|
3489
3507
|
|
|
3508
|
+
let skippedDeprecatedNoAuthToken = false;
|
|
3490
3509
|
if (selectedCredential?.credential.type === "api_key") {
|
|
3491
|
-
this.#
|
|
3492
|
-
|
|
3510
|
+
const apiKey = await this.#configValueResolver(selectedCredential.credential.key);
|
|
3511
|
+
if (!isDeprecatedSglangNoAuthToken(provider, apiKey)) {
|
|
3512
|
+
this.#recordSessionCredential(provider, sessionId, "api_key", selectedCredential.index);
|
|
3513
|
+
return apiKey;
|
|
3514
|
+
}
|
|
3515
|
+
skippedDeprecatedNoAuthToken = true;
|
|
3493
3516
|
}
|
|
3494
3517
|
|
|
3495
|
-
if (!selectedCredential) {
|
|
3518
|
+
if (!selectedCredential || skippedDeprecatedNoAuthToken) {
|
|
3496
3519
|
const apiKeySelection = this.#selectCredentialByType(provider, "api_key", sessionId);
|
|
3497
3520
|
if (apiKeySelection) {
|
|
3498
|
-
this.#
|
|
3499
|
-
|
|
3521
|
+
const apiKey = await this.#configValueResolver(apiKeySelection.credential.key);
|
|
3522
|
+
if (!isDeprecatedSglangNoAuthToken(provider, apiKey)) {
|
|
3523
|
+
this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
|
|
3524
|
+
return apiKey;
|
|
3525
|
+
}
|
|
3500
3526
|
}
|
|
3501
3527
|
}
|
|
3502
3528
|
|
package/src/model-thinking.ts
CHANGED
|
@@ -198,7 +198,11 @@ export function refreshModelThinking<TApi extends Api>(model: ApiModel<TApi>): A
|
|
|
198
198
|
*/
|
|
199
199
|
export function applyGeneratedModelPolicies(models: ApiModel<Api>[]): void {
|
|
200
200
|
for (let index = 0; index < models.length; index++) {
|
|
201
|
-
const
|
|
201
|
+
const source = models[index]!;
|
|
202
|
+
if (source.provider === "omlx") {
|
|
203
|
+
source.reasoning = true;
|
|
204
|
+
}
|
|
205
|
+
const model = refreshModelThinking(source);
|
|
202
206
|
applyGeneratedModelPolicy(model);
|
|
203
207
|
models[index] = model;
|
|
204
208
|
}
|
|
@@ -377,6 +381,16 @@ function applyGeneratedModelPolicy(model: ApiModel<Api>): void {
|
|
|
377
381
|
};
|
|
378
382
|
delete model.compat.thinkingFormat;
|
|
379
383
|
}
|
|
384
|
+
if (model.provider === "omlx" && model.api === "openai-completions") {
|
|
385
|
+
model.compat = {
|
|
386
|
+
...(model.compat ?? {}),
|
|
387
|
+
supportsStore: false,
|
|
388
|
+
supportsDeveloperRole: false,
|
|
389
|
+
supportsReasoningEffort: true,
|
|
390
|
+
thinkingFormat: "qwen-chat-template",
|
|
391
|
+
reasoningContentField: "reasoning_content",
|
|
392
|
+
};
|
|
393
|
+
}
|
|
380
394
|
model.name = scrubGeneratedModelName(model.name);
|
|
381
395
|
if (
|
|
382
396
|
model.api === "openai-completions" &&
|
|
@@ -527,6 +541,9 @@ function inferDefaultEffort<TApi extends Api>(model: ApiModel<TApi>, parsedModel
|
|
|
527
541
|
) {
|
|
528
542
|
return GPT_5_5_DEFAULT_EFFORT;
|
|
529
543
|
}
|
|
544
|
+
if (model.provider === "omlx") {
|
|
545
|
+
return Effort.Medium;
|
|
546
|
+
}
|
|
530
547
|
return undefined;
|
|
531
548
|
}
|
|
532
549
|
|
|
@@ -662,6 +679,9 @@ function inferFallbackEfforts<TApi extends Api>(model: ApiModel<TApi>): readonly
|
|
|
662
679
|
return DEFAULT_REASONING_EFFORTS;
|
|
663
680
|
}
|
|
664
681
|
if (model.api === "openai-completions") {
|
|
682
|
+
if (model.provider === "omlx") {
|
|
683
|
+
return [Effort.Low, Effort.Medium, Effort.High];
|
|
684
|
+
}
|
|
665
685
|
const compat = resolveOpenAICompat(model as ApiModel<"openai-completions">);
|
|
666
686
|
if (compat.thinkingFormat === "openai" && compat.supportsReasoningEffort) {
|
|
667
687
|
return DEFAULT_REASONING_EFFORTS_WITH_XHIGH;
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
nanoGptModelManagerOptions,
|
|
32
32
|
nvidiaModelManagerOptions,
|
|
33
33
|
ollamaModelManagerOptions,
|
|
34
|
+
omlxModelManagerOptions,
|
|
34
35
|
openaiModelManagerOptions,
|
|
35
36
|
opencodeGoModelManagerOptions,
|
|
36
37
|
opencodeZenModelManagerOptions,
|
|
@@ -38,6 +39,7 @@ import {
|
|
|
38
39
|
openrouterModelManagerOptions,
|
|
39
40
|
qianfanModelManagerOptions,
|
|
40
41
|
qwenPortalModelManagerOptions,
|
|
42
|
+
sglangModelManagerOptions,
|
|
41
43
|
syntheticModelManagerOptions,
|
|
42
44
|
togetherModelManagerOptions,
|
|
43
45
|
veniceModelManagerOptions,
|
|
@@ -260,12 +262,22 @@ export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [
|
|
|
260
262
|
catalog("LiteLLM", ["LITELLM_API_KEY"], { allowUnauthenticated: true }),
|
|
261
263
|
),
|
|
262
264
|
descriptor("lm-studio", "llama-3-8b", config => lmStudioModelManagerOptions(config), { allowUnauthenticated: true }),
|
|
265
|
+
descriptor("omlx", "Qwen3.5-122B-A10B-Q4", config => omlxModelManagerOptions(config), {
|
|
266
|
+
allowUnauthenticated: true,
|
|
267
|
+
}),
|
|
263
268
|
catalogDescriptor(
|
|
264
269
|
"vllm",
|
|
265
270
|
"gpt-oss-20b",
|
|
266
271
|
config => vllmModelManagerOptions(config),
|
|
267
272
|
catalog("vLLM", ["VLLM_API_KEY"], { allowUnauthenticated: true }),
|
|
268
273
|
),
|
|
274
|
+
catalogDescriptor(
|
|
275
|
+
"sglang",
|
|
276
|
+
"gpt-oss-20b",
|
|
277
|
+
config => sglangModelManagerOptions(config),
|
|
278
|
+
catalog("SGLang", ["SGLANG_API_KEY"], { allowUnauthenticated: true }),
|
|
279
|
+
{ allowUnauthenticated: true },
|
|
280
|
+
),
|
|
269
281
|
catalogDescriptor(
|
|
270
282
|
"moonshot",
|
|
271
283
|
"kimi-k2.5",
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
fetchOpenAICompatibleModels,
|
|
9
9
|
type OpenAICompatibleModelMapperContext,
|
|
10
10
|
type OpenAICompatibleModelRecord,
|
|
11
|
+
resolveCanonicalOpenAIBaseUrl,
|
|
12
|
+
resolveLoopbackOpenAIBaseUrl,
|
|
11
13
|
} from "../utils/discovery/openai-compatible";
|
|
12
14
|
import { toFireworksPublicModelId } from "../utils/fireworks-model-id";
|
|
13
15
|
import { getGitHubCopilotBaseUrl, OPENCODE_HEADERS, parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
|
|
@@ -197,6 +199,18 @@ function firstPositiveModelNumber(fallback: number, ...candidates: readonly unkn
|
|
|
197
199
|
return fallback;
|
|
198
200
|
}
|
|
199
201
|
|
|
202
|
+
function safePositiveInteger(value: unknown): number | undefined {
|
|
203
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function firstPositiveSafeInteger(fallback: number, ...candidates: readonly unknown[]): number {
|
|
207
|
+
for (const candidate of candidates) {
|
|
208
|
+
const value = safePositiveInteger(candidate);
|
|
209
|
+
if (value !== undefined) return value;
|
|
210
|
+
}
|
|
211
|
+
return fallback;
|
|
212
|
+
}
|
|
213
|
+
|
|
200
214
|
function mapLmStudioModel(
|
|
201
215
|
entry: OpenAICompatibleModelRecord,
|
|
202
216
|
defaults: Model<"openai-completions">,
|
|
@@ -226,6 +240,52 @@ function mapLmStudioModel(
|
|
|
226
240
|
),
|
|
227
241
|
};
|
|
228
242
|
}
|
|
243
|
+
function mapOmlxModel(
|
|
244
|
+
entry: OpenAICompatibleModelRecord,
|
|
245
|
+
defaults: Model<"openai-completions">,
|
|
246
|
+
reference: Model<"openai-completions"> | undefined,
|
|
247
|
+
): Model<"openai-completions"> {
|
|
248
|
+
const model = mapWithBundledReference(entry, defaults, reference);
|
|
249
|
+
return {
|
|
250
|
+
...model,
|
|
251
|
+
reasoning: true,
|
|
252
|
+
thinking: {
|
|
253
|
+
mode: "effort",
|
|
254
|
+
minLevel: Effort.Low,
|
|
255
|
+
maxLevel: Effort.High,
|
|
256
|
+
defaultLevel: Effort.Medium,
|
|
257
|
+
levels: [Effort.Low, Effort.Medium, Effort.High],
|
|
258
|
+
},
|
|
259
|
+
compat: {
|
|
260
|
+
...(model.compat ?? {}),
|
|
261
|
+
supportsStore: false,
|
|
262
|
+
supportsDeveloperRole: false,
|
|
263
|
+
supportsReasoningEffort: true,
|
|
264
|
+
thinkingFormat: "qwen-chat-template",
|
|
265
|
+
reasoningContentField: "reasoning_content",
|
|
266
|
+
},
|
|
267
|
+
contextWindow: firstPositiveSafeInteger(
|
|
268
|
+
model.contextWindow,
|
|
269
|
+
safePositiveInteger(entry.max_model_len),
|
|
270
|
+
entry.context_length,
|
|
271
|
+
entry.max_context_length,
|
|
272
|
+
getNestedModelValue(entry, ["meta", "n_ctx"]),
|
|
273
|
+
getNestedModelValue(entry, ["details", "context_length"]),
|
|
274
|
+
getNestedModelValue(entry, ["details", "n_ctx"]),
|
|
275
|
+
getNestedModelValue(entry, ["meta", "n_ctx_train"]),
|
|
276
|
+
),
|
|
277
|
+
maxTokens: firstPositiveSafeInteger(
|
|
278
|
+
model.maxTokens,
|
|
279
|
+
entry.max_completion_tokens,
|
|
280
|
+
entry.max_tokens,
|
|
281
|
+
entry.max_output_tokens,
|
|
282
|
+
getNestedModelValue(entry, ["details", "max_completion_tokens"]),
|
|
283
|
+
getNestedModelValue(entry, ["details", "max_tokens"]),
|
|
284
|
+
getNestedModelValue(entry, ["meta", "max_completion_tokens"]),
|
|
285
|
+
getNestedModelValue(entry, ["meta", "max_tokens"]),
|
|
286
|
+
),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
229
289
|
|
|
230
290
|
function normalizeAnthropicBaseUrl(baseUrl: string | undefined, fallback: string): string {
|
|
231
291
|
const value = baseUrl?.trim();
|
|
@@ -1381,6 +1441,42 @@ export function lmStudioModelManagerOptions(
|
|
|
1381
1441
|
}),
|
|
1382
1442
|
};
|
|
1383
1443
|
}
|
|
1444
|
+
// ---------------------------------------------------------------------------
|
|
1445
|
+
// 12.6. oMLX (Apple Silicon MLX Local Server)
|
|
1446
|
+
// ---------------------------------------------------------------------------
|
|
1447
|
+
|
|
1448
|
+
export interface OmlxModelManagerConfig {
|
|
1449
|
+
apiKey?: string;
|
|
1450
|
+
baseUrl?: string;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
export function omlxModelManagerOptions(config?: OmlxModelManagerConfig): ModelManagerOptions<"openai-completions"> {
|
|
1454
|
+
const apiKey = config?.apiKey;
|
|
1455
|
+
const baseUrl = resolveLoopbackOpenAIBaseUrl(
|
|
1456
|
+
config?.baseUrl ?? $credentialEnv("OMLX_BASE_URL"),
|
|
1457
|
+
"http://127.0.0.1:8080/v1",
|
|
1458
|
+
);
|
|
1459
|
+
const references = createBundledReferenceMap<"openai-completions">("omlx" as Parameters<typeof getBundledModels>[0]);
|
|
1460
|
+
return {
|
|
1461
|
+
providerId: "omlx",
|
|
1462
|
+
fetchDynamicModels: () =>
|
|
1463
|
+
fetchOpenAICompatibleModels({
|
|
1464
|
+
api: "openai-completions",
|
|
1465
|
+
provider: "omlx",
|
|
1466
|
+
baseUrl,
|
|
1467
|
+
apiKey,
|
|
1468
|
+
fetch: (input, init) =>
|
|
1469
|
+
fetch(input, {
|
|
1470
|
+
...init,
|
|
1471
|
+
redirect: "error",
|
|
1472
|
+
signal: init?.signal
|
|
1473
|
+
? AbortSignal.any([init.signal, AbortSignal.timeout(500)])
|
|
1474
|
+
: AbortSignal.timeout(500),
|
|
1475
|
+
}),
|
|
1476
|
+
mapModel: (entry, defaults) => mapOmlxModel(entry, defaults, references.get(defaults.id)),
|
|
1477
|
+
}),
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1384
1480
|
|
|
1385
1481
|
// ---------------------------------------------------------------------------
|
|
1386
1482
|
// 13. Synthetic
|
|
@@ -1735,6 +1831,62 @@ export function vllmModelManagerOptions(config?: VllmModelManagerConfig): ModelM
|
|
|
1735
1831
|
}),
|
|
1736
1832
|
};
|
|
1737
1833
|
}
|
|
1834
|
+
// ---------------------------------------------------------------------------
|
|
1835
|
+
// 22.5. SGLang
|
|
1836
|
+
// ---------------------------------------------------------------------------
|
|
1837
|
+
|
|
1838
|
+
export interface SglangModelManagerConfig {
|
|
1839
|
+
apiKey?: string;
|
|
1840
|
+
baseUrl?: string;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
export function sglangModelManagerOptions(
|
|
1844
|
+
config?: SglangModelManagerConfig,
|
|
1845
|
+
): ModelManagerOptions<"openai-completions"> {
|
|
1846
|
+
const apiKey = config?.apiKey;
|
|
1847
|
+
const configuredBaseUrl = config?.baseUrl ?? $credentialEnv("SGLANG_BASE_URL");
|
|
1848
|
+
const baseUrl =
|
|
1849
|
+
resolveCanonicalOpenAIBaseUrl(configuredBaseUrl) ?? (config?.baseUrl ? "" : "http://127.0.0.1:30000/v1");
|
|
1850
|
+
const isLoopback = baseUrl.length > 0 && resolveLoopbackOpenAIBaseUrl(baseUrl, "") === baseUrl;
|
|
1851
|
+
const references = createBundledReferenceMap<"openai-completions">(
|
|
1852
|
+
"sglang" as Parameters<typeof getBundledModels>[0],
|
|
1853
|
+
);
|
|
1854
|
+
return {
|
|
1855
|
+
providerId: "sglang",
|
|
1856
|
+
...(baseUrl && (apiKey || isLoopback)
|
|
1857
|
+
? {
|
|
1858
|
+
fetchDynamicModels: () =>
|
|
1859
|
+
fetchOpenAICompatibleModels({
|
|
1860
|
+
api: "openai-completions",
|
|
1861
|
+
provider: "sglang",
|
|
1862
|
+
baseUrl,
|
|
1863
|
+
apiKey,
|
|
1864
|
+
fetch: (input, init) =>
|
|
1865
|
+
fetch(input, {
|
|
1866
|
+
...init,
|
|
1867
|
+
redirect: "error",
|
|
1868
|
+
signal: isLoopback
|
|
1869
|
+
? init?.signal
|
|
1870
|
+
? AbortSignal.any([init.signal, AbortSignal.timeout(500)])
|
|
1871
|
+
: AbortSignal.timeout(500)
|
|
1872
|
+
: init?.signal,
|
|
1873
|
+
}),
|
|
1874
|
+
mapModel: (entry, defaults) => {
|
|
1875
|
+
const model = mapWithBundledReference(entry, defaults, references.get(defaults.id));
|
|
1876
|
+
const contextWindow = toNumber(entry.max_model_len);
|
|
1877
|
+
return {
|
|
1878
|
+
...model,
|
|
1879
|
+
contextWindow:
|
|
1880
|
+
contextWindow !== undefined && Number.isSafeInteger(contextWindow) && contextWindow > 0
|
|
1881
|
+
? contextWindow
|
|
1882
|
+
: model.contextWindow,
|
|
1883
|
+
};
|
|
1884
|
+
},
|
|
1885
|
+
}),
|
|
1886
|
+
}
|
|
1887
|
+
: {}),
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1738
1890
|
|
|
1739
1891
|
// ---------------------------------------------------------------------------
|
|
1740
1892
|
// 23. NanoGPT
|
|
@@ -1668,6 +1668,17 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
|
|
1668
1668
|
if (block) {
|
|
1669
1669
|
blocksByAnthropicIndex.delete(event.index);
|
|
1670
1670
|
delete (block as { index?: number }).index;
|
|
1671
|
+
if (block.type === "toolCall" && /\\u[0-9a-fA-F]{4}/.test(block.partialJson)) {
|
|
1672
|
+
const escapedCodeUnits = [...block.partialJson.matchAll(/\\u([0-9a-fA-F]{4})/g)].map(match =>
|
|
1673
|
+
Number.parseInt(match[1]!, 16),
|
|
1674
|
+
);
|
|
1675
|
+
if (escapedCodeUnits.some(codeUnit => codeUnit >= 0x80)) {
|
|
1676
|
+
Object.defineProperties(block, {
|
|
1677
|
+
escapedNonAsciiArguments: { value: true, configurable: true },
|
|
1678
|
+
escapedNonAsciiArgumentsRaw: { value: block.partialJson, configurable: true },
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1671
1682
|
if (block.type === "text") {
|
|
1672
1683
|
stream.push({
|
|
1673
1684
|
type: "text_end",
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
import { normalizeSystemPrompts, sanitizeJsonStrings } from "../utils";
|
|
39
39
|
import { createAbortSourceTracker } from "../utils/abort";
|
|
40
40
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
41
|
+
import { transportFailureFacts } from "../utils/fallback-transport";
|
|
41
42
|
import { toFirepassWireModelId, toFireworksWireModelId } from "../utils/fireworks-model-id";
|
|
42
43
|
import {
|
|
43
44
|
type CapturedHttpErrorResponse,
|
|
@@ -465,7 +466,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
465
466
|
options?.fetch,
|
|
466
467
|
options?.streamFirstEventTimeoutMs,
|
|
467
468
|
options?.authCredentialType,
|
|
468
|
-
options?.requestMaxRetries,
|
|
469
|
+
options?.fallbackManaged ? 0 : options?.requestMaxRetries,
|
|
469
470
|
options?.sessionId,
|
|
470
471
|
);
|
|
471
472
|
const premiumRequestsTotal = copilotPremiumRequests;
|
|
@@ -500,7 +501,10 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
500
501
|
body: params,
|
|
501
502
|
};
|
|
502
503
|
const { data, response, request_id } = await client.chat.completions
|
|
503
|
-
.create(params as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, {
|
|
504
|
+
.create(params as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, {
|
|
505
|
+
signal: requestSignal,
|
|
506
|
+
...(options?.fallbackManaged ? { maxRetries: 0 } : {}),
|
|
507
|
+
})
|
|
504
508
|
.withResponse();
|
|
505
509
|
await notifyProviderResponse(options, response, model, request_id);
|
|
506
510
|
return data;
|
|
@@ -510,6 +514,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
510
514
|
openaiStream = await callWithCopilotModelRetry(() => createCompletionsStream(), {
|
|
511
515
|
provider: model.provider,
|
|
512
516
|
signal: requestSignal,
|
|
517
|
+
fallbackManaged: options?.fallbackManaged,
|
|
513
518
|
});
|
|
514
519
|
} catch (error) {
|
|
515
520
|
const capturedErrorResponse = getCapturedErrorResponse();
|
|
@@ -766,13 +771,25 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
766
771
|
|
|
767
772
|
if (choice.finish_reason) {
|
|
768
773
|
const finishReasonResult = mapStopReason(choice.finish_reason);
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
774
|
+
if (choice.finish_reason === "content_filter") {
|
|
775
|
+
output.errorKind = "provider_safety_stop";
|
|
776
|
+
}
|
|
777
|
+
if (output.errorKind !== "provider_safety_stop" || choice.finish_reason === "content_filter") {
|
|
778
|
+
output.stopReason = finishReasonResult.stopReason;
|
|
779
|
+
if (finishReasonResult.errorMessage) {
|
|
780
|
+
output.errorMessage = finishReasonResult.errorMessage;
|
|
781
|
+
}
|
|
772
782
|
}
|
|
773
783
|
}
|
|
774
784
|
|
|
775
785
|
if (choice.delta) {
|
|
786
|
+
const refusal = choice.delta.refusal;
|
|
787
|
+
if (typeof refusal === "string" && refusal.length > 0) {
|
|
788
|
+
output.errorKind = "provider_safety_stop";
|
|
789
|
+
output.stopReason = "error";
|
|
790
|
+
output.errorMessage = "Provider refusal";
|
|
791
|
+
appendTextDelta(refusal);
|
|
792
|
+
}
|
|
776
793
|
const normalizedDeltaText = normalizeStreamingContentText(choice.delta.content);
|
|
777
794
|
if (normalizedDeltaText.length > 0) {
|
|
778
795
|
if (!firstTokenTime) firstTokenTime = Date.now();
|
|
@@ -944,13 +961,19 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
944
961
|
const firstEventTimeoutError = abortTracker.getLocalAbortReason();
|
|
945
962
|
output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
|
|
946
963
|
output.errorStatus = extractHttpStatusFromError(error) ?? getCapturedErrorResponse?.()?.status;
|
|
947
|
-
output.
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
964
|
+
output.transportFailure = transportFailureFacts(error);
|
|
965
|
+
if (isOpenAICompletionsSafetyStop(error, getCapturedErrorResponse?.())) {
|
|
966
|
+
output.errorKind = "provider_safety_stop";
|
|
967
|
+
}
|
|
968
|
+
if (output.errorKind !== "provider_safety_stop" || !output.errorMessage) {
|
|
969
|
+
output.errorMessage =
|
|
970
|
+
firstEventTimeoutError?.message ??
|
|
971
|
+
(await finalizeErrorMessage(error, rawRequestDump, getCapturedErrorResponse?.()));
|
|
972
|
+
// Some providers via OpenRouter include extra details here.
|
|
973
|
+
const rawMetadata = (error as { error?: { metadata?: { raw?: string } } })?.error?.metadata?.raw;
|
|
974
|
+
if (rawMetadata) output.errorMessage += `\n${rawMetadata}`;
|
|
975
|
+
output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider);
|
|
976
|
+
}
|
|
954
977
|
output.duration = Date.now() - startTime;
|
|
955
978
|
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
|
956
979
|
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
@@ -1264,8 +1287,12 @@ function buildParams(
|
|
|
1264
1287
|
// Qwen uses top-level enable_thinking: boolean
|
|
1265
1288
|
params.enable_thinking = !!options?.reasoning && !options?.disableReasoning;
|
|
1266
1289
|
} else if (supportsReasoningParams && compat.thinkingFormat === "qwen-chat-template" && model.reasoning) {
|
|
1290
|
+
const enableThinking = !!options?.reasoning && !options?.disableReasoning;
|
|
1267
1291
|
params.chat_template_kwargs = {
|
|
1268
|
-
enable_thinking:
|
|
1292
|
+
enable_thinking: enableThinking,
|
|
1293
|
+
...(enableThinking && options?.reasoning
|
|
1294
|
+
? { reasoning_effort: mapReasoningEffort(options.reasoning, compat.reasoningEffortMap) }
|
|
1295
|
+
: {}),
|
|
1269
1296
|
};
|
|
1270
1297
|
} else if (supportsReasoningParams && compat.thinkingFormat === "openrouter" && model.reasoning) {
|
|
1271
1298
|
// OpenRouter normalizes reasoning across providers via a nested reasoning object.
|
|
@@ -1926,7 +1953,22 @@ function shouldRetryWithoutStrictTools(
|
|
|
1926
1953
|
.join("\n");
|
|
1927
1954
|
return /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool/i.test(messageParts);
|
|
1928
1955
|
}
|
|
1929
|
-
|
|
1956
|
+
function isOpenAICompletionsSafetyStop(
|
|
1957
|
+
error: unknown,
|
|
1958
|
+
capturedErrorResponse: CapturedHttpErrorResponse | undefined,
|
|
1959
|
+
): boolean {
|
|
1960
|
+
const hasContentFilterCode = (value: unknown): boolean =>
|
|
1961
|
+
typeof value === "string" && value.toLowerCase() === "content_filter";
|
|
1962
|
+
const errorRecord = error as { code?: unknown; error?: { code?: unknown } } | undefined;
|
|
1963
|
+
if (hasContentFilterCode(errorRecord?.code) || hasContentFilterCode(errorRecord?.error?.code)) return true;
|
|
1964
|
+
if (!capturedErrorResponse?.bodyText) return false;
|
|
1965
|
+
try {
|
|
1966
|
+
const body = JSON.parse(capturedErrorResponse.bodyText) as { code?: unknown; error?: { code?: unknown } };
|
|
1967
|
+
return hasContentFilterCode(body.code) || hasContentFilterCode(body.error?.code);
|
|
1968
|
+
} catch {
|
|
1969
|
+
return false;
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1930
1972
|
function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): {
|
|
1931
1973
|
stopReason: StopReason;
|
|
1932
1974
|
errorMessage?: string;
|
|
@@ -795,16 +795,18 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
795
795
|
}
|
|
796
796
|
|
|
797
797
|
/**
|
|
798
|
-
* Mark tool-call blocks
|
|
799
|
-
*
|
|
798
|
+
* Mark tool-call blocks whose arguments are structurally incomplete when a
|
|
799
|
+
* response stops for length.
|
|
800
800
|
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
801
|
+
* A missing `output_item.done` normally means the call was cut off. Responses
|
|
802
|
+
* can also hit the output-token limit after emitting a complete JSON object but
|
|
803
|
+
* before finalizing the item (for example, after a long whitespace tail). A
|
|
804
|
+
* non-finalized JSON call is therefore safe only when its exact buffered text
|
|
805
|
+
* parses as complete JSON. Custom tools carry raw input with no structural
|
|
806
|
+
* completion marker, so they still require finalization.
|
|
807
|
+
*
|
|
808
|
+
* Finalized JSON calls get the same defensive parse check for misbehaving
|
|
809
|
+
* relays. No-op unless the turn stopped for length.
|
|
808
810
|
*
|
|
809
811
|
* Shared by both Responses providers (`openai-responses`, `openai-codex-responses`).
|
|
810
812
|
*/
|
|
@@ -816,15 +818,17 @@ export function flagTruncatedToolCalls(
|
|
|
816
818
|
if (stopReason !== "length") return;
|
|
817
819
|
for (const block of output.content) {
|
|
818
820
|
if (block.type !== "toolCall") continue;
|
|
821
|
+
const partial = (block as { partialJson?: string }).partialJson;
|
|
819
822
|
if (!isFinalized(block)) {
|
|
820
|
-
block.
|
|
823
|
+
if (block.customWireName || partial === undefined || !isCompleteJson(partial)) {
|
|
824
|
+
block.incompleteArguments = true;
|
|
825
|
+
}
|
|
821
826
|
continue;
|
|
822
827
|
}
|
|
823
|
-
// Finalized
|
|
824
|
-
//
|
|
825
|
-
if (!block.customWireName) {
|
|
826
|
-
|
|
827
|
-
if (partial !== undefined && !isCompleteJson(partial)) block.incompleteArguments = true;
|
|
828
|
+
// Finalized custom tools carry raw (non-JSON) input. Only JSON function
|
|
829
|
+
// calls need the defensive parse check.
|
|
830
|
+
if (!block.customWireName && partial !== undefined && !isCompleteJson(partial)) {
|
|
831
|
+
block.incompleteArguments = true;
|
|
828
832
|
}
|
|
829
833
|
}
|
|
830
834
|
}
|
package/src/stream.ts
CHANGED
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
} from "./model-thinking";
|
|
12
12
|
import type { BedrockOptions } from "./providers/amazon-bedrock";
|
|
13
13
|
import type { AnthropicOptions } from "./providers/anthropic";
|
|
14
|
+
import {
|
|
15
|
+
hasResolvableAwsProfileSource,
|
|
16
|
+
isValidBedrockBearerToken,
|
|
17
|
+
readAwsStaticEnvironmentCredentials,
|
|
18
|
+
} from "./providers/aws-credential-config";
|
|
14
19
|
import type { CursorOptions } from "./providers/cursor";
|
|
15
20
|
import { isGitLabDuoModel, streamGitLabDuo } from "./providers/gitlab-duo";
|
|
16
21
|
import type { GoogleOptions } from "./providers/google";
|
|
@@ -129,28 +134,12 @@ const serviceProviderMap: Record<string, KeyResolver> = {
|
|
|
129
134
|
return "<authenticated>";
|
|
130
135
|
}
|
|
131
136
|
},
|
|
132
|
-
// Amazon Bedrock
|
|
133
|
-
//
|
|
134
|
-
// 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys
|
|
135
|
-
// 3. AWS_BEARER_TOKEN_BEDROCK - Bedrock API keys (bearer token)
|
|
136
|
-
// 4. AWS_CONTAINER_CREDENTIALS_* - ECS/Task IAM role credentials
|
|
137
|
-
// 5. AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN - IRSA (EKS) web identity
|
|
137
|
+
// Amazon Bedrock is advertised only when a credential source can be
|
|
138
|
+
// resolved locally without probing network metadata providers.
|
|
138
139
|
"amazon-bedrock": () => {
|
|
139
|
-
const awsProfile = $credentialEnv("AWS_PROFILE");
|
|
140
|
-
const awsAccessKeyId = $credentialEnv("AWS_ACCESS_KEY_ID");
|
|
141
|
-
const awsSecretAccessKey = $credentialEnv("AWS_SECRET_ACCESS_KEY");
|
|
142
140
|
const awsBearerToken = $credentialEnv("AWS_BEARER_TOKEN_BEDROCK");
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
!!$credentialEnv("AWS_CONTAINER_CREDENTIALS_FULL_URI");
|
|
146
|
-
const hasWebIdentity = !!$credentialEnv("AWS_WEB_IDENTITY_TOKEN_FILE") && !!$credentialEnv("AWS_ROLE_ARN");
|
|
147
|
-
if (
|
|
148
|
-
awsProfile ||
|
|
149
|
-
(awsAccessKeyId && awsSecretAccessKey) ||
|
|
150
|
-
awsBearerToken ||
|
|
151
|
-
hasEcsCredentials ||
|
|
152
|
-
hasWebIdentity
|
|
153
|
-
) {
|
|
141
|
+
if (awsBearerToken !== undefined && !isValidBedrockBearerToken(awsBearerToken)) return undefined;
|
|
142
|
+
if (awsBearerToken || readAwsStaticEnvironmentCredentials() || hasResolvableAwsProfileSource()) {
|
|
154
143
|
return "<authenticated>";
|
|
155
144
|
}
|
|
156
145
|
},
|
|
@@ -173,6 +162,8 @@ const serviceProviderMap: Record<string, KeyResolver> = {
|
|
|
173
162
|
bizrouter: "BIZROUTER_API_KEY",
|
|
174
163
|
venice: "VENICE_API_KEY",
|
|
175
164
|
vllm: "VLLM_API_KEY",
|
|
165
|
+
omlx: "OMLX_API_KEY",
|
|
166
|
+
sglang: "SGLANG_API_KEY",
|
|
176
167
|
xiaomi: "XIAOMI_API_KEY",
|
|
177
168
|
};
|
|
178
169
|
|
|
@@ -371,7 +362,8 @@ export function streamSimple<TApi extends Api>(
|
|
|
371
362
|
context: Context,
|
|
372
363
|
options?: SimpleStreamOptions,
|
|
373
364
|
): AssistantMessageEventStream {
|
|
374
|
-
const retryApiKey =
|
|
365
|
+
const retryApiKey =
|
|
366
|
+
!options?.fallbackManaged && options?.onAuthError ? (options.apiKey ?? getEnvApiKey(model.provider)) : undefined;
|
|
375
367
|
if (retryApiKey) {
|
|
376
368
|
const outer = new AssistantMessageEventStream();
|
|
377
369
|
const onAuthError = options!.onAuthError!;
|
|
@@ -632,6 +624,8 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
632
624
|
maxRetryDelayMs: options?.maxRetryDelayMs,
|
|
633
625
|
requestMaxRetries: options?.requestMaxRetries,
|
|
634
626
|
streamMaxRetries: options?.streamMaxRetries,
|
|
627
|
+
fallbackManaged: options?.fallbackManaged,
|
|
628
|
+
fallbackAttempt: options?.fallbackAttempt,
|
|
635
629
|
metadata: options?.metadata,
|
|
636
630
|
sessionId: options?.sessionId,
|
|
637
631
|
providerSessionState: options?.providerSessionState,
|
package/src/types.ts
CHANGED
|
@@ -168,7 +168,9 @@ export type KnownProvider =
|
|
|
168
168
|
| "xiaomi-token-plan-ams"
|
|
169
169
|
| "xiaomi-token-plan-cn"
|
|
170
170
|
| "zenmux"
|
|
171
|
-
| "lm-studio"
|
|
171
|
+
| "lm-studio"
|
|
172
|
+
| "omlx"
|
|
173
|
+
| "sglang";
|
|
172
174
|
export type Provider = KnownProvider | string;
|
|
173
175
|
|
|
174
176
|
import type { Effort } from "./model-thinking";
|
|
@@ -529,6 +531,12 @@ export interface ToolCall {
|
|
|
529
531
|
* rejects the call with a retryable error instead.
|
|
530
532
|
*/
|
|
531
533
|
incompleteArguments?: boolean;
|
|
534
|
+
/**
|
|
535
|
+
* Transient raw JSON for a provider-detected `\uXXXX`-escaped non-ASCII
|
|
536
|
+
* tool payload. The agent loop validates and removes it before persistence.
|
|
537
|
+
*/
|
|
538
|
+
escapedNonAsciiArguments?: boolean;
|
|
539
|
+
escapedNonAsciiArgumentsRaw?: string;
|
|
532
540
|
}
|
|
533
541
|
|
|
534
542
|
export interface Usage {
|
|
@@ -1,8 +1,43 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
1
2
|
import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "@sayknow-cli/ai";
|
|
2
3
|
import * as z from "zod/v4";
|
|
3
4
|
import type { Api, FetchImpl, Model, Provider } from "../../types";
|
|
4
5
|
|
|
5
6
|
const MODELS_PATH = "/models";
|
|
7
|
+
const MAX_MODELS_RESPONSE_BYTES = 1_000_000;
|
|
8
|
+
function parseIpv6Hextets(host: string): number[] | undefined {
|
|
9
|
+
if (net.isIP(host) !== 6) return undefined;
|
|
10
|
+
const doubleColon = host.indexOf("::");
|
|
11
|
+
if (doubleColon !== host.lastIndexOf("::")) return undefined;
|
|
12
|
+
const parseSide = (value: string): number[] | undefined => {
|
|
13
|
+
if (!value) return [];
|
|
14
|
+
const parts = value.split(":");
|
|
15
|
+
if (parts.some(part => !/^[0-9a-f]{1,4}$/i.test(part))) return undefined;
|
|
16
|
+
return parts.map(part => Number.parseInt(part, 16));
|
|
17
|
+
};
|
|
18
|
+
if (doubleColon < 0) {
|
|
19
|
+
const hextets = parseSide(host);
|
|
20
|
+
return hextets?.length === 8 ? hextets : undefined;
|
|
21
|
+
}
|
|
22
|
+
const left = parseSide(host.slice(0, doubleColon));
|
|
23
|
+
const right = parseSide(host.slice(doubleColon + 2));
|
|
24
|
+
if (!left || !right) return undefined;
|
|
25
|
+
const missing = 8 - left.length - right.length;
|
|
26
|
+
if (missing < 1) return undefined;
|
|
27
|
+
return [...left, ...new Array<number>(missing).fill(0), ...right];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
31
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
32
|
+
if (host === "localhost") return true;
|
|
33
|
+
if (net.isIP(host) === 4) return host.split(".", 1)[0] === "127";
|
|
34
|
+
const hextets = parseIpv6Hextets(host);
|
|
35
|
+
if (!hextets) return false;
|
|
36
|
+
const isIpv6Loopback = hextets.slice(0, 7).every(part => part === 0) && hextets[7] === 1;
|
|
37
|
+
const isIpv4MappedLoopback =
|
|
38
|
+
hextets.slice(0, 5).every(part => part === 0) && hextets[5] === 0xffff && hextets[6]! >> 8 === 0x7f;
|
|
39
|
+
return isIpv6Loopback || isIpv4MappedLoopback;
|
|
40
|
+
}
|
|
6
41
|
|
|
7
42
|
/**
|
|
8
43
|
* Minimal OpenAI-style model entry shape consumed by discovery.
|
|
@@ -128,7 +163,9 @@ export async function fetchOpenAICompatibleModels<TApi extends Api>(
|
|
|
128
163
|
response = await fetchImpl(`${baseUrl}${MODELS_PATH}`, {
|
|
129
164
|
method: "GET",
|
|
130
165
|
headers: requestHeaders,
|
|
131
|
-
signal: options.signal
|
|
166
|
+
signal: options.signal
|
|
167
|
+
? AbortSignal.any([options.signal, AbortSignal.timeout(5_000)])
|
|
168
|
+
: AbortSignal.timeout(5_000),
|
|
132
169
|
});
|
|
133
170
|
} catch {
|
|
134
171
|
return null;
|
|
@@ -144,7 +181,7 @@ export async function fetchOpenAICompatibleModels<TApi extends Api>(
|
|
|
144
181
|
|
|
145
182
|
let payload: unknown;
|
|
146
183
|
try {
|
|
147
|
-
payload = await response
|
|
184
|
+
payload = JSON.parse(await readModelsResponse(response));
|
|
148
185
|
} catch {
|
|
149
186
|
return null;
|
|
150
187
|
}
|
|
@@ -188,6 +225,39 @@ export async function fetchOpenAICompatibleModels<TApi extends Api>(
|
|
|
188
225
|
return Array.from(deduped.values()).sort((left, right) => left.id.localeCompare(right.id));
|
|
189
226
|
}
|
|
190
227
|
|
|
228
|
+
async function readModelsResponse(response: Response): Promise<string> {
|
|
229
|
+
const contentLength = Number(response.headers.get("content-length"));
|
|
230
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_MODELS_RESPONSE_BYTES) {
|
|
231
|
+
throw new Error("OpenAI-compatible models response exceeds the size limit");
|
|
232
|
+
}
|
|
233
|
+
if (!response.body) return "";
|
|
234
|
+
const reader = response.body.getReader();
|
|
235
|
+
const chunks: Uint8Array[] = [];
|
|
236
|
+
let total = 0;
|
|
237
|
+
try {
|
|
238
|
+
while (true) {
|
|
239
|
+
const { done, value } = await reader.read();
|
|
240
|
+
if (done) break;
|
|
241
|
+
if (!value) continue;
|
|
242
|
+
total += value.byteLength;
|
|
243
|
+
if (total > MAX_MODELS_RESPONSE_BYTES) {
|
|
244
|
+
await reader.cancel();
|
|
245
|
+
throw new Error("OpenAI-compatible models response exceeds the size limit");
|
|
246
|
+
}
|
|
247
|
+
chunks.push(value);
|
|
248
|
+
}
|
|
249
|
+
} finally {
|
|
250
|
+
reader.releaseLock();
|
|
251
|
+
}
|
|
252
|
+
const body = new Uint8Array(total);
|
|
253
|
+
let offset = 0;
|
|
254
|
+
for (const chunk of chunks) {
|
|
255
|
+
body.set(chunk, offset);
|
|
256
|
+
offset += chunk.byteLength;
|
|
257
|
+
}
|
|
258
|
+
return new TextDecoder().decode(body);
|
|
259
|
+
}
|
|
260
|
+
|
|
191
261
|
function normalizeBaseUrl(baseUrl: string): string {
|
|
192
262
|
const trimmed = baseUrl.trim();
|
|
193
263
|
if (!trimmed) {
|
|
@@ -195,6 +265,40 @@ function normalizeBaseUrl(baseUrl: string): string {
|
|
|
195
265
|
}
|
|
196
266
|
return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
|
|
197
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* Returns a canonical HTTP(S) OpenAI-compatible base URL without embedded URL
|
|
270
|
+
* credentials, query parameters, or fragments.
|
|
271
|
+
*/
|
|
272
|
+
export function resolveCanonicalOpenAIBaseUrl(value: string | undefined): string | undefined {
|
|
273
|
+
const candidate = value?.trim();
|
|
274
|
+
if (!candidate) return undefined;
|
|
275
|
+
try {
|
|
276
|
+
const parsed = new URL(candidate);
|
|
277
|
+
if (
|
|
278
|
+
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
|
|
279
|
+
!parsed.username &&
|
|
280
|
+
!parsed.password &&
|
|
281
|
+
!parsed.search &&
|
|
282
|
+
!parsed.hash
|
|
283
|
+
) {
|
|
284
|
+
return candidate;
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
// Invalid endpoint.
|
|
288
|
+
}
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Returns a local OpenAI-compatible base URL only when it is an HTTP(S)
|
|
294
|
+
* loopback endpoint; otherwise returns the trusted fallback.
|
|
295
|
+
*/
|
|
296
|
+
export function resolveLoopbackOpenAIBaseUrl(value: string | undefined, fallback: string): string {
|
|
297
|
+
const candidate = resolveCanonicalOpenAIBaseUrl(value);
|
|
298
|
+
if (!candidate) return fallback;
|
|
299
|
+
const parsed = new URL(candidate);
|
|
300
|
+
return isLoopbackHost(parsed.hostname) ? candidate : fallback;
|
|
301
|
+
}
|
|
198
302
|
|
|
199
303
|
function extractModelEntries(payload: unknown): ParsedOpenAICompatibleModelRecord[] | null {
|
|
200
304
|
return extractModelEntriesFromNode(payload);
|
package/src/utils/oauth/index.ts
CHANGED
|
@@ -255,6 +255,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
|
|
|
255
255
|
name: "vLLM (Local OpenAI-compatible)",
|
|
256
256
|
available: true,
|
|
257
257
|
},
|
|
258
|
+
{
|
|
259
|
+
id: "sglang",
|
|
260
|
+
name: "SGLang (Local OpenAI-compatible)",
|
|
261
|
+
available: true,
|
|
262
|
+
},
|
|
258
263
|
{
|
|
259
264
|
id: "cloudflare-ai-gateway",
|
|
260
265
|
name: "Cloudflare AI Gateway",
|
|
@@ -398,6 +403,7 @@ export async function refreshOAuthToken(
|
|
|
398
403
|
case "bizrouter":
|
|
399
404
|
case "opengateway":
|
|
400
405
|
case "vllm":
|
|
406
|
+
case "sglang":
|
|
401
407
|
// API keys / static bearer tokens don't expire, return as-is
|
|
402
408
|
newCredentials = credentials;
|
|
403
409
|
break;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SGLang login flow.
|
|
3
|
+
*
|
|
4
|
+
* SGLang commonly exposes an OpenAI-compatible API on a local server. It may
|
|
5
|
+
* require a bearer token, but local servers commonly allow unauthenticated
|
|
6
|
+
* access. This flow stores an API-key-style credential for auth storage.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { OAuthController, OAuthProvider } from "./types";
|
|
10
|
+
|
|
11
|
+
const PROVIDER_ID: OAuthProvider = "sglang";
|
|
12
|
+
const AUTH_URL = "https://docs.sglang.io/docs/advanced_features/server_arguments.html";
|
|
13
|
+
const DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:30000/v1";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Login to SGLang with an explicit bearer token.
|
|
17
|
+
*/
|
|
18
|
+
export async function loginSglang(options: OAuthController): Promise<string> {
|
|
19
|
+
if (!options.onPrompt) {
|
|
20
|
+
throw new Error(`${PROVIDER_ID} login requires onPrompt callback`);
|
|
21
|
+
}
|
|
22
|
+
options.onAuth?.({
|
|
23
|
+
url: AUTH_URL,
|
|
24
|
+
instructions: `Paste the API key configured with SGLang's --api-key option. Local no-auth servers at ${DEFAULT_LOCAL_BASE_URL} are discovered automatically and do not need /login.`,
|
|
25
|
+
});
|
|
26
|
+
const apiKey = await options.onPrompt({
|
|
27
|
+
message: "Paste your SGLang API key",
|
|
28
|
+
placeholder: "SGLang API key",
|
|
29
|
+
allowEmpty: false,
|
|
30
|
+
});
|
|
31
|
+
if (options.signal?.aborted) {
|
|
32
|
+
throw new Error("Login cancelled");
|
|
33
|
+
}
|
|
34
|
+
const trimmed = apiKey.trim();
|
|
35
|
+
if (!trimmed) {
|
|
36
|
+
throw new Error("SGLang API key is required; local no-auth servers are discovered automatically");
|
|
37
|
+
}
|
|
38
|
+
return trimmed;
|
|
39
|
+
}
|
package/src/utils/oauth/types.ts
CHANGED
package/src/utils/overflow.ts
CHANGED
|
@@ -127,8 +127,12 @@ const EMPTY_RESPONSE_USAGE_THRESHOLD = 5;
|
|
|
127
127
|
* misleading error text.
|
|
128
128
|
*/
|
|
129
129
|
const OVERFLOW_PROVIDER_CODES = new Set(["context_length_exceeded", "request_too_large"]);
|
|
130
|
+
/**
|
|
131
|
+
* Codes that name a specific non-overflow *cause*. These are authoritative and
|
|
132
|
+
* can never be upgraded by error prose. Generic HTTP envelope types belong in
|
|
133
|
+
* {@link GENERIC_ENVELOPE_PROVIDER_CODES} instead.
|
|
134
|
+
*/
|
|
130
135
|
const NON_OVERFLOW_PROVIDER_CODES = new Set([
|
|
131
|
-
"invalid_request_error",
|
|
132
136
|
"authentication_error",
|
|
133
137
|
"invalid_api_key",
|
|
134
138
|
"invalid_token",
|
|
@@ -145,6 +149,7 @@ const NON_OVERFLOW_PROVIDER_CODES = new Set([
|
|
|
145
149
|
"rate_limit_error",
|
|
146
150
|
"rate_limit_exceeded",
|
|
147
151
|
"too_many_requests",
|
|
152
|
+
"empty_response",
|
|
148
153
|
]);
|
|
149
154
|
|
|
150
155
|
function transportCodes(transportFailure: TransportFailureFacts | undefined): string[] {
|
|
@@ -157,6 +162,53 @@ function hasTypedNonOverflowCode(transportFailure: TransportFailureFacts | undef
|
|
|
157
162
|
return transportCodes(transportFailure).some(code => NON_OVERFLOW_PROVIDER_CODES.has(code));
|
|
158
163
|
}
|
|
159
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Generic envelope codes that name the HTTP error *category*, not its cause.
|
|
167
|
+
*
|
|
168
|
+
* Anthropic reports context overflow through this envelope:
|
|
169
|
+
*
|
|
170
|
+
* {"type":"error","error":{"type":"invalid_request_error",
|
|
171
|
+
* "message":"prompt is too long: 1158066 tokens > 1000000 maximum"}}
|
|
172
|
+
*
|
|
173
|
+
* Treating the envelope as an authoritative non-overflow cause vetoed the
|
|
174
|
+
* overflow classification, so auto-compaction never ran and the session died on
|
|
175
|
+
* the very overflow it was supposed to absorb.
|
|
176
|
+
*
|
|
177
|
+
* Unlike {@link NON_OVERFLOW_PROVIDER_CODES} (auth, quota, rate limit), this
|
|
178
|
+
* envelope names no cause, so it must not veto an overflow the provider stated
|
|
179
|
+
* quantitatively. It still vetoes free-form prose: only the self-verifying
|
|
180
|
+
* measured form below can override it.
|
|
181
|
+
*/
|
|
182
|
+
const GENERIC_ENVELOPE_PROVIDER_CODES = new Set(["invalid_request_error"]);
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Anthropic's measured overflow report: `<used> tokens > <limit> maximum`.
|
|
186
|
+
*
|
|
187
|
+
* Deliberately far narrower than {@link OVERFLOW_PATTERNS}. Those patterns
|
|
188
|
+
* include loose prose (`too many tokens`, `token limit exceeded`) that a tool
|
|
189
|
+
* result or a model-authored string can trivially contain, so they must never
|
|
190
|
+
* be able to flip a typed transport classification. This form carries its own
|
|
191
|
+
* arithmetic proof and is verified below, so injected text cannot satisfy it
|
|
192
|
+
* without also asserting a real overage.
|
|
193
|
+
*/
|
|
194
|
+
const ANTHROPIC_MEASURED_OVERFLOW_PATTERN = /prompt is too long:\s*(\d+)\s*tokens?\s*>\s*(\d+)\s*maximum/i;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* True only for a provider-measured overflow that verifies against itself:
|
|
198
|
+
* the reported usage must actually exceed the reported maximum.
|
|
199
|
+
*/
|
|
200
|
+
function hasSelfVerifyingOverflowMeasurement(message: AssistantMessage): boolean {
|
|
201
|
+
if (message.stopReason !== "error") return false;
|
|
202
|
+
const errorMessage = message.errorMessage;
|
|
203
|
+
if (!errorMessage) return false;
|
|
204
|
+
const match = ANTHROPIC_MEASURED_OVERFLOW_PATTERN.exec(errorMessage);
|
|
205
|
+
if (!match) return false;
|
|
206
|
+
const used = Number(match[1]);
|
|
207
|
+
const maximum = Number(match[2]);
|
|
208
|
+
if (!Number.isFinite(used) || !Number.isFinite(maximum) || maximum <= 0) return false;
|
|
209
|
+
return used > maximum;
|
|
210
|
+
}
|
|
211
|
+
|
|
160
212
|
function isTypedNoBodyOverflow(
|
|
161
213
|
message: AssistantMessage,
|
|
162
214
|
transportFailure: TransportFailureFacts | undefined,
|
|
@@ -173,7 +225,18 @@ export function classifyContextOverflow(
|
|
|
173
225
|
if (transportFailure?.status === 429) return false;
|
|
174
226
|
const typedCodes = transportCodes(transportFailure);
|
|
175
227
|
if (typedCodes.some(code => OVERFLOW_PROVIDER_CODES.has(code))) return true;
|
|
228
|
+
// A specific non-overflow cause (auth, quota, rate limit) is authoritative
|
|
229
|
+
// and can never be upgraded by error prose.
|
|
176
230
|
if (hasTypedNonOverflowCode(transportFailure)) return false;
|
|
231
|
+
// A generic envelope (`invalid_request_error`) names no cause. It still
|
|
232
|
+
// vetoes free-form overflow prose, but must not veto a provider-measured,
|
|
233
|
+
// self-verifying overflow report — that is how Anthropic reports overflow.
|
|
234
|
+
if (
|
|
235
|
+
typedCodes.some(code => GENERIC_ENVELOPE_PROVIDER_CODES.has(code)) &&
|
|
236
|
+
!hasSelfVerifyingOverflowMeasurement(message)
|
|
237
|
+
) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
177
240
|
if (isTypedNoBodyOverflow(message, transportFailure)) return true;
|
|
178
241
|
|
|
179
242
|
const errorMessage = message.errorMessage;
|