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

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 (35) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/types/auth-storage.d.ts +8 -0
  3. package/dist/types/dialect/examples.d.ts +10 -2
  4. package/dist/types/dialect/inventory.d.ts +6 -9
  5. package/dist/types/dialect/rendering.d.ts +9 -0
  6. package/dist/types/dialect/types.d.ts +0 -1
  7. package/dist/types/error/flags.d.ts +5 -0
  8. package/dist/types/providers/openai-codex-responses.d.ts +15 -1
  9. package/dist/types/providers/openai-shared.d.ts +24 -6
  10. package/dist/types/providers/transform-messages.d.ts +1 -1
  11. package/dist/types/types.d.ts +6 -4
  12. package/dist/types/utils/harmony-leak.d.ts +9 -0
  13. package/dist/types/utils/schema/typescript.d.ts +8 -2
  14. package/package.json +4 -4
  15. package/src/auth-broker/discover.ts +2 -1
  16. package/src/auth-storage.ts +149 -15
  17. package/src/dialect/examples.ts +24 -12
  18. package/src/dialect/gemini.ts +17 -31
  19. package/src/dialect/harmony.ts +1 -2
  20. package/src/dialect/inventory.ts +20 -63
  21. package/src/dialect/rendering.ts +54 -0
  22. package/src/dialect/types.ts +0 -1
  23. package/src/error/flags.ts +8 -0
  24. package/src/providers/anthropic.ts +78 -33
  25. package/src/providers/cursor.ts +6 -3
  26. package/src/providers/openai-codex-responses.ts +191 -36
  27. package/src/providers/openai-completions.ts +7 -1
  28. package/src/providers/openai-responses.ts +5 -6
  29. package/src/providers/openai-shared.ts +114 -40
  30. package/src/providers/transform-messages.ts +1 -1
  31. package/src/stream.ts +2 -2
  32. package/src/types.ts +9 -8
  33. package/src/utils/harmony-leak.ts +12 -0
  34. package/src/utils/schema/typescript.ts +21 -7
  35. package/src/utils.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.5] - 2026-08-03
6
+
7
+ ### Changed
8
+
9
+ - Standardized tool-call examples in `renderToolExamples` and `renderToolInventory` to use Python keyword-argument syntax (`name(key="value")`) across all models, removing the model-specific dialect parameter and the `DialectRenderOptions.example` flag.
10
+ - Updated `renderToolInventory` to render the tool catalog as a unified OpenAI-Harmony-style `## functions` block using TypeScript type declarations and comments, replacing the previous per-tool Markdown sections.
11
+ - Added a `style: "harmony"` option to `jsonSchemaToTypeScript` for generating compact, comma-delimited TypeScript definitions.
12
+
13
+ ### Fixed
14
+
15
+ - Fixed a session-blocking issue where unescaped Harmony control tokens in replayed assistant responses and tool inputs caused subsequent requests to be rejected with `invalid_prompt` errors.
16
+ - Fixed an issue where Codex Responses dropped native image-generation results from assistant content and replays due to stale `generating` statuses.
17
+ - Fixed Anthropic stream truncation handling where unexpected connection closures were incorrectly treated as clean stops, causing the agent loop to halt silently mid-sentence.
18
+ - Optimized Anthropic prompt caching to prevent unnecessary cache invalidation of the entire system prefix when volatile project footer details (such as current working directory, date, or workspace tree) change.
19
+
20
+ ## [17.2.4] - 2026-08-01
21
+
22
+ ### Fixed
23
+
24
+ - 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)).
25
+ - 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.
26
+ - 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)).
27
+ - 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)).
28
+ - 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))
29
+ - 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)).
30
+
5
31
  ## [17.2.3] - 2026-08-01
6
32
 
7
33
  ### 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,2 +1,10 @@
1
- import type { Dialect, InbandTool } from "./types.js";
2
- export declare function renderToolExamples(tool: InbandTool, dialect: Dialect, intentField?: string): string;
1
+ import type { InbandTool } from "./types.js";
2
+ /**
3
+ * Render a tool's examples as an `<examples>` block. Calls render in Python
4
+ * keyword-argument syntax (`name(key="value", n=1)`) regardless of the model's
5
+ * tool-call dialect, so example bytes stay identical across models. Multiline
6
+ * string args render as verbatim `"""…"""` blocks, and a call whose only
7
+ * argument is a string renders as the bare value — the block already names the
8
+ * tool, and payload args (commands, code, patches) read best verbatim.
9
+ */
10
+ export declare function renderToolExamples(tool: InbandTool, intentField?: string): string;
@@ -1,12 +1,9 @@
1
1
  import type { InbandTool } from "./types.js";
2
2
  /**
3
- * Human-readable per-tool inventory: each tool renders as a `# Tool: <name>`
4
- * section with its description, a simplified TypeScript-style parameter
5
- * signature (derived from the wire JSON Schema), and examples in the model's
6
- * native dialect. Shared by the verbose system-prompt inventory and
7
- * `/dump` so both render the catalog the same way.
8
- *
9
- * `model` is a model id; the native example dialect is resolved from it
10
- * (`preferredDialect`, which falls back to XML for empty/unknown ids).
3
+ * Tool catalog in the OpenAI-Harmony `namespace functions { }` shape: each
4
+ * tool renders as its full description (and Python-syntax examples) as `//`
5
+ * comment lines above a flat `type <name> = (_: {…});` declaration. Shared by
6
+ * the verbose system-prompt inventory and `/dump` so both render the catalog
7
+ * the same way.
11
8
  */
12
- export declare function renderToolInventory(tools: readonly InbandTool[], model: string): string;
9
+ export declare function renderToolInventory(tools: readonly InbandTool[]): string;
@@ -4,6 +4,15 @@ export declare function renderToolResponseResults(results: readonly DialectToolR
4
4
  export declare function kimiCallId(name: string, id: string, index: number): string;
5
5
  export declare function harmonyRecipient(name: string): string;
6
6
  export declare function stringifyJson(value: unknown): string;
7
+ /**
8
+ * Render `name(key=value, …)` with Python-literal argument values. Top-level
9
+ * multiline strings render as verbatim `"""…"""` blocks so payload-carrying
10
+ * args (file content, scripts, patches) keep real newlines instead of `\n`
11
+ * escape soup; nested values always use escaped single-line literals.
12
+ */
13
+ export declare function pyCall(name: string, args: Record<string, unknown>): string;
14
+ /** Render a JSON-ish value as a Python literal (`True`/`False`/`None`, escaped strings, lists, dicts). */
15
+ export declare function pyValue(value: unknown): string;
7
16
  export declare function escapeXmlAttr(value: string): string;
8
17
  export declare function escapeXmlText(value: string): string;
9
18
  export type AssistantTranscriptParts = {
@@ -42,7 +42,6 @@ export interface DialectToolResult {
42
42
  }
43
43
  export interface DialectRenderOptions {
44
44
  readonly tools?: readonly InbandTool[];
45
- readonly example?: boolean;
46
45
  }
47
46
  export interface DialectDefinition {
48
47
  readonly dialect: CatalogDialect;
@@ -40,6 +40,11 @@ export declare function retriable(id: number | undefined, opts?: {
40
40
  }): boolean;
41
41
  export declare function status(error: unknown): number | undefined;
42
42
  export declare function isStreamReadErrorText(text: string): boolean;
43
+ /** Persisted-text form of {@link isStreamEnvelopeError}: recognizes the
44
+ * prefix-tagged envelope diagnostic on an aborted turn's `errorMessage` /
45
+ * `stopDetails.explanation` so loop-level salvage can classify it after the
46
+ * original `Error` instance is gone. */
47
+ export declare function isStreamEnvelopeErrorText(text: string): boolean;
43
48
  export declare function classify(error: unknown, api?: Api): number;
44
49
  /**
45
50
  * Whether an error (or message string) classifies as an account usage/quota
@@ -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;
@@ -415,17 +421,27 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
415
421
  preserveAssistantMessageIds?: boolean;
416
422
  }
417
423
  /**
418
- * Escape reserved Harmony control tokens in the client-boundary text of
419
- * replayed Responses input items user/developer/system message text and
420
- * tool-result output. Model-owned items (assistant output, reasoning, tool-call
421
- * arguments) carry no client data and are returned untouched.
424
+ * Escape reserved Harmony control tokens in the free-text fields of replayed
425
+ * Responses input items: user/developer/system text, tool-result output,
426
+ * assistant message text, and tool-call payloads.
427
+ *
428
+ * Tool-call items are covered deliberately. The original #6913 fix skipped
429
+ * model-owned items on the theory that they carry no client data — but a model
430
+ * legitimately writing *about* Harmony samples `<|channel|>` etc. into its own
431
+ * `function_call.arguments`, and a full-transcript replay (stale or blocked
432
+ * previous_response_id, provider fallback) feeds those bytes back as input,
433
+ * which gpt-5.x reject with invalid_prompt / "Request blocked", permanently
434
+ * poisoning the session. `arguments` is a JSON document, so it uses
435
+ * {@link escapeHarmonyControlTokensInJson} to stay parseable. Reasoning items
436
+ * are left untouched: `encrypted_content` is opaque and plaintext summaries
437
+ * are never rendered back into the prompt.
422
438
  *
423
439
  * Native history replay pushes stored `providerPayload` items straight onto the
424
440
  * wire, bypassing {@link convertResponsesInputContent}; without this a stored
425
441
  * `input_text` carrying `<|channel|>analysis` still reaches gpt-5.x raw (#6913).
426
442
  * Callers gate on {@link isHarmonyDialectModel}. Items are copied, not mutated.
427
443
  */
428
- export declare function escapeReplayedClientText(items: ResponseInput): ResponseInput;
444
+ export declare function escapeReplayedControlTokens(items: ResponseInput): ResponseInput;
429
445
  export declare function buildResponsesInput<TApi extends Api>(options: BuildResponsesInputOptions<TApi>): ResponseInput;
430
446
  export declare function convertResponsesAssistantMessage<TApi extends Api>(assistantMsg: AssistantMessage, model: Model<TApi>, msgIndex: number, knownCallIds: Set<string>, includeThinkingSignatures?: boolean, customCallIds?: Set<string>, preserveMessageIds?: boolean, supportsCustomToolCalls?: boolean, customToolWireNameMap?: ReadonlyMap<string, string>, computerCallIds?: Set<string>): ResponseInput;
431
447
  /** Appends one tool result while keeping consecutive outputs ahead of its synthetic image messages. */
@@ -510,6 +526,8 @@ export interface ProcessResponsesStreamOptions {
510
526
  requestServiceTier?: ServiceTier;
511
527
  }
512
528
  export declare function computerCallMetadata(item: ResponseComputerToolCall): ComputerToolCallMetadata;
529
+ /** Append a native Responses image result and emit its completion event. */
530
+ export declare function appendResponsesImageResult(output: AssistantMessage, stream: AssistantMessageEventStream, result: string): void;
513
531
  export declare function processResponsesStream<TApi extends Api>(openaiStream: AsyncIterable<ResponseStreamEvent>, output: AssistantMessage, stream: AssistantMessageEventStream, model: Model<TApi>, options?: ProcessResponsesStreamOptions): Promise<void>;
514
532
  export declare function mapOpenAIResponsesStopReason(status: ResponseStatus | undefined): StopReason;
515
533
  export declare function hasExecutableIncompleteResponsesToolCalls(output: AssistantMessage): boolean;
@@ -13,7 +13,7 @@ import type { Api, AssistantMessage, Message, Model } from "../types.js";
13
13
  * credential redaction is enabled. Exported so hosts can route the same shapes
14
14
  * through reversible obfuscation (keyed placeholders restored before local tool
15
15
  * execution) instead of the irreversible `[*_token_redacted]` rewrite below —
16
- * an irreversible placeholder echoed back in edit-tool `old_text` can never
16
+ * an irreversible placeholder echoed back in edit-tool `old_string` can never
17
17
  * match the real bytes on disk.
18
18
  */
19
19
  export declare const SENSITIVE_TOKEN_RE: RegExp;
@@ -124,10 +124,10 @@ export declare function serviceTierFamily(model: ServiceTierModel): ServiceTierF
124
124
  export declare function resolveModelServiceTier(tiers: ServiceTierByFamily | null | undefined, model: Pick<Model, "provider" | "api" | "id">): ServiceTier | undefined;
125
125
  /**
126
126
  * True when the tier should be sent on the wire as the provider's service-tier
127
- * request field. OpenAI / OpenAI-Codex accept `flex`/`scale`/`priority`; Google
128
- * (Gemini API + Vertex) and OpenRouter accept `flex`/`priority`; Fireworks
129
- * Serverless realizes only its Priority serving path. Anthropic is absent — it
130
- * realizes `priority` via `speed: "fast"`, not a service-tier field.
127
+ * request field. OpenAI / OpenAI-Codex accept every {@link ServiceTier};
128
+ * Google (Gemini API + Vertex) and OpenRouter accept `flex`/`priority`;
129
+ * Fireworks Serverless realizes only its Priority serving path. Anthropic is
130
+ * absent because it realizes `priority` via `speed: "fast"`.
131
131
  */
132
132
  export declare function shouldSendServiceTier(serviceTier: ServiceTier | null | undefined, target: Provider | ServiceTierModel | undefined): boolean;
133
133
  /**
@@ -660,6 +660,8 @@ export interface AssistantRetryRecovery {
660
660
  export interface ContextSnapshot {
661
661
  promptTokens: number;
662
662
  nonMessageTokens: number;
663
+ /** Estimated prompt tokens removed by local history rewrites after this provider snapshot was recorded. */
664
+ historyRewriteTokensRemoved?: number;
663
665
  lastMessageTimestamp?: number;
664
666
  }
665
667
  export interface AssistantMessage {
@@ -8,6 +8,15 @@ import type { AssistantMessage, Model, ToolCall } from "../types.js";
8
8
  * the persisted transcript keeps the byte-for-byte original.
9
9
  */
10
10
  export declare function escapeHarmonyControlTokens(text: string): string;
11
+ /**
12
+ * Escape reserved Harmony control tokens inside a JSON document string (e.g.
13
+ * `function_call.arguments`). Doubles the backslash so the document remains
14
+ * valid JSON whose *decoded* strings carry the inert `<\|token\|>` spelling.
15
+ * `<|` cannot occur outside a string literal in valid JSON, so the blanket
16
+ * replace never corrupts structure; malformed documents are escaped
17
+ * best-effort.
18
+ */
19
+ export declare function escapeHarmonyControlTokensInJson(text: string): string;
11
20
  /**
12
21
  * Whether requests to `model` are served by a Harmony-dialect backend
13
22
  * (gpt-5.x / gpt-oss), which rejects reserved control-token spellings appearing
@@ -9,10 +9,16 @@
9
9
  * literal enums/consts, and descriptions survive.
10
10
  */
11
11
  export interface JsonSchemaToTsOptions {
12
- /** Indentation unit for nested object bodies. Default two spaces. */
12
+ /** Indentation unit for nested object bodies. Default two spaces (none in `harmony` style). */
13
13
  readonly indent?: string;
14
- /** Emit `description` keywords as JSDoc comments on object properties. Default true. */
14
+ /** Emit `description` keywords as comments on object properties. Default true. */
15
15
  readonly comments?: boolean;
16
+ /**
17
+ * Output flavor. `default` renders JSDoc comments, `;` delimiters, and
18
+ * indented bodies; `harmony` renders the flat OpenAI-Harmony convention —
19
+ * `//` line comments, `,` delimiters, no indentation.
20
+ */
21
+ readonly style?: "default" | "harmony";
16
22
  }
17
23
  /** Convert a JSON Schema object into a simplified TypeScript type string. */
18
24
  export declare function jsonSchemaToTypeScript(schema: unknown, options?: JsonSchemaToTsOptions): 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.5",
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.5",
42
+ "@oh-my-pi/pi-utils": "17.2.5",
43
+ "@oh-my-pi/pi-wire": "17.2.5",
44
44
  "arktype": "2.2.3",
45
45
  "zod": "^4"
46
46
  },
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import * as path from "node:path";
8
8
  import {
9
+ $envExact,
9
10
  getAgentDbPath,
10
11
  getAgentDir,
11
12
  getAuthBrokerSnapshotCachePath,
@@ -55,7 +56,7 @@ export function getAuthBrokerTokenFilePath(): string {
55
56
  */
56
57
  async function defaultResolveConfigValue(config: string): Promise<string | undefined> {
57
58
  if (config.startsWith("!")) return undefined;
58
- const envValue = process.env[config];
59
+ const envValue = $envExact(config);
59
60
  return envValue || config;
60
61
  }
61
62
 
@@ -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, $envExact, 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";
@@ -643,7 +643,7 @@ export type AuthStorageOptions = {
643
643
  * Does NOT support "!command" syntax (that requires pi-natives).
644
644
  */
645
645
  async function defaultConfigValueResolver(config: string): Promise<string | undefined> {
646
- const envValue = process.env[config];
646
+ const envValue = $envExact(config);
647
647
  return envValue || config;
648
648
  }
649
649
 
@@ -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;