@oh-my-pi/pi-ai 16.1.1 → 16.1.3

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,36 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.3] - 2026-06-19
6
+
7
+ ### Added
8
+
9
+ - Added regression test pinning that `openai-completions` emits a `thinking` block for `reasoning_content` deltas even when `delta.content` is explicitly JSON `null` (the DeepSeek-format dual-key pattern used by custom GLM/Qwen reasoning providers). See [#2996](https://github.com/can1357/oh-my-pi/issues/2996).
10
+
11
+ ### Changed
12
+
13
+ - Improved the thinking loop guard to treat assistant text loops as retryable errors
14
+ - Refined text normalization logic to reduce false positives in the thinking loop detector
15
+
16
+ ### Fixed
17
+
18
+ - Fixed Ollama chat requests sending image payloads to text-only models. Image blocks are now omitted and replaced with the standard non-vision placeholder for models without vision support, while vision-capable Ollama models continue to receive images. ([#3009](https://github.com/can1357/oh-my-pi/pull/3009) by [@serverinspector](https://github.com/serverinspector))
19
+ - Fixed `SqliteAuthCredentialStore.close()` leaking one-off prepared statements created by inline `this.#db.prepare()` calls in `#authCredentialsTableExists`, `#readAuthSchemaVersion`, `#inferAuthSchemaVersion`, `#migrateAuthSchemaV0ToV1`, `#backfillCredentialIdentityKeys`, and `updateAuthCredential`. Each statement is now wrapped in `try/finally` with `stmt.finalize()`, and the `close()` method finalizes `#insertUsageCostStmt` and `#listUsageCostsStmt` which were previously missed. This caused EBUSY on Windows when tests tried to delete temp dirs containing open SQLite handles.
20
+
21
+ ## [16.1.2] - 2026-06-19
22
+
23
+ ### Added
24
+
25
+ - Added improved JSON repair capabilities for Anthropic tool arguments
26
+ - Added authentication broker discovery to sync credentials between local SQLite and remote state
27
+
28
+ ### Fixed
29
+
30
+ - Improved error feedback and transparency for malformed Anthropic tool call arguments
31
+ - Added automatic fallback for unsupported OpenAI reasoning effort levels
32
+ - Improved reliability when handling invalid reasoning parameter errors across OpenAI-compatible APIs
33
+ - Fixed OpenAI-compatible Chat Completions, Responses, and Azure Responses requests to retry once with the nearest provider-supported reasoning effort when an endpoint rejects `xhigh`/`minimal`-style effort values.
34
+
5
35
  ## [16.1.0] - 2026-06-19
6
36
 
7
37
  ### Added
@@ -0,0 +1,35 @@
1
+ import { AuthStorage } from "../auth-storage";
2
+ export interface AuthBrokerClientConfig {
3
+ url: string;
4
+ token: string;
5
+ }
6
+ export interface ResolveAuthBrokerConfigOptions {
7
+ agentDir?: string;
8
+ configValueResolver?: (config: string) => Promise<string | undefined>;
9
+ }
10
+ export interface DiscoverAuthStorageOptions {
11
+ agentDir?: string;
12
+ configValueResolver?: (config: string) => Promise<string | undefined>;
13
+ cachePath?: string;
14
+ sourceLabel?: string;
15
+ }
16
+ /** Path to the local bearer token file. Created by `omp auth-broker token`. */
17
+ export declare function getAuthBrokerTokenFilePath(): string;
18
+ /**
19
+ * Resolve broker connection configuration using the same precedence as the TUI:
20
+ *
21
+ * 1. `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN` env vars.
22
+ * 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml`.
23
+ * 3. `<config-root>/auth-broker.token` file (paired with a URL from env/config).
24
+ *
25
+ * Returns `null` when no broker URL is configured — callers should fall back to
26
+ * the local SQLite store. Throws when a URL is configured but no token is
27
+ * available, matching the TUI behavior.
28
+ */
29
+ export declare function resolveAuthBrokerConfig(options?: ResolveAuthBrokerConfigOptions): Promise<AuthBrokerClientConfig | null>;
30
+ /**
31
+ * Create an AuthStorage instance, using the broker when configured and falling
32
+ * back to the local SQLite store otherwise. This is the single source of truth
33
+ * for the TUI and the catalog generator.
34
+ */
35
+ export declare function discoverAuthStorage(options?: DiscoverAuthStorageOptions): Promise<AuthStorage>;
@@ -1,4 +1,5 @@
1
1
  export * from "./client";
2
+ export * from "./discover";
2
3
  export * from "./refresher";
3
4
  export * from "./remote-store";
4
5
  export * from "./server";
@@ -0,0 +1,25 @@
1
+ import type { CapturedHttpErrorResponse } from "../utils/http-inspector";
2
+ /** @internal */
3
+ export type OpenAIReasoningEffortFallback = string | null;
4
+ /** @internal */
5
+ export interface OpenAIReasoningEffortFallbackState {
6
+ reasoningEffortFallbacks: Map<string, OpenAIReasoningEffortFallback>;
7
+ }
8
+ /** @internal */
9
+ export declare function createOpenAIReasoningEffortFallbackState(): OpenAIReasoningEffortFallbackState;
10
+ /** @internal */
11
+ export declare function clearOpenAIReasoningEffortFallbackState(state: OpenAIReasoningEffortFallbackState): void;
12
+ /** @internal */
13
+ export declare function getOpenAIReasoningEffortFallback(state: OpenAIReasoningEffortFallbackState | undefined, key: string): OpenAIReasoningEffortFallback | undefined;
14
+ /** @internal */
15
+ export declare function rememberOpenAIReasoningEffortFallback(state: OpenAIReasoningEffortFallbackState | undefined, key: string, fallback: OpenAIReasoningEffortFallback): void;
16
+ /** @internal */
17
+ export declare function createOpenAIReasoningEffortFallbackKey(endpoint: "chat-completions" | "responses" | "azure-responses", baseUrl: string | undefined, wireModelId: string | undefined): string;
18
+ /** @internal */
19
+ export declare function readOpenAIReasoningEffort(params: unknown): string | undefined;
20
+ /** @internal */
21
+ export declare function applyOpenAIReasoningEffortFallback(params: unknown, fallback: OpenAIReasoningEffortFallback): boolean;
22
+ /** @internal */
23
+ export declare function resolveOpenAIReasoningEffortFallback(error: unknown, captured: CapturedHttpErrorResponse | undefined, params: unknown, options?: {
24
+ explicitDisable?: boolean;
25
+ }): OpenAIReasoningEffortFallback | undefined;
@@ -1,5 +1,6 @@
1
1
  import type { Context, Model, OpenAICompat, ProviderSessionState, ServiceTier, StreamFunction, StreamOptions, Tool, ToolChoice } from "../types";
2
2
  import { type OpenAIResponsesToolChoice } from "../utils/tool-choice";
3
+ import { type OpenAIReasoningEffortFallbackState } from "./openai-reasoning-fallback";
3
4
  import type { Tool as OpenAITool, ResponseCreateParamsStreaming, ResponseInput } from "./openai-responses-wire";
4
5
  import { type OpenAIStrictToolsScope, type OpenAIStrictToolsState } from "./openai-shared";
5
6
  export interface OpenAIResponsesOptions extends StreamOptions {
@@ -55,7 +56,7 @@ export interface OpenAIResponsesOptions extends StreamOptions {
55
56
  */
56
57
  extraBody?: Record<string, unknown>;
57
58
  }
58
- interface OpenAIResponsesProviderSessionState extends ProviderSessionState, OpenAIStrictToolsState {
59
+ interface OpenAIResponsesProviderSessionState extends ProviderSessionState, OpenAIStrictToolsState, OpenAIReasoningEffortFallbackState {
59
60
  nativeHistoryReplayWarmed: boolean;
60
61
  /** Stateful `previous_response_id` chain baselines, keyed by baseUrl/model/session. */
61
62
  chains: Map<string, OpenAIResponsesChainState>;
@@ -549,8 +549,8 @@ export interface Tool<TParameters extends TSchema = TSchema> {
549
549
  * Illustrative calls/notes; the AI layer renders them into an `<examples>`
550
550
  * block in the model's native tool-call syntax and appends to the wire
551
551
  * description. Author `call`/`bad`/`good` as plain argument objects WITHOUT
552
- * `_i` — when intent tracing injects `_i` into the schema, the renderer adds
553
- * a placeholder `_i` automatically. Type each tool's `examples` against its
552
+ * `i` — when intent tracing injects `i` into the schema, the renderer adds
553
+ * a placeholder `i` automatically. Type each tool's `examples` against its
554
554
  * own schema (e.g. `readonly ToolExample<typeof schema["type"]>[]`).
555
555
  */
556
556
  examples?: readonly ToolExample[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.1.1",
4
+ "version": "16.1.3",
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.0",
41
- "@oh-my-pi/pi-catalog": "16.1.1",
42
- "@oh-my-pi/pi-utils": "16.1.1",
43
- "@oh-my-pi/pi-wire": "16.1.1",
41
+ "@oh-my-pi/pi-catalog": "16.1.3",
42
+ "@oh-my-pi/pi-utils": "16.1.3",
43
+ "@oh-my-pi/pi-wire": "16.1.3",
44
44
  "arktype": "^2.2.0",
45
45
  "partial-json": "^0.1.7",
46
46
  "zod": "^4"
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Broker-aware auth-storage discovery used by both the coding-agent runtime and
3
+ * the catalog model generator. Keeps the precedence logic (env → config.yml →
4
+ * token file → local SQLite) in one place so build-time tooling sees the same
5
+ * credentials as the TUI.
6
+ */
7
+ import * as path from "node:path";
8
+ import {
9
+ getAgentDbPath,
10
+ getAgentDir,
11
+ getAuthBrokerSnapshotCachePath,
12
+ getConfigRootDir,
13
+ isEnoent,
14
+ logger,
15
+ } from "@oh-my-pi/pi-utils";
16
+ import { YAML } from "bun";
17
+ import { AuthStorage } from "../auth-storage";
18
+ import { AuthBrokerClient } from "./client";
19
+ import { RemoteAuthCredentialStore } from "./remote-store";
20
+ import { readAuthBrokerSnapshotCache, writeAuthBrokerSnapshotCache } from "./snapshot-cache";
21
+ import { DEFAULT_SNAPSHOT_CACHE_TTL_MS, type SnapshotResponse } from "./types";
22
+
23
+ export interface AuthBrokerClientConfig {
24
+ url: string;
25
+ token: string;
26
+ }
27
+
28
+ export interface ResolveAuthBrokerConfigOptions {
29
+ agentDir?: string;
30
+ configValueResolver?: (config: string) => Promise<string | undefined>;
31
+ }
32
+
33
+ export interface DiscoverAuthStorageOptions {
34
+ agentDir?: string;
35
+ configValueResolver?: (config: string) => Promise<string | undefined>;
36
+ cachePath?: string;
37
+ sourceLabel?: string;
38
+ }
39
+
40
+ /** Path to the local bearer token file. Created by `omp auth-broker token`. */
41
+ export function getAuthBrokerTokenFilePath(): string {
42
+ return path.join(getConfigRootDir(), "auth-broker.token");
43
+ }
44
+
45
+ /**
46
+ * Default resolver for config values: checks `process.env` first, then treats
47
+ * the value as a literal. Does NOT execute `!command` syntax; such values are
48
+ * left unresolved so the caller can fall back to the token file.
49
+ */
50
+ async function defaultResolveConfigValue(config: string): Promise<string | undefined> {
51
+ if (config.startsWith("!")) return undefined;
52
+ const envValue = process.env[config];
53
+ return envValue || config;
54
+ }
55
+
56
+ async function readTokenFile(): Promise<string | null> {
57
+ try {
58
+ const raw = await Bun.file(getAuthBrokerTokenFilePath()).text();
59
+ const trimmed = raw.trim();
60
+ return trimmed.length > 0 ? trimmed : null;
61
+ } catch (err) {
62
+ if (isEnoent(err)) return null;
63
+ logger.warn("auth-broker token file unreadable", { error: String(err) });
64
+ return null;
65
+ }
66
+ }
67
+
68
+ interface ConfigSnapshot {
69
+ url?: string;
70
+ token?: string;
71
+ }
72
+
73
+ async function readConfigYaml(agentDir: string): Promise<ConfigSnapshot> {
74
+ const configPath = path.join(agentDir, "config.yml");
75
+ try {
76
+ const raw = await Bun.file(configPath).text();
77
+ const parsed = YAML.parse(raw);
78
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
79
+ const record = parsed as Record<string, unknown>;
80
+ const url = typeof record["auth.broker.url"] === "string" ? (record["auth.broker.url"] as string) : undefined;
81
+ const token =
82
+ typeof record["auth.broker.token"] === "string" ? (record["auth.broker.token"] as string) : undefined;
83
+ return { url, token };
84
+ } catch (err) {
85
+ if (isEnoent(err)) return {};
86
+ logger.warn("auth-broker config.yml unreadable", { error: String(err) });
87
+ return {};
88
+ }
89
+ }
90
+
91
+ function resolveSnapshotTtlMs(): number {
92
+ const raw = process.env.OMP_AUTH_BROKER_SNAPSHOT_TTL_MS;
93
+ if (raw === undefined) return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
94
+ const value = raw.trim();
95
+ if (value === "") return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
96
+ const ttlMs = Number(value);
97
+ if (Number.isFinite(ttlMs) && ttlMs >= 0) return ttlMs;
98
+ logger.warn("Invalid OMP_AUTH_BROKER_SNAPSHOT_TTL_MS; using default", { value: raw });
99
+ return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
100
+ }
101
+
102
+ /**
103
+ * Resolve broker connection configuration using the same precedence as the TUI:
104
+ *
105
+ * 1. `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN` env vars.
106
+ * 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml`.
107
+ * 3. `<config-root>/auth-broker.token` file (paired with a URL from env/config).
108
+ *
109
+ * Returns `null` when no broker URL is configured — callers should fall back to
110
+ * the local SQLite store. Throws when a URL is configured but no token is
111
+ * available, matching the TUI behavior.
112
+ */
113
+ export async function resolveAuthBrokerConfig(
114
+ options: ResolveAuthBrokerConfigOptions = {},
115
+ ): Promise<AuthBrokerClientConfig | null> {
116
+ const agentDir = options.agentDir ?? getAgentDir();
117
+ const resolveConfig = options.configValueResolver ?? defaultResolveConfigValue;
118
+
119
+ const envUrl = process.env.OMP_AUTH_BROKER_URL;
120
+ const envToken = process.env.OMP_AUTH_BROKER_TOKEN;
121
+
122
+ let url = envUrl && envUrl.length > 0 ? envUrl : undefined;
123
+ let configToken: string | undefined;
124
+ if (!url || !envToken) {
125
+ const fromConfig = await readConfigYaml(agentDir);
126
+ if (!url && fromConfig.url) {
127
+ const resolved = await resolveConfig(fromConfig.url);
128
+ if (resolved && resolved.length > 0) url = resolved;
129
+ }
130
+ if (fromConfig.token) {
131
+ const resolved = await resolveConfig(fromConfig.token);
132
+ if (resolved && resolved.length > 0) configToken = resolved;
133
+ }
134
+ }
135
+ if (!url) return null;
136
+
137
+ const token =
138
+ (envToken && envToken.length > 0 ? envToken : undefined) ?? configToken ?? (await readTokenFile()) ?? undefined;
139
+ if (!token) {
140
+ throw new Error(
141
+ `OMP_AUTH_BROKER_URL is set (${url}) but no bearer token is available. ` +
142
+ `Set OMP_AUTH_BROKER_TOKEN, the \`auth.broker.token\` config entry, or place one at ${getAuthBrokerTokenFilePath()}.`,
143
+ );
144
+ }
145
+ return { url, token };
146
+ }
147
+
148
+ /**
149
+ * Create an AuthStorage instance, using the broker when configured and falling
150
+ * back to the local SQLite store otherwise. This is the single source of truth
151
+ * for the TUI and the catalog generator.
152
+ */
153
+ export async function discoverAuthStorage(options: DiscoverAuthStorageOptions = {}): Promise<AuthStorage> {
154
+ const agentDir = options.agentDir ?? getAgentDir();
155
+ const brokerConfig = await resolveAuthBrokerConfig({
156
+ agentDir,
157
+ configValueResolver: options.configValueResolver,
158
+ });
159
+
160
+ if (brokerConfig) {
161
+ const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
162
+ const cachePath = options.cachePath ?? getAuthBrokerSnapshotCachePath();
163
+ const ttlMs = resolveSnapshotTtlMs();
164
+ const persist =
165
+ ttlMs > 0
166
+ ? (snapshot: SnapshotResponse): void => {
167
+ void writeAuthBrokerSnapshotCache({
168
+ path: cachePath,
169
+ token: brokerConfig.token,
170
+ url: brokerConfig.url,
171
+ snapshot,
172
+ }).catch(error => {
173
+ logger.debug("auth-broker snapshot cache write failed", { error: String(error) });
174
+ });
175
+ }
176
+ : undefined;
177
+
178
+ let initialSnapshot: SnapshotResponse | undefined;
179
+ if (ttlMs > 0) {
180
+ initialSnapshot =
181
+ (await readAuthBrokerSnapshotCache({
182
+ path: cachePath,
183
+ token: brokerConfig.token,
184
+ url: brokerConfig.url,
185
+ ttlMs,
186
+ }).catch(error => {
187
+ logger.debug("auth-broker snapshot cache read failed", { error: String(error) });
188
+ return null;
189
+ })) ?? undefined;
190
+ }
191
+ if (!initialSnapshot) {
192
+ const initialResult = await client.fetchSnapshot();
193
+ if (initialResult.status !== 200) throw new Error("Auth broker returned no initial snapshot");
194
+ initialSnapshot = initialResult.snapshot;
195
+ persist?.(initialSnapshot);
196
+ }
197
+ const store = new RemoteAuthCredentialStore({
198
+ client,
199
+ initialSnapshot,
200
+ onSnapshot: persist,
201
+ });
202
+ const storage = new AuthStorage(store, {
203
+ configValueResolver: options.configValueResolver,
204
+ sourceLabel: options.sourceLabel ?? `broker ${brokerConfig.url}`,
205
+ });
206
+ await storage.reload();
207
+ return storage;
208
+ }
209
+
210
+ const dbPath = getAgentDbPath(agentDir);
211
+ const storage = await AuthStorage.create(dbPath, {
212
+ configValueResolver: options.configValueResolver,
213
+ sourceLabel: options.sourceLabel ?? `local ${dbPath}`,
214
+ });
215
+ await storage.reload();
216
+ return storage;
217
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./client";
2
+ export * from "./discover";
2
3
  export * from "./refresher";
3
4
  export * from "./remote-store";
4
5
  export * from "./server";
@@ -4742,25 +4742,47 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4742
4742
  }
4743
4743
 
4744
4744
  #authCredentialsTableExists(): boolean {
4745
- const row = this.#db
4746
- .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'")
4747
- .get() as { present?: number } | undefined;
4748
- return row?.present === 1;
4745
+ const stmt = this.#db.prepare(
4746
+ "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'",
4747
+ );
4748
+ try {
4749
+ const row = stmt.get() as { present?: number } | undefined;
4750
+ return row?.present === 1;
4751
+ } finally {
4752
+ stmt.finalize();
4753
+ }
4749
4754
  }
4750
4755
 
4751
4756
  #readAuthSchemaVersion(): number | null {
4752
- const row = this.#db.prepare("SELECT version FROM auth_schema_version WHERE id = 1").get() as
4753
- | { version?: number }
4754
- | undefined;
4755
- return typeof row?.version === "number" ? row.version : null;
4757
+ const stmt = this.#db.prepare("SELECT version FROM auth_schema_version WHERE id = 1");
4758
+ try {
4759
+ const row = stmt.get() as { version?: number } | undefined;
4760
+ return typeof row?.version === "number" ? row.version : null;
4761
+ } finally {
4762
+ stmt.finalize();
4763
+ }
4756
4764
  }
4757
4765
 
4758
4766
  #writeAuthSchemaVersion(version: number): void {
4759
- this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)").run(version);
4767
+ const stmt = this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)");
4768
+ try {
4769
+ stmt.run(version);
4770
+ } finally {
4771
+ stmt.finalize();
4772
+ }
4760
4773
  }
4761
4774
 
4762
4775
  #inferAuthSchemaVersion(): number {
4763
- const cols = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
4776
+ const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)");
4777
+ try {
4778
+ const cols = stmt.all() as Array<{ name?: string }>;
4779
+ return this.#inferAuthSchemaVersionFromColumns(cols);
4780
+ } finally {
4781
+ stmt.finalize();
4782
+ }
4783
+ }
4784
+
4785
+ #inferAuthSchemaVersionFromColumns(cols: Array<{ name?: string }>): number {
4764
4786
  const hasDisabledCause = cols.some(column => column.name === "disabled_cause");
4765
4787
  const hasIdentityKey = cols.some(column => column.name === "identity_key");
4766
4788
  const hasAccountId = cols.some(column => column.name === "account_id");
@@ -4808,8 +4830,14 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4808
4830
 
4809
4831
  #migrateAuthSchemaV0ToV1(): void {
4810
4832
  const migrate = this.#db.transaction(() => {
4811
- const v0Cols = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
4812
- const hasDisabled = v0Cols.some(col => col.name === "disabled");
4833
+ const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)");
4834
+ let hasDisabled = false;
4835
+ try {
4836
+ const v0Cols = stmt.all() as Array<{ name?: string }>;
4837
+ hasDisabled = v0Cols.some(col => col.name === "disabled");
4838
+ } finally {
4839
+ stmt.finalize();
4840
+ }
4813
4841
 
4814
4842
  this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0");
4815
4843
  this.#db.run(`
@@ -4885,21 +4913,29 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4885
4913
  }
4886
4914
 
4887
4915
  #backfillCredentialIdentityKeys(): void {
4888
- const rows = this.#db
4889
- .prepare(
4890
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
4891
- )
4892
- .all() as AuthRow[];
4916
+ const selectRowsStmt = this.#db.prepare(
4917
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
4918
+ );
4919
+ let rows: AuthRow[];
4920
+ try {
4921
+ rows = selectRowsStmt.all() as AuthRow[];
4922
+ } finally {
4923
+ selectRowsStmt.finalize();
4924
+ }
4893
4925
  if (rows.length === 0) return;
4894
4926
 
4895
4927
  let updateIdentity: Statement | null = null;
4896
- for (const row of rows) {
4897
- const identityKey = resolveRowCredentialIdentityKey(row.provider, row);
4898
- // Rows whose identity cannot be derived stay NULL; writing NULL over
4899
- // NULL would just burn a write transaction on every boot.
4900
- if (identityKey === null) continue;
4901
- updateIdentity ??= this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?");
4902
- updateIdentity.run(identityKey, row.id);
4928
+ try {
4929
+ for (const row of rows) {
4930
+ const identityKey = resolveRowCredentialIdentityKey(row.provider, row);
4931
+ // Rows whose identity cannot be derived stay NULL; writing NULL over
4932
+ // NULL would just burn a write transaction on every boot.
4933
+ if (identityKey === null) continue;
4934
+ updateIdentity ??= this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?");
4935
+ updateIdentity.run(identityKey, row.id);
4936
+ }
4937
+ } finally {
4938
+ updateIdentity?.finalize();
4903
4939
  }
4904
4940
  }
4905
4941
 
@@ -5063,9 +5099,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5063
5099
 
5064
5100
  updateAuthCredential(id: number, credential: AuthCredential): void {
5065
5101
  try {
5066
- const providerRow = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?").get(id) as
5067
- | { provider?: string }
5068
- | undefined;
5102
+ const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?");
5103
+ let providerRow: { provider?: string } | undefined;
5104
+ try {
5105
+ providerRow = providerStmt.get(id) as { provider?: string } | undefined;
5106
+ } finally {
5107
+ providerStmt.finalize();
5108
+ }
5069
5109
  const provider = providerRow?.provider ?? "";
5070
5110
  const serialized = serializeCredential(provider, credential);
5071
5111
  if (!serialized) return;
@@ -5334,6 +5374,8 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5334
5374
  this.#lastUsageHistoryStmt.finalize();
5335
5375
  this.#listUsageHistoryStmt.finalize();
5336
5376
  this.#updateUsageHistoryStmt.finalize();
5377
+ this.#insertUsageCostStmt.finalize();
5378
+ this.#listUsageCostsStmt.finalize();
5337
5379
  this.#db.close();
5338
5380
  }
5339
5381
  }
@@ -9,7 +9,7 @@ export function renderToolExamples(tool: InbandTool, dialect: Dialect, intentFie
9
9
  if (!examples?.length) return "";
10
10
  const definition = getDialectDefinition(dialect);
11
11
  const renderCall = (args: Record<string, unknown>): string => {
12
- // When intent tracing injects `_i` into the schema, examples must show a
12
+ // When intent tracing injects `i` into the schema, examples must show a
13
13
  // placeholder so the model learns to emit it. Keep it first, matching the
14
14
  // schema injection order.
15
15
  const finalArgs = intentField ? { [intentField]: INTENT_PLACEHOLDER, ...args } : args;
package/src/dialect/pi.md CHANGED
@@ -23,7 +23,7 @@ Call with a verbatim body — everything between `«` and `»` is taken literall
23
23
 
24
24
  Argument values:
25
25
 
26
- - Strings are written bare and verbatim (`path=src/a.ts`). Quote with `"…"` only when the value contains spaces or starts with `"`, `[`, or `{` (`_i="run the tests"`).
26
+ - Strings are written bare and verbatim (`path=src/a.ts`). Quote with `"…"` only when the value contains spaces or starts with `"`, `[`, or `{` (`i="run the tests"`).
27
27
  - Numbers, booleans, and `null` are JSON literals (`offset=50`, `force=true`).
28
28
  - Arrays and objects are inline JSON (`paths=["src","test"]`).
29
29
  - The body fence holds the call's first long/multi-line string parameter; its key is implied, never written.
@@ -50,7 +50,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
50
50
  import { isFoundryEnabled } from "../utils/foundry";
51
51
  import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
52
52
  import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
53
- import { parseStreamingJsonThrottled } from "../utils/json-parse";
53
+ import { parseJsonWithRepair, parseStreamingJsonThrottled } from "../utils/json-parse";
54
54
  import { notifyProviderResponse } from "../utils/provider-response";
55
55
  import { isCopilotTransientModelError } from "../utils/retry";
56
56
  import { COMBINATOR_KEYS, NO_STRICT, toolWireSchema } from "../utils/schema";
@@ -1752,14 +1752,25 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1752
1752
  const finalJson =
1753
1753
  block.partialJson.length > 0 ? block.partialJson : JSON.stringify(block.arguments ?? {});
1754
1754
  try {
1755
- block.arguments = JSON.parse(finalJson) as ToolCall["arguments"];
1755
+ block.arguments = parseJsonWithRepair(finalJson) as ToolCall["arguments"];
1756
1756
  } catch (parseError) {
1757
1757
  // Non-fatal: keep the best-effort arguments recovered by the throttled streaming
1758
1758
  // parser instead of failing the turn on malformed/truncated tool-argument JSON.
1759
1759
  reportAnthropicEnvelopeAnomaly(
1760
1760
  `tool_use ${block.id} arguments are not valid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
1761
1761
  );
1762
- block.arguments = (block.arguments ?? {}) as ToolCall["arguments"];
1762
+ const recoveredKeys = Object.keys(block.arguments ?? {});
1763
+ if (recoveredKeys.length === 0) {
1764
+ const maxLen = 512;
1765
+ const truncatedJson =
1766
+ finalJson.length <= maxLen
1767
+ ? finalJson
1768
+ : `${finalJson.slice(0, maxLen)}… [truncated ${finalJson.length - maxLen} chars]`;
1769
+ block.arguments = {
1770
+ __parseError: parseError instanceof Error ? parseError.message : String(parseError),
1771
+ __rawJson: truncatedJson,
1772
+ };
1773
+ }
1763
1774
  }
1764
1775
  delete (block as { partialJson?: string }).partialJson;
1765
1776
  delete (block as { lastParseLen?: number }).lastParseLen;