@oh-my-pi/pi-ai 16.3.10 → 16.3.12

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,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.3.12] - 2026-07-08
6
+
7
+ ### Added
8
+
9
+ - Added `AssistantMessage.toolCallAbortMessages` for per-tool placeholder labels on aborted assistant turns ([#2783](https://github.com/can1357/oh-my-pi/issues/2783)).
10
+
11
+ ### Fixed
12
+
13
+ - Fixed Anthropic replay 400s (`tool_use ids were found without tool_result blocks immediately after`) when a persisted assistant turn carries content after a completed tool call — such as a mid-turn `server-side-fallback` handoff (fallback block plus continued text/tool calls after the primary model's `tool_use`) or trailing text from cross-provider replays — by stable-partitioning assistant content so all `tool_use` blocks trail the non-`tool_use` chain. ([#4781](https://github.com/can1357/oh-my-pi/issues/4781), [#544](https://github.com/can1357/oh-my-pi/issues/544))
14
+ - Fixed access-token-only OAuth credentials attempting token refresh with an empty refresh token after expiry.
15
+ - Fixed gateway usage-limit retries falling through to cross-provider model fallback before trying a sibling credential from the same provider.
16
+ - Fixed Codex usage-limit rotation treating Plus and K-12 accounts as separate quota groups for shared 5-hour/7-day windows.
17
+ - Fixed OpenAI Responses streams that end with `response.done` being misclassified as premature stream closures.
18
+ - Fixed OpenCode Go `/login` credentials being shadowed by an existing `OPENCODE_API_KEY` env fallback after switching accounts. ([#4688](https://github.com/can1357/oh-my-pi/issues/4688))
19
+ - Fixed OpenAI Codex WebSocket continuations to treat proxy stale-anchor codes such as `codex_previous_response_stale` as an expired `previous_response_id` chain — same recovery class as the OpenAI-standard `previous_response_not_found` — so the turn is retried with full context instead of surfacing the error to the user ([#4624](https://github.com/can1357/oh-my-pi/issues/4624)).
20
+ - Fixed Azure Foundry Anthropic utility requests to omit the structured-output beta whenever strict tools are disabled, preventing `structured_outputs not supported in your workspace` failures for Sonnet 5 compaction ([#4679](https://github.com/can1357/oh-my-pi/issues/4679)).
21
+ - Fixed OAuth `launchUrl` advertisement for flows whose redirect never returns to the local callback server: custom-scheme redirects (e.g. GitLab Duo's `vscode://` URI, which `new URL` parses without complaint) and fixed non-loopback hosts no longer receive a `http://localhost:<port>/launch` copy target that misrepresents the callback endpoint and resolves nowhere for remote users.
22
+ - Codex load balancing: clear stale persisted and in-memory usage-limit blocks for an `openai-codex` account when a fresh live usage report shows it is allowed and below all limits, including broker-backed gateway snapshots, so traffic returns to recovered accounts instead of funneling to one sibling.
23
+
24
+ ## [16.3.11] - 2026-07-06
25
+
26
+ ### Fixed
27
+
28
+ - Fixed `openai-codex-responses` fresh plan execution requests that contained only system/developer guidance by mirroring the final instruction as user input so Codex accepts the first turn. ([#4714](https://github.com/can1357/oh-my-pi/issues/4714))
29
+ - Fixed Codex WebSocket compact/resume delta diagnostics to record request shape and raw-vs-displayed usage buckets, so persistent server-reported uncached suffixes without `orchestration_*` fields are visible in debug stats. ([#4707](https://github.com/can1357/oh-my-pi/issues/4707))
30
+
5
31
  ## [16.3.10] - 2026-07-06
6
32
 
7
33
  ### Fixed
@@ -21,6 +21,8 @@ export interface ApiKeyResolveContext {
21
21
  lastChance: boolean;
22
22
  /** The auth error that triggered this re-resolution, or `undefined` on the initial resolve. */
23
23
  error: unknown;
24
+ /** Bearer used by the failed attempt, when the caller can expose it. */
25
+ previousKey?: string;
24
26
  /** Caller cancel signal, threaded into any credential refresh / rotation work. */
25
27
  signal?: AbortSignal;
26
28
  }
@@ -56,7 +58,7 @@ export { isAuthRetryableError };
56
58
  */
57
59
  export declare const AUTH_RETRY_STEPS: readonly boolean[];
58
60
  /** Resolve a single retry step, swallowing resolver failures into `undefined`. */
59
- export declare function resolveRetryKey(resolver: ApiKeyResolver, lastChance: boolean, error: unknown, signal?: AbortSignal): Promise<string | undefined>;
61
+ export declare function resolveRetryKey(resolver: ApiKeyResolver, lastChance: boolean, error: unknown, signal?: AbortSignal, previousKey?: string): Promise<string | undefined>;
60
62
  /**
61
63
  * Runs an auth-protected operation through the central a/b/c retry policy.
62
64
  *
@@ -16,6 +16,7 @@ import { type CodexResetConsumeCode, type CodexResetCredit } from "./usage/opena
16
16
  export type ApiKeyCredential = {
17
17
  type: "api_key";
18
18
  key: string;
19
+ source?: "login";
19
20
  };
20
21
  export type OAuthCredential = {
21
22
  type: "oauth";
@@ -678,8 +679,8 @@ export declare class AuthStorage {
678
679
  /**
679
680
  * Classify where a provider's auth comes from, following the same precedence
680
681
  * as {@link AuthStorage.getApiKey}: runtime override → config override →
681
- * stored OAuth → env var → stored api_key → fallback resolver. Returns
682
- * undefined when no auth is configured.
682
+ * stored OAuth → login-stored api_key → env var → stored api_key →
683
+ * fallback resolver. Returns undefined when no auth is configured.
683
684
  *
684
685
  * Compact, structured counterpart to {@link describeCredentialSource}.
685
686
  */
@@ -792,6 +793,7 @@ export declare class AuthStorage {
792
793
  retryAfterMs?: number;
793
794
  baseUrl?: string;
794
795
  modelId?: string;
796
+ apiKey?: string;
795
797
  signal?: AbortSignal;
796
798
  }): Promise<UsageLimitMarkResult>;
797
799
  /**
@@ -807,9 +809,10 @@ export declare class AuthStorage {
807
809
  * 1. Runtime override (CLI --api-key)
808
810
  * 2. Config override (models.yml `providers.<name>.apiKey`)
809
811
  * 3. OAuth token from storage (auto-refreshed)
810
- * 4. Environment variable
811
- * 5. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
812
- * 6. Fallback resolver (models.yml custom providers, last-resort)
812
+ * 4. API key persisted by a successful `/login`
813
+ * 5. Environment variable
814
+ * 6. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
815
+ * 7. Fallback resolver (models.yml custom providers, last-resort)
813
816
  */
814
817
  getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined>;
815
818
  /**
@@ -908,6 +911,7 @@ export declare class AuthStorage {
908
911
  rotateSessionCredential(provider: string, sessionId: string | undefined, options?: {
909
912
  error?: unknown;
910
913
  modelId?: string;
914
+ apiKey?: string;
911
915
  signal?: AbortSignal;
912
916
  }): Promise<boolean>;
913
917
  /**
@@ -986,9 +990,10 @@ export declare class AuthStorage {
986
990
  * 1. Runtime override (`--api-key`).
987
991
  * 2. Config override (`models.yml` `providers.<name>.apiKey`).
988
992
  * 3. Stored OAuth credential.
989
- * 4. Env var overrides a stored static api_key (e.g. a stale broker copy).
990
- * 5. Stored api_key credential.
991
- * 6. Fallback resolver.
993
+ * 4. API key persisted by a successful `/login`.
994
+ * 5. Env var — overrides a stored static api_key (e.g. a stale broker copy).
995
+ * 6. Stored api_key credential.
996
+ * 7. Fallback resolver.
992
997
  *
993
998
  * The string is purely informational; consumers must not parse it.
994
999
  */
@@ -157,6 +157,7 @@ export type AnthropicClientOptionsArgs = {
157
157
  hasTools?: boolean;
158
158
  thinkingEnabled?: boolean;
159
159
  thinkingDisplay?: AnthropicThinkingDisplay;
160
+ disableStrictTools?: boolean;
160
161
  fetch?: FetchImpl;
161
162
  claudeCodeSessionId?: string;
162
163
  };
@@ -35,10 +35,46 @@ export interface OpenAICodexResponsesOptions extends StreamOptions {
35
35
  onModerationMetadata?: (metadata: unknown) => void;
36
36
  }
37
37
  type CodexTransport = "sse" | "websocket";
38
+ /** Shape of the Codex request sent on the latest provider turn. */
39
+ export interface OpenAICodexTurnRequestDiagnostics {
40
+ transport: "sse" | "websocket";
41
+ previousResponseIdPresent: boolean;
42
+ inputItemCount: number;
43
+ inputItemTypes: string[];
44
+ firstInputItemType?: string;
45
+ inputJsonBytes: number;
46
+ promptCacheKey?: string;
47
+ toolsHash?: string;
48
+ optionsHash: string;
49
+ canAppendBeforeRequest: boolean;
50
+ }
51
+ /** Raw provider usage plus the normalized buckets OMP displays for the latest Codex turn. */
52
+ export interface OpenAICodexTurnUsageDiagnostics {
53
+ rawInputTokens: number;
54
+ rawCachedTokens: number;
55
+ rawUncachedTokens: number;
56
+ rawOutputTokens: number;
57
+ rawTotalTokens?: number;
58
+ rawOrchestrationInputTokens?: number;
59
+ rawOrchestrationCachedTokens?: number;
60
+ rawOrchestrationOutputTokens?: number;
61
+ displayedInputTokens: number;
62
+ displayedOutputTokens: number;
63
+ displayedCacheReadTokens: number;
64
+ displayedCacheWriteTokens: number;
65
+ displayedTotalTokens: number;
66
+ displayedOrchestrationInputTokens: number;
67
+ displayedOrchestrationCacheReadTokens: number;
68
+ displayedOrchestrationOutputTokens: number;
69
+ }
70
+ /** Latest Codex turn request/usage diagnostics exposed to debug UIs and tests. */
71
+ export interface OpenAICodexTurnDiagnostics {
72
+ request: OpenAICodexTurnRequestDiagnostics;
73
+ usage?: OpenAICodexTurnUsageDiagnostics;
74
+ }
38
75
  /**
39
- * Per-session request-shape counters. Despite the name, these cover both
40
- * transports: once stateful SSE chaining is enabled, SSE requests are counted
41
- * too (the shared chained-request builder records every request it shapes).
76
+ * Per-session request-shape counters and latest turn diagnostics. Despite the
77
+ * name, these cover both transports.
42
78
  */
43
79
  export interface OpenAICodexWebSocketDebugStats {
44
80
  fullContextRequests: number;
@@ -46,6 +82,7 @@ export interface OpenAICodexWebSocketDebugStats {
46
82
  lastInputItems: number;
47
83
  lastDeltaInputItems?: number;
48
84
  lastPreviousResponseId?: string;
85
+ lastTurn?: OpenAICodexTurnDiagnostics;
49
86
  }
50
87
  /** @internal Exported for tests. */
51
88
  export declare function normalizeCodexToolChoice(choice: ToolChoice | undefined, tools?: Tool[], model?: Model<"openai-codex-responses">): string | Record<string, unknown> | undefined;
@@ -400,9 +400,10 @@ export interface ProcessResponsesStreamOptions {
400
400
  onFirstToken?: () => void;
401
401
  onOutputItemDone?: (item: ResponseOutputItem) => void;
402
402
  /**
403
- * Called when a terminal `response.completed` or `response.incomplete` event
404
- * is successfully processed. Only invoked on the successful-completion path;
405
- * thrown failure (`response.failed`) and cancellation paths never call this.
403
+ * Called when a terminal `response.completed`, `response.incomplete`, or
404
+ * `response.done` event is successfully processed. Only invoked on the
405
+ * successful-completion path; thrown failure (`response.failed`) and
406
+ * cancellation paths never call this.
406
407
  * Used by callers to detect premature stream closure (i.e. the stream ended
407
408
  * without a recognized terminal event).
408
409
  */
@@ -537,6 +537,8 @@ export interface AssistantMessage {
537
537
  stopReason: StopReason;
538
538
  stopDetails?: StopDetails | null;
539
539
  errorMessage?: string;
540
+ /** Per-tool abort messages used when an aborted assistant turn needs different placeholder results per tool call. */
541
+ toolCallAbortMessages?: Record<string, string>;
540
542
  /** HTTP status surfaced by the provider when the request failed. Populated by every provider's catch block alongside `errorMessage` so consumers (auth retry, telemetry, UI) can branch without regex-scraping the message. */
541
543
  errorStatus?: number;
542
544
  /** Structured machine-readable error classifier; see `utils/error-id.ts` for bit layout and helpers. */
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.3.10",
4
+ "version": "16.3.12",
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.3.10",
42
- "@oh-my-pi/pi-utils": "16.3.10",
43
- "@oh-my-pi/pi-wire": "16.3.10",
41
+ "@oh-my-pi/pi-catalog": "16.3.12",
42
+ "@oh-my-pi/pi-utils": "16.3.12",
43
+ "@oh-my-pi/pi-wire": "16.3.12",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -61,6 +61,41 @@ function toCredentialBlockSnapshot(block: StoredCredentialBlock): CredentialBloc
61
61
  };
62
62
  }
63
63
 
64
+ function credentialBlockSnapshotsEqual(
65
+ left: readonly CredentialBlockSnapshot[] | undefined,
66
+ right: readonly CredentialBlockSnapshot[] | undefined,
67
+ ): boolean {
68
+ const leftBlocks = left ?? [];
69
+ const rightBlocks = right ?? [];
70
+ if (leftBlocks.length !== rightBlocks.length) return false;
71
+ for (let index = 0; index < leftBlocks.length; index += 1) {
72
+ const leftBlock = leftBlocks[index]!;
73
+ const rightBlock = rightBlocks[index]!;
74
+ if (
75
+ leftBlock.providerKey !== rightBlock.providerKey ||
76
+ leftBlock.blockScope !== rightBlock.blockScope ||
77
+ leftBlock.blockedUntilMs !== rightBlock.blockedUntilMs
78
+ ) {
79
+ return false;
80
+ }
81
+ }
82
+ return true;
83
+ }
84
+
85
+ function snapshotBlocksChanged(previous: readonly SnapshotEntry[], next: readonly SnapshotEntry[]): boolean {
86
+ const previousBlocksById = new Map<number, readonly CredentialBlockSnapshot[] | undefined>();
87
+ for (const entry of previous) previousBlocksById.set(entry.id, entry.blocks);
88
+ for (const entry of next) {
89
+ const previousBlocks = previousBlocksById.get(entry.id);
90
+ if (!credentialBlockSnapshotsEqual(previousBlocks, entry.blocks)) return true;
91
+ previousBlocksById.delete(entry.id);
92
+ }
93
+ for (const previousBlocks of previousBlocksById.values()) {
94
+ if (previousBlocks && previousBlocks.length > 0) return true;
95
+ }
96
+ return false;
97
+ }
98
+
64
99
  function credentialEntryWithBlocks(
65
100
  entry: AuthCredentialSnapshotEntry,
66
101
  blocks: readonly CredentialBlockSnapshot[] | undefined,
@@ -173,6 +208,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
173
208
  #cache: Map<string, CacheEntry> = new Map();
174
209
  #usageCache?: UsageCacheEntry;
175
210
  #usageInflight?: Promise<UsageReport[] | null>;
211
+ #usageCacheEpoch = 0;
176
212
  #closed = false;
177
213
  /**
178
214
  * `true` once the SSE consumer received its first frame and hasn't dropped
@@ -202,10 +238,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
202
238
 
203
239
  #applySnapshot(snapshot: SnapshotResponse, generation: number): void {
204
240
  const nowMs = Date.now();
205
- this.#snapshot = {
206
- ...snapshot,
207
- credentials: snapshot.credentials.map(entry => this.#normalizeSnapshotEntryBlocks(entry, nowMs)),
208
- };
241
+ const credentials = snapshot.credentials.map(entry => this.#normalizeSnapshotEntryBlocks(entry, nowMs));
242
+ if (snapshotBlocksChanged(this.#snapshot.credentials, credentials)) this.#invalidateUsageCache();
243
+ this.#snapshot = { ...snapshot, credentials };
209
244
  this.#generation = generation;
210
245
  this.#snapshotReceivedAt = nowMs;
211
246
  const onSnapshot = this.#onSnapshot;
@@ -304,6 +339,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
304
339
  ): void {
305
340
  const incoming = this.#normalizeSnapshotEntryBlocks(entry, Date.now());
306
341
  const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === incoming.id);
342
+ const previousBlocks = index === -1 ? undefined : this.#snapshot.credentials[index]?.blocks;
343
+ if (!credentialBlockSnapshotsEqual(previousBlocks, incoming.blocks)) this.#invalidateUsageCache();
307
344
  const credentials =
308
345
  index === -1
309
346
  ? [...this.#snapshot.credentials, incoming]
@@ -314,6 +351,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
314
351
  }
315
352
 
316
353
  #removeStreamCredential(id: number, refresher: RefresherSchedule, generation: number, serverNowMs: number): void {
354
+ const removed = this.#snapshot.credentials.find(entry => entry.id === id);
355
+ if (removed?.blocks && removed.blocks.length > 0) this.#invalidateUsageCache();
317
356
  const credentials = this.#snapshot.credentials.filter(entry => entry.id !== id);
318
357
  this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
319
358
  this.#generation = generation;
@@ -376,6 +415,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
376
415
 
377
416
  upsertCredentialBlock(block: StoredCredentialBlock): void {
378
417
  this.#upsertSnapshotBlock(block);
418
+ this.#invalidateUsageCache();
379
419
  const body = toCredentialBlockSnapshot(block);
380
420
  void this.#client
381
421
  .upsertCredentialBlock(block.credentialId, body)
@@ -394,6 +434,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
394
434
 
395
435
  deleteCredentialBlocks(credentialId: number): void {
396
436
  this.#deleteSnapshotBlocks(credentialId);
437
+ this.#invalidateUsageCache();
397
438
  void this.#client
398
439
  .deleteCredentialBlocks(credentialId)
399
440
  .then(() => {
@@ -698,6 +739,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
698
739
  }
699
740
  }
700
741
 
742
+ #invalidateUsageCache(): void {
743
+ this.#usageCache = undefined;
744
+ this.#usageInflight = undefined;
745
+ this.#usageCacheEpoch += 1;
746
+ }
747
+
701
748
  /**
702
749
  * Store-level hook consumed by `AuthStorage` — routes refresh through the
703
750
  * broker so the actual refresh token never leaves the broker host. Returns
@@ -834,9 +881,11 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
834
881
  return Promise.resolve(cached.reports);
835
882
  }
836
883
  if (this.#usageInflight) return this.#usageInflight;
884
+ const epoch = this.#usageCacheEpoch;
837
885
  const inflight = this.#client
838
886
  .fetchUsage()
839
887
  .then(body => {
888
+ if (epoch !== this.#usageCacheEpoch) return this.#loadUsageReports();
840
889
  this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
841
890
  return body.reports;
842
891
  })
@@ -845,11 +894,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
845
894
  // Documented 15s TTL fallback: cache the null so sequential callers
846
895
  // don't re-hit the broker while it's still down. See
847
896
  // docs/auth-broker-gateway.md § "Client-side single-flight".
897
+ if (epoch !== this.#usageCacheEpoch) return this.#loadUsageReports();
848
898
  this.#usageCache = { reports: null, fetchedAt: Date.now() };
849
899
  return null;
850
900
  })
851
901
  .finally(() => {
852
- this.#usageInflight = undefined;
902
+ if (this.#usageInflight === inflight) this.#usageInflight = undefined;
853
903
  });
854
904
  this.#usageInflight = inflight;
855
905
  return inflight;
@@ -233,6 +233,7 @@ async function refreshGatewayApiKeyAfterAuthError(
233
233
  retryAfterMs,
234
234
  baseUrl: model.baseUrl,
235
235
  modelId: model.id,
236
+ apiKey: oldKey,
236
237
  signal,
237
238
  });
238
239
  logger.debug("auth-gateway retrying provider request after usage-limit block", {
package/src/auth-retry.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { OAuthAccess } from "./auth-storage";
2
2
  import * as AIError from "./error";
3
3
  import { isAuthRetryableError } from "./error/auth-classify";
4
+ import { isUsageLimit } from "./error/flags";
4
5
 
5
6
  /**
6
7
  * Context passed to an {@link ApiKeyResolver} on each resolution attempt.
@@ -23,6 +24,8 @@ export interface ApiKeyResolveContext {
23
24
  lastChance: boolean;
24
25
  /** The auth error that triggered this re-resolution, or `undefined` on the initial resolve. */
25
26
  error: unknown;
27
+ /** Bearer used by the failed attempt, when the caller can expose it. */
28
+ previousKey?: string;
26
29
  /** Caller cancel signal, threaded into any credential refresh / rotation work. */
27
30
  signal?: AbortSignal;
28
31
  }
@@ -87,9 +90,11 @@ export async function resolveRetryKey(
87
90
  lastChance: boolean,
88
91
  error: unknown,
89
92
  signal?: AbortSignal,
93
+ previousKey?: string,
90
94
  ): Promise<string | undefined> {
91
95
  try {
92
- return (await resolver({ lastChance, error, signal })) || undefined;
96
+ const rotateSibling = lastChance || (!lastChance && isUsageLimit(error));
97
+ return (await resolver({ lastChance: rotateSibling, error, signal, previousKey })) || undefined;
93
98
  } catch {
94
99
  return undefined;
95
100
  }
@@ -136,7 +141,7 @@ export async function withAuth<T>(
136
141
  }
137
142
 
138
143
  for (let i = 0; i < AUTH_RETRY_STEPS.length; i++) {
139
- const nextKey = await resolveRetryKey(resolver, AUTH_RETRY_STEPS[i]!, lastError, signal);
144
+ const nextKey = await resolveRetryKey(resolver, AUTH_RETRY_STEPS[i]!, lastError, signal, lastKey);
140
145
  if (nextKey === undefined || nextKey === lastKey) continue;
141
146
  lastKey = nextKey;
142
147
  try {