@volter/twin-xai 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 +246 -0
- package/client/xai-device-auth.css +246 -0
- package/client/xai-device-auth.tsx +138 -0
- package/dist/client/xai-device-auth.bundle.js +18 -0
- package/dist/client/xai-device-auth.css +246 -0
- package/dist/client/xai-device-auth.d.ts +19 -0
- package/dist/client/xai-device-auth.js +50 -0
- package/dist/client/xai-device-auth.tsx +138 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +28 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/index.js +70 -0
- package/dist/src/xai-budget.d.ts +60 -0
- package/dist/src/xai-budget.js +139 -0
- package/dist/src/xai-capabilities.d.ts +4 -0
- package/dist/src/xai-capabilities.js +1072 -0
- package/dist/src/xai-conformance.d.ts +13 -0
- package/dist/src/xai-conformance.js +148 -0
- package/dist/src/xai-connector.d.ts +82 -0
- package/dist/src/xai-connector.js +174 -0
- package/dist/src/xai-device-auth-css.gen.d.ts +1 -0
- package/dist/src/xai-device-auth-css.gen.js +6 -0
- package/dist/src/xai-device-auth-ui.d.ts +13 -0
- package/dist/src/xai-device-auth-ui.js +72 -0
- package/dist/src/xai-models.d.ts +57 -0
- package/dist/src/xai-models.js +102 -0
- package/dist/src/xai-oauth.d.ts +30 -0
- package/dist/src/xai-oauth.js +279 -0
- package/dist/src/xai-scenario.d.ts +33 -0
- package/dist/src/xai-scenario.js +139 -0
- package/dist/src/xai-server.d.ts +36 -0
- package/dist/src/xai-server.js +232 -0
- package/dist/src/xai-stub.d.ts +69 -0
- package/dist/src/xai-stub.js +210 -0
- package/dist/src/xai-twin.d.ts +89 -0
- package/dist/src/xai-twin.js +883 -0
- package/dist/src/xai-types.d.ts +118 -0
- package/dist/src/xai-types.js +6 -0
- package/package.json +76 -0
- package/src/cli.ts +27 -0
- package/src/index.ts +120 -0
- package/src/xai-budget.ts +165 -0
- package/src/xai-capabilities.ts +1046 -0
- package/src/xai-conformance.ts +136 -0
- package/src/xai-connector.ts +212 -0
- package/src/xai-device-auth-css.gen.ts +6 -0
- package/src/xai-device-auth-ui.ts +90 -0
- package/src/xai-journey.uitest.ts +155 -0
- package/src/xai-models.ts +154 -0
- package/src/xai-oauth.ts +301 -0
- package/src/xai-scenario.ts +148 -0
- package/src/xai-server.ts +258 -0
- package/src/xai-stub.ts +213 -0
- package/src/xai-twin.ts +960 -0
- package/src/xai-types.ts +111 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// xAI twin HTTP server — serve the full xAI twin handler over HTTP so the real `@ai-sdk/xai`
|
|
2
|
+
// provider (constructed with `baseURL: http://127.0.0.1:<port>/v1`) works unmodified. JSON
|
|
3
|
+
// bodies pass straight through. Writable by default; pass readOnly to reject mutations (D3).
|
|
4
|
+
//
|
|
5
|
+
// Streaming: when the request body has `"stream": true`, the server constructs a REAL SSE
|
|
6
|
+
// response by feeding the handler an sseSink that writes each chunk onto the HTTP stream in
|
|
7
|
+
// the `data: <json>\n\n` wire format, ending with `data: [DONE]\n\n`. (The handler itself
|
|
8
|
+
// stays socket-free — the sink is the only place a socket is touched, on the live HTTP path.)
|
|
9
|
+
//
|
|
10
|
+
// Scenario scripting (xai-scenario.ts): a JSON scenario file — via the scenarioPath option,
|
|
11
|
+
// the TWIN_XAI_SCENARIO env var, or `world-xai serve --scenario <path>` — scripts the exact
|
|
12
|
+
// assistant turns for POST /v1/chat/completions. One session per server (nthCall/once state).
|
|
13
|
+
import { serveHttp } from '@volter/world-core';
|
|
14
|
+
import { twinManifest, twinPublicBase, worldNow } from '@volter/world-core';
|
|
15
|
+
import { createXaiScenarioEngine, loadXaiScenarioDocument, type XaiScenarioEngine } from './xai-scenario.ts';
|
|
16
|
+
import { handleXaiTwinRequest } from './xai-twin.ts';
|
|
17
|
+
import { decideXaiTwinDeviceAuthorization } from './xai-oauth.ts';
|
|
18
|
+
import {
|
|
19
|
+
deviceConsentPageHtml,
|
|
20
|
+
deviceDonePageHtml,
|
|
21
|
+
deviceEntryPageHtml,
|
|
22
|
+
xaiDeviceFavicon,
|
|
23
|
+
xaiDeviceAuthView,
|
|
24
|
+
xaiDeviceScript,
|
|
25
|
+
xaiDeviceStylesheet,
|
|
26
|
+
XAI_DEVICE_FAVICON_PATH,
|
|
27
|
+
XAI_DEVICE_SCRIPT_PATH,
|
|
28
|
+
XAI_DEVICE_STYLE_PATH,
|
|
29
|
+
} from './xai-device-auth-ui.ts';
|
|
30
|
+
import type { SseEvent } from './xai-types.ts';
|
|
31
|
+
|
|
32
|
+
function wantsStream(body: string): boolean {
|
|
33
|
+
if (!body) return false;
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(body) as { stream?: unknown; deferred?: unknown };
|
|
36
|
+
// deferred takes precedence over stream: the vendor answers a deferred request with
|
|
37
|
+
// { request_id }, not an event stream.
|
|
38
|
+
return parsed?.stream === true && parsed?.deferred !== true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function encodeSse(event: SseEvent): string {
|
|
45
|
+
if (event.done) return 'data: [DONE]\n\n';
|
|
46
|
+
return `data: ${JSON.stringify(event.data)}\n\n`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const STREAMABLE = new Set(['/v1/chat/completions']);
|
|
50
|
+
|
|
51
|
+
function html(body: string, status = 200): Response {
|
|
52
|
+
return new Response(body, {
|
|
53
|
+
status,
|
|
54
|
+
headers: {
|
|
55
|
+
'cache-control': 'no-store',
|
|
56
|
+
'content-security-policy': "default-src 'self'; style-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
|
|
57
|
+
'content-type': 'text/html; charset=utf-8',
|
|
58
|
+
'referrer-policy': 'no-referrer',
|
|
59
|
+
'x-content-type-options': 'nosniff',
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Options every xAI-twin HTTP surface needs, independent of who owns the socket. */
|
|
65
|
+
export interface XaiTwinFetchOptions {
|
|
66
|
+
root?: string;
|
|
67
|
+
readOnly?: boolean;
|
|
68
|
+
scenarioPath?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The pack's whole HTTP surface as a plain `fetch` — Request in, Response out, no listener.
|
|
73
|
+
*
|
|
74
|
+
* This is the composable form (runtime contract R12b): a Worker / Durable Object entry has NO
|
|
75
|
+
* loopback ports, so it must mount a pack's handler IN-PROCESS. `createXaiTwinServer` is
|
|
76
|
+
* nothing but `Bun.serve` wrapped around this closure, so the standalone (R1) and hosted
|
|
77
|
+
* surfaces are the SAME code — there is no second HTTP adaptation to drift. openai is the
|
|
78
|
+
* reference for this shape; xai is its generative sibling.
|
|
79
|
+
*
|
|
80
|
+
* WHAT IT SERVES IS UNCHANGED (R9): xai is a GENERATIVE pack, so chat completions answer a
|
|
81
|
+
* labeled deterministic stub or a scripted scenario — never a model. The only wall-clock-shaped
|
|
82
|
+
* call on this path is `worldNow()`, the world's frozen instant, and GET /twin is built from
|
|
83
|
+
* constants plus the scenario engine's own counters, so replaying it on identical state is
|
|
84
|
+
* byte-identical.
|
|
85
|
+
*
|
|
86
|
+
* NOTHING ON THIS PATH TOUCHES A FILESYSTEM. The scenario document is read through the ACTIVE
|
|
87
|
+
* WORLD STORE (xai-scenario.ts), once, when the factory is called; the device-authorization
|
|
88
|
+
* stylesheet, script and favicon are all served from committed constants
|
|
89
|
+
* (xai-device-auth-ui.ts), so the OAuth protocol UI is workerd-servable too.
|
|
90
|
+
*/
|
|
91
|
+
export function createXaiTwinFetch(options: XaiTwinFetchOptions = {}): (request: Request) => Promise<Response> {
|
|
92
|
+
const readOnly = options.readOnly ?? false;
|
|
93
|
+
const scenarioPath = options.scenarioPath ?? process.env.TWIN_XAI_SCENARIO;
|
|
94
|
+
const scenarioEngine: XaiScenarioEngine | undefined = scenarioPath ? createXaiScenarioEngine(loadXaiScenarioDocument(scenarioPath)) : undefined;
|
|
95
|
+
return async function xaiTwinFetch(request: Request): Promise<Response> {
|
|
96
|
+
const url = new URL(request.url);
|
|
97
|
+
const path = url.pathname + (url.search || '');
|
|
98
|
+
const cleanPath = url.pathname.replace(/\/+$/, '');
|
|
99
|
+
|
|
100
|
+
// THE READ DOORS (TWIN-PROGRAMMING-MODEL): discovery + inspection, read-only.
|
|
101
|
+
if (request.method === 'GET' && cleanPath === '/twin') {
|
|
102
|
+
return Response.json(twinManifest({
|
|
103
|
+
vendor: 'xai',
|
|
104
|
+
twinOf: 'xAI Grok API (chat completions)',
|
|
105
|
+
stateSentence: 'Seed nothing — usage accrues from ordinary API use with any key.',
|
|
106
|
+
behaviorSentence: 'Completions are scripted by MSW-shaped handlers in the world dir (handlers/xai.json): {on:{userTextIncludes|anyTextIncludes|modelEquals|hasTool|toolResultFor|lastMessageIsToolResult|nthCall}, respond:{text|toolCalls, finishReason?}, once?, phase?}. Unmatched requests answer a labeled stub naming this door.',
|
|
107
|
+
exampleHandler: { on: { userTextIncludes: 'post', hasTool: 'draftPost' }, respond: { toolCalls: { name: 'draftPost', arguments: { text: 'demo' } } }, once: true },
|
|
108
|
+
engine: scenarioEngine as never,
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
if (request.method === 'GET' && cleanPath === '/twin/scenario') {
|
|
112
|
+
return Response.json(scenarioEngine ? scenarioEngine.status() : { vendor: 'xai', handlers: [], misses: 0, recentMisses: [] });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (request.method === 'GET' && cleanPath === XAI_DEVICE_STYLE_PATH) {
|
|
116
|
+
return new Response(xaiDeviceStylesheet(), {
|
|
117
|
+
headers: { 'cache-control': 'no-store', 'content-type': 'text/css; charset=utf-8', 'x-content-type-options': 'nosniff' },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (request.method === 'GET' && (cleanPath === XAI_DEVICE_FAVICON_PATH || cleanPath === '/favicon.ico')) {
|
|
122
|
+
return new Response(xaiDeviceFavicon(), {
|
|
123
|
+
headers: { 'cache-control': 'public, max-age=86400', 'content-type': 'image/svg+xml', 'x-content-type-options': 'nosniff' },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (request.method === 'GET' && cleanPath === XAI_DEVICE_SCRIPT_PATH) {
|
|
128
|
+
return new Response(xaiDeviceScript(), {
|
|
129
|
+
headers: { 'cache-control': 'no-store', 'content-type': 'text/javascript; charset=utf-8', 'x-content-type-options': 'nosniff' },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Internal Twin control routes exist for in-process capability setup only. They are never
|
|
134
|
+
// part of the network surface; browser authorization uses the real device forms below.
|
|
135
|
+
if (cleanPath.startsWith('/twin/')) {
|
|
136
|
+
return new Response(JSON.stringify({ code: 'not_found', error: 'The requested endpoint does not exist.' }), {
|
|
137
|
+
status: 404,
|
|
138
|
+
headers: { 'content-type': 'application/json' },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (request.method === 'GET' && cleanPath === '') {
|
|
143
|
+
return Response.redirect(`${twinPublicBase(request)}/oauth2/device`, 303);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (request.method === 'GET' && cleanPath === '/sign-out') {
|
|
147
|
+
// Account-shell sign-out is not an OAuth decision. The virtual account has no retained
|
|
148
|
+
// browser cookie, so return to the signed-out entry view and leave the device grant pending.
|
|
149
|
+
return Response.redirect(`${twinPublicBase(request)}/oauth2/device`, 303);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (request.method === 'GET' && cleanPath === '/oauth2/device') {
|
|
153
|
+
const invalid = url.searchParams.get('error') === 'invalid_code';
|
|
154
|
+
const userCode = invalid ? '' : url.searchParams.get('user_code') ?? '';
|
|
155
|
+
const view = xaiDeviceAuthView(userCode, options.root, invalid ? 'Invalid or expired code. Please try again.' : undefined, worldNow());
|
|
156
|
+
if (view.status === 'approved' || view.status === 'connected') return html(deviceDonePageHtml(true, twinPublicBase(request)));
|
|
157
|
+
if (view.status === 'denied') return html(deviceDonePageHtml(false, twinPublicBase(request)));
|
|
158
|
+
return html(deviceEntryPageHtml(view, twinPublicBase(request)));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (request.method === 'GET' && cleanPath === '/oauth2/device/consent') {
|
|
162
|
+
const view = xaiDeviceAuthView(url.searchParams.get('user_code') ?? '', options.root, undefined, worldNow());
|
|
163
|
+
if (view.status === 'pending') return html(deviceConsentPageHtml(view, twinPublicBase(request)));
|
|
164
|
+
if (view.status === 'approved' || view.status === 'connected') return html(deviceDonePageHtml(true, twinPublicBase(request)));
|
|
165
|
+
if (view.status === 'denied') return html(deviceDonePageHtml(false, twinPublicBase(request)));
|
|
166
|
+
const message = view.status === 'expired' ? 'This device code has expired. Start sign-in again from your terminal.' : 'Enter a valid device code.';
|
|
167
|
+
return html(deviceEntryPageHtml({ ...view, error: message }, twinPublicBase(request)), view.status === 'unknown' ? 404 : 400);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (request.method === 'GET' && cleanPath === '/oauth2/device/done') {
|
|
171
|
+
const view = xaiDeviceAuthView(url.searchParams.get('user_code') ?? '', options.root, undefined, worldNow());
|
|
172
|
+
if (view.status === 'approved' || view.status === 'connected') return html(deviceDonePageHtml(true, twinPublicBase(request)));
|
|
173
|
+
if (view.status === 'denied') return html(deviceDonePageHtml(false, twinPublicBase(request)));
|
|
174
|
+
const message = view.status === 'expired' ? 'This device code has expired. Start sign-in again from your terminal.' : 'This device authorization has not completed.';
|
|
175
|
+
return html(deviceEntryPageHtml({ ...view, error: message }, twinPublicBase(request)), view.status === 'unknown' ? 404 : 409);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Pass through the headers the handler models (auth 401/403, the rate-limit trigger),
|
|
179
|
+
// lower-cased. Their presence is what makes the live wire auth/rate-limit-aware
|
|
180
|
+
// (in-process trusted calls omit them and are not gated).
|
|
181
|
+
const passHeaders: Record<string, string> = {};
|
|
182
|
+
for (const k of ['authorization', 'x-grok-model-override', 'x-twin-force-rate-limit', 'x-xai-token-auth']) {
|
|
183
|
+
const v = request.headers.get(k);
|
|
184
|
+
if (v !== null) passHeaders[k] = v;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let body = '';
|
|
188
|
+
if (request.method !== 'GET') body = await request.text();
|
|
189
|
+
|
|
190
|
+
if (request.method === 'POST' && cleanPath === '/oauth2/device/continue') {
|
|
191
|
+
const form = new URLSearchParams(body);
|
|
192
|
+
const view = xaiDeviceAuthView(form.get('user_code') ?? '', options.root, undefined, worldNow());
|
|
193
|
+
if (view.status !== 'pending') {
|
|
194
|
+
return Response.redirect(`${twinPublicBase(request)}/oauth2/device?error=invalid_code`, 303);
|
|
195
|
+
}
|
|
196
|
+
return Response.redirect(`${twinPublicBase(request)}/oauth2/device/consent?user_code=${encodeURIComponent(view.userCode)}`, 303);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (request.method === 'POST' && cleanPath === '/oauth2/device/decision') {
|
|
200
|
+
if (readOnly) return new Response(JSON.stringify({ code: 'method_not_allowed', error: 'Twin is read-only' }), { status: 405, headers: { 'content-type': 'application/json' } });
|
|
201
|
+
const form = new URLSearchParams(body);
|
|
202
|
+
const approved = form.get('decision') === 'approve';
|
|
203
|
+
const decision = await decideXaiTwinDeviceAuthorization({
|
|
204
|
+
approved,
|
|
205
|
+
occurredAt: worldNow(),
|
|
206
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
207
|
+
userCode: form.get('user_code') ?? '',
|
|
208
|
+
});
|
|
209
|
+
if (decision.status >= 400) return new Response(JSON.stringify(decision.body), { status: decision.status, headers: { 'content-type': 'application/json' } });
|
|
210
|
+
return Response.redirect(`${twinPublicBase(request)}/oauth2/device/done?user_code=${encodeURIComponent(String(decision.body.user_code ?? ''))}`, 303);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Streaming POST → a real text/event-stream response built from the sink. The handler is
|
|
214
|
+
// synchronous-fast, so events are collected FIRST: a pre-stream failure (validation 400,
|
|
215
|
+
// auth 401/403, rate-limit 429) then returns the real vendor-shaped JSON error with its
|
|
216
|
+
// real status — the vendor rejects a bad request BEFORE opening the event stream, it does
|
|
217
|
+
// not wrap the error in a 200 SSE frame (§9 hardening).
|
|
218
|
+
if (!readOnly && request.method.toUpperCase() === 'POST' && STREAMABLE.has(cleanPath) && wantsStream(body)) {
|
|
219
|
+
const events: SseEvent[] = [];
|
|
220
|
+
const { status, body: out, headers: errHeaders } = await handleXaiTwinRequest({
|
|
221
|
+
method: request.method, path, body, readOnly, occurredAt: worldNow(), headers: passHeaders, origin: twinPublicBase(request),
|
|
222
|
+
requireTwinOauth: process.env.TWIN_XAI_AUTH_SEAM !== 'unsealed',
|
|
223
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
224
|
+
...(scenarioEngine ? { scenarioEngine } : {}),
|
|
225
|
+
sseSink: (e) => events.push(e),
|
|
226
|
+
});
|
|
227
|
+
if (status >= 400) {
|
|
228
|
+
return new Response(JSON.stringify(out), { status, headers: { 'content-type': 'application/json', 'x-request-id': 'req_twin', ...(errHeaders ?? {}) } });
|
|
229
|
+
}
|
|
230
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
231
|
+
start(controller) {
|
|
232
|
+
const enc = new TextEncoder();
|
|
233
|
+
for (const e of events) controller.enqueue(enc.encode(encodeSse(e)));
|
|
234
|
+
controller.close();
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
return new Response(stream, { headers: { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', 'x-request-id': 'req_twin' } });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const { status, body: out, headers: outHeaders } = await handleXaiTwinRequest({
|
|
241
|
+
method: request.method, path, body, readOnly,
|
|
242
|
+
occurredAt: worldNow(), headers: passHeaders, origin: twinPublicBase(request),
|
|
243
|
+
requireTwinOauth: process.env.TWIN_XAI_AUTH_SEAM !== 'unsealed',
|
|
244
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
245
|
+
...(scenarioEngine ? { scenarioEngine } : {}),
|
|
246
|
+
});
|
|
247
|
+
return new Response(JSON.stringify(out), { status, headers: { 'content-type': 'application/json', 'x-request-id': 'req_twin', ...(outHeaders ?? {}) } });
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function createXaiTwinServer(options: { root?: string; port?: number; readOnly?: boolean; scenarioPath?: string }): Promise<{ port: number; stop: () => void }> {
|
|
252
|
+
const server = await serveHttp({
|
|
253
|
+
port: options.port ?? 0,
|
|
254
|
+
idleTimeout: 60,
|
|
255
|
+
fetch: createXaiTwinFetch(options),
|
|
256
|
+
});
|
|
257
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
258
|
+
}
|
package/src/xai-stub.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// THE GENERATIVE-STUB CORE (this twin's answer for generated text).
|
|
2
|
+
//
|
|
3
|
+
// The twin CANNOT run Grok — there are no weights here. So POST /v1/chat/completions,
|
|
4
|
+
// /v1/completions, and /v1/messages return a DETERMINISTIC STUB completion that is CLEARLY a
|
|
5
|
+
// twin stub, NEVER pretending to be real model output; Live Search returns deterministic
|
|
6
|
+
// labeled citation URLs, never real web/X retrieval. What IS faithful is the ENTIRE PROTOCOL
|
|
7
|
+
// ENVELOPE: response shapes, streaming SSE chunk sequences, tool_calls, finish_reason,
|
|
8
|
+
// reasoning_content/reasoning-token accounting, and deterministic usage.
|
|
9
|
+
//
|
|
10
|
+
// The protocol is real; the generation is a deterministic labeled stub.
|
|
11
|
+
|
|
12
|
+
import type { ChatMessageParam, ChatToolCall } from './xai-types.ts';
|
|
13
|
+
|
|
14
|
+
/** Deterministic token estimate for a string: ~1 token per 4 chars (faithful order of
|
|
15
|
+
* magnitude; deterministic so usage counts are assertable, like the vendor's tokenizer on a
|
|
16
|
+
* fixed input). Never zero for non-empty text. */
|
|
17
|
+
export function estimateTokens(text: string): number {
|
|
18
|
+
if (!text) return 0;
|
|
19
|
+
return Math.max(1, Math.ceil(text.length / 4));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Flatten a chat message's content (string OR content-part array) to its text for token
|
|
23
|
+
* counting / echo. Non-text parts contribute their JSON length so the count is deterministic
|
|
24
|
+
* and reflects payload size. */
|
|
25
|
+
export function contentToText(content: ChatMessageParam['content']): string {
|
|
26
|
+
if (typeof content === 'string') return content;
|
|
27
|
+
if (content === null || content === undefined) return '';
|
|
28
|
+
if (!Array.isArray(content)) return '';
|
|
29
|
+
return content
|
|
30
|
+
.map((part) => {
|
|
31
|
+
if (part && typeof part === 'object' && (part as { type?: string }).type === 'text') {
|
|
32
|
+
return String((part as { text?: unknown }).text ?? '');
|
|
33
|
+
}
|
|
34
|
+
return JSON.stringify(part);
|
|
35
|
+
})
|
|
36
|
+
.join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Deterministic per-modality prompt token counts (xAI's usage.prompt_tokens_details splits
|
|
40
|
+
* text vs image tokens; the twin counts image_url parts at a fixed deterministic weight). */
|
|
41
|
+
export function countPromptTokenDetails(messages: ChatMessageParam[]): { text: number; image: number } {
|
|
42
|
+
let text = 0;
|
|
43
|
+
let image = 0;
|
|
44
|
+
for (const m of messages) {
|
|
45
|
+
if (Array.isArray(m.content)) {
|
|
46
|
+
for (const part of m.content) {
|
|
47
|
+
const p = part as { type?: string; text?: unknown };
|
|
48
|
+
if (p?.type === 'image_url') image += 256; // fixed deterministic per-image weight
|
|
49
|
+
else if (p?.type === 'text') text += estimateTokens(String(p.text ?? ''));
|
|
50
|
+
else text += estimateTokens(JSON.stringify(part));
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
text += estimateTokens(contentToText(m.content));
|
|
54
|
+
}
|
|
55
|
+
if (m.name) text += estimateTokens(m.name);
|
|
56
|
+
for (const tc of m.tool_calls ?? []) text += estimateTokens(JSON.stringify(tc));
|
|
57
|
+
}
|
|
58
|
+
return { text, image };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Deterministic total prompt-token count for the full set of messages. */
|
|
62
|
+
export function countPromptTokens(messages: ChatMessageParam[]): number {
|
|
63
|
+
const d = countPromptTokenDetails(messages);
|
|
64
|
+
return d.text + d.image;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The last user turn's text — the thing the stub echoes (deterministic, clearly labeled). */
|
|
68
|
+
export function lastUserText(messages: ChatMessageParam[]): string {
|
|
69
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
70
|
+
if (messages[i]!.role === 'user') return contentToText(messages[i]!.content);
|
|
71
|
+
}
|
|
72
|
+
// No user turn (e.g. only system) → fall back to the last message's text.
|
|
73
|
+
return messages.length ? contentToText(messages[messages.length - 1]!.content) : '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build the deterministic stub ASSISTANT text. It is unmistakably a twin stub: it carries the
|
|
78
|
+
* `[twin-stub:<model>]` marker and echoes the prompt, so no caller can mistake it for real
|
|
79
|
+
* Grok output. Deterministic for a given prompt → assertable in tests.
|
|
80
|
+
*/
|
|
81
|
+
export function stubAssistantText(messages: ChatMessageParam[], model: string): string {
|
|
82
|
+
const prompt = lastUserText(messages).trim();
|
|
83
|
+
const echo = prompt.length > 200 ? `${prompt.slice(0, 200)}…` : prompt;
|
|
84
|
+
return `[twin-stub:${model}] This is a deterministic stub from the xAI twin (no model weights are run). Echoing your last message: ${echo || '(empty)'}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The labeled-stub reasoning trace (grok-3-mini exposes reasoning_content; the twin's is
|
|
88
|
+
* clearly not a real chain-of-thought). */
|
|
89
|
+
export function stubReasoningText(messages: ChatMessageParam[], model: string, effort: string): string {
|
|
90
|
+
const prompt = lastUserText(messages).trim().slice(0, 80);
|
|
91
|
+
return `[twin-stub:${model}] deterministic reasoning trace (effort=${effort}); no real chain-of-thought is produced. Considering: ${prompt || '(empty)'}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Extract a tool's function name from a Chat Completions tool ({ type:'function',
|
|
95
|
+
* function:{ name } }) or a bare { name } entry. */
|
|
96
|
+
function toolName(t: unknown): string {
|
|
97
|
+
const o = t as { function?: { name?: unknown }; name?: unknown } | undefined;
|
|
98
|
+
if (o?.function && typeof o.function.name === 'string') return o.function.name;
|
|
99
|
+
if (typeof o?.name === 'string') return o.name;
|
|
100
|
+
return 'unknown_function';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function placeholderForSchema(def: unknown): unknown {
|
|
104
|
+
const d = def as { type?: unknown; enum?: unknown[] } | undefined;
|
|
105
|
+
if (Array.isArray(d?.enum) && d!.enum!.length) return d!.enum![0];
|
|
106
|
+
switch (d?.type) {
|
|
107
|
+
case 'number':
|
|
108
|
+
case 'integer': return 0;
|
|
109
|
+
case 'boolean': return false;
|
|
110
|
+
case 'array': return [];
|
|
111
|
+
case 'object': return {};
|
|
112
|
+
default: return '';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build a deterministic stub argument string for a tool. When the tool declares a JSON-schema
|
|
118
|
+
* `parameters` object, real Grok emits arguments that validate against the schema; the twin
|
|
119
|
+
* synthesizes a deterministic object containing every declared property with a
|
|
120
|
+
* type-appropriate placeholder so strict callers parse it cleanly.
|
|
121
|
+
*/
|
|
122
|
+
export function stubToolArguments(tool: unknown): string {
|
|
123
|
+
const o = tool as { function?: { parameters?: unknown }; parameters?: unknown; input_schema?: unknown } | undefined;
|
|
124
|
+
const schema = (o?.function?.parameters ?? o?.parameters ?? o?.input_schema) as { properties?: Record<string, unknown> } | undefined;
|
|
125
|
+
const props = schema && typeof schema === 'object' ? schema.properties : undefined;
|
|
126
|
+
if (!props || typeof props !== 'object') return '{}';
|
|
127
|
+
const out: Record<string, unknown> = {};
|
|
128
|
+
for (const [key, def] of Object.entries(props)) out[key] = placeholderForSchema(def);
|
|
129
|
+
return JSON.stringify(out);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* When tools are provided, real Grok may respond with `tool_calls` and
|
|
134
|
+
* `finish_reason:'tool_calls'`. The stub deterministically "calls" the tool selected by
|
|
135
|
+
* `forcedName` (a named tool_choice) or the FIRST provided tool. Arguments are synthesized
|
|
136
|
+
* from the tool's JSON schema — clearly a stub, but a vendor-faithful tool_calls envelope.
|
|
137
|
+
*/
|
|
138
|
+
export function stubToolCall(tools: unknown, seq: number, forcedName?: string): ChatToolCall | null {
|
|
139
|
+
if (!Array.isArray(tools) || tools.length === 0) return null;
|
|
140
|
+
const chosen = forcedName ? (tools.find((t) => toolName(t) === forcedName) ?? tools[0]) : tools[0];
|
|
141
|
+
return { id: `call_twin_${seq}`, type: 'function', function: { name: toolName(chosen), arguments: stubToolArguments(chosen) } };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Build a deterministic JSON-object stub for `response_format` json_object / json_schema
|
|
146
|
+
* (xAI structured outputs). Always valid JSON; for json_schema every declared property is
|
|
147
|
+
* filled with a schema-typed placeholder so the caller's strict parse succeeds.
|
|
148
|
+
*/
|
|
149
|
+
export function stubJsonObject(messages: ChatMessageParam[], model: string, jsonSchema?: unknown): string {
|
|
150
|
+
const schema = jsonSchema as { schema?: { properties?: Record<string, unknown> }; properties?: Record<string, unknown> } | undefined;
|
|
151
|
+
const props = schema?.schema?.properties ?? schema?.properties;
|
|
152
|
+
if (props && typeof props === 'object') {
|
|
153
|
+
const out: Record<string, unknown> = {};
|
|
154
|
+
for (const [key, def] of Object.entries(props)) out[key] = placeholderForSchema(def);
|
|
155
|
+
return JSON.stringify(out);
|
|
156
|
+
}
|
|
157
|
+
return JSON.stringify({ _twin_stub: true, model, echo: lastUserText(messages).slice(0, 200) });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Deterministic hashing / ids ─────────────────────────────────────────────────────────
|
|
161
|
+
/** A small deterministic 32-bit hash (FNV-1a) of a string. */
|
|
162
|
+
export function fnv1a(text: string): number {
|
|
163
|
+
let h = 0x811c9dc5;
|
|
164
|
+
for (let i = 0; i < text.length; i++) {
|
|
165
|
+
h ^= text.charCodeAt(i);
|
|
166
|
+
h = Math.imul(h, 0x01000193);
|
|
167
|
+
}
|
|
168
|
+
return h >>> 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** xAI response ids are UUID-formatted strings. Build a deterministic UUID-shaped id from a
|
|
172
|
+
* seed string (stable + assertable, like the twin's other deterministic outputs). */
|
|
173
|
+
export function uuidFromSeed(seed: string): string {
|
|
174
|
+
const h = (s: string) => fnv1a(s).toString(16).padStart(8, '0');
|
|
175
|
+
const a = h(seed);
|
|
176
|
+
const b = h(`${seed}#1`);
|
|
177
|
+
const c = h(`${seed}#2`);
|
|
178
|
+
const d = h(`${seed}#3`);
|
|
179
|
+
return `${a}-${b.slice(0, 4)}-${b.slice(4)}-${c.slice(0, 4)}-${c.slice(4)}${d.slice(0, 8)}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Deterministic pseudo-tokenizer (POST /v1/tokenize-text) ─────────────────────────────
|
|
183
|
+
export type XaiToken = { token_id: number; string_token: string; token_bytes: number[] };
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Deterministically tokenize text into the faithful /v1/tokenize-text shape. NOT the real
|
|
187
|
+
* Grok BPE — a whitespace-preserving split whose `string_token`s re-join into the exact
|
|
188
|
+
* input, with deterministic token ids seeded from each piece. Shape + determinism are
|
|
189
|
+
* faithful; the ids carry no model meaning.
|
|
190
|
+
*/
|
|
191
|
+
export function pseudoTokenize(text: string): XaiToken[] {
|
|
192
|
+
const pieces = text.match(/\s+|\S+/g) ?? [];
|
|
193
|
+
return pieces.map((piece) => ({
|
|
194
|
+
token_id: fnv1a(piece) % 200000,
|
|
195
|
+
string_token: piece,
|
|
196
|
+
token_bytes: Array.from(new TextEncoder().encode(piece)),
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Live Search: deterministic labeled citations ────────────────────────────────────────
|
|
201
|
+
/** Deterministic stub citation URLs for a Live Search request: seeded from the query + the
|
|
202
|
+
* requested source types, on a clearly-non-real host; the envelope (citations[] +
|
|
203
|
+
* usage.num_sources_used) is faithful. */
|
|
204
|
+
export function stubCitations(query: string, sourceTypes: string[]): string[] {
|
|
205
|
+
const types = sourceTypes.length ? sourceTypes : ['web'];
|
|
206
|
+
const out: string[] = [];
|
|
207
|
+
for (const t of types) {
|
|
208
|
+
const seed = fnv1a(`${t}|${query}`).toString(36);
|
|
209
|
+
out.push(`https://twin.invalid/xai-live-search-stub/${t}/${seed}-1`);
|
|
210
|
+
out.push(`https://twin.invalid/xai-live-search-stub/${t}/${seed}-2`);
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|