From 6c6bb49573fa99307a1cb3e1d32f10fda83339e1 Mon Sep 17 00:00:00 2001 From: yanalialiuk Date: Mon, 6 Jul 2026 11:57:57 +0300 Subject: [PATCH] feat(providers): add Atomic Chat local OpenAI-compatible provider Add first-class Atomic Chat support with default http://127.0.0.1:1337/v1, /v1/models discovery, doctor setup flow, and docs alongside Ollama Local. Co-authored-by: Cursor --- src/index.ts | 70 ++++++++++++++++++++ src/providers/registry.ts | 3 +- src/utils/config.ts | 11 ++- src/utils/provider-models.test.ts | 10 +++ src/utils/provider-models.ts | 9 ++- src/web/api/providers.ts | 2 +- website/docs/getting-started/setup.mdx | 2 +- website/docs/reference/provider-fallback.mdx | 3 +- website/src/pages/index.tsx | 1 + 9 files changed, 103 insertions(+), 8 deletions(-) diff --git a/src/index.ts b/src/index.ts index fc7d63f3..2567093c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -149,6 +149,7 @@ const PROVIDER_OPTIONS: Array<{ key: ProviderName; label: string }> = [ { key: 'grok', label: 'Grok (xAI)' }, { key: 'ollamaCloud', label: 'Ollama Cloud' }, { key: 'ollamaLocal', label: 'Ollama Local' }, + { key: 'atomicChat', label: 'Atomic Chat' }, { key: 'openaiCompat', label: 'OpenAI Compilations' }, { key: 'mimo', label: 'MiMo (Xiaomi)' }, { key: 'mimoTokenPlan', label: 'MiMo Token Plan (Xiaomi)' }, @@ -500,6 +501,65 @@ async function promptOllamaLocalModelSelection(config: MercuryConfig, isReconfig } } +async function promptAtomicChatModelSelection(config: MercuryConfig, isReconfig: boolean): Promise<{ baseUrl?: string; model?: string; skipped: boolean }> { + const existingConfig = config.providers.atomicChat; + const defaultBaseUrl = existingConfig.baseUrl || 'http://127.0.0.1:1337/v1'; + + const baseUrlPrompt = isReconfig && existingConfig.baseUrl + ? chalk.white(` Atomic Chat base URL [${existingConfig.baseUrl}]: `) + : chalk.white(` Atomic Chat base URL [${defaultBaseUrl}]: `); + const baseUrlInput = await ask(baseUrlPrompt); + if (baseUrlInput.toLowerCase() === 'none') { + if (isReconfig && existingConfig.baseUrl) { + return { baseUrl: existingConfig.baseUrl, model: existingConfig.model, skipped: true }; + } + return { skipped: true }; + } + const baseUrl = baseUrlInput || defaultBaseUrl; + const baseUrlError = validateBaseUrl(baseUrl); + if (baseUrlError) { + console.log(chalk.red(` ${baseUrlError}`)); + if (isReconfig && existingConfig.baseUrl) { + return { baseUrl: existingConfig.baseUrl, model: existingConfig.model, skipped: true }; + } + return { skipped: true }; + } + + console.log(chalk.dim(' Fetching Atomic Chat models...')); + try { + const catalog = await fetchProviderModelCatalog('atomicChat', { + ...existingConfig, + baseUrl, + }); + const model = await chooseProviderModel( + 'Atomic Chat', + catalog.recommendedModel, + catalog.models, + ); + return { baseUrl, model, skipped: false }; + } catch (error) { + const message = error instanceof ProviderModelFetchError + ? error.message + : 'Mercury could not fetch Atomic Chat models.'; + console.log(chalk.yellow(` ${message}`)); + console.log(chalk.dim(' Make sure Atomic Chat is running and a model is loaded, or enter the model id manually.')); + console.log(chalk.dim(' You can run `mercury doctor` later after starting Atomic Chat.')); + + const manualModel = await ask(chalk.white(' Atomic Chat model id (Enter to skip Atomic Chat for now): ')); + if (!manualModel) { + return { skipped: true }; + } + + const modelError = validateModelName(manualModel); + if (modelError) { + console.log(chalk.red(` ${modelError}`)); + return { skipped: true }; + } + + return { baseUrl, model: manualModel, skipped: false }; + } +} + async function promptOpenAICompatSetup(config: MercuryConfig, isReconfig: boolean): Promise<{ baseUrl?: string; apiKey?: string; model?: string; skipped: boolean }> { const existingConfig = config.providers.openaiCompat; @@ -1281,6 +1341,16 @@ async function configure(existingConfig?: MercuryConfig): Promise { continue; } + if (provider === 'atomicChat') { + const result = await promptAtomicChatModelSelection(config, isReconfig); + if (!result.skipped && result.baseUrl && result.model) { + config.providers.atomicChat.baseUrl = result.baseUrl; + config.providers.atomicChat.model = result.model; + config.providers.atomicChat.enabled = true; + } + continue; + } + if (provider === 'openaiCompat') { const result = await promptOpenAICompatSetup(config, isReconfig); if (!result.skipped && result.baseUrl && result.model) { diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 2de70f09..2221c5d3 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -10,7 +10,7 @@ async function createProvider(pc: ProviderConfig): Promise { } else if (pc.name === 'deepseek') { const { DeepSeekProvider } = await import('./deepseek.js'); return new DeepSeekProvider(pc); - } else if (pc.name === 'ollamaLocal') { + } else if (pc.name === 'ollamaLocal' || pc.name === 'atomicChat') { // Route through OpenAI-compatible provider — local Ollama exposes // /v1/chat/completions since v0.1.14. The ollama-ai-provider package // declares specificationVersion = "v1" which is incompatible with @@ -55,6 +55,7 @@ export class ProviderRegistry { config.providers.grok, config.providers.ollamaCloud, config.providers.ollamaLocal, + config.providers.atomicChat, config.providers.openaiCompat, config.providers.mimo, config.providers.mimoTokenPlan, diff --git a/src/utils/config.ts b/src/utils/config.ts index f7ce2b49..441956dc 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -54,6 +54,7 @@ export type ProviderName = | 'grok' | 'ollamaCloud' | 'ollamaLocal' + | 'atomicChat' | 'openaiCompat' | 'mimo' | 'mimoTokenPlan' @@ -74,6 +75,7 @@ export interface MercuryConfig { grok: ProviderConfig; ollamaCloud: ProviderConfig; ollamaLocal: ProviderConfig; + atomicChat: ProviderConfig; openaiCompat: ProviderConfig; mimo: ProviderConfig; mimoTokenPlan: ProviderConfig; @@ -246,6 +248,13 @@ export function getDefaultConfig(): MercuryConfig { model: getEnv('OLLAMA_LOCAL_MODEL', ''), enabled: getEnvBool('OLLAMA_LOCAL_ENABLED', false), }, + atomicChat: { + name: 'atomicChat', + apiKey: '', + baseUrl: getEnv('ATOMIC_CHAT_BASE_URL', 'http://127.0.0.1:1337/v1'), + model: getEnv('ATOMIC_CHAT_MODEL', ''), + enabled: getEnvBool('ATOMIC_CHAT_ENABLED', false), + }, openaiCompat: { name: 'openaiCompat', apiKey: getEnv('OPENAI_COMPAT_API_KEY', ''), @@ -446,7 +455,7 @@ export function getActiveProviders(config: MercuryConfig): ProviderConfig[] { export function isProviderConfigured(provider: ProviderConfig): boolean { if (!provider.enabled) return false; - if (provider.name === 'ollamaLocal') { + if (provider.name === 'ollamaLocal' || provider.name === 'atomicChat') { return provider.baseUrl.length > 0 && provider.model.length > 0; } if (provider.name === 'ollamaCloud') { diff --git a/src/utils/provider-models.test.ts b/src/utils/provider-models.test.ts index 74a464b5..a64dbc3e 100644 --- a/src/utils/provider-models.test.ts +++ b/src/utils/provider-models.test.ts @@ -116,4 +116,14 @@ describe('buildModelCatalog', () => { expect(catalog.recommendedModel).toBe('model-a'); }); + + it('uses the first model as recommended for Atomic Chat when no preferred list exists', () => { + const catalog = buildModelCatalog('atomicChat', [ + 'ornith-9b', + 'qwen3-coder', + ]); + + expect(catalog.recommendedModel).toBe('ornith-9b'); + expect(catalog.models).toContain('qwen3-coder'); + }); }); diff --git a/src/utils/provider-models.ts b/src/utils/provider-models.ts index 3a50d38f..2dff4023 100644 --- a/src/utils/provider-models.ts +++ b/src/utils/provider-models.ts @@ -72,6 +72,7 @@ const MIMO_PREFERRED_MODELS = [ const MIMO_TOKEN_PLAN_PREFERRED_MODELS = MIMO_PREFERRED_MODELS; const OPENAI_COMPAT_PREFERRED_MODELS = [] as const; +const ATOMIC_CHAT_PREFERRED_MODELS = [] as const; const CHATGPT_WEB_PREFERRED_MODELS = [ 'gpt-5.5', @@ -195,6 +196,7 @@ function chooseRecommendedModel( grok: GROK_PREFERRED_MODELS, ollamaCloud: OLLAMA_CLOUD_PREFERRED_MODELS, ollamaLocal: OLLAMA_LOCAL_PREFERRED_MODELS, + atomicChat: ATOMIC_CHAT_PREFERRED_MODELS, openaiCompat: OPENAI_COMPAT_PREFERRED_MODELS, mimo: MIMO_PREFERRED_MODELS, mimoTokenPlan: MIMO_TOKEN_PLAN_PREFERRED_MODELS, @@ -233,6 +235,7 @@ export function buildModelCatalog( grok: GROK_PREFERRED_MODELS, ollamaCloud: OLLAMA_CLOUD_PREFERRED_MODELS, ollamaLocal: OLLAMA_LOCAL_PREFERRED_MODELS, + atomicChat: ATOMIC_CHAT_PREFERRED_MODELS, openaiCompat: OPENAI_COMPAT_PREFERRED_MODELS, mimo: MIMO_PREFERRED_MODELS, mimoTokenPlan: MIMO_TOKEN_PLAN_PREFERRED_MODELS, @@ -260,7 +263,7 @@ async function fetchOpenAICompatModels(provider: ProviderName, config: ProviderC errorMessage = 'Mercury could not fetch models for this Grok key. Please re-enter it.'; } else if (provider === 'deepseek') { errorMessage = 'Mercury could not fetch models for this DeepSeek key. Please re-enter it.'; - } else if (provider === 'openaiCompat') { + } else if (provider === 'openaiCompat' || provider === 'atomicChat') { errorMessage = 'Mercury could not fetch models from this server. Please check the base URL and try again.'; } else { errorMessage = 'Mercury could not fetch models for this OpenAI key. Please re-enter it.'; @@ -278,7 +281,7 @@ async function fetchOpenAICompatModels(provider: ProviderName, config: ProviderC if (provider === 'deepseek') { return id.startsWith('deepseek-'); } - if (provider === 'openaiCompat') { + if (provider === 'openaiCompat' || provider === 'atomicChat') { return id.length > 0; } return isOpenAIChatModel(id); @@ -419,7 +422,7 @@ export async function fetchProviderModelCatalog( return fetchOllamaLocalModels(config); } - if (provider === 'openaiCompat') { + if (provider === 'openaiCompat' || provider === 'atomicChat') { return fetchOpenAICompatModels(provider, config); } diff --git a/src/web/api/providers.ts b/src/web/api/providers.ts index 8a9d3006..a8c37223 100644 --- a/src/web/api/providers.ts +++ b/src/web/api/providers.ts @@ -26,7 +26,7 @@ providers.post('/api/providers/:name', async (c) => { const body = await c.req.json(); const config = loadConfig(); - const validNames: ProviderName[] = ['openai', 'anthropic', 'deepseek', 'grok', 'ollamaCloud', 'ollamaLocal']; + const validNames: ProviderName[] = ['openai', 'anthropic', 'deepseek', 'grok', 'ollamaCloud', 'ollamaLocal', 'atomicChat']; if (!validNames.includes(providerName)) { return c.json({ error: 'Unknown provider' }, 400); } diff --git a/website/docs/getting-started/setup.mdx b/website/docs/getting-started/setup.mdx index 295d82e0..47e4f46b 100644 --- a/website/docs/getting-started/setup.mdx +++ b/website/docs/getting-started/setup.mdx @@ -7,7 +7,7 @@ Run `mercury` for the first time. The onboarding wizard asks for: - **Your name** — used by the agent to address you - **Agent name** — defaults to "Mercury", customize it -- **LLM providers** — choose one or more: DeepSeek, OpenAI, Anthropic, Grok, Ollama Cloud, Ollama Local, or OpenAI Compatible +- **LLM providers** — choose one or more: DeepSeek, OpenAI, Anthropic, Grok, Ollama Cloud, Ollama Local, Atomic Chat, or OpenAI Compatible - **Model selection** — after each valid key, Mercury fetches recommended chat models and lets you choose a default - **Telegram bot token** — optional, for remote access in private 1:1 chats diff --git a/website/docs/reference/provider-fallback.mdx b/website/docs/reference/provider-fallback.mdx index fc592a68..b34765e7 100644 --- a/website/docs/reference/provider-fallback.mdx +++ b/website/docs/reference/provider-fallback.mdx @@ -27,6 +27,7 @@ This is a session-level switch for the running process. Use `mercury doctor` to | **Grok (xAI)** | `grok-4` | `GROK_API_KEY` | OpenAI-compatible endpoint | | **Ollama Cloud** | `gpt-oss:120b` | `OLLAMA_CLOUD_API_KEY` | Remote Ollama models via API | | **Ollama Local** | `gpt-oss:20b` | No key needed | Local Ollama instance (127.0.0.1:11434) | +| **Atomic Chat** | Configurable | No key needed | Local Atomic Chat OpenAI API (127.0.0.1:1337/v1) | | **OpenAI Compatible** | Configurable | `OPENAI_COMPAT_API_KEY` | Any OpenAI-compatible server | ### OAuth Providers (No API Key Needed) @@ -128,6 +129,6 @@ You can also set environment variables directly or edit `~/.mercury/mercury.yaml :::tip[Environment variables] -Each provider has env vars for its API key, base URL, and model. For example, DeepSeek uses `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`, and `DEEPSEEK_MODEL`. Ollama Local requires no API key — just set `OLLAMA_LOCAL_ENABLED=true` and `OLLAMA_LOCAL_MODEL` to your preferred local model. +Each provider has env vars for its API key, base URL, and model. For example, DeepSeek uses `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`, and `DEEPSEEK_MODEL`. Ollama Local requires no API key — just set `OLLAMA_LOCAL_ENABLED=true` and `OLLAMA_LOCAL_MODEL` to your preferred local model. Atomic Chat uses `ATOMIC_CHAT_ENABLED=true`, `ATOMIC_CHAT_BASE_URL` (default `http://127.0.0.1:1337/v1`), and `ATOMIC_CHAT_MODEL`. ::: diff --git a/website/src/pages/index.tsx b/website/src/pages/index.tsx index 2c36c380..67b71616 100644 --- a/website/src/pages/index.tsx +++ b/website/src/pages/index.tsx @@ -877,6 +877,7 @@ export default function LandingPage(): React.ReactElement { { name: 'Grok (xAI)', desc: "xAI's models via OpenAI-compatible endpoint." }, { name: 'Ollama Cloud', desc: 'Remote Ollama models via API. No local setup.' }, { name: 'Ollama Local', desc: 'On your machine. Zero cost, fully private.' }, + { name: 'Atomic Chat', desc: 'Local models via Atomic Chat OpenAI-compatible API.' }, ].map((p, i) => (

{p.name} {(p as any).badge && {(p as any).badge}}