@gajae-code/ai 0.13.2 → 0.14.0

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +61 -2
  2. package/dist/types/auth-broker/client.d.ts +9 -1
  3. package/dist/types/auth-broker/redact.d.ts +7 -0
  4. package/dist/types/auth-broker/remote-store.d.ts +50 -9
  5. package/dist/types/auth-broker/types.d.ts +14 -0
  6. package/dist/types/auth-broker/wire-schemas.d.ts +25 -0
  7. package/dist/types/auth-storage.d.ts +200 -6
  8. package/dist/types/core.d.ts +1 -0
  9. package/dist/types/model-cache.d.ts +4 -1
  10. package/dist/types/model-manager.d.ts +11 -0
  11. package/dist/types/provider-models/openai-compat.d.ts +5 -0
  12. package/dist/types/provider-models/special.d.ts +3 -0
  13. package/dist/types/providers/anthropic.d.ts +31 -0
  14. package/dist/types/providers/cursor.d.ts +9 -1
  15. package/dist/types/providers/kiro-codewhisperer.d.ts +8 -0
  16. package/dist/types/providers/mock.d.ts +8 -0
  17. package/dist/types/providers/register-builtins.d.ts +1 -0
  18. package/dist/types/providers/transform-messages.d.ts +18 -0
  19. package/dist/types/types.d.ts +34 -8
  20. package/dist/types/usage/grok-cli.d.ts +5 -0
  21. package/dist/types/usage.d.ts +6 -0
  22. package/dist/types/utils/discovery/openai-compatible.d.ts +5 -0
  23. package/dist/types/utils/event-stream.d.ts +4 -2
  24. package/dist/types/utils/fallback-transport.d.ts +10 -0
  25. package/dist/types/utils/http-inspector.d.ts +1 -0
  26. package/dist/types/utils/idle-iterator.d.ts +13 -1
  27. package/dist/types/utils/json-parse.d.ts +19 -0
  28. package/dist/types/utils/oauth/callback-server.d.ts +13 -0
  29. package/dist/types/utils/oauth/kiro.d.ts +71 -0
  30. package/dist/types/utils/oauth/types.d.ts +1 -1
  31. package/dist/types/utils/parse-bind.d.ts +8 -5
  32. package/dist/types/utils/tool-call-healing.d.ts +7 -0
  33. package/dist/types/utils/tool-choice-capability.d.ts +11 -0
  34. package/package.json +3 -2
  35. package/src/auth-broker/client.ts +30 -0
  36. package/src/auth-broker/redact.ts +15 -0
  37. package/src/auth-broker/refresher.ts +4 -2
  38. package/src/auth-broker/remote-store.ts +693 -70
  39. package/src/auth-broker/server.ts +57 -12
  40. package/src/auth-broker/types.ts +16 -0
  41. package/src/auth-broker/wire-schemas.ts +21 -0
  42. package/src/auth-gateway/server.ts +84 -19
  43. package/src/auth-storage.ts +985 -41
  44. package/src/core.ts +1 -0
  45. package/src/model-cache.ts +23 -4
  46. package/src/model-manager.ts +70 -11
  47. package/src/model-thinking.ts +45 -1
  48. package/src/models.json +9604 -1932
  49. package/src/openai-completions-compat.ts +2 -1
  50. package/src/provider-models/descriptors.ts +7 -1
  51. package/src/provider-models/openai-compat.ts +52 -28
  52. package/src/provider-models/special.ts +12 -0
  53. package/src/providers/amazon-bedrock.ts +2 -1
  54. package/src/providers/anthropic.ts +831 -27
  55. package/src/providers/cursor.ts +83 -3
  56. package/src/providers/kiro-codewhisperer.ts +572 -0
  57. package/src/providers/mock.ts +15 -2
  58. package/src/providers/ollama.ts +9 -2
  59. package/src/providers/openai-codex-responses.ts +16 -9
  60. package/src/providers/openai-completions.ts +6 -1
  61. package/src/providers/openai-responses-shared.ts +180 -18
  62. package/src/providers/register-builtins.ts +24 -2
  63. package/src/providers/transform-messages.ts +64 -1
  64. package/src/stream.ts +25 -2
  65. package/src/types.ts +36 -7
  66. package/src/usage/grok-cli.ts +86 -1
  67. package/src/usage.ts +7 -0
  68. package/src/utils/discovery/openai-compatible.ts +89 -4
  69. package/src/utils/event-stream.ts +11 -2
  70. package/src/utils/fallback-transport.ts +44 -2
  71. package/src/utils/http-inspector.ts +1 -0
  72. package/src/utils/idle-iterator.ts +29 -6
  73. package/src/utils/json-parse.ts +80 -0
  74. package/src/utils/oauth/callback-server.ts +31 -1
  75. package/src/utils/oauth/index.ts +14 -1
  76. package/src/utils/oauth/kiro.ts +448 -0
  77. package/src/utils/oauth/synthetic.ts +2 -3
  78. package/src/utils/oauth/types.ts +1 -0
  79. package/src/utils/parse-bind.ts +27 -0
  80. package/src/utils/tool-call-healing.ts +13 -2
  81. package/src/utils/tool-choice-capability.ts +386 -6
package/src/stream.ts CHANGED
@@ -32,6 +32,7 @@ import type { CursorOptions } from "./providers/cursor";
32
32
  import type { GoogleOptions } from "./providers/google";
33
33
  import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli";
34
34
  import type { GoogleVertexOptions } from "./providers/google-vertex";
35
+ import type { KiroCodeWhispererOptions } from "./providers/kiro-codewhisperer";
35
36
  import type { OllamaChatOptions } from "./providers/ollama";
36
37
  import type { OpenAICompletionsOptions } from "./providers/openai-completions";
37
38
  // Heavy provider stream functions are imported lazily via register-builtins,
@@ -46,6 +47,7 @@ import {
46
47
  streamGoogle,
47
48
  streamGoogleGeminiCli,
48
49
  streamGoogleVertex,
50
+ streamKiroCodeWhisperer,
49
51
  streamOllama,
50
52
  streamOpenAICodexResponses,
51
53
  streamOpenAICompletions,
@@ -119,6 +121,11 @@ const serviceProviderMap: Record<string, KeyResolver> = {
119
121
  tavily: "TAVILY_API_KEY",
120
122
  parallel: "PARALLEL_API_KEY",
121
123
  kagi: "KAGI_API_KEY",
124
+ // Kiro uses AWS SSO OIDC OAuth flow; bearer token is stored as the OAuth access token.
125
+ kiro: () => {
126
+ const bearerToken = $credentialEnv("AWS_BEARER_TOKEN_KIRO");
127
+ if (bearerToken) return bearerToken;
128
+ },
122
129
  // GitHub Copilot uses GitHub personal access token
123
130
  "github-copilot": () => $pickCredentialEnv("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"),
124
131
  // Foundry mode optionally switches Anthropic auth to enterprise gateway credentials.
@@ -156,6 +163,7 @@ const serviceProviderMap: Record<string, KeyResolver> = {
156
163
  nvidia: "NVIDIA_API_KEY",
157
164
  nanogpt: "NANO_GPT_API_KEY",
158
165
  "lm-studio": "LM_STUDIO_API_KEY",
166
+ omlx: "OMLX_API_KEY",
159
167
  ollama: "OLLAMA_API_KEY",
160
168
  "ollama-cloud": "OLLAMA_CLOUD_API_KEY",
161
169
  "llama.cpp": "LLAMA_CPP_API_KEY",
@@ -330,6 +338,12 @@ export function stream<TApi extends Api>(
330
338
  } else if (model.api === "bedrock-converse-stream") {
331
339
  // Bedrock doesn't have any API keys instead it sources credentials from standard AWS env variables or from given AWS profile.
332
340
  return streamBedrock(model as Model<"bedrock-converse-stream">, context, (options || {}) as BedrockOptions);
341
+ } else if (model.api === "kiro-codewhisperer-stream") {
342
+ return streamKiroCodeWhisperer(
343
+ model as Model<"kiro-codewhisperer-stream">,
344
+ context,
345
+ (options || {}) as KiroCodeWhispererOptions,
346
+ );
333
347
  }
334
348
 
335
349
  const apiKey = options?.apiKey || (model.provider === "opencodex" ? "local" : getEnvApiKey(model.provider));
@@ -480,7 +494,11 @@ export function streamSimple<TApi extends Api>(
480
494
  }
481
495
  const retryApiKey = options?.onAuthError ? (options.apiKey ?? getEnvApiKey(model.provider)) : undefined;
482
496
  if (retryApiKey) {
483
- const outer = new AssistantMessageEventStream();
497
+ const consumerAbortController = new AbortController();
498
+ const outer = new AssistantMessageEventStream(() => consumerAbortController.abort());
499
+ const requestSignal = options?.signal
500
+ ? AbortSignal.any([options.signal, consumerAbortController.signal])
501
+ : consumerAbortController.signal;
484
502
  const onAuthError = options!.onAuthError!;
485
503
  const runAttempt = async (apiKey: string, captureAuthFailure: boolean): Promise<AuthRetryFailure | undefined> => {
486
504
  const bufferedEvents: AssistantMessageEvent[] = [];
@@ -491,7 +509,12 @@ export function streamSimple<TApi extends Api>(
491
509
  };
492
510
 
493
511
  try {
494
- const inner = streamSimple(model, context, { ...options, apiKey, onAuthError: undefined });
512
+ const inner = streamSimple(model, context, {
513
+ ...options,
514
+ apiKey,
515
+ onAuthError: undefined,
516
+ signal: requestSignal,
517
+ });
495
518
  for await (const event of inner) {
496
519
  if (!emittedReplayUnsafeEvent && event.type === "start") {
497
520
  bufferedEvents.push(event);
package/src/types.ts CHANGED
@@ -23,6 +23,7 @@ import type {
23
23
  import type { GoogleOptions } from "./providers/google";
24
24
  import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli";
25
25
  import type { GoogleVertexOptions } from "./providers/google-vertex";
26
+ import type { KiroCodeWhispererOptions } from "./providers/kiro-codewhisperer";
26
27
  import type { OllamaChatOptions } from "./providers/ollama";
27
28
  import type { OpenAICodexResponsesOptions } from "./providers/openai-codex-responses";
28
29
  import type { OpenAICompletionsOptions } from "./providers/openai-completions";
@@ -43,7 +44,8 @@ export type KnownApi =
43
44
  | "google-gemini-cli"
44
45
  | "google-vertex"
45
46
  | "ollama-chat"
46
- | "cursor-agent";
47
+ | "cursor-agent"
48
+ | "kiro-codewhisperer-stream";
47
49
  export type Api = KnownApi | (string & {});
48
50
  export interface ApiOptionsMap {
49
51
  "anthropic-messages": AnthropicOptions;
@@ -57,6 +59,7 @@ export interface ApiOptionsMap {
57
59
  "google-vertex": GoogleVertexOptions;
58
60
  "ollama-chat": OllamaChatOptions;
59
61
  "cursor-agent": CursorOptions;
62
+ "kiro-codewhisperer-stream": KiroCodeWhispererOptions;
60
63
  }
61
64
  // Compile-time exhaustiveness check - this will fail if ApiOptionsMap doesn't have all KnownApi keys
62
65
  type _CheckExhaustive =
@@ -116,6 +119,7 @@ export interface ThinkingConfig {
116
119
  export const KNOWN_PROVIDERS = [
117
120
  "alibaba-token-plan",
118
121
  "amazon-bedrock",
122
+ "kiro",
119
123
  "azure-openai",
120
124
  "anthropic",
121
125
  "google",
@@ -172,6 +176,7 @@ export const KNOWN_PROVIDERS = [
172
176
  "xiaomi-token-plan-cn",
173
177
  "zenmux",
174
178
  "lm-studio",
179
+ "omlx",
175
180
  ] as const;
176
181
 
177
182
  export type KnownProvider = (typeof KNOWN_PROVIDERS)[number];
@@ -577,13 +582,37 @@ export interface ToolCall {
577
582
  */
578
583
  customWireName?: string;
579
584
  /**
580
- * Set when the provider detected the argument JSON was truncated — the model
581
- * hit its output-token limit (or the response was otherwise cut short) before
582
- * emitting a complete arguments object. The `arguments` field then holds a
583
- * best-effort partial parse and must not be executed as-is; the agent loop
584
- * rejects the call with a retryable error instead.
585
+ * Set when the provider detected the argument JSON was not safely executable —
586
+ * the model hit its output-token limit (or the response was otherwise cut short)
587
+ * before emitting a complete arguments object, the terminal payload was malformed,
588
+ * the streamed and terminal payloads conflicted, or the tool-call identity was
589
+ * ambiguous on the wire. The `arguments` field then holds a best-effort partial
590
+ * parse and must not be executed as-is; the agent loop rejects the call with a
591
+ * retryable, reason-specific error instead.
585
592
  */
586
593
  incompleteArguments?: boolean;
594
+ /**
595
+ * When `incompleteArguments` is set, the typed cause so the agent loop can give
596
+ * reason-specific recovery guidance:
597
+ * - `"truncated"`: the response was cut short mid-arguments (output-token limit).
598
+ * - `"malformed"`: the terminal arguments did not decode to a valid JSON object.
599
+ * - `"conflicting"`: the streamed and terminal argument payloads disagree.
600
+ * - `"ambiguous"`: the tool-call identity could not be unambiguously resolved
601
+ * (duplicate `call_id`, id/call_id collision, etc.), so attribution is unsafe.
602
+ * Absent when `incompleteArguments` is not set. Existing callers that read only
603
+ * `incompleteArguments` continue to work.
604
+ */
605
+ incompleteArgumentsReason?: "truncated" | "malformed" | "conflicting" | "ambiguous";
606
+ /**
607
+ * Set when the raw argument JSON spelled a printable non-ASCII character as a
608
+ * `\uXXXX` escape instead of literal UTF-8. Such a payload parses cleanly but
609
+ * is unverifiable: one mistyped hex digit decodes to a different, equally
610
+ * valid character, so the text can be silently wrong with no in-band evidence.
611
+ * The agent loop rejects the call with a retryable error instead of executing
612
+ * it. Escapes that are required (control characters) or unavoidable (lone
613
+ * surrogates) never set this.
614
+ */
615
+ escapedNonAsciiArguments?: boolean;
587
616
  }
588
617
 
589
618
  export interface Usage {
@@ -634,7 +663,7 @@ export interface Usage {
634
663
  }
635
664
 
636
665
  export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
637
- export type AssistantErrorKind = "provider_safety_stop";
666
+ export type AssistantErrorKind = "provider_safety_stop" | "local_snapshot_failure" | "local_buffer_overflow";
638
667
 
639
668
  export interface OpenAIResponsesHistoryPayload {
640
669
  type: "openaiResponsesHistory";
@@ -13,8 +13,15 @@ interface BillingUsage {
13
13
  used: number;
14
14
  billingPeriodEnd: string;
15
15
  }
16
+
17
+ interface WeeklyBillingUsage {
18
+ creditUsagePercent: number;
19
+ billingPeriodEnd: string;
20
+ }
21
+
16
22
  const DEFAULT_GROK_BUILD_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
17
23
  const ALLOWED_GROK_BUILD_HOSTS = new Set(["cli-chat-proxy.grok.com"]);
24
+ const GROK_WEEKLY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
18
25
 
19
26
  function isRecord(value: unknown): value is Record<string, unknown> {
20
27
  return !!value && typeof value === "object";
@@ -46,6 +53,23 @@ export function parseGrokCliBillingUsage(payload: unknown): BillingUsage {
46
53
  return { monthlyLimit, used, billingPeriodEnd };
47
54
  }
48
55
 
56
+ export function parseGrokCliWeeklyBillingUsage(payload: unknown): WeeklyBillingUsage | undefined {
57
+ if (!isRecord(payload) || !isRecord(payload.config)) return undefined;
58
+ const currentPeriod = isRecord(payload.config.currentPeriod) ? payload.config.currentPeriod : undefined;
59
+ if (currentPeriod?.type !== "USAGE_PERIOD_TYPE_WEEKLY") return undefined;
60
+
61
+ const billingPeriodEnd = payload.config.billingPeriodEnd;
62
+ if (typeof billingPeriodEnd !== "string" || !Number.isFinite(new Date(billingPeriodEnd).getTime())) {
63
+ return undefined;
64
+ }
65
+
66
+ const rawCreditUsagePercent = payload.config.creditUsagePercent;
67
+ const creditUsagePercent = rawCreditUsagePercent === undefined ? 0 : finiteNumber(rawCreditUsagePercent);
68
+ if (creditUsagePercent === undefined) return undefined;
69
+
70
+ return { creditUsagePercent, billingPeriodEnd };
71
+ }
72
+
49
73
  function isAllowedGrokCredentialHost(baseUrl: string): boolean {
50
74
  try {
51
75
  const url = new URL(baseUrl);
@@ -108,6 +132,33 @@ function buildMonthlyUsageLimit(usage: BillingUsage, nowMs: number): UsageLimit
108
132
  };
109
133
  }
110
134
 
135
+ function buildWeeklyUsageLimit(usage: WeeklyBillingUsage): UsageLimit {
136
+ const percent = Math.min(Math.max(usage.creditUsagePercent, 0), 100);
137
+ const usedFraction = percent / 100;
138
+ const resetsAt = new Date(usage.billingPeriodEnd).getTime();
139
+ return {
140
+ id: "grok-build:weekly",
141
+ label: "SuperGrok weekly credits",
142
+ scope: { provider: "grok-build", shared: true, windowId: "weekly" },
143
+ window: {
144
+ id: "weekly",
145
+ label: "Weekly",
146
+ durationMs: GROK_WEEKLY_WINDOW_MS,
147
+ resetsAt,
148
+ },
149
+ amount: {
150
+ unit: "percent",
151
+ used: percent,
152
+ limit: 100,
153
+ remaining: 100 - percent,
154
+ usedFraction,
155
+ remainingFraction: 1 - usedFraction,
156
+ },
157
+ status: percent >= 95 ? "exhausted" : percent >= 80 ? "warning" : "ok",
158
+ notes: [`${percent}% used`],
159
+ };
160
+ }
161
+
111
162
  export const grokCliUsageProvider: UsageProvider = {
112
163
  id: "grok-build",
113
164
 
@@ -151,10 +202,42 @@ export const grokCliUsageProvider: UsageProvider = {
151
202
  }
152
203
 
153
204
  const nowMs = Date.now();
205
+ let weekly: WeeklyBillingUsage | undefined;
206
+ try {
207
+ const weeklyResponse = await ctx.fetch(`${billingBaseUrl}/billing?format=credits`, {
208
+ headers: {
209
+ Authorization: `Bearer ${accessToken}`,
210
+ "x-xai-token-auth": "xai-grok-cli",
211
+ accept: "application/json",
212
+ },
213
+ signal: params.signal,
214
+ });
215
+ if (weeklyResponse.ok) {
216
+ weekly = parseGrokCliWeeklyBillingUsage((await weeklyResponse.json()) as unknown);
217
+ }
218
+ } catch (error) {
219
+ if (params.signal?.aborted && billing.monthlyLimit <= 0) throw error;
220
+ ctx.logger?.debug("Grok Build weekly billing request failed", { error: String(error) });
221
+ }
222
+
223
+ const limits: UsageLimit[] = [];
224
+ if (billing.monthlyLimit > 0) {
225
+ limits.push(buildMonthlyUsageLimit(billing, nowMs));
226
+ }
227
+ if (weekly) {
228
+ limits.push(buildWeeklyUsageLimit(weekly));
229
+ }
230
+ if (limits.length === 0) {
231
+ ctx.logger?.warn("Grok Build billing response contained no usable quota", {
232
+ provider: params.provider,
233
+ });
234
+ return null;
235
+ }
236
+
154
237
  return {
155
238
  provider: "grok-build",
156
239
  fetchedAt: nowMs,
157
- limits: [buildMonthlyUsageLimit(billing, nowMs)],
240
+ limits,
158
241
  metadata: {
159
242
  email: params.credential.email,
160
243
  accountId: params.credential.accountId,
@@ -167,6 +250,8 @@ export const grokCliUsageProvider: UsageProvider = {
167
250
 
168
251
  export const grokCliRankingStrategy: CredentialRankingStrategy = {
169
252
  findWindowLimits(report) {
253
+ const weekly = report.limits.find(limit => limit.id === "grok-build:weekly");
254
+ if (weekly) return { secondary: weekly };
170
255
  const monthly = report.limits.find(limit => limit.id === "grok-build:7d");
171
256
  return { secondary: monthly };
172
257
  },
package/src/usage.ts CHANGED
@@ -131,6 +131,12 @@ export interface UsageLogger {
131
131
  }
132
132
 
133
133
  /** Credential bundle for usage endpoints. */
134
+ /** MCP OAuth authority carried through usage-triggered refresh. */
135
+ export interface UsageMCPOAuthBinding {
136
+ resourceOrigin: string;
137
+ tokenEndpoint: string;
138
+ }
139
+
134
140
  export interface UsageCredential {
135
141
  type: "api_key" | "oauth";
136
142
  apiKey?: string;
@@ -141,6 +147,7 @@ export interface UsageCredential {
141
147
  projectId?: string;
142
148
  email?: string;
143
149
  enterpriseUrl?: string;
150
+ mcpBinding?: UsageMCPOAuthBinding;
144
151
  metadata?: Record<string, unknown>;
145
152
  }
146
153
 
@@ -1,8 +1,10 @@
1
1
  import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "@gajae-code/ai";
2
2
  import * as z from "zod/v4";
3
3
  import type { Api, FetchImpl, Model, Provider } from "../../types";
4
+ import { toNumber } from "../../utils";
4
5
 
5
6
  const MODELS_PATH = "/models";
7
+ const MAX_MODELS_RESPONSE_BYTES = 1_000_000;
6
8
 
7
9
  /**
8
10
  * Minimal OpenAI-style model entry shape consumed by discovery.
@@ -100,6 +102,26 @@ export interface FetchOpenAICompatibleModelsOptions<TApi extends Api> {
100
102
  ) => Model<TApi> | null;
101
103
  }
102
104
 
105
+ /**
106
+ * Resolves an endpoint for an implicit local provider without allowing an
107
+ * environment override to turn its keyless discovery into a remote request.
108
+ */
109
+ export function resolveLoopbackOpenAIBaseUrl(value: string | undefined, fallback: string): string {
110
+ const candidate = value?.trim();
111
+ if (!candidate) return fallback;
112
+ try {
113
+ const parsed = new URL(candidate);
114
+ const host = parsed.hostname.toLowerCase();
115
+ const isLoopback = host === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host);
116
+ if ((parsed.protocol === "http:" || parsed.protocol === "https:") && isLoopback) {
117
+ return candidate;
118
+ }
119
+ } catch {
120
+ // Fall back to the fixed loopback endpoint below.
121
+ }
122
+ return fallback;
123
+ }
124
+
103
125
  /**
104
126
  * Fetches and normalizes an OpenAI-compatible `/models` catalog.
105
127
  *
@@ -128,7 +150,9 @@ export async function fetchOpenAICompatibleModels<TApi extends Api>(
128
150
  response = await fetchImpl(buildModelsUrl(baseUrl), {
129
151
  method: "GET",
130
152
  headers: requestHeaders,
131
- signal: options.signal,
153
+ signal: options.signal
154
+ ? AbortSignal.any([options.signal, AbortSignal.timeout(5_000)])
155
+ : AbortSignal.timeout(5_000),
132
156
  });
133
157
  } catch {
134
158
  return null;
@@ -144,7 +168,7 @@ export async function fetchOpenAICompatibleModels<TApi extends Api>(
144
168
 
145
169
  let payload: unknown;
146
170
  try {
147
- payload = await response.json();
171
+ payload = JSON.parse(await readModelsResponse(response));
148
172
  } catch {
149
173
  return null;
150
174
  }
@@ -162,6 +186,17 @@ export async function fetchOpenAICompatibleModels<TApi extends Api>(
162
186
 
163
187
  const deduped = new Map<string, Model<TApi>>();
164
188
  for (const entry of entries) {
189
+ const rawContextWindow = firstPositiveModelNumber(
190
+ UNK_CONTEXT_WINDOW,
191
+ entry.max_model_len,
192
+ entry.context_length,
193
+ entry.context_window,
194
+ entry.max_context_length,
195
+ entry.max_position_embeddings,
196
+ );
197
+
198
+ const rawMaxTokens = firstPositiveModelNumber(UNK_MAX_TOKENS, entry.max_tokens, entry.max_output_tokens);
199
+
165
200
  const defaults: Model<TApi> = {
166
201
  id: entry.id,
167
202
  name: typeof entry.name === "string" && entry.name.length > 0 ? entry.name : entry.id,
@@ -171,8 +206,8 @@ export async function fetchOpenAICompatibleModels<TApi extends Api>(
171
206
  reasoning: false,
172
207
  input: ["text"],
173
208
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
174
- contextWindow: UNK_CONTEXT_WINDOW,
175
- maxTokens: UNK_MAX_TOKENS,
209
+ contextWindow: rawContextWindow,
210
+ maxTokens: rawMaxTokens,
176
211
  };
177
212
 
178
213
  const mapped = options.mapModel?.(entry, defaults, context) ?? defaults;
@@ -188,6 +223,39 @@ export async function fetchOpenAICompatibleModels<TApi extends Api>(
188
223
  return Array.from(deduped.values()).sort((left, right) => left.id.localeCompare(right.id));
189
224
  }
190
225
 
226
+ async function readModelsResponse(response: Response): Promise<string> {
227
+ const contentLength = Number(response.headers.get("content-length"));
228
+ if (Number.isFinite(contentLength) && contentLength > MAX_MODELS_RESPONSE_BYTES) {
229
+ throw new Error("OpenAI-compatible models response exceeds the size limit");
230
+ }
231
+ if (!response.body) return "";
232
+ const reader = response.body.getReader();
233
+ const chunks: Uint8Array[] = [];
234
+ let total = 0;
235
+ try {
236
+ while (true) {
237
+ const { done, value } = await reader.read();
238
+ if (done) break;
239
+ if (!value) continue;
240
+ total += value.byteLength;
241
+ if (total > MAX_MODELS_RESPONSE_BYTES) {
242
+ await reader.cancel();
243
+ throw new Error("OpenAI-compatible models response exceeds the size limit");
244
+ }
245
+ chunks.push(value);
246
+ }
247
+ } finally {
248
+ reader.releaseLock();
249
+ }
250
+ const body = new Uint8Array(total);
251
+ let offset = 0;
252
+ for (const chunk of chunks) {
253
+ body.set(chunk, offset);
254
+ offset += chunk.byteLength;
255
+ }
256
+ return new TextDecoder().decode(body);
257
+ }
258
+
191
259
  function normalizeBaseUrl(baseUrl: string): string {
192
260
  const trimmed = baseUrl.trim();
193
261
  if (!trimmed) {
@@ -244,3 +312,20 @@ function extractModelEntriesFromNode(node: unknown): ParsedOpenAICompatibleModel
244
312
 
245
313
  return null;
246
314
  }
315
+
316
+ /**
317
+ * First finite positive number among candidates, else the fallback.
318
+ *
319
+ * Rejects non-numbers, non-finite values (JSON `1e400` parses to
320
+ * `Infinity`), zero, and negatives so a malformed catalog field can never
321
+ * poison compaction thresholds or output budgets with `Infinity`.
322
+ */
323
+ function firstPositiveModelNumber(fallback: number, ...candidates: readonly unknown[]): number {
324
+ for (const candidate of candidates) {
325
+ const value = toNumber(candidate);
326
+ if (value !== undefined && value > 0 && Number.isFinite(value)) {
327
+ return value;
328
+ }
329
+ }
330
+ return fallback;
331
+ }
@@ -35,8 +35,9 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
35
35
  rejectFinalResult!: (err: unknown) => void;
36
36
  isComplete: (event: T) => boolean;
37
37
  extractResult: (event: T) => R;
38
+ #onConsumerClose?: () => void;
38
39
 
39
- constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R) {
40
+ constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R, onConsumerClose?: () => void) {
40
41
  const { promise, resolve, reject } = Promise.withResolvers<R>();
41
42
  // Prevent an unhandled rejection when fail() is called but nobody awaits result().
42
43
  // Callers who do await result() still receive the rejection normally.
@@ -46,6 +47,7 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
46
47
  this.rejectFinalResult = reject;
47
48
  this.isComplete = isComplete;
48
49
  this.extractResult = extractResult;
50
+ this.#onConsumerClose = onConsumerClose;
49
51
  }
50
52
 
51
53
  #enqueue(node: QueueNode<T>): void {
@@ -82,6 +84,11 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
82
84
  return this.#pendingConsumerDrains.size;
83
85
  }
84
86
 
87
+ /** Whether an async iterator is currently consuming this stream. */
88
+ get hasActiveConsumer(): boolean {
89
+ return this.#activeConsumerCount > 0;
90
+ }
91
+
85
92
  #settleConsumerDrain(drain: ConsumerDrain, status: "resolve" | "reject", reason?: unknown): void {
86
93
  if (drain.settled) return;
87
94
  drain.settled = true;
@@ -235,6 +242,7 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
235
242
  } finally {
236
243
  this.#activeConsumerCount -= 1;
237
244
  this.#settleAllConsumerDrains("reject", new Error("Event stream consumer stopped before drain completed"));
245
+ if (!this.done) this.#onConsumerClose?.();
238
246
  }
239
247
  }
240
248
 
@@ -244,7 +252,7 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
244
252
  }
245
253
 
246
254
  export class AssistantMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
247
- constructor() {
255
+ constructor(onConsumerClose?: () => void) {
248
256
  super(
249
257
  event => event.type === "done" || event.type === "error",
250
258
  event => {
@@ -255,6 +263,7 @@ export class AssistantMessageEventStream extends EventStream<AssistantMessageEve
255
263
  }
256
264
  throw new Error("Unexpected event type for final result");
257
265
  },
266
+ onConsumerClose,
258
267
  );
259
268
  }
260
269
  }
@@ -49,6 +49,16 @@ export interface TransportFailureFacts {
49
49
  /** OpenAI's typed `error.code`, preserved separately at the transport boundary. */
50
50
  openaiErrorCode?: string;
51
51
  headers?: Record<string, string>;
52
+ /** Safe request-size observation for retry amplification policy. Never contains body content. */
53
+ requestBytes?: number;
54
+ /** Time spent waiting for the first semantic stream event on the failed request. */
55
+ firstEventElapsedMs?: number;
56
+ /** Configured first-event window before any bounded endpoint grace. */
57
+ firstEventTimeoutMs?: number;
58
+ /** Coarse endpoint class; deliberately excludes host, path, credentials, and query parameters. */
59
+ endpointClass?: "canonical" | "custom";
60
+ /** Provider-supplied ceiling for total attempts, including the initial request. */
61
+ retryMaxAttempts?: number;
52
62
  }
53
63
 
54
64
  /** Opaque per-invocation marker required by managed fallback transport calls. */
@@ -118,6 +128,14 @@ function finiteStatus(value: unknown): number | undefined {
118
128
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
119
129
  }
120
130
 
131
+ function finiteNonNegativeInteger(value: unknown): number | undefined {
132
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
133
+ }
134
+
135
+ function finitePositiveInteger(value: unknown): number | undefined {
136
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
137
+ }
138
+
121
139
  function stringValue(value: unknown): string | undefined {
122
140
  return typeof value === "string" ? value : undefined;
123
141
  }
@@ -207,6 +225,13 @@ export function transportFailureFacts(
207
225
  // (consumers deliberately re-run transportFailureFacts on embedded facts).
208
226
  const headers = retainedHeaderRecord(rawHeaders);
209
227
  const normalizedCode = providerCode?.toLowerCase();
228
+ const requestBytes = finiteNonNegativeInteger(propertyOf(value, "requestBytes"));
229
+ const firstEventElapsedMs = finiteNonNegativeInteger(propertyOf(value, "firstEventElapsedMs"));
230
+ const firstEventTimeoutMs = finiteNonNegativeInteger(propertyOf(value, "firstEventTimeoutMs"));
231
+ const retryMaxAttempts = finitePositiveInteger(propertyOf(value, "retryMaxAttempts"));
232
+ const endpointClassValue = propertyOf(value, "endpointClass");
233
+ const endpointClass =
234
+ endpointClassValue === "canonical" || endpointClassValue === "custom" ? endpointClassValue : undefined;
210
235
  if (
211
236
  status === undefined &&
212
237
  headers === undefined &&
@@ -215,11 +240,28 @@ export function transportFailureFacts(
215
240
  !isRateLimitCode(normalizedCode) &&
216
241
  !isContextOverflowCode(normalizedCode) &&
217
242
  normalizedCode !== STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE &&
218
- normalizedCode !== EMPTY_RESPONSE_PROVIDER_CODE
243
+ normalizedCode !== EMPTY_RESPONSE_PROVIDER_CODE &&
244
+ requestBytes === undefined &&
245
+ firstEventElapsedMs === undefined &&
246
+ firstEventTimeoutMs === undefined &&
247
+ endpointClass === undefined &&
248
+ retryMaxAttempts === undefined
219
249
  ) {
220
250
  return undefined;
221
251
  }
222
- return { kind: "transport", status, providerCode, anthropicErrorType, openaiErrorCode, headers };
252
+ return {
253
+ kind: "transport",
254
+ status,
255
+ providerCode,
256
+ anthropicErrorType,
257
+ openaiErrorCode,
258
+ headers,
259
+ ...(requestBytes === undefined ? {} : { requestBytes }),
260
+ ...(firstEventElapsedMs === undefined ? {} : { firstEventElapsedMs }),
261
+ ...(firstEventTimeoutMs === undefined ? {} : { firstEventTimeoutMs }),
262
+ ...(endpointClass === undefined ? {} : { endpointClass }),
263
+ ...(retryMaxAttempts === undefined ? {} : { retryMaxAttempts }),
264
+ };
223
265
  }
224
266
 
225
267
  function headersOf(headers: TransportHeaders | undefined): Headers | undefined {
@@ -12,6 +12,7 @@ export type RawHttpRequestDump = {
12
12
  url?: string;
13
13
  headers?: Record<string, string>;
14
14
  body?: unknown;
15
+ diagnostics?: Record<string, unknown>;
15
16
  };
16
17
 
17
18
  export type CapturedHttpErrorResponse = {
@@ -106,12 +106,30 @@ export function resolveOpenAISdkRequestTimeoutMs(
106
106
  }
107
107
 
108
108
  export type Watchdog = NodeJS.Timeout | undefined;
109
+ export interface FirstEventTimeoutFacts {
110
+ requestBytes?: number;
111
+ firstEventElapsedMs?: number;
112
+ firstEventTimeoutMs?: number;
113
+ endpointClass?: "canonical" | "custom";
114
+ retryMaxAttempts?: number;
115
+ }
116
+
109
117
  export class FirstEventTimeoutError extends Error {
110
118
  readonly providerCode = STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE;
119
+ readonly requestBytes?: number;
120
+ readonly firstEventElapsedMs?: number;
121
+ readonly firstEventTimeoutMs?: number;
122
+ readonly endpointClass?: "canonical" | "custom";
123
+ readonly retryMaxAttempts?: number;
111
124
 
112
- constructor(message: string) {
125
+ constructor(message: string, facts: FirstEventTimeoutFacts = {}) {
113
126
  super(message);
114
127
  this.name = "FirstEventTimeoutError";
128
+ this.requestBytes = facts.requestBytes;
129
+ this.firstEventElapsedMs = facts.firstEventElapsedMs;
130
+ this.firstEventTimeoutMs = facts.firstEventTimeoutMs;
131
+ this.endpointClass = facts.endpointClass;
132
+ this.retryMaxAttempts = facts.retryMaxAttempts;
115
133
  }
116
134
  }
117
135
 
@@ -202,6 +220,8 @@ export async function* iterateWithIdleTimeout<T>(
202
220
  }
203
221
  };
204
222
  let lastProgressAt = Date.now();
223
+ const firstItemDeadlineAt =
224
+ firstItemTimeoutMs !== undefined && firstItemTimeoutMs > 0 ? Date.now() + firstItemTimeoutMs : undefined;
205
225
 
206
226
  const noTimeoutEnforced =
207
227
  (firstItemTimeoutMs === undefined || firstItemTimeoutMs <= 0) &&
@@ -210,7 +230,8 @@ export async function* iterateWithIdleTimeout<T>(
210
230
  while (true) {
211
231
  let activeTimeoutMs: number | undefined;
212
232
  if (awaitingFirstItem) {
213
- activeTimeoutMs = firstItemTimeoutMs;
233
+ activeTimeoutMs =
234
+ firstItemDeadlineAt === undefined ? undefined : Math.max(0, firstItemDeadlineAt - Date.now());
214
235
  } else if (options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0) {
215
236
  activeTimeoutMs = options.idleTimeoutMs - (Date.now() - lastProgressAt);
216
237
  // The idle deadline may already have elapsed because the *consumer*
@@ -235,10 +256,7 @@ export async function* iterateWithIdleTimeout<T>(
235
256
 
236
257
  let timer: NodeJS.Timeout | undefined;
237
258
  let resolveTimeout: ((value: { kind: "timeout" }) => void) | undefined;
238
- const enforceTimeout =
239
- !noTimeoutEnforced &&
240
- activeTimeoutMs !== undefined &&
241
- (awaitingFirstItem ? activeTimeoutMs > 0 : activeTimeoutMs >= 0);
259
+ const enforceTimeout = !noTimeoutEnforced && activeTimeoutMs !== undefined && activeTimeoutMs >= 0;
242
260
  if (enforceTimeout) {
243
261
  const { promise, resolve } = Promise.withResolvers<{ kind: "timeout" }>();
244
262
  resolveTimeout = resolve;
@@ -283,6 +301,11 @@ export async function* iterateWithIdleTimeout<T>(
283
301
  if (outcome.kind === "error") {
284
302
  throw outcome.error;
285
303
  }
304
+ if (awaitingFirstItem && firstItemDeadlineAt !== undefined && Date.now() >= firstItemDeadlineAt) {
305
+ options.onFirstItemTimeout?.();
306
+ closeIterator();
307
+ throw new FirstEventTimeoutError(options.firstItemErrorMessage ?? options.errorMessage);
308
+ }
286
309
  if (outcome.result.done) {
287
310
  markFirstItemReceived();
288
311
  return;