diff --git a/.env.example b/.env.example index 325e49a6fb..fec65d70d1 100644 --- a/.env.example +++ b/.env.example @@ -24,10 +24,13 @@ CODEX_REFRESH_TOKEN= CODEX_ACCOUNT_ID= # CODEX_BIN_PATH= # Optional: path to Codex native binary (binary builds only) -# Default AI Assistant (claude | codex) +# Default AI Assistant (claude | codex | ollama) # Used for new conversations when no codebase specified DEFAULT_AI_ASSISTANT=claude +# Ollama server base URL (optional, defaults to http://localhost:11434) +# OLLAMA_BASE_URL=http://localhost:11434 + # Title Generation Model (optional) # Model used for generating conversation titles (lightweight task) # When unset, uses the SDK's default model diff --git a/packages/core/package.json b/packages/core/package.json index 970b01e4d4..9dc4a7b2d7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -22,7 +22,7 @@ "./state/*": "./src/state/*.ts" }, "scripts": { - "test": "bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts", + "test": "bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/config/ src/state/ && bun test src/db/conversations.test.ts && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts", "type-check": "bun x tsc --noEmit", "build": "echo 'No build needed - Bun runs TypeScript directly'" }, diff --git a/packages/core/src/config/config-loader.test.ts b/packages/core/src/config/config-loader.test.ts index da18deded7..8139f9e0ab 100644 --- a/packages/core/src/config/config-loader.test.ts +++ b/packages/core/src/config/config-loader.test.ts @@ -224,7 +224,7 @@ concurrency: const config = await loadConfig(); expect(config.assistant).toBe('claude'); - expect(config.assistants).toEqual({ claude: {}, codex: {} }); + expect(config.assistants).toEqual({ claude: {}, codex: {}, ollama: {} }); expect(config.streaming.telegram).toBe('stream'); expect(config.concurrency.maxConversations).toBe(10); }); diff --git a/packages/core/src/config/config-loader.ts b/packages/core/src/config/config-loader.ts index f0f51ba0a4..ee2ce7a68c 100644 --- a/packages/core/src/config/config-loader.ts +++ b/packages/core/src/config/config-loader.ts @@ -176,6 +176,7 @@ function getDefaults(): MergedConfig { assistants: { claude: {}, codex: {}, + ollama: {}, }, streaming: { telegram: 'stream', @@ -258,6 +259,7 @@ function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): Merged assistants: { claude: { ...defaults.assistants.claude }, codex: { ...defaults.assistants.codex }, + ollama: { ...defaults.assistants.ollama }, }, }; @@ -283,6 +285,9 @@ function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): Merged ...global.assistants.codex, }; } + if (global.assistants?.ollama !== undefined) { + result.assistants.ollama = global.assistants.ollama; + } // Streaming preferences if (global.streaming) { @@ -314,6 +319,7 @@ function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig { assistants: { claude: { ...merged.assistants.claude }, codex: { ...merged.assistants.codex }, + ollama: { ...merged.assistants.ollama }, }, }; @@ -334,6 +340,9 @@ function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig { ...repo.assistants.codex, }; } + if (repo.assistants?.ollama !== undefined) { + result.assistants.ollama = repo.assistants.ollama; + } // Commands config if (repo.commands) { @@ -446,6 +455,8 @@ export async function updateGlobalConfig(updates: Partial): Promis merged.assistants = { claude: { ...current.assistants?.claude, ...updates.assistants.claude }, codex: { ...current.assistants?.codex, ...updates.assistants.codex }, + // Replace ollama section entirely so cleared fields (model, baseUrl) are removed + ...(updates.assistants.ollama !== undefined ? { ollama: updates.assistants.ollama } : {}), }; } @@ -484,9 +495,19 @@ export async function updateGlobalConfig(updates: Partial): Promis * Strips filesystem paths and any other server-internal fields. */ export function toSafeConfig(config: MergedConfig): SafeConfig { + // Determine which providers are available based on configuration and environment. + // Claude is always available (API key checked at request time, not config time). + // Codex requires the binary to be present; we optimistically include it here. + // Ollama is included when a model is configured or OLLAMA_BASE_URL is set. + const availableAssistants: ('claude' | 'codex' | 'ollama')[] = ['claude', 'codex']; + if (config.assistants.ollama.model || process.env.OLLAMA_BASE_URL) { + availableAssistants.push('ollama'); + } + return { botName: config.botName, assistant: config.assistant, + availableAssistants, assistants: { claude: { model: config.assistants.claude.model, @@ -496,6 +517,10 @@ export function toSafeConfig(config: MergedConfig): SafeConfig { modelReasoningEffort: config.assistants.codex.modelReasoningEffort, webSearchMode: config.assistants.codex.webSearchMode, }, + ollama: { + model: config.assistants.ollama.model, + baseUrl: config.assistants.ollama.baseUrl, + }, }, streaming: { telegram: config.streaming.telegram, diff --git a/packages/core/src/config/config-types.ts b/packages/core/src/config/config-types.ts index 983720c13b..508db5f256 100644 --- a/packages/core/src/config/config-types.ts +++ b/packages/core/src/config/config-types.ts @@ -13,9 +13,13 @@ // Provider config defaults — canonical definitions live in @archon/providers/types. // Imported and re-exported here so existing consumers don't break. -import type { ClaudeProviderDefaults, CodexProviderDefaults } from '@archon/providers/types'; +import type { + ClaudeProviderDefaults, + CodexProviderDefaults, + OllamaProviderDefaults, +} from '@archon/providers/types'; -export type { ClaudeProviderDefaults, CodexProviderDefaults }; +export type { ClaudeProviderDefaults, CodexProviderDefaults, OllamaProviderDefaults }; export interface GlobalConfig { /** @@ -28,7 +32,7 @@ export interface GlobalConfig { * Default AI assistant when no codebase-specific preference * @default 'claude' */ - defaultAssistant?: 'claude' | 'codex'; + defaultAssistant?: 'claude' | 'codex' | 'ollama'; /** * Assistant-specific defaults (model, reasoning effort, etc.) @@ -36,6 +40,7 @@ export interface GlobalConfig { assistants?: { claude?: ClaudeProviderDefaults; codex?: CodexProviderDefaults; + ollama?: OllamaProviderDefaults; }; /** @@ -85,7 +90,7 @@ export interface RepoConfig { * AI assistant preference for this repository * Overrides global default */ - assistant?: 'claude' | 'codex'; + assistant?: 'claude' | 'codex' | 'ollama'; /** * Assistant-specific defaults for this repository @@ -93,6 +98,7 @@ export interface RepoConfig { assistants?: { claude?: ClaudeProviderDefaults; codex?: CodexProviderDefaults; + ollama?: OllamaProviderDefaults; }; /** @@ -182,10 +188,11 @@ export interface RepoConfig { */ export interface MergedConfig { botName: string; - assistant: 'claude' | 'codex'; + assistant: 'claude' | 'codex' | 'ollama'; assistants: { claude: ClaudeProviderDefaults; codex: CodexProviderDefaults; + ollama: OllamaProviderDefaults; }; streaming: { telegram: 'stream' | 'batch'; @@ -238,10 +245,13 @@ export interface MergedConfig { */ export interface SafeConfig { botName: string; - assistant: 'claude' | 'codex'; + assistant: 'claude' | 'codex' | 'ollama'; + /** Providers that are configured and available on this server. */ + availableAssistants: ('claude' | 'codex' | 'ollama')[]; assistants: { claude: Pick; codex: Pick; + ollama: Pick; }; streaming: { telegram: 'stream' | 'batch'; diff --git a/packages/core/src/db/conversations.test.ts b/packages/core/src/db/conversations.test.ts index e63ae767c5..163975d132 100644 --- a/packages/core/src/db/conversations.test.ts +++ b/packages/core/src/db/conversations.test.ts @@ -11,6 +11,16 @@ mock.module('./connection', () => ({ getDialect: () => mockPostgresDialect, })); +// Mock config-loader: getOrCreateConversation calls loadConfig() for the default assistant +mock.module('../config/config-loader', () => ({ + loadConfig: mock(() => + Promise.resolve({ + assistant: 'claude', + assistants: { claude: {}, codex: {}, ollama: {} }, + }) + ), +})); + import { getOrCreateConversation, updateConversation, diff --git a/packages/core/src/db/conversations.ts b/packages/core/src/db/conversations.ts index 0a7a237da3..15fce83899 100644 --- a/packages/core/src/db/conversations.ts +++ b/packages/core/src/db/conversations.ts @@ -5,6 +5,7 @@ import { pool, getDialect } from './connection'; import type { Conversation } from '../types'; import { ConversationNotFoundError } from '../types'; import { createLogger } from '@archon/paths'; +import { loadConfig } from '../config/config-loader'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ let cachedLog: ReturnType | undefined; @@ -72,7 +73,18 @@ export async function getOrCreateConversation( // Check if we should inherit from a parent conversation (e.g., Discord thread inheriting from parent channel) let inheritedCodebaseId: string | null = null; let inheritedCwd: string | null = null; - let assistantType = process.env.DEFAULT_AI_ASSISTANT ?? 'claude'; + const config = await loadConfig(); + let assistantType: 'claude' | 'codex' | 'ollama' = config.assistant; + const envAssistant = process.env.DEFAULT_AI_ASSISTANT; + if (envAssistant) { + if (envAssistant === 'claude' || envAssistant === 'codex' || envAssistant === 'ollama') { + assistantType = envAssistant; + } else { + throw new Error( + `Invalid DEFAULT_AI_ASSISTANT: "${envAssistant}". Must be one of: claude, codex, ollama` + ); + } + } if (parentConversationId) { const parent = await pool.query( @@ -82,7 +94,7 @@ export async function getOrCreateConversation( if (parent.rows[0]) { inheritedCodebaseId = parent.rows[0].codebase_id; inheritedCwd = parent.rows[0].cwd; - assistantType = parent.rows[0].ai_assistant_type; + assistantType = parent.rows[0].ai_assistant_type as 'claude' | 'codex' | 'ollama'; getLog().debug( { inheritedCodebaseId, inheritedCwd }, 'db.conversation_parent_context_inherited' @@ -100,7 +112,7 @@ export async function getOrCreateConversation( [codebaseId] ); if (codebase.rows[0]) { - assistantType = codebase.rows[0].ai_assistant_type; + assistantType = codebase.rows[0].ai_assistant_type as 'claude' | 'codex' | 'ollama'; } } diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index 856913f38d..01151b6ac6 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -759,7 +759,7 @@ export async function handleMessage( // Reuse the config already loaded during workflow discovery (avoids a second disk read). // Fall back to loadConfig only when no codebase is scoped (discoveredConfig is undefined). const config = discoveredConfig ?? (await loadConfig()); - const providerKey = conversation.ai_assistant_type as 'claude' | 'codex'; + const providerKey = conversation.ai_assistant_type as 'claude' | 'codex' | 'ollama'; let dbEnvVars: Record = {}; if (conversation.codebase_id) { try { diff --git a/packages/providers/package.json b/packages/providers/package.json index 2ef285486a..8aba31dd91 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -12,6 +12,8 @@ "./codex/provider": "./src/codex/provider.ts", "./codex/config": "./src/codex/config.ts", "./codex/binary-resolver": "./src/codex/binary-resolver.ts", + "./ollama/provider": "./src/ollama/provider.ts", + "./ollama/config": "./src/ollama/config.ts", "./errors": "./src/errors.ts", "./factory": "./src/factory.ts" }, diff --git a/packages/providers/src/factory.ts b/packages/providers/src/factory.ts index bcd15eb9b1..4f2c6f4590 100644 --- a/packages/providers/src/factory.ts +++ b/packages/providers/src/factory.ts @@ -2,18 +2,20 @@ * Agent Provider Factory * * Dynamic provider instantiation and static capability lookup. - * Built-in providers only: Claude and Codex. + * Built-in providers: Claude, Codex, and Ollama. */ import type { IAgentProvider, ProviderCapabilities } from './types'; import { ClaudeProvider } from './claude/provider'; import { CodexProvider } from './codex/provider'; +import { OllamaProvider } from './ollama/provider'; import { CLAUDE_CAPABILITIES } from './claude/capabilities'; import { CODEX_CAPABILITIES } from './codex/capabilities'; +import { OLLAMA_CAPABILITIES } from './ollama/capabilities'; import { UnknownProviderError } from './errors'; import { createLogger } from '@archon/paths'; /** Built-in provider types. */ -const REGISTERED_PROVIDERS = ['claude', 'codex'] as const; +const REGISTERED_PROVIDERS = ['claude', 'codex', 'ollama'] as const; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ let cachedLog: ReturnType | undefined; @@ -37,6 +39,9 @@ export function getAgentProvider(type: string): IAgentProvider { case 'codex': getLog().debug({ provider: 'codex' }, 'provider_selected'); return new CodexProvider(); + case 'ollama': + getLog().debug({ provider: 'ollama' }, 'provider_selected'); + return new OllamaProvider(); default: throw new UnknownProviderError(type, [...REGISTERED_PROVIDERS]); } @@ -52,6 +57,8 @@ export function getProviderCapabilities(type: string): ProviderCapabilities { return CLAUDE_CAPABILITIES; case 'codex': return CODEX_CAPABILITIES; + case 'ollama': + return OLLAMA_CAPABILITIES; default: throw new UnknownProviderError(type, [...REGISTERED_PROVIDERS]); } diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index 6bafb1da00..48fb8fc72d 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -23,10 +23,12 @@ export { UnknownProviderError } from './errors'; // Provider classes export { ClaudeProvider } from './claude/provider'; export { CodexProvider } from './codex/provider'; +export { OllamaProvider } from './ollama/provider'; // Config parsers export { parseClaudeConfig, type ClaudeProviderDefaults } from './claude/config'; export { parseCodexConfig, type CodexProviderDefaults } from './codex/config'; +export { parseOllamaConfig, type OllamaProviderDefaults } from './ollama/config'; // Utilities (needed by consumers) export { resetCodexSingleton } from './codex/provider'; diff --git a/packages/providers/src/ollama/capabilities.ts b/packages/providers/src/ollama/capabilities.ts new file mode 100644 index 0000000000..ca0fc6a737 --- /dev/null +++ b/packages/providers/src/ollama/capabilities.ts @@ -0,0 +1,22 @@ +import type { ProviderCapabilities } from '../types'; + +/** + * Ollama capability flags. + * Ollama runs locally via /api/chat — no session resume, MCP, hooks, or SDK-level + * tool restrictions. Structured output, cost control, effort, and sandbox are also + * unsupported at this time. + */ +export const OLLAMA_CAPABILITIES: ProviderCapabilities = { + sessionResume: false, + mcp: false, + hooks: false, + skills: false, + toolRestrictions: false, + structuredOutput: false, + envInjection: false, + costControl: false, + effortControl: false, + thinkingControl: false, + fallbackModel: false, + sandbox: false, +}; diff --git a/packages/providers/src/ollama/config.ts b/packages/providers/src/ollama/config.ts new file mode 100644 index 0000000000..8227e122e9 --- /dev/null +++ b/packages/providers/src/ollama/config.ts @@ -0,0 +1,26 @@ +/** + * Typed config parsing for Ollama provider defaults. + * Validates and narrows the opaque assistantConfig to typed fields. + */ +import type { OllamaProviderDefaults } from '../types'; + +// Re-export so consumers can import the type from either location +export type { OllamaProviderDefaults } from '../types'; + +/** + * Parse raw assistantConfig into typed Ollama defaults. + * Defensive: invalid fields are silently dropped. + */ +export function parseOllamaConfig(raw: Record): OllamaProviderDefaults { + const result: OllamaProviderDefaults = {}; + + if (typeof raw.model === 'string') { + result.model = raw.model; + } + + if (typeof raw.baseUrl === 'string') { + result.baseUrl = raw.baseUrl; + } + + return result; +} diff --git a/packages/providers/src/ollama/index.ts b/packages/providers/src/ollama/index.ts new file mode 100644 index 0000000000..ea4128b04a --- /dev/null +++ b/packages/providers/src/ollama/index.ts @@ -0,0 +1,2 @@ +export { OllamaProvider } from './provider'; +export { parseOllamaConfig, type OllamaProviderDefaults } from './config'; diff --git a/packages/providers/src/ollama/provider.ts b/packages/providers/src/ollama/provider.ts new file mode 100644 index 0000000000..a6a50dc999 --- /dev/null +++ b/packages/providers/src/ollama/provider.ts @@ -0,0 +1,183 @@ +/** + * Ollama local LLM provider. + * + * POSTs to the Ollama /api/chat endpoint with `stream: true` and reads + * the response as newline-delimited JSON. Each line is parsed as an + * OllamaChatChunk; content deltas are yielded as MessageChunks until + * the server sends `done: true` with final token counts. + * + * Of the SendQueryOptions fields, `model`, `systemPrompt`, and + * `abortSignal` are forwarded to the /api/chat payload. Provider-specific + * options (model default, baseUrl) flow through `assistantConfig`. + * + * Extending Ollama's agentic footprint — running local models as domain-expert + * nodes in multi-step workflows, cross-domain consults, and offline-capable + * pipeline steps — is a natural next direction for this integration. + */ +import type { + IAgentProvider, + SendQueryOptions, + MessageChunk, + ProviderCapabilities, +} from '../types'; +import { parseOllamaConfig } from './config'; +import { OLLAMA_CAPABILITIES } from './capabilities'; +import { createLogger } from '@archon/paths'; + +/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('provider.ollama'); + return cachedLog; +} + +const DEFAULT_BASE_URL = 'http://localhost:11434'; +const CHAT_PATH = '/api/chat'; + +/** Shape of each streamed NDJSON chunk from Ollama /api/chat */ +interface OllamaChatChunk { + model: string; + message?: { role: string; content: string }; + done: boolean; + done_reason?: string; + prompt_eval_count?: number; + eval_count?: number; +} + +/** + * Ollama agent provider. + * Implements IAgentProvider via the Ollama REST API. + */ +export class OllamaProvider implements IAgentProvider { + private readonly baseUrl: string; + + constructor() { + this.baseUrl = process.env.OLLAMA_BASE_URL ?? DEFAULT_BASE_URL; + } + + /** + * Send a prompt to Ollama and stream the response as MessageChunks. + * Model is required — resolved from options.model, then assistantConfig.model. + * baseUrl is resolved from assistantConfig.baseUrl, then OLLAMA_BASE_URL env, then default. + */ + async *sendQuery( + prompt: string, + _cwd: string, + _resumeSessionId?: string, + options?: SendQueryOptions + ): AsyncGenerator { + const assistantCfg = parseOllamaConfig(options?.assistantConfig ?? {}); + const model = options?.model ?? assistantCfg.model; + + if (!model) { + throw new Error( + 'Ollama requires a model to be specified. ' + + 'Set `model` in your workflow or .archon/config.yaml assistants.ollama.model.' + ); + } + + const messages: { role: string; content: string }[] = []; + if (options?.systemPrompt && typeof options.systemPrompt === 'string') { + messages.push({ role: 'system', content: options.systemPrompt }); + } + messages.push({ role: 'user', content: prompt }); + + const baseUrl = assistantCfg.baseUrl ?? this.baseUrl; + const url = `${baseUrl}${CHAT_PATH}`; + getLog().info({ model, url, messageCount: messages.length }, 'ollama.query_started'); + + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model, messages, stream: true }), + signal: options?.abortSignal, + }); + } catch (err) { + const error = err as Error; + if (error.name === 'AbortError') throw new Error('Query aborted'); + throw new Error( + `Ollama connection failed at ${url}: ${error.message}. ` + + 'Is Ollama running? Try: ollama serve' + ); + } + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error( + `Ollama API error ${response.status} ${response.statusText}${body ? `: ${body}` : ''}` + ); + } + + if (!response.body) { + throw new Error('Ollama API returned no response body'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let inputTokens = 0; + let outputTokens = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + // Keep incomplete last line in buffer + buffer = lines.pop() ?? ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + let chunk: OllamaChatChunk; + try { + chunk = JSON.parse(trimmed) as OllamaChatChunk; + } catch { + getLog().warn({ line: trimmed }, 'ollama.unparseable_chunk'); + continue; + } + + if (!chunk.done) { + const content = chunk.message?.content ?? ''; + if (content) { + yield { type: 'assistant', content }; + } + } else { + inputTokens = chunk.prompt_eval_count ?? 0; + outputTokens = chunk.eval_count ?? 0; + getLog().info( + { model, inputTokens, outputTokens, doneReason: chunk.done_reason }, + 'ollama.query_completed' + ); + } + } + } + } finally { + reader.releaseLock(); + } + + yield { + type: 'result', + ...(inputTokens || outputTokens + ? { + tokens: { input: inputTokens, output: outputTokens, total: inputTokens + outputTokens }, + } + : {}), + }; + } + + /** Returns the assistant type identifier used by the factory and config. */ + getType(): string { + return 'ollama'; + } + + /** Returns Ollama's capability flags. */ + getCapabilities(): ProviderCapabilities { + return OLLAMA_CAPABILITIES; + } +} diff --git a/packages/providers/src/types.ts b/packages/providers/src/types.ts index e0f196a500..09160bd192 100644 --- a/packages/providers/src/types.ts +++ b/packages/providers/src/types.ts @@ -6,6 +6,13 @@ // Canonical definitions — @archon/core/config/config-types.ts imports from here. // Single source of truth for provider-specific config shapes. +export interface OllamaProviderDefaults { + model?: string; + /** Ollama server base URL. Overrides OLLAMA_BASE_URL env var. + * @default 'http://localhost:11434' */ + baseUrl?: string; +} + export interface ClaudeProviderDefaults { model?: string; /** Claude Code settingSources — controls which CLAUDE.md files are loaded. diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index 4bc814f685..0e825e59bd 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -118,6 +118,7 @@ import { updateAssistantConfigResponseSchema, configResponseSchema, codebaseEnvironmentsResponseSchema, + ollamaModelsResponseSchema, } from './schemas/config.schemas'; // Read app version: use build-time constant in binary, package.json in dev @@ -771,6 +772,20 @@ const patchAssistantConfigRoute = createRoute({ }, }); +const getOllamaModelsRoute = createRoute({ + method: 'get', + path: '/api/ollama/models', + tags: ['System'], + summary: 'List available Ollama models from the configured Ollama server', + responses: { + 200: { + content: { 'application/json': { schema: ollamaModelsResponseSchema } }, + description: 'List of available Ollama model names', + }, + 500: jsonError('Server error or Ollama unreachable'), + }, +}); + const getCodebaseEnvironmentsRoute = createRoute({ method: 'get', path: '/api/codebases/{id}/environments', @@ -2449,10 +2464,11 @@ export function registerApiRoutes( if (body.assistant !== undefined) { updates.defaultAssistant = body.assistant; } - if (body.claude !== undefined || body.codex !== undefined) { + if (body.claude !== undefined || body.codex !== undefined || body.ollama !== undefined) { updates.assistants = { ...(body.claude ? { claude: body.claude } : {}), ...(body.codex ? { codex: body.codex } : {}), + ...(body.ollama ? { ollama: body.ollama } : {}), }; } @@ -2469,6 +2485,33 @@ export function registerApiRoutes( } }); + // GET /api/ollama/models - List available models from the Ollama server + registerOpenApiRoute(getOllamaModelsRoute, async c => { + try { + const config = await loadConfig(); + const baseUrl = + config.assistants.ollama?.baseUrl ?? + process.env.OLLAMA_BASE_URL ?? + 'http://localhost:11434'; + const response = await fetch(`${baseUrl}/api/tags`); + if (!response.ok) { + getLog().warn({ baseUrl, status: response.status }, 'ollama.models_fetch_failed'); + return c.json({ models: [], baseUrl }); + } + const data = (await response.json()) as { models?: { name: string }[] }; + const models = (data.models ?? []).map(m => m.name); + return c.json({ models, baseUrl }); + } catch (error) { + getLog().warn({ err: error }, 'ollama.models_fetch_error'); + const config = await loadConfig().catch(() => null); + const baseUrl = + config?.assistants.ollama?.baseUrl ?? + process.env.OLLAMA_BASE_URL ?? + 'http://localhost:11434'; + return c.json({ models: [], baseUrl }); + } + }); + // GET /api/codebases/:id/environments - List isolation environments for a codebase registerOpenApiRoute(getCodebaseEnvironmentsRoute, async c => { try { diff --git a/packages/server/src/routes/schemas/config.schemas.ts b/packages/server/src/routes/schemas/config.schemas.ts index d3ba003366..99b4b6da73 100644 --- a/packages/server/src/routes/schemas/config.schemas.ts +++ b/packages/server/src/routes/schemas/config.schemas.ts @@ -7,7 +7,8 @@ import { z } from '@hono/zod-openapi'; export const safeConfigSchema = z .object({ botName: z.string(), - assistant: z.enum(['claude', 'codex']), + assistant: z.enum(['claude', 'codex', 'ollama']), + availableAssistants: z.array(z.enum(['claude', 'codex', 'ollama'])), assistants: z.object({ claude: z.object({ model: z.string().optional() }), codex: z.object({ @@ -15,6 +16,10 @@ export const safeConfigSchema = z modelReasoningEffort: z.enum(['minimal', 'low', 'medium', 'high', 'xhigh']).optional(), webSearchMode: z.enum(['disabled', 'cached', 'live']).optional(), }), + ollama: z.object({ + model: z.string().optional(), + baseUrl: z.string().optional(), + }), }), streaming: z.object({ telegram: z.enum(['stream', 'batch']), @@ -34,7 +39,7 @@ export const safeConfigSchema = z /** Body for PATCH /api/config/assistants — all fields optional (partial update). */ export const updateAssistantConfigBodySchema = z .object({ - assistant: z.enum(['claude', 'codex']).optional(), + assistant: z.enum(['claude', 'codex', 'ollama']).optional(), claude: z .object({ model: z.string(), @@ -47,6 +52,12 @@ export const updateAssistantConfigBodySchema = z webSearchMode: z.enum(['disabled', 'cached', 'live']).optional(), }) .optional(), + ollama: z + .object({ + model: z.string().optional(), + baseUrl: z.string().optional(), + }) + .optional(), }) .openapi('UpdateAssistantConfigBody'); @@ -81,3 +92,11 @@ export const codebaseEnvironmentsResponseSchema = z environments: z.array(isolationEnvironmentSchema), }) .openapi('CodebaseEnvironmentsResponse'); + +/** Response for GET /api/ollama/models — list of available Ollama model names. */ +export const ollamaModelsResponseSchema = z + .object({ + models: z.array(z.string()), + baseUrl: z.string(), + }) + .openapi('OllamaModelsResponse'); diff --git a/packages/web/src/components/layout/TopNav.tsx b/packages/web/src/components/layout/TopNav.tsx index 45924f5004..3b5c3812e1 100644 --- a/packages/web/src/components/layout/TopNav.tsx +++ b/packages/web/src/components/layout/TopNav.tsx @@ -1,7 +1,7 @@ import { NavLink, Link } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { LayoutDashboard, MessageSquare, Workflow, Settings } from 'lucide-react'; -import { listWorkflowRuns, getUpdateCheck } from '@/lib/api'; +import { listWorkflowRuns, getUpdateCheck, getConfig } from '@/lib/api'; import { cn } from '@/lib/utils'; const tabs = [ @@ -19,6 +19,19 @@ export function TopNav(): React.ReactElement { }); const hasRunning = (runningRuns?.length ?? 0) > 0; + const { data: configData } = useQuery({ + queryKey: ['config'], + queryFn: getConfig, + staleTime: 30_000, + }); + + const modelLabel = ((): string | null => { + if (!configData) return null; + const { assistant, assistants } = configData.config; + const model = assistants[assistant]?.model; + return model ? `${assistant} · ${model}` : assistant; + })(); + const { data: updateCheck } = useQuery({ queryKey: ['update-check'], queryFn: getUpdateCheck, @@ -58,7 +71,16 @@ export function TopNav(): React.ReactElement { )} ))} - + {modelLabel && ( + + + {modelLabel} + + )} + v{import.meta.env.VITE_APP_VERSION as string} {updateCheck?.updateAvailable && updateCheck.releaseUrl && ( { + return fetchJSON('/api/ollama/models'); +} + export type IsolationEnvironment = components['schemas']['IsolationEnvironment']; export async function getCodebaseEnvironments(codebaseId: string): Promise { diff --git a/packages/web/src/routes/SettingsPage.tsx b/packages/web/src/routes/SettingsPage.tsx index 0b9c7b6e60..109a8dbed3 100644 --- a/packages/web/src/routes/SettingsPage.tsx +++ b/packages/web/src/routes/SettingsPage.tsx @@ -12,6 +12,7 @@ import { addCodebase, deleteCodebase, updateAssistantConfig, + getOllamaModels, getCodebaseEnvVars, setCodebaseEnvVar, deleteCodebaseEnvVar, @@ -380,9 +381,23 @@ function ProjectsSection(): React.ReactElement { ); } +/** Provider labels shown in the assistant dropdown. */ +const ASSISTANT_LABELS: Record<'claude' | 'codex' | 'ollama', string> = { + claude: 'Claude', + codex: 'Codex', + ollama: 'Ollama', +}; + +/** Claude model options. */ +const CLAUDE_MODELS = ['sonnet', 'opus', 'haiku'] as const; + function AssistantConfigSection({ config }: { config: SafeConfigResponse }): React.ReactElement { const queryClient = useQueryClient(); + + // Default provider const [assistant, setAssistant] = useState(config.assistant); + + // Per-provider settings const [claudeModel, setClaudeModel] = useState(config.assistants.claude.model ?? 'sonnet'); const [codexModel, setCodexModel] = useState(config.assistants.codex.model ?? ''); const [reasoning, setReasoning] = useState<'minimal' | 'low' | 'medium' | 'high' | 'xhigh'>( @@ -391,14 +406,35 @@ function AssistantConfigSection({ config }: { config: SafeConfigResponse }): Rea const [webSearch, setWebSearch] = useState<'disabled' | 'cached' | 'live'>( config.assistants.codex.webSearchMode ?? 'disabled' ); + const [ollamaModel, setOllamaModel] = useState(config.assistants.ollama?.model ?? ''); + const [ollamaBaseUrl, setOllamaBaseUrl] = useState( + config.assistants.ollama?.baseUrl ?? 'http://localhost:11434' + ); + + // Section enable/disable (local UI only — controls whether inputs are editable) + const [claudeEnabled, setClaudeEnabled] = useState(true); + const [codexEnabled, setCodexEnabled] = useState(true); + const [ollamaEnabled, setOllamaEnabled] = useState(true); + const [saveMsg, setSaveMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + /** Fetch installed Ollama models automatically when Ollama is the selected provider. */ + const { data: ollamaData } = useQuery({ + queryKey: ['ollamaModels'], + queryFn: getOllamaModels, + staleTime: 30_000, + enabled: assistant === 'ollama', + }); + const ollamaModels = ollamaData?.models ?? []; + const hasChanges = assistant !== config.assistant || claudeModel !== (config.assistants.claude.model ?? 'sonnet') || codexModel !== (config.assistants.codex.model ?? '') || reasoning !== (config.assistants.codex.modelReasoningEffort ?? 'medium') || - webSearch !== (config.assistants.codex.webSearchMode ?? 'disabled'); + webSearch !== (config.assistants.codex.webSearchMode ?? 'disabled') || + ollamaModel !== (config.assistants.ollama?.model ?? '') || + ollamaBaseUrl !== (config.assistants.ollama?.baseUrl ?? 'http://localhost:11434'); useEffect(() => { setAssistant(config.assistant); @@ -406,6 +442,8 @@ function AssistantConfigSection({ config }: { config: SafeConfigResponse }): Rea setCodexModel(config.assistants.codex.model ?? ''); setReasoning(config.assistants.codex.modelReasoningEffort ?? 'medium'); setWebSearch(config.assistants.codex.webSearchMode ?? 'disabled'); + setOllamaModel(config.assistants.ollama?.model ?? ''); + setOllamaBaseUrl(config.assistants.ollama?.baseUrl ?? 'http://localhost:11434'); }, [config]); const mutation = useMutation({ @@ -426,92 +464,266 @@ function AssistantConfigSection({ config }: { config: SafeConfigResponse }): Rea mutation.mutate({ assistant, claude: { model: claudeModel }, - // The generated type requires `model` when `codex` is present; omit the codex key - // entirely when no model is set so the server treats it as "no codex changes". + // Omit codex key when no model is set — server treats absence as "no codex changes" ...(codexModel ? { codex: { model: codexModel, modelReasoningEffort: reasoning, webSearchMode: webSearch }, } : {}), + ollama: { + ...(ollamaModel ? { model: ollamaModel } : {}), + ...(ollamaBaseUrl ? { baseUrl: ollamaBaseUrl } : {}), + }, }); } + const available = config.availableAssistants; + return ( Assistant Configuration -
+
+ {/* ── Default provider + contextual model ── */}
- + - - + {assistant === 'claude' && ( + <> + + + + )} - - { - setCodexModel(e.target.value); - }} - placeholder="gpt-5.3-codex" - /> + {assistant === 'codex' && ( + <> + + { + setCodexModel(e.target.value); + }} + placeholder="gpt-5.3-codex" + /> + + )} - - + {assistant === 'ollama' && ( + <> + + {ollamaModels.length > 0 ? ( + + ) : ( + { + setOllamaModel(e.target.value); + }} + placeholder={ollamaData ? 'No models found — type a name' : 'gemma4:latest'} + /> + )} + + )} +
- - + {/* ── Claude section ── */} +
+ + {claudeEnabled && ( +
+ + +
+ )} +
+ + {/* ── Codex section ── */} +
+ + {codexEnabled && ( +
+ + { + setCodexModel(e.target.value); + }} + placeholder="gpt-5.3-codex" + /> + + + + + + +
+ )} +
+ + {/* ── Ollama section ── */} +
+ + {ollamaEnabled && ( +
+ + { + setOllamaBaseUrl(e.target.value); + }} + placeholder="http://localhost:11434" + /> + + + {ollamaModels.length > 0 ? ( + + ) : ( + { + setOllamaModel(e.target.value); + }} + placeholder="gemma4:latest" + /> + )} +
+ )}
+ {/* ── Save ── */}