@sayknow-cli/ai 0.4.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +6 -0
  2. package/dist/types/auth-broker/client.d.ts +2 -1
  3. package/dist/types/auth-broker/remote-store.d.ts +2 -1
  4. package/dist/types/auth-broker/types.d.ts +3 -1
  5. package/dist/types/auth-broker/wire-schemas.d.ts +68 -0
  6. package/dist/types/auth-storage.d.ts +21 -1
  7. package/dist/types/provider-models/openai-compat.d.ts +19 -2
  8. package/dist/types/providers/anthropic.d.ts +11 -1
  9. package/dist/types/providers/azure-openai-responses.d.ts +6 -1
  10. package/dist/types/providers/google-auth.d.ts +2 -0
  11. package/dist/types/providers/google-gemini-headers.d.ts +1 -1
  12. package/dist/types/providers/google-vertex.d.ts +2 -0
  13. package/dist/types/providers/openai-codex-responses.d.ts +4 -0
  14. package/dist/types/providers/openai-completions.d.ts +2 -0
  15. package/dist/types/providers/openai-responses.d.ts +2 -0
  16. package/dist/types/providers/register-builtins.d.ts +8 -0
  17. package/dist/types/providers/transform-messages.d.ts +1 -0
  18. package/dist/types/types.d.ts +3 -1
  19. package/dist/types/usage/grok-cli.d.ts +3 -1
  20. package/dist/types/usage/kimi.d.ts +2 -0
  21. package/dist/types/utils/anthropic-auth.d.ts +8 -0
  22. package/dist/types/utils/foundry.d.ts +10 -0
  23. package/dist/types/utils/http-inspector.d.ts +13 -0
  24. package/dist/types/utils/idle-iterator.d.ts +3 -2
  25. package/dist/types/utils/oauth/alibaba-token-plan.d.ts +19 -0
  26. package/dist/types/utils/oauth/bizrouter.d.ts +1 -0
  27. package/dist/types/utils/oauth/opengateway.d.ts +1 -0
  28. package/dist/types/utils/oauth/types.d.ts +1 -1
  29. package/package.json +2 -2
  30. package/src/auth-broker/client.ts +13 -0
  31. package/src/auth-broker/refresher.ts +1 -0
  32. package/src/auth-broker/remote-store.ts +25 -0
  33. package/src/auth-broker/server.ts +10 -2
  34. package/src/auth-broker/types.ts +4 -0
  35. package/src/auth-broker/wire-schemas.ts +17 -1
  36. package/src/auth-storage.ts +234 -45
  37. package/src/cli.ts +2 -0
  38. package/src/model-thinking.ts +11 -3
  39. package/src/models.json +3289 -486
  40. package/src/provider-models/descriptors.ts +19 -6
  41. package/src/provider-models/openai-compat.ts +99 -18
  42. package/src/providers/amazon-bedrock.ts +4 -0
  43. package/src/providers/anthropic.ts +131 -28
  44. package/src/providers/azure-openai-responses.ts +16 -3
  45. package/src/providers/google-auth.ts +13 -2
  46. package/src/providers/google-gemini-headers.ts +1 -1
  47. package/src/providers/google-vertex.ts +7 -2
  48. package/src/providers/openai-anthropic-shim.ts +4 -0
  49. package/src/providers/openai-codex-responses.ts +52 -10
  50. package/src/providers/openai-completions-compat.ts +2 -2
  51. package/src/providers/openai-completions.ts +20 -3
  52. package/src/providers/openai-responses.ts +17 -10
  53. package/src/providers/register-builtins.ts +21 -2
  54. package/src/providers/transform-messages.ts +25 -6
  55. package/src/stream.ts +3 -1
  56. package/src/types.ts +9 -2
  57. package/src/usage/claude.ts +21 -3
  58. package/src/usage/grok-cli.ts +12 -1
  59. package/src/usage/kimi.ts +16 -2
  60. package/src/utils/anthropic-auth.ts +11 -3
  61. package/src/utils/foundry.ts +12 -2
  62. package/src/utils/http-inspector.ts +77 -0
  63. package/src/utils/idle-iterator.ts +20 -7
  64. package/src/utils/oauth/{alibaba-coding-plan.ts → alibaba-token-plan.ts} +12 -11
  65. package/src/utils/oauth/bizrouter.ts +15 -0
  66. package/src/utils/oauth/index.ts +15 -2
  67. package/src/utils/oauth/opengateway.ts +15 -0
  68. package/src/utils/oauth/types.ts +3 -1
  69. package/src/utils/validation.ts +17 -2
  70. package/src/utils.ts +41 -4
  71. package/dist/types/utils/oauth/alibaba-coding-plan.d.ts +0 -18
@@ -9,8 +9,9 @@ import type { OAuthProvider } from "../utils/oauth/types";
9
9
  import { googleModelManagerOptions } from "./google";
10
10
  import { ollamaCloudModelManagerOptions } from "./ollama";
11
11
  import {
12
- alibabaCodingPlanModelManagerOptions,
12
+ alibabaTokenPlanModelManagerOptions,
13
13
  anthropicModelManagerOptions,
14
+ bizrouterModelManagerOptions,
14
15
  cerebrasModelManagerOptions,
15
16
  cloudflareAiGatewayModelManagerOptions,
16
17
  deepinfraModelManagerOptions,
@@ -33,6 +34,7 @@ import {
33
34
  openaiModelManagerOptions,
34
35
  opencodeGoModelManagerOptions,
35
36
  opencodeZenModelManagerOptions,
37
+ opengatewayModelManagerOptions,
36
38
  openrouterModelManagerOptions,
37
39
  qianfanModelManagerOptions,
38
40
  qwenPortalModelManagerOptions,
@@ -130,10 +132,10 @@ function catalogDescriptor(
130
132
  export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [
131
133
  descriptor("anthropic", "claude-sonnet-4-6", config => anthropicModelManagerOptions(config)),
132
134
  catalogDescriptor(
133
- "alibaba-coding-plan",
134
- "qwen3.5-plus",
135
- config => alibabaCodingPlanModelManagerOptions(config),
136
- catalog("Alibaba Coding Plan", ["ALIBABA_CODING_PLAN_API_KEY"]),
135
+ "alibaba-token-plan",
136
+ "deepseek-v4-pro",
137
+ config => alibabaTokenPlanModelManagerOptions(config),
138
+ catalog("Alibaba Token Plan", ["ALIBABA_TOKEN_PLAN_API_KEY"], { oauthProvider: "alibaba-token-plan" }),
137
139
  ),
138
140
  descriptor("openai", "gpt-5.4", config => openaiModelManagerOptions(config)),
139
141
  descriptor("groq", "openai/gpt-oss-120b", config => groqModelManagerOptions(config)),
@@ -312,6 +314,18 @@ export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [
312
314
  config => zenmuxModelManagerOptions(config),
313
315
  catalog("ZenMux", ["ZENMUX_API_KEY"]),
314
316
  ),
317
+ catalogDescriptor(
318
+ "opengateway",
319
+ "openai/gpt-4o",
320
+ config => opengatewayModelManagerOptions(config),
321
+ catalog("OpenGateway by Sionic AI", ["OPENGATEWAY_API_KEY"]),
322
+ ),
323
+ catalogDescriptor(
324
+ "bizrouter",
325
+ "anthropic/claude-sonnet-4.5",
326
+ config => bizrouterModelManagerOptions(config),
327
+ catalog("BizRouter", ["BIZROUTER_API_KEY"]),
328
+ ),
315
329
  catalogDescriptor("zai", "glm-5.2", config => zaiModelManagerOptions(config), catalog("zAI", ["ZAI_API_KEY"])),
316
330
  catalogDescriptor(
317
331
  "glm-zcode",
@@ -334,7 +348,6 @@ export const DEFAULT_MODEL_PER_PROVIDER: Record<KnownProvider, string> = {
334
348
  ...Object.fromEntries(PROVIDER_DESCRIPTORS.map(d => [d.providerId, d.defaultModel])),
335
349
  // Providers not in PROVIDER_DESCRIPTORS (special auth or no standard discovery)
336
350
  "azure-openai": "gpt-4.1",
337
- "alibaba-coding-plan": "qwen3.5-plus",
338
351
  "amazon-bedrock": "us.anthropic.claude-opus-4-6-v1",
339
352
  "google-antigravity": "gemini-3-pro-high",
340
353
  "google-gemini-cli": "gemini-2.5-pro",
@@ -1,4 +1,4 @@
1
- import { $env, $inheritedEnv } from "@sayknow-cli/utils";
1
+ import { $credentialEnv } from "@sayknow-cli/utils";
2
2
  import type { ModelManagerOptions } from "../model-manager";
3
3
  import { Effort } from "../model-thinking";
4
4
  import { getBundledModels } from "../models";
@@ -553,13 +553,19 @@ export interface OpenAIModelManagerConfig {
553
553
  baseUrl?: string;
554
554
  }
555
555
 
556
+ /** Base URL for the OpenAI model manager, from trusted env only (`$env` merges the caller's `cwd/.env`). */
557
+ function resolveOpenAIModelManagerBaseUrl(config?: OpenAIModelManagerConfig): string {
558
+ return config?.baseUrl?.trim() || $credentialEnv("OPENAI_BASE_URL") || OPENAI_DEFAULT_BASE_URL;
559
+ }
560
+
561
+ /** Test seam: the model-manager base URL as resolved from trusted env. */
562
+ export function resolveOpenAIModelManagerBaseUrlForTest(config?: OpenAIModelManagerConfig): string {
563
+ return resolveOpenAIModelManagerBaseUrl(config);
564
+ }
565
+
556
566
  export function openaiModelManagerOptions(config?: OpenAIModelManagerConfig): ModelManagerOptions<"openai-responses"> {
557
567
  const apiKey = config?.apiKey;
558
- const baseUrl =
559
- config?.baseUrl?.trim() ||
560
- $inheritedEnv("OPENAI_BASE_URL") ||
561
- $env.OPENAI_BASE_URL?.trim() ||
562
- OPENAI_DEFAULT_BASE_URL;
568
+ const baseUrl = resolveOpenAIModelManagerBaseUrl(config);
563
569
  const references = createBundledReferenceMap<"openai-responses">("openai");
564
570
  return {
565
571
  providerId: "openai",
@@ -1099,6 +1105,81 @@ export function zenmuxModelManagerOptions(config?: ZenMuxModelManagerConfig): Mo
1099
1105
  };
1100
1106
  }
1101
1107
 
1108
+ // ---------------------------------------------------------------------------
1109
+ // 10.5.1 OpenGateway by Sionic AI
1110
+ // ---------------------------------------------------------------------------
1111
+
1112
+ export interface OpenGatewayModelManagerConfig {
1113
+ apiKey?: string;
1114
+ baseUrl?: string;
1115
+ }
1116
+
1117
+ /**
1118
+ * OpenGateway by Sionic AI — an OpenAI-compatible gateway that fronts OpenAI,
1119
+ * Anthropic, and Google models behind one API key. Models are discovered from
1120
+ * the OpenAI-compatible `/v1/models` endpoint.
1121
+ */
1122
+ export function opengatewayModelManagerOptions(
1123
+ config?: OpenGatewayModelManagerConfig,
1124
+ ): ModelManagerOptions<"openai-completions"> {
1125
+ return createSimpleOpenAICompletionsOptions("opengateway", "https://apis.opengateway.ai/v1", config);
1126
+ }
1127
+
1128
+ // ---------------------------------------------------------------------------
1129
+ // 10.5.2 BizRouter
1130
+ // ---------------------------------------------------------------------------
1131
+
1132
+ const BIZROUTER_BASE_URL = "https://api.bizrouter.ai/v1";
1133
+
1134
+ function toBizRouterPrice(value: unknown, fallback: number): number {
1135
+ const parsed = toNumber(value);
1136
+ return parsed === undefined || parsed < 0 ? fallback : parsed;
1137
+ }
1138
+
1139
+ export interface BizRouterModelManagerConfig {
1140
+ apiKey?: string;
1141
+ baseUrl?: string;
1142
+ }
1143
+
1144
+ export function bizrouterModelManagerOptions(
1145
+ config?: BizRouterModelManagerConfig,
1146
+ ): ModelManagerOptions<"openai-completions"> {
1147
+ const apiKey = config?.apiKey;
1148
+ const baseUrl = config?.baseUrl ?? BIZROUTER_BASE_URL;
1149
+ const references = createBundledReferenceMap<"openai-completions">("bizrouter");
1150
+ return {
1151
+ providerId: "bizrouter",
1152
+ ...(apiKey && {
1153
+ fetchDynamicModels: () =>
1154
+ fetchOpenAICompatibleModels({
1155
+ api: "openai-completions",
1156
+ provider: "bizrouter",
1157
+ baseUrl,
1158
+ apiKey,
1159
+ mapModel: (entry, defaults) => {
1160
+ const mapped = mapWithBundledReference(entry, defaults, references.get(defaults.id));
1161
+ return {
1162
+ ...mapped,
1163
+ name: toModelName(entry.display_name, mapped.name),
1164
+ contextWindow: toPositiveNumber(entry.context_length, mapped.contextWindow),
1165
+ maxTokens: toPositiveNumber(entry.max_output_tokens, mapped.maxTokens),
1166
+ input: toInputCapabilities(entry.input_modalities),
1167
+ cost: {
1168
+ input: toBizRouterPrice(entry.input_price_per_1m_usd, mapped.cost.input),
1169
+ output: toBizRouterPrice(entry.output_price_per_1m_usd, mapped.cost.output),
1170
+ cacheRead: mapped.cost.cacheRead,
1171
+ cacheWrite: mapped.cost.cacheWrite,
1172
+ },
1173
+ api: "openai-completions",
1174
+ provider: "bizrouter",
1175
+ baseUrl,
1176
+ };
1177
+ },
1178
+ }),
1179
+ }),
1180
+ };
1181
+ }
1182
+
1102
1183
  // ---------------------------------------------------------------------------
1103
1184
  // 10.6 Kilo Gateway
1104
1185
  // ---------------------------------------------------------------------------
@@ -1124,26 +1205,26 @@ export function kiloModelManagerOptions(config?: KiloModelManagerConfig): ModelM
1124
1205
  }
1125
1206
 
1126
1207
  // ---------------------------------------------------------------------------
1127
- // Alibaba Coding Plan
1208
+ // Alibaba Token Plan
1128
1209
  // ---------------------------------------------------------------------------
1129
1210
 
1130
- export interface AlibabaCodingPlanModelManagerConfig {
1211
+ export interface AlibabaTokenPlanModelManagerConfig {
1131
1212
  apiKey?: string;
1132
1213
  baseUrl?: string;
1133
1214
  }
1134
1215
 
1135
- export function alibabaCodingPlanModelManagerOptions(
1136
- config?: AlibabaCodingPlanModelManagerConfig,
1216
+ export function alibabaTokenPlanModelManagerOptions(
1217
+ config?: AlibabaTokenPlanModelManagerConfig,
1137
1218
  ): ModelManagerOptions<"openai-completions"> {
1138
1219
  const apiKey = config?.apiKey;
1139
- const baseUrl = config?.baseUrl ?? "https://coding-intl.dashscope.aliyuncs.com/v1";
1140
- const references = createBundledReferenceMap<"openai-completions">("alibaba-coding-plan");
1220
+ const baseUrl = config?.baseUrl ?? "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1";
1221
+ const references = createBundledReferenceMap<"openai-completions">("alibaba-token-plan");
1141
1222
  return {
1142
- providerId: "alibaba-coding-plan",
1223
+ providerId: "alibaba-token-plan",
1143
1224
  fetchDynamicModels: () =>
1144
1225
  fetchOpenAICompatibleModels({
1145
1226
  api: "openai-completions",
1146
- provider: "alibaba-coding-plan",
1227
+ provider: "alibaba-token-plan",
1147
1228
  baseUrl,
1148
1229
  apiKey,
1149
1230
  mapModel: (entry, defaults) => {
@@ -2356,11 +2437,11 @@ const MODELS_DEV_PROVIDER_DESCRIPTORS_CODING_PLANS: readonly ModelsDevProviderDe
2356
2437
  reasoningContentField: "reasoning_content",
2357
2438
  },
2358
2439
  }),
2359
- // --- Alibaba Coding Plan ---
2440
+ // --- Alibaba Token Plan ---
2360
2441
  openAiCompletionsDescriptor(
2361
- "alibaba-coding-plan",
2362
- "alibaba-coding-plan",
2363
- "https://coding-intl.dashscope.aliyuncs.com/v1",
2442
+ "alibaba-token-plan",
2443
+ "alibaba-token-plan",
2444
+ "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
2364
2445
  {
2365
2446
  compat: {
2366
2447
  supportsDeveloperRole: false,
@@ -910,11 +910,15 @@ function buildAdditionalModelRequestFields(
910
910
  /**
911
911
  * Adaptive thinking `display` is supported starting with Anthropic model Opus 4.7.
912
912
  * Older adaptive-thinking models (Opus 4.6, Sonnet 4.6+) reject the field.
913
+ * Fable (5+) postdates Opus 4.7, accepts `display`, and defaults it to
914
+ * "omitted" — thinking tokens are billed but no content streams back — so it
915
+ * must opt in like Opus 4.7+ (issue #2791).
913
916
  * Bedrock model ids are prefixed with region/inference-profile slugs (e.g.
914
917
  * `eu.anthropic.Anthropic model-opus-4-7-...`); the regex matches the `Anthropic model-opus-X-Y`
915
918
  * fragment regardless of prefix.
916
919
  */
917
920
  function supportsAdaptiveThinkingDisplay(modelId: string): boolean {
921
+ if (/claude-fable-\d/.test(modelId)) return true;
918
922
  const match = /claude-opus-(\d+)-(\d+)/.exec(modelId);
919
923
  if (!match) return false;
920
924
  const major = Number(match[1]);
@@ -11,6 +11,7 @@ import type {
11
11
  RawMessageStreamEvent,
12
12
  } from "@anthropic-ai/sdk/resources/messages";
13
13
  import {
14
+ $credentialEnv,
14
15
  $env,
15
16
  extractHttpStatusFromError,
16
17
  isEnoent,
@@ -60,7 +61,12 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
60
61
  import { transportFailureFacts } from "../utils/fallback-transport";
61
62
  import { isFoundryEnabled } from "../utils/foundry";
62
63
  import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
63
- import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
64
+ import {
65
+ getProviderFirstEventTimeoutFallbackMs,
66
+ getStreamFirstEventTimeoutMs,
67
+ getStreamIdleTimeoutMs,
68
+ iterateWithIdleTimeout,
69
+ } from "../utils/idle-iterator";
64
70
  import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse";
65
71
  import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
66
72
  import { notifyProviderResponse } from "../utils/provider-response";
@@ -306,8 +312,12 @@ let warnedStopSequencesTrim = false;
306
312
  /**
307
313
  * Adaptive thinking `display` is supported starting with Anthropic model Opus 4.7.
308
314
  * Older adaptive-thinking models (Opus 4.6, Sonnet 4.6+) reject the field.
315
+ * Fable (5+) postdates Opus 4.7, accepts `display`, and defaults it to
316
+ * "omitted" — thinking tokens are billed but no content streams back — so it
317
+ * must opt in like Opus 4.7+ (issue #2791).
309
318
  */
310
319
  function supportsAdaptiveThinkingDisplay(modelId: string): boolean {
320
+ if (/claude-fable-\d/.test(modelId)) return true;
311
321
  const match = /claude-opus-(\d+)-(\d+)/.exec(modelId);
312
322
  if (!match) return false;
313
323
  const major = Number(match[1]);
@@ -407,6 +417,23 @@ export function isAnthropicThinkingBlockMutationError(error: unknown): boolean {
407
417
  );
408
418
  }
409
419
 
420
+ /**
421
+ * 400 shape where a replayed `thinking`/`redacted_thinking` block fails signature
422
+ * validation, e.g. `messages.5.content.24: Invalid \`signature\` in \`thinking\` block`.
423
+ * Unlike the latest-assistant mutation error above, the cited block can sit anywhere
424
+ * in the replayed history, so recovery must repair every assistant message rather
425
+ * than only the latest one.
426
+ */
427
+ export function isAnthropicThinkingSignatureInvalidError(error: unknown): boolean {
428
+ if (extractHttpStatusFromError(error) !== 400) return false;
429
+ const message = error instanceof Error ? error.message : String(error);
430
+ return (
431
+ /invalid_request_error/i.test(message) &&
432
+ /thinking|redacted_thinking/i.test(message) &&
433
+ /invalid\s+`?signature`?/i.test(message)
434
+ );
435
+ }
436
+
410
437
  function hasStrictAnthropicTools(params: MessageCreateParamsStreaming): boolean {
411
438
  const tools = params.tools as Array<{ strict?: unknown }> | undefined;
412
439
  return tools?.some(tool => tool.strict === true) ?? false;
@@ -464,7 +491,8 @@ function getCacheControl(
464
491
  }
465
492
 
466
493
  // Stealth mode: Mimic Anthropic Code headers and tool prefixing.
467
- export const claudeCodeVersion = "2.1.63";
494
+ export const claudeCodeVersion = "2.1.219";
495
+ export const claudeCodeEntrypoint = "sdk-cli";
468
496
  export const claudeToolPrefix: string = "proxy_";
469
497
  export const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
470
498
 
@@ -540,7 +568,7 @@ function createClaudeBillingHeader(payload: unknown): string {
540
568
  const buildHash = Array.from(randomBytes, byte => byte.toString(16).padStart(2, "0"))
541
569
  .join("")
542
570
  .slice(0, 3);
543
- return `${CLAUDE_BILLING_HEADER_PREFIX} cc_version=${claudeCodeVersion}.${buildHash}; cc_entrypoint=cli; cch=${cch};`;
571
+ return `${CLAUDE_BILLING_HEADER_PREFIX} cc_version=${claudeCodeVersion}.${buildHash}; cc_entrypoint=${claudeCodeEntrypoint}; cch=${cch};`;
544
572
  }
545
573
 
546
574
  const CLAUDE_CLOAKING_USER_ID_REGEX =
@@ -600,6 +628,24 @@ export const stripClaudeToolPrefix = (name: string, prefixOverride: string = cla
600
628
  return name.slice(prefixOverride.length);
601
629
  };
602
630
 
631
+ // Anthropic requires image `data` to be standard (RFC 4648) base64: the standard
632
+ // alphabet only, correct quartet grouping, and padding (when present) confined to
633
+ // a trailing `=`/`==`. A resident image whose blob went missing bakes a
634
+ // human-readable placeholder into `data` (e.g. "[Session resident imageData blob
635
+ // missing: …]"), and other callers can pass whitespace, data URLs, or URL-safe
636
+ // variants — all of which the API rejects with a 400 `invalid base64 data` that
637
+ // fails the *entire* request and bricks the session. Validate the wire format
638
+ // strictly and degrade anything that is not standard base64 to text.
639
+ //
640
+ // Accepts canonical padded forms and their unpadded equivalents; rejects
641
+ // length % 4 === 1, misplaced/overlong padding, whitespace, data URLs, URL-safe
642
+ // (`-`/`_`) alphabets, prose, and empty input. The pattern has no nested
643
+ // quantifier, so even oversized inputs are rejected in linear time.
644
+ const ANTHROPIC_BASE64_IMAGE_DATA = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=?)?$/;
645
+ function isAnthropicBase64ImageData(data: string): boolean {
646
+ return data.length > 0 && data.length % 4 !== 1 && ANTHROPIC_BASE64_IMAGE_DATA.test(data);
647
+ }
648
+
603
649
  /**
604
650
  * Convert content blocks to Anthropic API format
605
651
  */
@@ -623,7 +669,18 @@ function convertContentBlocks(
623
669
  .filter((block): block is TextContent => block.type === "text")
624
670
  .map(block => block.text.toWellFormed())
625
671
  .filter(text => text.trim().length > 0);
626
- const imageBlocks = content.filter((block): block is ImageContent => block.type === "image");
672
+ const imageBlocks: ImageContent[] = [];
673
+ for (const block of content) {
674
+ if (block.type !== "image") continue;
675
+ if (isAnthropicBase64ImageData(block.data)) {
676
+ imageBlocks.push(block);
677
+ continue;
678
+ }
679
+ // Non-base64 image payload (e.g. a missing-blob placeholder): degrade to
680
+ // text so one lost image cannot invalidate the entire request.
681
+ const text = block.data.toWellFormed().trim();
682
+ if (text.length > 0) textBlocks.push(text);
683
+ }
627
684
  const omittedImages = !supportsImages && imageBlocks.length > 0;
628
685
  if (imageBlocks.length === 0 || !supportsImages) {
629
686
  if (omittedImages) {
@@ -761,10 +818,12 @@ function resolveAnthropicBaseUrl(model: Model<"anthropic-messages">, apiKey?: st
761
818
  // calls api.z.ai directly (no zcode.z.ai gateway, no captcha). Pin the base so dynamic
762
819
  // discovery / stale bundled catalogs / model cache can't redirect it elsewhere.
763
820
  if (model.provider === "glm-zcode") {
764
- return normalizeAnthropicBaseUrl(process.env.ZCODE_PLAN_ANTHROPIC_BASE_URL) ?? "https://api.z.ai/api/anthropic";
821
+ return (
822
+ normalizeAnthropicBaseUrl($credentialEnv("ZCODE_PLAN_ANTHROPIC_BASE_URL")) ?? "https://api.z.ai/api/anthropic"
823
+ );
765
824
  }
766
825
  if (model.provider === "anthropic" && isFoundryEnabled()) {
767
- const foundryBaseUrl = normalizeAnthropicBaseUrl($env.FOUNDRY_BASE_URL);
826
+ const foundryBaseUrl = normalizeAnthropicBaseUrl($credentialEnv("FOUNDRY_BASE_URL"));
768
827
  if (foundryBaseUrl) {
769
828
  return foundryBaseUrl;
770
829
  }
@@ -1127,6 +1186,40 @@ function shouldIgnoreAnthropicPreambleEvent(eventType: unknown): boolean {
1127
1186
  return !ANTHROPIC_PRE_MESSAGE_START_EVENT_TYPES.has(eventType);
1128
1187
  }
1129
1188
 
1189
+ function createAnthropicStreamProgressPredicate(): (event: unknown) => boolean {
1190
+ let outputTokens = -1;
1191
+
1192
+ return event => {
1193
+ if (!isRecord(event) || typeof event.type !== "string") return false;
1194
+ if (
1195
+ event.type === "message_start" ||
1196
+ event.type === "content_block_start" ||
1197
+ event.type === "content_block_stop" ||
1198
+ event.type === "message_stop"
1199
+ ) {
1200
+ return true;
1201
+ }
1202
+ if (event.type === "content_block_delta") {
1203
+ if (!isRecord(event.delta)) return false;
1204
+ const delta = event.delta;
1205
+ return (
1206
+ (typeof delta.text === "string" && delta.text.length > 0) ||
1207
+ (typeof delta.thinking === "string" && delta.thinking.length > 0) ||
1208
+ (typeof delta.partial_json === "string" && delta.partial_json.length > 0) ||
1209
+ (typeof delta.signature === "string" && delta.signature.length > 0)
1210
+ );
1211
+ }
1212
+ if (event.type === "message_delta") {
1213
+ if (isRecord(event.delta) && event.delta.stop_reason != null) return true;
1214
+ if (!isRecord(event.usage) || typeof event.usage.output_tokens !== "number") return false;
1215
+ if (event.usage.output_tokens <= outputTokens) return false;
1216
+ outputTokens = event.usage.output_tokens;
1217
+ return true;
1218
+ }
1219
+ return false;
1220
+ };
1221
+ }
1222
+
1130
1223
  function isTransientStreamEnvelopeError(error: unknown): boolean {
1131
1224
  if (!(error instanceof Error)) return false;
1132
1225
  return (
@@ -1283,20 +1376,19 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1283
1376
  let strictFallbackErrorMessage: string | undefined;
1284
1377
  let dropFastMode = providerSessionState?.fastModeDisabled ?? false;
1285
1378
  let droppedForcedToolChoice = false;
1286
- const prepareParams = async (paramsOptions?: {
1287
- repairLatestAssistantThinking?: boolean;
1288
- dropForcedToolChoice?: boolean;
1289
- }): Promise<MessageCreateParamsStreaming> => {
1290
- let nextParams = buildParams(
1291
- model,
1292
- baseUrl,
1293
- context,
1294
- isOAuthToken,
1295
- options,
1296
- disableStrictTools,
1297
- paramsOptions?.repairLatestAssistantThinking === true,
1298
- );
1299
- if (paramsOptions?.dropForcedToolChoice === true) {
1379
+ let repairLatestAssistantThinking = false;
1380
+ let repairAllAssistantThinking = false;
1381
+ const prepareParams = async (): Promise<MessageCreateParamsStreaming> => {
1382
+ // Degradation state is cumulative: every fallback rebuild must merge all
1383
+ // repairs activated so far. Rebuilding from only the immediate call lets
1384
+ // a later strict/forced-tool/fast-mode fallback reintroduce the rejected
1385
+ // shape (e.g. invalid thinking signatures or forced tool_choice), and
1386
+ // the one-shot thinking-repair guard then blocks recovery.
1387
+ let nextParams = buildParams(model, baseUrl, context, isOAuthToken, options, disableStrictTools, {
1388
+ repairLatestAssistantThinking,
1389
+ repairAllAssistantThinking,
1390
+ });
1391
+ if (droppedForcedToolChoice) {
1300
1392
  delete nextParams.tool_choice;
1301
1393
  }
1302
1394
  if (disableStrictTools) {
@@ -1367,7 +1459,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1367
1459
  firstTokenTime = undefined;
1368
1460
  };
1369
1461
  const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs();
1370
- const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
1462
+ const firstEventFallbackMs = getProviderFirstEventTimeoutFallbackMs(model.provider);
1463
+ const firstEventTimeoutMs =
1464
+ options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs);
1371
1465
  stream.push({ type: "start", partial: output });
1372
1466
  // Retry loop for transient errors from the stream.
1373
1467
  // Provider-level transport/rate-limit failures: only before any streamed content starts.
@@ -1401,6 +1495,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1401
1495
  let sawEvent = false;
1402
1496
  let sawMessageStart = false;
1403
1497
  let sawTerminalEnvelope = false;
1498
+ const isProgressEvent = createAnthropicStreamProgressPredicate();
1404
1499
 
1405
1500
  for await (const event of iterateWithIdleTimeout(anthropicStream, {
1406
1501
  idleTimeoutMs,
@@ -1410,6 +1505,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1410
1505
  onIdle: () => activeAbortTracker.abortLocally(idleTimeoutAbortError),
1411
1506
  onFirstItemTimeout: () => activeAbortTracker.abortLocally(firstEventTimeoutAbortError),
1412
1507
  abortSignal: options?.signal,
1508
+ isProgressItem: isProgressEvent,
1413
1509
  })) {
1414
1510
  sawEvent = true;
1415
1511
  if (sawProviderSafetyStop) {
@@ -1730,23 +1826,30 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1730
1826
  registryKey: resolveToolChoice(model, options?.toolChoice).registryKey,
1731
1827
  });
1732
1828
  droppedForcedToolChoice = true;
1733
- params = await prepareParams({ dropForcedToolChoice: true });
1829
+ params = await prepareParams();
1734
1830
  providerRetryAttempt = 0;
1735
1831
  resetOutputForRetry();
1736
1832
  continue;
1737
1833
  }
1834
+ const thinkingSignatureInvalid = isAnthropicThinkingSignatureInvalidError(streamFailure);
1738
1835
  if (
1739
1836
  !options?.fallbackManaged &&
1740
1837
  !thinkingRepairAttempted &&
1741
1838
  firstTokenTime === undefined &&
1742
- isAnthropicThinkingBlockMutationError(streamFailure)
1839
+ (thinkingSignatureInvalid || isAnthropicThinkingBlockMutationError(streamFailure))
1743
1840
  ) {
1744
- logger.debug("anthropic: repairing latest assistant thinking replay after provider rejection", {
1841
+ logger.debug("anthropic: repairing assistant thinking replay after provider rejection", {
1745
1842
  model: model.id,
1843
+ scope: thinkingSignatureInvalid ? "all" : "latest",
1746
1844
  error: streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
1747
1845
  });
1748
1846
  thinkingRepairAttempted = true;
1749
- params = await prepareParams({ repairLatestAssistantThinking: true });
1847
+ if (thinkingSignatureInvalid) {
1848
+ repairAllAssistantThinking = true;
1849
+ } else {
1850
+ repairLatestAssistantThinking = true;
1851
+ }
1852
+ params = await prepareParams();
1750
1853
  providerRetryAttempt = 0;
1751
1854
  resetOutputForRetry();
1752
1855
  continue;
@@ -2210,13 +2313,13 @@ function buildParams(
2210
2313
  isOAuthToken: boolean,
2211
2314
  options?: AnthropicOptions,
2212
2315
  disableStrictTools = false,
2213
- repairLatestAssistantThinking = false,
2316
+ thinkingRepair?: { repairLatestAssistantThinking?: boolean; repairAllAssistantThinking?: boolean },
2214
2317
  ): MessageCreateParamsStreaming {
2215
2318
  const { mode: cacheMode, cacheControl } = getCacheControl(model, baseUrl, options?.cacheRetention);
2216
2319
 
2217
2320
  const params: AnthropicSamplingParams = {
2218
2321
  model: model.id,
2219
- messages: convertAnthropicMessages(context.messages, model, isOAuthToken, { repairLatestAssistantThinking }),
2322
+ messages: convertAnthropicMessages(context.messages, model, isOAuthToken, thinkingRepair),
2220
2323
  max_tokens: options?.maxTokens || (model.maxTokens / 3) | 0,
2221
2324
  stream: true,
2222
2325
  };
@@ -2407,7 +2510,7 @@ export function convertAnthropicMessages(
2407
2510
  messages: Message[],
2408
2511
  model: Model<"anthropic-messages">,
2409
2512
  isOAuthToken: boolean,
2410
- options?: { repairLatestAssistantThinking?: boolean },
2513
+ options?: { repairLatestAssistantThinking?: boolean; repairAllAssistantThinking?: boolean },
2411
2514
  ): MessageParam[] {
2412
2515
  const params: MessageParam[] = [];
2413
2516
 
@@ -1,4 +1,4 @@
1
- import { $env, extractHttpStatusFromError, logger } from "@sayknow-cli/utils";
1
+ import { $credentialEnv, $env, extractHttpStatusFromError, logger } from "@sayknow-cli/utils";
2
2
  import { AzureOpenAI } from "openai";
3
3
  import type {
4
4
  Tool as OpenAITool,
@@ -234,8 +234,13 @@ function resolveAzureConfig(
234
234
  ): { baseUrl: string; apiVersion: string } {
235
235
  const apiVersion = options?.azureApiVersion || $env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION;
236
236
 
237
- const baseUrl = options?.azureBaseUrl?.trim() || $env.AZURE_OPENAI_BASE_URL?.trim() || undefined;
238
- const resourceName = options?.azureResourceName || $env.AZURE_OPENAI_RESOURCE_NAME;
237
+ // Trusted sources only: both of these decide the request endpoint that carries
238
+ // the Azure credential, and `$env` merges the caller's `cwd/.env`. The resource
239
+ // name is the alternate constructor for the same host
240
+ // (`https://<resource>.openai.azure.com/openai/v1`), so it needs the same
241
+ // boundary as the explicit base URL.
242
+ const baseUrl = options?.azureBaseUrl?.trim() || $credentialEnv("AZURE_OPENAI_BASE_URL") || undefined;
243
+ const resourceName = options?.azureResourceName || $credentialEnv("AZURE_OPENAI_RESOURCE_NAME");
239
244
 
240
245
  let resolvedBaseUrl = baseUrl;
241
246
 
@@ -259,6 +264,14 @@ function resolveAzureConfig(
259
264
  };
260
265
  }
261
266
 
267
+ /** Test seam: the Azure endpoint config as resolved from trusted env. */
268
+ export function resolveAzureConfigForTest(
269
+ model: Model<"azure-openai-responses">,
270
+ options?: AzureOpenAIResponsesOptions,
271
+ ): { baseUrl: string; apiVersion: string } {
272
+ return resolveAzureConfig(model, options);
273
+ }
274
+
262
275
  function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) {
263
276
  if (!apiKey) {
264
277
  const envKey = $env.AZURE_OPENAI_API_KEY;
@@ -15,7 +15,7 @@
15
15
  import { Buffer } from "node:buffer";
16
16
  import * as os from "node:os";
17
17
  import * as path from "node:path";
18
- import { $envpos, isEnoent, logger } from "@sayknow-cli/utils";
18
+ import { $credentialEnv, $envpos, isEnoent, logger } from "@sayknow-cli/utils";
19
19
  import type { FetchImpl } from "../types";
20
20
 
21
21
  const OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
@@ -70,8 +70,19 @@ async function readJsonFile<T>(filePath: string): Promise<T | undefined> {
70
70
  }
71
71
  }
72
72
 
73
+ /** Test seam: the ADC credentials file path as resolved from trusted env. */
74
+ export function resolveAdcCredentialsPathForTest(): string | undefined {
75
+ return $credentialEnv("GOOGLE_APPLICATION_CREDENTIALS");
76
+ }
77
+
73
78
  async function loadAdcCredentials(): Promise<{ source: string; creds: AdcFileCredentials } | undefined> {
74
- const gacPath = Bun.env.GOOGLE_APPLICATION_CREDENTIALS;
79
+ // Trusted sources only: this path is read as service-account / authorized-user
80
+ // credentials and exchanged for a Google access token, so whatever can set it
81
+ // chooses the identity the agent authenticates as. `Bun.env` is `process.env`
82
+ // and the env module merges the caller's `cwd/.env` into it, so reading it
83
+ // there would let repository content point this at a key file it ships.
84
+ // `stream.ts` already resolves the same variable through `$credentialEnv`.
85
+ const gacPath = $credentialEnv("GOOGLE_APPLICATION_CREDENTIALS");
75
86
  if (gacPath) {
76
87
  const creds = await readJsonFile<AdcFileCredentials>(gacPath);
77
88
  if (!creds) {
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export const GEMINI_CLI_VERSION_ENV = "SKC_AI_GEMINI_CLI_VERSION";
7
7
  export const LEGACY_GEMINI_CLI_VERSION_ENV = "PI_AI_GEMINI_CLI_VERSION";
8
- export const DEFAULT_GEMINI_CLI_VERSION = "0.50.0";
8
+ export const DEFAULT_GEMINI_CLI_VERSION = "0.52.0";
9
9
 
10
10
  export function getGeminiCliUserAgent(modelId = "gemini-3.1-pro-preview"): string {
11
11
  const version =
@@ -1,4 +1,4 @@
1
- import { $env } from "@sayknow-cli/utils";
1
+ import { $credentialEnv, $env } from "@sayknow-cli/utils";
2
2
  import type { Context, Model, StreamFunction } from "../types";
3
3
  import type { AssistantMessageEventStream } from "../utils/event-stream";
4
4
  import { getVertexAccessToken } from "./google-auth";
@@ -58,12 +58,17 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
58
58
  },
59
59
  });
60
60
 
61
+ /** Test seam: the Vertex API key as resolved from options plus trusted env. */
62
+ export function resolveVertexApiKeyForTest(options?: GoogleVertexOptions): string | undefined {
63
+ return resolveApiKey(options);
64
+ }
65
+
61
66
  function resolveApiKey(options?: GoogleVertexOptions): string | undefined {
62
67
  // options.apiKey may contain sentinel values like "<authenticated>" or "N/A"
63
68
  // leaked from the agent loop — only use it if it looks like a real API key.
64
69
  const optKey = options?.apiKey;
65
70
  const realKey = optKey && !optKey.startsWith("<") && optKey !== "N/A" ? optKey : undefined;
66
- return realKey || $env.GOOGLE_CLOUD_API_KEY;
71
+ return realKey || $credentialEnv("GOOGLE_CLOUD_API_KEY");
67
72
  }
68
73
 
69
74
  function resolveProject(options?: GoogleVertexOptions): string {
@@ -89,6 +89,8 @@ export function streamOpenAIAnthropicShim(
89
89
  onResponse: options?.onResponse,
90
90
  onSseEvent: options?.onSseEvent,
91
91
  fetch: options?.fetch,
92
+ streamIdleTimeoutMs: options?.streamIdleTimeoutMs,
93
+ streamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs,
92
94
  thinkingEnabled,
93
95
  thinkingBudgetTokens: thinkingBudget,
94
96
  });
@@ -118,6 +120,8 @@ export function streamOpenAIAnthropicShim(
118
120
  onResponse: options?.onResponse,
119
121
  onSseEvent: options?.onSseEvent,
120
122
  fetch: options?.fetch,
123
+ streamIdleTimeoutMs: options?.streamIdleTimeoutMs,
124
+ streamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs,
121
125
  reasoning: reasoningEffort,
122
126
  });
123
127