models: add synthetic provider
This commit is contained in:
+16
-1
@@ -100,6 +100,21 @@
|
||||
],
|
||||
"test_status": "pnpm typecheck passing (no new tests added in this change)"
|
||||
},
|
||||
"synthetic-model-provider": {
|
||||
"status": "completed",
|
||||
"date": "2026-02-14",
|
||||
"summary": "Added a local-only `synthetic` model provider for offline development and deterministic tests. Supports echo/fixed/error modes and a dev `@tool <name> <json>` directive to emit tool calls.",
|
||||
"files_created": [
|
||||
"src/models/synthetic.ts",
|
||||
"src/models/synthetic.test.ts"
|
||||
],
|
||||
"files_modified": [
|
||||
"src/config/schema.ts",
|
||||
"src/models/index.ts",
|
||||
"src/daemon/models.ts"
|
||||
],
|
||||
"test_status": "pnpm test:run src/models/synthetic.test.ts + pnpm typecheck passing"
|
||||
},
|
||||
"p0-p1-implementation-plan": {
|
||||
"file": "2026-02-06-p0-p1-implementation-plan.md",
|
||||
"status": "completed",
|
||||
@@ -1931,7 +1946,7 @@
|
||||
"tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor",
|
||||
"tier3_completion": "5/5 (100%) — lane queue, credential redaction, web UI token dashboard, xAI (Grok) provider, Voyage AI embeddings",
|
||||
"tier4_completion": "4/4 (100%) — gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes",
|
||||
"feature_gap_scorecard": "100/128 match (78%), 0 partial (0%), 28 missing (22%)",
|
||||
"feature_gap_scorecard": "101/128 match (79%), 0 partial (0%), 27 missing (21%)",
|
||||
"operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete — milestone done",
|
||||
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
|
||||
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback",
|
||||
|
||||
@@ -39,7 +39,7 @@ const serverSchema = z.object({
|
||||
});
|
||||
|
||||
/** All supported model provider identifiers. Used by the config schema and TUI autocompletion. */
|
||||
export const MODEL_PROVIDERS = ['anthropic', 'openai', 'gemini', 'ollama', 'llamacpp', 'openrouter', 'bedrock', 'github', 'zhipuai', 'xai'] as const;
|
||||
export const MODEL_PROVIDERS = ['anthropic', 'openai', 'gemini', 'ollama', 'llamacpp', 'openrouter', 'bedrock', 'github', 'zhipuai', 'xai', 'synthetic'] as const;
|
||||
|
||||
export type ModelProvider = (typeof MODEL_PROVIDERS)[number];
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Config, ModelConfig } from '../config/index.js';
|
||||
import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, GeminiClient, BedrockClient, GitHubModelsClient, ModelRouter, DEFAULT_RETRY_CONFIG } from '../models/index.js';
|
||||
import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, GeminiClient, BedrockClient, GitHubModelsClient, SyntheticClient, ModelRouter, DEFAULT_RETRY_CONFIG } from '../models/index.js';
|
||||
import type { ModelClient, RetryConfig, ModelTier } from '../models/index.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { getZaiApiKey } from '../auth/zai.js';
|
||||
@@ -132,6 +132,8 @@ export function createClientFromConfig(cfg: ModelConfig): ModelClient {
|
||||
});
|
||||
},
|
||||
});
|
||||
case 'synthetic':
|
||||
return new SyntheticClient({ model: cfg.model });
|
||||
default:
|
||||
throw new Error(`Unknown model provider: ${(cfg as Record<string, unknown>).provider}`);
|
||||
}
|
||||
|
||||
@@ -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