@volter/twin-moonshot 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 +164 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +25 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +86 -0
- package/dist/src/moonshot-budget.d.ts +57 -0
- package/dist/src/moonshot-budget.js +142 -0
- package/dist/src/moonshot-capabilities.d.ts +4 -0
- package/dist/src/moonshot-capabilities.js +1200 -0
- package/dist/src/moonshot-conformance.d.ts +14 -0
- package/dist/src/moonshot-conformance.js +405 -0
- package/dist/src/moonshot-connector.d.ts +168 -0
- package/dist/src/moonshot-connector.js +416 -0
- package/dist/src/moonshot-models.d.ts +36 -0
- package/dist/src/moonshot-models.js +37 -0
- package/dist/src/moonshot-scenario.d.ts +54 -0
- package/dist/src/moonshot-scenario.js +175 -0
- package/dist/src/moonshot-server.d.ts +13 -0
- package/dist/src/moonshot-server.js +202 -0
- package/dist/src/moonshot-stub.d.ts +70 -0
- package/dist/src/moonshot-stub.js +222 -0
- package/dist/src/moonshot-twin.d.ts +144 -0
- package/dist/src/moonshot-twin.js +1647 -0
- package/dist/src/moonshot-types.d.ts +251 -0
- package/dist/src/moonshot-types.js +19 -0
- package/package.json +53 -0
- package/src/cli.ts +25 -0
- package/src/index.ts +129 -0
- package/src/moonshot-budget.ts +163 -0
- package/src/moonshot-capabilities.ts +1220 -0
- package/src/moonshot-conformance.ts +416 -0
- package/src/moonshot-connector.ts +465 -0
- package/src/moonshot-models.ts +89 -0
- package/src/moonshot-scenario.ts +194 -0
- package/src/moonshot-server.ts +220 -0
- package/src/moonshot-stub.ts +230 -0
- package/src/moonshot-twin.ts +1670 -0
- package/src/moonshot-types.ts +225 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// The moonshot pack's scenario system on the kernel's ONE engine (@volter/world-core
|
|
2
|
+
// scenario.ts): Moonshot chat-completions vocabulary + the scripted-turn respond shape. THE
|
|
3
|
+
// KERNEL ENGINE IS NOT RE-IMPLEMENTED HERE — this file is only the pack's adapter (features,
|
|
4
|
+
// matchers, load validation) plus the realizer that turns a validated `respond` into Moonshot's
|
|
5
|
+
// own envelope shapes.
|
|
6
|
+
//
|
|
7
|
+
// The handler FILE (handlers/moonshot.json in a world dir) is the only write surface; the doors
|
|
8
|
+
// (`GET /twin`, `GET /twin/scenario`) are read-only. Scenario support is twin-only scaffolding
|
|
9
|
+
// for eval worlds, NOT vendor surface: the manifest's `moonshot.scenario.*` entries verify THIS
|
|
10
|
+
// scaffolding (filed under their own `scenario` area, section-commented "NOT vendor surface"),
|
|
11
|
+
// and the behavior is gated by moonshot-scenario.test.ts.
|
|
12
|
+
import { getActiveWorldStore, parseScenarioDocument, type PackScenarioAdapter, ScenarioError, type ScenarioDocument, ScenarioEngine, type ScenarioFeatures } from '@volter/world-core';
|
|
13
|
+
import { contentToText, fnv1a, lastUserText } from './moonshot-stub.ts';
|
|
14
|
+
import type { MoonshotMessageParam, MoonshotToolCall } from './moonshot-types.ts';
|
|
15
|
+
|
|
16
|
+
export type MoonshotScenarioRequest = {
|
|
17
|
+
model: string;
|
|
18
|
+
messages: MoonshotMessageParam[];
|
|
19
|
+
tools?: unknown;
|
|
20
|
+
/** Moonshot-specific: the k2.6/k2.7-code `thinking` object. Scriptable because thinking mode
|
|
21
|
+
* decides whether `reasoning_content` appears on the response. */
|
|
22
|
+
thinking?: { type?: string; keep?: string | null };
|
|
23
|
+
};
|
|
24
|
+
export type MoonshotScenarioEngine = ScenarioEngine<MoonshotScenarioRequest>;
|
|
25
|
+
|
|
26
|
+
export type ScenarioToolCall = { name: string; arguments: Record<string, unknown>; id?: string };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What a moonshot handler may script. `reasoning` is Moonshot-specific (the `reasoning_content`
|
|
30
|
+
* field on the assistant message, and the thinking block on the Messages surface); `error`
|
|
31
|
+
* scripts one of Moonshot's own documented failure envelopes rather than a success.
|
|
32
|
+
*/
|
|
33
|
+
export type MoonshotScenarioRespond = {
|
|
34
|
+
text?: string;
|
|
35
|
+
reasoning?: string;
|
|
36
|
+
toolCalls?: ScenarioToolCall | ScenarioToolCall[];
|
|
37
|
+
finishReason?: 'stop' | 'length' | 'tool_calls';
|
|
38
|
+
/** A scripted vendor failure: Moonshot's documented error types (platform.kimi.ai/docs/api/errors). */
|
|
39
|
+
error?: { type: 'rate_limit_reached_error' | 'server_unavailable' | 'server_error'; message?: string };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type ScriptedResult = {
|
|
43
|
+
text: string | null;
|
|
44
|
+
reasoning: string | null;
|
|
45
|
+
toolCalls: MoonshotToolCall[];
|
|
46
|
+
finishReason: 'stop' | 'length' | 'tool_calls';
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const RESPOND_KEYS = new Set(['text', 'reasoning', 'toolCalls', 'finishReason', 'error']);
|
|
50
|
+
const FINISH_REASONS = new Set(['stop', 'length', 'tool_calls']);
|
|
51
|
+
const ERROR_TYPES = new Set(['rate_limit_reached_error', 'server_unavailable', 'server_error']);
|
|
52
|
+
const THINKING_TYPES = new Set(['enabled', 'disabled']);
|
|
53
|
+
const nonEmptyString = (cond: unknown): cond is string => typeof cond === 'string' && cond.length > 0;
|
|
54
|
+
|
|
55
|
+
function lastToolResultNames(messages: MoonshotMessageParam[]): Set<string> {
|
|
56
|
+
const names = new Set<string>();
|
|
57
|
+
const last = messages[messages.length - 1] as { role?: string; tool_call_id?: unknown } | undefined;
|
|
58
|
+
if (!last || last.role !== 'tool' || typeof last.tool_call_id !== 'string') return names;
|
|
59
|
+
for (const m of messages) {
|
|
60
|
+
const am = m as { role?: string; tool_calls?: Array<{ id?: unknown; function?: { name?: unknown } }> };
|
|
61
|
+
if (am.role !== 'assistant' || !Array.isArray(am.tool_calls)) continue;
|
|
62
|
+
for (const tc of am.tool_calls) {
|
|
63
|
+
if (tc?.id === last.tool_call_id && typeof tc?.function?.name === 'string') names.add(tc.function.name);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return names;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function toolNames(tools: unknown): string[] {
|
|
70
|
+
if (!Array.isArray(tools)) return [];
|
|
71
|
+
return (tools as Array<{ function?: { name?: unknown }; name?: unknown }>).map((t) =>
|
|
72
|
+
typeof t?.function?.name === 'string' ? t.function.name : typeof t?.name === 'string' ? t.name : null,
|
|
73
|
+
).filter((n): n is string => n !== null);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const moonshotScenarioAdapter: PackScenarioAdapter<MoonshotScenarioRequest> = {
|
|
77
|
+
vendor: 'moonshot',
|
|
78
|
+
// R15 — a status fault in THIS vendor's envelope: the same {error:{message,type}} bodies
|
|
79
|
+
// moonshot-twin.ts serves for its own refusals (its 429 is `type: 'rate_limit_reached_error'`,
|
|
80
|
+
// its outage is `server_unavailable`). The Messages surface re-wears the body in the Anthropic
|
|
81
|
+
// envelope at its own route; the fault result carries the Moonshot shape.
|
|
82
|
+
renderFault: (f) => ({
|
|
83
|
+
body: f.status === 429
|
|
84
|
+
? { error: { message: f.message ?? 'Request rate limit reached: 3 requests per minute (Tier 0). Please retry after 20 seconds.', type: 'rate_limit_reached_error' } }
|
|
85
|
+
: f.status >= 500
|
|
86
|
+
? { error: { message: f.message ?? 'The server is overloaded or not ready to handle the request. Please try again later.', type: f.status === 503 ? 'server_unavailable' : 'server_error' } }
|
|
87
|
+
: { error: { message: f.message ?? 'The request was refused by a scripted fault.', type: 'invalid_request_error' } },
|
|
88
|
+
}),
|
|
89
|
+
features: (req): ScenarioFeatures => ({
|
|
90
|
+
model: req.model,
|
|
91
|
+
lastUserText: lastUserText(req.messages as MoonshotMessageParam[]).slice(0, 300),
|
|
92
|
+
tools: toolNames(req.tools),
|
|
93
|
+
lastMessageIsToolResult: (req.messages[req.messages.length - 1] as { role?: string } | undefined)?.role === 'tool',
|
|
94
|
+
toolResultFor: [...lastToolResultNames(req.messages)],
|
|
95
|
+
thinkingType: req.thinking?.type ?? 'enabled',
|
|
96
|
+
}),
|
|
97
|
+
matchers: {
|
|
98
|
+
modelEquals: (req, cond) => nonEmptyString(cond) && req.model === cond,
|
|
99
|
+
userTextIncludes: (req, cond) => nonEmptyString(cond) && lastUserText(req.messages as MoonshotMessageParam[]).toLowerCase().includes(cond.toLowerCase()),
|
|
100
|
+
anyTextIncludes: (req, cond) => nonEmptyString(cond) && req.messages.map((m) => contentToText(m.content)).join('\n').toLowerCase().includes(cond.toLowerCase()),
|
|
101
|
+
lastMessageIsToolResult: (req, cond) => typeof cond === 'boolean' && ((req.messages[req.messages.length - 1] as { role?: string } | undefined)?.role === 'tool') === cond,
|
|
102
|
+
toolResultFor: (req, cond) => nonEmptyString(cond) && lastToolResultNames(req.messages).has(cond),
|
|
103
|
+
hasTool: (req, cond) => nonEmptyString(cond) && toolNames(req.tools).includes(cond),
|
|
104
|
+
// Moonshot-specific: script behaviour per thinking mode (kimi-k2.6 accepts 'disabled';
|
|
105
|
+
// kimi-k2.7-code does not).
|
|
106
|
+
thinkingTypeEquals: (req, cond) => nonEmptyString(cond) && (req.thinking?.type ?? 'enabled') === cond,
|
|
107
|
+
},
|
|
108
|
+
text: (req) => req.messages.map((m) => contentToText(m.content)).join('\n'),
|
|
109
|
+
validateOn: (on) => {
|
|
110
|
+
for (const k of ['modelEquals', 'userTextIncludes', 'anyTextIncludes', 'toolResultFor', 'hasTool'] as const) {
|
|
111
|
+
if (on[k] !== undefined && (typeof on[k] !== 'string' || !on[k])) return `on.${k} is a non-empty string`;
|
|
112
|
+
}
|
|
113
|
+
if (on.lastMessageIsToolResult !== undefined && typeof on.lastMessageIsToolResult !== 'boolean') return 'on.lastMessageIsToolResult is a boolean';
|
|
114
|
+
if (on.thinkingTypeEquals !== undefined) {
|
|
115
|
+
if (typeof on.thinkingTypeEquals !== 'string' || !THINKING_TYPES.has(on.thinkingTypeEquals)) {
|
|
116
|
+
return `on.thinkingTypeEquals is one of ${[...THINKING_TYPES].join(', ')}`;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
},
|
|
121
|
+
validateRespond: (respond) => {
|
|
122
|
+
if (typeof respond !== 'object' || respond === null || Array.isArray(respond)) return 'respond is an object { text?, reasoning?, toolCalls?, finishReason?, error? }';
|
|
123
|
+
const r = respond as Record<string, unknown>;
|
|
124
|
+
for (const k of Object.keys(r)) if (!RESPOND_KEYS.has(k)) return `respond: unknown key "${k}" (valid: ${[...RESPOND_KEYS].join(', ')})`;
|
|
125
|
+
if (r.error !== undefined) {
|
|
126
|
+
const e = r.error as Record<string, unknown>;
|
|
127
|
+
if (!e || typeof e !== 'object' || Array.isArray(e)) return 'respond.error is an object { type, message? }';
|
|
128
|
+
if (typeof e.type !== 'string' || !ERROR_TYPES.has(e.type)) return `respond.error.type is one of ${[...ERROR_TYPES].join(', ')}`;
|
|
129
|
+
if (e.message !== undefined && typeof e.message !== 'string') return 'respond.error.message is a string';
|
|
130
|
+
// An error handler scripts a FAILURE — mixing it with success content is a mis-typed rule.
|
|
131
|
+
for (const k of ['text', 'reasoning', 'toolCalls', 'finishReason']) if (r[k] !== undefined) return `respond.error cannot be combined with respond.${k}`;
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
if (r.text !== undefined && typeof r.text !== 'string') return 'respond.text is a string';
|
|
135
|
+
if (r.reasoning !== undefined && typeof r.reasoning !== 'string') return 'respond.reasoning is a string';
|
|
136
|
+
if (r.finishReason !== undefined && (typeof r.finishReason !== 'string' || !FINISH_REASONS.has(r.finishReason))) return `respond.finishReason is one of ${[...FINISH_REASONS].join(', ')}`;
|
|
137
|
+
if (r.toolCalls !== undefined) {
|
|
138
|
+
for (const tc of Array.isArray(r.toolCalls) ? r.toolCalls : [r.toolCalls]) {
|
|
139
|
+
const t = tc as Record<string, unknown>;
|
|
140
|
+
if (!t || typeof t !== 'object' || Array.isArray(t)) return 'respond.toolCalls entries are objects';
|
|
141
|
+
if (typeof t.name !== 'string' || !t.name) return 'respond.toolCalls[].name is a non-empty string';
|
|
142
|
+
if (!t.arguments || typeof t.arguments !== 'object' || Array.isArray(t.arguments)) return 'respond.toolCalls[].arguments is an object';
|
|
143
|
+
if (t.id !== undefined && typeof t.id !== 'string') return 'respond.toolCalls[].id is a string';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (r.text === undefined && r.toolCalls === undefined) return 'respond needs text or toolCalls (or an error)';
|
|
147
|
+
return null;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Load + STRICTLY validate a scenario document. A malformed file throws at load with what is
|
|
152
|
+
* wrong — never a silent ignore-and-stub (a mis-typed rule falling back is a fake success). */
|
|
153
|
+
export function loadMoonshotScenarioDocument(path: string): ScenarioDocument {
|
|
154
|
+
let parsed: unknown;
|
|
155
|
+
try {
|
|
156
|
+
// Read through the ACTIVE WorldStore, never the filesystem directly (runtime contract
|
|
157
|
+
// R12b): the handlers document is WORLD STATE, so a MemoryWorldStore / DO-backed world
|
|
158
|
+
// serves ITS OWN scenario instead of whatever happens to sit on the host disk — and the
|
|
159
|
+
// serve path stays workerd-clean. A missing document keeps the historical ENOENT wording,
|
|
160
|
+
// so the loud load-time failure reads byte-identically to the read it replaces.
|
|
161
|
+
const raw = getActiveWorldStore().read(path);
|
|
162
|
+
if (raw === null) throw new Error(`ENOENT: no such file or directory, open '${path}'`);
|
|
163
|
+
parsed = JSON.parse(raw);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
throw new ScenarioError(`moonshot scenario: cannot read/parse ${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
166
|
+
}
|
|
167
|
+
return parseScenarioDocument(parsed, moonshotScenarioAdapter);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function createMoonshotScenarioEngine(document?: ScenarioDocument): MoonshotScenarioEngine {
|
|
171
|
+
return new ScenarioEngine(moonshotScenarioAdapter, document);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Turn a validated `respond` into the pack's own faithful assistant turn.
|
|
176
|
+
*
|
|
177
|
+
* Determinism (CLAUDE.md): the tool-call id is derived from the scripted call's own content and
|
|
178
|
+
* its position in the handler — never a module-level counter, which would make two identical
|
|
179
|
+
* scripted requests answer differently after a restart.
|
|
180
|
+
*/
|
|
181
|
+
export function realizeMoonshotRespond(respond: MoonshotScenarioRespond): ScriptedResult {
|
|
182
|
+
const toolCalls: MoonshotToolCall[] = [];
|
|
183
|
+
const scripted = respond.toolCalls ? (Array.isArray(respond.toolCalls) ? respond.toolCalls : [respond.toolCalls]) : [];
|
|
184
|
+
scripted.forEach((tc, index) => {
|
|
185
|
+
const seed = `${index}:${tc.name}:${JSON.stringify(tc.arguments)}`;
|
|
186
|
+
toolCalls.push({ id: tc.id ?? `call_scripted_${fnv1a(seed).toString(36)}`, type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.arguments) } });
|
|
187
|
+
});
|
|
188
|
+
return {
|
|
189
|
+
text: respond.text ?? (toolCalls.length ? null : ''),
|
|
190
|
+
reasoning: respond.reasoning ?? null,
|
|
191
|
+
toolCalls,
|
|
192
|
+
finishReason: respond.finishReason ?? (toolCalls.length ? 'tool_calls' : 'stop'),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Moonshot twin HTTP server — serve the full Moonshot twin handler over HTTP so the REAL clients
|
|
2
|
+
// work UNMODIFIED: the standard `openai` SDK with `baseURL: 'http://127.0.0.1:<port>/v1'`, the
|
|
3
|
+
// `anthropic` SDK with `baseURL: 'http://127.0.0.1:<port>/anthropic'`, and plain HTTP callers.
|
|
4
|
+
// There is no vendor SDK to configure — Moonshot's documented integration path IS these clients
|
|
5
|
+
// with a swapped base URL (platform.kimi.ai/docs/overview, read 2026-09-16).
|
|
6
|
+
//
|
|
7
|
+
// Streaming: when a chat/responses/messages request body has `"stream": true`, the server
|
|
8
|
+
// constructs a REAL SSE response by feeding the handler an injected sink — OpenAI grammar writes
|
|
9
|
+
// `data: <json>\n\n` frames ending with `data: [DONE]\n\n`; the Anthropic-compatible Messages
|
|
10
|
+
// surface and the Responses surface write `event: <name>\ndata: <json>\n\n` frames (Anthropic's
|
|
11
|
+
// grammar has no [DONE] sentinel; the stream ends after `message_stop` / `response.completed`).
|
|
12
|
+
//
|
|
13
|
+
// Multipart: POST /v1/files takes `multipart/form-data`. The server parses the form into the
|
|
14
|
+
// handler's JSON contract so the handler stays a pure JSON function.
|
|
15
|
+
//
|
|
16
|
+
// FETCH-FIRST (runtime contract R12b): the surface is the plain `createMoonshotTwinFetch` and the
|
|
17
|
+
// SERVER is one line of `Bun.serve` around it. This is a CUSTOM fetch, not the kernel adapter
|
|
18
|
+
// (`createTwinFetchFromHandler`): three SSE grammars plus the multipart adaptation genuinely
|
|
19
|
+
// exceed the common shape — openai-server.ts is the reference for that lane.
|
|
20
|
+
import { serveHttp, twinManifest, worldNow } from '@volter/world-core';
|
|
21
|
+
import {
|
|
22
|
+
handleMoonshotTwinRequest,
|
|
23
|
+
MESSAGES_PREFIX,
|
|
24
|
+
MOONSHOT_API_PREFIX,
|
|
25
|
+
type MessagesSseSink,
|
|
26
|
+
} from './moonshot-twin.ts';
|
|
27
|
+
import { createMoonshotScenarioEngine, loadMoonshotScenarioDocument, type MoonshotScenarioEngine } from './moonshot-scenario.ts';
|
|
28
|
+
import type { MessagesSseEvent, SseEvent, SseSink } from './moonshot-types.ts';
|
|
29
|
+
|
|
30
|
+
function wantsStream(body: string): boolean {
|
|
31
|
+
if (!body) return false;
|
|
32
|
+
try {
|
|
33
|
+
return (JSON.parse(body) as { stream?: unknown })?.stream === true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function encodeSse(event: SseEvent): string {
|
|
40
|
+
if (event.done) return 'data: [DONE]\n\n';
|
|
41
|
+
return `data: ${JSON.stringify(event.data)}\n\n`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Named-event framing (Anthropic Messages / Responses grammar): `event: <name>\ndata: <json>`. */
|
|
45
|
+
function encodeNamedSse(event: MessagesSseEvent): string {
|
|
46
|
+
if (event.done) return '\n';
|
|
47
|
+
const name = event.event ?? 'message';
|
|
48
|
+
return `event: ${name}\ndata: ${JSON.stringify(event.data)}\n\n`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The three streaming POST endpoints, each with its own wire grammar.
|
|
52
|
+
const OPENAI_STREAMABLE = new Set([`${MOONSHOT_API_PREFIX}/chat/completions`]);
|
|
53
|
+
const NAMED_STREAMABLE = new Set([`${MESSAGES_PREFIX}/messages`, `${MOONSHOT_API_PREFIX}/responses`]);
|
|
54
|
+
|
|
55
|
+
async function multipartToJson(request: Request): Promise<string> {
|
|
56
|
+
try {
|
|
57
|
+
const form = await request.formData();
|
|
58
|
+
const out: Record<string, unknown> = {};
|
|
59
|
+
// Forward every scalar field generically: `purpose` and `filename` on Moonshot's Files API.
|
|
60
|
+
for (const [key, value] of form.entries()) {
|
|
61
|
+
if (typeof value === 'string') {
|
|
62
|
+
if (key.endsWith('[]')) {
|
|
63
|
+
const k = key.slice(0, -2);
|
|
64
|
+
const prior = out[k];
|
|
65
|
+
out[k] = Array.isArray(prior) ? [...prior, value] : [value];
|
|
66
|
+
} else {
|
|
67
|
+
out[key] = value;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const file = form.get('file');
|
|
72
|
+
if (!(file instanceof File)) {
|
|
73
|
+
// The vendor's Upload File REQUIRES the file body (§9 round two, F3): a form without one
|
|
74
|
+
// reaches createFile marked, and the handler refuses it rather than minting an empty
|
|
75
|
+
// 'ready' file. (form.get returns the FIRST 'file' part when several are sent; the
|
|
76
|
+
// vendor's form has one.)
|
|
77
|
+
out._multipart_missing_file = true;
|
|
78
|
+
}
|
|
79
|
+
if (file instanceof File) {
|
|
80
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
81
|
+
out.file = file.name || 'upload';
|
|
82
|
+
out.filename = file.name || 'upload';
|
|
83
|
+
// The handler's JSON contract carries content as a STRING, so binary uploads travel
|
|
84
|
+
// base64-encoded WITH the `binary_content: true` marker; the file row stores the marker
|
|
85
|
+
// and the /content route decodes before serving (the twin imports nothing from the
|
|
86
|
+
// server — the marker is the contract). Encoding was a §9-round-one finding: the adapter
|
|
87
|
+
// used to store the base64 TEXT itself, so a real multipart client got base64 back from
|
|
88
|
+
// GET /content while `bytes` counted the raw length.
|
|
89
|
+
out.content = Buffer.from(bytes).toString('base64');
|
|
90
|
+
out.binary_content = true;
|
|
91
|
+
out.media_type = file.type || '';
|
|
92
|
+
out.bytes = bytes.length;
|
|
93
|
+
}
|
|
94
|
+
return JSON.stringify(out);
|
|
95
|
+
} catch {
|
|
96
|
+
return '{}';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Options every Moonshot-twin HTTP surface needs, independent of who owns the socket. */
|
|
101
|
+
export interface MoonshotTwinFetchOptions {
|
|
102
|
+
root?: string;
|
|
103
|
+
readOnly?: boolean;
|
|
104
|
+
scenarioPath?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createMoonshotTwinFetch(options: MoonshotTwinFetchOptions): (request: Request) => Promise<Response> {
|
|
108
|
+
const readOnly = options.readOnly ?? false;
|
|
109
|
+
// Scenario scripting (moonshot-scenario.ts): a JSON scenario file — via the scenarioPath option
|
|
110
|
+
// or the TWIN_MOONSHOT_SCENARIO env var — scripts the three inference endpoints. Loaded ONCE at
|
|
111
|
+
// startup (a malformed file fails loudly here, never silently).
|
|
112
|
+
const scenarioPath = options.scenarioPath ?? process.env.TWIN_MOONSHOT_SCENARIO;
|
|
113
|
+
const scenarioEngine: MoonshotScenarioEngine | undefined = scenarioPath ? createMoonshotScenarioEngine(loadMoonshotScenarioDocument(scenarioPath)) : undefined;
|
|
114
|
+
return async function moonshotTwinFetch(request: Request): Promise<Response> {
|
|
115
|
+
const url = new URL(request.url);
|
|
116
|
+
// THE READ DOORS (TWIN-PROGRAMMING-MODEL): discovery + inspection, read-only.
|
|
117
|
+
if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
|
|
118
|
+
return Response.json(twinManifest({
|
|
119
|
+
vendor: 'moonshot',
|
|
120
|
+
twinOf: 'Moonshot (Kimi) Platform API — OpenAI-compatible under /v1, Anthropic-compatible under /anthropic/v1; Moonshot ships no SDK of its own, its documented clients are the standard openai/anthropic SDKs with a swapped base URL',
|
|
121
|
+
stateSentence: 'Seed files through the ordinary API (POST /v1/files, multipart) with any key; files, batches and the account balance are stateful, everything else is deterministic.',
|
|
122
|
+
behaviorSentence: "The three inference endpoints are scripted by MSW-shaped handlers in the world dir (handlers/moonshot.json): {on:{userTextIncludes|anyTextIncludes|modelEquals|hasTool|toolResultFor|lastMessageIsToolResult|thinkingTypeEquals}, respond:{text|reasoning|toolCalls, finishReason?} | {error:{type:rate_limit_reached_error|server_unavailable|server_error}}, once?, phase?}. Unmatched requests answer a labeled stub naming this door.",
|
|
123
|
+
exampleHandler: { on: { userTextIncludes: 'summarize', hasTool: 'search_docs' }, respond: { text: 'Scripted summary.' }, once: true },
|
|
124
|
+
engine: scenarioEngine as never,
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin/scenario') {
|
|
128
|
+
return Response.json(scenarioEngine ? scenarioEngine.status() : { vendor: 'moonshot', handlers: [], misses: 0, recentMisses: [] });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const path = url.pathname + (url.search || '');
|
|
132
|
+
// Collapse REPEATED slashes as well as the trailing one, exactly as `routeMoonshot` does when
|
|
133
|
+
// it builds `seg` (and as the budget's split does for its anchored rules) — the two
|
|
134
|
+
// normalizations must agree or `POST //v1/chat/completions` with `stream: true` reaches the
|
|
135
|
+
// streaming ROUTE but misses the STREAMABLE set and is answered with a unary JSON body to a
|
|
136
|
+
// client reading text/event-stream (the groq pack's §9 round-two finding).
|
|
137
|
+
const cleanPath = url.pathname.replace(/\/{2,}/g, '/').replace(/\/+$/, '');
|
|
138
|
+
const contentType = request.headers.get('content-type') ?? '';
|
|
139
|
+
|
|
140
|
+
const passHeaders: Record<string, string> = {};
|
|
141
|
+
// `x-api-key` + `anthropic-version`: the /anthropic surface's documented client is the
|
|
142
|
+
// UNMODIFIED `@anthropic-ai/sdk`, which authenticates with x-api-key (never a bearer) and
|
|
143
|
+
// sends anthropic-version on every call — not forwarding them made the whole surface
|
|
144
|
+
// 401-dead for exactly the client Moonshot documents.
|
|
145
|
+
for (const k of ['authorization', 'x-api-key', 'anthropic-version', 'x-msh-request-nonce', 'x-twin-force-rate-limit', 'x-twin-force-server-unavailable']) {
|
|
146
|
+
const v = request.headers.get(k);
|
|
147
|
+
if (v !== null) passHeaders[k] = v;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let body = '';
|
|
151
|
+
if (request.method !== 'GET') {
|
|
152
|
+
body = contentType.includes('multipart/form-data') ? await multipartToJson(request) : await request.text();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Streaming POST → a real text/event-stream response built from the sink.
|
|
156
|
+
//
|
|
157
|
+
// Events are COLLECTED first, then framed. The handler is synchronous-fast, so buffering
|
|
158
|
+
// costs nothing — and it is what lets a PRE-STREAM failure (a 400 for a bad body, an auth
|
|
159
|
+
// 401, a rate-limit 429) answer with its REAL status and the vendor's JSON error envelope.
|
|
160
|
+
// Emitting the refusal as a lone data frame inside a 200 text/event-stream was a FAKE
|
|
161
|
+
// SUCCESS: Moonshot rejects a bad request BEFORE opening the event stream.
|
|
162
|
+
const isStreamable = !readOnly && request.method.toUpperCase() === 'POST'
|
|
163
|
+
&& (OPENAI_STREAMABLE.has(cleanPath) || NAMED_STREAMABLE.has(cleanPath))
|
|
164
|
+
&& wantsStream(body);
|
|
165
|
+
if (isStreamable) {
|
|
166
|
+
const named = NAMED_STREAMABLE.has(cleanPath);
|
|
167
|
+
const events: Array<SseEvent | MessagesSseEvent> = [];
|
|
168
|
+
const sink: SseSink = (e) => events.push(e);
|
|
169
|
+
const namedSink: MessagesSseSink = (e) => events.push(e);
|
|
170
|
+
const { status, body: out, headers: errHeaders } = await handleMoonshotTwinRequest({
|
|
171
|
+
...(scenarioEngine ? { scenarioEngine } : {}),
|
|
172
|
+
method: request.method, path, body, readOnly, occurredAt: worldNow(), headers: passHeaders,
|
|
173
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
174
|
+
sseSink: named ? undefined : sink,
|
|
175
|
+
messagesSseSink: named ? namedSink : undefined,
|
|
176
|
+
});
|
|
177
|
+
const resHeaders: Record<string, string> = { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive', ...(errHeaders ?? {}) };
|
|
178
|
+
if (status !== 200) {
|
|
179
|
+
// A pre-stream failure answers its REAL status + the vendor's JSON envelope.
|
|
180
|
+
return new Response(JSON.stringify(out), { status, headers: { 'content-type': 'application/json', ...(errHeaders ?? {}) } });
|
|
181
|
+
}
|
|
182
|
+
const frames = events.map((e) => ('event' in (e as MessagesSseEvent) && (e as MessagesSseEvent).event !== undefined ? encodeNamedSse(e as MessagesSseEvent) : encodeSse(e as SseEvent))).join('');
|
|
183
|
+
// The Msh-Request-* signature headers are minted IN THE HANDLER (one signer for every
|
|
184
|
+
// surface — see handleMoonshotTwinRequest) and arrive here through `errHeaders`, which
|
|
185
|
+
// resHeaders already spreads.
|
|
186
|
+
return new Response(frames, { status: 200, headers: resHeaders });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Unary path.
|
|
190
|
+
const { status, body: out, headers: errHeaders } = await handleMoonshotTwinRequest({
|
|
191
|
+
...(scenarioEngine ? { scenarioEngine } : {}),
|
|
192
|
+
method: request.method, path, body, readOnly, occurredAt: worldNow(), headers: passHeaders,
|
|
193
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
194
|
+
});
|
|
195
|
+
// The file-content route returns raw bytes; everything else is JSON. The handler's
|
|
196
|
+
// `x-twin-content-binary` response header (§9 round two, F1) says how to turn the body
|
|
197
|
+
// STRING into bytes: binary content is latin1-carried (every byte value intact, converted
|
|
198
|
+
// back here — a plain `new Response(string)` would re-encode as UTF-8 and mangle every
|
|
199
|
+
// byte ≥ 0x80); text content is the string itself, written UTF-8 so a non-ASCII text file
|
|
200
|
+
// round-trips byte for byte.
|
|
201
|
+
const isRawContent = request.method === 'GET' && /\/v1\/files\/[^/]+\/content$/.test(cleanPath);
|
|
202
|
+
if (isRawContent && status === 200) {
|
|
203
|
+
const binary = errHeaders?.['x-twin-content-binary'] === '1';
|
|
204
|
+
const bytes = binary ? Buffer.from(String(out), 'latin1') : Buffer.from(String(out), 'utf8');
|
|
205
|
+
const headers = { ...errHeaders };
|
|
206
|
+
delete headers['x-twin-content-binary'];
|
|
207
|
+
return new Response(bytes, { status, headers: { 'content-type': 'application/octet-stream', ...headers } });
|
|
208
|
+
}
|
|
209
|
+
return new Response(JSON.stringify(out), { status, headers: { 'content-type': 'application/json', ...(errHeaders ?? {}) } });
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function createMoonshotTwinServer(options: MoonshotTwinFetchOptions & { port?: number } = {}): Promise<{ port: number; stop: () => void }> {
|
|
214
|
+
const server = await serveHttp({
|
|
215
|
+
port: options.port ?? 0,
|
|
216
|
+
idleTimeout: 60,
|
|
217
|
+
fetch: createMoonshotTwinFetch(options),
|
|
218
|
+
});
|
|
219
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
220
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// THE GENERATIVE-STUB CORE — the deterministic labeled answer this twin gives where the vendor
|
|
2
|
+
// runs a model.
|
|
3
|
+
//
|
|
4
|
+
// The twin CANNOT run the model — there are no weights here. So `POST /v1/chat/completions`,
|
|
5
|
+
// `POST /v1/responses` and `POST /anthropic/v1/messages` return DETERMINISTIC STUB completions
|
|
6
|
+
// clearly labeled `[twin-stub:<model>]` — they NEVER pretend to be real model output. What IS
|
|
7
|
+
// faithful is the ENTIRE PROTOCOL ENVELOPE: the response shapes of all three protocols, the
|
|
8
|
+
// streaming chunk/event sequences, tool_calls / tool_use blocks, finish_reason / stop_reason,
|
|
9
|
+
// `reasoning_content` / thinking blocks, and Moonshot's cache-split `usage` objects.
|
|
10
|
+
//
|
|
11
|
+
// SERVE-PATH DETERMINISM (CLAUDE.md): every number below is a pure function of the request. The
|
|
12
|
+
// cache split in the usage object is DERIVED from the token counts by a fixed rule (Moonshot
|
|
13
|
+
// documents that a cache hit requires >256 prompt tokens), never from a wall clock — a replay is
|
|
14
|
+
// byte-identical.
|
|
15
|
+
//
|
|
16
|
+
// The stub IS the twin's answer: the manifest's chat/responses/messages capabilities are claimed
|
|
17
|
+
// on the envelope and the determinism, which is what a consumer binds to.
|
|
18
|
+
|
|
19
|
+
import type { MoonshotMessageParam, MoonshotToolCall } from './moonshot-types.ts';
|
|
20
|
+
|
|
21
|
+
/** Deterministic token estimate for a string: ~1 token per 4 chars (faithful order of
|
|
22
|
+
* magnitude; deterministic so usage counts are assertable). Never zero for non-empty text. */
|
|
23
|
+
export function estimateTokens(text: string): number {
|
|
24
|
+
if (!text) return 0;
|
|
25
|
+
return Math.max(1, Math.ceil(text.length / 4));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Flatten a chat message's content (string OR content-part array) to its text for token
|
|
29
|
+
* counting / echo. Non-text parts contribute their JSON length so the count is deterministic
|
|
30
|
+
* and reflects payload size. */
|
|
31
|
+
export function contentToText(content: unknown): string {
|
|
32
|
+
if (typeof content === 'string') return content;
|
|
33
|
+
if (content === null || content === undefined) return '';
|
|
34
|
+
if (!Array.isArray(content)) return '';
|
|
35
|
+
return content
|
|
36
|
+
.map((part) => {
|
|
37
|
+
if (part && typeof part === 'object' && (part as { type?: string }).type === 'text') {
|
|
38
|
+
return String((part as { text?: unknown }).text ?? '');
|
|
39
|
+
}
|
|
40
|
+
return JSON.stringify(part);
|
|
41
|
+
})
|
|
42
|
+
.join('\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Deterministic prompt-token count for a set of chat messages. */
|
|
46
|
+
export function countPromptTokens(messages: MoonshotMessageParam[]): number {
|
|
47
|
+
let total = 0;
|
|
48
|
+
for (const m of messages) {
|
|
49
|
+
total += estimateTokens(contentToText(m.content));
|
|
50
|
+
if (m.name) total += estimateTokens(m.name);
|
|
51
|
+
for (const tc of m.tool_calls ?? []) total += estimateTokens(JSON.stringify(tc));
|
|
52
|
+
}
|
|
53
|
+
return total;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The last user turn's text — the thing the stub echoes (deterministic, clearly labeled). */
|
|
57
|
+
export function lastUserText(messages: MoonshotMessageParam[]): string {
|
|
58
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
59
|
+
if (messages[i]!.role === 'user') return contentToText(messages[i]!.content);
|
|
60
|
+
}
|
|
61
|
+
return messages.length ? contentToText(messages[messages.length - 1]!.content) : '';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the deterministic stub ASSISTANT text. It is unmistakably a twin stub: it carries the
|
|
66
|
+
* `[twin-stub:<model>]` marker and echoes the prompt, so no caller can mistake it for real
|
|
67
|
+
* model output. Deterministic for a given prompt → assertable in tests.
|
|
68
|
+
*/
|
|
69
|
+
export function stubAssistantText(messages: MoonshotMessageParam[], model: string): string {
|
|
70
|
+
const prompt = lastUserText(messages).trim();
|
|
71
|
+
const echo = prompt.length > 200 ? `${prompt.slice(0, 200)}…` : prompt;
|
|
72
|
+
return `[twin-stub:${model}] This is a deterministic stub from the Moonshot twin (no model weights are run). Echoing your last message: ${echo || '(empty)'}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The `reasoning_content` string a thinking-mode model returns (kimi-k3 always; k2.6/k2.7-code
|
|
77
|
+
* when thinking is enabled). A labeled stub, like the content — never a real chain of thought.
|
|
78
|
+
*/
|
|
79
|
+
export function stubReasoningContent(messages: MoonshotMessageParam[], model: string): string {
|
|
80
|
+
return `[twin-stub:${model}] deterministic stub reasoning (no model weights are run) for: ${lastUserText(messages).trim().slice(0, 120) || '(empty)'}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A deterministic stub signature for a Messages `thinking` block (the vendor asks callers to
|
|
84
|
+
* pass it back unchanged; the twin's is a deterministic label). */
|
|
85
|
+
export function stubSignature(messages: MoonshotMessageParam[], model: string): string {
|
|
86
|
+
return `sig_twin_${fnv1a(`${model}:${lastUserText(messages)}`).toString(36)}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Usage assembly ──────────────────────────────────────────────────────────────────────
|
|
90
|
+
/**
|
|
91
|
+
* Moonshot's automatic context caching: a hit requires MORE THAN 256 prompt tokens
|
|
92
|
+
* (platform.kimi.ai/docs/guides/context-caching — automatic caching, read 2026-09-16). The twin
|
|
93
|
+
* derives the cached share deterministically from the token count: at or below the threshold the
|
|
94
|
+
* cache is cold (0); above it a fixed 25% of the prompt is served from cache. That fraction is a
|
|
95
|
+
* judgement call, not a vendor figure — what IS vendor-shaped is the FIELD (cached_tokens in
|
|
96
|
+
* chat usage; cached_tokens / cache_write_tokens in Responses usage) and the threshold rule.
|
|
97
|
+
*/
|
|
98
|
+
export const CACHE_HIT_THRESHOLD = 256;
|
|
99
|
+
|
|
100
|
+
export function stubCachedTokens(promptTokens: number): number {
|
|
101
|
+
return promptTokens > CACHE_HIT_THRESHOLD ? Math.floor(promptTokens * 0.25) : 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function buildChatUsage(promptTokens: number, completionTokens: number, promptCacheKey?: string): {
|
|
105
|
+
prompt_tokens: number; completion_tokens: number; total_tokens: number; cached_tokens: number;
|
|
106
|
+
} {
|
|
107
|
+
// The cache split is LOAD-BEARING on the key: Moonshot's context caching is keyed by
|
|
108
|
+
// `prompt_cache_key` (platform.kimi.ai/docs/guides/context-caching) — a request WITHOUT one has
|
|
109
|
+
// no cache namespace to hit, so `cached_tokens` is 0 however long the prompt is. With a key the
|
|
110
|
+
// threshold/fraction rule applies. (The threshold itself stays in stubCachedTokens.)
|
|
111
|
+
const cached = promptCacheKey !== undefined ? stubCachedTokens(promptTokens) : 0;
|
|
112
|
+
return {
|
|
113
|
+
prompt_tokens: promptTokens,
|
|
114
|
+
completion_tokens: completionTokens,
|
|
115
|
+
total_tokens: promptTokens + completionTokens,
|
|
116
|
+
cached_tokens: cached,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Tool calls ──────────────────────────────────────────────────────────────────────────
|
|
121
|
+
/** Extract a tool's function name from an OpenAI-shaped tool (`{type:'function',function:{name}}`). */
|
|
122
|
+
function toolName(t: unknown): string {
|
|
123
|
+
const o = t as { function?: { name?: unknown }; name?: unknown } | undefined;
|
|
124
|
+
if (o?.function && typeof o.function.name === 'string') return o.function.name;
|
|
125
|
+
if (typeof o?.name === 'string') return o.name;
|
|
126
|
+
return 'unknown_function';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function placeholderForSchema(def: unknown): unknown {
|
|
130
|
+
const d = def as { type?: unknown; enum?: unknown[] } | undefined;
|
|
131
|
+
if (Array.isArray(d?.enum) && d!.enum!.length) return d!.enum![0];
|
|
132
|
+
switch (d?.type) {
|
|
133
|
+
case 'number':
|
|
134
|
+
case 'integer': return 0;
|
|
135
|
+
case 'boolean': return false;
|
|
136
|
+
case 'array': return [];
|
|
137
|
+
case 'object': return {};
|
|
138
|
+
default: return '';
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Build a deterministic stub argument string for a tool (schema-typed placeholders). Reads the
|
|
143
|
+
* OpenAI shape (`function.parameters`), the bare `parameters`, AND the Anthropic Messages shape
|
|
144
|
+
* (`input_schema`) — the Messages surface stubs its tool_use input through the same helper. */
|
|
145
|
+
export function stubToolArguments(tool: unknown): string {
|
|
146
|
+
const o = tool as { function?: { parameters?: unknown }; parameters?: unknown; input_schema?: unknown } | undefined;
|
|
147
|
+
const schema = (o?.function?.parameters ?? o?.parameters ?? o?.input_schema) as { properties?: Record<string, unknown> } | undefined;
|
|
148
|
+
const props = schema && typeof schema === 'object' ? schema.properties : undefined;
|
|
149
|
+
if (!props || typeof props !== 'object') return '{}';
|
|
150
|
+
const out: Record<string, unknown> = {};
|
|
151
|
+
for (const [key, def] of Object.entries(props)) out[key] = placeholderForSchema(def);
|
|
152
|
+
return JSON.stringify(out);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* When tools are provided, the stub deterministically "calls" the tool selected by `forcedName`
|
|
157
|
+
* (a named tool_choice) or the FIRST provided tool. Returns the tool_call, or null when no
|
|
158
|
+
* tools were provided.
|
|
159
|
+
*/
|
|
160
|
+
export function stubToolCall(tools: unknown, seq: number, forcedName?: string): MoonshotToolCall | null {
|
|
161
|
+
if (!Array.isArray(tools) || tools.length === 0) return null;
|
|
162
|
+
const chosen = forcedName ? (tools.find((t) => toolName(t) === forcedName) ?? tools[0]) : tools[0];
|
|
163
|
+
return { id: `call_twin_${seq}`, type: 'function', function: { name: toolName(chosen), arguments: stubToolArguments(chosen) } };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Build a deterministic JSON-object stub for `response_format` json_object / json_schema. */
|
|
167
|
+
export function stubJsonObject(messages: MoonshotMessageParam[], model: string, jsonSchema?: unknown): string {
|
|
168
|
+
const schema = jsonSchema as { schema?: { properties?: Record<string, unknown> }; properties?: Record<string, unknown> } | undefined;
|
|
169
|
+
const props = schema?.schema?.properties ?? schema?.properties;
|
|
170
|
+
if (props && typeof props === 'object') {
|
|
171
|
+
const out: Record<string, unknown> = {};
|
|
172
|
+
for (const [key, def] of Object.entries(props)) out[key] = placeholderForSchema(def);
|
|
173
|
+
return JSON.stringify(out);
|
|
174
|
+
}
|
|
175
|
+
return JSON.stringify({ _twin_stub: true, model, echo: lastUserText(messages).slice(0, 200) });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Deterministic hashing / pseudo-content ──────────────────────────────────────────────
|
|
179
|
+
/** A small deterministic 32-bit hash (FNV-1a) of a string. */
|
|
180
|
+
export function fnv1a(text: string): number {
|
|
181
|
+
let h = 0x811c9dc5;
|
|
182
|
+
for (let i = 0; i < text.length; i++) {
|
|
183
|
+
h ^= text.charCodeAt(i);
|
|
184
|
+
h = Math.imul(h, 0x01000193);
|
|
185
|
+
}
|
|
186
|
+
return h >>> 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A deterministic id suffix from a request (so ids are stable + assertable). */
|
|
190
|
+
export function stableSuffix(...parts: unknown[]): string {
|
|
191
|
+
return fnv1a(parts.map((p) => JSON.stringify(p) ?? String(p)).join('|')).toString(36);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A deterministic stub web-search result set for POST /v1/tools/search{,_pro} and the Responses
|
|
196
|
+
* web_search tool. The twin cannot reach the web (D4 — no real network), so the results are a
|
|
197
|
+
* labeled, deterministic echo of the query: same query → same results.
|
|
198
|
+
*/
|
|
199
|
+
export function stubSearchResults(textQuery: string, limit: number, pro: boolean, includeContent = false): Array<Record<string, unknown>> {
|
|
200
|
+
const out: Array<Record<string, unknown>> = [];
|
|
201
|
+
for (let i = 0; i < limit; i++) {
|
|
202
|
+
const base = {
|
|
203
|
+
authority: 'twin',
|
|
204
|
+
date: '2026-01-01',
|
|
205
|
+
icon: '',
|
|
206
|
+
mime: 'text/html',
|
|
207
|
+
site_name: 'twin-stub',
|
|
208
|
+
snippet: `[twin-stub] deterministic search result ${i + 1}/${limit} for: ${textQuery}`,
|
|
209
|
+
title: `[twin-stub] result ${i + 1} for: ${textQuery}`,
|
|
210
|
+
url: `https://twin-stub.invalid/search?q=${encodeURIComponent(textQuery)}&i=${i}`,
|
|
211
|
+
};
|
|
212
|
+
if (pro) {
|
|
213
|
+
out.push({ ...base, chunks: [{ text: `[twin-stub] chunk for: ${textQuery}`, score: 0.5 }] });
|
|
214
|
+
} else {
|
|
215
|
+
// `include_content: true` SERVES the content (the option's documented meaning): a labeled
|
|
216
|
+
// deterministic page stub, like tools/fetch's. Default (false) is the vendor's bare shape.
|
|
217
|
+
out.push({ ...base, text: includeContent ? `[twin-stub] content for: ${textQuery} (result ${i + 1}) — the twin performs no real network I/O (D4).` : '' });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A deterministic stub markdown page for POST /v1/tools/fetch. */
|
|
224
|
+
export function stubFetchedMarkdown(url: string): { url: string; markdown: string; title: string } {
|
|
225
|
+
return {
|
|
226
|
+
url,
|
|
227
|
+
markdown: `[twin-stub] deterministic fetch of ${url} — the twin performs no real network I/O (D4).`,
|
|
228
|
+
title: `[twin-stub] ${url}`,
|
|
229
|
+
};
|
|
230
|
+
}
|