Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)' },
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -1281,6 +1341,16 @@ async function configure(existingConfig?: MercuryConfig): Promise<void> {
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) {
Expand Down
3 changes: 2 additions & 1 deletion src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ async function createProvider(pc: ProviderConfig): Promise<BaseProvider> {
} 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
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export type ProviderName =
| 'grok'
| 'ollamaCloud'
| 'ollamaLocal'
| 'atomicChat'
| 'openaiCompat'
| 'mimo'
| 'mimoTokenPlan'
Expand All @@ -74,6 +75,7 @@ export interface MercuryConfig {
grok: ProviderConfig;
ollamaCloud: ProviderConfig;
ollamaLocal: ProviderConfig;
atomicChat: ProviderConfig;
openaiCompat: ProviderConfig;
mimo: ProviderConfig;
mimoTokenPlan: ProviderConfig;
Expand Down Expand Up @@ -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', ''),
Expand Down Expand Up @@ -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') {
Expand Down
10 changes: 10 additions & 0 deletions src/utils/provider-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
9 changes: 6 additions & 3 deletions src/utils/provider-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.';
Expand All @@ -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);
Expand Down Expand Up @@ -419,7 +422,7 @@ export async function fetchProviderModelCatalog(
return fetchOllamaLocalModels(config);
}

if (provider === 'openaiCompat') {
if (provider === 'openaiCompat' || provider === 'atomicChat') {
return fetchOpenAICompatModels(provider, config);
}

Expand Down
2 changes: 1 addition & 1 deletion src/web/api/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion website/docs/getting-started/setup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion website/docs/reference/provider-fallback.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`.

:::
1 change: 1 addition & 0 deletions website/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<div key={i} className="lp-provider-card">
<h4>{p.name} {(p as any).badge && <span className="lp-provider-badge">{(p as any).badge}</span>}</h4>
Expand Down