@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.
- package/CHANGELOG.md +126 -0
- package/DAWG.md +327 -0
- package/LICENSE +21 -0
- package/README.md +213 -2
- package/core/diff.ts +249 -0
- package/core/drums.ts +102 -0
- package/core/key.ts +43 -0
- package/core/loop.ts +78 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +1388 -0
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +45 -4
- package/src/agent/agent.ts +853 -0
- package/src/agent/brief.ts +160 -0
- package/src/agent/gateway.ts +441 -0
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +157 -0
- package/src/agent/planner.ts +259 -0
- package/src/agent/provider.ts +454 -0
- package/src/agent/sse.ts +114 -0
- package/src/agent/tools.ts +1373 -0
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +262 -0
- package/src/agent/xcb.ts +579 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +841 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +134 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +861 -0
- package/src/auth/cli.ts +231 -0
- package/src/auth/credentials.ts +411 -0
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +1191 -0
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +207 -0
- package/src/auth/tui.ts +107 -0
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +69 -0
- package/src/commands/music.ts +461 -0
- package/src/commands/sample.ts +302 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +2209 -0
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +121 -0
- package/src/session/attach.ts +181 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +740 -0
- package/src/session/delta.ts +249 -0
- package/src/session/list.ts +180 -0
- package/src/session/lock.ts +92 -0
- package/src/session/meta.ts +253 -0
- package/src/session/naming.ts +430 -0
- package/src/session/port.ts +481 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +168 -0
- package/src/session/store.ts +581 -0
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +364 -0
- package/tui/app.ts +1372 -0
- package/tui/drums.ts +65 -0
- package/tui/highway.ts +921 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +80 -0
- package/tui/play-strip.ts +143 -0
- package/tui/prompt.ts +609 -0
- package/tui/render.ts +124 -0
- package/tui/screen.ts +247 -0
- package/tui/text.ts +72 -0
- 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
|
+
}
|