@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.
- package/CHANGELOG.md +26 -0
- package/dist/types/auth-storage.d.ts +8 -0
- package/dist/types/dialect/examples.d.ts +10 -2
- package/dist/types/dialect/inventory.d.ts +6 -9
- package/dist/types/dialect/rendering.d.ts +9 -0
- package/dist/types/dialect/types.d.ts +0 -1
- package/dist/types/error/flags.d.ts +5 -0
- package/dist/types/providers/openai-codex-responses.d.ts +15 -1
- package/dist/types/providers/openai-shared.d.ts +24 -6
- package/dist/types/providers/transform-messages.d.ts +1 -1
- package/dist/types/types.d.ts +6 -4
- package/dist/types/utils/harmony-leak.d.ts +9 -0
- package/dist/types/utils/schema/typescript.d.ts +8 -2
- package/package.json +4 -4
- package/src/auth-broker/discover.ts +2 -1
- package/src/auth-storage.ts +149 -15
- package/src/dialect/examples.ts +24 -12
- package/src/dialect/gemini.ts +17 -31
- package/src/dialect/harmony.ts +1 -2
- package/src/dialect/inventory.ts +20 -63
- package/src/dialect/rendering.ts +54 -0
- package/src/dialect/types.ts +0 -1
- package/src/error/flags.ts +8 -0
- package/src/providers/anthropic.ts +78 -33
- package/src/providers/cursor.ts +6 -3
- package/src/providers/openai-codex-responses.ts +191 -36
- package/src/providers/openai-completions.ts +7 -1
- package/src/providers/openai-responses.ts +5 -6
- package/src/providers/openai-shared.ts +114 -40
- package/src/providers/transform-messages.ts +1 -1
- package/src/stream.ts +2 -2
- package/src/types.ts +9 -8
- package/src/utils/harmony-leak.ts +12 -0
- package/src/utils/schema/typescript.ts +21 -7
- 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 {
|
|
2
|
-
|
|
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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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[]
|
|
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 = {
|
|
@@ -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
|
-
|
|
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
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
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
|
|
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 `
|
|
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;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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
|
|
128
|
-
* (Gemini API + Vertex) and OpenRouter accept `flex`/`priority`;
|
|
129
|
-
* Serverless realizes only its Priority serving path. Anthropic is
|
|
130
|
-
* realizes `priority` via `speed: "fast"
|
|
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
|
|
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.
|
|
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.
|
|
42
|
-
"@oh-my-pi/pi-utils": "17.2.
|
|
43
|
-
"@oh-my-pi/pi-wire": "17.2.
|
|
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 =
|
|
59
|
+
const envValue = $envExact(config);
|
|
59
60
|
return envValue || config;
|
|
60
61
|
}
|
|
61
62
|
|
package/src/auth-storage.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
5866
|
-
const
|
|
5867
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`.
|
|
6958
|
-
|
|
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;
|