diff --git a/bun.lock b/bun.lock index aa9bb7a52cc..9e75fc7ba9b 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/kilo-docs/lib/nav/ai-providers.ts b/packages/kilo-docs/lib/nav/ai-providers.ts index 408598f0f52..0711ee81510 100644 --- a/packages/kilo-docs/lib/nav/ai-providers.ts +++ b/packages/kilo-docs/lib/nav/ai-providers.ts @@ -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", diff --git a/packages/kilo-docs/pages/ai-providers/atomic-chat.md b/packages/kilo-docs/pages/ai-providers/atomic-chat.md new file mode 100644 index 00000000000..efa3189a6b7 --- /dev/null +++ b/packages/kilo-docs/pages/ai-providers/atomic-chat.md @@ -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) diff --git a/packages/kilo-docs/pages/ai-providers/index.md b/packages/kilo-docs/pages/ai-providers/index.md index a7c8c05fee3..ef843690443 100644 --- a/packages/kilo-docs/pages/ai-providers/index.md +++ b/packages/kilo-docs/pages/ai-providers/index.md @@ -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 diff --git a/packages/kilo-docs/pages/automate/extending/local-models.md b/packages/kilo-docs/pages/automate/extending/local-models.md index 81921bdfb88..c540a25281b 100644 --- a/packages/kilo-docs/pages/automate/extending/local-models.md +++ b/packages/kilo-docs/pages/automate/extending/local-models.md @@ -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. diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx index f5e4f71ea3b..556ce790f84 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx @@ -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 = (props) => {(item, index) => ( )} @@ -283,10 +288,29 @@ const ProviderConnectDialog: Component = (props) => const [value, setValue] = createSignal("") const [fields, setFields] = createStore>({}) 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 = (props) => style={{ display: "flex", "flex-direction": "column", gap: "16px" }} onSubmit={submit} > -
- {language.t("provider.connect.apiKey.description", { provider: name() })} -
+
{apiKeyDescription()}
{ - 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 } : {}), }, }) diff --git a/packages/opencode/src/kilocode/atomic-chat-feature.ts b/packages/opencode/src/kilocode/atomic-chat-feature.ts new file mode 100644 index 00000000000..daef896213b --- /dev/null +++ b/packages/opencode/src/kilocode/atomic-chat-feature.ts @@ -0,0 +1,37 @@ +import { pathToFileURL } from "url" +import { ATOMIC_CHAT_PLUGIN } from "@kilocode/plugin-atomic-chat" + +type PluginSpec = string | [string, Record] + +type Req = { + resolve: (id: string) => string +} + +type LogLike = { + debug: (msg: string, data?: Record) => 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] +} diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx index 1b38f8b0d43..21912638987 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx @@ -67,6 +67,15 @@ export const PROVIDER_TITLES: Record = { 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 () => ( + + Connect to Atomic Chat on this machine (default http://127.0.0.1:1337). Leave API key empty for local server. + + ) + } if (providerID !== "kilo") return undefined return () => ( @@ -125,3 +141,7 @@ export function renderApiDescription( ) } + +export function apiKeyPlaceholder(providerID: string) { + return isLocalOptionalApiKey(providerID) ? "Optional for localhost" : "API key" +} diff --git a/packages/opencode/src/kilocode/config/default-plugins.ts b/packages/opencode/src/kilocode/config/default-plugins.ts index ad709dd24e7..f17aafcf62c 100644 --- a/packages/opencode/src/kilocode/config/default-plugins.ts +++ b/packages/opencode/src/kilocode/config/default-plugins.ts @@ -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 diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 6c4c3a8081e..e65c9714d58 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -92,7 +92,8 @@ function getLegacyPlugins(mod: Record) { 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) } diff --git a/packages/opencode/test/kilocode/config/atomic-chat-default-plugin.test.ts b/packages/opencode/test/kilocode/config/atomic-chat-default-plugin.test.ts new file mode 100644 index 00000000000..24806d65236 --- /dev/null +++ b/packages/opencode/test/kilocode/config/atomic-chat-default-plugin.test.ts @@ -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) + }) +}) diff --git a/packages/opencode/test/tool/fixtures/models-api.json b/packages/opencode/test/tool/fixtures/models-api.json index 5a3eb7e8010..d560eced207 100644 --- a/packages/opencode/test/tool/fixtures/models-api.json +++ b/packages/opencode/test/tool/fixtures/models-api.json @@ -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"], diff --git a/packages/plugin-atomic-chat/package.json b/packages/plugin-atomic-chat/package.json new file mode 100644 index 00000000000..60eed149db9 --- /dev/null +++ b/packages/plugin-atomic-chat/package.json @@ -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" + } +} diff --git a/packages/plugin-atomic-chat/src/cache/model-status-cache.ts b/packages/plugin-atomic-chat/src/cache/model-status-cache.ts new file mode 100644 index 00000000000..58b6dea806b --- /dev/null +++ b/packages/plugin-atomic-chat/src/cache/model-status-cache.ts @@ -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): Promise { + 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): Promise { + 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 + } +} diff --git a/packages/plugin-atomic-chat/src/cache/shared-model-status-cache.ts b/packages/plugin-atomic-chat/src/cache/shared-model-status-cache.ts new file mode 100644 index 00000000000..5a3fea0bea3 --- /dev/null +++ b/packages/plugin-atomic-chat/src/cache/shared-model-status-cache.ts @@ -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() diff --git a/packages/plugin-atomic-chat/src/constants.ts b/packages/plugin-atomic-chat/src/constants.ts new file mode 100644 index 00000000000..e7fa6aa9f7e --- /dev/null +++ b/packages/plugin-atomic-chat/src/constants.ts @@ -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 diff --git a/packages/plugin-atomic-chat/src/index.ts b/packages/plugin-atomic-chat/src/index.ts new file mode 100644 index 00000000000..375dabcbfa5 --- /dev/null +++ b/packages/plugin-atomic-chat/src/index.ts @@ -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 diff --git a/packages/plugin-atomic-chat/src/plugin/auth-hook.ts b/packages/plugin-atomic-chat/src/plugin/auth-hook.ts new file mode 100644 index 00000000000..5ccc0bb1a4e --- /dev/null +++ b/packages/plugin-atomic-chat/src/plugin/auth-hook.ts @@ -0,0 +1,14 @@ +import type { Hooks } from '@kilocode/plugin' +import { ATOMIC_CHAT_PROVIDER_KEY } from '../constants' + +export function createAuthHook(): NonNullable { + return { + provider: ATOMIC_CHAT_PROVIDER_KEY, + methods: [ + { + type: 'api', + label: 'Local server', + }, + ], + } +} diff --git a/packages/plugin-atomic-chat/src/plugin/chat-params-hook.ts b/packages/plugin-atomic-chat/src/plugin/chat-params-hook.ts new file mode 100644 index 00000000000..c68fdc9278b --- /dev/null +++ b/packages/plugin-atomic-chat/src/plugin/chat-params-hook.ts @@ -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, + } + } + } +} diff --git a/packages/plugin-atomic-chat/src/plugin/config-hook.ts b/packages/plugin-atomic-chat/src/plugin/config-hook.ts new file mode 100644 index 00000000000..15bf5d2f6a9 --- /dev/null +++ b/packages/plugin-atomic-chat/src/plugin/config-hook.ts @@ -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})`) + } + } +} diff --git a/packages/plugin-atomic-chat/src/plugin/enhance-config.ts b/packages/plugin-atomic-chat/src/plugin/enhance-config.ts new file mode 100644 index 00000000000..d5d07807468 --- /dev/null +++ b/packages/plugin-atomic-chat/src/plugin/enhance-config.ts @@ -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) { + 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 { + 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 = {} + 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), + }) + }) + } +} diff --git a/packages/plugin-atomic-chat/src/plugin/event-hook.ts b/packages/plugin-atomic-chat/src/plugin/event-hook.ts new file mode 100644 index 00000000000..87309d0679a --- /dev/null +++ b/packages/plugin-atomic-chat/src/plugin/event-hook.ts @@ -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 + } + } +} diff --git a/packages/plugin-atomic-chat/src/plugin/get-loaded-models.ts b/packages/plugin-atomic-chat/src/plugin/get-loaded-models.ts new file mode 100644 index 00000000000..179613145ce --- /dev/null +++ b/packages/plugin-atomic-chat/src/plugin/get-loaded-models.ts @@ -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 { + return await fetchModelsDirect(baseURL) +} + +export function getLoadedModels( + baseURL: string = DEFAULT_ATOMIC_CHAT_ORIGIN, + options?: { refresh?: boolean } +): Promise { + const fetchFn = () => fetchLoadedModelIds(baseURL) + if (options?.refresh) { + return sharedModelStatusCache.forceRefresh(baseURL, fetchFn) + } + return sharedModelStatusCache.getModels(baseURL, fetchFn) +} diff --git a/packages/plugin-atomic-chat/src/plugin/index.ts b/packages/plugin-atomic-chat/src/plugin/index.ts new file mode 100644 index 00000000000..2d661fe8af0 --- /dev/null +++ b/packages/plugin-atomic-chat/src/plugin/index.ts @@ -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), + } +} diff --git a/packages/plugin-atomic-chat/src/types/index.ts b/packages/plugin-atomic-chat/src/types/index.ts new file mode 100644 index 00000000000..deeabde4547 --- /dev/null +++ b/packages/plugin-atomic-chat/src/types/index.ts @@ -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 + }> +} diff --git a/packages/plugin-atomic-chat/src/ui/toast-notifier.ts b/packages/plugin-atomic-chat/src/ui/toast-notifier.ts new file mode 100644 index 00000000000..57afe86d8eb --- /dev/null +++ b/packages/plugin-atomic-chat/src/ui/toast-notifier.ts @@ -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 { + 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 { + 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 { + 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 { + 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) + } + } +} diff --git a/packages/plugin-atomic-chat/src/utils/atomic-chat-api.ts b/packages/plugin-atomic-chat/src/utils/atomic-chat-api.ts new file mode 100644 index 00000000000..8e15ee0125b --- /dev/null +++ b/packages/plugin-atomic-chat/src/utils/atomic-chat-api.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/packages/plugin-atomic-chat/src/utils/format-model-name.ts b/packages/plugin-atomic-chat/src/utils/format-model-name.ts new file mode 100644 index 00000000000..d24fc37a14a --- /dev/null +++ b/packages/plugin-atomic-chat/src/utils/format-model-name.ts @@ -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 +} diff --git a/packages/plugin-atomic-chat/src/utils/index.ts b/packages/plugin-atomic-chat/src/utils/index.ts new file mode 100644 index 00000000000..9c6a32402a6 --- /dev/null +++ b/packages/plugin-atomic-chat/src/utils/index.ts @@ -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( + operation: () => Promise, + 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 +} diff --git a/packages/plugin-atomic-chat/src/utils/should-probe-atomic-chat.ts b/packages/plugin-atomic-chat/src/utils/should-probe-atomic-chat.ts new file mode 100644 index 00000000000..611c2feb3c2 --- /dev/null +++ b/packages/plugin-atomic-chat/src/utils/should-probe-atomic-chat.ts @@ -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 + 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) + ) +} diff --git a/packages/plugin-atomic-chat/src/utils/validation/index.ts b/packages/plugin-atomic-chat/src/utils/validation/index.ts new file mode 100644 index 00000000000..69af870d5ee --- /dev/null +++ b/packages/plugin-atomic-chat/src/utils/validation/index.ts @@ -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' diff --git a/packages/plugin-atomic-chat/src/utils/validation/safe-operations.ts b/packages/plugin-atomic-chat/src/utils/validation/safe-operations.ts new file mode 100644 index 00000000000..0891cc0a1c2 --- /dev/null +++ b/packages/plugin-atomic-chat/src/utils/validation/safe-operations.ts @@ -0,0 +1,13 @@ +export async function safeAsyncOperation( + operation: () => Promise, + fallback?: T, + onError?: (error: Error) => void +): Promise { + try { + return await operation() + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + onError?.(err) + return fallback + } +} diff --git a/packages/plugin-atomic-chat/src/utils/validation/type-guards.ts b/packages/plugin-atomic-chat/src/utils/validation/type-guards.ts new file mode 100644 index 00000000000..5bfc29598a3 --- /dev/null +++ b/packages/plugin-atomic-chat/src/utils/validation/type-guards.ts @@ -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 +} diff --git a/packages/plugin-atomic-chat/src/utils/validation/validate-config.ts b/packages/plugin-atomic-chat/src/utils/validation/validate-config.ts new file mode 100644 index 00000000000..b94e2335117 --- /dev/null +++ b/packages/plugin-atomic-chat/src/utils/validation/validate-config.ts @@ -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 + } +} diff --git a/packages/plugin-atomic-chat/src/utils/validation/validate-hook-input.ts b/packages/plugin-atomic-chat/src/utils/validation/validate-hook-input.ts new file mode 100644 index 00000000000..f659e507343 --- /dev/null +++ b/packages/plugin-atomic-chat/src/utils/validation/validate-hook-input.ts @@ -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, + } +} diff --git a/packages/plugin-atomic-chat/src/utils/validation/validation-result.ts b/packages/plugin-atomic-chat/src/utils/validation/validation-result.ts new file mode 100644 index 00000000000..1235d33c7f6 --- /dev/null +++ b/packages/plugin-atomic-chat/src/utils/validation/validation-result.ts @@ -0,0 +1,5 @@ +export interface ValidationResult { + isValid: boolean + errors: string[] + warnings: string[] +} diff --git a/packages/plugin-atomic-chat/test/plugin.test.ts b/packages/plugin-atomic-chat/test/plugin.test.ts new file mode 100644 index 00000000000..a5411df6707 --- /dev/null +++ b/packages/plugin-atomic-chat/test/plugin.test.ts @@ -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' }) + ) + }) + }) +}) diff --git a/packages/plugin-atomic-chat/test/should-probe-atomic-chat.test.ts b/packages/plugin-atomic-chat/test/should-probe-atomic-chat.test.ts new file mode 100644 index 00000000000..106f31d64b2 --- /dev/null +++ b/packages/plugin-atomic-chat/test/should-probe-atomic-chat.test.ts @@ -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) + }) +}) diff --git a/packages/plugin-atomic-chat/tsconfig.json b/packages/plugin-atomic-chat/tsconfig.json new file mode 100644 index 00000000000..7c3ad88a0bf --- /dev/null +++ b/packages/plugin-atomic-chat/tsconfig.json @@ -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/**/*"] +} diff --git a/packages/ui/src/assets/icons/provider/atomic-chat.svg b/packages/ui/src/assets/icons/provider/atomic-chat.svg new file mode 100644 index 00000000000..09f93dcbcf1 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/atomic-chat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/components/provider-icons/sprite.svg b/packages/ui/src/components/provider-icons/sprite.svg index 7c848819997..190d2d8661b 100644 --- a/packages/ui/src/components/provider-icons/sprite.svg +++ b/packages/ui/src/components/provider-icons/sprite.svg @@ -1859,6 +1859,15 @@ OjAwvs/J9QAAACh0RVh0ZGF0ZTp0aW1lc3RhbXAAMjAyNi0wMy0yNVQwODowMToxNyswMDowMOna stroke-linejoin="round" > + + + + + + + + +