From e8f317a81551c00df90c353f3933adf744d00431 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 7 Nov 2026 10:21:28 +0000 Subject: [PATCH] feat(api): c0mpute as a swarm provider, house key for users without one /api/swarm: provider c0mpute runs on Infernet's OpenAI-compatible endpoint (infernetprotocol.com/v1; api.c0mpute.com never resolved). A user's own key keeps priority; with no key, provider c0mpute runs on the house C0MPUTE_API_KEY, capped per user per day. A request naming neither goes to c0mpute only with SWARM_DEFAULT_PROVIDER=c0mpute (private messages to peer nodes is an explicit choice). c0mpute runs answer 200 at once with keepalive spaces then the JSON, so minutes-long calls survive the 300 s proxy. c0mpute also added to /api/models, the web settings, the sidebar and model-providers provider tables (own key). Co-Authored-By: Claude Opus 5.5 --- .../extensions/ai-sidebar/providers.js | 3 + apps/web/public/settings.js | 3 + packages/model-providers/src/catalog.ts | 2 + packages/model-providers/src/endpoints.ts | 1 + packages/model-providers/src/index.test.ts | 6 +- packages/model-providers/src/index.ts | 1 + services/api/src/index.ts | 5 +- services/api/src/swarm.test.ts | 74 ++++++++++ services/api/src/swarm.ts | 139 +++++++++++++++++- 9 files changed, 222 insertions(+), 12 deletions(-) create mode 100644 services/api/src/swarm.test.ts diff --git a/apps/desktop/extensions/ai-sidebar/providers.js b/apps/desktop/extensions/ai-sidebar/providers.js index 8e5e1fc1..23683cb9 100644 --- a/apps/desktop/extensions/ai-sidebar/providers.js +++ b/apps/desktop/extensions/ai-sidebar/providers.js @@ -12,6 +12,8 @@ export const PROVIDERS = { huggingface: { label: 'Hugging Face', baseUrl: 'https://router.huggingface.co/v1', anthropic: false, keyless: false }, kimi: { label: 'Kimi (Moonshot)', baseUrl: 'https://api.moonshot.ai/v1', anthropic: false, keyless: false }, qwen: { label: 'Qwen (Alibaba)', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', anthropic: false, keyless: false }, + // Your own Infernet account key (ik_live_...), billed per job on c0mpute.com. + c0mpute: { label: 'c0mpute (peer network)', baseUrl: 'https://infernetprotocol.com/v1', anthropic: false, keyless: false }, ollama: { label: 'Ollama (local)', baseUrl: 'http://localhost:11434/v1', anthropic: false, keyless: true }, lmstudio: { label: 'LM Studio (local)', baseUrl: 'http://localhost:1234/v1', anthropic: false, keyless: true }, vllm: { label: 'vLLM (self-hosted)', baseUrl: 'http://localhost:8000/v1', anthropic: false, keyless: true }, @@ -28,6 +30,7 @@ export const KNOWN_MODELS = { huggingface: ['meta-llama/Llama-3.3-70B-Instruct', 'Qwen/Qwen2.5-72B-Instruct'], kimi: ['kimi-k2', 'kimi-latest', 'moonshot-v1-128k'], qwen: ['qwen-max', 'qwen-plus', 'qwen-turbo'], + c0mpute: ['huihui_ai/qwen3.5-abliterated:9b', 'qwen2.5:7b', 'gemma3:4b'], ollama: ['llama3.2', 'qwen2.5', 'mistral', 'phi4'], lmstudio: [], vllm: [], diff --git a/apps/web/public/settings.js b/apps/web/public/settings.js index d0f94555..b21caf31 100644 --- a/apps/web/public/settings.js +++ b/apps/web/public/settings.js @@ -31,6 +31,8 @@ const PROVIDERS = [ { id: 'huggingface', label: 'Hugging Face', keyless: false }, { id: 'kimi', label: 'Kimi (Moonshot)', keyless: false }, { id: 'qwen', label: 'Qwen (Alibaba)', keyless: false }, + // Your own Infernet account key (ik_live_...), billed per job on c0mpute.com. + { id: 'c0mpute', label: 'c0mpute (peer network)', keyless: false }, { id: 'ollama', label: 'Ollama (local)', keyless: true }, { id: 'lmstudio', label: 'LM Studio (local)', keyless: true }, { id: 'vllm', label: 'vLLM (self-hosted)', keyless: true }, @@ -48,6 +50,7 @@ const KNOWN_MODELS = { huggingface: ['meta-llama/Llama-3.3-70B-Instruct', 'Qwen/Qwen2.5-72B-Instruct'], kimi: ['kimi-k2', 'kimi-latest', 'moonshot-v1-128k'], qwen: ['qwen-max', 'qwen-plus', 'qwen-turbo'], + c0mpute: ['huihui_ai/qwen3.5-abliterated:9b', 'qwen2.5:7b', 'gemma3:4b'], ollama: ['llama3.2', 'qwen2.5', 'mistral', 'phi4'], lmstudio: [], vllm: [], diff --git a/packages/model-providers/src/catalog.ts b/packages/model-providers/src/catalog.ts index 29a0480f..102ca286 100644 --- a/packages/model-providers/src/catalog.ts +++ b/packages/model-providers/src/catalog.ts @@ -31,6 +31,8 @@ export const PROVIDERS: Record = { huggingface: { id: 'huggingface', label: 'Hugging Face', envVar: 'HUGGINGFACE_API_KEY', local: false, openaiCompatible: false }, kimi: { id: 'kimi', label: 'Kimi (Moonshot AI)', envVar: 'MOONSHOT_API_KEY', envVarAliases: ['KIMI_API_KEY'], local: false, openaiCompatible: true }, qwen: { id: 'qwen', label: 'Qwen (Alibaba)', envVar: 'DASHSCOPE_API_KEY', envVarAliases: ['QWEN_API_KEY'], local: false, openaiCompatible: true }, + // c0mpute: the Infernet peer network. A user's own ik_live_ key, or none for the free tier. + c0mpute: { id: 'c0mpute', label: 'c0mpute (peer network)', envVar: 'C0MPUTE_API_KEY', envVarAliases: ['INFERNET_API_KEY'], local: false, openaiCompatible: true }, ollama: { id: 'ollama', label: 'Ollama (local)', local: true, openaiCompatible: true }, lmstudio: { id: 'lmstudio', label: 'LM Studio (local)', local: true, openaiCompatible: true }, vllm: { id: 'vllm', label: 'vLLM (self-hosted)', local: true, openaiCompatible: true }, diff --git a/packages/model-providers/src/endpoints.ts b/packages/model-providers/src/endpoints.ts index 7f95e19e..bf0fbdb0 100644 --- a/packages/model-providers/src/endpoints.ts +++ b/packages/model-providers/src/endpoints.ts @@ -15,6 +15,7 @@ export const DEFAULT_BASE_URLS: Record = { huggingface: 'https://router.huggingface.co/v1', kimi: 'https://api.moonshot.ai/v1', qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + c0mpute: 'https://infernetprotocol.com/v1', ollama: 'http://localhost:11434/v1', lmstudio: 'http://localhost:1234/v1', vllm: 'http://localhost:8000/v1', diff --git a/packages/model-providers/src/index.test.ts b/packages/model-providers/src/index.test.ts index f4e91525..79dee5b7 100644 --- a/packages/model-providers/src/index.test.ts +++ b/packages/model-providers/src/index.test.ts @@ -19,12 +19,12 @@ describe('@tronbrowser/model-providers catalog', () => { expect(isLocalProvider('kimi')).toBe(false); }); - it('has the 8 BYOK providers from crawlproof.com', () => { + it('has the 8 BYOK providers from crawlproof.com plus c0mpute', () => { expect([...BYOK_PROVIDERS].sort()).toEqual( - ['anthropic', 'deepseek', 'google', 'huggingface', 'kimi', 'openai', 'perplexity', 'qwen'].sort(), + ['anthropic', 'c0mpute', 'deepseek', 'google', 'huggingface', 'kimi', 'openai', 'perplexity', 'qwen'].sort(), ); expect(LOCAL_PROVIDERS).toEqual(['ollama', 'lmstudio', 'vllm']); - expect(PROVIDER_IDS).toHaveLength(11); + expect(PROVIDER_IDS).toHaveLength(12); }); it('maps providers to the crawlproof.com env var names', () => { diff --git a/packages/model-providers/src/index.ts b/packages/model-providers/src/index.ts index 2f1d7b15..352ed73b 100644 --- a/packages/model-providers/src/index.ts +++ b/packages/model-providers/src/index.ts @@ -19,6 +19,7 @@ export const PROVIDER_IDS = [ 'huggingface', 'kimi', 'qwen', + 'c0mpute', 'ollama', 'lmstudio', 'vllm', diff --git a/services/api/src/index.ts b/services/api/src/index.ts index 0be2e572..57de0edf 100644 --- a/services/api/src/index.ts +++ b/services/api/src/index.ts @@ -11,7 +11,7 @@ import { import { token, uuid, hashPassword, verifyPassword, SESSION_TTL, EMAIL_TOKEN_TTL } from './auth.js'; import { sendEmail } from './email.js'; import { store } from './store/routes.js'; -import { swarmRoutes } from './swarm.js'; +import { swarmRoutes, C0MPUTE_BASE } from './swarm.js'; import { dnsRoutes } from './dns.js'; import { tronRelay } from './mcp/tron.js'; import { safeRedirect } from './redirect.js'; @@ -87,7 +87,7 @@ setInterval(() => push.sweep().catch(() => undefined), 3600_000).unref(); /* ---------- Extension store (tronbrowser.dev/store) ---------- */ app.route('/api/store', store); -/* ---------- Agent swarm (@logicsrc/agentswarm, BYO key) ---------- */ +/* ---------- Agent swarm (@logicsrc/agentswarm, BYO key or c0mpute) ---------- */ app.route('/api/swarm', swarmRoutes({ currentUser })); /* ---------- DNS verifier (signed-in ops tool for /dns) ---------- */ @@ -274,6 +274,7 @@ const PROVIDER_BASE: Record = { huggingface: 'https://router.huggingface.co/v1', kimi: 'https://api.moonshot.ai/v1', qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + c0mpute: C0MPUTE_BASE, }; app.post('/api/models', async (c) => { const user = await currentUser(c); diff --git a/services/api/src/swarm.test.ts b/services/api/src/swarm.test.ts new file mode 100644 index 00000000..cd83b6c2 --- /dev/null +++ b/services/api/src/swarm.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { resolveSwarmProvider, slowJson, takeHouseRun, C0MPUTE_BASE } from './swarm.js'; + +describe('resolveSwarmProvider', () => { + const env = { C0MPUTE_API_KEY: 'ik_live_house' }; + + it("a user's own key keeps priority, whatever the provider", () => { + expect(resolveSwarmProvider({ provider: 'openai', apiKey: 'sk-mine' }, env)).toEqual({ + provider: 'openai', + apiKey: 'sk-mine', + house: false, + }); + expect(resolveSwarmProvider({ apiKey: 'sk-ant' }, env)).toEqual({ provider: 'anthropic', apiKey: 'sk-ant', house: false }); + expect(resolveSwarmProvider({ provider: 'c0mpute', apiKey: 'ik_live_mine' }, env)).toEqual({ + provider: 'c0mpute', + apiKey: 'ik_live_mine', + house: false, + }); + }); + + it('no key + provider c0mpute runs on the house key', () => { + expect(resolveSwarmProvider({ provider: 'c0mpute' }, env)).toEqual({ + provider: 'c0mpute', + apiKey: 'ik_live_house', + house: true, + }); + }); + + it('no key and no provider is refused unless the operator made c0mpute the default', () => { + expect(resolveSwarmProvider({}, env)).toHaveProperty('error'); + expect(resolveSwarmProvider({}, { ...env, SWARM_DEFAULT_PROVIDER: 'c0mpute' })).toMatchObject({ + provider: 'c0mpute', + house: true, + }); + }); + + it('no key with another provider, or no house key configured, is refused', () => { + expect(resolveSwarmProvider({ provider: 'openai' }, env)).toHaveProperty('error'); + expect(resolveSwarmProvider({ provider: 'c0mpute' }, {})).toHaveProperty('error'); + }); + + it('points at the Infernet OpenAI-compatible endpoint', () => { + expect(C0MPUTE_BASE).toBe('https://infernetprotocol.com/v1'); + }); +}); + +describe('takeHouseRun', () => { + it('caps house-key runs per user per day', () => { + const env = { SWARM_C0MPUTE_DAILY: '2' }; + const day = new Date('2026-11-07T12:00:00Z'); + expect(takeHouseRun('u1', env, day)).toBe(true); + expect(takeHouseRun('u1', env, day)).toBe(true); + expect(takeHouseRun('u1', env, day)).toBe(false); + expect(takeHouseRun('u2', env, day)).toBe(true); + expect(takeHouseRun('u1', env, new Date('2026-11-08T00:00:01Z'))).toBe(true); + }); +}); + +describe('slowJson', () => { + it('keeps the connection alive with spaces, then sends parseable JSON', async () => { + const res = slowJson(() => new Promise((r) => setTimeout(() => r({ ok: 1 }), 60)), 10); + expect(res.status).toBe(200); + const text = await res.text(); + expect(text.startsWith(' ')).toBe(true); + expect(JSON.parse(text)).toEqual({ ok: 1 }); + }); + + it('turns a failure into an error body', async () => { + const res = slowJson(async () => { + throw new Error('no node'); + }); + expect(JSON.parse(await res.text())).toEqual({ error: 'no node' }); + }); +}); diff --git a/services/api/src/swarm.ts b/services/api/src/swarm.ts index ddda0ac2..4c39ac67 100644 --- a/services/api/src/swarm.ts +++ b/services/api/src/swarm.ts @@ -3,6 +3,21 @@ * @logicsrc/agentswarm. Signed-in, bring-your-own-key: the caller supplies a * provider + API key per request (used transiently, NOT stored — same model as * /api/models). An optional `rubric` turns on the self-check loop. + * + * c0mpute (the Infernet peer network, OpenAI-compatible at + * infernetprotocol.com/v1) is a provider too. A caller with a c0mpute key of + * their own uses it; a caller with no key at all may pick `provider: "c0mpute"` + * and run on the house key (C0MPUTE_API_KEY), capped per user per day. A + * caller's own key always wins. + * + * A request that names no provider and no key is NOT sent to c0mpute unless + * SWARM_DEFAULT_PROVIDER=c0mpute: the messages are the user's own, and sending + * them to peer nodes is a choice the user (or the operator) makes explicitly. + * + * c0mpute is slow under load (minutes per call), longer than the edge proxy's + * read timeout. A house-key run therefore answers 200 at once and writes a + * space every 15 s until the JSON result (or `{"error": ...}`) follows; leading + * whitespace is valid JSON, so `await res.json()` works unchanged. */ import { Hono } from 'hono'; import { @@ -12,6 +27,13 @@ import { type SwarmRunner, } from '@logicsrc/agentswarm'; +/** Infernet's OpenAI-compatible endpoint (api.c0mpute.com does not resolve). */ +export const C0MPUTE_BASE = 'https://infernetprotocol.com/v1'; +/** The house model every Infernet node pulls. */ +export const C0MPUTE_MODEL = 'huihui_ai/qwen3.5-abliterated:9b'; +/** House-key swarm runs one user may start per UTC day (SWARM_C0MPUTE_DAILY). */ +export const DEFAULT_C0MPUTE_DAILY = 30; + /** OpenAI-compatible provider bases (mirrors /api/models). anthropic is special-cased. */ const OPENAI_COMPAT_BASE: Record = { openai: 'https://api.openai.com/v1', @@ -21,7 +43,7 @@ const OPENAI_COMPAT_BASE: Record = { huggingface: 'https://router.huggingface.co/v1', kimi: 'https://api.moonshot.ai/v1', qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - c0mpute: 'https://api.c0mpute.com/v1', + c0mpute: C0MPUTE_BASE, }; /** Build a LangChain chat model for the requested provider with the caller's key. */ @@ -40,9 +62,101 @@ async function buildModel( throw new Error(`unsupported or disallowed provider/baseUrl: ${provider}`); } const { ChatOpenAI } = await import('@langchain/openai'); + if (provider === 'c0mpute') { + return new ChatOpenAI({ + apiKey, + model: model || C0MPUTE_MODEL, + configuration: { baseURL: base }, + // Peer nodes: often 60-240 s a call under load. + timeout: 15 * 60_000, + maxRetries: 1, + }); + } return new ChatOpenAI({ apiKey, model: model || 'gpt-4o-mini', configuration: { baseURL: base } }); } +/** + * Which provider and key a swarm request runs on. + * + * own key -> the caller's provider and key (c0mpute included) + * no key, provider c0mpute -> c0mpute on the house key + * no key, no provider -> c0mpute on the house key only when + * SWARM_DEFAULT_PROVIDER=c0mpute; else refused + * no key, any other provider -> refused, as before + */ +export function resolveSwarmProvider( + body: { provider?: unknown; apiKey?: unknown }, + env: Record = process.env, +): { provider: string; apiKey: string; house: boolean } | { error: string } { + const own = typeof body.apiKey === 'string' ? body.apiKey.trim() : ''; + const named = typeof body.provider === 'string' && body.provider.trim() ? body.provider.trim() : ''; + if (own) return { provider: named || 'anthropic', apiKey: own, house: false }; + + const wantsC0mpute = + named === 'c0mpute' || (!named && (env.SWARM_DEFAULT_PROVIDER || '').trim() === 'c0mpute'); + if (!wantsC0mpute) { + return { error: 'apiKey required (bring your own key), or set provider "c0mpute" to run on c0mpute without one' }; + } + const house = (env.C0MPUTE_API_KEY || '').trim(); + if (!house) return { error: 'c0mpute is not configured on this server; bring your own key' }; + return { provider: 'c0mpute', apiKey: house, house: true }; +} + +/** In-memory per-user daily count of house-key runs. */ +const houseRuns = new Map(); +export function takeHouseRun(userId: string, env: Record = process.env, now = new Date()): boolean { + const cap = Number(env.SWARM_C0MPUTE_DAILY); + const limit = Number.isFinite(cap) && cap > 0 ? Math.floor(cap) : DEFAULT_C0MPUTE_DAILY; + const day = now.toISOString().slice(0, 10); + const cur = houseRuns.get(userId); + const n = cur && cur.day === day ? cur.n : 0; + if (n >= limit) return false; + houseRuns.set(userId, { day, n: n + 1 }); + return true; +} + +/** + * A JSON response for slow work: 200 now, a space every `everyMs` so no proxy + * or idle timer closes the connection, then the JSON body. + */ +export function slowJson(work: () => Promise, everyMs = 15_000): Response { + const enc = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + const tick = setInterval(() => { + try { + controller.enqueue(enc.encode(' ')); + } catch { + clearInterval(tick); + } + }, everyMs); + let body: unknown; + try { + body = await work(); + } catch (e: any) { + body = { error: e?.message || String(e) }; + } finally { + clearInterval(tick); + } + try { + controller.enqueue(enc.encode(JSON.stringify(body))); + controller.close(); + } catch { + /* client went away */ + } + }, + }); + return new Response(stream, { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + // Ask nginx not to buffer, so the keepalive spaces reach the client. + 'x-accel-buffering': 'no', + }, + }); +} + export interface SwarmDeps { /** Resolves the signed-in user, or null. */ currentUser: (c: any) => Promise<{ id: string } | null>; @@ -57,22 +171,33 @@ export function swarmRoutes(deps: SwarmDeps): Hono { if (!user) return c.json({ error: 'unauthorized' }, 401); const body = await c.req.json().catch(() => ({} as any)); - const { messages, rubric, provider = 'anthropic', apiKey, model, baseUrl } = body; + const { messages, rubric, model, baseUrl } = body; if (!Array.isArray(messages) || messages.length === 0) { return c.json({ error: 'messages must be a non-empty array' }, 400); } - if (!apiKey || typeof apiKey !== 'string') { - return c.json({ error: 'apiKey required (bring your own key)' }, 400); + const chosen = resolveSwarmProvider(body); + if ('error' in chosen) return c.json({ error: chosen.error }, 400); + if (chosen.house && !takeHouseRun(user.id)) { + return c.json({ error: 'daily c0mpute limit reached; bring your own key or try tomorrow' }, 429); } - try { - const llm = await buildModel(String(provider), apiKey, model, baseUrl); + const run = async () => { + // The house key only ever goes to the house endpoint. + const llm = await buildModel(chosen.provider, chosen.apiKey, model, chosen.house ? undefined : baseUrl); let runner: SwarmRunner = await createDeepAgentRunner({ model: llm }); if (typeof rubric === 'string' && rubric.trim()) { runner = createRubricRunner({ runner, judge: await createLLMJudge({ chatModel: llm }) }); } const result = await runner.run({ messages, rubric }); - return c.json(result); + return chosen.provider === 'c0mpute' && result && typeof result === 'object' + ? { ...(result as object), provider: 'c0mpute' } + : result; + }; + + if (chosen.provider === 'c0mpute') return slowJson(run); + + try { + return c.json((await run()) as any); } catch (e: any) { return c.json({ error: e?.message || String(e) }, 500); }