@gajae-code/ai 0.18.0 → 0.18.5

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,47 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.18.5] - 2026-09-30
6
+
7
+ ## [0.18.4] - 2026-09-30
8
+
9
+ ## [0.18.3] - 2026-09-30
10
+
11
+ ## [0.18.2] - 2026-09-30
12
+
13
+ ### Added
14
+
15
+ - Bundled Claude Sonnet 5.5: `anthropic/claude-sonnet-5-5` and Amazon Bedrock `anthropic.claude-sonnet-5-5` (plus `au.`/`eu.`/`global.`/`jp.`/`us.` inference profiles), with 1M context, 128K output, and $2/$10 per MTok pricing (#6111).
16
+
17
+ - Bundled GPT-6.1 Sol for the Codex provider: `openai-codex/gpt-6.1-sol` (272K context, 128K output, reasoning effort low..max) with $2/$10 per MTok and $0.10 cached-input pricing (#6147). Presets and model-profile defaults are unchanged.
18
+
19
+ ### Removed
20
+
21
+ - Removed `installH2Fetch`. Bun's HTTP/2 client can wedge a pooled connection while uploading a large request body (~300 KB, a typical long agent context): every later request on that connection waits indefinitely for response headers. All `fetch()` traffic now uses HTTP/1.1.
22
+
23
+ ### Fixed
24
+
25
+ - A large (≥1 MB) request to a custom Anthropic-compatible endpoint is now retried when its connection drops before any response (ECONNRESET, socket closed, `Connection error`). Previously the one-upload ceiling meant for first-event stalls also covered these failures, so a single network blip ended the turn. Server responses (e.g. 529) and first-event timeouts still respect the ceiling (#6072).
26
+
27
+ - Kiro (OAuth CodeWhisperer transport) now replays earlier tool calls as structured `toolUses` and sends each tool result as `{ toolUseId, status, content: [{ text }] }`, linked to the call it answers, instead of serializing calls into assistant text and nesting results. Parallel tool results share one history entry, and image results are marked `[image omitted]` rather than sent empty (#6079).
28
+
29
+ - OpenAI-compatible completions requests now log OpenAI SDK connection timeouts and retries to the gjc log. Previously a request stalled before response headers retried silently for up to several minutes.
30
+
31
+ ## [0.18.1] - 2026-09-29
32
+
33
+ ### Changed
34
+
35
+ - Bump the bundled Claude Code fingerprint floor from 2.1.281 to 2.1.284.
36
+ - 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.
37
+
38
+ ### Fixed
39
+
40
+ - 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).
41
+
42
+ - Bound Codex turns that stall after an empty reasoning item and streamed tool-call start to the shared stream idle timeout.
43
+
44
+ - 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.
45
+
5
46
  ## [0.18.0] - 2026-09-27
6
47
 
7
48
  ### 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";
@@ -45,7 +46,6 @@ export * from "./utils/anthropic-auth";
45
46
  export * from "./utils/discovery";
46
47
  export * from "./utils/event-stream";
47
48
  export * from "./utils/fallback-transport";
48
- export * from "./utils/h2-fetch";
49
49
  export * from "./utils/oauth";
50
50
  export type { OAuthCredentials, OAuthLoginOptions, OAuthProvider, OAuthProviderId, OAuthProviderInfo, } from "./utils/oauth/types";
51
51
  export * from "./utils/overflow";
@@ -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.5",
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.5",
45
+ "@gajae-code/utils": "0.18.5",
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";
@@ -45,7 +46,6 @@ export * from "./utils/anthropic-auth";
45
46
  export * from "./utils/discovery";
46
47
  export * from "./utils/event-stream";
47
48
  export * from "./utils/fallback-transport";
48
- export * from "./utils/h2-fetch";
49
49
  export * from "./utils/oauth";
50
50
  export type {
51
51
  OAuthCredentials,
@@ -35,6 +35,15 @@ const GPT_6_SOL_PRICING: TieredPricing = {
35
35
  },
36
36
  };
37
37
 
38
+ // TODO(gpt-6.1-sol): cache-write and long-context prices not yet published; derived from gpt-6-sol / 2x-1.5x rule
39
+ const GPT_6_1_SOL_PRICING: TieredPricing = {
40
+ cost: { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 },
41
+ longContextPricing: {
42
+ threshold: LONG_CONTEXT_THRESHOLD,
43
+ cost: { input: 4, output: 15, cacheRead: 0.2, cacheWrite: 5 },
44
+ },
45
+ };
46
+
38
47
  // GPT-6 Luna: $0.10/$0.50 standard, cache read $0.01, cache write $0.125;
39
48
  // inputs past 272K apply 2x to input/cache and 1.5x to output.
40
49
  const GPT_6_LUNA_PRICING: TieredPricing = {
@@ -49,6 +58,7 @@ const GPT_6_LUNA_PRICING: TieredPricing = {
49
58
  const OPENAI_GPT_5_6_PRICING: ReadonlyMap<string, TieredPricing> = new Map([
50
59
  ["gpt-6-astra", GPT_6_ASTRA_PRICING],
51
60
  ["gpt-6-sol", GPT_6_SOL_PRICING],
61
+ ["gpt-6.1-sol", GPT_6_1_SOL_PRICING],
52
62
  ["gpt-6-luna", GPT_6_LUNA_PRICING],
53
63
  ["gpt-5.6", GPT_5_6_SOL_PRICING],
54
64
  ["gpt-5.6-sol", GPT_5_6_SOL_PRICING],
package/src/models.json CHANGED
@@ -964,6 +964,31 @@
964
964
  "maxLevel": "high"
965
965
  }
966
966
  },
967
+ "anthropic.claude-sonnet-5-5": {
968
+ "id": "anthropic.claude-sonnet-5-5",
969
+ "name": "Anthropic Sonnet 5.5",
970
+ "api": "bedrock-converse-stream",
971
+ "provider": "amazon-bedrock",
972
+ "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com",
973
+ "reasoning": true,
974
+ "input": [
975
+ "text",
976
+ "image"
977
+ ],
978
+ "cost": {
979
+ "input": 2,
980
+ "output": 10,
981
+ "cacheRead": 0.2,
982
+ "cacheWrite": 2.5
983
+ },
984
+ "contextWindow": 1000000,
985
+ "maxTokens": 128000,
986
+ "thinking": {
987
+ "mode": "anthropic-budget-effort",
988
+ "minLevel": "minimal",
989
+ "maxLevel": "high"
990
+ }
991
+ },
967
992
  "apac.amazon.nova-lite-v1:0": {
968
993
  "id": "apac.amazon.nova-lite-v1:0",
969
994
  "name": "Nova Lite (APAC)",
@@ -1308,6 +1333,31 @@
1308
1333
  "maxLevel": "high"
1309
1334
  }
1310
1335
  },
1336
+ "au.anthropic.claude-sonnet-5-5": {
1337
+ "id": "au.anthropic.claude-sonnet-5-5",
1338
+ "name": "Anthropic Sonnet 5.5 (AU)",
1339
+ "api": "bedrock-converse-stream",
1340
+ "provider": "amazon-bedrock",
1341
+ "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com",
1342
+ "reasoning": true,
1343
+ "input": [
1344
+ "text",
1345
+ "image"
1346
+ ],
1347
+ "cost": {
1348
+ "input": 2.2,
1349
+ "output": 11,
1350
+ "cacheRead": 0.22,
1351
+ "cacheWrite": 2.75
1352
+ },
1353
+ "contextWindow": 1000000,
1354
+ "maxTokens": 128000,
1355
+ "thinking": {
1356
+ "mode": "anthropic-budget-effort",
1357
+ "minLevel": "minimal",
1358
+ "maxLevel": "high"
1359
+ }
1360
+ },
1311
1361
  "ca.amazon.nova-lite-v1:0": {
1312
1362
  "id": "ca.amazon.nova-lite-v1:0",
1313
1363
  "name": "Nova Lite (CA)",
@@ -2072,6 +2122,31 @@
2072
2122
  "maxLevel": "high"
2073
2123
  }
2074
2124
  },
2125
+ "eu.anthropic.claude-sonnet-5-5": {
2126
+ "id": "eu.anthropic.claude-sonnet-5-5",
2127
+ "name": "Anthropic Sonnet 5.5 (EU)",
2128
+ "api": "bedrock-converse-stream",
2129
+ "provider": "amazon-bedrock",
2130
+ "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com",
2131
+ "reasoning": true,
2132
+ "input": [
2133
+ "text",
2134
+ "image"
2135
+ ],
2136
+ "cost": {
2137
+ "input": 2.2,
2138
+ "output": 11,
2139
+ "cacheRead": 0.22,
2140
+ "cacheWrite": 2.75
2141
+ },
2142
+ "contextWindow": 1000000,
2143
+ "maxTokens": 128000,
2144
+ "thinking": {
2145
+ "mode": "anthropic-budget-effort",
2146
+ "minLevel": "minimal",
2147
+ "maxLevel": "high"
2148
+ }
2149
+ },
2075
2150
  "eu.mistral.pixtral-large-2502-v1:0": {
2076
2151
  "id": "eu.mistral.pixtral-large-2502-v1:0",
2077
2152
  "name": "Pixtral Large (25.02) (EU)",
@@ -2477,6 +2552,31 @@
2477
2552
  "maxLevel": "high"
2478
2553
  }
2479
2554
  },
2555
+ "global.anthropic.claude-sonnet-5-5": {
2556
+ "id": "global.anthropic.claude-sonnet-5-5",
2557
+ "name": "Anthropic Sonnet 5.5 (Global)",
2558
+ "api": "bedrock-converse-stream",
2559
+ "provider": "amazon-bedrock",
2560
+ "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com",
2561
+ "reasoning": true,
2562
+ "input": [
2563
+ "text",
2564
+ "image"
2565
+ ],
2566
+ "cost": {
2567
+ "input": 2,
2568
+ "output": 10,
2569
+ "cacheRead": 0.2,
2570
+ "cacheWrite": 2.5
2571
+ },
2572
+ "contextWindow": 1000000,
2573
+ "maxTokens": 128000,
2574
+ "thinking": {
2575
+ "mode": "anthropic-budget-effort",
2576
+ "minLevel": "minimal",
2577
+ "maxLevel": "high"
2578
+ }
2579
+ },
2480
2580
  "global.openai.gpt-5.6-luna": {
2481
2581
  "id": "global.openai.gpt-5.6-luna",
2482
2582
  "name": "GPT-5.6 Luna (Global)",
@@ -3020,6 +3120,31 @@
3020
3120
  "maxLevel": "high"
3021
3121
  }
3022
3122
  },
3123
+ "jp.anthropic.claude-sonnet-5-5": {
3124
+ "id": "jp.anthropic.claude-sonnet-5-5",
3125
+ "name": "Anthropic Sonnet 5.5 (JP)",
3126
+ "api": "bedrock-converse-stream",
3127
+ "provider": "amazon-bedrock",
3128
+ "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com",
3129
+ "reasoning": true,
3130
+ "input": [
3131
+ "text",
3132
+ "image"
3133
+ ],
3134
+ "cost": {
3135
+ "input": 2.2,
3136
+ "output": 11,
3137
+ "cacheRead": 0.22,
3138
+ "cacheWrite": 2.75
3139
+ },
3140
+ "contextWindow": 1000000,
3141
+ "maxTokens": 128000,
3142
+ "thinking": {
3143
+ "mode": "anthropic-budget-effort",
3144
+ "minLevel": "minimal",
3145
+ "maxLevel": "high"
3146
+ }
3147
+ },
3023
3148
  "meta.llama3-1-405b-instruct-v1:0": {
3024
3149
  "id": "meta.llama3-1-405b-instruct-v1:0",
3025
3150
  "name": "Llama 3.1 405B Instruct",
@@ -4501,6 +4626,31 @@
4501
4626
  "maxLevel": "high"
4502
4627
  }
4503
4628
  },
4629
+ "us.anthropic.claude-sonnet-5-5": {
4630
+ "id": "us.anthropic.claude-sonnet-5-5",
4631
+ "name": "Anthropic Sonnet 5.5 (US)",
4632
+ "api": "bedrock-converse-stream",
4633
+ "provider": "amazon-bedrock",
4634
+ "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com",
4635
+ "reasoning": true,
4636
+ "input": [
4637
+ "text",
4638
+ "image"
4639
+ ],
4640
+ "cost": {
4641
+ "input": 2.2,
4642
+ "output": 11,
4643
+ "cacheRead": 0.22,
4644
+ "cacheWrite": 2.75
4645
+ },
4646
+ "contextWindow": 1000000,
4647
+ "maxTokens": 128000,
4648
+ "thinking": {
4649
+ "mode": "anthropic-budget-effort",
4650
+ "minLevel": "minimal",
4651
+ "maxLevel": "high"
4652
+ }
4653
+ },
4504
4654
  "us.deepseek.r1-v1:0": {
4505
4655
  "id": "us.deepseek.r1-v1:0",
4506
4656
  "name": "DeepSeek-R1 (US)",
@@ -5656,6 +5806,31 @@
5656
5806
  "minLevel": "minimal",
5657
5807
  "maxLevel": "max"
5658
5808
  }
5809
+ },
5810
+ "claude-sonnet-5-5": {
5811
+ "id": "claude-sonnet-5-5",
5812
+ "name": "Anthropic Sonnet 5.5",
5813
+ "api": "anthropic-messages",
5814
+ "provider": "anthropic",
5815
+ "baseUrl": "https://api.anthropic.com",
5816
+ "reasoning": true,
5817
+ "input": [
5818
+ "text",
5819
+ "image"
5820
+ ],
5821
+ "cost": {
5822
+ "input": 2,
5823
+ "output": 10,
5824
+ "cacheRead": 0.2,
5825
+ "cacheWrite": 2.5
5826
+ },
5827
+ "contextWindow": 1000000,
5828
+ "maxTokens": 128000,
5829
+ "thinking": {
5830
+ "mode": "anthropic-adaptive",
5831
+ "minLevel": "minimal",
5832
+ "maxLevel": "max"
5833
+ }
5659
5834
  }
5660
5835
  },
5661
5836
  "azure-openai": {
@@ -68026,6 +68201,41 @@
68026
68201
  }
68027
68202
  }
68028
68203
  },
68204
+ "gpt-6.1-sol": {
68205
+ "id": "gpt-6.1-sol",
68206
+ "name": "GPT-6.1-Sol",
68207
+ "api": "openai-codex-responses",
68208
+ "provider": "openai-codex",
68209
+ "baseUrl": "https://chatgpt.com/backend-api",
68210
+ "reasoning": true,
68211
+ "input": [
68212
+ "text",
68213
+ "image"
68214
+ ],
68215
+ "cost": {
68216
+ "input": 2,
68217
+ "output": 10,
68218
+ "cacheRead": 0.1,
68219
+ "cacheWrite": 2.5
68220
+ },
68221
+ "contextWindow": 272000,
68222
+ "maxTokens": 128000,
68223
+ "preferWebsockets": true,
68224
+ "thinking": {
68225
+ "mode": "effort",
68226
+ "minLevel": "low",
68227
+ "maxLevel": "max"
68228
+ },
68229
+ "longContextPricing": {
68230
+ "threshold": 272000,
68231
+ "cost": {
68232
+ "input": 4,
68233
+ "output": 15,
68234
+ "cacheRead": 0.2,
68235
+ "cacheWrite": 5
68236
+ }
68237
+ }
68238
+ },
68029
68239
  "gpt-daybreak-blue-latest": {
68030
68240
  "id": "gpt-daybreak-blue-latest",
68031
68241
  "name": "Daybreak Blue",
@@ -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 =
@@ -1764,6 +1764,23 @@ function isTransientStreamEnvelopeError(error: unknown): boolean {
1764
1764
  );
1765
1765
  }
1766
1766
 
1767
+ /**
1768
+ * A request whose connection failed before the server returned any response:
1769
+ * the SDK's connection error, or a reset/closed/refused socket with no HTTP status.
1770
+ */
1771
+ function isPreResponseConnectionFailure(error: unknown): boolean {
1772
+ if (!(error instanceof Error)) return false;
1773
+ if (extractHttpStatusFromError(error) !== undefined) return false;
1774
+ if (error instanceof Anthropic.APIConnectionTimeoutError) return false;
1775
+ if (error instanceof Anthropic.APIConnectionError) return true;
1776
+ const code = (error as { code?: unknown }).code;
1777
+ if (typeof code === "string" && /^(?:ECONNRESET|ECONNREFUSED|EPIPE|ENOTFOUND|EAI_AGAIN)$/.test(code)) return true;
1778
+ return (
1779
+ isUnexpectedSocketCloseMessage(error.message) ||
1780
+ /\b(?:ECONNRESET|ECONNREFUSED|EPIPE)\b|^connection error\.?$|other side closed/i.test(error.message)
1781
+ );
1782
+ }
1783
+
1767
1784
  function isProviderRetryableStreamEnvelopeError(error: unknown): boolean {
1768
1785
  if (!(error instanceof Error)) return false;
1769
1786
  return /stream event order|before message_start/i.test(error.message);
@@ -2672,7 +2689,14 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2672
2689
  // Otherwise the multi-megabyte body is re-uploaded up to the default
2673
2690
  // streamMaxRetries budget despite the ceiling. Once iteration has
2674
2691
  // begun, only the grace-clock path above decides.
2675
- if (requestUploadCeilingBound && firstEventWaitStartedAt === undefined) {
2692
+ // A connection that dropped before any response (reset, socket closed,
2693
+ // connect failure) is exempt: the server never answered, so a retry is
2694
+ // not a re-upload after a stall, and a network blip must not end the turn.
2695
+ if (
2696
+ requestUploadCeilingBound &&
2697
+ firstEventWaitStartedAt === undefined &&
2698
+ !isPreResponseConnectionFailure(streamFailure)
2699
+ ) {
2676
2700
  Object.assign(streamFailure as Error, {
2677
2701
  requestBytes,
2678
2702
  endpointClass,
@@ -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
+ }
@@ -58,11 +58,15 @@ interface WireToolSpec {
58
58
  }
59
59
 
60
60
  interface WireToolResult {
61
- toolResultMessage: {
62
- content: string;
63
- toolUseId: string;
64
- status?: "success" | "error";
65
- };
61
+ toolUseId: string;
62
+ status: "success" | "error";
63
+ content: Array<{ text: string }>;
64
+ }
65
+
66
+ interface WireToolUse {
67
+ toolUseId: string;
68
+ name: string;
69
+ input: Record<string, unknown>;
66
70
  }
67
71
 
68
72
  interface WireUserMessage {
@@ -71,7 +75,7 @@ interface WireUserMessage {
71
75
  modelId?: string;
72
76
  userInputMessageContext?: {
73
77
  tools?: { tools: WireToolSpec[] };
74
- toolResults?: { toolResults: WireToolResult[][] };
78
+ toolResults?: WireToolResult[];
75
79
  editorStateContext?: Record<string, unknown>;
76
80
  };
77
81
  origin?: string;
@@ -81,6 +85,7 @@ interface WireUserMessage {
81
85
  interface WireAssistantMessage {
82
86
  assistantResponseMessage: {
83
87
  content: string;
88
+ toolUses?: WireToolUse[];
84
89
  };
85
90
  }
86
91
 
@@ -383,6 +388,21 @@ function buildConversationState(
383
388
 
384
389
  for (let i = 0; i < messages.length - 1; i++) {
385
390
  const msg = messages[i];
391
+ const previous = history[history.length - 1];
392
+ // Parallel tool calls produce consecutive toolResult messages; they answer one
393
+ // assistant turn, so their results share a single user entry.
394
+ if (
395
+ msg.role === "toolResult" &&
396
+ messages[i - 1]?.role === "toolResult" &&
397
+ previous &&
398
+ "userInputMessage" in previous
399
+ ) {
400
+ previous.userInputMessage.userInputMessageContext ??= {};
401
+ const context = previous.userInputMessage.userInputMessageContext;
402
+ context.toolResults ??= [];
403
+ context.toolResults.push(convertToolResult(msg as ToolResultMessage));
404
+ continue;
405
+ }
386
406
  history.push(convertToWireMessage(msg, modelId, i === 0 ? systemPrompt : undefined));
387
407
  }
388
408
 
@@ -419,20 +439,27 @@ function convertToWireMessage(
419
439
  if (msg.role === "toolResult") {
420
440
  return convertToWireUserMessage(msg, modelId, systemPrompt);
421
441
  }
422
- // assistant → assistant response
442
+ // assistant → assistant response; tool calls stay structured so later tool
443
+ // results can reference the toolUseId they answer.
423
444
  const textParts: string[] = [];
445
+ const toolUses: WireToolUse[] = [];
424
446
  for (const block of msg.content) {
425
447
  if (typeof block === "string") {
426
448
  textParts.push(block);
427
449
  } else if (block.type === "text") {
428
450
  textParts.push(block.text);
429
451
  } else if (block.type === "toolCall") {
430
- textParts.push(JSON.stringify({ toolUseId: block.id, name: block.name, input: block.arguments }));
452
+ toolUses.push({
453
+ toolUseId: block.id,
454
+ name: block.name,
455
+ input: (block.arguments ?? {}) as Record<string, unknown>,
456
+ });
431
457
  }
432
458
  }
433
459
  return {
434
460
  assistantResponseMessage: {
435
- content: textParts.join("\n") || "",
461
+ content: textParts.join("\n"),
462
+ ...(toolUses.length > 0 ? { toolUses } : {}),
436
463
  },
437
464
  };
438
465
  }
@@ -442,7 +469,9 @@ function convertToWireUserMessage(
442
469
  modelId: string,
443
470
  systemPrompt?: string,
444
471
  ): WireUserMessage {
445
- let content = extractTextContent(msg);
472
+ // A tool result travels in userInputMessageContext.toolResults; repeating it as
473
+ // message text would show the model each result twice.
474
+ let content = msg.role === "toolResult" ? "Tool results provided." : extractTextContent(msg);
446
475
  if (systemPrompt) {
447
476
  content = `${systemPrompt}\n\n${content}`;
448
477
  }
@@ -454,27 +483,28 @@ function convertToWireUserMessage(
454
483
  },
455
484
  };
456
485
 
457
- // Handle tool results
458
486
  if (msg.role === "toolResult") {
459
- const toolResultMsg = msg as ToolResultMessage;
460
- const toolResults = (toolResultMsg.content ?? []).map(detail => ({
461
- toolResultMessage: {
462
- content: detail.type === "text" ? detail.text : "",
463
- toolUseId: toolResultMsg.toolCallId,
464
- status: toolResultMsg.isError ? ("error" as const) : ("success" as const),
465
- },
466
- }));
467
- if (toolResults.length > 0) {
468
- if (!userMsg.userInputMessage.userInputMessageContext) {
469
- userMsg.userInputMessage.userInputMessageContext = {};
470
- }
471
- userMsg.userInputMessage.userInputMessageContext.toolResults = { toolResults: [toolResults] };
472
- }
487
+ userMsg.userInputMessage.userInputMessageContext = {
488
+ toolResults: [convertToolResult(msg as ToolResultMessage)],
489
+ };
473
490
  }
474
491
 
475
492
  return userMsg;
476
493
  }
477
494
 
495
+ /** One CodeWhisperer tool result per tool call, keyed by the toolUseId it answers. */
496
+ function convertToolResult(msg: ToolResultMessage): WireToolResult {
497
+ const text = (msg.content ?? [])
498
+ .map(block => (block.type === "text" ? block.text : block.type === "image" ? "[image omitted]" : ""))
499
+ .filter(part => part.length > 0)
500
+ .join("\n");
501
+ return {
502
+ toolUseId: msg.toolCallId,
503
+ status: msg.isError ? "error" : "success",
504
+ content: [{ text }],
505
+ };
506
+ }
507
+
478
508
  function extractTextContent(msg: Context["messages"][number]): string {
479
509
  if (typeof msg.content === "string") return msg.content;
480
510
  if (Array.isArray(msg.content)) {
@@ -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,6 +1,6 @@
1
1
  import { scheduler } from "node:timers/promises";
2
2
  import { $credentialEnv, $env, extractHttpStatusFromError, logger } from "@gajae-code/utils";
3
- import OpenAI, { APIConnectionTimeoutError } from "openai";
3
+ import OpenAI, { APIConnectionTimeoutError, type ClientOptions as OpenAIClientOptions } from "openai";
4
4
  import type {
5
5
  ChatCompletionAssistantMessageParam,
6
6
  ChatCompletionChunk,
@@ -1509,6 +1509,22 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
1509
1509
  return stream;
1510
1510
  };
1511
1511
 
1512
+ /**
1513
+ * The OpenAI SDK retries pre-header timeouts and connection failures silently
1514
+ * (its retry lines log at `info`, below the default `warn`, and go to console,
1515
+ * which corrupts the TUI). Route them into the gjc log so a request stuck
1516
+ * before response headers — where our stream watchdogs are not armed yet —
1517
+ * leaves a trace instead of looking like a hung turn.
1518
+ */
1519
+ const OPENAI_SDK_LOGGER: NonNullable<OpenAIClientOptions["logger"]> = {
1520
+ error: message => logger.warn(`openai-sdk: ${message}`),
1521
+ warn: message => logger.warn(`openai-sdk: ${message}`),
1522
+ info: message => {
1523
+ if (/retrying|timed out|connection failed/.test(message)) logger.warn(`openai-sdk: ${message}`);
1524
+ },
1525
+ debug: () => {},
1526
+ };
1527
+
1512
1528
  async function createClient(
1513
1529
  model: Model<"openai-completions">,
1514
1530
  context: Context,
@@ -1680,6 +1696,8 @@ async function createClient(
1680
1696
  defaultHeaders: headers,
1681
1697
  fetch: debugFetch,
1682
1698
  ...(sdkTimeoutMs !== undefined ? { timeout: sdkTimeoutMs } : {}),
1699
+ logLevel: "info",
1700
+ logger: OPENAI_SDK_LOGGER,
1683
1701
  }),
1684
1702
  copilotPremiumRequests,
1685
1703
  baseUrl,
@@ -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
 
@@ -1,35 +0,0 @@
1
- /**
2
- * Patch `globalThis.fetch` to advertise HTTP/2 in TLS ALPN, with transparent
3
- * HTTP/1.1 fallback when the server doesn't negotiate `h2`.
4
- *
5
- * Bun's HTTP/2 client is gated on `BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT`,
6
- * read by the native runtime before any JS executes; assigning to
7
- * `process.env` from inside JS is a no-op. Per-request `protocol: "http2"`
8
- * activates h2 over TLS ALPN and rejects with `error.code === "HTTP2Unsupported"`
9
- * if the server picks anything else, so we catch and retry without the hint.
10
- *
11
- * Some HTTPS endpoints (e.g. corporate API gateways behind reverse proxies)
12
- * advertise h2 via ALPN but then refuse or reset the connection at the HTTP/2
13
- * framing layer. Bun surfaces these as `ConnectionRefused`, `ConnectionReset`,
14
- * `ConnectionClosed`, or `HTTP2StreamReset` rather than `HTTP2Unsupported`.
15
- * `ConnectionRefused` is raised before the request is written, while
16
- * `HTTP2RefusedStream` is the explicit HTTP/2 promise that a stream was never
17
- * processed (including a stream above a graceful GOAWAY last-stream-id).
18
- * `ConnectionReset`, `ConnectionClosed`, and a generic `HTTP2StreamReset` do
19
- * not carry that promise: the peer may have consumed the body before the
20
- * connection failed. Retrying those codes would duplicate non-idempotent side
21
- * effects, so they preserve the original error instead of falling back.
22
- *
23
- * ALPN-refusing hosts (notably zcode.z.ai, the GLM ZCode OAuth broker) abort
24
- * the TLS handshake entirely when the client offers ALPN h2. Bun reports that
25
- * abort as `UNKNOWN_CERTIFICATE_VERIFICATION_ERROR` even though the host's
26
- * certificate chain verifies fine over h1 (issue #5178), so that code is a
27
- * fallback trigger too — never a reason to accept a bad certificate: the h1
28
- * attempt below performs full verification on its own.
29
- *
30
- * Bun negotiates h2 via ALPN over TLS only (no h2c), so plain `http://` URLs
31
- * skip the attempt entirely — avoids the throw/retry round-trip for localhost.
32
- *
33
- * Idempotent.
34
- */
35
- export declare function installH2Fetch(): void;
@@ -1,133 +0,0 @@
1
- /**
2
- * Patch `globalThis.fetch` to advertise HTTP/2 in TLS ALPN, with transparent
3
- * HTTP/1.1 fallback when the server doesn't negotiate `h2`.
4
- *
5
- * Bun's HTTP/2 client is gated on `BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT`,
6
- * read by the native runtime before any JS executes; assigning to
7
- * `process.env` from inside JS is a no-op. Per-request `protocol: "http2"`
8
- * activates h2 over TLS ALPN and rejects with `error.code === "HTTP2Unsupported"`
9
- * if the server picks anything else, so we catch and retry without the hint.
10
- *
11
- * Some HTTPS endpoints (e.g. corporate API gateways behind reverse proxies)
12
- * advertise h2 via ALPN but then refuse or reset the connection at the HTTP/2
13
- * framing layer. Bun surfaces these as `ConnectionRefused`, `ConnectionReset`,
14
- * `ConnectionClosed`, or `HTTP2StreamReset` rather than `HTTP2Unsupported`.
15
- * `ConnectionRefused` is raised before the request is written, while
16
- * `HTTP2RefusedStream` is the explicit HTTP/2 promise that a stream was never
17
- * processed (including a stream above a graceful GOAWAY last-stream-id).
18
- * `ConnectionReset`, `ConnectionClosed`, and a generic `HTTP2StreamReset` do
19
- * not carry that promise: the peer may have consumed the body before the
20
- * connection failed. Retrying those codes would duplicate non-idempotent side
21
- * effects, so they preserve the original error instead of falling back.
22
- *
23
- * ALPN-refusing hosts (notably zcode.z.ai, the GLM ZCode OAuth broker) abort
24
- * the TLS handshake entirely when the client offers ALPN h2. Bun reports that
25
- * abort as `UNKNOWN_CERTIFICATE_VERIFICATION_ERROR` even though the host's
26
- * certificate chain verifies fine over h1 (issue #5178), so that code is a
27
- * fallback trigger too — never a reason to accept a bad certificate: the h1
28
- * attempt below performs full verification on its own.
29
- *
30
- * Bun negotiates h2 via ALPN over TLS only (no h2c), so plain `http://` URLs
31
- * skip the attempt entirely — avoids the throw/retry round-trip for localhost.
32
- *
33
- * Idempotent.
34
- */
35
-
36
- const installed: unique symbol = Symbol.for("gajae-code.h2fetch.installed");
37
-
38
- interface PatchedFetch {
39
- [installed]?: true;
40
- }
41
-
42
- export function installH2Fetch(): void {
43
- const original = globalThis.fetch as typeof fetch & PatchedFetch;
44
- if (original[installed]) return;
45
-
46
- /** Error codes that indicate h2 negotiation/transport failure (not an application error). */
47
- const h2FallbackCodes: ReadonlySet<string> = new Set([
48
- "HTTP2Unsupported", // Server selected h1 in ALPN
49
- "ConnectionRefused", // Server refused the h2 connection
50
- "HTTP2RefusedStream", // REFUSED_STREAM / never-processed h2 stream
51
- // Bun's h2 client reports an ALPN-refusing host's TLS abort with this
52
- // code; the h1 fallback below re-verifies the certificate itself.
53
- "UNKNOWN_CERTIFICATE_VERIFICATION_ERROR",
54
- ]);
55
- const wrapper = async function h2fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
56
- if (!isHttps(input)) return original(input, init);
57
- const snapshot = snapshotRequest(input, init);
58
- try {
59
- return await original(snapshot.input, { ...snapshot.init, protocol: "http2" });
60
- } catch (err) {
61
- const code = (err as { code?: string }).code ?? "";
62
- if (!h2FallbackCodes.has(code)) throw err;
63
- if (code === "HTTP2RefusedStream" && !isReplayableRequest(snapshot.input, snapshot.init)) throw err;
64
- return original(snapshot.input, snapshot.init);
65
- }
66
- } as typeof fetch & PatchedFetch;
67
-
68
- // Preserve `fetch.preconnect` and any other statics SDK code might poke at.
69
- Object.assign(wrapper, original);
70
- wrapper[installed] = true;
71
- globalThis.fetch = wrapper;
72
- }
73
-
74
- function snapshotRequest(
75
- input: string | URL | Request,
76
- init?: RequestInit,
77
- ): { input: string | URL | Request; init?: RequestInit } {
78
- const snapshotInput = input instanceof URL ? new URL(input.href) : input;
79
- const requestLike = typeof input !== "string" && !(input instanceof URL);
80
- if (init === undefined && !requestLike) {
81
- return { input: snapshotInput };
82
- }
83
-
84
- const snapshotInit: RequestInit = { ...(init ?? {}) };
85
- if (init?.headers !== undefined) {
86
- snapshotInit.headers = snapshotHeaders(init.headers);
87
- } else if (requestLike) {
88
- snapshotInit.headers = new Headers(input.headers);
89
- }
90
- return { input: snapshotInput, init: snapshotInit };
91
- }
92
-
93
- function snapshotHeaders(headers: NonNullable<RequestInit["headers"]>): NonNullable<RequestInit["headers"]> {
94
- if (headers instanceof Headers) return new Headers(headers);
95
- if (Array.isArray(headers)) {
96
- return headers.map(([name, value]) => [name, value] as [string, string]);
97
- }
98
- return { ...headers };
99
- }
100
-
101
- function isHttps(input: string | URL | Request): boolean {
102
- if (typeof input === "string") return input.startsWith("https:");
103
- if (input instanceof URL) return input.protocol === "https:";
104
- return input.url.startsWith("https:");
105
- }
106
-
107
- /**
108
- * Whether the request body can be handed to fetch a second time. This is only
109
- * used after `HTTP2RefusedStream`, which proves that the peer did not consume
110
- * request bytes; a one-shot ReadableStream is still not reusable locally.
111
- */
112
- function isReplayableRequest(input: string | URL | Request, init?: RequestInit): boolean {
113
- try {
114
- if (init?.body !== undefined) return isReplayableBody(init.body);
115
- if (typeof input === "string" || input instanceof URL) return true;
116
- // Request bodies are exposed as ReadableStreams. The same Request object
117
- // cannot be used for the second fetch once the first attempt touched it.
118
- return input.body === null;
119
- } catch {
120
- // Cross-realm or proxy Request objects may throw while exposing their
121
- // method/body. Do not retry when replayability cannot be established.
122
- return false;
123
- }
124
- }
125
-
126
- function isReplayableBody(body: RequestInit["body"]): boolean {
127
- if (body === null || typeof body === "string") return true;
128
- if (typeof Blob !== "undefined" && body instanceof Blob) return true;
129
- // ArrayBuffer/views, FormData, and URLSearchParams are mutable caller-owned
130
- // values. Fail closed instead of replaying a potentially changed payload if
131
- // the asynchronous refusal arrives after the caller mutates one.
132
- return false;
133
- }