From ba484b1458030044c344b403a0ca76c36117af11 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Sat, 7 Feb 2026 01:12:00 +0000 Subject: [PATCH 1/9] feat: switch to GHCR base image and track image version per bot Use ghcr.io/openclaw/openclaw:latest as the default base image instead of requiring a local build. Add image_version column (migration v5) so each bot records which image its container was created from, displayed in the dashboard bot card. Co-Authored-By: Claude Opus 4.6 --- Dockerfile.botenv | 2 +- README.md | 9 +++------ dashboard/src/dashboard/BotCard.tsx | 6 ++++++ dashboard/src/hooks/useBots.test.ts | 3 +++ dashboard/src/types.ts | 1 + docker-compose.yml | 2 +- src/bots/store.test.ts | 9 +++++++++ src/bots/store.ts | 6 ++++++ src/config.test.ts | 2 +- src/config.ts | 2 +- src/db/migrations.test.ts | 17 +++++++++++++---- src/db/migrations.ts | 11 +++++++++++ src/server.ts | 2 +- src/types/bot.ts | 1 + 14 files changed, 58 insertions(+), 15 deletions(-) diff --git a/Dockerfile.botenv b/Dockerfile.botenv index 051be38..1343124 100644 --- a/Dockerfile.botenv +++ b/Dockerfile.botenv @@ -2,7 +2,7 @@ # Build: docker compose build botenv # Usage: Automatically used by botmaker when spawning bot containers -ARG BASE_IMAGE=openclaw:latest +ARG BASE_IMAGE=ghcr.io/openclaw/openclaw:latest FROM ${BASE_IMAGE} # Switch to root for package installation diff --git a/README.md b/README.md index c44fd50..14ee41f 100644 --- a/README.md +++ b/README.md @@ -60,12 +60,9 @@ Traditional setups pass API keys directly to bots—if a bot is compromised, you - Docker and Docker Compose - Node.js 20+ (for development only) -- OpenClaw base image — build from [OpenClaw repo](https://github.com/jgarzik/openclaw) or use a prebuilt image: +- OpenClaw base image — pulled automatically from GHCR, or pull manually: ```bash - # Option A: Build from source - git clone https://github.com/jgarzik/openclaw && cd openclaw && docker build -t openclaw:latest . - - # Option B: Use a prebuilt image (set OPENCLAW_BASE_IMAGE in docker-compose.yml) + docker pull ghcr.io/openclaw/openclaw:latest ``` ## Quick Start @@ -182,7 +179,7 @@ curl -X POST -H "Authorization: Bearer $TOKEN" http://localhost:7100/api/logout | `DATA_DIR` | ./data | Database and bot workspaces | | `SECRETS_DIR` | ./secrets | Per-bot secret storage | | `BOTENV_IMAGE` | botmaker-env:latest | Bot container image (built from botenv) | -| `OPENCLAW_BASE_IMAGE` | openclaw:latest | Base image for botenv | +| `OPENCLAW_BASE_IMAGE` | ghcr.io/openclaw/openclaw:latest | Base image for botenv | | `BOT_PORT_START` | 19000 | Starting port for bot containers | | `SESSION_EXPIRY_MS` | 86400000 | Session expiry in milliseconds (default 24h) | diff --git a/dashboard/src/dashboard/BotCard.tsx b/dashboard/src/dashboard/BotCard.tsx index 93a624c..5bb7c9f 100644 --- a/dashboard/src/dashboard/BotCard.tsx +++ b/dashboard/src/dashboard/BotCard.tsx @@ -81,6 +81,12 @@ export function BotCard({ bot, onStart, onStop, onDelete, loading }: BotCardProp {bot.port} )} + {bot.image_version && ( +
+ Image + {bot.image_version} +
+ )} {bot.port && (isRunning || isStarting) && ( diff --git a/dashboard/src/hooks/useBots.test.ts b/dashboard/src/hooks/useBots.test.ts index 968a1b2..3e60a89 100644 --- a/dashboard/src/hooks/useBots.test.ts +++ b/dashboard/src/hooks/useBots.test.ts @@ -24,6 +24,7 @@ describe('useBots', () => { container_id: 'container-1', port: 3001, gateway_token: 'token-1', + image_version: 'ghcr.io/openclaw/openclaw:latest', status: 'running' as const, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', @@ -38,6 +39,7 @@ describe('useBots', () => { container_id: null, port: null, gateway_token: null, + image_version: null, status: 'stopped' as const, created_at: '2024-01-02T00:00:00Z', updated_at: '2024-01-02T00:00:00Z', @@ -118,6 +120,7 @@ describe('useBots', () => { container_id: null, port: null, gateway_token: null, + image_version: null, status: 'created' as const, created_at: '2024-01-03T00:00:00Z', updated_at: '2024-01-03T00:00:00Z', diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts index 4deea7f..73bd591 100644 --- a/dashboard/src/types.ts +++ b/dashboard/src/types.ts @@ -22,6 +22,7 @@ export interface Bot { container_id: string | null; port: number | null; gateway_token: string | null; + image_version: string | null; status: BotStatus; created_at: string; updated_at: string; diff --git a/docker-compose.yml b/docker-compose.yml index 1ed2138..16ed8a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: context: . dockerfile: Dockerfile.botenv args: - BASE_IMAGE: ${OPENCLAW_BASE_IMAGE:-openclaw:latest} + BASE_IMAGE: ${OPENCLAW_BASE_IMAGE:-ghcr.io/openclaw/openclaw:latest} image: ${BOTENV_IMAGE:-botmaker-env:latest} botmaker: diff --git a/src/bots/store.test.ts b/src/bots/store.test.ts index c89e0f3..86dc011 100644 --- a/src/bots/store.test.ts +++ b/src/bots/store.test.ts @@ -197,6 +197,15 @@ describe('Bot Store', () => { expect(updated?.tags).toBe('["new1","new2"]'); }); + it('should update image_version', () => { + const created = createBot(createTestBotInput()); + expect(created.image_version).toBeNull(); + + const updated = updateBot(created.id, { image_version: 'ghcr.io/openclaw/openclaw:latest' }); + expect(updated).not.toBeNull(); + expect(updated?.image_version).toBe('ghcr.io/openclaw/openclaw:latest'); + }); + it('should clear tags', () => { const created = createBot(createTestBotInput({ tags: ['tag'] })); const updated = updateBot(created.id, { tags: null }); diff --git a/src/bots/store.ts b/src/bots/store.ts index 5c52ad6..6a73061 100644 --- a/src/bots/store.ts +++ b/src/bots/store.ts @@ -29,6 +29,7 @@ export interface UpdateBotInput { port?: number | null; gateway_token?: string | null; tags?: string[] | null; + image_version?: string | null; status?: BotStatus; } @@ -62,6 +63,7 @@ export function createBot(input: CreateBotInput): Bot { port: input.port, gateway_token: input.gateway_token, tags: tagsJson, + image_version: null, status: 'created', created_at: now, updated_at: now, @@ -169,6 +171,10 @@ export function updateBot(id: string, input: UpdateBotInput): Bot | null { updates.push('tags = ?'); values.push(input.tags && input.tags.length > 0 ? JSON.stringify(input.tags) : null); } + if (input.image_version !== undefined) { + updates.push('image_version = ?'); + values.push(input.image_version); + } if (input.status !== undefined) { updates.push('status = ?'); values.push(input.status); diff --git a/src/config.test.ts b/src/config.test.ts index 1b1c523..82367b1 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -42,7 +42,7 @@ describe('Config', () => { expect(config.secretsDir).toBe('./secrets'); expect(config.dataVolumeName).toBeNull(); expect(config.secretsVolumeName).toBeNull(); - expect(config.openclawImage).toBe('openclaw:latest'); + expect(config.openclawImage).toBe('ghcr.io/openclaw/openclaw:latest'); expect(config.openclawGitTag).toBe('main'); expect(config.botPortStart).toBe(19000); expect(config.proxyAdminUrl).toBeNull(); diff --git a/src/config.ts b/src/config.ts index e3adcf2..97d937b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -89,7 +89,7 @@ export function getConfig(): AppConfig { secretsDir: getEnvOrDefault('SECRETS_DIR', './secrets'), dataVolumeName: process.env.DATA_VOLUME_NAME ?? null, secretsVolumeName: process.env.SECRETS_VOLUME_NAME ?? null, - openclawImage: getEnvOrDefault('OPENCLAW_IMAGE', 'openclaw:latest'), + openclawImage: getEnvOrDefault('OPENCLAW_IMAGE', 'ghcr.io/openclaw/openclaw:latest'), openclawGitTag: getEnvOrDefault('OPENCLAW_GIT_TAG', 'main'), botPortStart: getEnvIntOrDefault('BOT_PORT_START', 19000), proxyAdminUrl: process.env.PROXY_ADMIN_URL ?? null, diff --git a/src/db/migrations.test.ts b/src/db/migrations.test.ts index cfcfd32..3335a69 100644 --- a/src/db/migrations.test.ts +++ b/src/db/migrations.test.ts @@ -64,7 +64,7 @@ describe('Database Migrations', () => { runMigrations(db); const version = getMigrationVersion(); - expect(version).toBe(4); // v0, v1, v2, v3, v4 + expect(version).toBe(5); // v0, v1, v2, v3, v4, v5 }); it('should add port column (v1)', () => { @@ -90,6 +90,14 @@ describe('Database Migrations', () => { const columns = getColumns('bots'); expect(columns).toContain('tags'); }); + + it('should add image_version column (v5)', () => { + createBaseSchema(); + runMigrations(db); + + const columns = getColumns('bots'); + expect(columns).toContain('image_version'); + }); }); describe('idempotent re-run', () => { @@ -102,7 +110,7 @@ describe('Database Migrations', () => { runMigrations(db); const version = getMigrationVersion(); - expect(version).toBe(4); + expect(version).toBe(5); }); it('should not duplicate migration records', () => { @@ -111,7 +119,7 @@ describe('Database Migrations', () => { runMigrations(db); const count = db.prepare('SELECT COUNT(*) as count FROM migrations').get() as { count: number }; - expect(count.count).toBe(5); // v0, v1, v2, v3, v4 + expect(count.count).toBe(6); // v0, v1, v2, v3, v4, v5 }); }); @@ -160,9 +168,10 @@ describe('Database Migrations', () => { const columns = getColumns('bots'); expect(columns).toContain('tags'); + expect(columns).toContain('image_version'); const version = getMigrationVersion(); - expect(version).toBe(4); + expect(version).toBe(5); }); }); }); diff --git a/src/db/migrations.ts b/src/db/migrations.ts index be68b7b..75e1632 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -74,4 +74,15 @@ export function runMigrations(db: Database.Database): void { ); })(); } + + // Migration v5: Add image_version column to track which image each bot was created with + if (currentVersion < 5) { + db.transaction(() => { + db.exec('ALTER TABLE bots ADD COLUMN image_version TEXT'); + db.prepare('INSERT INTO migrations (version, applied_at) VALUES (?, ?)').run( + 5, + new Date().toISOString() + ); + })(); + } } diff --git a/src/server.ts b/src/server.ts index 95e5e44..72e1629 100644 --- a/src/server.ts +++ b/src/server.ts @@ -392,7 +392,7 @@ export async function buildServer(): Promise { const db = getDb(); db.transaction(() => { - updateBot(bot.id, { container_id: containerId }); + updateBot(bot.id, { container_id: containerId, image_version: config.openclawImage }); })(); await docker.startContainer(bot.hostname); db.transaction(() => { diff --git a/src/types/bot.ts b/src/types/bot.ts index 0202de3..90fecd0 100644 --- a/src/types/bot.ts +++ b/src/types/bot.ts @@ -11,6 +11,7 @@ export interface Bot { port: number | null; // Allocated port for container gateway_token: string | null; // OpenClaw gateway authentication token tags: string | null; // JSON array of API routing tags + image_version: string | null; // Docker image used to create this bot's container status: BotStatus; created_at: string; // ISO datetime updated_at: string; // ISO datetime From 447c2fea926a60257fec1f8c16d78e9dd31a6117 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Sat, 7 Feb 2026 06:50:09 +0000 Subject: [PATCH 2/9] fix: surface actual Docker error on 404 instead of misleading "container not found" During createContainer(), a 404 means the image doesn't exist, not the container. Include the raw Docker error message so the real problem (e.g. "no such image: botmaker-env:latest") is visible in logs/UI. Co-Authored-By: Claude Opus 4.6 --- src/services/docker-errors.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/services/docker-errors.ts b/src/services/docker-errors.ts index e33b6fb..dc31383 100644 --- a/src/services/docker-errors.ts +++ b/src/services/docker-errors.ts @@ -32,7 +32,7 @@ export class ContainerError extends Error { * Wraps raw Docker errors with domain-specific error codes. * * Error code mapping: - * - 404 -> NOT_FOUND (container doesn't exist) + * - 404 -> NOT_FOUND (resource doesn't exist — image or container) * - 409 -> ALREADY_EXISTS (container name conflict) * - 304 -> ignored (not modified, container already in desired state) * - ETIMEDOUT/timeout -> NETWORK_ERROR (Docker daemon unreachable) @@ -41,11 +41,13 @@ export class ContainerError extends Error { export function wrapDockerError(err: unknown, botId: string): ContainerError { const dockerErr = err as { statusCode?: number; code?: string; message?: string }; - // Container not found + // Docker resource not found (image or container) if (dockerErr.statusCode === 404) { + const rawMsg = dockerErr.message ?? ''; + const detail = rawMsg ? `: ${rawMsg}` : ''; return new ContainerError( 'NOT_FOUND', - `Container for bot ${botId} not found`, + `Docker resource not found for bot ${botId}${detail}`, botId, err instanceof Error ? err : undefined ); From f1d7c8b85c30741eace5e2bfebe1a20177d3bc72 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Sat, 7 Feb 2026 14:12:26 +0000 Subject: [PATCH 3/9] feat: add Ollama (local LLM) support via keyring-proxy Add ollama as a new vendor in keyring-proxy with HTTP transport support (configurable protocol/port), dashboard provider config with dynamic model fetching from superproxy, and backend /api/ollama/models endpoint for runtime model discovery with graceful fallback. Co-Authored-By: Claude Opus 4.6 --- dashboard/src/api.ts | 9 ++ dashboard/src/config/providers/index.ts | 3 +- dashboard/src/config/providers/ollama.ts | 12 ++ dashboard/src/config/providers/types.ts | 2 + dashboard/src/wizard/pages/Page3Toggles.tsx | 2 +- dashboard/src/wizard/pages/Page4Config.css | 21 ++++ dashboard/src/wizard/pages/Page4Config.tsx | 117 ++++++++++++++++++++ proxy/src/services/upstream.ts | 9 +- proxy/src/types.ts | 10 ++ src/bots/templates.ts | 1 + src/server.ts | 28 +++++ 11 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 dashboard/src/config/providers/ollama.ts diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index bc9ea6c..07176bd 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -178,3 +178,12 @@ export async function fetchProxyHealth(): Promise { }); return handleResponse(response); } + +export async function fetchOllamaModels(baseUrl: string): Promise { + const response = await fetch( + `${API_BASE}/ollama/models?baseUrl=${encodeURIComponent(baseUrl)}`, + { headers: getAuthHeaders() }, + ); + const data = await handleResponse<{ models: string[] }>(response); + return data.models; +} diff --git a/dashboard/src/config/providers/index.ts b/dashboard/src/config/providers/index.ts index f6333d1..0276ef5 100644 --- a/dashboard/src/config/providers/index.ts +++ b/dashboard/src/config/providers/index.ts @@ -4,10 +4,11 @@ import { anthropic } from './anthropic'; import { google } from './google'; import { venice } from './venice'; import { openrouter } from './openrouter'; +import { ollama } from './ollama'; export type { ProviderConfig, ModelInfo }; -export const PROVIDERS: ProviderConfig[] = [openai, anthropic, google, venice, openrouter]; +export const PROVIDERS: ProviderConfig[] = [openai, anthropic, google, venice, openrouter, ollama]; export const AI_PROVIDERS = PROVIDERS.map((p) => ({ value: p.id, diff --git a/dashboard/src/config/providers/ollama.ts b/dashboard/src/config/providers/ollama.ts new file mode 100644 index 0000000..c5f52b5 --- /dev/null +++ b/dashboard/src/config/providers/ollama.ts @@ -0,0 +1,12 @@ +import type { ProviderConfig } from './types'; + +export const ollama: ProviderConfig = { + id: 'ollama', + label: 'Ollama', + baseUrl: 'http://host.docker.internal:4001/v1', + keyHint: 'superproxy master API key', + defaultModel: '', + models: [], + dynamicModels: true, + baseUrlEditable: true, +}; diff --git a/dashboard/src/config/providers/types.ts b/dashboard/src/config/providers/types.ts index ebed86e..c0e31f6 100644 --- a/dashboard/src/config/providers/types.ts +++ b/dashboard/src/config/providers/types.ts @@ -11,4 +11,6 @@ export interface ProviderConfig { models: ModelInfo[]; defaultModel: string; keyHint?: string; // Placeholder hint for API key format (e.g., "sk-ant-...") + dynamicModels?: boolean; // Models should be fetched at runtime (e.g., Ollama) + baseUrlEditable?: boolean; // Show editable base URL field in wizard } diff --git a/dashboard/src/wizard/pages/Page3Toggles.tsx b/dashboard/src/wizard/pages/Page3Toggles.tsx index f41486e..c63010d 100644 --- a/dashboard/src/wizard/pages/Page3Toggles.tsx +++ b/dashboard/src/wizard/pages/Page3Toggles.tsx @@ -6,7 +6,7 @@ import { FeatureCheckbox } from '../components'; import type { SessionScope } from '../../types'; import './Page3Toggles.css'; -const POPULAR_PROVIDERS = ['openai', 'anthropic', 'venice']; +const POPULAR_PROVIDERS = ['openai', 'anthropic', 'venice', 'ollama']; export function Page3Toggles() { const { state, dispatch } = useWizard(); diff --git a/dashboard/src/wizard/pages/Page4Config.css b/dashboard/src/wizard/pages/Page4Config.css index 4f1f13f..595be71 100644 --- a/dashboard/src/wizard/pages/Page4Config.css +++ b/dashboard/src/wizard/pages/Page4Config.css @@ -34,3 +34,24 @@ .page4-empty p + p { margin-top: var(--space-xs); } + +.page4-loading { + font-size: 11px; + color: var(--text-muted); + font-weight: 400; +} + +.page4-refresh-btn { + margin-top: var(--space-xs); + padding: 4px 12px; + font-size: 12px; + background: var(--bg-secondary, #2a2a2a); + border: 1px solid var(--border-color, #444); + border-radius: 4px; + color: var(--text-secondary); + cursor: pointer; +} + +.page4-refresh-btn:hover { + background: var(--bg-hover, #333); +} diff --git a/dashboard/src/wizard/pages/Page4Config.tsx b/dashboard/src/wizard/pages/Page4Config.tsx index 4a6d2d0..725000b 100644 --- a/dashboard/src/wizard/pages/Page4Config.tsx +++ b/dashboard/src/wizard/pages/Page4Config.tsx @@ -1,7 +1,10 @@ +import { useState, useEffect, useCallback } from 'react'; import { useWizard } from '../context/WizardContext'; import { getProvider, getModels } from '../../config/providers'; import { getChannel } from '../../config/channels'; import { ConfigSection } from '../components'; +import { fetchOllamaModels } from '../../api'; +import type { ModelInfo } from '../../config/providers'; import './Page4Config.css'; const TTS_VOICES = [ @@ -13,9 +16,31 @@ const TTS_VOICES = [ { id: 'shimmer', label: 'Shimmer' }, ]; +/** Hook to fetch dynamic models for providers that support it. */ +function useDynamicModels(_providerId: string, baseUrl: string) { + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(false); + + const refresh = useCallback(() => { + if (!baseUrl) return; + setLoading(true); + fetchOllamaModels(baseUrl) + .then((ids) => { setModels(ids.map((id) => ({ id }))); }) + .catch(() => { setModels([]); }) + .finally(() => { setLoading(false); }); + }, [baseUrl]); + + useEffect(() => { refresh(); }, [refresh]); + + return { models, loading, refresh }; +} + export function Page4Config() { const { state, dispatch } = useWizard(); + // Track per-provider base URL overrides (for baseUrlEditable providers) + const [baseUrls, setBaseUrls] = useState>({}); + const handleModelChange = (providerId: string, model: string) => { dispatch({ type: 'SET_PROVIDER_CONFIG', providerId, config: { model } }); }; @@ -32,6 +57,11 @@ export function Page4Config() { dispatch({ type: 'SET_FEATURE', feature: 'sandboxTimeout', value: timeout }); }; + const getBaseUrl = (providerId: string): string => { + const provider = getProvider(providerId); + return baseUrls[providerId] ?? provider?.baseUrl ?? ''; + }; + return (
{state.enabledProviders.length > 0 && ( @@ -39,6 +69,22 @@ export function Page4Config() {

LLM Provider Configuration

{state.enabledProviders.map((providerId) => { const provider = getProvider(providerId); + + if (provider?.dynamicModels) { + return ( + { + setBaseUrls((prev) => ({ ...prev, [providerId]: url })); + }} + model={state.providerConfigs[providerId]?.model ?? ''} + onModelChange={(model) => { handleModelChange(providerId, model); }} + /> + ); + } + const models = getModels(providerId); const config = state.providerConfigs[providerId] ?? { model: '' }; @@ -149,3 +195,74 @@ export function Page4Config() {
); } + +/** Config section for providers with dynamic model lists (e.g., Ollama). */ +function DynamicProviderConfig({ + providerId, + baseUrl, + onBaseUrlChange, + model, + onModelChange, +}: { + providerId: string; + baseUrl: string; + onBaseUrlChange: (url: string) => void; + model: string; + onModelChange: (model: string) => void; +}) { + const provider = getProvider(providerId); + const { models, loading, refresh } = useDynamicModels(providerId, baseUrl); + + return ( + + {provider?.baseUrlEditable && ( +
+ + { onBaseUrlChange(e.target.value); }} + placeholder="http://host.docker.internal:4001/v1" + /> +
+ )} + +
+ + {models.length > 0 ? ( + + ) : ( + { onModelChange(e.target.value); }} + placeholder={loading ? 'Loading models...' : 'Enter model name (e.g., llama3)'} + /> + )} + {!loading && models.length === 0 && ( + + )} +
+
+ ); +} diff --git a/proxy/src/services/upstream.ts b/proxy/src/services/upstream.ts index 35fc47e..e6ee7b0 100644 --- a/proxy/src/services/upstream.ts +++ b/proxy/src/services/upstream.ts @@ -1,4 +1,5 @@ import https from 'https'; +import http from 'http'; import type { IncomingMessage, ServerResponse } from 'http'; import type { FastifyReply } from 'fastify'; import type { VendorConfig } from '../types.js'; @@ -48,16 +49,20 @@ export async function forwardToUpstream( upstreamHeaders['content-length'] = String(body.length); } + const protocol = vendorConfig.protocol ?? 'https'; + const port = vendorConfig.port ?? (protocol === 'https' ? 443 : 80); + const options = { hostname: vendorConfig.host, - port: 443, + port, path: upstreamPath, method, headers: upstreamHeaders, timeout: REQUEST_TIMEOUT_MS, }; - const proxyReq = https.request(options, (proxyRes: IncomingMessage) => { + const transport = protocol === 'http' ? http : https; + const proxyReq = transport.request(options, (proxyRes: IncomingMessage) => { const statusCode = proxyRes.statusCode ?? 500; // Build headers to forward (excluding hop-by-hop) diff --git a/proxy/src/types.ts b/proxy/src/types.ts index edb9d07..9e05bfb 100644 --- a/proxy/src/types.ts +++ b/proxy/src/types.ts @@ -29,6 +29,8 @@ export interface VendorConfig { basePath: string; authHeader: string; authFormat: (key: string) => string; + port?: number; // default: 443 + protocol?: 'http' | 'https'; // default: 'https' } export const VENDOR_CONFIGS: Record = { @@ -62,4 +64,12 @@ export const VENDOR_CONFIGS: Record = { authHeader: 'Authorization', authFormat: (key) => `Bearer ${key}`, }, + ollama: { + host: 'host.docker.internal', + basePath: '/v1', + authHeader: 'Authorization', + authFormat: (key) => `Bearer ${key}`, + port: 4001, + protocol: 'http', + }, }; diff --git a/src/bots/templates.ts b/src/bots/templates.ts index a791a43..b36e8d5 100644 --- a/src/bots/templates.ts +++ b/src/bots/templates.ts @@ -63,6 +63,7 @@ function getApiTypeForProvider(provider: string): string { return 'google-gemini'; case 'venice': case 'openrouter': + case 'ollama': return 'openai-completions'; // OpenAI-compatible APIs case 'openai': default: diff --git a/src/server.ts b/src/server.ts index 72e1629..b1526cd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -604,6 +604,34 @@ export async function buildServer(): Promise { } }); + // Ollama/superproxy dynamic model listing + server.get<{ Querystring: { baseUrl?: string } }>('/api/ollama/models', async (request, reply) => { + const baseUrl = request.query.baseUrl; + if (!baseUrl) { + reply.code(400); + return { error: 'Missing baseUrl query parameter' }; + } + + try { + const url = new URL('/models', baseUrl); + const controller = new AbortController(); + const timeout = setTimeout(() => { controller.abort(); }, 5000); + const response = await fetch(url.toString(), { signal: controller.signal }); + clearTimeout(timeout); + + if (!response.ok) { + return { models: [] }; + } + + const data = await response.json() as { data?: { id: string }[] }; + const models = (data.data ?? []).map((m: { id: string }) => m.id); + return { models }; + } catch { + // Connection refused, timeout, etc. — graceful fallback + return { models: [] }; + } + }); + // Serve static dashboard files (if built) const dashboardDist = join(process.cwd(), 'dashboard', 'dist'); if (existsSync(dashboardDist)) { From 961ad3e0a64305b1543ccf1bb211697f209dc7ef Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Sat, 7 Feb 2026 14:45:43 +0000 Subject: [PATCH 4/9] fix: pass API key for Ollama model discovery and fix URL path construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The superproxy requires auth for /v1/models. Also fixed URL construction that was stripping the /v1 prefix (new URL('/models', base) → string concat). Added API key input field to the dynamic provider config in the wizard. Co-Authored-By: Claude Opus 4.6 --- dashboard/src/api.ts | 11 ++++---- dashboard/src/wizard/pages/Page4Config.tsx | 29 +++++++++++++++++++--- src/server.ts | 15 ++++++++--- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index 07176bd..cdee2ef 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -179,11 +179,12 @@ export async function fetchProxyHealth(): Promise { return handleResponse(response); } -export async function fetchOllamaModels(baseUrl: string): Promise { - const response = await fetch( - `${API_BASE}/ollama/models?baseUrl=${encodeURIComponent(baseUrl)}`, - { headers: getAuthHeaders() }, - ); +export async function fetchOllamaModels(baseUrl: string, apiKey?: string): Promise { + let url = `${API_BASE}/ollama/models?baseUrl=${encodeURIComponent(baseUrl)}`; + if (apiKey) { + url += `&apiKey=${encodeURIComponent(apiKey)}`; + } + const response = await fetch(url, { headers: getAuthHeaders() }); const data = await handleResponse<{ models: string[] }>(response); return data.models; } diff --git a/dashboard/src/wizard/pages/Page4Config.tsx b/dashboard/src/wizard/pages/Page4Config.tsx index 725000b..c3da245 100644 --- a/dashboard/src/wizard/pages/Page4Config.tsx +++ b/dashboard/src/wizard/pages/Page4Config.tsx @@ -17,18 +17,18 @@ const TTS_VOICES = [ ]; /** Hook to fetch dynamic models for providers that support it. */ -function useDynamicModels(_providerId: string, baseUrl: string) { +function useDynamicModels(baseUrl: string, apiKey: string) { const [models, setModels] = useState([]); const [loading, setLoading] = useState(false); const refresh = useCallback(() => { if (!baseUrl) return; setLoading(true); - fetchOllamaModels(baseUrl) + fetchOllamaModels(baseUrl, apiKey || undefined) .then((ids) => { setModels(ids.map((id) => ({ id }))); }) .catch(() => { setModels([]); }) .finally(() => { setLoading(false); }); - }, [baseUrl]); + }, [baseUrl, apiKey]); useEffect(() => { refresh(); }, [refresh]); @@ -40,6 +40,8 @@ export function Page4Config() { // Track per-provider base URL overrides (for baseUrlEditable providers) const [baseUrls, setBaseUrls] = useState>({}); + // Track per-provider API key for dynamic model fetching + const [apiKeys, setApiKeys] = useState>({}); const handleModelChange = (providerId: string, model: string) => { dispatch({ type: 'SET_PROVIDER_CONFIG', providerId, config: { model } }); @@ -79,6 +81,10 @@ export function Page4Config() { onBaseUrlChange={(url) => { setBaseUrls((prev) => ({ ...prev, [providerId]: url })); }} + apiKey={apiKeys[providerId] ?? ''} + onApiKeyChange={(key) => { + setApiKeys((prev) => ({ ...prev, [providerId]: key })); + }} model={state.providerConfigs[providerId]?.model ?? ''} onModelChange={(model) => { handleModelChange(providerId, model); }} /> @@ -201,17 +207,21 @@ function DynamicProviderConfig({ providerId, baseUrl, onBaseUrlChange, + apiKey, + onApiKeyChange, model, onModelChange, }: { providerId: string; baseUrl: string; onBaseUrlChange: (url: string) => void; + apiKey: string; + onApiKeyChange: (key: string) => void; model: string; onModelChange: (model: string) => void; }) { const provider = getProvider(providerId); - const { models, loading, refresh } = useDynamicModels(providerId, baseUrl); + const { models, loading, refresh } = useDynamicModels(baseUrl, apiKey); return ( )} +
+ + { onApiKeyChange(e.target.value); }} + placeholder={provider?.keyHint ?? 'API key'} + /> +
+
)} -
- - { onApiKeyChange(e.target.value); }} - placeholder={provider?.keyHint ?? 'API key'} - /> -
+ {!provider?.noAuth && ( +
+ + { onApiKeyChange(e.target.value); }} + placeholder={provider?.keyHint ?? 'API key'} + /> +
+ )}