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

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,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.0.8] - 2026-06-18
6
+
7
+ ### Fixed
8
+
9
+ - Improved reliability of auth-broker snapshot loading by implementing a robust manual schema check
10
+ - Fixed MCP tool argument validation to drop optional empty-string parameters before schema validation, matching the existing optional null handling and avoiding pattern/type failures for omitted model-filled fields. ([#2981](https://github.com/can1357/oh-my-pi/issues/2981))
11
+ - Fixed API-key credential replacement to hard-delete superseded disabled `api_key` rows so `auth_credentials` does not grow indefinitely after key rotation. ([#2941](https://github.com/can1357/oh-my-pi/issues/2941))
12
+ - Fixed Cursor provider streaming to close text blocks before tool calls so post-tool text opens a new content block and TUI transcript cards render inline instead of grouped near the bottom. ([#2924](https://github.com/can1357/oh-my-pi/issues/2924))
13
+
5
14
  ## [16.0.7] - 2026-06-18
6
15
 
7
16
  ### Changed
@@ -1,8 +1,8 @@
1
1
  export { type Type, type } from "arktype";
2
2
  export { type ZodType, z } from "zod/v4";
3
3
  export * from "./api-registry";
4
- export * from "./auth-broker";
5
- export { type AuthGatewayBootOptions, type ModelResolver, startAuthGateway } from "./auth-gateway/server";
4
+ export type * from "./auth-broker";
5
+ export type { AuthGatewayBootOptions, ModelResolver } from "./auth-gateway/server";
6
6
  export * from "./auth-gateway/types";
7
7
  export * from "./auth-retry";
8
8
  export * from "./auth-storage";
@@ -10,7 +10,8 @@ export declare function validateToolCall(tools: Tool[], toolCall: ToolCall): Too
10
10
  /**
11
11
  * Validates tool call arguments against the tool's schema (Zod or plain JSON
12
12
  * Schema). Applies LLM-quirk coercions (numeric strings, JSON-string
13
- * containers, null-for-optional, null-for-default) before declaring failure.
13
+ * containers, null/invalid-empty-string-for-optional, null-for-default) before
14
+ * declaring failure.
14
15
  *
15
16
  * @throws Error with a formatted message when validation cannot be reconciled.
16
17
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.0.7",
4
+ "version": "16.0.8",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,8 +38,8 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.0.7",
42
- "@oh-my-pi/pi-utils": "16.0.7",
41
+ "@oh-my-pi/pi-catalog": "16.0.8",
42
+ "@oh-my-pi/pi-utils": "16.0.8",
43
43
  "arktype": "^2.2.0",
44
44
  "partial-json": "^0.1.7",
45
45
  "zod": "^4"
@@ -9,9 +9,7 @@
9
9
  import * as fs from "node:fs/promises";
10
10
  import * as path from "node:path";
11
11
  import { isEnoent, logger } from "@oh-my-pi/pi-utils";
12
- import { type } from "arktype";
13
12
  import type { SnapshotResponse } from "./types";
14
- import { snapshotResponseSchema } from "./wire-schemas";
15
13
 
16
14
  const MAGIC = new Uint8Array([0x4f, 0x4d, 0x50, 0x53]); // "OMPS"
17
15
  const VERSION = 1;
@@ -40,6 +38,25 @@ export interface WriteAuthBrokerSnapshotCacheOptions {
40
38
  snapshot: SnapshotResponse;
41
39
  }
42
40
 
41
+ /**
42
+ * Cheap structural guard for a decrypted cache payload. The bytes are already
43
+ * AES-256-GCM authenticated, so this only rejects shape/version drift (a cache
44
+ * written by a different omp build, or a buggy write) — not tampering. A
45
+ * mismatch returns null so the caller refetches a fresh snapshot.
46
+ */
47
+ function isSnapshotResponseShape(v: unknown): v is SnapshotResponse {
48
+ if (typeof v !== "object" || v === null) return false;
49
+ const o = v as Record<string, unknown>;
50
+ return (
51
+ typeof o.generation === "number" &&
52
+ typeof o.generatedAt === "number" &&
53
+ typeof o.serverNowMs === "number" &&
54
+ typeof o.refresher === "object" &&
55
+ o.refresher !== null &&
56
+ Array.isArray(o.credentials)
57
+ );
58
+ }
59
+
43
60
  export async function readAuthBrokerSnapshotCache(
44
61
  opts: ReadAuthBrokerSnapshotCacheOptions,
45
62
  ): Promise<SnapshotResponse | null> {
@@ -56,12 +73,11 @@ export async function readAuthBrokerSnapshotCache(
56
73
  const plaintext = await decryptCachePayload(data, opts.token, opts.url);
57
74
  if (!plaintext) return null;
58
75
  const parsed: unknown = JSON.parse(TEXT_DECODER.decode(plaintext));
59
- const result = snapshotResponseSchema(parsed);
60
- if (result instanceof type.errors) {
76
+ if (!isSnapshotResponseShape(parsed)) {
61
77
  logger.debug("auth-broker snapshot cache schema invalid", { path: opts.path });
62
78
  return null;
63
79
  }
64
- const snapshot = result;
80
+ const snapshot = parsed;
65
81
  const now = opts.now?.() ?? Date.now();
66
82
  if (now - snapshot.generatedAt > opts.ttlMs) return null;
67
83
  return snapshot;
@@ -4953,21 +4953,30 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4953
4953
  }
4954
4954
 
4955
4955
  /**
4956
- * Hard-deletes disabled rows for a provider when an active row with the same identity exists.
4957
- * This prevents unbounded accumulation of soft-deleted credentials while preserving
4958
- * disabled rows that have no active replacement (safety net for recovery).
4956
+ * Hard-deletes disabled rows for a provider when an active replacement exists.
4957
+ * OAuth credentials match by identity key; API keys match by provider and type.
4958
+ * Disabled rows without an active same-type replacement remain recoverable.
4959
4959
  */
4960
4960
  #purgeSupersededDisabledRows(provider: string, activeRows: StoredAuthCredential[]): void {
4961
4961
  try {
4962
+ let hasActiveApiKey = false;
4962
4963
  const activeIdentityKeys = new Set<string>();
4963
4964
  for (const row of activeRows) {
4965
+ if (row.credential.type === "api_key") {
4966
+ hasActiveApiKey = true;
4967
+ continue;
4968
+ }
4964
4969
  const identityKey = resolveCredentialIdentityKey(provider, row.credential);
4965
4970
  if (identityKey) activeIdentityKeys.add(identityKey);
4966
4971
  }
4967
- if (activeIdentityKeys.size === 0) return;
4972
+ if (!hasActiveApiKey && activeIdentityKeys.size === 0) return;
4968
4973
 
4969
4974
  const disabledRows = this.#listDisabledByProviderStmt.all(provider) as AuthRow[];
4970
4975
  for (const row of disabledRows) {
4976
+ if (hasActiveApiKey && row.credential_type === "api_key") {
4977
+ this.#hardDeleteStmt.run(row.id);
4978
+ continue;
4979
+ }
4971
4980
  const identityKey = resolveRowCredentialIdentityKey(provider, row);
4972
4981
  if (identityKey && activeIdentityKeys.has(identityKey)) {
4973
4982
  this.#hardDeleteStmt.run(row.id);
package/src/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export { type Type, type } from "arktype";
2
2
  export { type ZodType, z } from "zod/v4";
3
3
  export * from "./api-registry";
4
- export * from "./auth-broker";
5
- export { type AuthGatewayBootOptions, type ModelResolver, startAuthGateway } from "./auth-gateway/server";
4
+ export type * from "./auth-broker";
5
+ export type { AuthGatewayBootOptions, ModelResolver } from "./auth-gateway/server";
6
6
  export * from "./auth-gateway/types";
7
7
  export * from "./auth-retry";
8
8
  export * from "./auth-storage";
@@ -548,24 +548,8 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
548
548
  }
549
549
  });
550
550
 
551
- if (state.currentTextBlock) {
552
- const idx = output.content.indexOf(state.currentTextBlock);
553
- stream.push({
554
- type: "text_end",
555
- contentIndex: idx,
556
- content: state.currentTextBlock.text,
557
- partial: output,
558
- });
559
- }
560
- if (state.currentThinkingBlock) {
561
- const idx = output.content.indexOf(state.currentThinkingBlock);
562
- stream.push({
563
- type: "thinking_end",
564
- contentIndex: idx,
565
- content: state.currentThinkingBlock.thinking,
566
- partial: output,
567
- });
568
- }
551
+ endCurrentTextBlock(output, stream, state);
552
+ endCurrentThinkingBlock(output, stream, state);
569
553
  if (state.currentToolCall) {
570
554
  const idx = output.content.indexOf(state.currentToolCall);
571
555
  state.currentToolCall.arguments = parseStreamingJson(state.currentToolCall.partialJson);
@@ -1972,6 +1956,38 @@ export function mergeCursorMcpToolCallArgs(
1972
1956
  return merged;
1973
1957
  }
1974
1958
 
1959
+ function endCurrentTextBlock(output: AssistantMessage, stream: AssistantMessageEventStream, state: BlockState): void {
1960
+ const block = state.currentTextBlock;
1961
+ if (!block) return;
1962
+ const idx = output.content.indexOf(block);
1963
+ delete (block as { index?: number }).index;
1964
+ stream.push({
1965
+ type: "text_end",
1966
+ contentIndex: idx,
1967
+ content: block.text,
1968
+ partial: output,
1969
+ });
1970
+ state.setTextBlock(null);
1971
+ }
1972
+
1973
+ function endCurrentThinkingBlock(
1974
+ output: AssistantMessage,
1975
+ stream: AssistantMessageEventStream,
1976
+ state: BlockState,
1977
+ ): void {
1978
+ const block = state.currentThinkingBlock;
1979
+ if (!block) return;
1980
+ const idx = output.content.indexOf(block);
1981
+ delete (block as { index?: number }).index;
1982
+ stream.push({
1983
+ type: "thinking_end",
1984
+ contentIndex: idx,
1985
+ content: block.thinking,
1986
+ partial: output,
1987
+ });
1988
+ state.setThinkingBlock(null);
1989
+ }
1990
+
1975
1991
  /** Exported for tests: drives one Cursor interaction update through the streaming state machine. */
1976
1992
  export function processInteractionUpdate(
1977
1993
  update: any,
@@ -2017,18 +2033,10 @@ export function processInteractionUpdate(
2017
2033
  const idx = output.content.indexOf(state.currentThinkingBlock!);
2018
2034
  stream.push({ type: "thinking_delta", contentIndex: idx, delta, partial: output });
2019
2035
  } else if (updateCase === "thinkingCompleted") {
2020
- if (state.currentThinkingBlock) {
2021
- const idx = output.content.indexOf(state.currentThinkingBlock);
2022
- delete (state.currentThinkingBlock as any).index;
2023
- stream.push({
2024
- type: "thinking_end",
2025
- contentIndex: idx,
2026
- content: state.currentThinkingBlock.thinking,
2027
- partial: output,
2028
- });
2029
- state.setThinkingBlock(null);
2030
- }
2036
+ endCurrentThinkingBlock(output, stream, state);
2031
2037
  } else if (updateCase === "toolCallStarted") {
2038
+ endCurrentTextBlock(output, stream, state);
2039
+ endCurrentThinkingBlock(output, stream, state);
2032
2040
  const toolCall = update.message.value.toolCall;
2033
2041
  if (toolCall) {
2034
2042
  const mcpCall = toolCall.mcpToolCall;
@@ -764,10 +764,13 @@ function normalizeOptionalNullsForSchema(
764
764
  if (!(key in nextValue)) continue;
765
765
  const currentValue = nextValue[key];
766
766
  const isNullish = currentValue === null || currentValue === "null";
767
+ const isInvalidEmptyString =
768
+ currentValue === "" && !required.has(key) && !branchMatchesSchema(propertySchema, currentValue);
767
769
 
768
- // Strip null and the string "null" from optional fields.
769
- // The LLM sometimes outputs string "null" to mean "no value".
770
- if (isNullish && !required.has(key)) {
770
+ // Strip null/string "null" from optional fields, and strip empty
771
+ // strings only when the property schema would reject the explicit value.
772
+ // LLMs sometimes output these placeholders to mean "no value".
773
+ if ((isNullish || isInvalidEmptyString) && !required.has(key)) {
771
774
  if (!changed) {
772
775
  nextValue = { ...nextValue };
773
776
  changed = true;
@@ -1281,7 +1284,8 @@ function truncateArgsForError(value: unknown): unknown {
1281
1284
  /**
1282
1285
  * Validates tool call arguments against the tool's schema (Zod or plain JSON
1283
1286
  * Schema). Applies LLM-quirk coercions (numeric strings, JSON-string
1284
- * containers, null-for-optional, null-for-default) before declaring failure.
1287
+ * containers, null/invalid-empty-string-for-optional, null-for-default) before
1288
+ * declaring failure.
1285
1289
  *
1286
1290
  * @throws Error with a formatted message when validation cannot be reconciled.
1287
1291
  */
@@ -1290,9 +1294,10 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
1290
1294
  const ctx = getValidationContext(tool);
1291
1295
  const { json } = ctx;
1292
1296
 
1293
- // Always normalize first — strip null and string "null" from optional
1294
- // fields and substitute defaults. Handles LLM outputting string "null"
1295
- // to mean "no value" even when validation would otherwise pass.
1297
+ // Always normalize first — strip null/string "null" from optional fields,
1298
+ // strip optional empty strings only when their property schema rejects the
1299
+ // explicit value, and substitute defaults. Handles LLM outputting
1300
+ // placeholders for "no value" even when validation would otherwise pass.
1296
1301
  let normalizedArgs: unknown = originalArgs;
1297
1302
  let changed = false;
1298
1303
  const initialNormalization = normalizeOptionalNullsForSchema(json, normalizedArgs);