@gajae-code/ai 0.14.0 → 0.14.1
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 +7 -0
- package/dist/types/providers/anthropic.d.ts +3 -0
- package/dist/types/utils/discovery/cursor.d.ts +26 -0
- package/dist/types/utils/idle-iterator.d.ts +16 -0
- package/dist/types/utils/oauth/api-key-validation.d.ts +6 -0
- package/package.json +3 -3
- package/src/model-thinking.ts +19 -0
- package/src/provider-models/openai-compat.ts +18 -0
- package/src/providers/anthropic.ts +20 -0
- package/src/providers/google-shared.ts +17 -5
- package/src/providers/openai-completions.ts +5 -1
- package/src/utils/discovery/cursor.ts +85 -3
- package/src/utils/idle-iterator.ts +33 -0
- package/src/utils/oauth/api-key-validation.ts +53 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.14.1] - 2026-08-18
|
|
6
|
+
- Anthropic clients now set an SDK request `timeout` derived from the first-event window (`resolveAnthropicSdkRequestTimeoutMs`; 300s by default for Anthropic, floored at the env/default first-event window, disabled by an explicit `streamFirstEventTimeoutMs: 0`). The Anthropic first-event watchdog deliberately arms only after response headers arrive, so a connection that silently died before headers — the exact failure mode of recent Anthropic stream instability right after a completed tool call — was previously bounded only by the SDK's 10-minute default per attempt multiplied by its internal retry budget, observable as an endless "Working…" spinner for up to an hour with no error, no retry indicator, and no automatic recovery. This mirrors the existing `resolveOpenAISdkRequestTimeoutMs` stalled-before-headers bound on the OpenAI family.
|
|
7
|
+
- oMLX OpenAI-compatible completions now send `chat_template_kwargs.reasoning_effort` with `enable_thinking` when `thinkingFormat` is `qwen-chat-template`. Discovered oMLX models are treated as reasoning models with `low`/`medium`/`high` effort so local Qwen presets can differentiate roles without swapping weights.
|
|
8
|
+
- Fixed a resume-breaking HTTP 400 on `google-gemini-cli`/`google-antigravity` replay: assistant thinking blocks whose `thinkingSignature` is missing, empty (persistence clears oversized signatures to `""`), or invalid no longer emit an unsigned `{"thought": true}` part. Cloud Code Assist maps such parts to Anthropic `thinking` blocks and rejects the whole request with `messages.N.content.0.thinking.signature: Field required`, permanently bricking resumed sessions (#4630). Unsigned thinking now degrades to plain text — the same treatment cross-model reasoning already gets — while validly signed thinking still replays natively as a thought part with its `thoughtSignature`.
|
|
9
|
+
- `validateApiKeyAgainstModelsEndpoint` no longer accepts an API key on HTTP status alone. A 200 whose body is not JSON or carries no recognizable model list (OpenAI-compatible `data` array, gateway `models` array, or a bare array) now fails closed with an actionable error — previously a captive portal or broken gateway answering 200 with an HTML page silently validated and stored the key. Affects every provider using `kind: "models-endpoint"` (Synthetic, DeepSeek, DeepInfra, Fireworks, BizRouter, NanoGPT, OpenGateway, ZenMux, Fugu). Upstream bodies echoed into validation errors are now bounded to 200 characters on both validators.
|
|
10
|
+
|
|
5
11
|
## [0.14.0] - 2026-08-17
|
|
6
12
|
- Cursor native tool calls (shell/read/write/… oneof variants) now convert their protobuf payloads into plain JSON-safe data before attaching them as toolCall `arguments`: `$typeName` markers are stripped, safe-range bigints become numbers (decimal strings beyond `Number.MAX_SAFE_INTEGER`), byte arrays become base64 strings, and cycles/functions collapse to null. Raw protobuf-es payloads carry `bigint` fields (`fileSize`, `durationMs`, `fileOutputThresholdBytes`, …) that defeat `JSON.stringify`, which broke managed snapshot staging, JSONL transcript persistence, and provider replay — the issue #4578 local-snapshot producer defect class fixed at its producer boundary.
|
|
7
13
|
- Generic OpenAI-compatible `/v1/models` discovery now reads served context-window and output-limit metadata instead of defaulting every dynamically listed model to the unknown-window sentinel. `max_model_len` (vLLM/SGLang/oMLX), `context_length`, `context_window`, `max_context_length` (LM Studio), and `max_position_embeddings` populate `contextWindow` in that precedence order, while `max_tokens`/`max_output_tokens` populate `maxTokens`; total-window fields never leak into the output-token ceiling. Malformed values (non-finite, zero, negative, non-numeric) are rejected per-field with fallback to the next candidate, so a `1e400`-style catalog entry can no longer poison compaction thresholds or compact-input budgets.
|
|
@@ -40,6 +46,7 @@
|
|
|
40
46
|
|
|
41
47
|
### Fixed
|
|
42
48
|
|
|
49
|
+
- Cursor OAuth `GetUsableModels` discovery now reads the live million-token window from model display names and aliases (`Claude Opus 5 1M Thinking`) instead of keeping the bundled 200k default. `GetUsableModels` still has no numeric `context_window` field, so the named window is the authoritative live signal; larger bundled fallbacks such as Gemini's 1048576-token window are preserved.
|
|
43
50
|
- Grok Build `/usage` now uses the weekly credits quota when the monthly billing endpoint reports a zero limit, avoiding misleading `100% free` and `used/0` output.
|
|
44
51
|
- A deterministic Anthropic thinking-replay rejection now converges under a managed fallback attempt instead of repeating forever (#4262). Every coding-agent turn prompts with `fallbackManaged: true`, and the whole in-provider thinking-replay repair sits behind `!options?.fallbackManaged` because the fallback controller owns retries — the shipping CLI therefore never repaired anything: each turn rebuilt the same replay from the same history, drew the same 400, and the session burned one rejected request per turn without ever self-healing (reported as ~1 rejected 1.3 MB request every 12s, ~300/h, never converging). The provider still does not retry inside a managed attempt — it records the full-history repair escalation on the provider session state instead, so the next managed attempt builds a repaired replay at the cost of zero extra round trips. Only the two deterministic rejections (`blocks ... cannot be modified`, ``Invalid `signature` in `thinking` block``) qualify; the proxy-masked generic `api_error` names no cause and may be a transient blip, so it still never costs the session its native replay.
|
|
45
52
|
- A long-lived process now recovers when the OAuth row it holds in memory is soft-disabled or replaced in the shared store by a peer (`invalid_grant` disable, credential removal, or a re-login that inserts a new row). The refresh lease answers `missing` for a row that is no longer active, which surfaced as `OAuth refresh credential disappeared` — an error the failure classifier reads as transient, so the vanished row was temp-blocked and retried for the life of the process while a valid re-login row sat unused in the store. Resumed sessions therefore failed every request with the misleading `No credentials found for <provider>` until the CLI was restarted, even though `gjc` had just been logged back in (observed as a per-request refresh-failure flood against `anthropic`). `#tryOAuthCredential` now detects that the attempted row is gone from the store, reloads the snapshot, and re-resolves within the existing reload budget, alongside the peer-rotation recovery it already performed.
|
|
@@ -201,6 +201,8 @@ export type AnthropicClientOptionsArgs = {
|
|
|
201
201
|
fetch?: FetchImpl;
|
|
202
202
|
requestMaxRetries?: number;
|
|
203
203
|
maxRetryDelayMs?: number;
|
|
204
|
+
streamFirstEventTimeoutMs?: number;
|
|
205
|
+
streamIdleTimeoutMs?: number;
|
|
204
206
|
};
|
|
205
207
|
export type AnthropicClientOptionsResult = {
|
|
206
208
|
isOAuthToken: boolean;
|
|
@@ -208,6 +210,7 @@ export type AnthropicClientOptionsResult = {
|
|
|
208
210
|
authToken?: string | null;
|
|
209
211
|
baseURL?: string;
|
|
210
212
|
maxRetries: number;
|
|
213
|
+
timeout?: number;
|
|
211
214
|
dangerouslyAllowBrowser: boolean;
|
|
212
215
|
defaultHeaders: Record<string, string>;
|
|
213
216
|
logLevel: AnthropicSdkClientOptions["logLevel"];
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Model } from "../../types";
|
|
2
|
+
/** Tokens represented by a Cursor display-name "1M" window. */
|
|
3
|
+
export declare const CURSOR_NAMED_MILLION_CONTEXT_WINDOW = 1000000;
|
|
2
4
|
/**
|
|
3
5
|
* Options for fetching dynamic Cursor models from `GetUsableModels`.
|
|
4
6
|
*/
|
|
@@ -21,3 +23,27 @@ export interface CursorModelDiscoveryOptions {
|
|
|
21
23
|
* Returns `[]` only when the endpoint responds successfully with no usable models.
|
|
22
24
|
*/
|
|
23
25
|
export declare function fetchCursorUsableModels(options: CursorModelDiscoveryOptions): Promise<Model<"cursor-agent">[] | null>;
|
|
26
|
+
export interface CursorLiveContextWindowInput {
|
|
27
|
+
id: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
displayName?: string;
|
|
30
|
+
displayNameShort?: string;
|
|
31
|
+
displayModelId?: string;
|
|
32
|
+
aliases?: readonly string[];
|
|
33
|
+
fallback: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Cursor `GetUsableModels` does not send numeric context-window metadata.
|
|
37
|
+
* The live million-token window is advertised in display names and aliases
|
|
38
|
+
* (for example "Claude Opus 5 1M Thinking"). Never shrink a larger bundled
|
|
39
|
+
* fallback such as Gemini's 1048576-token window.
|
|
40
|
+
*/
|
|
41
|
+
export declare function resolveCursorLiveContextWindow(input: CursorLiveContextWindowInput): number;
|
|
42
|
+
/**
|
|
43
|
+
* Normalize `GetUsableModels` payloads into canonical Cursor model entries.
|
|
44
|
+
* Tests inject `references` so the overlay can be verified without HTTP/2.
|
|
45
|
+
*/
|
|
46
|
+
export declare function normalizeCursorDiscoveryModels(models: readonly unknown[] | undefined, options?: {
|
|
47
|
+
baseUrl?: string;
|
|
48
|
+
references?: ReadonlyMap<string, Model<"cursor-agent">>;
|
|
49
|
+
}): Model<"cursor-agent">[];
|
|
@@ -47,6 +47,22 @@ export declare function getStreamFirstEventTimeoutMs(idleTimeoutMs?: number, fal
|
|
|
47
47
|
* slow setup.
|
|
48
48
|
*/
|
|
49
49
|
export declare function resolveOpenAISdkRequestTimeoutMs(provider: string, streamFirstEventTimeoutOverride?: number): number | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Resolves the Anthropic SDK client `timeout` so stalled-before-headers requests
|
|
52
|
+
* are bounded. The Anthropic first-event watchdog deliberately arms only once
|
|
53
|
+
* response headers have arrived (setup latency must not consume the first-event
|
|
54
|
+
* budget), which left the connect/headers phase governed solely by the SDK
|
|
55
|
+
* default of 10 minutes per attempt — multiplied by SDK-internal retries, a
|
|
56
|
+
* connection that silently died right after a completed tool call could spin
|
|
57
|
+
* with no user-visible error for the better part of an hour.
|
|
58
|
+
*
|
|
59
|
+
* - Explicit `0` disables the request timeout, matching a disabled first-event
|
|
60
|
+
* watchdog.
|
|
61
|
+
* - An explicit nonzero override is floored at the env/default first-event
|
|
62
|
+
* window (which itself never undershoots the provider idle window) so a short
|
|
63
|
+
* post-connect first-event budget cannot kill legitimate slow setup.
|
|
64
|
+
*/
|
|
65
|
+
export declare function resolveAnthropicSdkRequestTimeoutMs(provider: string, streamFirstEventTimeoutOverride?: number, streamIdleTimeoutOverride?: number): number | undefined;
|
|
50
66
|
export type Watchdog = NodeJS.Timeout | undefined;
|
|
51
67
|
export interface FirstEventTimeoutFacts {
|
|
52
68
|
requestBytes?: number;
|
|
@@ -22,6 +22,12 @@ export declare function validateOpenAICompatibleApiKey(options: OpenAICompatible
|
|
|
22
22
|
*
|
|
23
23
|
* Useful for providers where access to specific models may vary by plan and
|
|
24
24
|
* should not block key validation.
|
|
25
|
+
*
|
|
26
|
+
* A 200 status alone is NOT accepted: a captive portal, misrouting proxy, or
|
|
27
|
+
* broken gateway can answer 200 with an HTML page or an empty JSON object, and
|
|
28
|
+
* accepting the key on status alone would store a credential that was never
|
|
29
|
+
* actually checked. The body must parse as JSON and carry a recognizable model
|
|
30
|
+
* list before the key is considered validated.
|
|
25
31
|
*/
|
|
26
32
|
export declare function validateApiKeyAgainstModelsEndpoint(options: ModelListValidationOptions): Promise<void>;
|
|
27
33
|
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/ai",
|
|
4
|
-
"version": "0.14.
|
|
4
|
+
"version": "0.14.1",
|
|
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.14.
|
|
44
|
-
"@gajae-code/utils": "0.14.
|
|
43
|
+
"@gajae-code/natives": "0.14.1",
|
|
44
|
+
"@gajae-code/utils": "0.14.1",
|
|
45
45
|
"openai": "^6.36.0",
|
|
46
46
|
"partial-json": "^0.1.7",
|
|
47
47
|
"zod": "4.4.3"
|
package/src/model-thinking.ts
CHANGED
|
@@ -221,6 +221,9 @@ export function applyGeneratedModelPolicies(models: ApiModel<Api>[]): void {
|
|
|
221
221
|
if (source.id.split("/").at(-1)?.toLowerCase() === "muse-spark-1.2") {
|
|
222
222
|
source.reasoning = true;
|
|
223
223
|
}
|
|
224
|
+
if (source.provider === "omlx") {
|
|
225
|
+
source.reasoning = true;
|
|
226
|
+
}
|
|
224
227
|
const model = refreshModelThinking(source);
|
|
225
228
|
applyGeneratedModelPolicy(model);
|
|
226
229
|
models[index] = model;
|
|
@@ -430,6 +433,16 @@ function applyGeneratedModelPolicy(model: ApiModel<Api>): void {
|
|
|
430
433
|
};
|
|
431
434
|
delete model.compat.thinkingFormat;
|
|
432
435
|
}
|
|
436
|
+
if (model.provider === "omlx" && model.api === "openai-completions") {
|
|
437
|
+
model.compat = {
|
|
438
|
+
...(model.compat ?? {}),
|
|
439
|
+
supportsStore: false,
|
|
440
|
+
supportsDeveloperRole: false,
|
|
441
|
+
supportsReasoningEffort: true,
|
|
442
|
+
thinkingFormat: "qwen-chat-template",
|
|
443
|
+
reasoningContentField: "reasoning_content",
|
|
444
|
+
};
|
|
445
|
+
}
|
|
433
446
|
model.name = scrubGeneratedModelName(model.name);
|
|
434
447
|
if (
|
|
435
448
|
model.api === "openai-completions" &&
|
|
@@ -621,6 +634,9 @@ function inferDefaultEffort<TApi extends Api>(model: ApiModel<TApi>, parsedModel
|
|
|
621
634
|
) {
|
|
622
635
|
return GPT_5_5_DEFAULT_EFFORT;
|
|
623
636
|
}
|
|
637
|
+
if (model.provider === "omlx") {
|
|
638
|
+
return Effort.Medium;
|
|
639
|
+
}
|
|
624
640
|
return undefined;
|
|
625
641
|
}
|
|
626
642
|
|
|
@@ -784,6 +800,9 @@ function inferFallbackEfforts<TApi extends Api>(model: ApiModel<TApi>): readonly
|
|
|
784
800
|
return DEFAULT_REASONING_EFFORTS;
|
|
785
801
|
}
|
|
786
802
|
if (model.api === "openai-completions") {
|
|
803
|
+
if (model.provider === "omlx") {
|
|
804
|
+
return [Effort.Low, Effort.Medium, Effort.High];
|
|
805
|
+
}
|
|
787
806
|
const compat = resolveOpenAICompat(model as ApiModel<"openai-completions">);
|
|
788
807
|
if (compat.thinkingFormat === "openai" && compat.supportsReasoningEffort) {
|
|
789
808
|
return DEFAULT_REASONING_EFFORTS_WITH_XHIGH;
|
|
@@ -1418,6 +1418,24 @@ export function omlxModelManagerOptions(config?: OmlxModelManagerConfig): ModelM
|
|
|
1418
1418
|
provider: "omlx",
|
|
1419
1419
|
baseUrl,
|
|
1420
1420
|
apiKey,
|
|
1421
|
+
mapModel: (_entry, defaults) => ({
|
|
1422
|
+
...defaults,
|
|
1423
|
+
reasoning: true,
|
|
1424
|
+
thinking: {
|
|
1425
|
+
mode: "effort",
|
|
1426
|
+
minLevel: Effort.Low,
|
|
1427
|
+
maxLevel: Effort.High,
|
|
1428
|
+
defaultLevel: Effort.Medium,
|
|
1429
|
+
levels: [Effort.Low, Effort.Medium, Effort.High],
|
|
1430
|
+
},
|
|
1431
|
+
compat: {
|
|
1432
|
+
supportsStore: false,
|
|
1433
|
+
supportsDeveloperRole: false,
|
|
1434
|
+
supportsReasoningEffort: true,
|
|
1435
|
+
thinkingFormat: "qwen-chat-template",
|
|
1436
|
+
reasoningContentField: "reasoning_content",
|
|
1437
|
+
},
|
|
1438
|
+
}),
|
|
1421
1439
|
}),
|
|
1422
1440
|
};
|
|
1423
1441
|
}
|
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
getStreamFirstEventTimeoutMs,
|
|
73
73
|
getStreamIdleTimeoutMs,
|
|
74
74
|
iterateWithIdleTimeout,
|
|
75
|
+
resolveAnthropicSdkRequestTimeoutMs,
|
|
75
76
|
} from "../utils/idle-iterator";
|
|
76
77
|
import {
|
|
77
78
|
findUnnecessaryUnicodeEscape,
|
|
@@ -1117,6 +1118,8 @@ export type AnthropicClientOptionsArgs = {
|
|
|
1117
1118
|
fetch?: FetchImpl;
|
|
1118
1119
|
requestMaxRetries?: number;
|
|
1119
1120
|
maxRetryDelayMs?: number;
|
|
1121
|
+
streamFirstEventTimeoutMs?: number;
|
|
1122
|
+
streamIdleTimeoutMs?: number;
|
|
1120
1123
|
};
|
|
1121
1124
|
|
|
1122
1125
|
export type AnthropicClientOptionsResult = {
|
|
@@ -1125,6 +1128,7 @@ export type AnthropicClientOptionsResult = {
|
|
|
1125
1128
|
authToken?: string | null;
|
|
1126
1129
|
baseURL?: string;
|
|
1127
1130
|
maxRetries: number;
|
|
1131
|
+
timeout?: number;
|
|
1128
1132
|
dangerouslyAllowBrowser: boolean;
|
|
1129
1133
|
defaultHeaders: Record<string, string>;
|
|
1130
1134
|
logLevel: AnthropicSdkClientOptions["logLevel"];
|
|
@@ -1907,6 +1911,8 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
|
|
1907
1911
|
fetch: options?.fetch,
|
|
1908
1912
|
requestMaxRetries: options?.requestMaxRetries,
|
|
1909
1913
|
maxRetryDelayMs: options?.maxRetryDelayMs,
|
|
1914
|
+
streamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs,
|
|
1915
|
+
streamIdleTimeoutMs: options?.streamIdleTimeoutMs,
|
|
1910
1916
|
});
|
|
1911
1917
|
client = created.client;
|
|
1912
1918
|
isOAuthToken = created.isOAuthToken;
|
|
@@ -2996,6 +3002,16 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
2996
3002
|
const baseFetch = args.fetch ?? fetch;
|
|
2997
3003
|
const boundedFetch = wrapAnthropicFetchForBoundedRateLimits(baseFetch, args.maxRetryDelayMs);
|
|
2998
3004
|
const debugFetch = onSseEvent ? wrapFetchForSseDebug(boundedFetch, event => onSseEvent(event, model)) : boundedFetch;
|
|
3005
|
+
// Bound the connect/headers phase. The first-event watchdog arms only after
|
|
3006
|
+
// response headers arrive, so a request whose connection dies before headers
|
|
3007
|
+
// was previously governed only by the Anthropic SDK's 10-minute default per
|
|
3008
|
+
// attempt times its internal retry budget — observable as an endless spinner
|
|
3009
|
+
// right after a completed tool call.
|
|
3010
|
+
const sdkTimeoutMs = resolveAnthropicSdkRequestTimeoutMs(
|
|
3011
|
+
model.provider,
|
|
3012
|
+
args.streamFirstEventTimeoutMs,
|
|
3013
|
+
args.streamIdleTimeoutMs,
|
|
3014
|
+
);
|
|
2999
3015
|
if (model.provider === "github-copilot") {
|
|
3000
3016
|
const copilotApiKey = parseGitHubCopilotApiKey(apiKey).accessToken;
|
|
3001
3017
|
const betaFeatures = [...extraBetas];
|
|
@@ -3020,6 +3036,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
3020
3036
|
authToken: copilotApiKey,
|
|
3021
3037
|
baseURL: baseUrl,
|
|
3022
3038
|
maxRetries: resolveRetryBudget(args.requestMaxRetries, 5),
|
|
3039
|
+
...(sdkTimeoutMs !== undefined ? { timeout: sdkTimeoutMs } : {}),
|
|
3023
3040
|
dangerouslyAllowBrowser: true,
|
|
3024
3041
|
defaultHeaders,
|
|
3025
3042
|
logLevel: ANTHROPIC_SDK_LOG_LEVEL,
|
|
@@ -3054,6 +3071,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
3054
3071
|
authToken: null,
|
|
3055
3072
|
baseURL: baseUrl,
|
|
3056
3073
|
maxRetries: resolveRetryBudget(args.requestMaxRetries, 5),
|
|
3074
|
+
...(sdkTimeoutMs !== undefined ? { timeout: sdkTimeoutMs } : {}),
|
|
3057
3075
|
dangerouslyAllowBrowser: true,
|
|
3058
3076
|
defaultHeaders,
|
|
3059
3077
|
logLevel: ANTHROPIC_SDK_LOG_LEVEL,
|
|
@@ -3072,6 +3090,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
3072
3090
|
authToken: null,
|
|
3073
3091
|
baseURL: baseUrl,
|
|
3074
3092
|
maxRetries: resolveRetryBudget(args.requestMaxRetries, 5),
|
|
3093
|
+
...(sdkTimeoutMs !== undefined ? { timeout: sdkTimeoutMs } : {}),
|
|
3075
3094
|
dangerouslyAllowBrowser: true,
|
|
3076
3095
|
defaultHeaders,
|
|
3077
3096
|
logLevel: ANTHROPIC_SDK_LOG_LEVEL,
|
|
@@ -3086,6 +3105,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
3086
3105
|
authToken: oauthToken ? apiKey : undefined,
|
|
3087
3106
|
baseURL: baseUrl,
|
|
3088
3107
|
maxRetries: resolveRetryBudget(args.requestMaxRetries, 5),
|
|
3108
|
+
...(sdkTimeoutMs !== undefined ? { timeout: sdkTimeoutMs } : {}),
|
|
3089
3109
|
dangerouslyAllowBrowser: true,
|
|
3090
3110
|
defaultHeaders,
|
|
3091
3111
|
logLevel: ANTHROPIC_SDK_LOG_LEVEL,
|
|
@@ -257,11 +257,23 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
|
|
257
257
|
// Otherwise convert to plain text (no tags to avoid model mimicking them)
|
|
258
258
|
if (isSameProviderAndModel) {
|
|
259
259
|
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thinkingSignature);
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
260
|
+
// An unsigned `thought: true` part cannot be replayed: Cloud Code Assist
|
|
261
|
+
// maps it to an Anthropic `thinking` block and rejects the whole request
|
|
262
|
+
// with `thinking.signature: Field required` (#4630). Persisted signatures
|
|
263
|
+
// can be missing entirely or cleared to "" (oversized-signature
|
|
264
|
+
// persistence), so degrade those blocks to the same plain-text treatment
|
|
265
|
+
// cross-model reasoning already gets. Signed thinking still replays natively.
|
|
266
|
+
if (!thoughtSignature) {
|
|
267
|
+
parts.push({
|
|
268
|
+
text: block.thinking.toWellFormed(),
|
|
269
|
+
});
|
|
270
|
+
} else {
|
|
271
|
+
parts.push({
|
|
272
|
+
thought: true,
|
|
273
|
+
text: block.thinking.toWellFormed(),
|
|
274
|
+
thoughtSignature,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
265
277
|
} else {
|
|
266
278
|
parts.push({
|
|
267
279
|
text: block.thinking.toWellFormed(),
|
|
@@ -1398,8 +1398,12 @@ function buildParams(
|
|
|
1398
1398
|
// Qwen uses top-level enable_thinking: boolean
|
|
1399
1399
|
params.enable_thinking = !!options?.reasoning && !options?.disableReasoning;
|
|
1400
1400
|
} else if (supportsReasoningParams && compat.thinkingFormat === "qwen-chat-template" && model.reasoning) {
|
|
1401
|
+
const enableThinking = !!options?.reasoning && !options?.disableReasoning;
|
|
1401
1402
|
params.chat_template_kwargs = {
|
|
1402
|
-
enable_thinking:
|
|
1403
|
+
enable_thinking: enableThinking,
|
|
1404
|
+
...(enableThinking && options?.reasoning
|
|
1405
|
+
? { reasoning_effort: mapReasoningEffort(options.reasoning, compat.reasoningEffortMap) }
|
|
1406
|
+
: {}),
|
|
1403
1407
|
};
|
|
1404
1408
|
} else if (supportsReasoningParams && compat.thinkingFormat === "openrouter" && model.reasoning) {
|
|
1405
1409
|
// OpenRouter normalizes reasoning across providers via a nested reasoning object.
|
|
@@ -11,6 +11,9 @@ const CURSOR_GET_USABLE_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels";
|
|
|
11
11
|
|
|
12
12
|
const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
13
13
|
const DEFAULT_MAX_TOKENS = 64_000;
|
|
14
|
+
/** Tokens represented by a Cursor display-name "1M" window. */
|
|
15
|
+
export const CURSOR_NAMED_MILLION_CONTEXT_WINDOW = 1_000_000;
|
|
16
|
+
const CURSOR_NAMED_MILLION_WINDOW_RE = /\b(\d+(?:\.\d+)?)M\b/i;
|
|
14
17
|
|
|
15
18
|
const OptionalDisplayNameSchema = z.string().optional().catch(undefined);
|
|
16
19
|
const CursorAliasesSchema = z
|
|
@@ -78,8 +81,9 @@ export async function fetchCursorUsableModels(
|
|
|
78
81
|
return null;
|
|
79
82
|
}
|
|
80
83
|
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
return normalizeCursorDiscoveryModels(parsedDecoded.data.models, {
|
|
85
|
+
baseUrl: options.baseUrl,
|
|
86
|
+
});
|
|
83
87
|
} catch {
|
|
84
88
|
return null;
|
|
85
89
|
}
|
|
@@ -227,6 +231,74 @@ function decodeConnectUnaryBody(payload: Uint8Array): Uint8Array | null {
|
|
|
227
231
|
return null;
|
|
228
232
|
}
|
|
229
233
|
|
|
234
|
+
export interface CursorLiveContextWindowInput {
|
|
235
|
+
id: string;
|
|
236
|
+
name?: string;
|
|
237
|
+
displayName?: string;
|
|
238
|
+
displayNameShort?: string;
|
|
239
|
+
displayModelId?: string;
|
|
240
|
+
aliases?: readonly string[];
|
|
241
|
+
fallback: number;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Cursor `GetUsableModels` does not send numeric context-window metadata.
|
|
246
|
+
* The live million-token window is advertised in display names and aliases
|
|
247
|
+
* (for example "Claude Opus 5 1M Thinking"). Never shrink a larger bundled
|
|
248
|
+
* fallback such as Gemini's 1048576-token window.
|
|
249
|
+
*/
|
|
250
|
+
export function resolveCursorLiveContextWindow(input: CursorLiveContextWindowInput): number {
|
|
251
|
+
const fallback = Number.isFinite(input.fallback) && input.fallback > 0 ? input.fallback : DEFAULT_CONTEXT_WINDOW;
|
|
252
|
+
const named = parseCursorNamedMillionWindow([
|
|
253
|
+
input.displayName,
|
|
254
|
+
input.displayNameShort,
|
|
255
|
+
input.name,
|
|
256
|
+
input.displayModelId,
|
|
257
|
+
input.id,
|
|
258
|
+
...(input.aliases ?? []),
|
|
259
|
+
]);
|
|
260
|
+
if (named === undefined) {
|
|
261
|
+
return fallback;
|
|
262
|
+
}
|
|
263
|
+
return Math.max(named, fallback);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function parseCursorNamedMillionWindow(texts: Array<string | undefined>): number | undefined {
|
|
267
|
+
for (const text of texts) {
|
|
268
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const match = CURSOR_NAMED_MILLION_WINDOW_RE.exec(text);
|
|
272
|
+
if (!match) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const millions = Number(match[1]);
|
|
276
|
+
if (!Number.isFinite(millions) || millions <= 0) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const tokens = Math.round(millions * CURSOR_NAMED_MILLION_CONTEXT_WINDOW);
|
|
280
|
+
if (Number.isFinite(tokens) && tokens > 0) {
|
|
281
|
+
return tokens;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Normalize `GetUsableModels` payloads into canonical Cursor model entries.
|
|
289
|
+
* Tests inject `references` so the overlay can be verified without HTTP/2.
|
|
290
|
+
*/
|
|
291
|
+
export function normalizeCursorDiscoveryModels(
|
|
292
|
+
models: readonly unknown[] | undefined,
|
|
293
|
+
options: {
|
|
294
|
+
baseUrl?: string;
|
|
295
|
+
references?: ReadonlyMap<string, Model<"cursor-agent">>;
|
|
296
|
+
} = {},
|
|
297
|
+
): Model<"cursor-agent">[] {
|
|
298
|
+
const references = options.references ? new Map(options.references) : createCursorReferenceMap();
|
|
299
|
+
return normalizeCursorModels(models, options.baseUrl, references);
|
|
300
|
+
}
|
|
301
|
+
|
|
230
302
|
function normalizeCursorModels(
|
|
231
303
|
models: readonly unknown[] | undefined,
|
|
232
304
|
baseUrlOverride: string | undefined,
|
|
@@ -267,6 +339,15 @@ function normalizeCursorModel(
|
|
|
267
339
|
const name = pickModelDisplayName(details, id);
|
|
268
340
|
const reference = references.get(id);
|
|
269
341
|
const reasoning = Boolean(details.thinkingDetails) || reference?.reasoning === true;
|
|
342
|
+
const contextWindow = resolveCursorLiveContextWindow({
|
|
343
|
+
id,
|
|
344
|
+
name,
|
|
345
|
+
displayName: details.displayName,
|
|
346
|
+
displayNameShort: details.displayNameShort,
|
|
347
|
+
displayModelId: details.displayModelId,
|
|
348
|
+
aliases: details.aliases,
|
|
349
|
+
fallback: reference?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
350
|
+
});
|
|
270
351
|
|
|
271
352
|
if (reference) {
|
|
272
353
|
return {
|
|
@@ -275,6 +356,7 @@ function normalizeCursorModel(
|
|
|
275
356
|
name,
|
|
276
357
|
baseUrl: baseUrlOverride ?? reference.baseUrl,
|
|
277
358
|
reasoning,
|
|
359
|
+
contextWindow,
|
|
278
360
|
};
|
|
279
361
|
}
|
|
280
362
|
return {
|
|
@@ -286,7 +368,7 @@ function normalizeCursorModel(
|
|
|
286
368
|
reasoning,
|
|
287
369
|
input: ["text"],
|
|
288
370
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
289
|
-
contextWindow
|
|
371
|
+
contextWindow,
|
|
290
372
|
maxTokens: DEFAULT_MAX_TOKENS,
|
|
291
373
|
};
|
|
292
374
|
}
|
|
@@ -105,6 +105,39 @@ export function resolveOpenAISdkRequestTimeoutMs(
|
|
|
105
105
|
return envSdkTimeoutMs;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Resolves the Anthropic SDK client `timeout` so stalled-before-headers requests
|
|
110
|
+
* are bounded. The Anthropic first-event watchdog deliberately arms only once
|
|
111
|
+
* response headers have arrived (setup latency must not consume the first-event
|
|
112
|
+
* budget), which left the connect/headers phase governed solely by the SDK
|
|
113
|
+
* default of 10 minutes per attempt — multiplied by SDK-internal retries, a
|
|
114
|
+
* connection that silently died right after a completed tool call could spin
|
|
115
|
+
* with no user-visible error for the better part of an hour.
|
|
116
|
+
*
|
|
117
|
+
* - Explicit `0` disables the request timeout, matching a disabled first-event
|
|
118
|
+
* watchdog.
|
|
119
|
+
* - An explicit nonzero override is floored at the env/default first-event
|
|
120
|
+
* window (which itself never undershoots the provider idle window) so a short
|
|
121
|
+
* post-connect first-event budget cannot kill legitimate slow setup.
|
|
122
|
+
*/
|
|
123
|
+
export function resolveAnthropicSdkRequestTimeoutMs(
|
|
124
|
+
provider: string,
|
|
125
|
+
streamFirstEventTimeoutOverride?: number,
|
|
126
|
+
streamIdleTimeoutOverride?: number,
|
|
127
|
+
): number | undefined {
|
|
128
|
+
const idleTimeoutMs =
|
|
129
|
+
streamIdleTimeoutOverride ?? getStreamIdleTimeoutMs(getProviderStreamIdleTimeoutFallbackMs(provider));
|
|
130
|
+
const envSdkTimeoutMs = getStreamFirstEventTimeoutMs(
|
|
131
|
+
idleTimeoutMs,
|
|
132
|
+
getProviderFirstEventTimeoutFallbackMs(provider),
|
|
133
|
+
);
|
|
134
|
+
if (streamFirstEventTimeoutOverride === 0) return undefined;
|
|
135
|
+
if (streamFirstEventTimeoutOverride !== undefined) {
|
|
136
|
+
return Math.max(envSdkTimeoutMs ?? 0, streamFirstEventTimeoutOverride);
|
|
137
|
+
}
|
|
138
|
+
return envSdkTimeoutMs;
|
|
139
|
+
}
|
|
140
|
+
|
|
108
141
|
export type Watchdog = NodeJS.Timeout | undefined;
|
|
109
142
|
export interface FirstEventTimeoutFacts {
|
|
110
143
|
requestBytes?: number;
|
|
@@ -15,6 +15,14 @@ type ModelListValidationOptions = {
|
|
|
15
15
|
|
|
16
16
|
const VALIDATION_TIMEOUT_MS = 15_000;
|
|
17
17
|
|
|
18
|
+
/** Most characters of an upstream body echoed into a validation error. */
|
|
19
|
+
const VALIDATION_DETAILS_LIMIT = 200;
|
|
20
|
+
|
|
21
|
+
function boundedDetails(text: string): string {
|
|
22
|
+
const trimmed = text.trim();
|
|
23
|
+
return trimmed.length > VALIDATION_DETAILS_LIMIT ? `${trimmed.slice(0, VALIDATION_DETAILS_LIMIT)}…` : trimmed;
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
/**
|
|
19
27
|
* Validate an API key against an OpenAI-compatible chat completions endpoint.
|
|
20
28
|
*
|
|
@@ -45,7 +53,7 @@ export async function validateOpenAICompatibleApiKey(options: OpenAICompatibleVa
|
|
|
45
53
|
|
|
46
54
|
let details = "";
|
|
47
55
|
try {
|
|
48
|
-
details = (await response.text())
|
|
56
|
+
details = boundedDetails(await response.text());
|
|
49
57
|
} catch {
|
|
50
58
|
// ignore body parse errors, status is enough
|
|
51
59
|
}
|
|
@@ -55,12 +63,30 @@ export async function validateOpenAICompatibleApiKey(options: OpenAICompatibleVa
|
|
|
55
63
|
: `${options.provider} API key validation failed (${response.status})`;
|
|
56
64
|
throw new Error(message);
|
|
57
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Whether a 200 body is a recognizable model list. OpenAI-compatible endpoints
|
|
68
|
+
* return `{"object":"list","data":[...]}`; some gateways answer with a bare
|
|
69
|
+
* array or `{"models":[...]}`. Anything else — including valid JSON without a
|
|
70
|
+
* list — is not evidence that the credential reached a models endpoint.
|
|
71
|
+
*/
|
|
72
|
+
function isModelList(parsed: unknown): boolean {
|
|
73
|
+
if (Array.isArray(parsed)) return true;
|
|
74
|
+
if (typeof parsed !== "object" || parsed === null) return false;
|
|
75
|
+
const record = parsed as { data?: unknown; models?: unknown };
|
|
76
|
+
return Array.isArray(record.data) || Array.isArray(record.models);
|
|
77
|
+
}
|
|
58
78
|
|
|
59
79
|
/**
|
|
60
80
|
* Validate an API key against a provider models endpoint.
|
|
61
81
|
*
|
|
62
82
|
* Useful for providers where access to specific models may vary by plan and
|
|
63
83
|
* should not block key validation.
|
|
84
|
+
*
|
|
85
|
+
* A 200 status alone is NOT accepted: a captive portal, misrouting proxy, or
|
|
86
|
+
* broken gateway can answer 200 with an HTML page or an empty JSON object, and
|
|
87
|
+
* accepting the key on status alone would store a credential that was never
|
|
88
|
+
* actually checked. The body must parse as JSON and carry a recognizable model
|
|
89
|
+
* list before the key is considered validated.
|
|
64
90
|
*/
|
|
65
91
|
export async function validateApiKeyAgainstModelsEndpoint(options: ModelListValidationOptions): Promise<void> {
|
|
66
92
|
const timeoutSignal = AbortSignal.timeout(VALIDATION_TIMEOUT_MS);
|
|
@@ -75,12 +101,37 @@ export async function validateApiKeyAgainstModelsEndpoint(options: ModelListVali
|
|
|
75
101
|
});
|
|
76
102
|
|
|
77
103
|
if (response.ok) {
|
|
104
|
+
let body: string;
|
|
105
|
+
try {
|
|
106
|
+
body = await response.text();
|
|
107
|
+
} catch (error) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`${options.provider} API key validation failed: the models endpoint response body could not be read (${
|
|
110
|
+
error instanceof Error ? error.message : String(error)
|
|
111
|
+
})`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
let parsed: unknown;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(body);
|
|
117
|
+
} catch {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`${options.provider} API key validation failed: the models endpoint returned ${response.status} with a non-JSON body` +
|
|
120
|
+
`${body.trim() ? ` (${boundedDetails(body)})` : ""}. Refusing to accept the key on status alone.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (!isModelList(parsed)) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`${options.provider} API key validation failed: the models endpoint returned ${response.status} without a recognizable ` +
|
|
126
|
+
`model list. Refusing to accept the key on status alone.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
78
129
|
return;
|
|
79
130
|
}
|
|
80
131
|
|
|
81
132
|
let details = "";
|
|
82
133
|
try {
|
|
83
|
-
details = (await response.text())
|
|
134
|
+
details = boundedDetails(await response.text());
|
|
84
135
|
} catch {
|
|
85
136
|
// ignore body parse errors, status is enough
|
|
86
137
|
}
|