@oh-my-pi/pi-ai 16.0.7 → 16.0.9

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,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.0.9] - 2026-06-18
6
+
7
+ ### Fixed
8
+
9
+ - Fixed OAuth login replacing all other active accounts for the same provider, allowing multiple OAuth accounts to coexist concurrently.
10
+ - Fixed legacy `api_key` credentials not being replaced/disabled atomically upon upgrading to OAuth login.
11
+ - Fixed a logic issue where AuthStorage lost session-to-credential stickiness upon CLI restarts, causing cold-starts for server-side prompt cache (KV cache) and wasting tokens.
12
+ - Fixed GitHub Copilot Responses requests rejecting image inputs that carry the `detail: "original"` hint with an HTTP 400 by degrading the hint to `"auto"` for hosts that do not support it; other hosts still preserve native-resolution frames (snapcompact). ([#2822](https://github.com/can1357/oh-my-pi/issues/2822))
13
+
14
+ ## [16.0.8] - 2026-06-18
15
+
16
+ ### Fixed
17
+
18
+ - Improved reliability of auth-broker snapshot loading by implementing a robust manual schema check
19
+ - Fixed MCP tool argument validation to drop optional empty-string parameters before schema validation, matching the existing optional null handling and avoiding pattern/type failures for omitted model-filled fields. ([#2981](https://github.com/can1357/oh-my-pi/issues/2981))
20
+ - Fixed API-key credential replacement to hard-delete superseded disabled `api_key` rows so `auth_credentials` does not grow indefinitely after key rotation. ([#2941](https://github.com/can1357/oh-my-pi/issues/2941))
21
+ - Fixed Cursor provider streaming to close text blocks before tool calls so post-tool text opens a new content block and TUI transcript cards render inline instead of grouped near the bottom. ([#2924](https://github.com/can1357/oh-my-pi/issues/2924))
22
+
5
23
  ## [16.0.7] - 2026-06-18
6
24
 
7
25
  ### Changed
@@ -1,8 +1,8 @@
1
1
  export { type Type, type } from "arktype";
2
2
  export { type ZodType, z } from "zod/v4";
3
3
  export * from "./api-registry";
4
- export * from "./auth-broker";
5
- export { type AuthGatewayBootOptions, type ModelResolver, startAuthGateway } from "./auth-gateway/server";
4
+ export type * from "./auth-broker";
5
+ export type { AuthGatewayBootOptions, ModelResolver } from "./auth-gateway/server";
6
6
  export * from "./auth-gateway/types";
7
7
  export * from "./auth-retry";
8
8
  export * from "./auth-storage";
@@ -341,11 +341,12 @@ export declare function repairOrphanResponsesToolOutputs(input: ResponseInput):
341
341
  * {@link repairOrphanResponsesToolOutputs}.
342
342
  */
343
343
  export declare function repairOrphanResponsesToolCalls(input: ResponseInput): ResponseInput;
344
- export declare function convertResponsesInputContent(content: string | Array<TextContent | ImageContent>, supportsImages: boolean): ResponseInputContent[] | undefined;
344
+ export declare function convertResponsesInputContent(content: string | Array<TextContent | ImageContent>, supportsImages: boolean, supportsImageDetailOriginal: boolean): ResponseInputContent[] | undefined;
345
345
  export interface BuildResponsesInputOptions<TApi extends Api> {
346
346
  model: Model<TApi>;
347
347
  context: Context;
348
348
  strictResponsesPairing: boolean;
349
+ supportsImageDetailOriginal: boolean;
349
350
  systemRole?: "system" | "developer";
350
351
  nativeHistory?: {
351
352
  replay: boolean;
@@ -357,7 +358,7 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
357
358
  }
358
359
  export declare function buildResponsesInput<TApi extends Api>(options: BuildResponsesInputOptions<TApi>): ResponseInput;
359
360
  export declare function convertResponsesAssistantMessage<TApi extends Api>(assistantMsg: AssistantMessage, model: Model<TApi>, msgIndex: number, knownCallIds: Set<string>, includeThinkingSignatures?: boolean, customCallIds?: Set<string>): ResponseInput;
360
- export declare function appendResponsesToolResultMessages<TApi extends Api>(messages: ResponseInput, toolResult: ToolResultMessage, model: Model<TApi>, strictResponsesPairing: boolean, knownCallIds: ReadonlySet<string>, customCallIds?: ReadonlySet<string>): void;
361
+ export declare function appendResponsesToolResultMessages<TApi extends Api>(messages: ResponseInput, toolResult: ToolResultMessage, model: Model<TApi>, strictResponsesPairing: boolean, supportsImageDetailOriginal: boolean, knownCallIds: ReadonlySet<string>, customCallIds?: ReadonlySet<string>): void;
361
362
  /**
362
363
  * Per-block accumulation helpers shared by the two Responses decode loops —
363
364
  * {@link processResponsesStream} (generic Responses) and the Codex stream
@@ -10,7 +10,8 @@ export declare function validateToolCall(tools: Tool[], toolCall: ToolCall): Too
10
10
  /**
11
11
  * Validates tool call arguments against the tool's schema (Zod or plain JSON
12
12
  * Schema). Applies LLM-quirk coercions (numeric strings, JSON-string
13
- * containers, null-for-optional, null-for-default) before declaring failure.
13
+ * containers, null/invalid-empty-string-for-optional, null-for-default) before
14
+ * declaring failure.
14
15
  *
15
16
  * @throws Error with a formatted message when validation cannot be reconciled.
16
17
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.0.7",
4
+ "version": "16.0.9",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,8 +38,8 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.0.7",
42
- "@oh-my-pi/pi-utils": "16.0.7",
41
+ "@oh-my-pi/pi-catalog": "16.0.9",
42
+ "@oh-my-pi/pi-utils": "16.0.9",
43
43
  "arktype": "^2.2.0",
44
44
  "partial-json": "^0.1.7",
45
45
  "zod": "^4"
@@ -9,9 +9,7 @@
9
9
  import * as fs from "node:fs/promises";
10
10
  import * as path from "node:path";
11
11
  import { isEnoent, logger } from "@oh-my-pi/pi-utils";
12
- import { type } from "arktype";
13
12
  import type { SnapshotResponse } from "./types";
14
- import { snapshotResponseSchema } from "./wire-schemas";
15
13
 
16
14
  const MAGIC = new Uint8Array([0x4f, 0x4d, 0x50, 0x53]); // "OMPS"
17
15
  const VERSION = 1;
@@ -40,6 +38,25 @@ export interface WriteAuthBrokerSnapshotCacheOptions {
40
38
  snapshot: SnapshotResponse;
41
39
  }
42
40
 
41
+ /**
42
+ * Cheap structural guard for a decrypted cache payload. The bytes are already
43
+ * AES-256-GCM authenticated, so this only rejects shape/version drift (a cache
44
+ * written by a different omp build, or a buggy write) — not tampering. A
45
+ * mismatch returns null so the caller refetches a fresh snapshot.
46
+ */
47
+ function isSnapshotResponseShape(v: unknown): v is SnapshotResponse {
48
+ if (typeof v !== "object" || v === null) return false;
49
+ const o = v as Record<string, unknown>;
50
+ return (
51
+ typeof o.generation === "number" &&
52
+ typeof o.generatedAt === "number" &&
53
+ typeof o.serverNowMs === "number" &&
54
+ typeof o.refresher === "object" &&
55
+ o.refresher !== null &&
56
+ Array.isArray(o.credentials)
57
+ );
58
+ }
59
+
43
60
  export async function readAuthBrokerSnapshotCache(
44
61
  opts: ReadAuthBrokerSnapshotCacheOptions,
45
62
  ): Promise<SnapshotResponse | null> {
@@ -56,12 +73,11 @@ export async function readAuthBrokerSnapshotCache(
56
73
  const plaintext = await decryptCachePayload(data, opts.token, opts.url);
57
74
  if (!plaintext) return null;
58
75
  const parsed: unknown = JSON.parse(TEXT_DECODER.decode(plaintext));
59
- const result = snapshotResponseSchema(parsed);
60
- if (result instanceof type.errors) {
76
+ if (!isSnapshotResponseShape(parsed)) {
61
77
  logger.debug("auth-broker snapshot cache schema invalid", { path: opts.path });
62
78
  return null;
63
79
  }
64
- const snapshot = result;
80
+ const snapshot = parsed;
65
81
  const now = opts.now?.() ?? Date.now();
66
82
  if (now - snapshot.generatedAt > opts.ttlMs) return null;
67
83
  return snapshot;
@@ -1352,6 +1352,19 @@ export class AuthStorage {
1352
1352
  const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map();
1353
1353
  sessionMap.set(sessionId, { type, index });
1354
1354
  this.#sessionLastCredential.set(provider, sessionMap);
1355
+
1356
+ try {
1357
+ const credentialId = this.#getStoredCredentials(provider)[index]?.id;
1358
+ if (credentialId !== undefined) {
1359
+ const cacheKey = `session:sticky:${provider}:${sessionId}`;
1360
+ const cacheValue = JSON.stringify({ type, index, credentialId });
1361
+ // Expires in 30 days
1362
+ const expiresAtSec = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60;
1363
+ this.#store.setCache(cacheKey, cacheValue, expiresAtSec);
1364
+ }
1365
+ } catch (err) {
1366
+ logger.debug("Failed to write session sticky credential to persistent store cache", { err });
1367
+ }
1355
1368
  }
1356
1369
 
1357
1370
  /** Retrieves the last credential used by a session. */
@@ -1360,17 +1373,59 @@ export class AuthStorage {
1360
1373
  sessionId: string | undefined,
1361
1374
  ): { type: AuthCredential["type"]; index: number } | undefined {
1362
1375
  if (!sessionId) return undefined;
1363
- return this.#sessionLastCredential.get(provider)?.get(sessionId);
1376
+ let sessionMap = this.#sessionLastCredential.get(provider);
1377
+ if (sessionMap?.has(sessionId)) {
1378
+ return sessionMap.get(sessionId);
1379
+ }
1380
+ try {
1381
+ const cacheKey = `session:sticky:${provider}:${sessionId}`;
1382
+ const raw = this.#store.getCache(cacheKey);
1383
+ if (raw) {
1384
+ const val = JSON.parse(raw) as { type: AuthCredential["type"]; index: number; credentialId?: number };
1385
+
1386
+ if (val.credentialId !== undefined) {
1387
+ const stored = this.#getStoredCredentials(provider);
1388
+ const actualIndex = stored.findIndex(entry => entry.id === val.credentialId);
1389
+ if (actualIndex === -1 || stored[actualIndex]?.credential.type !== val.type) {
1390
+ this.#store.setCache(cacheKey, "", 0);
1391
+ return undefined;
1392
+ }
1393
+ val.index = actualIndex;
1394
+ } else {
1395
+ // Fallback: drop unsafe index-only cache rows to prevent wrong-account routing
1396
+ this.#store.setCache(cacheKey, "", 0);
1397
+ return undefined;
1398
+ }
1399
+
1400
+ if (!sessionMap) {
1401
+ sessionMap = new Map();
1402
+ this.#sessionLastCredential.set(provider, sessionMap);
1403
+ }
1404
+ const sessionVal = { type: val.type, index: val.index };
1405
+ sessionMap.set(sessionId, sessionVal);
1406
+ return sessionVal;
1407
+ }
1408
+ } catch (err) {
1409
+ logger.debug("Failed to read session sticky credential from persistent store cache", { err });
1410
+ }
1411
+ return undefined;
1364
1412
  }
1365
1413
 
1366
1414
  /** Clears the last credential used by a session for a provider. */
1367
1415
  #clearSessionCredential(provider: string, sessionId: string | undefined): void {
1368
1416
  if (!sessionId) return;
1369
1417
  const sessionMap = this.#sessionLastCredential.get(provider);
1370
- if (!sessionMap) return;
1371
- sessionMap.delete(sessionId);
1372
- if (sessionMap.size === 0) {
1373
- this.#sessionLastCredential.delete(provider);
1418
+ if (sessionMap) {
1419
+ sessionMap.delete(sessionId);
1420
+ if (sessionMap.size === 0) {
1421
+ this.#sessionLastCredential.delete(provider);
1422
+ }
1423
+ }
1424
+ try {
1425
+ const cacheKey = `session:sticky:${provider}:${sessionId}`;
1426
+ this.#store.setCache(cacheKey, "", 0);
1427
+ } catch (err) {
1428
+ logger.debug("Failed to clear session sticky credential from persistent store cache", { err });
1374
1429
  }
1375
1430
  }
1376
1431
 
@@ -1549,6 +1604,17 @@ export class AuthStorage {
1549
1604
  return rows;
1550
1605
  }
1551
1606
 
1607
+ async #upsertOAuthCredential(provider: string, credential: OAuthCredential): Promise<void> {
1608
+ const stored = this.#store.upsertAuthCredentialRemote
1609
+ ? await this.#store.upsertAuthCredentialRemote(provider, credential)
1610
+ : this.#store.upsertAuthCredentialForProvider(provider, credential);
1611
+ this.#setStoredCredentials(
1612
+ provider,
1613
+ stored.map(entry => ({ id: entry.id, credential: entry.credential })),
1614
+ );
1615
+ this.#resetProviderAssignments(provider);
1616
+ }
1617
+
1552
1618
  /**
1553
1619
  * Remove credential for a provider.
1554
1620
  */
@@ -1786,10 +1852,10 @@ export class AuthStorage {
1786
1852
  return;
1787
1853
  }
1788
1854
  const newCredential: OAuthCredential = { type: "oauth", ...result };
1789
- // Use set() instead of #upsertOAuthCredential to replace ALL existing credentials
1790
- // (including legacy api_key rows from older versions) with the new OAuth credential.
1791
- // This ensures getApiKey() doesn't match an old api_key row before the new OAuth row.
1792
- await this.set(def.storeCredentialsAs ?? provider, newCredential);
1855
+ // Use #upsertOAuthCredential to upsert the new credential.
1856
+ // Any legacy api_key rows from older versions will be cleaned up so they do not
1857
+ // shadow the new OAuth row, while preserving other active OAuth credentials.
1858
+ await this.#upsertOAuthCredential(def.storeCredentialsAs ?? provider, newCredential);
1793
1859
  }
1794
1860
 
1795
1861
  /**
@@ -4916,6 +4982,14 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4916
4982
  identityKey: resolveRowCredentialIdentityKey(providerName, row),
4917
4983
  }));
4918
4984
 
4985
+ if (item.type === "oauth") {
4986
+ for (const row of existing) {
4987
+ if (row.credential && row.credential.type === "api_key") {
4988
+ this.#deleteStmt.run("replaced by oauth login", row.id);
4989
+ }
4990
+ }
4991
+ }
4992
+
4919
4993
  let targetId: number | null = null;
4920
4994
  for (const row of existing) {
4921
4995
  if (!matchesReplacementCredential(providerName, row.credential, row.identityKey, item)) continue;
@@ -4953,21 +5027,30 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4953
5027
  }
4954
5028
 
4955
5029
  /**
4956
- * Hard-deletes disabled rows for a provider when an active row with the same identity exists.
4957
- * This prevents unbounded accumulation of soft-deleted credentials while preserving
4958
- * disabled rows that have no active replacement (safety net for recovery).
5030
+ * Hard-deletes disabled rows for a provider when an active replacement exists.
5031
+ * OAuth credentials match by identity key; API keys match by provider and type.
5032
+ * Disabled rows without an active same-type replacement remain recoverable.
4959
5033
  */
4960
5034
  #purgeSupersededDisabledRows(provider: string, activeRows: StoredAuthCredential[]): void {
4961
5035
  try {
5036
+ let hasActiveApiKey = false;
4962
5037
  const activeIdentityKeys = new Set<string>();
4963
5038
  for (const row of activeRows) {
5039
+ if (row.credential.type === "api_key") {
5040
+ hasActiveApiKey = true;
5041
+ continue;
5042
+ }
4964
5043
  const identityKey = resolveCredentialIdentityKey(provider, row.credential);
4965
5044
  if (identityKey) activeIdentityKeys.add(identityKey);
4966
5045
  }
4967
- if (activeIdentityKeys.size === 0) return;
5046
+ if (!hasActiveApiKey && activeIdentityKeys.size === 0) return;
4968
5047
 
4969
5048
  const disabledRows = this.#listDisabledByProviderStmt.all(provider) as AuthRow[];
4970
5049
  for (const row of disabledRows) {
5050
+ if (hasActiveApiKey && row.credential_type === "api_key") {
5051
+ this.#hardDeleteStmt.run(row.id);
5052
+ continue;
5053
+ }
4971
5054
  const identityKey = resolveRowCredentialIdentityKey(provider, row);
4972
5055
  if (identityKey && activeIdentityKeys.has(identityKey)) {
4973
5056
  this.#hardDeleteStmt.run(row.id);
package/src/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export { type Type, type } from "arktype";
2
2
  export { type ZodType, z } from "zod/v4";
3
3
  export * from "./api-registry";
4
- export * from "./auth-broker";
5
- export { type AuthGatewayBootOptions, type ModelResolver, startAuthGateway } from "./auth-gateway/server";
4
+ export type * from "./auth-broker";
5
+ export type { AuthGatewayBootOptions, ModelResolver } from "./auth-gateway/server";
6
6
  export * from "./auth-gateway/types";
7
7
  export * from "./auth-retry";
8
8
  export * from "./auth-storage";
@@ -297,6 +297,7 @@ function buildParams(
297
297
  model,
298
298
  context,
299
299
  strictResponsesPairing: true,
300
+ supportsImageDetailOriginal: model.compat.supportsImageDetailOriginal,
300
301
  systemRole,
301
302
  includeThinkingSignatures: true,
302
303
  developerStringContent: true,
@@ -548,24 +548,8 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
548
548
  }
549
549
  });
550
550
 
551
- if (state.currentTextBlock) {
552
- const idx = output.content.indexOf(state.currentTextBlock);
553
- stream.push({
554
- type: "text_end",
555
- contentIndex: idx,
556
- content: state.currentTextBlock.text,
557
- partial: output,
558
- });
559
- }
560
- if (state.currentThinkingBlock) {
561
- const idx = output.content.indexOf(state.currentThinkingBlock);
562
- stream.push({
563
- type: "thinking_end",
564
- contentIndex: idx,
565
- content: state.currentThinkingBlock.thinking,
566
- partial: output,
567
- });
568
- }
551
+ endCurrentTextBlock(output, stream, state);
552
+ endCurrentThinkingBlock(output, stream, state);
569
553
  if (state.currentToolCall) {
570
554
  const idx = output.content.indexOf(state.currentToolCall);
571
555
  state.currentToolCall.arguments = parseStreamingJson(state.currentToolCall.partialJson);
@@ -1972,6 +1956,38 @@ export function mergeCursorMcpToolCallArgs(
1972
1956
  return merged;
1973
1957
  }
1974
1958
 
1959
+ function endCurrentTextBlock(output: AssistantMessage, stream: AssistantMessageEventStream, state: BlockState): void {
1960
+ const block = state.currentTextBlock;
1961
+ if (!block) return;
1962
+ const idx = output.content.indexOf(block);
1963
+ delete (block as { index?: number }).index;
1964
+ stream.push({
1965
+ type: "text_end",
1966
+ contentIndex: idx,
1967
+ content: block.text,
1968
+ partial: output,
1969
+ });
1970
+ state.setTextBlock(null);
1971
+ }
1972
+
1973
+ function endCurrentThinkingBlock(
1974
+ output: AssistantMessage,
1975
+ stream: AssistantMessageEventStream,
1976
+ state: BlockState,
1977
+ ): void {
1978
+ const block = state.currentThinkingBlock;
1979
+ if (!block) return;
1980
+ const idx = output.content.indexOf(block);
1981
+ delete (block as { index?: number }).index;
1982
+ stream.push({
1983
+ type: "thinking_end",
1984
+ contentIndex: idx,
1985
+ content: block.thinking,
1986
+ partial: output,
1987
+ });
1988
+ state.setThinkingBlock(null);
1989
+ }
1990
+
1975
1991
  /** Exported for tests: drives one Cursor interaction update through the streaming state machine. */
1976
1992
  export function processInteractionUpdate(
1977
1993
  update: any,
@@ -2017,18 +2033,10 @@ export function processInteractionUpdate(
2017
2033
  const idx = output.content.indexOf(state.currentThinkingBlock!);
2018
2034
  stream.push({ type: "thinking_delta", contentIndex: idx, delta, partial: output });
2019
2035
  } else if (updateCase === "thinkingCompleted") {
2020
- if (state.currentThinkingBlock) {
2021
- const idx = output.content.indexOf(state.currentThinkingBlock);
2022
- delete (state.currentThinkingBlock as any).index;
2023
- stream.push({
2024
- type: "thinking_end",
2025
- contentIndex: idx,
2026
- content: state.currentThinkingBlock.thinking,
2027
- partial: output,
2028
- });
2029
- state.setThinkingBlock(null);
2030
- }
2036
+ endCurrentThinkingBlock(output, stream, state);
2031
2037
  } else if (updateCase === "toolCallStarted") {
2038
+ endCurrentTextBlock(output, stream, state);
2039
+ endCurrentThinkingBlock(output, stream, state);
2032
2040
  const toolCall = update.message.value.toolCall;
2033
2041
  if (toolCall) {
2034
2042
  const mcpCall = toolCall.mcpToolCall;
@@ -3253,7 +3253,15 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
3253
3253
  }
3254
3254
 
3255
3255
  if (msg.role === "toolResult") {
3256
- appendResponsesToolResultMessages(messages, msg, model, false, knownCallIds, customCallIds);
3256
+ appendResponsesToolResultMessages(
3257
+ messages,
3258
+ msg,
3259
+ model,
3260
+ false,
3261
+ model.compat.supportsImageDetailOriginal,
3262
+ knownCallIds,
3263
+ customCallIds,
3264
+ );
3257
3265
  }
3258
3266
 
3259
3267
  msgIndex += 1;
@@ -3271,7 +3279,10 @@ function normalizeInputMessageContent(
3271
3279
  return [{ type: "input_text", text: content.toWellFormed() }];
3272
3280
  }
3273
3281
 
3274
- return convertResponsesInputContent(content, model.input.includes("image")) ?? [];
3282
+ return (
3283
+ convertResponsesInputContent(content, model.input.includes("image"), model.compat.supportsImageDetailOriginal) ??
3284
+ []
3285
+ );
3275
3286
  }
3276
3287
 
3277
3288
  /** @internal Exported for tests. */
@@ -687,6 +687,7 @@ export function buildParams(
687
687
  model,
688
688
  context,
689
689
  strictResponsesPairing,
690
+ supportsImageDetailOriginal: model.compat.supportsImageDetailOriginal,
690
691
  nativeHistory: {
691
692
  replay: shouldReplayNativeHistory,
692
693
  filterReasoning: policy.reasoning.filterReasoningHistory,
@@ -1178,9 +1178,24 @@ export function repairOrphanResponsesToolCalls(input: ResponseInput): ResponseIn
1178
1178
  return repaired;
1179
1179
  }
1180
1180
 
1181
+ /**
1182
+ * Some Responses backends (notably GitHub Copilot) reject the OpenAI image
1183
+ * `detail: "original"` value with a 400. When the model does not advertise
1184
+ * support for it, degrade `"original"` to `"auto"` so the request still goes
1185
+ * through with the closest valid fidelity instead of failing outright. See #2822.
1186
+ */
1187
+ function clampResponsesImageDetail(
1188
+ detail: ImageContent["detail"],
1189
+ supportsImageDetailOriginal: boolean,
1190
+ ): ResponseInputImage["detail"] {
1191
+ const resolved = detail ?? "auto";
1192
+ return resolved === "original" && !supportsImageDetailOriginal ? "auto" : resolved;
1193
+ }
1194
+
1181
1195
  export function convertResponsesInputContent(
1182
1196
  content: string | Array<TextContent | ImageContent>,
1183
1197
  supportsImages: boolean,
1198
+ supportsImageDetailOriginal: boolean,
1184
1199
  ): ResponseInputContent[] | undefined {
1185
1200
  if (typeof content === "string") {
1186
1201
  if (content.trim().length === 0) return undefined;
@@ -1200,7 +1215,7 @@ export function convertResponsesInputContent(
1200
1215
  for (const item of imageBlocks) {
1201
1216
  normalizedContent.push({
1202
1217
  type: "input_image",
1203
- detail: item.detail ?? "auto",
1218
+ detail: clampResponsesImageDetail(item.detail, supportsImageDetailOriginal),
1204
1219
  image_url: `data:${item.mimeType};base64,${item.data}`,
1205
1220
  } satisfies ResponseInputImage);
1206
1221
  }
@@ -1217,6 +1232,7 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
1217
1232
  model: Model<TApi>;
1218
1233
  context: Context;
1219
1234
  strictResponsesPairing: boolean;
1235
+ supportsImageDetailOriginal: boolean;
1220
1236
  systemRole?: "system" | "developer";
1221
1237
  nativeHistory?: {
1222
1238
  replay: boolean;
@@ -1267,7 +1283,11 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
1267
1283
  msgIndex++;
1268
1284
  continue;
1269
1285
  }
1270
- const content = convertResponsesInputContent(msg.content, options.model.input.includes("image"));
1286
+ const content = convertResponsesInputContent(
1287
+ msg.content,
1288
+ options.model.input.includes("image"),
1289
+ options.supportsImageDetailOriginal,
1290
+ );
1271
1291
  if (!content) continue;
1272
1292
  messages.push({
1273
1293
  role: "user",
@@ -1318,6 +1338,7 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
1318
1338
  msg,
1319
1339
  options.model,
1320
1340
  options.strictResponsesPairing,
1341
+ options.supportsImageDetailOriginal,
1321
1342
  knownCallIds,
1322
1343
  customCallIds,
1323
1344
  );
@@ -1419,6 +1440,7 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
1419
1440
  toolResult: ToolResultMessage,
1420
1441
  model: Model<TApi>,
1421
1442
  strictResponsesPairing: boolean,
1443
+ supportsImageDetailOriginal: boolean,
1422
1444
  knownCallIds: ReadonlySet<string>,
1423
1445
  customCallIds?: ReadonlySet<string>,
1424
1446
  ): void {
@@ -1475,7 +1497,7 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
1475
1497
  if (block.type === "image") {
1476
1498
  contentParts.push({
1477
1499
  type: "input_image",
1478
- detail: block.detail ?? "auto",
1500
+ detail: clampResponsesImageDetail(block.detail, supportsImageDetailOriginal),
1479
1501
  image_url: `data:${block.mimeType};base64,${block.data}`,
1480
1502
  } satisfies ResponseInputImage);
1481
1503
  }
@@ -764,10 +764,13 @@ function normalizeOptionalNullsForSchema(
764
764
  if (!(key in nextValue)) continue;
765
765
  const currentValue = nextValue[key];
766
766
  const isNullish = currentValue === null || currentValue === "null";
767
+ const isInvalidEmptyString =
768
+ currentValue === "" && !required.has(key) && !branchMatchesSchema(propertySchema, currentValue);
767
769
 
768
- // Strip null and the string "null" from optional fields.
769
- // The LLM sometimes outputs string "null" to mean "no value".
770
- if (isNullish && !required.has(key)) {
770
+ // Strip null/string "null" from optional fields, and strip empty
771
+ // strings only when the property schema would reject the explicit value.
772
+ // LLMs sometimes output these placeholders to mean "no value".
773
+ if ((isNullish || isInvalidEmptyString) && !required.has(key)) {
771
774
  if (!changed) {
772
775
  nextValue = { ...nextValue };
773
776
  changed = true;
@@ -1281,7 +1284,8 @@ function truncateArgsForError(value: unknown): unknown {
1281
1284
  /**
1282
1285
  * Validates tool call arguments against the tool's schema (Zod or plain JSON
1283
1286
  * Schema). Applies LLM-quirk coercions (numeric strings, JSON-string
1284
- * containers, null-for-optional, null-for-default) before declaring failure.
1287
+ * containers, null/invalid-empty-string-for-optional, null-for-default) before
1288
+ * declaring failure.
1285
1289
  *
1286
1290
  * @throws Error with a formatted message when validation cannot be reconciled.
1287
1291
  */
@@ -1290,9 +1294,10 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
1290
1294
  const ctx = getValidationContext(tool);
1291
1295
  const { json } = ctx;
1292
1296
 
1293
- // Always normalize first — strip null and string "null" from optional
1294
- // fields and substitute defaults. Handles LLM outputting string "null"
1295
- // to mean "no value" even when validation would otherwise pass.
1297
+ // Always normalize first — strip null/string "null" from optional fields,
1298
+ // strip optional empty strings only when their property schema rejects the
1299
+ // explicit value, and substitute defaults. Handles LLM outputting
1300
+ // placeholders for "no value" even when validation would otherwise pass.
1296
1301
  let normalizedArgs: unknown = originalArgs;
1297
1302
  let changed = false;
1298
1303
  const initialNormalization = normalizeOptionalNullsForSchema(json, normalizedArgs);