theorum 0.1.2 → 0.1.4
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 +83 -27
- package/deno.json +57 -0
- package/deno.lock +469 -0
- package/docs/AGENT_PROFILE_CONTRACT.md +45 -18
- package/docs/CLI_SPEC.md +2 -2
- package/docs/SECRETS.md +27 -22
- package/mod.ts +95 -0
- package/package.json +37 -29
- package/src/cli/commands/profile.ts +57 -0
- package/src/cli/commands/run.ts +66 -0
- package/src/cli/commands/test.ts +195 -0
- package/src/cli/index.ts +161 -0
- package/src/cli/matrix/fixtures.ts +96 -0
- package/src/cli/matrix/synthesizer.ts +240 -0
- package/src/guardrails/error.ts +142 -0
- package/src/guardrails/injection.ts +254 -0
- package/src/guardrails/mod.ts +40 -0
- package/src/guardrails/quota.ts +87 -0
- package/src/guardrails/sanitize.ts +165 -0
- package/src/guardrails/sensitive.ts +96 -0
- package/src/host/mint-trace.ts +54 -0
- package/src/host/mod.ts +20 -0
- package/src/host/reply.ts +32 -0
- package/src/kernel/engine/assert.ts +29 -0
- package/src/kernel/engine/boundary.ts +75 -0
- package/src/kernel/engine/delta.ts +440 -0
- package/src/kernel/engine/hash.ts +11 -0
- package/src/kernel/engine/record.ts +8 -0
- package/src/kernel/engine/repair.ts +56 -0
- package/src/kernel/engine/runner/gates.ts +338 -0
- package/src/kernel/engine/runner/mod.ts +135 -0
- package/src/kernel/engine/runner/state.ts +31 -0
- package/src/kernel/engine/runner/steps.ts +183 -0
- package/src/kernel/engine/runner/stream.ts +83 -0
- package/src/kernel/engine/runner/tokens.ts +47 -0
- package/src/kernel/engine/runner/tools.ts +266 -0
- package/src/kernel/engine/runner.ts +7 -0
- package/src/kernel/engine/tree.ts +18 -0
- package/src/kernel/mod.ts +39 -0
- package/src/kernel/registry/attachments.ts +195 -0
- package/src/kernel/registry/catalog.ts +181 -0
- package/src/kernel/registry/ingress.ts +223 -0
- package/src/kernel/registry/profiles.ts +165 -0
- package/src/kernel/registry/provider-request.ts +31 -0
- package/src/kernel/registry/resolve.ts +290 -0
- package/src/kernel/registry/schemas.ts +29 -0
- package/src/kernel/registry/tools.ts +45 -0
- package/src/kernel/registry/vault.ts +26 -0
- package/src/kernel/types.ts +576 -0
- package/{esm/src/observability/mod.js → src/observability/mod.ts} +11 -2
- package/src/observability/spans.ts +70 -0
- package/src/observability/trace-attach.ts +96 -0
- package/src/observability/trace-record.ts +254 -0
- package/src/observability/trace-usage.ts +36 -0
- package/src/observability/trace.ts +143 -0
- package/src/presets/google.ts +147 -0
- package/src/presets/mod.ts +24 -0
- package/src/providers/create-provider.ts +64 -0
- package/src/providers/gemini-tape.ts +59 -0
- package/src/providers/google-tap.ts +57 -0
- package/src/providers/interactions.ts +182 -0
- package/src/providers/keys.ts +182 -0
- package/src/providers/mod.ts +12 -0
- package/src/providers/openrouter-mod.ts +16 -0
- package/src/providers/openrouter-payload.ts +232 -0
- package/src/providers/openrouter.ts +758 -0
- package/src/providers/pcm.ts +37 -0
- package/src/providers/provider.ts +206 -0
- package/src/providers/speech.ts +180 -0
- package/src/providers/sse.ts +59 -0
- package/esm/_dnt.polyfills.d.ts +0 -11
- package/esm/_dnt.polyfills.js +0 -15
- package/esm/_dnt.shims.d.ts +0 -5
- package/esm/_dnt.shims.js +0 -61
- package/esm/mod.d.ts +0 -37
- package/esm/mod.js +0 -35
- package/esm/package.json +0 -3
- package/esm/src/guardrails/error.d.ts +0 -35
- package/esm/src/guardrails/error.js +0 -116
- package/esm/src/guardrails/injection.d.ts +0 -12
- package/esm/src/guardrails/injection.js +0 -220
- package/esm/src/guardrails/keys.d.ts +0 -12
- package/esm/src/guardrails/keys.js +0 -132
- package/esm/src/guardrails/mod.d.ts +0 -14
- package/esm/src/guardrails/mod.js +0 -14
- package/esm/src/guardrails/sanitize.d.ts +0 -22
- package/esm/src/guardrails/sanitize.js +0 -133
- package/esm/src/guardrails/sensitive.d.ts +0 -12
- package/esm/src/guardrails/sensitive.js +0 -88
- package/esm/src/kernel/engine/boundary.d.ts +0 -10
- package/esm/src/kernel/engine/boundary.js +0 -55
- package/esm/src/kernel/engine/delta.d.ts +0 -8
- package/esm/src/kernel/engine/delta.js +0 -362
- package/esm/src/kernel/engine/hash.d.ts +0 -1
- package/esm/src/kernel/engine/hash.js +0 -9
- package/esm/src/kernel/engine/record.d.ts +0 -2
- package/esm/src/kernel/engine/record.js +0 -7
- package/esm/src/kernel/engine/repair.d.ts +0 -9
- package/esm/src/kernel/engine/repair.js +0 -38
- package/esm/src/kernel/engine/runner.d.ts +0 -14
- package/esm/src/kernel/engine/runner.js +0 -731
- package/esm/src/kernel/engine/tree.d.ts +0 -2
- package/esm/src/kernel/engine/tree.js +0 -17
- package/esm/src/kernel/mod.d.ts +0 -16
- package/esm/src/kernel/mod.js +0 -15
- package/esm/src/kernel/registry/catalog.d.ts +0 -24
- package/esm/src/kernel/registry/catalog.js +0 -213
- package/esm/src/kernel/registry/profiles.d.ts +0 -36
- package/esm/src/kernel/registry/profiles.js +0 -111
- package/esm/src/kernel/registry/resolve.d.ts +0 -20
- package/esm/src/kernel/registry/resolve.js +0 -235
- package/esm/src/kernel/registry/schemas.d.ts +0 -14
- package/esm/src/kernel/registry/schemas.js +0 -23
- package/esm/src/kernel/registry/tools.d.ts +0 -12
- package/esm/src/kernel/registry/tools.js +0 -36
- package/esm/src/kernel/types.d.ts +0 -497
- package/esm/src/kernel/types.js +0 -10
- package/esm/src/observability/mod.d.ts +0 -12
- package/esm/src/observability/spans.d.ts +0 -16
- package/esm/src/observability/spans.js +0 -56
- package/esm/src/observability/trace-attach.d.ts +0 -16
- package/esm/src/observability/trace-attach.js +0 -81
- package/esm/src/observability/trace-record.d.ts +0 -112
- package/esm/src/observability/trace-record.js +0 -140
- package/esm/src/observability/trace-usage.d.ts +0 -3
- package/esm/src/observability/trace-usage.js +0 -32
- package/esm/src/observability/trace.d.ts +0 -23
- package/esm/src/observability/trace.js +0 -121
- package/esm/src/providers/attachments.d.ts +0 -17
- package/esm/src/providers/attachments.js +0 -156
- package/esm/src/providers/gemini-tape.d.ts +0 -3
- package/esm/src/providers/gemini-tape.js +0 -46
- package/esm/src/providers/google-tap.d.ts +0 -3
- package/esm/src/providers/google-tap.js +0 -48
- package/esm/src/providers/interactions.d.ts +0 -5
- package/esm/src/providers/interactions.js +0 -127
- package/esm/src/providers/media.d.ts +0 -5
- package/esm/src/providers/media.js +0 -125
- package/esm/src/providers/mod.d.ts +0 -15
- package/esm/src/providers/mod.js +0 -13
- package/esm/src/providers/openrouter-payload.d.ts +0 -24
- package/esm/src/providers/openrouter-payload.js +0 -177
- package/esm/src/providers/openrouter.d.ts +0 -17
- package/esm/src/providers/openrouter.js +0 -332
- package/esm/src/providers/provider.d.ts +0 -13
- package/esm/src/providers/provider.js +0 -123
- package/esm/src/providers/sse.d.ts +0 -7
- package/esm/src/providers/sse.js +0 -53
- package/esm/src/providers/tts.d.ts +0 -24
- package/esm/src/providers/tts.js +0 -144
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host provider factory — the single public door for binding a profile to a transport.
|
|
3
|
+
*
|
|
4
|
+
* Routes from `profile.model.protocol` / `provider` (and whether the profile is
|
|
5
|
+
* a speech role). Adapters under this folder are internal implementation.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { TheorumError } from '../guardrails/error.ts';
|
|
11
|
+
import type { ModelProvider, Profile } from '../kernel/types.ts';
|
|
12
|
+
import type { GeminiTransport } from './keys.ts';
|
|
13
|
+
import { createOpenRouterProvider } from './openrouter.ts';
|
|
14
|
+
import type { OpenRouterConfig } from './openrouter-payload.ts';
|
|
15
|
+
import { createInteractionsProvider } from './provider.ts';
|
|
16
|
+
import { createSpeechProvider } from './speech.ts';
|
|
17
|
+
|
|
18
|
+
/** Credentials supplied by the host when creating a provider. */
|
|
19
|
+
export interface CreateProviderOptions {
|
|
20
|
+
/** Google Interactions (chat, image, and speech when protocol is geminiInteractions). */
|
|
21
|
+
gemini?: GeminiTransport;
|
|
22
|
+
/**
|
|
23
|
+
* OpenRouter-compatible credentials for `openAi` profiles.
|
|
24
|
+
* Used for chat completions or `/audio/speech` when the profile is a speech role.
|
|
25
|
+
* Optional `voice` is a fallback when `outputs.speech.voice` is omitted.
|
|
26
|
+
*/
|
|
27
|
+
openRouter?: OpenRouterConfig & { voice?: string };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isSpeechRole(profile: Profile): boolean {
|
|
31
|
+
return profile.outputs.speech !== undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create a `ModelProvider` for a profile.
|
|
36
|
+
* One call: protocol/provider (and speech role) pick the transport.
|
|
37
|
+
*/
|
|
38
|
+
export function createProvider(
|
|
39
|
+
profile: Profile,
|
|
40
|
+
options: CreateProviderOptions = {},
|
|
41
|
+
): ModelProvider {
|
|
42
|
+
const { protocol, provider } = profile.model;
|
|
43
|
+
|
|
44
|
+
if (protocol === 'geminiInteractions' && provider === 'google') {
|
|
45
|
+
if (!options.gemini) {
|
|
46
|
+
throw new TheorumError('createProvider requires gemini transport for google Interactions');
|
|
47
|
+
}
|
|
48
|
+
return createInteractionsProvider(options.gemini);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (protocol === 'openAi' && provider === 'openrouter') {
|
|
52
|
+
if (!options.openRouter) {
|
|
53
|
+
throw new TheorumError('createProvider requires openRouter config for openAi/openrouter');
|
|
54
|
+
}
|
|
55
|
+
if (isSpeechRole(profile)) {
|
|
56
|
+
return createSpeechProvider(options.openRouter);
|
|
57
|
+
}
|
|
58
|
+
return createOpenRouterProvider(options.openRouter);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw new TheorumError(
|
|
62
|
+
`createProvider: unsupported protocol/provider pair '${protocol}'/'${provider}'`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { OMIT_CANARY } from '../kernel/engine/boundary.ts';
|
|
2
|
+
import { sha256 } from '../kernel/engine/hash.ts';
|
|
3
|
+
import { mapStrings } from '../kernel/engine/tree.ts';
|
|
4
|
+
|
|
5
|
+
function isImageBlob(rec: Record<string, unknown>): boolean {
|
|
6
|
+
if (rec.type === 'image' || rec.type === 'media') {
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
if (typeof rec.mimeType === 'string' || typeof rec.mime_type === 'string') {
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function scrubEntry(
|
|
16
|
+
rec: Record<string, unknown>,
|
|
17
|
+
key: string,
|
|
18
|
+
nested: unknown,
|
|
19
|
+
): Promise<[string, unknown]> {
|
|
20
|
+
if (key === 'data' && typeof nested === 'string' && isImageBlob(rec)) {
|
|
21
|
+
return [key, await sha256(nested)];
|
|
22
|
+
}
|
|
23
|
+
return [key, await scrubGemini(nested)];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function scrubRecord(rec: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
27
|
+
return Promise.all(Object.entries(rec).map(([key, nested]) => scrubEntry(rec, key, nested))).then(
|
|
28
|
+
(pairs) => {
|
|
29
|
+
const out = Object.fromEntries(pairs);
|
|
30
|
+
if (typeof rec.data === 'string' && isImageBlob(rec)) {
|
|
31
|
+
out.dataKind = 'sha256';
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function scrubGemini(value: unknown): Promise<unknown> {
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
return Promise.all(value.map((item) => scrubGemini(item)));
|
|
41
|
+
}
|
|
42
|
+
if (value && typeof value === 'object') {
|
|
43
|
+
return scrubRecord(value as Record<string, unknown>);
|
|
44
|
+
}
|
|
45
|
+
return Promise.resolve(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function redactCanaryInTree(value: unknown, canary: string): unknown {
|
|
49
|
+
if (!canary) {
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
return mapStrings(value, (text) => text.replaceAll(canary, OMIT_CANARY));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function tapeGemini(value: unknown, canary: string): Promise<unknown> {
|
|
56
|
+
return redactCanaryInTree(await scrubGemini(value), canary);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { tapeGemini };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ProviderCompleteRequest } from '../kernel/types.ts';
|
|
2
|
+
|
|
3
|
+
const SECRET_HEADER = /key|auth|cookie|secret|token/i;
|
|
4
|
+
|
|
5
|
+
function tapeHeaderValue(key: string, value: string): string {
|
|
6
|
+
if (SECRET_HEADER.test(key)) {
|
|
7
|
+
return '[redacted]';
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function tapeHeaders(headers?: HeadersInit): Record<string, string> {
|
|
13
|
+
const out: Record<string, string> = {};
|
|
14
|
+
for (const [key, value] of new Headers(headers).entries()) {
|
|
15
|
+
out[key] = tapeHeaderValue(key, value);
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function throwRow(err: unknown): Record<string, unknown> {
|
|
21
|
+
if (err instanceof Error) {
|
|
22
|
+
return { eventType: 'http_throw', name: err.name, message: err.message };
|
|
23
|
+
}
|
|
24
|
+
return { eventType: 'http_throw', name: 'Error', message: String(err) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function tapFetch(
|
|
28
|
+
tap: ProviderCompleteRequest['tapGemini'],
|
|
29
|
+
send: typeof fetch = fetch,
|
|
30
|
+
): typeof fetch {
|
|
31
|
+
return async (url, init) => {
|
|
32
|
+
const method = init?.method ?? 'GET';
|
|
33
|
+
tap?.({
|
|
34
|
+
eventType: 'http_request',
|
|
35
|
+
method,
|
|
36
|
+
url: String(url),
|
|
37
|
+
headers: tapeHeaders(init?.headers),
|
|
38
|
+
});
|
|
39
|
+
try {
|
|
40
|
+
const res = await send(url, init);
|
|
41
|
+
tap?.({
|
|
42
|
+
eventType: 'http_response',
|
|
43
|
+
status: res.status,
|
|
44
|
+
headers: tapeHeaders(res.headers),
|
|
45
|
+
});
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
tap?.({ eventType: 'http_error_body', body: await res.clone().text() });
|
|
48
|
+
}
|
|
49
|
+
return res;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
tap?.(throwRow(err));
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { tapFetch };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { TheorumError } from '../guardrails/error.ts';
|
|
2
|
+
import { getTool } from '../kernel/registry/catalog.ts';
|
|
3
|
+
import { getStructured } from '../kernel/registry/schemas.ts';
|
|
4
|
+
import type { InteractionPart, ProviderCompleteRequest } from '../kernel/types.ts';
|
|
5
|
+
|
|
6
|
+
function camelToSnake(key: string): string {
|
|
7
|
+
return key.replaceAll(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function toGoogleValue(value: unknown): unknown {
|
|
11
|
+
if (Array.isArray(value)) {
|
|
12
|
+
return value.map(toGoogleValue);
|
|
13
|
+
}
|
|
14
|
+
if (value && typeof value === 'object') {
|
|
15
|
+
const out: Record<string, unknown> = {};
|
|
16
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
17
|
+
// JSON Schema property names must stay as authored (e.g. correctAnswer in
|
|
18
|
+
// both properties and required). Snake-casing breaks Gemini validation.
|
|
19
|
+
if (key === 'schema') {
|
|
20
|
+
out[camelToSnake(key)] = nested;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
out[camelToSnake(key)] = toGoogleValue(nested);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function wirePart(part: InteractionPart): Record<string, string> {
|
|
31
|
+
if (part.type === 'text') {
|
|
32
|
+
return { type: 'text', text: part.text };
|
|
33
|
+
}
|
|
34
|
+
return { type: part.type, mimeType: part.mimeType, data: part.data };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const USER_INPUT = 'user_input';
|
|
38
|
+
|
|
39
|
+
function userInputStep(parts: InteractionPart[]): {
|
|
40
|
+
type: string;
|
|
41
|
+
content: Record<string, string>[];
|
|
42
|
+
} {
|
|
43
|
+
return { type: USER_INPUT, content: parts.map(wirePart) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function historyStep(msg: import('../kernel/types.ts').TurnHistoryMessage): {
|
|
47
|
+
type: string;
|
|
48
|
+
content: Record<string, string>[];
|
|
49
|
+
} {
|
|
50
|
+
const isAssistant = msg.role === 'assistant';
|
|
51
|
+
const type = isAssistant ? 'model_turn' : 'user_input';
|
|
52
|
+
if (msg.parts && msg.parts.length > 0) {
|
|
53
|
+
return { type, content: msg.parts.map(wirePart) };
|
|
54
|
+
}
|
|
55
|
+
return { type, content: [{ type: 'text', text: msg.content ?? '' }] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function systemHoldsUserInput(system: string, parts: InteractionPart[]): boolean {
|
|
59
|
+
for (const part of parts) {
|
|
60
|
+
if (part.type === 'text' && part.text && system.includes(part.text)) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function jsonResponseFormat(schema: Record<string, unknown>): unknown[] {
|
|
68
|
+
return [{ type: 'text', mimeType: 'application/json', schema }];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function attachResponseFormat(req: ProviderCompleteRequest, camel: Record<string, unknown>): void {
|
|
72
|
+
if (req.speech) {
|
|
73
|
+
if (req.image) {
|
|
74
|
+
throw new TheorumError('cannot mix speech and image response formats');
|
|
75
|
+
}
|
|
76
|
+
if (req.structured) {
|
|
77
|
+
throw new TheorumError('cannot mix speech and structured response formats');
|
|
78
|
+
}
|
|
79
|
+
camel.responseFormat = { type: 'audio' };
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (req.image) {
|
|
83
|
+
camel.responseFormat = {
|
|
84
|
+
type: 'image',
|
|
85
|
+
mimeType: req.image.mimeType,
|
|
86
|
+
aspectRatio: req.image.aspectRatio,
|
|
87
|
+
imageSize: req.image.size,
|
|
88
|
+
};
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!req.structured) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const spec = getStructured(req.structured);
|
|
95
|
+
if (spec.enforced !== 'responseFormat' || !spec.jsonSchema) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
camel.responseFormat = jsonResponseFormat(spec.jsonSchema);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function attachSpeechConfig(
|
|
102
|
+
req: ProviderCompleteRequest,
|
|
103
|
+
generationConfig: Record<string, unknown>,
|
|
104
|
+
): void {
|
|
105
|
+
if (!req.speech) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (req.speech.voice) {
|
|
109
|
+
generationConfig.speechConfig = [{ voice: req.speech.voice }];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function inputStepsFromRequest(req: ProviderCompleteRequest): {
|
|
114
|
+
type: string;
|
|
115
|
+
content: Record<string, string>[];
|
|
116
|
+
}[] {
|
|
117
|
+
const inputSteps: { type: string; content: Record<string, string>[] }[] = [];
|
|
118
|
+
for (const h of req.history ?? []) {
|
|
119
|
+
inputSteps.push(historyStep(h));
|
|
120
|
+
}
|
|
121
|
+
if (req.input.length > 0 || inputSteps.length === 0) {
|
|
122
|
+
inputSteps.push(userInputStep(req.input));
|
|
123
|
+
}
|
|
124
|
+
return inputSteps;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function applyOptionalRequestFields(
|
|
128
|
+
req: ProviderCompleteRequest,
|
|
129
|
+
camel: Record<string, unknown>,
|
|
130
|
+
): void {
|
|
131
|
+
if (req.store !== undefined) {
|
|
132
|
+
camel.store = req.store;
|
|
133
|
+
}
|
|
134
|
+
if (req.previousInteractionId) {
|
|
135
|
+
camel.previousInteractionId = req.previousInteractionId;
|
|
136
|
+
}
|
|
137
|
+
if (req.system) {
|
|
138
|
+
camel.systemInstruction = req.system;
|
|
139
|
+
}
|
|
140
|
+
if (req.builtins.length > 0) {
|
|
141
|
+
camel.tools = req.builtins.map((id) => {
|
|
142
|
+
const type = getTool(id)?.interactionsType;
|
|
143
|
+
if (!type) {
|
|
144
|
+
throw new TheorumError(`Builtin '${id}' has no Interactions wire type`);
|
|
145
|
+
}
|
|
146
|
+
return { type };
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function baseInteractionsBody(req: ProviderCompleteRequest): Record<string, unknown> {
|
|
152
|
+
const generationConfig: Record<string, unknown> = {
|
|
153
|
+
temperature: req.temperature,
|
|
154
|
+
maxOutputTokens: req.maxOutputTokens,
|
|
155
|
+
};
|
|
156
|
+
if (req.speech) {
|
|
157
|
+
// TTS models reject chat thinking knobs; voice lives under speech_config.
|
|
158
|
+
attachSpeechConfig(req, generationConfig);
|
|
159
|
+
} else {
|
|
160
|
+
generationConfig.thinkingLevel = req.thinking;
|
|
161
|
+
generationConfig.thinkingSummaries = req.summaries;
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
model: req.apiId,
|
|
165
|
+
stream: true,
|
|
166
|
+
input: inputStepsFromRequest(req),
|
|
167
|
+
generationConfig,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Interactions REST body for one complete() call (Google snake_case keys). */
|
|
172
|
+
function toInteractionsBody(req: ProviderCompleteRequest): Record<string, unknown> {
|
|
173
|
+
if (systemHoldsUserInput(req.system, req.input)) {
|
|
174
|
+
throw new TheorumError('user input cannot be placed in the system block');
|
|
175
|
+
}
|
|
176
|
+
const camel = baseInteractionsBody(req);
|
|
177
|
+
applyOptionalRequestFields(req, camel);
|
|
178
|
+
attachResponseFormat(req, camel);
|
|
179
|
+
return toGoogleValue(camel) as Record<string, unknown>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export { camelToSnake, toInteractionsBody };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini key vault selection, quota overflow, and fetch retries.
|
|
3
|
+
*
|
|
4
|
+
* Host applications supply vault credentials through `GeminiTransport`.
|
|
5
|
+
* THEORUM does not read environment variables for these keys.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { TheorumError, UPSTREAM_FAILED } from '../guardrails/error.ts';
|
|
11
|
+
import type { GeminiBucket } from '../kernel/types.ts';
|
|
12
|
+
|
|
13
|
+
type GeminiVault = Record<GeminiBucket, string | undefined>;
|
|
14
|
+
|
|
15
|
+
interface GeminiTransport {
|
|
16
|
+
vault: GeminiVault;
|
|
17
|
+
wait?: (ms: number) => Promise<void>;
|
|
18
|
+
fetch?: typeof fetch;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ATTEMPTS = 3;
|
|
22
|
+
const LAST_ATTEMPT = ATTEMPTS - 1;
|
|
23
|
+
const BACKOFF_FIRST_MS = 1000;
|
|
24
|
+
const BACKOFF_SECOND_MS = 2000;
|
|
25
|
+
const BACKOFF_THIRD_MS = 4000;
|
|
26
|
+
const BACKOFF_MS = [BACKOFF_FIRST_MS, BACKOFF_SECOND_MS, BACKOFF_THIRD_MS];
|
|
27
|
+
|
|
28
|
+
const HTTP_TIMEOUT = 408;
|
|
29
|
+
const HTTP_QUOTA = 429;
|
|
30
|
+
const HTTP_SERVER = 500;
|
|
31
|
+
const HTTP_BAD_GATEWAY = 502;
|
|
32
|
+
const HTTP_UNAVAILABLE = 503;
|
|
33
|
+
const HTTP_GATEWAY_TIMEOUT = 504;
|
|
34
|
+
|
|
35
|
+
const QUOTA_RE = /quota/i;
|
|
36
|
+
const TRANSIENT_THROWN_RE =
|
|
37
|
+
/name resolution|dns|econnreset|econnrefused|etimedout|network|fetch failed|temporarily unavailable|socket|503|502|504/i;
|
|
38
|
+
|
|
39
|
+
function waitDefault(ms: number): Promise<void> {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
setTimeout(resolve, ms);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isQuota(err: unknown): boolean {
|
|
46
|
+
const s = String(err);
|
|
47
|
+
return s.includes(String(HTTP_QUOTA)) || s.includes('RESOURCE_EXHAUSTED') || QUOTA_RE.test(s);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isTransientHttp(status: number): boolean {
|
|
51
|
+
return (
|
|
52
|
+
status === HTTP_TIMEOUT ||
|
|
53
|
+
status === HTTP_QUOTA ||
|
|
54
|
+
status === HTTP_SERVER ||
|
|
55
|
+
status === HTTP_BAD_GATEWAY ||
|
|
56
|
+
status === HTTP_UNAVAILABLE ||
|
|
57
|
+
status === HTTP_GATEWAY_TIMEOUT
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isTransientThrown(err: unknown): boolean {
|
|
62
|
+
return TRANSIENT_THROWN_RE.test(String(err));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function requireKey(vault: GeminiVault, bucket: GeminiBucket): string {
|
|
66
|
+
const key = vault[bucket];
|
|
67
|
+
if (!key) {
|
|
68
|
+
throw new TheorumError(UPSTREAM_FAILED);
|
|
69
|
+
}
|
|
70
|
+
return key;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function backoffMs(attempt: number): number {
|
|
74
|
+
return BACKOFF_MS[attempt] ?? BACKOFF_SECOND_MS;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function runWithBackoff<T>(
|
|
78
|
+
apiKey: string,
|
|
79
|
+
run: (apiKey: string) => Promise<T>,
|
|
80
|
+
wait: (ms: number) => Promise<void>,
|
|
81
|
+
attempt: number,
|
|
82
|
+
): Promise<T> {
|
|
83
|
+
try {
|
|
84
|
+
return await run(apiKey);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (!(isQuota(err) || isTransientThrown(err)) || attempt === LAST_ATTEMPT) {
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
await wait(backoffMs(attempt));
|
|
90
|
+
return runWithBackoff(apiKey, run, wait, attempt + 1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function canOverflow(
|
|
95
|
+
bucket: GeminiBucket,
|
|
96
|
+
vault: GeminiVault,
|
|
97
|
+
primary: string,
|
|
98
|
+
): string | undefined {
|
|
99
|
+
if (bucket === 'paid') {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
const { paid } = vault;
|
|
103
|
+
if (!paid || paid === primary) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
return paid;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function withGeminiKey<T>(
|
|
110
|
+
bucket: GeminiBucket,
|
|
111
|
+
run: (apiKey: string) => Promise<T>,
|
|
112
|
+
transport: GeminiTransport,
|
|
113
|
+
): Promise<T> {
|
|
114
|
+
const wait = transport.wait ?? waitDefault;
|
|
115
|
+
const primary = requireKey(transport.vault, bucket);
|
|
116
|
+
try {
|
|
117
|
+
return await runWithBackoff(primary, run, wait, 0);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const paid = canOverflow(bucket, transport.vault, primary);
|
|
120
|
+
if (!(isQuota(err) && paid)) {
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
return await runWithBackoff(paid, run, wait, 0);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function withApiKey(init: RequestInit, apiKey: string): RequestInit {
|
|
128
|
+
const headers = new Headers(init.headers);
|
|
129
|
+
headers.set('x-goog-api-key', apiKey);
|
|
130
|
+
if (!headers.has('Content-Type') && (init.method || 'GET').toUpperCase() !== 'GET') {
|
|
131
|
+
headers.set('Content-Type', 'application/json');
|
|
132
|
+
}
|
|
133
|
+
return { ...init, headers };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface FetchAttempt {
|
|
137
|
+
href: string;
|
|
138
|
+
init: RequestInit;
|
|
139
|
+
apiKey: string;
|
|
140
|
+
transport: GeminiTransport;
|
|
141
|
+
attempt: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function fetchWithBackoff(args: FetchAttempt): Promise<Response> {
|
|
145
|
+
const wait = args.transport.wait ?? waitDefault;
|
|
146
|
+
const send = args.transport.fetch ?? fetch;
|
|
147
|
+
try {
|
|
148
|
+
const last = await send(args.href, withApiKey(args.init, args.apiKey));
|
|
149
|
+
if (!isTransientHttp(last.status) || args.attempt === LAST_ATTEMPT) {
|
|
150
|
+
return last;
|
|
151
|
+
}
|
|
152
|
+
await wait(backoffMs(args.attempt));
|
|
153
|
+
return fetchWithBackoff({ ...args, attempt: args.attempt + 1 });
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (!isTransientThrown(err) || args.attempt === LAST_ATTEMPT) {
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
await wait(backoffMs(args.attempt));
|
|
159
|
+
return fetchWithBackoff({ ...args, attempt: args.attempt + 1 });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function fetchGemini(
|
|
164
|
+
url: string,
|
|
165
|
+
init: RequestInit,
|
|
166
|
+
bucket: GeminiBucket,
|
|
167
|
+
transport: GeminiTransport,
|
|
168
|
+
): Promise<Response> {
|
|
169
|
+
const parsed = new URL(url);
|
|
170
|
+
parsed.searchParams.delete('key');
|
|
171
|
+
const href = parsed.toString();
|
|
172
|
+
const primary = requireKey(transport.vault, bucket);
|
|
173
|
+
let last = await fetchWithBackoff({ href, init, apiKey: primary, transport, attempt: 0 });
|
|
174
|
+
const paid = canOverflow(bucket, transport.vault, primary);
|
|
175
|
+
if (last.status === HTTP_QUOTA && paid) {
|
|
176
|
+
last = await fetchWithBackoff({ href, init, apiKey: paid, transport, attempt: 0 });
|
|
177
|
+
}
|
|
178
|
+
return last;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export type { GeminiTransport, GeminiVault };
|
|
182
|
+
export { fetchGemini, withGeminiKey };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider adapters for THEORUM's `ModelProvider` contract.
|
|
3
|
+
*
|
|
4
|
+
* Host apps should use `createProvider(profile, options)` — the single door.
|
|
5
|
+
* Credentials are always supplied by the host application.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type { CreateProviderOptions } from './create-provider.ts';
|
|
11
|
+
export { createProvider } from './create-provider.ts';
|
|
12
|
+
export type { GeminiTransport, GeminiVault } from './keys.ts';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter provider adapter and payload helpers.
|
|
3
|
+
*
|
|
4
|
+
* Prefer `createProvider(profile, { openRouter })` from `theorum` / `theorum/providers`
|
|
5
|
+
* for turn execution. This entrypoint is for hosts that build OpenRouter payloads
|
|
6
|
+
* outside the runner.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { createOpenRouterProvider } from './openrouter.ts';
|
|
12
|
+
export type { OpenRouterConfig } from './openrouter-payload.ts';
|
|
13
|
+
export {
|
|
14
|
+
resolveOpenRouterModel,
|
|
15
|
+
toOpenRouterPayload,
|
|
16
|
+
} from './openrouter-payload.ts';
|