@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,118 @@
|
|
|
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
|
+
|
|
28
|
+
export const OPENROUTER_MODELS: OpenRouterModel[] = [
|
|
29
|
+
{
|
|
30
|
+
id: 'openai/gpt-4o',
|
|
31
|
+
name: 'OpenAI: GPT-4o',
|
|
32
|
+
created: 1715731200,
|
|
33
|
+
description: 'Deterministic local catalog entry for GPT-4o.',
|
|
34
|
+
context_length: 128000,
|
|
35
|
+
architecture: {
|
|
36
|
+
modality: 'text+image->text',
|
|
37
|
+
tokenizer: 'GPT',
|
|
38
|
+
instruct_type: 'chatml',
|
|
39
|
+
input_modalities: ['text', 'image'],
|
|
40
|
+
output_modalities: ['text'],
|
|
41
|
+
},
|
|
42
|
+
pricing: { prompt: '0.0000025', completion: '0.00001', image: '0', request: '0' },
|
|
43
|
+
top_provider: { context_length: 128000, max_completion_tokens: 16384, is_moderated: true },
|
|
44
|
+
per_request_limits: null,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'anthropic/claude-sonnet-4.5',
|
|
48
|
+
name: 'Anthropic: Claude Sonnet 4.5',
|
|
49
|
+
created: 1759449600,
|
|
50
|
+
description: 'Deterministic local catalog entry for Claude Sonnet.',
|
|
51
|
+
context_length: 200000,
|
|
52
|
+
architecture: {
|
|
53
|
+
modality: 'text+image->text',
|
|
54
|
+
tokenizer: 'Claude',
|
|
55
|
+
instruct_type: null,
|
|
56
|
+
input_modalities: ['text', 'image'],
|
|
57
|
+
output_modalities: ['text'],
|
|
58
|
+
},
|
|
59
|
+
pricing: { prompt: '0.000003', completion: '0.000015', image: '0', request: '0' },
|
|
60
|
+
top_provider: { context_length: 200000, max_completion_tokens: 64000, is_moderated: true },
|
|
61
|
+
per_request_limits: null,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'google/gemini-2.5-flash',
|
|
65
|
+
name: 'Google: Gemini 2.5 Flash',
|
|
66
|
+
created: 1747267200,
|
|
67
|
+
description: 'Deterministic local catalog entry for Gemini 2.5 Flash.',
|
|
68
|
+
context_length: 1000000,
|
|
69
|
+
architecture: {
|
|
70
|
+
modality: 'text+image+audio->text',
|
|
71
|
+
tokenizer: 'Gemini',
|
|
72
|
+
instruct_type: null,
|
|
73
|
+
input_modalities: ['text', 'image', 'audio'],
|
|
74
|
+
output_modalities: ['text'],
|
|
75
|
+
},
|
|
76
|
+
pricing: { prompt: '0.0000003', completion: '0.0000025', image: '0', request: '0' },
|
|
77
|
+
top_provider: { context_length: 1000000, max_completion_tokens: 8192, is_moderated: true },
|
|
78
|
+
per_request_limits: null,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'google/gemini-3-flash-preview',
|
|
82
|
+
name: 'Google: Gemini 3 Flash Preview',
|
|
83
|
+
created: 1763424000,
|
|
84
|
+
description: 'Deterministic local catalog entry for Gemini Flash.',
|
|
85
|
+
context_length: 1000000,
|
|
86
|
+
architecture: {
|
|
87
|
+
modality: 'text+image+audio->text',
|
|
88
|
+
tokenizer: 'Gemini',
|
|
89
|
+
instruct_type: null,
|
|
90
|
+
input_modalities: ['text', 'image', 'audio'],
|
|
91
|
+
output_modalities: ['text'],
|
|
92
|
+
},
|
|
93
|
+
pricing: { prompt: '0.0000005', completion: '0.000003', image: '0', request: '0' },
|
|
94
|
+
top_provider: { context_length: 1000000, max_completion_tokens: 8192, is_moderated: true },
|
|
95
|
+
per_request_limits: null,
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
export function findOpenRouterModel(id: string): OpenRouterModel | undefined {
|
|
100
|
+
return OPENROUTER_MODELS.find((model) => model.id === id);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type OpenRouterProvider = {
|
|
104
|
+
name: string;
|
|
105
|
+
slug: string;
|
|
106
|
+
privacy_policy_url: string | null;
|
|
107
|
+
terms_of_service_url: string | null;
|
|
108
|
+
status_page_url: string | null;
|
|
109
|
+
may_log_prompts: boolean;
|
|
110
|
+
may_train_on_data: boolean;
|
|
111
|
+
moderated_by_openrouter: boolean;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const OPENROUTER_PROVIDERS: OpenRouterProvider[] = [
|
|
115
|
+
{ 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 },
|
|
116
|
+
{ 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 },
|
|
117
|
+
{ 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 },
|
|
118
|
+
];
|
|
@@ -0,0 +1,156 @@
|
|
|
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, type PackScenarioAdapter, ScenarioError, type ScenarioDocument, ScenarioEngine, type ScenarioFeatures } from '@volter/world-core';
|
|
16
|
+
import { contentToText, lastUserText, stableHash } from './openrouter-stub.ts';
|
|
17
|
+
import type { ChatMessage } from './openrouter-types.ts';
|
|
18
|
+
|
|
19
|
+
/** The request slice the scenario system sees — built by the chat route from validated args. */
|
|
20
|
+
export type OpenRouterScenarioRequest = { model: string; messages: ChatMessage[]; tools?: unknown };
|
|
21
|
+
export type OpenRouterScenarioEngine = ScenarioEngine<OpenRouterScenarioRequest>;
|
|
22
|
+
|
|
23
|
+
/** A scripted tool call — the arguments object is emitted verbatim as the JSON string the
|
|
24
|
+
* OpenAI-shaped wire carries. */
|
|
25
|
+
export type ScenarioToolCall = { name: string; arguments: Record<string, unknown>; id?: string };
|
|
26
|
+
|
|
27
|
+
export type OpenRouterScenarioRespond = {
|
|
28
|
+
text?: string;
|
|
29
|
+
toolCalls?: ScenarioToolCall | ScenarioToolCall[];
|
|
30
|
+
finishReason?: 'stop' | 'length' | 'tool_calls' | 'content_filter';
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** What a fired handler yields — the assistant turn plus the finish_reason for the envelope. */
|
|
34
|
+
export type ScriptedResult = {
|
|
35
|
+
text: string | null;
|
|
36
|
+
toolCalls: Array<Record<string, unknown>>;
|
|
37
|
+
finishReason: 'stop' | 'length' | 'tool_calls' | 'content_filter';
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const RESPOND_KEYS = new Set(['text', 'toolCalls', 'finishReason']);
|
|
41
|
+
const FINISH_REASONS = new Set(['stop', 'length', 'tool_calls', 'content_filter']);
|
|
42
|
+
const nonEmptyString = (cond: unknown): cond is string => typeof cond === 'string' && cond.length > 0;
|
|
43
|
+
|
|
44
|
+
/** The tool NAMES the trailing `role:"tool"` message answers, resolved through the tool_call ids
|
|
45
|
+
* of earlier assistant turns. */
|
|
46
|
+
function lastToolResultNames(messages: ChatMessage[]): Set<string> {
|
|
47
|
+
const names = new Set<string>();
|
|
48
|
+
const last = messages[messages.length - 1] as { role?: string; tool_call_id?: unknown } | undefined;
|
|
49
|
+
if (!last || last.role !== 'tool' || typeof last.tool_call_id !== 'string') return names;
|
|
50
|
+
for (const m of messages) {
|
|
51
|
+
const am = m as { role?: string; tool_calls?: unknown };
|
|
52
|
+
if (am.role !== 'assistant' || !Array.isArray(am.tool_calls)) continue;
|
|
53
|
+
for (const tc of am.tool_calls as Array<{ id?: unknown; function?: { name?: unknown } }>) {
|
|
54
|
+
if (tc?.id === last.tool_call_id && typeof tc?.function?.name === 'string') names.add(tc.function.name);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return names;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toolNames(tools: unknown): string[] {
|
|
61
|
+
if (!Array.isArray(tools)) return [];
|
|
62
|
+
return (tools as Array<{ function?: { name?: unknown }; name?: unknown }>)
|
|
63
|
+
.map((t) => (typeof t?.function?.name === 'string' ? t.function.name : typeof t?.name === 'string' ? t.name : null))
|
|
64
|
+
.filter((n): n is string => n !== null);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const openrouterScenarioAdapter: PackScenarioAdapter<OpenRouterScenarioRequest> = {
|
|
68
|
+
vendor: 'openrouter',
|
|
69
|
+
features: (req): ScenarioFeatures => ({
|
|
70
|
+
model: req.model,
|
|
71
|
+
lastUserText: lastUserText(req.messages).slice(0, 300),
|
|
72
|
+
tools: toolNames(req.tools),
|
|
73
|
+
lastMessageIsToolResult: (req.messages[req.messages.length - 1] as { role?: string } | undefined)?.role === 'tool',
|
|
74
|
+
toolResultFor: [...lastToolResultNames(req.messages)],
|
|
75
|
+
}),
|
|
76
|
+
matchers: {
|
|
77
|
+
// The routed model id as the caller asked for it, with OpenRouter's `:online` suffix already
|
|
78
|
+
// stripped by the chat route (the suffix selects the web plugin, not a different model).
|
|
79
|
+
modelEquals: (req, cond) => nonEmptyString(cond) && req.model === cond,
|
|
80
|
+
userTextIncludes: (req, cond) => nonEmptyString(cond) && lastUserText(req.messages).toLowerCase().includes(cond.toLowerCase()),
|
|
81
|
+
anyTextIncludes: (req, cond) => nonEmptyString(cond) && req.messages.map((m) => contentToText(m.content)).join('\n').toLowerCase().includes(cond.toLowerCase()),
|
|
82
|
+
lastMessageIsToolResult: (req, cond) => typeof cond === 'boolean' && ((req.messages[req.messages.length - 1] as { role?: string } | undefined)?.role === 'tool') === cond,
|
|
83
|
+
toolResultFor: (req, cond) => nonEmptyString(cond) && lastToolResultNames(req.messages).has(cond),
|
|
84
|
+
hasTool: (req, cond) => nonEmptyString(cond) && toolNames(req.tools).includes(cond),
|
|
85
|
+
},
|
|
86
|
+
text: (req) => req.messages.map((m) => contentToText(m.content)).join('\n'),
|
|
87
|
+
validateOn: (on) => {
|
|
88
|
+
for (const k of ['modelEquals', 'userTextIncludes', 'anyTextIncludes', 'toolResultFor', 'hasTool'] as const) {
|
|
89
|
+
if (on[k] !== undefined && (typeof on[k] !== 'string' || !on[k])) return `on.${k} is a non-empty string`;
|
|
90
|
+
}
|
|
91
|
+
if (on.lastMessageIsToolResult !== undefined && typeof on.lastMessageIsToolResult !== 'boolean') return 'on.lastMessageIsToolResult is a boolean';
|
|
92
|
+
return null;
|
|
93
|
+
},
|
|
94
|
+
validateRespond: (respond) => {
|
|
95
|
+
if (typeof respond !== 'object' || respond === null || Array.isArray(respond)) return 'respond is an object { text?, toolCalls?, finishReason? }';
|
|
96
|
+
const r = respond as Record<string, unknown>;
|
|
97
|
+
for (const k of Object.keys(r)) if (!RESPOND_KEYS.has(k)) return `respond: unknown key "${k}" (valid: ${[...RESPOND_KEYS].join(', ')})`;
|
|
98
|
+
if (r.text !== undefined && typeof r.text !== 'string') return 'respond.text is a string';
|
|
99
|
+
if (r.finishReason !== undefined && (typeof r.finishReason !== 'string' || !FINISH_REASONS.has(r.finishReason))) return `respond.finishReason is one of ${[...FINISH_REASONS].join(', ')}`;
|
|
100
|
+
if (r.toolCalls !== undefined) {
|
|
101
|
+
for (const tc of Array.isArray(r.toolCalls) ? r.toolCalls : [r.toolCalls]) {
|
|
102
|
+
const t = tc as Record<string, unknown>;
|
|
103
|
+
if (!t || typeof t !== 'object' || Array.isArray(t)) return 'respond.toolCalls entries are objects';
|
|
104
|
+
if (typeof t.name !== 'string' || !t.name) return 'respond.toolCalls[].name is a non-empty string';
|
|
105
|
+
if (!t.arguments || typeof t.arguments !== 'object' || Array.isArray(t.arguments)) return 'respond.toolCalls[].arguments is an object';
|
|
106
|
+
if (t.id !== undefined && typeof t.id !== 'string') return 'respond.toolCalls[].id is a string';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (r.text === undefined && r.toolCalls === undefined) return 'respond needs text or toolCalls';
|
|
110
|
+
return null;
|
|
111
|
+
},
|
|
112
|
+
renderFault: (fault) => ({
|
|
113
|
+
body: { error: { message: fault.message ?? `twin fault: ${fault.status}`, type: 'twin_fault', code: 'twin_fault' } },
|
|
114
|
+
}),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Load + strictly validate a handlers document (handlers/openrouter.json). A broken file fails
|
|
118
|
+
* server construction loudly; it never falls back or misfires silently. */
|
|
119
|
+
export function loadOpenRouterScenarioDocument(path: string): ScenarioDocument {
|
|
120
|
+
let parsed: unknown;
|
|
121
|
+
try {
|
|
122
|
+
// Read through the ACTIVE WorldStore, never the filesystem directly (runtime contract R12b):
|
|
123
|
+
// the handlers document is WORLD STATE, so a MemoryWorldStore / DO-backed world serves ITS
|
|
124
|
+
// OWN scenario instead of whatever happens to sit on the host disk — and the serve path
|
|
125
|
+
// stays workerd-clean.
|
|
126
|
+
const raw = getActiveWorldStore().read(path);
|
|
127
|
+
if (raw === null) throw new Error(`ENOENT: no such file or directory, open '${path}'`);
|
|
128
|
+
parsed = JSON.parse(raw);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
throw new ScenarioError(`openrouter scenario: cannot read/parse ${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
131
|
+
}
|
|
132
|
+
return parseScenarioDocument(parsed, openrouterScenarioAdapter);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createOpenRouterScenarioEngine(document?: ScenarioDocument): OpenRouterScenarioEngine {
|
|
136
|
+
return new ScenarioEngine(openrouterScenarioAdapter, document);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Realize a fired handler's respond payload into the OpenAI-shaped assistant turn. Scripted
|
|
140
|
+
* tool-call ids are DERIVED from the call itself (name + arguments), never a counter: two
|
|
141
|
+
* identical worlds replaying the same script mint the same id (R9). */
|
|
142
|
+
export function realizeOpenRouterRespond(respond: OpenRouterScenarioRespond): ScriptedResult {
|
|
143
|
+
const toolCalls: Array<Record<string, unknown>> = [];
|
|
144
|
+
for (const tc of respond.toolCalls ? (Array.isArray(respond.toolCalls) ? respond.toolCalls : [respond.toolCalls]) : []) {
|
|
145
|
+
toolCalls.push({
|
|
146
|
+
id: tc.id ?? `call_scripted_${stableHash({ name: tc.name, arguments: tc.arguments }).slice(0, 8)}`,
|
|
147
|
+
type: 'function',
|
|
148
|
+
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
text: respond.text ?? (toolCalls.length ? null : ''),
|
|
153
|
+
toolCalls,
|
|
154
|
+
finishReason: respond.finishReason ?? (toolCalls.length ? 'tool_calls' : 'stop'),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
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.ts';
|
|
14
|
+
import { worldNow, twinManifest } from '@volter/world-core';
|
|
15
|
+
import { createOpenRouterScenarioEngine, loadOpenRouterScenarioDocument, type OpenRouterScenarioEngine } from './openrouter-scenario.ts';
|
|
16
|
+
import type { SseEvent } from './openrouter-types.ts';
|
|
17
|
+
import { forwardLocalGeneration, localGenerationOrigin, readLocalGenerationBody, type LocalGenerationFetch } from './openrouter-local-generation.ts';
|
|
18
|
+
|
|
19
|
+
function wantsStream(body: string): boolean {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(body || '{}')?.stream === true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function encodeSse(event: SseEvent): string {
|
|
28
|
+
return event.done ? 'data: [DONE]\n\n' : `data: ${JSON.stringify(event.data)}\n\n`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Options every OpenRouter-twin HTTP surface needs, independent of who owns the socket. */
|
|
32
|
+
export interface OpenRouterTwinFetchOptions {
|
|
33
|
+
root?: string;
|
|
34
|
+
readOnly?: boolean;
|
|
35
|
+
/** Path to the world dir's handlers/openrouter.json — read ONCE, at construction, through the
|
|
36
|
+
* active WorldStore. Absent → no engine, and the labeled deterministic stub answers. */
|
|
37
|
+
scenarioPath?: string;
|
|
38
|
+
/** Explicitly selected World-owned loopback service. Absent means deterministic twin output. */
|
|
39
|
+
localGenerationUrl?: string;
|
|
40
|
+
/** Injectable transport for deterministic pack tests; the production default is global fetch. */
|
|
41
|
+
localGenerationFetch?: LocalGenerationFetch;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createOpenRouterTwinFetch(options: OpenRouterTwinFetchOptions): (request: Request) => Promise<Response> {
|
|
45
|
+
const readOnly = options.readOnly ?? false;
|
|
46
|
+
const scenarioPath = options.scenarioPath ?? process.env.TWIN_OPENROUTER_SCENARIO;
|
|
47
|
+
const scenarioEngine: OpenRouterScenarioEngine | undefined = scenarioPath ? createOpenRouterScenarioEngine(loadOpenRouterScenarioDocument(scenarioPath)) : undefined;
|
|
48
|
+
const selectedUrl = options.localGenerationUrl !== undefined ? options.localGenerationUrl : process.env.TWIN_OPENROUTER_LOCAL_GENERATION_URL;
|
|
49
|
+
const localOrigin = selectedUrl === undefined ? undefined : localGenerationOrigin(selectedUrl);
|
|
50
|
+
return async function openRouterTwinFetch(request: Request): Promise<Response> {
|
|
51
|
+
const url = new URL(request.url);
|
|
52
|
+
// THE READ DOORS (runtime contract R5, TWIN-PROGRAMMING-MODEL): discovery + inspection,
|
|
53
|
+
// keyless and read-only. They answer BEFORE any vendor routing, so a door is never an
|
|
54
|
+
// authenticated vendor route.
|
|
55
|
+
if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
|
|
56
|
+
return Response.json(twinManifest({
|
|
57
|
+
vendor: 'openrouter',
|
|
58
|
+
twinOf: 'the OpenRouter API',
|
|
59
|
+
stateSentence: localOrigin
|
|
60
|
+
? '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.'
|
|
61
|
+
: '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.',
|
|
62
|
+
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.'}`,
|
|
63
|
+
exampleHandler: { on: { userTextIncludes: 'summarize' }, respond: { text: 'Scripted summary.' } },
|
|
64
|
+
engine: scenarioEngine as never,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin/scenario') {
|
|
68
|
+
return Response.json(scenarioEngine ? scenarioEngine.status() : { vendor: 'openrouter', handlers: [], misses: 0, recentMisses: [] });
|
|
69
|
+
}
|
|
70
|
+
const path = url.pathname + url.search;
|
|
71
|
+
const normalizedPath = url.pathname.replace(/^\/api\/v1/, '/v1');
|
|
72
|
+
const localRoute = localOrigin !== undefined && request.method.toUpperCase() === 'POST'
|
|
73
|
+
&& (normalizedPath === '/v1/chat/completions' || normalizedPath === '/v1/responses');
|
|
74
|
+
if (localRoute && readOnly) {
|
|
75
|
+
const refusal = await handleOpenRouterTwinRequest({ method: request.method, path, readOnly });
|
|
76
|
+
return new Response(JSON.stringify(refusal.body), { status: refusal.status, headers: { 'content-type': 'application/json' } });
|
|
77
|
+
}
|
|
78
|
+
let body: string;
|
|
79
|
+
let localBytes: Uint8Array<ArrayBuffer> | undefined;
|
|
80
|
+
if (localRoute) {
|
|
81
|
+
try {
|
|
82
|
+
const bounded = await readLocalGenerationBody(request);
|
|
83
|
+
body = bounded.text;
|
|
84
|
+
localBytes = bounded.bytes;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (request.signal.aborted) throw error;
|
|
87
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
88
|
+
const status = message.startsWith('local_generation_body_too_large') ? 413 : message.startsWith('local_generation_invalid_utf8') ? 400 : 500;
|
|
89
|
+
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 });
|
|
90
|
+
}
|
|
91
|
+
} else body = request.method === 'GET' ? '' : await request.text();
|
|
92
|
+
|
|
93
|
+
const events: SseEvent[] = [];
|
|
94
|
+
const streamChat = request.method.toUpperCase() === 'POST' && normalizedPath === '/v1/chat/completions' && wantsStream(body);
|
|
95
|
+
const twinRequest = {
|
|
96
|
+
method: request.method,
|
|
97
|
+
path,
|
|
98
|
+
body,
|
|
99
|
+
readOnly,
|
|
100
|
+
signal: request.signal,
|
|
101
|
+
occurredAt: worldNow(),
|
|
102
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
103
|
+
...(scenarioEngine ? { scenarioEngine } : {}),
|
|
104
|
+
...(streamChat ? { sseSink: (event: SseEvent) => events.push(event) } : {}),
|
|
105
|
+
};
|
|
106
|
+
const result = localRoute && localOrigin && localBytes
|
|
107
|
+
? await handleOpenRouterTwinRequestWithGeneration(twinRequest, () => forwardLocalGeneration({
|
|
108
|
+
origin: localOrigin, request, pathname: url.pathname, body: localBytes,
|
|
109
|
+
...(options.localGenerationFetch ? { fetchImpl: options.localGenerationFetch } : {}),
|
|
110
|
+
}))
|
|
111
|
+
: await handleOpenRouterTwinRequest(twinRequest);
|
|
112
|
+
if (result instanceof Response) return result;
|
|
113
|
+
|
|
114
|
+
// Streaming POST → a real text/event-stream response built from the sink.
|
|
115
|
+
//
|
|
116
|
+
// Events are COLLECTED first, then framed. The handler is synchronous-fast, so buffering
|
|
117
|
+
// costs nothing — and it is what lets a PRE-STREAM failure answer with its REAL status and
|
|
118
|
+
// the vendor's JSON error envelope. Emitting the refusal as a lone `data:` frame inside a
|
|
119
|
+
// 200 text/event-stream was a FAKE SUCCESS the in-process path could not see:
|
|
120
|
+
// `handleOpenRouterTwinRequest` returns 404 for an unknown model and 400 for a body with
|
|
121
|
+
// no `messages`, while the wire returned 200 in both cases — so every verify asserting
|
|
122
|
+
// those statuses asserted something the socket never carried. OpenRouter rejects a bad
|
|
123
|
+
// request BEFORE opening the event stream; this twin decides every refusal before the
|
|
124
|
+
// first chunk.
|
|
125
|
+
if (!readOnly && streamChat) {
|
|
126
|
+
if (result.status >= 400) {
|
|
127
|
+
return new Response(JSON.stringify(result.body), {
|
|
128
|
+
status: result.status,
|
|
129
|
+
headers: { 'content-type': 'application/json', ...result.headers },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
133
|
+
start(controller) {
|
|
134
|
+
const encoder = new TextEncoder();
|
|
135
|
+
for (const event of events) controller.enqueue(encoder.encode(encodeSse(event)));
|
|
136
|
+
controller.close();
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
return new Response(stream, {
|
|
140
|
+
headers: {
|
|
141
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
142
|
+
'cache-control': 'no-cache',
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return new Response(JSON.stringify(result.body), { status: result.status, headers: { 'content-type': 'application/json', ...result.headers } });
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function createOpenRouterTwinServer(options: OpenRouterTwinFetchOptions & { port?: number } = {}): Promise<{ port: number; stop: () => void }> {
|
|
152
|
+
const server = await serveHttp({
|
|
153
|
+
port: options.port ?? 0,
|
|
154
|
+
idleTimeout: 60,
|
|
155
|
+
fetch: createOpenRouterTwinFetch(options),
|
|
156
|
+
});
|
|
157
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
158
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ChatMessage } from './openrouter-types.ts';
|
|
2
|
+
|
|
3
|
+
export function contentToText(content: unknown): string {
|
|
4
|
+
if (typeof content === 'string') return content;
|
|
5
|
+
if (Array.isArray(content)) {
|
|
6
|
+
return content.map((part) => {
|
|
7
|
+
if (typeof part === 'string') return part;
|
|
8
|
+
if (part && typeof part === 'object' && 'text' in part) return String((part as { text?: unknown }).text ?? '');
|
|
9
|
+
return '';
|
|
10
|
+
}).filter(Boolean).join(' ');
|
|
11
|
+
}
|
|
12
|
+
return '';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function lastUserText(messages: ChatMessage[]): string {
|
|
16
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
17
|
+
if (messages[i]?.role === 'user') return contentToText(messages[i]?.content);
|
|
18
|
+
}
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function estimateTokens(input: string): number {
|
|
23
|
+
const trimmed = input.trim();
|
|
24
|
+
if (!trimmed) return 0;
|
|
25
|
+
return Math.max(1, Math.ceil(trimmed.length / 4));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function countPromptTokens(messages: ChatMessage[]): number {
|
|
29
|
+
return messages.reduce((sum, message) => sum + estimateTokens(`${message.role}: ${contentToText(message.content)}`), 0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function stubAssistantText(messages: ChatMessage[], model: string): string {
|
|
33
|
+
const text = lastUserText(messages) || 'empty prompt';
|
|
34
|
+
return `[twin-stub:openrouter:${model}] Echoing the last user message for deterministic simulation: ${text}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function stableHash(value: unknown): string {
|
|
38
|
+
const s = JSON.stringify(value);
|
|
39
|
+
let h = 2166136261;
|
|
40
|
+
for (let i = 0; i < s.length; i++) {
|
|
41
|
+
h ^= s.charCodeAt(i);
|
|
42
|
+
h = Math.imul(h, 16777619);
|
|
43
|
+
}
|
|
44
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function firstToolCall(tools: unknown): Record<string, unknown> | null {
|
|
48
|
+
if (!Array.isArray(tools) || tools.length === 0) return null;
|
|
49
|
+
const tool = tools[0] as Record<string, any>;
|
|
50
|
+
const fn = tool.function && typeof tool.function === 'object' ? tool.function : tool;
|
|
51
|
+
const name = typeof fn.name === 'string' ? fn.name : 'tool';
|
|
52
|
+
return {
|
|
53
|
+
id: `call_twin_${stableHash(name).slice(0, 8)}`,
|
|
54
|
+
type: 'function',
|
|
55
|
+
function: {
|
|
56
|
+
name,
|
|
57
|
+
arguments: JSON.stringify({ twin: true, input: 'deterministic tool arguments' }),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|