models: add synthetic provider

This commit is contained in:
William Valentin
2026-02-14 09:34:39 -08:00
parent b22d6fa0ce
commit 184ebe4480
6 changed files with 218 additions and 3 deletions
+1
View File
@@ -3,6 +3,7 @@ export { OpenAIClient, type OpenAIClientConfig } from './openai.js';
export { GeminiClient, type GeminiClientConfig } from './gemini.js';
export { BedrockClient, type BedrockClientConfig } from './bedrock.js';
export { GitHubModelsClient, type GitHubModelsClientConfig } from './github.js';
export { SyntheticClient, type SyntheticClientConfig } from './synthetic.js';
export { OllamaClient, type OllamaClientConfig } from './local/index.js';
export { LlamaCppClient, type LlamaCppClientConfig } from './local/index.js';
export { ModelRouter, type ModelRouterConfig, type ModelTier } from './router.js';
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { SyntheticClient } from './synthetic.js';
import type { ChatRequest } from './types.js';
function makeRequest(text: string): ChatRequest {
return {
messages: [{ role: 'user', content: text }],
};
}
describe('SyntheticClient', () => {
it('echoes the last user message by default', async () => {
const client = new SyntheticClient({ model: 'echo' });
const res = await client.chat(makeRequest('hello'));
expect(res.content).toBe('hello');
expect(res.stopReason).toBe('end_turn');
expect(res.toolCalls).toBeUndefined();
expect(res.usage.inputTokens).toBeGreaterThan(0);
expect(res.usage.outputTokens).toBeGreaterThan(0);
});
it('supports fixed responses via model=fixed:<text>', async () => {
const client = new SyntheticClient({ model: 'fixed:ok' });
const res = await client.chat(makeRequest('ignored'));
expect(res.content).toBe('ok');
expect(res.stopReason).toBe('end_turn');
});
it('supports tool calls via @tool directive', async () => {
const client = new SyntheticClient({ model: 'echo' });
const res = await client.chat(makeRequest('@tool memory.read {"namespace":"global"}'));
expect(res.stopReason).toBe('tool_use');
expect(res.content).toBe('');
expect(res.toolCalls?.length).toBe(1);
expect(res.toolCalls?.[0]?.name).toBe('memory.read');
expect(res.toolCalls?.[0]?.args).toEqual({ namespace: 'global' });
});
it('streams content + done', async () => {
const client = new SyntheticClient({ model: 'fixed:stream-me' });
const events: string[] = [];
for await (const ev of client.chatStream!(makeRequest('x'))) {
events.push(ev.type);
}
expect(events).toEqual(['content', 'done']);
});
it('streams tool_use + done for @tool directives', async () => {
const client = new SyntheticClient({ model: 'echo' });
const types: string[] = [];
const toolNames: string[] = [];
for await (const ev of client.chatStream!(makeRequest('@tool file.read {"path":"README.md"}'))) {
types.push(ev.type);
if (ev.type === 'tool_use' && ev.toolCall) {
toolNames.push(ev.toolCall.name);
}
}
expect(types).toEqual(['tool_use', 'done']);
expect(toolNames).toEqual(['file.read']);
});
});
+135
View File
@@ -0,0 +1,135 @@
import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient, ModelToolCall, TokenUsage } from './types.js';
import { getMessageText } from './media.js';
export interface SyntheticClientConfig {
/**
* Controls behavior. Suggested values:
* - "echo" (default): reply with the last user message text.
* - "fixed:<text>": always reply with <text>.
* - "error:<message>": always throw an error with <message>.
*/
model: string;
}
function getLastUserText(request: ChatRequest): string {
for (let i = request.messages.length - 1; i >= 0; i--) {
const msg = request.messages[i];
if (msg.role === 'user') {
return getMessageText(msg);
}
}
return '';
}
function estimateTokens(text: string): number {
// Rough heuristic for deterministic, offline usage reporting.
return Math.max(1, Math.ceil(text.length / 4));
}
function buildUsage(request: ChatRequest, content: string): TokenUsage {
const inputText = request.messages.map(m => getMessageText(m)).join('\n');
return {
inputTokens: estimateTokens(inputText),
outputTokens: estimateTokens(content),
};
}
function parseToolDirective(text: string): ModelToolCall | null {
// Dev/test escape hatch: "@tool <name> <json>"
const trimmed = text.trim();
if (!trimmed.startsWith('@tool ')) {return null;}
const rest = trimmed.slice('@tool '.length).trim();
const firstSpace = rest.indexOf(' ');
const name = (firstSpace === -1 ? rest : rest.slice(0, firstSpace)).trim();
const rawArgs = (firstSpace === -1 ? '' : rest.slice(firstSpace + 1)).trim();
if (!name) {return null;}
let args: unknown = {};
if (rawArgs) {
try {
args = JSON.parse(rawArgs);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Synthetic tool directive JSON parse error: ${message}`);
}
}
return { id: 'synthetic-tool-1', name, args };
}
function parseMode(model: string): { kind: 'echo' | 'fixed' | 'error'; value?: string } {
if (model.startsWith('fixed:')) {
return { kind: 'fixed', value: model.slice('fixed:'.length) };
}
if (model.startsWith('error:')) {
return { kind: 'error', value: model.slice('error:'.length) };
}
if (model === 'echo' || !model) {
return { kind: 'echo' };
}
// Unknown values default to echo to keep config forgiving.
return { kind: 'echo' };
}
export class SyntheticClient implements ModelClient {
private readonly mode: { kind: 'echo' | 'fixed' | 'error'; value?: string };
constructor(config: SyntheticClientConfig) {
this.mode = parseMode(config.model);
}
async chat(request: ChatRequest): Promise<ChatResponse> {
const lastUserText = getLastUserText(request);
const toolCall = parseToolDirective(lastUserText);
if (toolCall) {
const usage = buildUsage(request, '');
return {
content: '',
stopReason: 'tool_use',
usage,
toolCalls: [toolCall],
thinkingContent: request.thinking ? '[synthetic] thinking enabled' : undefined,
};
}
if (this.mode.kind === 'error') {
throw new Error(this.mode.value || 'Synthetic error');
}
const content = this.mode.kind === 'fixed'
? (this.mode.value ?? '')
: lastUserText;
const usage = buildUsage(request, content);
return {
content,
stopReason: 'end_turn',
usage,
thinkingContent: request.thinking ? '[synthetic] thinking enabled' : undefined,
};
}
async *chatStream(request: ChatRequest): AsyncIterable<ChatStreamEvent> {
try {
const response = await this.chat(request);
if (response.toolCalls?.length) {
for (const toolCall of response.toolCalls) {
yield { type: 'tool_use', toolCall };
}
yield { type: 'done', usage: response.usage };
return;
}
if (response.content) {
yield { type: 'content', content: response.content };
}
yield { type: 'done', usage: response.usage };
} catch (error) {
yield { type: 'error', error: error instanceof Error ? error : new Error(String(error)) };
}
}
}