@gajae-code/ai 0.16.0 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.16.3] - 2026-09-04
6
+
7
+ ## [0.16.2] - 2026-09-04
8
+
9
+ - OpenAI Codex GPT-5.6 Sol selections now reject a known non-Pro ChatGPT OAuth account before dispatch, instead of allowing a binding that fails later with the provider's raw entitlement error. The same rejection is normalized for HTTP and streaming provider responses with guidance to choose a callable model or use an API-key credential.
10
+
11
+ ## [0.16.1] - 2026-09-03
12
+
13
+ ### Added
14
+
15
+ - Exported `fetchModelsDevPayload` from `provider-models/openai-compat` and coalesced its downloads. models.dev publishes one catalog document describing every provider, but each models.dev-backed provider downloaded it separately, so a single discovery pass transferred the same payload once per provider (and `packages/coding-agent` kept a second downloader of its own). Downloads are now shared per fetch implementation for a 60s window, failures are not retained, and `coding-agent` model discovery consumes the same fetcher.
16
+
17
+ - Exported `detectDiscoveredApiFamily` from `utils/discovery/openai-compatible`: infers the wire API family (`anthropic-messages` vs `openai-completions`) for a discovered model on a mixed OpenAI-compatible gateway, using the OpenAI `owned_by` owner first and the model id (`claude-*` vs `gpt-*`/`o1`/`codex`/…) as fallback, returning `undefined` when inconclusive. Consumed by custom-provider auto model discovery in `packages/coding-agent`.
18
+
19
+ ### Fixed
20
+
21
+ - Antigravity discovery now keeps mid-rollout models that the backend marks `isInternal` when the same response surfaces them through `agentModelSorts`. Internal models absent from the IDE's surfaced model groups remain hidden, and denylisted or retired selectors still take precedence.
22
+ - Tokenless loopback auth-broker requests carrying a browser `Origin` header are now rejected before credential reads or mutations. Native loopback clients without `Origin`, authenticated browser-origin clients, and the public health endpoint retain their existing behavior.
23
+ - `glm-zcode` login instructions now warn users who have the ZCode desktop app installed to cancel the browser's `zcode://` open prompt. The app exchanges the single-use authorization code itself, so a code pasted afterwards is rejected by the broker (`500 {"code":2007}`) and the documented paste flow failed without explanation.
24
+
25
+ - Cursor sessions now report prompt tokens instead of zero. `ConversationTokenDetails.used_tokens` counts the whole conversation, but the checkpoint handler assigned it to `usage.output` and returned early whenever token deltas had been seen — which is the normal streaming path — so `usage.input`, `usage.cacheRead`, and `usage.cacheWrite` were left at their zero initializers for every cursor request. `calculatePromptTokens` therefore fell through to its output-only fallback, so the context indicator tracked the last response's output size rather than the conversation, and automatic compaction never observed a full context. Conversation usage is now recorded through the stream and split into prompt and output tokens when the stream finalizes.
26
+
27
+ - OAuth callback responses now serialize provider-controlled result fields as safe JSON script data, preventing callback values from terminating the embedded state element while preserving exact JSON values and callback behavior.
28
+
5
29
  ## [0.16.0] - 2026-09-02
6
30
 
7
31
  ### Added
@@ -23,6 +23,7 @@ export interface ModelsDevModel {
23
23
  npm?: string;
24
24
  };
25
25
  }
26
+ export declare function fetchModelsDevPayload(fetchImpl?: typeof fetch): Promise<unknown>;
26
27
  export interface OpenAIModelManagerConfig {
27
28
  apiKey?: string;
28
29
  baseUrl?: string;
@@ -1,8 +1,9 @@
1
1
  import { type JsonValue } from "@bufbuild/protobuf";
2
- import type { CursorExecHandlerResult, CursorExecHandlers, CursorToolResultHandler, Message, Model, StreamFunction, StreamOptions, ToolCall, ToolResultMessage } from "../types";
2
+ import type { AssistantMessage, CursorExecHandlerResult, CursorExecHandlers, CursorToolResultHandler, Message, Model, StreamFunction, StreamOptions, ToolCall, ToolResultMessage, Usage } from "../types";
3
3
  import { kCursorExecResolved } from "../utils/block-symbols";
4
4
  import { CURSOR_CLIENT_VERSION } from "./cursor/client-version";
5
5
  import type { CursorRule, RequestedModel_ModelParameterbytes } from "./cursor/gen/agent_pb";
6
+ import { type ConversationStateStructure } from "./cursor/gen/agent_pb";
6
7
  export declare const CURSOR_API_URL = "https://api2.cursor.sh";
7
8
  export { CURSOR_CLIENT_VERSION };
8
9
  /** Drop all cached state + blob bytes for a conversation (F15 bound + session-teardown hook). */
@@ -31,6 +32,19 @@ type ToolCallState = ToolCall & {
31
32
  kind: "mcp" | "todo_write" | "native" | "cursor-exec";
32
33
  [kCursorExecResolved]?: true;
33
34
  };
35
+ interface UsageState {
36
+ sawTokenDelta: boolean;
37
+ /**
38
+ * Latest `ConversationTokenDetails.used_tokens`: the whole conversation's
39
+ * token consumption as counted by Cursor, not this turn's output.
40
+ */
41
+ conversationUsedTokens: number;
42
+ /** Output tokens already included in the latest checkpoint snapshot. */
43
+ checkpointOutputTokens: number;
44
+ /** Whether the current stream received a checkpoint, including an explicit zero. */
45
+ hasConversationCheckpoint: boolean;
46
+ pendingCheckpoint?: ConversationStateStructure;
47
+ }
34
48
  /** Exported for tests: verifies handler is invoked with correct `this` when passed as bound. */
35
49
  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<{
36
50
  execResult: TResult;
@@ -44,6 +58,18 @@ export declare function createCursorMessageQueueForTest(onError?: (error: unknow
44
58
  /** Exported for direct regression coverage of the JSON-safety boundary. */
45
59
  export declare function cursorJsonSafeValueForTest(value: unknown): unknown;
46
60
  export declare function buildNativeToolCallBlock(toolCall: Record<string, unknown>, callId: string, index: number): ToolCallState | null;
61
+ /**
62
+ * Cursor streams output tokens as deltas and reports whole-conversation
63
+ * consumption separately as `ConversationTokenDetails.used_tokens`. Derive
64
+ * prompt tokens from the difference so context accounting and compaction see a
65
+ * real prompt size instead of zero.
66
+ */
67
+ export declare function finalizeCursorUsage(output: AssistantMessage, usageState: UsageState): void;
68
+ /** Exposes {@link finalizeCursorUsage} for tests without a live HTTP/2 stream. */
69
+ export declare function finalizeCursorUsageForTest(usedTokens: number, outputTokens: number, options?: {
70
+ checkpointOutputTokens?: number;
71
+ hasConversationCheckpoint?: boolean;
72
+ }): Usage;
47
73
  /**
48
74
  * Build `ConversationStateStructure.rootPromptMessagesJson` blob IDs for the
49
75
  * system prompt plus prior conversation history, as JSON blobs matching
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Model entitlement facts shared by Codex credential selection and provider
3
+ * error presentation.
4
+ *
5
+ * GPT-5.6 Sol is a Pro-tier ChatGPT Codex model. The usage endpoint is the
6
+ * authority for the account tier; this module only names the model policy and
7
+ * keeps the provider's deterministic rejection wording in one place.
8
+ */
9
+ export declare function requiresOpenAICodexProModel(provider: string, modelId: string | undefined): boolean;
10
+ export declare function requiresStrictOpenAICodexProModel(provider: string, modelId: string | undefined): boolean;
11
+ export declare function isOpenAICodexChatGPTEntitlementError(message: string | undefined, code?: string): boolean;
12
+ export declare function formatOpenAICodexChatGPTEntitlementError(modelId: string | undefined): string;
@@ -1,6 +1,27 @@
1
1
  import type { Api, FetchImpl, Model, Provider } from "../../types";
2
2
  /** Catalog identities are rendered and used for routing; unsafe values are dropped, never rewritten. */
3
3
  export declare function isSafeCatalogModelId(value: unknown): value is string;
4
+ /**
5
+ * The two wire families a mixed OpenAI-compatible gateway (e.g. CLIProxyAPI)
6
+ * can front. A gateway exposes an OpenAI-shaped `/v1/models` catalog but may
7
+ * proxy Anthropic models that must be driven through the Anthropic Messages
8
+ * transport rather than OpenAI Chat Completions.
9
+ */
10
+ export type DiscoveredApiFamily = "anthropic-messages" | "openai-completions";
11
+ /**
12
+ * Infer the wire API family for one discovered model on a mixed
13
+ * OpenAI-compatible gateway.
14
+ *
15
+ * Uses the `owned_by` owner string first (authoritative when the gateway
16
+ * populates it — `"anthropic"` / `"openai"`), then falls back to the model id
17
+ * (`claude-*` → Anthropic, `gpt-*`/`o1`/`codex`/… → OpenAI). Returns
18
+ * `undefined` when neither signal is conclusive so the caller can keep the
19
+ * provider-level default instead of guessing.
20
+ */
21
+ export declare function detectDiscoveredApiFamily(entry: {
22
+ id?: unknown;
23
+ owned_by?: unknown;
24
+ }): DiscoveredApiFamily | undefined;
4
25
  /**
5
26
  * Minimal OpenAI-style model entry shape consumed by discovery.
6
27
  *
@@ -14,6 +14,13 @@
14
14
  * or `ConnectionClosed` rather than `HTTP2Unsupported`, so we treat those
15
15
  * codes as h2-fallback triggers as well.
16
16
  *
17
+ * ALPN-refusing hosts (notably zcode.z.ai, the GLM ZCode OAuth broker) abort
18
+ * the TLS handshake entirely when the client offers ALPN h2. Bun reports that
19
+ * abort as `UNKNOWN_CERTIFICATE_VERIFICATION_ERROR` even though the host's
20
+ * certificate chain verifies fine over h1 (issue #5178), so that code is a
21
+ * fallback trigger too — never a reason to accept a bad certificate: the h1
22
+ * attempt below performs full verification on its own.
23
+ *
17
24
  * Bun negotiates h2 via ALPN over TLS only (no h2c), so plain `http://` URLs
18
25
  * skip the attempt entirely — avoids the throw/retry round-trip for localhost.
19
26
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.16.0",
4
+ "version": "0.16.3",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/sdk": "^0.94.0",
42
42
  "@bufbuild/protobuf": "^2.12.0",
43
- "@gajae-code/natives": "0.16.0",
44
- "@gajae-code/utils": "0.16.0",
43
+ "@gajae-code/natives": "0.16.3",
44
+ "@gajae-code/utils": "0.16.3",
45
45
  "openai": "^6.36.0",
46
46
  "partial-json": "^0.1.7",
47
47
  "zod": "4.4.3"
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import * as crypto from "node:crypto";
13
13
  import { logger } from "@gajae-code/utils";
14
- import { timingSafeEqual } from "../auth-gateway/http";
14
+ import { isNoAuthBrowserOriginRequest, timingSafeEqual } from "../auth-gateway/http";
15
15
  import type { AuthStorage } from "../auth-storage";
16
16
  import type { Provider } from "../types";
17
17
  import { assertAuthenticatedOrLoopback, parseBind } from "../utils/parse-bind";
@@ -594,6 +594,15 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
594
594
  const body: HealthzResponse = { ok: true, version };
595
595
  return json(200, body);
596
596
  }
597
+ if (isNoAuthBrowserOriginRequest(req, tokens)) {
598
+ logger.info("auth-broker no-auth browser-origin request rejected", {
599
+ method: req.method,
600
+ path: pathname,
601
+ peer,
602
+ originPresent: true,
603
+ });
604
+ return json(403, { error: "no-auth rejects requests carrying Origin" });
605
+ }
597
606
  if (!isAuthorized(req, tokens)) {
598
607
  logger.info("auth-broker request unauthorized", { method: req.method, path: pathname, peer });
599
608
  return json(401, { error: "unauthorized" });
@@ -26,6 +26,11 @@ import type {
26
26
  UsageReport,
27
27
  } from "./usage";
28
28
 
29
+ import {
30
+ formatOpenAICodexChatGPTEntitlementError,
31
+ requiresOpenAICodexProModel,
32
+ requiresStrictOpenAICodexProModel,
33
+ } from "./utils/codex-entitlement";
29
34
  import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken, resolveOAuthStorageProvider } from "./utils/oauth";
30
35
  import { loginDeepInfra } from "./utils/oauth/deepinfra";
31
36
  import { loginDeepSeek } from "./utils/oauth/deepseek";
@@ -1140,10 +1145,6 @@ export function readBrokerErrorBody(error: unknown): string | undefined {
1140
1145
  }
1141
1146
  }
1142
1147
 
1143
- function requiresOpenAICodexProModel(provider: string, modelId: string | undefined): boolean {
1144
- return provider === "openai-codex" && typeof modelId === "string" && modelId.includes("-spark");
1145
- }
1146
-
1147
1148
  function getUsagePlanType(report: UsageReport | null): string | undefined {
1148
1149
  const metadata = report?.metadata;
1149
1150
  if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return undefined;
@@ -4955,6 +4956,19 @@ export class AuthStorage {
4955
4956
  // non-Pro accounts can still attempt Spark requests (e.g. trial/grandfathered access).
4956
4957
  const enforceProRequirement =
4957
4958
  requiresProModel && candidates.some(candidate => hasOpenAICodexProPlan(candidate.usage));
4959
+ // Spark retains its historical Plus fallback for grandfathered accounts.
4960
+ // Sol is different: a confirmed non-Pro plan cannot call it, so reject the
4961
+ // model before returning an OAuth bearer and letting the turn fail remotely.
4962
+ const strictProRequirement = requiresStrictOpenAICodexProModel(provider, options?.modelId);
4963
+ if (
4964
+ strictProRequirement &&
4965
+ candidates.length > 0 &&
4966
+ candidates.every(
4967
+ candidate => getUsagePlanType(candidate.usage) !== undefined && !hasOpenAICodexProPlan(candidate.usage),
4968
+ )
4969
+ ) {
4970
+ throw new Error(formatOpenAICodexChatGPTEntitlementError(options?.modelId));
4971
+ }
4958
4972
 
4959
4973
  const fallback = candidates[0];
4960
4974
 
@@ -59,15 +59,39 @@ function toInputCapabilities(value: unknown): ("text" | "image")[] {
59
59
  return supportsImage ? ["text", "image"] : ["text"];
60
60
  }
61
61
 
62
- async function fetchModelsDevPayload(fetchImpl: typeof fetch = fetch): Promise<unknown> {
63
- const response = await fetchImpl(MODELS_DEV_URL, {
64
- method: "GET",
65
- headers: { Accept: "application/json" },
66
- });
67
- if (!response.ok) {
68
- throw new Error(`models.dev fetch failed: ${response.status}`);
62
+ /**
63
+ * models.dev serves one catalog document describing every provider, and a
64
+ * discovery pass resolves several providers from it. Downloads are coalesced
65
+ * per fetch implementation so one pass transfers the catalog once instead of
66
+ * once per provider; the window is short enough that a later refresh still
67
+ * observes catalog updates.
68
+ */
69
+ const MODELS_DEV_PAYLOAD_TTL_MS = 60_000;
70
+ const modelsDevPayloadCache = new WeakMap<typeof fetch, { at: number; payload: Promise<unknown> }>();
71
+
72
+ export async function fetchModelsDevPayload(fetchImpl: typeof fetch = fetch): Promise<unknown> {
73
+ const now = Date.now();
74
+ const cached = modelsDevPayloadCache.get(fetchImpl);
75
+ if (cached && now - cached.at < MODELS_DEV_PAYLOAD_TTL_MS) return cached.payload;
76
+ const payload = (async () => {
77
+ const response = await fetchImpl(MODELS_DEV_URL, {
78
+ method: "GET",
79
+ headers: { Accept: "application/json" },
80
+ signal: AbortSignal.timeout(5_000),
81
+ });
82
+ if (!response.ok) {
83
+ throw new Error(`models.dev fetch failed: ${response.status}`);
84
+ }
85
+ return (await response.json()) as unknown;
86
+ })();
87
+ const entry = { at: now, payload };
88
+ modelsDevPayloadCache.set(fetchImpl, entry);
89
+ try {
90
+ return await payload;
91
+ } catch (error) {
92
+ if (modelsDevPayloadCache.get(fetchImpl) === entry) modelsDevPayloadCache.delete(fetchImpl);
93
+ throw error;
69
94
  }
70
- return response.json();
71
95
  }
72
96
 
73
97
  function anthropicToolChoiceCompat(modelId: string): Pick<Model<"anthropic-messages">, "compat"> {
@@ -1,8 +1,9 @@
1
1
  import { type JsonValue } from "@bufbuild/protobuf";
2
- import type { CursorExecHandlerResult, CursorExecHandlers, CursorToolResultHandler, Message, Model, StreamFunction, StreamOptions, ToolCall, ToolResultMessage } from "../types";
2
+ import type { AssistantMessage, CursorExecHandlerResult, CursorExecHandlers, CursorToolResultHandler, Message, Model, StreamFunction, StreamOptions, ToolCall, ToolResultMessage, Usage } from "../types";
3
3
  import { kCursorExecResolved } from "../utils/block-symbols";
4
4
  import { CURSOR_CLIENT_VERSION } from "./cursor/client-version";
5
5
  import type { CursorRule, RequestedModel_ModelParameterbytes } from "./cursor/gen/agent_pb";
6
+ import { type ConversationStateStructure } from "./cursor/gen/agent_pb";
6
7
  export declare const CURSOR_API_URL = "https://api2.cursor.sh";
7
8
  export { CURSOR_CLIENT_VERSION };
8
9
  /** Drop all cached state + blob bytes for a conversation (F15 bound + session-teardown hook). */
@@ -31,6 +32,19 @@ type ToolCallState = ToolCall & {
31
32
  kind: "mcp" | "todo_write" | "native" | "cursor-exec";
32
33
  [kCursorExecResolved]?: true;
33
34
  };
35
+ interface UsageState {
36
+ sawTokenDelta: boolean;
37
+ /**
38
+ * Latest `ConversationTokenDetails.used_tokens`: the whole conversation's
39
+ * token consumption as counted by Cursor, not this turn's output.
40
+ */
41
+ conversationUsedTokens: number;
42
+ /** Output tokens already included in the latest checkpoint snapshot. */
43
+ checkpointOutputTokens: number;
44
+ /** Whether the current stream received a checkpoint, including an explicit zero. */
45
+ hasConversationCheckpoint: boolean;
46
+ pendingCheckpoint?: ConversationStateStructure;
47
+ }
34
48
  /** Exported for tests: verifies handler is invoked with correct `this` when passed as bound. */
35
49
  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<{
36
50
  execResult: TResult;
@@ -44,6 +58,18 @@ export declare function createCursorMessageQueueForTest(onError?: (error: unknow
44
58
  /** Exported for direct regression coverage of the JSON-safety boundary. */
45
59
  export declare function cursorJsonSafeValueForTest(value: unknown): unknown;
46
60
  export declare function buildNativeToolCallBlock(toolCall: Record<string, unknown>, callId: string, index: number): ToolCallState | null;
61
+ /**
62
+ * Cursor streams output tokens as deltas and reports whole-conversation
63
+ * consumption separately as `ConversationTokenDetails.used_tokens`. Derive
64
+ * prompt tokens from the difference so context accounting and compaction see a
65
+ * real prompt size instead of zero.
66
+ */
67
+ export declare function finalizeCursorUsage(output: AssistantMessage, usageState: UsageState): void;
68
+ /** Exposes {@link finalizeCursorUsage} for tests without a live HTTP/2 stream. */
69
+ export declare function finalizeCursorUsageForTest(usedTokens: number, outputTokens: number, options?: {
70
+ checkpointOutputTokens?: number;
71
+ hasConversationCheckpoint?: boolean;
72
+ }): Usage;
47
73
  /**
48
74
  * Build `ConversationStateStructure.rootPromptMessagesJson` blob IDs for the
49
75
  * system prompt plus prior conversation history, as JSON blobs matching
@@ -24,6 +24,7 @@ import type {
24
24
  Tool,
25
25
  ToolCall,
26
26
  ToolResultMessage,
27
+ Usage,
27
28
  } from "../types";
28
29
  import { normalizeSystemPrompts } from "../utils";
29
30
  import { kCursorExecResolved } from "../utils/block-symbols";
@@ -76,6 +77,7 @@ import {
76
77
  type ConversationStateStructure,
77
78
  ConversationStateStructureSchema,
78
79
  ConversationStepSchema,
80
+ ConversationTokenDetailsSchema,
79
81
  ConversationTurnStructureSchema,
80
82
  CursorRuleSchema,
81
83
  CursorRuleSource,
@@ -175,6 +177,7 @@ export { CURSOR_CLIENT_VERSION };
175
177
 
176
178
  const conversationStateCache = new Map<string, ConversationStateStructure>();
177
179
  const conversationBlobStores = new Map<string, Map<string, Uint8Array>>();
180
+ const conversationUsageContextCache = new Map<string, CursorUsageContext>();
178
181
 
179
182
  // F15: bound the module-global conversation caches so long-lived / many-session use cannot
180
183
  // grow them without limit. LRU by conversation count + TTL on idle conversations.
@@ -186,6 +189,7 @@ const conversationLastAccess = new Map<string, number>();
186
189
  export function disposeCursorConversation(conversationId: string): void {
187
190
  conversationStateCache.delete(conversationId);
188
191
  conversationBlobStores.delete(conversationId);
192
+ conversationUsageContextCache.delete(conversationId);
189
193
  conversationLastAccess.delete(conversationId);
190
194
  }
191
195
 
@@ -337,9 +341,18 @@ class CursorRequestCoordinator implements CursorRequestWriter {
337
341
 
338
342
  admit(taskFactory: () => Promise<void>): void {
339
343
  if (!this.canAdmitTask()) return;
344
+ this.#admitOrdered(taskFactory, false);
345
+ }
346
+
347
+ admitCheckpoint(taskFactory: () => Promise<void>): Promise<void> {
348
+ if (this.#state === "failed") return Promise.reject(this.#failure ?? new Error("Cursor request failed"));
349
+ return this.#admitOrdered(taskFactory, true);
350
+ }
351
+
352
+ #admitOrdered(taskFactory: () => Promise<void>, allowAfterSuccess: boolean): Promise<void> {
340
353
  const orderedTask = this.#hasAdmittedTask
341
354
  ? this.#taskChain.then(() => {
342
- if (this.#state === "failed" || this.#state === "succeeded") return;
355
+ if (this.#state === "failed" || (!allowAfterSuccess && this.#state === "succeeded")) return;
343
356
  return taskFactory();
344
357
  })
345
358
  : taskFactory();
@@ -355,6 +368,7 @@ class CursorRequestCoordinator implements CursorRequestWriter {
355
368
  () => this.#tasks.delete(orderedTask),
356
369
  () => this.#tasks.delete(orderedTask),
357
370
  );
371
+ return orderedTask;
358
372
  }
359
373
 
360
374
  turnEnded(): void {
@@ -651,6 +665,9 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
651
665
  let onAbort: (() => void) | undefined;
652
666
  let coordinator: CursorRequestCoordinator = undefined!;
653
667
  const baseUrl = model.baseUrl || CURSOR_API_URL;
668
+ let activeConversationId: string | undefined;
669
+ let previousConversationState: ConversationStateStructure | undefined;
670
+ let previousUsageContext: CursorUsageContext | undefined;
654
671
 
655
672
  try {
656
673
  const apiKey = options?.apiKey;
@@ -662,16 +679,22 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
662
679
  }
663
680
 
664
681
  const conversationId = options?.conversationId ?? options?.sessionId ?? crypto.randomUUID();
665
- const blobStore = conversationBlobStores.get(conversationId) ?? new Map<string, Uint8Array>();
666
- conversationBlobStores.set(conversationId, blobStore);
682
+ activeConversationId = conversationId;
683
+ const cachedBlobStore = conversationBlobStores.get(conversationId);
684
+ const blobStore = new Map(cachedBlobStore);
667
685
  const cachedState = conversationStateCache.get(conversationId);
686
+ previousConversationState = cachedState;
687
+ const usageContext = buildCursorUsageContext(context, model, options);
688
+ previousUsageContext = conversationUsageContextCache.get(conversationId);
689
+ conversationUsageContextCache.set(conversationId, usageContext);
690
+ const reusableCachedState =
691
+ cachedState && canReuseCursorUsageContext(previousUsageContext, usageContext) ? cachedState : undefined;
668
692
  const { requestBytes, conversationState } = buildGrpcRequest(model, context, options, {
669
693
  conversationId,
670
694
  blobStore,
671
- conversationState: cachedState,
695
+ conversationState: reusableCachedState,
672
696
  });
673
697
  conversationStateCache.set(conversationId, conversationState);
674
- touchCursorConversation(conversationId);
675
698
  const requestContextTools = buildMcpToolDefinitions(context.tools);
676
699
  const targetUrl = new URL(baseUrl);
677
700
  const proxyUrl = getProxyForUrl(model.provider, targetUrl);
@@ -709,6 +732,28 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
709
732
  };
710
733
  let resolveH2: (() => void) | undefined;
711
734
  let rejectH2: ((error: Error) => void) | undefined;
735
+ const inboundEnd = Promise.withResolvers<void>();
736
+ let inboundSettled = false;
737
+ let inboundTimeout: NodeJS.Timeout | undefined;
738
+ const settleInbound = (error?: Error) => {
739
+ if (inboundSettled) return;
740
+ inboundSettled = true;
741
+ if (inboundTimeout) {
742
+ clearTimeout(inboundTimeout);
743
+ inboundTimeout = undefined;
744
+ }
745
+ if (error) inboundEnd.reject(error);
746
+ else inboundEnd.resolve();
747
+ };
748
+ void inboundEnd.promise.catch(() => {});
749
+ const armInboundTimeout = () => {
750
+ const timeoutMs = options?.streamIdleTimeoutMs ?? 0;
751
+ if (timeoutMs <= 0 || inboundSettled) return;
752
+ inboundTimeout = setTimeout(
753
+ () => settleInbound(new Error("Cursor stream did not reach its inbound terminal frame")),
754
+ timeoutMs,
755
+ );
756
+ };
712
757
  coordinator = new CursorRequestCoordinator(
713
758
  h2Request,
714
759
  stopHeartbeat,
@@ -724,16 +769,32 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
724
769
  },
725
770
  options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs(),
726
771
  );
727
- h2Client.on("error", error => coordinator.fail(error));
728
- h2Request.on("error", error => coordinator.fail(error));
772
+ h2Client.on("error", error => {
773
+ settleInbound(error);
774
+ coordinator.fail(error);
775
+ });
776
+ h2Request.on("error", error => {
777
+ settleInbound(error);
778
+ coordinator.fail(error);
779
+ });
729
780
 
730
781
  stream.push({ type: "start", partial: output });
731
782
 
732
783
  let pendingBuffer = Buffer.alloc(0);
784
+ const checkpointTasks: Promise<void>[] = [];
733
785
  let currentTextBlock: (TextContent & { index: number }) | null = null;
734
786
  let currentThinkingBlock: (ThinkingContent & { index: number }) | null = null;
735
787
  let currentToolCall: ToolCallState | null = null;
736
- const usageState: UsageState = { sawTokenDelta: false };
788
+ const cachedConversationUsedTokens =
789
+ conversationState.tokenDetails && canReuseCursorUsageContext(previousUsageContext, usageContext)
790
+ ? conversationState.tokenDetails.usedTokens
791
+ : 0;
792
+ const usageState: UsageState = {
793
+ sawTokenDelta: false,
794
+ conversationUsedTokens: cachedConversationUsedTokens,
795
+ checkpointOutputTokens: 0,
796
+ hasConversationCheckpoint: false,
797
+ };
737
798
 
738
799
  const state: BlockState = {
739
800
  get currentTextBlock() {
@@ -763,8 +824,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
763
824
  };
764
825
 
765
826
  const onConversationCheckpoint = (checkpoint: ConversationStateStructure) => {
766
- conversationStateCache.set(conversationId, checkpoint);
767
- touchCursorConversation(conversationId);
827
+ usageState.pendingCheckpoint = checkpoint;
768
828
  };
769
829
 
770
830
  h2Request.on("trailers", trailers => {
@@ -775,11 +835,18 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
775
835
  }
776
836
  });
777
837
  h2Request.on("end", () => {
838
+ settleInbound();
778
839
  if (!coordinator.hasTurnEnded()) {
779
840
  coordinator.fail(new Error("Cursor stream ended before turnEnded"));
780
841
  }
781
842
  });
843
+ h2Request.on("close", () => {
844
+ const error = new Error("Cursor stream closed before inbound completion");
845
+ if (!inboundSettled) settleInbound(error);
846
+ coordinator.fail(error);
847
+ });
782
848
  onAbort = () => {
849
+ settleInbound(new Error("Request was aborted"));
783
850
  coordinator.fail(new Error("Request was aborted"));
784
851
  };
785
852
  if (options?.signal) {
@@ -813,6 +880,21 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
813
880
  const isTurnEnded =
814
881
  serverMessage.message.case === "interactionUpdate" &&
815
882
  serverMessage.message.value.message?.case === "turnEnded";
883
+ const isConversationCheckpoint = serverMessage.message.case === "conversationCheckpointUpdate";
884
+ if (isConversationCheckpoint) {
885
+ checkpointTasks.push(
886
+ coordinator.admitCheckpoint(() => {
887
+ handleConversationCheckpointUpdate(
888
+ serverMessage.message.value as ConversationStateStructure,
889
+ output,
890
+ usageState,
891
+ onConversationCheckpoint,
892
+ );
893
+ return Promise.resolve();
894
+ }),
895
+ );
896
+ continue;
897
+ }
816
898
  // Serialize handlers: exec messages can be asynchronous, and resolving the
817
899
  // request on turnEnded before prior handlers finish loses their responses.
818
900
  if (!coordinator.canAdmitTask()) continue;
@@ -866,6 +948,9 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
866
948
  resolve();
867
949
  }
868
950
  });
951
+ armInboundTimeout();
952
+ await inboundEnd.promise;
953
+ await Promise.all(checkpointTasks);
869
954
 
870
955
  if (state.currentTextBlock) {
871
956
  const idx = output.content.indexOf(state.currentTextBlock);
@@ -899,6 +984,36 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
899
984
  });
900
985
  }
901
986
 
987
+ finalizeCursorUsage(output, usageState);
988
+ const stateToCommit =
989
+ usageState.pendingCheckpoint ?? conversationStateCache.get(conversationId) ?? conversationState;
990
+ if (
991
+ usageState.pendingCheckpoint ||
992
+ usageState.hasConversationCheckpoint ||
993
+ usageState.conversationUsedTokens > 0
994
+ ) {
995
+ conversationStateCache.set(
996
+ conversationId,
997
+ create(ConversationStateStructureSchema, {
998
+ ...stateToCommit,
999
+ ...(usageState.hasConversationCheckpoint || usageState.conversationUsedTokens > 0
1000
+ ? {
1001
+ tokenDetails: create(ConversationTokenDetailsSchema, {
1002
+ usedTokens: output.usage.totalTokens,
1003
+ maxTokens: stateToCommit.tokenDetails?.maxTokens ?? 0,
1004
+ }),
1005
+ }
1006
+ : {}),
1007
+ }),
1008
+ );
1009
+ touchCursorConversation(conversationId);
1010
+ }
1011
+ conversationUsageContextCache.set(conversationId, {
1012
+ ...usageContext,
1013
+ messageKeys: [...usageContext.messageKeys, hashCursorUsageMessage(output)],
1014
+ });
1015
+ conversationBlobStores.set(conversationId, blobStore);
1016
+ touchCursorConversation(conversationId);
902
1017
  calculateCost(model, output.usage);
903
1018
 
904
1019
  output.duration = Date.now() - startTime;
@@ -910,6 +1025,12 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
910
1025
  });
911
1026
  stream.end();
912
1027
  } catch (error) {
1028
+ if (activeConversationId) {
1029
+ if (previousConversationState) conversationStateCache.set(activeConversationId, previousConversationState);
1030
+ else conversationStateCache.delete(activeConversationId);
1031
+ if (previousUsageContext) conversationUsageContextCache.set(activeConversationId, previousUsageContext);
1032
+ else conversationUsageContextCache.delete(activeConversationId);
1033
+ }
913
1034
  // Keep the completion promise terminal even for synchronous setup/write
914
1035
  // failures that may not emit a separate HTTP/2 error event.
915
1036
  const mappedError = mapH2TransportError(coordinator?.failureError() ?? error, baseUrl);
@@ -959,6 +1080,24 @@ interface BlockState {
959
1080
 
960
1081
  interface UsageState {
961
1082
  sawTokenDelta: boolean;
1083
+ /**
1084
+ * Latest `ConversationTokenDetails.used_tokens`: the whole conversation's
1085
+ * token consumption as counted by Cursor, not this turn's output.
1086
+ */
1087
+ conversationUsedTokens: number;
1088
+ /** Output tokens already included in the latest checkpoint snapshot. */
1089
+ checkpointOutputTokens: number;
1090
+ /** Whether the current stream received a checkpoint, including an explicit zero. */
1091
+ hasConversationCheckpoint: boolean;
1092
+ pendingCheckpoint?: ConversationStateStructure;
1093
+ }
1094
+
1095
+ interface CursorUsageContext {
1096
+ modelKey: string;
1097
+ systemPromptKey: string;
1098
+ customSystemPromptKey: string;
1099
+ toolsKey: string;
1100
+ messageKeys: string[];
962
1101
  }
963
1102
 
964
1103
  async function handleServerMessage(
@@ -2855,17 +2994,58 @@ function handleConversationCheckpointUpdate(
2855
2994
  onConversationCheckpoint?: (checkpoint: ConversationStateStructure) => void,
2856
2995
  ): void {
2857
2996
  onConversationCheckpoint?.(checkpoint);
2858
- if (usageState.sawTokenDelta) {
2859
- return;
2860
- }
2861
2997
  const usedTokens = checkpoint.tokenDetails?.usedTokens ?? 0;
2862
- if (usedTokens <= 0) {
2998
+ if (!checkpoint.tokenDetails) {
2863
2999
  return;
2864
3000
  }
2865
- if (output.usage.output !== usedTokens) {
2866
- output.usage.output = usedTokens;
2867
- output.usage.totalTokens = output.usage.input + output.usage.output;
3001
+ const previousUsedTokens = usageState.conversationUsedTokens;
3002
+ // `used_tokens` counts the whole conversation, so it is prompt-side usage and
3003
+ // must not be attributed to this turn's output. Checkpoints can arrive while
3004
+ // output is still streaming; the split is applied once the stream finalizes.
3005
+ usageState.conversationUsedTokens = usedTokens;
3006
+ usageState.checkpointOutputTokens =
3007
+ usageState.hasConversationCheckpoint && usedTokens < previousUsedTokens ? 0 : output.usage.output;
3008
+ usageState.hasConversationCheckpoint = true;
3009
+ }
3010
+
3011
+ /**
3012
+ * Cursor streams output tokens as deltas and reports whole-conversation
3013
+ * consumption separately as `ConversationTokenDetails.used_tokens`. Derive
3014
+ * prompt tokens from the difference so context accounting and compaction see a
3015
+ * real prompt size instead of zero.
3016
+ */
3017
+ export function finalizeCursorUsage(output: AssistantMessage, usageState: UsageState): void {
3018
+ const used = usageState.conversationUsedTokens;
3019
+ if (!usageState.hasConversationCheckpoint && used <= 0) {
3020
+ return;
2868
3021
  }
3022
+ const outputIncludedInSnapshot = usageState.hasConversationCheckpoint ? usageState.checkpointOutputTokens : 0;
3023
+ output.usage.input = Math.max(0, used - outputIncludedInSnapshot);
3024
+ output.usage.totalTokens = output.usage.input + output.usage.output;
3025
+ }
3026
+
3027
+ /** Exposes {@link finalizeCursorUsage} for tests without a live HTTP/2 stream. */
3028
+ export function finalizeCursorUsageForTest(
3029
+ usedTokens: number,
3030
+ outputTokens: number,
3031
+ options: { checkpointOutputTokens?: number; hasConversationCheckpoint?: boolean } = {},
3032
+ ): Usage {
3033
+ const usage: Usage = {
3034
+ input: 0,
3035
+ output: outputTokens,
3036
+ cacheRead: 0,
3037
+ cacheWrite: 0,
3038
+ totalTokens: outputTokens,
3039
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
3040
+ };
3041
+ finalizeCursorUsage({ usage } as AssistantMessage, {
3042
+ sawTokenDelta: true,
3043
+ conversationUsedTokens: usedTokens,
3044
+ checkpointOutputTokens:
3045
+ options.checkpointOutputTokens ?? ((options.hasConversationCheckpoint ?? usedTokens > 0) ? outputTokens : 0),
3046
+ hasConversationCheckpoint: options.hasConversationCheckpoint ?? usedTokens > 0,
3047
+ });
3048
+ return usage;
2869
3049
  }
2870
3050
 
2871
3051
  function createBlobId(data: Uint8Array): Uint8Array {
@@ -3188,6 +3368,43 @@ function buildConversationTurns(messages: Message[], blobStore: Map<string, Uint
3188
3368
  return turns;
3189
3369
  }
3190
3370
 
3371
+ function buildCursorUsageContext(
3372
+ context: Context,
3373
+ model: Model<"cursor-agent">,
3374
+ options: CursorOptions | undefined,
3375
+ ): CursorUsageContext {
3376
+ return {
3377
+ modelKey: hashCursorUsageValue({ provider: model.provider, id: model.id, wireModelId: model.wireModelId }),
3378
+ systemPromptKey: hashCursorUsageValue(context.systemPrompt ?? []),
3379
+ customSystemPromptKey: hashCursorUsageValue(options?.customSystemPrompt ?? ""),
3380
+ toolsKey: hashCursorUsageValue(context.tools ?? []),
3381
+ messageKeys: context.messages.map(message => hashCursorUsageMessage(message)),
3382
+ };
3383
+ }
3384
+
3385
+ function hashCursorUsageMessage(message: { role: string; content: unknown }): string {
3386
+ return hashCursorUsageValue({ role: message.role, content: message.content });
3387
+ }
3388
+
3389
+ function hashCursorUsageValue(value: unknown): string {
3390
+ return createHash("sha256")
3391
+ .update(JSON.stringify(value) ?? "")
3392
+ .digest("hex");
3393
+ }
3394
+
3395
+ function canReuseCursorUsageContext(previous: CursorUsageContext | undefined, current: CursorUsageContext): boolean {
3396
+ if (
3397
+ !previous ||
3398
+ previous.modelKey !== current.modelKey ||
3399
+ previous.systemPromptKey !== current.systemPromptKey ||
3400
+ previous.customSystemPromptKey !== current.customSystemPromptKey ||
3401
+ previous.toolsKey !== current.toolsKey
3402
+ )
3403
+ return false;
3404
+ if (previous.messageKeys.length > current.messageKeys.length) return false;
3405
+ return previous.messageKeys.every((key, index) => key === current.messageKeys[index]);
3406
+ }
3407
+
3191
3408
  /** Exported for tests: decodes Cursor history blobs built from conversation messages. */
3192
3409
  export function buildCursorHistoryForTest(messages: Message[]): {
3193
3410
  rootPromptMessagesJson: unknown[];
@@ -51,6 +51,10 @@ import {
51
51
  normalizeSystemPrompts,
52
52
  sanitizeOpenAIResponsesHistoryItemsForReplay,
53
53
  } from "../utils";
54
+ import {
55
+ formatOpenAICodexChatGPTEntitlementError,
56
+ isOpenAICodexChatGPTEntitlementError,
57
+ } from "../utils/codex-entitlement";
54
58
  import { AssistantMessageEventStream } from "../utils/event-stream";
55
59
  import { STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE, transportFailureFacts } from "../utils/fallback-transport";
56
60
  import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
@@ -1234,7 +1238,7 @@ function handleCodexStreamEvent(args: {
1234
1238
  }
1235
1239
 
1236
1240
  if (eventType === "error" || eventType === "response.failed") {
1237
- throw createCodexProviderStreamError(rawEvent);
1241
+ throw createCodexProviderStreamError(rawEvent, model.id);
1238
1242
  }
1239
1243
 
1240
1244
  return firstTokenTime;
@@ -2853,7 +2857,11 @@ async function openCodexSseEventStream(
2853
2857
  updateCodexSessionMetadataFromHeaders(state, response.headers);
2854
2858
  if (!response.ok) {
2855
2859
  const info = await parseCodexError(response);
2856
- const error = new Error(info.friendlyMessage || info.message);
2860
+ const error = new Error(
2861
+ isOpenAICodexChatGPTEntitlementError(info.message, info.code)
2862
+ ? formatOpenAICodexChatGPTEntitlementError(body.model)
2863
+ : info.friendlyMessage || info.message,
2864
+ );
2857
2865
  (error as { headers?: Headers; status?: number }).headers = response.headers;
2858
2866
  (error as { headers?: Headers; status?: number }).status = response.status;
2859
2867
  (error as { code?: string }).code = info.code;
@@ -3235,11 +3243,12 @@ function isRetryableCodexFailureEvent(rawEvent: Record<string, unknown>): boolea
3235
3243
  return !!message && CODEX_RETRYABLE_EVENT_MESSAGE.test(message);
3236
3244
  }
3237
3245
 
3238
- function createCodexProviderStreamError(rawEvent: Record<string, unknown>): CodexProviderStreamError {
3246
+ function createCodexProviderStreamError(rawEvent: Record<string, unknown>, modelId: string): CodexProviderStreamError {
3239
3247
  const code = getCodexEventErrorCode(rawEvent);
3240
3248
  const message = getCodexEventErrorMessage(rawEvent);
3241
- const formattedMessage =
3242
- typeof rawEvent.type === "string" && rawEvent.type === "error"
3249
+ const formattedMessage = isOpenAICodexChatGPTEntitlementError(message, code)
3250
+ ? formatOpenAICodexChatGPTEntitlementError(modelId)
3251
+ : typeof rawEvent.type === "string" && rawEvent.type === "error"
3243
3252
  ? formatCodexErrorEvent(rawEvent, code, message)
3244
3253
  : (formatCodexFailure(rawEvent) ?? "Codex response failed");
3245
3254
  return new CodexProviderStreamError(
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Model entitlement facts shared by Codex credential selection and provider
3
+ * error presentation.
4
+ *
5
+ * GPT-5.6 Sol is a Pro-tier ChatGPT Codex model. The usage endpoint is the
6
+ * authority for the account tier; this module only names the model policy and
7
+ * keeps the provider's deterministic rejection wording in one place.
8
+ */
9
+ export declare function requiresOpenAICodexProModel(provider: string, modelId: string | undefined): boolean;
10
+ export declare function requiresStrictOpenAICodexProModel(provider: string, modelId: string | undefined): boolean;
11
+ export declare function isOpenAICodexChatGPTEntitlementError(message: string | undefined, code?: string): boolean;
12
+ export declare function formatOpenAICodexChatGPTEntitlementError(modelId: string | undefined): string;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Model entitlement facts shared by Codex credential selection and provider
3
+ * error presentation.
4
+ *
5
+ * GPT-5.6 Sol is a Pro-tier ChatGPT Codex model. The usage endpoint is the
6
+ * authority for the account tier; this module only names the model policy and
7
+ * keeps the provider's deterministic rejection wording in one place.
8
+ */
9
+
10
+ export function requiresOpenAICodexProModel(provider: string, modelId: string | undefined): boolean {
11
+ return (
12
+ provider === "openai-codex" &&
13
+ typeof modelId === "string" &&
14
+ (modelId.toLowerCase().includes("-spark") || modelId.toLowerCase() === "gpt-5.6-sol")
15
+ );
16
+ }
17
+
18
+ export function requiresStrictOpenAICodexProModel(provider: string, modelId: string | undefined): boolean {
19
+ return provider === "openai-codex" && modelId?.toLowerCase() === "gpt-5.6-sol";
20
+ }
21
+
22
+ export function isOpenAICodexChatGPTEntitlementError(message: string | undefined, code?: string): boolean {
23
+ return (
24
+ /\bnot supported when using codex with a chatgpt account\b/i.test(message ?? "") &&
25
+ (code === undefined || code.toLowerCase() === "invalid_request_error")
26
+ );
27
+ }
28
+
29
+ export function formatOpenAICodexChatGPTEntitlementError(modelId: string | undefined): string {
30
+ const safeModelId = modelId
31
+ ?.replace(/[\x00-\x1f\x7f-\x9f]+/gu, " ")
32
+ .trim()
33
+ .slice(0, 128);
34
+ const model = safeModelId ? ` model "${safeModelId}"` : " model";
35
+ return `This ChatGPT Codex account cannot use${model}. Select a model available to this ChatGPT account, such as "gpt-5.5", or use an API-key credential that supports the model.`;
36
+ }
@@ -218,13 +218,22 @@ export async function fetchAntigravityDiscoveryModels(
218
218
  continue;
219
219
  }
220
220
 
221
+ const surfacedModelIds = new Set<string>();
222
+ for (const sort of parsed.agentModelSorts ?? []) {
223
+ for (const group of sort.groups ?? []) {
224
+ for (const modelId of group.modelIds ?? []) {
225
+ surfacedModelIds.add(modelId);
226
+ }
227
+ }
228
+ }
229
+
221
230
  const models: Model<"google-gemini-cli">[] = [];
222
231
 
223
232
  for (const [modelId, model] of Object.entries(parsed.models ?? {})) {
224
233
  if (ANTIGRAVITY_DISCOVERY_DENYLIST.has(modelId) || isRetiredModelKey(targetProvider, modelId)) {
225
234
  continue;
226
235
  }
227
- if (model.isInternal === true) {
236
+ if (model.isInternal === true && !surfacedModelIds.has(modelId)) {
228
237
  continue;
229
238
  }
230
239
 
@@ -52,6 +52,44 @@ export function isSafeCatalogModelId(value: unknown): value is string {
52
52
  );
53
53
  }
54
54
 
55
+ /**
56
+ * The two wire families a mixed OpenAI-compatible gateway (e.g. CLIProxyAPI)
57
+ * can front. A gateway exposes an OpenAI-shaped `/v1/models` catalog but may
58
+ * proxy Anthropic models that must be driven through the Anthropic Messages
59
+ * transport rather than OpenAI Chat Completions.
60
+ */
61
+ export type DiscoveredApiFamily = "anthropic-messages" | "openai-completions";
62
+
63
+ const ANTHROPIC_OWNER_PATTERN = /\banthropic\b/i;
64
+ const OPENAI_OWNER_PATTERN = /\b(openai|open-ai)\b/i;
65
+ // Anthropic model ids are consistently `claude-*` across every gateway; the
66
+ // `owned_by` owner string is the primary signal and the id is the fallback.
67
+ const ANTHROPIC_MODEL_ID_PATTERN = /(^|[/:])claude[-.]/i;
68
+ const OPENAI_MODEL_ID_PATTERN = /(^|[/:])(gpt[-.]?|o[1-9]|codex|text-|chatgpt|davinci|dall-e|gpt-image|whisper|tts-)/i;
69
+
70
+ /**
71
+ * Infer the wire API family for one discovered model on a mixed
72
+ * OpenAI-compatible gateway.
73
+ *
74
+ * Uses the `owned_by` owner string first (authoritative when the gateway
75
+ * populates it — `"anthropic"` / `"openai"`), then falls back to the model id
76
+ * (`claude-*` → Anthropic, `gpt-*`/`o1`/`codex`/… → OpenAI). Returns
77
+ * `undefined` when neither signal is conclusive so the caller can keep the
78
+ * provider-level default instead of guessing.
79
+ */
80
+ export function detectDiscoveredApiFamily(entry: {
81
+ id?: unknown;
82
+ owned_by?: unknown;
83
+ }): DiscoveredApiFamily | undefined {
84
+ const owner = typeof entry.owned_by === "string" ? entry.owned_by : "";
85
+ if (ANTHROPIC_OWNER_PATTERN.test(owner)) return "anthropic-messages";
86
+ if (OPENAI_OWNER_PATTERN.test(owner)) return "openai-completions";
87
+ const id = typeof entry.id === "string" ? entry.id : "";
88
+ if (ANTHROPIC_MODEL_ID_PATTERN.test(id)) return "anthropic-messages";
89
+ if (OPENAI_MODEL_ID_PATTERN.test(id)) return "openai-completions";
90
+ return undefined;
91
+ }
92
+
55
93
  /**
56
94
  * Minimal OpenAI-style model entry shape consumed by discovery.
57
95
  *
@@ -14,6 +14,13 @@
14
14
  * or `ConnectionClosed` rather than `HTTP2Unsupported`, so we treat those
15
15
  * codes as h2-fallback triggers as well.
16
16
  *
17
+ * ALPN-refusing hosts (notably zcode.z.ai, the GLM ZCode OAuth broker) abort
18
+ * the TLS handshake entirely when the client offers ALPN h2. Bun reports that
19
+ * abort as `UNKNOWN_CERTIFICATE_VERIFICATION_ERROR` even though the host's
20
+ * certificate chain verifies fine over h1 (issue #5178), so that code is a
21
+ * fallback trigger too — never a reason to accept a bad certificate: the h1
22
+ * attempt below performs full verification on its own.
23
+ *
17
24
  * Bun negotiates h2 via ALPN over TLS only (no h2c), so plain `http://` URLs
18
25
  * skip the attempt entirely — avoids the throw/retry round-trip for localhost.
19
26
  *
@@ -36,6 +43,9 @@ export function installH2Fetch(): void {
36
43
  "ConnectionRefused", // Server refused the h2 connection
37
44
  "ConnectionReset", // Server reset during h2 handshake
38
45
  "ConnectionClosed", // Server closed before h2 response
46
+ // Bun's h2 client reports an ALPN-refusing host's TLS abort with this
47
+ // code; the h1 fallback below re-verifies the certificate itself.
48
+ "UNKNOWN_CERTIFICATE_VERIFICATION_ERROR",
39
49
  ]);
40
50
  const wrapper = async function h2fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
41
51
  if (!isHttps(input)) return original(input, init);
@@ -19,6 +19,13 @@ const DEFAULT_TIMEOUT = 300_000;
19
19
  const DEFAULT_HOSTNAME = "localhost";
20
20
  const CALLBACK_PATH = "/callback";
21
21
 
22
+ function serializeScriptData(value: unknown): string {
23
+ return JSON.stringify(value)
24
+ .replaceAll("<", "\\u003c")
25
+ .replaceAll("\u2028", "\\u2028")
26
+ .replaceAll("\u2029", "\\u2029");
27
+ }
28
+
22
29
  export type CallbackResult = { code: string; state: string };
23
30
 
24
31
  export interface OAuthCallbackFlowOptions {
@@ -259,7 +266,7 @@ export abstract class OAuthCallbackFlow {
259
266
  });
260
267
 
261
268
  return new Response(
262
- (templateHtml as unknown as string).replaceAll("__OAUTH_STATE__", JSON.stringify(resultState)),
269
+ (templateHtml as unknown as string).replaceAll("__OAUTH_STATE__", () => serializeScriptData(resultState)),
263
270
  {
264
271
  status: resultState.ok ? 200 : 500,
265
272
  headers: { "Content-Type": "text/html" },
@@ -387,7 +387,7 @@ export class GlmZcodeOAuthFlow extends OAuthCallbackFlow {
387
387
  return {
388
388
  url: `${authorizeUrl}?${params.toString()}`,
389
389
  instructions:
390
- "Complete Z.AI login in your browser. This is an UNOFFICIAL ZCode-based login — use at your own risk; it may stop working or violate ZCode/Z.AI Terms of Service. Because this CLI cannot receive the zcode:// redirect, paste the final redirect URL or authorization code when prompted.",
390
+ "Complete Z.AI login in your browser. This is an UNOFFICIAL ZCode-based login — use at your own risk; it may stop working or violate ZCode/Z.AI Terms of Service. Because this CLI cannot receive the zcode:// redirect, paste the final redirect URL or authorization code when prompted. If the ZCode desktop app is installed, cancel the browser's prompt to open it: the app exchanges the single-use code itself and the pasted code is then rejected (broker error 2007).",
391
391
  };
392
392
  }
393
393