Add Atomic Chat as a first-class local provider. (#10555)

* Add Atomic Chat as a first-class local provider.

Ship the atomic-chat plugin with auto-discovery on localhost:1337, register the provider in models.dev fixtures, and document setup for VS Code and CLI.

* Remove dead code

Removed atomicChat provider mapping from legacy migration.

* Update Atomic Chat provider icon to theme-colored vector.

Replace the embedded raster icon with a currentColor SVG symbol so it matches provider icon styling and inherits UI color consistently.

* Allow optional API key for local Atomic Chat and LM Studio providers.

Load the atomic-chat plugin despite named constant exports, register local-server auth in the plugin, and let CLI and VS Code connect with an empty key on localhost.

* Gate Atomic Chat discovery behind explicit opt-in

* Update packages/plugin-atomic-chat/src/utils/should-probe-atomic-chat.ts

* Remove dead branch in atomic-chat model ref probe.

Drop the modelID check (never equals provider key) and the duplicate providerID branch.

* Fix PR CI: register atomic chat plugin and complete i18n.

Restore atomic-chat in default-plugins after main merge, add missing sidebar locale keys, and annotate shared upstream diffs.

* Format i18n locale files and ProviderConnectDialog with Prettier.

* Address PR review: docs order, narrow annotations, plugin fixes.

Reorder local-models providers per maintainer feedback; use inline kilocode_change markers in dialog-provider. Share ModelStatusCache, fix config discovery abort/timeout, throw on fetch failure, and resolve bot review warnings.

* Align dialog-provider with upstream keymap bindings after rebase.

Restore useBindings instead of useKeyboard so shared-file diff stays minimal and passes annotation checks.

* Update packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx

* Deduplicate Atomic Chat /v1/models fetch on config discovery.

Use a single shared models endpoint request in enhanceConfig instead of separate health and discovery calls.

* Fix kilo-code-bot review: import provider key and log cache errors.

Export ATOMIC_CHAT_PROVIDER_KEY from local-providers for the webview, log cache warm failures, and throw on network errors in fetchModelsEndpoint.

* fix(plugin-atomic-chat): harden discovery and reduce chat toasts

Reuse auto-detect model list to avoid duplicate /v1/models calls, wire AbortSignal into fetch, replace silent catches with logging, and only surface validation errors in chat.params.

* fix(plugin-atomic-chat): refresh model cache on chat validation retries

Bypass the 15s model list cache after the first failed attempt so retryWithBackoff can observe newly loaded models.

* Update packages/plugin-atomic-chat/src/utils/index.ts

* fix(plugin-atomic-chat): repair categorizeError not_found branch

Restore return statement and map "not loaded" validation errors to not_found after bot commit broke the if block.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com>
This commit is contained in:
yanalialiuk
2026-06-04 17:03:00 +03:00
committed by GitHub
parent 3b3ce7e80a
commit 2d109462db
65 changed files with 2132 additions and 19 deletions
+17
View File
@@ -409,6 +409,7 @@
"@kilocode/kilo-indexing": "workspace:*",
"@kilocode/kilo-telemetry": "workspace:*",
"@kilocode/plugin": "workspace:*",
"@kilocode/plugin-atomic-chat": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
@@ -556,6 +557,20 @@
"@opentui/solid",
],
},
"packages/plugin-atomic-chat": {
"name": "@kilocode/plugin-atomic-chat",
"version": "0.1.0",
"dependencies": {
"@kilocode/plugin": "workspace:*",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"vitest": "^4.0.16",
},
},
"packages/script": {
"name": "@opencode-ai/script",
"version": "7.3.29",
@@ -1329,6 +1344,8 @@
"@kilocode/plugin": ["@kilocode/plugin@workspace:packages/plugin"],
"@kilocode/plugin-atomic-chat": ["@kilocode/plugin-atomic-chat@workspace:packages/plugin-atomic-chat"],
"@kilocode/sdk": ["@kilocode/sdk@workspace:packages/sdk/js"],
"@kobalte/core": ["@kobalte/core@0.13.11", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@internationalized/date": "^3.4.0", "@internationalized/number": "^3.2.1", "@kobalte/utils": "^0.9.1", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.26", "solid-presence": "^0.1.8", "solid-prevent-scroll": "^0.1.4" }, "peerDependencies": { "solid-js": "^1.8.15" } }, "sha512-hK7TYpdib/XDb/r/4XDBFaO9O+3ZHz4ZWryV4/3BfES+tSQVgg2IJupDnztKXB0BqbSRy/aWlHKw1SPtNPYCFQ=="],
@@ -55,6 +55,7 @@ export const AiProvidersNav: NavSection[] = [
links: [
{ href: "/ai-providers/ollama", children: "Ollama" },
{ href: "/ai-providers/lmstudio", children: "LM Studio" },
{ href: "/ai-providers/atomic-chat", children: "Atomic Chat" },
{ href: "/ai-providers/vscode-lm", children: "VS Code LM API" },
{
href: "/ai-providers/openai-compatible",
@@ -0,0 +1,112 @@
---
title: "Using Atomic Chat with Kilo Code | Local LLMs"
description: "Run local models in Kilo Code via Atomic Chat's OpenAI-compatible API. Setup for VS Code and the CLI."
sidebar_label: Atomic Chat
---
# Using Atomic Chat With Kilo Code
[Kilo Code](https://kilocode.ai/) supports [Atomic Chat](https://atomic.chat/) as a local provider. Atomic Chat runs models on your machine and exposes an OpenAI-compatible API (default `http://127.0.0.1:1337/v1`).
**Website:** [https://atomic.chat/](https://atomic.chat/)
**Repository:** [https://github.com/AtomicBot-ai/Atomic-Chat](https://github.com/AtomicBot-ai/Atomic-Chat)
## Prerequisites
1. Install [Atomic Chat](https://atomic.chat/) (macOS or Windows).
2. Download and load a model in the app.
3. Enable the **local API server** (default port **1337**).
4. Confirm the API responds:
```bash
curl http://127.0.0.1:1337/v1/models
```
## Configuration in Kilo Code
Kilo Code ships the `@kilocode/plugin-atomic-chat` plugin by default. It **does not** call localhost unless you opt in (see below). When enabled, it discovers models from `GET /v1/models` and can warn if the selected model is not loaded.
**Localhost HTTP runs only when one of these is true:**
- You configure `provider.atomic-chat` in `kilo.jsonc`
- You set `"model": "atomic-chat/..."` (or per-agent model uses `atomic-chat`)
- You enable optional auto-detect: `"atomicChat": { "autoDetect": true }` (probes ports **1337** and **1338**)
Otherwise no requests are made to Atomic Chat (suitable for restricted environments).
{% tabs %}
{% tab label="VSCode" %}
Open **Settings** (gear icon) → **Providers****Atomic Chat**. No API key is required for the default local server. Adjust the base URL if Atomic Chat uses a non-default host or port.
{% /tab %}
{% tab label="CLI" %}
**Config file** (`~/.config/kilo/kilo.jsonc` or `./kilo.jsonc`):
```jsonc
{
"provider": {
"atomic-chat": {
"options": {
"baseURL": "http://127.0.0.1:1337/v1",
},
},
},
}
```
Set your default model (use an id from `curl http://127.0.0.1:1337/v1/models`):
```jsonc
{
"model": "atomic-chat/gemma-4-E4B-it-IQ4_XS",
}
```
Optional auto-detect without a provider block:
```jsonc
{
"atomicChat": { "autoDetect": true },
}
```
To disable the provider entirely, use `disabled_providers: ["atomic-chat"]` or remove `@kilocode/plugin-atomic-chat` from the `plugin` array in your config.
{% /tab %}
{% /tabs %}
## Custom or unlisted models
If a loaded model does not appear in the picker, register it under `provider.atomic-chat.models`:
```jsonc
{
"model": "atomic-chat/my-local-model",
"provider": {
"atomic-chat": {
"models": {
"my-local-model": {
"id": "exact-id-from-v1-models",
"name": "My Local Model",
},
},
},
},
}
```
See [Custom Models](/docs/code-with-ai/agents/custom-models) for all model fields.
## Tips
- Prefer capable models with large context windows; agent workflows use long prompts.
- Keep only the models you need loaded in Atomic Chat to save memory.
- For embeddings via Atomic Chat, use the **openai-compatible** indexing provider with the same base URL.
## Related
- [LM Studio](/docs/ai-providers/lmstudio)
- [Ollama](/docs/ai-providers/ollama)
- [Local models overview](/docs/automate/extending/local-models)
@@ -33,6 +33,7 @@ Major AI companies offering powerful models via API:
Run models on your own hardware for privacy and offline use:
- **[Atomic Chat](/docs/ai-providers/atomic-chat)** - Local models with TurboQuant inference and auto-discovery in Kilo Code
- **[Ollama](/docs/ai-providers/ollama)** - Easy local model management
- **[LM Studio](/docs/ai-providers/lmstudio)** - Desktop app for local models
- **[OpenAI Compatible](/docs/ai-providers/openai-compatible)** - Any OpenAI-compatible endpoint
@@ -5,7 +5,7 @@ description: "Run AI models locally with Kilo Code"
# Using Local Models
Kilo Code supports running language models locally on your own machine using [Ollama](https://ollama.com/) and [LM Studio](https://lmstudio.ai/). This offers several advantages:
Kilo Code supports running language models locally on your own machine using [Ollama](https://ollama.com/), [LM Studio](https://lmstudio.ai/), and [Atomic Chat](https://atomic.chat/). This offers several advantages:
- **Privacy:** Your code and data never leave your computer.
- **Offline Access:** You can use Kilo Code even without an internet connection.
@@ -21,10 +21,11 @@ Kilo Code supports running language models locally on your own machine using [Ol
## Supported Local Model Providers
Kilo Code currently supports two main local model providers:
Kilo Code supports several local model providers:
1. **Ollama:** A popular open-source tool for running large language models locally. It supports a wide range of models.
2. **LM Studio:** A user-friendly desktop application that simplifies the process of downloading, configuring, and running local models. It also provides a local server that emulates the OpenAI API.
2. **LM Studio:** A user-friendly desktop application that simplifies downloading and running local models, with a local server that emulates the OpenAI API.
3. **[Atomic Chat](https://atomic.chat/):** Open-source local AI with TurboQuant-optimized inference, a built-in chat UI, and an OpenAI-compatible API on port **1337**. Kilo Code can discover loaded models when you opt in (`provider.atomic-chat`, `atomicChat.autoDetect`, or an `atomic-chat/...` model).
## Setting Up Local Models
@@ -32,12 +33,11 @@ For detailed setup instructions, see:
- [Setting up Ollama](/docs/ai-providers/ollama)
- [Setting up LM Studio](/docs/ai-providers/lmstudio)
Both providers offer similar capabilities but with different user interfaces and workflows. Ollama provides more control through its command-line interface, while LM Studio offers a more user-friendly graphical interface.
- [Setting up Atomic Chat](/docs/ai-providers/atomic-chat)
## Troubleshooting
- **"No connection could be made because the target machine actively refused it":** This usually means that the Ollama or LM Studio server isn't running, or is running on a different port/address than Kilo Code is configured to use. Double-check the Base URL setting.
- **"No connection could be made because the target machine actively refused it":** This usually means that Atomic Chat, Ollama, or LM Studio isn't running, or is on a different port than Kilo Code expects (Atomic Chat: `http://127.0.0.1:1337/v1`, LM Studio: `http://127.0.0.1:1234/v1`, Ollama: `http://127.0.0.1:11434`). Double-check the Base URL setting.
- **Slow Response Times:** Local models can be slower than cloud-based models, especially on less powerful hardware. If performance is an issue, try using a smaller model.
@@ -12,6 +12,11 @@ import { useLanguage } from "../../context/language"
import { useProvider } from "../../context/provider"
import { useVSCode } from "../../context/vscode"
import { createProviderAction } from "../../utils/provider-action"
import {
ATOMIC_CHAT_PROVIDER_KEY,
isLocalProviderOptionalApiKey,
LOCAL_PROVIDER_API_KEY_PLACEHOLDER,
} from "../../utils/local-providers"
interface ProviderConnectDialogProps {
providerID: string
@@ -265,7 +270,7 @@ const ProviderConnectDialog: Component<ProviderConnectDialogProps> = (props) =>
<For each={methods()}>
{(item, index) => (
<Button variant="secondary" size="large" onClick={() => selectMethod(index())}>
{item.type === "api" ? language.t("provider.connect.method.apiKey") : item.label}
{item.type === "api" ? item.label || language.t("provider.connect.method.apiKey") : item.label}
</Button>
)}
</For>
@@ -283,10 +288,29 @@ const ProviderConnectDialog: Component<ProviderConnectDialogProps> = (props) =>
const [value, setValue] = createSignal("")
const [fields, setFields] = createStore<Record<string, string>>({})
const prompts = createMemo(() => method()?.prompts?.filter((prompt) => visible(prompt, fields)) ?? [])
const apiKeyOptional = () => isLocalProviderOptionalApiKey(props.providerID)
function apiKeyDescription() {
if (props.providerID === ATOMIC_CHAT_PROVIDER_KEY) {
return language.t("provider.connect.atomicChat.description")
}
if (apiKeyOptional()) {
return language.t("provider.connect.apiKey.description.local", { provider: name() })
}
return language.t("provider.connect.apiKey.description", { provider: name() })
}
function apiKeyLabel() {
if (apiKeyOptional()) {
return language.t("provider.connect.apiKey.label.optional", { provider: name() })
}
return language.t("provider.connect.apiKey.label", { provider: name() })
}
function submit(e: SubmitEvent) {
e.preventDefault()
const apiKey = value().trim()
const trimmed = value().trim()
const apiKey = trimmed || (apiKeyOptional() ? LOCAL_PROVIDER_API_KEY_PLACEHOLDER : "")
if (!apiKey) {
setState({ ...state, error: language.t("provider.connect.apiKey.required"), field: "apiKey" })
return
@@ -313,14 +337,16 @@ const ProviderConnectDialog: Component<ProviderConnectDialogProps> = (props) =>
style={{ display: "flex", "flex-direction": "column", gap: "16px" }}
onSubmit={submit}
>
<div class="provider-connect-body">
{language.t("provider.connect.apiKey.description", { provider: name() })}
</div>
<div class="provider-connect-body">{apiKeyDescription()}</div>
<TextField
autofocus
type="password"
label={language.t("provider.connect.apiKey.label", { provider: name() })}
placeholder={language.t("provider.connect.apiKey.placeholder")}
label={apiKeyLabel()}
placeholder={
apiKeyOptional()
? language.t("provider.connect.apiKey.placeholder.optional")
: language.t("provider.connect.apiKey.placeholder")
}
value={value()}
onChange={setValue}
validationState={state.field === "apiKey" ? "invalid" : undefined}
+6
View File
@@ -136,8 +136,14 @@ export const dict = {
"provider.connect.status.failed": "فشل التفويض: {{error}}",
"provider.connect.apiKey.description":
"أدخل مفتاح واجهة برمجة تطبيقات {{provider}} الخاص بك لتوصيل حسابك واستخدام نماذج {{provider}} في Kilo.",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "مفتاح واجهة برمجة تطبيقات {{provider}}",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "مفتاح API",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "مفتاح API مطلوب",
"provider.connect.prompt.required": "{{field}} مطلوب",
"provider.connect.azure.endpointType.label": "حدد تكوين نقطة نهاية Azure",
+6
View File
@@ -136,8 +136,14 @@ export const dict = {
"provider.connect.status.failed": "Autorização falhou: {{error}}",
"provider.connect.apiKey.description":
"Digite sua chave de API do {{provider}} para conectar sua conta e usar modelos do {{provider}} no Kilo.",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "Chave de API do {{provider}}",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "Chave de API",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "A chave de API é obrigatória",
"provider.connect.prompt.required": "{{field}} é obrigatório",
"provider.connect.azure.endpointType.label": "Selecionar configuração de endpoint do Azure",
+6
View File
@@ -136,8 +136,14 @@ export const dict = {
"provider.connect.status.failed": "Autorizacija nije uspjela: {{error}}",
"provider.connect.apiKey.description":
"Unesi svoj {{provider}} API ključ da povežeš račun i koristiš {{provider}} modele u Kilo-u.",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "{{provider}} API ključ",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "API ključ",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "API ključ je obavezan",
"provider.connect.prompt.required": "{{field}} je obavezno",
"provider.connect.azure.endpointType.label": "Odaberite konfiguraciju krajnje tačke za Azure",
+6
View File
@@ -136,8 +136,14 @@ export const dict = {
"provider.connect.status.failed": "Godkendelse mislykkedes: {{error}}",
"provider.connect.apiKey.description":
"Indtast din {{provider}} API-nøgle for at forbinde din konto og bruge {{provider}} modeller i Kilo.",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "{{provider}} API-nøgle",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "API-nøgle",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "API-nøgle er påkrævet",
"provider.connect.prompt.required": "{{field}} er påkrævet",
"provider.connect.azure.endpointType.label": "Vælg Azure-slutpunktskonfiguration",
+6
View File
@@ -140,8 +140,14 @@ export const dict = {
"provider.connect.status.failed": "Autorisierung fehlgeschlagen: {{error}}",
"provider.connect.apiKey.description":
"Geben Sie Ihren {{provider}} API-Schlüssel ein, um Ihr Konto zu verbinden und {{provider}} Modelle in Kilo zu nutzen.",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "{{provider}} API-Schlüssel",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "API-Schlüssel",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "API-Schlüssel ist erforderlich",
"provider.connect.prompt.required": "{{field}} ist erforderlich",
"provider.connect.azure.endpointType.label": "Azure-Endpunktkonfiguration auswählen",
@@ -136,8 +136,14 @@ export const dict = {
"provider.connect.status.failed": "Authorization failed: {{error}}",
"provider.connect.apiKey.description":
"Enter your {{provider}} API key to connect your account and use {{provider}} models in Kilo.",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "{{provider}} API key",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "API key",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "API key is required",
"provider.connect.prompt.required": "{{field}} is required",
"provider.connect.azure.endpointType.label": "Select Azure endpoint configuration",
+6
View File
@@ -136,8 +136,14 @@ export const dict = {
"provider.connect.status.failed": "Autorización fallida: {{error}}",
"provider.connect.apiKey.description":
"Introduce tu clave API de {{provider}} para conectar tu cuenta y usar modelos de {{provider}} en Kilo.",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "Clave API de {{provider}}",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "Clave API",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "La clave API es obligatoria",
"provider.connect.prompt.required": "{{field}} es obligatorio",
"provider.connect.azure.endpointType.label": "Seleccionar configuración de endpoint de Azure",
+6
View File
@@ -137,8 +137,14 @@ export const dict = {
"provider.connect.status.failed": "Échec de l'autorisation : {{error}}",
"provider.connect.apiKey.description":
"Entrez votre clé API {{provider}} pour connecter votre compte et utiliser les modèles {{provider}} dans Kilo.",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "Clé API {{provider}}",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "Clé API",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "La clé API est requise",
"provider.connect.prompt.required": "{{field}} est requis",
"provider.connect.azure.endpointType.label": "Sélectionner la configuration du point de terminaison Azure",
+6
View File
@@ -123,8 +123,14 @@ export const dict = {
"provider.connect.status.failed": "Autorizzazione non riuscita: {{error}}",
"provider.connect.apiKey.description":
"Inserisci la tua API key {{provider}} per connettere l'account e usare i modelli {{provider}} in Kilo.",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "API key {{provider}}",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "API key",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "API key obbligatoria",
"provider.connect.opencodeZen.line1":
"OpenCode Zen ti offre una selezione curata di modelli affidabili e ottimizzati per agenti di coding.",
+6
View File
@@ -136,8 +136,14 @@ export const dict = {
"provider.connect.status.failed": "認証に失敗しました: {{error}}",
"provider.connect.apiKey.description":
"{{provider}}のAPIキーを入力してアカウントを接続し、Kiloで{{provider}}モデルを使用します。",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "{{provider}} APIキー",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "APIキー",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "APIキーが必要です",
"provider.connect.prompt.required": "{{field}}は必須です",
"provider.connect.azure.endpointType.label": "Azure エンドポイント構成の選択",
+6
View File
@@ -140,8 +140,14 @@ export const dict = {
"provider.connect.status.failed": "인증 실패: {{error}}",
"provider.connect.apiKey.description":
"{{provider}} API 키를 입력하여 계정을 연결하고 Kilo에서 {{provider}} 모델을 사용하세요.",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "{{provider}} API 키",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "API 키",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "API 키가 필요합니다",
"provider.connect.prompt.required": "{{field}} 항목은 필수입니다",
"provider.connect.azure.endpointType.label": "Azure 엔드포인트 구성 선택",
+6
View File
@@ -136,8 +136,14 @@ export const dict = {
"provider.connect.status.failed": "Autorisatie mislukt: {{error}}",
"provider.connect.apiKey.description":
"Voer uw {{provider}} API-sleutel in om uw account te verbinden en {{provider}} modellen te gebruiken in Kilo.",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "{{provider}} API-sleutel",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "API-sleutel",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "API-sleutel is vereist",
"provider.connect.prompt.required": "{{field}} is verplicht",
"provider.connect.azure.endpointType.label": "Selecteer Azure-eindpuntconfiguratie",
+6
View File
@@ -139,8 +139,14 @@ export const dict = {
"provider.connect.status.failed": "Autorisering mislyktes: {{error}}",
"provider.connect.apiKey.description":
"Skriv inn din {{provider}} API-nøkkel for å koble til kontoen din og bruke {{provider}}-modeller i Kilo.",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "{{provider}} API-nøkkel",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "API-nøkkel",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "API-nøkkel er påkrevd",
"provider.connect.prompt.required": "{{field}} er påkrevd",
"provider.connect.azure.endpointType.label": "Velg Azure-endepunktskonfigurasjon",
+6
View File
@@ -136,8 +136,14 @@ export const dict = {
"provider.connect.status.failed": "Autoryzacja nie powiodła się: {{error}}",
"provider.connect.apiKey.description":
"Wprowadź swój klucz API {{provider}}, aby połączyć konto i używać modeli {{provider}} w Kilo.",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "Klucz API {{provider}}",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "Klucz API",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "Klucz API jest wymagany",
"provider.connect.prompt.required": "{{field}} jest wymagane",
"provider.connect.azure.endpointType.label": "Wybierz konfigurację punktu końcowego Azure",
+6
View File
@@ -136,8 +136,14 @@ export const dict = {
"provider.connect.status.failed": "Ошибка авторизации: {{error}}",
"provider.connect.apiKey.description":
"Введите ваш API ключ {{provider}} для подключения аккаунта и использования моделей {{provider}} в Kilo.",
"provider.connect.apiKey.description.local":
"Подключение к локальному серверу {{provider}}. Оставьте ключ пустым, если сервер его не требует (обычно для localhost).",
"provider.connect.atomicChat.description":
"Подключение к Atomic Chat на этом компьютере (по умолчанию http://127.0.0.1:1337). Для локального API ключ не нужен — запустите Atomic Chat, загрузите модель и нажмите Connect.",
"provider.connect.apiKey.label": "{{provider}} API ключ",
"provider.connect.apiKey.label.optional": "{{provider}} API ключ (необязательно)",
"provider.connect.apiKey.placeholder": "API ключ",
"provider.connect.apiKey.placeholder.optional": "Пусто для локального сервера",
"provider.connect.apiKey.required": "API ключ обязателен",
"provider.connect.prompt.required": "{{field}} обязательно",
"provider.connect.azure.endpointType.label": "Выберите конфигурацию конечной точки Azure",
+6
View File
@@ -136,8 +136,14 @@ export const dict = {
"provider.connect.status.failed": "การอนุญาตล้มเหลว: {{error}}",
"provider.connect.apiKey.description":
"ป้อนคีย์ API ของ {{provider}} เพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน Kilo",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "คีย์ API ของ {{provider}}",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "คีย์ API",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "ต้องใช้คีย์ API",
"provider.connect.prompt.required": "จำเป็นต้องระบุ {{field}}",
"provider.connect.azure.endpointType.label": "เลือกการกำหนดค่าปลายทางของ Azure",
+6
View File
@@ -136,8 +136,14 @@ export const dict = {
"provider.connect.status.failed": "Yetkilendirme başarısız: {{error}}",
"provider.connect.apiKey.description":
"{{provider}} hesabınızı bağlamak ve Kilo'da {{provider}} modellerini kullanmak için {{provider}} API anahtarınızı girin.",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "{{provider}} API anahtarı",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "API anahtarı",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "API anahtarı gerekli",
"provider.connect.prompt.required": "{{field}} zorunludur",
"provider.connect.azure.endpointType.label": "Azure uç nokta yapılandırmasını seçin",
+6
View File
@@ -136,8 +136,14 @@ export const dict = {
"provider.connect.status.failed": "Авторизація не вдалася: {{error}}",
"provider.connect.apiKey.description":
"Введіть свій API-ключ {{provider}}, щоб підключити акаунт {{provider}} і використовувати моделі {{provider}} в Kilo.",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "API-ключ {{provider}}",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "API-ключ",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "API-ключ обов'язковий",
"provider.connect.prompt.required": "{{field}} є обов'язковим",
"provider.connect.azure.endpointType.label": "Виберіть конфігурацію кінцевої точки Azure",
+6
View File
@@ -140,8 +140,14 @@ export const dict = {
"provider.connect.status.failed": "授权失败:{{error}}",
"provider.connect.apiKey.description":
"输入你的 {{provider}} API 密钥以连接帐户,并在 Kilo 中使用 {{provider}} 模型。",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "{{provider}} API 密钥",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "API 密钥",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "API 密钥为必填项",
"provider.connect.prompt.required": "{{field}} 为必填项",
"provider.connect.azure.endpointType.label": "选择 Azure 端点配置",
+6
View File
@@ -140,8 +140,14 @@ export const dict = {
"provider.connect.status.failed": "授權失敗:{{error}}",
"provider.connect.apiKey.description":
"輸入你的 {{provider}} API 金鑰以連線帳戶,並在 Kilo 中使用 {{provider}} 模型。",
"provider.connect.apiKey.description.local":
"Connect to your local {{provider}} server. Leave the API key empty if the server does not require one (default for localhost).",
"provider.connect.atomicChat.description":
"Connect to Atomic Chat on your machine (default http://127.0.0.1:1337). No API key is required for the local server — start Atomic Chat, load a model, then connect.",
"provider.connect.apiKey.label": "{{provider}} API 金鑰",
"provider.connect.apiKey.label.optional": "{{provider}} API key (optional)",
"provider.connect.apiKey.placeholder": "API 金鑰",
"provider.connect.apiKey.placeholder.optional": "Leave empty for local server",
"provider.connect.apiKey.required": "API 金鑰為必填",
"provider.connect.prompt.required": "{{field}} 為必填項",
"provider.connect.azure.endpointType.label": "選擇 Azure 端點設定",
@@ -0,0 +1,11 @@
export const ATOMIC_CHAT_PROVIDER_KEY = "atomic-chat"
/** Local OpenAI-compatible providers where the API key is not required (localhost). */
export const LOCAL_PROVIDER_OPTIONAL_API_KEY = new Set([ATOMIC_CHAT_PROVIDER_KEY, "lmstudio"])
export function isLocalProviderOptionalApiKey(providerID: string): boolean {
return LOCAL_PROVIDER_OPTIONAL_API_KEY.has(providerID)
}
/** Placeholder stored when the user connects without an API key. */
export const LOCAL_PROVIDER_API_KEY_PLACEHOLDER = "local"
+1
View File
@@ -114,6 +114,7 @@
"@kilocode/kilo-indexing": "workspace:*",
"@kilocode/kilo-telemetry": "workspace:*",
"@kilocode/plugin": "workspace:*",
"@kilocode/plugin-atomic-chat": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
@@ -369,18 +369,21 @@ function ApiMethod(props: ApiMethodProps) {
const toast = useToast()
const { theme } = useTheme()
const optionalApiKey = KiloProvider.isLocalOptionalApiKey(props.providerID) // kilocode_change
return (
<DialogPrompt
title={props.title}
placeholder="API key"
placeholder={KiloProvider.apiKeyPlaceholder(props.providerID)} // kilocode_change
description={KiloProvider.renderApiDescription(props.providerID, theme)} // kilocode_change
onConfirm={async (value) => {
if (!value) return
const key = value.trim() || (optionalApiKey ? KiloProvider.LOCAL_API_KEY_PLACEHOLDER : "") // kilocode_change
if (!key) return // kilocode_change
await sdk.client.auth.set({
providerID: props.providerID,
auth: {
type: "api",
key: value,
key, // kilocode_change
...(props.metadata ? { metadata: props.metadata } : {}),
},
})
@@ -0,0 +1,37 @@
import { pathToFileURL } from "url"
import { ATOMIC_CHAT_PLUGIN } from "@kilocode/plugin-atomic-chat"
type PluginSpec = string | [string, Record<string, unknown>]
type Req = {
resolve: (id: string) => string
}
type LogLike = {
debug: (msg: string, data?: Record<string, unknown>) => void
}
export function hasAtomicChatPlugin(plugins: readonly PluginSpec[]): boolean {
return plugins.some((item) => {
const spec = typeof item === "string" ? item : item[0]
return spec.includes("plugin-atomic-chat") || spec === ATOMIC_CHAT_PLUGIN
})
}
export function resolveAtomicChatPlugin(req: Req, log?: LogLike): string {
try {
const file = req.resolve(ATOMIC_CHAT_PLUGIN)
return pathToFileURL(file).href
} catch (err) {
const error = err instanceof Error ? err.message : String(err)
log?.debug("failed to resolve atomic chat plugin package, using package marker", { error })
return ATOMIC_CHAT_PLUGIN
}
}
export function ensureAtomicChatPlugin(items: readonly PluginSpec[], plugin?: string): PluginSpec[] {
const plugins = [...items]
if (!plugin) return plugins
if (hasAtomicChatPlugin(plugins)) return plugins
return [...plugins, plugin]
}
@@ -67,6 +67,15 @@ export const PROVIDER_TITLES: Record<string, string> = {
openai: "OpenAI / Codex",
}
/** Local OpenAI-compatible providers where API key is optional (localhost). */
export const LOCAL_OPTIONAL_API_KEY = new Set(["atomic-chat", "lmstudio"])
export function isLocalOptionalApiKey(providerID: string) {
return LOCAL_OPTIONAL_API_KEY.has(providerID)
}
export const LOCAL_API_KEY_PLACEHOLDER = "local"
// ---------------------------------------------------------------------------
// Auto-method renderer
// ---------------------------------------------------------------------------
@@ -113,6 +122,13 @@ export function renderApiDescription(
providerID: string,
theme: { textMuted: RGBA; text: RGBA; primary: RGBA },
): (() => JSX.Element) | undefined {
if (providerID === "atomic-chat") {
return () => (
<text fg={theme.textMuted}>
Connect to Atomic Chat on this machine (default http://127.0.0.1:1337). Leave API key empty for local server.
</text>
)
}
if (providerID !== "kilo") return undefined
return () => (
<box gap={1}>
@@ -125,3 +141,7 @@ export function renderApiDescription(
</box>
)
}
export function apiKeyPlaceholder(providerID: string) {
return isLocalOptionalApiKey(providerID) ? "Optional for localhost" : "API key"
}
@@ -1,6 +1,7 @@
import { createRequire } from "module"
import type { ConfigPlugin } from "@/config/plugin"
import { isIndexingPlugin } from "@kilocode/kilo-indexing/detect"
import { ensureAtomicChatPlugin, resolveAtomicChatPlugin } from "@/kilocode/atomic-chat-feature"
import { ensureIndexingPlugin, resolveIndexingPlugin } from "@/kilocode/indexing-feature"
type Log = {
@@ -14,8 +15,14 @@ export namespace KilocodeDefaultPlugins {
cfg: T,
opts: { disabled: boolean; log?: Log },
): T {
const plugin = opts.disabled ? undefined : resolveIndexingPlugin(req, opts.log)
cfg.plugin = ensureIndexingPlugin(cfg.plugin ?? [], plugin)
let plugins = cfg.plugin ?? []
if (!opts.disabled) {
plugins = ensureIndexingPlugin(plugins, resolveIndexingPlugin(req, opts.log))
plugins = ensureAtomicChatPlugin(plugins, resolveAtomicChatPlugin(req, opts.log))
}
cfg.plugin = plugins
// Built-in indexing is not loaded through external plugins and must not wait for their setup.
cfg.plugin_origins = cfg.plugin_origins?.filter((item) => !isIndexingPlugin(item.spec))
return cfg
+2 -1
View File
@@ -92,7 +92,8 @@ function getLegacyPlugins(mod: Record<string, unknown>) {
if (seen.has(entry)) continue
seen.add(entry)
const plugin = getServerPlugin(entry)
if (!plugin) throw new TypeError("Plugin export is not a function")
// kilocode_change: skip named exports (e.g. constants from @kilocode/plugin-atomic-chat)
if (!plugin) continue // kilocode_change
result.push(plugin)
}
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test"
import { hasAtomicChatPlugin } from "@/kilocode/atomic-chat-feature"
import { KilocodeDefaultPlugins } from "@/kilocode/config/default-plugins"
describe("kilocode default atomic chat plugin", () => {
test("apply adds atomic chat plugin when default plugins are enabled", () => {
const cfg = { plugin: [] as string[] }
KilocodeDefaultPlugins.apply(cfg, { disabled: false })
expect(hasAtomicChatPlugin(cfg.plugin ?? [])).toBe(true)
})
test("apply does not add atomic chat plugin when default plugins are disabled", () => {
const cfg = { plugin: ["global-plugin-1"] as string[] }
KilocodeDefaultPlugins.apply(cfg, { disabled: true })
expect(hasAtomicChatPlugin(cfg.plugin ?? [])).toBe(false)
expect(cfg.plugin).toEqual(["global-plugin-1"])
})
test("apply does not duplicate atomic chat plugin", () => {
const cfg = { plugin: ["@kilocode/plugin-atomic-chat"] as string[] }
KilocodeDefaultPlugins.apply(cfg, { disabled: false })
expect(cfg.plugin?.filter((p) => hasAtomicChatPlugin([p])).length).toBe(1)
})
})
@@ -17742,6 +17742,91 @@
}
}
},
"atomic-chat": {
"id": "atomic-chat",
"env": ["ATOMIC_CHAT_API_KEY"],
"npm": "@ai-sdk/openai-compatible",
"api": "http://127.0.0.1:1337/v1",
"name": "Atomic Chat",
"doc": "https://atomic.chat",
"models": {
"gemma-4-E4B-it-IQ4_XS": {
"id": "gemma-4-E4B-it-IQ4_XS",
"name": "Gemma 4 E4B Instruct (IQ4_XS)",
"family": "gemma",
"attachment": false,
"reasoning": false,
"tool_call": false,
"temperature": true,
"release_date": "2026-04-02",
"last_updated": "2026-04-02",
"modalities": {"input":["text"],"output":["text"]},
"open_weights": true,
"cost": {"input":0,"output":0},
"limit": {"context":32768,"output":8192}
},
"gemma-4-E4B-it-MLX-4bit": {
"id": "gemma-4-E4B-it-MLX-4bit",
"name": "Gemma 4 E4B Instruct (MLX 4-bit)",
"family": "gemma",
"attachment": false,
"reasoning": false,
"tool_call": false,
"temperature": true,
"release_date": "2026-04-02",
"last_updated": "2026-04-02",
"modalities": {"input":["text"],"output":["text"]},
"open_weights": true,
"cost": {"input":0,"output":0},
"limit": {"context":32768,"output":8192}
},
"Qwen3_5-9B-Q4_K_M": {
"id": "Qwen3_5-9B-Q4_K_M",
"name": "Qwen 3.5 9B (Q4_K_M)",
"family": "qwen",
"attachment": true,
"reasoning": false,
"tool_call": true,
"temperature": true,
"release_date": "2026-03-05",
"last_updated": "2026-04-04",
"modalities": {"input":["text","image"],"output":["text"]},
"open_weights": true,
"cost": {"input":0,"output":0},
"limit": {"context":32768,"output":8192}
},
"Meta-Llama-3_1-8B-Instruct-GGUF": {
"id": "Meta-Llama-3_1-8B-Instruct-GGUF",
"name": "Meta Llama 3.1 8B Instruct (GGUF)",
"family": "llama",
"attachment": false,
"reasoning": false,
"tool_call": true,
"temperature": true,
"release_date": "2024-07-23",
"last_updated": "2024-07-23",
"modalities": {"input":["text"],"output":["text"]},
"open_weights": true,
"cost": {"input":0,"output":0},
"limit": {"context":131072,"output":4096}
},
"Qwen3_5-9B-MLX-4bit": {
"id": "Qwen3_5-9B-MLX-4bit",
"name": "Qwen 3.5 9B (MLX 4-bit)",
"family": "qwen",
"attachment": true,
"reasoning": false,
"tool_call": true,
"temperature": true,
"release_date": "2026-03-05",
"last_updated": "2026-04-04",
"modalities": {"input":["text","image"],"output":["text"]},
"open_weights": true,
"cost": {"input":0,"output":0},
"limit": {"context":32768,"output":8192}
}
}
},
"lmstudio": {
"id": "lmstudio",
"env": ["LMSTUDIO_API_KEY"],
+34
View File
@@ -0,0 +1,34 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/plugin-atomic-chat",
"version": "0.1.0",
"description": "Kilo Code plugin for Atomic Chat: auto-detection and dynamic model discovery (OpenAI-compatible local API)",
"type": "module",
"license": "MIT",
"exports": {
".": "./src/index.ts"
},
"files": [
"src"
],
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@kilocode/plugin": "workspace:*"
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:",
"vitest": "^4.0.16"
},
"repository": {
"type": "git",
"url": "https://github.com/Kilo-Org/kilocode",
"directory": "packages/plugin-atomic-chat"
}
}
@@ -0,0 +1,115 @@
import type { CacheStats } from '../types'
import { LOG_PREFIX } from '../constants'
export class ModelStatusCache {
private cache = new Map<
string,
{
models: string[]
timestamp: number
ttl: number
}
>()
private readonly DEFAULT_TTL = 15000
private readonly MAX_CACHE_SIZE = 50
async getModels(baseURL: string, fetchFn: () => Promise<string[]>): Promise<string[]> {
const now = Date.now()
const cached = this.cache.get(baseURL)
if (cached && now - cached.timestamp < cached.ttl) {
return cached.models
}
try {
const models = await fetchFn()
this.cache.set(baseURL, {
models: [...models],
timestamp: now,
ttl: this.DEFAULT_TTL,
})
if (this.cache.size > this.MAX_CACHE_SIZE) {
this.cleanup()
}
return models
} catch (error) {
if (cached) {
console.warn(`${LOG_PREFIX} Using stale cache data due to fetch error`, {
baseURL,
age: now - cached.timestamp,
error: error instanceof Error ? error.message : String(error),
})
if (now - cached.timestamp > cached.ttl * 5) {
this.invalidate(baseURL)
}
return cached.models
}
throw error
}
}
invalidate(baseURL: string): void {
this.cache.delete(baseURL)
}
invalidateAll(): void {
this.cache.clear()
}
async forceRefresh(baseURL: string, fetchFn: () => Promise<string[]>): Promise<string[]> {
this.invalidate(baseURL)
return this.getModels(baseURL, fetchFn)
}
getStats(): CacheStats {
const now = Date.now()
return {
size: this.cache.size,
entries: Array.from(this.cache.entries()).map(([baseURL, data]) => ({
baseURL,
age: now - data.timestamp,
modelCount: data.models.length,
ttl: data.ttl,
})),
}
}
private cleanup(): void {
const now = Date.now()
const entries = Array.from(this.cache.entries())
for (const [baseURL, data] of entries) {
if (now - data.timestamp > data.ttl * 5) {
this.cache.delete(baseURL)
}
}
if (this.cache.size <= this.MAX_CACHE_SIZE) {
return
}
const sorted = Array.from(this.cache.entries()).sort(
(a, b) => a[1].timestamp - b[1].timestamp
)
const excess = this.cache.size - this.MAX_CACHE_SIZE
for (let i = 0; i < excess; i++) {
this.cache.delete(sorted[i]![0])
}
}
setTTL(baseURL: string, ttl: number): void {
const cached = this.cache.get(baseURL)
if (cached) {
cached.ttl = ttl
}
}
isValid(baseURL: string): boolean {
const cached = this.cache.get(baseURL)
const now = Date.now()
return cached !== undefined && now - cached.timestamp < cached.ttl
}
}
@@ -0,0 +1,4 @@
import { ModelStatusCache } from './model-status-cache'
/** Single shared cache for model list lookups across plugin hooks. */
export const sharedModelStatusCache = new ModelStatusCache()
@@ -0,0 +1,11 @@
/** Provider id and `kilo.json` key (see https://atomic.chat and models.dev). */
export const ATOMIC_CHAT_PROVIDER_KEY = 'atomic-chat' as const
export const DEFAULT_ATOMIC_CHAT_ORIGIN = 'http://127.0.0.1:1337'
/** Ports tried when auto-detecting a running Atomic Chat API (default is 1337). */
export const ATOMIC_CHAT_PROBE_PORTS = [1337, 1338] as const
export const LOG_PREFIX = '[@kilocode/plugin-atomic-chat]' as const
export const ATOMIC_CHAT_PLUGIN = '@kilocode/plugin-atomic-chat' as const
+12
View File
@@ -0,0 +1,12 @@
import { AtomicChatPlugin } from './plugin'
export { AtomicChatPlugin }
export {
ATOMIC_CHAT_PROVIDER_KEY,
ATOMIC_CHAT_PLUGIN,
DEFAULT_ATOMIC_CHAT_ORIGIN,
ATOMIC_CHAT_PROBE_PORTS,
LOG_PREFIX,
} from './constants'
export default AtomicChatPlugin
@@ -0,0 +1,14 @@
import type { Hooks } from '@kilocode/plugin'
import { ATOMIC_CHAT_PROVIDER_KEY } from '../constants'
export function createAuthHook(): NonNullable<Hooks['auth']> {
return {
provider: ATOMIC_CHAT_PROVIDER_KEY,
methods: [
{
type: 'api',
label: 'Local server',
},
],
}
}
@@ -0,0 +1,131 @@
import { sharedModelStatusCache } from '../cache/shared-model-status-cache'
import { ToastNotifier } from '../ui/toast-notifier'
import { findSimilarModels, retryWithBackoff, categorizeError, generateAutoFixSuggestions } from '../utils'
import { getLoadedModels } from './get-loaded-models'
import { normalizeBaseURL } from '../utils/atomic-chat-api'
import { isPluginHookInput, isAtomicChatProvider, isValidModel } from '../utils/validation'
import { DEFAULT_ATOMIC_CHAT_ORIGIN, LOG_PREFIX } from '../constants'
export function createChatParamsHook(toastNotifier: ToastNotifier) {
return async (input: any, output: any) => {
if (!isPluginHookInput(input)) {
console.error(`${LOG_PREFIX} Invalid chat.params input`)
return
}
const { model, provider } = input
if (!isValidModel(model)) {
console.error(`${LOG_PREFIX} Invalid model object`)
return
}
if (!isAtomicChatProvider(provider)) {
return
}
const baseURL = normalizeBaseURL(provider.options?.baseURL || DEFAULT_ATOMIC_CHAT_ORIGIN)
let lastLoadedModels: string[] = []
let validationAttempt = 0
const validationResult = await retryWithBackoff(
async () => {
const refresh = validationAttempt > 0
validationAttempt++
const loadedModels = await getLoadedModels(baseURL, { refresh })
lastLoadedModels = loadedModels
if (!loadedModels.includes(model.id)) {
throw new Error(`Model '${model.id}' not loaded`)
}
return loadedModels
},
3,
500
)
if (!validationResult.success || !validationResult.result) {
const errorCategory = categorizeError(validationResult.error || 'Validation operation failed', {
baseURL,
modelId: model.id,
})
const autoFixSuggestions = generateAutoFixSuggestions(errorCategory)
console.warn(`${LOG_PREFIX} Model validation failed`, {
model: model.id,
error: validationResult.error,
errorType: errorCategory.type,
baseURL,
})
const availableModels =
errorCategory.type === 'offline' ? [] : lastLoadedModels.length > 0 ? lastLoadedModels : []
const similarModels = findSimilarModels(model.id, availableModels)
await toastNotifier.error(
`Model '${model.id}' not ready: ${errorCategory.message}`,
'Model Validation Failed',
8000
)
if (!output.options) {
output.options = {}
}
output.options.atomicChatValidation = {
status: 'error',
model: model.id,
availableModels,
errorCategory: errorCategory.type,
severity: errorCategory.severity,
message: errorCategory.message,
canRetry: errorCategory.canRetry,
autoFixAvailable: errorCategory.autoFixAvailable,
autoFixSuggestions,
steps:
errorCategory.type === 'not_found'
? [
'1. Open Atomic Chat',
'2. Load the model you want to use',
'3. Confirm curl http://127.0.0.1:1337/v1/models lists that model id',
'4. Retry in Kilo Code',
]
: [
'1. Ensure Atomic Chat is running',
'2. Verify the API URL in kilo.json matches Atomic Chat settings',
'3. Retry your request',
],
similarModels: similarModels.map((item) => ({
model: item.model,
similarity: Math.round(item.similarity * 100),
reason: item.reason,
})),
}
} else {
const cacheStats = sharedModelStatusCache.getStats()
const cacheEntry = cacheStats.entries.find((entry) => entry.baseURL === baseURL)
const cacheAge = cacheEntry ? cacheEntry.age : 0
const loadedModels = validationResult.result || []
if (!output.options) {
output.options = {}
}
output.options.atomicChatValidation = {
status: 'success',
model: model.id,
availableModels: loadedModels,
message: `Model '${model.id}' is listed by Atomic Chat and ready.`,
cacheInfo: {
age: cacheAge,
valid: sharedModelStatusCache.isValid(baseURL),
totalCacheEntries: cacheStats.size,
},
performanceHint:
loadedModels.length > 1
? `Note: ${loadedModels.length} models reported. Unload unused models in Atomic Chat if performance suffers.`
: cacheAge > 20000
? `Cache is ${Math.round(cacheAge / 1000)}s old; refresh if model status seems wrong.`
: undefined,
}
}
}
}
@@ -0,0 +1,57 @@
import { ToastNotifier } from '../ui/toast-notifier'
import { validateConfig } from '../utils/validation'
import { enhanceConfig, shouldProbeAtomicChat } from './enhance-config'
import type { PluginInput } from '@kilocode/plugin'
import { ATOMIC_CHAT_PROVIDER_KEY, LOG_PREFIX } from '../constants'
const CONFIG_DISCOVERY_TIMEOUT_MS = 5000
export function createConfigHook(client: PluginInput['client'], toastNotifier: ToastNotifier) {
return async (config: any) => {
const section = config?.provider?.[ATOMIC_CHAT_PROVIDER_KEY]
const initialModelCount = section?.models ? Object.keys(section.models).length : 0
if (config && (Object.isFrozen?.(config) || Object.isSealed?.(config))) {
console.warn(`${LOG_PREFIX} Config object is frozen/sealed - cannot modify directly`)
return
}
const validation = validateConfig(config)
if (!validation.isValid) {
console.error(`${LOG_PREFIX} Invalid config provided:`, validation.errors)
toastNotifier.error('Plugin configuration is invalid', 'Configuration Error').catch((err) => {
console.warn(`${LOG_PREFIX} Failed to show configuration error toast`, {
error: err instanceof Error ? err.message : String(err),
})
})
return
}
if (validation.warnings.length > 0) {
console.warn(`${LOG_PREFIX} Config warnings:`, validation.warnings)
}
if (!shouldProbeAtomicChat(config)) {
return
}
const abort = new AbortController()
const timeout = setTimeout(() => abort.abort(), CONFIG_DISCOVERY_TIMEOUT_MS)
try {
await enhanceConfig(config, client, toastNotifier, abort.signal)
} catch (error) {
console.error(`${LOG_PREFIX} Config enhancement failed:`, error)
} finally {
clearTimeout(timeout)
}
const finalSection = config?.provider?.[ATOMIC_CHAT_PROVIDER_KEY]
const finalModelCount = finalSection?.models ? Object.keys(finalSection.models).length : 0
if (finalModelCount === 0 && finalSection) {
console.warn(`${LOG_PREFIX} No models discovered — Atomic Chat may be offline or no model loaded`)
} else if (finalModelCount > 0) {
console.log(`${LOG_PREFIX} Loaded ${finalModelCount} models (was ${initialModelCount})`)
}
}
}
@@ -0,0 +1,181 @@
import { sharedModelStatusCache } from '../cache/shared-model-status-cache'
import { ToastNotifier } from '../ui/toast-notifier'
import { categorizeModel, formatModelName, extractModelOwner } from '../utils'
import { normalizeBaseURL, fetchModelsEndpoint, autoDetectAtomicChat } from '../utils/atomic-chat-api'
import {
getAtomicSection,
hasAtomicChatProviderSection,
isAtomicChatAutoDetectEnabled,
shouldProbeAtomicChat,
} from '../utils/should-probe-atomic-chat'
import type { PluginInput } from '@kilocode/plugin'
import type { AtomicChatModel } from '../types'
import { ATOMIC_CHAT_PROVIDER_KEY, DEFAULT_ATOMIC_CHAT_ORIGIN, LOG_PREFIX } from '../constants'
export { shouldProbeAtomicChat } from '../utils/should-probe-atomic-chat'
function setAtomicSection(config: any, value: Record<string, unknown>) {
if (!config.provider) {
config.provider = {}
}
config.provider[ATOMIC_CHAT_PROVIDER_KEY] = value
}
export async function enhanceConfig(
config: any,
_client: PluginInput['client'],
toastNotifier: ToastNotifier,
signal?: AbortSignal
): Promise<void> {
if (!shouldProbeAtomicChat(config) || signal?.aborted) {
return
}
try {
let atomicProvider = getAtomicSection(config)
let baseURL: string
let models: AtomicChatModel[] | undefined
if (atomicProvider) {
baseURL = normalizeBaseURL(atomicProvider.options?.baseURL || DEFAULT_ATOMIC_CHAT_ORIGIN)
} else if (isAtomicChatAutoDetectEnabled(config)) {
const detected = await autoDetectAtomicChat(signal)
if (!detected || signal?.aborted) {
return
}
baseURL = detected.baseURL
models = detected.models
setAtomicSection(config, {
npm: '@ai-sdk/openai-compatible',
name: 'Atomic Chat (local)',
options: {
baseURL: `${baseURL}/v1`,
},
models: {},
})
atomicProvider = getAtomicSection(config)
} else {
baseURL = normalizeBaseURL(DEFAULT_ATOMIC_CHAT_ORIGIN)
setAtomicSection(config, {
npm: '@ai-sdk/openai-compatible',
name: 'Atomic Chat (local)',
options: {
baseURL: `${baseURL}/v1`,
},
models: {},
})
atomicProvider = getAtomicSection(config)
}
if (signal?.aborted) {
return
}
if (models === undefined) {
try {
const result = await fetchModelsEndpoint(baseURL, signal)
if (!result.ok) {
console.warn(`${LOG_PREFIX} Atomic Chat API appears unreachable`, { baseURL })
return
}
models = result.models
} catch (error) {
console.warn(`${LOG_PREFIX} Atomic Chat API appears unreachable`, {
baseURL,
error: error instanceof Error ? error.message : String(error),
})
return
}
}
if (signal?.aborted) {
return
}
if (models.length > 0) {
const existingModels = atomicProvider?.models || {}
const discoveredModels: Record<string, any> = {}
let chatModelsCount = 0
let embeddingModelsCount = 0
for (const model of models) {
let modelKey = model.id
if (!/^[a-zA-Z0-9_-]+$/.test(modelKey)) {
modelKey = model.id.replace(/[^a-zA-Z0-9_-]/g, '_')
}
if (!existingModels[modelKey] && !existingModels[model.id]) {
const modelType = categorizeModel(model.id)
const owner = extractModelOwner(model.id)
const modelConfig: any = {
id: model.id,
name: formatModelName(model),
}
if (owner) {
modelConfig.organizationOwner = owner
}
if (modelType === 'embedding') {
embeddingModelsCount++
modelConfig.modalities = {
input: ['text'],
output: ['embedding'],
}
} else if (modelType === 'chat') {
chatModelsCount++
modelConfig.modalities = {
input: ['text', 'image'],
output: ['text'],
}
}
discoveredModels[modelKey] = modelConfig
}
}
if (Object.keys(discoveredModels).length > 0) {
const section = getAtomicSection(config)
if (!section) {
return
}
section.models = {
...existingModels,
...discoveredModels,
}
if (chatModelsCount === 0 && embeddingModelsCount > 0) {
console.warn(
`${LOG_PREFIX} Only embedding-style models detected; load a chat model in Atomic Chat for coding agents.`
)
}
}
} else {
console.warn(`${LOG_PREFIX} No models returned from Atomic Chat. Load a model and ensure the server is running.`)
}
if (
!signal?.aborted &&
(hasAtomicChatProviderSection(config) || isAtomicChatAutoDetectEnabled(config)) &&
models.length > 0
) {
try {
const modelIds = models.map((m) => m.id)
await sharedModelStatusCache.getModels(baseURL, async () => modelIds)
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to warm model status cache`, {
error: err instanceof Error ? err.message : String(err),
})
}
}
} catch (error) {
console.error(`${LOG_PREFIX} Unexpected error in enhanceConfig:`, error)
toastNotifier
.warning('Plugin configuration failed', 'Configuration Error')
.catch((err) => {
console.warn(`${LOG_PREFIX} Failed to show configuration warning toast`, {
error: err instanceof Error ? err.message : String(err),
})
})
}
}
@@ -0,0 +1,16 @@
import { validateHookInput } from '../utils/validation'
import { LOG_PREFIX } from '../constants'
export function createEventHook() {
return async ({ event }: { event: any }) => {
const validation = validateHookInput('event', { event })
if (!validation.isValid) {
console.error(`${LOG_PREFIX} Invalid event input:`, validation.errors)
return
}
if (event.type === 'session.created' || event.type === 'session.updated') {
// reserved for future health hooks
}
}
}
@@ -0,0 +1,18 @@
import { sharedModelStatusCache } from '../cache/shared-model-status-cache'
import { fetchModelsDirect } from '../utils/atomic-chat-api'
import { DEFAULT_ATOMIC_CHAT_ORIGIN } from '../constants'
async function fetchLoadedModelIds(baseURL: string): Promise<string[]> {
return await fetchModelsDirect(baseURL)
}
export function getLoadedModels(
baseURL: string = DEFAULT_ATOMIC_CHAT_ORIGIN,
options?: { refresh?: boolean }
): Promise<string[]> {
const fetchFn = () => fetchLoadedModelIds(baseURL)
if (options?.refresh) {
return sharedModelStatusCache.forceRefresh(baseURL, fetchFn)
}
return sharedModelStatusCache.getModels(baseURL, fetchFn)
}
@@ -0,0 +1,31 @@
import type { Plugin, PluginInput } from '@kilocode/plugin'
import { ToastNotifier } from '../ui/toast-notifier'
import { createConfigHook } from './config-hook'
import { createEventHook } from './event-hook'
import { createChatParamsHook } from './chat-params-hook'
import { createAuthHook } from './auth-hook'
import { LOG_PREFIX } from '../constants'
export const AtomicChatPlugin: Plugin = async (input: PluginInput) => {
console.log(`${LOG_PREFIX} Atomic Chat plugin initialized`)
const { client } = input
if (!client || typeof client !== 'object') {
console.error(`${LOG_PREFIX} Invalid client provided to plugin`)
return {
config: async () => {},
event: async () => {},
'chat.params': async () => {},
}
}
const toastNotifier = new ToastNotifier(client)
return {
auth: createAuthHook(),
config: createConfigHook(client, toastNotifier),
event: createEventHook(),
'chat.params': createChatParamsHook(toastNotifier),
}
}
@@ -0,0 +1,47 @@
// OpenAI-compatible /v1/models entry
export interface AtomicChatModel {
id: string
object: string
created: number
owned_by: string
}
export interface AtomicChatModelsResponse {
object: string
data: AtomicChatModel[]
}
export type ModelType = 'chat' | 'embedding' | 'unknown'
export type LoadingStatus = 'not_loaded' | 'loading' | 'loaded' | 'error'
export interface ModelValidationError {
type: 'offline' | 'not_found' | 'network' | 'permission' | 'timeout' | 'unknown'
severity: 'low' | 'medium' | 'high' | 'critical'
message: string
canRetry: boolean
autoFixAvailable: boolean
}
export interface AutoFixSuggestion {
action: string
command?: string
steps?: string[]
automated: boolean
}
export interface SimilarModel {
model: string
similarity: number
reason: string
}
export interface CacheStats {
size: number
entries: Array<{
baseURL: string
age: number
modelCount: number
ttl: number
}>
}
@@ -0,0 +1,81 @@
import { LOG_PREFIX } from '../constants'
export class ToastNotifier {
constructor(private readonly client: any) {}
async success(message: string, title?: string, duration?: number): Promise<void> {
try {
if (!this.client?.tui?.showToast) {
console.warn(`${LOG_PREFIX} Toast API not available (client.tui.showToast missing)`)
return
}
await this.client.tui.showToast({
body: {
title,
message,
variant: 'success',
duration: duration || 3000,
},
})
} catch (error) {
console.error(`${LOG_PREFIX} Failed to show success toast`, error)
}
}
async error(message: string, title?: string, duration?: number): Promise<void> {
try {
if (!this.client?.tui?.showToast) {
console.warn(`${LOG_PREFIX} Toast API not available (client.tui.showToast missing)`)
return
}
await this.client.tui.showToast({
body: {
title,
message,
variant: 'error',
duration: duration || 5000,
},
})
} catch (error) {
console.error(`${LOG_PREFIX} Failed to show error toast`, error)
}
}
async warning(message: string, title?: string, duration?: number): Promise<void> {
try {
if (!this.client?.tui?.showToast) {
console.warn(`${LOG_PREFIX} Toast API not available (client.tui.showToast missing)`)
return
}
await this.client.tui.showToast({
body: {
title,
message,
variant: 'warning',
duration: duration || 4000,
},
})
} catch (error) {
console.error(`${LOG_PREFIX} Failed to show warning toast`, error)
}
}
async progress(message: string, title?: string, progress?: number): Promise<void> {
try {
if (!this.client?.tui?.showToast) {
console.warn(`${LOG_PREFIX} Toast API not available (client.tui.showToast missing)`)
return
}
await this.client.tui.showToast({
body: {
title,
message: progress !== undefined ? `${message} (${progress}%)` : message,
variant: 'info',
duration: progress !== undefined ? 0 : 2000,
},
})
} catch (error) {
console.error(`${LOG_PREFIX} Failed to show progress toast`, error)
}
}
}
@@ -0,0 +1,118 @@
import type { AtomicChatModel, AtomicChatModelsResponse } from '../types'
import { ATOMIC_CHAT_PROBE_PORTS, DEFAULT_ATOMIC_CHAT_ORIGIN, LOG_PREFIX } from '../constants'
const MODELS_ENDPOINT = '/v1/models'
const FETCH_TIMEOUT_MS = 3000
export function normalizeBaseURL(baseURL: string = DEFAULT_ATOMIC_CHAT_ORIGIN): string {
let normalized = baseURL.replace(/\/+$/, '')
if (normalized.endsWith('/v1')) {
normalized = normalized.slice(0, -3)
}
return normalized
}
export function buildAPIURL(baseURL: string, endpoint: string = MODELS_ENDPOINT): string {
const normalized = normalizeBaseURL(baseURL)
return `${normalized}${endpoint}`
}
export type ModelsEndpointResult = {
ok: boolean
models: AtomicChatModel[]
}
export type AutoDetectResult = {
baseURL: string
models: AtomicChatModel[]
}
function fetchSignal(outer?: AbortSignal): AbortSignal {
const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS)
if (!outer) {
return timeout
}
return AbortSignal.any([outer, timeout])
}
/** Single GET /v1/models — shared by discovery, health, auto-detect, and chat validation. */
export async function fetchModelsEndpoint(
baseURL: string,
signal?: AbortSignal
): Promise<ModelsEndpointResult> {
const url = buildAPIURL(baseURL)
const response = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
signal: fetchSignal(signal),
})
if (!response.ok) {
return { ok: false, models: [] }
}
const data = (await response.json()) as AtomicChatModelsResponse
return { ok: true, models: data.data ?? [] }
}
export async function checkAtomicChatHealth(
baseURL: string = DEFAULT_ATOMIC_CHAT_ORIGIN,
signal?: AbortSignal
): Promise<boolean> {
try {
const { ok } = await fetchModelsEndpoint(baseURL, signal)
return ok
} catch (error) {
console.warn(`${LOG_PREFIX} Health check failed`, {
baseURL,
error: error instanceof Error ? error.message : String(error),
})
return false
}
}
export async function discoverAtomicChatModels(
baseURL: string = DEFAULT_ATOMIC_CHAT_ORIGIN,
signal?: AbortSignal
): Promise<AtomicChatModel[]> {
try {
const { ok, models } = await fetchModelsEndpoint(baseURL, signal)
return ok ? models : []
} catch (error) {
console.warn(`${LOG_PREFIX} Model discovery failed`, {
baseURL,
error: error instanceof Error ? error.message : String(error),
})
return []
}
}
export async function fetchModelsDirect(
baseURL: string = DEFAULT_ATOMIC_CHAT_ORIGIN,
signal?: AbortSignal
): Promise<string[]> {
const { ok, models } = await fetchModelsEndpoint(baseURL, signal)
if (!ok) {
throw new Error('Atomic Chat models endpoint returned a non-success status')
}
return models.map((model) => model.id)
}
/** Probes local ports; returns the first reachable server and its model list (one HTTP call). */
export async function autoDetectAtomicChat(signal?: AbortSignal): Promise<AutoDetectResult | null> {
for (const port of ATOMIC_CHAT_PROBE_PORTS) {
if (signal?.aborted) {
return null
}
const baseURL = `http://127.0.0.1:${port}`
try {
const { ok, models } = await fetchModelsEndpoint(baseURL, signal)
if (ok) {
return { baseURL, models }
}
} catch (error) {
console.warn(`${LOG_PREFIX} Auto-detect probe failed for port ${port}`, {
error: error instanceof Error ? error.message : String(error),
})
}
}
return null
}
@@ -0,0 +1,42 @@
import type { AtomicChatModel } from '../types'
export function extractModelOwner(modelId: string): string | undefined {
const parts = modelId.split('/')
if (parts.length > 1) {
return parts[0]
}
return undefined
}
export function formatModelName(model: AtomicChatModel): string {
const { id } = model
const parts = id.split('/')
const modelPart = parts.length > 1 ? parts[1] : parts[0]
const acronyms = new Set(['gpt', 'oss', 'api', 'gguf', 'ggml', 'nomic', 'vl', 'it', 'mlx'])
const tokens = modelPart
.split(/[-_]/)
.filter(Boolean)
.map((token) => {
const lowerToken = token.toLowerCase()
if (acronyms.has(lowerToken)) {
return token.toUpperCase()
}
if (/^\d+[bkmg]$/i.test(token)) {
return token.toUpperCase()
}
if (/^q\d+$/i.test(token)) {
return token.toUpperCase()
}
if (/^\d+\.\d+/.test(token)) {
return token
}
if (/^[a-z]\d+[a-z]$/i.test(token) || /^\d+[a-z]$/i.test(token)) {
return token.toUpperCase()
}
return token.charAt(0).toUpperCase() + token.slice(1).toLowerCase()
})
.join(' ')
return tokens
}
@@ -0,0 +1,206 @@
import type { ModelValidationError, AutoFixSuggestion, SimilarModel } from '../types'
import { LOG_PREFIX } from '../constants'
export { formatModelName, extractModelOwner } from './format-model-name'
export function categorizeModel(modelId: string): 'chat' | 'embedding' | 'unknown' {
const lowerId = modelId.toLowerCase()
if (lowerId.includes('embedding') || lowerId.includes('embed')) {
return 'embedding'
}
if (
lowerId.includes('gpt') ||
lowerId.includes('llama') ||
lowerId.includes('claude') ||
lowerId.includes('qwen') ||
lowerId.includes('mistral') ||
lowerId.includes('gemma') ||
lowerId.includes('phi') ||
lowerId.includes('falcon') ||
lowerId.includes('deepseek')
) {
return 'chat'
}
return 'unknown'
}
export function findSimilarModels(targetModel: string, availableModels: string[]): SimilarModel[] {
const target = targetModel.toLowerCase()
const targetTokens = target.split(/[-_\s]/).filter(Boolean)
return availableModels
.map((model) => {
const candidate = model.toLowerCase()
const candidateTokens = candidate.split(/[-_\s]/).filter(Boolean)
let similarity = 0
const reasons: string[] = []
if (candidate === target) {
similarity = 1.0
reasons.push('Exact match')
}
const targetPrefix = targetTokens[0]
const candidatePrefix = candidateTokens[0]
if (targetPrefix && candidatePrefix && targetPrefix === candidatePrefix) {
similarity += 0.5
reasons.push(`Same family: ${targetPrefix}`)
}
const commonSuffixes = ['3b', '7b', '13b', '70b', 'q4', 'q8', 'instruct', 'chat', 'base']
for (const suffix of commonSuffixes) {
if (target.includes(suffix) && candidate.includes(suffix)) {
similarity += 0.2
reasons.push(`Shared suffix: ${suffix}`)
}
}
const commonTokens = targetTokens.filter((token) => candidateTokens.includes(token))
if (commonTokens.length > 0) {
similarity += (commonTokens.length / Math.max(targetTokens.length, candidateTokens.length)) * 0.3
reasons.push(`Common tokens: ${commonTokens.join(', ')}`)
}
return {
model,
similarity: Math.min(similarity, 1.0),
reason: reasons.join(', '),
}
})
.filter((item) => item.similarity > 0.1)
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 5)
}
export async function retryWithBackoff<T>(
operation: () => Promise<T>,
maxAttempts: number = 3,
baseDelay: number = 1000
): Promise<{ success: boolean; result?: T; error?: string }> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const result = await operation()
return { success: true, result }
} catch (error) {
if (attempt === maxAttempts - 1) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
}
}
const delay = baseDelay * Math.pow(2, attempt)
console.warn(`${LOG_PREFIX} Retrying operation after ${delay}ms`, {
attempt: attempt + 1,
maxAttempts,
error: error instanceof Error ? error.message : String(error),
})
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
return { success: false, error: 'Max attempts exceeded' }
}
export function categorizeError(error: unknown, context: { baseURL: string; modelId: string }): ModelValidationError {
const errorStr = String(error).toLowerCase()
const { baseURL, modelId } = context
if (
errorStr.includes('econnrefused') ||
errorStr.includes('fetch failed') ||
errorStr.includes('failed to fetch') ||
errorStr.includes('network')
) {
return {
type: 'offline',
severity: 'critical',
message: `Cannot reach Atomic Chat at ${baseURL}. Start Atomic Chat and enable the local OpenAI-compatible server.`,
canRetry: true,
autoFixAvailable: true,
}
}
if (errorStr.includes('timeout') || errorStr.includes('aborted')) {
return {
type: 'timeout',
severity: 'medium',
message: `Request to Atomic Chat timed out.`,
canRetry: true,
autoFixAvailable: false,
}
}
if (
errorStr.includes('404') ||
errorStr.includes('not found') ||
errorStr.includes('not loaded')
) {
return {
type: 'not_found',
severity: 'high',
message: `Model '${modelId}' is not loaded in Atomic Chat. Load it and confirm GET /v1/models lists it.`,
canRetry: false,
autoFixAvailable: false,
}
}
if (errorStr.includes('401') || errorStr.includes('403') || errorStr.includes('unauthorized')) {
return {
type: 'permission',
severity: 'high',
message: `Authentication or permission issue with Atomic Chat.`,
canRetry: false,
autoFixAvailable: false,
}
}
return {
type: 'unknown',
severity: 'medium',
message: `Unexpected error: ${errorStr}`,
canRetry: true,
autoFixAvailable: false,
}
}
export function generateAutoFixSuggestions(errorCategory: ModelValidationError): AutoFixSuggestion[] {
const suggestions: AutoFixSuggestion[] = []
switch (errorCategory.type) {
case 'offline':
suggestions.push({
action: 'Start Atomic Chat',
steps: [
'1. Open the Atomic Chat application',
'2. Ensure the local API server is running (default http://127.0.0.1:1337/v1)',
'3. Check firewall settings if the port is blocked',
],
automated: false,
})
break
case 'not_found':
suggestions.push({
action: 'Load a model in Atomic Chat',
steps: [
'1. Open Atomic Chat',
'2. Download or select a model and load it',
'3. Run curl http://127.0.0.1:1337/v1/models to verify the model id',
'4. Retry in Kilo Code',
],
automated: false,
})
break
case 'timeout':
suggestions.push({
action: 'Retry or reduce load',
steps: [
'1. Try a smaller / faster model',
'2. Close other heavy apps',
'3. Retry the request',
],
automated: false,
})
break
}
return suggestions
}
@@ -0,0 +1,55 @@
import { ATOMIC_CHAT_PROVIDER_KEY } from '../constants'
export function getAtomicSection(config: any) {
return config?.provider?.[ATOMIC_CHAT_PROVIDER_KEY]
}
/** User added `provider.atomic-chat` in kilo.json (explicit opt-in). */
export function hasAtomicChatProviderSection(config: any): boolean {
return Boolean(getAtomicSection(config))
}
/** Opt-in localhost probing without a full provider block. */
export function isAtomicChatAutoDetectEnabled(config: any): boolean {
return config?.atomicChat?.autoDetect === true
}
function modelRefUsesAtomicChat(ref: unknown): boolean {
if (typeof ref === 'string') {
return ref.startsWith(`${ATOMIC_CHAT_PROVIDER_KEY}/`)
}
if (!ref || typeof ref !== 'object') {
return false
}
const record = ref as Record<string, unknown>
return record.providerID === ATOMIC_CHAT_PROVIDER_KEY
}
/** Default or per-agent model points at Atomic Chat (explicit opt-in). */
export function isAtomicChatModelSelected(config: any): boolean {
if (modelRefUsesAtomicChat(config?.model)) {
return true
}
const modes = config?.model
if (!modes || typeof modes !== 'object' || Array.isArray(modes)) {
return false
}
for (const value of Object.values(modes)) {
if (modelRefUsesAtomicChat(value)) {
return true
}
}
return false
}
/**
* Network discovery (health check, GET /v1/models) runs only when the user opted in.
* Avoids localhost HTTP for installs that never configure Atomic Chat.
*/
export function shouldProbeAtomicChat(config: any): boolean {
return (
hasAtomicChatProviderSection(config) ||
isAtomicChatAutoDetectEnabled(config) ||
isAtomicChatModelSelected(config)
)
}
@@ -0,0 +1,5 @@
export type { ValidationResult } from './validation-result'
export { validateConfig } from './validate-config'
export { validateHookInput } from './validate-hook-input'
export { isPluginHookInput, isAtomicChatProvider, isValidModel } from './type-guards'
export { safeAsyncOperation } from './safe-operations'
@@ -0,0 +1,13 @@
export async function safeAsyncOperation<T>(
operation: () => Promise<T>,
fallback?: T,
onError?: (error: Error) => void
): Promise<T | undefined> {
try {
return await operation()
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
onError?.(err)
return fallback
}
}
@@ -0,0 +1,25 @@
import { ATOMIC_CHAT_PROVIDER_KEY } from '../../constants'
export function isPluginHookInput(input: any): input is {
sessionID?: string
agent?: string
model?: any
provider?: any
message?: any
event?: any
} {
return input && typeof input === 'object'
}
export function isAtomicChatProvider(provider: any): boolean {
return (
provider &&
typeof provider === 'object' &&
provider.info &&
provider.info.id === ATOMIC_CHAT_PROVIDER_KEY
)
}
export function isValidModel(model: any): model is { id: string; [key: string]: any } {
return model && typeof model === 'object' && typeof model.id === 'string' && model.id.length > 0
}
@@ -0,0 +1,57 @@
import type { ValidationResult } from './validation-result'
import { ATOMIC_CHAT_PROVIDER_KEY } from '../../constants'
export function validateConfig(config: any): ValidationResult {
const errors: string[] = []
const warnings: string[] = []
if (!config || typeof config !== 'object') {
errors.push('Config must be an object')
return { isValid: false, errors, warnings }
}
if (config.provider && typeof config.provider === 'object') {
const atomic = config.provider[ATOMIC_CHAT_PROVIDER_KEY]
if (atomic) {
if (!atomic.npm) {
atomic.npm = '@ai-sdk/openai-compatible'
warnings.push(`Atomic Chat provider missing npm field, auto-set to @ai-sdk/openai-compatible`)
}
if (!atomic.name) {
atomic.name = 'Atomic Chat (local)'
warnings.push('Atomic Chat provider missing name field, auto-set to "Atomic Chat (local)"')
}
if (!atomic.options) {
atomic.options = {}
warnings.push('Atomic Chat provider missing options field, auto-created empty options')
} else {
if (!atomic.options.baseURL) {
warnings.push('Atomic Chat provider missing baseURL, will use default')
} else if (typeof atomic.options.baseURL !== 'string') {
errors.push('Atomic Chat provider baseURL must be a string')
} else if (!isValidURL(atomic.options.baseURL)) {
warnings.push('Atomic Chat provider baseURL may be invalid')
}
}
if (atomic.models && typeof atomic.models !== 'object') {
errors.push('Atomic Chat provider models must be an object')
}
}
}
return {
isValid: errors.length === 0,
errors,
warnings,
}
}
function isValidURL(url: string): boolean {
try {
new URL(url)
return true
} catch {
return false
}
}
@@ -0,0 +1,47 @@
import type { ValidationResult } from './validation-result'
export function validateHookInput(hookName: string, input: any): ValidationResult {
const errors: string[] = []
const warnings: string[] = []
if (!input || typeof input !== 'object') {
errors.push(`${hookName}: Input must be an object`)
return { isValid: false, errors, warnings }
}
switch (hookName) {
case 'chat.params':
if (!input.sessionID || typeof input.sessionID !== 'string') {
errors.push('chat.params: sessionID is required and must be a string')
}
if (!input.model || typeof input.model !== 'object') {
errors.push('chat.params: model is required and must be an object')
} else {
if (!input.model.id || typeof input.model.id !== 'string') {
errors.push('chat.params: model.id is required and must be a string')
}
}
if (!input.provider || typeof input.provider !== 'object') {
errors.push('chat.params: provider is required and must be an object')
} else {
if (!input.provider.info || !input.provider.info.id) {
warnings.push('chat.params: provider.info.id is missing')
}
}
break
case 'event':
if (!input.event || typeof input.event !== 'object') {
errors.push('event: event is required and must be an object')
} else if (!input.event.type) {
warnings.push('event: event.type is missing')
}
break
}
return {
isValid: errors.length === 0,
errors,
warnings,
}
}
@@ -0,0 +1,5 @@
export interface ValidationResult {
isValid: boolean
errors: string[]
warnings: string[]
}
@@ -0,0 +1,251 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { AtomicChatPlugin } from '../src/index'
import { ATOMIC_CHAT_PROVIDER_KEY } from '../src/constants'
import { sharedModelStatusCache } from '../src/cache/shared-model-status-cache'
const mockFetch = vi.fn()
global.fetch = mockFetch
if (!global.AbortSignal.timeout) {
global.AbortSignal.timeout = vi.fn(() => {
const controller = new AbortController()
setTimeout(() => controller.abort(), 3000)
return controller.signal
})
}
describe('AtomicChatPlugin', () => {
let mockClient: any
let pluginHooks: any
beforeEach(async () => {
mockFetch.mockClear()
sharedModelStatusCache.invalidateAll()
mockClient = {
tui: {
showToast: vi.fn().mockResolvedValue(true),
},
}
const mockInput: any = {
client: mockClient,
project: {
id: 'test-project',
name: 'test',
path: '/tmp',
worktree: '',
time: { created: Date.now() },
},
directory: '/tmp',
worktree: '',
$: vi.fn(),
}
pluginHooks = await AtomicChatPlugin(mockInput)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('initializes hooks', async () => {
const mockInput: any = {
client: mockClient,
project: {
id: 'test-project',
name: 'test',
path: '/tmp',
worktree: '',
time: { created: Date.now() },
},
directory: '/tmp',
worktree: '',
$: vi.fn(),
}
const hooks = await AtomicChatPlugin(mockInput)
expect(hooks.config).toBeTypeOf('function')
expect(hooks.event).toBeTypeOf('function')
expect(hooks['chat.params']).toBeTypeOf('function')
})
it('registers optional local-server auth (no API key required)', async () => {
expect(pluginHooks.auth?.provider).toBe(ATOMIC_CHAT_PROVIDER_KEY)
expect(pluginHooks.auth?.methods[0]?.type).toBe('api')
expect(pluginHooks.auth?.methods[0]?.label).toBe('Local server')
})
it('handles invalid client', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const hooks = await AtomicChatPlugin({ client: null } as any)
expect(hooks.config).toBeTypeOf('function')
expect(consoleSpy).toHaveBeenCalledWith('[@kilocode/plugin-atomic-chat] Invalid client provided to plugin')
consoleSpy.mockRestore()
})
describe('config hook', () => {
it('rejects invalid config', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await pluginHooks.config(null)
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('does not probe localhost when Atomic Chat is not configured', async () => {
const config: any = {}
await pluginHooks.config(config)
expect(mockFetch).not.toHaveBeenCalled()
expect(config.provider?.[ATOMIC_CHAT_PROVIDER_KEY]).toBeUndefined()
})
it('auto-detects only when atomicChat.autoDetect is enabled', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
data: [{ id: 'm1', object: 'model', created: 1, owned_by: 'local' }],
}),
})
const config: any = { atomicChat: { autoDetect: true } }
await pluginHooks.config(config)
expect(mockFetch).toHaveBeenCalled()
expect(config.provider?.[ATOMIC_CHAT_PROVIDER_KEY]).toBeDefined()
expect(config.provider[ATOMIC_CHAT_PROVIDER_KEY].options.baseURL).toBe('http://127.0.0.1:1337/v1')
})
it('merges discovered models', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
data: [{ id: 'new-model', object: 'model', created: 1, owned_by: 'local' }],
}),
})
const config: any = {
provider: {
[ATOMIC_CHAT_PROVIDER_KEY]: {
npm: '@ai-sdk/openai-compatible',
name: 'Atomic Chat (local)',
options: { baseURL: 'http://127.0.0.1:1337/v1' },
models: {
'existing-model': { name: 'Existing Model' },
},
},
},
}
await pluginHooks.config(config)
expect(config.provider[ATOMIC_CHAT_PROVIDER_KEY].models).toEqual({
'existing-model': { name: 'Existing Model' },
'new-model': expect.objectContaining({
id: 'new-model',
name: 'New Model',
}),
})
})
it('handles offline API', async () => {
mockFetch.mockRejectedValue(new Error('Connection refused'))
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const config: any = {
provider: {
[ATOMIC_CHAT_PROVIDER_KEY]: {
npm: '@ai-sdk/openai-compatible',
name: 'Atomic Chat (local)',
options: { baseURL: 'http://127.0.0.1:1337/v1' },
},
},
}
await pluginHooks.config(config)
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
})
describe('event hook', () => {
it('validates event', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await pluginHooks.event({ event: null })
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('accepts session events', async () => {
await pluginHooks.event({ event: { type: 'session.created' } })
expect(true).toBe(true)
})
})
describe('chat.params hook', () => {
it('rejects invalid input', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await pluginHooks['chat.params'](null, {})
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('skips other providers', async () => {
const output: any = {}
await pluginHooks['chat.params'](
{
model: { id: 'x' },
provider: { info: { id: 'anthropic' } },
},
output
)
expect(output).toEqual({})
expect(mockClient.tui.showToast).not.toHaveBeenCalled()
})
it('validates model availability', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
data: [{ id: 'test-model', object: 'model', created: 1, owned_by: 'local' }],
}),
})
const output: any = {}
await pluginHooks['chat.params'](
{
sessionID: 's1',
model: { id: 'test-model' },
provider: {
info: { id: ATOMIC_CHAT_PROVIDER_KEY },
options: { baseURL: 'http://127.0.0.1:1337/v1' },
},
},
output
)
expect(mockClient.tui.showToast).not.toHaveBeenCalled()
expect(output.options?.atomicChatValidation).toEqual(
expect.objectContaining({ status: 'success', model: 'test-model' })
)
})
it('handles missing model', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ data: [] }),
})
const output: any = {}
await pluginHooks['chat.params'](
{
sessionID: 's1',
model: { id: 'missing' },
provider: {
info: { id: ATOMIC_CHAT_PROVIDER_KEY },
options: { baseURL: 'http://127.0.0.1:1337/v1' },
},
},
output
)
expect(output.options?.atomicChatValidation).toEqual(
expect.objectContaining({ status: 'error', model: 'missing' })
)
})
})
})
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import {
shouldProbeAtomicChat,
isAtomicChatAutoDetectEnabled,
hasAtomicChatProviderSection,
} from '../src/utils/should-probe-atomic-chat'
import { ATOMIC_CHAT_PROVIDER_KEY } from '../src/constants'
describe('shouldProbeAtomicChat', () => {
it('returns false for empty config (no localhost HTTP)', () => {
expect(shouldProbeAtomicChat({})).toBe(false)
expect(shouldProbeAtomicChat({ provider: {} })).toBe(false)
})
it('returns true when provider.atomic-chat is configured', () => {
expect(
shouldProbeAtomicChat({
provider: { [ATOMIC_CHAT_PROVIDER_KEY]: { options: { baseURL: 'http://127.0.0.1:1337/v1' } } },
})
).toBe(true)
})
it('returns true when atomicChat.autoDetect is enabled', () => {
expect(shouldProbeAtomicChat({ atomicChat: { autoDetect: true } })).toBe(true)
expect(isAtomicChatAutoDetectEnabled({ atomicChat: { autoDetect: true } })).toBe(true)
})
it('returns true when default model uses atomic-chat', () => {
expect(shouldProbeAtomicChat({ model: 'atomic-chat/gemma-4-E4B-it-IQ4_XS' })).toBe(true)
})
it('returns true when per-agent model uses atomic-chat', () => {
expect(
shouldProbeAtomicChat({
model: {
code: { providerID: ATOMIC_CHAT_PROVIDER_KEY, modelID: 'gemma-4-E4B-it-IQ4_XS' },
},
})
).toBe(true)
})
it('hasAtomicChatProviderSection reflects provider block only', () => {
expect(hasAtomicChatProviderSection({})).toBe(false)
expect(hasAtomicChatProviderSection({ provider: { [ATOMIC_CHAT_PROVIDER_KEY]: {} } })).toBe(true)
})
})
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"lib": ["es2022", "dom", "dom.iterable"],
"types": ["node"]
},
"include": ["src/**/*", "test/**/*"]
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.4 KiB

@@ -1859,6 +1859,15 @@ OjAwvs/J9QAAACh0RVh0ZGF0ZTp0aW1lc3RhbXAAMjAyNi0wMy0yNVQwODowMToxNyswMDowMOna
stroke-linejoin="round"
></path>
</symbol>
<symbol viewBox="0 0 200 200" id="atomic-chat">
<g fill="currentColor">
<rect x="83" y="15" width="34" height="170" rx="17"></rect>
<rect x="83" y="15" width="34" height="170" rx="17" transform="rotate(45 100 100)"></rect>
<rect x="83" y="15" width="34" height="170" rx="17" transform="rotate(90 100 100)"></rect>
<rect x="83" y="15" width="34" height="170" rx="17" transform="rotate(135 100 100)"></rect>
<rect x="83" y="5" width="34" height="190" rx="17" transform="rotate(45 100 100)"></rect>
</g>
</symbol>
<symbol viewBox="0 0 40 40" id="anthropic">
<path
d="M26.9568 9.88184H22.1265L30.7753 31.7848H35.4917L26.9568 9.88184ZM13.028 9.88184L4.4917 31.7848H9.32203L11.2305 27.1793H20.2166L22.0126 31.6724H26.8444L18.0832 9.88184H13.028ZM12.5783 23.1361L15.4987 15.3853L18.5315 23.1361H12.5783Z"

Before

Width:  |  Height:  |  Size: 349 KiB

After

Width:  |  Height:  |  Size: 349 KiB

@@ -110,6 +110,7 @@ export const iconNames = [
"bailing",
"azure",
"azure-cognitive-services",
"atomic-chat", // kilocode_change
"anthropic",
"amazon-bedrock",
"alibaba",