|
| 1 | +/** |
| 2 | + * /api/swarm — run a deepagents-backed agent swarm, powered by |
| 3 | + * @logicsrc/agentswarm. Signed-in, bring-your-own-key: the caller supplies a |
| 4 | + * provider + API key per request (used transiently, NOT stored — same model as |
| 5 | + * /api/models). An optional `rubric` turns on the self-check loop. |
| 6 | + */ |
| 7 | +import { Hono } from 'hono'; |
| 8 | +import { |
| 9 | + createDeepAgentRunner, |
| 10 | + createRubricRunner, |
| 11 | + createLLMJudge, |
| 12 | + type SwarmRunner, |
| 13 | +} from '@logicsrc/agentswarm'; |
| 14 | + |
| 15 | +/** OpenAI-compatible provider bases (mirrors /api/models). anthropic is special-cased. */ |
| 16 | +const OPENAI_COMPAT_BASE: Record<string, string> = { |
| 17 | + openai: 'https://api.openai.com/v1', |
| 18 | + google: 'https://generativelanguage.googleapis.com/v1beta/openai', |
| 19 | + deepseek: 'https://api.deepseek.com/v1', |
| 20 | + perplexity: 'https://api.perplexity.ai', |
| 21 | + huggingface: 'https://router.huggingface.co/v1', |
| 22 | + kimi: 'https://api.moonshot.ai/v1', |
| 23 | + qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1', |
| 24 | + c0mpute: 'https://api.c0mpute.com/v1', |
| 25 | +}; |
| 26 | + |
| 27 | +/** Build a LangChain chat model for the requested provider with the caller's key. */ |
| 28 | +async function buildModel( |
| 29 | + provider: string, |
| 30 | + apiKey: string, |
| 31 | + model?: string, |
| 32 | + baseUrl?: string, |
| 33 | +): Promise<object> { |
| 34 | + if (provider === 'anthropic') { |
| 35 | + const { ChatAnthropic } = await import('@langchain/anthropic'); |
| 36 | + return new ChatAnthropic({ apiKey, model: model || 'claude-sonnet-4-6' }); |
| 37 | + } |
| 38 | + const base = (baseUrl || OPENAI_COMPAT_BASE[provider] || '').replace(/\/$/, ''); |
| 39 | + if (!base || /localhost|127\.0\.0\.1/.test(base)) { |
| 40 | + throw new Error(`unsupported or disallowed provider/baseUrl: ${provider}`); |
| 41 | + } |
| 42 | + const { ChatOpenAI } = await import('@langchain/openai'); |
| 43 | + return new ChatOpenAI({ apiKey, model: model || 'gpt-4o-mini', configuration: { baseURL: base } }); |
| 44 | +} |
| 45 | + |
| 46 | +export interface SwarmDeps { |
| 47 | + /** Resolves the signed-in user, or null. */ |
| 48 | + currentUser: (c: any) => Promise<{ id: string } | null>; |
| 49 | +} |
| 50 | + |
| 51 | +/** Hono sub-router mounted at /api/swarm. */ |
| 52 | +export function swarmRoutes(deps: SwarmDeps): Hono { |
| 53 | + const r = new Hono(); |
| 54 | + |
| 55 | + r.post('/', async (c) => { |
| 56 | + const user = await deps.currentUser(c); |
| 57 | + if (!user) return c.json({ error: 'unauthorized' }, 401); |
| 58 | + |
| 59 | + const body = await c.req.json().catch(() => ({} as any)); |
| 60 | + const { messages, rubric, provider = 'anthropic', apiKey, model, baseUrl } = body; |
| 61 | + if (!Array.isArray(messages) || messages.length === 0) { |
| 62 | + return c.json({ error: 'messages must be a non-empty array' }, 400); |
| 63 | + } |
| 64 | + if (!apiKey || typeof apiKey !== 'string') { |
| 65 | + return c.json({ error: 'apiKey required (bring your own key)' }, 400); |
| 66 | + } |
| 67 | + |
| 68 | + try { |
| 69 | + const llm = await buildModel(String(provider), apiKey, model, baseUrl); |
| 70 | + let runner: SwarmRunner = await createDeepAgentRunner({ model: llm }); |
| 71 | + if (typeof rubric === 'string' && rubric.trim()) { |
| 72 | + runner = createRubricRunner({ runner, judge: await createLLMJudge({ chatModel: llm }) }); |
| 73 | + } |
| 74 | + const result = await runner.run({ messages, rubric }); |
| 75 | + return c.json(result); |
| 76 | + } catch (e: any) { |
| 77 | + return c.json({ error: e?.message || String(e) }, 500); |
| 78 | + } |
| 79 | + }); |
| 80 | + |
| 81 | + return r; |
| 82 | +} |
0 commit comments