diff --git a/core/llm/autodetect.ts b/core/llm/autodetect.ts index 736caebcc6e..82df165d4b6 100644 --- a/core/llm/autodetect.ts +++ b/core/llm/autodetect.ts @@ -44,6 +44,7 @@ import { } from "./templates/edit.js"; const PROVIDER_HANDLES_TEMPLATING: string[] = [ + "atomic-chat", "lmstudio", "lemonade", "openai", diff --git a/core/llm/llms/AtomicChat.ts b/core/llm/llms/AtomicChat.ts new file mode 100644 index 00000000000..363e13fd710 --- /dev/null +++ b/core/llm/llms/AtomicChat.ts @@ -0,0 +1,12 @@ +import { LLMOptions } from "../../index.js"; + +import OpenAI from "./OpenAI.js"; + +class AtomicChat extends OpenAI { + static providerName = "atomic-chat"; + static defaultOptions: Partial = { + apiBase: "http://127.0.0.1:1337/v1/", + }; +} + +export default AtomicChat; diff --git a/core/llm/llms/OpenAI-compatible.vitest.ts b/core/llm/llms/OpenAI-compatible.vitest.ts index 402fb7e7585..6b7ee2e4668 100644 --- a/core/llm/llms/OpenAI-compatible.vitest.ts +++ b/core/llm/llms/OpenAI-compatible.vitest.ts @@ -10,6 +10,7 @@ import OpenRouter from "./OpenRouter.js"; import xAI from "./xAI.js"; import Mistral from "./Mistral.js"; import Mimo from "./Mimo.js"; +import AtomicChat from "./AtomicChat.js"; import LMStudio from "./LMStudio.js"; import Cerebras from "./Cerebras.js"; import DeepInfra from "./DeepInfra.js"; @@ -306,6 +307,11 @@ createOpenAISubclassTests(Mimo, { defaultApiBase: "https://api.xiaomimimo.com/v1/", }); +createOpenAISubclassTests(AtomicChat, { + providerName: "atomic-chat", + defaultApiBase: "http://127.0.0.1:1337/v1/", +}); + createOpenAISubclassTests(LMStudio, { providerName: "lmstudio", defaultApiBase: "http://localhost:1234/v1/", diff --git a/core/llm/llms/index.ts b/core/llm/llms/index.ts index 453b2d90cd8..373c535f457 100644 --- a/core/llm/llms/index.ts +++ b/core/llm/llms/index.ts @@ -10,6 +10,7 @@ import { import { renderTemplatedString } from "../../util/handlebars/renderTemplatedString"; import { BaseLLM } from "../index"; import Anthropic from "./Anthropic"; +import AtomicChat from "./AtomicChat"; import Asksage from "./Asksage"; import Azure from "./Azure"; import Bedrock from "./Bedrock"; @@ -73,6 +74,7 @@ import xAI from "./xAI"; import zAI from "./zAI"; export const LLMClasses = [ Anthropic, + AtomicChat, Cohere, CometAPI, FunctionNetwork, diff --git a/docs/customize/model-providers/more/atomic-chat.mdx b/docs/customize/model-providers/more/atomic-chat.mdx new file mode 100644 index 00000000000..58032af3b71 --- /dev/null +++ b/docs/customize/model-providers/more/atomic-chat.mdx @@ -0,0 +1,71 @@ +--- +title: "Atomic Chat" +description: "Configure Atomic Chat with Continue for local LLM inference through an OpenAI-compatible API" +--- + + + Get started with [Atomic Chat](https://atomic.chat/) — a desktop app that serves local models at `http://127.0.0.1:1337/v1` + + +## Overview + +Atomic Chat runs open-source models on your machine and exposes an OpenAI-compatible HTTP API. Continue connects to that endpoint for chat, edit, and agent workflows. + +## Configuration + +### Option 1: Using the Continue UI (Recommended) + +1. Open the model selector in Continue +2. Choose **Add Model** +3. Select **Atomic Chat** from the provider list +4. Pick a model from the autodetected list (requires Atomic Chat to be running) + +### Option 2: Manual configuration + + + + ```yaml title="config.yaml" + name: My Config + version: 0.0.1 + schema: v1 + + models: + - name: Atomic Chat + provider: atomic-chat + model: + apiBase: http://127.0.0.1:1337/v1/ + ``` + + + ```json title="config.json" + { + "models": [ + { + "title": "Atomic Chat", + "provider": "atomic-chat", + "model": "", + "apiBase": "http://127.0.0.1:1337/v1/" + } + ] + } + ``` + + + +Replace `` with an id from Atomic Chat. List available models: + +```bash +curl -s http://127.0.0.1:1337/v1/models | jq '.data[].id' +``` + +## Getting started + +1. Install and open [Atomic Chat](https://atomic.chat/) +2. Load a model in the app so the API is listening on port **1337** (or **1338** if you changed the port) +3. Add **Atomic Chat** as a provider in Continue and select your model + +## Tool calling + +For agent mode and tools, prefer models with strong tool-calling support (for example Qwen-Coder or DeepSeek-Coder variants). + +[View the source](https://github.com/continuedev/continue/blob/main/core/llm/llms/AtomicChat.ts) diff --git a/gui/public/logos/atomic-chat.png b/gui/public/logos/atomic-chat.png new file mode 100644 index 00000000000..5938b12376f Binary files /dev/null and b/gui/public/logos/atomic-chat.png differ diff --git a/gui/src/forms/AddModelForm.tsx b/gui/src/forms/AddModelForm.tsx index 7041d2c0814..465b3eb714b 100644 --- a/gui/src/forms/AddModelForm.tsx +++ b/gui/src/forms/AddModelForm.tsx @@ -2,7 +2,7 @@ import { ArrowPathIcon, ArrowTopRightOnSquareIcon, } from "@heroicons/react/24/outline"; -import { useCallback, useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { Button, Input, StyledActionButton } from "../components"; import Alert from "../components/gui/Alert"; @@ -30,6 +30,7 @@ const MODEL_PROVIDERS_URL = "https://docs.continue.dev/customize/model-providers"; const CODESTRAL_URL = "https://console.mistral.ai/codestral"; const CONTINUE_SETUP_URL = "https://docs.continue.dev/setup/overview"; +const LOCAL_DYNAMIC_MODEL_PROVIDERS = ["atomic-chat"]; export function AddModelForm({ onDone, @@ -49,6 +50,10 @@ export function AddModelForm({ [], ); const [isFetchingModels, setIsFetchingModels] = useState(false); + const selectedProviderRef = useRef(selectedProvider.provider); + const fetchGenerationRef = useRef(0); + + selectedProviderRef.current = selectedProvider.provider; useEffect(() => { void initializeDynamicModels(ideMessenger); @@ -56,25 +61,38 @@ export function AddModelForm({ useEffect(() => { setFetchedModelsList([]); + fetchGenerationRef.current += 1; }, [selectedProvider]); const handleFetchModels = useCallback(async () => { const apiKey = formMethods.watch("apiKey"); const apiBase = formMethods.watch("apiBase"); - if (!apiKey) return; + const providerAtFetchTime = selectedProviderRef.current; + const isLocalDynamic = + LOCAL_DYNAMIC_MODEL_PROVIDERS.includes(providerAtFetchTime); + if (!apiKey && !isLocalDynamic) return; - const providerAtFetchTime = selectedProvider.provider; + const fetchGeneration = fetchGenerationRef.current; setIsFetchingModels(true); try { const models = await fetchProviderModels( ideMessenger, providerAtFetchTime, - apiKey, - apiBase, - ); - setFetchedModelsList((prev) => - selectedProvider.provider === providerAtFetchTime ? models : prev, + apiKey || undefined, + apiBase || selectedProvider.params?.apiBase, ); + if ( + fetchGenerationRef.current !== fetchGeneration || + selectedProviderRef.current !== providerAtFetchTime + ) { + return; + } + setFetchedModelsList(models); + if (isLocalDynamic && models.length > 0) { + setSelectedModel((current) => + current.params.model === "AUTODETECT" ? models[0] : current, + ); + } } catch (error) { console.error("Failed to fetch models:", error); } finally { @@ -82,6 +100,12 @@ export function AddModelForm({ } }, [ideMessenger, selectedProvider, formMethods]); + useEffect(() => { + if (LOCAL_DYNAMIC_MODEL_PROVIDERS.includes(selectedProvider.provider)) { + void handleFetchModels(); + } + }, [selectedProvider.provider, handleFetchModels]); + const popularProviderTitles = [ providers["openai"]?.title || "", providers["anthropic"]?.title || "", @@ -112,6 +136,10 @@ export function AddModelForm({ : selectedProvider.apiKeyUrl; function isDisabled() { + if (selectedProvider.provider === "atomic-chat") { + return selectedModel.params.model === "AUTODETECT"; + } + if (selectedProvider.downloadUrl) { return false; } @@ -127,7 +155,11 @@ export function AddModelForm({ } useEffect(() => { - setSelectedModel(selectedProvider.packages[0]); + const defaultModel = + selectedProvider.packages.find( + (pkg) => pkg.params.model !== "AUTODETECT", + ) ?? selectedProvider.packages[0]; + setSelectedModel(defaultModel); if (!selectedProvider.tags?.includes(ModelProviderTags.RequiresApiKey)) { formMethods.setValue("apiKey", ""); } @@ -245,10 +277,13 @@ export function AddModelForm({ type="button" title="Use entered API key to fetch available models" className={`cursor-pointer border-none bg-transparent p-0 ${ - apiKeyValue && - apiKeyValue.length > 0 && - selectedProvider.provider !== "ollama" && - selectedProvider.provider !== "openrouter" + (apiKeyValue && + apiKeyValue.length > 0 && + selectedProvider.provider !== "ollama" && + selectedProvider.provider !== "openrouter") || + LOCAL_DYNAMIC_MODEL_PROVIDERS.includes( + selectedProvider.provider, + ) ? `text-description-muted hover:text-foreground` : "invisible" }`} diff --git a/gui/src/pages/AddNewModel/configs/fetchProviderModels.ts b/gui/src/pages/AddNewModel/configs/fetchProviderModels.ts index 6e0dce45a16..980ce1f1cf2 100644 --- a/gui/src/pages/AddNewModel/configs/fetchProviderModels.ts +++ b/gui/src/pages/AddNewModel/configs/fetchProviderModels.ts @@ -61,6 +61,23 @@ function toOpenRouterPackage(model: FetchedModel): ModelPackage { }; } +function toAtomicChatPackage(model: FetchedModel): ModelPackage { + const id = model.modelId ?? model.name; + return { + title: model.name, + description: model.description ?? model.name, + params: { + title: model.name, + model: id, + ...modelParams(model), + }, + isOpenSource: true, + tags: [ModelProviderTags.Local, ModelProviderTags.OpenSource], + providerOptions: ["atomic-chat"], + icon: model.icon ?? "atomic-chat.png", + }; +} + function toGenericPackage(model: FetchedModel, provider: string): ModelPackage { const id = model.modelId ?? model.name; return { @@ -96,10 +113,13 @@ async function fetchModels( export async function fetchProviderModels( ideMessenger: IIdeMessenger, provider: string, - apiKey: string, + apiKey?: string, apiBase?: string, ): Promise { const models = await fetchModels(ideMessenger, provider, apiKey, apiBase); + if (provider === "atomic-chat") { + return models.map(toAtomicChatPackage); + } return models.map((m) => toGenericPackage(m, provider)); } @@ -141,4 +161,28 @@ export async function initializeDynamicModels(ideMessenger: IIdeMessenger) { } catch (error) { console.error("Failed to initialize OpenRouter models:", error); } + + if (providers.atomicChat) { + const autodetect = { + ...models.AUTODETECT, + params: { ...models.AUTODETECT.params, title: "Atomic Chat" }, + }; + + try { + const fetched = await fetchModels( + ideMessenger, + "atomic-chat", + undefined, + providers.atomicChat.params.apiBase, + ); + if (fetched.length > 0) { + providers.atomicChat.packages = fetched.map(toAtomicChatPackage); + } else { + providers.atomicChat.packages = [autodetect]; + } + } catch (error) { + console.error("Failed to initialize Atomic Chat models:", error); + providers.atomicChat.packages = [autodetect]; + } + } } diff --git a/gui/src/pages/AddNewModel/configs/providers.ts b/gui/src/pages/AddNewModel/configs/providers.ts index 82f1e15c7a0..d00815f41bd 100644 --- a/gui/src/pages/AddNewModel/configs/providers.ts +++ b/gui/src/pages/AddNewModel/configs/providers.ts @@ -796,6 +796,37 @@ Select the \`GPT-4o\` model below to complete your provider configuration, but n ], apiKeyUrl: "https://console.x.ai/", }, + atomicChat: { + title: "Atomic Chat", + provider: "atomic-chat", + description: "Local LLMs via the Atomic Chat desktop app", + longDescription: + "Atomic Chat runs models on your machine and serves them through an OpenAI-compatible API (default `http://127.0.0.1:1337/v1`).\n\n1. Download from [atomic.chat](https://atomic.chat/) and open the app\n2. Load a model so the local API is running\n3. Add Atomic Chat in Continue and choose a model from the list\n\nModel ids must match those returned by `GET /v1/models`.", + icon: "atomic-chat.png", + tags: [ModelProviderTags.Local, ModelProviderTags.OpenSource], + params: { + apiBase: "http://127.0.0.1:1337/v1/", + }, + packages: [ + { + ...models.AUTODETECT, + params: { + ...models.AUTODETECT.params, + title: "Atomic Chat", + }, + }, + ], + collectInputFor: [ + ...completionParamsInputsConfigs, + { + ...apiBaseInput, + defaultValue: "http://127.0.0.1:1337/v1/", + required: true, + }, + ], + downloadUrl: "https://atomic.chat/", + refPage: "atomic-chat", + }, lemonade: { title: "Lemonade", provider: "lemonade", diff --git a/packages/openai-adapters/src/index.ts b/packages/openai-adapters/src/index.ts index c9eb4da00fa..432a5adef4a 100644 --- a/packages/openai-adapters/src/index.ts +++ b/packages/openai-adapters/src/index.ts @@ -185,6 +185,8 @@ export function constructLlmApi(config: LLMConfig): BaseLlmApi | undefined { case "llama.cpp": case "llamafile": return openAICompatible("http://localhost:8000/", config); + case "atomic-chat": + return openAICompatible("http://127.0.0.1:1337/v1/", config); case "lmstudio": return openAICompatible("http://localhost:1234/", config); case "ollama": diff --git a/packages/openai-adapters/src/types.ts b/packages/openai-adapters/src/types.ts index 3b324b0ac6b..ed4e65a9565 100644 --- a/packages/openai-adapters/src/types.ts +++ b/packages/openai-adapters/src/types.ts @@ -46,6 +46,7 @@ export const OpenAIConfigSchema = BasePlusConfig.extend({ z.literal("function-network"), z.literal("llama.cpp"), z.literal("llamafile"), + z.literal("atomic-chat"), z.literal("lmstudio"), z.literal("ollama"), z.literal("cerebras"),