@oh-my-pi/pi-ai 16.3.11 → 16.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/types/auth-broker/discover.d.ts +1 -1
  3. package/dist/types/auth-retry.d.ts +3 -1
  4. package/dist/types/auth-storage.d.ts +13 -8
  5. package/dist/types/providers/anthropic.d.ts +1 -0
  6. package/dist/types/providers/cursor.d.ts +5 -0
  7. package/dist/types/providers/openai-shared.d.ts +6 -3
  8. package/dist/types/providers/register-builtins.d.ts +5 -0
  9. package/dist/types/registry/oauth/callback-server.d.ts +2 -0
  10. package/dist/types/registry/oauth/xai-oauth.d.ts +1 -7
  11. package/dist/types/registry/registry.d.ts +2 -1
  12. package/dist/types/registry/xai-oauth.d.ts +2 -1
  13. package/dist/types/types.d.ts +2 -0
  14. package/dist/types/utils/event-stream.d.ts +7 -0
  15. package/dist/types/utils/idle-iterator.d.ts +10 -0
  16. package/package.json +4 -4
  17. package/src/auth-broker/discover.ts +20 -16
  18. package/src/auth-broker/remote-store.ts +55 -5
  19. package/src/auth-gateway/server.ts +1 -0
  20. package/src/auth-retry.ts +7 -2
  21. package/src/auth-storage.ts +210 -46
  22. package/src/providers/anthropic.ts +78 -8
  23. package/src/providers/cursor.ts +17 -10
  24. package/src/providers/openai-codex-responses.ts +37 -6
  25. package/src/providers/openai-responses.ts +1 -1
  26. package/src/providers/openai-shared.ts +30 -14
  27. package/src/providers/register-builtins.ts +15 -0
  28. package/src/registry/oauth/__tests__/xai-oauth.test.ts +55 -0
  29. package/src/registry/oauth/callback-server.ts +40 -12
  30. package/src/registry/oauth/xai-oauth.ts +6 -10
  31. package/src/registry/xai-oauth.ts +2 -1
  32. package/src/stream.ts +7 -1
  33. package/src/types.ts +2 -0
  34. package/src/usage/openai-codex.ts +3 -2
  35. package/src/utils/event-stream.ts +26 -0
  36. package/src/utils/idle-iterator.ts +65 -9
package/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.3.13] - 2026-07-09
6
+
7
+ ### Changed
8
+
9
+ - Changed the xAI Grok OAuth (`xai-oauth`) provider to use manual code-paste login by default. `/login` now accepts a pasted authorization code or full `http://127.0.0.1:56121/callback?code=...` redirect URL without starting a local callback listener ([#3277](https://github.com/can1357/oh-my-pi/pull/3277) by [@Jaaneek](https://github.com/Jaaneek)).
10
+ - Renamed the xAI Grok OAuth provider in login and credential prompts to "xAI Grok OAuth (SuperGrok or X Premium+)" ([#3277](https://github.com/can1357/oh-my-pi/pull/3277) by [@Jaaneek](https://github.com/Jaaneek)).
11
+
12
+ ### Fixed
13
+
14
+ - Fixed the generic lazy-stream idle watchdog aborting healthy `cursor-agent` streams with "Provider stream stalled while waiting for the next event" while a Cursor exec-channel local tool (shell/read/grep/write/MCP/…) legitimately ran longer than the idle budget. Provider streams now advertise consumer-side local work in flight and the watchdog slides its deadline instead of aborting; genuinely silent streams still time out. ([#4593](https://github.com/can1357/oh-my-pi/issues/4593))
15
+ - Fixed OpenAI Codex/Responses reasoning streams so streamed thinking content is preserved when the final `output_item.done` reconstructs to an empty summary ([#4918](https://github.com/can1357/oh-my-pi/issues/4918)).
16
+ - Fixed Anthropic streams hanging forever when generation wedges mid-stream (notably long `write` tool calls on Opus 4.8 high/xhigh) while the server keeps sending `ping` keepalives: pings now extend the idle watchdog only within a bounded window (3x the idle timeout) since the last real stream event, so a stalled tool-call stream times out and recovers instead of hanging with no retry path ([#4900](https://github.com/can1357/oh-my-pi/issues/4900)).
17
+
18
+ ## [16.3.12] - 2026-07-08
19
+
20
+ ### Added
21
+
22
+ - Added `AssistantMessage.toolCallAbortMessages` for per-tool placeholder labels on aborted assistant turns ([#2783](https://github.com/can1357/oh-my-pi/issues/2783)).
23
+
24
+ ### Fixed
25
+
26
+ - 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))
27
+ - Fixed access-token-only OAuth credentials attempting token refresh with an empty refresh token after expiry.
28
+ - Fixed gateway usage-limit retries falling through to cross-provider model fallback before trying a sibling credential from the same provider.
29
+ - Fixed Codex usage-limit rotation treating Plus and K-12 accounts as separate quota groups for shared 5-hour/7-day windows.
30
+ - Fixed OpenAI Responses streams that end with `response.done` being misclassified as premature stream closures.
31
+ - 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))
32
+ - 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)).
33
+ - 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)).
34
+ - 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.
35
+ - 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.
36
+
5
37
  ## [16.3.11] - 2026-07-06
6
38
 
7
39
  ### Fixed
@@ -19,7 +19,7 @@ export declare function getAuthBrokerTokenFilePath(): string;
19
19
  * Resolve broker connection configuration using the same precedence as the TUI:
20
20
  *
21
21
  * 1. `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN` env vars.
22
- * 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml`.
22
+ * 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml` or `<agentDir>/config.yaml`.
23
23
  * 3. `<config-root>/auth-broker.token` file (paired with a URL from env/config).
24
24
  *
25
25
  * Returns `null` when no broker URL is configured — callers should fall back to
@@ -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
  };
@@ -1,4 +1,7 @@
1
+ import http2 from "node:http2";
1
2
  import { type JsonValue } from "@bufbuild/protobuf";
3
+ import type { McpToolDefinition } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
4
+ import { type AgentServerMessage, type ConversationStateStructure } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
2
5
  import type { AssistantMessage, CursorExecHandlerResult, CursorExecHandlers, CursorToolResultHandler, Message, StreamFunction, StreamOptions, TextContent, ThinkingContent, ToolCall, ToolResultMessage } from "../types.js";
3
6
  import { kCursorExecResolved, kStreamingBlockIndex, kStreamingBlockKind, kStreamingLastParseLen, kStreamingPartialJson } from "../utils/block-symbols.js";
4
7
  import { AssistantMessageEventStream } from "../utils/event-stream.js";
@@ -39,6 +42,8 @@ export interface BlockState {
39
42
  export interface UsageState {
40
43
  sawTokenDelta: boolean;
41
44
  }
45
+ /** Exported for tests: drives one Cursor server message through the stream (exec waits mark the stream busy). */
46
+ export declare function handleServerMessage(msg: AgentServerMessage, output: AssistantMessage, stream: AssistantMessageEventStream, state: BlockState, blobStore: Map<string, Uint8Array>, h2Request: http2.ClientHttp2Stream, execHandlers: CursorExecHandlers | undefined, onToolResult: CursorToolResultHandler | undefined, usageState: UsageState, requestContextTools: McpToolDefinition[], onConversationCheckpoint?: (checkpoint: ConversationStateStructure) => void): Promise<void>;
42
47
  /** Exported for tests: verifies handler is invoked with correct `this` when passed as bound. */
43
48
  export declare function resolveExecHandler<TArgs, TResult>(args: TArgs, handler: ((args: TArgs) => Promise<CursorExecHandlerResult<TResult>>) | undefined, onToolResult: CursorToolResultHandler | undefined, buildFromToolResult: (toolResult: ToolResultMessage) => TResult, buildRejected: (reason: string) => TResult, buildError: (error: string) => TResult): Promise<{
44
49
  execResult: TResult;
@@ -382,6 +382,8 @@ type ResponsesToolCallBlock = ToolCall & {
382
382
  [kStreamingLastParseLen]?: number;
383
383
  };
384
384
  export declare function appendReasoningSummaryPart(item: ResponseReasoningItem, part: ResponseReasoningItem["summary"][number]): void;
385
+ /** Chooses the final reasoning text without discarding content already streamed into the block. */
386
+ export declare function finalizeReasoningThinking(item: ResponseReasoningItem, streamedThinking: string): string;
385
387
  export declare function appendReasoningSummaryTextDelta(item: ResponseReasoningItem, block: ThinkingContent, delta: string, stream: AssistantMessageEventStream, output: AssistantMessage, contentIndex: number): void;
386
388
  export declare function appendReasoningSummaryPartDone(item: ResponseReasoningItem, block: ThinkingContent, stream: AssistantMessageEventStream, output: AssistantMessage, contentIndex: number): void;
387
389
  export declare function appendMessageContentPart(item: ResponseOutputMessage, part: ResponseContentPartAddedEvent["part"] | undefined): void;
@@ -400,9 +402,10 @@ export interface ProcessResponsesStreamOptions {
400
402
  onFirstToken?: () => void;
401
403
  onOutputItemDone?: (item: ResponseOutputItem) => void;
402
404
  /**
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.
405
+ * Called when a terminal `response.completed`, `response.incomplete`, or
406
+ * `response.done` event is successfully processed. Only invoked on the
407
+ * successful-completion path; thrown failure (`response.failed`) and
408
+ * cancellation paths never call this.
406
409
  * Used by callers to detect premature stream closure (i.e. the stream ended
407
410
  * without a recognized terminal event).
408
411
  */
@@ -13,10 +13,15 @@
13
13
  import type { AssistantMessageEventStream, Context, Model, OptionsForApi } from "../types.js";
14
14
  import { AssistantMessageEventStream as EventStreamImpl } from "../utils/event-stream.js";
15
15
  import type { BedrockOptions } from "./amazon-bedrock.js";
16
+ import type { CursorOptions } from "./cursor.js";
17
+ interface CursorProviderModule {
18
+ streamCursor: (model: Model<"cursor-agent">, context: Context, options: CursorOptions) => AssistantMessageEventStream;
19
+ }
16
20
  interface BedrockProviderModule {
17
21
  streamBedrock: (model: Model<"bedrock-converse-stream">, context: Context, options: BedrockOptions) => AssistantMessageEventStream;
18
22
  }
19
23
  export declare function setBedrockProviderModule(module: BedrockProviderModule): void;
24
+ export declare function setCursorProviderModule(module: CursorProviderModule): void;
20
25
  export declare const streamAnthropic: (model: Model<"anthropic-messages">, context: Context, options: OptionsForApi<"anthropic-messages">) => EventStreamImpl;
21
26
  export declare const streamAzureOpenAIResponses: (model: Model<"azure-openai-responses">, context: Context, options: OptionsForApi<"azure-openai-responses">) => EventStreamImpl;
22
27
  export declare const streamGoogle: (model: Model<"google-generative-ai">, context: Context, options: OptionsForApi<"google-generative-ai">) => EventStreamImpl;
@@ -23,6 +23,8 @@ export interface OAuthCallbackFlowOptions {
23
23
  * an actionable message before opening the browser.
24
24
  */
25
25
  allowPortFallback?: boolean;
26
+ /** Skip the local callback server entirely; the user pastes the code or redirect URL back. */
27
+ manualInputOnly?: boolean;
26
28
  }
27
29
  /**
28
30
  * Abstract base class for OAuth flows with local callback servers.
@@ -24,10 +24,7 @@ export declare function validateXAIEndpoint(url: string, field: string): string;
24
24
  */
25
25
  export declare function isXAIAccessTokenExpiring(jwt: string, skewSeconds?: number): boolean;
26
26
  /**
27
- * xAI Grok OAuth loopback flow (Hermes `_xai_oauth_loopback_login` L5315-5469).
28
- *
29
- * Uses a fixed redirect URI so the callback server fails fast instead of
30
- * falling back to a random port that xAI's redirect_uri allowlist rejects.
27
+ * xAI Grok OAuth code flow (Hermes `_xai_oauth_loopback_login` L5315-5469).
31
28
  */
32
29
  export declare class XAIOAuthFlow extends OAuthCallbackFlow {
33
30
  #private;
@@ -38,9 +35,6 @@ export declare class XAIOAuthFlow extends OAuthCallbackFlow {
38
35
  }>;
39
36
  exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials>;
40
37
  }
41
- /**
42
- * Login with xAI Grok OAuth (SuperGrok Subscription).
43
- */
44
38
  export declare function loginXAIOAuth(ctrl: OAuthController): Promise<OAuthCredentials>;
45
39
  /**
46
40
  * Refresh an xAI OAuth access token using a stored refresh_token.
@@ -265,9 +265,10 @@ declare const ALL: ({
265
265
  readonly name: "xAI";
266
266
  } | {
267
267
  readonly id: "xai-oauth";
268
- readonly name: "xAI Grok OAuth (SuperGrok Subscription)";
268
+ readonly name: "xAI Grok OAuth (SuperGrok or X Premium+)";
269
269
  readonly login: (cb: import("./oauth/index.js").OAuthLoginCallbacks) => Promise<import("./oauth/index.js").OAuthCredentials>;
270
270
  readonly refreshToken: (credentials: import("./oauth/index.js").OAuthCredentials) => Promise<import("./oauth/index.js").OAuthCredentials>;
271
+ readonly pasteCodeFlow: true;
271
272
  } | {
272
273
  readonly id: "xiaomi";
273
274
  readonly name: "Xiaomi MiMo";
@@ -1,7 +1,8 @@
1
1
  import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types.js";
2
2
  export declare const xaiOauthProvider: {
3
3
  readonly id: "xai-oauth";
4
- readonly name: "xAI Grok OAuth (SuperGrok Subscription)";
4
+ readonly name: "xAI Grok OAuth (SuperGrok or X Premium+)";
5
5
  readonly login: (cb: OAuthLoginCallbacks) => Promise<OAuthCredentials>;
6
6
  readonly refreshToken: (credentials: OAuthCredentials) => Promise<OAuthCredentials>;
7
+ readonly pasteCodeFlow: true;
7
8
  };
@@ -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. */
@@ -22,6 +22,13 @@ export declare class EventStream<T, R = T> implements AsyncIterable<T> {
22
22
  fail(err: unknown): void;
23
23
  [Symbol.asyncIterator](): AsyncIterator<T>;
24
24
  result(): Promise<R>;
25
+ /** True while local work tracked via {@link trackLocalWork} is pending. */
26
+ get hasPendingLocalWork(): boolean;
27
+ /**
28
+ * Track a local-work promise so idle watchdogs on this stream do not treat
29
+ * the event silence while it is pending as a provider stall.
30
+ */
31
+ trackLocalWork<TWork>(work: Promise<TWork>): Promise<TWork>;
25
32
  }
26
33
  export declare class AssistantMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
27
34
  constructor();
@@ -83,6 +83,16 @@ export interface IdleTimeoutIteratorOptions {
83
83
  * keepalive/no-op events from keeping a stalled tool call alive forever.
84
84
  */
85
85
  isProgressItem?: (item: unknown) => boolean;
86
+ /**
87
+ * Reports consumer-side local work in flight for the stream: the provider
88
+ * transport is waiting on a server-requested local tool bridge (e.g. the
89
+ * Cursor exec channel) before anything can flow upstream again. While it
90
+ * returns true, an expired idle / first-item deadline slides forward
91
+ * instead of aborting — the silence is ours, not a provider stall. The
92
+ * watchdog re-arms with a full budget once the local work completes, so a
93
+ * provider that stalls afterwards is still caught.
94
+ */
95
+ hasPendingLocalWork?: () => boolean;
86
96
  /**
87
97
  * Cancel iteration as soon as this signal aborts. Required for caller-driven
88
98
  * cancellation (ESC) when the underlying transport does not surface signal
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.11",
4
+ "version": "16.3.13",
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.11",
42
- "@oh-my-pi/pi-utils": "16.3.11",
43
- "@oh-my-pi/pi-wire": "16.3.11",
41
+ "@oh-my-pi/pi-catalog": "16.3.13",
42
+ "@oh-my-pi/pi-utils": "16.3.13",
43
+ "@oh-my-pi/pi-wire": "16.3.13",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -1,6 +1,6 @@
1
1
  /**
2
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 →
3
+ * the catalog model generator. Keeps the precedence logic (env → config.yml/config.yaml
4
4
  * token file → local SQLite) in one place so build-time tooling sees the same
5
5
  * credentials as the TUI.
6
6
  */
@@ -12,6 +12,7 @@ import {
12
12
  getConfigRootDir,
13
13
  isEnoent,
14
14
  logger,
15
+ MAIN_CONFIG_FILENAMES,
15
16
  } from "@oh-my-pi/pi-utils";
16
17
  import { YAML } from "bun";
17
18
  import { AuthStorage } from "../auth-storage";
@@ -72,21 +73,24 @@ interface ConfigSnapshot {
72
73
  }
73
74
 
74
75
  async function readConfigYaml(agentDir: string): Promise<ConfigSnapshot> {
75
- const configPath = path.join(agentDir, "config.yml");
76
- try {
77
- const raw = await Bun.file(configPath).text();
78
- const parsed = YAML.parse(raw);
79
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
80
- const record = parsed as Record<string, unknown>;
81
- const url = typeof record["auth.broker.url"] === "string" ? (record["auth.broker.url"] as string) : undefined;
82
- const token =
83
- typeof record["auth.broker.token"] === "string" ? (record["auth.broker.token"] as string) : undefined;
84
- return { url, token };
85
- } catch (err) {
86
- if (isEnoent(err)) return {};
87
- logger.warn("auth-broker config.yml unreadable", { error: String(err) });
88
- return {};
76
+ for (const filename of MAIN_CONFIG_FILENAMES) {
77
+ const configPath = path.join(agentDir, filename);
78
+ try {
79
+ const raw = await Bun.file(configPath).text();
80
+ const parsed = YAML.parse(raw);
81
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
82
+ const record = parsed as Record<string, unknown>;
83
+ const url = typeof record["auth.broker.url"] === "string" ? (record["auth.broker.url"] as string) : undefined;
84
+ const token =
85
+ typeof record["auth.broker.token"] === "string" ? (record["auth.broker.token"] as string) : undefined;
86
+ return { url, token };
87
+ } catch (err) {
88
+ if (isEnoent(err)) continue;
89
+ logger.warn("auth-broker config unreadable", { path: configPath, error: String(err) });
90
+ return {};
91
+ }
89
92
  }
93
+ return {};
90
94
  }
91
95
 
92
96
  function resolveSnapshotTtlMs(): number {
@@ -104,7 +108,7 @@ function resolveSnapshotTtlMs(): number {
104
108
  * Resolve broker connection configuration using the same precedence as the TUI:
105
109
  *
106
110
  * 1. `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN` env vars.
107
- * 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml`.
111
+ * 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml` or `<agentDir>/config.yaml`.
108
112
  * 3. `<config-root>/auth-broker.token` file (paired with a URL from env/config).
109
113
  *
110
114
  * Returns `null` when no broker URL is configured — callers should fall back to
@@ -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 {