Skip to content

Commit c0c9b90

Browse files
createpjfclaude
andcommitted
feat: add local model support (Ollama + LM Studio) — v2.0.1
- Auto-discover local LLM servers at localhost:11434 and localhost:1234 - Dynamic model listing via /v1/models probe (30s polling) - $0 pricing for all local inference, 120s timeout for cold starts - Skip auth headers for local providers - 3 new API endpoints for local provider management - Frontend UI with status dots, model count, URL editing, refresh - cost_first routing naturally prefers local ($0) - Provider colors: Ollama (#0077B6), LM Studio (#E63946) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c86492f commit c0c9b90

File tree

14 files changed

+503
-20
lines changed

14 files changed

+503
-20
lines changed

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@routebox/desktop",
33
"private": true,
4-
"version": "2.0.0",
4+
"version": "2.0.1",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

apps/desktop/src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "routebox-desktop"
3-
version = "2.0.0"
3+
version = "2.0.1"
44
description = "RouteBox macOS Menu Bar App"
55
authors = ["RouteBox"]
66
edition = "2021"

apps/desktop/src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "RouteBox",
4-
"version": "2.0.0",
4+
"version": "2.0.1",
55
"identifier": "com.routebox.desktop",
66
"build": {
77
"beforeDevCommand": "pnpm dev",

apps/desktop/src/components/ProviderKeyManager.tsx

Lines changed: 154 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, useEffect, useCallback } from "react";
2-
import { Key, Trash2, Check, Loader2, AlertCircle, Shield } from "lucide-react";
2+
import { Key, Trash2, Check, Loader2, AlertCircle, Shield, RefreshCw, Monitor } from "lucide-react";
33
import clsx from "clsx";
4-
import { api, type ProviderRegistryEntry } from "@/lib/api";
4+
import { api, type ProviderRegistryEntry, type LocalProviderInfo } from "@/lib/api";
55

66
interface ProviderKeyManagerProps {
77
/** Called when a key is saved/deleted so parent can react */
@@ -10,17 +10,25 @@ interface ProviderKeyManagerProps {
1010

1111
export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerProps) {
1212
const [providers, setProviders] = useState<ProviderRegistryEntry[]>([]);
13+
const [localProviders, setLocalProviders] = useState<LocalProviderInfo[]>([]);
1314
const [loading, setLoading] = useState(true);
1415
const [editingProvider, setEditingProvider] = useState<string | null>(null);
1516
const [keyInput, setKeyInput] = useState("");
1617
const [saving, setSaving] = useState(false);
1718
const [error, setError] = useState<string | null>(null);
1819
const [successProvider, setSuccessProvider] = useState<string | null>(null);
20+
const [editingLocalUrl, setEditingLocalUrl] = useState<string | null>(null);
21+
const [localUrlInput, setLocalUrlInput] = useState("");
22+
const [refreshingLocal, setRefreshingLocal] = useState<string | null>(null);
1923

2024
const fetchRegistry = useCallback(async () => {
2125
try {
22-
const res = await api.getProviderRegistry();
23-
setProviders(res.providers);
26+
const [regRes, localRes] = await Promise.all([
27+
api.getProviderRegistry(),
28+
api.getLocalProviders().catch(() => ({ providers: [] })),
29+
]);
30+
setProviders(regRes.providers);
31+
setLocalProviders(localRes.providers);
2432
} catch {
2533
// silent — may not be connected yet
2634
} finally {
@@ -61,6 +69,35 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro
6169
}
6270
}, [fetchRegistry, onProvidersChanged]);
6371

72+
const handleRefreshLocal = useCallback(async (name: string) => {
73+
setRefreshingLocal(name);
74+
try {
75+
const updated = await api.refreshLocalProvider(name);
76+
setLocalProviders((prev) =>
77+
prev.map((lp) => lp.name === name ? { ...lp, ...updated } : lp)
78+
);
79+
} catch {}
80+
setRefreshingLocal(null);
81+
}, []);
82+
83+
const handleSaveLocalUrl = useCallback(async (name: string) => {
84+
if (!localUrlInput.trim()) return;
85+
setSaving(true);
86+
setError(null);
87+
try {
88+
const updated = await api.setLocalProviderUrl(name, localUrlInput.trim());
89+
setLocalProviders((prev) =>
90+
prev.map((lp) => lp.name === name ? { ...lp, ...updated } : lp)
91+
);
92+
setEditingLocalUrl(null);
93+
setLocalUrlInput("");
94+
} catch (err) {
95+
setError(err instanceof Error ? err.message : "Failed to update URL");
96+
} finally {
97+
setSaving(false);
98+
}
99+
}, [localUrlInput]);
100+
64101
if (loading) {
65102
return (
66103
<div className="glass-card-static p-3 flex items-center justify-center">
@@ -80,6 +117,118 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro
80117
}
81118

82119
return (
120+
<div className="space-y-3">
121+
{/* Local providers */}
122+
{localProviders.length > 0 && (
123+
<div className="glass-card-static overflow-hidden">
124+
<div className="flex items-center gap-1.5 px-3 h-8 border-b border-border-light">
125+
<Monitor size={12} strokeWidth={1.75} className="text-text-tertiary" />
126+
<span className="text-[11px] font-semibold text-text-secondary uppercase tracking-wider">Local</span>
127+
</div>
128+
{localProviders.map((lp, idx) => {
129+
const isEditingUrl = editingLocalUrl === lp.name;
130+
const isRefreshing = refreshingLocal === lp.name;
131+
const isLast = idx === localProviders.length - 1;
132+
133+
return (
134+
<div key={lp.name}>
135+
<div
136+
className={clsx(
137+
"flex items-center gap-2.5 h-10 px-3 transition-colors",
138+
!isEditingUrl && "cursor-pointer hover:bg-bg-row-hover",
139+
!isLast && !isEditingUrl && "border-b border-border-light"
140+
)}
141+
onClick={() => {
142+
if (!isEditingUrl) {
143+
setEditingLocalUrl(lp.name);
144+
setLocalUrlInput(lp.baseUrl);
145+
setError(null);
146+
}
147+
}}
148+
>
149+
<div
150+
className={clsx(
151+
"w-1.5 h-1.5 rounded-full shrink-0",
152+
lp.isOnline ? "bg-accent-green" : "bg-text-tertiary/40"
153+
)}
154+
/>
155+
<span className="text-[13px] text-text-primary flex-1">{lp.name}</span>
156+
<div className="flex items-center gap-1.5">
157+
{lp.isOnline ? (
158+
<span className="text-[11px] text-text-tertiary">
159+
{lp.modelCount} model{lp.modelCount !== 1 ? "s" : ""}
160+
</span>
161+
) : (
162+
<span className="text-[11px] text-text-tertiary">Offline</span>
163+
)}
164+
<button
165+
onClick={(e) => {
166+
e.stopPropagation();
167+
handleRefreshLocal(lp.name);
168+
}}
169+
className="flex items-center justify-center w-6 h-6 rounded-md hover:bg-bg-input transition-colors"
170+
title="Refresh"
171+
>
172+
<RefreshCw
173+
size={12}
174+
strokeWidth={1.75}
175+
className={clsx("text-text-tertiary", isRefreshing && "animate-spin")}
176+
/>
177+
</button>
178+
</div>
179+
</div>
180+
181+
{isEditingUrl && (
182+
<div className={clsx("px-3 pb-2.5 pt-1", !isLast && "border-b border-border-light")}>
183+
<div className="flex items-center gap-2">
184+
<input
185+
type="text"
186+
value={localUrlInput}
187+
onChange={(e) => {
188+
setLocalUrlInput(e.target.value);
189+
setError(null);
190+
}}
191+
placeholder={`Base URL (e.g. http://localhost:11434/v1)`}
192+
className="input input-mono flex-1 !h-8 !text-[11px]"
193+
autoFocus
194+
onKeyDown={(e) => {
195+
if (e.key === "Enter") handleSaveLocalUrl(lp.name);
196+
if (e.key === "Escape") {
197+
setEditingLocalUrl(null);
198+
setError(null);
199+
}
200+
}}
201+
/>
202+
<button
203+
onClick={() => handleSaveLocalUrl(lp.name)}
204+
disabled={saving || !localUrlInput.trim()}
205+
className={clsx(
206+
"btn-primary !h-8 !text-[11px] !px-3 shrink-0",
207+
(saving || !localUrlInput.trim()) && "opacity-40 cursor-not-allowed"
208+
)}
209+
>
210+
{saving ? (
211+
<Loader2 size={12} strokeWidth={1.75} className="animate-spin" />
212+
) : (
213+
"Save"
214+
)}
215+
</button>
216+
</div>
217+
{error && editingLocalUrl === lp.name && (
218+
<div className="flex items-center gap-1.5 mt-1.5">
219+
<AlertCircle size={12} strokeWidth={1.75} className="text-accent-red shrink-0" />
220+
<span className="text-[11px] text-accent-red">{error}</span>
221+
</div>
222+
)}
223+
</div>
224+
)}
225+
</div>
226+
);
227+
})}
228+
</div>
229+
)}
230+
231+
{/* Cloud providers */}
83232
<div className="glass-card-static overflow-hidden">
84233
{providers.map((p, idx) => {
85234
const isEditing = editingProvider === p.name;
@@ -200,5 +349,6 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro
200349
);
201350
})}
202351
</div>
352+
</div>
203353
);
204354
}

apps/desktop/src/lib/api.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,24 @@ export interface ProviderRegistryEntry {
9898
keySource: "env" | "db" | null;
9999
maskedKey: string | null;
100100
isActive: boolean;
101+
isLocal?: boolean;
102+
baseUrl?: string;
103+
modelCount?: number;
104+
}
105+
106+
// ── Local Providers ──────────────────────────────────────────────────────────
107+
108+
export interface LocalProviderInfo {
109+
name: string;
110+
baseUrl: string;
111+
isOnline: boolean;
112+
modelCount: number;
113+
models: string[];
114+
lastChecked: number;
115+
}
116+
117+
export interface LocalProvidersResponse {
118+
providers: LocalProviderInfo[];
101119
}
102120

103121
export interface ProviderRegistryResponse {
@@ -296,6 +314,21 @@ export const api = {
296314
retries: 0,
297315
}),
298316

317+
// Local providers
318+
getLocalProviders: () =>
319+
request<LocalProvidersResponse>("/api/v1/local-providers"),
320+
setLocalProviderUrl: (name: string, baseUrl: string) =>
321+
request<LocalProviderInfo>(`/api/v1/local-providers/${encodeURIComponent(name)}/url`, {
322+
method: "PUT",
323+
body: JSON.stringify({ baseUrl }),
324+
retries: 0,
325+
}),
326+
refreshLocalProvider: (name: string) =>
327+
request<LocalProviderInfo>(`/api/v1/local-providers/${encodeURIComponent(name)}/refresh`, {
328+
method: "POST",
329+
retries: 0,
330+
}),
331+
299332
// V2: Usage
300333
getUsageToday: () => request<UsageTodayResponse>("/api/v1/usage/today"),
301334
getUsageMonth: () => request<UsageMonthResponse>("/api/v1/usage/month"),

apps/desktop/src/lib/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export const PROVIDER_COLORS: Record<string, string> = {
4747
Groq: "#F55036",
4848
Together: "#6366F1",
4949
"FLock.io": "#7C3AED",
50+
Ollama: "#0077B6",
51+
"LM Studio": "#E63946",
5052
};
5153

5254
export const ROUTING_STRATEGIES = [

apps/gateway/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { authMiddleware } from "./lib/auth";
77
import { providers, rebuildProviders } from "./lib/providers";
88
import { loadAllProviderKeys } from "./lib/db";
99
import { metrics } from "./lib/metrics";
10+
import { initLocalProviders, probeAllLocalProviders, startLocalProviderPolling, localProviders } from "./lib/local-providers";
1011

1112
// ── Load DB keys on startup ─────────────────────────────────────────────────
1213
const dbKeys = new Map<string, string>();
@@ -18,6 +19,17 @@ if (dbKeys.size > 0) {
1819
metrics.syncProviders();
1920
}
2021

22+
// ── Local provider discovery (Ollama, LM Studio) ────────────────────────────
23+
initLocalProviders();
24+
probeAllLocalProviders().then(() => {
25+
const online = localProviders.filter((lp) => lp.isOnline);
26+
if (online.length > 0) {
27+
console.log(` Local: ${online.map((lp) => `${lp.name} (${lp.models.length} models)`).join(", ")}`);
28+
}
29+
metrics.syncProviders();
30+
}).catch(() => {});
31+
startLocalProviderPolling();
32+
2133
const app = new Hono();
2234

2335
// CORS — restricted to Tauri webview and local development
@@ -49,6 +61,10 @@ app.get("/health", (c) => c.json({
4961
status: "ok",
5062
uptime: process.uptime(),
5163
providers: providers.map((p) => p.name),
64+
localProviders: localProviders.filter((lp) => lp.isOnline).map((lp) => ({
65+
name: lp.name,
66+
models: lp.models.length,
67+
})),
5268
}));
5369

5470
// Auth for REST + proxy

0 commit comments

Comments
 (0)