Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 1dd1bfe

Browse files
Yana Lyalyukcursoragent
authored andcommitted
feat(providers): add Atomic Chat OpenAI-compatible provider
- Add atomic-chat provider with configurable base URL (default 127.0.0.1:1337) - Fetch models from GET /v1/models; optional Bearer API key - Wire extension host, types, settings UI, validation, and model picker - Fix validate.spec RouterModels mock for new provider key Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e921f9d commit 1dd1bfe

25 files changed

Lines changed: 511 additions & 1 deletion

apps/cli/src/lib/utils/context-window.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined {
4242
return config.ollamaModelId
4343
case "lmstudio":
4444
return config.lmStudioModelId
45+
case "atomic-chat":
46+
return config.atomicChatModelId
4547
case "openai":
4648
return config.openAiModelId
4749
case "requesty":

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ export const SECRET_STATE_KEYS = [
255255
"awsSessionToken",
256256
"openAiApiKey",
257257
"ollamaApiKey",
258+
"atomicChatApiKey",
258259
"geminiApiKey",
259260
"openAiNativeApiKey",
260261
"deepSeekApiKey",

packages/types/src/provider-settings.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const isDynamicProvider = (key: string): key is DynamicProvider =>
4747
* Local providers require localhost API calls in order to get the model list.
4848
*/
4949

50-
export const localProviders = ["ollama", "lmstudio"] as const
50+
export const localProviders = ["ollama", "lmstudio", "atomic-chat"] as const
5151

5252
export type LocalProvider = (typeof localProviders)[number]
5353

@@ -274,6 +274,12 @@ const lmStudioSchema = baseProviderSettingsSchema.extend({
274274
lmStudioSpeculativeDecodingEnabled: z.boolean().optional(),
275275
})
276276

277+
const atomicChatSchema = baseProviderSettingsSchema.extend({
278+
atomicChatModelId: z.string().optional(),
279+
atomicChatBaseUrl: z.string().optional(),
280+
atomicChatApiKey: z.string().optional(),
281+
})
282+
277283
const geminiSchema = apiModelIdProviderModelSchema.extend({
278284
geminiApiKey: z.string().optional(),
279285
googleGeminiBaseUrl: z.string().optional(),
@@ -394,6 +400,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
394400
ollamaSchema.merge(z.object({ apiProvider: z.literal("ollama") })),
395401
vsCodeLmSchema.merge(z.object({ apiProvider: z.literal("vscode-lm") })),
396402
lmStudioSchema.merge(z.object({ apiProvider: z.literal("lmstudio") })),
403+
atomicChatSchema.merge(z.object({ apiProvider: z.literal("atomic-chat") })),
397404
geminiSchema.merge(z.object({ apiProvider: z.literal("gemini") })),
398405
geminiCliSchema.merge(z.object({ apiProvider: z.literal("gemini-cli") })),
399406
openAiCodexSchema.merge(z.object({ apiProvider: z.literal("openai-codex") })),
@@ -427,6 +434,7 @@ export const providerSettingsSchema = z.object({
427434
...ollamaSchema.shape,
428435
...vsCodeLmSchema.shape,
429436
...lmStudioSchema.shape,
437+
...atomicChatSchema.shape,
430438
...geminiSchema.shape,
431439
...geminiCliSchema.shape,
432440
...openAiCodexSchema.shape,
@@ -473,6 +481,7 @@ export const modelIdKeys = [
473481
"ollamaModelId",
474482
"lmStudioModelId",
475483
"lmStudioDraftModelId",
484+
"atomicChatModelId",
476485
"requestyModelId",
477486
"unboundModelId",
478487
"litellmModelId",
@@ -504,6 +513,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
504513
"openai-native": "openAiModelId",
505514
ollama: "ollamaModelId",
506515
lmstudio: "lmStudioModelId",
516+
"atomic-chat": "atomicChatModelId",
507517
gemini: "apiModelId",
508518
"gemini-cli": "apiModelId",
509519
mistral: "apiModelId",
@@ -636,4 +646,5 @@ export const MODELS_BY_PROVIDER: Record<
636646
// Local providers; models discovered from localhost endpoints.
637647
lmstudio: { id: "lmstudio", label: "LM Studio", models: [] },
638648
ollama: { id: "ollama", label: "Ollama", models: [] },
649+
"atomic-chat": { id: "atomic-chat", label: "Atomic Chat", models: [] },
639650
}

packages/types/src/providers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ export function getProviderDefaultModelId(
9797
return "" // Ollama uses dynamic model selection
9898
case "lmstudio":
9999
return "" // LMStudio uses dynamic model selection
100+
case "atomic-chat":
101+
return "" // Atomic Chat uses dynamic model selection
100102
case "vscode-lm":
101103
return vscodeLlmDefaultModelId
102104
case "sambanova":

packages/types/src/vscode-extension-host.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface ExtensionMessage {
3939
| "openAiModels"
4040
| "ollamaModels"
4141
| "lmStudioModels"
42+
| "atomicChatModels"
4243
| "vsCodeLmModels"
4344
| "vsCodeLmApiAvailable"
4445
| "updatePrompt"
@@ -126,6 +127,7 @@ export interface ExtensionMessage {
126127
openAiModels?: string[]
127128
ollamaModels?: ModelRecord
128129
lmStudioModels?: ModelRecord
130+
atomicChatModels?: ModelRecord
129131
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
130132
mcpServers?: McpServer[]
131133
commits?: GitCommit[]
@@ -402,6 +404,7 @@ export interface WebviewMessage {
402404
| "requestOpenAiModels"
403405
| "requestOllamaModels"
404406
| "requestLmStudioModels"
407+
| "requestAtomicChatModels"
405408
| "requestVsCodeLmModels"
406409
| "openImage"
407410
| "saveImage"

src/api/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
OpenAiHandler,
1616
OpenAiCodexHandler,
1717
LmStudioHandler,
18+
AtomicChatHandler,
1819
GeminiHandler,
1920
OpenAiNativeHandler,
2021
DeepSeekHandler,
@@ -137,6 +138,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
137138
return new NativeOllamaHandler(options)
138139
case "lmstudio":
139140
return new LmStudioHandler(options)
141+
case "atomic-chat":
142+
return new AtomicChatHandler(options)
140143
case "gemini":
141144
return new GeminiHandler(options)
142145
case "openai-codex":

src/api/providers/atomic-chat.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { Anthropic } from "@anthropic-ai/sdk"
2+
import OpenAI from "openai"
3+
4+
import { type ModelInfo, openAiModelInfoSaneDefaults, LMSTUDIO_DEFAULT_TEMPERATURE } from "@roo-code/types"
5+
6+
import type { ApiHandlerOptions } from "../../shared/api"
7+
8+
import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser"
9+
import { TagMatcher } from "../../utils/tag-matcher"
10+
11+
import { convertToOpenAiMessages } from "../transform/openai-format"
12+
import { ApiStream } from "../transform/stream"
13+
14+
import { BaseProvider } from "./base-provider"
15+
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
16+
import { getModelsFromCache } from "./fetchers/modelCache"
17+
import { getApiRequestTimeout } from "./utils/timeout-config"
18+
import { handleOpenAIError } from "./utils/openai-error-handler"
19+
import { DEFAULT_HEADERS } from "./constants"
20+
21+
/**
22+
* Atomic Chat — local OpenAI-compatible API (default http://127.0.0.1:1337/v1).
23+
* @see https://github.com/AtomicBot-ai/Atomic-Chat
24+
*/
25+
export class AtomicChatHandler extends BaseProvider implements SingleCompletionHandler {
26+
protected options: ApiHandlerOptions
27+
private client: OpenAI
28+
private readonly providerName = "Atomic Chat"
29+
30+
constructor(options: ApiHandlerOptions) {
31+
super()
32+
this.options = options
33+
34+
const baseRoot = (this.options.atomicChatBaseUrl || "http://127.0.0.1:1337").replace(/\/+$/, "")
35+
const apiKey = this.options.atomicChatApiKey?.trim() || "noop"
36+
37+
this.client = new OpenAI({
38+
baseURL: `${baseRoot}/v1`,
39+
apiKey,
40+
timeout: getApiRequestTimeout(),
41+
defaultHeaders: {
42+
...DEFAULT_HEADERS,
43+
},
44+
})
45+
}
46+
47+
override async *createMessage(
48+
systemPrompt: string,
49+
messages: Anthropic.Messages.MessageParam[],
50+
metadata?: ApiHandlerCreateMessageMetadata,
51+
): ApiStream {
52+
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
53+
{ role: "system", content: systemPrompt },
54+
...convertToOpenAiMessages(messages),
55+
]
56+
57+
const toContentBlocks = (
58+
blocks: Anthropic.Messages.MessageParam[] | string,
59+
): Anthropic.Messages.ContentBlockParam[] => {
60+
if (typeof blocks === "string") {
61+
return [{ type: "text", text: blocks }]
62+
}
63+
64+
const result: Anthropic.Messages.ContentBlockParam[] = []
65+
for (const msg of blocks) {
66+
if (typeof msg.content === "string") {
67+
result.push({ type: "text", text: msg.content })
68+
} else if (Array.isArray(msg.content)) {
69+
for (const part of msg.content) {
70+
if (part.type === "text") {
71+
result.push({ type: "text", text: part.text })
72+
}
73+
}
74+
}
75+
}
76+
return result
77+
}
78+
79+
let inputTokens = 0
80+
try {
81+
inputTokens = await this.countTokens([{ type: "text", text: systemPrompt }, ...toContentBlocks(messages)])
82+
} catch (err) {
83+
console.error("[AtomicChat] Failed to count input tokens:", err)
84+
inputTokens = 0
85+
}
86+
87+
let assistantText = ""
88+
89+
try {
90+
const params: OpenAI.Chat.ChatCompletionCreateParamsStreaming = {
91+
model: this.getModel().id,
92+
messages: openAiMessages,
93+
temperature: this.options.modelTemperature ?? LMSTUDIO_DEFAULT_TEMPERATURE,
94+
stream: true,
95+
tools: this.convertToolsForOpenAI(metadata?.tools),
96+
tool_choice: metadata?.tool_choice,
97+
parallel_tool_calls: metadata?.parallelToolCalls ?? true,
98+
}
99+
100+
let results
101+
try {
102+
results = await this.client.chat.completions.create(params)
103+
} catch (error) {
104+
throw handleOpenAIError(error, this.providerName)
105+
}
106+
107+
const matcher = new TagMatcher(
108+
"think",
109+
(chunk) =>
110+
({
111+
type: chunk.matched ? "reasoning" : "text",
112+
text: chunk.data,
113+
}) as const,
114+
)
115+
116+
for await (const chunk of results) {
117+
const delta = chunk.choices[0]?.delta
118+
const finishReason = chunk.choices[0]?.finish_reason
119+
120+
if (delta?.content) {
121+
assistantText += delta.content
122+
for (const processedChunk of matcher.update(delta.content)) {
123+
yield processedChunk
124+
}
125+
}
126+
127+
if (delta?.tool_calls) {
128+
for (const toolCall of delta.tool_calls) {
129+
yield {
130+
type: "tool_call_partial",
131+
index: toolCall.index,
132+
id: toolCall.id,
133+
name: toolCall.function?.name,
134+
arguments: toolCall.function?.arguments,
135+
}
136+
}
137+
}
138+
139+
if (finishReason) {
140+
const endEvents = NativeToolCallParser.processFinishReason(finishReason)
141+
for (const event of endEvents) {
142+
yield event
143+
}
144+
}
145+
}
146+
147+
for (const processedChunk of matcher.final()) {
148+
yield processedChunk
149+
}
150+
151+
let outputTokens = 0
152+
try {
153+
outputTokens = await this.countTokens([{ type: "text", text: assistantText }])
154+
} catch (err) {
155+
console.error("[AtomicChat] Failed to count output tokens:", err)
156+
outputTokens = 0
157+
}
158+
159+
yield {
160+
type: "usage",
161+
inputTokens,
162+
outputTokens,
163+
} as const
164+
} catch {
165+
throw new Error(
166+
"Atomic Chat request failed. Ensure the app is running, the local API server is enabled, and the model is loaded with enough context for Roo Code.",
167+
)
168+
}
169+
}
170+
171+
override getModel(): { id: string; info: ModelInfo } {
172+
const models = getModelsFromCache("atomic-chat")
173+
if (models && this.options.atomicChatModelId && models[this.options.atomicChatModelId]) {
174+
return {
175+
id: this.options.atomicChatModelId,
176+
info: models[this.options.atomicChatModelId],
177+
}
178+
}
179+
return {
180+
id: this.options.atomicChatModelId || "",
181+
info: openAiModelInfoSaneDefaults,
182+
}
183+
}
184+
185+
async completePrompt(prompt: string): Promise<string> {
186+
try {
187+
const params: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming = {
188+
model: this.getModel().id,
189+
messages: [{ role: "user", content: prompt }],
190+
temperature: this.options.modelTemperature ?? LMSTUDIO_DEFAULT_TEMPERATURE,
191+
stream: false,
192+
}
193+
194+
let response
195+
try {
196+
response = await this.client.chat.completions.create(params)
197+
} catch (error) {
198+
throw handleOpenAIError(error, this.providerName)
199+
}
200+
return response.choices[0]?.message.content || ""
201+
} catch {
202+
throw new Error(
203+
"Atomic Chat request failed. Ensure the app is running and the local API server is reachable.",
204+
)
205+
}
206+
}
207+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import axios from "axios"
2+
import type { ModelInfo, ModelRecord } from "@roo-code/types"
3+
import { openAiModelInfoSaneDefaults } from "@roo-code/types"
4+
5+
/**
6+
* Fetches model IDs from Atomic Chat's OpenAI-compatible API.
7+
* @see https://github.com/AtomicBot-ai/Atomic-Chat
8+
*/
9+
export async function getAtomicChatModels(baseUrl = "http://127.0.0.1:1337", apiKey?: string): Promise<ModelRecord> {
10+
const models: ModelRecord = {}
11+
const root = baseUrl === "" ? "http://127.0.0.1:1337" : baseUrl.replace(/\/+$/, "")
12+
13+
try {
14+
if (!URL.canParse(root)) {
15+
return models
16+
}
17+
18+
const headers: Record<string, string> = {}
19+
if (apiKey?.trim()) {
20+
headers.Authorization = `Bearer ${apiKey.trim()}`
21+
}
22+
23+
const response = await axios.get<{ data?: Array<{ id: string }> }>(`${root}/v1/models`, {
24+
headers,
25+
timeout: 10_000,
26+
})
27+
28+
const list = response.data?.data ?? []
29+
for (const entry of list) {
30+
if (entry?.id) {
31+
models[entry.id] = { ...openAiModelInfoSaneDefaults }
32+
}
33+
}
34+
35+
return models
36+
} catch {
37+
return models
38+
}
39+
}

src/api/providers/fetchers/modelCache.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { getLiteLLMModels } from "./litellm"
2323
import { GetModelsOptions } from "../../../shared/api"
2424
import { getOllamaModels } from "./ollama"
2525
import { getLMStudioModels } from "./lmstudio"
26+
import { getAtomicChatModels } from "./atomic-chat"
2627
import { getPoeModels } from "./poe"
2728

2829
const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 })
@@ -81,6 +82,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise<Model
8182
case "lmstudio":
8283
models = await getLMStudioModels(options.baseUrl)
8384
break
85+
case "atomic-chat":
86+
models = await getAtomicChatModels(options.baseUrl, options.apiKey)
87+
break
8488
case "vercel-ai-gateway":
8589
models = await getVercelAiGatewayModels()
8690
break

0 commit comments

Comments
 (0)