@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 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 left incomplete by a length-truncated response so the
49
- * agent loop rejects them instead of executing a best-effort partial parse.
48
+ * Mark tool-call blocks whose arguments are structurally incomplete when a
49
+ * response stops for length.
50
50
  *
51
- * The universal signal is finalization: a call that never received its terminal
52
- * `output_item.done` (passed in via `isFinalized`) was cut off mid-arguments.
53
- * This covers both JSON function calls and raw-input custom tools without
54
- * mis-flagging a *completed* custom tool whose raw input is not valid JSON. As a
55
- * defensive secondary, a finalized JSON function call whose buffered arguments
56
- * still don't parse (e.g. a misbehaving relay) is flagged too. No-op unless the
57
- * turn stopped for length.
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
  */
@@ -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.1",
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.1",
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"
@@ -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
- return this.#configValueResolver(apiKeySelection.credential.key);
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.#recordSessionCredential(provider, sessionId, "api_key", selectedCredential.index);
3492
- return this.#configValueResolver(selectedCredential.credential.key);
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.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
3499
- return this.#configValueResolver(apiKeySelection.credential.key);
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
 
@@ -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 model = refreshModelThinking(models[index]!);
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, { signal: requestSignal })
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
- output.stopReason = finishReasonResult.stopReason;
770
- if (finishReasonResult.errorMessage) {
771
- output.errorMessage = finishReasonResult.errorMessage;
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.errorMessage =
948
- firstEventTimeoutError?.message ??
949
- (await finalizeErrorMessage(error, rawRequestDump, getCapturedErrorResponse?.()));
950
- // Some providers via OpenRouter include extra details here.
951
- const rawMetadata = (error as { error?: { metadata?: { raw?: string } } })?.error?.metadata?.raw;
952
- if (rawMetadata) output.errorMessage += `\n${rawMetadata}`;
953
- output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider);
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: !!options?.reasoning && !options?.disableReasoning,
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 left incomplete by a length-truncated response so the
799
- * agent loop rejects them instead of executing a best-effort partial parse.
798
+ * Mark tool-call blocks whose arguments are structurally incomplete when a
799
+ * response stops for length.
800
800
  *
801
- * The universal signal is finalization: a call that never received its terminal
802
- * `output_item.done` (passed in via `isFinalized`) was cut off mid-arguments.
803
- * This covers both JSON function calls and raw-input custom tools without
804
- * mis-flagging a *completed* custom tool whose raw input is not valid JSON. As a
805
- * defensive secondary, a finalized JSON function call whose buffered arguments
806
- * still don't parse (e.g. a misbehaving relay) is flagged too. No-op unless the
807
- * turn stopped for length.
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.incompleteArguments = true;
823
+ if (block.customWireName || partial === undefined || !isCompleteJson(partial)) {
824
+ block.incompleteArguments = true;
825
+ }
821
826
  continue;
822
827
  }
823
- // Finalized: custom tools carry raw (non-JSON) input and are complete once
824
- // finalized; only JSON function calls get the parse double-check.
825
- if (!block.customWireName) {
826
- const partial = (block as { partialJson?: string }).partialJson;
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 supports multiple credential sources:
133
- // 1. AWS_PROFILE - named profile from ~/.aws/credentials
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
- const hasEcsCredentials =
144
- !!$credentialEnv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") ||
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 = options?.onAuthError ? (options.apiKey ?? getEnvApiKey(model.provider)) : undefined;
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.json();
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);
@@ -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
+ }
@@ -52,6 +52,7 @@ export type OAuthProvider =
52
52
  | "venice"
53
53
  | "vercel-ai-gateway"
54
54
  | "vllm"
55
+ | "sglang"
55
56
  | "xai"
56
57
  | "glm-zcode"
57
58
  | "xiaomi"
@@ -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;