Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/desktop/extensions/ai-sidebar/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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: [],
Expand Down
3 changes: 3 additions & 0 deletions apps/web/public/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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: [],
Expand Down
2 changes: 2 additions & 0 deletions packages/model-providers/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export const PROVIDERS: Record<ProviderId, ProviderInfo> = {
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 },
Expand Down
1 change: 1 addition & 0 deletions packages/model-providers/src/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const DEFAULT_BASE_URLS: Record<ProviderId, string> = {
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',
Expand Down
6 changes: 3 additions & 3 deletions packages/model-providers/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/model-providers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const PROVIDER_IDS = [
'huggingface',
'kimi',
'qwen',
'c0mpute',
'ollama',
'lmstudio',
'vllm',
Expand Down
5 changes: 3 additions & 2 deletions services/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) ---------- */
Expand Down Expand Up @@ -274,6 +274,7 @@ const PROVIDER_BASE: Record<string, string> = {
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);
Expand Down
74 changes: 74 additions & 0 deletions services/api/src/swarm.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
139 changes: 132 additions & 7 deletions services/api/src/swarm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string, string> = {
openai: 'https://api.openai.com/v1',
Expand All @@ -21,7 +43,7 @@ const OPENAI_COMPAT_BASE: Record<string, string> = {
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. */
Expand All @@ -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<string, string | undefined> = 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<string, { day: string; n: number }>();
export function takeHouseRun(userId: string, env: Record<string, string | undefined> = 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<unknown>, everyMs = 15_000): Response {
const enc = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
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>;
Expand All @@ -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);
}
Expand Down
Loading