@modelworks/quack 0.0.0-stage → 0.2.9

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.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Project-level `.quack/` directory management: init, config/state read/write.
3
+ *
4
+ * @module
5
+ */
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ /** The `.quack/` directory name, placed at the project root. */
9
+ export const QUACK_DIR = ".quack";
10
+ /** Path to the `.quack/` directory in the current working directory. */
11
+ export function quackDir() {
12
+ return path.join(process.cwd(), QUACK_DIR);
13
+ }
14
+ /** Path to `.quack/config.json`. */
15
+ export function configPath() {
16
+ return path.join(quackDir(), "config.json");
17
+ }
18
+ /** Path to `.quack/state.json`. */
19
+ export function statePath() {
20
+ return path.join(quackDir(), "state.json");
21
+ }
22
+ /** Does a `.quack/` directory exist in the current project? */
23
+ export function isQuackProject() {
24
+ return fs.existsSync(quackDir()) && fs.existsSync(configPath());
25
+ }
26
+ /** Assert the current directory is a quack project; throw a friendly error if not. */
27
+ export function requireQuackProject() {
28
+ if (!isQuackProject()) {
29
+ throw new Error("Not a quack project. Run `quack init` first.");
30
+ }
31
+ }
32
+ /** Empty config template. `default_agent` defaults to `opencode`. */
33
+ function emptyConfig() {
34
+ return { version: 1, default_agent: "opencode", circuits: [] };
35
+ }
36
+ /** Empty state template. */
37
+ function emptyState() {
38
+ return { circuits: {} };
39
+ }
40
+ /** `.gitignore` contents for `.quack/` — ignores `state.json` (runs/ is committed). */
41
+ const GITIGNORE_CONTENTS = "state.json\ndebug.log\n";
42
+ /**
43
+ * `quack init` — create the `.quack/` directory with empty config,
44
+ * state, and `.gitignore`.
45
+ *
46
+ * Errors if `.quack/` already exists, unless `force` is `true`.
47
+ */
48
+ export function initProject(force = false) {
49
+ const dir = quackDir();
50
+ if (fs.existsSync(dir)) {
51
+ if (!force) {
52
+ throw new Error("Already a quack project. Use `--force` to overwrite.");
53
+ }
54
+ fs.rmSync(dir, { recursive: true });
55
+ }
56
+ fs.mkdirSync(dir, { recursive: true });
57
+ fs.mkdirSync(path.join(dir, "runs"), { recursive: true });
58
+ fs.writeFileSync(configPath(), JSON.stringify(emptyConfig(), null, 2) + "\n");
59
+ fs.writeFileSync(statePath(), JSON.stringify(emptyState(), null, 2) + "\n");
60
+ fs.writeFileSync(path.join(dir, ".gitignore"), GITIGNORE_CONTENTS);
61
+ console.log(`Initialized quack project in ${dir}`);
62
+ }
63
+ // ---------------------------------------------------------------------------
64
+ // Config read/write (`.quack/config.json` — committable)
65
+ // ---------------------------------------------------------------------------
66
+ /** Read the project config. Throws if `.quack/` doesn't exist. */
67
+ export function readConfig() {
68
+ requireQuackProject();
69
+ const raw = fs.readFileSync(configPath(), "utf8");
70
+ return JSON.parse(raw);
71
+ }
72
+ /** Write the project config (atomic-ish: write then sync). */
73
+ export function writeConfig(config) {
74
+ requireQuackProject();
75
+ fs.writeFileSync(configPath(), JSON.stringify(config, null, 2) + "\n");
76
+ }
77
+ // ---------------------------------------------------------------------------
78
+ // State read/write (`.quack/state.json` — gitignored, per-user)
79
+ // ---------------------------------------------------------------------------
80
+ /**
81
+ * Read the project state.
82
+ *
83
+ * Returns an empty state if `.quack/` doesn't exist or `state.json`
84
+ * is missing (e.g. a freshly cloned repo where only `config.json`
85
+ * was committed). The caller is responsible for auto-syncing state
86
+ * from config when needed.
87
+ */
88
+ export function readState() {
89
+ if (!isQuackProject()) {
90
+ return emptyState();
91
+ }
92
+ try {
93
+ const raw = fs.readFileSync(statePath(), "utf8");
94
+ return JSON.parse(raw);
95
+ }
96
+ catch {
97
+ return emptyState();
98
+ }
99
+ }
100
+ /** Write the project state. */
101
+ export function writeState(state) {
102
+ requireQuackProject();
103
+ fs.writeFileSync(statePath(), JSON.stringify(state, null, 2) + "\n");
104
+ }
105
+ // ---------------------------------------------------------------------------
106
+ // Convenience operations
107
+ // ---------------------------------------------------------------------------
108
+ /** Add a circuit entry to the config (no-op if already present). Returns `true` if added. */
109
+ export function addCircuitToConfig(entry) {
110
+ const config = readConfig();
111
+ if (config.circuits.some((c) => c.circuit_uid === entry.circuit_uid)) {
112
+ return false;
113
+ }
114
+ config.circuits.push(entry);
115
+ writeConfig(config);
116
+ return true;
117
+ }
118
+ /** Remove a circuit from config by `circuit_uid`. Returns `true` if removed. */
119
+ export function removeCircuitFromConfig(circuitUid) {
120
+ const config = readConfig();
121
+ const before = config.circuits.length;
122
+ config.circuits = config.circuits.filter((c) => c.circuit_uid !== circuitUid);
123
+ if (config.circuits.length === before)
124
+ return false;
125
+ writeConfig(config);
126
+ return true;
127
+ }
128
+ /** Upsert a circuit's state entry (keyed by `circuit_uid`). */
129
+ export function upsertCircuitState(entry) {
130
+ const state = readState();
131
+ state.circuits[entry.circuit_uid] = entry;
132
+ writeState(state);
133
+ }
134
+ /** Remove a circuit's state entry. Returns `true` if removed. */
135
+ export function removeCircuitState(circuitUid) {
136
+ const state = readState();
137
+ if (!(circuitUid in state.circuits))
138
+ return false;
139
+ delete state.circuits[circuitUid];
140
+ writeState(state);
141
+ return true;
142
+ }
143
+ /** Get a circuit's state entry, or `null` if not present. */
144
+ export function getCircuitState(circuitUid) {
145
+ const state = readState();
146
+ return state.circuits[circuitUid] ?? null;
147
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Local HTTP reverse proxy.
3
+ *
4
+ * Forwards every request to the same path on `apiBase`, injecting
5
+ * `Authorization: Bearer <token>` and streaming the response body.
6
+ * On 401, refreshes the access token and retries once.
7
+ *
8
+ * @module
9
+ */
10
+ import type { ProxyHandle, ProxyOptions } from "./types.js";
11
+ /**
12
+ * Start a local HTTP reverse proxy on a random port (`127.0.0.1`).
13
+ *
14
+ * Resolves with a handle containing the assigned port and a `close()`
15
+ * method. The proxy lives for as long as quack runs; `launch.ts` closes
16
+ * it when the spawned opencode process exits.
17
+ */
18
+ export declare function startProxy(opts: ProxyOptions): Promise<ProxyHandle>;
package/dist/proxy.js ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Local HTTP reverse proxy.
3
+ *
4
+ * Forwards every request to the same path on `apiBase`, injecting
5
+ * `Authorization: Bearer <token>` and streaming the response body.
6
+ * On 401, refreshes the access token and retries once.
7
+ *
8
+ * @module
9
+ */
10
+ import http from "node:http";
11
+ /**
12
+ * Start a local HTTP reverse proxy on a random port (`127.0.0.1`).
13
+ *
14
+ * Resolves with a handle containing the assigned port and a `close()`
15
+ * method. The proxy lives for as long as quack runs; `launch.ts` closes
16
+ * it when the spawned opencode process exits.
17
+ */
18
+ export function startProxy(opts) {
19
+ return new Promise((resolve, reject) => {
20
+ const server = http.createServer(async (req, res) => {
21
+ const startTime = Date.now();
22
+ // Flush debug lines immediately so we see them even if the request hangs.
23
+ const debug = (line) => {
24
+ if (opts.onDebug)
25
+ opts.onDebug(line);
26
+ };
27
+ // TCP-level connection log — fires for EVERY request, even before
28
+ // HTTP parsing.
29
+ if (opts.onDebug) {
30
+ opts.onDebug(`[tcp] ${new Date().toISOString()} ${req.method} ${req.url} from ${req.socket.remoteAddress}:${req.socket.remotePort}`);
31
+ }
32
+ try {
33
+ const targetUrl = new URL(req.url ?? "/", opts.apiBase);
34
+ // Debug: log incoming request immediately (before body reading).
35
+ debug(`=== ${new Date().toISOString()} ===`);
36
+ debug(`${req.method} ${req.url}`);
37
+ debug(`→ forwarded to ${targetUrl.href}`);
38
+ debug(`content-length: ${req.headers["content-length"] ?? "(none)"}`);
39
+ debug(`transfer-encoding: ${req.headers["transfer-encoding"] ?? "(none)"}`);
40
+ // Read the full request body before forwarding (needed for 401 retry).
41
+ const chunks = [];
42
+ for await (const chunk of req)
43
+ chunks.push(chunk);
44
+ let body = chunks.length > 0 ? Buffer.concat(chunks) : null;
45
+ debug(`body read: ${body ? body.length + " bytes" : "(empty)"}`);
46
+ // Strip output token limits from circuit adaptor query requests.
47
+ //
48
+ // OpenCode's openai-compatible provider sends a low default output
49
+ // token limit (~100-128). Reasoning models like DeepSeek V4.1 Flash
50
+ // spend this budget on reasoning before producing content, resulting
51
+ // in empty responses. Stripping lets the circuit's own default_value
52
+ // (e.g. 128000) apply instead.
53
+ //
54
+ // Different SDKs use different field names for the same concept:
55
+ // - OpenAI Chat Completions API: max_tokens / max_completion_tokens
56
+ // - AI SDK: maxOutputTokens
57
+ const isAdaptorQuery = req.method === "POST" &&
58
+ /\/api\/circuit-adaptors\/[^/]+\/query/.test(req.url ?? "");
59
+ if (isAdaptorQuery && body) {
60
+ try {
61
+ const parsed = JSON.parse(body.toString("utf8"));
62
+ let stripped = false;
63
+ for (const key of [
64
+ "max_tokens",
65
+ "max_completion_tokens",
66
+ "max_output_tokens",
67
+ "maxOutputTokens",
68
+ ]) {
69
+ if (key in parsed) {
70
+ delete parsed[key];
71
+ stripped = true;
72
+ }
73
+ }
74
+ if (stripped) {
75
+ body = Buffer.from(JSON.stringify(parsed), "utf8");
76
+ debug(`stripped output token limit from request body`);
77
+ }
78
+ }
79
+ catch {
80
+ // Not JSON or malformed — forward as-is.
81
+ }
82
+ }
83
+ // Build forwarded headers — strip client auth + host, inject our own.
84
+ const headers = {};
85
+ for (const [key, value] of Object.entries(req.headers)) {
86
+ const lower = key.toLowerCase();
87
+ if (lower === "authorization" || lower === "host")
88
+ continue;
89
+ headers[key] = Array.isArray(value) ? value.join(", ") : value ?? "";
90
+ }
91
+ headers.authorization = `Bearer ${opts.getAccessToken()}`;
92
+ headers.host = targetUrl.host;
93
+ // Update content-length if we modified the body.
94
+ if (body !== null)
95
+ headers["content-length"] = String(body.length);
96
+ if (opts.onDebug) {
97
+ debug(`--- request headers (incoming) ---`);
98
+ for (const [k, v] of Object.entries(req.headers)) {
99
+ const val = Array.isArray(v) ? v.join(", ") : v ?? "";
100
+ // Mask the real token in the debug log.
101
+ const masked = k.toLowerCase() === "authorization"
102
+ ? `Bearer ${val.slice(7, 12)}...`
103
+ : val;
104
+ debug(` ${k}: ${masked}`);
105
+ }
106
+ debug(`--- request body ---`);
107
+ debug(body ? body.toString("utf8").slice(0, 2000) : "(empty)");
108
+ }
109
+ // Forward request.
110
+ let upstream = await fetch(targetUrl, {
111
+ method: req.method,
112
+ headers,
113
+ body: body ?? undefined,
114
+ });
115
+ // Token refresh on 401 — retry once.
116
+ if (upstream.status === 401) {
117
+ debug(`--- 401 received, refreshing token and retrying ---`);
118
+ const refreshed = await opts.refreshToken();
119
+ if (refreshed) {
120
+ headers.authorization = `Bearer ${opts.getAccessToken()}`;
121
+ upstream = await fetch(targetUrl, {
122
+ method: req.method,
123
+ headers,
124
+ body: body ?? undefined,
125
+ });
126
+ }
127
+ }
128
+ // Forward response status + headers.
129
+ res.statusCode = upstream.status;
130
+ for (const [key, value] of upstream.headers) {
131
+ // Rewrite content-type for circuit adaptor SSE responses.
132
+ // The API returns `application/stream+json` but OpenCode's
133
+ // openai-compatible provider expects `text/event-stream` to
134
+ // parse SSE chunks. Without this, the provider doesn't
135
+ // recognize the stream and the response appears empty.
136
+ if (key.toLowerCase() === "content-type" &&
137
+ isAdaptorQuery &&
138
+ value.includes("application/stream+json")) {
139
+ res.setHeader(key, "text/event-stream; charset=utf-8");
140
+ continue;
141
+ }
142
+ res.setHeader(key, value);
143
+ }
144
+ // Capture the circuit run UID for history saving.
145
+ const runUid = upstream.headers.get("x-circuit-run-uid");
146
+ const onRunComplete = opts.onRunComplete;
147
+ if (opts.onDebug) {
148
+ const elapsed = Date.now() - startTime;
149
+ debug(`--- response ${upstream.status} (${elapsed}ms) ---`);
150
+ debug(`--- response headers ---`);
151
+ for (const [k, v] of upstream.headers) {
152
+ debug(` ${k}: ${v}`);
153
+ }
154
+ }
155
+ // Stream the response body (SSE, NDJSON, JSON).
156
+ // Cast: `@types/node`'s `Readable.fromWeb` and the global
157
+ // `ReadableStream` from `fetch` have structurally incompatible
158
+ // `pipeThrough` types. Runtime is correct.
159
+ if (upstream.body) {
160
+ if (opts.onDebug) {
161
+ // Capture streamed body for debug logging.
162
+ let bodyBytes = 0;
163
+ const decoder = new TextDecoder();
164
+ let debugBody = "";
165
+ const reader = upstream.body.getReader();
166
+ const pump = async () => {
167
+ try {
168
+ for (;;) {
169
+ const { done, value } = await reader.read();
170
+ if (done)
171
+ break;
172
+ bodyBytes += value.length;
173
+ if (debugBody.length < 2000) {
174
+ debugBody += decoder.decode(value, { stream: true });
175
+ }
176
+ res.write(value);
177
+ }
178
+ res.end();
179
+ }
180
+ catch (err) {
181
+ debug(`--- stream error: ${err.message} ---`);
182
+ if (!res.writableEnded)
183
+ res.end();
184
+ }
185
+ debug(`--- response body (${bodyBytes} bytes) ---`);
186
+ debug(debugBody.slice(0, 2000) + (bodyBytes > 2000 ? "..." : ""));
187
+ const totalMs = Date.now() - startTime;
188
+ debug(`=== END ${req.method} ${req.url} ${upstream.status} (${totalMs}ms) ===\n`);
189
+ // Fire-and-forget: save run history after stream completes.
190
+ if (runUid && onRunComplete)
191
+ onRunComplete(runUid);
192
+ };
193
+ pump();
194
+ }
195
+ else {
196
+ const reader = upstream.body.getReader();
197
+ const pumpPlain = async () => {
198
+ try {
199
+ for (;;) {
200
+ const { done, value } = await reader.read();
201
+ if (done)
202
+ break;
203
+ res.write(value);
204
+ }
205
+ res.end();
206
+ }
207
+ catch {
208
+ if (!res.writableEnded)
209
+ res.end();
210
+ }
211
+ // Fire-and-forget: save run history after stream completes.
212
+ if (runUid && onRunComplete)
213
+ onRunComplete(runUid);
214
+ };
215
+ pumpPlain();
216
+ }
217
+ }
218
+ else {
219
+ res.end();
220
+ if (opts.onDebug) {
221
+ debug(`--- response body (empty) ---`);
222
+ const totalMs = Date.now() - startTime;
223
+ debug(`=== END ${req.method} ${req.url} ${upstream.status} (${totalMs}ms) ===\n`);
224
+ }
225
+ // Fire-and-forget: save run history even for empty-body responses.
226
+ if (runUid && onRunComplete)
227
+ onRunComplete(runUid);
228
+ }
229
+ opts.onLog?.(req.method ?? "?", targetUrl.pathname, upstream.status);
230
+ }
231
+ catch (err) {
232
+ debug(`--- proxy error: ${err.message} ---`);
233
+ if (!res.headersSent) {
234
+ res.statusCode = 502;
235
+ res.setHeader("content-type", "application/json");
236
+ res.end(JSON.stringify({
237
+ error: "proxy_error",
238
+ message: err.message,
239
+ }));
240
+ }
241
+ }
242
+ });
243
+ server.listen(0, "127.0.0.1", () => {
244
+ const addr = server.address();
245
+ if (!addr || typeof addr === "object" && !("port" in addr)) {
246
+ reject(new Error("Could not bind proxy port"));
247
+ return;
248
+ }
249
+ const port = addr.port;
250
+ resolve({ port, close: () => server.close() });
251
+ });
252
+ server.on("error", reject);
253
+ });
254
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Shared types for @modelworks/quack.
3
+ *
4
+ * @module
5
+ */
6
+ /**
7
+ * Stored OAuth credentials for the authenticated user.
8
+ *
9
+ * Persisted at `~/.config/quack/credentials.json` (chmod 0600).
10
+ * The `api_base` field is stored here so token refresh hits the
11
+ * right server (supports `QUACK_API_BASE` / `--api-base` overrides).
12
+ */
13
+ export interface Credentials {
14
+ access_token: string;
15
+ refresh_token: string;
16
+ /** Unix timestamp (seconds) when the access token expires. */
17
+ expires_at: number;
18
+ token_type: string;
19
+ user_uid: string;
20
+ /** Base URL of the ModelWorks API, e.g. `https://modelworks.ai`. */
21
+ api_base: string;
22
+ }
23
+ /**
24
+ * Dynamic Client Registration record.
25
+ *
26
+ * Persisted at `~/.config/quack/client.json`. Created once on first
27
+ * `quack login`, reused on subsequent logins.
28
+ */
29
+ export interface ClientRegistration {
30
+ client_id: string;
31
+ }
32
+ /**
33
+ * One entry in `.quack/config.json`'s `circuits` array.
34
+ *
35
+ * Committable and shareable across a team — contains only public
36
+ * metadata, no user-specific state.
37
+ */
38
+ export interface CircuitConfigEntry {
39
+ url: string;
40
+ circuit_uid: string;
41
+ name: string;
42
+ /** ISO 8601 timestamp when the circuit was added. */
43
+ added_at: string;
44
+ }
45
+ /**
46
+ * One entry in `.quack/state.json`'s `circuits` map, keyed by `circuit_uid`.
47
+ *
48
+ * Gitignored, per-user — a snapshot from `quack add` / `quack refresh`
49
+ * / `quack opencode` auto-sync. Holds everything `quack opencode`
50
+ * needs to build the opencode config without re-fetching.
51
+ */
52
+ export interface CircuitStateEntry {
53
+ circuit_uid: string;
54
+ /** The RUNNING version resolved at add/refresh time. */
55
+ version_uid: string;
56
+ circuit_name: string;
57
+ /** Owner's username (e.g. `alice`), for display as `<username>/<circuit_name>`. */
58
+ owner_username: string;
59
+ /**
60
+ * Adapter types the circuit supports, e.g.
61
+ * `["OPEN_AI_SSE", "MODELWORKS_HTTP", "MCP"]`.
62
+ *
63
+ * Discovered from the circuit version's `json_blob` `adapters`
64
+ * array when visible (public/owner), or from listing existing
65
+ * adaptors when `json_blob` is hidden (protected, non-owner).
66
+ */
67
+ adapters: string[];
68
+ /**
69
+ * UID (`ca-xxx`) of the temporal OpenAI SSE adaptor quack created
70
+ * for this circuit, or `null` if the circuit isn't SSE-compatible.
71
+ */
72
+ sse_adaptor_uid: string | null;
73
+ /**
74
+ * Map of adapter type → adaptor UID (`ca-xxx`) for each temporal
75
+ * adaptor quack created. Currently only `OPEN_AI_SSE` is created
76
+ * by quack, but existing adaptors discovered via the list endpoint
77
+ * are also stored here.
78
+ */
79
+ adaptor_uids: Record<string, string>;
80
+ /** ISO 8601 timestamp of the last resolution. */
81
+ resolved_at: string;
82
+ }
83
+ /**
84
+ * `.quack/config.json` — the project-level, committable config.
85
+ */
86
+ export interface QuackConfig {
87
+ version: number;
88
+ /**
89
+ * Agent to launch when the user runs bare `quack` (no args).
90
+ * Optional — if absent, the fallback chain (last_agent →
91
+ * interactive pick → error) resolves the agent.
92
+ */
93
+ default_agent?: string;
94
+ /**
95
+ * Circuit UID (`c-xxx`) to use as the default model in OpenCode.
96
+ * Team-shared — committed to `config.json`. Set via `quack circuit use`
97
+ * or auto-set to the first SSE-compatible circuit on `quack circuit add`.
98
+ * At launch, resolved to an OpenCode model ID and injected as `model`.
99
+ */
100
+ default_circuit?: string;
101
+ circuits: CircuitConfigEntry[];
102
+ }
103
+ /**
104
+ * `.quack/state.json` — the per-user, gitignored cached state.
105
+ */
106
+ export interface QuackState {
107
+ circuits: Record<string, CircuitStateEntry>;
108
+ /**
109
+ * The last agent successfully launched by bare `quack`.
110
+ * Per-user convenience — used as a fallback when
111
+ * `default_agent` is not set in config.json.
112
+ */
113
+ last_agent?: string;
114
+ }
115
+ /**
116
+ * Parsed circuit URL or bare UID.
117
+ *
118
+ * For slug-based URLs (`/{username}/{circuitName}`), `slug` is set and
119
+ * `circuit_uid` is empty — the caller must resolve the slug to a circuit
120
+ * UID via the API (`GET /api/slug/{slug}`).
121
+ */
122
+ export interface ParsedCircuitRef {
123
+ circuit_uid: string;
124
+ version_uid: string | null;
125
+ /** `"username/circuitName"` for slug-based URLs, null for UID-based refs. */
126
+ slug: string | null;
127
+ }
128
+ /**
129
+ * A circuit version record as returned by the ModelWorks API
130
+ * (`GET /circuits/<uid>/versions` or `GET /circuit-versions/<uid>`).
131
+ *
132
+ * Only the fields quack reads are typed here. `json_blob` is optional
133
+ * because it's hidden for protected circuits when the caller is not
134
+ * the owner (see `circuit_version.rs` visibility rules).
135
+ */
136
+ export interface CircuitVersionPublic {
137
+ uid: string;
138
+ circuit_uid: string;
139
+ state: string;
140
+ json_blob: string | null;
141
+ updated_at: string;
142
+ }
143
+ /**
144
+ * A circuit record as returned by `GET /circuits/<uid>`.
145
+ */
146
+ export interface CircuitPublic {
147
+ uid: string;
148
+ name: string;
149
+ user_uid: string;
150
+ is_private: boolean;
151
+ is_protected: boolean;
152
+ }
153
+ /**
154
+ * A circuit adaptor record as returned by
155
+ * `GET /circuit-adaptors/by-circuit-version/<version_uid>`.
156
+ */
157
+ export interface CircuitAdaptorPublic {
158
+ uid: string;
159
+ circuit_version_uid: string;
160
+ adapter_type: string;
161
+ user_uid: string | null;
162
+ user_token_uid: string | null;
163
+ }
164
+ /**
165
+ * A circuit run record stored locally at `.quack/runs/cr-xxx.json`.
166
+ *
167
+ * Committed to the repo (team-shared). Contains only public metadata —
168
+ * no request body, no response body, nothing sensitive.
169
+ */
170
+ export interface RunRecord {
171
+ run_uid: string;
172
+ circuit_uid: string;
173
+ circuit_name: string;
174
+ owner_username: string;
175
+ caller_username: string;
176
+ version_uid: string;
177
+ status: string;
178
+ created_at: string;
179
+ completed_at: string;
180
+ duration_ms: number | null;
181
+ cost_credits: number | null;
182
+ input_tokens: number | null;
183
+ output_tokens: number | null;
184
+ }
185
+ /**
186
+ * Handle to a running local proxy.
187
+ */
188
+ export interface ProxyHandle {
189
+ port: number;
190
+ close: () => void;
191
+ }
192
+ /**
193
+ * Options for {@link startProxy}.
194
+ */
195
+ export interface ProxyOptions {
196
+ /** Upstream API base URL, e.g. `https://modelworks.ai`. */
197
+ apiBase: string;
198
+ /** Returns the current access token (refreshed if needed before calling). */
199
+ getAccessToken: () => string;
200
+ /** Refreshes the access token. Returns `true` on success. */
201
+ refreshToken: () => Promise<boolean>;
202
+ /** Optional request logger for `--verbose`. */
203
+ onLog?: (method: string, path: string, status: number) => void;
204
+ /** Optional debug logger — full request/response details to a string sink. */
205
+ onDebug?: (entry: string) => void;
206
+ /**
207
+ * Called after a response stream completes, if the response included
208
+ * an `X-Circuit-Run-Uid` header. Fire-and-forget — the proxy does not
209
+ * await this. Used to fetch and save the run record locally.
210
+ */
211
+ onRunComplete?: (runUid: string) => void;
212
+ }
213
+ /**
214
+ * The OpenCode config that quack injects via `OPENCODE_CONFIG_CONTENT`.
215
+ *
216
+ * Only the two top-level keys quack touches are typed here.
217
+ */
218
+ export interface OpenCodeConfig {
219
+ $schema?: string;
220
+ /** Default model ID, e.g. `circuit-wandb-glm-52/wandb-glm-52`. */
221
+ model?: string;
222
+ mcp?: {
223
+ servers: Record<string, OpenCodeMcpServer>;
224
+ };
225
+ providers?: Record<string, OpenCodeProvider>;
226
+ }
227
+ export interface OpenCodeMcpServer {
228
+ type: "remote";
229
+ url: string;
230
+ oauth: false;
231
+ protocol: "auto" | "2026-07-28" | "legacy";
232
+ headers?: Record<string, string>;
233
+ }
234
+ export interface OpenCodeProvider {
235
+ name: string;
236
+ package: string;
237
+ env?: string[];
238
+ settings: {
239
+ baseURL: string;
240
+ apiKey?: string;
241
+ };
242
+ models: Record<string, {
243
+ name: string;
244
+ }>;
245
+ }
package/dist/types.js ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Shared types for @modelworks/quack.
3
+ *
4
+ * @module
5
+ */
6
+ export {};