theorum 0.1.2 → 0.1.3
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/README.md +62 -27
- package/docs/AGENT_PROFILE_CONTRACT.md +45 -18
- package/docs/CLI_SPEC.md +2 -2
- package/docs/SECRETS.md +27 -22
- package/esm/_dnt.polyfills.d.ts +99 -0
- package/esm/_dnt.polyfills.js +130 -1
- package/esm/mod.d.ts +19 -3
- package/esm/mod.js +17 -3
- package/esm/src/cli/commands/profile.d.ts +2 -0
- package/esm/src/cli/commands/profile.js +53 -0
- package/esm/src/cli/commands/run.d.ts +10 -0
- package/esm/src/cli/commands/run.js +55 -0
- package/esm/src/cli/commands/test.d.ts +15 -0
- package/esm/src/cli/commands/test.js +140 -0
- package/esm/src/cli/index.d.ts +4 -0
- package/esm/src/cli/index.js +148 -0
- package/esm/src/cli/matrix/fixtures.d.ts +13 -0
- package/esm/src/cli/matrix/fixtures.js +77 -0
- package/esm/src/cli/matrix/synthesizer.d.ts +29 -0
- package/esm/src/cli/matrix/synthesizer.js +187 -0
- package/esm/src/guardrails/error.d.ts +1 -3
- package/esm/src/guardrails/error.js +4 -6
- package/esm/src/guardrails/mod.d.ts +1 -1
- package/esm/src/guardrails/mod.js +1 -1
- package/esm/src/guardrails/sanitize.js +1 -1
- package/esm/src/host/mint-trace.d.ts +27 -0
- package/esm/src/host/mint-trace.js +33 -0
- package/esm/src/host/mod.d.ts +13 -0
- package/esm/src/host/mod.js +12 -0
- package/esm/src/host/reply.d.ts +14 -0
- package/esm/src/host/reply.js +27 -0
- package/esm/src/kernel/engine/delta.js +31 -4
- package/esm/src/kernel/engine/repair.d.ts +1 -3
- package/esm/src/kernel/engine/repair.js +1 -1
- package/esm/src/kernel/engine/runner/gates.d.ts +4 -0
- package/esm/src/kernel/engine/runner/gates.js +206 -0
- package/esm/src/kernel/engine/runner/mod.d.ts +14 -0
- package/esm/src/kernel/engine/runner/mod.js +103 -0
- package/esm/src/kernel/engine/runner/state.d.ts +16 -0
- package/esm/src/kernel/engine/runner/state.js +8 -0
- package/esm/src/kernel/engine/runner/steps.d.ts +15 -0
- package/esm/src/kernel/engine/runner/steps.js +119 -0
- package/esm/src/kernel/engine/runner/stream.d.ts +11 -0
- package/esm/src/kernel/engine/runner/stream.js +65 -0
- package/esm/src/kernel/engine/runner/tokens.d.ts +3 -0
- package/esm/src/kernel/engine/runner/tokens.js +38 -0
- package/esm/src/kernel/engine/runner/tools.d.ts +13 -0
- package/esm/src/kernel/engine/runner/tools.js +197 -0
- package/esm/src/kernel/engine/runner.d.ts +1 -9
- package/esm/src/kernel/engine/runner.js +1 -726
- package/esm/src/kernel/mod.d.ts +3 -1
- package/esm/src/kernel/mod.js +2 -1
- package/esm/src/{providers → kernel/registry}/attachments.d.ts +2 -3
- package/esm/src/{providers → kernel/registry}/attachments.js +6 -6
- package/esm/src/kernel/registry/catalog.d.ts +28 -21
- package/esm/src/kernel/registry/catalog.js +55 -133
- package/esm/src/kernel/registry/ingress.d.ts +13 -0
- package/esm/src/kernel/registry/ingress.js +163 -0
- package/esm/src/kernel/registry/profiles.d.ts +2 -4
- package/esm/src/kernel/registry/profiles.js +14 -5
- package/esm/src/kernel/registry/provider-request.d.ts +4 -0
- package/esm/src/kernel/registry/provider-request.js +25 -0
- package/esm/src/kernel/registry/resolve.d.ts +0 -1
- package/esm/src/kernel/registry/resolve.js +35 -53
- package/esm/src/kernel/registry/tools.js +2 -2
- package/esm/src/kernel/registry/vault.d.ts +11 -0
- package/esm/src/kernel/registry/vault.js +18 -0
- package/esm/src/kernel/types.d.ts +102 -78
- package/esm/src/observability/trace-attach.js +6 -20
- package/esm/src/presets/google.d.ts +50 -0
- package/esm/src/presets/google.js +96 -0
- package/esm/src/presets/mod.d.ts +11 -0
- package/esm/src/presets/mod.js +10 -0
- package/esm/src/providers/create-provider.d.ts +29 -0
- package/esm/src/providers/create-provider.js +38 -0
- package/esm/src/providers/gemini-tape.d.ts +1 -2
- package/esm/src/providers/gemini-tape.js +1 -1
- package/esm/src/providers/interactions.js +41 -16
- package/esm/src/{guardrails → providers}/keys.d.ts +10 -3
- package/esm/src/{guardrails → providers}/keys.js +10 -13
- package/esm/src/providers/mod.d.ts +4 -7
- package/esm/src/providers/mod.js +2 -5
- package/esm/src/providers/openrouter-mod.d.ts +13 -0
- package/esm/src/providers/openrouter-mod.js +12 -0
- package/esm/src/providers/openrouter-payload.d.ts +11 -3
- package/esm/src/providers/openrouter-payload.js +28 -26
- package/esm/src/providers/openrouter.d.ts +5 -7
- package/esm/src/providers/openrouter.js +488 -231
- package/esm/src/providers/pcm.d.ts +7 -0
- package/esm/src/providers/pcm.js +35 -0
- package/esm/src/providers/provider.d.ts +3 -1
- package/esm/src/providers/provider.js +45 -2
- package/esm/src/providers/speech.d.ts +23 -0
- package/esm/src/providers/speech.js +125 -0
- package/package.json +20 -3
- package/esm/src/providers/media.d.ts +0 -5
- package/esm/src/providers/media.js +0 -125
- package/esm/src/providers/tts.d.ts +0 -24
- package/esm/src/providers/tts.js +0 -144
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PCM → WAV helpers shared by speech transports.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
const SAMPLE_RATE = 24000;
|
|
7
|
+
function writeAscii(view, offset, str) {
|
|
8
|
+
for (let i = 0; i < str.length; i++)
|
|
9
|
+
view.setUint8(offset + i, str.charCodeAt(i));
|
|
10
|
+
}
|
|
11
|
+
/** Wrap raw PCM bytes in a RIFF/WAVE container. */
|
|
12
|
+
export function wrapPcmAsWav(pcm, sampleRate = SAMPLE_RATE) {
|
|
13
|
+
const numChannels = 1;
|
|
14
|
+
const bitsPerSample = 16;
|
|
15
|
+
const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
|
|
16
|
+
const blockAlign = (numChannels * bitsPerSample) / 8;
|
|
17
|
+
const dataSize = pcm.length;
|
|
18
|
+
const buf = new ArrayBuffer(44 + dataSize);
|
|
19
|
+
const view = new DataView(buf);
|
|
20
|
+
writeAscii(view, 0, 'RIFF');
|
|
21
|
+
view.setUint32(4, 36 + dataSize, true);
|
|
22
|
+
writeAscii(view, 8, 'WAVE');
|
|
23
|
+
writeAscii(view, 12, 'fmt ');
|
|
24
|
+
view.setUint32(16, 16, true);
|
|
25
|
+
view.setUint16(20, 1, true); // PCM
|
|
26
|
+
view.setUint16(22, numChannels, true);
|
|
27
|
+
view.setUint32(24, sampleRate, true);
|
|
28
|
+
view.setUint32(28, byteRate, true);
|
|
29
|
+
view.setUint16(32, blockAlign, true);
|
|
30
|
+
view.setUint16(34, bitsPerSample, true);
|
|
31
|
+
writeAscii(view, 36, 'data');
|
|
32
|
+
view.setUint32(40, dataSize, true);
|
|
33
|
+
new Uint8Array(buf, 44).set(pcm);
|
|
34
|
+
return new Uint8Array(buf);
|
|
35
|
+
}
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* This adapter converts THEORUM's provider-neutral request into the Google
|
|
5
5
|
* Interactions wire format and streams normalized `TurnEvent` objects.
|
|
6
|
+
* Speech-role turns use `response_format: audio` + `speech_config` (same
|
|
7
|
+
* transport as chat/image).
|
|
6
8
|
*
|
|
7
9
|
* @module
|
|
8
10
|
*/
|
|
9
|
-
import { type GeminiTransport } from '../guardrails/keys.js';
|
|
10
11
|
import type { ModelProvider } from '../kernel/types.js';
|
|
12
|
+
import { type GeminiTransport } from './keys.js';
|
|
11
13
|
/** Create a `ModelProvider` backed by Google Interactions streaming. */
|
|
12
14
|
declare function createInteractionsProvider(transport: GeminiTransport): ModelProvider;
|
|
13
15
|
export { createInteractionsProvider };
|
|
@@ -3,16 +3,51 @@
|
|
|
3
3
|
*
|
|
4
4
|
* This adapter converts THEORUM's provider-neutral request into the Google
|
|
5
5
|
* Interactions wire format and streams normalized `TurnEvent` objects.
|
|
6
|
+
* Speech-role turns use `response_format: audio` + `speech_config` (same
|
|
7
|
+
* transport as chat/image).
|
|
6
8
|
*
|
|
7
9
|
* @module
|
|
8
10
|
*/
|
|
9
11
|
import { publicError, TheorumError } from '../guardrails/error.js';
|
|
10
|
-
import { fetchGemini } from '../guardrails/keys.js';
|
|
11
12
|
import { eventsFromComplete, eventsFromDelta, extractTokenEvent, groundingFromEvent, tryStructured, } from '../kernel/engine/delta.js';
|
|
12
13
|
import { tapFetch } from './google-tap.js';
|
|
13
14
|
import { toInteractionsBody } from './interactions.js';
|
|
15
|
+
import { fetchGemini } from './keys.js';
|
|
16
|
+
import { wrapPcmAsWav } from './pcm.js';
|
|
14
17
|
import { INTERACTIONS_URL, takeSsePayloads } from './sse.js';
|
|
15
18
|
const HTTP_OK = 200;
|
|
19
|
+
function base64ToBytes(data) {
|
|
20
|
+
const bin = atob(data);
|
|
21
|
+
const out = new Uint8Array(bin.length);
|
|
22
|
+
for (let i = 0; i < bin.length; i++)
|
|
23
|
+
out[i] = bin.charCodeAt(i);
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
function bytesToBase64(bytes) {
|
|
27
|
+
let bin = '';
|
|
28
|
+
for (let i = 0; i < bytes.length; i++)
|
|
29
|
+
bin += String.fromCharCode(bytes[i]);
|
|
30
|
+
return btoa(bin);
|
|
31
|
+
}
|
|
32
|
+
function isRawPcmMime(mime) {
|
|
33
|
+
const lower = mime.toLowerCase();
|
|
34
|
+
return lower === 'audio/pcm' || lower === 'audio/l16' || lower === 'audio/raw';
|
|
35
|
+
}
|
|
36
|
+
/** Google TTS returns raw PCM; wrap as WAV for hosts (matches OpenRouter pcm path). */
|
|
37
|
+
function normalizeSpeechMedia(event, speech) {
|
|
38
|
+
if (!(speech && event.type === 'media' && event.media)) {
|
|
39
|
+
return event;
|
|
40
|
+
}
|
|
41
|
+
const { mimeType, data } = event.media;
|
|
42
|
+
if (!isRawPcmMime(mimeType)) {
|
|
43
|
+
return event;
|
|
44
|
+
}
|
|
45
|
+
const wav = wrapPcmAsWav(base64ToBytes(data));
|
|
46
|
+
return {
|
|
47
|
+
type: 'media',
|
|
48
|
+
media: { mimeType: 'audio/wav', data: bytesToBase64(wav) },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
16
51
|
async function* readSseRecords(res) {
|
|
17
52
|
if (!res.body) {
|
|
18
53
|
throw new TheorumError('empty Gemini stream');
|
|
@@ -82,6 +117,13 @@ function withTap(req, transport) {
|
|
|
82
117
|
};
|
|
83
118
|
}
|
|
84
119
|
async function* streamComplete(req, transport) {
|
|
120
|
+
if (!req.geminiBucket) {
|
|
121
|
+
yield {
|
|
122
|
+
type: 'error',
|
|
123
|
+
error: publicError('missing Gemini vault bucket for Interactions'),
|
|
124
|
+
};
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
85
127
|
const res = await fetchGemini(INTERACTIONS_URL, { method: 'POST', body: JSON.stringify(toInteractionsBody(req)) }, req.geminiBucket, withTap(req, transport));
|
|
86
128
|
if (res.status !== HTTP_OK) {
|
|
87
129
|
const errorBody = await res.text().catch(() => '');
|
|
@@ -92,11 +134,12 @@ async function* streamComplete(req, transport) {
|
|
|
92
134
|
return;
|
|
93
135
|
}
|
|
94
136
|
const acc = { text: '' };
|
|
137
|
+
const speech = Boolean(req.speech);
|
|
95
138
|
for await (const payload of readSseRecords(res)) {
|
|
96
139
|
req.tapGemini?.(payload);
|
|
97
140
|
const events = foldPayload(payload, acc);
|
|
98
141
|
for (const event of events) {
|
|
99
|
-
yield event;
|
|
142
|
+
yield normalizeSpeechMedia(event, speech);
|
|
100
143
|
}
|
|
101
144
|
}
|
|
102
145
|
if (req.structured && acc.text) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI-compatible `/audio/speech` transport (internal).
|
|
3
|
+
*
|
|
4
|
+
* Hosts use `createProvider(profile, { openRouter })` — this module is selected
|
|
5
|
+
* when the profile is an openAi speech role. Not a separate public door.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import type { ModelProvider, ProviderCompleteRequest, TurnEvent } from '../kernel/types.js';
|
|
10
|
+
/** Credentials for the openAi speech path (same shape as OpenRouter chat config + voice). */
|
|
11
|
+
export interface SpeechProviderConfig {
|
|
12
|
+
apiKey?: string;
|
|
13
|
+
/** Fallback TTS voice when the profile does not pin `outputs.speech.voice`. */
|
|
14
|
+
voice?: string;
|
|
15
|
+
baseUrl?: string;
|
|
16
|
+
siteUrl?: string;
|
|
17
|
+
siteName?: string;
|
|
18
|
+
fetch?: typeof fetch;
|
|
19
|
+
}
|
|
20
|
+
declare function streamSpeech(req: ProviderCompleteRequest, config?: SpeechProviderConfig): AsyncGenerator<TurnEvent>;
|
|
21
|
+
/** Internal ModelProvider for openAi speech roles. */
|
|
22
|
+
declare function createSpeechProvider(config?: SpeechProviderConfig): ModelProvider;
|
|
23
|
+
export { createSpeechProvider, streamSpeech };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI-compatible `/audio/speech` transport (internal).
|
|
3
|
+
*
|
|
4
|
+
* Hosts use `createProvider(profile, { openRouter })` — this module is selected
|
|
5
|
+
* when the profile is an openAi speech role. Not a separate public door.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import { publicError } from '../guardrails/error.js';
|
|
10
|
+
import { wrapPcmAsWav } from './pcm.js';
|
|
11
|
+
const HTTP_OK = 200;
|
|
12
|
+
function bytesToBase64(bytes) {
|
|
13
|
+
let bin = '';
|
|
14
|
+
for (let i = 0; i < bytes.length; i++)
|
|
15
|
+
bin += String.fromCharCode(bytes[i]);
|
|
16
|
+
return btoa(bin);
|
|
17
|
+
}
|
|
18
|
+
function extractInputText(input) {
|
|
19
|
+
return input
|
|
20
|
+
.filter((p) => p.type === 'text')
|
|
21
|
+
.map((p) => (p.type === 'text' ? p.text : ''))
|
|
22
|
+
.join(' ')
|
|
23
|
+
.trim();
|
|
24
|
+
}
|
|
25
|
+
function resolveSpeechWireModel(req) {
|
|
26
|
+
if (req.openRouterId) {
|
|
27
|
+
return req.openRouterId;
|
|
28
|
+
}
|
|
29
|
+
if (req.apiId.includes('/')) {
|
|
30
|
+
return req.apiId;
|
|
31
|
+
}
|
|
32
|
+
if (req.apiId) {
|
|
33
|
+
return `google/${req.apiId}`;
|
|
34
|
+
}
|
|
35
|
+
return String(req.model);
|
|
36
|
+
}
|
|
37
|
+
function buildHeaders(apiKey, config) {
|
|
38
|
+
const headers = {
|
|
39
|
+
Authorization: `Bearer ${apiKey}`,
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
};
|
|
42
|
+
if (config.siteUrl) {
|
|
43
|
+
headers['HTTP-Referer'] = config.siteUrl;
|
|
44
|
+
}
|
|
45
|
+
if (config.siteName) {
|
|
46
|
+
headers['X-Title'] = config.siteName;
|
|
47
|
+
}
|
|
48
|
+
return headers;
|
|
49
|
+
}
|
|
50
|
+
function buildPayload(req, text, speech, configVoice) {
|
|
51
|
+
const voice = speech?.voice ?? configVoice;
|
|
52
|
+
const format = speech?.format ?? 'pcm';
|
|
53
|
+
const payload = {
|
|
54
|
+
model: resolveSpeechWireModel(req),
|
|
55
|
+
input: text,
|
|
56
|
+
response_format: format,
|
|
57
|
+
};
|
|
58
|
+
if (voice) {
|
|
59
|
+
payload.voice = voice;
|
|
60
|
+
}
|
|
61
|
+
return payload;
|
|
62
|
+
}
|
|
63
|
+
async function requestSpeech(apiKey, text, req, config) {
|
|
64
|
+
const fetchFn = config.fetch ?? fetch;
|
|
65
|
+
const baseUrl = config.baseUrl?.replace(/\/+$/, '') ?? 'https://openrouter.ai/api/v1';
|
|
66
|
+
const url = `${baseUrl}/audio/speech`;
|
|
67
|
+
return await fetchFn(url, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: buildHeaders(apiKey, config),
|
|
70
|
+
body: JSON.stringify(buildPayload(req, text, req.speech, config.voice)),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function* yieldSpeechSuccess(rawBytes, text, format) {
|
|
74
|
+
let mediaMime = 'audio/mpeg';
|
|
75
|
+
let mediaBytes = rawBytes;
|
|
76
|
+
if (format === 'pcm') {
|
|
77
|
+
mediaMime = 'audio/wav';
|
|
78
|
+
mediaBytes = wrapPcmAsWav(rawBytes);
|
|
79
|
+
}
|
|
80
|
+
yield {
|
|
81
|
+
type: 'media',
|
|
82
|
+
media: { mimeType: mediaMime, data: bytesToBase64(mediaBytes) },
|
|
83
|
+
};
|
|
84
|
+
const inputTokens = Math.max(1, Math.round(text.length / 4));
|
|
85
|
+
const outputTokens = Math.max(1, Math.round(rawBytes.length / 100));
|
|
86
|
+
yield {
|
|
87
|
+
type: 'tokens',
|
|
88
|
+
tokens: { input: inputTokens, output: outputTokens, total: inputTokens + outputTokens },
|
|
89
|
+
};
|
|
90
|
+
yield { type: 'done' };
|
|
91
|
+
}
|
|
92
|
+
async function* streamSpeech(req, config = {}) {
|
|
93
|
+
const apiKey = config.apiKey?.trim() || undefined;
|
|
94
|
+
if (!apiKey) {
|
|
95
|
+
yield { type: 'error', error: publicError('missing API key for speech') };
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const text = extractInputText(req.input);
|
|
99
|
+
if (!text) {
|
|
100
|
+
yield { type: 'error', error: publicError('empty text for speech') };
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const res = await requestSpeech(apiKey, text, req, config);
|
|
104
|
+
if (res.status !== HTTP_OK) {
|
|
105
|
+
yield { type: 'error', error: publicError(`Speech HTTP ${String(res.status)}`) };
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const arrayBuffer = await res.arrayBuffer();
|
|
109
|
+
const rawBytes = new Uint8Array(arrayBuffer);
|
|
110
|
+
if (rawBytes.length === 0) {
|
|
111
|
+
yield { type: 'error', error: publicError('no audio returned from speech') };
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const format = req.speech?.format ?? 'pcm';
|
|
115
|
+
for (const ev of yieldSpeechSuccess(rawBytes, text, format)) {
|
|
116
|
+
yield ev;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Internal ModelProvider for openAi speech roles. */
|
|
120
|
+
function createSpeechProvider(config = {}) {
|
|
121
|
+
return {
|
|
122
|
+
complete: (req) => streamSpeech(req, config),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export { createSpeechProvider, streamSpeech };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "theorum",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "A flat TypeScript agent kernel for typed profiles, deterministic turn execution, dynamic tools, provider adapters, guardrails, and host-injected traces.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -26,23 +26,40 @@
|
|
|
26
26
|
"import": "./esm/src/providers/mod.js"
|
|
27
27
|
},
|
|
28
28
|
"./openrouter": {
|
|
29
|
-
"import": "./esm/src/providers/openrouter.js"
|
|
29
|
+
"import": "./esm/src/providers/openrouter-mod.js"
|
|
30
30
|
},
|
|
31
31
|
"./guardrails": {
|
|
32
32
|
"import": "./esm/src/guardrails/mod.js"
|
|
33
33
|
},
|
|
34
34
|
"./observability": {
|
|
35
35
|
"import": "./esm/src/observability/mod.js"
|
|
36
|
+
},
|
|
37
|
+
"./host": {
|
|
38
|
+
"import": "./esm/src/host/mod.js"
|
|
39
|
+
},
|
|
40
|
+
"./cli": {
|
|
41
|
+
"import": "./esm/src/cli/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./presets": {
|
|
44
|
+
"import": "./esm/src/presets/mod.js"
|
|
45
|
+
},
|
|
46
|
+
"./presets/google": {
|
|
47
|
+
"import": "./esm/src/presets/google.js"
|
|
36
48
|
}
|
|
37
49
|
},
|
|
38
50
|
"scripts": {},
|
|
51
|
+
"bin": {
|
|
52
|
+
"theorum": "esm/src/cli/index.js"
|
|
53
|
+
},
|
|
39
54
|
"type": "module",
|
|
40
55
|
"sideEffects": false,
|
|
41
56
|
"dependencies": {
|
|
57
|
+
"@openrouter/ai-sdk-provider": "1.5.4",
|
|
58
|
+
"ai": "5.0.244",
|
|
42
59
|
"@deno/shim-deno": "~0.18.0"
|
|
43
60
|
},
|
|
44
61
|
"devDependencies": {
|
|
45
62
|
"@types/node": "^20.9.0"
|
|
46
63
|
},
|
|
47
64
|
"_generatedBy": "dnt@0.43.2"
|
|
48
|
-
}
|
|
65
|
+
}
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
import type { BuiltinToolId, ImageResponseFormat, InteractionPart, ModelId, Profile, TurnRequest } from '../kernel/types.js';
|
|
2
|
-
declare function resolveImageFormat(profile: Profile, model: ModelId, slots?: Record<string, string>): ImageResponseFormat | null;
|
|
3
|
-
declare function resolveInputParts(profile: Profile, model: ModelId, req: TurnRequest): InteractionPart[];
|
|
4
|
-
declare function assertImageGrounding(model: ModelId, builtins: BuiltinToolId[]): void;
|
|
5
|
-
export { assertImageGrounding, resolveImageFormat, resolveInputParts };
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
import { TheorumError } from '../guardrails/error.js';
|
|
2
|
-
import { wrapUserData } from '../kernel/engine/boundary.js';
|
|
3
|
-
import { synthesizeRepairPrompt } from '../kernel/engine/repair.js';
|
|
4
|
-
import { geminiKindForMime, mimeAllowed, mimeEssence, modelEntry, } from '../kernel/registry/catalog.js';
|
|
5
|
-
import { assertAttachmentLimits, requireMediaLimits } from './attachments.js';
|
|
6
|
-
function listedValue(allowed, requested) {
|
|
7
|
-
if (!requested) {
|
|
8
|
-
return allowed[0];
|
|
9
|
-
}
|
|
10
|
-
for (const item of allowed) {
|
|
11
|
-
if (item === requested) {
|
|
12
|
-
return item;
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
return undefined;
|
|
16
|
-
}
|
|
17
|
-
function requireImageSpec(profile, model) {
|
|
18
|
-
const spec = modelEntry(model).image;
|
|
19
|
-
if (!spec) {
|
|
20
|
-
throw new TheorumError(`Profile ${profile.id} requests media but ${model} is not an image model`);
|
|
21
|
-
}
|
|
22
|
-
if (profile.outputs.structured !== null) {
|
|
23
|
-
throw new TheorumError(`Profile ${profile.id} cannot mix structured JSON with native image output`);
|
|
24
|
-
}
|
|
25
|
-
return spec;
|
|
26
|
-
}
|
|
27
|
-
function resolveImageFormat(profile, model, slots) {
|
|
28
|
-
if (!profile.outputs.media) {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
const spec = requireImageSpec(profile, model);
|
|
32
|
-
const aspectRatio = listedValue(spec.aspectRatios, slots?.aspectRatio);
|
|
33
|
-
const imageSize = listedValue(spec.sizes, slots?.imageSize);
|
|
34
|
-
if (!(aspectRatio && imageSize)) {
|
|
35
|
-
throw new TheorumError(`Unknown image aspect or size for ${profile.id}`);
|
|
36
|
-
}
|
|
37
|
-
return {
|
|
38
|
-
type: 'image',
|
|
39
|
-
mimeType: spec.outputMime,
|
|
40
|
-
aspectRatio,
|
|
41
|
-
imageSize,
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
function assertGeminiMime(mime) {
|
|
45
|
-
const kind = geminiKindForMime(mime);
|
|
46
|
-
if (!kind) {
|
|
47
|
-
throw new TheorumError(`MIME '${mime}' is not a Gemini input type`);
|
|
48
|
-
}
|
|
49
|
-
return kind;
|
|
50
|
-
}
|
|
51
|
-
function mediaParts(profile, model, blobs, channel) {
|
|
52
|
-
const accept = channel === 'voice' ? profile.inputs.voice?.accept : profile.inputs.attachments?.accept;
|
|
53
|
-
if (!accept) {
|
|
54
|
-
throw new TheorumError(`Profile ${profile.id} does not accept ${channel}`);
|
|
55
|
-
}
|
|
56
|
-
const spec = modelEntry(model).image;
|
|
57
|
-
const imageCount = blobs.filter((blob) => geminiKindForMime(blob.mimeType) === 'image').length;
|
|
58
|
-
if (spec && imageCount > spec.maxInputImages) {
|
|
59
|
-
throw new TheorumError(`At most ${spec.maxInputImages} reference images on ${model}`);
|
|
60
|
-
}
|
|
61
|
-
return blobs.map((blob) => {
|
|
62
|
-
const kind = assertGeminiMime(blob.mimeType);
|
|
63
|
-
if (!mimeAllowed(accept, blob.mimeType)) {
|
|
64
|
-
throw new TheorumError(`MIME '${blob.mimeType}' is not accepted on ${profile.id}`);
|
|
65
|
-
}
|
|
66
|
-
const essence = mimeEssence(blob.mimeType);
|
|
67
|
-
if (kind === 'image' && spec && !spec.inputMimes.includes(essence) && essence !== 'image/jpg') {
|
|
68
|
-
throw new TheorumError(`MIME '${blob.mimeType}' is not valid on ${model}`);
|
|
69
|
-
}
|
|
70
|
-
return {
|
|
71
|
-
type: kind,
|
|
72
|
-
mimeType: essence === 'image/jpg' ? 'image/jpeg' : essence,
|
|
73
|
-
data: blob.data,
|
|
74
|
-
};
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
function extractTextPart(profile, req) {
|
|
78
|
-
const { text, repair, history } = req.input ?? {};
|
|
79
|
-
if (profile.inputs.text === false) {
|
|
80
|
-
if (text) {
|
|
81
|
-
throw new TheorumError(`Profile ${profile.id} does not accept text input`);
|
|
82
|
-
}
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
let promptText = text;
|
|
86
|
-
if (repair) {
|
|
87
|
-
promptText = synthesizeRepairPrompt({ profile, repair, history });
|
|
88
|
-
}
|
|
89
|
-
if (!promptText) {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
return { type: 'text', text: wrapUserData(promptText) };
|
|
93
|
-
}
|
|
94
|
-
function extractMediaParts(profile, model, req) {
|
|
95
|
-
const { attachments, voice } = req.input ?? {};
|
|
96
|
-
const files = attachments ?? [];
|
|
97
|
-
const clips = voice ?? [];
|
|
98
|
-
if (files.length + clips.length > 0) {
|
|
99
|
-
assertAttachmentLimits([...files, ...clips], requireMediaLimits(profile));
|
|
100
|
-
}
|
|
101
|
-
const parts = [];
|
|
102
|
-
if (files.length > 0) {
|
|
103
|
-
parts.push(...mediaParts(profile, model, files, 'attachments'));
|
|
104
|
-
}
|
|
105
|
-
if (clips.length > 0) {
|
|
106
|
-
parts.push(...mediaParts(profile, model, clips, 'voice'));
|
|
107
|
-
}
|
|
108
|
-
return parts;
|
|
109
|
-
}
|
|
110
|
-
function resolveInputParts(profile, model, req) {
|
|
111
|
-
const parts = [];
|
|
112
|
-
const textPart = extractTextPart(profile, req);
|
|
113
|
-
if (textPart) {
|
|
114
|
-
parts.push(textPart);
|
|
115
|
-
}
|
|
116
|
-
parts.push(...extractMediaParts(profile, model, req));
|
|
117
|
-
return parts;
|
|
118
|
-
}
|
|
119
|
-
function assertImageGrounding(model, builtins) {
|
|
120
|
-
const spec = modelEntry(model).image;
|
|
121
|
-
if (spec && !spec.allowsGrounding && builtins.length > 0) {
|
|
122
|
-
throw new TheorumError(`Grounding tools are not valid on ${model}`);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
export { assertImageGrounding, resolveImageFormat, resolveInputParts };
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OpenRouter TTS provider utilities.
|
|
3
|
-
*
|
|
4
|
-
* Provides direct text-to-speech streaming and a `ModelProvider` wrapper for
|
|
5
|
-
* voice output profiles.
|
|
6
|
-
*
|
|
7
|
-
* @module
|
|
8
|
-
*/
|
|
9
|
-
import type { ModelProvider, ProviderCompleteRequest, TurnEvent } from '../kernel/types.js';
|
|
10
|
-
/** Wrap raw PCM bytes in a RIFF/WAVE container. */
|
|
11
|
-
export declare function wrapPcmAsWav(pcm: Uint8Array, sampleRate?: number): Uint8Array;
|
|
12
|
-
/** Host-supplied OpenRouter TTS configuration. */
|
|
13
|
-
export interface OpenRouterTtsConfig {
|
|
14
|
-
apiKey?: string;
|
|
15
|
-
voiceName?: string;
|
|
16
|
-
baseUrl?: string;
|
|
17
|
-
siteUrl?: string;
|
|
18
|
-
siteName?: string;
|
|
19
|
-
fetch?: typeof fetch;
|
|
20
|
-
}
|
|
21
|
-
/** Stream one OpenRouter TTS synthesis request as THEORUM events. */
|
|
22
|
-
export declare function streamOpenRouterTts(req: ProviderCompleteRequest, config?: OpenRouterTtsConfig): AsyncGenerator<TurnEvent>;
|
|
23
|
-
/** Create a `ModelProvider` that emits TTS media events. */
|
|
24
|
-
export declare function createOpenRouterTtsProvider(config?: OpenRouterTtsConfig): ModelProvider;
|
package/esm/src/providers/tts.js
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OpenRouter TTS provider utilities.
|
|
3
|
-
*
|
|
4
|
-
* Provides direct text-to-speech streaming and a `ModelProvider` wrapper for
|
|
5
|
-
* voice output profiles.
|
|
6
|
-
*
|
|
7
|
-
* @module
|
|
8
|
-
*/
|
|
9
|
-
import { publicError } from '../guardrails/error.js';
|
|
10
|
-
import { resolveOpenRouterApiKey } from './openrouter.js';
|
|
11
|
-
const SAMPLE_RATE = 24000;
|
|
12
|
-
const TTS_MODEL = 'google/gemini-3.1-flash-tts-preview';
|
|
13
|
-
const HTTP_OK = 200;
|
|
14
|
-
function bytesToBase64(bytes) {
|
|
15
|
-
let bin = '';
|
|
16
|
-
for (let i = 0; i < bytes.length; i++)
|
|
17
|
-
bin += String.fromCharCode(bytes[i]);
|
|
18
|
-
return btoa(bin);
|
|
19
|
-
}
|
|
20
|
-
function writeAscii(view, offset, str) {
|
|
21
|
-
for (let i = 0; i < str.length; i++)
|
|
22
|
-
view.setUint8(offset + i, str.charCodeAt(i));
|
|
23
|
-
}
|
|
24
|
-
/** Wrap raw PCM bytes in a RIFF/WAVE container. */
|
|
25
|
-
export function wrapPcmAsWav(pcm, sampleRate = SAMPLE_RATE) {
|
|
26
|
-
const numChannels = 1;
|
|
27
|
-
const bitsPerSample = 16;
|
|
28
|
-
const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
|
|
29
|
-
const blockAlign = (numChannels * bitsPerSample) / 8;
|
|
30
|
-
const dataSize = pcm.length;
|
|
31
|
-
const buf = new ArrayBuffer(44 + dataSize);
|
|
32
|
-
const view = new DataView(buf);
|
|
33
|
-
writeAscii(view, 0, 'RIFF');
|
|
34
|
-
view.setUint32(4, 36 + dataSize, true);
|
|
35
|
-
writeAscii(view, 8, 'WAVE');
|
|
36
|
-
writeAscii(view, 12, 'fmt ');
|
|
37
|
-
view.setUint32(16, 16, true);
|
|
38
|
-
view.setUint16(20, 1, true); // PCM
|
|
39
|
-
view.setUint16(22, numChannels, true);
|
|
40
|
-
view.setUint32(24, sampleRate, true);
|
|
41
|
-
view.setUint32(28, byteRate, true);
|
|
42
|
-
view.setUint16(32, blockAlign, true);
|
|
43
|
-
view.setUint16(34, bitsPerSample, true);
|
|
44
|
-
writeAscii(view, 36, 'data');
|
|
45
|
-
view.setUint32(40, dataSize, true);
|
|
46
|
-
new Uint8Array(buf, 44).set(pcm);
|
|
47
|
-
return new Uint8Array(buf);
|
|
48
|
-
}
|
|
49
|
-
function extractInputText(input) {
|
|
50
|
-
return input
|
|
51
|
-
.filter((p) => p.type === 'text')
|
|
52
|
-
.map((p) => (p.type === 'text' ? p.text : ''))
|
|
53
|
-
.join(' ')
|
|
54
|
-
.trim();
|
|
55
|
-
}
|
|
56
|
-
function buildHeaders(apiKey, config) {
|
|
57
|
-
const headers = {
|
|
58
|
-
Authorization: `Bearer ${apiKey}`,
|
|
59
|
-
'Content-Type': 'application/json',
|
|
60
|
-
};
|
|
61
|
-
if (config.siteUrl) {
|
|
62
|
-
headers['HTTP-Referer'] = config.siteUrl;
|
|
63
|
-
}
|
|
64
|
-
if (config.siteName) {
|
|
65
|
-
headers['X-Title'] = config.siteName;
|
|
66
|
-
}
|
|
67
|
-
return headers;
|
|
68
|
-
}
|
|
69
|
-
function buildPayload(text, voiceSpec, configVoiceName) {
|
|
70
|
-
const voice = voiceSpec?.voice ?? configVoiceName;
|
|
71
|
-
const responseFormat = voiceSpec?.responseFormat ?? 'pcm';
|
|
72
|
-
const payload = {
|
|
73
|
-
model: TTS_MODEL,
|
|
74
|
-
input: text,
|
|
75
|
-
response_format: responseFormat,
|
|
76
|
-
};
|
|
77
|
-
if (voice) {
|
|
78
|
-
payload.voice = voice;
|
|
79
|
-
}
|
|
80
|
-
return payload;
|
|
81
|
-
}
|
|
82
|
-
async function requestTts(apiKey, text, req, config) {
|
|
83
|
-
const fetchFn = config.fetch ?? fetch;
|
|
84
|
-
const baseUrl = config.baseUrl?.replace(/\/+$/, '') ?? 'https://openrouter.ai/api/v1';
|
|
85
|
-
const url = `${baseUrl}/audio/speech`;
|
|
86
|
-
return await fetchFn(url, {
|
|
87
|
-
method: 'POST',
|
|
88
|
-
headers: buildHeaders(apiKey, config),
|
|
89
|
-
body: JSON.stringify(buildPayload(text, req.voice, config.voiceName)),
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
function* yieldTtsSuccess(rawBytes, text, responseFormat) {
|
|
93
|
-
let mediaMime = 'audio/mpeg';
|
|
94
|
-
let mediaBytes = rawBytes;
|
|
95
|
-
if (responseFormat === 'pcm') {
|
|
96
|
-
mediaMime = 'audio/wav';
|
|
97
|
-
mediaBytes = wrapPcmAsWav(rawBytes);
|
|
98
|
-
}
|
|
99
|
-
yield {
|
|
100
|
-
type: 'media',
|
|
101
|
-
media: { mimeType: mediaMime, data: bytesToBase64(mediaBytes) },
|
|
102
|
-
};
|
|
103
|
-
const inputTokens = Math.max(1, Math.round(text.length / 4));
|
|
104
|
-
const outputTokens = Math.max(1, Math.round(rawBytes.length / 100));
|
|
105
|
-
yield {
|
|
106
|
-
type: 'tokens',
|
|
107
|
-
tokens: { input: inputTokens, output: outputTokens, total: inputTokens + outputTokens },
|
|
108
|
-
};
|
|
109
|
-
yield { type: 'done' };
|
|
110
|
-
}
|
|
111
|
-
/** Stream one OpenRouter TTS synthesis request as THEORUM events. */
|
|
112
|
-
export async function* streamOpenRouterTts(req, config = {}) {
|
|
113
|
-
const apiKey = resolveOpenRouterApiKey(config.apiKey);
|
|
114
|
-
if (!apiKey) {
|
|
115
|
-
yield { type: 'error', error: publicError('missing OpenRouter API key for TTS') };
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
const text = extractInputText(req.input);
|
|
119
|
-
if (!text) {
|
|
120
|
-
yield { type: 'error', error: publicError('empty text for TTS') };
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
const res = await requestTts(apiKey, text, req, config);
|
|
124
|
-
if (res.status !== HTTP_OK) {
|
|
125
|
-
yield { type: 'error', error: publicError(`OpenRouter TTS HTTP ${String(res.status)}`) };
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
const arrayBuffer = await res.arrayBuffer();
|
|
129
|
-
const rawBytes = new Uint8Array(arrayBuffer);
|
|
130
|
-
if (rawBytes.length === 0) {
|
|
131
|
-
yield { type: 'error', error: publicError('no audio returned from TTS') };
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
const responseFormat = req.voice?.responseFormat ?? 'pcm';
|
|
135
|
-
for (const ev of yieldTtsSuccess(rawBytes, text, responseFormat)) {
|
|
136
|
-
yield ev;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
/** Create a `ModelProvider` that emits TTS media events. */
|
|
140
|
-
export function createOpenRouterTtsProvider(config = {}) {
|
|
141
|
-
return {
|
|
142
|
-
complete: (req) => streamOpenRouterTts(req, config),
|
|
143
|
-
};
|
|
144
|
-
}
|