@sayknow-cli/ai 0.4.6 → 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
@@ -26,6 +26,34 @@ type ErrorWithStatus = {
26
26
 
27
27
  const SENSITIVE_HEADERS = ["authorization", "x-api-key", "api-key", "cookie", "set-cookie", "proxy-authorization"];
28
28
 
29
+ /**
30
+ * Connection-level failure codes, meaning the request never reached the
31
+ * provider and no HTTP status exists. Bun reports the first group for `fetch`;
32
+ * the `E*`/`UND_ERR_*` group comes from Node-style DNS and socket errors.
33
+ *
34
+ * Deliberately excludes aborts and TLS/certificate codes: an abort is a
35
+ * user/watchdog outcome with its own display path, and its message must keep
36
+ * matching the abort normalizers in `modes/utils/abort-message`.
37
+ */
38
+ const TRANSPORT_FAILURE_CODES: ReadonlySet<string> = new Set([
39
+ "ConnectionClosed",
40
+ "ConnectionRefused",
41
+ "ConnectionReset",
42
+ "ConnectionTimeout",
43
+ "FailedToOpenSocket",
44
+ "HTTP2Unsupported",
45
+ "EAI_AGAIN",
46
+ "ECONNREFUSED",
47
+ "ECONNRESET",
48
+ "EHOSTUNREACH",
49
+ "ENETUNREACH",
50
+ "ENOTFOUND",
51
+ "EPIPE",
52
+ "ETIMEDOUT",
53
+ "UND_ERR_CONNECT_TIMEOUT",
54
+ "UND_ERR_SOCKET",
55
+ ]);
56
+
29
57
  /**
30
58
  * Privacy note appended next to a saved raw HTTP request dump. The dump is
31
59
  * sanitized (secrets/thinking redacted) but can still contain prompt content
@@ -88,6 +116,54 @@ export async function appendRawHttpRequestDumpFor400(
88
116
  }
89
117
  }
90
118
 
119
+ /** Origin and path of `value`, dropping query, fragment, and credentials so a
120
+ * key carried in the request URL (Google `?key=`, signed URLs) never lands in
121
+ * a user-visible error string. */
122
+ function redactRequestUrl(value: unknown): string | undefined {
123
+ if (typeof value !== "string" || value.trim().length === 0) return undefined;
124
+ try {
125
+ const url = new URL(value);
126
+ return `${url.origin}${url.pathname}`;
127
+ } catch {
128
+ return undefined;
129
+ }
130
+ }
131
+
132
+ function findTransportFailure(error: unknown, depth: number): { code: string; url?: string } | undefined {
133
+ if (!error || typeof error !== "object" || depth > 2) return undefined;
134
+ const info = error as { code?: unknown; path?: unknown; url?: unknown; cause?: unknown };
135
+ if (typeof info.code === "string" && TRANSPORT_FAILURE_CODES.has(info.code)) {
136
+ return { code: info.code, url: redactRequestUrl(info.path) ?? redactRequestUrl(info.url) };
137
+ }
138
+ return findTransportFailure(info.cause, depth + 1);
139
+ }
140
+
141
+ /**
142
+ * Name the failed connection when the request never produced an HTTP status.
143
+ *
144
+ * Bun raises DNS and socket failures as a bare `Error` whose message is a
145
+ * standalone hint ("Was there a typo in the url or port?", "Unable to connect.
146
+ * Is the computer able to access the url?") while the actionable facts live on
147
+ * `code` and `path`. Those properties are dropped when only `message` reaches
148
+ * the assistant message, so a provider outage, a local DNS failure, and a
149
+ * mistyped custom base URL all render as the same context-free sentence.
150
+ * Appending the code and the target URL tells the user which host failed and
151
+ * whether the fault is theirs.
152
+ */
153
+ export function appendTransportFailureContext(
154
+ message: string,
155
+ error: unknown,
156
+ rawRequestDump: RawHttpRequestDump | undefined,
157
+ ): string {
158
+ if (extractHttpStatusFromError(error) !== undefined) return message;
159
+ const failure = findTransportFailure(error, 0);
160
+ if (!failure) return message;
161
+
162
+ const url = failure.url ?? redactRequestUrl(rawRequestDump?.url);
163
+ const context = url ? `transport=${failure.code} url=${url}` : `transport=${failure.code}`;
164
+ return message.includes(context) ? message : `${message} (${context})`;
165
+ }
166
+
91
167
  export async function finalizeErrorMessage(
92
168
  error: unknown,
93
169
  rawRequestDump: RawHttpRequestDump | undefined,
@@ -105,6 +181,7 @@ export async function finalizeErrorMessage(
105
181
  if (isModelUnavailableError(message, error)) {
106
182
  message = `${message}\n\n${formatModelUnavailableGuidance(rawRequestDump)}`;
107
183
  }
184
+ message = appendTransportFailureContext(message, error, rawRequestDump);
108
185
  return appendRawHttpRequestDumpFor400(message, error, rawRequestDump);
109
186
  }
110
187
 
@@ -2,6 +2,11 @@ import { $env } from "@sayknow-cli/utils";
2
2
 
3
3
  const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120_000;
4
4
  const DEFAULT_STREAM_FIRST_EVENT_TIMEOUT_MS = 100_000;
5
+ const KIMI_CODE_FIRST_EVENT_TIMEOUT_MS = 300_000;
6
+
7
+ export function getProviderFirstEventTimeoutFallbackMs(provider: string): number | undefined {
8
+ return provider === "kimi-code" ? KIMI_CODE_FIRST_EVENT_TIMEOUT_MS : undefined;
9
+ }
5
10
 
6
11
  function normalizeIdleTimeoutMs(value: string | undefined, fallback: number): number | undefined {
7
12
  if (value === undefined) return fallback;
@@ -14,7 +19,7 @@ function normalizeIdleTimeoutMs(value: string | undefined, fallback: number): nu
14
19
  /**
15
20
  * Returns the idle timeout used for provider streaming transports.
16
21
  *
17
- * `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` is accepted as a backward-compatible alias.
22
+ * `SKC_OPENAI_STREAM_IDLE_TIMEOUT_MS` is honored first; `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` is a backward-compatible alias.
18
23
  * Set `PI_STREAM_IDLE_TIMEOUT_MS=0` to disable the watchdog.
19
24
  *
20
25
  * Providers that legitimately stream much slower than the global default can pass
@@ -22,17 +27,20 @@ function normalizeIdleTimeoutMs(value: string | undefined, fallback: number): nu
22
27
  * Caller options still take precedence; env overrides still trump the fallback.
23
28
  */
24
29
  export function getStreamIdleTimeoutMs(fallbackMs: number = DEFAULT_STREAM_IDLE_TIMEOUT_MS): number | undefined {
25
- return normalizeIdleTimeoutMs($env.PI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS, fallbackMs);
30
+ return normalizeIdleTimeoutMs(
31
+ $env.SKC_OPENAI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS,
32
+ fallbackMs,
33
+ );
26
34
  }
27
35
 
28
36
  /**
29
37
  * Returns the idle timeout used for OpenAI-family streaming transports.
30
38
  *
31
- * Set `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS=0` to disable the watchdog.
39
+ * Honors `SKC_OPENAI_STREAM_IDLE_TIMEOUT_MS` first (`PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` is the legacy alias). Set `=0` to disable.
32
40
  */
33
41
  export function getOpenAIStreamIdleTimeoutMs(): number | undefined {
34
42
  return normalizeIdleTimeoutMs(
35
- $env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_STREAM_IDLE_TIMEOUT_MS,
43
+ $env.SKC_OPENAI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_STREAM_IDLE_TIMEOUT_MS,
36
44
  DEFAULT_STREAM_IDLE_TIMEOUT_MS,
37
45
  );
38
46
  }
@@ -168,8 +176,6 @@ export async function* iterateWithIdleTimeout<T>(
168
176
  }
169
177
  }
170
178
 
171
- const nextResultPromise = withRacy(iterator.next());
172
-
173
179
  const racers: Array<
174
180
  Promise<
175
181
  | { kind: "next"; result: IteratorResult<T> }
@@ -177,7 +183,7 @@ export async function* iterateWithIdleTimeout<T>(
177
183
  | { kind: "timeout" }
178
184
  | { kind: "abort" }
179
185
  >
180
- > = [nextResultPromise];
186
+ > = [];
181
187
 
182
188
  let timer: NodeJS.Timeout | undefined;
183
189
  let resolveTimeout: ((value: { kind: "timeout" }) => void) | undefined;
@@ -202,6 +208,13 @@ export async function* iterateWithIdleTimeout<T>(
202
208
  racers.push(promise);
203
209
  }
204
210
 
211
+ // Arm timeout/abort races before asking the source for its next item. A
212
+ // periodic keepalive iterator commonly registers its own timer inside
213
+ // `next()`; registering that first lets equal-deadline keepalives win every
214
+ // race and extend the idle window forever. Already-buffered items still
215
+ // settle as microtasks before a 0ms watchdog.
216
+ racers.unshift(withRacy(iterator.next()));
217
+
205
218
  try {
206
219
  const outcome = await Promise.race(racers);
207
220
  if (outcome.kind === "abort") {
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Alibaba Coding Plan login flow.
2
+ * Alibaba Token Plan login flow.
3
3
  *
4
- * Alibaba Coding Plan provides OpenAI-compatible models via https://coding-intl.dashscope.aliyuncs.com/v1.
4
+ * Alibaba Token Plan provides OpenAI-compatible models via
5
+ * https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1.
5
6
  *
6
7
  * This is not OAuth - it's a simple API key flow:
7
- * 1. Open browser to Alibaba Cloud DashScope API key settings
8
+ * 1. Open browser to Alibaba Cloud Model Studio console
8
9
  * 2. User copies their API key
9
10
  * 3. User pastes the API key into the CLI
10
11
  */
@@ -13,27 +14,27 @@ import { validateOpenAICompatibleApiKey } from "./api-key-validation";
13
14
  import type { OAuthController } from "./types";
14
15
 
15
16
  const AUTH_URL = "https://modelstudio.console.alibabacloud.com/";
16
- const API_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1";
17
- const VALIDATION_MODEL = "qwen3.5-plus";
17
+ const API_BASE_URL = "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1";
18
+ const VALIDATION_MODEL = "deepseek-v4-pro";
18
19
 
19
20
  /**
20
- * Login to Alibaba Coding Plan.
21
+ * Login to Alibaba Token Plan.
21
22
  *
22
23
  * Opens browser to API keys page, prompts user to paste their API key.
23
24
  * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
24
25
  */
25
- export async function loginAlibabaCodingPlan(options: OAuthController): Promise<string> {
26
+ export async function loginAlibabaTokenPlan(options: OAuthController): Promise<string> {
26
27
  if (!options.onPrompt) {
27
- throw new Error("Alibaba Coding Plan login requires onPrompt callback");
28
+ throw new Error("Alibaba Token Plan login requires onPrompt callback");
28
29
  }
29
30
 
30
31
  options.onAuth?.({
31
32
  url: AUTH_URL,
32
- instructions: "Copy your API key from the Alibaba Cloud DashScope console",
33
+ instructions: "Copy your API key from the Alibaba Cloud Model Studio console",
33
34
  });
34
35
 
35
36
  const apiKey = await options.onPrompt({
36
- message: "Paste your Alibaba Coding Plan API key",
37
+ message: "Paste your Alibaba Token Plan API key",
37
38
  placeholder: "sk-...",
38
39
  });
39
40
 
@@ -48,7 +49,7 @@ export async function loginAlibabaCodingPlan(options: OAuthController): Promise<
48
49
 
49
50
  options.onProgress?.("Validating API key...");
50
51
  await validateOpenAICompatibleApiKey({
51
- provider: "Alibaba Coding Plan",
52
+ provider: "Alibaba Token Plan",
52
53
  apiKey: trimmed,
53
54
  baseUrl: API_BASE_URL,
54
55
  model: VALIDATION_MODEL,
@@ -0,0 +1,15 @@
1
+ /** BizRouter login flow (API key paste, validated via /v1/models). */
2
+ import { createApiKeyLogin } from "./api-key-login";
3
+
4
+ export const loginBizRouter = createApiKeyLogin({
5
+ providerLabel: "BizRouter",
6
+ authUrl: "https://bizrouter.ai/settings/keys",
7
+ instructions: "Create or copy your BizRouter API key",
8
+ promptMessage: "Paste your BizRouter API key",
9
+ placeholder: "sk-br-v1-...",
10
+ validation: {
11
+ kind: "models-endpoint",
12
+ provider: "BizRouter",
13
+ modelsUrl: "https://api.bizrouter.ai/v1/models",
14
+ },
15
+ });
@@ -16,8 +16,8 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
16
16
  available: true,
17
17
  },
18
18
  {
19
- id: "alibaba-coding-plan",
20
- name: "Alibaba Coding Plan",
19
+ id: "alibaba-token-plan",
20
+ name: "Alibaba Token Plan",
21
21
  available: true,
22
22
  },
23
23
  {
@@ -240,6 +240,16 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
240
240
  name: "ZenMux",
241
241
  available: true,
242
242
  },
243
+ {
244
+ id: "bizrouter",
245
+ name: "BizRouter",
246
+ available: true,
247
+ },
248
+ {
249
+ id: "opengateway",
250
+ name: "OpenGateway by Sionic AI",
251
+ available: true,
252
+ },
243
253
  {
244
254
  id: "vllm",
245
255
  name: "vLLM (Local OpenAI-compatible)",
@@ -370,6 +380,7 @@ export async function refreshOAuthToken(
370
380
  case "together":
371
381
  case "litellm":
372
382
  case "lm-studio":
383
+ case "alibaba-token-plan":
373
384
  case "ollama":
374
385
  case "ollama-cloud":
375
386
  case "xiaomi":
@@ -384,6 +395,8 @@ export async function refreshOAuthToken(
384
395
  case "vercel-ai-gateway":
385
396
  case "qwen-portal":
386
397
  case "zenmux":
398
+ case "bizrouter":
399
+ case "opengateway":
387
400
  case "vllm":
388
401
  // API keys / static bearer tokens don't expire, return as-is
389
402
  newCredentials = credentials;
@@ -0,0 +1,15 @@
1
+ /** OpenGateway (by Sionic AI) login flow (API key paste, validated via /v1/models). */
2
+ import { createApiKeyLogin } from "./api-key-login";
3
+
4
+ export const loginOpenGateway = createApiKeyLogin({
5
+ providerLabel: "OpenGateway by Sionic AI",
6
+ authUrl: "https://opengateway.ai/dashboard",
7
+ instructions: "Create or copy your OpenGateway API key",
8
+ promptMessage: "Paste your OpenGateway API key",
9
+ placeholder: "sk-...",
10
+ validation: {
11
+ kind: "models-endpoint",
12
+ provider: "OpenGateway by Sionic AI",
13
+ modelsUrl: "https://apis.opengateway.ai/v1/models",
14
+ },
15
+ });
@@ -9,8 +9,9 @@ export type OAuthCredentials = {
9
9
  };
10
10
 
11
11
  export type OAuthProvider =
12
- | "alibaba-coding-plan"
12
+ | "alibaba-token-plan"
13
13
  | "anthropic"
14
+ | "bizrouter"
14
15
  | "cerebras"
15
16
  | "cloudflare-ai-gateway"
16
17
  | "cursor"
@@ -40,6 +41,7 @@ export type OAuthProvider =
40
41
  | "openai-codex-device"
41
42
  | "opencode-go"
42
43
  | "opencode-zen"
44
+ | "opengateway"
43
45
  | "parallel"
44
46
  | "perplexity"
45
47
  | "qianfan"
@@ -25,7 +25,7 @@
25
25
  import { structuredCloneJSON } from "@sayknow-cli/utils";
26
26
  import type { ZodType } from "zod/v4";
27
27
  import type { $ZodIssue as ZodIssue } from "zod/v4/core";
28
- import type { Tool, ToolCall } from "../types";
28
+ import type { RawArgumentRejectionCode, Tool, ToolCall } from "../types";
29
29
  import { upgradeJsonSchemaTo202012 } from "./schema/draft";
30
30
  import {
31
31
  isJsonSchemaValueValid,
@@ -958,6 +958,15 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): ToolCall["a
958
958
  return validateToolArguments(tool, toolCall);
959
959
  }
960
960
 
961
+ const RAW_ARGUMENT_REJECTION_MESSAGES: Record<RawArgumentRejectionCode, string> = {
962
+ "ask-intent-review-requires-positive-round":
963
+ "deepInterview.intent_review is post-Round-0 only and requires a positive round",
964
+ "ask-intent-contract-requires-non-empty-authority":
965
+ "deepInterview.intent_contract requires non-empty items and confirmation_options",
966
+ "ask-deep-interview-metadata-requires-deep-interview-gate":
967
+ "deepInterview metadata cannot be combined with a non-deep-interview workflowGate",
968
+ };
969
+
961
970
  /**
962
971
  * Validates tool call arguments against the tool's schema (Zod or plain JSON
963
972
  * Schema). Applies LLM-quirk coercions (numeric strings, JSON-string
@@ -969,7 +978,13 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
969
978
  const originalArgs = toolCall.arguments;
970
979
  const rawValidation = tool.rawArgumentValidation?.(originalArgs);
971
980
  if (rawValidation?.outcome === "reject") {
972
- throw new Error(`Validation failed for tool "${toolCall.name}": raw arguments rejected before coercion`);
981
+ const base = `Validation failed for tool "${toolCall.name}": raw arguments rejected before coercion`;
982
+ const code = rawValidation.code;
983
+ const correction =
984
+ typeof code === "string" && Object.hasOwn(RAW_ARGUMENT_REJECTION_MESSAGES, code)
985
+ ? RAW_ARGUMENT_REJECTION_MESSAGES[code as RawArgumentRejectionCode]
986
+ : undefined;
987
+ throw new Error(correction ? `${base}; ${correction}` : base);
973
988
  }
974
989
  const rawArgs = rawValidation?.outcome === "accept" ? rawValidation.arguments : originalArgs;
975
990
  const ctx = getValidationContext(tool);
package/src/utils.ts CHANGED
@@ -271,17 +271,53 @@ function normalizeResponsesImageUrlForReplay(value: unknown): NormalizedResponse
271
271
  return { imageUrl: stringifyResponsesStringParamForReplay(value) };
272
272
  }
273
273
 
274
+ /**
275
+ * OpenAI Responses `input_image.image_url` must be a fetchable HTTP(S) URL or an
276
+ * image data URI. Session resident-blob materialization may leave a human-readable
277
+ * placeholder like `[Session resident imageUrl blob missing: sha256:…; …]` in this
278
+ * field; replaying that string as `image_url` makes Codex reject the entire turn
279
+ * with `invalid_value` (#2924).
280
+ */
281
+ function isProviderSafeResponsesImageUrl(value: string): boolean {
282
+ const url = value.trim();
283
+ if (url.length === 0) return false;
284
+ if (url.startsWith("https://") || url.startsWith("http://")) return true;
285
+ // Accept only image data URIs — other data: schemes are not valid image inputs.
286
+ if (url.startsWith("data:image/")) return true;
287
+ return false;
288
+ }
289
+
290
+ function hasNonEmptyResponsesFileId(part: Record<string, unknown>): boolean {
291
+ return typeof part.file_id === "string" && part.file_id.trim().length > 0;
292
+ }
293
+
274
294
  function sanitizeResponsesMessageContentForReplay(content: unknown): unknown {
275
295
  if (typeof content === "string") return neutralizeReservedControlTokens(content.toWellFormed());
276
296
  if (!Array.isArray(content)) return content;
277
- return content.map(part => {
278
- if (!part || typeof part !== "object") return part;
297
+ const sanitizedContent: unknown[] = [];
298
+ for (const part of content) {
299
+ if (!part || typeof part !== "object") {
300
+ sanitizedContent.push(part);
301
+ continue;
302
+ }
279
303
  const sanitizedPart = { ...(part as Record<string, unknown>) };
280
304
  if ("text" in sanitizedPart) {
281
305
  sanitizedPart.text = normalizeResponsesMessageTextForReplay(sanitizedPart.text);
282
306
  }
283
307
  if ("image_url" in sanitizedPart) {
284
308
  const normalizedImageUrl = normalizeResponsesImageUrlForReplay(sanitizedPart.image_url);
309
+ if (!isProviderSafeResponsesImageUrl(normalizedImageUrl.imageUrl)) {
310
+ // Keep the part when a provider file_id can stand alone; otherwise drop
311
+ // only this image part so neighboring text/history still replays.
312
+ if (!hasNonEmptyResponsesFileId(sanitizedPart)) continue;
313
+ delete sanitizedPart.image_url;
314
+ if (sanitizedPart.type === "image_url") sanitizedPart.type = "input_image";
315
+ if ("detail" in sanitizedPart && !isResponsesImageDetail(sanitizedPart.detail)) {
316
+ delete sanitizedPart.detail;
317
+ }
318
+ sanitizedContent.push(sanitizedPart);
319
+ continue;
320
+ }
285
321
  sanitizedPart.image_url = normalizedImageUrl.imageUrl;
286
322
  if (sanitizedPart.type === "image_url") {
287
323
  sanitizedPart.type = "input_image";
@@ -292,8 +328,9 @@ function sanitizeResponsesMessageContentForReplay(content: unknown): unknown {
292
328
  delete sanitizedPart.detail;
293
329
  }
294
330
  }
295
- return sanitizedPart;
296
- });
331
+ sanitizedContent.push(sanitizedPart);
332
+ }
333
+ return sanitizedContent;
297
334
  }
298
335
 
299
336
  function sanitizeResponsesStringFieldsForReplay(item: Record<string, unknown>): void {
@@ -1,18 +0,0 @@
1
- /**
2
- * Alibaba Coding Plan login flow.
3
- *
4
- * Alibaba Coding Plan provides OpenAI-compatible models via https://coding-intl.dashscope.aliyuncs.com/v1.
5
- *
6
- * This is not OAuth - it's a simple API key flow:
7
- * 1. Open browser to Alibaba Cloud DashScope API key settings
8
- * 2. User copies their API key
9
- * 3. User pastes the API key into the CLI
10
- */
11
- import type { OAuthController } from "./types";
12
- /**
13
- * Login to Alibaba Coding Plan.
14
- *
15
- * Opens browser to API keys page, prompts user to paste their API key.
16
- * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
17
- */
18
- export declare function loginAlibabaCodingPlan(options: OAuthController): Promise<string>;