@gajae-code/ai 0.17.1 → 0.17.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +117 -0
- package/dist/types/auth-gateway/server.d.ts +23 -1
- package/dist/types/auth-storage.d.ts +12 -1
- package/dist/types/model-thinking.d.ts +10 -6
- package/dist/types/provider-models/openai-compat.d.ts +2 -2
- package/dist/types/providers/anthropic.d.ts +1 -1
- package/dist/types/providers/cursor.d.ts +10 -0
- package/dist/types/providers/openai-completions.d.ts +9 -1
- package/dist/types/types.d.ts +16 -0
- package/dist/types/utils/discovery/openai-compatible.d.ts +10 -0
- package/dist/types/utils/fallback-transport.d.ts +4 -0
- package/dist/types/utils/h2-fetch.d.ts +8 -2
- package/dist/types/utils/stream-repetition-guard.d.ts +107 -0
- package/dist/types/utils/tool-call-healing.d.ts +4 -0
- package/dist/types/utils/tool-fence-strip.d.ts +27 -0
- package/package.json +3 -3
- package/src/auth-gateway/server.ts +48 -9
- package/src/auth-storage.ts +185 -48
- package/src/model-manager.ts +11 -8
- package/src/model-pricing.ts +22 -0
- package/src/model-thinking.d.ts +10 -6
- package/src/model-thinking.ts +93 -11
- package/src/models.json +241 -15
- package/src/provider-models/openai-compat.ts +27 -19
- package/src/providers/anthropic.d.ts +1 -1
- package/src/providers/anthropic.ts +10 -2
- package/src/providers/cursor.d.ts +10 -0
- package/src/providers/cursor.ts +176 -31
- package/src/providers/openai-completions.d.ts +9 -1
- package/src/providers/openai-completions.ts +379 -128
- package/src/providers/openai-opencodex-responses.ts +15 -5
- package/src/stream.ts +24 -1
- package/src/types.d.ts +16 -0
- package/src/types.ts +17 -0
- package/src/utils/discovery/openai-compatible.ts +16 -2
- package/src/utils/fallback-transport.d.ts +4 -0
- package/src/utils/fallback-transport.ts +11 -0
- package/src/utils/h2-fetch.ts +70 -7
- package/src/utils/http-inspector.ts +4 -2
- package/src/utils/idle-iterator.ts +109 -96
- package/src/utils/json-parse.ts +12 -4
- package/src/utils/stream-repetition-guard.d.ts +107 -0
- package/src/utils/stream-repetition-guard.ts +290 -0
- package/src/utils/tool-call-healing.d.ts +4 -0
- package/src/utils/tool-call-healing.ts +4 -0
- package/src/utils/tool-fence-strip.d.ts +27 -0
- package/src/utils/tool-fence-strip.ts +64 -0
|
@@ -31,6 +31,11 @@ interface CatalogRow {
|
|
|
31
31
|
maxTokens?: unknown;
|
|
32
32
|
reasoning?: unknown;
|
|
33
33
|
input?: unknown;
|
|
34
|
+
capabilities?: {
|
|
35
|
+
context_length?: unknown;
|
|
36
|
+
input_modalities?: unknown;
|
|
37
|
+
supports_reasoning?: unknown;
|
|
38
|
+
};
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
export interface OpenCodexEndpoint {
|
|
@@ -120,6 +125,9 @@ function asPositiveNumber(value: unknown, fallback: number): number {
|
|
|
120
125
|
|
|
121
126
|
function normalizeCatalogPayload(payload: unknown): CatalogRow[] {
|
|
122
127
|
if (Array.isArray(payload)) return payload as CatalogRow[];
|
|
128
|
+
if (payload && typeof payload === "object" && Array.isArray((payload as { data?: unknown }).data)) {
|
|
129
|
+
return (payload as { data: CatalogRow[] }).data;
|
|
130
|
+
}
|
|
123
131
|
if (payload && typeof payload === "object" && Array.isArray((payload as { models?: unknown }).models)) {
|
|
124
132
|
return (payload as { models: CatalogRow[] }).models;
|
|
125
133
|
}
|
|
@@ -127,12 +135,14 @@ function normalizeCatalogPayload(payload: unknown): CatalogRow[] {
|
|
|
127
135
|
}
|
|
128
136
|
|
|
129
137
|
function normalizeModel(row: CatalogRow, endpoint: OpenCodexEndpoint): Model<"openai-responses"> | undefined {
|
|
138
|
+
if (!row || typeof row !== "object") return undefined;
|
|
130
139
|
const rawId = typeof row.id === "string" ? row.id.trim() : typeof row.model === "string" ? row.model.trim() : "";
|
|
131
140
|
if (!rawId || rawId.includes("\n")) return undefined;
|
|
132
141
|
const publicId = `opencodex/${rawId}`;
|
|
142
|
+
const modalities = row.input ?? row.capabilities?.input_modalities;
|
|
133
143
|
const input =
|
|
134
|
-
Array.isArray(
|
|
135
|
-
?
|
|
144
|
+
Array.isArray(modalities) && modalities.every(value => value === "text" || value === "image")
|
|
145
|
+
? modalities
|
|
136
146
|
: ["text"];
|
|
137
147
|
return {
|
|
138
148
|
id: publicId,
|
|
@@ -142,10 +152,10 @@ function normalizeModel(row: CatalogRow, endpoint: OpenCodexEndpoint): Model<"op
|
|
|
142
152
|
provider: "opencodex",
|
|
143
153
|
baseUrl: `${endpoint.baseUrl}/v1`,
|
|
144
154
|
compat: { supportsServiceTier: true },
|
|
145
|
-
reasoning: row.reasoning !== false,
|
|
155
|
+
reasoning: (row.reasoning ?? row.capabilities?.supports_reasoning) !== false,
|
|
146
156
|
input,
|
|
147
157
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
148
|
-
contextWindow: asPositiveNumber(row.contextWindow, 128_000),
|
|
158
|
+
contextWindow: asPositiveNumber(row.contextWindow ?? row.capabilities?.context_length, 128_000),
|
|
149
159
|
maxTokens: asPositiveNumber(row.maxTokens, 16_384),
|
|
150
160
|
};
|
|
151
161
|
}
|
|
@@ -154,7 +164,7 @@ export async function fetchOpenCodexModels(): Promise<readonly Model<"openai-res
|
|
|
154
164
|
const endpoint = await resolveOpenCodexEndpoint();
|
|
155
165
|
if (!endpoint) return null;
|
|
156
166
|
try {
|
|
157
|
-
const rows = normalizeCatalogPayload(await fetchJson(`${endpoint.baseUrl}/
|
|
167
|
+
const rows = normalizeCatalogPayload(await fetchJson(`${endpoint.baseUrl}/v1/models`));
|
|
158
168
|
const models = rows
|
|
159
169
|
.map(row => normalizeModel(row, endpoint))
|
|
160
170
|
.filter((model): model is Model<"openai-responses"> => model !== undefined);
|
package/src/stream.ts
CHANGED
|
@@ -27,6 +27,7 @@ function markManagedAttemptValidated<T extends object>(options: T): T {
|
|
|
27
27
|
import { getCustomApi } from "./api-registry";
|
|
28
28
|
import type { Effort } from "./model-thinking";
|
|
29
29
|
import {
|
|
30
|
+
getMiniMaxThinkingMode,
|
|
30
31
|
mapEffortToAnthropicAdaptiveEffort,
|
|
31
32
|
mapEffortToGoogleThinkingLevel,
|
|
32
33
|
requireSupportedEffort,
|
|
@@ -499,6 +500,9 @@ export async function complete<TApi extends Api>(
|
|
|
499
500
|
options?: OptionsForApi<TApi>,
|
|
500
501
|
): Promise<AssistantMessage> {
|
|
501
502
|
const s = stream(model, context, options);
|
|
503
|
+
for await (const _event of s) {
|
|
504
|
+
// Completion callers only need the terminal message, not buffered events.
|
|
505
|
+
}
|
|
502
506
|
return s.result();
|
|
503
507
|
}
|
|
504
508
|
|
|
@@ -823,6 +827,9 @@ export async function completeSimple<TApi extends Api>(
|
|
|
823
827
|
options?: SimpleStreamOptions,
|
|
824
828
|
): Promise<AssistantMessage> {
|
|
825
829
|
const s = streamSimple(model, context, options);
|
|
830
|
+
for await (const _event of s) {
|
|
831
|
+
// Completion callers only need the terminal message, not buffered events.
|
|
832
|
+
}
|
|
826
833
|
return s.result();
|
|
827
834
|
}
|
|
828
835
|
|
|
@@ -978,6 +985,16 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
978
985
|
|
|
979
986
|
switch (model.api) {
|
|
980
987
|
case "anthropic-messages": {
|
|
988
|
+
const miniMaxMode = getMiniMaxThinkingMode(model);
|
|
989
|
+
if (miniMaxMode) {
|
|
990
|
+
return castApi<"anthropic-messages">({
|
|
991
|
+
...base,
|
|
992
|
+
thinkingEnabled:
|
|
993
|
+
miniMaxMode === "toggle" ? !!options?.reasoning && !options?.disableReasoning : undefined,
|
|
994
|
+
toolChoice: mapAnthropicToolChoice(options?.toolChoice),
|
|
995
|
+
serviceTier: options?.serviceTier,
|
|
996
|
+
});
|
|
997
|
+
}
|
|
981
998
|
// Explicitly disable thinking when reasoning is not specified or model doesn't support it
|
|
982
999
|
const reasoning = options?.reasoning;
|
|
983
1000
|
if (!reasoning || !model.reasoning) {
|
|
@@ -1094,9 +1111,15 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
1094
1111
|
return castApi<"openai-completions">({
|
|
1095
1112
|
...base,
|
|
1096
1113
|
reasoning: resolveOpenAiReasoningEffort(model, options),
|
|
1097
|
-
|
|
1114
|
+
// Agent-level off is represented by an absent effort. MiniMax's
|
|
1115
|
+
// native OpenAI endpoint defaults to on, so send an explicit switch.
|
|
1116
|
+
disableReasoning:
|
|
1117
|
+
getMiniMaxThinkingMode(model) === "toggle"
|
|
1118
|
+
? !options?.reasoning || options?.disableReasoning
|
|
1119
|
+
: options?.disableReasoning,
|
|
1098
1120
|
toolChoice: mapOpenAiToolChoice(options?.toolChoice),
|
|
1099
1121
|
serviceTier: options?.serviceTier,
|
|
1122
|
+
repetitionGuard: options?.repetitionGuard,
|
|
1100
1123
|
});
|
|
1101
1124
|
|
|
1102
1125
|
case "openai-responses":
|
package/src/types.d.ts
CHANGED
|
@@ -338,6 +338,14 @@ export interface AttemptScopeRef {
|
|
|
338
338
|
readonly generation: number;
|
|
339
339
|
readonly lineage: string;
|
|
340
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Runaway-repetition guard thresholds, per stream channel. A number sets the
|
|
343
|
+
* consecutive-repeat threshold; `false` disables that channel's guard.
|
|
344
|
+
*/
|
|
345
|
+
export interface RepetitionGuardOptions {
|
|
346
|
+
thinking?: number | false;
|
|
347
|
+
text?: number | false;
|
|
348
|
+
}
|
|
341
349
|
export interface SimpleStreamOptions extends StreamOptions {
|
|
342
350
|
reasoning?: Effort;
|
|
343
351
|
/**
|
|
@@ -373,6 +381,14 @@ export interface SimpleStreamOptions extends StreamOptions {
|
|
|
373
381
|
syntheticApiFormat?: "openai" | "anthropic";
|
|
374
382
|
/** Hint that websocket transport should be preferred when supported by the provider implementation. */
|
|
375
383
|
preferWebsockets?: boolean;
|
|
384
|
+
/**
|
|
385
|
+
* Runaway-repetition guard thresholds, per stream channel. Honoured by the
|
|
386
|
+
* openai-completions transport; ignored by providers without a guard.
|
|
387
|
+
* Defaults: thinking = DEFAULT_REPETITION_THRESHOLD, text = false — visible
|
|
388
|
+
* output is a deliverable and intentional repetition there (logs, fixtures,
|
|
389
|
+
* tables, generated code) must survive byte for byte (#5627).
|
|
390
|
+
*/
|
|
391
|
+
repetitionGuard?: RepetitionGuardOptions;
|
|
376
392
|
}
|
|
377
393
|
export type StreamFunction<TApi extends Api> = (model: Model<TApi>, context: Context, options: OptionsForApi<TApi>) => AssistantMessageEventStream;
|
|
378
394
|
export interface TextSignatureV1 {
|
package/src/types.ts
CHANGED
|
@@ -556,6 +556,15 @@ export interface AttemptScopeRef {
|
|
|
556
556
|
readonly lineage: string;
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
+
/**
|
|
560
|
+
* Runaway-repetition guard thresholds, per stream channel. A number sets the
|
|
561
|
+
* consecutive-repeat threshold; `false` disables that channel's guard.
|
|
562
|
+
*/
|
|
563
|
+
export interface RepetitionGuardOptions {
|
|
564
|
+
thinking?: number | false;
|
|
565
|
+
text?: number | false;
|
|
566
|
+
}
|
|
567
|
+
|
|
559
568
|
// Unified options with reasoning passed to streamSimple() and completeSimple()
|
|
560
569
|
export interface SimpleStreamOptions extends StreamOptions {
|
|
561
570
|
reasoning?: Effort;
|
|
@@ -592,6 +601,14 @@ export interface SimpleStreamOptions extends StreamOptions {
|
|
|
592
601
|
syntheticApiFormat?: "openai" | "anthropic";
|
|
593
602
|
/** Hint that websocket transport should be preferred when supported by the provider implementation. */
|
|
594
603
|
preferWebsockets?: boolean;
|
|
604
|
+
/**
|
|
605
|
+
* Runaway-repetition guard thresholds, per stream channel. Honoured by the
|
|
606
|
+
* openai-completions transport; ignored by providers without a guard.
|
|
607
|
+
* Defaults: thinking = DEFAULT_REPETITION_THRESHOLD, text = false — visible
|
|
608
|
+
* output is a deliverable and intentional repetition there (logs, fixtures,
|
|
609
|
+
* tables, generated code) must survive byte for byte (#5627).
|
|
610
|
+
*/
|
|
611
|
+
repetitionGuard?: RepetitionGuardOptions;
|
|
595
612
|
}
|
|
596
613
|
|
|
597
614
|
// Generic StreamFunction with typed options
|
|
@@ -5,6 +5,15 @@ import type { Api, FetchImpl, Model, Provider } from "../../types";
|
|
|
5
5
|
import { toNumber } from "../../utils";
|
|
6
6
|
|
|
7
7
|
const MODELS_PATH = "/models";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Shared `/models` request deadline for setup-time probing and runtime
|
|
11
|
+
* discovery. One policy so an endpoint that passes setup validation cannot
|
|
12
|
+
* be unreachable under the runtime deadline (previously 10s vs 5s: a 6s
|
|
13
|
+
* endpoint passed setup, saved discovery-only config, then stayed
|
|
14
|
+
* unavailable at runtime with no recovery hint).
|
|
15
|
+
*/
|
|
16
|
+
export const MODELS_LIST_REQUEST_TIMEOUT_MS = 10_000;
|
|
8
17
|
const MAX_MODELS_RESPONSE_BYTES = 1_000_000;
|
|
9
18
|
const MAX_CATALOG_MODEL_ID_LENGTH = 200;
|
|
10
19
|
|
|
@@ -233,8 +242,8 @@ export async function fetchOpenAICompatibleModels<TApi extends Api>(
|
|
|
233
242
|
method: "GET",
|
|
234
243
|
headers: requestHeaders,
|
|
235
244
|
signal: options.signal
|
|
236
|
-
? AbortSignal.any([options.signal, AbortSignal.timeout(
|
|
237
|
-
: AbortSignal.timeout(
|
|
245
|
+
? AbortSignal.any([options.signal, AbortSignal.timeout(MODELS_LIST_REQUEST_TIMEOUT_MS)])
|
|
246
|
+
: AbortSignal.timeout(MODELS_LIST_REQUEST_TIMEOUT_MS),
|
|
238
247
|
});
|
|
239
248
|
} catch {
|
|
240
249
|
return null;
|
|
@@ -308,6 +317,11 @@ export async function fetchOpenAICompatibleModels<TApi extends Api>(
|
|
|
308
317
|
return Array.from(deduped.values()).sort((left, right) => left.id.localeCompare(right.id));
|
|
309
318
|
}
|
|
310
319
|
|
|
320
|
+
/** Bounded JSON body reader for `/models` responses (shared by runtime and setup probe). */
|
|
321
|
+
export async function readBoundedModelsJson(response: Response): Promise<unknown> {
|
|
322
|
+
return JSON.parse(await readModelsResponse(response));
|
|
323
|
+
}
|
|
324
|
+
|
|
311
325
|
async function readModelsResponse(response: Response): Promise<string> {
|
|
312
326
|
const contentLength = Number(response.headers.get("content-length"));
|
|
313
327
|
if (Number.isFinite(contentLength) && contentLength > MAX_MODELS_RESPONSE_BYTES) {
|
|
@@ -43,6 +43,10 @@ export type TransportHeaders = Headers | Record<string, string | undefined>;
|
|
|
43
43
|
*/
|
|
44
44
|
export interface TransportFailureFacts {
|
|
45
45
|
kind: "transport";
|
|
46
|
+
/** Diagnostic HTTP/2 reset code; not HTTP status or retry authority. */
|
|
47
|
+
http2RstCode?: number;
|
|
48
|
+
/** Native HTTP/2 error code; diagnostic only, not retry authority. */
|
|
49
|
+
nativeErrorCode?: string;
|
|
46
50
|
status?: number;
|
|
47
51
|
/** Canonical provider error code used for fallback classification. */
|
|
48
52
|
providerCode?: string;
|
|
@@ -48,6 +48,10 @@ export type TransportHeaders = Headers | Record<string, string | undefined>;
|
|
|
48
48
|
*/
|
|
49
49
|
export interface TransportFailureFacts {
|
|
50
50
|
kind: "transport";
|
|
51
|
+
/** Diagnostic HTTP/2 reset code; not HTTP status or retry authority. */
|
|
52
|
+
http2RstCode?: number;
|
|
53
|
+
/** Native HTTP/2 error code; diagnostic only, not retry authority. */
|
|
54
|
+
nativeErrorCode?: string;
|
|
51
55
|
status?: number;
|
|
52
56
|
/** Canonical provider error code used for fallback classification. */
|
|
53
57
|
providerCode?: string;
|
|
@@ -235,6 +239,9 @@ export function transportFailureFacts(
|
|
|
235
239
|
// (consumers deliberately re-run transportFailureFacts on embedded facts).
|
|
236
240
|
const headers = retainedHeaderRecord(rawHeaders);
|
|
237
241
|
const normalizedCode = providerCode?.toLowerCase();
|
|
242
|
+
const http2RstCode = finiteNonNegativeInteger(propertyOf(value, "http2RstCode"));
|
|
243
|
+
const nativeCode = stringValue(propertyOf(value, "nativeErrorCode")) ?? stringValue(propertyOf(value, "code"));
|
|
244
|
+
const nativeErrorCode = nativeCode && /^ERR_HTTP2_[A-Z_]+$/.test(nativeCode) ? nativeCode : undefined;
|
|
238
245
|
const requestBytes = finiteNonNegativeInteger(propertyOf(value, "requestBytes"));
|
|
239
246
|
const firstEventElapsedMs = finiteNonNegativeInteger(propertyOf(value, "firstEventElapsedMs"));
|
|
240
247
|
const firstEventTimeoutMs = finiteNonNegativeInteger(propertyOf(value, "firstEventTimeoutMs"));
|
|
@@ -244,6 +251,8 @@ export function transportFailureFacts(
|
|
|
244
251
|
endpointClassValue === "canonical" || endpointClassValue === "custom" ? endpointClassValue : undefined;
|
|
245
252
|
const credentialModelUnavailable = propertyOf(value, "credentialModelUnavailable") === true;
|
|
246
253
|
if (
|
|
254
|
+
http2RstCode === undefined &&
|
|
255
|
+
nativeErrorCode === undefined &&
|
|
247
256
|
status === undefined &&
|
|
248
257
|
headers === undefined &&
|
|
249
258
|
!isQuotaCode(normalizedCode) &&
|
|
@@ -268,6 +277,8 @@ export function transportFailureFacts(
|
|
|
268
277
|
}
|
|
269
278
|
return {
|
|
270
279
|
kind: "transport",
|
|
280
|
+
...(http2RstCode === undefined ? {} : { http2RstCode }),
|
|
281
|
+
...(nativeErrorCode === undefined ? {} : { nativeErrorCode }),
|
|
271
282
|
status,
|
|
272
283
|
providerCode,
|
|
273
284
|
anthropicErrorType,
|
package/src/utils/h2-fetch.ts
CHANGED
|
@@ -11,8 +11,14 @@
|
|
|
11
11
|
* Some HTTPS endpoints (e.g. corporate API gateways behind reverse proxies)
|
|
12
12
|
* advertise h2 via ALPN but then refuse or reset the connection at the HTTP/2
|
|
13
13
|
* framing layer. Bun surfaces these as `ConnectionRefused`, `ConnectionReset`,
|
|
14
|
-
* or `
|
|
15
|
-
*
|
|
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.
|
|
16
22
|
*
|
|
17
23
|
* ALPN-refusing hosts (notably zcode.z.ai, the GLM ZCode OAuth broker) abort
|
|
18
24
|
* the TLS handshake entirely when the client offers ALPN h2. Bun reports that
|
|
@@ -41,19 +47,21 @@ export function installH2Fetch(): void {
|
|
|
41
47
|
const h2FallbackCodes: ReadonlySet<string> = new Set([
|
|
42
48
|
"HTTP2Unsupported", // Server selected h1 in ALPN
|
|
43
49
|
"ConnectionRefused", // Server refused the h2 connection
|
|
44
|
-
"
|
|
45
|
-
"ConnectionClosed", // Server closed before h2 response
|
|
50
|
+
"HTTP2RefusedStream", // REFUSED_STREAM / never-processed h2 stream
|
|
46
51
|
// Bun's h2 client reports an ALPN-refusing host's TLS abort with this
|
|
47
52
|
// code; the h1 fallback below re-verifies the certificate itself.
|
|
48
53
|
"UNKNOWN_CERTIFICATE_VERIFICATION_ERROR",
|
|
49
54
|
]);
|
|
50
55
|
const wrapper = async function h2fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
|
|
51
56
|
if (!isHttps(input)) return original(input, init);
|
|
57
|
+
const snapshot = snapshotRequest(input, init);
|
|
52
58
|
try {
|
|
53
|
-
return await original(input, { ...init, protocol: "http2" });
|
|
59
|
+
return await original(snapshot.input, { ...snapshot.init, protocol: "http2" });
|
|
54
60
|
} catch (err) {
|
|
55
|
-
|
|
56
|
-
|
|
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);
|
|
57
65
|
}
|
|
58
66
|
} as typeof fetch & PatchedFetch;
|
|
59
67
|
|
|
@@ -63,8 +71,63 @@ export function installH2Fetch(): void {
|
|
|
63
71
|
globalThis.fetch = wrapper;
|
|
64
72
|
}
|
|
65
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
|
+
|
|
66
101
|
function isHttps(input: string | URL | Request): boolean {
|
|
67
102
|
if (typeof input === "string") return input.startsWith("https:");
|
|
68
103
|
if (input instanceof URL) return input.protocol === "https:";
|
|
69
104
|
return input.url.startsWith("https:");
|
|
70
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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { APP_NAME, extractHttpStatusFromError,
|
|
3
|
+
import { APP_NAME, extractHttpStatusFromError, getEffectiveLogsDir } from "@gajae-code/utils";
|
|
4
4
|
import { isCopilotTransientModelError } from "./retry.js";
|
|
5
5
|
import { formatErrorMessageWithRetryAfter } from "./retry-after.js";
|
|
6
6
|
|
|
@@ -43,6 +43,8 @@ const TRANSPORT_FAILURE_CODES: ReadonlySet<string> = new Set([
|
|
|
43
43
|
"ConnectionReset",
|
|
44
44
|
"ConnectionTimeout",
|
|
45
45
|
"FailedToOpenSocket",
|
|
46
|
+
"HTTP2RefusedStream",
|
|
47
|
+
"HTTP2StreamReset",
|
|
46
48
|
"HTTP2Unsupported",
|
|
47
49
|
"EAI_AGAIN",
|
|
48
50
|
"ECONNREFUSED",
|
|
@@ -109,7 +111,7 @@ const MAX_RETAINED_DUMPS = 50;
|
|
|
109
111
|
|
|
110
112
|
/** Directory holding the retained HTTP 400 dumps. */
|
|
111
113
|
export function httpRequestDumpDir(): string {
|
|
112
|
-
return path.join(
|
|
114
|
+
return path.join(getEffectiveLogsDir(), "http-400-requests");
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
/**
|
|
@@ -255,11 +255,19 @@ export async function* iterateWithIdleTimeout<T>(
|
|
|
255
255
|
const firstItemTimeoutMs = options.firstItemTimeoutMs ?? options.idleTimeoutMs;
|
|
256
256
|
const abortSignal = options.abortSignal;
|
|
257
257
|
const iterator = iterable[Symbol.asyncIterator]();
|
|
258
|
+
let naturallyExhausted = false;
|
|
259
|
+
let iteratorClosed = false;
|
|
258
260
|
|
|
259
261
|
const closeIterator = (): void => {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
262
|
+
if (iteratorClosed) return;
|
|
263
|
+
iteratorClosed = true;
|
|
264
|
+
try {
|
|
265
|
+
const returnPromise = iterator.return?.();
|
|
266
|
+
if (returnPromise) {
|
|
267
|
+
void returnPromise.catch(() => {});
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
// Cleanup must not replace the source error or timeout that triggered it.
|
|
263
271
|
}
|
|
264
272
|
};
|
|
265
273
|
|
|
@@ -296,109 +304,114 @@ export async function* iterateWithIdleTimeout<T>(
|
|
|
296
304
|
(firstItemTimeoutMs === undefined || firstItemTimeoutMs <= 0) &&
|
|
297
305
|
(options.idleTimeoutMs === undefined || options.idleTimeoutMs <= 0);
|
|
298
306
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
activeTimeoutMs
|
|
307
|
+
try {
|
|
308
|
+
while (true) {
|
|
309
|
+
let activeTimeoutMs: number | undefined;
|
|
310
|
+
if (awaitingFirstItem) {
|
|
311
|
+
activeTimeoutMs =
|
|
312
|
+
firstItemDeadlineAt === undefined ? undefined : Math.max(0, firstItemDeadlineAt - Date.now());
|
|
313
|
+
} else if (options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0) {
|
|
314
|
+
activeTimeoutMs = options.idleTimeoutMs - (Date.now() - lastProgressAt);
|
|
315
|
+
// The idle deadline may already have elapsed because the *consumer*
|
|
316
|
+
// was slow, not because the provider stalled — and the next item may
|
|
317
|
+
// already be buffered and ready to deliver. Clamp to 0 instead of
|
|
318
|
+
// throwing eagerly so the next() race below still gets a chance to
|
|
319
|
+
// win (it settles on a microtask, ahead of the 0ms timer). Only a
|
|
320
|
+
// genuinely hung iterator loses that race and surfaces as a stall.
|
|
321
|
+
if (activeTimeoutMs < 0) {
|
|
322
|
+
activeTimeoutMs = 0;
|
|
323
|
+
}
|
|
314
324
|
}
|
|
315
|
-
}
|
|
316
325
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
326
|
+
const racers: Array<
|
|
327
|
+
Promise<
|
|
328
|
+
| { kind: "next"; result: IteratorResult<T> }
|
|
329
|
+
| { kind: "error"; error: unknown }
|
|
330
|
+
| { kind: "timeout" }
|
|
331
|
+
| { kind: "abort" }
|
|
332
|
+
>
|
|
333
|
+
> = [];
|
|
325
334
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
+
let timer: NodeJS.Timeout | undefined;
|
|
336
|
+
let resolveTimeout: ((value: { kind: "timeout" }) => void) | undefined;
|
|
337
|
+
const enforceTimeout = !noTimeoutEnforced && activeTimeoutMs !== undefined && activeTimeoutMs >= 0;
|
|
338
|
+
if (enforceTimeout) {
|
|
339
|
+
const { promise, resolve } = Promise.withResolvers<{ kind: "timeout" }>();
|
|
340
|
+
resolveTimeout = resolve;
|
|
341
|
+
timer = setTimeout(() => resolve({ kind: "timeout" }), activeTimeoutMs);
|
|
342
|
+
racers.push(promise);
|
|
343
|
+
}
|
|
335
344
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
+
let abortListener: (() => void) | undefined;
|
|
346
|
+
let resolveAbort: ((value: { kind: "abort" }) => void) | undefined;
|
|
347
|
+
if (abortSignal) {
|
|
348
|
+
const { promise, resolve } = Promise.withResolvers<{ kind: "abort" }>();
|
|
349
|
+
resolveAbort = resolve;
|
|
350
|
+
abortListener = () => resolve({ kind: "abort" });
|
|
351
|
+
abortSignal.addEventListener("abort", abortListener, { once: true });
|
|
352
|
+
racers.push(promise);
|
|
353
|
+
}
|
|
345
354
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
355
|
+
// Arm timeout/abort races before asking the source for its next item. A
|
|
356
|
+
// periodic keepalive iterator commonly registers its own timer inside
|
|
357
|
+
// `next()`; registering that first lets equal-deadline keepalives win every
|
|
358
|
+
// race and extend the idle window forever. Already-buffered items still
|
|
359
|
+
// settle as microtasks before a 0ms watchdog.
|
|
360
|
+
racers.unshift(withRacy(iterator.next()));
|
|
352
361
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
362
|
+
try {
|
|
363
|
+
const outcome = await Promise.race(racers);
|
|
364
|
+
if (outcome.kind === "abort") {
|
|
365
|
+
closeIterator();
|
|
366
|
+
throw abortReason(abortSignal!);
|
|
367
|
+
}
|
|
368
|
+
if (outcome.kind === "timeout") {
|
|
369
|
+
if (!awaitingFirstItem) {
|
|
370
|
+
options.onIdle?.();
|
|
371
|
+
} else {
|
|
372
|
+
options.onFirstItemTimeout?.();
|
|
373
|
+
}
|
|
374
|
+
closeIterator();
|
|
375
|
+
throw awaitingFirstItem
|
|
376
|
+
? new FirstEventTimeoutError(options.firstItemErrorMessage ?? options.errorMessage)
|
|
377
|
+
: new Error(options.errorMessage);
|
|
378
|
+
}
|
|
379
|
+
if (outcome.kind === "error") {
|
|
380
|
+
throw outcome.error;
|
|
381
|
+
}
|
|
382
|
+
if (awaitingFirstItem && firstItemDeadlineAt !== undefined && Date.now() >= firstItemDeadlineAt) {
|
|
363
383
|
options.onFirstItemTimeout?.();
|
|
384
|
+
closeIterator();
|
|
385
|
+
throw new FirstEventTimeoutError(options.firstItemErrorMessage ?? options.errorMessage);
|
|
364
386
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
markFirstItemReceived();
|
|
390
|
-
lastProgressAt = Date.now();
|
|
391
|
-
}
|
|
392
|
-
yield item;
|
|
393
|
-
} finally {
|
|
394
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
395
|
-
// Resolve dangling promises so the racers don't leak (Promise.race is one-shot).
|
|
396
|
-
resolveTimeout?.({ kind: "timeout" });
|
|
397
|
-
if (abortListener && abortSignal) {
|
|
398
|
-
abortSignal.removeEventListener("abort", abortListener);
|
|
387
|
+
if (outcome.result.done) {
|
|
388
|
+
naturallyExhausted = true;
|
|
389
|
+
markFirstItemReceived();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const item = outcome.result.value;
|
|
393
|
+
// Non-progress items (e.g. provider keepalives, synthetic `start` events that
|
|
394
|
+
// arrive before the model has produced any tokens) MUST NOT flip us out of
|
|
395
|
+
// `awaitingFirstItem`. Otherwise the next iteration switches from the (longer)
|
|
396
|
+
// first-item watchdog to the (shorter) idle watchdog while we're still waiting
|
|
397
|
+
// on the model's first real output.
|
|
398
|
+
if (isProgressItem(item)) {
|
|
399
|
+
markFirstItemReceived();
|
|
400
|
+
lastProgressAt = Date.now();
|
|
401
|
+
}
|
|
402
|
+
yield item;
|
|
403
|
+
} finally {
|
|
404
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
405
|
+
// Resolve dangling promises so the racers don't leak (Promise.race is one-shot).
|
|
406
|
+
resolveTimeout?.({ kind: "timeout" });
|
|
407
|
+
if (abortListener && abortSignal) {
|
|
408
|
+
abortSignal.removeEventListener("abort", abortListener);
|
|
409
|
+
}
|
|
410
|
+
resolveAbort?.({ kind: "abort" });
|
|
399
411
|
}
|
|
400
|
-
resolveAbort?.({ kind: "abort" });
|
|
401
412
|
}
|
|
413
|
+
} finally {
|
|
414
|
+
if (!naturallyExhausted) closeIterator();
|
|
402
415
|
}
|
|
403
416
|
}
|
|
404
417
|
|