@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.
- package/CHANGELOG.md +56 -0
- package/dist/types/auth-broker/server.d.ts +5 -0
- package/dist/types/auth-broker/types.d.ts +4 -0
- package/dist/types/auth-storage.d.ts +26 -1
- package/dist/types/error/classes.d.ts +5 -1
- package/dist/types/error/rate-limit.d.ts +5 -4
- package/dist/types/providers/anthropic-client.d.ts +13 -1
- package/dist/types/providers/anthropic.d.ts +9 -1
- package/dist/types/providers/cursor/exec-modern.d.ts +98 -0
- package/dist/types/providers/cursor-pi-args.d.ts +99 -0
- package/dist/types/providers/cursor.d.ts +53 -3
- package/dist/types/providers/error-message.d.ts +2 -4
- package/dist/types/providers/openai-codex-responses.d.ts +10 -0
- package/dist/types/providers/openai-responses-wire.d.ts +8 -0
- package/dist/types/providers/openai-shared.d.ts +13 -1
- package/dist/types/registry/exa.d.ts +8 -0
- package/dist/types/registry/registry.d.ts +7 -1
- package/dist/types/registry/xai.d.ts +4 -1
- package/dist/types/stream.d.ts +2 -0
- package/dist/types/types.d.ts +101 -1
- package/dist/types/usage/umans.d.ts +2 -0
- package/dist/types/utils/block-symbols.d.ts +11 -0
- package/dist/types/utils/harmony-leak.d.ts +15 -7
- package/dist/types/utils/schema/normalize.d.ts +1 -0
- package/package.json +4 -4
- package/src/auth-broker/client.ts +5 -1
- package/src/auth-broker/server.ts +103 -9
- package/src/auth-broker/snapshot-cache.ts +12 -3
- package/src/auth-broker/types.ts +6 -0
- package/src/auth-storage.ts +439 -28
- package/src/error/classes.ts +98 -3
- package/src/error/flags.ts +6 -1
- package/src/error/rate-limit.ts +18 -12
- package/src/providers/amazon-bedrock.ts +3 -3
- package/src/providers/anthropic-client.ts +24 -2
- package/src/providers/anthropic.ts +31 -6
- package/src/providers/cursor/exec-modern.ts +495 -0
- package/src/providers/cursor/proto/agent.proto +1007 -0
- package/src/providers/cursor-pi-args.ts +135 -0
- package/src/providers/cursor.ts +1138 -66
- package/src/providers/devin.ts +2 -1
- package/src/providers/error-message.ts +3 -1
- package/src/providers/openai-codex-responses.ts +106 -26
- package/src/providers/openai-completions.ts +19 -4
- package/src/providers/openai-responses-wire.ts +8 -0
- package/src/providers/openai-responses.ts +12 -1
- package/src/providers/openai-shared.ts +75 -12
- package/src/registry/api-key-validation.ts +18 -34
- package/src/registry/exa.ts +19 -0
- package/src/registry/novita.ts +6 -3
- package/src/registry/registry.ts +3 -1
- package/src/registry/xai.ts +17 -1
- package/src/stream.ts +1 -2
- package/src/types.ts +115 -0
- package/src/usage/umans.ts +192 -0
- package/src/utils/block-symbols.ts +12 -0
- package/src/utils/harmony-leak.ts +32 -0
- package/src/utils/idle-iterator.ts +2 -2
- 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
|
};
|
package/dist/types/stream.d.ts
CHANGED
|
@@ -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;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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;
|
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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.
|
|
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.
|
|
42
|
-
"@oh-my-pi/pi-utils": "17.
|
|
43
|
-
"@oh-my-pi/pi-wire": "17.
|
|
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
|
|
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(
|
|
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(
|
|
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 =
|
|
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:
|
|
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:
|
|
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);
|
package/src/auth-broker/types.ts
CHANGED
|
@@ -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
|
|