← back to Exo
dashboard/src/routes/integrations/+page.svelte
689 lines
<script lang="ts">
import { browser } from "$app/environment";
import { fade } from "svelte/transition";
import HeaderNav from "$lib/components/HeaderNav.svelte";
import IntegrationCard from "$lib/components/IntegrationCard.svelte";
import { instances, refreshState } from "$lib/stores/app.svelte";
import { onMount } from "svelte";
const apiUrl = browser
? window.location.origin.replace("localhost", "127.0.0.1")
: "http://127.0.0.1:52415";
const instancesData = $derived(instances());
let modelCapabilities = $state<Record<string, string[]>>({});
let modelContextLengths = $state<Record<string, number>>({});
let modelReasoningDialects = $state<Record<string, string>>({});
const runningModels = $derived.by(() => {
const models: string[] = [];
for (const [, wrapper] of Object.entries(instancesData)) {
if (wrapper && typeof wrapper === "object") {
const values = Object.values(wrapper as Record<string, unknown>);
if (values.length > 0) {
const instance = values[0];
if (instance && typeof instance === "object") {
const inst = instance as {
shardAssignments?: { modelId?: string };
};
const modelId = inst.shardAssignments?.modelId;
if (modelId && !models.includes(modelId)) {
models.push(modelId);
}
}
}
}
}
return models;
});
function estimateParamSize(modelId: string): number {
const match = modelId.match(/(\d+(?:\.\d+)?)[Bb]/);
return match ? parseFloat(match[1]) : 0;
}
const modelsBySize = $derived(
[...runningModels].sort(
(a, b) => estimateParamSize(b) - estimateParamSize(a),
),
);
const defaultTiers = $derived.by(() => {
const n = modelsBySize.length;
if (n === 0)
return {
opus: "your-model-id",
sonnet: "your-model-id",
haiku: "your-model-id",
};
if (n === 1)
return {
opus: modelsBySize[0],
sonnet: modelsBySize[0],
haiku: modelsBySize[0],
};
if (n === 2)
return {
opus: modelsBySize[0],
sonnet: modelsBySize[1],
haiku: modelsBySize[1],
};
return {
opus: modelsBySize[0],
sonnet: modelsBySize[Math.floor(n / 2)],
haiku: modelsBySize[n - 1],
};
});
let opusModel = $state("");
let sonnetModel = $state("");
let haikuModel = $state("");
$effect(() => {
opusModel = defaultTiers.opus;
sonnetModel = defaultTiers.sonnet;
haikuModel = defaultTiers.haiku;
});
let codexModel = $state("");
let codexMcpPath = $state("/Users/username");
let openClawModel = $state("");
let piModel = $state("");
$effect(() => {
const def = modelsBySize.length > 0 ? modelsBySize[0] : "your-model-id";
codexModel = def;
openClawModel = def;
piModel = def;
});
const claudeShellCommand = $derived(
[
`ANTHROPIC_BASE_URL=${apiUrl} \\`,
`ANTHROPIC_API_KEY=x \\`,
`ANTHROPIC_DEFAULT_OPUS_MODEL=${opusModel} \\`,
`ANTHROPIC_DEFAULT_SONNET_MODEL=${sonnetModel} \\`,
`ANTHROPIC_DEFAULT_HAIKU_MODEL=${haikuModel} \\`,
`API_TIMEOUT_MS=3000000 \\`,
`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \\`,
`claude`,
].join("\n"),
);
const claudeSettingsJson = $derived(
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: apiUrl,
ANTHROPIC_API_KEY: "x",
ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel,
API_TIMEOUT_MS: "3000000",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
},
},
null,
2,
),
);
const openCodeConfig = $derived.by(() => {
const models: Record<string, Record<string, unknown>> = {};
for (const modelId of runningModels) {
const caps = modelCapabilities[modelId] || [];
const ctxLen = modelContextLengths[modelId] || 0;
const dialect = modelReasoningDialects[modelId];
const entry: Record<string, unknown> = { name: modelId };
if (ctxLen > 0) {
entry.limit = { context: ctxLen, output: Math.min(ctxLen, 16384) };
}
if (caps.includes("vision")) {
entry.modalities = { input: ["text", "image"], output: ["text"] };
}
// Reasoning round-trip: opencode's `interleaved` field tells the
// openai-compatible adapter to send the assistant's prior
// reasoning_content back in subsequent turns. Emit it for dialects
// whose chat templates use prior reasoning:
// - `tool_conditional` (DeepSeek V3.2 / V4): wrapper preserves all
// reasoning when tools are present.
// - `post_last_user` (Qwen3-Thinking, GLM 4.5+, MiniMax M2.x):
// Jinja template reads reasoning_content for assistant turns since
// the last user message — exactly the tool-chain window.
// - `channel` (gpt-oss / Harmony): the model's Jinja template reads
// `message.thinking` rather than `message.reasoning_content`, but
// the server bridges `reasoning_content` → `thinking` before
// rendering, so the round-trip works through the standard field.
// `suffix` (Kimi): reasoning lives in content; no separate field path.
if (
dialect === "tool_conditional" ||
dialect === "post_last_user" ||
dialect === "channel"
) {
entry.interleaved = { field: "reasoning_content" };
}
models[modelId] = entry;
}
if (Object.keys(models).length === 0) {
models["your-model-id"] = { name: "your-model-name" };
}
const firstModel =
runningModels.length > 0 ? runningModels[0] : "your-model-id";
return JSON.stringify(
{
$schema: "https://opencode.ai/config.json",
provider: {
exo: {
npm: "@ai-sdk/openai-compatible",
name: "exo",
options: {
baseURL: `${apiUrl}/v1`,
apiKey: "x",
},
models,
},
},
model: `exo/${firstModel}`,
},
null,
2,
);
});
const codexShellCommand = $derived(`EXO_API_KEY=x npx @openai/codex`);
const codexConfig = $derived(
[
`model = "${codexModel}"`,
`model_provider = "exo"`,
``,
`[model_providers.exo]`,
`name = "exo"`,
`base_url = "${apiUrl}/v1"`,
`env_key = "EXO_API_KEY"`,
``,
`[mcp_servers.filesystem]`,
`command = "npx"`,
`args = ["-y", "@modelcontextprotocol/server-filesystem", "${codexMcpPath}"]`,
].join("\n"),
);
const openClawConfig = $derived(
JSON.stringify(
{
gateway: { mode: "local" },
models: {
providers: {
exo: {
baseUrl: `${apiUrl}/v1`,
apiKey: "x",
api: "openai-completions",
models: [
{
id: openClawModel,
name: "exo local",
input: (modelCapabilities[openClawModel] || []).includes(
"vision",
)
? ["text", "image"]
: ["text"],
},
],
},
},
},
agents: {
defaults: {
model: `exo/${openClawModel}`,
},
},
},
null,
2,
),
);
const piModelsJson = $derived.by(() => {
const models: Record<string, unknown>[] = [];
for (const modelId of runningModels) {
const caps = modelCapabilities[modelId] || [];
const ctxLen = modelContextLengths[modelId] || 0;
const entry: Record<string, unknown> = { id: modelId };
if (caps.includes("vision")) {
entry.input = ["text", "image"];
}
// Mark thinking-capable models so pi surfaces its thinking-level selector
// for them. exo capability strings: "thinking" (model emits reasoning
// content) and "thinking_toggle" (user can turn it on/off).
if (caps.includes("thinking") || caps.includes("thinking_toggle")) {
entry.reasoning = true;
}
if (ctxLen > 0) {
entry.contextWindow = ctxLen;
}
models.push(entry);
}
if (models.length === 0) {
models.push({ id: "your-model-id" });
}
return JSON.stringify(
{
providers: {
exo: {
baseUrl: `${apiUrl}/v1`,
api: "openai-completions",
apiKey: "exo",
compat: {
supportsDeveloperRole: false,
// exo's OpenAI surface takes a boolean `enable_thinking` toggle,
// not graded effort levels, so disable pi's `reasoning_effort`
// parameter and use the matching top-level-boolean format.
supportsReasoningEffort: false,
thinkingFormat: "qwen",
},
models,
},
},
},
null,
2,
);
});
const piShellCommand = $derived(`pi --provider exo --model ${piModel}`);
const ollamaCommand = $derived(
`OLLAMA_HOST=${apiUrl}/ollama ollama run ${modelsBySize.length > 0 ? modelsBySize[0] : "your-model-id"}`,
);
const openWebUiCommand = $derived(
[
`docker run -d -p 3000:8080 \\`,
` -e OLLAMA_BASE_URL=${apiUrl.replace("localhost", "host.docker.internal")}/ollama \\`,
` -v open-webui:/app/backend/data \\`,
` --name open-webui \\`,
` ghcr.io/open-webui/open-webui:main`,
].join("\n"),
);
const n8nDockerCommand = $derived(
[
`docker run -d -p 5678:5678 \\`,
` -v n8n_data:/home/node/.n8n \\`,
` --name n8n \\`,
` docker.n8n.io/n8nio/n8n`,
].join("\n"),
);
const n8nCredentialSteps = $derived(
[
`1. Go to Credentials → Add Credential → search "OpenAI API"`,
`2. Set API Key to: x`,
`3. Set Base URL to: ${apiUrl.replace("127.0.0.1", "host.docker.internal").replace("localhost", "host.docker.internal")}/v1`,
`4. Save the credential`,
].join("\n"),
);
const n8nWorkflowSteps = $derived(
[
`1. Create a new workflow → "Start from Scratch"`,
`2. Add an "AI Agent" or "Basic LLM Chain" node`,
`3. Inside it, add an "OpenAI Chat Model" sub-node`,
`4. Select the OpenAI credential you just created`,
`5. Set Model to "From list" and pick your model (e.g. ${modelsBySize.length > 0 ? modelsBySize[0] : "your-model-id"})`,
`6. Optionally toggle "Use Responses API", add Built-in Tools, or click "Add Option" for sampling settings`,
`7. Connect a "Chat Trigger" node for interactive chat`,
`8. On the Chat Trigger, enable "Allow File Uploads" for vision`,
].join("\n"),
);
const firefoxConfig = $derived(
[
`1. Open about:config in Firefox`,
`2. Set browser.ml.chat.enabled to true`,
`3. Set browser.ml.chat.hideLocalhost to false`,
`4. Set browser.ml.chat.provider to: ${apiUrl}/`,
].join("\n"),
);
const tabs = [
"Claude Code",
"OpenCode",
"Codex",
"OpenClaw",
"Pi",
"Open WebUI",
"n8n",
"Firefox",
] as const;
type Tab = (typeof tabs)[number];
const stored = browser ? localStorage.getItem("exo-integrations-tab") : null;
let activeTab = $state<Tab>(
stored && tabs.includes(stored as Tab) ? (stored as Tab) : "Claude Code",
);
$effect(() => {
if (browser) localStorage.setItem("exo-integrations-tab", activeTab);
});
const selectClass =
"bg-black/30 border border-exo-light-gray/20 rounded px-2 py-1.5 text-white font-mono text-xs focus:border-exo-yellow/50 focus:outline-none appearance-none cursor-pointer";
onMount(async () => {
refreshState();
try {
const resp = await fetch("/v1/models");
const data = (await resp.json()) as {
data: {
id: string;
capabilities: string[];
context_length: number;
reasoning_dialect?: string;
}[];
};
const caps: Record<string, string[]> = {};
const ctxs: Record<string, number> = {};
const dialects: Record<string, string> = {};
for (const model of data.data) {
caps[model.id] = model.capabilities || [];
if (model.context_length > 0) ctxs[model.id] = model.context_length;
if (model.reasoning_dialect)
dialects[model.id] = model.reasoning_dialect;
}
modelCapabilities = caps;
modelContextLengths = ctxs;
modelReasoningDialects = dialects;
} catch {
/* ignore */
}
});
</script>
<div class="min-h-screen bg-exo-dark-gray flex flex-col">
<HeaderNav showHome={true} />
<main
class="flex-1 max-w-3xl mx-auto w-full px-4 md:px-6 py-8"
in:fade={{ duration: 200 }}
>
<div class="mb-8">
<h1
class="text-white text-xl md:text-2xl font-semibold tracking-wide mb-2"
>
Integrations
</h1>
<p class="text-exo-light-gray/60 text-sm">
Connect external tools to your exo cluster.
</p>
</div>
<!-- Status -->
<div class="mb-8">
<span class="text-exo-light-gray/70 text-xs uppercase tracking-wider"
>API Endpoint</span
>
<span class="text-white font-mono text-sm ml-2">{apiUrl}</span>
{#if runningModels.length > 0}
<div class="text-exo-light-gray/50 text-xs mt-2">
Running model{runningModels.length > 1 ? "s" : ""}:
<ul class="mt-1 space-y-0.5 list-none">
{#each runningModels as model}
<li class="text-exo-yellow font-mono">{model}</li>
{/each}
</ul>
</div>
{:else}
<p class="text-exo-light-gray/40 text-xs mt-2 italic">
No models currently running
</p>
{/if}
</div>
<!-- API Endpoints -->
<div class="mb-8">
<div
class="flex flex-col sm:flex-row gap-3 text-xs font-mono text-exo-light-gray/70"
>
<div
class="flex-1 bg-black/20 border border-exo-light-gray/10 rounded px-3 py-2"
>
<span class="text-exo-light-gray/40 text-[10px] uppercase block mb-1"
>OpenAI-compatible</span
>
<span class="text-white/80">{apiUrl}/v1</span>
</div>
<div
class="flex-1 bg-black/20 border border-exo-light-gray/10 rounded px-3 py-2"
>
<span class="text-exo-light-gray/40 text-[10px] uppercase block mb-1"
>Claude-compatible</span
>
<span class="text-white/80">{apiUrl}</span>
</div>
<div
class="flex-1 bg-black/20 border border-exo-light-gray/10 rounded px-3 py-2"
>
<span class="text-exo-light-gray/40 text-[10px] uppercase block mb-1"
>Ollama-compatible</span
>
<span class="text-white/80">{apiUrl}/ollama</span>
</div>
</div>
</div>
<!-- Tabs -->
<div
class="flex flex-wrap gap-2 mb-6 border-b border-exo-light-gray/10 pb-3"
>
{#each tabs as tab}
<button
onclick={() => (activeTab = tab)}
class="px-3 py-1.5 text-xs rounded-md transition-all cursor-pointer
{activeTab === tab
? 'bg-exo-yellow/15 text-exo-yellow border border-exo-yellow/30'
: 'text-exo-light-gray/60 hover:text-white/80 border border-transparent hover:border-exo-light-gray/20'}"
>
{tab}
</button>
{/each}
</div>
<!-- Tab Content -->
<div class="space-y-4">
{#if activeTab === "Claude Code"}
{#if runningModels.length > 1}
<div class="grid grid-cols-3 gap-3 text-xs">
{#each [{ label: "Opus", bind: () => opusModel, set: (v: string) => (opusModel = v) }, { label: "Sonnet", bind: () => sonnetModel, set: (v: string) => (sonnetModel = v) }, { label: "Haiku", bind: () => haikuModel, set: (v: string) => (haikuModel = v) }] as tier}
<div>
<span
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
>{tier.label}</span
>
<select
value={tier.bind()}
onchange={(e) =>
tier.set((e.target as HTMLSelectElement).value)}
class="w-full {selectClass}"
>
{#each runningModels as model}
<option value={model}>{model.split("/").pop()}</option>
{/each}
</select>
</div>
{/each}
</div>
{/if}
<IntegrationCard
title="Shell Command"
subtitle="Run in terminal"
description="Launch Claude Code with exo as the backend. Paste this into your terminal."
config={claudeShellCommand}
language="bash"
/>
<IntegrationCard
title="Settings File"
subtitle="~/.claude/settings.json"
description="Or add this to your Claude Code settings for persistent configuration."
config={claudeSettingsJson}
/>
{:else if activeTab === "OpenCode"}
<IntegrationCard
title="Config File"
subtitle="opencode.json"
description="Add this to your project root or ~/.config/opencode/opencode.json for global config. Vision models automatically get image input modality."
config={openCodeConfig}
/>
{:else if activeTab === "Codex"}
<div class="flex gap-3 text-xs">
{#if runningModels.length > 1}
<div>
<span
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
>Model</span
>
<select bind:value={codexModel} class={selectClass}>
{#each runningModels as model}
<option value={model}>{model.split("/").pop()}</option>
{/each}
</select>
</div>
{/if}
<div class="flex-1">
<span
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
>MCP Filesystem Path</span
>
<input
type="text"
bind:value={codexMcpPath}
class="w-full bg-black/30 border border-exo-light-gray/20 rounded px-2 py-1.5 text-white font-mono text-xs focus:border-exo-yellow/50 focus:outline-none"
/>
</div>
</div>
<IntegrationCard
title="Config File"
subtitle="~/.codex/config.toml"
description="Add this to your Codex CLI config so the model and provider persist."
config={codexConfig}
/>
<IntegrationCard
title="Shell Command"
subtitle="Run in terminal"
description="Launch Codex with exo as the backend."
config={codexShellCommand}
language="bash"
/>
{:else if activeTab === "OpenClaw"}
{#if runningModels.length > 1}
<div class="text-xs">
<span
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
>Model</span
>
<select bind:value={openClawModel} class={selectClass}>
{#each runningModels as model}
<option value={model}>{model.split("/").pop()}</option>
{/each}
</select>
</div>
{/if}
<IntegrationCard
title="Config File"
subtitle="~/.openclaw/openclaw.json"
description="Add this to your OpenClaw config. If you haven't installed OpenClaw yet, run: npm install -g openclaw@latest"
config={openClawConfig}
/>
<IntegrationCard
title="Setup Commands"
subtitle="Run in terminal"
description="After saving the config, run these commands to fix metadata and start the gateway."
config={`openclaw doctor --fix${(modelCapabilities[openClawModel] || []).includes("vision") ? `\nopenclaw models set-image exo/${openClawModel}` : ""}\nopenclaw gateway &\nopenclaw dashboard`}
language="bash"
/>
{:else if activeTab === "Pi"}
{#if runningModels.length > 1}
<div class="text-xs">
<span
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
>Model</span
>
<select bind:value={piModel} class={selectClass}>
{#each runningModels as model}
<option value={model}>{model.split("/").pop()}</option>
{/each}
</select>
</div>
{/if}
<IntegrationCard
title="Models Config"
subtitle="~/.pi/agent/models.json"
description="Register exo as a custom provider in pi. Create or edit this file, then run pi and pick an exo model via /model. Install pi with: npm install -g @mariozechner/pi-coding-agent"
config={piModelsJson}
/>
<IntegrationCard
title="Shell Command"
subtitle="Run in terminal"
description="Launch pi directly with the exo provider and model selected."
config={piShellCommand}
language="bash"
/>
{:else if activeTab === "Open WebUI"}
<IntegrationCard
title="1. Start Open WebUI"
subtitle="Run in terminal"
description="Run this to start Open WebUI."
config={openWebUiCommand}
language="bash"
/>
<IntegrationCard
title="2. Open & Select Model"
subtitle="http://localhost:3000"
description={`Open http://localhost:3000 in your browser. Select the running model from the dropdown at the top: ${runningModels.length > 0 ? runningModels.join(", ") : "no models running"}`}
config={"open http://localhost:3000"}
language="bash"
/>
<IntegrationCard
title="Ollama CLI"
subtitle="Run in terminal"
description="Or use the Ollama CLI directly."
config={ollamaCommand}
language="bash"
/>
{:else if activeTab === "n8n"}
<IntegrationCard
title="1. Start n8n"
subtitle="Run in terminal"
description="Start n8n with Docker. If you already have n8n running, skip this step."
config={n8nDockerCommand}
language="bash"
/>
<IntegrationCard
title="2. Open n8n"
subtitle="http://localhost:5678"
description="Open n8n in your browser. If this is your first time, complete the setup and select 'Start from Scratch' when prompted."
config={"open http://localhost:5678"}
language="bash"
/>
<IntegrationCard
title="3. Add OpenAI Credential"
subtitle="n8n UI → Credentials"
description="Create an OpenAI credential pointing at your exo cluster."
config={n8nCredentialSteps}
/>
<IntegrationCard
title="4. Build a Workflow"
subtitle="n8n UI → Workflows"
description="Create a workflow that uses your exo-powered model."
config={n8nWorkflowSteps}
/>
{:else if activeTab === "Firefox"}
<IntegrationCard
title="Firefox AI Chatbot"
subtitle="about:config"
description="Use the exo dashboard as Firefox's built-in AI chatbot. Requires Firefox 130+."
config={firefoxConfig}
/>
{/if}
</div>
</main>
</div>