@sayknow-cli/ai 0.5.2 → 0.5.8

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.
@@ -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",
@@ -326,7 +338,7 @@ export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [
326
338
  config => bizrouterModelManagerOptions(config),
327
339
  catalog("BizRouter", ["BIZROUTER_API_KEY"]),
328
340
  ),
329
- catalogDescriptor("zai", "glm-5.2", config => zaiModelManagerOptions(config), catalog("zAI", ["ZAI_API_KEY"])),
341
+ catalogDescriptor("zai", "glm-5.3", config => zaiModelManagerOptions(config), catalog("zAI", ["ZAI_API_KEY"])),
330
342
  catalogDescriptor(
331
343
  "glm-zcode",
332
344
  "glm-5.2",
@@ -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
@@ -491,7 +491,7 @@ function getCacheControl(
491
491
  }
492
492
 
493
493
  // Stealth mode: Mimic Anthropic Code headers and tool prefixing.
494
- export const claudeCodeVersion = "2.1.219";
494
+ export const claudeCodeVersion = "2.1.267";
495
495
  export const claudeCodeEntrypoint = "sdk-cli";
496
496
  export const claudeToolPrefix: string = "proxy_";
497
497
  export const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
@@ -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;
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;