@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,20 @@
|
|
|
1
|
+
import { type LocalGenerationFetch } from './openrouter-local-generation.js';
|
|
2
|
+
/** Options every OpenRouter-twin HTTP surface needs, independent of who owns the socket. */
|
|
3
|
+
export interface OpenRouterTwinFetchOptions {
|
|
4
|
+
root?: string;
|
|
5
|
+
readOnly?: boolean;
|
|
6
|
+
/** Path to the world dir's handlers/openrouter.json — read ONCE, at construction, through the
|
|
7
|
+
* active WorldStore. Absent → no engine, and the labeled deterministic stub answers. */
|
|
8
|
+
scenarioPath?: string;
|
|
9
|
+
/** Explicitly selected World-owned loopback service. Absent means deterministic twin output. */
|
|
10
|
+
localGenerationUrl?: string;
|
|
11
|
+
/** Injectable transport for deterministic pack tests; the production default is global fetch. */
|
|
12
|
+
localGenerationFetch?: LocalGenerationFetch;
|
|
13
|
+
}
|
|
14
|
+
export declare function createOpenRouterTwinFetch(options: OpenRouterTwinFetchOptions): (request: Request) => Promise<Response>;
|
|
15
|
+
export declare function createOpenRouterTwinServer(options?: OpenRouterTwinFetchOptions & {
|
|
16
|
+
port?: number;
|
|
17
|
+
}): Promise<{
|
|
18
|
+
port: number;
|
|
19
|
+
stop: () => void;
|
|
20
|
+
}>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// OpenRouter twin HTTP server.
|
|
2
|
+
//
|
|
3
|
+
// FETCH-FIRST (runtime contract R12b): the surface is the plain `createOpenRouterTwinFetch` and
|
|
4
|
+
// the SERVER is one line of `Bun.serve` around it. This is a CUSTOM fetch, not the kernel adapter
|
|
5
|
+
// (`createTwinFetchFromHandler`): the SSE streaming lane genuinely exceeds the common shape —
|
|
6
|
+
// openai-server.ts is the reference for it. The two READ DOORS it serves are the same ones that
|
|
7
|
+
// adapter provides (runtime contract R5): keyless `GET /twin` and `GET /twin/scenario`.
|
|
8
|
+
//
|
|
9
|
+
// SCENARIO scripting (openrouter-scenario.ts): pass `scenarioPath` (or set the
|
|
10
|
+
// TWIN_OPENROUTER_SCENARIO env var) to script chat-completion turns. A malformed scenario throws
|
|
11
|
+
// at construction — loudly, never a silent fallback.
|
|
12
|
+
import { serveHttp } from '@volter/world-core';
|
|
13
|
+
import { handleOpenRouterTwinRequest, handleOpenRouterTwinRequestWithGeneration } from "./openrouter-twin.js";
|
|
14
|
+
import { worldNow, twinManifest } from '@volter/world-core';
|
|
15
|
+
import { createOpenRouterScenarioEngine, loadOpenRouterScenarioDocument } from "./openrouter-scenario.js";
|
|
16
|
+
import { forwardLocalGeneration, localGenerationOrigin, readLocalGenerationBody } from "./openrouter-local-generation.js";
|
|
17
|
+
function wantsStream(body) {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(body || '{}')?.stream === true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function encodeSse(event) {
|
|
26
|
+
return event.done ? 'data: [DONE]\n\n' : `data: ${JSON.stringify(event.data)}\n\n`;
|
|
27
|
+
}
|
|
28
|
+
export function createOpenRouterTwinFetch(options) {
|
|
29
|
+
const readOnly = options.readOnly ?? false;
|
|
30
|
+
const scenarioPath = options.scenarioPath ?? process.env.TWIN_OPENROUTER_SCENARIO;
|
|
31
|
+
const scenarioEngine = scenarioPath ? createOpenRouterScenarioEngine(loadOpenRouterScenarioDocument(scenarioPath)) : undefined;
|
|
32
|
+
const selectedUrl = options.localGenerationUrl !== undefined ? options.localGenerationUrl : process.env.TWIN_OPENROUTER_LOCAL_GENERATION_URL;
|
|
33
|
+
const localOrigin = selectedUrl === undefined ? undefined : localGenerationOrigin(selectedUrl);
|
|
34
|
+
return async function openRouterTwinFetch(request) {
|
|
35
|
+
const url = new URL(request.url);
|
|
36
|
+
// THE READ DOORS (runtime contract R5, TWIN-PROGRAMMING-MODEL): discovery + inspection,
|
|
37
|
+
// keyless and read-only. They answer BEFORE any vendor routing, so a door is never an
|
|
38
|
+
// authenticated vendor route.
|
|
39
|
+
if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
|
|
40
|
+
return Response.json(twinManifest({
|
|
41
|
+
vendor: 'openrouter',
|
|
42
|
+
twinOf: 'the OpenRouter API',
|
|
43
|
+
stateSentence: localOrigin
|
|
44
|
+
? 'Stored twin resources and scripted chat generations use the ordinary twin log. Forwarded local generations belong only to the local service ledger; twin generation and analytics routes do not include them.'
|
|
45
|
+
: 'Stateful where it stores: seed keys, BYOK credentials, presets and generation stats through the ordinary API with any credential. Generations are recorded as you make them.',
|
|
46
|
+
behaviorSentence: `Chat completions (and the Responses beta) are scripted by MSW-shaped handlers in the world dir (handlers/openrouter.json): {on:{userTextIncludes|anyTextIncludes|modelEquals|hasTool|toolResultFor|lastMessageIsToolResult|nthCall}, respond:{text|toolCalls, finishReason?}, once?, phase?}. ${localOrigin ? 'Only unmatched supported generation requests are handed to an explicitly selected World-owned local service; this opt-in output is nondeterministic and is not replayable twin state.' : 'Unmatched requests answer the labeled deterministic stub naming this door.'}`,
|
|
47
|
+
exampleHandler: { on: { userTextIncludes: 'summarize' }, respond: { text: 'Scripted summary.' } },
|
|
48
|
+
engine: scenarioEngine,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin/scenario') {
|
|
52
|
+
return Response.json(scenarioEngine ? scenarioEngine.status() : { vendor: 'openrouter', handlers: [], misses: 0, recentMisses: [] });
|
|
53
|
+
}
|
|
54
|
+
const path = url.pathname + url.search;
|
|
55
|
+
const normalizedPath = url.pathname.replace(/^\/api\/v1/, '/v1');
|
|
56
|
+
const localRoute = localOrigin !== undefined && request.method.toUpperCase() === 'POST'
|
|
57
|
+
&& (normalizedPath === '/v1/chat/completions' || normalizedPath === '/v1/responses');
|
|
58
|
+
if (localRoute && readOnly) {
|
|
59
|
+
const refusal = await handleOpenRouterTwinRequest({ method: request.method, path, readOnly });
|
|
60
|
+
return new Response(JSON.stringify(refusal.body), { status: refusal.status, headers: { 'content-type': 'application/json' } });
|
|
61
|
+
}
|
|
62
|
+
let body;
|
|
63
|
+
let localBytes;
|
|
64
|
+
if (localRoute) {
|
|
65
|
+
try {
|
|
66
|
+
const bounded = await readLocalGenerationBody(request);
|
|
67
|
+
body = bounded.text;
|
|
68
|
+
localBytes = bounded.bytes;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (request.signal.aborted)
|
|
72
|
+
throw error;
|
|
73
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
74
|
+
const status = message.startsWith('local_generation_body_too_large') ? 413 : message.startsWith('local_generation_invalid_utf8') ? 400 : 500;
|
|
75
|
+
return Response.json({ error: { message, type: 'local_generation_error', code: status === 413 ? 'local_generation_body_too_large' : status === 400 ? 'invalid_utf8' : 'local_generation_body_read_failed' } }, { status });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else
|
|
79
|
+
body = request.method === 'GET' ? '' : await request.text();
|
|
80
|
+
const events = [];
|
|
81
|
+
const streamChat = request.method.toUpperCase() === 'POST' && normalizedPath === '/v1/chat/completions' && wantsStream(body);
|
|
82
|
+
const twinRequest = {
|
|
83
|
+
method: request.method,
|
|
84
|
+
path,
|
|
85
|
+
body,
|
|
86
|
+
readOnly,
|
|
87
|
+
signal: request.signal,
|
|
88
|
+
occurredAt: worldNow(),
|
|
89
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
90
|
+
...(scenarioEngine ? { scenarioEngine } : {}),
|
|
91
|
+
...(streamChat ? { sseSink: (event) => events.push(event) } : {}),
|
|
92
|
+
};
|
|
93
|
+
const result = localRoute && localOrigin && localBytes
|
|
94
|
+
? await handleOpenRouterTwinRequestWithGeneration(twinRequest, () => forwardLocalGeneration({
|
|
95
|
+
origin: localOrigin, request, pathname: url.pathname, body: localBytes,
|
|
96
|
+
...(options.localGenerationFetch ? { fetchImpl: options.localGenerationFetch } : {}),
|
|
97
|
+
}))
|
|
98
|
+
: await handleOpenRouterTwinRequest(twinRequest);
|
|
99
|
+
if (result instanceof Response)
|
|
100
|
+
return result;
|
|
101
|
+
// Streaming POST → a real text/event-stream response built from the sink.
|
|
102
|
+
//
|
|
103
|
+
// Events are COLLECTED first, then framed. The handler is synchronous-fast, so buffering
|
|
104
|
+
// costs nothing — and it is what lets a PRE-STREAM failure answer with its REAL status and
|
|
105
|
+
// the vendor's JSON error envelope. Emitting the refusal as a lone `data:` frame inside a
|
|
106
|
+
// 200 text/event-stream was a FAKE SUCCESS the in-process path could not see:
|
|
107
|
+
// `handleOpenRouterTwinRequest` returns 404 for an unknown model and 400 for a body with
|
|
108
|
+
// no `messages`, while the wire returned 200 in both cases — so every verify asserting
|
|
109
|
+
// those statuses asserted something the socket never carried. OpenRouter rejects a bad
|
|
110
|
+
// request BEFORE opening the event stream; this twin decides every refusal before the
|
|
111
|
+
// first chunk.
|
|
112
|
+
if (!readOnly && streamChat) {
|
|
113
|
+
if (result.status >= 400) {
|
|
114
|
+
return new Response(JSON.stringify(result.body), {
|
|
115
|
+
status: result.status,
|
|
116
|
+
headers: { 'content-type': 'application/json', ...result.headers },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const stream = new ReadableStream({
|
|
120
|
+
start(controller) {
|
|
121
|
+
const encoder = new TextEncoder();
|
|
122
|
+
for (const event of events)
|
|
123
|
+
controller.enqueue(encoder.encode(encodeSse(event)));
|
|
124
|
+
controller.close();
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
return new Response(stream, {
|
|
128
|
+
headers: {
|
|
129
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
130
|
+
'cache-control': 'no-cache',
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return new Response(JSON.stringify(result.body), { status: result.status, headers: { 'content-type': 'application/json', ...result.headers } });
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
export async function createOpenRouterTwinServer(options = {}) {
|
|
138
|
+
const server = await serveHttp({
|
|
139
|
+
port: options.port ?? 0,
|
|
140
|
+
idleTimeout: 60,
|
|
141
|
+
fetch: createOpenRouterTwinFetch(options),
|
|
142
|
+
});
|
|
143
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
144
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ChatMessage } from './openrouter-types.js';
|
|
2
|
+
export declare function contentToText(content: unknown): string;
|
|
3
|
+
export declare function lastUserText(messages: ChatMessage[]): string;
|
|
4
|
+
export declare function estimateTokens(input: string): number;
|
|
5
|
+
export declare function countPromptTokens(messages: ChatMessage[]): number;
|
|
6
|
+
export declare function stubAssistantText(messages: ChatMessage[], model: string): string;
|
|
7
|
+
export declare function stableHash(value: unknown): string;
|
|
8
|
+
export declare function firstToolCall(tools: unknown): Record<string, unknown> | null;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export function contentToText(content) {
|
|
2
|
+
if (typeof content === 'string')
|
|
3
|
+
return content;
|
|
4
|
+
if (Array.isArray(content)) {
|
|
5
|
+
return content.map((part) => {
|
|
6
|
+
if (typeof part === 'string')
|
|
7
|
+
return part;
|
|
8
|
+
if (part && typeof part === 'object' && 'text' in part)
|
|
9
|
+
return String(part.text ?? '');
|
|
10
|
+
return '';
|
|
11
|
+
}).filter(Boolean).join(' ');
|
|
12
|
+
}
|
|
13
|
+
return '';
|
|
14
|
+
}
|
|
15
|
+
export function lastUserText(messages) {
|
|
16
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
17
|
+
if (messages[i]?.role === 'user')
|
|
18
|
+
return contentToText(messages[i]?.content);
|
|
19
|
+
}
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
export function estimateTokens(input) {
|
|
23
|
+
const trimmed = input.trim();
|
|
24
|
+
if (!trimmed)
|
|
25
|
+
return 0;
|
|
26
|
+
return Math.max(1, Math.ceil(trimmed.length / 4));
|
|
27
|
+
}
|
|
28
|
+
export function countPromptTokens(messages) {
|
|
29
|
+
return messages.reduce((sum, message) => sum + estimateTokens(`${message.role}: ${contentToText(message.content)}`), 0);
|
|
30
|
+
}
|
|
31
|
+
export function stubAssistantText(messages, model) {
|
|
32
|
+
const text = lastUserText(messages) || 'empty prompt';
|
|
33
|
+
return `[twin-stub:openrouter:${model}] Echoing the last user message for deterministic simulation: ${text}`;
|
|
34
|
+
}
|
|
35
|
+
export function stableHash(value) {
|
|
36
|
+
const s = JSON.stringify(value);
|
|
37
|
+
let h = 2166136261;
|
|
38
|
+
for (let i = 0; i < s.length; i++) {
|
|
39
|
+
h ^= s.charCodeAt(i);
|
|
40
|
+
h = Math.imul(h, 16777619);
|
|
41
|
+
}
|
|
42
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
43
|
+
}
|
|
44
|
+
export function firstToolCall(tools) {
|
|
45
|
+
if (!Array.isArray(tools) || tools.length === 0)
|
|
46
|
+
return null;
|
|
47
|
+
const tool = tools[0];
|
|
48
|
+
const fn = tool.function && typeof tool.function === 'object' ? tool.function : tool;
|
|
49
|
+
const name = typeof fn.name === 'string' ? fn.name : 'tool';
|
|
50
|
+
return {
|
|
51
|
+
id: `call_twin_${stableHash(name).slice(0, 8)}`,
|
|
52
|
+
type: 'function',
|
|
53
|
+
function: {
|
|
54
|
+
name,
|
|
55
|
+
arguments: JSON.stringify({ twin: true, input: 'deterministic tool arguments' }),
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { OpenRouterRequest, OpenRouterResponse } from './openrouter-types.js';
|
|
2
|
+
/** The legacy handler remains JSON-shaped; only the fetch adapter can select a local handoff. */
|
|
3
|
+
export declare function handleOpenRouterTwinRequest(req: OpenRouterRequest): Promise<OpenRouterResponse>;
|
|
4
|
+
export declare function handleOpenRouterTwinRequestWithGeneration(req: OpenRouterRequest, localGeneration?: () => Promise<Response>): Promise<OpenRouterResponse | Response>;
|