mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user