diff --git a/web/src/kernel/dynamic-tools.ts b/web/src/kernel/dynamic-tools.ts index 1b2dacc..ff11b77 100644 --- a/web/src/kernel/dynamic-tools.ts +++ b/web/src/kernel/dynamic-tools.ts @@ -3,6 +3,7 @@ // No eval(). No code execution. Just parameterized prompts. import { type EdgeEnv } from './dispatch.js'; +import { buildLLMProviderFactory } from './provider-factory.js'; import type { ToolExecutor, ToolStatus } from '../schema-enums.js'; // ─── Types ────────────────────────────────────────────────── @@ -160,58 +161,20 @@ export async function executeDynamicTool( ): Promise { const rendered = renderPrompt(tool.prompt_template, inputs); const start = Date.now(); - let text = ''; - let cost = 0; - - if (tool.executor === 'workers_ai' && env.ai) { - // Workers AI — free inference - const result = await env.ai.run('@cf/meta/llama-3.1-8b-instruct' as Parameters[0], { - messages: [ - { role: 'system', content: 'You are a focused tool. Answer precisely. No preamble.' }, - { role: 'user', content: rendered }, - ], - max_tokens: 1024, - }) as { response?: string }; - text = result.response ?? ''; - cost = 0; - } else if (tool.executor === 'groq' && env.groqApiKey) { - // Groq — fast, cheap - const res = await fetch('https://api.groq.com/openai/v1/chat/completions', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${env.groqApiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: env.groqModel ?? 'llama-3.3-70b-versatile', - messages: [ - { role: 'system', content: 'You are a focused tool. Answer precisely. No preamble.' }, - { role: 'user', content: rendered }, - ], - max_tokens: 1024, - }), - signal: AbortSignal.timeout(15_000), - }); - if (!res.ok) throw new Error(`Groq error: ${res.status}`); - const data = await res.json() as { choices: Array<{ message: { content: string } }> }; - text = data.choices[0]?.message?.content ?? ''; - cost = 0.001; // ~$0.001 per Groq call - } else { - // Default: Workers AI fallback (always available on CF Workers) - if (env.ai) { - const result = await env.ai.run('@cf/meta/llama-3.1-8b-instruct' as Parameters[0], { - messages: [ - { role: 'system', content: 'You are a focused tool. Answer precisely. No preamble.' }, - { role: 'user', content: rendered }, - ], - max_tokens: 1024, - }) as { response?: string }; - text = result.response ?? ''; - cost = 0; - } else { - throw new Error(`Executor "${tool.executor}" not available — no API key or binding`); - } - } + const executor = tool.executor as ToolExecutor; + const model = resolveDynamicToolModel(executor, env); + const factory = buildLLMProviderFactory(env); + const result = await factory.generateResponse({ + model, + messages: [ + { role: 'system', content: 'You are a focused tool. Answer precisely. No preamble.' }, + { role: 'user', content: rendered }, + ], + maxTokens: 1024, + temperature: 0.2, + }); + const text = result.message ?? ''; + const cost = result.usage.cost; const latencyMs = Date.now() - start; @@ -229,6 +192,21 @@ export async function executeDynamicTool( return { text, cost, latency_ms: latencyMs, executor: tool.executor }; } +function resolveDynamicToolModel(executor: ToolExecutor, env: EdgeEnv): string { + if (executor === 'workers_ai') { + if (!env.ai) throw new Error('Executor "workers_ai" not available — no AI binding'); + return '@cf/meta/llama-3.1-8b-instruct'; + } + + if (executor === 'groq') { + if (!env.groqApiKey) throw new Error('Executor "groq" not available — no Groq API key'); + return env.groqModel ?? 'llama-3.3-70b-versatile'; + } + + if (!env.ai) throw new Error(`Executor "${executor}" not available — no AI binding`); + return env.gptOssModel; +} + // ─── Lifecycle (GC + Promotion) ───────────────────────────── const MAX_ACTIVE_TOOLS = 50; diff --git a/web/tests/dynamic-tools-provider.test.ts b/web/tests/dynamic-tools-provider.test.ts new file mode 100644 index 0000000..3f4c564 --- /dev/null +++ b/web/tests/dynamic-tools-provider.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { EdgeEnv } from '../src/kernel/dispatch.js'; +import type { DynamicTool } from '../src/kernel/dynamic-tools.js'; + +const mocks = vi.hoisted(() => ({ + buildLLMProviderFactory: vi.fn(), + generateResponse: vi.fn(), +})); + +vi.mock('../src/kernel/provider-factory.js', () => ({ + buildLLMProviderFactory: mocks.buildLLMProviderFactory, +})); + +const { executeDynamicTool } = await import('../src/kernel/dynamic-tools.js'); + +function makeDb() { + const run = vi.fn().mockResolvedValue({}); + const bind = vi.fn().mockReturnValue({ run }); + const prepare = vi.fn().mockReturnValue({ bind }); + return { db: { prepare } as unknown as D1Database, prepare, bind, run }; +} + +function makeEnv(overrides?: Partial): EdgeEnv { + const { db } = makeDb(); + return { + db, + anthropicApiKey: 'anthropic-test', + claudeModel: 'claude-sonnet', + opusModel: 'claude-opus', + gptOssModel: '@cf/openai/gpt-oss-120b', + groqApiKey: 'groq-test', + groqModel: 'llama-3.3-70b-versatile', + groqResponseModel: 'llama-3.1-8b-instant', + groqGptOssModel: 'openai/gpt-oss-120b', + bizopsFetcher: {} as Fetcher, + bizopsToken: 'bizops-test', + resendApiKey: 'resend-test', + resendApiKeyPersonal: 'resend-personal-test', + githubToken: 'github-test', + githubRepo: 'Stackbilt-dev/aegis-oss', + braveApiKey: 'brave-test', + notifyEmail: 'notify@test.dev', + baseUrl: 'https://aegis.test', + ai: { run: vi.fn() } as unknown as Ai, + anthropicBaseUrl: 'https://api.anthropic.com', + groqBaseUrl: 'https://api.groq.com', + ...overrides, + }; +} + +function makeTool(overrides?: Partial): DynamicTool { + return { + id: 'tool-1', + name: 'summarize_thing', + description: 'Summarize a thing', + input_schema: '{}', + prompt_template: 'Summarize {{thing}}', + executor: 'gpt_oss', + created_by: 'test', + status: 'active', + ttl_days: null, + use_count: 2, + last_used_at: null, + avg_latency_ms: 10, + avg_cost: 0.001, + created_at: '2026-06-02T00:00:00.000Z', + updated_at: '2026-06-02T00:00:00.000Z', + expires_at: null, + ...overrides, + }; +} + +describe('executeDynamicTool provider execution', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.buildLLMProviderFactory.mockReturnValue({ generateResponse: mocks.generateResponse }); + mocks.generateResponse.mockResolvedValue({ + message: 'provider output', + usage: { cost: 0.004, inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + model: '@cf/openai/gpt-oss-120b', + provider: 'cloudflare', + responseTime: 50, + }); + }); + + it('executes gpt_oss dynamic tools through the provider factory', async () => { + const db = makeDb(); + const env = makeEnv({ db: db.db }); + + const result = await executeDynamicTool(makeTool(), { thing: 'AEGIS' }, env); + + expect(mocks.buildLLMProviderFactory).toHaveBeenCalledWith(env); + expect(mocks.generateResponse).toHaveBeenCalledWith({ + model: '@cf/openai/gpt-oss-120b', + messages: [ + { role: 'system', content: 'You are a focused tool. Answer precisely. No preamble.' }, + { role: 'user', content: 'Summarize AEGIS' }, + ], + maxTokens: 1024, + temperature: 0.2, + }); + expect(db.bind).toHaveBeenCalledWith(3, expect.any(Number), 0.002, 'tool-1'); + expect(result).toEqual({ + text: 'provider output', + cost: 0.004, + latency_ms: expect.any(Number), + executor: 'gpt_oss', + }); + }); + + it('maps workers_ai dynamic tools to the Workers AI provider model', async () => { + await executeDynamicTool(makeTool({ executor: 'workers_ai' }), { thing: 'AEGIS' }, makeEnv()); + + expect(mocks.generateResponse).toHaveBeenCalledWith(expect.objectContaining({ + model: '@cf/meta/llama-3.1-8b-instruct', + })); + }); + + it('maps groq dynamic tools to the configured Groq model', async () => { + await executeDynamicTool(makeTool({ executor: 'groq' }), { thing: 'AEGIS' }, makeEnv({ groqModel: 'llama-test' })); + + expect(mocks.generateResponse).toHaveBeenCalledWith(expect.objectContaining({ + model: 'llama-test', + })); + }); + + it('fails gpt_oss dynamic tools without an AI binding', async () => { + await expect(executeDynamicTool(makeTool(), { thing: 'AEGIS' }, makeEnv({ ai: undefined }))) + .rejects.toThrow('Executor "gpt_oss" not available'); + }); +});