@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 +24 -0
- package/dist/types/provider-models/openai-compat.d.ts +1 -0
- package/dist/types/providers/cursor.d.ts +27 -1
- package/dist/types/utils/codex-entitlement.d.ts +12 -0
- package/dist/types/utils/discovery/openai-compatible.d.ts +21 -0
- package/dist/types/utils/h2-fetch.d.ts +7 -0
- package/package.json +3 -3
- package/src/auth-broker/server.ts +10 -1
- package/src/auth-storage.ts +18 -4
- package/src/provider-models/openai-compat.ts +32 -8
- package/src/providers/cursor.d.ts +27 -1
- package/src/providers/cursor.ts +234 -17
- package/src/providers/openai-codex-responses.ts +14 -5
- package/src/utils/codex-entitlement.d.ts +12 -0
- package/src/utils/codex-entitlement.ts +36 -0
- package/src/utils/discovery/antigravity.ts +10 -1
- package/src/utils/discovery/openai-compatible.ts +38 -0
- package/src/utils/h2-fetch.ts +10 -0
- package/src/utils/oauth/callback-server.ts +8 -1
- package/src/utils/oauth/glm-zcode.ts +1 -1
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
|
|
@@ -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.
|
|
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.
|
|
44
|
-
"@gajae-code/utils": "0.16.
|
|
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" });
|
package/src/auth-storage.ts
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
package/src/providers/cursor.ts
CHANGED
|
@@ -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
|
-
|
|
666
|
-
conversationBlobStores.
|
|
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:
|
|
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 =>
|
|
728
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
2998
|
+
if (!checkpoint.tokenDetails) {
|
|
2863
2999
|
return;
|
|
2864
3000
|
}
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
*
|
package/src/utils/h2-fetch.ts
CHANGED
|
@@ -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__",
|
|
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
|
|