@gajae-code/ai 0.18.0 → 0.18.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 CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.18.1] - 2026-09-29
6
+
7
+ ### Changed
8
+
9
+ - Bump the bundled Claude Code fingerprint floor from 2.1.281 to 2.1.284.
10
+ - Anthropic OAuth requests now sign `claude-cli/<version>` and the billing `cc_version` with the latest published Claude Code release instead of a build-time constant. The version is resolved in the background from Anthropic's `latest` release channel and the npm dist-tag (higher wins), refreshed at most every 6 hours, cached in `~/.gjc/cache/claude-code-version.json`, and never blocks a request or drops below the bundled floor. Set `GJC_CLAUDE_CODE_VERSION=X.Y.Z` to pin it. A stale fingerprint is answered with HTTP 400 `claude_code_version_too_old` on newer models, so this no longer waits for a gjc release to recover.
11
+
12
+ ### Fixed
13
+
14
+ - OpenGateway models discovered from `/v1/models` now take their capabilities from the bundled catalog entry for the same upstream model, and `-ultrafast` deployments resolve to their upstream model, so `opengateway/deepseek/deepseek-v4.1-flash-ultrafast` and the GLM/Kimi ultrafast models are registered with reasoning support and accept thinking levels (#6070).
15
+
16
+ - Bound Codex turns that stall after an empty reasoning item and streamed tool-call start to the shared stream idle timeout.
17
+
18
+ - Apply the shared stream idle timeout to Codex WebSocket responses when no WebSocket-specific timeout is configured, so a stalled preferred transport surfaces an error within the expected idle window.
19
+
5
20
  ## [0.18.0] - 2026-09-27
6
21
 
7
22
  ### Added
@@ -14,6 +14,7 @@ export * from "./provider-diagnostic";
14
14
  export * from "./provider-models";
15
15
  export * from "./providers/anthropic";
16
16
  export * from "./providers/azure-openai-responses";
17
+ export * from "./providers/claude-code-version";
17
18
  export type * from "./providers/cursor";
18
19
  export * from "./providers/gitlab-duo";
19
20
  export type * from "./providers/google";
@@ -121,8 +121,11 @@ export interface OpenGatewayModelManagerConfig {
121
121
  }
122
122
  /**
123
123
  * OpenGateway by Sionic AI — an OpenAI-compatible gateway that fronts OpenAI,
124
- * Anthropic, and Google models behind one API key. Models are discovered from
125
- * the OpenAI-compatible `/v1/models` endpoint.
124
+ * Anthropic, Google, DeepSeek, Z.ai, Moonshot and Qwen models behind one API key.
125
+ * Models are discovered from the OpenAI-compatible `/v1/models` endpoint, which
126
+ * reports no capability metadata, so capabilities come from the bundled catalog:
127
+ * the OpenGateway entry first, then the same upstream id from any bundled
128
+ * provider, with `-ultrafast` deployments resolved to their upstream model.
126
129
  */
127
130
  export declare function opengatewayModelManagerOptions(config?: OpenGatewayModelManagerConfig): ModelManagerOptions<"openai-completions">;
128
131
  export interface BizRouterModelManagerConfig {
@@ -107,7 +107,6 @@ export interface CpaToolAliasRestoreFailure {
107
107
  */
108
108
  export declare function parseCpaToolAliasRestoreFailure(error: unknown): CpaToolAliasRestoreFailure | undefined;
109
109
  export declare function isCpaToolAliasRestoreFailure(error: unknown): boolean;
110
- export declare const claudeCodeVersion = "2.1.281";
111
110
  export declare const claudeCodeEntrypoint = "sdk-cli";
112
111
  export declare const claudeToolPrefix: string;
113
112
  export declare const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
@@ -0,0 +1,39 @@
1
+ /** Bundled floor; `scripts/check-spoofed-versions.ts --update` keeps it current. */
2
+ export declare const CLAUDE_CODE_BASELINE_VERSION = "2.1.284";
3
+ /** Pins the fingerprint and disables network resolution. */
4
+ export declare const CLAUDE_CODE_VERSION_ENV = "GJC_CLAUDE_CODE_VERSION";
5
+ /**
6
+ * Anthropic's native-installer release channel. `stable` deliberately lags the
7
+ * npm package during a staged rollout, so `latest` is the channel that matches
8
+ * what a freshly updated Claude Code actually reports.
9
+ */
10
+ export declare const CLAUDE_RELEASE_CHANNEL_URL = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/latest";
11
+ export declare const CLAUDE_NPM_DIST_TAG_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
12
+ type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
13
+ export declare function compareClaudeCodeVersions(a: string, b: string): number;
14
+ /**
15
+ * Latest published Claude Code version: the higher of the native-installer
16
+ * `latest` channel and the npm dist-tag, so one lagging or unavailable source
17
+ * cannot pin the fingerprint back. `null` when both sources fail.
18
+ */
19
+ export declare function fetchLatestClaudeCodeVersion(fetchImpl?: FetchLike, userAgent?: string): Promise<string | null>;
20
+ export interface ClaudeCodeVersionResolverOptions {
21
+ baseline: string;
22
+ /** Exact pin; disables network resolution when set. */
23
+ pinned?: string;
24
+ /** `null` disables both disk caching and network resolution. */
25
+ cachePath: string | null;
26
+ fetch?: FetchLike;
27
+ now?: () => number;
28
+ refreshIntervalMs?: number;
29
+ }
30
+ export interface ClaudeCodeVersionResolver {
31
+ /** Current fingerprint; schedules a background refresh when the cache is stale. */
32
+ get(): string;
33
+ /** Refresh now (deduplicated); resolves with the fingerprint afterwards. */
34
+ refresh(): Promise<string>;
35
+ }
36
+ export declare function createClaudeCodeVersionResolver(options: ClaudeCodeVersionResolverOptions): ClaudeCodeVersionResolver;
37
+ /** Claude Code version the `claude-cli` fingerprint and billing attribution sign with. */
38
+ export declare function getClaudeCodeVersion(): string;
39
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.18.0",
4
+ "version": "0.18.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",
@@ -41,8 +41,8 @@
41
41
  "@agentclientprotocol/sdk": "1.3.0",
42
42
  "@anthropic-ai/sdk": "^0.94.0",
43
43
  "@bufbuild/protobuf": "^2.12.0",
44
- "@gajae-code/natives": "0.18.0",
45
- "@gajae-code/utils": "0.18.0",
44
+ "@gajae-code/natives": "0.18.1",
45
+ "@gajae-code/utils": "0.18.1",
46
46
  "openai": "^6.36.0",
47
47
  "partial-json": "^0.1.7",
48
48
  "zod": "4.4.3"
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export * from "./provider-diagnostic";
14
14
  export * from "./provider-models";
15
15
  export * from "./providers/anthropic";
16
16
  export * from "./providers/azure-openai-responses";
17
+ export * from "./providers/claude-code-version";
17
18
  export type * from "./providers/cursor";
18
19
  export * from "./providers/gitlab-duo";
19
20
  export type * from "./providers/google";
@@ -1175,15 +1175,52 @@ export interface OpenGatewayModelManagerConfig {
1175
1175
  baseUrl?: string;
1176
1176
  }
1177
1177
 
1178
+ const OPENGATEWAY_BASE_URL = "https://apis.opengateway.ai/v1";
1179
+ /** OpenGateway serves accelerated deployments of an upstream model as `<upstream id>-ultrafast`. */
1180
+ const OPENGATEWAY_ULTRAFAST_SUFFIX = "-ultrafast";
1181
+
1178
1182
  /**
1179
1183
  * OpenGateway by Sionic AI — an OpenAI-compatible gateway that fronts OpenAI,
1180
- * Anthropic, and Google models behind one API key. Models are discovered from
1181
- * the OpenAI-compatible `/v1/models` endpoint.
1184
+ * Anthropic, Google, DeepSeek, Z.ai, Moonshot and Qwen models behind one API key.
1185
+ * Models are discovered from the OpenAI-compatible `/v1/models` endpoint, which
1186
+ * reports no capability metadata, so capabilities come from the bundled catalog:
1187
+ * the OpenGateway entry first, then the same upstream id from any bundled
1188
+ * provider, with `-ultrafast` deployments resolved to their upstream model.
1182
1189
  */
1183
1190
  export function opengatewayModelManagerOptions(
1184
1191
  config?: OpenGatewayModelManagerConfig,
1185
1192
  ): ModelManagerOptions<"openai-completions"> {
1186
- return createSimpleOpenAICompletionsOptions("opengateway", "https://apis.opengateway.ai/v1", config);
1193
+ const apiKey = config?.apiKey;
1194
+ const baseUrl = config?.baseUrl ?? OPENGATEWAY_BASE_URL;
1195
+ const resolveReference = createReferenceResolver(createBundledReferenceMap<"openai-completions">("opengateway"));
1196
+ return {
1197
+ providerId: "opengateway",
1198
+ ...(apiKey && {
1199
+ fetchDynamicModels: () =>
1200
+ fetchOpenAICompatibleModels({
1201
+ api: "openai-completions",
1202
+ provider: "opengateway",
1203
+ baseUrl,
1204
+ apiKey,
1205
+ mapModel: (entry, defaults) => {
1206
+ const reference =
1207
+ resolveReference(defaults.id) ??
1208
+ (defaults.id.endsWith(OPENGATEWAY_ULTRAFAST_SUFFIX)
1209
+ ? resolveReference(defaults.id.slice(0, -OPENGATEWAY_ULTRAFAST_SUFFIX.length))
1210
+ : undefined);
1211
+ const mapped = mapWithBundledReference(entry, defaults, reference);
1212
+ return {
1213
+ ...mapped,
1214
+ contextWindow: toPositiveNumber(entry.context_window, mapped.contextWindow),
1215
+ maxTokens: toPositiveNumber(entry.max_output_tokens, mapped.maxTokens),
1216
+ api: "openai-completions",
1217
+ provider: "opengateway",
1218
+ baseUrl,
1219
+ };
1220
+ },
1221
+ }),
1222
+ }),
1223
+ };
1187
1224
  }
1188
1225
 
1189
1226
  // ---------------------------------------------------------------------------
@@ -107,7 +107,6 @@ export interface CpaToolAliasRestoreFailure {
107
107
  */
108
108
  export declare function parseCpaToolAliasRestoreFailure(error: unknown): CpaToolAliasRestoreFailure | undefined;
109
109
  export declare function isCpaToolAliasRestoreFailure(error: unknown): boolean;
110
- export declare const claudeCodeVersion = "2.1.281";
111
110
  export declare const claudeCodeEntrypoint = "sdk-cli";
112
111
  export declare const claudeToolPrefix: string;
113
112
  export declare const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
@@ -113,6 +113,7 @@ import {
113
113
  type ResolveToolChoiceResult,
114
114
  resolveToolChoice,
115
115
  } from "../utils/tool-choice-capability";
116
+ import { getClaudeCodeVersion } from "./claude-code-version";
116
117
  import {
117
118
  buildCopilotDynamicHeaders,
118
119
  hasCopilotVisionInput,
@@ -294,7 +295,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
294
295
  const incomingUserAgent = getHeaderCaseInsensitive(options.modelHeaders, "User-Agent");
295
296
  const userAgent = isClaudeCodeClientUserAgent(incomingUserAgent)
296
297
  ? incomingUserAgent
297
- : `claude-cli/${claudeCodeVersion} (external, cli)`;
298
+ : `claude-cli/${getClaudeCodeVersion()} (external, cli)`;
298
299
  return {
299
300
  ...modelHeaders,
300
301
  ...claudeCodeHeaders,
@@ -851,7 +852,6 @@ function getCacheControl(
851
852
  }
852
853
 
853
854
  // Stealth mode: Mimic Anthropic Code headers and tool prefixing.
854
- export const claudeCodeVersion = "2.1.281";
855
855
  export const claudeCodeEntrypoint = "sdk-cli";
856
856
  export const claudeToolPrefix: string = "proxy_";
857
857
  export const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
@@ -928,7 +928,7 @@ function createClaudeBillingHeader(payload: unknown): string {
928
928
  const buildHash = Array.from(randomBytes, byte => byte.toString(16).padStart(2, "0"))
929
929
  .join("")
930
930
  .slice(0, 3);
931
- return `${CLAUDE_BILLING_HEADER_PREFIX} cc_version=${claudeCodeVersion}.${buildHash}; cc_entrypoint=${claudeCodeEntrypoint}; cch=${cch};`;
931
+ return `${CLAUDE_BILLING_HEADER_PREFIX} cc_version=${getClaudeCodeVersion()}.${buildHash}; cc_entrypoint=${claudeCodeEntrypoint}; cch=${cch};`;
932
932
  }
933
933
 
934
934
  const CLAUDE_CLOAKING_USER_ID_REGEX =
@@ -0,0 +1,39 @@
1
+ /** Bundled floor; `scripts/check-spoofed-versions.ts --update` keeps it current. */
2
+ export declare const CLAUDE_CODE_BASELINE_VERSION = "2.1.284";
3
+ /** Pins the fingerprint and disables network resolution. */
4
+ export declare const CLAUDE_CODE_VERSION_ENV = "GJC_CLAUDE_CODE_VERSION";
5
+ /**
6
+ * Anthropic's native-installer release channel. `stable` deliberately lags the
7
+ * npm package during a staged rollout, so `latest` is the channel that matches
8
+ * what a freshly updated Claude Code actually reports.
9
+ */
10
+ export declare const CLAUDE_RELEASE_CHANNEL_URL = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/latest";
11
+ export declare const CLAUDE_NPM_DIST_TAG_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
12
+ type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
13
+ export declare function compareClaudeCodeVersions(a: string, b: string): number;
14
+ /**
15
+ * Latest published Claude Code version: the higher of the native-installer
16
+ * `latest` channel and the npm dist-tag, so one lagging or unavailable source
17
+ * cannot pin the fingerprint back. `null` when both sources fail.
18
+ */
19
+ export declare function fetchLatestClaudeCodeVersion(fetchImpl?: FetchLike, userAgent?: string): Promise<string | null>;
20
+ export interface ClaudeCodeVersionResolverOptions {
21
+ baseline: string;
22
+ /** Exact pin; disables network resolution when set. */
23
+ pinned?: string;
24
+ /** `null` disables both disk caching and network resolution. */
25
+ cachePath: string | null;
26
+ fetch?: FetchLike;
27
+ now?: () => number;
28
+ refreshIntervalMs?: number;
29
+ }
30
+ export interface ClaudeCodeVersionResolver {
31
+ /** Current fingerprint; schedules a background refresh when the cache is stale. */
32
+ get(): string;
33
+ /** Refresh now (deduplicated); resolves with the fingerprint afterwards. */
34
+ refresh(): Promise<string>;
35
+ }
36
+ export declare function createClaudeCodeVersionResolver(options: ClaudeCodeVersionResolverOptions): ClaudeCodeVersionResolver;
37
+ /** Claude Code version the `claude-cli` fingerprint and billing attribution sign with. */
38
+ export declare function getClaudeCodeVersion(): string;
39
+ export {};
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Claude Code client-version resolution for the `claude-cli` fingerprint.
3
+ *
4
+ * Anthropic gates newer models behind a minimum Claude Code version and answers
5
+ * stale fingerprints with HTTP 400 (`claude_code_version_too_old`). A version
6
+ * baked in at build time goes stale between releases, so the fingerprint is the
7
+ * higher of the bundled floor and the latest published Claude Code release,
8
+ * resolved in the background and cached on disk. Requests never wait on the
9
+ * network: they sign with whatever is known at that instant.
10
+ */
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { getClaudeCodeVersionCachePath, isEnoent, logger } from "@gajae-code/utils";
14
+
15
+ /** Bundled floor; `scripts/check-spoofed-versions.ts --update` keeps it current. */
16
+ export const CLAUDE_CODE_BASELINE_VERSION = "2.1.284";
17
+
18
+ /** Pins the fingerprint and disables network resolution. */
19
+ export const CLAUDE_CODE_VERSION_ENV = "GJC_CLAUDE_CODE_VERSION";
20
+
21
+ /**
22
+ * Anthropic's native-installer release channel. `stable` deliberately lags the
23
+ * npm package during a staged rollout, so `latest` is the channel that matches
24
+ * what a freshly updated Claude Code actually reports.
25
+ */
26
+ export const CLAUDE_RELEASE_CHANNEL_URL =
27
+ "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/latest";
28
+ export const CLAUDE_NPM_DIST_TAG_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
29
+
30
+ const REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000;
31
+ const FETCH_TIMEOUT_MS = 5_000;
32
+ const EXACT_SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
33
+ const EMBEDDED_SEMVER_RE = /(\d+\.\d+\.\d+)/;
34
+
35
+ type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
36
+
37
+ export function compareClaudeCodeVersions(a: string, b: string): number {
38
+ const left = a.split(".").map(Number);
39
+ const right = b.split(".").map(Number);
40
+ for (let i = 0; i < 3; i++) {
41
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
42
+ if (diff !== 0) return diff;
43
+ }
44
+ return 0;
45
+ }
46
+
47
+ function isExactSemver(value: unknown): value is string {
48
+ return typeof value === "string" && EXACT_SEMVER_RE.test(value);
49
+ }
50
+
51
+ function highest(candidates: ReadonlyArray<string | null | undefined>): string | null {
52
+ let best: string | null = null;
53
+ for (const candidate of candidates) {
54
+ if (!isExactSemver(candidate)) continue;
55
+ if (best === null || compareClaudeCodeVersions(candidate, best) > 0) best = candidate;
56
+ }
57
+ return best;
58
+ }
59
+
60
+ async function fetchText(fetchImpl: FetchLike, url: string, userAgent: string): Promise<string | null> {
61
+ try {
62
+ const res = await fetchImpl(url, {
63
+ headers: { "User-Agent": userAgent },
64
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
65
+ });
66
+ if (!res.ok) return null;
67
+ return await res.text();
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ async function fetchReleaseChannel(fetchImpl: FetchLike, userAgent: string): Promise<string | null> {
74
+ const text = await fetchText(fetchImpl, CLAUDE_RELEASE_CHANNEL_URL, userAgent);
75
+ return text === null ? null : (EMBEDDED_SEMVER_RE.exec(text.trim())?.[1] ?? null);
76
+ }
77
+
78
+ async function fetchNpmDistTag(fetchImpl: FetchLike, userAgent: string): Promise<string | null> {
79
+ const text = await fetchText(fetchImpl, CLAUDE_NPM_DIST_TAG_URL, userAgent);
80
+ if (text === null) return null;
81
+ try {
82
+ const data = JSON.parse(text) as { version?: unknown };
83
+ return typeof data.version === "string" ? (EMBEDDED_SEMVER_RE.exec(data.version)?.[1] ?? null) : null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Latest published Claude Code version: the higher of the native-installer
91
+ * `latest` channel and the npm dist-tag, so one lagging or unavailable source
92
+ * cannot pin the fingerprint back. `null` when both sources fail.
93
+ */
94
+ export async function fetchLatestClaudeCodeVersion(
95
+ fetchImpl: FetchLike = fetch,
96
+ userAgent = "gajae-code/claude-code-version",
97
+ ): Promise<string | null> {
98
+ return highest(
99
+ await Promise.all([fetchReleaseChannel(fetchImpl, userAgent), fetchNpmDistTag(fetchImpl, userAgent)]),
100
+ );
101
+ }
102
+
103
+ interface CachedClaudeCodeVersion {
104
+ version: string;
105
+ checkedAt: number;
106
+ }
107
+
108
+ export interface ClaudeCodeVersionResolverOptions {
109
+ baseline: string;
110
+ /** Exact pin; disables network resolution when set. */
111
+ pinned?: string;
112
+ /** `null` disables both disk caching and network resolution. */
113
+ cachePath: string | null;
114
+ fetch?: FetchLike;
115
+ now?: () => number;
116
+ refreshIntervalMs?: number;
117
+ }
118
+
119
+ export interface ClaudeCodeVersionResolver {
120
+ /** Current fingerprint; schedules a background refresh when the cache is stale. */
121
+ get(): string;
122
+ /** Refresh now (deduplicated); resolves with the fingerprint afterwards. */
123
+ refresh(): Promise<string>;
124
+ }
125
+
126
+ export function createClaudeCodeVersionResolver(options: ClaudeCodeVersionResolverOptions): ClaudeCodeVersionResolver {
127
+ const { baseline, cachePath } = options;
128
+ const fetchImpl = options.fetch ?? fetch;
129
+ const now = options.now ?? Date.now;
130
+ const refreshIntervalMs = options.refreshIntervalMs ?? REFRESH_INTERVAL_MS;
131
+ const pinned = isExactSemver(options.pinned) ? options.pinned : undefined;
132
+
133
+ let loaded = false;
134
+ let resolved: string | undefined;
135
+ let checkedAt = 0;
136
+ let inflight: Promise<string> | undefined;
137
+
138
+ const current = (): string => (resolved && compareClaudeCodeVersions(resolved, baseline) > 0 ? resolved : baseline);
139
+
140
+ const loadCache = (): void => {
141
+ if (loaded || cachePath === null) return;
142
+ loaded = true;
143
+ try {
144
+ const data = JSON.parse(fs.readFileSync(cachePath, "utf-8")) as Partial<CachedClaudeCodeVersion>;
145
+ if (isExactSemver(data.version) && typeof data.checkedAt === "number" && Number.isFinite(data.checkedAt)) {
146
+ resolved = data.version;
147
+ checkedAt = data.checkedAt;
148
+ }
149
+ } catch (error) {
150
+ if (!isEnoent(error)) logger.debug("Ignoring unreadable Claude Code version cache", { error: String(error) });
151
+ }
152
+ };
153
+
154
+ const refresh = (): Promise<string> => {
155
+ if (pinned || cachePath === null) return Promise.resolve(pinned ?? current());
156
+ loadCache();
157
+ inflight ??= (async () => {
158
+ try {
159
+ const latest = await fetchLatestClaudeCodeVersion(fetchImpl);
160
+ // A failed lookup still counts as a check so an offline host does not
161
+ // retry on every request; the next attempt waits a full interval.
162
+ checkedAt = now();
163
+ if (latest && (!resolved || compareClaudeCodeVersions(latest, resolved) > 0)) resolved = latest;
164
+ if (resolved) {
165
+ const record: CachedClaudeCodeVersion = { version: resolved, checkedAt };
166
+ await Bun.write(cachePath, `${JSON.stringify(record)}\n`);
167
+ }
168
+ } catch (error) {
169
+ logger.debug("Claude Code version refresh failed", { error: String(error) });
170
+ } finally {
171
+ inflight = undefined;
172
+ }
173
+ return current();
174
+ })();
175
+ return inflight;
176
+ };
177
+
178
+ return {
179
+ get() {
180
+ if (pinned) return pinned;
181
+ if (cachePath === null) return baseline;
182
+ loadCache();
183
+ if (!inflight && now() - checkedAt >= refreshIntervalMs) void refresh();
184
+ return current();
185
+ },
186
+ refresh,
187
+ };
188
+ }
189
+
190
+ let defaultResolver: ClaudeCodeVersionResolver | undefined;
191
+
192
+ function getDefaultResolver(): ClaudeCodeVersionResolver {
193
+ defaultResolver ??= createClaudeCodeVersionResolver({
194
+ baseline: CLAUDE_CODE_BASELINE_VERSION,
195
+ pinned: process.env[CLAUDE_CODE_VERSION_ENV]?.trim() || undefined,
196
+ // Tests must stay hermetic and deterministic: no network, no user cache.
197
+ cachePath: process.env.NODE_ENV === "test" ? null : path.resolve(getClaudeCodeVersionCachePath()),
198
+ });
199
+ return defaultResolver;
200
+ }
201
+
202
+ /** Claude Code version the `claude-cli` fingerprint and billing attribution sign with. */
203
+ export function getClaudeCodeVersion(): string {
204
+ return getDefaultResolver().get();
205
+ }
@@ -222,6 +222,14 @@ function isCodexStreamProgressEvent(event: unknown): boolean {
222
222
  const delta = (event as { delta?: unknown }).delta;
223
223
  return typeof delta === "string" && delta.length > 0;
224
224
  }
225
+
226
+ function codexOutputHasMeaningfulProgress(output: AssistantMessage): boolean {
227
+ return output.content.some(
228
+ block =>
229
+ (block.type === "text" && block.text.length > 0) || (block.type === "thinking" && block.thinking.length > 0),
230
+ );
231
+ }
232
+
225
233
  type CodexTransport = "sse" | "websocket";
226
234
  interface CodexInitialTransport {
227
235
  eventStream: AsyncGenerator<Record<string, unknown>>;
@@ -412,7 +420,7 @@ function getCodexWebSocketIdleTimeoutMs(overrideMs?: number): number {
412
420
  overrideMs ??
413
421
  parseCodexPositiveInteger(
414
422
  $env.GJC_OPENAI_CODE_WEBSOCKET_IDLE_TIMEOUT_MS ?? $env.PI_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS,
415
- CODEX_WEBSOCKET_IDLE_TIMEOUT_MS,
423
+ getOpenAIStreamIdleTimeoutMs() ?? CODEX_WEBSOCKET_IDLE_TIMEOUT_MS,
416
424
  )
417
425
  );
418
426
  }
@@ -1728,6 +1736,20 @@ async function recoverCodexStreamError(
1728
1736
  error: unknown,
1729
1737
  ): Promise<boolean> {
1730
1738
  if (isCodexFirstEventTimeout(error)) return false;
1739
+ // A post-progress idle timeout has already consumed the stream's silence budget.
1740
+ // Replaying it over another transport can leave the turn silent for another full window.
1741
+ if (
1742
+ (codexOutputHasMeaningfulProgress(context.output) ||
1743
+ (context.output.content.some(block => block.type === "thinking") &&
1744
+ context.output.content.some(
1745
+ block => block.type === "toolCall" && !runtime.finalizedToolCallIds.has(block.id),
1746
+ ))) &&
1747
+ error instanceof Error &&
1748
+ isCodexWebSocketTransportError(error) &&
1749
+ error.message.includes("idle timeout waiting for websocket")
1750
+ ) {
1751
+ return false;
1752
+ }
1731
1753
  if (await tryRetryWithoutForcedToolChoice(context, runtime, error)) {
1732
1754
  return true;
1733
1755
  }
@@ -1,5 +1,5 @@
1
1
  import { scheduler } from "node:timers/promises";
2
- import { claudeCodeVersion } from "../providers/anthropic";
2
+ import { getClaudeCodeVersion } from "../providers/claude-code-version";
3
3
  import type {
4
4
  CredentialRankingStrategy,
5
5
  UsageAmount,
@@ -32,7 +32,6 @@ const CLAUDE_HEADERS = {
32
32
  "anthropic-beta":
33
33
  "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05",
34
34
  "content-type": "application/json",
35
- "user-agent": `claude-cli/${claudeCodeVersion} (external, cli)`,
36
35
  connection: "keep-alive",
37
36
  } as const;
38
37
 
@@ -352,6 +351,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
352
351
  const url = `${baseUrl}/usage`;
353
352
  const headers: Record<string, string> = {
354
353
  ...CLAUDE_HEADERS,
354
+ "user-agent": `claude-cli/${getClaudeCodeVersion()} (external, cli)`,
355
355
  authorization: `Bearer ${credential.accessToken}`,
356
356
  };
357
357