@oh-my-pi/pi-ai 17.1.8 → 17.2.0

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 (59) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/types/auth-broker/server.d.ts +5 -0
  3. package/dist/types/auth-broker/types.d.ts +4 -0
  4. package/dist/types/auth-storage.d.ts +26 -1
  5. package/dist/types/error/classes.d.ts +5 -1
  6. package/dist/types/error/rate-limit.d.ts +5 -4
  7. package/dist/types/providers/anthropic-client.d.ts +13 -1
  8. package/dist/types/providers/anthropic.d.ts +9 -1
  9. package/dist/types/providers/cursor/exec-modern.d.ts +98 -0
  10. package/dist/types/providers/cursor-pi-args.d.ts +99 -0
  11. package/dist/types/providers/cursor.d.ts +53 -3
  12. package/dist/types/providers/error-message.d.ts +2 -4
  13. package/dist/types/providers/openai-codex-responses.d.ts +10 -0
  14. package/dist/types/providers/openai-responses-wire.d.ts +8 -0
  15. package/dist/types/providers/openai-shared.d.ts +13 -1
  16. package/dist/types/registry/exa.d.ts +8 -0
  17. package/dist/types/registry/registry.d.ts +7 -1
  18. package/dist/types/registry/xai.d.ts +4 -1
  19. package/dist/types/stream.d.ts +2 -0
  20. package/dist/types/types.d.ts +101 -1
  21. package/dist/types/usage/umans.d.ts +2 -0
  22. package/dist/types/utils/block-symbols.d.ts +11 -0
  23. package/dist/types/utils/harmony-leak.d.ts +15 -7
  24. package/dist/types/utils/schema/normalize.d.ts +1 -0
  25. package/package.json +4 -4
  26. package/src/auth-broker/client.ts +5 -1
  27. package/src/auth-broker/server.ts +103 -9
  28. package/src/auth-broker/snapshot-cache.ts +12 -3
  29. package/src/auth-broker/types.ts +6 -0
  30. package/src/auth-storage.ts +439 -28
  31. package/src/error/classes.ts +98 -3
  32. package/src/error/flags.ts +6 -1
  33. package/src/error/rate-limit.ts +18 -12
  34. package/src/providers/amazon-bedrock.ts +3 -3
  35. package/src/providers/anthropic-client.ts +24 -2
  36. package/src/providers/anthropic.ts +31 -6
  37. package/src/providers/cursor/exec-modern.ts +495 -0
  38. package/src/providers/cursor/proto/agent.proto +1007 -0
  39. package/src/providers/cursor-pi-args.ts +135 -0
  40. package/src/providers/cursor.ts +1138 -66
  41. package/src/providers/devin.ts +2 -1
  42. package/src/providers/error-message.ts +3 -1
  43. package/src/providers/openai-codex-responses.ts +106 -26
  44. package/src/providers/openai-completions.ts +19 -4
  45. package/src/providers/openai-responses-wire.ts +8 -0
  46. package/src/providers/openai-responses.ts +12 -1
  47. package/src/providers/openai-shared.ts +75 -12
  48. package/src/registry/api-key-validation.ts +18 -34
  49. package/src/registry/exa.ts +19 -0
  50. package/src/registry/novita.ts +6 -3
  51. package/src/registry/registry.ts +3 -1
  52. package/src/registry/xai.ts +17 -1
  53. package/src/stream.ts +1 -2
  54. package/src/types.ts +115 -0
  55. package/src/usage/umans.ts +192 -0
  56. package/src/utils/block-symbols.ts +12 -0
  57. package/src/utils/harmony-leak.ts +32 -0
  58. package/src/utils/idle-iterator.ts +2 -2
  59. package/src/utils/schema/normalize.ts +140 -38
@@ -0,0 +1,8 @@
1
+ import type { OAuthLoginCallbacks } from "./oauth/types.js";
2
+ export declare const loginExa: (options: import("./oauth/index.js").OAuthController) => Promise<string>;
3
+ export declare const exaProvider: {
4
+ readonly id: "exa";
5
+ readonly name: "Exa";
6
+ readonly envKeys: "EXA_API_KEY";
7
+ readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
8
+ };
@@ -64,6 +64,11 @@ declare const ALL: ({
64
64
  readonly login: (cb: import("./oauth/index.js").OAuthLoginCallbacks) => Promise<string>;
65
65
  readonly callbackPort: 59653;
66
66
  readonly pasteCodeFlow: true;
67
+ } | {
68
+ readonly id: "exa";
69
+ readonly name: "Exa";
70
+ readonly envKeys: "EXA_API_KEY";
71
+ readonly login: (cb: import("./oauth/index.js").OAuthLoginCallbacks) => Promise<string>;
67
72
  } | {
68
73
  readonly id: "firepass";
69
74
  readonly name: "Fire Pass (Fireworks Kimi K2.6 Turbo subscription)";
@@ -282,7 +287,8 @@ declare const ALL: ({
282
287
  readonly login: (cb: import("./oauth/index.js").OAuthLoginCallbacks) => Promise<string>;
283
288
  } | {
284
289
  readonly id: "xai";
285
- readonly name: "xAI";
290
+ readonly name: "xAI API";
291
+ readonly login: (cb: import("./oauth/index.js").OAuthLoginCallbacks) => Promise<string>;
286
292
  } | {
287
293
  readonly id: "xai-oauth";
288
294
  readonly name: "xAI Grok OAuth (SuperGrok or X Premium+)";
@@ -1,4 +1,7 @@
1
+ import type { OAuthLoginCallbacks } from "./oauth/types.js";
2
+ export declare const loginXAI: (options: import("./oauth/index.js").OAuthController) => Promise<string>;
1
3
  export declare const xaiProvider: {
2
4
  readonly id: "xai";
3
- readonly name: "xAI";
5
+ readonly name: "xAI API";
6
+ readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
4
7
  };
@@ -5,6 +5,8 @@ import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli.js";
5
5
  import type { GoogleVertexOptions } from "./providers/google-vertex.js";
6
6
  import type { Api, AssistantMessage, Context, Model, OptionsForApi, SimpleStreamOptions, ToolChoice } from "./types.js";
7
7
  import { AssistantMessageEventStream } from "./utils/event-stream.js";
8
+ /** Strict official-Codex endpoint check; exact origin or a path boundary after {@link CODEX_BASE_URL}. */
9
+ export declare function isOfficialCodexApiUrl(baseUrl: string | undefined): boolean;
8
10
  export declare function configureProviderMaxInFlightRequests(limits: Record<string, number> | undefined): void;
9
11
  export declare const __providerInFlightForTesting: {
10
12
  setRoot(root: string | undefined): void;
@@ -1,6 +1,6 @@
1
1
  export * from "@oh-my-pi/pi-catalog/effort";
2
2
  export * from "@oh-my-pi/pi-catalog/types";
3
- import type { DeleteArgs, DeleteResult, DiagnosticsArgs, DiagnosticsResult, GrepArgs, GrepResult, LsArgs, LsResult, McpResult, ReadArgs, ReadResult, ShellArgs, ShellResult, WriteArgs, WriteResult } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
3
+ import type { DeleteArgs, DeleteResult, DiagnosticsArgs, DiagnosticsResult, GrepArgs, GrepResult, LsArgs, LsResult, McpResult, PiBashExecArgs, PiBashExecResult, PiEditExecArgs, PiEditExecResult, PiFindExecArgs, PiFindExecResult, PiGrepExecArgs, PiGrepExecResult, PiLsExecArgs, PiLsExecResult, PiReadExecArgs, PiReadExecResult, PiWriteExecArgs, PiWriteExecResult, ReadArgs, ReadResult, ShellArgs, ShellResult, WriteArgs, WriteResult } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
4
4
  import type { Effort } from "@oh-my-pi/pi-catalog/effort";
5
5
  import type { Api, FetchImpl, Model, Provider, ThinkingBudgets, Usage } from "@oh-my-pi/pi-catalog/types";
6
6
  import type { Type } from "arktype";
@@ -754,6 +754,14 @@ export interface CursorMcpCall {
754
754
  toolCallId: string;
755
755
  args: Record<string, unknown>;
756
756
  rawArgs: Record<string, Uint8Array>;
757
+ /**
758
+ * The frame asks only whether this call would be permitted — it must not
759
+ * run. The server sends it to resolve a smart-mode approval decision ahead
760
+ * of the real invocation, and answers with the dedicated `approved`
761
+ * variant, so executing here would fire a side-effecting tool the user has
762
+ * not yet been asked about (and fire it twice once the real call arrives).
763
+ */
764
+ approvalOnly?: boolean;
757
765
  }
758
766
  export interface CursorTodoSnapshotItem {
759
767
  content: string;
@@ -796,6 +804,51 @@ export interface CursorShellStreamCallbacks {
796
804
  onStdout(data: string): void;
797
805
  onStderr(data: string): void;
798
806
  }
807
+ /**
808
+ * A modern Pi exec frame plus the call id the dispatcher minted for it.
809
+ *
810
+ * Unlike the legacy exec args (`ReadArgs`, `ShellArgs`, ...), the Pi frames
811
+ * carry no `tool_call_id` field: on modern builds the id rides the streamed
812
+ * `ToolCall` envelope (`ToolCall.tool_call_id = 57`) instead of each variant's
813
+ * args. The exec channel has no access to that envelope, so the dispatcher
814
+ * mints an id and hands it to the handler, keeping the synthesized transcript
815
+ * block and its paired `toolResult` on the same key.
816
+ */
817
+ export interface CursorPiCall<TArgs> {
818
+ args: TArgs;
819
+ toolCallId: string;
820
+ }
821
+ /** One resource a host's MCP servers advertise. */
822
+ export interface CursorMcpResource {
823
+ uri: string;
824
+ name?: string;
825
+ description?: string;
826
+ mimeType?: string;
827
+ /** The server advertising it; Cursor addresses reads by this name. */
828
+ server: string;
829
+ }
830
+ /**
831
+ * The content of one resource read.
832
+ *
833
+ * `text` and `blob` are the wire's content oneof: exactly one is sent, with
834
+ * `text` winning when a host supplies both. A download instead sets
835
+ * `downloadPath` and no content at all — the model is told where the file
836
+ * landed rather than being handed its bytes.
837
+ */
838
+ export interface CursorMcpResourceContent {
839
+ uri: string;
840
+ name?: string;
841
+ description?: string;
842
+ mimeType?: string;
843
+ text?: string;
844
+ blob?: Uint8Array;
845
+ /**
846
+ * Where the host wrote the resource, workspace-relative, when the frame
847
+ * asked for a download. Set this INSTEAD of `text`/`blob`: the wire
848
+ * contract is that a download returns no content to the model.
849
+ */
850
+ downloadPath?: string;
851
+ }
799
852
  export interface CursorExecHandlers {
800
853
  read?: (args: ReadArgs) => Promise<CursorExecHandlerResult<ReadResult>>;
801
854
  ls?: (args: LsArgs) => Promise<CursorExecHandlerResult<LsResult>>;
@@ -806,6 +859,53 @@ export interface CursorExecHandlers {
806
859
  shellStream?: (args: ShellArgs, callbacks: CursorShellStreamCallbacks) => Promise<CursorExecHandlerResult<ShellResult>>;
807
860
  diagnostics?: (args: DiagnosticsArgs) => Promise<CursorExecHandlerResult<DiagnosticsResult>>;
808
861
  mcp?: (call: CursorMcpCall) => Promise<CursorExecHandlerResult<McpResult>>;
862
+ /**
863
+ * Answers "would this MCP call be permitted", without running it.
864
+ *
865
+ * A modern `mcpArgs` frame carrying `smart_mode_approval_only` asks for the
866
+ * permission decision alone, ahead of the real invocation. Executing the
867
+ * tool to answer it would fire a side effect the user never approved — and
868
+ * fire it twice once the real call arrives.
869
+ *
870
+ * `true` only when the host's policy resolves to a definite allow. A pending
871
+ * prompt is `false`: it can only be answered interactively at execution
872
+ * time, and there is no "ask me later" reply in this frame's result. When no
873
+ * handler is registered the provider refuses, since it cannot decide.
874
+ */
875
+ mcpApprovalPreflight?: (call: CursorMcpCall) => Promise<boolean>;
876
+ /**
877
+ * Modern Cursor CLI Pi tool frames (`ExecServerMessage` 45-51). They are a
878
+ * distinct frame family from the legacy `readArgs`/`shellArgs`/... set, not
879
+ * an alias: different args, different result oneofs, and no `tool_call_id`.
880
+ */
881
+ piRead?: (call: CursorPiCall<PiReadExecArgs>) => Promise<CursorExecHandlerResult<PiReadExecResult>>;
882
+ piBash?: (call: CursorPiCall<PiBashExecArgs>) => Promise<CursorExecHandlerResult<PiBashExecResult>>;
883
+ piEdit?: (call: CursorPiCall<PiEditExecArgs>) => Promise<CursorExecHandlerResult<PiEditExecResult>>;
884
+ piWrite?: (call: CursorPiCall<PiWriteExecArgs>) => Promise<CursorExecHandlerResult<PiWriteExecResult>>;
885
+ piGrep?: (call: CursorPiCall<PiGrepExecArgs>) => Promise<CursorExecHandlerResult<PiGrepExecResult>>;
886
+ piFind?: (call: CursorPiCall<PiFindExecArgs>) => Promise<CursorExecHandlerResult<PiFindExecResult>>;
887
+ piLs?: (call: CursorPiCall<PiLsExecArgs>) => Promise<CursorExecHandlerResult<PiLsExecResult>>;
888
+ /**
889
+ * The resources the host's MCP servers advertise, optionally filtered to one
890
+ * server. Without a handler the provider answers an empty catalog, which
891
+ * hides resources a host is in fact holding live connections to.
892
+ */
893
+ listMcpResources?: (args: {
894
+ server?: string;
895
+ }) => Promise<CursorMcpResource[]>;
896
+ /**
897
+ * Read one resource. `null` means the server or uri is genuinely unknown,
898
+ * which the provider answers as `not_found`; throwing surfaces as `error`.
899
+ */
900
+ readMcpResource?: (args: {
901
+ server: string;
902
+ uri: string;
903
+ /**
904
+ * When set, write the resource here (workspace-relative) and return
905
+ * `downloadPath` instead of content.
906
+ */
907
+ downloadPath?: string;
908
+ }) => Promise<CursorMcpResourceContent | null>;
809
909
  /** Mirror Cursor's server-owned todo list into local session state. */
810
910
  todoSync?: CursorTodoSyncHandler;
811
911
  onToolResult?: CursorToolResultHandler;
@@ -0,0 +1,2 @@
1
+ import type { UsageProvider } from "../usage.js";
2
+ export declare const umansUsageProvider: UsageProvider;
@@ -14,6 +14,17 @@ export declare function clearStreamingPartialJson(block: StreamingPartialJsonCar
14
14
  export declare const kStreamingBlockIndex: unique symbol;
15
15
  /** Stores the last parsed argument prefix length for throttled streaming JSON parsing. */
16
16
  export declare const kStreamingLastParseLen: unique symbol;
17
+ /**
18
+ * The Cursor interaction envelope's `call_id` for a streamed tool-call block.
19
+ *
20
+ * Tracked separately from the block's own `id` because they are NOT the same
21
+ * key: MCP and Pi blocks are filed under the id inside the call's `args`, which
22
+ * is what the exec channel pairs its result under, while every streamed
23
+ * `ToolCall*Update` correlates on the envelope's `call_id`. Matching
24
+ * completions against the block id would mis-route every call whose args carry
25
+ * their own id.
26
+ */
27
+ export declare const kStreamingEnvelopeId: unique symbol;
17
28
  /** Marks streamed tool-call arguments that already received an authoritative done payload. */
18
29
  export declare const kStreamingArgumentsDone: unique symbol;
19
30
  /** Classifies Cursor's in-flight tool-call kind without leaking provider-private state. */
@@ -1,13 +1,21 @@
1
+ import type { AssistantMessage, Model, ToolCall } from "../types.js";
1
2
  /**
2
- * GPT-5 Harmony-header leakage detection and recovery.
3
+ * Escape reserved Harmony control tokens in arbitrary text so it can be
4
+ * transported as data to a harmony-dialect model. Returns the input unchanged
5
+ * when it carries no reserved spelling.
3
6
  *
4
- * Background and policy: see `docs/ERRATA-GPT5-HARMONY.md`. This module
5
- * implements §3 of that document: detection by signal fusion, plus a
6
- * truncate-and-resume primitive for the `edit` tool when its input is in
7
- * hashline DSL form. Other tools and surfaces fall through to
8
- * abort-and-retry handled by the agent loop.
7
+ * Callers MUST gate on a harmony target and escape only the transport copy —
8
+ * the persisted transcript keeps the byte-for-byte original.
9
9
  */
10
- import type { AssistantMessage, Model, ToolCall } from "../types.js";
10
+ export declare function escapeHarmonyControlTokens(text: string): string;
11
+ /**
12
+ * Whether requests to `model` are served by a Harmony-dialect backend
13
+ * (gpt-5.x / gpt-oss), which rejects reserved control-token spellings appearing
14
+ * as data in the request. Resolves the wire model id (`requestModelId ?? id`)
15
+ * so deployment/catalog aliases — e.g. an Azure alias whose `requestModelId` is
16
+ * `gpt-5.4` — are detected even when the local id is opaque.
17
+ */
18
+ export declare function isHarmonyDialectModel(model: Model): boolean;
11
19
  declare const SIGNAL_ORDER: readonly ["M", "C", "G", "S", "B", "R", "T"];
12
20
  export type HarmonySignalClass = "H" | (typeof SIGNAL_ORDER)[number];
13
21
  export type HarmonySurface = "assistant_text" | "assistant_thinking" | "tool_arg";
@@ -26,6 +26,7 @@ export interface NormalizeSchemaOptions {
26
26
  inferTypeForBareEnum: boolean;
27
27
  foldOneOfIntoAnyOf: boolean;
28
28
  dropNonScalarEnum: boolean;
29
+ stringEnumsOnly?: boolean;
29
30
  rejectResidualIncompatibilities?: ReadonlyArray<ResidualSchemaIncompatibility>;
30
31
  validateAndFallback?: {
31
32
  fallback: unknown;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "17.1.8",
4
+ "version": "17.2.0",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/pi-catalog": "17.1.8",
42
- "@oh-my-pi/pi-utils": "17.1.8",
43
- "@oh-my-pi/pi-wire": "17.1.8",
41
+ "@oh-my-pi/pi-catalog": "17.2.0",
42
+ "@oh-my-pi/pi-utils": "17.2.0",
43
+ "@oh-my-pi/pi-wire": "17.2.0",
44
44
  "arktype": "2.2.3",
45
45
  "zod": "^4"
46
46
  },
@@ -28,6 +28,7 @@ import type {
28
28
  UsageResponse,
29
29
  UsageStaleResponse,
30
30
  } from "./types";
31
+ import { AUTH_BROKER_CAPABILITIES_HEADER, AUTH_BROKER_CAPABILITY_CODEX_METER_BLOCK_SCOPES } from "./types";
31
32
  import { getAuthBrokerWireSchemas } from "./wire-schema-resource";
32
33
 
33
34
  type AuthBrokerResponseSchemaName =
@@ -135,7 +136,9 @@ export class AuthBrokerClient {
135
136
  const query = new URLSearchParams();
136
137
  if (opts.waitMs !== undefined) query.set("wait", String(opts.waitMs));
137
138
  const path = `/v1/snapshot${query.size > 0 ? `?${query.toString()}` : ""}`;
138
- const headers: Record<string, string> = {};
139
+ const headers: Record<string, string> = {
140
+ [AUTH_BROKER_CAPABILITIES_HEADER]: AUTH_BROKER_CAPABILITY_CODEX_METER_BLOCK_SCOPES,
141
+ };
139
142
  if (opts.ifGenerationGt !== undefined) headers["If-None-Match"] = `"${opts.ifGenerationGt}"`;
140
143
  const timeoutMs =
141
144
  opts.waitMs !== undefined && opts.waitMs > 0 ? Math.max(this.#timeoutMs, opts.waitMs + 1000) : undefined;
@@ -176,6 +179,7 @@ export class AuthBrokerClient {
176
179
  const headers: Record<string, string> = {
177
180
  Accept: "text/event-stream",
178
181
  Authorization: `Bearer ${this.#token}`,
182
+ [AUTH_BROKER_CAPABILITIES_HEADER]: AUTH_BROKER_CAPABILITY_CODEX_METER_BLOCK_SCOPES,
179
183
  };
180
184
  if (opts.signal?.aborted) {
181
185
  throw new AuthBrokerError("Auth broker request aborted", { cause: opts.signal.reason });
@@ -32,6 +32,8 @@ import type {
32
32
  SnapshotStreamSnapshotEvent,
33
33
  } from "./types";
34
34
  import {
35
+ AUTH_BROKER_CAPABILITIES_HEADER,
36
+ AUTH_BROKER_CAPABILITY_CODEX_METER_BLOCK_SCOPES,
35
37
  DEFAULT_AUTH_BROKER_BIND,
36
38
  DEFAULT_REFRESH_INTERVAL_MS,
37
39
  DEFAULT_REFRESH_SKEW_MS,
@@ -40,6 +42,8 @@ import {
40
42
  } from "./types";
41
43
  import { getAuthBrokerWireSchemas } from "./wire-schema-resource";
42
44
 
45
+ const DEFAULT_EXTERNAL_CHANGE_POLL_MS = 250;
46
+
43
47
  export interface AuthBrokerServerOptions {
44
48
  /** Underlying credential storage (wraps the local SQLite store on the broker). */
45
49
  storage: AuthStorage;
@@ -61,6 +65,11 @@ export interface AuthBrokerServerOptions {
61
65
  * without long sleeps. Default {@link DEFAULT_STREAM_KEEPALIVE_MS}.
62
66
  */
63
67
  streamKeepaliveMs?: number;
68
+ /**
69
+ * Override cross-process SQLite change polling in milliseconds.
70
+ * Internal-only — tests use a short interval. Default 250ms.
71
+ */
72
+ externalChangePollMs?: number;
64
73
  }
65
74
 
66
75
  export interface AuthBrokerServerHandle {
@@ -91,6 +100,15 @@ function isAuthorized(req: Request, tokens: ReadonlySet<string>): boolean {
91
100
  return tokens.has(match[1].trim());
92
101
  }
93
102
 
103
+ function supportsCodexMeterBlockScopes(req: Request): boolean {
104
+ const capabilities = req.headers.get(AUTH_BROKER_CAPABILITIES_HEADER);
105
+ return (
106
+ capabilities
107
+ ?.split(",")
108
+ .some(capability => capability.trim() === AUTH_BROKER_CAPABILITY_CODEX_METER_BLOCK_SCOPES) ?? false
109
+ );
110
+ }
111
+
94
112
  /**
95
113
  * Parse + validate a JSON request body against an ArkType schema. Returns a
96
114
  * `Response` (400) on parse/validation failure so handlers can early-return.
@@ -135,6 +153,7 @@ function snapshotHeaders(generation: number): Record<string, string> {
135
153
  return {
136
154
  ETag: `"${generation}"`,
137
155
  "Cache-Control": "no-store",
156
+ Vary: AUTH_BROKER_CAPABILITIES_HEADER,
138
157
  };
139
158
  }
140
159
 
@@ -171,11 +190,18 @@ function delayResult(ms: number): { promise: Promise<"timeout">; cancel: () => v
171
190
  class GenerationGate {
172
191
  readonly #storage: AuthStorage;
173
192
  readonly #unsubscribe: () => void;
193
+ readonly #pollTimer: NodeJS.Timeout;
194
+ #pollInFlight = false;
174
195
  #waiters: Map<number, Set<() => void>> = new Map();
175
196
 
176
- constructor(storage: AuthStorage) {
197
+ constructor(storage: AuthStorage, pollIntervalMs: number) {
177
198
  this.#storage = storage;
178
199
  this.#unsubscribe = storage.onGenerationChanged(generation => this.#wake(generation));
200
+ this.#pollTimer = setInterval(() => {
201
+ void this.#pollExternalChanges();
202
+ }, pollIntervalMs);
203
+ this.#pollTimer.unref?.();
204
+ void this.#pollExternalChanges();
179
205
  }
180
206
 
181
207
  waitForChange(afterGeneration: number, signal: AbortSignal): Promise<"changed" | "aborted"> {
@@ -207,6 +233,7 @@ class GenerationGate {
207
233
  }
208
234
 
209
235
  close(): void {
236
+ clearInterval(this.#pollTimer);
210
237
  this.#unsubscribe();
211
238
  for (const waiters of this.#waiters.values()) {
212
239
  for (const resolve of waiters) resolve();
@@ -214,6 +241,18 @@ class GenerationGate {
214
241
  this.#waiters.clear();
215
242
  }
216
243
 
244
+ async #pollExternalChanges(): Promise<void> {
245
+ if (this.#pollInFlight) return;
246
+ this.#pollInFlight = true;
247
+ try {
248
+ await this.#storage.pollExternalChanges();
249
+ } catch (error) {
250
+ logger.debug("Auth broker external store change poll failed", { error: String(error) });
251
+ } finally {
252
+ this.#pollInFlight = false;
253
+ }
254
+ }
255
+
217
256
  #wake(generation: number): void {
218
257
  for (const [waitingFor, waiters] of [...this.#waiters]) {
219
258
  if (generation <= waitingFor) continue;
@@ -277,9 +316,46 @@ function compareCredentialBlockSnapshots(a: CredentialBlockSnapshot, b: Credenti
277
316
  return a.blockedUntilMs - b.blockedUntilMs;
278
317
  }
279
318
 
319
+ const CODEX_BLOCK_PROVIDER_KEY = "openai-codex:oauth";
320
+ const CODEX_LEGACY_PROJECTED_BLOCK_SCOPES = new Set(["chat", "spark", "shared"]);
321
+
322
+ /**
323
+ * Older clients only consult the Codex `shared` scope. Keep SQLite canonical
324
+ * state meter-scoped, but conservatively collapse those scopes on their wire
325
+ * view so any active meter block remains visible to them.
326
+ */
327
+ function projectCredentialBlocksForLegacyClient(blocks: readonly CredentialBlockSnapshot[]): CredentialBlockSnapshot[] {
328
+ const projected: CredentialBlockSnapshot[] = [];
329
+ let shared: CredentialBlockSnapshot | undefined;
330
+ for (const block of blocks) {
331
+ if (
332
+ block.providerKey !== CODEX_BLOCK_PROVIDER_KEY ||
333
+ !CODEX_LEGACY_PROJECTED_BLOCK_SCOPES.has(block.blockScope)
334
+ ) {
335
+ projected.push(block);
336
+ continue;
337
+ }
338
+ const updatedAtMs =
339
+ block.updatedAtMs === undefined
340
+ ? shared?.updatedAtMs
341
+ : shared?.updatedAtMs === undefined
342
+ ? block.updatedAtMs
343
+ : Math.max(shared.updatedAtMs, block.updatedAtMs);
344
+ shared = {
345
+ providerKey: CODEX_BLOCK_PROVIDER_KEY,
346
+ blockScope: "shared",
347
+ blockedUntilMs: Math.max(shared?.blockedUntilMs ?? 0, block.blockedUntilMs),
348
+ ...(updatedAtMs !== undefined ? { updatedAtMs } : {}),
349
+ };
350
+ }
351
+ if (shared) projected.push(shared);
352
+ return projected;
353
+ }
354
+
280
355
  function buildCredentialBlockGroups(
281
356
  blocks: readonly StoredCredentialBlock[],
282
357
  serverNowMs: number,
358
+ clientSupportsCodexMeterBlockScopes: boolean,
283
359
  ): Map<number, CredentialBlockSnapshot[]> {
284
360
  const byCredentialId = new Map<number, CredentialBlockSnapshot[]>();
285
361
  for (const block of blocks) {
@@ -297,16 +373,30 @@ function buildCredentialBlockGroups(
297
373
  byCredentialId.set(block.credentialId, [snapshotBlock]);
298
374
  }
299
375
  }
300
- for (const credentialBlocks of byCredentialId.values()) credentialBlocks.sort(compareCredentialBlockSnapshots);
376
+ for (const [credentialId, credentialBlocks] of byCredentialId) {
377
+ const projected = clientSupportsCodexMeterBlockScopes
378
+ ? credentialBlocks
379
+ : projectCredentialBlocksForLegacyClient(credentialBlocks);
380
+ projected.sort(compareCredentialBlockSnapshots);
381
+ byCredentialId.set(credentialId, projected);
382
+ }
301
383
  return byCredentialId;
302
384
  }
303
385
 
304
- function buildSnapshot(storage: AuthStorage, refresher: AuthBrokerRefresher | undefined): SnapshotResponse {
386
+ function buildSnapshot(
387
+ storage: AuthStorage,
388
+ refresher: AuthBrokerRefresher | undefined,
389
+ clientSupportsCodexMeterBlockScopes: boolean,
390
+ ): SnapshotResponse {
305
391
  const serverNowMs = Date.now();
306
392
  const base = storage.exportSnapshot();
307
393
  const { wire, nextSweepAt } = resolveRefresherSchedule(refresher, serverNowMs);
308
394
  const credentialIds = base.credentials.map(entry => entry.id);
309
- const blocksByCredentialId = buildCredentialBlockGroups(storage.listCredentialBlocks(credentialIds), serverNowMs);
395
+ const blocksByCredentialId = buildCredentialBlockGroups(
396
+ storage.listCredentialBlocks(credentialIds),
397
+ serverNowMs,
398
+ clientSupportsCodexMeterBlockScopes,
399
+ );
310
400
  const credentials: SnapshotEntry[] = base.credentials.map(entry => {
311
401
  const blocks = blocksByCredentialId.get(entry.id);
312
402
  const rotatesInMs = computeRotatesInMs(entry, wire, nextSweepAt, serverNowMs);
@@ -330,12 +420,13 @@ async function serveSnapshot(
330
420
  peer: string,
331
421
  ): Promise<Response> {
332
422
  await storage.reload();
423
+ const clientSupportsCodexMeterBlockScopes = supportsCodexMeterBlockScopes(req);
333
424
  let currentGeneration = storage.getGeneration();
334
425
  const clientGeneration = parseGenerationTag(req.headers.get("if-none-match"));
335
426
  const waitMs = parseWaitMs(url);
336
427
 
337
428
  if (clientGeneration === undefined || currentGeneration !== clientGeneration || waitMs <= 0) {
338
- const body = buildSnapshot(storage, refresher);
429
+ const body = buildSnapshot(storage, refresher, clientSupportsCodexMeterBlockScopes);
339
430
  logger.info("auth-broker snapshot served", {
340
431
  peer,
341
432
  credentials: body.credentials.length,
@@ -355,7 +446,7 @@ async function serveSnapshot(
355
446
  await storage.reload();
356
447
  currentGeneration = storage.getGeneration();
357
448
  if (currentGeneration !== clientGeneration) {
358
- const body = buildSnapshot(storage, refresher);
449
+ const body = buildSnapshot(storage, refresher, clientSupportsCodexMeterBlockScopes);
359
450
  logger.info("auth-broker snapshot long-poll changed", {
360
451
  peer,
361
452
  credentials: body.credentials.length,
@@ -401,6 +492,7 @@ function serveSnapshotStream(
401
492
  ): Response {
402
493
  const encoder = new TextEncoder();
403
494
  const openedAt = Date.now();
495
+ const clientSupportsCodexMeterBlockScopes = supportsCodexMeterBlockScopes(req);
404
496
  const lastByCredId = new Map<number, string>();
405
497
  let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
406
498
  let unsubscribe: (() => void) | null = null;
@@ -458,7 +550,7 @@ function serveSnapshotStream(
458
550
  pendingBumps = 0;
459
551
  await storage.reload();
460
552
  if (closed) return;
461
- const snapshot = buildSnapshot(storage, refresher);
553
+ const snapshot = buildSnapshot(storage, refresher, clientSupportsCodexMeterBlockScopes);
462
554
  // Generation must move forward; a duplicate listener firing without a
463
555
  // real bump is a no-op below (fingerprints unchanged).
464
556
  if (snapshot.generation < lastGeneration) {
@@ -513,7 +605,7 @@ function serveSnapshotStream(
513
605
  async start(c) {
514
606
  controller = c;
515
607
  await storage.reload();
516
- const initial = buildSnapshot(storage, refresher);
608
+ const initial = buildSnapshot(storage, refresher, clientSupportsCodexMeterBlockScopes);
517
609
  lastGeneration = initial.generation;
518
610
  for (const entry of initial.credentials) lastByCredId.set(entry.id, fingerprintEntry(entry));
519
611
  const initialEvent: SnapshotStreamSnapshotEvent = { kind: "snapshot", ...initial };
@@ -541,6 +633,7 @@ function serveSnapshotStream(
541
633
  "Cache-Control": "no-cache",
542
634
  Connection: "keep-alive",
543
635
  "X-Accel-Buffering": "no",
636
+ Vary: AUTH_BROKER_CAPABILITIES_HEADER,
544
637
  },
545
638
  });
546
639
  }
@@ -551,6 +644,7 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
551
644
  const tokens = new Set<string>(opts.bearerTokens);
552
645
  const version = opts.version;
553
646
  const streamKeepaliveMs = opts.streamKeepaliveMs ?? DEFAULT_STREAM_KEEPALIVE_MS;
647
+ const externalChangePollMs = opts.externalChangePollMs ?? DEFAULT_EXTERNAL_CHANGE_POLL_MS;
554
648
 
555
649
  const refresher = opts.disableRefresher
556
650
  ? undefined
@@ -560,7 +654,7 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
560
654
  refreshIntervalMs: opts.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS,
561
655
  });
562
656
  refresher?.start();
563
- const generationGate = new GenerationGate(opts.storage);
657
+ const generationGate = new GenerationGate(opts.storage, externalChangePollMs);
564
658
 
565
659
  const server = Bun.serve({
566
660
  hostname: bind.hostname,
@@ -12,7 +12,7 @@ import { isEnoent, logger } from "@oh-my-pi/pi-utils";
12
12
  import type { SnapshotResponse } from "./types";
13
13
 
14
14
  const MAGIC = new Uint8Array([0x4f, 0x4d, 0x50, 0x53]); // "OMPS"
15
- const VERSION = 1;
15
+ const VERSION = 2;
16
16
  const VERSION_OFFSET = MAGIC.byteLength;
17
17
  const IV_OFFSET = VERSION_OFFSET + 1;
18
18
  const IV_LENGTH = 12;
@@ -118,7 +118,7 @@ async function encryptCachePayload(snapshot: SnapshotResponse, token: string, ur
118
118
  {
119
119
  name: AES_ALGORITHM,
120
120
  iv,
121
- additionalData: TEXT_ENCODER.encode(url),
121
+ additionalData: cacheAdditionalData(url),
122
122
  },
123
123
  key,
124
124
  plaintext,
@@ -156,7 +156,7 @@ async function decryptCachePayload(data: Uint8Array, token: string, url: string)
156
156
  {
157
157
  name: AES_ALGORITHM,
158
158
  iv,
159
- additionalData: TEXT_ENCODER.encode(url),
159
+ additionalData: cacheAdditionalData(url),
160
160
  },
161
161
  key,
162
162
  ciphertext,
@@ -168,6 +168,15 @@ async function decryptCachePayload(data: Uint8Array, token: string, url: string)
168
168
  }
169
169
  }
170
170
 
171
+ function cacheAdditionalData(url: string): Uint8Array<ArrayBuffer> {
172
+ const urlBytes = TEXT_ENCODER.encode(url);
173
+ const additionalData = new Uint8Array(IV_OFFSET + urlBytes.byteLength);
174
+ additionalData.set(MAGIC, 0);
175
+ additionalData[VERSION_OFFSET] = VERSION;
176
+ additionalData.set(urlBytes, IV_OFFSET);
177
+ return additionalData;
178
+ }
179
+
171
180
  async function deriveAesKey(token: string, usages: Array<"encrypt" | "decrypt">): Promise<CryptoKey> {
172
181
  const digest = await globalThis.crypto.subtle.digest("SHA-256", TEXT_ENCODER.encode(token));
173
182
  return globalThis.crypto.subtle.importKey("raw", digest, AES_ALGORITHM, false, usages);
@@ -165,6 +165,12 @@ export type SnapshotStreamEvent = SnapshotStreamSnapshotEvent | SnapshotStreamEn
165
165
  */
166
166
  export const AUTH_BROKER_API_PREFIX = "/v1";
167
167
 
168
+ /** Request header used by clients to advertise optional auth-broker protocol features. */
169
+ export const AUTH_BROKER_CAPABILITIES_HEADER = "OMP-Auth-Broker-Capabilities";
170
+
171
+ /** Client understands independent Codex `chat` and `spark` credential-block scopes. */
172
+ export const AUTH_BROKER_CAPABILITY_CODEX_METER_BLOCK_SCOPES = "codex-meter-block-scopes";
173
+
168
174
  /** Default port when none is configured. Loopback-only, no external exposure. */
169
175
  export const DEFAULT_AUTH_BROKER_BIND = "127.0.0.1:8765";
170
176