@oh-my-pi/pi-ai 17.2.3 → 17.2.4

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,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.4] - 2026-08-01
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Codex WebSocket tool-result turns replaying full history when the preceding tool-call ID required Responses API normalization ([#7279](https://github.com/can1357/oh-my-pi/issues/7279)).
10
+ - Fixed direct Anthropic provider streams ignoring `model.compat.streamIdleTimeoutMs`. Requests dispatched through `streamAnthropic` can now widen the inter-event idle watchdog or set it to `0` to disable that watchdog; caller options and environment overrides retain precedence. Setting the compat value to `0` disables only the inter-event watchdog and leaves the first-event watchdog enabled; wider idle values continue to floor the first-event budget under the existing timeout contract.
11
+ - Fixed OpenRouter DeepSeek models failing structured subagents when the upstream returns an opaque HTTP 400 for a strict yield schema, retrying once without strict tools and remembering the fallback for the provider session ([#7264](https://github.com/can1357/oh-my-pi/issues/7264)).
12
+ - Fixed provider-native Codex compaction streams bypassing WebSocket-first transport selection and SSE transport fallback ([#7198](https://github.com/can1357/oh-my-pi/issues/7198)).
13
+ - Fixed `SqliteAuthCredentialStore.open()` running the `auth_credential_refresh_leases` DDL (`CREATE TABLE`/`CREATE INDEX`) with Bun's default `busy_timeout=0`, before the constructor's `#initializeSchema()` installed the busy handler. Under a concurrent write lock (e.g. WAL recovery on parallel omp startups) the lock-taking DDL failed immediately and, since the error wasn't BUSY-classified, bypassed `open()`'s bounded retry loop. The busy handler is now installed on the connection immediately after it opens, before any lock-taking statement, honoring the issue-#2421 invariant on every entry path. ([#7298](https://github.com/can1357/oh-my-pi/issues/7298))
14
+ - Fixed a corrupt credential store (`agent.db`) silently disabling every persisted rate-limit block. `AuthStorage` caught unrecoverable SQLite errors (`SQLITE_CORRUPT` family / `SQLITE_NOTADB`) from the persisted block read/write paths at `debug` level with no latch, so the broken store was re-queried on every credential evaluation while blocks quietly stopped applying. The first unrecoverable error is now reported once at `error` level with the store location and repair guidance, and every later persisted-block read/write short-circuits for the process lifetime; in-memory backoff still preserves availability ([#7296](https://github.com/can1357/oh-my-pi/issues/7296)).
15
+
5
16
  ## [17.2.3] - 2026-08-01
6
17
 
7
18
  ### Added
@@ -1271,6 +1271,14 @@ export declare class AuthStorage {
1271
1271
  * and `SQLITE_BUSY_TIMEOUT`. All warrant the same backoff-and-retry treatment.
1272
1272
  */
1273
1273
  export declare function isSqliteBusyError(err: unknown): boolean;
1274
+ /**
1275
+ * SQLite's unrecoverable-corruption result codes — the `SQLITE_CORRUPT` family
1276
+ * (base plus extended variants like `SQLITE_CORRUPT_VTAB` / `SQLITE_CORRUPT_INDEX`)
1277
+ * and `SQLITE_NOTADB` (the file header is not a database). Unlike
1278
+ * {@link isSqliteBusyError}, these never clear by retrying: the store must be
1279
+ * repaired or replaced, so callers latch and stop touching it.
1280
+ */
1281
+ export declare function isSqliteCorruptionError(err: unknown): boolean;
1274
1282
  /**
1275
1283
  * Default SQLite-backed implementation of {@link AuthCredentialStore}.
1276
1284
  *
@@ -1,5 +1,5 @@
1
1
  import type { CodexCompactionContext, CodexCompactionRequestContext, Context, Model, ProviderSessionState, ServiceTier, StreamFunction, StreamOptions, Tool, ToolChoice } from "../types.js";
2
- import { type CodexReasoningContext, type RequestBody } from "./openai-codex/request-transformer.js";
2
+ import { type CodexLiteShapedBody, type CodexReasoningContext, type RequestBody } from "./openai-codex/request-transformer.js";
3
3
  import type { ResponseInput } from "./openai-responses-wire.js";
4
4
  export interface OpenAICodexResponsesOptions extends StreamOptions {
5
5
  reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
@@ -43,6 +43,15 @@ export interface OpenAICodexResponsesOptions extends StreamOptions {
43
43
  */
44
44
  onModerationMetadata?: (metadata: unknown) => void;
45
45
  }
46
+ /** Raw V2 compaction body accepted by the Codex transport selector. */
47
+ export interface OpenAICodexCompactionBody extends CodexLiteShapedBody {
48
+ model: string;
49
+ [key: string]: unknown;
50
+ }
51
+ /** Transport controls for a provider-native Codex V2 compaction stream. */
52
+ export interface OpenAICodexCompactionStreamOptions extends OpenAICodexResponsesOptions {
53
+ apiKey: string;
54
+ }
46
55
  /** Inputs for synthesizing Codex request identity outside the normal stream path. */
47
56
  export interface OpenAICodexCompatibilityMetadataOptions {
48
57
  sessionId?: string;
@@ -161,6 +170,11 @@ export declare function resetOpenAICodexHistoryAfterCompaction(options: OpenAICo
161
170
  export declare function normalizeCodexToolChoice(choice: ToolChoice | undefined, tools?: Tool[], model?: Model<"openai-codex-responses">): string | Record<string, unknown> | undefined;
162
171
  /** @internal Exported for tests. */
163
172
  export declare function buildTransformedCodexRequestBody(model: Model<"openai-codex-responses">, context: Context, options: OpenAICodexResponsesOptions | undefined, promptCacheKey?: string | undefined): Promise<RequestBody>;
173
+ /**
174
+ * Open a provider-native V2 compaction stream through Codex's WebSocket-first
175
+ * transport, replaying WebSocket transport failures over SSE.
176
+ */
177
+ export declare function openCodexCompactionEventStream(model: Model<"openai-codex-responses">, body: OpenAICodexCompactionBody, options: OpenAICodexCompactionStreamOptions): Promise<AsyncGenerator<Record<string, unknown>>>;
164
178
  export declare const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses">;
165
179
  export declare function prewarmOpenAICodexResponses(model: Model<"openai-codex-responses">, options?: Pick<OpenAICodexResponsesOptions, "apiKey" | "headers" | "sessionId" | "signal" | "preferWebsockets" | "providerSessionState" | "responsesLite">): Promise<void>;
166
180
  export interface OpenAICodexTransportDetails {
@@ -343,7 +343,13 @@ export declare function resolveOpenAIResponsesOutputClamp(model: Pick<Model, "pr
343
343
  */
344
344
  export declare function applyChatCompletionsToolStream(params: OpenAICompletionsParams, model: Model<"openai-completions">, compat: ResolvedOpenAICompat): void;
345
345
  export declare function isCompiledGrammarTooLargeStrictError(error: unknown, capturedErrorResponse: CapturedHttpErrorResponse | undefined): boolean;
346
- export declare function shouldRetryWithoutStrictTools(error: unknown, capturedErrorResponse: CapturedHttpErrorResponse | undefined, strictToolsApplied: boolean, tools: Tool[] | undefined): boolean;
346
+ interface StrictToolsRetryContext {
347
+ model: OpenAIModelIdentity;
348
+ strictToolsApplied: boolean;
349
+ tools: Tool[] | undefined;
350
+ }
351
+ /** Decide whether an OpenAI-family request should retry once with non-strict tools. */
352
+ export declare function shouldRetryWithoutStrictTools(error: unknown, capturedErrorResponse: CapturedHttpErrorResponse | undefined, context: StrictToolsRetryContext): boolean;
347
353
  export declare const OPENAI_RESPONSES_PROGRESS_EVENT_TYPES: ReadonlySet<string>;
348
354
  export declare function isOpenAIResponsesProgressEvent(event: unknown): boolean;
349
355
  export declare function encodeTextSignatureV1(id: string, phase?: TextSignatureV1["phase"]): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "17.2.3",
4
+ "version": "17.2.4",
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,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/pi-catalog": "17.2.3",
42
- "@oh-my-pi/pi-utils": "17.2.3",
43
- "@oh-my-pi/pi-wire": "17.2.3",
41
+ "@oh-my-pi/pi-catalog": "17.2.4",
42
+ "@oh-my-pi/pi-utils": "17.2.4",
43
+ "@oh-my-pi/pi-wire": "17.2.4",
44
44
  "arktype": "2.2.3",
45
45
  "zod": "^4"
46
46
  },
@@ -12,7 +12,7 @@ import { createHash } from "node:crypto";
12
12
  import * as fs from "node:fs/promises";
13
13
  import * as path from "node:path";
14
14
  import { parseAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan";
15
- import { $env, getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
15
+ import { $env, getAgentDbPath, getDbBusyTimeoutMs, logger } from "@oh-my-pi/pi-utils";
16
16
  import type { ApiKeyResolver } from "./auth-retry";
17
17
  import * as AIError from "./error";
18
18
  import { isUsageLimitOutcome } from "./error/rate-limit";
@@ -1264,6 +1264,15 @@ export class AuthStorage {
1264
1264
  #credentialBackoff: Map<string, Map<number, number>> = new Map();
1265
1265
  /** Earliest time a freshly-set in-memory block may be cleared by live usage reconciliation. */
1266
1266
  #credentialBackoffProbeAfter: Map<string, Map<number, number>> = new Map();
1267
+ /**
1268
+ * Latched true once the persistent credential-block store reports an
1269
+ * unrecoverable error (SQLite corruption / not-a-database). While set, every
1270
+ * persisted-block read and write short-circuits for the life of the process:
1271
+ * availability is preserved through {@link AuthStorage.#credentialBackoff}, but
1272
+ * cross-process persistence is abandoned rather than re-querying a broken store
1273
+ * on every credential evaluation.
1274
+ */
1275
+ #persistedBlockStoreDamaged = false;
1267
1276
  #usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
1268
1277
  #rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
1269
1278
  #usageCache: UsageCache;
@@ -1312,8 +1321,10 @@ export class AuthStorage {
1312
1321
  }
1313
1322
  try {
1314
1323
  this.#store.cleanExpiredCredentialBlocks?.(Date.now());
1315
- } catch {
1316
- // Best-effort.
1324
+ } catch (err) {
1325
+ // Best-effort, but init-time corruption must latch the block store
1326
+ // immediately so the first evaluation doesn't re-query a broken DB.
1327
+ this.#handlePersistedBlockStoreError(err);
1317
1328
  }
1318
1329
  this.#usageFetch = options.usageFetch ?? fetch;
1319
1330
  this.#usageRequestTimeoutMs = options.usageRequestTimeoutMs ?? DEFAULT_USAGE_REQUEST_TIMEOUT_MS;
@@ -1483,7 +1494,16 @@ export class AuthStorage {
1483
1494
  * Reload credentials from storage.
1484
1495
  */
1485
1496
  async reload(): Promise<void> {
1486
- const records = this.#store.listAuthCredentials();
1497
+ let records: StoredAuthCredential[];
1498
+ try {
1499
+ records = this.#store.listAuthCredentials();
1500
+ } catch (err) {
1501
+ // Latch + surface repair guidance on corruption, but still fail the
1502
+ // reload: silently continuing with zero credentials would log the
1503
+ // user out of every provider without explanation.
1504
+ this.#handlePersistedBlockStoreError(err);
1505
+ throw err;
1506
+ }
1487
1507
  const grouped = new Map<string, StoredCredential[]>();
1488
1508
  for (const record of records) {
1489
1509
  const list = grouped.get(record.provider) ?? [];
@@ -1701,11 +1721,13 @@ export class AuthStorage {
1701
1721
  providerKey: string,
1702
1722
  blockScope: string | undefined,
1703
1723
  ): number | undefined {
1724
+ if (this.#persistedBlockStoreDamaged) return undefined;
1704
1725
  const getCredentialBlock = this.#store.getCredentialBlock?.bind(this.#store);
1705
1726
  if (!getCredentialBlock) return undefined;
1706
1727
  try {
1707
1728
  return getCredentialBlock(credentialId, providerKey, blockScope ?? "");
1708
1729
  } catch (err) {
1730
+ if (this.#handlePersistedBlockStoreError(err)) return undefined;
1709
1731
  logger.debug("Failed to read credential block from persistent store", {
1710
1732
  err,
1711
1733
  credentialId,
@@ -1716,6 +1738,26 @@ export class AuthStorage {
1716
1738
  }
1717
1739
  }
1718
1740
 
1741
+ #readPersistedCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number {
1742
+ if (this.#persistedBlockStoreDamaged) return 0;
1743
+ const getCredentialBlockReconcileAfter = this.#store.getCredentialBlockReconcileAfter?.bind(this.#store);
1744
+ if (!getCredentialBlockReconcileAfter) return 0;
1745
+ try {
1746
+ return getCredentialBlockReconcileAfter(credentialId, providerKey, blockScope) ?? 0;
1747
+ } catch (err) {
1748
+ if (this.#handlePersistedBlockStoreError(err)) return 0;
1749
+ // Advisory read: transient failures (e.g. SQLITE_BUSY) fall back to
1750
+ // the in-memory probe window, mirroring #readPersistedCredentialBlock.
1751
+ logger.debug("Failed to read credential block reconcile-after time from persistent store", {
1752
+ err,
1753
+ credentialId,
1754
+ providerKey,
1755
+ blockScope,
1756
+ });
1757
+ return 0;
1758
+ }
1759
+ }
1760
+
1719
1761
  /** Returns block expiry timestamp for a credential, checking unscoped and scoped blocks. */
1720
1762
  #getCredentialBlockedUntil(
1721
1763
  provider: string,
@@ -1792,7 +1834,7 @@ export class AuthStorage {
1792
1834
  this.#invalidateUsageReportCache(provider);
1793
1835
 
1794
1836
  const upsertCredentialBlock = this.#store.upsertCredentialBlock?.bind(this.#store);
1795
- if (!upsertCredentialBlock) return;
1837
+ if (!upsertCredentialBlock || this.#persistedBlockStoreDamaged) return;
1796
1838
  const credentialId = this.#getStoredCredentials(provider)[credentialIndex]?.id;
1797
1839
  if (credentialId === undefined) return;
1798
1840
  try {
@@ -1803,6 +1845,7 @@ export class AuthStorage {
1803
1845
  blockedUntilMs: nextBlockedUntil,
1804
1846
  });
1805
1847
  } catch (err) {
1848
+ if (this.#handlePersistedBlockStoreError(err)) return;
1806
1849
  logger.debug("Failed to persist credential block", {
1807
1850
  err,
1808
1851
  credentialId,
@@ -1814,6 +1857,36 @@ export class AuthStorage {
1814
1857
  }
1815
1858
  }
1816
1859
 
1860
+ #handlePersistedBlockStoreError(err: unknown): boolean {
1861
+ if (!isSqliteCorruptionError(err)) return false;
1862
+ this.#reportDamagedBlockStore(err);
1863
+ return true;
1864
+ }
1865
+
1866
+ #assertPersistedBlockStoreWritable(): void {
1867
+ if (!this.#persistedBlockStoreDamaged) return;
1868
+ const store = this.#sourceLabel ?? `local ${getAgentDbPath()}`;
1869
+ throw new Error(`Persistent credential block store ${store} is unavailable after SQLite corruption`);
1870
+ }
1871
+
1872
+ /**
1873
+ * Latches {@link AuthStorage.#persistedBlockStoreDamaged} on the first
1874
+ * unrecoverable persisted-block store error and surfaces it once at `error`
1875
+ * level with the store location, so an operator can repair or replace it.
1876
+ * Later reads/writes short-circuit silently — the in-memory backoff map keeps
1877
+ * rate-limit blocks applying for the life of the process; only cross-process
1878
+ * persistence is lost.
1879
+ */
1880
+ #reportDamagedBlockStore(err: unknown): void {
1881
+ if (this.#persistedBlockStoreDamaged) return;
1882
+ this.#persistedBlockStoreDamaged = true;
1883
+ const store = this.#sourceLabel ?? `local ${getAgentDbPath()}`;
1884
+ logger.error(
1885
+ "Persistent credential store is corrupt; cross-process rate-limit persistence is disabled for this process. In-memory backoff still applies. Repair the store with `sqlite3 <path> '.recover'` or delete it to recreate on next login.",
1886
+ { err, store },
1887
+ );
1888
+ }
1889
+
1817
1890
  /**
1818
1891
  * Records which credential was used for a session (for rate-limit switching).
1819
1892
  * `lastUsedAtMs` backdates the sticky (session-file pin restores on resume);
@@ -5862,9 +5935,12 @@ export class AuthStorage {
5862
5935
  const scopedBackoffKey = this.#toScopedBackoffKey(providerKey, blockScope);
5863
5936
  const globalProbeAfterMs = this.#credentialBackoffProbeAfter.get(providerKey)?.get(credentialIndex) ?? 0;
5864
5937
  const scopedProbeAfterMs = this.#credentialBackoffProbeAfter.get(scopedBackoffKey)?.get(credentialIndex) ?? 0;
5865
- const getStoreReconcileAfter = this.#store.getCredentialBlockReconcileAfter?.bind(this.#store);
5866
- const storeGlobalProbeAfterMs = getStoreReconcileAfter?.(credentialId, providerKey, "") ?? 0;
5867
- const storeScopedProbeAfterMs = getStoreReconcileAfter?.(credentialId, providerKey, blockScope ?? "") ?? 0;
5938
+ const storeGlobalProbeAfterMs = this.#readPersistedCredentialBlockReconcileAfter(credentialId, providerKey, "");
5939
+ const storeScopedProbeAfterMs = this.#readPersistedCredentialBlockReconcileAfter(
5940
+ credentialId,
5941
+ providerKey,
5942
+ blockScope ?? "",
5943
+ );
5868
5944
  if (Math.max(globalProbeAfterMs, scopedProbeAfterMs, storeGlobalProbeAfterMs, storeScopedProbeAfterMs) > nowMs) {
5869
5945
  return;
5870
5946
  }
@@ -6340,16 +6416,30 @@ export class AuthStorage {
6340
6416
  * Broker-server seam: list non-expired persisted blocks for snapshot entries.
6341
6417
  */
6342
6418
  listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] {
6343
- return this.#store.listCredentialBlocks?.(credentialIds) ?? [];
6419
+ if (this.#persistedBlockStoreDamaged) return [];
6420
+ const listCredentialBlocks = this.#store.listCredentialBlocks?.bind(this.#store);
6421
+ if (!listCredentialBlocks) return [];
6422
+ try {
6423
+ return listCredentialBlocks(credentialIds);
6424
+ } catch (err) {
6425
+ if (this.#handlePersistedBlockStoreError(err)) return [];
6426
+ throw err;
6427
+ }
6344
6428
  }
6345
6429
 
6346
6430
  /**
6347
6431
  * Broker-server seam: persist one credential block and notify snapshot waiters.
6348
6432
  */
6349
6433
  upsertCredentialBlock(block: StoredCredentialBlock): void {
6434
+ this.#assertPersistedBlockStoreWritable();
6350
6435
  const upsertCredentialBlock = this.#store.upsertCredentialBlock?.bind(this.#store);
6351
6436
  if (!upsertCredentialBlock) return;
6352
- upsertCredentialBlock(block);
6437
+ try {
6438
+ upsertCredentialBlock(block);
6439
+ } catch (err) {
6440
+ if (this.#handlePersistedBlockStoreError(err)) this.#assertPersistedBlockStoreWritable();
6441
+ throw err;
6442
+ }
6353
6443
  this.#invalidateUsageReportCacheForProviderKey(block.providerKey);
6354
6444
  this.#bumpGeneration("credential-block");
6355
6445
  }
@@ -6358,17 +6448,29 @@ export class AuthStorage {
6358
6448
  * Broker-server seam: clear all persisted blocks for one credential and notify snapshot waiters.
6359
6449
  */
6360
6450
  deleteCredentialBlock(credentialId: number, providerKey: string, blockScope: string): void {
6451
+ this.#assertPersistedBlockStoreWritable();
6361
6452
  const deleteCredentialBlock = this.#store.deleteCredentialBlock?.bind(this.#store);
6362
6453
  if (!deleteCredentialBlock) return;
6363
- deleteCredentialBlock(credentialId, providerKey, blockScope);
6454
+ try {
6455
+ deleteCredentialBlock(credentialId, providerKey, blockScope);
6456
+ } catch (err) {
6457
+ if (this.#handlePersistedBlockStoreError(err)) this.#assertPersistedBlockStoreWritable();
6458
+ throw err;
6459
+ }
6364
6460
  this.#invalidateUsageReportCacheForProviderKey(providerKey);
6365
6461
  this.#bumpGeneration("credential-block");
6366
6462
  }
6367
6463
 
6368
6464
  deleteCredentialBlocks(credentialId: number): void {
6465
+ this.#assertPersistedBlockStoreWritable();
6369
6466
  const deleteCredentialBlocks = this.#store.deleteCredentialBlocks?.bind(this.#store);
6370
6467
  if (!deleteCredentialBlocks) return;
6371
- deleteCredentialBlocks(credentialId);
6468
+ try {
6469
+ deleteCredentialBlocks(credentialId);
6470
+ } catch (err) {
6471
+ if (this.#handlePersistedBlockStoreError(err)) this.#assertPersistedBlockStoreWritable();
6472
+ throw err;
6473
+ }
6372
6474
  this.#bumpGeneration("credential-block");
6373
6475
  }
6374
6476
 
@@ -6482,6 +6584,19 @@ export function isSqliteBusyError(err: unknown): boolean {
6482
6584
  return typeof code === "string" && code.startsWith("SQLITE_BUSY");
6483
6585
  }
6484
6586
 
6587
+ /**
6588
+ * SQLite's unrecoverable-corruption result codes — the `SQLITE_CORRUPT` family
6589
+ * (base plus extended variants like `SQLITE_CORRUPT_VTAB` / `SQLITE_CORRUPT_INDEX`)
6590
+ * and `SQLITE_NOTADB` (the file header is not a database). Unlike
6591
+ * {@link isSqliteBusyError}, these never clear by retrying: the store must be
6592
+ * repaired or replaced, so callers latch and stop touching it.
6593
+ */
6594
+ export function isSqliteCorruptionError(err: unknown): boolean {
6595
+ if (err === null || typeof err !== "object" || !("code" in err)) return false;
6596
+ const code = err.code;
6597
+ return typeof code === "string" && (code.startsWith("SQLITE_CORRUPT") || code === "SQLITE_NOTADB");
6598
+ }
6599
+
6485
6600
  function normalizeStoredAccountId(accountId: string | null | undefined): string | null {
6486
6601
  const normalized = accountId?.trim();
6487
6602
  return normalized && normalized.length > 0 ? normalized : null;
@@ -6914,6 +7029,12 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6914
7029
  let db: Database | undefined;
6915
7030
  try {
6916
7031
  db = new Database(dbPath);
7032
+ // Install the busy handler BEFORE the first lock-taking statement
7033
+ // on this connection. The leases DDL below and the constructor's
7034
+ // schema init both acquire locks during WAL recovery; without a
7035
+ // non-zero `busy_timeout` they fail immediately with SQLITE_BUSY.
7036
+ // See issue #2421.
7037
+ SqliteAuthCredentialStore.#installBusyTimeout(db);
6917
7038
  try {
6918
7039
  await fs.chmod(dbPath, 0o600);
6919
7040
  } catch {
@@ -6950,12 +7071,25 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6950
7071
  `);
6951
7072
  }
6952
7073
 
7074
+ /**
7075
+ * Install the per-connection busy handler so lock-taking statements wait for
7076
+ * a contended writer instead of failing immediately (Bun defaults
7077
+ * `busy_timeout` to 0). MUST run before the first lock-taking statement on
7078
+ * the connection: concurrent omp startups race WAL recovery and the leases
7079
+ * DDL. Uses the centralized timeout so headless hosts keep their bounded
7080
+ * busy wait instead of the interactive 5s value. See issues #2421, #7298.
7081
+ */
7082
+ static #installBusyTimeout(db: Database): void {
7083
+ db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
7084
+ }
7085
+
6953
7086
  #initializeSchema(): void {
6954
7087
  // Install the busy handler BEFORE any lock-taking statement (incl.
6955
7088
  // `PRAGMA journal_mode=WAL`, which acquires an exclusive lock during WAL
6956
7089
  // recovery). Without this, concurrent omp startups can crash here with
6957
- // `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY`. See issue #2421.
6958
- this.#db.run("PRAGMA busy_timeout = 5000");
7090
+ // `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY`. Re-setting when opened via
7091
+ // `open()` (which already installed it) is idempotent. See issue #2421.
7092
+ SqliteAuthCredentialStore.#installBusyTimeout(this.#db);
6959
7093
  this.#db.run(`
6960
7094
  PRAGMA journal_mode=WAL;
6961
7095
  PRAGMA synchronous=NORMAL;
@@ -2002,7 +2002,7 @@ const streamAnthropicOnce = (
2002
2002
  | (AnthropicServerToolContent & { [kStreamingPartialJson]?: string })
2003
2003
  | (ToolCall & { [kStreamingPartialJson]: string; [kStreamingLastParseLen]?: number })
2004
2004
  ) & { [kStreamingBlockIndex]: number };
2005
- const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs();
2005
+ const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs(model.compat.streamIdleTimeoutMs);
2006
2006
  const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
2007
2007
  const requestTimeoutMs =
2008
2008
  firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
@@ -69,6 +69,7 @@ import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, tool
69
69
  import { notifyRawSseEvent } from "../utils/sse-debug";
70
70
  import { compactGrammarDefinition } from "./grammar";
71
71
  import {
72
+ type CodexLiteShapedBody,
72
73
  type CodexReasoningContext,
73
74
  type CodexRequestOptions,
74
75
  type InputItem,
@@ -165,6 +166,17 @@ export interface OpenAICodexResponsesOptions extends StreamOptions {
165
166
  onModerationMetadata?: (metadata: unknown) => void;
166
167
  }
167
168
 
169
+ /** Raw V2 compaction body accepted by the Codex transport selector. */
170
+ export interface OpenAICodexCompactionBody extends CodexLiteShapedBody {
171
+ model: string;
172
+ [key: string]: unknown;
173
+ }
174
+
175
+ /** Transport controls for a provider-native Codex V2 compaction stream. */
176
+ export interface OpenAICodexCompactionStreamOptions extends OpenAICodexResponsesOptions {
177
+ apiKey: string;
178
+ }
179
+
168
180
  /** Inputs for synthesizing Codex request identity outside the normal stream path. */
169
181
  export interface OpenAICodexCompatibilityMetadataOptions {
170
182
  sessionId?: string;
@@ -1360,12 +1372,16 @@ function createRequestSetup(options: OpenAICodexResponsesOptions | undefined): C
1360
1372
  };
1361
1373
  }
1362
1374
 
1363
- async function buildCodexRequestContext(
1375
+ function createCodexRequestContext(
1364
1376
  model: Model<"openai-codex-responses">,
1365
- context: Context,
1377
+ transformedBody: RequestBody,
1366
1378
  options: OpenAICodexResponsesOptions | undefined,
1367
- output: AssistantMessage,
1368
- ): Promise<CodexRequestContext> {
1379
+ contextOptions: {
1380
+ isolateCompactionTransport: boolean;
1381
+ startNewTurn?: boolean;
1382
+ turnStartedAtUnixMs?: number;
1383
+ },
1384
+ ): CodexRequestContext {
1369
1385
  const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
1370
1386
  if (!apiKey) {
1371
1387
  throw new AIError.MissingApiKeyError(model.provider);
@@ -1374,15 +1390,13 @@ async function buildCodexRequestContext(
1374
1390
  const accountId = getCodexAccountId(apiKey);
1375
1391
  const baseUrl = model.baseUrl || CODEX_BASE_URL;
1376
1392
  const url = resolveCodexResponsesUrl(baseUrl);
1377
- const promptCacheKey = getOpenAIPromptCacheKey(options);
1393
+
1378
1394
  const transportSessionId = normalizeOpenAIPromptCacheKey(options?.sessionId);
1379
1395
  const codexClientVersion = CODEX_CLIENT_VERSION;
1380
- const transformedBody = await buildTransformedCodexRequestBody(model, context, options, promptCacheKey);
1381
-
1382
1396
  const requestHeaders = { ...(model.headers ?? {}), ...(options?.headers ?? {}) };
1383
1397
  const rawRequestDump: RawHttpRequestDump = {
1384
1398
  provider: model.provider,
1385
- api: output.api,
1399
+ api: model.api,
1386
1400
  model: model.id,
1387
1401
  method: "POST",
1388
1402
  url,
@@ -1390,7 +1404,10 @@ async function buildCodexRequestContext(
1390
1404
  };
1391
1405
 
1392
1406
  const providerSessionState = getCodexProviderSessionState(options?.providerSessionState);
1393
- const isolatedTransportState = options?.codexCompaction ? createCodexProviderSessionState() : undefined;
1407
+ const isolatedTransportState =
1408
+ contextOptions.isolateCompactionTransport && options?.codexCompaction
1409
+ ? createCodexProviderSessionState()
1410
+ : undefined;
1394
1411
  const transportProviderSessionState = isolatedTransportState ?? providerSessionState;
1395
1412
  const responsesLite = resolveCodexResponsesLite(model, options?.responsesLite);
1396
1413
  const sessionKey = getCodexWebSocketSessionKey(transportSessionId, model, accountId, apiKey, baseUrl, responsesLite);
@@ -1413,20 +1430,14 @@ async function buildCodexRequestContext(
1413
1430
  websocketState.turnState = sharedWebsocketState.turnState;
1414
1431
  websocketState.modelsEtag = sharedWebsocketState.modelsEtag;
1415
1432
  }
1416
- const withinTurnContinuation = isCodexWithinTurnContinuation(context);
1417
1433
  const metadataSessionId = transportSessionId ?? crypto.randomUUID();
1418
1434
  const metadataSession = getOrCreateCodexMetadataSessionState(metadataSessionId, providerSessionState);
1419
1435
  const compaction = options?.codexCompaction;
1420
1436
  const requestKind: OpenAICodexRequestKind = compaction ? "compaction" : "turn";
1421
- const startNewTurn = resolveCodexStartNewTurn(
1422
- metadataSession,
1423
- requestKind,
1424
- compaction,
1425
- compaction ? undefined : !withinTurnContinuation,
1426
- );
1437
+ const startNewTurn = resolveCodexStartNewTurn(metadataSession, requestKind, compaction, contextOptions.startNewTurn);
1427
1438
  if (websocketState && startNewTurn) {
1428
- // Codex scopes turn-state to one turn. Mid-turn compaction and tool-loop
1429
- // follow-ups preserve it; new user or compaction turns start without it.
1439
+ // Codex scopes turn-state to one turn. Mid-turn compaction preserves it;
1440
+ // a pre-turn or standalone compaction starts without it.
1430
1441
  websocketState.turnState = undefined;
1431
1442
  }
1432
1443
  const requestMetadata = createCodexRequestMetadata(metadataSession, requestKind, {
@@ -1435,7 +1446,7 @@ async function buildCodexRequestContext(
1435
1446
  ? startNewTurn || !metadataSession.turnId
1436
1447
  ? Date.now()
1437
1448
  : undefined
1438
- : getCodexTurnStartedAtUnixMs(context),
1449
+ : contextOptions.turnStartedAtUnixMs,
1439
1450
  clientMetadata: transformedBody.client_metadata,
1440
1451
  parentTurnId: options?.parentTurnId,
1441
1452
  compaction,
@@ -1459,6 +1470,20 @@ async function buildCodexRequestContext(
1459
1470
  };
1460
1471
  }
1461
1472
 
1473
+ async function buildCodexRequestContext(
1474
+ model: Model<"openai-codex-responses">,
1475
+ context: Context,
1476
+ options: OpenAICodexResponsesOptions | undefined,
1477
+ ): Promise<CodexRequestContext> {
1478
+ const promptCacheKey = getOpenAIPromptCacheKey(options);
1479
+ const transformedBody = await buildTransformedCodexRequestBody(model, context, options, promptCacheKey);
1480
+ return createCodexRequestContext(model, transformedBody, options, {
1481
+ isolateCompactionTransport: true,
1482
+ startNewTurn: options?.codexCompaction ? undefined : !isCodexWithinTurnContinuation(context),
1483
+ turnStartedAtUnixMs: options?.codexCompaction ? undefined : getCodexTurnStartedAtUnixMs(context),
1484
+ });
1485
+ }
1486
+
1462
1487
  /** @internal Exported for tests. */
1463
1488
  export async function buildTransformedCodexRequestBody(
1464
1489
  model: Model<"openai-codex-responses">,
@@ -1566,6 +1591,127 @@ async function openInitialCodexEventStream(
1566
1591
  }
1567
1592
  return openCodexSseTransport(model, requestContext, requestSetup, options, websocketState, transformedBody);
1568
1593
  }
1594
+
1595
+ function toCodexRequestBody(body: OpenAICodexCompactionBody): RequestBody {
1596
+ const request: RequestBody = { model: body.model };
1597
+ for (const key in body) {
1598
+ if (key !== "model") request[key] = body[key];
1599
+ }
1600
+ return request;
1601
+ }
1602
+
1603
+ /**
1604
+ * Open a provider-native V2 compaction stream through Codex's WebSocket-first
1605
+ * transport, replaying WebSocket transport failures over SSE.
1606
+ */
1607
+ export async function openCodexCompactionEventStream(
1608
+ model: Model<"openai-codex-responses">,
1609
+ body: OpenAICodexCompactionBody,
1610
+ options: OpenAICodexCompactionStreamOptions,
1611
+ ): Promise<AsyncGenerator<Record<string, unknown>>> {
1612
+ const requestSetup = createRequestSetup(options);
1613
+ let requestContext: CodexRequestContext;
1614
+ let initial: {
1615
+ eventStream: AsyncGenerator<Record<string, unknown>>;
1616
+ requestBodyForState: RequestBody;
1617
+ transport: CodexTransport;
1618
+ };
1619
+ try {
1620
+ requestContext = createCodexRequestContext(model, toCodexRequestBody(body), options, {
1621
+ isolateCompactionTransport: false,
1622
+ });
1623
+ initial = await openInitialCodexEventStream(model, options, requestSetup, requestContext);
1624
+ } catch (error) {
1625
+ requestSetup.requestAbortController.abort();
1626
+ throw error;
1627
+ }
1628
+
1629
+ if (requestContext.websocketState) {
1630
+ requestContext.websocketState.lastTransport = initial.transport;
1631
+ // The compaction request may use the existing append baseline, but the
1632
+ // replacement history makes that baseline stale for the next normal turn.
1633
+ resetCodexWebSocketAppendState(requestContext.websocketState);
1634
+ }
1635
+ return streamCodexCompactionEvents(model, options, requestSetup, requestContext, initial);
1636
+ }
1637
+
1638
+ async function* streamCodexCompactionEvents(
1639
+ model: Model<"openai-codex-responses">,
1640
+ options: OpenAICodexCompactionStreamOptions,
1641
+ requestSetup: CodexRequestSetup,
1642
+ requestContext: CodexRequestContext,
1643
+ initial: {
1644
+ eventStream: AsyncGenerator<Record<string, unknown>>;
1645
+ requestBodyForState: RequestBody;
1646
+ transport: CodexTransport;
1647
+ },
1648
+ ): AsyncGenerator<Record<string, unknown>> {
1649
+ let completed = false;
1650
+ const websocketState = requestContext.websocketState;
1651
+ const previousTurnState = websocketState?.turnState;
1652
+ const previousModelsEtag = websocketState?.modelsEtag;
1653
+ try {
1654
+ if (initial.transport === "websocket") {
1655
+ // Do not expose a WebSocket attempt until it finishes: an SSE replay
1656
+ // must replace, not extend, any partial compaction output.
1657
+ const bufferedEvents: Array<Record<string, unknown>> = [];
1658
+ try {
1659
+ for await (const event of initial.eventStream) bufferedEvents.push(event);
1660
+ } catch (error) {
1661
+ if (options.signal?.aborted || !(error instanceof CodexWebSocketTransportError)) {
1662
+ throw error;
1663
+ }
1664
+ const state = requestContext.websocketState;
1665
+ if (state) recordCodexWebSocketFailure(state, true);
1666
+ const fallback = await openCodexSseTransport(model, requestContext, requestSetup, options, state);
1667
+ if (state) state.lastTransport = fallback.transport;
1668
+ yield* drainCodexCompactionEvents(fallback.eventStream, requestContext.websocketState);
1669
+ completed = true;
1670
+ return;
1671
+ }
1672
+ // Apply metadata only once the WebSocket attempt succeeded: a discarded
1673
+ // attempt must not leak its `x-codex-turn-state` into the session.
1674
+ for (const event of bufferedEvents) {
1675
+ applyCodexCompactionResponseMetadata(requestContext.websocketState, event);
1676
+ yield event;
1677
+ }
1678
+ } else {
1679
+ yield* drainCodexCompactionEvents(initial.eventStream, requestContext.websocketState);
1680
+ }
1681
+ completed = true;
1682
+ } finally {
1683
+ if (!completed) {
1684
+ requestSetup.requestAbortController.abort();
1685
+ if (websocketState) {
1686
+ websocketState.turnState = previousTurnState;
1687
+ websocketState.modelsEtag = previousModelsEtag;
1688
+ }
1689
+ }
1690
+ }
1691
+ }
1692
+
1693
+ /**
1694
+ * Capture `x-codex-turn-state`/`x-models-etag` refreshes carried by a
1695
+ * `response.metadata` frame so a mid-turn compaction leaves the live session on
1696
+ * the latest turn state, matching the normal Codex stream processor.
1697
+ */
1698
+ function applyCodexCompactionResponseMetadata(
1699
+ state: CodexWebSocketSessionState | undefined,
1700
+ event: Record<string, unknown>,
1701
+ ): void {
1702
+ if (!state || event.type !== "response.metadata") return;
1703
+ updateCodexSessionMetadataFromHeaders(state, toCodexHeaders(event.headers));
1704
+ }
1705
+
1706
+ async function* drainCodexCompactionEvents(
1707
+ events: AsyncGenerator<Record<string, unknown>>,
1708
+ state: CodexWebSocketSessionState | undefined,
1709
+ ): AsyncGenerator<Record<string, unknown>> {
1710
+ for await (const event of events) {
1711
+ applyCodexCompactionResponseMetadata(state, event);
1712
+ yield event;
1713
+ }
1714
+ }
1569
1715
  async function openCodexWebSocketTransport(
1570
1716
  model: Model<"openai-codex-responses">,
1571
1717
  options: OpenAICodexResponsesOptions | undefined,
@@ -2279,12 +2425,15 @@ class CodexStreamProcessor {
2279
2425
  resetCodexWebSocketAppendState(state);
2280
2426
  } else {
2281
2427
  state.lastRequest = structuredCloneJSON(runtime.requestBodyForState);
2282
- if (responseId) {
2428
+ const replayableResponseItems = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(
2429
+ structuredCloneJSON(runtime.nativeOutputItems),
2430
+ );
2431
+ if (responseId && replayableResponseItems) {
2283
2432
  state.lastResponseId = responseId;
2284
- state.lastResponseItems = stripInputItemIds(structuredCloneJSON(runtime.nativeOutputItems));
2433
+ state.lastResponseItems = replayableResponseItems;
2285
2434
  state.canAppend = rawEvent.type === "response.done" || rawEvent.type === "response.completed";
2286
2435
  } else {
2287
- // Without a response id the append baseline cannot be trusted.
2436
+ // Without both a response id and replayable output, the append baseline cannot be trusted.
2288
2437
  state.canAppend = false;
2289
2438
  }
2290
2439
  }
@@ -2710,7 +2859,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
2710
2859
  let requestContext: CodexRequestContext | undefined;
2711
2860
 
2712
2861
  try {
2713
- requestContext = await buildCodexRequestContext(model, context, options, output);
2862
+ requestContext = await buildCodexRequestContext(model, context, options);
2714
2863
  const initialTransport = await openInitialCodexEventStream(model, options, requestSetup, requestContext);
2715
2864
  const runtime = new CodexStreamRuntime({
2716
2865
  ...initialTransport,
@@ -3005,14 +3154,6 @@ export function getOpenAICodexTransportDetails(
3005
3154
  };
3006
3155
  }
3007
3156
 
3008
- function stripInputItemIds(items: Array<Record<string, unknown>>): InputItem[] {
3009
- return items.map(item => {
3010
- if (item.id == null) return item as InputItem;
3011
- const { id: _id, ...rest } = item;
3012
- return rest as InputItem;
3013
- });
3014
- }
3015
-
3016
3157
  const codexDiagnosticsTextEncoder = new TextEncoder();
3017
3158
 
3018
3159
  function jsonByteLength(value: unknown): number {
@@ -757,7 +757,13 @@ const streamOpenAICompletionsOnce = (
757
757
  disableStrictTools = true;
758
758
  openaiStream = await createCompletionsStream("none");
759
759
  } else {
760
- if (!shouldRetryWithoutStrictTools(error, capturedErrorResponse, appliedStrictTools, context.tools)) {
760
+ if (
761
+ !shouldRetryWithoutStrictTools(error, capturedErrorResponse, {
762
+ model,
763
+ strictToolsApplied: appliedStrictTools,
764
+ tools: context.tools,
765
+ })
766
+ ) {
761
767
  throw error;
762
768
  }
763
769
  // Remember the rejection for the rest of the session so every
@@ -610,12 +610,11 @@ const streamOpenAIResponsesOnce = (
610
610
  strictRetryAvailable &&
611
611
  !requestSignal.aborted &&
612
612
  (compiledGrammarTooLarge ||
613
- shouldRetryWithoutStrictTools(
614
- error,
615
- capturedErrorResponse,
616
- activeStrictToolsApplied,
617
- context.tools,
618
- ));
613
+ shouldRetryWithoutStrictTools(error, capturedErrorResponse, {
614
+ model,
615
+ strictToolsApplied: activeStrictToolsApplied,
616
+ tools: context.tools,
617
+ }));
619
618
  if (canRetryWithoutStrictTools) {
620
619
  strictRetryAvailable = false;
621
620
  forceDisableStrictTools = true;
@@ -1181,21 +1181,46 @@ export function isCompiledGrammarTooLargeStrictError(
1181
1181
  );
1182
1182
  }
1183
1183
 
1184
+ interface StrictToolsRetryContext {
1185
+ model: OpenAIModelIdentity;
1186
+ strictToolsApplied: boolean;
1187
+ tools: Tool[] | undefined;
1188
+ }
1189
+
1190
+ /** Decide whether an OpenAI-family request should retry once with non-strict tools. */
1184
1191
  export function shouldRetryWithoutStrictTools(
1185
1192
  error: unknown,
1186
1193
  capturedErrorResponse: CapturedHttpErrorResponse | undefined,
1187
- strictToolsApplied: boolean,
1188
- tools: Tool[] | undefined,
1194
+ context: StrictToolsRetryContext,
1189
1195
  ): boolean {
1196
+ const { model, strictToolsApplied, tools } = context;
1190
1197
  if (!tools || tools.length === 0 || !strictToolsApplied) return false;
1191
1198
  const status = extractHttpStatusFromError(error) ?? capturedErrorResponse?.status;
1192
1199
  if (status !== 400 && status !== 422) return false;
1200
+ const errorMessage = error instanceof Error ? error.message.trim() : "";
1193
1201
  const messageParts = [error instanceof Error ? error.message : undefined, capturedErrorResponse?.bodyText]
1194
1202
  .filter((value): value is string => typeof value === "string" && value.trim().length > 0)
1195
1203
  .join("\n");
1196
- return /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool|tool parameters? schema|invalid schema for function|structured[_ -]?outputs?\b[^\n]*(?:not (?:supported|available|enabled)|unsupported)|(?:not support|unsupported)[^\n]*structured[_ -]?outputs?\b/i.test(
1197
- messageParts,
1198
- );
1204
+ if (
1205
+ /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool|tool parameters? schema|invalid schema for function|structured[_ -]?outputs?\b[^\n]*(?:not (?:supported|available|enabled)|unsupported)|(?:not support|unsupported)[^\n]*structured[_ -]?outputs?\b/i.test(
1206
+ messageParts,
1207
+ )
1208
+ ) {
1209
+ return true;
1210
+ }
1211
+ if (model.provider !== "openrouter" || !/^(?:400\s+)?Provider returned error$/i.test(errorMessage)) return false;
1212
+ const body = capturedErrorResponse?.bodyJson;
1213
+ if (body && typeof body === "object" && "error" in body) {
1214
+ const errorBody = body.error;
1215
+ if (errorBody && typeof errorBody === "object" && "metadata" in errorBody) {
1216
+ const metadata = errorBody.metadata;
1217
+ if (metadata && typeof metadata === "object" && "raw" in metadata) {
1218
+ const raw = metadata.raw;
1219
+ if (typeof raw === "string" ? raw.trim().length > 0 : raw != null) return false;
1220
+ }
1221
+ }
1222
+ }
1223
+ return true;
1199
1224
  }
1200
1225
 
1201
1226
  function normalizeOpenAIStableId(value: string | undefined, maxLength: number, hashPrefix: string): string | undefined {
package/src/stream.ts CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  } from "@oh-my-pi/pi-catalog/model-thinking";
16
16
  import { CATALOG_PROVIDERS, type ProviderCatalogEntry } from "@oh-my-pi/pi-catalog/provider-models";
17
17
  import { CODEX_BASE_URL } from "@oh-my-pi/pi-catalog/wire/codex";
18
- import { $env, $pickenv, getConfigRootDir, isEnoent, logger, withExtraCaFetch } from "@oh-my-pi/pi-utils";
18
+ import { $env, $pickenv, getProviderInFlightRoot, isEnoent, logger, withExtraCaFetch } from "@oh-my-pi/pi-utils";
19
19
  import { getCustomApi } from "./api-registry";
20
20
  import { createAuthRetryKeyState, isApiKeyResolver, resolveNextAuthRetryKey } from "./auth-retry";
21
21
  import * as AIError from "./error";
@@ -189,7 +189,7 @@ function resolveProviderInFlightLimit(
189
189
 
190
190
  function providerInFlightRoot(): string {
191
191
  if (providerInFlightRootOverride) return providerInFlightRootOverride;
192
- return path.join(getConfigRootDir(), "run", "provider-inflight");
192
+ return getProviderInFlightRoot();
193
193
  }
194
194
 
195
195
  function providerInFlightSegment(provider: string): string {