@hraness/dawg 0.0.0-stage → 0.3.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 (113) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +327 -0
  3. package/LICENSE +21 -0
  4. package/README.md +213 -2
  5. package/core/diff.ts +249 -0
  6. package/core/drums.ts +102 -0
  7. package/core/key.ts +43 -0
  8. package/core/loop.ts +78 -0
  9. package/core/pitch.ts +60 -0
  10. package/core/score.ts +1388 -0
  11. package/core/sdk/eval-child.ts +113 -0
  12. package/core/sdk/eval.ts +257 -0
  13. package/core/sdk/print.ts +393 -0
  14. package/core/sdk/v1.ts +954 -0
  15. package/core/slug.ts +19 -0
  16. package/package.json +45 -4
  17. package/src/agent/agent.ts +853 -0
  18. package/src/agent/brief.ts +160 -0
  19. package/src/agent/gateway.ts +441 -0
  20. package/src/agent/models.ts +633 -0
  21. package/src/agent/ops.ts +157 -0
  22. package/src/agent/planner.ts +259 -0
  23. package/src/agent/provider.ts +454 -0
  24. package/src/agent/sse.ts +114 -0
  25. package/src/agent/tools.ts +1373 -0
  26. package/src/agent/usage.ts +296 -0
  27. package/src/agent/workspace.ts +683 -0
  28. package/src/agent/xcb-agent.ts +262 -0
  29. package/src/agent/xcb.ts +579 -0
  30. package/src/audio/click.ts +125 -0
  31. package/src/audio/clock.ts +68 -0
  32. package/src/audio/engine.ts +841 -0
  33. package/src/audio/live.ts +152 -0
  34. package/src/audio/lock.ts +57 -0
  35. package/src/audio/player.ts +134 -0
  36. package/src/audio/render-worker.ts +68 -0
  37. package/src/audio/renderer.ts +174 -0
  38. package/src/audio/sampler.ts +292 -0
  39. package/src/audio/samples.ts +683 -0
  40. package/src/audio/wav.ts +861 -0
  41. package/src/auth/cli.ts +231 -0
  42. package/src/auth/credentials.ts +411 -0
  43. package/src/auth/discover.ts +481 -0
  44. package/src/auth/login.ts +1191 -0
  45. package/src/auth/openrouter.ts +206 -0
  46. package/src/auth/picker.ts +282 -0
  47. package/src/auth/runner.ts +207 -0
  48. package/src/auth/tui.ts +107 -0
  49. package/src/commands/edit.ts +170 -0
  50. package/src/commands/help.ts +247 -0
  51. package/src/commands/history.ts +69 -0
  52. package/src/commands/music.ts +461 -0
  53. package/src/commands/sample.ts +302 -0
  54. package/src/daemon.ts +31 -0
  55. package/src/main.ts +2209 -0
  56. package/src/media/analyze.ts +364 -0
  57. package/src/media/backend.ts +253 -0
  58. package/src/media/cli.ts +173 -0
  59. package/src/media/download.ts +281 -0
  60. package/src/media/dsp.ts +281 -0
  61. package/src/media/import.ts +130 -0
  62. package/src/media/lyrics.ts +201 -0
  63. package/src/media/notes.ts +363 -0
  64. package/src/media/paths.ts +168 -0
  65. package/src/media/process.ts +226 -0
  66. package/src/media/registry.ts +9 -0
  67. package/src/media/sidecar.ts +72 -0
  68. package/src/media/stemdeck.ts +254 -0
  69. package/src/media/stems.ts +173 -0
  70. package/src/media/tools.ts +292 -0
  71. package/src/media/types.ts +92 -0
  72. package/src/media/vendor/basic-pitch.ts +261 -0
  73. package/src/media/vendor/drums.ts +817 -0
  74. package/src/media/vendor/grid.ts +203 -0
  75. package/src/media/vendor/util.ts +139 -0
  76. package/src/media/vendor/wav.ts +233 -0
  77. package/src/project/check.ts +80 -0
  78. package/src/project/init.ts +253 -0
  79. package/src/project/sync.ts +432 -0
  80. package/src/project/typecheck.ts +149 -0
  81. package/src/render.ts +121 -0
  82. package/src/session/attach.ts +181 -0
  83. package/src/session/client.ts +498 -0
  84. package/src/session/daemon.ts +740 -0
  85. package/src/session/delta.ts +249 -0
  86. package/src/session/list.ts +180 -0
  87. package/src/session/lock.ts +92 -0
  88. package/src/session/meta.ts +253 -0
  89. package/src/session/naming.ts +430 -0
  90. package/src/session/port.ts +481 -0
  91. package/src/session/presence.ts +159 -0
  92. package/src/session/protocol.ts +618 -0
  93. package/src/session/rebase.ts +168 -0
  94. package/src/session/store.ts +581 -0
  95. package/src/tui/menu.ts +1083 -0
  96. package/src/tui/play-mode.ts +442 -0
  97. package/src/tui/play-session.ts +636 -0
  98. package/src/web/fetch.ts +340 -0
  99. package/src/web/http.ts +137 -0
  100. package/src/web/search.ts +681 -0
  101. package/tui/activity.ts +364 -0
  102. package/tui/app.ts +1372 -0
  103. package/tui/drums.ts +65 -0
  104. package/tui/highway.ts +921 -0
  105. package/tui/input.ts +63 -0
  106. package/tui/keys.ts +102 -0
  107. package/tui/layers.ts +80 -0
  108. package/tui/play-strip.ts +143 -0
  109. package/tui/prompt.ts +609 -0
  110. package/tui/render.ts +124 -0
  111. package/tui/screen.ts +247 -0
  112. package/tui/text.ts +72 -0
  113. package/tui/theme.ts +451 -0
@@ -0,0 +1,160 @@
1
+ import type { TrackScore } from "../../core/score.ts";
2
+ import { AVAILABLE_EFFECTS, AVAILABLE_INSTRUMENTS } from "../audio/wav.ts";
3
+ import type { ProjectOutline } from "./workspace.ts";
4
+
5
+ export const MAX_BRIEF_BYTES = 12 * 1024;
6
+ const MAX_FOCUSED_NOTES = 96;
7
+ const MAX_RECENT = 8;
8
+ const MAX_RECENT_CHARS = 120;
9
+ const NOTE_NAMES = [
10
+ "C",
11
+ "C#",
12
+ "D",
13
+ "D#",
14
+ "E",
15
+ "F",
16
+ "F#",
17
+ "G",
18
+ "G#",
19
+ "A",
20
+ "A#",
21
+ "B",
22
+ ];
23
+
24
+ /**
25
+ * A compact, deterministic snapshot of the composition for the model. It is
26
+ * derived only from score state and caller-supplied operation summaries, so
27
+ * the same score, revision, and history always yield the same bytes, and no
28
+ * environment value (including credentials) can reach it.
29
+ */
30
+ export function compositionBrief(options: {
31
+ score: TrackScore;
32
+ revision: number;
33
+ focusedTrackId: string;
34
+ recentOperations?: readonly string[];
35
+ /** Bounded project tree and notes head from `projectOutline`; dropped first under pressure. */
36
+ project?: ProjectOutline;
37
+ maxBytes?: number;
38
+ }): string {
39
+ const { score } = options;
40
+ const maxBytes = options.maxBytes ?? MAX_BRIEF_BYTES;
41
+ const tpb = score.ticksPerBeat;
42
+ const beats = (ticks: number) => Math.round((ticks / tpb) * 1000) / 1000;
43
+ const tracks = score.tracks.map((track) => {
44
+ const notes = score.notes.filter((note) => note.trackId === track.id);
45
+ const pitches = notes.map((note) => note.pitch);
46
+ return {
47
+ id: track.id,
48
+ name: track.name,
49
+ instrument: track.instrument,
50
+ ...(track.muted ? { muted: true } : {}),
51
+ volume: track.volume,
52
+ pan: track.pan,
53
+ notes: notes.length,
54
+ ...(notes.length > 0
55
+ ? {
56
+ range: `${noteName(Math.min(...pitches))}..${noteName(Math.max(...pitches))}`,
57
+ }
58
+ : {}),
59
+ ...(track.volumeAutomation.length > 0
60
+ ? { volumeAutomation: track.volumeAutomation.length }
61
+ : {}),
62
+ ...(track.panAutomation.length > 0
63
+ ? { panAutomation: track.panAutomation.length }
64
+ : {}),
65
+ ...(track.solo ? { solo: true } : {}),
66
+ ...(track.filter ? { filter: track.filter } : {}),
67
+ ...(track.delay ? { delay: track.delay } : {}),
68
+ ...((track.filterAutomation?.length ?? 0) > 0
69
+ ? { filterAutomation: track.filterAutomation!.length }
70
+ : {}),
71
+ ...(track.reverb ? { reverb: track.reverb } : {}),
72
+ ...((track.resonanceAutomation?.length ?? 0) > 0
73
+ ? { resonanceAutomation: track.resonanceAutomation!.length }
74
+ : {}),
75
+ ...((track.delayFeedbackAutomation?.length ?? 0) > 0
76
+ ? { delayFeedbackAutomation: track.delayFeedbackAutomation!.length }
77
+ : {}),
78
+ ...((track.delayMixAutomation?.length ?? 0) > 0
79
+ ? { delayMixAutomation: track.delayMixAutomation!.length }
80
+ : {}),
81
+ };
82
+ });
83
+ const focusedNotes = score.notes
84
+ .filter((note) => note.trackId === options.focusedTrackId)
85
+ .slice()
86
+ .sort(
87
+ (left, right) =>
88
+ left.startTick - right.startTick ||
89
+ left.pitch - right.pitch ||
90
+ left.id.localeCompare(right.id),
91
+ )
92
+ .map((note) => [
93
+ note.id,
94
+ noteName(note.pitch),
95
+ beats(note.startTick),
96
+ beats(note.durationTicks),
97
+ Math.round(note.velocity * 100) / 100,
98
+ ]);
99
+ const recent = (options.recentOperations ?? [])
100
+ .slice(-MAX_RECENT)
101
+ .map((line) => line.replace(/\s+/g, " ").slice(0, MAX_RECENT_CHARS));
102
+ const project = options.project;
103
+ const build = (
104
+ noteLimit: number,
105
+ trackLimit: number,
106
+ projectLevel: number,
107
+ ) => {
108
+ const visible = focusedNotes.slice(0, noteLimit);
109
+ return JSON.stringify({
110
+ revision: options.revision,
111
+ tempoBpm: score.tempoBpm,
112
+ meter: `${score.beatsPerBar}/4`,
113
+ bars: score.bars,
114
+ loopBeats: score.bars * score.beatsPerBar,
115
+ ...(score.key ? { key: score.key } : {}),
116
+ focusedTrack: options.focusedTrackId,
117
+ tracks: tracks.slice(0, trackLimit),
118
+ ...(tracks.length > trackLimit
119
+ ? { omittedTracks: tracks.length - trackLimit }
120
+ : {}),
121
+ focusedNotes: {
122
+ columns: ["id", "pitch", "startBeat", "durationBeats", "velocity"],
123
+ rows: visible,
124
+ ...(focusedNotes.length > visible.length
125
+ ? { omitted: focusedNotes.length - visible.length }
126
+ : {}),
127
+ },
128
+ recentOperations: recent,
129
+ instruments: AVAILABLE_INSTRUMENTS,
130
+ effects: AVAILABLE_EFFECTS,
131
+ ...(project && projectLevel > 0 && project.tree.length > 0
132
+ ? {
133
+ project: {
134
+ tree: project.tree,
135
+ ...(project.notes !== undefined && projectLevel > 1
136
+ ? { notes: project.notes }
137
+ : {}),
138
+ },
139
+ }
140
+ : {}),
141
+ });
142
+ };
143
+ const encoder = new TextEncoder();
144
+ let noteLimit = Math.min(MAX_FOCUSED_NOTES, focusedNotes.length);
145
+ let trackLimit = tracks.length;
146
+ let projectLevel = 2;
147
+ let brief = build(noteLimit, trackLimit, projectLevel);
148
+ while (encoder.encode(brief).byteLength > maxBytes) {
149
+ if (noteLimit > 0) noteLimit = Math.floor(noteLimit / 2);
150
+ else if (projectLevel > 0) projectLevel -= 1;
151
+ else if (trackLimit > 1) trackLimit = Math.floor(trackLimit / 2);
152
+ else break;
153
+ brief = build(noteLimit, trackLimit, projectLevel);
154
+ }
155
+ return brief;
156
+ }
157
+
158
+ export function noteName(midi: number): string {
159
+ return `${NOTE_NAMES[midi % 12]}${Math.floor(midi / 12) - 1}`;
160
+ }
@@ -0,0 +1,441 @@
1
+ import { readSseData } from "./sse.ts";
2
+
3
+ /**
4
+ * The two original model aliases. A model is either one of these or an exact
5
+ * `vendor/model` ID chosen from the model picker (see `models.ts`).
6
+ */
7
+ export const GATEWAY_MODELS = Object.freeze(["opus-5.5", "sol-6.1"] as const);
8
+ export type GatewayModel = (typeof GATEWAY_MODELS)[number];
9
+
10
+ /**
11
+ * Default provider IDs, confirmed against the AI Gateway catalog
12
+ * (`GET https://ai-gateway.vercel.sh/v1/models`, 2026-10-06): both are
13
+ * language models tagged `tool-use`.
14
+ */
15
+ export const DEFAULT_MODEL_IDS: Readonly<Record<GatewayModel, string>> =
16
+ Object.freeze({
17
+ "opus-5.5": "anthropic/claude-opus-5.5",
18
+ "sol-6.1": "openai/gpt-6.1-sol",
19
+ });
20
+
21
+ const MODEL_ID_PATTERN =
22
+ /^[a-z0-9][a-z0-9-]{0,63}\/[a-z0-9][a-z0-9._-]{0,127}$/i;
23
+ const MAX_ERROR_BODY_BYTES = 4 * 1024;
24
+
25
+ export function isGatewayModel(value: unknown): value is GatewayModel {
26
+ return (
27
+ typeof value === "string" &&
28
+ (GATEWAY_MODELS as readonly string[]).includes(value)
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Resolve an alias (`opus-5.5`) or an exact `vendor/model` ID to the ID sent
34
+ * to the provider, rejecting anything else.
35
+ */
36
+ export function resolveModelId(
37
+ alias: string,
38
+ overrides: Partial<Record<GatewayModel, string | undefined>> = {},
39
+ ): string {
40
+ if (!isGatewayModel(alias)) {
41
+ if (MODEL_ID_PATTERN.test(alias)) return alias;
42
+ throw new Error(
43
+ `unknown model "${alias.slice(0, 32)}"; use ${GATEWAY_MODELS.join(" or ")} or a vendor/model ID`,
44
+ );
45
+ }
46
+ const id = overrides[alias] ?? DEFAULT_MODEL_IDS[alias];
47
+ if (!MODEL_ID_PATTERN.test(id))
48
+ throw new Error(`model ID for ${alias} must look like provider/model`);
49
+ return id;
50
+ }
51
+
52
+ function checkedModelId(id: string): string {
53
+ if (!MODEL_ID_PATTERN.test(id))
54
+ throw new Error("model ID must look like provider/model");
55
+ return id;
56
+ }
57
+
58
+ export type ChatToolCall = {
59
+ id: string;
60
+ type: "function";
61
+ function: { name: string; arguments: string };
62
+ };
63
+
64
+ export type ChatMessage =
65
+ | { role: "system" | "user"; content: string }
66
+ | { role: "assistant"; content: string | null; tool_calls?: ChatToolCall[] }
67
+ | { role: "tool"; tool_call_id: string; content: string };
68
+
69
+ export type ChatTool = {
70
+ type: "function";
71
+ function: {
72
+ name: string;
73
+ description: string;
74
+ parameters: Record<string, unknown>;
75
+ };
76
+ };
77
+
78
+ export type ChatStreamRequest = {
79
+ /** An alias (`opus-5.5`) or an exact `vendor/model` ID. */
80
+ model: string;
81
+ messages: readonly ChatMessage[];
82
+ tools?: readonly ChatTool[];
83
+ temperature?: number;
84
+ /** Exact provider model ID, bypassing the alias (e.g. a small model for naming). */
85
+ modelId?: string;
86
+ maxTokens?: number;
87
+ maxResponseBytes: number;
88
+ };
89
+
90
+ /** Normalized stream events; provider chunk shapes never leave this module. */
91
+ export type ChatStreamEvent =
92
+ | { type: "text"; delta: string }
93
+ /** Progress worth a status line, such as a retry; never model output. */
94
+ | { type: "activity"; message: string }
95
+ | {
96
+ type: "tool-delta";
97
+ index: number;
98
+ id?: string;
99
+ name?: string;
100
+ arguments?: string;
101
+ }
102
+ | { type: "finish"; reason: string }
103
+ /**
104
+ * Token usage for one request, from the final chunk when the request asked
105
+ * for `stream_options.include_usage`. `costUsd` is the provider's own
106
+ * charge when it reports one (OpenRouter's `usage.cost`).
107
+ */
108
+ | {
109
+ type: "usage";
110
+ inputTokens: number;
111
+ outputTokens: number;
112
+ cachedInputTokens?: number;
113
+ costUsd?: number;
114
+ };
115
+
116
+ export type GatewayClient = {
117
+ /** Which service this client talks to. */
118
+ readonly provider?: ApiProvider;
119
+ /** The provider model ID that a turn will use (for diagnostics only). */
120
+ modelId(model: string): string;
121
+ stream(
122
+ request: ChatStreamRequest,
123
+ signal?: AbortSignal,
124
+ ): AsyncIterable<ChatStreamEvent>;
125
+ };
126
+
127
+ export class GatewayError extends Error {
128
+ constructor(
129
+ message: string,
130
+ readonly status?: number,
131
+ ) {
132
+ super(message);
133
+ this.name = "GatewayError";
134
+ }
135
+ }
136
+
137
+ type GatewayFetcher = (
138
+ input: RequestInfo | URL,
139
+ init?: RequestInit,
140
+ ) => Promise<Response>;
141
+
142
+ /** Retries after a failed request that has not yet produced a byte. */
143
+ export const GATEWAY_RETRIES = 2;
144
+ /** Base backoff; each retry doubles it, with +-50% jitter. */
145
+ const RETRY_BASE_MS = 500;
146
+ /** Response headers must arrive within this long, per attempt. */
147
+ export const GATEWAY_HEADER_TIMEOUT_MS = 15_000;
148
+
149
+ export type GatewayRetryOptions = Readonly<{
150
+ retries?: number;
151
+ baseMs?: number;
152
+ sleep?: (ms: number) => Promise<void>;
153
+ random?: () => number;
154
+ }>;
155
+
156
+ function retryableStatus(status: number): boolean {
157
+ return status === 408 || status === 429 || status >= 500;
158
+ }
159
+
160
+ /** The two OpenAI-compatible, key-based services. */
161
+ export type ApiProvider = "gateway" | "openrouter";
162
+ export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
163
+ export const GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1";
164
+ /** OpenRouter's app attribution headers (https://openrouter.ai/docs/app-attribution). */
165
+ const OPENROUTER_HEADERS = Object.freeze({
166
+ "http-referer": "https://dawg.sh",
167
+ "x-title": "dawg",
168
+ });
169
+
170
+ export type ApiClientOptions = {
171
+ apiKey?: string;
172
+ baseUrl?: string;
173
+ modelIds?: Partial<Record<GatewayModel, string>>;
174
+ fetcher?: GatewayFetcher;
175
+ retry?: GatewayRetryOptions;
176
+ headerTimeoutMs?: number;
177
+ };
178
+
179
+ /** The streaming tool-calling client for OpenRouter's OpenAI-compatible API. */
180
+ export function createOpenRouterClient(
181
+ options: ApiClientOptions = {},
182
+ ): GatewayClient {
183
+ return createApiClient("openrouter", {
184
+ ...options,
185
+ baseUrl:
186
+ options.baseUrl ?? process.env.OPENROUTER_BASE_URL ?? OPENROUTER_BASE_URL,
187
+ apiKey: options.apiKey ?? process.env.OPENROUTER_API_KEY,
188
+ });
189
+ }
190
+
191
+ export function createGatewayClient(
192
+ options: ApiClientOptions = {},
193
+ ): GatewayClient {
194
+ return createApiClient("gateway", {
195
+ ...options,
196
+ baseUrl:
197
+ options.baseUrl ?? process.env.AI_GATEWAY_BASE_URL ?? GATEWAY_BASE_URL,
198
+ apiKey: options.apiKey ?? process.env.AI_GATEWAY_API_KEY,
199
+ });
200
+ }
201
+
202
+ function createApiClient(
203
+ provider: ApiProvider,
204
+ options: ApiClientOptions,
205
+ ): GatewayClient {
206
+ const fetcher = options.fetcher ?? fetch;
207
+ const name = provider === "openrouter" ? "OpenRouter" : "AI Gateway";
208
+ const baseUrl = (options.baseUrl ?? GATEWAY_BASE_URL).replace(/\/$/, "");
209
+ const apiKey = options.apiKey;
210
+ const retries = options.retry?.retries ?? GATEWAY_RETRIES;
211
+ const retryBaseMs = options.retry?.baseMs ?? RETRY_BASE_MS;
212
+ const sleep = options.retry?.sleep ?? ((ms: number) => Bun.sleep(ms));
213
+ const random = options.retry?.random ?? Math.random;
214
+ const headerTimeoutMs = options.headerTimeoutMs ?? GATEWAY_HEADER_TIMEOUT_MS;
215
+ const overrides: Partial<Record<GatewayModel, string | undefined>> = {
216
+ "opus-5.5": process.env.DAWG_OPUS_MODEL || undefined,
217
+ "sol-6.1": process.env.DAWG_SOL_MODEL || undefined,
218
+ ...options.modelIds,
219
+ };
220
+ const redact = (text: string): string =>
221
+ apiKey && apiKey.length > 0 ? text.split(apiKey).join("[redacted]") : text;
222
+
223
+ return {
224
+ provider,
225
+ modelId: (model) => resolveModelId(model, overrides),
226
+ async *stream(request, signal) {
227
+ const model =
228
+ request.modelId !== undefined
229
+ ? checkedModelId(request.modelId)
230
+ : resolveModelId(request.model, overrides);
231
+ if (!apiKey)
232
+ throw new GatewayError(
233
+ provider === "openrouter"
234
+ ? "no OpenRouter key; run `dawg login openrouter` or set OPENROUTER_API_KEY"
235
+ : "no AI Gateway key; run `dawg login` or set AI_GATEWAY_API_KEY",
236
+ );
237
+ const body: Record<string, unknown> = {
238
+ model,
239
+ messages: request.messages,
240
+ stream: true,
241
+ // The final chunk then carries token usage (and OpenRouter's cost).
242
+ stream_options: { include_usage: true },
243
+ };
244
+ if (provider === "openrouter") body.usage = { include: true };
245
+ if (request.tools && request.tools.length > 0) {
246
+ body.tools = request.tools;
247
+ body.tool_choice = "auto";
248
+ }
249
+ if (request.temperature !== undefined)
250
+ body.temperature = request.temperature;
251
+ if (
252
+ request.maxTokens !== undefined &&
253
+ Number.isInteger(request.maxTokens) &&
254
+ request.maxTokens > 0
255
+ )
256
+ body.max_tokens = Math.min(request.maxTokens, 4096);
257
+ const encoded = JSON.stringify(body);
258
+ // Retry only before any byte of a response has been consumed: a
259
+ // network failure, a header-phase timeout, or 408/429/5xx. Once the
260
+ // stream is flowing, a failure surfaces as-is (replaying could repeat
261
+ // tool calls the model already made).
262
+ let response: Response | undefined;
263
+ for (let attempt = 0; ; attempt += 1) {
264
+ if (signal?.aborted) throw abortError(signal);
265
+ const headerSignal = AbortSignal.any([
266
+ ...(signal === undefined ? [] : [signal]),
267
+ AbortSignal.timeout(headerTimeoutMs),
268
+ ]);
269
+ const init: RequestInit = {
270
+ method: "POST",
271
+ headers: {
272
+ authorization: `Bearer ${apiKey}`,
273
+ "content-type": "application/json",
274
+ accept: "text/event-stream",
275
+ ...(provider === "openrouter" ? OPENROUTER_HEADERS : {}),
276
+ },
277
+ body: encoded,
278
+ signal: headerSignal,
279
+ };
280
+ let reason: string;
281
+ try {
282
+ const candidate = await fetcher(`${baseUrl}/chat/completions`, init);
283
+ if (candidate.ok) {
284
+ response = candidate;
285
+ break;
286
+ }
287
+ const detail = redact(await boundedErrorDetail(candidate));
288
+ const error = new GatewayError(
289
+ `${name} request failed (${candidate.status})${detail ? `: ${detail}` : ""}`,
290
+ candidate.status,
291
+ );
292
+ if (!retryableStatus(candidate.status) || attempt >= retries)
293
+ throw error;
294
+ reason = String(candidate.status);
295
+ } catch (error) {
296
+ if (error instanceof GatewayError) throw error;
297
+ if (signal?.aborted) throw abortError(signal);
298
+ if (attempt >= retries)
299
+ throw new GatewayError(
300
+ headerSignal.aborted
301
+ ? `${name} did not respond within ${headerTimeoutMs} ms`
302
+ : `${name} request failed: ${redact(error instanceof Error ? error.message : String(error))}`,
303
+ );
304
+ reason = headerSignal.aborted ? "timeout" : "network";
305
+ }
306
+ yield { type: "activity", message: `retrying (${reason})…` };
307
+ const backoff = retryBaseMs * 2 ** attempt;
308
+ await sleep(Math.round(backoff * (0.5 + random())));
309
+ }
310
+ if (!response.body)
311
+ throw new GatewayError(`${name} returned an empty stream`);
312
+ const streamOptions: { maxBytes: number; signal?: AbortSignal } = {
313
+ maxBytes: request.maxResponseBytes,
314
+ };
315
+ if (signal !== undefined) streamOptions.signal = signal;
316
+ for await (const data of readSseData(response.body, streamOptions)) {
317
+ let chunk: unknown;
318
+ try {
319
+ chunk = JSON.parse(data);
320
+ } catch {
321
+ throw new GatewayError(`${name} sent a malformed stream chunk`);
322
+ }
323
+ yield* normalizeChunk(chunk, redact, name);
324
+ }
325
+ },
326
+ };
327
+ }
328
+
329
+ function* normalizeChunk(
330
+ chunk: unknown,
331
+ redact: (text: string) => string,
332
+ name = "AI Gateway",
333
+ ): Generator<ChatStreamEvent> {
334
+ if (!isRecord(chunk)) return;
335
+ if (isRecord(chunk.error)) {
336
+ const message =
337
+ typeof chunk.error.message === "string"
338
+ ? chunk.error.message.slice(0, 300)
339
+ : "unknown provider error";
340
+ throw new GatewayError(`${name} stream error: ${redact(message)}`);
341
+ }
342
+ const usage = parseUsage(chunk.usage);
343
+ if (usage) yield usage;
344
+ const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
345
+ if (!isRecord(choice)) return;
346
+ const delta = isRecord(choice.delta) ? choice.delta : undefined;
347
+ if (delta && typeof delta.content === "string" && delta.content.length > 0)
348
+ yield { type: "text", delta: delta.content };
349
+ if (delta && Array.isArray(delta.tool_calls)) {
350
+ for (const [position, call] of delta.tool_calls.entries()) {
351
+ if (!isRecord(call)) continue;
352
+ const event: ChatStreamEvent = {
353
+ type: "tool-delta",
354
+ index:
355
+ typeof call.index === "number" && Number.isInteger(call.index)
356
+ ? call.index
357
+ : position,
358
+ };
359
+ if (typeof call.id === "string") event.id = call.id;
360
+ const fn = isRecord(call.function) ? call.function : undefined;
361
+ if (fn && typeof fn.name === "string" && fn.name.length > 0)
362
+ event.name = fn.name;
363
+ if (fn && typeof fn.arguments === "string")
364
+ event.arguments = fn.arguments;
365
+ yield event;
366
+ }
367
+ }
368
+ if (typeof choice.finish_reason === "string")
369
+ yield { type: "finish", reason: choice.finish_reason };
370
+ }
371
+
372
+ /** Bounded, non-negative token counts; anything else is ignored. */
373
+ function count(value: unknown): number | undefined {
374
+ return typeof value === "number" &&
375
+ Number.isFinite(value) &&
376
+ value >= 0 &&
377
+ value < 1e9
378
+ ? Math.floor(value)
379
+ : undefined;
380
+ }
381
+
382
+ export function parseUsage(
383
+ value: unknown,
384
+ ): Extract<ChatStreamEvent, { type: "usage" }> | undefined {
385
+ if (!isRecord(value)) return undefined;
386
+ const inputTokens = count(value.prompt_tokens);
387
+ const outputTokens = count(value.completion_tokens);
388
+ if (inputTokens === undefined || outputTokens === undefined) return undefined;
389
+ const event: Extract<ChatStreamEvent, { type: "usage" }> = {
390
+ type: "usage",
391
+ inputTokens,
392
+ outputTokens,
393
+ };
394
+ const details = isRecord(value.prompt_tokens_details)
395
+ ? value.prompt_tokens_details
396
+ : undefined;
397
+ const cached = count(details?.cached_tokens);
398
+ if (cached !== undefined) event.cachedInputTokens = cached;
399
+ const rawCost =
400
+ typeof value.cost === "string" ? Number(value.cost) : value.cost;
401
+ if (
402
+ typeof rawCost === "number" &&
403
+ Number.isFinite(rawCost) &&
404
+ rawCost >= 0 &&
405
+ rawCost < 1000
406
+ )
407
+ event.costUsd = rawCost;
408
+ return event;
409
+ }
410
+
411
+ function abortError(signal: AbortSignal): Error {
412
+ const reason: unknown = signal.reason;
413
+ if (reason instanceof Error) return reason;
414
+ const error = new Error("agent request was aborted");
415
+ error.name = "AbortError";
416
+ return error;
417
+ }
418
+
419
+ async function boundedErrorDetail(response: Response): Promise<string> {
420
+ try {
421
+ const text = (await response.text()).slice(0, MAX_ERROR_BODY_BYTES);
422
+ try {
423
+ const parsed: unknown = JSON.parse(text);
424
+ if (
425
+ isRecord(parsed) &&
426
+ isRecord(parsed.error) &&
427
+ typeof parsed.error.message === "string"
428
+ )
429
+ return parsed.error.message.slice(0, 300);
430
+ } catch {
431
+ // Plain-text error bodies are summarized below.
432
+ }
433
+ return text.replace(/\s+/g, " ").trim().slice(0, 300);
434
+ } catch {
435
+ return "";
436
+ }
437
+ }
438
+
439
+ function isRecord(value: unknown): value is Record<string, unknown> {
440
+ return typeof value === "object" && value !== null && !Array.isArray(value);
441
+ }