jeopi-ai 16.4.2 → 16.4.4
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 +28 -0
- package/dist/types/auth-broker/discover.d.ts +2 -1
- package/dist/types/auth-broker/remote-store.d.ts +2 -0
- package/dist/types/auth-gateway/http.d.ts +18 -1
- package/dist/types/auth-storage.d.ts +4 -0
- package/dist/types/error/flags.d.ts +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/providers/openai-chat-server-schema.d.ts +3 -3
- package/dist/types/providers/openai-shared.d.ts +2 -0
- package/dist/types/usage/cursor.d.ts +3 -0
- package/dist/types/usage/openai-codex-reset.d.ts +1 -1
- package/package.json +4 -4
- package/src/auth-broker/discover.ts +37 -16
- package/src/auth-broker/remote-store.ts +7 -0
- package/src/auth-gateway/http.ts +35 -2
- package/src/auth-gateway/server.ts +23 -3
- package/src/auth-storage.ts +66 -38
- package/src/error/flags.ts +9 -0
- package/src/error/provider.ts +7 -6
- package/src/index.ts +1 -0
- package/src/providers/cursor.ts +10 -1
- package/src/providers/openai-chat-server-schema.ts +2 -1
- package/src/providers/openai-codex/request-transformer.ts +1 -1
- package/src/providers/openai-codex-responses.ts +2 -3
- package/src/providers/openai-responses-server.ts +8 -2
- package/src/providers/openai-shared.ts +14 -4
- package/src/registry/oauth/callback-server.ts +21 -1
- package/src/registry/oauth/oauth.html +1 -1
- package/src/usage/cursor.ts +169 -0
- package/src/usage/openai-codex-reset.ts +6 -2
- package/src/utils/schema/normalize.ts +15 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.4.4] - 2026-08-03
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Escaped OAuth callback state before embedding it in the HTML script payload, preventing callback error text from breaking out of the response document.
|
|
10
|
+
|
|
11
|
+
## [16.4.3] - 2026-07-22
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Added diagnostic response headers to auth-gateway inference endpoints: `x-request-id`/`request-id` (correlates with gateway logs; surfaced by OpenAI/Anthropic SDKs) and LiteLLM-style `x-litellm-model-id`/`x-litellm-model-api-base` on every response, plus `x-litellm-response-cost`, `x-litellm-response-duration-ms`, and `openai-processing-ms` on non-streaming responses. Ported from oh-my-pi (upstream `3e5b7da6f`).
|
|
16
|
+
- Added Cursor OAuth and access-token usage reporting to `jeopi usage` via Cursor's account usage endpoint. Ported from oh-my-pi (upstream `965f5b0bb`).
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- OpenAI Responses server non-streaming envelopes always include the required `incomplete_details` field now, using `null` for completed responses instead of omitting the key. Ported from oh-my-pi (upstream `3188506e6`).
|
|
21
|
+
- Responses API tool results with genuinely empty content (e.g. an empty file read) no longer serialize as `"(see attached image)"` — the placeholder is now only emitted when the result actually carries an image block. Ported from oh-my-pi (upstream `31c9f4850`).
|
|
22
|
+
- Preserved Cloud Code Assist tool schemas when mixed-type unions carry branch-local validation descriptions: a differing `description` between merged union variants (or between the merged variant and the parent schema) now concatenates instead of aborting the collapse and leaving the schema on the incompatible `anyOf`/`oneOf` shape. Ported from oh-my-pi (upstream `3f52e26a7`).
|
|
23
|
+
- Fixed OAuth credential resolution returning "No API key found" when the only Pro-eligible OpenAI Codex account was usage-blocked and the sole unblocked account failed the model's Pro gate: resolution now runs a last-resort ladder that first yields a Pro-fitting account regardless of usage blocks (so callers get real usage-limit retry semantics instead of a missing key), then tries every account with the Pro filter dropped before reporting no credential. Ported from oh-my-pi (upstream `7cef4a769`, adapted to jeopi's Pro-only `enforceProRequirement` gate — jeopi has not adopted upstream's more general multi-tier `planRequirement` abstraction this fix originally targeted).
|
|
24
|
+
- Fixed empty provider responses (e.g. "Cloud Code Assist API returned an empty response") being classified as non-retryable: `ProviderResponseError` with kind `empty-body` now carries the transient flag, so session retry and configured model-fallback chains engage instead of hard-failing the turn. Ported from oh-my-pi (upstream `fabded89e`).
|
|
25
|
+
- Fixed the OAuth completion page copy to tell users they can close the tab manually when browsers such as Firefox ignore best-effort `window.close()` calls. Ported from oh-my-pi (upstream `5e781a9c7`).
|
|
26
|
+
- Fixed Cursor `max_mode` requests to send discovered max-mode metadata on both model payload fields (`modelDetails.maxMode` and the new `requestedModel`), so premium models that require max mode no longer silently run without it. Ported from oh-my-pi (upstream `358811115`).
|
|
27
|
+
- Fixed provider credential changes leaving persisted session-sticky OAuth credential mappings active, so existing sessions reselect accounts after login/logout instead of reusing stale `session:sticky:<provider>:<sessionId>` rows: `#resetProviderAssignments` (called on every credential add/remove/login) now also purges every persisted sticky cache row for that provider via a new `deleteCachePrefix` store method, implemented for both `SqliteAuthCredentialStore` (SQL `substr` prefix match) and `RemoteAuthCredentialStore` (in-memory prefix scan). Other providers' sticky rows are untouched. Ported from oh-my-pi (upstream `7029789e7`).
|
|
28
|
+
- Fixed Codex saved-reset redemption to include the selected account in the consume request body, so `/usage reset` applies to the chosen OpenAI account in multi-account setups instead of an ambiguous default. Ported from oh-my-pi (upstream `1d9889810`).
|
|
29
|
+
- Fixed OpenAI Responses `content_filter` terminal events being auto-retried as provider finish errors; `kind: "content-blocked"` provider errors now classify under a dedicated `Flag.ContentBlocked` (kept out of the retriable set) instead of reusing `Flag.ProviderFinishError` (which is retriable), so a safety-filter block stays a hard failure without a same-model retry loop. Ported from oh-my-pi (upstream `c95a2b993` by @belchetz).
|
|
30
|
+
- Fixed OpenAI Chat Completions request parsing to accept assistant tool-call replay messages with `content: null` as absent content, instead of rejecting the whole request when a client (or the gateway's own prior response) round-trips a `null` content field alongside `tool_calls`. Ported from oh-my-pi (upstream `38af95646`).
|
|
31
|
+
- Fixed auth-broker config discovery to accept nested `auth.broker.url` and `auth.broker.token` YAML keys from either `config.yml` or `config.yaml`, while preserving the legacy flat dotted form (nested values win). ([#4734](https://github.com/can1357/oh-my-pi/issues/4734), upstream `b190a3c15` and `5c16dcb15`).
|
|
32
|
+
|
|
5
33
|
## [16.2.28] - 2026-07-08
|
|
6
34
|
|
|
7
35
|
### Fixed
|
|
@@ -19,7 +19,8 @@ export declare function getAuthBrokerTokenFilePath(): string;
|
|
|
19
19
|
* Resolve broker connection configuration using the same precedence as the TUI:
|
|
20
20
|
*
|
|
21
21
|
* 1. `JEOPI_AUTH_BROKER_URL` / `JEOPI_AUTH_BROKER_TOKEN` env vars.
|
|
22
|
-
* 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml
|
|
22
|
+
* 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml` or
|
|
23
|
+
* `<agentDir>/config.yaml`.
|
|
23
24
|
* 3. `<config-root>/auth-broker.token` file (paired with a URL from env/config).
|
|
24
25
|
*
|
|
25
26
|
* Returns `null` when no broker URL is configured — callers should fall back to
|
|
@@ -73,6 +73,8 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
73
73
|
deleteAuthCredentialsRemote(provider: string, disabledCause: string): Promise<void>;
|
|
74
74
|
getCache(key: string): string | null;
|
|
75
75
|
setCache(key: string, value: string, expiresAtSec: number): void;
|
|
76
|
+
/** Drop all cache rows whose keys start with the supplied prefix. */
|
|
77
|
+
deleteCachePrefix(prefix: string): void;
|
|
76
78
|
cleanExpiredCache(): void;
|
|
77
79
|
/**
|
|
78
80
|
* Store-level hook consumed by `AuthStorage` — routes refresh through the
|
|
@@ -1,4 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
import type { Api, AssistantMessage, Model } from "../types";
|
|
2
|
+
export declare function json(status: number, body: unknown, headers?: Record<string, string>): Response;
|
|
3
|
+
/**
|
|
4
|
+
* Diagnostic response headers for translated inference requests, mirroring the
|
|
5
|
+
* names existing gateway-aware clients already parse: `x-request-id` /
|
|
6
|
+
* `request-id` (surfaced as `_request_id` by the OpenAI and Anthropic SDKs,
|
|
7
|
+
* matches the gateway log line), LiteLLM's model-resolution and cost headers,
|
|
8
|
+
* and OpenAI's `openai-processing-ms`. Model/request-id headers are always
|
|
9
|
+
* present; `message` — the final assistant message, available only on
|
|
10
|
+
* non-streaming responses — adds the computed cost, and `startedAt` the wall
|
|
11
|
+
* time. Streaming responses send headers before usage exists, so they carry
|
|
12
|
+
* only the identity headers.
|
|
13
|
+
*/
|
|
14
|
+
export declare function gatewayResponseHeaders(model: Model<Api>, info: {
|
|
15
|
+
requestId: string;
|
|
16
|
+
message?: AssistantMessage;
|
|
17
|
+
startedAt?: number;
|
|
18
|
+
}): Record<string, string>;
|
|
2
19
|
export declare function resolvePeer(req: Request): string;
|
|
3
20
|
/**
|
|
4
21
|
* Constant-time byte comparison. Falls back to a manual XOR accumulator if
|
|
@@ -226,6 +226,8 @@ export interface AuthCredentialStore {
|
|
|
226
226
|
includeExpired?: boolean;
|
|
227
227
|
}): string | null;
|
|
228
228
|
setCache(key: string, value: string, expiresAtSec: number): void;
|
|
229
|
+
/** Drop all cache rows whose keys start with the supplied prefix. */
|
|
230
|
+
deleteCachePrefix?(prefix: string): void;
|
|
229
231
|
cleanExpiredCache(): void;
|
|
230
232
|
/**
|
|
231
233
|
* Append usage-limit snapshots for trend history. Optional: stores without
|
|
@@ -992,6 +994,8 @@ export declare class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
992
994
|
includeExpired?: boolean;
|
|
993
995
|
}): string | null;
|
|
994
996
|
setCache(key: string, value: string, expiresAtSec: number): void;
|
|
997
|
+
/** Drop all cache rows whose keys start with the supplied prefix. */
|
|
998
|
+
deleteCachePrefix(prefix: string): void;
|
|
995
999
|
cleanExpiredCache(): void;
|
|
996
1000
|
recordUsageSnapshots(entries: UsageHistoryEntry[]): void;
|
|
997
1001
|
listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[];
|
|
@@ -8,6 +8,7 @@ export declare const Flag: {
|
|
|
8
8
|
readonly StaleResponsesItem: 1048576;
|
|
9
9
|
readonly MalformedFunctionCall: 2097152;
|
|
10
10
|
readonly ProviderFinishError: 4194304;
|
|
11
|
+
readonly ContentBlocked: 32768;
|
|
11
12
|
readonly ContextOverflow: 8388608;
|
|
12
13
|
readonly AuthFailed: 16777216;
|
|
13
14
|
readonly SilentAbort: 33554432;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export * from "./stream";
|
|
|
29
29
|
export * from "./types";
|
|
30
30
|
export * from "./usage";
|
|
31
31
|
export * from "./usage/claude";
|
|
32
|
+
export * from "./usage/cursor";
|
|
32
33
|
export * from "./usage/gemini";
|
|
33
34
|
export * from "./usage/github-copilot";
|
|
34
35
|
export * from "./usage/google-antigravity";
|
|
@@ -232,7 +232,7 @@ export declare const assistantMessageSchema: import("arktype/internal/variants/o
|
|
|
232
232
|
refusal: string;
|
|
233
233
|
} | {
|
|
234
234
|
type: string;
|
|
235
|
-
})[] | undefined;
|
|
235
|
+
})[] | null | undefined;
|
|
236
236
|
tool_calls?: {
|
|
237
237
|
id: string;
|
|
238
238
|
type?: "function" | undefined;
|
|
@@ -404,7 +404,7 @@ export declare const messageSchema: import("arktype/internal/variants/object.ts"
|
|
|
404
404
|
refusal: string;
|
|
405
405
|
} | {
|
|
406
406
|
type: string;
|
|
407
|
-
})[] | undefined;
|
|
407
|
+
})[] | null | undefined;
|
|
408
408
|
tool_calls?: {
|
|
409
409
|
id: string;
|
|
410
410
|
type?: "function" | undefined;
|
|
@@ -581,7 +581,7 @@ export declare const openaiChatRequestSchema: import("arktype/internal/variants/
|
|
|
581
581
|
refusal: string;
|
|
582
582
|
} | {
|
|
583
583
|
type: string;
|
|
584
|
-
})[] | undefined;
|
|
584
|
+
})[] | null | undefined;
|
|
585
585
|
tool_calls?: {
|
|
586
586
|
id: string;
|
|
587
587
|
type?: "function" | undefined;
|
|
@@ -385,6 +385,8 @@ export declare function appendReasoningSummaryTextDelta(item: ResponseReasoningI
|
|
|
385
385
|
export declare function appendReasoningSummaryPartDone(item: ResponseReasoningItem, block: ThinkingContent, stream: AssistantMessageEventStream, output: AssistantMessage, contentIndex: number): void;
|
|
386
386
|
export declare function appendMessageContentPart(item: ResponseOutputMessage, part: ResponseContentPartAddedEvent["part"] | undefined): void;
|
|
387
387
|
export declare function appendMessageTextDelta(item: ResponseOutputMessage, block: TextContent, delta: string, stream: AssistantMessageEventStream, output: AssistantMessage, contentIndex: number, partType: "output_text" | "refusal"): void;
|
|
388
|
+
/** Chooses final message text while treating non-empty terminal content as authoritative. */
|
|
389
|
+
export declare function finalizeMessageText(item: ResponseOutputMessage, streamedText: string): string;
|
|
388
390
|
export declare function accumulateToolCallArgumentsDelta(block: ResponsesToolCallBlock, delta: string, stream: AssistantMessageEventStream, output: AssistantMessage, contentIndex: number): void;
|
|
389
391
|
/**
|
|
390
392
|
* Finalize streamed function-call arguments from the authoritative `.done`
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* GET /wham/rate-limit-reset-credits → list redeemable credits
|
|
10
10
|
* POST /wham/rate-limit-reset-credits/consume → spend one credit
|
|
11
|
-
* body: { credit_id, redeem_request_id }
|
|
11
|
+
* body: { credit_id, redeem_request_id, account_id? }
|
|
12
12
|
*
|
|
13
13
|
* `redeem_request_id` is a client-generated idempotency key (UUID). The consume
|
|
14
14
|
* response carries a `code`: `"reset"` on success, otherwise a business reason
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "jeopi-ai",
|
|
4
|
-
"version": "16.4.
|
|
4
|
+
"version": "16.4.4",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://github.com/akillness/jeopi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"jeopi-catalog": "16.4.
|
|
42
|
-
"jeopi-utils": "16.4.
|
|
43
|
-
"jeopi-wire": "16.4.
|
|
41
|
+
"jeopi-catalog": "16.4.4",
|
|
42
|
+
"jeopi-utils": "16.4.4",
|
|
43
|
+
"jeopi-wire": "16.4.4",
|
|
44
44
|
"arktype": "^2.2.0",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Broker-aware auth-storage discovery used by both the coding-agent runtime and
|
|
3
|
-
* the catalog model generator. Keeps the precedence logic (env → config
|
|
3
|
+
* the catalog model generator. Keeps the precedence logic (env → config YAML →
|
|
4
4
|
* token file → local SQLite) in one place so build-time tooling sees the same
|
|
5
5
|
* credentials as the TUI.
|
|
6
6
|
*/
|
|
@@ -71,22 +71,42 @@ interface ConfigSnapshot {
|
|
|
71
71
|
token?: string;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Resolve a dotted config key against a parsed YAML record, accepting nested
|
|
76
|
+
* keys and the legacy literal-dot form. Nested values take precedence.
|
|
77
|
+
*/
|
|
78
|
+
function readDottedString(record: Record<string, unknown>, dottedKey: string): string | undefined {
|
|
79
|
+
let current: unknown = record;
|
|
80
|
+
for (const segment of dottedKey.split(".")) {
|
|
81
|
+
if (current === null || typeof current !== "object" || Array.isArray(current)) {
|
|
82
|
+
current = undefined;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
current = (current as Record<string, unknown>)[segment];
|
|
86
|
+
}
|
|
87
|
+
if (typeof current === "string") return current;
|
|
88
|
+
const flat = record[dottedKey];
|
|
89
|
+
return typeof flat === "string" ? flat : undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
async function readConfigYaml(agentDir: string): Promise<ConfigSnapshot> {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
93
|
+
for (const filename of ["config.yml", "config.yaml"]) {
|
|
94
|
+
const configPath = path.join(agentDir, filename);
|
|
95
|
+
try {
|
|
96
|
+
const raw = await Bun.file(configPath).text();
|
|
97
|
+
const parsed = YAML.parse(raw);
|
|
98
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
99
|
+
const record = parsed as Record<string, unknown>;
|
|
100
|
+
const url = readDottedString(record, "auth.broker.url");
|
|
101
|
+
const token = readDottedString(record, "auth.broker.token");
|
|
102
|
+
return { url, token };
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (isEnoent(err)) continue;
|
|
105
|
+
logger.warn("auth-broker config unreadable", { filename, error: String(err) });
|
|
106
|
+
return {};
|
|
107
|
+
}
|
|
89
108
|
}
|
|
109
|
+
return {};
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
function resolveSnapshotTtlMs(): number {
|
|
@@ -104,7 +124,8 @@ function resolveSnapshotTtlMs(): number {
|
|
|
104
124
|
* Resolve broker connection configuration using the same precedence as the TUI:
|
|
105
125
|
*
|
|
106
126
|
* 1. `JEOPI_AUTH_BROKER_URL` / `JEOPI_AUTH_BROKER_TOKEN` env vars.
|
|
107
|
-
* 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml
|
|
127
|
+
* 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml` or
|
|
128
|
+
* `<agentDir>/config.yaml`.
|
|
108
129
|
* 3. `<config-root>/auth-broker.token` file (paired with a URL from env/config).
|
|
109
130
|
*
|
|
110
131
|
* Returns `null` when no broker URL is configured — callers should fall back to
|
|
@@ -468,6 +468,13 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
468
468
|
this.#cache.set(key, { value, expiresAtSec });
|
|
469
469
|
}
|
|
470
470
|
|
|
471
|
+
/** Drop all cache rows whose keys start with the supplied prefix. */
|
|
472
|
+
deleteCachePrefix(prefix: string): void {
|
|
473
|
+
for (const key of this.#cache.keys()) {
|
|
474
|
+
if (key.startsWith(prefix)) this.#cache.delete(key);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
471
478
|
cleanExpiredCache(): void {
|
|
472
479
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
473
480
|
for (const [key, entry] of this.#cache) {
|
package/src/auth-gateway/http.ts
CHANGED
|
@@ -5,19 +5,50 @@
|
|
|
5
5
|
* and peer-resolution logic.
|
|
6
6
|
*/
|
|
7
7
|
import { timingSafeEqual as nodeTimingSafeEqual } from "node:crypto";
|
|
8
|
+
import type { Api, AssistantMessage, Model } from "../types";
|
|
8
9
|
|
|
9
10
|
const JSON_HEADERS = {
|
|
10
11
|
"Content-Type": "application/json",
|
|
11
12
|
"X-Content-Type-Options": "nosniff",
|
|
12
13
|
} as const;
|
|
13
14
|
|
|
14
|
-
export function json(status: number, body: unknown): Response {
|
|
15
|
+
export function json(status: number, body: unknown, headers?: Record<string, string>): Response {
|
|
15
16
|
return new Response(JSON.stringify(body) ?? "null", {
|
|
16
17
|
status,
|
|
17
|
-
headers: JSON_HEADERS,
|
|
18
|
+
headers: headers ? { ...JSON_HEADERS, ...headers } : JSON_HEADERS,
|
|
18
19
|
});
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Diagnostic response headers for translated inference requests, mirroring the
|
|
24
|
+
* names existing gateway-aware clients already parse: `x-request-id` /
|
|
25
|
+
* `request-id` (surfaced as `_request_id` by the OpenAI and Anthropic SDKs,
|
|
26
|
+
* matches the gateway log line), LiteLLM's model-resolution and cost headers,
|
|
27
|
+
* and OpenAI's `openai-processing-ms`. Model/request-id headers are always
|
|
28
|
+
* present; `message` — the final assistant message, available only on
|
|
29
|
+
* non-streaming responses — adds the computed cost, and `startedAt` the wall
|
|
30
|
+
* time. Streaming responses send headers before usage exists, so they carry
|
|
31
|
+
* only the identity headers.
|
|
32
|
+
*/
|
|
33
|
+
export function gatewayResponseHeaders(
|
|
34
|
+
model: Model<Api>,
|
|
35
|
+
info: { requestId: string; message?: AssistantMessage; startedAt?: number },
|
|
36
|
+
): Record<string, string> {
|
|
37
|
+
const headers: Record<string, string> = {
|
|
38
|
+
"x-request-id": info.requestId,
|
|
39
|
+
"request-id": info.requestId,
|
|
40
|
+
"x-litellm-model-id": model.id,
|
|
41
|
+
};
|
|
42
|
+
if (model.baseUrl) headers["x-litellm-model-api-base"] = model.baseUrl;
|
|
43
|
+
if (info.message) headers["x-litellm-response-cost"] = info.message.usage.cost.total.toString();
|
|
44
|
+
if (info.startedAt !== undefined) {
|
|
45
|
+
const elapsed = (performance.now() - info.startedAt).toFixed(0);
|
|
46
|
+
headers["x-litellm-response-duration-ms"] = elapsed;
|
|
47
|
+
headers["openai-processing-ms"] = elapsed;
|
|
48
|
+
}
|
|
49
|
+
return headers;
|
|
50
|
+
}
|
|
51
|
+
|
|
21
52
|
export function resolvePeer(req: Request): string {
|
|
22
53
|
const fwd = req.headers.get("x-forwarded-for");
|
|
23
54
|
if (fwd) return fwd.split(",")[0].trim();
|
|
@@ -165,6 +196,8 @@ const CORS_HEADERS: Record<string, string> = {
|
|
|
165
196
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
166
197
|
"Access-Control-Allow-Headers":
|
|
167
198
|
"authorization, content-type, anthropic-version, anthropic-beta, openai-organization, openai-project, x-stainless-*, x-api-key",
|
|
199
|
+
"Access-Control-Expose-Headers":
|
|
200
|
+
"x-request-id, request-id, x-litellm-model-id, x-litellm-model-api-base, x-litellm-response-cost, x-litellm-response-duration-ms, openai-processing-ms",
|
|
168
201
|
"Access-Control-Max-Age": "86400",
|
|
169
202
|
};
|
|
170
203
|
|
|
@@ -32,7 +32,15 @@ import { completeSimple, streamSimple } from "../stream";
|
|
|
32
32
|
import type { Api, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "../types";
|
|
33
33
|
import { deterministicUuid } from "../utils/deterministic-id";
|
|
34
34
|
import { parseBind } from "../utils/parse-bind";
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
captureRequestHeaders,
|
|
37
|
+
corsHeaders,
|
|
38
|
+
gatewayResponseHeaders,
|
|
39
|
+
isAuthorized,
|
|
40
|
+
json,
|
|
41
|
+
resolvePeer,
|
|
42
|
+
withCors,
|
|
43
|
+
} from "./http";
|
|
36
44
|
import type {
|
|
37
45
|
AuthGatewayServerHandle,
|
|
38
46
|
AuthGatewayServerOptions,
|
|
@@ -333,6 +341,8 @@ async function handleFormatEndpoint(
|
|
|
333
341
|
req: Request,
|
|
334
342
|
peer: string,
|
|
335
343
|
): Promise<Response> {
|
|
344
|
+
const startedAt = performance.now();
|
|
345
|
+
const requestId = crypto.randomUUID();
|
|
336
346
|
const controller = mirrorRequestAbort(req);
|
|
337
347
|
if (controller.signal.aborted) return clientClosedResponse(route);
|
|
338
348
|
|
|
@@ -429,6 +439,7 @@ async function handleFormatEndpoint(
|
|
|
429
439
|
);
|
|
430
440
|
|
|
431
441
|
logger.info("auth-gateway request", {
|
|
442
|
+
requestId,
|
|
432
443
|
format: route.label,
|
|
433
444
|
model: parsed.modelId,
|
|
434
445
|
resolvedProvider: model.provider,
|
|
@@ -457,7 +468,11 @@ async function handleFormatEndpoint(
|
|
|
457
468
|
const classified = classifyGatewayError(errorMessage);
|
|
458
469
|
return route.module.formatError(classified.status, classified.type, errorMessage);
|
|
459
470
|
}
|
|
460
|
-
return json(
|
|
471
|
+
return json(
|
|
472
|
+
200,
|
|
473
|
+
route.module.encodeResponse(message, parsed.modelId),
|
|
474
|
+
gatewayResponseHeaders(model, { requestId, message, startedAt }),
|
|
475
|
+
);
|
|
461
476
|
} catch (error) {
|
|
462
477
|
if (controller.signal.aborted) return clientClosedResponse(route);
|
|
463
478
|
const classified = classifyGatewayError(error);
|
|
@@ -492,6 +507,7 @@ async function handleFormatEndpoint(
|
|
|
492
507
|
return new Response(sseStream, {
|
|
493
508
|
status: 200,
|
|
494
509
|
headers: {
|
|
510
|
+
...gatewayResponseHeaders(model, { requestId }),
|
|
495
511
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
496
512
|
"Cache-Control": "no-cache",
|
|
497
513
|
Connection: "keep-alive",
|
|
@@ -518,6 +534,8 @@ async function handleFormatEndpoint(
|
|
|
518
534
|
* path.
|
|
519
535
|
*/
|
|
520
536
|
async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response> {
|
|
537
|
+
const startedAt = performance.now();
|
|
538
|
+
const requestId = crypto.randomUUID();
|
|
521
539
|
const controller = mirrorRequestAbort(req);
|
|
522
540
|
const aborted = (): Response => piNative.formatError(499, "request_aborted", "client closed request");
|
|
523
541
|
if (controller.signal.aborted) return aborted();
|
|
@@ -604,6 +622,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
|
|
604
622
|
streamOpts.sessionId ??= sessionId;
|
|
605
623
|
|
|
606
624
|
logger.info("auth-gateway request", {
|
|
625
|
+
requestId,
|
|
607
626
|
format: "pi-native",
|
|
608
627
|
model: parsed.modelId,
|
|
609
628
|
resolvedProvider: model.provider,
|
|
@@ -632,7 +651,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
|
|
632
651
|
const classified = classifyGatewayError(errorMessage);
|
|
633
652
|
return piNative.formatError(classified.status, classified.type, errorMessage);
|
|
634
653
|
}
|
|
635
|
-
return json(200, { message });
|
|
654
|
+
return json(200, { message }, gatewayResponseHeaders(model, { requestId, message, startedAt }));
|
|
636
655
|
} catch (error) {
|
|
637
656
|
if (controller.signal.aborted) return aborted();
|
|
638
657
|
const classified = classifyGatewayError(error);
|
|
@@ -663,6 +682,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
|
|
663
682
|
return new Response(sseStream, {
|
|
664
683
|
status: 200,
|
|
665
684
|
headers: {
|
|
685
|
+
...gatewayResponseHeaders(model, { requestId }),
|
|
666
686
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
667
687
|
"Cache-Control": "no-cache",
|
|
668
688
|
Connection: "keep-alive",
|
package/src/auth-storage.ts
CHANGED
|
@@ -36,6 +36,7 @@ import type {
|
|
|
36
36
|
} from "./usage";
|
|
37
37
|
import { resolveUsedFraction } from "./usage";
|
|
38
38
|
import { claudeRankingStrategy, claudeUsageProvider } from "./usage/claude";
|
|
39
|
+
import { cursorUsageProvider } from "./usage/cursor";
|
|
39
40
|
import { googleGeminiCliUsageProvider } from "./usage/gemini";
|
|
40
41
|
import { githubCopilotUsageProvider } from "./usage/github-copilot";
|
|
41
42
|
import { antigravityRankingStrategy, antigravityUsageProvider } from "./usage/google-antigravity";
|
|
@@ -52,6 +53,7 @@ import { opencodeGoUsageProvider } from "./usage/opencode-go";
|
|
|
52
53
|
import { zaiUsageProvider } from "./usage/zai";
|
|
53
54
|
|
|
54
55
|
const USAGE_RANKING_METRIC_EPSILON = 1e-9;
|
|
56
|
+
const SESSION_STICKY_CACHE_PREFIX = "session:sticky:";
|
|
55
57
|
|
|
56
58
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
59
|
// Credential Types
|
|
@@ -298,6 +300,8 @@ export interface AuthCredentialStore {
|
|
|
298
300
|
deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void;
|
|
299
301
|
getCache(key: string, options?: { includeExpired?: boolean }): string | null;
|
|
300
302
|
setCache(key: string, value: string, expiresAtSec: number): void;
|
|
303
|
+
/** Drop all cache rows whose keys start with the supplied prefix. */
|
|
304
|
+
deleteCachePrefix?(prefix: string): void;
|
|
301
305
|
cleanExpiredCache(): void;
|
|
302
306
|
/**
|
|
303
307
|
* Append usage-limit snapshots for trend history. Optional: stores without
|
|
@@ -504,6 +508,7 @@ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [
|
|
|
504
508
|
zaiUsageProvider,
|
|
505
509
|
opencodeGoUsageProvider,
|
|
506
510
|
githubCopilotUsageProvider,
|
|
511
|
+
cursorUsageProvider,
|
|
507
512
|
];
|
|
508
513
|
|
|
509
514
|
const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
|
|
@@ -1357,7 +1362,7 @@ export class AuthStorage {
|
|
|
1357
1362
|
try {
|
|
1358
1363
|
const credentialId = this.#getStoredCredentials(provider)[index]?.id;
|
|
1359
1364
|
if (credentialId !== undefined) {
|
|
1360
|
-
const cacheKey =
|
|
1365
|
+
const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
|
|
1361
1366
|
const cacheValue = JSON.stringify({ type, index, credentialId });
|
|
1362
1367
|
// Expires in 30 days
|
|
1363
1368
|
const expiresAtSec = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60;
|
|
@@ -1379,7 +1384,7 @@ export class AuthStorage {
|
|
|
1379
1384
|
return sessionMap.get(sessionId);
|
|
1380
1385
|
}
|
|
1381
1386
|
try {
|
|
1382
|
-
const cacheKey =
|
|
1387
|
+
const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
|
|
1383
1388
|
const raw = this.#store.getCache(cacheKey);
|
|
1384
1389
|
if (raw) {
|
|
1385
1390
|
const val = JSON.parse(raw) as { type: AuthCredential["type"]; index: number; credentialId?: number };
|
|
@@ -1423,7 +1428,7 @@ export class AuthStorage {
|
|
|
1423
1428
|
}
|
|
1424
1429
|
}
|
|
1425
1430
|
try {
|
|
1426
|
-
const cacheKey =
|
|
1431
|
+
const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
|
|
1427
1432
|
this.#store.setCache(cacheKey, "", 0);
|
|
1428
1433
|
} catch (err) {
|
|
1429
1434
|
logger.debug("Failed to clear session sticky credential from persistent store cache", { err });
|
|
@@ -1464,6 +1469,14 @@ export class AuthStorage {
|
|
|
1464
1469
|
return fallback;
|
|
1465
1470
|
}
|
|
1466
1471
|
|
|
1472
|
+
#clearProviderSessionCredentialCache(provider: string): void {
|
|
1473
|
+
try {
|
|
1474
|
+
this.#store.deleteCachePrefix?.(`${SESSION_STICKY_CACHE_PREFIX}${provider}:`);
|
|
1475
|
+
} catch (err) {
|
|
1476
|
+
logger.debug("Failed to clear provider session sticky credentials from persistent store cache", { err });
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1467
1480
|
/**
|
|
1468
1481
|
* Clears round-robin and session assignment state for a provider.
|
|
1469
1482
|
* Called when credentials are added/removed to prevent stale index references.
|
|
@@ -1475,6 +1488,7 @@ export class AuthStorage {
|
|
|
1475
1488
|
}
|
|
1476
1489
|
}
|
|
1477
1490
|
this.#sessionLastCredential.delete(provider);
|
|
1491
|
+
this.#clearProviderSessionCredentialCache(provider);
|
|
1478
1492
|
for (const key of this.#credentialBackoff.keys()) {
|
|
1479
1493
|
if (key.startsWith(`${provider}:`)) {
|
|
1480
1494
|
this.#credentialBackoff.delete(key);
|
|
@@ -3222,8 +3236,17 @@ export class AuthStorage {
|
|
|
3222
3236
|
|
|
3223
3237
|
/**
|
|
3224
3238
|
* Resolves an OAuth credential, trying credentials in priority order.
|
|
3225
|
-
*
|
|
3226
|
-
*
|
|
3239
|
+
*
|
|
3240
|
+
* Resolution ladder — a request in hand always beats "no API key":
|
|
3241
|
+
* 1. strict: unblocked credentials only, usage limits respected, Pro
|
|
3242
|
+
* filter enforced (when any account is confirmed Pro-eligible);
|
|
3243
|
+
* 2. Pro-fitting last resort: same Pro filter, but blocked/exhausted
|
|
3244
|
+
* accounts are allowed (blocked candidates rank earliest-unblocking
|
|
3245
|
+
* first) so the caller gets real usage-limit semantics from the wire
|
|
3246
|
+
* instead of a missing key;
|
|
3247
|
+
* 3. unfiltered last resort: the Pro filter matched nothing usable —
|
|
3248
|
+
* skip it and try every account once; the server is the final arbiter
|
|
3249
|
+
* of model access.
|
|
3227
3250
|
*
|
|
3228
3251
|
* Returns both the API key bytes for outbound requests AND the refreshed
|
|
3229
3252
|
* {@link OAuthCredential} so callers needing identity metadata (account id,
|
|
@@ -3360,40 +3383,33 @@ export class AuthStorage {
|
|
|
3360
3383
|
const enforceProRequirement =
|
|
3361
3384
|
requiresProModel && candidates.some(candidate => hasOpenAICodexProPlan(candidate.usage));
|
|
3362
3385
|
|
|
3363
|
-
const
|
|
3386
|
+
const passes: Array<{ allowBlocked: boolean; enforceProRequirement: boolean }> = [
|
|
3387
|
+
{ allowBlocked: false, enforceProRequirement },
|
|
3388
|
+
{ allowBlocked: true, enforceProRequirement },
|
|
3389
|
+
];
|
|
3390
|
+
if (enforceProRequirement) passes.push({ allowBlocked: true, enforceProRequirement: false });
|
|
3364
3391
|
|
|
3365
|
-
for (const
|
|
3366
|
-
const
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
if (fallback && this.#isCredentialBlocked(providerKey, fallback.selection.index, blockScope)) {
|
|
3387
|
-
return this.#tryOAuthCredential(provider, fallback.selection, providerKey, sessionId, options, {
|
|
3388
|
-
checkUsage,
|
|
3389
|
-
allowBlocked: true,
|
|
3390
|
-
prefetchedUsage: fallback.usage,
|
|
3391
|
-
usagePrechecked: fallback.usageChecked,
|
|
3392
|
-
enforceProRequirement,
|
|
3393
|
-
strategy,
|
|
3394
|
-
rankingContext,
|
|
3395
|
-
blockScope,
|
|
3396
|
-
});
|
|
3392
|
+
for (const pass of passes) {
|
|
3393
|
+
for (const candidate of candidates) {
|
|
3394
|
+
const resolved = await this.#tryOAuthCredential(
|
|
3395
|
+
provider,
|
|
3396
|
+
candidate.selection,
|
|
3397
|
+
providerKey,
|
|
3398
|
+
sessionId,
|
|
3399
|
+
options,
|
|
3400
|
+
{
|
|
3401
|
+
checkUsage,
|
|
3402
|
+
allowBlocked: pass.allowBlocked,
|
|
3403
|
+
prefetchedUsage: candidate.usage,
|
|
3404
|
+
usagePrechecked: candidate.usageChecked,
|
|
3405
|
+
enforceProRequirement: pass.enforceProRequirement,
|
|
3406
|
+
strategy,
|
|
3407
|
+
rankingContext,
|
|
3408
|
+
blockScope,
|
|
3409
|
+
},
|
|
3410
|
+
);
|
|
3411
|
+
if (resolved) return resolved;
|
|
3412
|
+
}
|
|
3397
3413
|
}
|
|
3398
3414
|
|
|
3399
3415
|
return undefined;
|
|
@@ -4755,6 +4771,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
4755
4771
|
#getCacheStmt: Statement;
|
|
4756
4772
|
#getCacheIncludingExpiredStmt: Statement;
|
|
4757
4773
|
#upsertCacheStmt: Statement;
|
|
4774
|
+
#deleteCachePrefixStmt: Statement;
|
|
4758
4775
|
#deleteExpiredCacheStmt: Statement;
|
|
4759
4776
|
#insertUsageHistoryStmt: Statement;
|
|
4760
4777
|
#insertUsageCostStmt: Statement;
|
|
@@ -4800,6 +4817,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
4800
4817
|
this.#upsertCacheStmt = this.#db.prepare(
|
|
4801
4818
|
"INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
|
|
4802
4819
|
);
|
|
4820
|
+
this.#deleteCachePrefixStmt = this.#db.prepare("DELETE FROM cache WHERE substr(key, 1, ?) = ?");
|
|
4803
4821
|
this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
|
|
4804
4822
|
this.#insertUsageHistoryStmt = this.#db.prepare(
|
|
4805
4823
|
"INSERT INTO usage_history (recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
@@ -5364,6 +5382,15 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5364
5382
|
}
|
|
5365
5383
|
}
|
|
5366
5384
|
|
|
5385
|
+
/** Drop all cache rows whose keys start with the supplied prefix. */
|
|
5386
|
+
deleteCachePrefix(prefix: string): void {
|
|
5387
|
+
try {
|
|
5388
|
+
this.#deleteCachePrefixStmt.run(prefix.length, prefix);
|
|
5389
|
+
} catch {
|
|
5390
|
+
// Ignore cache delete failures
|
|
5391
|
+
}
|
|
5392
|
+
}
|
|
5393
|
+
|
|
5367
5394
|
cleanExpiredCache(): void {
|
|
5368
5395
|
try {
|
|
5369
5396
|
this.#deleteExpiredCacheStmt.run();
|
|
@@ -5564,6 +5591,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5564
5591
|
this.#getCacheStmt.finalize();
|
|
5565
5592
|
this.#getCacheIncludingExpiredStmt.finalize();
|
|
5566
5593
|
this.#upsertCacheStmt.finalize();
|
|
5594
|
+
this.#deleteCachePrefixStmt.finalize();
|
|
5567
5595
|
this.#deleteExpiredCacheStmt.finalize();
|
|
5568
5596
|
this.#insertUsageHistoryStmt.finalize();
|
|
5569
5597
|
this.#lastUsageHistoryStmt.finalize();
|
package/src/error/flags.ts
CHANGED
|
@@ -17,6 +17,7 @@ export const Flag = {
|
|
|
17
17
|
StaleResponsesItem: 0x0010_0000,
|
|
18
18
|
MalformedFunctionCall: 0x0020_0000,
|
|
19
19
|
ProviderFinishError: 0x0040_0000,
|
|
20
|
+
ContentBlocked: 0x0000_8000,
|
|
20
21
|
ContextOverflow: 0x0080_0000,
|
|
21
22
|
AuthFailed: 0x0100_0000,
|
|
22
23
|
SilentAbort: 0x0200_0000,
|
|
@@ -40,6 +41,7 @@ const KIND_MASK =
|
|
|
40
41
|
Flag.StaleResponsesItem |
|
|
41
42
|
Flag.MalformedFunctionCall |
|
|
42
43
|
Flag.ProviderFinishError |
|
|
44
|
+
Flag.ContentBlocked |
|
|
43
45
|
Flag.ContextOverflow |
|
|
44
46
|
Flag.AuthFailed |
|
|
45
47
|
Flag.SilentAbort |
|
|
@@ -92,6 +94,7 @@ const AUTH_FAILURE_PATTERN =
|
|
|
92
94
|
/\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
|
|
93
95
|
const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
|
|
94
96
|
const PROVIDER_FINISH_ERROR_PATTERN = /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i;
|
|
97
|
+
const CONTENT_FILTER_PATTERN = /\b(?:incomplete:\s*)?content_filter\b/i;
|
|
95
98
|
const STALE_RESPONSE_ITEM_PATTERNS = [/\bItem with id ['"][^'"]+['"] not found\.?/i, /previous[ _]?response/i] as const;
|
|
96
99
|
const STALE_RESPONSE_ITEM_DETAIL_PATTERN = /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i;
|
|
97
100
|
/**
|
|
@@ -167,6 +170,7 @@ const ERROR_KIND_LABELS: readonly [Flag, string][] = [
|
|
|
167
170
|
[Flag.StaleResponsesItem, "stale-responses-item"],
|
|
168
171
|
[Flag.MalformedFunctionCall, "malformed-function-call"],
|
|
169
172
|
[Flag.ProviderFinishError, "provider-finish-error"],
|
|
173
|
+
[Flag.ContentBlocked, "content-blocked"],
|
|
170
174
|
[Flag.ContextOverflow, "context-overflow"],
|
|
171
175
|
[Flag.AuthFailed, "auth-failed"],
|
|
172
176
|
[Flag.SilentAbort, "silent-abort"],
|
|
@@ -285,6 +289,10 @@ function isProviderFinishErrorText(text: string): boolean {
|
|
|
285
289
|
return PROVIDER_FINISH_ERROR_PATTERN.test(text);
|
|
286
290
|
}
|
|
287
291
|
|
|
292
|
+
function isContentBlockedText(text: string): boolean {
|
|
293
|
+
return CONTENT_FILTER_PATTERN.test(text);
|
|
294
|
+
}
|
|
295
|
+
|
|
288
296
|
function matchesOverflowText(text: string): boolean {
|
|
289
297
|
return OVERFLOW_PATTERNS.some(p => p.test(text)) || OVERFLOW_NO_BODY_PATTERN.test(text);
|
|
290
298
|
}
|
|
@@ -295,6 +303,7 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
|
|
|
295
303
|
if (matchesOverflowText(errorMessage)) kinds |= Flag.ContextOverflow;
|
|
296
304
|
if (isMalformedFunctionCallText(errorMessage)) kinds |= Flag.MalformedFunctionCall;
|
|
297
305
|
if (isProviderFinishErrorText(errorMessage)) kinds |= Flag.ProviderFinishError;
|
|
306
|
+
if (isContentBlockedText(errorMessage)) kinds |= Flag.ContentBlocked;
|
|
298
307
|
if (isAuthFailureText(errorMessage)) kinds |= Flag.AuthFailed;
|
|
299
308
|
|
|
300
309
|
const statusClean = errorStatus ? errorStatus : (status({ message: errorMessage }) ?? undefined);
|
package/src/error/provider.ts
CHANGED
|
@@ -36,13 +36,14 @@ export class ProviderResponseError extends Error {
|
|
|
36
36
|
this.name = "ProviderResponseError";
|
|
37
37
|
this.provider = options.provider;
|
|
38
38
|
this.kind = options.kind ?? "output";
|
|
39
|
-
// A safety filter block is
|
|
40
|
-
if (this.kind === "content-blocked") attach(this, create(Flag.
|
|
39
|
+
// A safety filter block is terminal and intentionally non-retryable.
|
|
40
|
+
if (this.kind === "content-blocked") attach(this, create(Flag.ContentBlocked));
|
|
41
41
|
// An incomplete stream (connection dropped / truncated before any terminal
|
|
42
|
-
// event) never produced
|
|
43
|
-
// is safe to retry
|
|
44
|
-
// retry
|
|
45
|
-
|
|
42
|
+
// event) or an empty body never produced any content — the request didn't
|
|
43
|
+
// complete, so it is safe to retry and eligible for model fallback. The
|
|
44
|
+
// retry layer's replay-unsafe guard still blocks a retry when partial tool
|
|
45
|
+
// output was already emitted.
|
|
46
|
+
else if (this.kind === "incomplete-stream" || this.kind === "empty-body") attach(this, create(Flag.Transient));
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ export * from "./stream";
|
|
|
29
29
|
export * from "./types";
|
|
30
30
|
export * from "./usage";
|
|
31
31
|
export * from "./usage/claude";
|
|
32
|
+
export * from "./usage/cursor";
|
|
32
33
|
export * from "./usage/gemini";
|
|
33
34
|
export * from "./usage/github-copilot";
|
|
34
35
|
export * from "./usage/google-antigravity";
|
package/src/providers/cursor.ts
CHANGED
|
@@ -76,6 +76,7 @@ import {
|
|
|
76
76
|
RequestContextResultSchema,
|
|
77
77
|
RequestContextSchema,
|
|
78
78
|
RequestContextSuccessSchema,
|
|
79
|
+
RequestedModelSchema,
|
|
79
80
|
ResumeActionSchema,
|
|
80
81
|
SelectedContextSchema,
|
|
81
82
|
SelectedImageSchema,
|
|
@@ -2642,16 +2643,24 @@ function buildGrpcRequest(
|
|
|
2642
2643
|
turns,
|
|
2643
2644
|
});
|
|
2644
2645
|
|
|
2646
|
+
const wireModelId = model.requestModelId ?? model.id;
|
|
2647
|
+
const cursorMaxMode = model.cursorMaxMode === true;
|
|
2645
2648
|
const modelDetails = create(ModelDetailsSchema, {
|
|
2646
|
-
modelId:
|
|
2649
|
+
modelId: wireModelId,
|
|
2647
2650
|
displayModelId: model.id,
|
|
2648
2651
|
displayName: model.name,
|
|
2652
|
+
...(cursorMaxMode ? { maxMode: true } : undefined),
|
|
2653
|
+
});
|
|
2654
|
+
const requestedModel = create(RequestedModelSchema, {
|
|
2655
|
+
modelId: wireModelId,
|
|
2656
|
+
maxMode: cursorMaxMode,
|
|
2649
2657
|
});
|
|
2650
2658
|
|
|
2651
2659
|
const runRequest = create(AgentRunRequestSchema, {
|
|
2652
2660
|
conversationState,
|
|
2653
2661
|
action,
|
|
2654
2662
|
modelDetails,
|
|
2663
|
+
requestedModel,
|
|
2655
2664
|
conversationId: state.conversationId,
|
|
2656
2665
|
});
|
|
2657
2666
|
|
|
@@ -114,6 +114,7 @@ export const toolChoiceSchema = type("'auto' | 'none' | 'required'")
|
|
|
114
114
|
// ─── Messages ───────────────────────────────────────────────────────────────
|
|
115
115
|
|
|
116
116
|
const baseContent = type("string").or(userContentPartSchema.array());
|
|
117
|
+
const assistantContent = baseContent.or("null");
|
|
117
118
|
|
|
118
119
|
export const systemMessageSchema = type({
|
|
119
120
|
role: "'system'",
|
|
@@ -132,7 +133,7 @@ export const userMessageSchema = type({
|
|
|
132
133
|
|
|
133
134
|
export const assistantMessageSchema = type({
|
|
134
135
|
role: "'assistant'",
|
|
135
|
-
"content?":
|
|
136
|
+
"content?": assistantContent,
|
|
136
137
|
"tool_calls?": toolCallSchema.array(),
|
|
137
138
|
// DeepSeek-style reasoning channel. The gateway emits it on the way out
|
|
138
139
|
// (encodeResponse/encodeStream); accept it back so thinking-mode
|
|
@@ -281,7 +281,7 @@ export async function transformRequestBody(
|
|
|
281
281
|
|
|
282
282
|
body.text = {
|
|
283
283
|
...body.text,
|
|
284
|
-
verbosity: options.textVerbosity || "
|
|
284
|
+
verbosity: options.textVerbosity || "medium",
|
|
285
285
|
};
|
|
286
286
|
|
|
287
287
|
const include = Array.isArray(options.include) ? [...options.include] : [];
|
|
@@ -86,6 +86,7 @@ import {
|
|
|
86
86
|
encodeResponsesToolCallId,
|
|
87
87
|
encodeTextSignatureV1,
|
|
88
88
|
finalizeCustomToolCallInputDone,
|
|
89
|
+
finalizeMessageText,
|
|
89
90
|
finalizePendingResponsesToolCalls,
|
|
90
91
|
finalizeToolCallArgumentsDone,
|
|
91
92
|
isOpenAIResponsesProgressEvent,
|
|
@@ -1458,9 +1459,7 @@ class CodexStreamProcessor {
|
|
|
1458
1459
|
}
|
|
1459
1460
|
|
|
1460
1461
|
if (item.type === "message" && block?.type === "text") {
|
|
1461
|
-
block.text = item.
|
|
1462
|
-
.map(content => (content.type === "output_text" ? content.text : content.refusal))
|
|
1463
|
-
.join("");
|
|
1462
|
+
block.text = finalizeMessageText(item, block.text);
|
|
1464
1463
|
const phase = item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined;
|
|
1465
1464
|
block.textSignature = encodeTextSignatureV1(item.id, phase);
|
|
1466
1465
|
stream.push({
|
|
@@ -561,6 +561,10 @@ function responseStatusForStopReason(message: AssistantMessage): ResponseStatus
|
|
|
561
561
|
return "completed";
|
|
562
562
|
}
|
|
563
563
|
|
|
564
|
+
function incompleteDetailsForStatus(status: ResponseStatus): { reason: "max_output_tokens" } | null {
|
|
565
|
+
return status === "incomplete" ? { reason: "max_output_tokens" } : null;
|
|
566
|
+
}
|
|
567
|
+
|
|
564
568
|
function buildReasoningItem(part: ThinkingContent): ReasoningOutputItem {
|
|
565
569
|
const baseId = part.itemId ?? makeReasoningId();
|
|
566
570
|
if (part.thinkingSignature) {
|
|
@@ -711,7 +715,7 @@ function buildResponseEnvelope(
|
|
|
711
715
|
model: requestedModelId,
|
|
712
716
|
output: items,
|
|
713
717
|
usage,
|
|
714
|
-
|
|
718
|
+
incomplete_details: incompleteDetailsForStatus(status),
|
|
715
719
|
...(status === "failed" ? { error: { message: message.errorMessage ?? "response failed" } } : {}),
|
|
716
720
|
};
|
|
717
721
|
}
|
|
@@ -805,6 +809,7 @@ export function encodeStream(
|
|
|
805
809
|
model: requestedModelId,
|
|
806
810
|
output,
|
|
807
811
|
usage: null,
|
|
812
|
+
incomplete_details: incompleteDetailsForStatus(status),
|
|
808
813
|
});
|
|
809
814
|
|
|
810
815
|
const openMessage = (signature?: MessageSignature): OpenMessage => {
|
|
@@ -1235,7 +1240,7 @@ export function encodeStream(
|
|
|
1235
1240
|
model: requestedModelId,
|
|
1236
1241
|
output: items,
|
|
1237
1242
|
usage,
|
|
1238
|
-
|
|
1243
|
+
incomplete_details: incompleteDetailsForStatus(status),
|
|
1239
1244
|
...(status === "failed"
|
|
1240
1245
|
? { error: { message: message?.errorMessage ?? "response failed" } }
|
|
1241
1246
|
: {}),
|
|
@@ -1260,6 +1265,7 @@ export function encodeStream(
|
|
|
1260
1265
|
model: requestedModelId,
|
|
1261
1266
|
output: [],
|
|
1262
1267
|
error: { message: err instanceof Error ? err.message : String(err) },
|
|
1268
|
+
incomplete_details: null,
|
|
1263
1269
|
},
|
|
1264
1270
|
}),
|
|
1265
1271
|
),
|
|
@@ -1594,12 +1594,19 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
|
|
|
1594
1594
|
const hasImages = toolResult.content.some((block): block is ImageContent => block.type === "image");
|
|
1595
1595
|
const omittedImages = hasImages && !supportsImages;
|
|
1596
1596
|
const normalized = normalizeResponsesToolCallId(toolResult.toolCallId);
|
|
1597
|
+
// "(see attached image)" is only truthful when the result actually carries
|
|
1598
|
+
// images (they ride as a separate user message on the Responses API). A
|
|
1599
|
+
// genuinely empty text result (empty file read, silent tool) must stay
|
|
1600
|
+
// empty — the placeholder sent models chasing an attachment that never
|
|
1601
|
+
// existed.
|
|
1597
1602
|
const output = (
|
|
1598
1603
|
omittedImages
|
|
1599
1604
|
? joinTextWithImagePlaceholder(textResult, true)
|
|
1600
1605
|
: textResult.length > 0
|
|
1601
1606
|
? textResult
|
|
1602
|
-
:
|
|
1607
|
+
: hasImages
|
|
1608
|
+
? "(see attached image)"
|
|
1609
|
+
: ""
|
|
1603
1610
|
).toWellFormed();
|
|
1604
1611
|
if (strictResponsesPairing && !knownCallIds.has(normalized.callId)) {
|
|
1605
1612
|
// Strict backends (Azure, Copilot) reject unpaired outputs outright, but
|
|
@@ -1737,6 +1744,11 @@ export function appendMessageTextDelta(
|
|
|
1737
1744
|
}
|
|
1738
1745
|
stream.push({ type: "text_delta", contentIndex, delta, partial: output });
|
|
1739
1746
|
}
|
|
1747
|
+
/** Chooses final message text while treating non-empty terminal content as authoritative. */
|
|
1748
|
+
export function finalizeMessageText(item: ResponseOutputMessage, streamedText: string): string {
|
|
1749
|
+
if (!item.content?.length) return streamedText || "";
|
|
1750
|
+
return item.content.map(part => (part.type === "output_text" ? (part.text ?? "") : (part.refusal ?? ""))).join("");
|
|
1751
|
+
}
|
|
1740
1752
|
|
|
1741
1753
|
export function accumulateToolCallArgumentsDelta(
|
|
1742
1754
|
block: ResponsesToolCallBlock,
|
|
@@ -2129,9 +2141,7 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
2129
2141
|
closeOpenItem(event.output_index, item.id, entry);
|
|
2130
2142
|
} else if (item.type === "message") {
|
|
2131
2143
|
const block = entry?.block.type === "text" ? entry.block : undefined;
|
|
2132
|
-
const text = item
|
|
2133
|
-
.map(part => (part.type === "output_text" ? (part.text ?? "") : (part.refusal ?? "")))
|
|
2134
|
-
.join("");
|
|
2144
|
+
const text = finalizeMessageText(item, block?.text ?? "");
|
|
2135
2145
|
const textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined);
|
|
2136
2146
|
let contentIndex: number;
|
|
2137
2147
|
if (block) {
|
|
@@ -18,6 +18,26 @@ const DEFAULT_TIMEOUT = 300_000;
|
|
|
18
18
|
const DEFAULT_HOSTNAME = "localhost";
|
|
19
19
|
const CALLBACK_PATH = "/callback";
|
|
20
20
|
|
|
21
|
+
function escapeJsonForHtmlScript(value: object): string {
|
|
22
|
+
const json = JSON.stringify(value) ?? "null";
|
|
23
|
+
return json.replace(/[<>&\u2028\u2029]/g, character => {
|
|
24
|
+
switch (character) {
|
|
25
|
+
case "<":
|
|
26
|
+
return "\\u003C";
|
|
27
|
+
case ">":
|
|
28
|
+
return "\\u003E";
|
|
29
|
+
case "&":
|
|
30
|
+
return "\\u0026";
|
|
31
|
+
case "\u2028":
|
|
32
|
+
return "\\u2028";
|
|
33
|
+
case "\u2029":
|
|
34
|
+
return "\\u2029";
|
|
35
|
+
default:
|
|
36
|
+
return character;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
export type CallbackResult = { code: string; state: string };
|
|
22
42
|
|
|
23
43
|
export interface OAuthCallbackFlowOptions {
|
|
@@ -230,7 +250,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
230
250
|
});
|
|
231
251
|
|
|
232
252
|
return new Response(
|
|
233
|
-
(templateHtml as unknown as string).replaceAll("__OAUTH_STATE__",
|
|
253
|
+
(templateHtml as unknown as string).replaceAll("__OAUTH_STATE__", () => escapeJsonForHtmlScript(resultState)),
|
|
234
254
|
{
|
|
235
255
|
status: resultState.ok ? 200 : 500,
|
|
236
256
|
headers: { "Content-Type": "text/html" },
|
|
@@ -299,7 +299,7 @@
|
|
|
299
299
|
if (serverState.ok) {
|
|
300
300
|
app.classList.add("success", "countdown");
|
|
301
301
|
title.textContent = "Authentication Successful";
|
|
302
|
-
message.innerHTML = "You have successfully logged in.<br>
|
|
302
|
+
message.innerHTML = "You have successfully logged in.<br>You can now close this tab.";
|
|
303
303
|
setTimeout(() => window.close(), 3000);
|
|
304
304
|
} else {
|
|
305
305
|
app.classList.add("error");
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
UsageAmount,
|
|
3
|
+
UsageFetchContext,
|
|
4
|
+
UsageFetchParams,
|
|
5
|
+
UsageLimit,
|
|
6
|
+
UsageProvider,
|
|
7
|
+
UsageReport,
|
|
8
|
+
UsageStatus,
|
|
9
|
+
UsageWindow,
|
|
10
|
+
} from "../usage";
|
|
11
|
+
import { toNumber } from "./shared";
|
|
12
|
+
|
|
13
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
14
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseTimestamp(value: unknown): number | undefined {
|
|
18
|
+
const numeric = toNumber(value);
|
|
19
|
+
if (numeric !== undefined) return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric;
|
|
20
|
+
if (typeof value !== "string" || !value.trim()) return undefined;
|
|
21
|
+
const parsed = Date.parse(value);
|
|
22
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeCursorBaseUrl(baseUrl?: string): string {
|
|
26
|
+
if (!baseUrl) return "https://api2.cursor.sh";
|
|
27
|
+
return baseUrl.replace(/\/+$/, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function deriveResetsAt(payload: Record<string, unknown>): number | undefined {
|
|
31
|
+
const endKeys = ["billingCycleEnd", "endOfMonth", "resetsAt", "nextReset"];
|
|
32
|
+
for (const key of endKeys) {
|
|
33
|
+
const parsed = parseTimestamp(payload[key]);
|
|
34
|
+
if (parsed !== undefined) return parsed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const startKeys = ["startOfMonth", "billingCycleStart", "startOfBillingCycle"];
|
|
38
|
+
for (const key of startKeys) {
|
|
39
|
+
const parsed = parseTimestamp(payload[key]);
|
|
40
|
+
if (parsed !== undefined) {
|
|
41
|
+
const date = new Date(parsed);
|
|
42
|
+
date.setUTCMonth(date.getUTCMonth() + 1);
|
|
43
|
+
return date.getTime();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseCursorUsage(payload: unknown, fetchedAt = Date.now()): UsageReport | null {
|
|
50
|
+
if (!isRecord(payload)) return null;
|
|
51
|
+
const limits: UsageLimit[] = [];
|
|
52
|
+
const resetsAt = deriveResetsAt(payload);
|
|
53
|
+
|
|
54
|
+
const window: UsageWindow = {
|
|
55
|
+
id: "monthly",
|
|
56
|
+
label: "Monthly",
|
|
57
|
+
...(resetsAt !== undefined ? { resetsAt } : {}),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
61
|
+
if (!isRecord(value)) continue;
|
|
62
|
+
|
|
63
|
+
const usedVal =
|
|
64
|
+
toNumber(value.numRequests) ?? toNumber(value.used) ?? toNumber(value.amountUsed) ?? toNumber(value.usdUsed);
|
|
65
|
+
const limitVal =
|
|
66
|
+
toNumber(value.maxRequestUsage) ??
|
|
67
|
+
toNumber(value.limit) ??
|
|
68
|
+
toNumber(value.amountLimit) ??
|
|
69
|
+
toNumber(value.usdLimit);
|
|
70
|
+
|
|
71
|
+
if (usedVal !== undefined && limitVal !== undefined) {
|
|
72
|
+
const isUsd =
|
|
73
|
+
key === "planUsage" ||
|
|
74
|
+
key.toLowerCase().includes("usd") ||
|
|
75
|
+
key.toLowerCase().includes("billing") ||
|
|
76
|
+
key.toLowerCase().includes("stripe");
|
|
77
|
+
|
|
78
|
+
const unit = isUsd ? "usd" : "requests";
|
|
79
|
+
const cleanBucket = key.toLowerCase().trim();
|
|
80
|
+
const limitId = isUsd ? `cursor:usd:${cleanBucket}` : `cursor:requests:${cleanBucket}`;
|
|
81
|
+
const label = isUsd ? `${key} spend` : `${key} requests`;
|
|
82
|
+
|
|
83
|
+
const amount: UsageAmount = {
|
|
84
|
+
used: usedVal,
|
|
85
|
+
limit: limitVal,
|
|
86
|
+
remaining: Math.max(0, limitVal - usedVal),
|
|
87
|
+
usedFraction: limitVal > 0 ? usedVal / limitVal : 0,
|
|
88
|
+
remainingFraction: limitVal > 0 ? Math.max(0, limitVal - usedVal) / limitVal : 0,
|
|
89
|
+
unit,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const usedFraction = amount.usedFraction;
|
|
93
|
+
let status: UsageStatus = "unknown";
|
|
94
|
+
if (usedFraction !== undefined) {
|
|
95
|
+
if (usedFraction >= 1) {
|
|
96
|
+
status = "exhausted";
|
|
97
|
+
} else if (usedFraction >= 0.9) {
|
|
98
|
+
status = "warning";
|
|
99
|
+
} else {
|
|
100
|
+
status = "ok";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
limits.push({
|
|
105
|
+
id: limitId,
|
|
106
|
+
label,
|
|
107
|
+
scope: { provider: "cursor", windowId: window.id },
|
|
108
|
+
window,
|
|
109
|
+
amount,
|
|
110
|
+
status,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (limits.length === 0) return null;
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
provider: "cursor",
|
|
119
|
+
fetchedAt,
|
|
120
|
+
limits,
|
|
121
|
+
raw: payload,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const cursorUsageProvider: UsageProvider = {
|
|
126
|
+
id: "cursor",
|
|
127
|
+
supports(params: UsageFetchParams): boolean {
|
|
128
|
+
if (params.provider !== "cursor") return false;
|
|
129
|
+
const { credential } = params;
|
|
130
|
+
if (credential.type === "oauth") return Boolean(credential.accessToken);
|
|
131
|
+
if (credential.type === "api_key") return Boolean(credential.apiKey);
|
|
132
|
+
return false;
|
|
133
|
+
},
|
|
134
|
+
async fetchUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
|
|
135
|
+
if (params.provider !== "cursor") return null;
|
|
136
|
+
const { credential } = params;
|
|
137
|
+
const token = credential.type === "oauth" ? credential.accessToken : credential.apiKey;
|
|
138
|
+
if (!token) return null;
|
|
139
|
+
|
|
140
|
+
const baseUrl = normalizeCursorBaseUrl(params.baseUrl ?? credential.apiEndpoint);
|
|
141
|
+
const url = `${baseUrl}/auth/usage`;
|
|
142
|
+
const headers: Record<string, string> = {
|
|
143
|
+
Accept: "application/json",
|
|
144
|
+
Authorization: `Bearer ${token}`,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const response = await ctx.fetch(url, { headers, signal: params.signal });
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
ctx.logger?.warn("Cursor usage request failed", { status: response.status, provider: params.provider });
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const payload = await response.json();
|
|
154
|
+
const report = parseCursorUsage(payload);
|
|
155
|
+
if (report) {
|
|
156
|
+
const metadata = {
|
|
157
|
+
...(credential.email ? { email: credential.email } : {}),
|
|
158
|
+
...(credential.accountId ? { accountId: credential.accountId } : {}),
|
|
159
|
+
...(credential.projectId ? { projectId: credential.projectId } : {}),
|
|
160
|
+
};
|
|
161
|
+
if (Object.keys(metadata).length > 0) report.metadata = metadata;
|
|
162
|
+
}
|
|
163
|
+
return report;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
ctx.logger?.warn("Cursor usage request error", { provider: params.provider, error: String(error) });
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
};
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* GET /wham/rate-limit-reset-credits → list redeemable credits
|
|
10
10
|
* POST /wham/rate-limit-reset-credits/consume → spend one credit
|
|
11
|
-
* body: { credit_id, redeem_request_id }
|
|
11
|
+
* body: { credit_id, redeem_request_id, account_id? }
|
|
12
12
|
*
|
|
13
13
|
* `redeem_request_id` is a client-generated idempotency key (UUID). The consume
|
|
14
14
|
* response carries a `code`: `"reset"` on success, otherwise a business reason
|
|
@@ -159,7 +159,11 @@ export async function consumeCodexResetCredit(
|
|
|
159
159
|
const response = await auth.fetch(url, {
|
|
160
160
|
method: "POST",
|
|
161
161
|
headers: buildHeaders(auth, true),
|
|
162
|
-
body: JSON.stringify({
|
|
162
|
+
body: JSON.stringify({
|
|
163
|
+
credit_id: auth.creditId,
|
|
164
|
+
redeem_request_id: redeemRequestId,
|
|
165
|
+
account_id: auth.accountId,
|
|
166
|
+
}),
|
|
163
167
|
signal: auth.signal,
|
|
164
168
|
});
|
|
165
169
|
let body: unknown;
|
|
@@ -581,7 +581,11 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf"
|
|
|
581
581
|
|
|
582
582
|
const existingValue = mergedVariantFields[key];
|
|
583
583
|
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, variantValue)) {
|
|
584
|
-
return schema;
|
|
584
|
+
if (key !== "description") return schema;
|
|
585
|
+
// Descriptions are annotations, so merge branch-local spill text instead of
|
|
586
|
+
// treating it as a structural incompatibility.
|
|
587
|
+
mergedVariantFields[key] = mergeSchemaDescriptions(existingValue, variantValue);
|
|
588
|
+
continue;
|
|
585
589
|
}
|
|
586
590
|
mergedVariantFields[key] = variantValue;
|
|
587
591
|
}
|
|
@@ -622,7 +626,9 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf"
|
|
|
622
626
|
const value = mergedVariantFields[key];
|
|
623
627
|
const existingValue = nextSchema[key];
|
|
624
628
|
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, value)) {
|
|
625
|
-
return schema;
|
|
629
|
+
if (key !== "description") return schema;
|
|
630
|
+
nextSchema[key] = mergeSchemaDescriptions(existingValue, value);
|
|
631
|
+
continue;
|
|
626
632
|
}
|
|
627
633
|
if (existingValue === undefined) {
|
|
628
634
|
nextSchema[key] = value;
|
|
@@ -631,6 +637,13 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf"
|
|
|
631
637
|
return nextSchema;
|
|
632
638
|
}
|
|
633
639
|
|
|
640
|
+
function mergeSchemaDescriptions(existing: unknown, incoming: unknown): string {
|
|
641
|
+
if (typeof existing !== "string") return typeof incoming === "string" ? incoming : "";
|
|
642
|
+
if (typeof incoming !== "string" || incoming.length === 0 || existing === incoming) return existing;
|
|
643
|
+
if (existing.length === 0) return incoming;
|
|
644
|
+
return `${existing}\n\n${incoming}`;
|
|
645
|
+
}
|
|
646
|
+
|
|
634
647
|
function collapseSameTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
|
|
635
648
|
const variantsRaw = schema[combiner];
|
|
636
649
|
if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) return schema;
|