diff --git a/docs/plans/state.json b/docs/plans/state.json index bec4543..570b629 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -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 ` 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", diff --git a/src/config/schema.ts b/src/config/schema.ts index e2e6b70..b795f3c 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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]; diff --git a/src/daemon/models.ts b/src/daemon/models.ts index d833dc8..a3bf1df 100644 --- a/src/daemon/models.ts +++ b/src/daemon/models.ts @@ -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).provider}`); } diff --git a/src/models/index.ts b/src/models/index.ts index 72574a0..a1a9c62 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -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'; diff --git a/src/models/synthetic.test.ts b/src/models/synthetic.test.ts new file mode 100644 index 0000000..3f1caf2 --- /dev/null +++ b/src/models/synthetic.test.ts @@ -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:', 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']); + }); +}); + diff --git a/src/models/synthetic.ts b/src/models/synthetic.ts new file mode 100644 index 0000000..122c5d1 --- /dev/null +++ b/src/models/synthetic.ts @@ -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:": always reply with . + * - "error:": always throw an error with . + */ + 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 " + 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 { + 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 { + 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)) }; + } + } +} +