@volter/twin-openrouter 0.1.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/LICENSE +202 -0
- package/README.md +131 -0
- package/client/openrouter-mirror.css +131 -0
- package/client/openrouter-mirror.tsx +221 -0
- package/dist/client/openrouter-mirror.bundle.js +235 -0
- package/dist/client/openrouter-mirror.css +131 -0
- package/dist/client/openrouter-mirror.d.ts +1 -0
- package/dist/client/openrouter-mirror.js +95 -0
- package/dist/client/openrouter-mirror.tsx +221 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +31 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.js +66 -0
- package/dist/src/openrouter-budget.d.ts +58 -0
- package/dist/src/openrouter-budget.js +133 -0
- package/dist/src/openrouter-capabilities.d.ts +3 -0
- package/dist/src/openrouter-capabilities.js +720 -0
- package/dist/src/openrouter-conformance.d.ts +11 -0
- package/dist/src/openrouter-conformance.js +53 -0
- package/dist/src/openrouter-connector.d.ts +90 -0
- package/dist/src/openrouter-connector.js +245 -0
- package/dist/src/openrouter-local-generation.d.ts +17 -0
- package/dist/src/openrouter-local-generation.js +271 -0
- package/dist/src/openrouter-mirror-ui.d.ts +12 -0
- package/dist/src/openrouter-mirror-ui.js +78 -0
- package/dist/src/openrouter-models.d.ts +39 -0
- package/dist/src/openrouter-models.js +78 -0
- package/dist/src/openrouter-scenario.d.ts +36 -0
- package/dist/src/openrouter-scenario.js +145 -0
- package/dist/src/openrouter-server.d.ts +20 -0
- package/dist/src/openrouter-server.js +144 -0
- package/dist/src/openrouter-stub.d.ts +8 -0
- package/dist/src/openrouter-stub.js +58 -0
- package/dist/src/openrouter-twin.d.ts +4 -0
- package/dist/src/openrouter-twin.js +1506 -0
- package/dist/src/openrouter-types.d.ts +50 -0
- package/dist/src/openrouter-types.js +1 -0
- package/dist/test-fixtures/openrouter-openapi-operations.SOURCE.md +16 -0
- package/dist/test-fixtures/openrouter-openapi-operations.json +1041 -0
- package/package.json +71 -0
- package/src/cli.ts +29 -0
- package/src/index.ts +108 -0
- package/src/openrouter-budget.ts +159 -0
- package/src/openrouter-capabilities.ts +861 -0
- package/src/openrouter-conformance.ts +60 -0
- package/src/openrouter-connector.ts +264 -0
- package/src/openrouter-local-generation.ts +207 -0
- package/src/openrouter-mirror-ui.ts +84 -0
- package/src/openrouter-models.ts +118 -0
- package/src/openrouter-scenario.ts +156 -0
- package/src/openrouter-server.ts +158 -0
- package/src/openrouter-stub.ts +60 -0
- package/src/openrouter-twin.ts +1441 -0
- package/src/openrouter-types.ts +49 -0
- package/test-fixtures/openrouter-openapi-operations.SOURCE.md +16 -0
- package/test-fixtures/openrouter-openapi-operations.json +1041 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/** Optional pack-owned transport to a separately World-owned loopback generation service. */
|
|
2
|
+
export const LOCAL_GENERATION_BODY_LIMIT = 8 * 1024 * 1024;
|
|
3
|
+
export function localGenerationOrigin(value) {
|
|
4
|
+
const match = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})\/?$/.exec(value);
|
|
5
|
+
const port = match ? Number(match[1]) : 0;
|
|
6
|
+
if (!port || port > 65535) {
|
|
7
|
+
throw new Error('local_generation_url_invalid: expected http://127.0.0.1:<nonzero-port> with no credentials, path, query, or fragment');
|
|
8
|
+
}
|
|
9
|
+
return `http://127.0.0.1:${port}`;
|
|
10
|
+
}
|
|
11
|
+
/** Read only the opt-in generation request body; ordinary twin routes retain their old path. */
|
|
12
|
+
export async function readLocalGenerationBody(request) {
|
|
13
|
+
request.signal.throwIfAborted();
|
|
14
|
+
const reader = request.body?.getReader();
|
|
15
|
+
if (!reader)
|
|
16
|
+
return { bytes: new Uint8Array(0), text: '' };
|
|
17
|
+
const parts = [];
|
|
18
|
+
let length = 0;
|
|
19
|
+
let readFailed = false;
|
|
20
|
+
let done = false;
|
|
21
|
+
let primary;
|
|
22
|
+
let failed = false;
|
|
23
|
+
let cleanupFailure;
|
|
24
|
+
let cleanupFailed = false;
|
|
25
|
+
let result;
|
|
26
|
+
let abortCleanup;
|
|
27
|
+
const onAbort = () => {
|
|
28
|
+
abortCleanup ??= reader.cancel(request.signal.reason);
|
|
29
|
+
// The request's finally block observes the same promise even if cancellation rejects first.
|
|
30
|
+
void abortCleanup.catch(() => { });
|
|
31
|
+
};
|
|
32
|
+
request.signal.addEventListener('abort', onAbort, { once: true });
|
|
33
|
+
if (request.signal.aborted)
|
|
34
|
+
onAbort();
|
|
35
|
+
try {
|
|
36
|
+
while (true) {
|
|
37
|
+
request.signal.throwIfAborted();
|
|
38
|
+
let part;
|
|
39
|
+
try {
|
|
40
|
+
part = await reader.read();
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
readFailed = true;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
request.signal.throwIfAborted();
|
|
47
|
+
if (part.done) {
|
|
48
|
+
done = true;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
length += part.value.byteLength;
|
|
52
|
+
if (length > LOCAL_GENERATION_BODY_LIMIT)
|
|
53
|
+
throw new Error('local_generation_body_too_large: 8 MiB limit');
|
|
54
|
+
parts.push(part.value);
|
|
55
|
+
}
|
|
56
|
+
const bytes = new Uint8Array(length);
|
|
57
|
+
let offset = 0;
|
|
58
|
+
for (const part of parts) {
|
|
59
|
+
bytes.set(part, offset);
|
|
60
|
+
offset += part.byteLength;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
result = { bytes, text: new TextDecoder('utf-8', { fatal: true }).decode(bytes) };
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
throw new Error('local_generation_invalid_utf8: request body is not valid UTF-8', { cause: error });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
primary = error;
|
|
71
|
+
failed = true;
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
request.signal.removeEventListener('abort', onAbort);
|
|
75
|
+
if (!done && (!readFailed || abortCleanup)) {
|
|
76
|
+
try {
|
|
77
|
+
await (abortCleanup ?? reader.cancel(primary));
|
|
78
|
+
}
|
|
79
|
+
catch (cleanup) {
|
|
80
|
+
cleanupFailure = cleanup;
|
|
81
|
+
cleanupFailed = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
reader.releaseLock();
|
|
86
|
+
}
|
|
87
|
+
catch (cleanup) {
|
|
88
|
+
cleanupFailure = cleanupFailed ? new AggregateError([cleanupFailure, cleanup], 'local_generation_body_cleanup_failed') : cleanup;
|
|
89
|
+
cleanupFailed = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (cleanupFailed)
|
|
93
|
+
throw new AggregateError(failed ? [primary, cleanupFailure] : [cleanupFailure], 'local_generation_body_cleanup_failed');
|
|
94
|
+
if (failed)
|
|
95
|
+
throw primary;
|
|
96
|
+
if (!result)
|
|
97
|
+
throw new Error('local_generation_body_read_failed: no completed body');
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
function localError(status, code, message) {
|
|
101
|
+
return Response.json({ error: { message, type: 'local_generation_error', code } }, { status });
|
|
102
|
+
}
|
|
103
|
+
/** One pull from the caller means at most one read from the local response. */
|
|
104
|
+
export async function forwardLocalGeneration(args) {
|
|
105
|
+
const relay = new AbortController();
|
|
106
|
+
const abort = () => relay.abort(args.request.signal.reason);
|
|
107
|
+
args.request.signal.addEventListener('abort', abort, { once: true });
|
|
108
|
+
if (args.request.signal.aborted)
|
|
109
|
+
abort();
|
|
110
|
+
const headers = new Headers();
|
|
111
|
+
for (const key of ['content-type', 'accept']) {
|
|
112
|
+
const value = args.request.headers.get(key);
|
|
113
|
+
if (value !== null)
|
|
114
|
+
headers.set(key, value);
|
|
115
|
+
}
|
|
116
|
+
let upstream;
|
|
117
|
+
try {
|
|
118
|
+
upstream = await (args.fetchImpl ?? fetch)(`${args.origin}${args.pathname}`, {
|
|
119
|
+
method: 'POST', headers, body: args.body, redirect: 'manual', signal: relay.signal,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
args.request.signal.removeEventListener('abort', abort);
|
|
124
|
+
if (args.request.signal.aborted)
|
|
125
|
+
throw error;
|
|
126
|
+
return localError(503, 'local_generation_unavailable', `Local generation service unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
127
|
+
}
|
|
128
|
+
if (upstream.status >= 300 && upstream.status < 400) {
|
|
129
|
+
try {
|
|
130
|
+
await upstream.body?.cancel();
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
args.request.signal.removeEventListener('abort', abort);
|
|
134
|
+
return localError(502, 'local_generation_cleanup_failed', `Local generation redirect body cleanup failed: ${String(error)}`);
|
|
135
|
+
}
|
|
136
|
+
args.request.signal.removeEventListener('abort', abort);
|
|
137
|
+
return localError(502, 'local_generation_redirect_refused', 'Local generation service redirect refused');
|
|
138
|
+
}
|
|
139
|
+
if (args.request.signal.aborted) {
|
|
140
|
+
args.request.signal.removeEventListener('abort', abort);
|
|
141
|
+
try {
|
|
142
|
+
await upstream.body?.cancel(args.request.signal.reason);
|
|
143
|
+
}
|
|
144
|
+
catch (cleanup) {
|
|
145
|
+
throw new AggregateError([args.request.signal.reason, cleanup], 'local_generation_cleanup_failed');
|
|
146
|
+
}
|
|
147
|
+
throw args.request.signal.reason;
|
|
148
|
+
}
|
|
149
|
+
const responseHeaders = new Headers();
|
|
150
|
+
for (const key of ['content-type', 'retry-after', 'retry-after-ms']) {
|
|
151
|
+
const value = upstream.headers.get(key);
|
|
152
|
+
if (value !== null)
|
|
153
|
+
responseHeaders.set(key, value);
|
|
154
|
+
}
|
|
155
|
+
if (!upstream.body) {
|
|
156
|
+
args.request.signal.removeEventListener('abort', abort);
|
|
157
|
+
return new Response(null, { status: upstream.status, headers: responseHeaders });
|
|
158
|
+
}
|
|
159
|
+
const reader = upstream.body.getReader();
|
|
160
|
+
let terminal = false;
|
|
161
|
+
let cancelPromise;
|
|
162
|
+
let failure;
|
|
163
|
+
let abortCleanup;
|
|
164
|
+
let streamController;
|
|
165
|
+
let readerTerminal = 'pending';
|
|
166
|
+
void reader.closed.then(() => { readerTerminal = 'closed'; }, () => { readerTerminal = 'errored'; });
|
|
167
|
+
const cancelReader = (reason) => {
|
|
168
|
+
if (cancelPromise)
|
|
169
|
+
return cancelPromise;
|
|
170
|
+
cancelPromise = (async () => {
|
|
171
|
+
let primary;
|
|
172
|
+
let failed = false;
|
|
173
|
+
await Promise.resolve();
|
|
174
|
+
if (readerTerminal === 'pending') {
|
|
175
|
+
try {
|
|
176
|
+
await reader.cancel(reason);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
primary = error;
|
|
180
|
+
failed = true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
reader.releaseLock();
|
|
185
|
+
}
|
|
186
|
+
catch (cleanup) {
|
|
187
|
+
primary = failed ? new AggregateError([primary, cleanup], 'local_generation_cleanup_failed') : cleanup;
|
|
188
|
+
failed = true;
|
|
189
|
+
}
|
|
190
|
+
if (failed)
|
|
191
|
+
throw primary;
|
|
192
|
+
})();
|
|
193
|
+
return cancelPromise;
|
|
194
|
+
};
|
|
195
|
+
const finish = () => {
|
|
196
|
+
if (terminal)
|
|
197
|
+
return;
|
|
198
|
+
terminal = true;
|
|
199
|
+
args.request.signal.removeEventListener('abort', onAbort);
|
|
200
|
+
};
|
|
201
|
+
const onAbort = () => {
|
|
202
|
+
relay.abort(args.request.signal.reason);
|
|
203
|
+
failure = args.request.signal.reason;
|
|
204
|
+
abortCleanup = cancelReader(failure);
|
|
205
|
+
void abortCleanup.then(() => { finish(); try {
|
|
206
|
+
streamController?.error(failure);
|
|
207
|
+
}
|
|
208
|
+
catch { /* an active pull already reported the abort */ } }, (cleanup) => {
|
|
209
|
+
failure = new AggregateError([failure, cleanup], 'local_generation_cleanup_failed');
|
|
210
|
+
finish();
|
|
211
|
+
try {
|
|
212
|
+
streamController?.error(failure);
|
|
213
|
+
}
|
|
214
|
+
catch { /* an active pull already reported the failure */ }
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
args.request.signal.removeEventListener('abort', abort);
|
|
218
|
+
args.request.signal.addEventListener('abort', onAbort, { once: true });
|
|
219
|
+
if (args.request.signal.aborted)
|
|
220
|
+
onAbort();
|
|
221
|
+
const body = new ReadableStream({
|
|
222
|
+
start(controller) {
|
|
223
|
+
streamController = controller;
|
|
224
|
+
if (failure !== undefined && !abortCleanup)
|
|
225
|
+
controller.error(failure);
|
|
226
|
+
},
|
|
227
|
+
async pull(controller) {
|
|
228
|
+
if (failure !== undefined) {
|
|
229
|
+
if (abortCleanup)
|
|
230
|
+
await abortCleanup.then(() => { }, () => { });
|
|
231
|
+
finish();
|
|
232
|
+
try {
|
|
233
|
+
controller.error(failure);
|
|
234
|
+
}
|
|
235
|
+
catch { /* abort callback already reported it */ }
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
const part = await reader.read();
|
|
240
|
+
if (abortCleanup) {
|
|
241
|
+
await abortCleanup.then(() => { }, () => { });
|
|
242
|
+
throw failure;
|
|
243
|
+
}
|
|
244
|
+
if (part.done) {
|
|
245
|
+
finish();
|
|
246
|
+
reader.releaseLock();
|
|
247
|
+
controller.close();
|
|
248
|
+
}
|
|
249
|
+
else
|
|
250
|
+
controller.enqueue(part.value);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
finish();
|
|
254
|
+
try {
|
|
255
|
+
reader.releaseLock();
|
|
256
|
+
}
|
|
257
|
+
catch { /* read/cancel race; retain original stream error */ }
|
|
258
|
+
try {
|
|
259
|
+
controller.error(failure ?? error);
|
|
260
|
+
}
|
|
261
|
+
catch { /* already errored by abort callback */ }
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
async cancel(reason) {
|
|
265
|
+
finish();
|
|
266
|
+
relay.abort(reason);
|
|
267
|
+
await cancelReader(reason);
|
|
268
|
+
},
|
|
269
|
+
}, { highWaterMark: 0 });
|
|
270
|
+
return new Response(body, { status: upstream.status, headers: responseHeaders });
|
|
271
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare function buildOpenRouterMirrorClient(): Promise<string>;
|
|
2
|
+
export declare function createOpenRouterMirrorServer(options?: {
|
|
3
|
+
root?: string;
|
|
4
|
+
port?: number;
|
|
5
|
+
readOnly?: boolean;
|
|
6
|
+
}): Promise<{
|
|
7
|
+
port: number;
|
|
8
|
+
stop: () => void;
|
|
9
|
+
}>;
|
|
10
|
+
export declare function openrouterMirrorHtml(): string;
|
|
11
|
+
/** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
|
|
12
|
+
export declare function openrouterMirrorStyles(): Promise<string>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// OpenRouter MIRROR UI — a models/playground/generations console served as a React/TSX app
|
|
2
|
+
// (bundled by Bun).
|
|
3
|
+
//
|
|
4
|
+
// PURE FRONTEND (R3): the mirror imports no handler module and no twin internals — it MOUNTS the
|
|
5
|
+
// pack's OWN fetch adapter (`createOpenRouterTwinFetch`) as its API backend and reads every byte of
|
|
6
|
+
// state back over the wire. The client fetches OPENROUTER'S REAL VENDOR PATHS (`/api/v1/models`,
|
|
7
|
+
// `/api/v1/chat/completions`, `/api/v1/generations`, `/api/v1/generation?id=…`) on the same origin,
|
|
8
|
+
// so there is exactly ONE serving code path and API<->UI parity cannot drift: the screen shows real
|
|
9
|
+
// twin state or nothing. Mounting the adapter also hands the mirror the streaming lane, the world
|
|
10
|
+
// clock and an honorable `readOnly` for free — the hand-rolled dispatch this replaced used
|
|
11
|
+
// `new Date()` and ignored `readOnly` (the airtable §9 round-two finding 13 drift, verbatim).
|
|
12
|
+
import { readFile } from 'node:fs/promises';
|
|
13
|
+
import { bundleClient, fileResponse } from '@volter/world-core';
|
|
14
|
+
import { serveHttp } from '@volter/world-core';
|
|
15
|
+
import { createOpenRouterTwinFetch } from "./openrouter-server.js";
|
|
16
|
+
const CLIENT_ENTRY = () => new URL('../client/openrouter-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
|
|
17
|
+
const CLIENT_CSS = () => new URL('../client/openrouter-mirror.css', import.meta.url).pathname; // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
|
|
18
|
+
const APP_SHELL = `<!doctype html>
|
|
19
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
20
|
+
<base href="/"><title>OpenRouter (twin)</title><link rel="stylesheet" href="assets/styles.css"></head>
|
|
21
|
+
<body><div id="root"></div><script type="module" src="assets/app.js"></script></body></html>`;
|
|
22
|
+
let clientBundle = null;
|
|
23
|
+
export function buildOpenRouterMirrorClient() {
|
|
24
|
+
if (!clientBundle) {
|
|
25
|
+
clientBundle = bundleClient(CLIENT_ENTRY())
|
|
26
|
+
.catch((error) => {
|
|
27
|
+
clientBundle = null;
|
|
28
|
+
throw error;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return clientBundle;
|
|
32
|
+
}
|
|
33
|
+
export async function createOpenRouterMirrorServer(options = {}) {
|
|
34
|
+
// Built ONCE, above the socket — never per request: the adapter is a closure over the twin's
|
|
35
|
+
// options and is the very same one `createOpenRouterTwinServer` serves.
|
|
36
|
+
const twin = createOpenRouterTwinFetch(options);
|
|
37
|
+
const server = await serveHttp({
|
|
38
|
+
// LOOPBACK-SPECIFIC bind (2026-08-20, the roving ui-verify flake): with the default
|
|
39
|
+
// wildcard hostname, `port: 0` can be handed a port some long-running app already LISTENS
|
|
40
|
+
// on at 127.0.0.1 (SO_REUSEADDR allows the overlapping non-identical bind), and the more
|
|
41
|
+
// specific loopback listener then shadows this server for every 127.0.0.1 fetch — the
|
|
42
|
+
// verify talks to a STRANGER (captured: a desktop app's asset server answering 404s on the
|
|
43
|
+
// mirror's port). Binding 127.0.0.1 makes the kernel allocate a port that is actually free
|
|
44
|
+
// on loopback, so the verify's fetches deterministically reach THIS server.
|
|
45
|
+
hostname: '127.0.0.1',
|
|
46
|
+
port: options.port ?? 0,
|
|
47
|
+
idleTimeout: 60,
|
|
48
|
+
async fetch(request) {
|
|
49
|
+
const url = new URL(request.url);
|
|
50
|
+
if (request.method === 'GET' && url.pathname === '/assets/app.js') {
|
|
51
|
+
try {
|
|
52
|
+
return new Response(await buildOpenRouterMirrorClient(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } });
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
return new Response(String(error), { status: 500 });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
|
|
59
|
+
return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
|
|
60
|
+
}
|
|
61
|
+
if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
|
|
62
|
+
return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
63
|
+
}
|
|
64
|
+
// everything else -> the twin's OWN FETCH ADAPTER (composition, R2/R3). That includes the
|
|
65
|
+
// uniform `GET /twin` door and the SSE lane; nothing about the mirror port may differ from
|
|
66
|
+
// the API port.
|
|
67
|
+
return twin(request);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
71
|
+
}
|
|
72
|
+
export function openrouterMirrorHtml() {
|
|
73
|
+
return APP_SHELL;
|
|
74
|
+
}
|
|
75
|
+
/** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
|
|
76
|
+
export function openrouterMirrorStyles() {
|
|
77
|
+
return readFile(CLIENT_CSS(), 'utf8');
|
|
78
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type OpenRouterModel = {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
created: number;
|
|
5
|
+
description: string;
|
|
6
|
+
context_length: number;
|
|
7
|
+
architecture: {
|
|
8
|
+
modality: string;
|
|
9
|
+
tokenizer: string;
|
|
10
|
+
instruct_type: string | null;
|
|
11
|
+
input_modalities: string[];
|
|
12
|
+
output_modalities: string[];
|
|
13
|
+
};
|
|
14
|
+
pricing: {
|
|
15
|
+
prompt: string;
|
|
16
|
+
completion: string;
|
|
17
|
+
image: string;
|
|
18
|
+
request: string;
|
|
19
|
+
};
|
|
20
|
+
top_provider: {
|
|
21
|
+
context_length: number;
|
|
22
|
+
max_completion_tokens: number;
|
|
23
|
+
is_moderated: boolean;
|
|
24
|
+
};
|
|
25
|
+
per_request_limits: Record<string, unknown> | null;
|
|
26
|
+
};
|
|
27
|
+
export declare const OPENROUTER_MODELS: OpenRouterModel[];
|
|
28
|
+
export declare function findOpenRouterModel(id: string): OpenRouterModel | undefined;
|
|
29
|
+
export type OpenRouterProvider = {
|
|
30
|
+
name: string;
|
|
31
|
+
slug: string;
|
|
32
|
+
privacy_policy_url: string | null;
|
|
33
|
+
terms_of_service_url: string | null;
|
|
34
|
+
status_page_url: string | null;
|
|
35
|
+
may_log_prompts: boolean;
|
|
36
|
+
may_train_on_data: boolean;
|
|
37
|
+
moderated_by_openrouter: boolean;
|
|
38
|
+
};
|
|
39
|
+
export declare const OPENROUTER_PROVIDERS: OpenRouterProvider[];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export const OPENROUTER_MODELS = [
|
|
2
|
+
{
|
|
3
|
+
id: 'openai/gpt-4o',
|
|
4
|
+
name: 'OpenAI: GPT-4o',
|
|
5
|
+
created: 1715731200,
|
|
6
|
+
description: 'Deterministic local catalog entry for GPT-4o.',
|
|
7
|
+
context_length: 128000,
|
|
8
|
+
architecture: {
|
|
9
|
+
modality: 'text+image->text',
|
|
10
|
+
tokenizer: 'GPT',
|
|
11
|
+
instruct_type: 'chatml',
|
|
12
|
+
input_modalities: ['text', 'image'],
|
|
13
|
+
output_modalities: ['text'],
|
|
14
|
+
},
|
|
15
|
+
pricing: { prompt: '0.0000025', completion: '0.00001', image: '0', request: '0' },
|
|
16
|
+
top_provider: { context_length: 128000, max_completion_tokens: 16384, is_moderated: true },
|
|
17
|
+
per_request_limits: null,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'anthropic/claude-sonnet-4.5',
|
|
21
|
+
name: 'Anthropic: Claude Sonnet 4.5',
|
|
22
|
+
created: 1759449600,
|
|
23
|
+
description: 'Deterministic local catalog entry for Claude Sonnet.',
|
|
24
|
+
context_length: 200000,
|
|
25
|
+
architecture: {
|
|
26
|
+
modality: 'text+image->text',
|
|
27
|
+
tokenizer: 'Claude',
|
|
28
|
+
instruct_type: null,
|
|
29
|
+
input_modalities: ['text', 'image'],
|
|
30
|
+
output_modalities: ['text'],
|
|
31
|
+
},
|
|
32
|
+
pricing: { prompt: '0.000003', completion: '0.000015', image: '0', request: '0' },
|
|
33
|
+
top_provider: { context_length: 200000, max_completion_tokens: 64000, is_moderated: true },
|
|
34
|
+
per_request_limits: null,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'google/gemini-2.5-flash',
|
|
38
|
+
name: 'Google: Gemini 2.5 Flash',
|
|
39
|
+
created: 1747267200,
|
|
40
|
+
description: 'Deterministic local catalog entry for Gemini 2.5 Flash.',
|
|
41
|
+
context_length: 1000000,
|
|
42
|
+
architecture: {
|
|
43
|
+
modality: 'text+image+audio->text',
|
|
44
|
+
tokenizer: 'Gemini',
|
|
45
|
+
instruct_type: null,
|
|
46
|
+
input_modalities: ['text', 'image', 'audio'],
|
|
47
|
+
output_modalities: ['text'],
|
|
48
|
+
},
|
|
49
|
+
pricing: { prompt: '0.0000003', completion: '0.0000025', image: '0', request: '0' },
|
|
50
|
+
top_provider: { context_length: 1000000, max_completion_tokens: 8192, is_moderated: true },
|
|
51
|
+
per_request_limits: null,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'google/gemini-3-flash-preview',
|
|
55
|
+
name: 'Google: Gemini 3 Flash Preview',
|
|
56
|
+
created: 1763424000,
|
|
57
|
+
description: 'Deterministic local catalog entry for Gemini Flash.',
|
|
58
|
+
context_length: 1000000,
|
|
59
|
+
architecture: {
|
|
60
|
+
modality: 'text+image+audio->text',
|
|
61
|
+
tokenizer: 'Gemini',
|
|
62
|
+
instruct_type: null,
|
|
63
|
+
input_modalities: ['text', 'image', 'audio'],
|
|
64
|
+
output_modalities: ['text'],
|
|
65
|
+
},
|
|
66
|
+
pricing: { prompt: '0.0000005', completion: '0.000003', image: '0', request: '0' },
|
|
67
|
+
top_provider: { context_length: 1000000, max_completion_tokens: 8192, is_moderated: true },
|
|
68
|
+
per_request_limits: null,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
export function findOpenRouterModel(id) {
|
|
72
|
+
return OPENROUTER_MODELS.find((model) => model.id === id);
|
|
73
|
+
}
|
|
74
|
+
export const OPENROUTER_PROVIDERS = [
|
|
75
|
+
{ name: 'OpenAI', slug: 'openai', privacy_policy_url: 'https://openai.com/policies/privacy-policy', terms_of_service_url: 'https://openai.com/policies/terms-of-use', status_page_url: 'https://status.openai.com', may_log_prompts: false, may_train_on_data: false, moderated_by_openrouter: false },
|
|
76
|
+
{ name: 'Anthropic', slug: 'anthropic', privacy_policy_url: 'https://www.anthropic.com/legal/privacy', terms_of_service_url: 'https://www.anthropic.com/legal/commercial-terms', status_page_url: 'https://status.anthropic.com', may_log_prompts: false, may_train_on_data: false, moderated_by_openrouter: false },
|
|
77
|
+
{ name: 'Google AI Studio', slug: 'google-ai-studio', privacy_policy_url: 'https://policies.google.com/privacy', terms_of_service_url: 'https://ai.google.dev/gemini-api/terms', status_page_url: null, may_log_prompts: true, may_train_on_data: false, moderated_by_openrouter: false },
|
|
78
|
+
];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type PackScenarioAdapter, type ScenarioDocument, ScenarioEngine } from '@volter/world-core';
|
|
2
|
+
import type { ChatMessage } from './openrouter-types.js';
|
|
3
|
+
/** The request slice the scenario system sees — built by the chat route from validated args. */
|
|
4
|
+
export type OpenRouterScenarioRequest = {
|
|
5
|
+
model: string;
|
|
6
|
+
messages: ChatMessage[];
|
|
7
|
+
tools?: unknown;
|
|
8
|
+
};
|
|
9
|
+
export type OpenRouterScenarioEngine = ScenarioEngine<OpenRouterScenarioRequest>;
|
|
10
|
+
/** A scripted tool call — the arguments object is emitted verbatim as the JSON string the
|
|
11
|
+
* OpenAI-shaped wire carries. */
|
|
12
|
+
export type ScenarioToolCall = {
|
|
13
|
+
name: string;
|
|
14
|
+
arguments: Record<string, unknown>;
|
|
15
|
+
id?: string;
|
|
16
|
+
};
|
|
17
|
+
export type OpenRouterScenarioRespond = {
|
|
18
|
+
text?: string;
|
|
19
|
+
toolCalls?: ScenarioToolCall | ScenarioToolCall[];
|
|
20
|
+
finishReason?: 'stop' | 'length' | 'tool_calls' | 'content_filter';
|
|
21
|
+
};
|
|
22
|
+
/** What a fired handler yields — the assistant turn plus the finish_reason for the envelope. */
|
|
23
|
+
export type ScriptedResult = {
|
|
24
|
+
text: string | null;
|
|
25
|
+
toolCalls: Array<Record<string, unknown>>;
|
|
26
|
+
finishReason: 'stop' | 'length' | 'tool_calls' | 'content_filter';
|
|
27
|
+
};
|
|
28
|
+
export declare const openrouterScenarioAdapter: PackScenarioAdapter<OpenRouterScenarioRequest>;
|
|
29
|
+
/** Load + strictly validate a handlers document (handlers/openrouter.json). A broken file fails
|
|
30
|
+
* server construction loudly; it never falls back or misfires silently. */
|
|
31
|
+
export declare function loadOpenRouterScenarioDocument(path: string): ScenarioDocument;
|
|
32
|
+
export declare function createOpenRouterScenarioEngine(document?: ScenarioDocument): OpenRouterScenarioEngine;
|
|
33
|
+
/** Realize a fired handler's respond payload into the OpenAI-shaped assistant turn. Scripted
|
|
34
|
+
* tool-call ids are DERIVED from the call itself (name + arguments), never a counter: two
|
|
35
|
+
* identical worlds replaying the same script mint the same id (R9). */
|
|
36
|
+
export declare function realizeOpenRouterRespond(respond: OpenRouterScenarioRespond): ScriptedResult;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The openrouter pack's HALF of the scenario system, on the kernel's ONE engine (@volter/world-core
|
|
2
|
+
// scenario.ts). The GRAMMAR (ordering, once/scope/phase, extractors, strict parsing, miss
|
|
3
|
+
// records, twin.use) is the kernel's and identical for every vendor; this module declares only
|
|
4
|
+
// the VOCABULARY:
|
|
5
|
+
// • the `on` keys a handler may match (model / last-user text / any text / tools / tool
|
|
6
|
+
// results) — OpenRouter's wire is the OpenAI chat-completions shape, so the vocabulary is
|
|
7
|
+
// that shape's, deliberately the same words the openai pack uses,
|
|
8
|
+
// • what a `respond` payload may contain ({ text | toolCalls, finishReason? }),
|
|
9
|
+
// • how a fired handler REALIZES into an assistant turn (content or tool_calls + the
|
|
10
|
+
// finish_reason the envelope reports).
|
|
11
|
+
// The engine is consulted ONCE per generation request (chat completions and the Responses beta,
|
|
12
|
+
// which share `buildChoice`); a miss leaves the pack's labeled deterministic stub in place and
|
|
13
|
+
// is inspectable at GET /twin/scenario. The handler FILE (handlers/openrouter.json in a world
|
|
14
|
+
// dir) is the only write surface — there is no runtime write door.
|
|
15
|
+
import { getActiveWorldStore, parseScenarioDocument, ScenarioError, ScenarioEngine } from '@volter/world-core';
|
|
16
|
+
import { contentToText, lastUserText, stableHash } from "./openrouter-stub.js";
|
|
17
|
+
const RESPOND_KEYS = new Set(['text', 'toolCalls', 'finishReason']);
|
|
18
|
+
const FINISH_REASONS = new Set(['stop', 'length', 'tool_calls', 'content_filter']);
|
|
19
|
+
const nonEmptyString = (cond) => typeof cond === 'string' && cond.length > 0;
|
|
20
|
+
/** The tool NAMES the trailing `role:"tool"` message answers, resolved through the tool_call ids
|
|
21
|
+
* of earlier assistant turns. */
|
|
22
|
+
function lastToolResultNames(messages) {
|
|
23
|
+
const names = new Set();
|
|
24
|
+
const last = messages[messages.length - 1];
|
|
25
|
+
if (!last || last.role !== 'tool' || typeof last.tool_call_id !== 'string')
|
|
26
|
+
return names;
|
|
27
|
+
for (const m of messages) {
|
|
28
|
+
const am = m;
|
|
29
|
+
if (am.role !== 'assistant' || !Array.isArray(am.tool_calls))
|
|
30
|
+
continue;
|
|
31
|
+
for (const tc of am.tool_calls) {
|
|
32
|
+
if (tc?.id === last.tool_call_id && typeof tc?.function?.name === 'string')
|
|
33
|
+
names.add(tc.function.name);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return names;
|
|
37
|
+
}
|
|
38
|
+
function toolNames(tools) {
|
|
39
|
+
if (!Array.isArray(tools))
|
|
40
|
+
return [];
|
|
41
|
+
return tools
|
|
42
|
+
.map((t) => (typeof t?.function?.name === 'string' ? t.function.name : typeof t?.name === 'string' ? t.name : null))
|
|
43
|
+
.filter((n) => n !== null);
|
|
44
|
+
}
|
|
45
|
+
export const openrouterScenarioAdapter = {
|
|
46
|
+
vendor: 'openrouter',
|
|
47
|
+
features: (req) => ({
|
|
48
|
+
model: req.model,
|
|
49
|
+
lastUserText: lastUserText(req.messages).slice(0, 300),
|
|
50
|
+
tools: toolNames(req.tools),
|
|
51
|
+
lastMessageIsToolResult: req.messages[req.messages.length - 1]?.role === 'tool',
|
|
52
|
+
toolResultFor: [...lastToolResultNames(req.messages)],
|
|
53
|
+
}),
|
|
54
|
+
matchers: {
|
|
55
|
+
// The routed model id as the caller asked for it, with OpenRouter's `:online` suffix already
|
|
56
|
+
// stripped by the chat route (the suffix selects the web plugin, not a different model).
|
|
57
|
+
modelEquals: (req, cond) => nonEmptyString(cond) && req.model === cond,
|
|
58
|
+
userTextIncludes: (req, cond) => nonEmptyString(cond) && lastUserText(req.messages).toLowerCase().includes(cond.toLowerCase()),
|
|
59
|
+
anyTextIncludes: (req, cond) => nonEmptyString(cond) && req.messages.map((m) => contentToText(m.content)).join('\n').toLowerCase().includes(cond.toLowerCase()),
|
|
60
|
+
lastMessageIsToolResult: (req, cond) => typeof cond === 'boolean' && (req.messages[req.messages.length - 1]?.role === 'tool') === cond,
|
|
61
|
+
toolResultFor: (req, cond) => nonEmptyString(cond) && lastToolResultNames(req.messages).has(cond),
|
|
62
|
+
hasTool: (req, cond) => nonEmptyString(cond) && toolNames(req.tools).includes(cond),
|
|
63
|
+
},
|
|
64
|
+
text: (req) => req.messages.map((m) => contentToText(m.content)).join('\n'),
|
|
65
|
+
validateOn: (on) => {
|
|
66
|
+
for (const k of ['modelEquals', 'userTextIncludes', 'anyTextIncludes', 'toolResultFor', 'hasTool']) {
|
|
67
|
+
if (on[k] !== undefined && (typeof on[k] !== 'string' || !on[k]))
|
|
68
|
+
return `on.${k} is a non-empty string`;
|
|
69
|
+
}
|
|
70
|
+
if (on.lastMessageIsToolResult !== undefined && typeof on.lastMessageIsToolResult !== 'boolean')
|
|
71
|
+
return 'on.lastMessageIsToolResult is a boolean';
|
|
72
|
+
return null;
|
|
73
|
+
},
|
|
74
|
+
validateRespond: (respond) => {
|
|
75
|
+
if (typeof respond !== 'object' || respond === null || Array.isArray(respond))
|
|
76
|
+
return 'respond is an object { text?, toolCalls?, finishReason? }';
|
|
77
|
+
const r = respond;
|
|
78
|
+
for (const k of Object.keys(r))
|
|
79
|
+
if (!RESPOND_KEYS.has(k))
|
|
80
|
+
return `respond: unknown key "${k}" (valid: ${[...RESPOND_KEYS].join(', ')})`;
|
|
81
|
+
if (r.text !== undefined && typeof r.text !== 'string')
|
|
82
|
+
return 'respond.text is a string';
|
|
83
|
+
if (r.finishReason !== undefined && (typeof r.finishReason !== 'string' || !FINISH_REASONS.has(r.finishReason)))
|
|
84
|
+
return `respond.finishReason is one of ${[...FINISH_REASONS].join(', ')}`;
|
|
85
|
+
if (r.toolCalls !== undefined) {
|
|
86
|
+
for (const tc of Array.isArray(r.toolCalls) ? r.toolCalls : [r.toolCalls]) {
|
|
87
|
+
const t = tc;
|
|
88
|
+
if (!t || typeof t !== 'object' || Array.isArray(t))
|
|
89
|
+
return 'respond.toolCalls entries are objects';
|
|
90
|
+
if (typeof t.name !== 'string' || !t.name)
|
|
91
|
+
return 'respond.toolCalls[].name is a non-empty string';
|
|
92
|
+
if (!t.arguments || typeof t.arguments !== 'object' || Array.isArray(t.arguments))
|
|
93
|
+
return 'respond.toolCalls[].arguments is an object';
|
|
94
|
+
if (t.id !== undefined && typeof t.id !== 'string')
|
|
95
|
+
return 'respond.toolCalls[].id is a string';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (r.text === undefined && r.toolCalls === undefined)
|
|
99
|
+
return 'respond needs text or toolCalls';
|
|
100
|
+
return null;
|
|
101
|
+
},
|
|
102
|
+
renderFault: (fault) => ({
|
|
103
|
+
body: { error: { message: fault.message ?? `twin fault: ${fault.status}`, type: 'twin_fault', code: 'twin_fault' } },
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
/** Load + strictly validate a handlers document (handlers/openrouter.json). A broken file fails
|
|
107
|
+
* server construction loudly; it never falls back or misfires silently. */
|
|
108
|
+
export function loadOpenRouterScenarioDocument(path) {
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
// Read through the ACTIVE WorldStore, never the filesystem directly (runtime contract R12b):
|
|
112
|
+
// the handlers document is WORLD STATE, so a MemoryWorldStore / DO-backed world serves ITS
|
|
113
|
+
// OWN scenario instead of whatever happens to sit on the host disk — and the serve path
|
|
114
|
+
// stays workerd-clean.
|
|
115
|
+
const raw = getActiveWorldStore().read(path);
|
|
116
|
+
if (raw === null)
|
|
117
|
+
throw new Error(`ENOENT: no such file or directory, open '${path}'`);
|
|
118
|
+
parsed = JSON.parse(raw);
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
throw new ScenarioError(`openrouter scenario: cannot read/parse ${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
122
|
+
}
|
|
123
|
+
return parseScenarioDocument(parsed, openrouterScenarioAdapter);
|
|
124
|
+
}
|
|
125
|
+
export function createOpenRouterScenarioEngine(document) {
|
|
126
|
+
return new ScenarioEngine(openrouterScenarioAdapter, document);
|
|
127
|
+
}
|
|
128
|
+
/** Realize a fired handler's respond payload into the OpenAI-shaped assistant turn. Scripted
|
|
129
|
+
* tool-call ids are DERIVED from the call itself (name + arguments), never a counter: two
|
|
130
|
+
* identical worlds replaying the same script mint the same id (R9). */
|
|
131
|
+
export function realizeOpenRouterRespond(respond) {
|
|
132
|
+
const toolCalls = [];
|
|
133
|
+
for (const tc of respond.toolCalls ? (Array.isArray(respond.toolCalls) ? respond.toolCalls : [respond.toolCalls]) : []) {
|
|
134
|
+
toolCalls.push({
|
|
135
|
+
id: tc.id ?? `call_scripted_${stableHash({ name: tc.name, arguments: tc.arguments }).slice(0, 8)}`,
|
|
136
|
+
type: 'function',
|
|
137
|
+
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
text: respond.text ?? (toolCalls.length ? null : ''),
|
|
142
|
+
toolCalls,
|
|
143
|
+
finishReason: respond.finishReason ?? (toolCalls.length ? 'tool_calls' : 'stop'),
|
|
144
|
+
};
|
|
145
|
+
}
|