@gajae-code/ai 0.18.1 → 0.18.6
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 +74 -0
- package/dist/types/auth-storage.d.ts +8 -1
- package/dist/types/index.d.ts +0 -1
- package/dist/types/providers/cursor.d.ts +6 -3
- package/dist/types/providers/kiro-api-key.d.ts +1 -0
- package/dist/types/providers/openai-codex-responses.d.ts +2 -0
- package/dist/types/types.d.ts +1 -1
- package/dist/types/utils/fallback-transport.d.ts +6 -0
- package/dist/types/utils/oauth/glm-zcode.d.ts +5 -0
- package/dist/types/utils/oauth/index.d.ts +4 -0
- package/package.json +3 -3
- package/src/auth-storage.ts +115 -39
- package/src/index.ts +0 -1
- package/src/model-pricing.ts +10 -0
- package/src/model-thinking.ts +8 -0
- package/src/models.json +210 -0
- package/src/providers/anthropic.ts +25 -1
- package/src/providers/aws-eventstream.ts +2 -1
- package/src/providers/cursor.d.ts +6 -3
- package/src/providers/cursor.ts +73 -64
- package/src/providers/kiro-api-key.d.ts +1 -0
- package/src/providers/kiro-api-key.ts +1 -1
- package/src/providers/kiro-codewhisperer.ts +142 -34
- package/src/providers/openai-codex-responses.d.ts +2 -0
- package/src/providers/openai-codex-responses.ts +438 -40
- package/src/providers/openai-completions.ts +19 -1
- package/src/stream.ts +20 -0
- package/src/types.d.ts +1 -1
- package/src/types.ts +3 -0
- package/src/utils/fallback-transport.d.ts +6 -0
- package/src/utils/fallback-transport.ts +6 -0
- package/src/utils/idle-iterator.ts +10 -3
- package/src/utils/oauth/glm-zcode.d.ts +5 -0
- package/src/utils/oauth/glm-zcode.ts +8 -1
- package/src/utils/oauth/index.ts +11 -1
- package/src/utils/validation.ts +5 -0
- package/dist/types/utils/h2-fetch.d.ts +0 -35
- package/src/utils/h2-fetch.ts +0 -133
|
@@ -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,
|
package/src/stream.ts
CHANGED
|
@@ -1022,8 +1022,28 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
1022
1022
|
// For older models: use budget-based thinking
|
|
1023
1023
|
if (model.thinking?.mode === "anthropic-adaptive") {
|
|
1024
1024
|
const effort = mapEffortToAnthropicAdaptiveEffort(model, reasoning);
|
|
1025
|
+
|
|
1026
|
+
// Adaptive mode sends effort directly (no explicit budget_tokens).
|
|
1027
|
+
// However, the thinking consumes tokens from the overall max_tokens budget.
|
|
1028
|
+
// If max_tokens is capped at DEFAULT_REQUEST_MAX_TOKENS (32k),
|
|
1029
|
+
// high/xhigh/max reasoning consumes all 32k, leaving no output tokens.
|
|
1030
|
+
// Solution: when caller did not explicitly set maxTokens,
|
|
1031
|
+
// increase the cap to model.maxTokens to allow room for output.
|
|
1032
|
+
let adaptiveMaxTokens = base.maxTokens ?? model.maxTokens;
|
|
1033
|
+
const hasExplicitMaxTokens = Number.isSafeInteger(options?.maxTokens) && (options?.maxTokens as number) > 0;
|
|
1034
|
+
if (
|
|
1035
|
+
!hasExplicitMaxTokens &&
|
|
1036
|
+
adaptiveMaxTokens === DEFAULT_REQUEST_MAX_TOKENS &&
|
|
1037
|
+
Number.isSafeInteger(model.maxTokens) &&
|
|
1038
|
+
model.maxTokens > DEFAULT_REQUEST_MAX_TOKENS
|
|
1039
|
+
) {
|
|
1040
|
+
// Increase to model.maxTokens to leave room for both thinking and output
|
|
1041
|
+
adaptiveMaxTokens = model.maxTokens;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1025
1044
|
return castApi<"anthropic-messages">({
|
|
1026
1045
|
...base,
|
|
1046
|
+
maxTokens: adaptiveMaxTokens,
|
|
1027
1047
|
thinkingEnabled: true,
|
|
1028
1048
|
effort,
|
|
1029
1049
|
toolChoice: mapAnthropicToolChoice(options?.toolChoice),
|
package/src/types.d.ts
CHANGED
|
@@ -746,7 +746,7 @@ export type TSchema = ZodType | TJsonSchema;
|
|
|
746
746
|
export type Static<S> = S extends ZodType ? z.infer<S> : S extends {
|
|
747
747
|
static: infer T;
|
|
748
748
|
} ? T : unknown;
|
|
749
|
-
export type RawArgumentRejectionCode = "ask-deep-interview-question-body-required" | "ask-intent-review-requires-positive-round" | "ask-intent-contract-requires-non-empty-authority" | "ask-deep-interview-metadata-requires-deep-interview-gate" | "ask-round-zero-metadata-requires-full-topology-fields" | "todo-write-unknown-root-key" | "todo-write-unknown-op-entry-key" | "todo-write-unknown-op-value" | "todo-write-done-drop-requires-target" | "todo-write-unknown-init-entry-key";
|
|
749
|
+
export type RawArgumentRejectionCode = "ask-question-body-required" | "ask-deep-interview-metadata-required" | "ask-deep-interview-single-question-required" | "ask-deep-interview-question-body-required" | "ask-intent-review-requires-positive-round" | "ask-intent-contract-requires-non-empty-authority" | "ask-deep-interview-metadata-requires-deep-interview-gate" | "ask-round-zero-metadata-requires-full-topology-fields" | "todo-write-unknown-root-key" | "todo-write-unknown-op-entry-key" | "todo-write-unknown-op-value" | "todo-write-done-drop-requires-target" | "todo-write-unknown-init-entry-key";
|
|
750
750
|
/**
|
|
751
751
|
* Optional structured detail attached to a raw-argument rejection. The fixed
|
|
752
752
|
* per-code guidance in `RAW_ARGUMENT_REJECTION_MESSAGES` explains the shape;
|
package/src/types.ts
CHANGED
|
@@ -1019,6 +1019,9 @@ export type TSchema = ZodType | TJsonSchema;
|
|
|
1019
1019
|
export type Static<S> = S extends ZodType ? z.infer<S> : S extends { static: infer T } ? T : unknown;
|
|
1020
1020
|
|
|
1021
1021
|
export type RawArgumentRejectionCode =
|
|
1022
|
+
| "ask-question-body-required"
|
|
1023
|
+
| "ask-deep-interview-metadata-required"
|
|
1024
|
+
| "ask-deep-interview-single-question-required"
|
|
1022
1025
|
| "ask-deep-interview-question-body-required"
|
|
1023
1026
|
| "ask-intent-review-requires-positive-round"
|
|
1024
1027
|
| "ask-intent-contract-requires-non-empty-authority"
|
|
@@ -23,6 +23,12 @@ export interface FallbackTrigger {
|
|
|
23
23
|
export declare const STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE = "stream_first_event_timeout";
|
|
24
24
|
/** Stable code for a nominally successful response with no content or token usage. */
|
|
25
25
|
export declare const EMPTY_RESPONSE_PROVIDER_CODE = "empty_response";
|
|
26
|
+
/**
|
|
27
|
+
* Terminal error code for a successful HTTP response whose body is not the protocol the
|
|
28
|
+
* provider speaks (for example a JSON or HTML error returned with 200). Deterministic for
|
|
29
|
+
* the request, so session retry and managed fallback must surface it instead of replaying.
|
|
30
|
+
*/
|
|
31
|
+
export declare const PROVIDER_PROTOCOL_MISMATCH_ERROR_CODE = "provider_protocol_mismatch";
|
|
26
32
|
/**
|
|
27
33
|
* OpenAI's typed capacity-overload code. It arrives without an HTTP status —
|
|
28
34
|
* inside an HTTP 200 terminal Responses envelope or a Codex error event — so the
|
|
@@ -26,6 +26,12 @@ export interface FallbackTrigger {
|
|
|
26
26
|
export const STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE = "stream_first_event_timeout";
|
|
27
27
|
/** Stable code for a nominally successful response with no content or token usage. */
|
|
28
28
|
export const EMPTY_RESPONSE_PROVIDER_CODE = "empty_response";
|
|
29
|
+
/**
|
|
30
|
+
* Terminal error code for a successful HTTP response whose body is not the protocol the
|
|
31
|
+
* provider speaks (for example a JSON or HTML error returned with 200). Deterministic for
|
|
32
|
+
* the request, so session retry and managed fallback must surface it instead of replaying.
|
|
33
|
+
*/
|
|
34
|
+
export const PROVIDER_PROTOCOL_MISMATCH_ERROR_CODE = "provider_protocol_mismatch";
|
|
29
35
|
/**
|
|
30
36
|
* OpenAI's typed capacity-overload code. It arrives without an HTTP status —
|
|
31
37
|
* inside an HTTP 200 terminal Responses envelope or a Codex error event — so the
|
|
@@ -202,6 +202,13 @@ export class FirstEventTimeoutError extends Error {
|
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Longest delay setTimeout honors. Bun, like Node, fires any larger delay after 1 ms,
|
|
207
|
+
* so a very long timeout (for example an env value meant as "effectively never")
|
|
208
|
+
* would otherwise abort the stream immediately.
|
|
209
|
+
*/
|
|
210
|
+
const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
|
|
211
|
+
|
|
205
212
|
const dummyWatchdog = setTimeout(() => {}, 1);
|
|
206
213
|
clearTimeout(dummyWatchdog);
|
|
207
214
|
|
|
@@ -211,7 +218,7 @@ clearTimeout(dummyWatchdog);
|
|
|
211
218
|
*/
|
|
212
219
|
export function createWatchdog(timeoutMs: number | undefined, onTimeout: () => void): Watchdog {
|
|
213
220
|
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
214
|
-
return setTimeout(onTimeout, timeoutMs);
|
|
221
|
+
return setTimeout(onTimeout, Math.min(timeoutMs, MAX_TIMER_DELAY_MS));
|
|
215
222
|
}
|
|
216
223
|
return undefined;
|
|
217
224
|
}
|
|
@@ -335,10 +342,10 @@ export async function* iterateWithIdleTimeout<T>(
|
|
|
335
342
|
let timer: NodeJS.Timeout | undefined;
|
|
336
343
|
let resolveTimeout: ((value: { kind: "timeout" }) => void) | undefined;
|
|
337
344
|
const enforceTimeout = !noTimeoutEnforced && activeTimeoutMs !== undefined && activeTimeoutMs >= 0;
|
|
338
|
-
if (enforceTimeout) {
|
|
345
|
+
if (enforceTimeout && activeTimeoutMs !== undefined) {
|
|
339
346
|
const { promise, resolve } = Promise.withResolvers<{ kind: "timeout" }>();
|
|
340
347
|
resolveTimeout = resolve;
|
|
341
|
-
timer = setTimeout(() => resolve({ kind: "timeout" }), activeTimeoutMs);
|
|
348
|
+
timer = setTimeout(() => resolve({ kind: "timeout" }), Math.min(activeTimeoutMs, MAX_TIMER_DELAY_MS));
|
|
342
349
|
racers.push(promise);
|
|
343
350
|
}
|
|
344
351
|
|
|
@@ -57,6 +57,11 @@ export declare class GlmZcodeOAuthFlow extends OAuthCallbackFlow {
|
|
|
57
57
|
}>;
|
|
58
58
|
exchangeToken(code: string, state: string, redirectUri: string): Promise<OAuthCredentials>;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Manual-input prompt for the paste fallback. Repeats the redirect-URL shape and
|
|
62
|
+
* the DevTools pointer so a re-prompt after a rejected paste stays self-contained.
|
|
63
|
+
*/
|
|
64
|
+
export declare const GLM_ZCODE_MANUAL_INPUT_PROMPT = "Paste the full zcode://oauth/callback?code=\u2026&state=\u2026 URL (copy it from browser DevTools \u2192 Network tab; the address bar never shows it) or the authorization code:";
|
|
60
65
|
export declare function loginGlmZcode(ctrl: OAuthController, options?: GlmZcodeOAuthFlowOptions): Promise<OAuthCredentials>;
|
|
61
66
|
export interface GlmZcodeRefreshOptions {
|
|
62
67
|
signal?: AbortSignal;
|
|
@@ -387,7 +387,7 @@ export class GlmZcodeOAuthFlow extends OAuthCallbackFlow {
|
|
|
387
387
|
return {
|
|
388
388
|
url: `${authorizeUrl}?${params.toString()}`,
|
|
389
389
|
instructions:
|
|
390
|
-
"Complete Z.AI login in your browser. This is an UNOFFICIAL ZCode-based login — use at your own risk; it may stop working or violate ZCode/Z.AI Terms of Service.
|
|
390
|
+
"Complete Z.AI login in your browser. This is an UNOFFICIAL ZCode-based login — use at your own risk; it may stop working or violate ZCode/Z.AI Terms of Service. If the ZCode desktop app is installed, cancel the browser's prompt to open it: the app exchanges the single-use code itself and the pasted code is then rejected (broker error 2007). After login the browser redirects to a zcode:// address, which never appears in the address bar — copy it from DevTools instead: open the browser DevTools Network tab (filter: zcode), select the zcode://oauth/callback?code=…&state=… request, and paste that full URL (or just the code) at the prompt.",
|
|
391
391
|
};
|
|
392
392
|
}
|
|
393
393
|
|
|
@@ -396,6 +396,13 @@ export class GlmZcodeOAuthFlow extends OAuthCallbackFlow {
|
|
|
396
396
|
}
|
|
397
397
|
}
|
|
398
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Manual-input prompt for the paste fallback. Repeats the redirect-URL shape and
|
|
401
|
+
* the DevTools pointer so a re-prompt after a rejected paste stays self-contained.
|
|
402
|
+
*/
|
|
403
|
+
export const GLM_ZCODE_MANUAL_INPUT_PROMPT =
|
|
404
|
+
"Paste the full zcode://oauth/callback?code=…&state=… URL (copy it from browser DevTools → Network tab; the address bar never shows it) or the authorization code:";
|
|
405
|
+
|
|
399
406
|
export async function loginGlmZcode(
|
|
400
407
|
ctrl: OAuthController,
|
|
401
408
|
options?: GlmZcodeOAuthFlowOptions,
|
package/src/utils/oauth/index.ts
CHANGED
|
@@ -9,6 +9,16 @@ import type {
|
|
|
9
9
|
OAuthProviderInterface,
|
|
10
10
|
} from "./types";
|
|
11
11
|
|
|
12
|
+
export class UnknownOAuthProviderError extends Error {
|
|
13
|
+
readonly provider: string;
|
|
14
|
+
|
|
15
|
+
constructor(provider: string) {
|
|
16
|
+
super(`Unknown OAuth provider: ${provider}`);
|
|
17
|
+
this.name = "UnknownOAuthProviderError";
|
|
18
|
+
this.provider = provider;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
const builtInOAuthProviders: OAuthProviderInfo[] = [
|
|
13
23
|
{
|
|
14
24
|
id: "anthropic",
|
|
@@ -442,7 +452,7 @@ export async function refreshOAuthToken(
|
|
|
442
452
|
newCredentials = credentials;
|
|
443
453
|
break;
|
|
444
454
|
default:
|
|
445
|
-
throw new
|
|
455
|
+
throw new UnknownOAuthProviderError(provider);
|
|
446
456
|
}
|
|
447
457
|
return newCredentials;
|
|
448
458
|
}
|
package/src/utils/validation.ts
CHANGED
|
@@ -960,6 +960,11 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): ToolCall["a
|
|
|
960
960
|
}
|
|
961
961
|
|
|
962
962
|
const RAW_ARGUMENT_REJECTION_MESSAGES: Record<RawArgumentRejectionCode, string> = {
|
|
963
|
+
"ask-question-body-required": "ask question bodies must contain non-whitespace text; remove empty extra questions",
|
|
964
|
+
"ask-deep-interview-metadata-required":
|
|
965
|
+
"active deep-interview questions require structured deepInterview metadata; question prose and workflowGate do not record an answer",
|
|
966
|
+
"ask-deep-interview-single-question-required":
|
|
967
|
+
"active deep-interview asks require exactly one question; include metadata on that question, never as an extra question",
|
|
963
968
|
"ask-deep-interview-question-body-required":
|
|
964
969
|
"deep-interview question bodies must contain a specific question, not a placeholder",
|
|
965
970
|
"ask-intent-review-requires-positive-round":
|
|
@@ -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;
|
package/src/utils/h2-fetch.ts
DELETED
|
@@ -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
|
-
}
|