models: add synthetic provider
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user