Integrations helpers (#1810)

## Motivation

<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
This commit is contained in:
rltakashige
2026-03-30 14:28:41 +01:00
committed by GitHub
parent e5cb7b80d0
commit 39c39e8199
98 changed files with 1394 additions and 108 deletions
+53 -15
View File
@@ -1,21 +1,38 @@
<script lang="ts">
import { browser } from "$app/environment";
export let showHome = true;
export let onHome: (() => void) | null = null;
export let showSidebarToggle = false;
export let sidebarVisible = true;
export let onToggleSidebar: (() => void) | null = null;
export let showMobileMenuToggle = false;
export let mobileMenuOpen = false;
export let onToggleMobileMenu: (() => void) | null = null;
export let showMobileRightToggle = false;
export let mobileRightOpen = false;
export let onToggleMobileRight: (() => void) | null = null;
export let downloadProgress: {
count: number;
percentage: number;
} | null = null;
interface Props {
showHome?: boolean;
onHome?: (() => void) | null;
showSidebarToggle?: boolean;
sidebarVisible?: boolean;
onToggleSidebar?: (() => void) | null;
showMobileMenuToggle?: boolean;
mobileMenuOpen?: boolean;
onToggleMobileMenu?: (() => void) | null;
showMobileRightToggle?: boolean;
mobileRightOpen?: boolean;
onToggleMobileRight?: (() => void) | null;
downloadProgress?: {
count: number;
percentage: number;
} | null;
}
let {
showHome = true,
onHome = null,
showSidebarToggle = false,
sidebarVisible = true,
onToggleSidebar = null,
showMobileMenuToggle = false,
mobileMenuOpen = false,
onToggleMobileMenu = null,
showMobileRightToggle = false,
mobileRightOpen = false,
onToggleMobileRight = null,
downloadProgress = null,
}: Props = $props();
function handleHome(): void {
if (onHome) {
@@ -259,5 +276,26 @@
{/if}
<span class="hidden sm:inline">Downloads</span>
</a>
<a
href="/#/integrations"
class="text-xs md:text-sm text-white/70 hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-1.5 md:gap-2 cursor-pointer"
title="Integration configs for external tools"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path
d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"
/>
</svg>
<span class="hidden sm:inline">Integrations</span>
</a>
</nav>
</header>
@@ -0,0 +1,52 @@
<script lang="ts">
interface Props {
title: string;
subtitle: string;
config: string;
description?: string;
language?: "json" | "bash";
}
let {
title,
subtitle,
config,
description = "",
language = "json",
}: Props = $props();
let copied = $state(false);
async function copyToClipboard() {
await navigator.clipboard.writeText(config);
copied = true;
setTimeout(() => (copied = false), 2000);
}
</script>
<div
class="border border-exo-light-gray/20 rounded-lg bg-exo-medium-gray/20 overflow-hidden"
>
<div class="flex items-center justify-between px-5 py-4">
<div>
<h3 class="text-white text-sm font-semibold tracking-wide">{title}</h3>
<p class="text-exo-light-gray/60 text-xs mt-0.5 font-mono">{subtitle}</p>
</div>
<button
onclick={copyToClipboard}
class="px-3 py-1.5 text-xs rounded border transition-all duration-200 cursor-pointer
{copied
? 'border-green-500/50 text-green-400 bg-green-500/10'
: 'border-exo-light-gray/30 text-exo-light-gray hover:border-exo-yellow/50 hover:text-exo-yellow'}"
>
{copied ? "Copied!" : "Copy"}
</button>
</div>
{#if description}
<p class="text-exo-light-gray/70 text-xs px-5 pb-3">{description}</p>
{/if}
<div class="bg-black/30 border-t border-exo-light-gray/10">
<pre
class="text-xs text-exo-light-gray/90 font-mono p-4 overflow-x-auto whitespace-pre">{config}</pre>
</div>
</div>
+1
View File
@@ -13,3 +13,4 @@ export { default as ModelFilterPopover } from "./ModelFilterPopover.svelte";
export { default as ModelPickerGroup } from "./ModelPickerGroup.svelte";
export { default as ModelPickerModal } from "./ModelPickerModal.svelte";
export { default as ChatModelSelector } from "./ChatModelSelector.svelte";
export { default as IntegrationCard } from "./IntegrationCard.svelte";
@@ -0,0 +1,577 @@
<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>>({});
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("");
$effect(() => {
const def = modelsBySize.length > 0 ? modelsBySize[0] : "your-model-id";
codexModel = def;
openClawModel = 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 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"] };
}
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 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",
"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 }[];
};
const caps: Record<string, string[]> = {};
const ctxs: Record<string, number> = {};
for (const model of data.data) {
caps[model.id] = model.capabilities || [];
if (model.context_length > 0) ctxs[model.id] = model.context_length;
}
modelCapabilities = caps;
modelContextLengths = ctxs;
} 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 === "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>
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "DeepSeek V3.1"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 405874409472
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "DeepSeek V3.1"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 765577920512
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "DeepSeek V3.2"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 378086226621
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "DeepSeek V3.2"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 755957120916
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "GLM 4.5 Air"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 122406567936
@@ -9,5 +9,7 @@ quantization = "bf16"
base_model = "GLM 4.5 Air"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 229780750336
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 198556925568
@@ -9,5 +9,7 @@ quantization = "6bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 286737579648
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 396963397248
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 19327352832
@@ -9,5 +9,7 @@ quantization = "5bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 22548578304
@@ -9,5 +9,7 @@ quantization = "6bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 26843545600
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 34359738368
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
context_length = 202752
[storage_size]
in_bytes = 790517400864
@@ -9,5 +9,7 @@ quantization = "MXFP4-Q8"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
context_length = 202752
[storage_size]
in_bytes = 405478939008
@@ -9,5 +9,7 @@ quantization = "bf16"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
context_length = 202752
[storage_size]
in_bytes = 1487822475264
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Kimi K2"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 620622774272
@@ -9,5 +9,7 @@ quantization = ""
base_model = "Kimi K2"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 262144
[storage_size]
in_bytes = 706522120192
@@ -9,6 +9,8 @@ quantization = ""
base_model = "Kimi K2.5"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 662498705408
@@ -8,5 +8,7 @@ quantization = "4bit"
base_model = "NVIDIA Llama-3.1-Nemotron-70B-Instruct"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 39688355840
@@ -8,5 +8,7 @@ quantization = "8bit"
base_model = "NVIDIA Llama-3.1-Nemotron-70B-Instruct"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 74964549632
@@ -8,5 +8,7 @@ quantization = "bf16"
base_model = "NVIDIA Llama-3.1-Nemotron-70B-Instruct"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 141107412992
@@ -8,5 +8,7 @@ quantization = "4bit"
base_model = "NVIDIA Llama-3.1-Nemotron-Nano-4B-v1.1"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 2538706944
@@ -8,5 +8,7 @@ quantization = "8bit"
base_model = "NVIDIA Llama-3.1-Nemotron-Nano-4B-v1.1"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 4794980352
@@ -8,5 +8,7 @@ quantization = "bf16"
base_model = "NVIDIA Llama-3.1-Nemotron-Nano-4B-v1.1"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 9025492992
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Llama 3.2 1B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 729808896
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Llama 3.2 3B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 1863319552
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Llama 3.2 3B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 3501195264
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Llama 3.3 70B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 40652242944
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Llama 3.3 70B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 76799803392
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Llama 3.1 70B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 40652242944
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Llama 3.1 8B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 4637851648
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Llama 3.1 8B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 8954839040
@@ -9,5 +9,7 @@ quantization = "bf16"
base_model = "Llama 3.1 8B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 16882073600
@@ -9,5 +9,7 @@ quantization = "3bit"
base_model = "MiniMax M2.1"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 196608
[storage_size]
in_bytes = 100086644736
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "MiniMax M2.1"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 196608
[storage_size]
in_bytes = 242986745856
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "MiniMax M2.5"
capabilities = ["text", "thinking"]
context_length = 196608
[storage_size]
in_bytes = 128666664960
@@ -9,5 +9,7 @@ quantization = "6bit"
base_model = "MiniMax M2.5"
capabilities = ["text", "thinking"]
context_length = 196608
[storage_size]
in_bytes = 185826705408
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "MiniMax M2.5"
capabilities = ["text", "thinking"]
context_length = 196608
[storage_size]
in_bytes = 242986745856
@@ -8,5 +8,7 @@ quantization = "4bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 17775342336
@@ -8,5 +8,7 @@ quantization = "5bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 21721476864
@@ -8,5 +8,7 @@ quantization = "6bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 25667611392
@@ -8,5 +8,7 @@ quantization = "8bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 33559880448
@@ -8,5 +8,7 @@ quantization = "bf16"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 63155889408
@@ -8,5 +8,7 @@ quantization = "4bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 16788808704
@@ -8,5 +8,7 @@ quantization = "4bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 19323906944
@@ -8,5 +8,7 @@ quantization = "4bit"
base_model = "NVIDIA Nemotron-Nano-9B-v2"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 5002791936
@@ -8,5 +8,7 @@ quantization = "6bit"
base_model = "NVIDIA Nemotron-Nano-9B-v2"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 7224298496
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Qwen3 0.6B"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 32768
[storage_size]
in_bytes = 342884352
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Qwen3 0.6B"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 32768
[storage_size]
in_bytes = 698351616
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Qwen3 235B"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 262144
[storage_size]
in_bytes = 141733920768
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Qwen3 235B"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 262144
[storage_size]
in_bytes = 268435456000
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Qwen3 30B"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 32768
[storage_size]
in_bytes = 17612931072
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Qwen3 30B"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 32768
[storage_size]
in_bytes = 33279705088
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Qwen3 Coder 480B"
capabilities = ["text", "code"]
context_length = 262144
[storage_size]
in_bytes = 289910292480
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Qwen3 Coder 480B"
capabilities = ["text", "code"]
context_length = 262144
[storage_size]
in_bytes = 579820584960
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Qwen3 Coder Next"
capabilities = ["text", "code"]
context_length = 262144
[storage_size]
in_bytes = 45644286500
@@ -9,5 +9,7 @@ quantization = "5bit"
base_model = "Qwen3 Coder Next"
capabilities = ["text", "code"]
context_length = 262144
[storage_size]
in_bytes = 57657697020
@@ -9,5 +9,7 @@ quantization = "6bit"
base_model = "Qwen3 Coder Next"
capabilities = ["text", "code"]
context_length = 262144
[storage_size]
in_bytes = 68899327465
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Qwen3 Coder Next"
capabilities = ["text", "code"]
context_length = 262144
[storage_size]
in_bytes = 89357758772
@@ -9,5 +9,7 @@ quantization = "bf16"
base_model = "Qwen3 Coder Next"
capabilities = ["text", "code"]
context_length = 262144
[storage_size]
in_bytes = 157548627945
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Qwen3 Next 80B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 46976204800
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Qwen3 Next 80B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 88814387200
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Qwen3 Next 80B"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 262144
[storage_size]
in_bytes = 47080074240
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Qwen3 Next 80B"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 262144
[storage_size]
in_bytes = 88814387200
@@ -8,5 +8,7 @@ quantization = "4bit"
base_model = "Qwen3-VL 4B"
capabilities = ["text", "thinking", "vision"]
context_length = 262144
[storage_size]
in_bytes = 3340000000
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 69593314272
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "6bit"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 100120675296
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 130648036320
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "bf16"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 245125640160
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 27B"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 16054266848
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 27B"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 29500943328
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 2B"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 2662787264
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 35B A3B"
capabilities = ["text", "thinking", "thinking_toggle"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 20391405152
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 35B A3B"
capabilities = ["text", "thinking", "thinking_toggle"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 37721130592
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 397B A17B"
capabilities = ["text", "thinking", "thinking_toggle"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 223860768352
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "6bit"
base_model = "Qwen3.5 397B A17B"
capabilities = ["text", "thinking", "thinking_toggle"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 322946674272
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 397B A17B"
capabilities = ["text", "thinking", "thinking_toggle"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 422032580192
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 9B"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 5950062560
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 9B"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 10426433504
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Step 3.5 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 262144
[storage_size]
in_bytes = 114572190076
@@ -9,5 +9,7 @@ quantization = "6bit"
base_model = "Step 3.5 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 262144
[storage_size]
in_bytes = 159039627774
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Step 3.5 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 262144
[storage_size]
in_bytes = 209082699847
@@ -9,5 +9,7 @@ quantization = "MXFP4-Q8"
base_model = "GPT-OSS 120B"
capabilities = ["text", "thinking"]
context_length = 131072
[storage_size]
in_bytes = 70652212224
@@ -9,5 +9,7 @@ quantization = "MXFP4-Q8"
base_model = "GPT-OSS 20B"
capabilities = ["text", "thinking"]
context_length = 131072
[storage_size]
in_bytes = 12025908224
@@ -9,5 +9,7 @@ quantization = "fp16"
base_model = "Llama 3.3 70B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 144383672320
+228 -50
View File
@@ -1,5 +1,6 @@
"""OpenAI Responses API adapter for converting requests/responses."""
import json
from collections.abc import AsyncGenerator
from itertools import count
from typing import Any
@@ -10,7 +11,26 @@ from exo.api.adapters.chat_completions import (
)
from exo.api.types import Usage
from exo.api.types.openai_responses import (
ApplyPatchCallInputItem,
ApplyPatchCallOutputInputItem,
CodeInterpreterCallInputItem,
CompactionInputItem,
ComputerCallInputItem,
ComputerCallOutputInputItem,
CustomToolCallInputItem,
CustomToolCallOutputInputItem,
FileSearchCallInputItem,
FunctionCallInputItem,
FunctionCallOutputInputItem,
ImageGenerationCallInputItem,
ItemReferenceInputItem,
LocalShellCallInputItem,
LocalShellCallOutputInputItem,
McpApprovalRequestInputItem,
McpApprovalResponseInputItem,
McpCallInputItem,
McpListToolsInputItem,
ReasoningInputItem,
ResponseCompletedEvent,
ResponseContentPart,
ResponseContentPartAddedEvent,
@@ -39,7 +59,13 @@ from exo.api.types.openai_responses import (
ResponseTextDeltaEvent,
ResponseTextDoneEvent,
ResponseUsage,
ShellCallInputItem,
ShellCallOutputInputItem,
ToolSearchCallInputItem,
ToolSearchOutputInputItem,
WebSearchCallInputItem,
)
from exo.shared.logging import logger
from exo.shared.types.chunks import (
ErrorChunk,
PrefillProgressChunk,
@@ -87,64 +113,216 @@ async def responses_request_to_text_generation(
)
for item in request.input:
if isinstance(item, ResponseInputMessage):
content = _extract_content(item.content)
if isinstance(item.content, list):
for part in item.content:
if isinstance(part, ResponseInputImagePart) and part.image_url:
url = part.image_url
if url.startswith(("http://", "https://")):
images.append(await fetch_image_url(url))
else:
images.append(extract_base64_from_data_url(url))
has_images = True
if item.role in ("user", "assistant", "developer"):
input_messages.append(InputMessage(role=item.role, content=content))
if item.role == "system":
chat_template_messages.append(
{"role": "system", "content": content}
)
elif has_images:
multimodal: list[dict[str, Any]] = []
match item:
case ResponseInputMessage():
content = _extract_content(item.content)
if isinstance(item.content, list):
for part in item.content:
if isinstance(part, ResponseInputImagePart):
multimodal.append({"type": "image"})
elif hasattr(part, "text"):
multimodal.append({"type": "text", "text": part.text})
if (
isinstance(part, ResponseInputImagePart)
and part.image_url
):
url = part.image_url
if url.startswith(("http://", "https://")):
images.append(await fetch_image_url(url))
else:
images.append(extract_base64_from_data_url(url))
has_images = True
if item.role in ("user", "assistant", "developer"):
input_messages.append(
InputMessage(role=item.role, content=content)
)
if item.role == "system":
chat_template_messages.append(
{"role": "system", "content": content}
)
elif has_images:
multimodal: list[dict[str, Any]] = []
if isinstance(item.content, list):
for part in item.content:
if isinstance(part, ResponseInputImagePart):
multimodal.append({"type": "image"})
elif hasattr(part, "text"):
multimodal.append(
{"type": "text", "text": part.text}
)
chat_template_messages.append(
{"role": item.role, "content": multimodal}
)
has_images = False
else:
chat_template_messages.append(
{"role": item.role, "content": content}
)
case (
FunctionCallInputItem()
| McpCallInputItem()
| CustomToolCallInputItem()
):
chat_template_messages.append(
{"role": item.role, "content": multimodal}
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": item.call_id,
"type": "function",
"function": {
"name": item.name,
"arguments": item.arguments,
},
}
],
}
)
has_images = False
else:
case (
LocalShellCallInputItem()
| ShellCallInputItem()
| ComputerCallInputItem()
):
chat_template_messages.append(
{"role": item.role, "content": content}
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": item.call_id,
"type": "function",
"function": {
"name": item.type,
"arguments": json.dumps(item.action),
},
}
],
}
)
elif isinstance(item, FunctionCallInputItem):
chat_template_messages.append(
{
"role": "assistant",
"content": "",
"tool_calls": [
case ApplyPatchCallInputItem():
chat_template_messages.append(
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": item.call_id,
"type": "function",
"function": {
"name": "apply_patch",
"arguments": json.dumps({"patch": item.patch}),
},
}
],
}
)
case (
WebSearchCallInputItem()
| FileSearchCallInputItem()
| CodeInterpreterCallInputItem()
| ImageGenerationCallInputItem()
| ToolSearchCallInputItem()
):
args: dict[str, Any] = {}
if isinstance(item, WebSearchCallInputItem):
args = {"query": item.query}
elif isinstance(item, FileSearchCallInputItem):
args = {"queries": item.queries}
elif isinstance(item, CodeInterpreterCallInputItem):
args = {"code": item.code}
elif isinstance(item, ImageGenerationCallInputItem):
args = {"prompt": item.prompt}
else:
args = {"query": item.query}
chat_template_messages.append(
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": item.call_id,
"type": "function",
"function": {
"name": item.type,
"arguments": json.dumps(args),
},
}
],
}
)
case (
FunctionCallOutputInputItem()
| LocalShellCallOutputInputItem()
| ShellCallOutputInputItem()
| ApplyPatchCallOutputInputItem()
| ComputerCallOutputInputItem()
| CustomToolCallOutputInputItem()
| ToolSearchOutputInputItem()
):
output = (
item.output
if isinstance(item.output, str)
else json.dumps(item.output)
)
chat_template_messages.append(
{
"role": "tool",
"tool_call_id": item.call_id,
"content": output,
}
)
case ReasoningInputItem():
reasoning_text = ""
if item.content:
reasoning_text = "".join(
entry.get("text", "") for entry in item.content
)
elif item.summary:
reasoning_text = "".join(
entry.get("text", "") for entry in item.summary
)
if reasoning_text:
chat_template_messages.append(
{"role": "assistant", "content": reasoning_text}
)
case CompactionInputItem():
if item.summary:
chat_template_messages.append(
{"role": "system", "content": item.summary}
)
case McpListToolsInputItem():
tools_desc = ", ".join(t.get("name", "") for t in item.tools)
if tools_desc:
chat_template_messages.append(
{
"id": item.call_id,
"type": "function",
"function": {
"name": item.name,
"arguments": item.arguments,
},
"role": "system",
"content": f"Available MCP tools ({item.server_label}): {tools_desc}",
}
],
}
)
else:
chat_template_messages.append(
{
"role": "tool",
"tool_call_id": item.call_id,
"content": item.output,
}
)
)
case McpApprovalRequestInputItem():
chat_template_messages.append(
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": item.call_id,
"type": "function",
"function": {
"name": item.name,
"arguments": item.arguments,
},
}
],
}
)
case McpApprovalResponseInputItem():
chat_template_messages.append(
{
"role": "tool",
"tool_call_id": item.approval_request_id,
"content": f"{'Approved' if item.approve else 'Denied'}{': ' + item.reason if item.reason else ''}",
}
)
case ItemReferenceInputItem():
logger.info("Cannot handle ItemReferenceInputItem, skipping")
input_value = (
input_messages
+1
View File
@@ -1660,6 +1660,7 @@ class API:
quantization=card.quantization,
base_model=card.base_model,
capabilities=card.capabilities,
context_length=card.context_length,
)
for card in cards
]
+221 -1
View File
@@ -75,8 +75,228 @@ class FunctionCallOutputInputItem(BaseModel, frozen=True):
status: ResponseStatus | None = None
class ReasoningInputItem(BaseModel, frozen=True):
type: Literal["reasoning"] = "reasoning"
id: str | None = None
summary: list[dict[str, Any]] | None = None
encrypted_content: str | None = None
content: list[dict[str, Any]] | None = None
status: ResponseStatus | None = None
class ComputerCallInputItem(BaseModel, frozen=True):
type: Literal["computer_call"] = "computer_call"
id: str | None = None
call_id: str = ""
action: dict[str, Any] = {}
status: ResponseStatus | None = None
class ComputerCallOutputInputItem(BaseModel, frozen=True):
type: Literal["computer_call_output"] = "computer_call_output"
id: str | None = None
call_id: str = ""
output: dict[str, Any] | str = ""
status: ResponseStatus | None = None
class WebSearchCallInputItem(BaseModel, frozen=True):
type: Literal["web_search_call"] = "web_search_call"
id: str | None = None
call_id: str = ""
query: str = ""
status: ResponseStatus | None = None
class FileSearchCallInputItem(BaseModel, frozen=True):
type: Literal["file_search_call"] = "file_search_call"
id: str | None = None
call_id: str = ""
queries: list[str] = []
results: list[dict[str, Any]] | None = None
status: ResponseStatus | None = None
class CodeInterpreterCallInputItem(BaseModel, frozen=True):
type: Literal["code_interpreter_call"] = "code_interpreter_call"
id: str | None = None
call_id: str = ""
code: str = ""
results: list[dict[str, Any]] | None = None
status: ResponseStatus | None = None
class ImageGenerationCallInputItem(BaseModel, frozen=True):
type: Literal["image_generation_call"] = "image_generation_call"
id: str | None = None
call_id: str = ""
prompt: str = ""
result: dict[str, Any] | None = None
status: ResponseStatus | None = None
class LocalShellCallInputItem(BaseModel, frozen=True):
type: Literal["local_shell_call"] = "local_shell_call"
id: str | None = None
call_id: str = ""
action: dict[str, Any] = {}
status: ResponseStatus | None = None
class LocalShellCallOutputInputItem(BaseModel, frozen=True):
type: Literal["local_shell_call_output"] = "local_shell_call_output"
id: str | None = None
call_id: str = ""
output: str = ""
status: ResponseStatus | None = None
class ShellCallInputItem(BaseModel, frozen=True):
type: Literal["shell_call"] = "shell_call"
id: str | None = None
call_id: str = ""
action: dict[str, Any] = {}
status: ResponseStatus | None = None
class ShellCallOutputInputItem(BaseModel, frozen=True):
type: Literal["shell_call_output"] = "shell_call_output"
id: str | None = None
call_id: str = ""
output: str = ""
status: ResponseStatus | None = None
class ApplyPatchCallInputItem(BaseModel, frozen=True):
type: Literal["apply_patch_call"] = "apply_patch_call"
id: str | None = None
call_id: str = ""
patch: str = ""
status: ResponseStatus | None = None
class ApplyPatchCallOutputInputItem(BaseModel, frozen=True):
type: Literal["apply_patch_call_output"] = "apply_patch_call_output"
id: str | None = None
call_id: str = ""
output: str = ""
status: ResponseStatus | None = None
class ToolSearchCallInputItem(BaseModel, frozen=True):
type: Literal["tool_search_call"] = "tool_search_call"
id: str | None = None
call_id: str = ""
query: str = ""
status: ResponseStatus | None = None
class ToolSearchOutputInputItem(BaseModel, frozen=True):
type: Literal["tool_search_output"] = "tool_search_output"
id: str | None = None
call_id: str = ""
output: str = ""
status: ResponseStatus | None = None
class McpCallInputItem(BaseModel, frozen=True):
type: Literal["mcp_call"] = "mcp_call"
id: str | None = None
call_id: str = ""
name: str = ""
arguments: str = ""
server_label: str = ""
approval_request_id: str | None = None
error: str | None = None
output: str | None = None
status: ResponseStatus | None = None
class McpListToolsInputItem(BaseModel, frozen=True):
type: Literal["mcp_list_tools"] = "mcp_list_tools"
id: str | None = None
server_label: str = ""
tools: list[dict[str, Any]] = []
error: str | None = None
status: ResponseStatus | None = None
class McpApprovalRequestInputItem(BaseModel, frozen=True):
type: Literal["mcp_approval_request"] = "mcp_approval_request"
id: str | None = None
call_id: str = ""
name: str = ""
arguments: str = ""
server_label: str = ""
status: ResponseStatus | None = None
class McpApprovalResponseInputItem(BaseModel, frozen=True):
type: Literal["mcp_approval_response"] = "mcp_approval_response"
id: str | None = None
approval_request_id: str = ""
approve: bool = True
reason: str = ""
status: ResponseStatus | None = None
class CustomToolCallInputItem(BaseModel, frozen=True):
type: Literal["custom_tool_call"] = "custom_tool_call"
id: str | None = None
call_id: str = ""
name: str = ""
arguments: str = ""
status: ResponseStatus | None = None
class CustomToolCallOutputInputItem(BaseModel, frozen=True):
type: Literal["custom_tool_call_output"] = "custom_tool_call_output"
id: str | None = None
call_id: str = ""
output: str = ""
status: ResponseStatus | None = None
class CompactionInputItem(BaseModel, frozen=True):
type: Literal["compaction"] = "compaction"
id: str | None = None
summary: str = ""
encrypted_content: str | None = None
status: ResponseStatus | None = None
class ItemReferenceInputItem(BaseModel, frozen=True):
type: Literal["item_reference"] = "item_reference"
id: str | None = None
ResponseInputItem = (
ResponseInputMessage | FunctionCallInputItem | FunctionCallOutputInputItem
ResponseInputMessage
| FunctionCallInputItem
| FunctionCallOutputInputItem
| ReasoningInputItem
| ComputerCallInputItem
| ComputerCallOutputInputItem
| WebSearchCallInputItem
| FileSearchCallInputItem
| CodeInterpreterCallInputItem
| ImageGenerationCallInputItem
| LocalShellCallInputItem
| LocalShellCallOutputInputItem
| ShellCallInputItem
| ShellCallOutputInputItem
| ApplyPatchCallInputItem
| ApplyPatchCallOutputInputItem
| ToolSearchCallInputItem
| ToolSearchOutputInputItem
| McpCallInputItem
| McpListToolsInputItem
| McpApprovalRequestInputItem
| McpApprovalResponseInputItem
| CustomToolCallInputItem
| CustomToolCallOutputInputItem
| CompactionInputItem
| ItemReferenceInputItem
)
+4
View File
@@ -134,6 +134,7 @@ class ModelCard(CamelCaseModel):
quantization: str = ""
base_model: str = ""
capabilities: list[str] = []
context_length: int = 0
uses_cfg: bool = False
trust_remote_code: bool = True
is_custom: bool = False
@@ -202,6 +203,7 @@ class ModelCard(CamelCaseModel):
hidden_size=config_data.hidden_size or 0,
supports_tensor=config_data.supports_tensor,
num_key_value_heads=config_data.num_key_value_heads,
context_length=config_data.max_position_embeddings,
tasks=[ModelTask.TextGeneration],
trust_remote_code=False,
is_custom=True,
@@ -240,6 +242,7 @@ class ConfigData(BaseModel):
"decoder_layers",
)
)
max_position_embeddings: int = 0
vision: VisionCardConfig | None = None
@property
@@ -269,6 +272,7 @@ class ConfigData(BaseModel):
"architectures",
"hidden_size",
"num_key_value_heads",
"max_position_embeddings",
"num_hidden_layers",
"num_layers",
"n_layer",
@@ -132,6 +132,8 @@ class ExoBatchGenerator:
vision_processor=self.vision_processor,
tokenizer=self.tokenizer,
model=self.model,
model_id=task_params.model,
task_params=task_params,
)
except Exception:
logger.opt(exception=True).warning(
@@ -508,6 +508,8 @@ def mlx_generate(
vision_processor=vision_processor,
tokenizer=tokenizer,
model=model,
model_id=task.model,
task_params=task,
)
except Exception:
logger.opt(exception=True).warning(
+55 -19
View File
@@ -503,11 +503,39 @@ def _needs_dsml_encoding(task_params: TextGenerationTaskParams) -> bool:
return "deepseek-v3.2" in task_params.model.lower()
def apply_chat_template(
def consolidate_system_messages(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""
System messages almost exclusively must go at the start of a message
and there must only be a single one.
Also, Codex sends "developer" messages which are just system prompts.
"""
system_parts: list[str] = []
non_system: list[dict[str, Any]] = []
for msg in messages:
if msg.get("role") in ("system", "developer"):
content = cast(str, msg.get("content", ""))
if content:
system_parts.append(content)
else:
non_system.append(msg)
formatted_messages = non_system
if system_parts:
formatted_messages.insert(
0, {"role": "system", "content": "\n".join(system_parts)}
)
return formatted_messages
def render_chat_template(
tokenizer: TokenizerWrapper,
messages: list[dict[str, Any]],
task_params: TextGenerationTaskParams,
) -> str:
"""Convert TextGenerationTaskParams to a chat template prompt.
"""
Convert TextGenerationTaskParams to a chat template prompt.
Converts the internal format (input + instructions) to a messages list
that can be processed by the tokenizer's chat template.
@@ -515,23 +543,7 @@ def apply_chat_template(
When chat_template_messages is available (from Chat Completions API),
uses those directly to preserve tool_calls, thinking, and other fields.
"""
formatted_messages: list[dict[str, Any]] = []
if task_params.chat_template_messages is not None:
# Use pre-formatted messages that preserve tool_calls, thinking, etc.
formatted_messages = list(task_params.chat_template_messages)
else:
# Add system message (instructions) if present
if task_params.instructions:
formatted_messages.append(
{"role": "system", "content": task_params.instructions}
)
# Convert input to messages
for msg in task_params.input:
if not msg.content:
logger.warning("Received message with empty content, skipping")
continue
formatted_messages.append({"role": msg.role, "content": msg.content})
formatted_messages = consolidate_system_messages(messages)
# For assistant prefilling, append content after templating to avoid a closing turn token.
partial_assistant_content: str | None = None
@@ -592,6 +604,30 @@ def apply_chat_template(
if partial_assistant_content:
prompt += partial_assistant_content
return prompt
def apply_chat_template(
tokenizer: TokenizerWrapper,
task_params: TextGenerationTaskParams,
) -> str:
messages: list[dict[str, Any]] = []
if task_params.chat_template_messages is not None:
# Use pre-formatted messages that preserve tool_calls, thinking, etc.
messages = list(task_params.chat_template_messages)
else:
# Add system message (instructions) if present
if task_params.instructions:
messages.append({"role": "system", "content": task_params.instructions})
# Convert input to messages
for msg in task_params.input:
if not msg.content:
logger.warning("Received message with empty content, skipping")
continue
messages.append({"role": msg.role, "content": msg.content})
prompt = render_chat_template(tokenizer, messages, task_params)
logger.info(prompt)
return prompt
+12 -9
View File
@@ -25,8 +25,12 @@ from exo.download.download_utils import build_model_path
from exo.shared.models.model_cards import VisionCardConfig
from exo.shared.types.common import ModelId
from exo.shared.types.mlx import Model
from exo.shared.types.text_generation import TextGenerationTaskParams
from exo.worker.engines.mlx.cache import encode_prompt
from exo.worker.engines.mlx.utils_mlx import fix_unmatched_think_end_tokens
from exo.worker.engines.mlx.utils_mlx import (
fix_unmatched_think_end_tokens,
render_chat_template,
)
from exo.worker.runner.bootstrap import logger
@@ -86,15 +90,9 @@ def build_vision_prompt(
chat_template_messages: list[dict[str, Any]],
n_tokens_per_image: list[int],
image_token: str,
task_params: TextGenerationTaskParams,
) -> str:
logger.info(
f"Vision prompt messages: {[{k: (v[:50] if isinstance(v, str) else v) for k, v in m.items()} for m in chat_template_messages]}" # type: ignore
)
prompt: str = tokenizer.apply_chat_template(
chat_template_messages,
tokenize=False,
add_generation_prompt=True,
)
prompt = render_chat_template(tokenizer, chat_template_messages, task_params)
image_idx = 0
result: list[str] = []
@@ -498,6 +496,7 @@ class VisionProcessor:
chat_template_messages: list[dict[str, Any]],
tokenizer: TokenizerWrapper,
model: Model,
task_params: TextGenerationTaskParams,
) -> VisionResult:
logger.info(f"Vision pipeline: {len(images)} image(s)")
@@ -529,6 +528,7 @@ class VisionProcessor:
formatted_messages,
n_tokens_per_image,
image_token,
task_params,
)
logger.info(
@@ -572,6 +572,8 @@ def prepare_vision(
vision_processor: VisionProcessor,
tokenizer: TokenizerWrapper,
model: Model,
model_id: ModelId,
task_params: TextGenerationTaskParams,
) -> VisionResult | None:
if not images:
return None
@@ -586,4 +588,5 @@ def prepare_vision(
chat_template_messages=chat_template_messages,
tokenizer=tokenizer,
model=model,
task_params=task_params,
)