mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
feat: add Anaconda Desktop provider
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
"@kilocode/sdk": minor
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Connect to a local Anaconda Desktop text-generation model server from the CLI or VS Code.
|
||||
@@ -56,6 +56,10 @@ export const AiProvidersNav: NavSection[] = [
|
||||
{ href: "/ai-providers/ollama", children: "Ollama" },
|
||||
{ href: "/ai-providers/lmstudio", children: "LM Studio" },
|
||||
{ href: "/ai-providers/atomic-chat", children: "Atomic Chat" },
|
||||
{
|
||||
href: "/ai-providers/anaconda-desktop",
|
||||
children: "Anaconda Desktop",
|
||||
},
|
||||
{ href: "/ai-providers/vscode-lm", children: "VS Code LM API" },
|
||||
{
|
||||
href: "/ai-providers/openai-compatible",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: "Using Anaconda Desktop with Kilo Code | Local Models"
|
||||
description: "Connect Kilo Code to a local Anaconda Desktop text-generation model server from the TUI or VS Code."
|
||||
sidebar_label: Anaconda Desktop
|
||||
---
|
||||
|
||||
# Using Anaconda Desktop With Kilo Code
|
||||
|
||||
Kilo Code can discover the text-generation model served by [Anaconda Desktop](https://www.anaconda.com/products/desktop) and connect to its local OpenAI-compatible endpoint. Kilo imports the server connection for you, so you do not need to copy an API key or configure a base URL manually.
|
||||
|
||||
**Official documentation:** [Anaconda Desktop](https://www.anaconda.com/docs/tools/anaconda-desktop/key-features)
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
Anaconda Desktop and Kilo must run on the same supported computer.
|
||||
|
||||
| Operating system | Supported installation |
|
||||
|---|---|
|
||||
| Windows | Windows 11, x86-64 |
|
||||
| macOS | macOS 13 or later, Apple Silicon |
|
||||
| Linux | Debian or Ubuntu, x86-64 or ARM64 |
|
||||
|
||||
Remote backends, remote-only model endpoints, and non-interactive or headless setup are not supported. Complete setup in the Kilo TUI or VS Code on the computer running Anaconda Desktop.
|
||||
|
||||
## Set Up Anaconda Desktop
|
||||
|
||||
1. Download and install Anaconda Desktop from the [official product page](https://www.anaconda.com/products/desktop). See Anaconda's [installation guide](https://www.anaconda.com/docs/tools/anaconda-desktop/install-desktop) for platform-specific steps.
|
||||
2. Open Anaconda Desktop and sign in with your Anaconda account or your organization's assigned credentials.
|
||||
3. Select **Model Catalog** and filter for a **Text Generation** model. Prefer a model tagged **Tool Calling** for full Kilo agent functionality.
|
||||
4. Select a quantization that fits your computer, then click **Download**. Anaconda's [model catalog guide](https://www.anaconda.com/docs/tools/anaconda-desktop/model-catalog) explains model types, hardware requirements, and quantization choices.
|
||||
5. Select **Model Servers**, choose the downloaded model and file, and start its server. Enable tool calling when the selected model and server configuration support it.
|
||||
|
||||
{% callout type="note" %}
|
||||
Kilo only discovers Desktop state and connects to an existing server. Downloading or deleting models and creating, switching, starting, stopping, or deleting servers must be done in Anaconda Desktop. See Anaconda's [model server guide](https://www.anaconda.com/docs/tools/anaconda-desktop/servers).
|
||||
{% /callout %}
|
||||
|
||||
## Connect Kilo Code
|
||||
|
||||
{% tabs %}
|
||||
{% tab label="TUI" %}
|
||||
|
||||
1. Run `/connect` in the Kilo TUI.
|
||||
2. Select **Anaconda Desktop**.
|
||||
3. Follow the setup dialog. Kilo can open Anaconda Desktop; after making changes there, return and choose **Check again**.
|
||||
4. When the model server is ready, choose **Connect** to import its connection and make the served model available in the model picker.
|
||||
|
||||
To refresh an existing connection, run `/connect`, select **Anaconda Desktop**, and choose **connect / refresh now** after changing the model, server address or port, or server API key in Desktop. Kilo re-discovers the active server and replaces its stored model and connection information.
|
||||
|
||||
{% /tab %}
|
||||
{% tab label="VS Code" %}
|
||||
|
||||
1. Open Kilo Code **Settings** using the gear icon and select **Providers**.
|
||||
2. Add **Anaconda Desktop**. No manual API-key field is shown.
|
||||
3. Follow the setup dialog. Kilo can open Anaconda Desktop; after making changes there, return and select **Check again**.
|
||||
4. When the model server is ready, select **Connect** to import its connection and refresh the model picker.
|
||||
|
||||
For an existing connection, open **Settings**, select **Providers**, and select **Manage / Refresh** for Anaconda Desktop after changing the model, server address or port, or server API key in Desktop.
|
||||
|
||||
{% /tab %}
|
||||
{% /tabs %}
|
||||
|
||||
## Tool Calling
|
||||
|
||||
When Desktop reports that the model server supports tool calling, Kilo allows you to connect without an additional warning. Tool calling lets the model use Kilo's tools to inspect files, edit code, and run commands.
|
||||
|
||||
{% callout type="warning" %}
|
||||
If tool support is unavailable or cannot be detected, Kilo shows a warning and requires confirmation before connecting. You can still use the model for text generation, but normal coding-agent actions are limited and may fail. For the best experience, choose a **Text Generation** model tagged **Tool Calling** and enable the server's tool-call support when available.
|
||||
{% /callout %}
|
||||
|
||||
## How Keys Are Handled
|
||||
|
||||
Anaconda Desktop uses two separate credentials:
|
||||
|
||||
- **Desktop management key** - Allows local discovery of Desktop models and servers. Kilo reads it from Desktop's configuration only when needed and never copies it into Kilo storage.
|
||||
- **Inference server key** - Authenticates chat-completion requests to the running model server. Kilo imports this key into its normal provider authentication storage together with the local endpoint and model details.
|
||||
|
||||
Kilo never asks you to paste either key. If the inference server key or endpoint changes, use **Refresh** to import the current values.
|
||||
|
||||
## Disconnect
|
||||
|
||||
In VS Code, open **Settings** > **Providers** and select **Disconnect** for Anaconda Desktop. In a terminal, run `kilo auth logout` and select Anaconda Desktop. Disconnecting removes only Kilo's stored provider authentication and connection metadata. It does not stop Anaconda Desktop, stop the model server, or delete the downloaded model.
|
||||
|
||||
Use Anaconda Desktop itself to stop or change the server.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Desktop is not detected:** Install it from the [official product page](https://www.anaconda.com/products/desktop), or use **Open Anaconda Desktop** if it is installed but not running.
|
||||
- **Sign-in is required:** Open Desktop, complete sign-in, and leave the Kilo setup dialog open so it can detect the change.
|
||||
- **No model is available:** Download a model whose type is **Text Generation**, not an embedding-only model.
|
||||
- **No server is available:** Start the downloaded model from **Model Servers** in Desktop.
|
||||
- **The server changed:** Use **Refresh** in Kilo to replace the stored model, endpoint, and inference key.
|
||||
- **The server is unhealthy:** Check the server status and logs in Desktop, then restart it there before refreshing Kilo.
|
||||
@@ -34,6 +34,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
|
||||
- **[Anaconda Desktop](/docs/ai-providers/anaconda-desktop)** - Discover and connect to a local text-generation model server
|
||||
- **[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
|
||||
|
||||
@@ -188,6 +188,8 @@
|
||||
<!-- packages/opencode/src/provider/transform.ts -->
|
||||
- <https://vercel.link/ai-gateway-token>
|
||||
<!-- packages/opencode/src/cli/cmd/providers.ts -->
|
||||
- <https://www.anaconda.com/products/desktop>
|
||||
<!-- packages/opencode/src/kilocode/anaconda-desktop/domain.ts -->
|
||||
- <https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz>
|
||||
<!-- packages/opencode/src/lsp/server.ts -->
|
||||
- <https://www.googleapis.com/auth/cloud-platform>
|
||||
|
||||
@@ -144,6 +144,7 @@ import {
|
||||
resolveStoredKey,
|
||||
} from "./provider-actions"
|
||||
import type { StoredProviderKey } from "./provider-actions"
|
||||
import { AnacondaDesktopBridge } from "./anaconda-desktop/bridge"
|
||||
import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models"
|
||||
import type { Agent } from "@kilocode/sdk/v2/client"
|
||||
import { configFeatures } from "./features"
|
||||
@@ -343,6 +344,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private readonly sandboxTransitions = new Map<string, Promise<void>>()
|
||||
private readonly revisions = new Map<string, { id: string; seq: number }>()
|
||||
private readonly refreshes = new Map<string, number>()
|
||||
private readonly anacondaDesktop = new AnacondaDesktopBridge()
|
||||
private sessionStatusMap = new Map<string, SessionStatus["type"]>() // Latest status used for destructive config warnings.
|
||||
private sessionDirectories = new Map<string, string>() // Per-session directory overrides, such as Agent Manager worktrees.
|
||||
private readonly aborts = new SessionAbort()
|
||||
@@ -1015,6 +1017,18 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
case "saveCustomProvider":
|
||||
await this.handleProviderAction(message)
|
||||
break
|
||||
case "anacondaDesktopStatus":
|
||||
case "anacondaDesktopOpen":
|
||||
case "anacondaDesktopSync":
|
||||
case "cancelAnacondaDesktopRequest":
|
||||
await this.anacondaDesktop.handle(message, {
|
||||
client: this.client,
|
||||
directory: this.getWorkspaceDirectory(),
|
||||
post: (reply) => this.postMessage(reply),
|
||||
refresh: () => this.fetchAndSendProviders(),
|
||||
error: getErrorMessage,
|
||||
})
|
||||
break
|
||||
case "fetchCustomProviderModels":
|
||||
this.handleFetchCustomProviderModels(message).catch((e) =>
|
||||
console.error("[Kilo New] fetchCustomProviderModels failed:", e),
|
||||
@@ -3856,6 +3870,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.trackedSessionIds.clear()
|
||||
this.syncedChildSessions.clear()
|
||||
this.sessionDirectories.clear()
|
||||
this.anacondaDesktop.dispose()
|
||||
this.aborts.clear()
|
||||
this.sessionStatusMap.clear()
|
||||
this.ignoreController?.dispose()
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { KiloClient } from "@kilocode/sdk/v2"
|
||||
import type { AnacondaDesktopAction, AnacondaDesktopExtensionMessage, AnacondaDesktopWebviewMessage } from "./messages"
|
||||
|
||||
interface Context {
|
||||
client?: KiloClient | null
|
||||
directory: string
|
||||
post: (message: AnacondaDesktopExtensionMessage) => void
|
||||
refresh: () => Promise<void>
|
||||
error: (error: unknown) => string
|
||||
}
|
||||
|
||||
type Request = Exclude<AnacondaDesktopWebviewMessage, { type: "cancelAnacondaDesktopRequest" }>
|
||||
|
||||
function action(message: Request): AnacondaDesktopAction {
|
||||
if (message.type === "anacondaDesktopStatus") return "status"
|
||||
if (message.type === "anacondaDesktopOpen") return "open"
|
||||
return "sync"
|
||||
}
|
||||
|
||||
export class AnacondaDesktopBridge {
|
||||
private readonly requests = new Map<string, AbortController>()
|
||||
|
||||
async handle(message: AnacondaDesktopWebviewMessage, ctx: Context) {
|
||||
if (message.type === "cancelAnacondaDesktopRequest") {
|
||||
this.requests.get(message.requestId)?.abort()
|
||||
this.requests.delete(message.requestId)
|
||||
return
|
||||
}
|
||||
|
||||
if (!ctx.client) {
|
||||
ctx.post({
|
||||
type: "anacondaDesktopActionError",
|
||||
requestId: message.requestId,
|
||||
action: action(message),
|
||||
message: "Not connected to CLI backend",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const ctrl = new AbortController()
|
||||
this.requests.set(message.requestId, ctrl)
|
||||
try {
|
||||
if (message.type === "anacondaDesktopStatus") {
|
||||
const response = await ctx.client.anacondaDesktop.status(
|
||||
{ directory: ctx.directory },
|
||||
{ throwOnError: true, signal: ctrl.signal },
|
||||
)
|
||||
if (!response.data) throw new Error("Failed to check Anaconda Desktop")
|
||||
if (ctrl.signal.aborted) return
|
||||
ctx.post({ type: "anacondaDesktopStatusResult", requestId: message.requestId, status: response.data })
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === "anacondaDesktopOpen") {
|
||||
await ctx.client.anacondaDesktop.open({ directory: ctx.directory }, { throwOnError: true, signal: ctrl.signal })
|
||||
if (ctrl.signal.aborted) return
|
||||
ctx.post({ type: "anacondaDesktopOpened", requestId: message.requestId })
|
||||
return
|
||||
}
|
||||
|
||||
const response = await ctx.client.anacondaDesktop.sync(
|
||||
{
|
||||
directory: ctx.directory,
|
||||
acknowledgeToolLimitations: message.acknowledgeToolLimitations,
|
||||
},
|
||||
{ throwOnError: true, signal: ctrl.signal },
|
||||
)
|
||||
if (!response.data) throw new Error("Failed to synchronize Anaconda Desktop")
|
||||
if (ctrl.signal.aborted) return
|
||||
await ctx.refresh()
|
||||
if (ctrl.signal.aborted) return
|
||||
ctx.post({ type: "anacondaDesktopSynced", requestId: message.requestId, status: response.data })
|
||||
} catch (error) {
|
||||
if (ctrl.signal.aborted) return
|
||||
ctx.post({
|
||||
type: "anacondaDesktopActionError",
|
||||
requestId: message.requestId,
|
||||
action: action(message),
|
||||
message: ctx.error(error) || `Failed to ${action(message)} Anaconda Desktop`,
|
||||
})
|
||||
} finally {
|
||||
if (this.requests.get(message.requestId) === ctrl) this.requests.delete(message.requestId)
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (const ctrl of this.requests.values()) ctrl.abort()
|
||||
this.requests.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { AnacondaDesktopStatus } from "@kilocode/sdk/v2/client"
|
||||
|
||||
export type AnacondaDesktopAction = "status" | "open" | "sync"
|
||||
|
||||
export type AnacondaDesktopWebviewMessage =
|
||||
| { type: "anacondaDesktopStatus"; requestId: string }
|
||||
| { type: "anacondaDesktopOpen"; requestId: string }
|
||||
| {
|
||||
type: "anacondaDesktopSync"
|
||||
requestId: string
|
||||
acknowledgeToolLimitations: boolean
|
||||
}
|
||||
| { type: "cancelAnacondaDesktopRequest"; requestId: string }
|
||||
|
||||
export type AnacondaDesktopExtensionMessage =
|
||||
| {
|
||||
type: "anacondaDesktopStatusResult"
|
||||
requestId: string
|
||||
status: AnacondaDesktopStatus
|
||||
}
|
||||
| { type: "anacondaDesktopOpened"; requestId: string }
|
||||
| {
|
||||
type: "anacondaDesktopSynced"
|
||||
requestId: string
|
||||
status: Extract<AnacondaDesktopStatus, { type: "ready" }>
|
||||
}
|
||||
| {
|
||||
type: "anacondaDesktopActionError"
|
||||
requestId: string
|
||||
action: AnacondaDesktopAction
|
||||
message: string
|
||||
}
|
||||
|
||||
export type AnacondaDesktopRequest = Exclude<AnacondaDesktopWebviewMessage, { type: "cancelAnacondaDesktopRequest" }>
|
||||
export type AnacondaDesktopResult = Exclude<AnacondaDesktopExtensionMessage, { type: "anacondaDesktopActionError" }>
|
||||
export type AnacondaDesktopError = Extract<AnacondaDesktopExtensionMessage, { type: "anacondaDesktopActionError" }>
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { AnacondaDesktopBridge } from "../../src/anaconda-desktop/bridge"
|
||||
import { createAnacondaDesktopAction } from "../../webview-ui/src/utils/anaconda-desktop-action"
|
||||
import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages"
|
||||
|
||||
const ready = {
|
||||
type: "ready" as const,
|
||||
serverID: "server",
|
||||
models: [{ id: "model", name: "Local Model" }],
|
||||
context: 32768,
|
||||
toolcall: "unknown" as const,
|
||||
}
|
||||
|
||||
describe("AnacondaDesktopBridge", () => {
|
||||
it("forwards explicit tool acknowledgement", async () => {
|
||||
const calls: unknown[][] = []
|
||||
let refreshed = false
|
||||
const bridge = new AnacondaDesktopBridge()
|
||||
await bridge.handle(
|
||||
{ type: "anacondaDesktopSync", requestId: "sync", acknowledgeToolLimitations: true },
|
||||
{
|
||||
client: {
|
||||
anacondaDesktop: {
|
||||
sync: async (...args: unknown[]) => {
|
||||
calls.push(args)
|
||||
return { data: ready }
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
directory: "/workspace",
|
||||
post: () => {},
|
||||
refresh: async () => {
|
||||
refreshed = true
|
||||
},
|
||||
error: String,
|
||||
},
|
||||
)
|
||||
|
||||
expect(calls[0]?.[0]).toEqual({ directory: "/workspace", acknowledgeToolLimitations: true })
|
||||
expect(refreshed).toBe(true)
|
||||
})
|
||||
|
||||
it("aborts cancelled requests and suppresses their result", async () => {
|
||||
const posts: unknown[] = []
|
||||
const bridge = new AnacondaDesktopBridge()
|
||||
const client = {
|
||||
anacondaDesktop: {
|
||||
status: (_: unknown, opts: { signal: AbortSignal }) =>
|
||||
new Promise((_, reject) => opts.signal.addEventListener("abort", () => reject(new Error("aborted")))),
|
||||
},
|
||||
}
|
||||
const ctx = {
|
||||
client: client as never,
|
||||
directory: "/workspace",
|
||||
post: (message: unknown) => posts.push(message),
|
||||
refresh: async () => {},
|
||||
error: String,
|
||||
}
|
||||
const request = bridge.handle({ type: "anacondaDesktopStatus", requestId: "request" }, ctx)
|
||||
|
||||
await bridge.handle({ type: "cancelAnacondaDesktopRequest", requestId: "request" }, ctx)
|
||||
await request
|
||||
expect(posts).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function transport() {
|
||||
const sent: WebviewMessage[] = []
|
||||
const listeners = new Set<(message: ExtensionMessage) => void>()
|
||||
return {
|
||||
sent,
|
||||
postMessage: (message: WebviewMessage) => sent.push(message),
|
||||
onMessage: (handler: (message: ExtensionMessage) => void) => {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
receive: (message: ExtensionMessage) => listeners.forEach((handler) => handler(message)),
|
||||
}
|
||||
}
|
||||
|
||||
it("correlates results and cancels pending webview requests", () => {
|
||||
const vscode = transport()
|
||||
const action = createAnacondaDesktopAction(vscode)
|
||||
const seen: string[] = []
|
||||
const requestId = action.send(
|
||||
{ type: "anacondaDesktopStatus" },
|
||||
{ onStatus: (message) => seen.push(message.status.type) },
|
||||
)
|
||||
|
||||
vscode.receive({
|
||||
type: "anacondaDesktopStatusResult",
|
||||
requestId,
|
||||
status: { type: "no-running-server", downloadedModels: 1 },
|
||||
})
|
||||
const cancelled = action.send({ type: "anacondaDesktopOpen" })
|
||||
action.clear(cancelled)
|
||||
|
||||
expect(seen).toEqual(["no-running-server"])
|
||||
expect(vscode.sent.at(-1)).toEqual({ type: "cancelAnacondaDesktopRequest", requestId: cancelled })
|
||||
action.dispose()
|
||||
})
|
||||
@@ -0,0 +1,301 @@
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { Card, CardDescription, CardTitle } from "@kilocode/kilo-ui/card"
|
||||
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
|
||||
import { Dialog } from "@kilocode/kilo-ui/dialog"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { Tag } from "@kilocode/kilo-ui/tag"
|
||||
import { showToast } from "@kilocode/kilo-ui/toast"
|
||||
import type { AnacondaDesktopStatus } from "@kilocode/sdk/v2/client"
|
||||
import { For, Match, Show, Switch, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useProvider } from "../../context/provider"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
import { createAnacondaDesktopAction } from "../../utils/anaconda-desktop-action"
|
||||
|
||||
interface AnacondaDesktopDialogProps {
|
||||
status?: AnacondaDesktopStatus
|
||||
connected?: boolean
|
||||
}
|
||||
|
||||
const ID = "anaconda-desktop"
|
||||
|
||||
function AnacondaDesktopDialog(props: AnacondaDesktopDialogProps) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const provider = useProvider()
|
||||
const vscode = useVSCode()
|
||||
const action = createAnacondaDesktopAction(vscode)
|
||||
const [status, setStatus] = createSignal<AnacondaDesktopStatus | undefined>(props.status)
|
||||
const [checking, setChecking] = createSignal(false)
|
||||
const [opening, setOpening] = createSignal(false)
|
||||
const [syncing, setSyncing] = createSignal(false)
|
||||
const [error, setError] = createSignal<string>()
|
||||
const managing = props.connected ?? provider.connected().includes(ID)
|
||||
|
||||
function check() {
|
||||
if (checking() || opening() || syncing()) return
|
||||
setChecking(true)
|
||||
setError()
|
||||
action.send(
|
||||
{ type: "anacondaDesktopStatus" },
|
||||
{
|
||||
onStatus: (message) => {
|
||||
setChecking(false)
|
||||
setStatus(message.status)
|
||||
},
|
||||
onError: (message) => {
|
||||
setChecking(false)
|
||||
setError(message.message)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!props.status) check()
|
||||
})
|
||||
onCleanup(action.dispose)
|
||||
|
||||
function open() {
|
||||
if (opening() || checking() || syncing()) return
|
||||
setOpening(true)
|
||||
setError()
|
||||
action.send(
|
||||
{ type: "anacondaDesktopOpen" },
|
||||
{
|
||||
onOpened: () => setOpening(false),
|
||||
onError: (message) => {
|
||||
setOpening(false)
|
||||
setError(message.message)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function download(url: string) {
|
||||
vscode.postMessage({ type: "openExternal", url })
|
||||
}
|
||||
|
||||
function sync(acknowledge: boolean) {
|
||||
if (syncing() || checking() || opening()) return
|
||||
setSyncing(true)
|
||||
setError()
|
||||
action.send(
|
||||
{ type: "anacondaDesktopSync", acknowledgeToolLimitations: acknowledge },
|
||||
{
|
||||
onSynced: () => {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t(
|
||||
managing ? "provider.anaconda.toast.refreshed.title" : "provider.connect.toast.connected.title",
|
||||
{ provider: "Anaconda Desktop" },
|
||||
),
|
||||
description: language.t(
|
||||
managing
|
||||
? "provider.anaconda.toast.refreshed.description"
|
||||
: "provider.connect.toast.connected.description",
|
||||
{ provider: "Anaconda Desktop" },
|
||||
),
|
||||
})
|
||||
dialog.close()
|
||||
},
|
||||
onError: (message) => {
|
||||
setSyncing(false)
|
||||
setError(message.message)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function description(current: AnacondaDesktopStatus) {
|
||||
if (current.type === "unsupported-platform") {
|
||||
return language.t("provider.anaconda.state.unsupported", { platform: current.platform })
|
||||
}
|
||||
if (current.type === "not-installed") return language.t("provider.anaconda.state.notInstalled")
|
||||
if (current.type === "not-running") return language.t("provider.anaconda.state.notRunning")
|
||||
if (current.type === "invalid-config") return language.t("provider.anaconda.state.invalidConfig")
|
||||
if (current.type === "signed-out") return language.t("provider.anaconda.state.signedOut")
|
||||
if (current.type === "management-unauthorized") return language.t("provider.anaconda.state.unauthorized")
|
||||
if (current.type === "management-unavailable") return language.t("provider.anaconda.state.unavailable")
|
||||
if (current.type === "no-downloaded-model") return language.t("provider.anaconda.state.noModel")
|
||||
if (current.type === "no-running-server") {
|
||||
const key =
|
||||
current.downloadedModels === 1
|
||||
? "provider.anaconda.state.noServer_one"
|
||||
: "provider.anaconda.state.noServer_other"
|
||||
return language.t(key, { count: current.downloadedModels })
|
||||
}
|
||||
if (current.type === "inference-unhealthy") return language.t("provider.anaconda.state.unhealthy")
|
||||
return language.t("provider.anaconda.state.ready")
|
||||
}
|
||||
|
||||
function label(current: AnacondaDesktopStatus) {
|
||||
if (current.type === "ready") return language.t("provider.anaconda.status.ready")
|
||||
if (current.type === "unsupported-platform" || current.type === "not-installed") {
|
||||
return language.t("provider.anaconda.status.unavailable")
|
||||
}
|
||||
if (
|
||||
current.type === "not-running" ||
|
||||
current.type === "no-downloaded-model" ||
|
||||
current.type === "no-running-server"
|
||||
) {
|
||||
return language.t("provider.anaconda.status.waiting")
|
||||
}
|
||||
return language.t("provider.anaconda.status.attention")
|
||||
}
|
||||
|
||||
function tools(value: Extract<AnacondaDesktopStatus, { type: "ready" }>["toolcall"]) {
|
||||
if (value === "supported") return language.t("provider.anaconda.tools.supported")
|
||||
if (value === "unsupported") return language.t("provider.anaconda.tools.unsupported")
|
||||
return language.t("provider.anaconda.tools.unknown")
|
||||
}
|
||||
|
||||
const ready = createMemo(() => {
|
||||
const current = status()
|
||||
return current?.type === "ready" ? current : undefined
|
||||
})
|
||||
const canOpen = createMemo(() => {
|
||||
const current = status()
|
||||
if (!current) return false
|
||||
return (
|
||||
current.type === "not-running" ||
|
||||
current.type === "invalid-config" ||
|
||||
current.type === "signed-out" ||
|
||||
current.type === "management-unauthorized" ||
|
||||
current.type === "management-unavailable" ||
|
||||
current.type === "no-downloaded-model" ||
|
||||
current.type === "no-running-server" ||
|
||||
current.type === "inference-unhealthy"
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog title={language.t(managing ? "provider.anaconda.title.manage" : "provider.anaconda.title.connect")} fit>
|
||||
<div class="dialog-confirm-body" style={{ display: "flex", "flex-direction": "column", gap: "16px" }}>
|
||||
<Switch>
|
||||
<Match when={status()}>
|
||||
{(current) => (
|
||||
<Card variant={current().type === "ready" ? "success" : "info"}>
|
||||
<CardTitle variant={current().type === "ready" ? "success" : "info"}>{label(current())}</CardTitle>
|
||||
<CardDescription>{description(current())}</CardDescription>
|
||||
</Card>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={checking()}>
|
||||
<div class="provider-connect-status">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.anaconda.status.checking")}</span>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<Show when={ready()}>
|
||||
{(current) => (
|
||||
<>
|
||||
<Card>
|
||||
<CardTitle icon="server">{current().serverName ?? language.t("provider.anaconda.server")}</CardTitle>
|
||||
<CardDescription>
|
||||
<div style={{ display: "flex", "flex-direction": "column", gap: "10px" }}>
|
||||
<div style={{ display: "flex", "flex-wrap": "wrap", gap: "6px" }}>
|
||||
<For each={current().models}>{(model) => <Tag size="large">{model.name}</Tag>}</For>
|
||||
</div>
|
||||
<div style={{ display: "flex", "justify-content": "space-between", gap: "12px" }}>
|
||||
<span>{language.t("provider.anaconda.context")}</span>
|
||||
<strong>{language.t("provider.anaconda.contextValue", { count: current().context })}</strong>
|
||||
</div>
|
||||
<div style={{ display: "flex", "justify-content": "space-between", gap: "12px" }}>
|
||||
<span>{language.t("provider.anaconda.tools")}</span>
|
||||
<strong>{tools(current().toolcall)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</CardDescription>
|
||||
</Card>
|
||||
<Show when={current().toolcall !== "supported"}>
|
||||
<Card variant="warning">
|
||||
<CardTitle variant="warning">{language.t("provider.anaconda.warning.title")}</CardTitle>
|
||||
<CardDescription>{language.t("provider.anaconda.warning.description")}</CardDescription>
|
||||
</Card>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={error()}>
|
||||
{(message) => (
|
||||
<Card variant="error">
|
||||
<CardTitle variant="error">{language.t("common.requestFailed")}</CardTitle>
|
||||
<CardDescription>{message()}</CardDescription>
|
||||
</Card>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={(!!status() && checking()) || opening() || syncing()}>
|
||||
<div class="provider-connect-status">
|
||||
<Spinner />
|
||||
<span>
|
||||
{language.t(
|
||||
checking()
|
||||
? "provider.anaconda.status.checking"
|
||||
: syncing()
|
||||
? "provider.anaconda.status.syncing"
|
||||
: "provider.anaconda.status.opening",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="dialog-confirm-actions">
|
||||
<Button variant="ghost" size="large" onClick={() => dialog.close()} disabled={syncing()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Show when={status()?.type === "not-installed"}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
icon="link"
|
||||
onClick={() => {
|
||||
const current = status()
|
||||
if (current?.type === "not-installed") download(current.downloadURL)
|
||||
}}
|
||||
>
|
||||
{language.t("provider.anaconda.action.download")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={canOpen()}>
|
||||
<Button variant="secondary" size="large" onClick={open} disabled={checking() || opening() || syncing()}>
|
||||
{language.t("provider.anaconda.action.open")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={!checking()}>
|
||||
<Button variant="secondary" size="large" onClick={check} disabled={opening() || syncing()}>
|
||||
{language.t("provider.anaconda.action.checkAgain")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={ready()?.toolcall === "supported"}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={() => sync(false)}
|
||||
disabled={checking() || opening() || syncing()}
|
||||
>
|
||||
{language.t(managing ? "common.refresh" : "common.connect")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={ready()?.toolcall !== "supported" && !!ready()}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={() => sync(true)}
|
||||
disabled={checking() || opening() || syncing()}
|
||||
>
|
||||
{language.t("provider.anaconda.action.continue")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnacondaDesktopDialog
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
isLocalProviderOptionalApiKey,
|
||||
LOCAL_PROVIDER_API_KEY_PLACEHOLDER,
|
||||
} from "../../utils/local-providers"
|
||||
import AnacondaDesktopDialog from "./AnacondaDesktopDialog"
|
||||
|
||||
interface ProviderConnectDialogProps {
|
||||
providerID: string
|
||||
@@ -56,6 +57,8 @@ function visible(prompt: Prompt, values: Record<string, string>) {
|
||||
}
|
||||
|
||||
const ProviderConnectDialog: Component<ProviderConnectDialogProps> = (props) => {
|
||||
if (props.providerID === "anaconda-desktop") return <AnacondaDesktopDialog />
|
||||
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const provider = useProvider()
|
||||
|
||||
@@ -71,6 +71,7 @@ const ProvidersTab: Component = () => {
|
||||
}
|
||||
|
||||
function sourceTag(item: Provider) {
|
||||
if (item.id === "anaconda-desktop") return language.t("settings.providers.tag.local")
|
||||
const current = source(item)
|
||||
if (current === "env") return language.t("settings.providers.tag.environment")
|
||||
if (current === "api") return language.t("provider.connect.method.apiKey")
|
||||
@@ -261,6 +262,11 @@ const ProvidersTab: Component = () => {
|
||||
{language.t("settings.providers.action.signInChatGPT")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={item.id === "anaconda-desktop"}>
|
||||
<Button size="large" variant="ghost" onClick={() => connectProvider(item)}>
|
||||
{language.t("provider.anaconda.action.manage")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={canDisconnect(item)}>
|
||||
<Show when={isCustom(item)}>
|
||||
<Button size="large" variant="ghost" onClick={() => editProvider(item)}>
|
||||
|
||||
+8
@@ -1,4 +1,12 @@
|
||||
import { anacondaDesktopDict } from "./en"
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "تحقق مرة أخرى",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"يتوفر نموذج واحد مُنزّل لتوليد النصوص. في Anaconda Desktop، شغّل خادم نموذج. يوصى بشدة باستخدام نماذج تدعم استدعاء الأدوات.",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"تتوفر نماذج مُنزّلة لتوليد النصوص، وعددها {{count}}. في Anaconda Desktop، شغّل خادم نموذج. يوصى بشدة باستخدام نماذج تدعم استدعاء الأدوات.",
|
||||
"command.category.suggested": "مقترح",
|
||||
"command.category.view": "عرض",
|
||||
"command.category.project": "مشروع",
|
||||
|
||||
+8
@@ -1,4 +1,12 @@
|
||||
import { anacondaDesktopDict } from "./en"
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "Verificar novamente",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"Há 1 modelo de geração de texto baixado disponível. No Anaconda Desktop, inicie um servidor de modelo. Modelos com suporte a chamadas de ferramentas são altamente recomendados.",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"Há {{count}} modelos de geração de texto baixados disponíveis. No Anaconda Desktop, inicie um servidor de modelo. Modelos com suporte a chamadas de ferramentas são altamente recomendados.",
|
||||
"command.category.suggested": "Sugerido",
|
||||
"command.category.view": "Visualizar",
|
||||
"command.category.project": "Projeto",
|
||||
|
||||
+8
@@ -1,4 +1,12 @@
|
||||
import { anacondaDesktopDict } from "./en"
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "Provjeri ponovo",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"Dostupan je 1 preuzeti model za generisanje teksta. U Anaconda Desktopu pokrenite server modela. Modeli s podrškom za pozivanje alata se snažno preporučuju.",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"Preuzeti modeli za generisanje teksta dostupni su (ukupno: {{count}}). U Anaconda Desktopu pokrenite server modela. Modeli s podrškom za pozivanje alata se snažno preporučuju.",
|
||||
"command.category.suggested": "Predloženo",
|
||||
"command.category.view": "Prikaz",
|
||||
"command.category.project": "Projekat",
|
||||
|
||||
+8
@@ -1,4 +1,12 @@
|
||||
import { anacondaDesktopDict } from "./en"
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "Tjek igen",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"Der er 1 downloadet tekstgenereringsmodel tilgængelig. Start en modelserver i Anaconda Desktop. Modeller med understøttelse af værktøjskald anbefales kraftigt.",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"Der er {{count}} downloadede tekstgenereringsmodeller tilgængelige. Start en modelserver i Anaconda Desktop. Modeller med understøttelse af værktøjskald anbefales kraftigt.",
|
||||
"command.category.suggested": "Foreslået",
|
||||
"command.category.view": "Vis",
|
||||
"command.category.project": "Projekt",
|
||||
|
||||
+7
-1
@@ -1,8 +1,14 @@
|
||||
import { dict as en } from "./en"
|
||||
import { anacondaDesktopDict, dict as en } from "./en"
|
||||
|
||||
type Keys = keyof typeof en
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "Erneut prüfen",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"1 heruntergeladenes Textgenerierungsmodell ist verfügbar. Starten Sie in Anaconda Desktop einen Modellserver. Modelle mit Unterstützung für Tool-Aufrufe werden dringend empfohlen.",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"{{count}} heruntergeladene Textgenerierungsmodelle sind verfügbar. Starten Sie in Anaconda Desktop einen Modellserver. Modelle mit Unterstützung für Tool-Aufrufe werden dringend empfohlen.",
|
||||
"command.category.suggested": "Vorgeschlagen",
|
||||
"command.category.view": "Ansicht",
|
||||
"command.category.project": "Projekt",
|
||||
|
||||
@@ -1,4 +1,57 @@
|
||||
export const anacondaDesktopDict = {
|
||||
"provider.anaconda.title.connect": "Connect Anaconda Desktop",
|
||||
"provider.anaconda.title.manage": "Manage Anaconda Desktop",
|
||||
"provider.anaconda.status.checking": "Checking Anaconda Desktop...",
|
||||
"provider.anaconda.status.opening": "Opening Anaconda Desktop...",
|
||||
"provider.anaconda.status.syncing": "Refreshing provider models...",
|
||||
"provider.anaconda.status.ready": "Ready to connect",
|
||||
"provider.anaconda.status.waiting": "Waiting for Desktop",
|
||||
"provider.anaconda.status.attention": "Needs attention",
|
||||
"provider.anaconda.status.unavailable": "Unavailable",
|
||||
"provider.anaconda.state.unsupported": "Anaconda Desktop is not supported on {{platform}}.",
|
||||
"provider.anaconda.state.notInstalled":
|
||||
"Install Anaconda Desktop on this machine, then return here. Kilo does not run the installer for you.",
|
||||
"provider.anaconda.state.notRunning": "Open Anaconda Desktop, finish setup and sign in, then choose Check again.",
|
||||
"provider.anaconda.state.invalidConfig":
|
||||
"Anaconda Desktop setup is incomplete. Open Desktop, finish setup, and restart it if needed.",
|
||||
"provider.anaconda.state.signedOut": "Open Anaconda Desktop and sign in before connecting Kilo.",
|
||||
"provider.anaconda.state.unauthorized":
|
||||
"Kilo could not access Anaconda Desktop. Open Desktop, sign in again, and restart it if needed.",
|
||||
"provider.anaconda.state.unavailable":
|
||||
"Anaconda Desktop is not responding yet. Open it and wait for the application to finish starting.",
|
||||
"provider.anaconda.state.noModel":
|
||||
"In Anaconda Desktop, download a text-generation model. Choose one with tool calling when possible, then start its server.",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"1 downloaded text-generation model is available. In Anaconda Desktop, start a model server. Models with tool calling support are strongly recommended.",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"{{count}} downloaded text-generation models are available. In Anaconda Desktop, start a model server. Models with tool calling support are strongly recommended.",
|
||||
"provider.anaconda.state.unhealthy":
|
||||
"The active inference server is not healthy yet. Check it in Anaconda Desktop and restart the server if needed.",
|
||||
"provider.anaconda.state.ready":
|
||||
"Kilo found a healthy local text-generation server and can import its current connection settings.",
|
||||
"provider.anaconda.server": "Active inference server",
|
||||
"provider.anaconda.context": "Context window",
|
||||
"provider.anaconda.contextValue": "{{count}} tokens",
|
||||
"provider.anaconda.tools": "Tool calling",
|
||||
"provider.anaconda.tools.supported": "Supported",
|
||||
"provider.anaconda.tools.unsupported": "Not enabled",
|
||||
"provider.anaconda.tools.unknown": "Unknown",
|
||||
"provider.anaconda.warning.title": "Tool support is limited",
|
||||
"provider.anaconda.warning.description":
|
||||
"This server does not confirm tool calling. Coding-agent actions may fail or be unavailable. Continue only if you accept these limitations.",
|
||||
"provider.anaconda.action.download": "Download Anaconda Desktop",
|
||||
"provider.anaconda.action.open": "Open Anaconda Desktop",
|
||||
"provider.anaconda.action.checkAgain": "Check again",
|
||||
"provider.anaconda.action.continue": "Continue anyway",
|
||||
"provider.anaconda.action.manage": "Manage / Refresh",
|
||||
"provider.anaconda.toast.refreshed.title": "Anaconda Desktop refreshed",
|
||||
"provider.anaconda.toast.refreshed.description": "The active local server and models are up to date in Kilo.",
|
||||
"settings.providers.note.anacondaDesktop": "Run a model served locally by Anaconda Desktop.",
|
||||
"settings.providers.tag.local": "Local",
|
||||
} as const
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"command.category.suggested": "Suggested",
|
||||
"command.category.view": "View",
|
||||
"command.category.project": "Project",
|
||||
|
||||
+8
@@ -1,4 +1,12 @@
|
||||
import { anacondaDesktopDict } from "./en"
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "Comprobar de nuevo",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"Hay 1 modelo de generación de texto descargado disponible. En Anaconda Desktop, inicia un servidor de modelos. Se recomienda encarecidamente usar modelos compatibles con llamadas a herramientas.",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"Hay {{count}} modelos de generación de texto descargados disponibles. En Anaconda Desktop, inicia un servidor de modelos. Se recomienda encarecidamente usar modelos compatibles con llamadas a herramientas.",
|
||||
"command.category.suggested": "Sugerido",
|
||||
"command.category.view": "Ver",
|
||||
"command.category.project": "Proyecto",
|
||||
|
||||
+8
@@ -1,4 +1,12 @@
|
||||
import { anacondaDesktopDict } from "./en"
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "Vérifier à nouveau",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"1 modèle de génération de texte téléchargé est disponible. Dans Anaconda Desktop, démarrez un serveur de modèle. Les modèles prenant en charge l’appel d’outils sont vivement recommandés.",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"{{count}} modèles de génération de texte téléchargés sont disponibles. Dans Anaconda Desktop, démarrez un serveur de modèle. Les modèles prenant en charge l’appel d’outils sont vivement recommandés.",
|
||||
"command.category.suggested": "Suggéré",
|
||||
"command.category.view": "Affichage",
|
||||
"command.category.project": "Projet",
|
||||
|
||||
+8
@@ -1,4 +1,12 @@
|
||||
import { anacondaDesktopDict } from "./en"
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "Controlla di nuovo",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"È disponibile 1 modello di generazione del testo scaricato. In Anaconda Desktop, avvia un server di modelli. I modelli che supportano le chiamate agli strumenti sono fortemente consigliati.",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"Sono disponibili {{count}} modelli di generazione del testo scaricati. In Anaconda Desktop, avvia un server di modelli. I modelli che supportano le chiamate agli strumenti sono fortemente consigliati.",
|
||||
"command.category.suggested": "Suggeriti",
|
||||
"command.category.view": "Vista",
|
||||
"command.category.project": "Progetto",
|
||||
|
||||
+8
@@ -1,4 +1,12 @@
|
||||
import { anacondaDesktopDict } from "./en"
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "再確認",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"ダウンロード済みのテキスト生成モデルが1つ利用可能です。Anaconda Desktopでモデルサーバーを起動してください。ツール呼び出しに対応したモデルの使用を強く推奨します。",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"ダウンロード済みのテキスト生成モデルが{{count}}個利用可能です。Anaconda Desktopでモデルサーバーを起動してください。ツール呼び出しに対応したモデルの使用を強く推奨します。",
|
||||
"command.category.suggested": "おすすめ",
|
||||
"command.category.view": "表示",
|
||||
"command.category.project": "プロジェクト",
|
||||
|
||||
+7
-1
@@ -1,8 +1,14 @@
|
||||
import { dict as en } from "./en"
|
||||
import { anacondaDesktopDict, dict as en } from "./en"
|
||||
|
||||
type Keys = keyof typeof en
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "다시 확인",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"다운로드된 텍스트 생성 모델 1개를 사용할 수 있습니다. Anaconda Desktop에서 모델 서버를 시작하세요. 도구 호출을 지원하는 모델을 강력히 권장합니다.",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"다운로드된 텍스트 생성 모델 {{count}}개를 사용할 수 있습니다. Anaconda Desktop에서 모델 서버를 시작하세요. 도구 호출을 지원하는 모델을 강력히 권장합니다.",
|
||||
"command.category.suggested": "추천",
|
||||
"command.category.view": "보기",
|
||||
"command.category.project": "프로젝트",
|
||||
|
||||
+8
@@ -1,4 +1,12 @@
|
||||
import { anacondaDesktopDict } from "./en"
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "Opnieuw controleren",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"Er is 1 gedownload tekstgeneratiemodel beschikbaar. Start een modelserver in Anaconda Desktop. Modellen met ondersteuning voor toolaanroepen worden sterk aanbevolen.",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"Er zijn {{count}} gedownloade tekstgeneratiemodellen beschikbaar. Start een modelserver in Anaconda Desktop. Modellen met ondersteuning voor toolaanroepen worden sterk aanbevolen.",
|
||||
"command.category.suggested": "Voorgesteld",
|
||||
"command.category.view": "Weergave",
|
||||
"command.category.project": "Project",
|
||||
|
||||
+7
-1
@@ -1,7 +1,13 @@
|
||||
import { dict as en } from "./en"
|
||||
import { anacondaDesktopDict, dict as en } from "./en"
|
||||
type Keys = keyof typeof en
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "Sjekk igjen",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"1 nedlastet tekstgenereringsmodell er tilgjengelig. Start en modellserver i Anaconda Desktop. Modeller med støtte for verktøykall anbefales på det sterkeste.",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"{{count}} nedlastede tekstgenereringsmodeller er tilgjengelige. Start en modellserver i Anaconda Desktop. Modeller med støtte for verktøykall anbefales på det sterkeste.",
|
||||
"command.category.suggested": "Foreslått",
|
||||
"command.category.view": "Visning",
|
||||
"command.category.project": "Prosjekt",
|
||||
|
||||
+8
@@ -1,4 +1,12 @@
|
||||
import { anacondaDesktopDict } from "./en"
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "Sprawdź ponownie",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"Dostępny jest 1 pobrany model generowania tekstu. W Anaconda Desktop uruchom serwer modelu. Zdecydowanie zalecamy modele obsługujące wywoływanie narzędzi.",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"Dostępne są pobrane modele generowania tekstu (łącznie: {{count}}). W Anaconda Desktop uruchom serwer modelu. Zdecydowanie zalecamy modele obsługujące wywoływanie narzędzi.",
|
||||
"command.category.suggested": "Sugerowane",
|
||||
"command.category.view": "Widok",
|
||||
"command.category.project": "Projekt",
|
||||
|
||||
+8
@@ -1,4 +1,12 @@
|
||||
import { anacondaDesktopDict } from "./en"
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "Проверить снова",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"Доступна 1 загруженная модель генерации текста. Запустите сервер модели в Anaconda Desktop. Настоятельно рекомендуется использовать модели с поддержкой вызова инструментов.",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"Доступно загруженных моделей генерации текста: {{count}}. Запустите сервер модели в Anaconda Desktop. Настоятельно рекомендуется использовать модели с поддержкой вызова инструментов.",
|
||||
"command.category.suggested": "Предложено",
|
||||
"command.category.view": "Просмотр",
|
||||
"command.category.project": "Проект",
|
||||
|
||||
+8
@@ -1,4 +1,12 @@
|
||||
import { anacondaDesktopDict } from "./en"
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "ตรวจสอบอีกครั้ง",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"มีโมเดลสร้างข้อความที่ดาวน์โหลดไว้แล้วพร้อมใช้งานอยู่ 1 โมเดล โปรดเริ่มเซิร์ฟเวอร์โมเดลใน Anaconda Desktop ขอแนะนำอย่างยิ่งให้ใช้โมเดลที่รองรับการเรียกใช้เครื่องมือ",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"มีโมเดลสร้างข้อความที่ดาวน์โหลดไว้แล้วพร้อมใช้งานอยู่ {{count}} โมเดล โปรดเริ่มเซิร์ฟเวอร์โมเดลใน Anaconda Desktop ขอแนะนำอย่างยิ่งให้ใช้โมเดลที่รองรับการเรียกใช้เครื่องมือ",
|
||||
"command.category.suggested": "แนะนำ",
|
||||
"command.category.view": "มุมมอง",
|
||||
"command.category.project": "โปรเจกต์",
|
||||
|
||||
+8
@@ -1,4 +1,12 @@
|
||||
import { anacondaDesktopDict } from "./en"
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "Tekrar kontrol et",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"İndirilmiş 1 metin üretme modeli kullanılabilir. Anaconda Desktop'ta bir model sunucusu başlatın. Araç çağırma desteği olan modeller önemle tavsiye edilir.",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"İndirilmiş {{count}} metin üretme modeli kullanılabilir. Anaconda Desktop'ta bir model sunucusu başlatın. Araç çağırma desteği olan modeller önemle tavsiye edilir.",
|
||||
"command.category.suggested": "Önerilen",
|
||||
"command.category.view": "Görünüm",
|
||||
"command.category.project": "Proje",
|
||||
|
||||
+8
@@ -1,4 +1,12 @@
|
||||
import { anacondaDesktopDict } from "./en"
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "Перевірити ще раз",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"Доступна 1 завантажена модель генерації тексту. Запустіть сервер моделі в Anaconda Desktop. Наполегливо рекомендуємо використовувати моделі з підтримкою виклику інструментів.",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"Доступно завантажених моделей генерації тексту: {{count}}. Запустіть сервер моделі в Anaconda Desktop. Наполегливо рекомендуємо використовувати моделі з підтримкою виклику інструментів.",
|
||||
"command.category.suggested": "Запропоновані",
|
||||
"command.category.view": "Вигляд",
|
||||
"command.category.project": "Проєкт",
|
||||
|
||||
+7
-1
@@ -1,8 +1,14 @@
|
||||
import { dict as en } from "./en"
|
||||
import { anacondaDesktopDict, dict as en } from "./en"
|
||||
|
||||
type Keys = keyof typeof en
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "再次检查",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"有 1 个已下载的文本生成模型可用。请在 Anaconda Desktop 中启动一个模型服务器。强烈建议使用支持工具调用的模型。",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"有 {{count}} 个已下载的文本生成模型可用。请在 Anaconda Desktop 中启动一个模型服务器。强烈建议使用支持工具调用的模型。",
|
||||
"command.category.suggested": "建议",
|
||||
"command.category.view": "视图",
|
||||
"command.category.project": "项目",
|
||||
|
||||
+7
-1
@@ -1,8 +1,14 @@
|
||||
import { dict as en } from "./en"
|
||||
import { anacondaDesktopDict, dict as en } from "./en"
|
||||
|
||||
type Keys = keyof typeof en
|
||||
|
||||
export const dict = {
|
||||
...anacondaDesktopDict,
|
||||
"provider.anaconda.action.checkAgain": "再次檢查",
|
||||
"provider.anaconda.state.noServer_one":
|
||||
"有 1 個已下載的文字生成模型可用。請在 Anaconda Desktop 中啟動一個模型伺服器。強烈建議使用支援工具呼叫的模型。",
|
||||
"provider.anaconda.state.noServer_other":
|
||||
"有 {{count}} 個已下載的文字生成模型可用。請在 Anaconda Desktop 中啟動一個模型伺服器。強烈建議使用支援工具呼叫的模型。",
|
||||
"command.category.suggested": "建議",
|
||||
"command.category.view": "檢視",
|
||||
"command.category.project": "專案",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
/** Stories for Anaconda Desktop provider setup. */
|
||||
|
||||
import type { Meta, StoryObj } from "storybook-solidjs-vite"
|
||||
import type { AnacondaDesktopStatus } from "@kilocode/sdk/v2/client"
|
||||
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
|
||||
import { onMount } from "solid-js"
|
||||
import { StoryProviders } from "./StoryProviders"
|
||||
import AnacondaDesktopDialog from "../components/settings/AnacondaDesktopDialog"
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Anaconda Desktop",
|
||||
parameters: { layout: "fullscreen" },
|
||||
}
|
||||
export default meta
|
||||
type Story = StoryObj
|
||||
|
||||
function Dialog(props: { status: AnacondaDesktopStatus }) {
|
||||
const dialog = useDialog()
|
||||
onMount(() => dialog.show(() => <AnacondaDesktopDialog status={props.status} />))
|
||||
return null
|
||||
}
|
||||
|
||||
export const NotInstalled: Story = {
|
||||
name: "Not installed",
|
||||
render: () => (
|
||||
<StoryProviders>
|
||||
<Dialog status={{ type: "not-installed", downloadURL: "about:blank" }} />
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
export const Waiting: Story = {
|
||||
name: "Waiting for server",
|
||||
render: () => (
|
||||
<StoryProviders>
|
||||
<Dialog status={{ type: "no-running-server", downloadedModels: 3 }} />
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
export const Ready: Story = {
|
||||
name: "Ready with tools",
|
||||
render: () => (
|
||||
<StoryProviders>
|
||||
<Dialog
|
||||
status={{
|
||||
type: "ready",
|
||||
serverID: "local-server",
|
||||
serverName: "Qwen Coder",
|
||||
models: [{ id: "qwen-coder", name: "Qwen 2.5 Coder 14B" }],
|
||||
context: 32768,
|
||||
toolcall: "supported",
|
||||
}}
|
||||
/>
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
export const LimitedTools: Story = {
|
||||
name: "Ready without tools",
|
||||
render: () => (
|
||||
<StoryProviders>
|
||||
<Dialog
|
||||
status={{
|
||||
type: "ready",
|
||||
serverID: "local-server",
|
||||
serverName: "Llama Local",
|
||||
models: [{ id: "llama-local", name: "Llama 3.2 3B Instruct" }],
|
||||
context: 16384,
|
||||
toolcall: "unsupported",
|
||||
}}
|
||||
/>
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
SessionUpdate,
|
||||
} from "./sessions"
|
||||
import type { PermissionRequest } from "./permissions"
|
||||
import type { AnacondaDesktopExtensionMessage } from "../../../../src/anaconda-desktop/messages"
|
||||
import type { QuestionRequest, SuggestionRequest, TodoItem } from "./questions"
|
||||
import type { ModelSelection, Provider, ProviderAuthState } from "./providers"
|
||||
import type { AgentInfo, SkillInfo, SlashCommandInfo } from "./agents"
|
||||
@@ -1125,6 +1126,7 @@ export type ExtensionMessage =
|
||||
| ProviderConnectedMessage
|
||||
| ProviderDisconnectedMessage
|
||||
| ProviderActionErrorMessage
|
||||
| AnacondaDesktopExtensionMessage
|
||||
| CustomProviderModelsFetchedMessage
|
||||
| RecentsLoadedMessage
|
||||
| FavoritesLoadedMessage
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Config } from "./config"
|
||||
import type { ModelAllocation, ReviewComment } from "./agent-manager"
|
||||
import type { ReviewMessageData } from "../../../../src/shared/review-comments"
|
||||
import type { WorkStyle, WorkStyleState } from "../../../../src/shared/work-style-presets"
|
||||
import type { AnacondaDesktopWebviewMessage } from "../../../../src/anaconda-desktop/messages"
|
||||
import type {
|
||||
ClearLegacyDataMessage,
|
||||
FinalizeLegacyMigrationMessage,
|
||||
@@ -1282,6 +1283,7 @@ export type WebviewMessage =
|
||||
| AuthorizeProviderOAuthMessage
|
||||
| CompleteProviderOAuthMessage
|
||||
| DisconnectProviderMessage
|
||||
| AnacondaDesktopWebviewMessage
|
||||
| SaveCustomProviderMessage
|
||||
| FetchCustomProviderModelsMessage
|
||||
| PersistRecentsRequest
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ExtensionMessage, WebviewMessage } from "../types/messages"
|
||||
import type {
|
||||
AnacondaDesktopError,
|
||||
AnacondaDesktopRequest,
|
||||
AnacondaDesktopResult,
|
||||
} from "../../../src/anaconda-desktop/messages"
|
||||
|
||||
type Transport = {
|
||||
postMessage: (message: WebviewMessage) => void
|
||||
onMessage: (handler: (message: ExtensionMessage) => void) => () => void
|
||||
}
|
||||
|
||||
type RequestInput<T> = T extends { requestId: string } ? Omit<T, "requestId"> : never
|
||||
|
||||
type Handlers = {
|
||||
onStatus?: (message: Extract<AnacondaDesktopResult, { type: "anacondaDesktopStatusResult" }>) => void
|
||||
onOpened?: (message: Extract<AnacondaDesktopResult, { type: "anacondaDesktopOpened" }>) => void
|
||||
onSynced?: (message: Extract<AnacondaDesktopResult, { type: "anacondaDesktopSynced" }>) => void
|
||||
onError?: (message: AnacondaDesktopError) => void
|
||||
}
|
||||
|
||||
export function createAnacondaDesktopAction(vscode: Transport) {
|
||||
const pending = new Map<string, Handlers>()
|
||||
const unsubscribe = vscode.onMessage((message) => {
|
||||
if (!message.type.startsWith("anacondaDesktop") || !("requestId" in message)) return
|
||||
const handlers = pending.get(message.requestId)
|
||||
if (!handlers) return
|
||||
pending.delete(message.requestId)
|
||||
|
||||
if (message.type === "anacondaDesktopStatusResult") handlers.onStatus?.(message)
|
||||
if (message.type === "anacondaDesktopOpened") handlers.onOpened?.(message)
|
||||
if (message.type === "anacondaDesktopSynced") handlers.onSynced?.(message)
|
||||
if (message.type === "anacondaDesktopActionError") handlers.onError?.(message)
|
||||
})
|
||||
|
||||
function send(message: RequestInput<AnacondaDesktopRequest>, handlers: Handlers = {}) {
|
||||
const requestId = crypto.randomUUID()
|
||||
pending.set(requestId, handlers)
|
||||
vscode.postMessage({ ...message, requestId } as AnacondaDesktopRequest)
|
||||
return requestId
|
||||
}
|
||||
|
||||
function clear(requestId?: string) {
|
||||
const ids = requestId ? [requestId] : [...pending.keys()]
|
||||
for (const id of ids) {
|
||||
if (!pending.delete(id)) continue
|
||||
vscode.postMessage({ type: "cancelAnacondaDesktopRequest", requestId: id })
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
clear()
|
||||
unsubscribe()
|
||||
}
|
||||
|
||||
return { clear, send, dispose }
|
||||
}
|
||||
@@ -135,6 +135,7 @@ export function createDialogProviderOptions() {
|
||||
gutter: failedGutter ?? (connected && onboarded() ? () => <text fg={theme.success}>✓</text> : undefined), // kilocode_change
|
||||
async onSelect() {
|
||||
if (consoleManaged) return
|
||||
if (KiloProvider.selectProvider({ providerID, replace: dialog.replace, model: DialogModel })) return // kilocode_change
|
||||
|
||||
const methods = sync.data.provider_auth[providerID] ?? [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Context, Duration, Effect, Layer, Option, Redacted, Result, Schema } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import * as DesktopPlatform from "./platform"
|
||||
import {
|
||||
endpoint,
|
||||
normalizeLoopbackEndpoint,
|
||||
readConfig,
|
||||
readStore,
|
||||
warning,
|
||||
CatalogResponse,
|
||||
DOWNLOAD_URL,
|
||||
HealthResponse,
|
||||
InferenceModelsResponse,
|
||||
ManagementRoot,
|
||||
PropsResponse,
|
||||
REQUEST_TIMEOUT,
|
||||
RequestError,
|
||||
ServersResponse,
|
||||
type CatalogModel,
|
||||
type InferenceModel,
|
||||
type Metadata,
|
||||
type ModelDescriptor,
|
||||
type PropsResponse as Props,
|
||||
type Server,
|
||||
type Status,
|
||||
type ToolCapability,
|
||||
} from "./domain"
|
||||
|
||||
export interface Connection {
|
||||
readonly key: Redacted.Redacted<string>
|
||||
readonly metadata: Metadata
|
||||
}
|
||||
|
||||
export interface DiscoveryResult {
|
||||
readonly status: Status
|
||||
readonly connection?: Connection
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly discover: () => Effect.Effect<DiscoveryResult>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@kilocode/AnacondaDesktopDiscovery") {}
|
||||
|
||||
interface Options {
|
||||
readonly timeout?: Duration.Input
|
||||
}
|
||||
|
||||
function record(input: unknown): input is Readonly<Record<string, unknown>> {
|
||||
return typeof input === "object" && input !== null && !Array.isArray(input)
|
||||
}
|
||||
|
||||
function secret(input: Redacted.Redacted<string> | undefined) {
|
||||
return input ?? Redacted.make("", { label: "Anaconda inference key" })
|
||||
}
|
||||
|
||||
const decodeRoot = Schema.decodeUnknownOption(ManagementRoot)
|
||||
const decodeCatalog = Schema.decodeUnknownOption(CatalogResponse)
|
||||
const decodeServers = Schema.decodeUnknownOption(ServersResponse)
|
||||
const decodeHealth = Schema.decodeUnknownOption(HealthResponse)
|
||||
const decodeModels = Schema.decodeUnknownOption(InferenceModelsResponse)
|
||||
const decodeProps = Schema.decodeUnknownOption(PropsResponse)
|
||||
|
||||
function request(
|
||||
http: HttpClient.HttpClient,
|
||||
target: "management" | "inference",
|
||||
url: string,
|
||||
key: Redacted.Redacted<string>,
|
||||
timeout: Duration.Input,
|
||||
) {
|
||||
const base = HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson)
|
||||
const auth = Redacted.value(key) === "" ? base : base.pipe(HttpClientRequest.bearerToken(key))
|
||||
return http.execute(auth).pipe(
|
||||
Effect.flatMap((response) => {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return Effect.fail(new RequestError({ target, reason: "unauthorized" }))
|
||||
}
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return Effect.fail(new RequestError({ target, reason: "unexpected-status" }))
|
||||
}
|
||||
return response.json.pipe(Effect.mapError(() => new RequestError({ target, reason: "malformed" })))
|
||||
}),
|
||||
Effect.timeoutOrElse({
|
||||
duration: timeout,
|
||||
orElse: () => Effect.fail(new RequestError({ target, reason: "timeout" })),
|
||||
}),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof RequestError ? error : new RequestError({ target, reason: "transport" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function management(error: RequestError): DiscoveryResult {
|
||||
if (error.reason === "transport") return { status: { type: "not-running" } }
|
||||
if (error.reason === "unauthorized") return { status: { type: "management-unauthorized" } }
|
||||
return {
|
||||
status: {
|
||||
type: "management-unavailable",
|
||||
reason: error.reason === "timeout" ? "timeout" : "unexpected-response",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function unhealthy(serverID: string): DiscoveryResult {
|
||||
return { status: { type: "inference-unhealthy", serverID } }
|
||||
}
|
||||
|
||||
function identity(server: Server) {
|
||||
if (server.id) return server.id
|
||||
if (typeof server.serverProcessId === "number") return `process-${server.serverProcessId}`
|
||||
return server.modelFile?.id ?? "unknown"
|
||||
}
|
||||
|
||||
function running(server: Server) {
|
||||
if (server.status.trim().toLowerCase() !== "running") return false
|
||||
return !server.tag || server.tag.trim().toLowerCase() === "inference"
|
||||
}
|
||||
|
||||
function params(server: Server) {
|
||||
return server.server ?? server.serverConfig?.serverParams ?? server.serverConfig?.apiParams ?? server
|
||||
}
|
||||
|
||||
function key(server: Server) {
|
||||
const runtime = server.server
|
||||
const config = server.serverConfig
|
||||
return secret(
|
||||
runtime?.api_key ??
|
||||
runtime?.apiKey ??
|
||||
config?.serverParams?.api_key ??
|
||||
config?.serverParams?.apiKey ??
|
||||
config?.apiParams?.api_key ??
|
||||
config?.apiParams?.apiKey ??
|
||||
config?.api_key ??
|
||||
config?.apiKey ??
|
||||
server.api_key ??
|
||||
server.apiKey,
|
||||
)
|
||||
}
|
||||
|
||||
function location(server: Server) {
|
||||
const source = params(server)
|
||||
const url = source.url ?? server.url
|
||||
if (url) return normalizeLoopbackEndpoint(url)
|
||||
|
||||
const hostname = source.host ?? server.host
|
||||
const port = source.port ?? server.port
|
||||
if (!hostname || typeof port !== "number" || !Number.isInteger(port)) return
|
||||
return endpoint(hostname, port)
|
||||
}
|
||||
|
||||
function files(model: CatalogModel) {
|
||||
return [
|
||||
...(model.files ?? []),
|
||||
...(model.metadata?.files ?? []),
|
||||
...(model.metadata?.quantizations ?? []).map((item) => ({ name: item.modelFileName })),
|
||||
]
|
||||
}
|
||||
|
||||
function catalog(models: ReadonlyArray<CatalogModel>, server: Server) {
|
||||
const file = server.modelFile
|
||||
if (!file) return
|
||||
return models.find((model) => {
|
||||
if (file.id && files(model).some((item) => "id" in item && item.id === file.id)) return true
|
||||
if (file.name && files(model).some((item) => item.name === file.name)) return true
|
||||
return file.name === model.name
|
||||
})
|
||||
}
|
||||
|
||||
function generation(model: CatalogModel) {
|
||||
const task = (model.trainedFor ?? model.metadata?.trainedFor)?.trim().toLowerCase()
|
||||
return !task || task === "text-generation"
|
||||
}
|
||||
|
||||
function text(model: CatalogModel | undefined, props: Props) {
|
||||
if (model && !generation(model)) return false
|
||||
const kind = [props.type, props.model_type, props.task]
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
if (/embed|rerank|sentence[-_ ]similarity/.test(kind)) return false
|
||||
const capabilities = record(props.capabilities) ? props.capabilities : undefined
|
||||
if (capabilities?.chat === false || capabilities?.completion === false) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function size(props: Props, model: CatalogModel | undefined) {
|
||||
const defaults = record(props.default_generation_settings) ? props.default_generation_settings : undefined
|
||||
const values = [
|
||||
defaults?.n_ctx,
|
||||
props.n_ctx,
|
||||
props.context_size,
|
||||
props.contextWindowSize,
|
||||
model?.contextWindowSize,
|
||||
model?.metadata?.contextWindowSize,
|
||||
]
|
||||
const found = values.find((item) => typeof item === "number" && Number.isFinite(item) && item >= 0)
|
||||
return typeof found === "number" ? Math.floor(found) : 0
|
||||
}
|
||||
|
||||
function tools(props: Props): ToolCapability {
|
||||
const caps = record(props.chat_template_caps) ? props.chat_template_caps : undefined
|
||||
if (!caps) return "unknown"
|
||||
const values = [caps.supports_tools, caps.supports_tool_calls, caps.supportsToolCalls].filter(
|
||||
(item): item is boolean => typeof item === "boolean",
|
||||
)
|
||||
if (values.includes(true)) return "supported"
|
||||
if (values.length > 0) return "unsupported"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
type Modality = "text" | "audio" | "image" | "video" | "pdf"
|
||||
|
||||
function modality(input: string): input is Modality {
|
||||
if (input === "text") return true
|
||||
if (input === "audio") return true
|
||||
if (input === "image") return true
|
||||
if (input === "video") return true
|
||||
return input === "pdf"
|
||||
}
|
||||
|
||||
function list(input: ReadonlyArray<string> | undefined) {
|
||||
return (input ?? []).map((item) => item.toLowerCase()).filter(modality)
|
||||
}
|
||||
|
||||
function descriptors(
|
||||
entries: ReadonlyArray<InferenceModel>,
|
||||
model: CatalogModel | undefined,
|
||||
server: Server,
|
||||
props: Props,
|
||||
toolcall: ToolCapability,
|
||||
): ModelDescriptor[] {
|
||||
const reported = record(props.modalities) ? props.modalities : undefined
|
||||
const vision = reported?.vision === true
|
||||
const audio = reported?.audio === true
|
||||
const file = server.modelFile?.name
|
||||
const base = model?.name ?? file
|
||||
const note = warning(toolcall)
|
||||
return entries.map((entry) => {
|
||||
const input = new Set<Modality>(["text", ...list(entry.modalities?.input)])
|
||||
const output = new Set<Modality>(["text", ...list(entry.modalities?.output)])
|
||||
if (vision) input.add("image")
|
||||
if (audio) input.add("audio")
|
||||
const name = entries.length === 1 && base ? base : entry.id
|
||||
const family =
|
||||
model?.family ?? model?.metadata?.family ?? model?.model_type ?? model?.metadata?.model_type ?? entry.family
|
||||
return {
|
||||
id: entry.id,
|
||||
name,
|
||||
...(family?.trim() ? { family: family.trim() } : {}),
|
||||
input: [...input],
|
||||
output: [...output],
|
||||
...(note ? { description: note } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function makeLayer(options: Options = {}) {
|
||||
return Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const platform = yield* DesktopPlatform.Service
|
||||
const timeout = options.timeout ?? REQUEST_TIMEOUT
|
||||
|
||||
const discover = Effect.fn("AnacondaDesktopDiscovery.discover")(function* () {
|
||||
if (!DesktopPlatform.supported(platform.info)) {
|
||||
return {
|
||||
status: { type: "unsupported-platform", platform: platform.info.platform },
|
||||
} satisfies DiscoveryResult
|
||||
}
|
||||
|
||||
const install = yield* platform.installation().pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!install) {
|
||||
return { status: { type: "not-installed", downloadURL: DOWNLOAD_URL } } satisfies DiscoveryResult
|
||||
}
|
||||
|
||||
const dir = yield* platform.dataDir().pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!dir) {
|
||||
return {
|
||||
status: { type: "unsupported-platform", platform: platform.info.platform },
|
||||
} satisfies DiscoveryResult
|
||||
}
|
||||
|
||||
const cfg = yield* readConfig(dir).pipe(Effect.provideService(AppFileSystem.Service, fs), Effect.result)
|
||||
if (Result.isFailure(cfg)) {
|
||||
return {
|
||||
status: { type: "invalid-config", reason: cfg.failure.reason },
|
||||
} satisfies DiscoveryResult
|
||||
}
|
||||
|
||||
const origin = `http://127.0.0.1:${cfg.success.aiNavApiServerPort}`
|
||||
const root = yield* request(http, "management", `${origin}/api`, cfg.success.aiNavApiKey, timeout).pipe(
|
||||
Effect.result,
|
||||
)
|
||||
if (Result.isFailure(root)) return management(root.failure)
|
||||
if (Option.isNone(decodeRoot(root.success))) {
|
||||
return management(new RequestError({ target: "management", reason: "malformed" }))
|
||||
}
|
||||
|
||||
const signed = yield* readStore(dir).pipe(Effect.provideService(AppFileSystem.Service, fs), Effect.result)
|
||||
if (Result.isFailure(signed) || !signed.success) {
|
||||
return { status: { type: "signed-out" } } satisfies DiscoveryResult
|
||||
}
|
||||
|
||||
const found = yield* request(
|
||||
http,
|
||||
"management",
|
||||
`${origin}/api/models?downloaded=true`,
|
||||
cfg.success.aiNavApiKey,
|
||||
timeout,
|
||||
).pipe(Effect.result)
|
||||
if (Result.isFailure(found)) return management(found.failure)
|
||||
const decodedModels = decodeCatalog(found.success)
|
||||
if (Option.isNone(decodedModels)) {
|
||||
return management(new RequestError({ target: "management", reason: "malformed" }))
|
||||
}
|
||||
const downloaded = decodedModels.value.data.filter((model) => files(model).length > 0 && generation(model))
|
||||
if (downloaded.length === 0) {
|
||||
return { status: { type: "no-downloaded-model" } } satisfies DiscoveryResult
|
||||
}
|
||||
|
||||
const queried = yield* request(
|
||||
http,
|
||||
"management",
|
||||
`${origin}/api/servers?status=running&tag=inference`,
|
||||
cfg.success.aiNavApiKey,
|
||||
timeout,
|
||||
).pipe(Effect.result)
|
||||
if (Result.isFailure(queried)) return management(queried.failure)
|
||||
const decodedServers = decodeServers(queried.success)
|
||||
if (Option.isNone(decodedServers)) {
|
||||
return management(new RequestError({ target: "management", reason: "malformed" }))
|
||||
}
|
||||
const servers = decodedServers.value.data.filter(running)
|
||||
if (servers.length === 0) {
|
||||
return {
|
||||
status: {
|
||||
type: "no-running-server",
|
||||
downloadedModels: downloaded.length,
|
||||
},
|
||||
} satisfies DiscoveryResult
|
||||
}
|
||||
|
||||
const server = servers[0]
|
||||
const serverID = identity(server)
|
||||
const baseURL = location(server)
|
||||
if (!baseURL) return unhealthy(serverID)
|
||||
const api = baseURL.replace(/\/v1$/, "")
|
||||
const inferenceKey = key(server)
|
||||
|
||||
const health = yield* request(http, "inference", `${api}/health`, inferenceKey, timeout).pipe(Effect.result)
|
||||
if (Result.isFailure(health)) return unhealthy(serverID)
|
||||
const decodedHealth = decodeHealth(health.success)
|
||||
if (Option.isNone(decodedHealth) || !["ok", "healthy"].includes(decodedHealth.value.status.toLowerCase())) {
|
||||
return unhealthy(serverID)
|
||||
}
|
||||
|
||||
const listed = yield* request(http, "inference", `${api}/v1/models`, inferenceKey, timeout).pipe(Effect.result)
|
||||
if (Result.isFailure(listed)) return unhealthy(serverID)
|
||||
const decodedInference = decodeModels(listed.success)
|
||||
if (Option.isNone(decodedInference) || decodedInference.value.data.length === 0) return unhealthy(serverID)
|
||||
|
||||
const properties = yield* request(http, "inference", `${api}/props`, inferenceKey, timeout).pipe(Effect.result)
|
||||
if (Result.isFailure(properties)) return unhealthy(serverID)
|
||||
const decodedProps = decodeProps(properties.success)
|
||||
if (Option.isNone(decodedProps)) return unhealthy(serverID)
|
||||
|
||||
const model = catalog(downloaded, server)
|
||||
if (!text(model, decodedProps.value)) return unhealthy(serverID)
|
||||
const context = size(decodedProps.value, model)
|
||||
const toolcall = tools(decodedProps.value)
|
||||
const models = descriptors(decodedInference.value.data, model, server, decodedProps.value, toolcall)
|
||||
const metadata: Metadata = {
|
||||
version: "1",
|
||||
serverID,
|
||||
baseURL,
|
||||
models,
|
||||
context,
|
||||
toolcall,
|
||||
}
|
||||
return {
|
||||
status: {
|
||||
type: "ready",
|
||||
serverID,
|
||||
...(model?.name
|
||||
? { serverName: model.name }
|
||||
: server.modelFile?.name
|
||||
? { serverName: server.modelFile.name }
|
||||
: {}),
|
||||
models: models.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
})),
|
||||
context,
|
||||
toolcall,
|
||||
},
|
||||
connection: { key: inferenceKey, metadata },
|
||||
} satisfies DiscoveryResult
|
||||
})
|
||||
|
||||
return Service.of({ discover })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const layer = makeLayer()
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(DesktopPlatform.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
@@ -0,0 +1,429 @@
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
|
||||
export const PROVIDER_ID = "anaconda-desktop"
|
||||
export const DOWNLOAD_URL = "https://www.anaconda.com/products/desktop"
|
||||
export const CONFIG_FILE = "config.json"
|
||||
export const STORE_FILE = "anaconda-desktop-encrypted-store.json"
|
||||
export const OAUTH_SUFFIX = "_ai-navigator-workos-oauth"
|
||||
export const REQUEST_TIMEOUT = "4 seconds"
|
||||
|
||||
export const ToolCapability = Schema.Literals(["supported", "unsupported", "unknown"])
|
||||
export type ToolCapability = typeof ToolCapability.Type
|
||||
|
||||
export const Modality = Schema.Literals(["text", "audio", "image", "video", "pdf"])
|
||||
export type Modality = typeof Modality.Type
|
||||
|
||||
export const Port = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65_535 }))
|
||||
export const ContextSize = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }))
|
||||
|
||||
export const ModelDescriptor = Schema.Struct({
|
||||
id: Schema.NonEmptyString,
|
||||
name: Schema.NonEmptyString,
|
||||
family: Schema.optional(Schema.NonEmptyString),
|
||||
input: Schema.Array(Modality).check(Schema.isNonEmpty()),
|
||||
output: Schema.Array(Modality).check(Schema.isNonEmpty()),
|
||||
description: Schema.optional(Schema.NonEmptyString),
|
||||
})
|
||||
export type ModelDescriptor = typeof ModelDescriptor.Type
|
||||
|
||||
export const Metadata = Schema.Struct({
|
||||
version: Schema.Literal("1"),
|
||||
serverID: Schema.NonEmptyString,
|
||||
baseURL: Schema.NonEmptyString,
|
||||
models: Schema.Array(ModelDescriptor).check(Schema.isNonEmpty()),
|
||||
context: ContextSize,
|
||||
toolcall: ToolCapability,
|
||||
})
|
||||
export type Metadata = typeof Metadata.Type
|
||||
|
||||
export const EncodedMetadata = Schema.Struct({
|
||||
version: Schema.Literal("1"),
|
||||
serverID: Schema.NonEmptyString,
|
||||
baseURL: Schema.NonEmptyString,
|
||||
models: Schema.fromJsonString(Schema.Array(ModelDescriptor).check(Schema.isNonEmpty())),
|
||||
context: Schema.NumberFromString.pipe(
|
||||
Schema.check(Schema.isInt(), Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })),
|
||||
),
|
||||
toolcall: ToolCapability,
|
||||
})
|
||||
|
||||
export const Config = Schema.Struct({
|
||||
aiNavApiKey: Schema.RedactedFromValue(Schema.NonEmptyString, { label: "Anaconda Desktop management key" }),
|
||||
aiNavApiServerPort: Port,
|
||||
})
|
||||
export type Config = typeof Config.Type
|
||||
|
||||
const CatalogFile = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const CatalogQuantization = Schema.Struct({
|
||||
modelFileName: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const CatalogMetadata = Schema.Struct({
|
||||
trainedFor: Schema.optional(Schema.String),
|
||||
contextWindowSize: Schema.optional(Schema.Finite),
|
||||
description: Schema.optional(Schema.String),
|
||||
model_type: Schema.optional(Schema.String),
|
||||
family: Schema.optional(Schema.String),
|
||||
quantizations: Schema.optional(Schema.Array(CatalogQuantization)),
|
||||
files: Schema.optional(Schema.Array(CatalogFile)),
|
||||
})
|
||||
|
||||
export const CatalogModel = Schema.Struct({
|
||||
id: Schema.NonEmptyString,
|
||||
name: Schema.NonEmptyString,
|
||||
trainedFor: Schema.optional(Schema.String),
|
||||
contextWindowSize: Schema.optional(Schema.Finite),
|
||||
model_type: Schema.optional(Schema.String),
|
||||
family: Schema.optional(Schema.String),
|
||||
metadata: Schema.optional(CatalogMetadata),
|
||||
files: Schema.optional(Schema.Array(CatalogFile)),
|
||||
})
|
||||
export type CatalogModel = typeof CatalogModel.Type
|
||||
|
||||
export const CatalogResponse = Schema.Struct({
|
||||
data: Schema.Array(CatalogModel),
|
||||
})
|
||||
|
||||
const KeyFields = {
|
||||
apiKey: Schema.optional(Schema.RedactedFromValue(Schema.String, { label: "Anaconda inference key" })),
|
||||
api_key: Schema.optional(Schema.RedactedFromValue(Schema.String, { label: "Anaconda inference key" })),
|
||||
}
|
||||
|
||||
const Params = Schema.Struct({
|
||||
host: Schema.optional(Schema.String),
|
||||
port: Schema.optional(Schema.Finite),
|
||||
url: Schema.optional(Schema.String),
|
||||
...KeyFields,
|
||||
})
|
||||
|
||||
const ServerConfig = Schema.Struct({
|
||||
modelFileName: Schema.optional(Schema.String),
|
||||
apiParams: Schema.optional(Params),
|
||||
serverParams: Schema.optional(Params),
|
||||
...KeyFields,
|
||||
})
|
||||
|
||||
const ServerRuntime = Schema.Struct({
|
||||
host: Schema.optional(Schema.String),
|
||||
port: Schema.optional(Schema.Finite),
|
||||
url: Schema.optional(Schema.String),
|
||||
...KeyFields,
|
||||
})
|
||||
|
||||
export const Server = Schema.Struct({
|
||||
id: Schema.optional(Schema.NonEmptyString),
|
||||
serverProcessId: Schema.optional(Schema.NullOr(Schema.Int)),
|
||||
status: Schema.String,
|
||||
tag: Schema.optional(Schema.String),
|
||||
modelFile: Schema.optional(CatalogFile),
|
||||
serverConfig: Schema.optional(ServerConfig),
|
||||
server: Schema.optional(ServerRuntime),
|
||||
host: Schema.optional(Schema.String),
|
||||
port: Schema.optional(Schema.Finite),
|
||||
url: Schema.optional(Schema.String),
|
||||
...KeyFields,
|
||||
})
|
||||
export type Server = typeof Server.Type
|
||||
|
||||
export const ServersResponse = Schema.Struct({
|
||||
data: Schema.Array(Server),
|
||||
})
|
||||
|
||||
export const ManagementRoot = Schema.Struct({
|
||||
data: Schema.Record(Schema.String, Schema.Unknown),
|
||||
})
|
||||
|
||||
export const HealthResponse = Schema.Struct({
|
||||
status: Schema.String,
|
||||
})
|
||||
|
||||
const ReportedModalities = Schema.Struct({
|
||||
input: Schema.optional(Schema.Array(Schema.String)),
|
||||
output: Schema.optional(Schema.Array(Schema.String)),
|
||||
})
|
||||
|
||||
export const InferenceModel = Schema.Struct({
|
||||
id: Schema.NonEmptyString,
|
||||
owned_by: Schema.optional(Schema.String),
|
||||
family: Schema.optional(Schema.String),
|
||||
modalities: Schema.optional(ReportedModalities),
|
||||
})
|
||||
export type InferenceModel = typeof InferenceModel.Type
|
||||
|
||||
export const InferenceModelsResponse = Schema.Struct({
|
||||
data: Schema.Array(InferenceModel),
|
||||
})
|
||||
|
||||
export const PropsResponse = Schema.Record(Schema.String, Schema.Unknown)
|
||||
export type PropsResponse = typeof PropsResponse.Type
|
||||
|
||||
export const StatusModel = Schema.Struct({
|
||||
id: Schema.NonEmptyString,
|
||||
name: Schema.NonEmptyString,
|
||||
})
|
||||
export type StatusModel = typeof StatusModel.Type
|
||||
|
||||
export const UnsupportedStatus = Schema.Struct({
|
||||
type: Schema.Literal("unsupported-platform"),
|
||||
platform: Schema.String,
|
||||
})
|
||||
|
||||
export const NotInstalledStatus = Schema.Struct({
|
||||
type: Schema.Literal("not-installed"),
|
||||
downloadURL: Schema.String,
|
||||
})
|
||||
|
||||
export const NotRunningStatus = Schema.Struct({
|
||||
type: Schema.Literal("not-running"),
|
||||
})
|
||||
|
||||
export const InvalidConfigStatus = Schema.Struct({
|
||||
type: Schema.Literal("invalid-config"),
|
||||
reason: Schema.Literals(["missing", "malformed", "missing-key", "invalid-port"]),
|
||||
})
|
||||
|
||||
export const SignedOutStatus = Schema.Struct({
|
||||
type: Schema.Literal("signed-out"),
|
||||
})
|
||||
|
||||
export const ManagementUnauthorizedStatus = Schema.Struct({
|
||||
type: Schema.Literal("management-unauthorized"),
|
||||
})
|
||||
|
||||
export const ManagementUnavailableStatus = Schema.Struct({
|
||||
type: Schema.Literal("management-unavailable"),
|
||||
reason: Schema.Literals(["timeout", "unexpected-response"]),
|
||||
})
|
||||
|
||||
export const NoDownloadedModelStatus = Schema.Struct({
|
||||
type: Schema.Literal("no-downloaded-model"),
|
||||
})
|
||||
|
||||
export const NoRunningServerStatus = Schema.Struct({
|
||||
type: Schema.Literal("no-running-server"),
|
||||
downloadedModels: Schema.Int,
|
||||
})
|
||||
|
||||
export const InferenceUnhealthyStatus = Schema.Struct({
|
||||
type: Schema.Literal("inference-unhealthy"),
|
||||
serverID: Schema.NonEmptyString,
|
||||
})
|
||||
|
||||
export const ReadyStatus = Schema.Struct({
|
||||
type: Schema.Literal("ready"),
|
||||
serverID: Schema.NonEmptyString,
|
||||
serverName: Schema.optional(Schema.NonEmptyString),
|
||||
models: Schema.Array(StatusModel).check(Schema.isNonEmpty()),
|
||||
context: ContextSize,
|
||||
toolcall: ToolCapability,
|
||||
})
|
||||
export type ReadyStatus = typeof ReadyStatus.Type
|
||||
|
||||
export const Status = Schema.Union([
|
||||
UnsupportedStatus,
|
||||
NotInstalledStatus,
|
||||
NotRunningStatus,
|
||||
InvalidConfigStatus,
|
||||
SignedOutStatus,
|
||||
ManagementUnauthorizedStatus,
|
||||
ManagementUnavailableStatus,
|
||||
NoDownloadedModelStatus,
|
||||
NoRunningServerStatus,
|
||||
InferenceUnhealthyStatus,
|
||||
ReadyStatus,
|
||||
]).annotate({ discriminator: "type", identifier: "AnacondaDesktopStatus" })
|
||||
export type Status = typeof Status.Type
|
||||
|
||||
export class ConfigError extends Schema.TaggedErrorClass<ConfigError>()("AnacondaDesktopConfigError", {
|
||||
reason: Schema.Literals(["missing", "malformed", "missing-key", "invalid-port"]),
|
||||
}) {}
|
||||
|
||||
export class StoreError extends Schema.TaggedErrorClass<StoreError>()("AnacondaDesktopStoreError", {
|
||||
reason: Schema.Literals(["missing", "malformed"]),
|
||||
}) {}
|
||||
|
||||
export class PlatformError extends Schema.TaggedErrorClass<PlatformError>()("AnacondaDesktopPlatformError", {
|
||||
operation: Schema.Literals(["data-dir", "installation", "open"]),
|
||||
reason: Schema.Literals(["unsupported", "not-installed", "failed"]),
|
||||
}) {}
|
||||
|
||||
export class RequestError extends Schema.TaggedErrorClass<RequestError>()("AnacondaDesktopRequestError", {
|
||||
target: Schema.Literals(["management", "inference"]),
|
||||
reason: Schema.Literals(["timeout", "transport", "unauthorized", "unexpected-status", "malformed"]),
|
||||
}) {}
|
||||
|
||||
export class NotReadyError extends Schema.TaggedErrorClass<NotReadyError>()("AnacondaDesktopNotReadyError", {
|
||||
status: Status,
|
||||
}) {}
|
||||
|
||||
export class ToolAcknowledgementError extends Schema.TaggedErrorClass<ToolAcknowledgementError>()(
|
||||
"AnacondaDesktopToolAcknowledgementError",
|
||||
{
|
||||
status: ReadyStatus,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class SyncError extends Schema.TaggedErrorClass<SyncError>()("AnacondaDesktopSyncError", {
|
||||
operation: Schema.Literals(["encode", "store"]),
|
||||
}) {}
|
||||
|
||||
const json = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const config = Schema.decodeUnknownOption(Config)
|
||||
|
||||
function record(input: unknown): input is Record<string, unknown> {
|
||||
return typeof input === "object" && input !== null && !Array.isArray(input)
|
||||
}
|
||||
|
||||
export const parseConfig = Effect.fn("AnacondaDesktop.parseConfig")(function* (text: string) {
|
||||
const parsed = json(text)
|
||||
if (Option.isNone(parsed) || !record(parsed.value)) {
|
||||
return yield* new ConfigError({ reason: "malformed" })
|
||||
}
|
||||
|
||||
const key = parsed.value.aiNavApiKey
|
||||
if (typeof key !== "string" || key.trim() === "") {
|
||||
return yield* new ConfigError({ reason: "missing-key" })
|
||||
}
|
||||
|
||||
const port = parsed.value.aiNavApiServerPort
|
||||
if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65_535) {
|
||||
return yield* new ConfigError({ reason: "invalid-port" })
|
||||
}
|
||||
|
||||
const result = config({ aiNavApiKey: key.trim(), aiNavApiServerPort: port })
|
||||
if (Option.isNone(result)) return yield* new ConfigError({ reason: "malformed" })
|
||||
return result.value
|
||||
})
|
||||
|
||||
function present(input: unknown) {
|
||||
if (input === null || input === undefined) return false
|
||||
if (typeof input === "string") return input.trim() !== ""
|
||||
if (Array.isArray(input)) return input.length > 0
|
||||
if (record(input)) return Object.keys(input).length > 0
|
||||
return true
|
||||
}
|
||||
|
||||
export const parseStore = Effect.fn("AnacondaDesktop.parseStore")(function* (text: string) {
|
||||
const parsed = json(text)
|
||||
if (Option.isNone(parsed) || !record(parsed.value)) {
|
||||
return yield* new StoreError({ reason: "malformed" })
|
||||
}
|
||||
|
||||
return Object.entries(parsed.value).some(([key, value]) => key.endsWith(OAUTH_SUFFIX) && present(value))
|
||||
})
|
||||
|
||||
export const readConfig = Effect.fn("AnacondaDesktop.readConfig")(function* (dir: string) {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const text = yield* fs
|
||||
.readFileStringSafe(path.join(dir, CONFIG_FILE))
|
||||
.pipe(Effect.mapError(() => new ConfigError({ reason: "malformed" as const })))
|
||||
if (text === undefined) return yield* new ConfigError({ reason: "missing" })
|
||||
return yield* parseConfig(text)
|
||||
})
|
||||
|
||||
export const readStore = Effect.fn("AnacondaDesktop.readStore")(function* (dir: string) {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const text = yield* fs
|
||||
.readFileStringSafe(path.join(dir, STORE_FILE))
|
||||
.pipe(Effect.mapError(() => new StoreError({ reason: "malformed" as const })))
|
||||
if (text === undefined) return yield* new StoreError({ reason: "missing" })
|
||||
return yield* parseStore(text)
|
||||
})
|
||||
|
||||
function host(value: string) {
|
||||
const trimmed = value.trim().toLowerCase()
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed.slice(1, -1)
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function isLoopbackHost(value: string) {
|
||||
const name = host(value)
|
||||
if (name === "localhost" || name === "::1") return true
|
||||
if (name.startsWith("::ffff:")) return isLoopbackHost(name.slice("::ffff:".length))
|
||||
const octets = name.split(".")
|
||||
if (octets.length !== 4) return false
|
||||
if (octets.some((part) => !/^\d{1,3}$/.test(part) || Number(part) > 255)) return false
|
||||
return Number(octets[0]) === 127
|
||||
}
|
||||
|
||||
function clientHost(value: string) {
|
||||
const name = host(value)
|
||||
if (name === "0.0.0.0") return "127.0.0.1"
|
||||
if (name === "::") return "::1"
|
||||
return name
|
||||
}
|
||||
|
||||
function parsed(input: string) {
|
||||
const value = input.trim()
|
||||
if (!URL.canParse(value)) return
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== "http:") return
|
||||
if (url.username || url.password || url.search || url.hash) return
|
||||
if (url.pathname !== "/" && url.pathname !== "" && url.pathname !== "/v1" && url.pathname !== "/v1/") return
|
||||
const name = clientHost(url.hostname)
|
||||
if (!isLoopbackHost(name)) return
|
||||
const fallback = url.port || "80"
|
||||
const port = Number(fallback)
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) return
|
||||
const hostname = name.includes(":") ? `[${name}]` : name
|
||||
return `http://${hostname}:${port}/v1`
|
||||
}
|
||||
|
||||
export function normalizeLoopbackEndpoint(input: string) {
|
||||
return parsed(input)
|
||||
}
|
||||
|
||||
export function endpoint(hostname: string, port: number) {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) return
|
||||
const value = hostname.trim()
|
||||
if (!value) return
|
||||
if (value.startsWith("http://") || value.startsWith("https://")) {
|
||||
if (!URL.canParse(value)) return
|
||||
const url = new URL(value)
|
||||
if (!url.port) url.port = String(port)
|
||||
return normalizeLoopbackEndpoint(url.toString())
|
||||
}
|
||||
const name = host(value)
|
||||
const address = name.includes(":") ? `[${name}]` : name
|
||||
return normalizeLoopbackEndpoint(`http://${address}:${port}`)
|
||||
}
|
||||
|
||||
function unique(models: ReadonlyArray<ModelDescriptor>) {
|
||||
return new Set(models.map((model) => model.id)).size === models.length
|
||||
}
|
||||
|
||||
const decode = Schema.decodeUnknownOption(EncodedMetadata)
|
||||
const encode = Schema.encodeUnknownOption(EncodedMetadata)
|
||||
|
||||
export function decodeMetadata(input: Record<string, string> | undefined): Metadata | undefined {
|
||||
if (!input) return
|
||||
const result = decode(input)
|
||||
if (Option.isNone(result)) return
|
||||
const baseURL = normalizeLoopbackEndpoint(result.value.baseURL)
|
||||
if (!baseURL || !unique(result.value.models)) return
|
||||
return { ...result.value, baseURL }
|
||||
}
|
||||
|
||||
export function encodeMetadata(input: Metadata): Record<string, string> | undefined {
|
||||
const checked = Schema.decodeUnknownOption(Metadata)(input)
|
||||
if (Option.isNone(checked)) return
|
||||
const baseURL = normalizeLoopbackEndpoint(checked.value.baseURL)
|
||||
if (!baseURL || !unique(checked.value.models)) return
|
||||
const result = encode({ ...checked.value, baseURL })
|
||||
if (Option.isNone(result)) return
|
||||
return result.value
|
||||
}
|
||||
|
||||
export function warning(toolcall: ToolCapability) {
|
||||
if (toolcall === "supported") return
|
||||
if (toolcall === "unsupported") {
|
||||
return "This local model does not support tool calling, so normal coding-agent actions are limited."
|
||||
}
|
||||
return "Tool-call support could not be confirmed for this local model, so normal coding-agent actions may be limited."
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { arch, homedir } from "node:os"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { PlatformError } from "./domain"
|
||||
|
||||
export interface Info {
|
||||
readonly platform: NodeJS.Platform
|
||||
readonly arch: string
|
||||
readonly home: string
|
||||
readonly env: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
export interface Installation {
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly info: Info
|
||||
readonly dataDir: () => Effect.Effect<string, PlatformError>
|
||||
readonly installation: () => Effect.Effect<Installation | undefined, PlatformError>
|
||||
readonly open: () => Effect.Effect<void, PlatformError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@kilocode/AnacondaDesktopPlatform") {}
|
||||
|
||||
function value(info: Info, key: string) {
|
||||
const direct = info.env[key]
|
||||
if (direct) return direct
|
||||
const found = Object.entries(info.env).find(([name, item]) => name.toLowerCase() === key.toLowerCase() && item)
|
||||
return found?.[1]
|
||||
}
|
||||
|
||||
export function supported(info: Pick<Info, "platform" | "arch">) {
|
||||
if (info.platform === "darwin") return info.arch === "arm64"
|
||||
if (info.platform === "win32") return info.arch === "x64"
|
||||
if (info.platform === "linux") return info.arch === "x64" || info.arch === "arm64"
|
||||
return false
|
||||
}
|
||||
|
||||
export function directory(info: Info) {
|
||||
if (info.platform === "darwin") return path.join(info.home, "Library", "Application Support", "anaconda-desktop")
|
||||
if (info.platform === "win32") {
|
||||
const root =
|
||||
value(info, "APPDATA") ?? path.win32.join(value(info, "USERPROFILE") ?? info.home, "AppData", "Roaming")
|
||||
return path.win32.join(root, "anaconda-desktop")
|
||||
}
|
||||
if (info.platform === "linux") {
|
||||
const root = value(info, "XDG_DATA_HOME") ?? path.posix.join(info.home, ".local", "share")
|
||||
return path.posix.join(root, "anaconda-desktop")
|
||||
}
|
||||
}
|
||||
|
||||
export function candidates(info: Info) {
|
||||
if (info.platform === "darwin") {
|
||||
return ["/Applications/Anaconda Desktop.app", path.join(info.home, "Applications", "Anaconda Desktop.app")]
|
||||
}
|
||||
|
||||
if (info.platform === "win32") {
|
||||
const local = value(info, "LOCALAPPDATA") ?? path.win32.join(info.home, "AppData", "Local")
|
||||
const program = value(info, "ProgramFiles") ?? "C:\\Program Files"
|
||||
const x86 = value(info, "ProgramFiles(x86)")
|
||||
return [
|
||||
path.win32.join(local, "Programs", "Anaconda Desktop", "Anaconda Desktop.exe"),
|
||||
path.win32.join(local, "anaconda-desktop", "Anaconda Desktop.exe"),
|
||||
path.win32.join(program, "Anaconda Desktop", "Anaconda Desktop.exe"),
|
||||
...(x86 ? [path.win32.join(x86, "Anaconda Desktop", "Anaconda Desktop.exe")] : []),
|
||||
]
|
||||
}
|
||||
|
||||
if (info.platform === "linux") {
|
||||
const env = value(info, "PATH")?.split(path.posix.delimiter).filter(Boolean) ?? []
|
||||
return [
|
||||
...env.map((dir) => path.posix.join(dir, "anaconda-desktop")),
|
||||
"/usr/bin/anaconda-desktop",
|
||||
"/usr/local/bin/anaconda-desktop",
|
||||
path.posix.join(info.home, ".local", "bin", "anaconda-desktop"),
|
||||
]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function command(info: Info, install: Installation) {
|
||||
if (info.platform === "darwin") return ["/usr/bin/open", install.path]
|
||||
if (info.platform === "win32") return [install.path]
|
||||
if (info.platform === "linux") {
|
||||
const wayland = value(info, "XDG_SESSION_TYPE")?.toLowerCase() === "wayland" || !!value(info, "WAYLAND_DISPLAY")
|
||||
return [install.path, ...(wayland ? ["--ozone-platform=x11"] : [])]
|
||||
}
|
||||
}
|
||||
|
||||
const variables = [
|
||||
"APPDATA",
|
||||
"ComSpec",
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"DESKTOP_SESSION",
|
||||
"DISPLAY",
|
||||
"GDMSESSION",
|
||||
"HOME",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"LOCALAPPDATA",
|
||||
"PATH",
|
||||
"PATHEXT",
|
||||
"ProgramData",
|
||||
"ProgramFiles",
|
||||
"ProgramFiles(x86)",
|
||||
"SHELL",
|
||||
"SystemRoot",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"TMPDIR",
|
||||
"USERPROFILE",
|
||||
"WAYLAND_DISPLAY",
|
||||
"WINDIR",
|
||||
"XAUTHORITY",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_CURRENT_DESKTOP",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"XDG_STATE_HOME",
|
||||
"XDG_SESSION_TYPE",
|
||||
] as const
|
||||
|
||||
export function environment(info: Info): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = Object.fromEntries(Object.keys(info.env).map((key) => [key, undefined]))
|
||||
for (const key of variables) {
|
||||
const item = value(info, key)
|
||||
if (item) env[key] = item
|
||||
}
|
||||
if (info.platform === "win32") env.USERPROFILE ??= info.home
|
||||
else env.HOME ??= info.home
|
||||
return env
|
||||
}
|
||||
|
||||
function current(): Info {
|
||||
return {
|
||||
platform: process.platform,
|
||||
arch: arch(),
|
||||
home: homedir(),
|
||||
env: process.env,
|
||||
}
|
||||
}
|
||||
|
||||
export function makeLayer(info: Info) {
|
||||
return Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
const dataDir = Effect.fn("AnacondaDesktopPlatform.dataDir")(function* () {
|
||||
const dir = directory(info)
|
||||
if (dir) return dir
|
||||
return yield* new PlatformError({ operation: "data-dir", reason: "unsupported" })
|
||||
})
|
||||
|
||||
const installation = Effect.fn("AnacondaDesktopPlatform.installation")(function* () {
|
||||
if (!supported(info)) {
|
||||
return yield* new PlatformError({ operation: "installation", reason: "unsupported" })
|
||||
}
|
||||
for (const candidate of candidates(info)) {
|
||||
if (yield* fs.existsSafe(candidate)) return { path: candidate }
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const open = Effect.fn("AnacondaDesktopPlatform.open")(function* () {
|
||||
const install = yield* installation()
|
||||
if (!install) return yield* new PlatformError({ operation: "open", reason: "not-installed" })
|
||||
const cmd = command(info, install)
|
||||
if (!cmd) return yield* new PlatformError({ operation: "open", reason: "unsupported" })
|
||||
const child = yield* Effect.try({
|
||||
try: () => Process.spawn(cmd, { env: environment(info) }),
|
||||
catch: () => new PlatformError({ operation: "open", reason: "failed" }),
|
||||
})
|
||||
yield* Effect.callback<void, PlatformError>((resume) => {
|
||||
const done = () => {
|
||||
child.removeListener("error", fail)
|
||||
child.unref()
|
||||
resume(Effect.void)
|
||||
}
|
||||
const fail = () => {
|
||||
child.removeListener("spawn", done)
|
||||
resume(Effect.fail(new PlatformError({ operation: "open", reason: "failed" })))
|
||||
}
|
||||
child.once("spawn", done)
|
||||
child.once("error", fail)
|
||||
return Effect.sync(() => {
|
||||
child.removeListener("spawn", done)
|
||||
child.removeListener("error", fail)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ info, dataDir, installation, open })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const layer = makeLayer(current())
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Hooks, Plugin } from "@kilocode/plugin"
|
||||
import type { Model } from "@kilocode/sdk/v2"
|
||||
import type { Provider } from "@opencode-ai/core/models-dev"
|
||||
import { decodeMetadata, PROVIDER_ID, type Metadata, type Modality, type ModelDescriptor } from "./domain"
|
||||
|
||||
export const PLACEHOLDER_MODEL_ID = "setup-required"
|
||||
|
||||
export const CatalogProvider = {
|
||||
id: PROVIDER_ID,
|
||||
name: "Anaconda Desktop",
|
||||
description: "Run models served by Anaconda Desktop on this machine.",
|
||||
env: [],
|
||||
api: "",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: {
|
||||
[PLACEHOLDER_MODEL_ID]: {
|
||||
id: PLACEHOLDER_MODEL_ID,
|
||||
name: "Set up Anaconda Desktop",
|
||||
family: PROVIDER_ID,
|
||||
release_date: "",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: true,
|
||||
tool_call: false,
|
||||
cost: { input: 0, output: 0 },
|
||||
limit: { context: 0, output: 0 },
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
},
|
||||
},
|
||||
} satisfies Provider
|
||||
|
||||
export function overlay(providers: Record<string, Provider>): Record<string, Provider> {
|
||||
return { ...providers, [PROVIDER_ID]: CatalogProvider }
|
||||
}
|
||||
|
||||
function model(input: ModelDescriptor, metadata: Metadata): Model {
|
||||
const includes = (items: ReadonlyArray<Modality>, value: Modality) => items.includes(value)
|
||||
return {
|
||||
id: input.id,
|
||||
providerID: PROVIDER_ID,
|
||||
api: {
|
||||
id: input.id,
|
||||
url: metadata.baseURL,
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
name: input.name,
|
||||
...(input.family ? { family: input.family } : {}),
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: false,
|
||||
attachment: input.input.some((item) => item !== "text"),
|
||||
toolcall: metadata.toolcall === "supported",
|
||||
input: {
|
||||
text: includes(input.input, "text"),
|
||||
audio: includes(input.input, "audio"),
|
||||
image: includes(input.input, "image"),
|
||||
video: includes(input.input, "video"),
|
||||
pdf: includes(input.input, "pdf"),
|
||||
},
|
||||
output: {
|
||||
text: includes(input.output, "text"),
|
||||
audio: includes(input.output, "audio"),
|
||||
image: includes(input.output, "image"),
|
||||
video: includes(input.output, "video"),
|
||||
pdf: includes(input.output, "pdf"),
|
||||
},
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
limit: {
|
||||
context: metadata.context,
|
||||
output: 0,
|
||||
},
|
||||
status: "active",
|
||||
options: input.description ? { description: input.description } : {},
|
||||
headers: {},
|
||||
release_date: "",
|
||||
variants: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function hooks(): Hooks {
|
||||
return {
|
||||
auth: {
|
||||
provider: PROVIDER_ID,
|
||||
methods: [
|
||||
{
|
||||
type: "api",
|
||||
label: "Local Anaconda Desktop",
|
||||
},
|
||||
],
|
||||
async loader(auth) {
|
||||
const stored = await auth()
|
||||
if (stored.type !== "api") return {}
|
||||
const metadata = decodeMetadata("metadata" in stored ? stored.metadata : undefined)
|
||||
if (!metadata) return {}
|
||||
return { baseURL: metadata.baseURL }
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
id: PROVIDER_ID,
|
||||
async models(_provider, ctx) {
|
||||
if (ctx.auth?.type !== "api") return {}
|
||||
const metadata = decodeMetadata("metadata" in ctx.auth ? ctx.auth.metadata : undefined)
|
||||
if (!metadata) return {}
|
||||
return Object.fromEntries(metadata.models.map((item) => [item.id, model(item, metadata)]))
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const AnacondaDesktopPlugin: Plugin = async () => hooks()
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Auth } from "@/auth"
|
||||
import { invalidateAfterProviderAuthChange } from "@/kilocode/server/provider-auth-lifecycle"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { ModelCache } from "@/provider/model-cache"
|
||||
import { Context, Effect, Layer, Redacted } from "effect"
|
||||
import * as Discovery from "./discovery"
|
||||
import * as DesktopPlatform from "./platform"
|
||||
import {
|
||||
encodeMetadata,
|
||||
NotReadyError,
|
||||
PROVIDER_ID,
|
||||
SyncError,
|
||||
ToolAcknowledgementError,
|
||||
type PlatformError,
|
||||
type ReadyStatus,
|
||||
type Status,
|
||||
} from "./domain"
|
||||
|
||||
export interface Interface {
|
||||
readonly status: () => Effect.Effect<Status>
|
||||
readonly open: () => Effect.Effect<true, PlatformError>
|
||||
readonly sync: (
|
||||
acknowledge?: boolean,
|
||||
) => Effect.Effect<ReadyStatus, NotReadyError | SyncError | ToolAcknowledgementError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@kilocode/AnacondaDesktop") {}
|
||||
|
||||
function same(left: Record<string, string> | undefined, right: Record<string, string>) {
|
||||
if (!left) return false
|
||||
const keys = Object.keys(right)
|
||||
if (Object.keys(left).length !== keys.length) return false
|
||||
return keys.every((key) => left[key] === right[key])
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
const cache = yield* ModelCache.Service
|
||||
const discovery = yield* Discovery.Service
|
||||
const instances = yield* InstanceStore.Service
|
||||
const platform = yield* DesktopPlatform.Service
|
||||
|
||||
const status = Effect.fn("AnacondaDesktop.status")(function* () {
|
||||
return (yield* discovery.discover()).status
|
||||
})
|
||||
|
||||
const open = Effect.fn("AnacondaDesktop.open")(function* () {
|
||||
yield* platform.open()
|
||||
return true as const
|
||||
})
|
||||
|
||||
const sync = Effect.fn("AnacondaDesktop.sync")(function* (acknowledge = false) {
|
||||
const found = yield* discovery.discover()
|
||||
if (found.status.type !== "ready" || !found.connection) {
|
||||
return yield* new NotReadyError({ status: found.status })
|
||||
}
|
||||
if (found.status.toolcall !== "supported" && !acknowledge) {
|
||||
return yield* new ToolAcknowledgementError({ status: found.status })
|
||||
}
|
||||
|
||||
const metadata = encodeMetadata(found.connection.metadata)
|
||||
if (!metadata) return yield* new SyncError({ operation: "encode" })
|
||||
const key = Redacted.value(found.connection.key)
|
||||
const stored = yield* auth.get(PROVIDER_ID).pipe(Effect.mapError(() => new SyncError({ operation: "store" })))
|
||||
if (stored?.type === "api" && stored.key === key && same(stored.metadata, metadata)) return found.status
|
||||
yield* auth
|
||||
.set(
|
||||
PROVIDER_ID,
|
||||
new Auth.Api({
|
||||
type: "api",
|
||||
key,
|
||||
metadata,
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.mapError(() => new SyncError({ operation: "store" })))
|
||||
yield* invalidateAfterProviderAuthChange(PROVIDER_ID).pipe(
|
||||
Effect.provideService(ModelCache.Service, cache),
|
||||
Effect.provideService(InstanceStore.Service, instances),
|
||||
)
|
||||
return found.status
|
||||
})
|
||||
|
||||
return Service.of({ status, open, sync })
|
||||
}),
|
||||
)
|
||||
|
||||
const platform = DesktopPlatform.layer
|
||||
const discovery = Discovery.layer.pipe(Layer.provide(platform))
|
||||
|
||||
export const liveLayer = layer.pipe(Layer.provide(discovery), Layer.provide(platform))
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { AnacondaDesktopStatus } from "@kilocode/sdk/v2"
|
||||
import { DOWNLOAD_URL } from "../domain"
|
||||
|
||||
export type ReadyStatus = Extract<AnacondaDesktopStatus, { type: "ready" }>
|
||||
|
||||
export type SetupState = {
|
||||
status?: AnacondaDesktopStatus
|
||||
phase: "idle" | "checking" | "opening" | "syncing"
|
||||
error?: string
|
||||
}
|
||||
|
||||
type Api = {
|
||||
status(signal: AbortSignal): Promise<AnacondaDesktopStatus>
|
||||
open(signal: AbortSignal): Promise<void>
|
||||
sync(acknowledge: boolean, signal: AbortSignal): Promise<ReadyStatus>
|
||||
}
|
||||
|
||||
type Options = {
|
||||
api: Api
|
||||
synced(status: ReadyStatus, signal: AbortSignal): Promise<void> | void
|
||||
change?(state: SetupState): void
|
||||
}
|
||||
|
||||
function message(error: unknown) {
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
return "The Anaconda Desktop operation failed."
|
||||
}
|
||||
|
||||
export function complete(input: { pick(): void; signal?: AbortSignal }) {
|
||||
if (input.signal?.aborted) return false
|
||||
input.pick()
|
||||
return true
|
||||
}
|
||||
|
||||
export function createSetupController(options: Options) {
|
||||
let state: SetupState = { phase: "idle" }
|
||||
let active = false
|
||||
let busy = false
|
||||
let abort: AbortController | undefined
|
||||
|
||||
const update = (next: Partial<SetupState>) => {
|
||||
state = { ...state, ...next }
|
||||
options.change?.(state)
|
||||
}
|
||||
|
||||
const operate = async (phase: SetupState["phase"], task: (signal: AbortSignal) => Promise<void>) => {
|
||||
if (!active || busy) return false
|
||||
busy = true
|
||||
const ctrl = new AbortController()
|
||||
abort = ctrl
|
||||
update({ phase, error: undefined })
|
||||
|
||||
try {
|
||||
await task(ctrl.signal)
|
||||
if (!active || ctrl.signal.aborted) return false
|
||||
update({ phase: "idle", error: undefined })
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!active || ctrl.signal.aborted) return false
|
||||
update({ phase: "idle", error: message(error) })
|
||||
return false
|
||||
} finally {
|
||||
if (abort === ctrl) abort = undefined
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
const check = () =>
|
||||
operate("checking", async (signal) => {
|
||||
const status = await options.api.status(signal)
|
||||
if (!signal.aborted) update({ status })
|
||||
})
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (active) return
|
||||
active = true
|
||||
void check()
|
||||
},
|
||||
stop() {
|
||||
if (!active) return
|
||||
active = false
|
||||
abort?.abort()
|
||||
abort = undefined
|
||||
},
|
||||
refresh: check,
|
||||
open() {
|
||||
return operate("opening", (signal) => options.api.open(signal))
|
||||
},
|
||||
connect() {
|
||||
const status = state.status
|
||||
if (status?.type !== "ready") return Promise.resolve(false)
|
||||
return operate("syncing", async (signal) => {
|
||||
const synced = await options.api.sync(status.toolcall !== "supported", signal)
|
||||
if (active && !signal.aborted) await options.synced(synced, signal)
|
||||
})
|
||||
},
|
||||
snapshot() {
|
||||
return state
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type SetupAction = "download" | "open" | "connect" | "refresh"
|
||||
|
||||
export type SetupView = {
|
||||
title: string
|
||||
lines: string[]
|
||||
actions: Array<{ key: string; label: string; type: SetupAction }>
|
||||
downloadURL?: string
|
||||
warning?: boolean
|
||||
}
|
||||
|
||||
const refresh = { key: "r", label: "check again", type: "refresh" } as const
|
||||
const desktop = { key: "o", label: "open Anaconda Desktop", type: "open" } as const
|
||||
|
||||
function ready(status: Extract<AnacondaDesktopStatus, { type: "ready" }>): SetupView {
|
||||
const models = status.models.map((model) => model.name).join(", ")
|
||||
const server = status.serverName ? `${status.serverName} (${status.serverID})` : status.serverID
|
||||
const context = status.context > 0 ? `${status.context.toLocaleString()} tokens` : "not reported"
|
||||
const base = [`Server: ${server}`, `Models: ${models}`, `Context: ${context}`]
|
||||
|
||||
if (status.toolcall === "supported") {
|
||||
return {
|
||||
title: "Anaconda Desktop is ready",
|
||||
lines: [...base, "Tool calling: supported. Connect to import this server into Kilo."],
|
||||
actions: [{ key: "c", label: "connect / refresh now", type: "connect" }, desktop, refresh],
|
||||
}
|
||||
}
|
||||
|
||||
const capability = status.toolcall === "unsupported" ? "not supported" : "unknown"
|
||||
return {
|
||||
title: "Limited tool support",
|
||||
lines: [
|
||||
...base,
|
||||
`Tool calling: ${capability}.`,
|
||||
"Coding-agent actions may fail. Continue only if you accept these limitations.",
|
||||
],
|
||||
actions: [{ key: "c", label: "connect anyway", type: "connect" }, desktop, refresh],
|
||||
warning: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function setupView(status?: AnacondaDesktopStatus): SetupView {
|
||||
if (!status) {
|
||||
return {
|
||||
title: "Connect Anaconda Desktop",
|
||||
lines: ["Checking this machine for Anaconda Desktop..."],
|
||||
actions: [refresh],
|
||||
}
|
||||
}
|
||||
|
||||
switch (status.type) {
|
||||
case "unsupported-platform":
|
||||
return {
|
||||
title: "Platform not supported",
|
||||
lines: [
|
||||
`Anaconda Desktop cannot be connected on ${status.platform}.`,
|
||||
"Local setup is supported on macOS, Windows, and Linux.",
|
||||
],
|
||||
actions: [refresh],
|
||||
}
|
||||
case "not-installed":
|
||||
return {
|
||||
title: "Install Anaconda Desktop",
|
||||
lines: ["Anaconda Desktop was not found on this machine.", "Download and install it, then check again."],
|
||||
actions: [{ key: "d", label: "open official download page", type: "download" }, refresh],
|
||||
downloadURL: DOWNLOAD_URL,
|
||||
}
|
||||
case "not-running":
|
||||
return {
|
||||
title: "Start Anaconda Desktop",
|
||||
lines: [
|
||||
"Anaconda Desktop is installed but is not running.",
|
||||
"Open it here, then choose check again.",
|
||||
],
|
||||
actions: [desktop, refresh],
|
||||
}
|
||||
case "invalid-config": {
|
||||
const reason = {
|
||||
missing: "Desktop has not created its local configuration yet.",
|
||||
malformed: "Desktop's local configuration is malformed.",
|
||||
"missing-key": "Desktop's management credential is missing.",
|
||||
"invalid-port": "Desktop's management port is invalid.",
|
||||
}[status.reason]
|
||||
return {
|
||||
title: "Finish Desktop setup",
|
||||
lines: [reason, "Open Anaconda Desktop and finish setup or restart the app."],
|
||||
actions: [desktop, refresh],
|
||||
}
|
||||
}
|
||||
case "signed-out":
|
||||
return {
|
||||
title: "Sign in to Anaconda Desktop",
|
||||
lines: ["No saved Anaconda Desktop sign-in was found.", "Open Desktop and sign in, then choose check again."],
|
||||
actions: [desktop, refresh],
|
||||
}
|
||||
case "management-unauthorized":
|
||||
return {
|
||||
title: "Reconnect Anaconda Desktop",
|
||||
lines: [
|
||||
"Desktop rejected its local management credential.",
|
||||
"Open Desktop, sign in again if needed, and restart it.",
|
||||
],
|
||||
actions: [desktop, refresh],
|
||||
}
|
||||
case "management-unavailable":
|
||||
return {
|
||||
title: "Anaconda Desktop is unavailable",
|
||||
lines: [
|
||||
status.reason === "timeout"
|
||||
? "Desktop did not respond before the local request timed out."
|
||||
: "Desktop returned an unexpected local response.",
|
||||
"Open or restart Desktop, then choose check again.",
|
||||
],
|
||||
actions: [desktop, refresh],
|
||||
}
|
||||
case "no-downloaded-model":
|
||||
return {
|
||||
title: "Download a text-generation model",
|
||||
lines: [
|
||||
"No downloaded model is available in Anaconda Desktop.",
|
||||
"Open Desktop and download a text-generation model.",
|
||||
],
|
||||
actions: [desktop, refresh],
|
||||
}
|
||||
case "no-running-server":
|
||||
return {
|
||||
title: "Start a model server",
|
||||
lines: [
|
||||
`${status.downloadedModels} downloaded model${status.downloadedModels === 1 ? " is" : "s are"} available.`,
|
||||
"In Desktop, start a model server. Models with tool calling support are strongly recommended.",
|
||||
],
|
||||
actions: [desktop, refresh],
|
||||
}
|
||||
case "inference-unhealthy":
|
||||
return {
|
||||
title: "Model server is not healthy",
|
||||
lines: [
|
||||
`Desktop reports server ${status.serverID}, but it is not ready for chat completions.`,
|
||||
"Open Desktop and restart or inspect the server.",
|
||||
],
|
||||
actions: [desktop, refresh],
|
||||
}
|
||||
case "ready":
|
||||
return ready(status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import open from "open"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useBindings } from "@tui/keymap"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { Link } from "@tui/ui/link"
|
||||
import { useToast } from "@tui/ui/toast"
|
||||
import { PROVIDER_ID } from "../domain"
|
||||
import { complete, createSetupController, setupView, type SetupAction, type SetupState } from "./model"
|
||||
|
||||
type ModelComponent = (props: { providerID?: string }) => JSX.Element
|
||||
|
||||
export function selectProvider(input: {
|
||||
providerID: string
|
||||
replace(component: () => JSX.Element): void
|
||||
model: ModelComponent
|
||||
}) {
|
||||
if (input.providerID !== PROVIDER_ID) return false
|
||||
input.replace(() => <AnacondaDesktopSetup model={input.model} />)
|
||||
return true
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
if (typeof error !== "object" || error === null || !("message" in error)) return fallback
|
||||
return typeof error.message === "string" && error.message ? error.message : fallback
|
||||
}
|
||||
|
||||
export function AnacondaDesktopSetup(props: { model: ModelComponent }) {
|
||||
const sdk = useSDK()
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
const Model = props.model
|
||||
const [state, setState] = createSignal<SetupState>({ phase: "idle" })
|
||||
const view = createMemo(() => setupView(state().status))
|
||||
|
||||
const controller = createSetupController({
|
||||
api: {
|
||||
async status(signal) {
|
||||
const result = await sdk.client.anacondaDesktop.status(undefined, { signal })
|
||||
if (result.data) return result.data
|
||||
throw new Error(errorMessage(result.error, "Anaconda Desktop status could not be checked."))
|
||||
},
|
||||
async open(signal) {
|
||||
const result = await sdk.client.anacondaDesktop.open(undefined, { signal })
|
||||
if (result.data) return
|
||||
throw new Error(errorMessage(result.error, "Anaconda Desktop could not be opened."))
|
||||
},
|
||||
async sync(acknowledgeToolLimitations, signal) {
|
||||
const result = await sdk.client.anacondaDesktop.sync({ acknowledgeToolLimitations }, { signal })
|
||||
if (result.data) return result.data
|
||||
throw new Error(errorMessage(result.error, "The Anaconda Desktop connection could not be synchronized."))
|
||||
},
|
||||
},
|
||||
change: setState,
|
||||
synced(_, signal) {
|
||||
complete({
|
||||
pick: () => dialog.replace(() => <Model providerID={PROVIDER_ID} />),
|
||||
signal,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const run = (action: SetupAction) => {
|
||||
if (action === "refresh") return void controller.refresh()
|
||||
if (action === "open") return void controller.open()
|
||||
if (action === "connect") return void controller.connect()
|
||||
const url = view().downloadURL
|
||||
if (!url) return
|
||||
void open(url).catch(toast.error)
|
||||
}
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: view().actions.map((action) => ({
|
||||
key: action.key,
|
||||
desc: action.label,
|
||||
group: "Dialog",
|
||||
cmd: () => run(action.type),
|
||||
})),
|
||||
}))
|
||||
|
||||
onMount(controller.start)
|
||||
onCleanup(controller.stop)
|
||||
|
||||
const activity = createMemo(() => {
|
||||
if (state().phase === "checking") return "Checking Desktop status..."
|
||||
if (state().phase === "opening") return "Opening Anaconda Desktop..."
|
||||
if (state().phase === "syncing") return "Synchronizing provider and models..."
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={view().warning ? theme.warning : theme.text}>
|
||||
{view().title}
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box gap={0}>
|
||||
<For each={view().lines}>{(line) => <text fg={theme.textMuted}>{line}</text>}</For>
|
||||
</box>
|
||||
|
||||
<Show when={view().downloadURL}>{(url) => <Link href={url()} fg={theme.primary} wrapMode="word" />}</Show>
|
||||
|
||||
<Show when={state().error}>{(error) => <text fg={theme.error}>{error()}</text>}</Show>
|
||||
<Show when={activity()}>{(message) => <text fg={theme.textMuted}>{message()}</text>}</Show>
|
||||
|
||||
<box gap={0}>
|
||||
<For each={view().actions}>
|
||||
{(action) => (
|
||||
<text fg={theme.text} onMouseUp={() => run(action.type)}>
|
||||
{action.key} <span style={{ fg: theme.textMuted }}>{action.label}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// kilocode_change - new file
|
||||
/**
|
||||
* Kilo-specific overrides for the provider dialog.
|
||||
*
|
||||
@@ -10,6 +9,7 @@ import type { JSX } from "solid-js"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import type { ProviderAuthAuthorization } from "@kilocode/sdk/v2"
|
||||
import { KiloAutoMethod } from "@/kilocode/components/dialog-kilo-auto-method"
|
||||
export { selectProvider } from "@/kilocode/anaconda-desktop/tui/setup"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Failed-state gutter/description helpers
|
||||
@@ -51,6 +51,7 @@ export const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
"github-copilot": 1,
|
||||
openai: 2,
|
||||
google: 3,
|
||||
"anaconda-desktop": 4,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -61,6 +62,7 @@ export const PROVIDER_DESCRIPTIONS: Record<string, string> = {
|
||||
kilo: "(Recommended)",
|
||||
anthropic: "(Claude Max or API key)",
|
||||
openai: "(ChatGPT login or API key)",
|
||||
"anaconda-desktop": "(Local models)",
|
||||
}
|
||||
|
||||
export const PROVIDER_TITLES: Record<string, string> = {
|
||||
|
||||
@@ -16,9 +16,10 @@ const notes: Record<string, string> = {
|
||||
google: "settings.providers.note.google",
|
||||
openrouter: "settings.providers.note.openrouter",
|
||||
vercel: "settings.providers.note.vercel",
|
||||
"anaconda-desktop": "settings.providers.note.anacondaDesktop",
|
||||
}
|
||||
|
||||
const order = ["kilo", "anthropic", "deepseek", "openai", "google", "openrouter", "vercel"] as const
|
||||
const order = ["kilo", "anthropic", "deepseek", "openai", "google", "anaconda-desktop", "openrouter", "vercel"] as const
|
||||
|
||||
const priority = new Map<string, number>(order.map((id, index) => [id, index]))
|
||||
|
||||
@@ -34,7 +35,7 @@ export function providerMetadata(id: string): ProviderMetadata {
|
||||
const note = notes[name]
|
||||
return {
|
||||
noteKey: note,
|
||||
icon: icons.has(name as IconName) ? name : "synthetic",
|
||||
icon: name === "anaconda-desktop" ? "inference" : icons.has(name as IconName) ? name : "synthetic",
|
||||
priority: priority.get(name),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { ReadyStatus, Status } from "@/kilocode/anaconda-desktop/domain"
|
||||
import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
} from "@/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { described } from "@/server/routes/instance/httpapi/groups/metadata"
|
||||
|
||||
const root = "/kilocode/anaconda-desktop"
|
||||
|
||||
export const AnacondaDesktopPaths = {
|
||||
status: `${root}/status`,
|
||||
open: `${root}/open`,
|
||||
sync: `${root}/sync`,
|
||||
} as const
|
||||
|
||||
export const AnacondaDesktopSyncPayload = Schema.Struct({
|
||||
acknowledgeToolLimitations: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
export class AnacondaDesktopConflictError extends Schema.ErrorClass<AnacondaDesktopConflictError>(
|
||||
"AnacondaDesktopConflictError",
|
||||
)(
|
||||
{
|
||||
code: Schema.Literals(["unsupported-platform", "not-installed", "not-ready", "acknowledgement-required"]),
|
||||
message: Schema.String,
|
||||
status: Schema.optional(Status),
|
||||
},
|
||||
{ httpApiStatus: 409 },
|
||||
) {}
|
||||
|
||||
export class AnacondaDesktopOperationError extends Schema.ErrorClass<AnacondaDesktopOperationError>(
|
||||
"AnacondaDesktopOperationError",
|
||||
)(
|
||||
{
|
||||
operation: Schema.Literals(["open", "sync"]),
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 500 },
|
||||
) {}
|
||||
|
||||
export const AnacondaDesktopApi = HttpApi.make("anaconda-desktop")
|
||||
.add(
|
||||
HttpApiGroup.make("anaconda-desktop")
|
||||
.add(
|
||||
HttpApiEndpoint.get("status", AnacondaDesktopPaths.status, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Status, "Anaconda Desktop setup status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "anacondaDesktop.status",
|
||||
summary: "Get Anaconda Desktop setup status",
|
||||
description: "Discover the locally installed Anaconda Desktop and its active inference server.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("open", AnacondaDesktopPaths.open, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Literal(true), "Anaconda Desktop opened"),
|
||||
error: [AnacondaDesktopConflictError, AnacondaDesktopOperationError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "anacondaDesktop.open",
|
||||
summary: "Open Anaconda Desktop",
|
||||
description: "Open the locally installed Anaconda Desktop application.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("sync", AnacondaDesktopPaths.sync, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: AnacondaDesktopSyncPayload,
|
||||
success: described(ReadyStatus, "Anaconda Desktop connection synchronized"),
|
||||
error: [AnacondaDesktopConflictError, AnacondaDesktopOperationError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "anacondaDesktop.sync",
|
||||
summary: "Synchronize Anaconda Desktop provider",
|
||||
description:
|
||||
"Discover the active local inference server and replace Kilo provider authentication metadata.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "anaconda-desktop",
|
||||
description: "Local Anaconda Desktop provider setup routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "kilo HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Kilo HttpApi surface.",
|
||||
}),
|
||||
)
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
WorkspaceRoutingQuery,
|
||||
} from "@/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { described } from "@/server/routes/instance/httpapi/groups/metadata"
|
||||
import { AnacondaDesktopApi } from "./anaconda-desktop"
|
||||
import {
|
||||
Failure as NotebookFailure,
|
||||
Request as NotebookRequest,
|
||||
@@ -123,6 +124,7 @@ export const KilocodeApi = HttpApi.make("kilocode")
|
||||
.middleware(WorkspaceRoutingMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.addHttpApi(AnacondaDesktopApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "kilo HttpApi",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import * as AnacondaDesktop from "@/kilocode/anaconda-desktop/service"
|
||||
import { NotReadyError, PlatformError, SyncError, ToolAcknowledgementError } from "@/kilocode/anaconda-desktop/domain"
|
||||
import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
|
||||
import { AnacondaDesktopConflictError, AnacondaDesktopOperationError } from "../groups/anaconda-desktop"
|
||||
|
||||
function openError(error: PlatformError) {
|
||||
if (error.reason === "unsupported") {
|
||||
return new AnacondaDesktopConflictError({
|
||||
code: "unsupported-platform",
|
||||
message: "Anaconda Desktop is not supported on this platform.",
|
||||
})
|
||||
}
|
||||
if (error.reason === "not-installed") {
|
||||
return new AnacondaDesktopConflictError({
|
||||
code: "not-installed",
|
||||
message: "Anaconda Desktop is not installed.",
|
||||
})
|
||||
}
|
||||
return new AnacondaDesktopOperationError({
|
||||
operation: "open",
|
||||
message: "Anaconda Desktop could not be opened.",
|
||||
})
|
||||
}
|
||||
|
||||
function syncError(error: NotReadyError | SyncError | ToolAcknowledgementError) {
|
||||
if (error instanceof NotReadyError) {
|
||||
return new AnacondaDesktopConflictError({
|
||||
code: "not-ready",
|
||||
message: "Anaconda Desktop does not have a healthy text-generation server ready.",
|
||||
status: error.status,
|
||||
})
|
||||
}
|
||||
if (error instanceof ToolAcknowledgementError) {
|
||||
return new AnacondaDesktopConflictError({
|
||||
code: "acknowledgement-required",
|
||||
message: "Acknowledge limited tool support before connecting this model server.",
|
||||
status: error.status,
|
||||
})
|
||||
}
|
||||
return new AnacondaDesktopOperationError({
|
||||
operation: "sync",
|
||||
message: "The Anaconda Desktop connection could not be stored.",
|
||||
})
|
||||
}
|
||||
|
||||
export const anacondaDesktopHandlers = HttpApiBuilder.group(InstanceHttpApi, "anaconda-desktop", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const desktop = yield* AnacondaDesktop.Service
|
||||
|
||||
return handlers
|
||||
.handle("status", () => desktop.status())
|
||||
.handle("open", () => desktop.open().pipe(Effect.mapError(openError)))
|
||||
.handle("sync", (ctx) =>
|
||||
desktop.sync(ctx.payload.acknowledgeToolLimitations === true).pipe(Effect.mapError(syncError)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -5,8 +5,10 @@ import { compressionLayer } from "@/server/routes/instance/httpapi/middleware/co
|
||||
import { corsVaryFix } from "@/server/routes/instance/httpapi/middleware/cors-vary"
|
||||
import { errorLayer } from "@/server/routes/instance/httpapi/middleware/error"
|
||||
import { fenceLayer } from "@/server/routes/instance/httpapi/middleware/fence"
|
||||
import * as AnacondaDesktop from "@/kilocode/anaconda-desktop/service"
|
||||
|
||||
import { agentBuilderHandlers } from "./handlers/agent-builder"
|
||||
import { anacondaDesktopHandlers } from "./handlers/anaconda-desktop"
|
||||
import { backgroundProcessHandlers } from "./handlers/background-process"
|
||||
import { commitMessageHandlers } from "./handlers/commit-message"
|
||||
import { configConsoleHandlers } from "./handlers/config-console"
|
||||
@@ -23,6 +25,7 @@ import { telemetryHandlers } from "./handlers/telemetry"
|
||||
|
||||
export const provide = Layer.provide([
|
||||
agentBuilderHandlers,
|
||||
anacondaDesktopHandlers.pipe(Layer.provide(AnacondaDesktop.liveLayer)),
|
||||
backgroundProcessHandlers,
|
||||
commitMessageHandlers,
|
||||
configConsoleHandlers,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { PluginLoader } from "./loader"
|
||||
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
|
||||
import { KiloAuthPlugin } from "@kilocode/kilo-gateway" // kilocode_change
|
||||
import { AtomicChatPlugin } from "@kilocode/plugin-atomic-chat" // kilocode_change
|
||||
import { AnacondaDesktopPlugin } from "@/kilocode/anaconda-desktop/provider" // kilocode_change
|
||||
import { registerAdapter } from "@/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "@/control-plane/types"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
@@ -69,6 +70,7 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
|
||||
return [
|
||||
KiloAuthPlugin, // kilocode_change
|
||||
AtomicChatPlugin, // kilocode_change
|
||||
AnacondaDesktopPlugin, // kilocode_change
|
||||
// Temporary rollout: pre-release builds use WebSockets by default; releases require explicit opt-in.
|
||||
(input) =>
|
||||
CodexAuthPlugin(input, {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ModelCache } from "./model-cache"
|
||||
import * as Core from "@opencode-ai/core/models-dev"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { AI_SDK_PROVIDERS, KILO_OPENROUTER_BASE, PROMPTS } from "@kilocode/kilo-gateway"
|
||||
import { overlay } from "@/kilocode/anaconda-desktop/provider"
|
||||
|
||||
export const Model = Core.Model
|
||||
export type Model = Core.Model
|
||||
@@ -40,7 +41,7 @@ export const layer: Layer.Layer<Service, never, Core.Service | Config.Service |
|
||||
const cache = yield* ModelCache.Service
|
||||
|
||||
const get = Effect.fn("ModelsDev.get")(function* () {
|
||||
const providers = { ...(yield* core.get()) }
|
||||
const providers = overlay(yield* core.get())
|
||||
delete providers.kilo
|
||||
|
||||
const cfg = yield* config.get()
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ModelCache } from "@/provider/model-cache" // kilocode_change
|
||||
import { disposeAllInstancesAfterProviderAuthCallback } from "@/kilocode/server/provider-auth-lifecycle" // kilocode_change
|
||||
import { providerMetadata } from "@/kilocode/provider/metadata" // kilocode_change
|
||||
import { filterPromptTrainingModels } from "@/kilocode/provider/model-filter" // kilocode_change
|
||||
import { overlay as overlayAnacondaDesktop } from "@/kilocode/anaconda-desktop/provider" // kilocode_change
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
@@ -43,7 +44,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
|
||||
|
||||
const list = Effect.fn("ProviderHttpApi.list")(function* () {
|
||||
const config = yield* cfg.get()
|
||||
const all = yield* ModelsDev.Service.use((s) => s.get())
|
||||
const all = overlayAnacondaDesktop(yield* ModelsDev.Service.use((s) => s.get())) // kilocode_change
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
const filtered: Record<string, (typeof all)[string]> = {}
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import { expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { Effect, Layer, Redacted } from "effect"
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import * as Discovery from "../../../src/kilocode/anaconda-desktop/discovery"
|
||||
import * as DesktopPlatform from "../../../src/kilocode/anaconda-desktop/platform"
|
||||
import { CONFIG_FILE, STORE_FILE } from "../../../src/kilocode/anaconda-desktop/domain"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
const managementKey = "fixture-management-key"
|
||||
const inferenceKey = "fixture-inference-key"
|
||||
|
||||
interface Settings {
|
||||
readonly installed?: boolean
|
||||
readonly config?: unknown
|
||||
readonly signed?: boolean
|
||||
readonly rootStatus?: number
|
||||
readonly root?: unknown
|
||||
readonly closed?: boolean
|
||||
readonly models?: ReadonlyArray<unknown>
|
||||
readonly servers?: (port: number) => ReadonlyArray<unknown>
|
||||
readonly health?: unknown
|
||||
readonly inferenceModels?: ReadonlyArray<unknown>
|
||||
readonly props?: unknown
|
||||
readonly delay?: number
|
||||
}
|
||||
|
||||
function defaults(port: number) {
|
||||
return [
|
||||
{
|
||||
serverProcessId: 4242,
|
||||
status: "RUNNING",
|
||||
tag: "inference",
|
||||
modelFile: { id: "file-1", name: "model-q4.gguf" },
|
||||
server: { host: "0.0.0.0", port, api_key: inferenceKey },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function fixture(settings: Settings = {}) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "anaconda-desktop-test-"))),
|
||||
(dir) => Effect.promise(() => rm(dir, { recursive: true, force: true })),
|
||||
).pipe(
|
||||
Effect.flatMap((home) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const hits: Array<{ method: string; path: string; authorized: boolean }> = []
|
||||
const inference = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
hits.push({
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
authorized: request.headers.get("authorization") === `Bearer ${inferenceKey}`,
|
||||
})
|
||||
if (url.pathname === "/health") return Response.json(settings.health ?? { status: "ok" })
|
||||
if (url.pathname === "/v1/models") {
|
||||
return Response.json({
|
||||
data: settings.inferenceModels ?? [{ id: "model-q4.gguf", owned_by: "llamacpp" }],
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/props") {
|
||||
return Response.json(
|
||||
settings.props ?? {
|
||||
default_generation_settings: { n_ctx: 16_384 },
|
||||
chat_template_caps: { supports_tools: true },
|
||||
modalities: { vision: true, audio: false },
|
||||
},
|
||||
)
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
const port = inference.port
|
||||
if (port === undefined) throw new Error("inference fixture did not bind a port")
|
||||
const management = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
hits.push({
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
authorized: request.headers.get("authorization") === `Bearer ${managementKey}`,
|
||||
})
|
||||
if (settings.delay) await Bun.sleep(settings.delay)
|
||||
if (url.pathname === "/api") {
|
||||
return Response.json(settings.root ?? { data: { version: "fixture" } }, {
|
||||
status: settings.rootStatus ?? 200,
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/models") {
|
||||
return Response.json({
|
||||
data: settings.models ?? [
|
||||
{
|
||||
id: "model-1",
|
||||
name: "Fixture Model",
|
||||
metadata: {
|
||||
trainedFor: "text-generation",
|
||||
contextWindowSize: 8192,
|
||||
model_type: "fixture-family",
|
||||
quantizations: [{ modelFileName: "model-q4.gguf" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/servers") {
|
||||
return Response.json({ data: settings.servers?.(port) ?? defaults(port) })
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
const managementPort = management.port
|
||||
if (managementPort === undefined) {
|
||||
management.stop(true)
|
||||
inference.stop(true)
|
||||
throw new Error("management fixture did not bind a port")
|
||||
}
|
||||
return { home, hits, inference, inferencePort: port, management, managementPort }
|
||||
}),
|
||||
(value) =>
|
||||
Effect.sync(() => {
|
||||
value.management.stop(true)
|
||||
value.inference.stop(true)
|
||||
}),
|
||||
).pipe(
|
||||
Effect.tap((value) =>
|
||||
Effect.promise(async () => {
|
||||
const dir = path.join(value.home, ".local", "share", "anaconda-desktop")
|
||||
const bin = path.join(value.home, "bin")
|
||||
await mkdir(dir, { recursive: true })
|
||||
await mkdir(bin, { recursive: true })
|
||||
if (settings.installed !== false) await writeFile(path.join(bin, "anaconda-desktop"), "fixture")
|
||||
await writeFile(
|
||||
path.join(dir, CONFIG_FILE),
|
||||
JSON.stringify(
|
||||
settings.config ?? {
|
||||
aiNavApiKey: managementKey,
|
||||
aiNavApiServerPort: value.managementPort,
|
||||
},
|
||||
),
|
||||
)
|
||||
if (settings.signed !== false) {
|
||||
await writeFile(
|
||||
path.join(dir, STORE_FILE),
|
||||
JSON.stringify({ "fixture_ai-navigator-workos-oauth": "opaque-fixture" }),
|
||||
)
|
||||
}
|
||||
if (settings.closed) value.management.stop(true)
|
||||
}),
|
||||
),
|
||||
Effect.map((value) => {
|
||||
const info: DesktopPlatform.Info = {
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
home: value.home,
|
||||
env: { PATH: path.join(value.home, "bin") },
|
||||
}
|
||||
const platform = DesktopPlatform.makeLayer(info).pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
const layer = Discovery.makeLayer({ timeout: "100 millis" }).pipe(
|
||||
Layer.provide(platform),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
return { ...value, layer }
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("discovers a healthy text-generation server without exposing either key", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture()
|
||||
const found = yield* Discovery.Service.use((service) => service.discover()).pipe(Effect.provide(test.layer))
|
||||
|
||||
expect(found.status).toEqual({
|
||||
type: "ready",
|
||||
serverID: "process-4242",
|
||||
serverName: "Fixture Model",
|
||||
models: [{ id: "model-q4.gguf", name: "Fixture Model" }],
|
||||
context: 16_384,
|
||||
toolcall: "supported",
|
||||
})
|
||||
expect(found.connection?.metadata).toMatchObject({
|
||||
baseURL: `http://127.0.0.1:${test.inferencePort}/v1`,
|
||||
context: 16_384,
|
||||
toolcall: "supported",
|
||||
models: [{ family: "fixture-family", input: ["text", "image"], output: ["text"] }],
|
||||
})
|
||||
expect(found.connection && Redacted.isRedacted(found.connection.key)).toBe(true)
|
||||
const serialized = JSON.stringify(found)
|
||||
expect(serialized).not.toContain(managementKey)
|
||||
expect(serialized).not.toContain(inferenceKey)
|
||||
expect(test.hits.every((hit) => hit.method === "GET")).toBe(true)
|
||||
expect(test.hits.filter((hit) => hit.path.startsWith("/api")).every((hit) => hit.authorized)).toBe(true)
|
||||
expect(test.hits.filter((hit) => !hit.path.startsWith("/api")).every((hit) => hit.authorized)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("classifies setup states before inference discovery", () =>
|
||||
Effect.gen(function* () {
|
||||
const absent = yield* fixture({ installed: false })
|
||||
const absentStatus = yield* Discovery.Service.use((service) => service.discover()).pipe(
|
||||
Effect.provide(absent.layer),
|
||||
)
|
||||
expect(absentStatus.status.type).toBe("not-installed")
|
||||
|
||||
const invalid = yield* fixture({ config: { aiNavApiKey: "", aiNavApiServerPort: 8001 } })
|
||||
const invalidStatus = yield* Discovery.Service.use((service) => service.discover()).pipe(
|
||||
Effect.provide(invalid.layer),
|
||||
)
|
||||
expect(invalidStatus.status).toEqual({ type: "invalid-config", reason: "missing-key" })
|
||||
|
||||
const signed = yield* fixture({ signed: false })
|
||||
const signedStatus = yield* Discovery.Service.use((service) => service.discover()).pipe(
|
||||
Effect.provide(signed.layer),
|
||||
)
|
||||
expect(signedStatus.status).toEqual({ type: "signed-out" })
|
||||
|
||||
const unauthorized = yield* fixture({ rootStatus: 401 })
|
||||
const unauthorizedStatus = yield* Discovery.Service.use((service) => service.discover()).pipe(
|
||||
Effect.provide(unauthorized.layer),
|
||||
)
|
||||
expect(unauthorizedStatus.status).toEqual({ type: "management-unauthorized" })
|
||||
|
||||
const stopped = yield* fixture({ closed: true })
|
||||
const stoppedStatus = yield* Discovery.Service.use((service) => service.discover()).pipe(
|
||||
Effect.provide(stopped.layer),
|
||||
)
|
||||
expect(stoppedStatus.status).toEqual({ type: "not-running" })
|
||||
|
||||
const malformed = yield* fixture({ root: { unexpected: true } })
|
||||
const malformedStatus = yield* Discovery.Service.use((service) => service.discover()).pipe(
|
||||
Effect.provide(malformed.layer),
|
||||
)
|
||||
expect(malformedStatus.status).toEqual({
|
||||
type: "management-unavailable",
|
||||
reason: "unexpected-response",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("classifies downloaded and running-server inventory", () =>
|
||||
Effect.gen(function* () {
|
||||
const empty = yield* fixture({ models: [] })
|
||||
const emptyStatus = yield* Discovery.Service.use((service) => service.discover()).pipe(Effect.provide(empty.layer))
|
||||
expect(emptyStatus.status).toEqual({ type: "no-downloaded-model" })
|
||||
|
||||
const stopped = yield* fixture({
|
||||
servers: () => [],
|
||||
models: [
|
||||
{
|
||||
id: "active",
|
||||
name: "Active",
|
||||
metadata: { trainedFor: "text-generation", files: [{ name: "active.gguf" }] },
|
||||
},
|
||||
{
|
||||
id: "deleted",
|
||||
name: "Deleted",
|
||||
metadata: { trainedFor: "text-generation", files: [] },
|
||||
},
|
||||
{
|
||||
id: "embedding",
|
||||
name: "Embedding",
|
||||
metadata: { trainedFor: "sentence-similarity", files: [{ name: "embedding.gguf" }] },
|
||||
},
|
||||
],
|
||||
})
|
||||
const stoppedStatus = yield* Discovery.Service.use((service) => service.discover()).pipe(
|
||||
Effect.provide(stopped.layer),
|
||||
)
|
||||
expect(stoppedStatus.status).toEqual({ type: "no-running-server", downloadedModels: 1 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("marks unusable inference servers unhealthy", () =>
|
||||
Effect.gen(function* () {
|
||||
const remote = yield* fixture({
|
||||
servers: (port) => [{ ...defaults(port)[0], server: { host: "192.168.1.10", port, apiKey: inferenceKey } }],
|
||||
})
|
||||
const remoteStatus = yield* Discovery.Service.use((service) => service.discover()).pipe(
|
||||
Effect.provide(remote.layer),
|
||||
)
|
||||
expect(remoteStatus.status).toMatchObject({ type: "inference-unhealthy" })
|
||||
|
||||
const empty = yield* fixture({ inferenceModels: [] })
|
||||
const emptyStatus = yield* Discovery.Service.use((service) => service.discover()).pipe(Effect.provide(empty.layer))
|
||||
expect(emptyStatus.status).toMatchObject({ type: "inference-unhealthy" })
|
||||
|
||||
const embedding = yield* fixture({
|
||||
models: [
|
||||
{
|
||||
id: "embed",
|
||||
name: "Embed",
|
||||
metadata: {
|
||||
trainedFor: "sentence-similarity",
|
||||
quantizations: [{ modelFileName: "model-q4.gguf" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const embeddingStatus = yield* Discovery.Service.use((service) => service.discover()).pipe(
|
||||
Effect.provide(embedding.layer),
|
||||
)
|
||||
expect(embeddingStatus.status).toEqual({ type: "no-downloaded-model" })
|
||||
|
||||
const unhealthy = yield* fixture({ health: { status: "loading" } })
|
||||
const unhealthyStatus = yield* Discovery.Service.use((service) => service.discover()).pipe(
|
||||
Effect.provide(unhealthy.layer),
|
||||
)
|
||||
expect(unhealthyStatus.status).toMatchObject({ type: "inference-unhealthy" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("distinguishes false and unknown tool support and accepts an empty inference key", () =>
|
||||
Effect.gen(function* () {
|
||||
const unsupported = yield* fixture({ props: { chat_template_caps: { supports_tools: false } } })
|
||||
const unsupportedStatus = yield* Discovery.Service.use((service) => service.discover()).pipe(
|
||||
Effect.provide(unsupported.layer),
|
||||
)
|
||||
expect(unsupportedStatus.status).toMatchObject({ type: "ready", toolcall: "unsupported" })
|
||||
expect(unsupportedStatus.connection?.metadata.models[0].description).toContain("does not support tool calling")
|
||||
|
||||
const unknown = yield* fixture({
|
||||
props: {},
|
||||
servers: (port) => [
|
||||
{
|
||||
id: "server-1",
|
||||
status: "running",
|
||||
tag: "inference",
|
||||
modelFile: { name: "model-q4.gguf" },
|
||||
serverConfig: { apiParams: { host: "127.0.0.1", port, apiKey: "" } },
|
||||
},
|
||||
],
|
||||
})
|
||||
const unknownStatus = yield* Discovery.Service.use((service) => service.discover()).pipe(
|
||||
Effect.provide(unknown.layer),
|
||||
)
|
||||
expect(unknownStatus.status).toMatchObject({ type: "ready", toolcall: "unknown" })
|
||||
expect(unknownStatus.connection && Redacted.value(unknownStatus.connection.key)).toBe("")
|
||||
expect(unknown.hits.filter((hit) => !hit.path.startsWith("/api")).every((hit) => !hit.authorized)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("bounds management calls with a typed timeout state", () =>
|
||||
Effect.gen(function* () {
|
||||
const slow = yield* fixture({ delay: 200 })
|
||||
const status = yield* Discovery.Service.use((service) => service.discover()).pipe(Effect.provide(slow.layer))
|
||||
expect(status.status).toEqual({ type: "management-unavailable", reason: "timeout" })
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { Effect, Redacted, Result } from "effect"
|
||||
import {
|
||||
decodeMetadata,
|
||||
encodeMetadata,
|
||||
endpoint,
|
||||
normalizeLoopbackEndpoint,
|
||||
parseConfig,
|
||||
parseStore,
|
||||
type Metadata,
|
||||
} from "../../../src/kilocode/anaconda-desktop/domain"
|
||||
import {
|
||||
candidates,
|
||||
command,
|
||||
directory,
|
||||
environment,
|
||||
supported,
|
||||
type Info,
|
||||
} from "../../../src/kilocode/anaconda-desktop/platform"
|
||||
import { Process } from "../../../src/util/process"
|
||||
|
||||
const linux = (env: NodeJS.ProcessEnv = {}): Info => ({
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
home: "/home/kilo",
|
||||
env,
|
||||
})
|
||||
|
||||
const metadata: Metadata = {
|
||||
version: "1",
|
||||
serverID: "server-1",
|
||||
baseURL: "http://127.0.0.1:8080/v1",
|
||||
models: [
|
||||
{
|
||||
id: "local-model",
|
||||
name: "Local Model",
|
||||
input: ["text"],
|
||||
output: ["text"],
|
||||
description: "Tool-call support is unknown.",
|
||||
},
|
||||
],
|
||||
context: 8192,
|
||||
toolcall: "unknown",
|
||||
}
|
||||
|
||||
describe("Anaconda Desktop config", () => {
|
||||
test("strictly parses the management key and port into a redacted value", () => {
|
||||
const parsed = Effect.runSync(
|
||||
parseConfig(JSON.stringify({ aiNavApiKey: "test-management-key", aiNavApiServerPort: 8001 })),
|
||||
)
|
||||
|
||||
expect(Redacted.isRedacted(parsed.aiNavApiKey)).toBe(true)
|
||||
expect(Redacted.value(parsed.aiNavApiKey)).toBe("test-management-key")
|
||||
expect(parsed.aiNavApiServerPort).toBe(8001)
|
||||
expect(JSON.stringify(parsed)).not.toContain("test-management-key")
|
||||
})
|
||||
|
||||
test("classifies malformed, missing-key, and invalid-port values without echoing input", () => {
|
||||
const cases = [
|
||||
["not-json:test-management-key", "malformed"],
|
||||
[JSON.stringify({ aiNavApiKey: "", aiNavApiServerPort: 8001 }), "missing-key"],
|
||||
[JSON.stringify({ aiNavApiKey: "test-management-key", aiNavApiServerPort: "8001" }), "invalid-port"],
|
||||
[JSON.stringify({ aiNavApiKey: "test-management-key", aiNavApiServerPort: 70_000 }), "invalid-port"],
|
||||
] as const
|
||||
|
||||
for (const [input, reason] of cases) {
|
||||
const result = Effect.runSync(Effect.result(parseConfig(input)))
|
||||
expect(Result.isFailure(result)).toBe(true)
|
||||
if (Result.isFailure(result)) {
|
||||
expect(result.failure.reason).toBe(reason)
|
||||
expect(JSON.stringify(result.failure)).not.toContain("test-management-key")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("checks only opaque non-empty OAuth entries with the required suffix", () => {
|
||||
expect(
|
||||
Effect.runSync(parseStore(JSON.stringify({ "account_ai-navigator-workos-oauth": "opaque-encrypted-value" }))),
|
||||
).toBe(true)
|
||||
expect(Effect.runSync(parseStore(JSON.stringify({ "account_ai-navigator-workos-oauth": "" })))).toBe(false)
|
||||
expect(Effect.runSync(parseStore(JSON.stringify({ unrelated: "opaque-encrypted-value" })))).toBe(false)
|
||||
|
||||
const result = Effect.runSync(Effect.result(parseStore("[]")))
|
||||
expect(Result.isFailure(result)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Anaconda Desktop metadata", () => {
|
||||
test("round-trips trusted loopback metadata and rejects malformed or remote records", () => {
|
||||
const encoded = encodeMetadata(metadata)
|
||||
expect(encoded).toBeDefined()
|
||||
if (!encoded) throw new Error("metadata did not encode")
|
||||
expect(decodeMetadata(encoded)).toEqual(metadata)
|
||||
expect(decodeMetadata({ ...encoded, version: "2" })).toBeUndefined()
|
||||
expect(decodeMetadata({ ...encoded, models: "not-json" })).toBeUndefined()
|
||||
expect(decodeMetadata({ ...encoded, baseURL: "http://example.com:8080/v1" })).toBeUndefined()
|
||||
expect(endpoint("0.0.0.0", 8080)).toBe("http://127.0.0.1:8080/v1")
|
||||
expect(endpoint("::", 8080)).toBe("http://[::1]:8080/v1")
|
||||
expect(normalizeLoopbackEndpoint("http://localhost:8080/")).toBe("http://localhost:8080/v1")
|
||||
expect(normalizeLoopbackEndpoint("https://127.0.0.1:8080/v1")).toBeUndefined()
|
||||
expect(normalizeLoopbackEndpoint("http://192.168.1.2:8080/v1")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Anaconda Desktop platform adapters", () => {
|
||||
test("resolves official cross-platform user-data locations", () => {
|
||||
expect(directory({ platform: "darwin", arch: "arm64", home: "/Users/kilo", env: {} })).toBe(
|
||||
"/Users/kilo/Library/Application Support/anaconda-desktop",
|
||||
)
|
||||
expect(
|
||||
directory({ platform: "win32", arch: "x64", home: "C:\\Users\\kilo", env: { APPDATA: "D:\\Roaming" } }),
|
||||
).toBe(path.win32.join("D:\\Roaming", "anaconda-desktop"))
|
||||
expect(directory(linux({ XDG_DATA_HOME: "/data" }))).toBe("/data/anaconda-desktop")
|
||||
expect(directory(linux())).toBe("/home/kilo/.local/share/anaconda-desktop")
|
||||
})
|
||||
|
||||
test("supports only documented operating-system and architecture pairs", () => {
|
||||
expect(supported({ platform: "darwin", arch: "arm64" })).toBe(true)
|
||||
expect(supported({ platform: "darwin", arch: "x64" })).toBe(false)
|
||||
expect(supported({ platform: "win32", arch: "x64" })).toBe(true)
|
||||
expect(supported({ platform: "linux", arch: "arm64" })).toBe(true)
|
||||
expect(supported({ platform: "freebsd", arch: "x64" })).toBe(false)
|
||||
})
|
||||
|
||||
test("builds hidden Windows and Wayland-safe Linux launch commands", () => {
|
||||
const windows: Info = {
|
||||
platform: "win32",
|
||||
arch: "x64",
|
||||
home: "C:\\Users\\kilo",
|
||||
env: { LOCALAPPDATA: "C:\\Users\\kilo\\AppData\\Local" },
|
||||
}
|
||||
const executable = candidates(windows)[0]
|
||||
expect(command(windows, { path: executable })).toEqual([executable])
|
||||
expect(command(linux({ XDG_SESSION_TYPE: "wayland" }), { path: "/usr/bin/anaconda-desktop" })).toEqual([
|
||||
"/usr/bin/anaconda-desktop",
|
||||
"--ozone-platform=x11",
|
||||
])
|
||||
})
|
||||
|
||||
test("passes only required desktop environment variables to the launched app", async () => {
|
||||
const env = environment(
|
||||
linux({
|
||||
PATH: "/usr/bin",
|
||||
DISPLAY: ":0",
|
||||
KILO_SERVER_PASSWORD: "secret",
|
||||
ANTHROPIC_API_KEY: "secret",
|
||||
}),
|
||||
)
|
||||
const output = await Process.run(
|
||||
[
|
||||
process.execPath,
|
||||
"-e",
|
||||
"process.stdout.write(JSON.stringify({ path: process.env.PATH, secret: process.env.KILO_SERVER_PASSWORD }))",
|
||||
],
|
||||
{ env },
|
||||
)
|
||||
|
||||
expect(JSON.parse(output.stdout.toString())).toEqual({ path: "/usr/bin" })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Provider } from "@kilocode/sdk/v2"
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
|
||||
import { generateText } from "ai"
|
||||
import { encodeMetadata, PROVIDER_ID, type Metadata } from "../../../src/kilocode/anaconda-desktop/domain"
|
||||
import { CatalogProvider, hooks, PLACEHOLDER_MODEL_ID } from "../../../src/kilocode/anaconda-desktop/provider"
|
||||
|
||||
const metadata: Metadata = {
|
||||
version: "1",
|
||||
serverID: "server-1",
|
||||
baseURL: "http://127.0.0.1:8080/v1",
|
||||
models: [
|
||||
{
|
||||
id: "model.gguf",
|
||||
name: "Local Model",
|
||||
family: "gemma",
|
||||
input: ["text", "image"],
|
||||
output: ["text"],
|
||||
description: "This local model does not support tool calling.",
|
||||
},
|
||||
],
|
||||
context: 131_072,
|
||||
toolcall: "unsupported",
|
||||
}
|
||||
|
||||
const provider: Provider = {
|
||||
id: PROVIDER_ID,
|
||||
name: CatalogProvider.name,
|
||||
source: "custom",
|
||||
env: [],
|
||||
options: {},
|
||||
models: {},
|
||||
}
|
||||
|
||||
describe("Anaconda Desktop plugin", () => {
|
||||
test("loads only the safe OpenAI-compatible base URL", async () => {
|
||||
const plugin = hooks()
|
||||
const loader = plugin.auth?.loader
|
||||
if (!loader) throw new Error("auth loader is missing")
|
||||
const encoded = encodeMetadata(metadata)
|
||||
if (!encoded) throw new Error("metadata did not encode")
|
||||
|
||||
const options = await loader(async () => ({ type: "api", key: "test-inference-key", metadata: encoded }), provider)
|
||||
expect(options).toEqual({ baseURL: metadata.baseURL })
|
||||
expect(JSON.stringify(options)).not.toContain("test-inference-key")
|
||||
})
|
||||
|
||||
test("replaces the placeholder with stored active-server models", async () => {
|
||||
const plugin = hooks()
|
||||
const load = plugin.provider?.models
|
||||
if (!load) throw new Error("provider model hook is missing")
|
||||
const encoded = encodeMetadata(metadata)
|
||||
if (!encoded) throw new Error("metadata did not encode")
|
||||
|
||||
const models = await load(provider, { auth: { type: "api", key: "test-inference-key", metadata: encoded } })
|
||||
expect(Object.keys(models)).toEqual(["model.gguf"])
|
||||
expect(models[PLACEHOLDER_MODEL_ID]).toBeUndefined()
|
||||
expect(models["model.gguf"]).toMatchObject({
|
||||
providerID: PROVIDER_ID,
|
||||
api: {
|
||||
id: "model.gguf",
|
||||
url: "http://127.0.0.1:8080/v1",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
capabilities: {
|
||||
attachment: true,
|
||||
toolcall: false,
|
||||
input: { text: true, image: true },
|
||||
output: { text: true },
|
||||
},
|
||||
limit: { context: 131_072, output: 0 },
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
})
|
||||
|
||||
test("uses the stored key through the standard OpenAI-compatible runtime", async () => {
|
||||
const key = "fixture-standard-runtime-key"
|
||||
const requests: Array<{ path: string; authorization: string | null }> = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
requests.push({
|
||||
path: new URL(request.url).pathname,
|
||||
authorization: request.headers.get("authorization"),
|
||||
})
|
||||
return Response.json({
|
||||
id: "fixture-completion",
|
||||
object: "chat.completion",
|
||||
created: 0,
|
||||
model: "model.gguf",
|
||||
choices: [{ index: 0, message: { role: "assistant", content: "ready" }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const port = server.port
|
||||
if (port === undefined) throw new Error("runtime fixture did not bind a port")
|
||||
const sdk = createOpenAICompatible({
|
||||
name: PROVIDER_ID,
|
||||
baseURL: `http://127.0.0.1:${port}/v1`,
|
||||
apiKey: key,
|
||||
})
|
||||
const result = await generateText({ model: sdk.languageModel("model.gguf"), prompt: "hello" })
|
||||
|
||||
expect(result.text).toBe("ready")
|
||||
expect(requests).toEqual([{ path: "/v1/chat/completions", authorization: `Bearer ${key}` }])
|
||||
} finally {
|
||||
server.stop(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Auth } from "@/auth"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { ModelCache } from "@/provider/model-cache"
|
||||
import { Effect, Layer, Redacted, Ref } from "effect"
|
||||
import * as Discovery from "../../../src/kilocode/anaconda-desktop/discovery"
|
||||
import {
|
||||
decodeMetadata,
|
||||
PROVIDER_ID,
|
||||
type Metadata,
|
||||
type ReadyStatus,
|
||||
} from "../../../src/kilocode/anaconda-desktop/domain"
|
||||
import * as DesktopPlatform from "../../../src/kilocode/anaconda-desktop/platform"
|
||||
import * as Desktop from "../../../src/kilocode/anaconda-desktop/service"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
function ready(
|
||||
serverID: string,
|
||||
port: number,
|
||||
key: string,
|
||||
toolcall: Metadata["toolcall"] = "supported",
|
||||
): Discovery.DiscoveryResult {
|
||||
const model = `${serverID}.gguf`
|
||||
const metadata: Metadata = {
|
||||
version: "1",
|
||||
serverID,
|
||||
baseURL: `http://127.0.0.1:${port}/v1`,
|
||||
models: [{ id: model, name: serverID, input: ["text"], output: ["text"] }],
|
||||
context: 8192,
|
||||
toolcall,
|
||||
}
|
||||
const status: ReadyStatus = {
|
||||
type: "ready",
|
||||
serverID,
|
||||
models: [{ id: model, name: serverID }],
|
||||
context: 8192,
|
||||
toolcall,
|
||||
}
|
||||
return {
|
||||
status,
|
||||
connection: { key: Redacted.make(key, { label: "fixture inference key" }), metadata },
|
||||
}
|
||||
}
|
||||
|
||||
it.live("sync atomically replaces the standard auth record and invalidates provider state", () =>
|
||||
Effect.gen(function* () {
|
||||
const index = yield* Ref.make(0)
|
||||
const records = yield* Ref.make<Record<string, Auth.Info>>({})
|
||||
const events = yield* Ref.make<string[]>([])
|
||||
const values = [ready("first", 8080, "first-fixture-key"), ready("second", 8081, "second-fixture-key")]
|
||||
|
||||
const discovery = Layer.succeed(
|
||||
Discovery.Service,
|
||||
Discovery.Service.of({ discover: () => Ref.get(index).pipe(Effect.map((value) => values[value])) }),
|
||||
)
|
||||
const platform = Layer.succeed(
|
||||
DesktopPlatform.Service,
|
||||
DesktopPlatform.Service.of({
|
||||
info: { platform: "linux", arch: "x64", home: "/tmp", env: {} },
|
||||
dataDir: () => Effect.succeed("/tmp"),
|
||||
installation: () => Effect.succeed({ path: "/usr/bin/anaconda-desktop" }),
|
||||
open: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const auth = Layer.succeed(
|
||||
Auth.Service,
|
||||
Auth.Service.of({
|
||||
get: (id) => Ref.get(records).pipe(Effect.map((items) => items[id])),
|
||||
all: () => Ref.get(records),
|
||||
set: (id, value) => Ref.update(records, (items) => ({ ...items, [id]: value })),
|
||||
remove: (id) =>
|
||||
Ref.update(records, (items) => Object.fromEntries(Object.entries(items).filter(([key]) => key !== id))),
|
||||
}),
|
||||
)
|
||||
const cache = Layer.mock(ModelCache.Service)({
|
||||
clear: (id) => Ref.update(events, (items) => [...items, `clear:${id}`]),
|
||||
})
|
||||
const instances = Layer.mock(InstanceStore.Service)({
|
||||
disposeAll: () => Ref.update(events, (items) => [...items, "dispose"]),
|
||||
})
|
||||
const layer = Desktop.layer.pipe(
|
||||
Layer.provide(discovery),
|
||||
Layer.provide(platform),
|
||||
Layer.provide(auth),
|
||||
Layer.provide(cache),
|
||||
Layer.provide(instances),
|
||||
)
|
||||
|
||||
const first = yield* Desktop.Service.use((service) => service.sync()).pipe(Effect.provide(layer))
|
||||
expect(first.serverID).toBe("first")
|
||||
const unchanged = yield* Desktop.Service.use((service) => service.sync()).pipe(Effect.provide(layer))
|
||||
expect(unchanged.serverID).toBe("first")
|
||||
expect(yield* Ref.get(events)).toEqual([`clear:${PROVIDER_ID}`, "dispose"])
|
||||
yield* Ref.set(index, 1)
|
||||
const second = yield* Desktop.Service.use((service) => service.sync()).pipe(Effect.provide(layer))
|
||||
expect(second.serverID).toBe("second")
|
||||
|
||||
const stored = (yield* Ref.get(records))[PROVIDER_ID]
|
||||
expect(stored?.type).toBe("api")
|
||||
if (stored?.type !== "api") throw new Error("standard API auth record was not stored")
|
||||
expect(stored.key).toBe("second-fixture-key")
|
||||
const metadata = decodeMetadata(stored.metadata)
|
||||
expect(metadata?.serverID).toBe("second")
|
||||
expect(metadata?.baseURL).toBe("http://127.0.0.1:8081/v1")
|
||||
expect(yield* Ref.get(events)).toEqual([`clear:${PROVIDER_ID}`, "dispose", `clear:${PROVIDER_ID}`, "dispose"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("sync requires acknowledgement for limited tool support", () =>
|
||||
Effect.gen(function* () {
|
||||
const writes = yield* Ref.make(0)
|
||||
const discovery = Layer.succeed(
|
||||
Discovery.Service,
|
||||
Discovery.Service.of({ discover: () => Effect.succeed(ready("limited", 8080, "fixture-key", "unknown")) }),
|
||||
)
|
||||
const platform = Layer.mock(DesktopPlatform.Service)({
|
||||
info: { platform: "linux", arch: "x64", home: "/tmp", env: {} },
|
||||
open: () => Effect.void,
|
||||
})
|
||||
const auth = Layer.mock(Auth.Service)({
|
||||
get: () => Effect.succeed(undefined),
|
||||
set: () => Ref.update(writes, (count) => count + 1),
|
||||
})
|
||||
const cache = Layer.mock(ModelCache.Service)({ clear: () => Effect.void })
|
||||
const instances = Layer.mock(InstanceStore.Service)({ disposeAll: () => Effect.void })
|
||||
const layer = Desktop.layer.pipe(
|
||||
Layer.provide(discovery),
|
||||
Layer.provide(platform),
|
||||
Layer.provide(auth),
|
||||
Layer.provide(cache),
|
||||
Layer.provide(instances),
|
||||
)
|
||||
|
||||
const refused = yield* Desktop.Service.use((service) => service.sync()).pipe(Effect.provide(layer), Effect.result)
|
||||
expect(refused._tag).toBe("Failure")
|
||||
expect(yield* Ref.get(writes)).toBe(0)
|
||||
|
||||
const accepted = yield* Desktop.Service.use((service) => service.sync(true)).pipe(Effect.provide(layer))
|
||||
expect(accepted.serverID).toBe("limited")
|
||||
expect(yield* Ref.get(writes)).toBe(1)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AnacondaDesktopStatus } from "@kilocode/sdk/v2"
|
||||
import { createSetupController, type ReadyStatus } from "../../../../src/kilocode/anaconda-desktop/tui/model"
|
||||
|
||||
const ready = (toolcall: ReadyStatus["toolcall"] = "supported"): ReadyStatus => ({
|
||||
type: "ready",
|
||||
serverID: "server-1",
|
||||
models: [{ id: "model-1", name: "Local Model" }],
|
||||
context: 8192,
|
||||
toolcall,
|
||||
})
|
||||
|
||||
const waiting: AnacondaDesktopStatus = { type: "no-running-server", downloadedModels: 1 }
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
for (const _ of Array.from({ length: 4 })) await Promise.resolve()
|
||||
}
|
||||
|
||||
function setup(input: {
|
||||
status(signal: AbortSignal): Promise<AnacondaDesktopStatus>
|
||||
open?(signal: AbortSignal): Promise<void>
|
||||
sync?(acknowledge: boolean, signal: AbortSignal): Promise<ReadyStatus>
|
||||
}) {
|
||||
return createSetupController({
|
||||
api: {
|
||||
status: input.status,
|
||||
open: input.open ?? (async () => {}),
|
||||
sync: input.sync ?? (async () => ready()),
|
||||
},
|
||||
synced: () => {},
|
||||
})
|
||||
}
|
||||
|
||||
describe("Anaconda Desktop TUI setup controller", () => {
|
||||
test("checks once on start and only checks again on explicit refresh", async () => {
|
||||
const calls: Array<ReturnType<typeof deferred<AnacondaDesktopStatus>>> = []
|
||||
const controller = setup({
|
||||
status: async () => {
|
||||
const call = deferred<AnacondaDesktopStatus>()
|
||||
calls.push(call)
|
||||
return call.promise
|
||||
},
|
||||
})
|
||||
|
||||
controller.start()
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(await controller.refresh()).toBe(false)
|
||||
calls[0].resolve(waiting)
|
||||
await flush()
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
const refreshed = controller.refresh()
|
||||
expect(calls).toHaveLength(2)
|
||||
calls[1].resolve(waiting)
|
||||
expect(await refreshed).toBe(true)
|
||||
controller.stop()
|
||||
})
|
||||
|
||||
test("stops an in-flight check without applying its result", async () => {
|
||||
const pending = deferred<AnacondaDesktopStatus>()
|
||||
let signal: AbortSignal | undefined
|
||||
const controller = setup({
|
||||
status: (current) => {
|
||||
signal = current
|
||||
return pending.promise
|
||||
},
|
||||
})
|
||||
|
||||
controller.start()
|
||||
controller.stop()
|
||||
expect(signal?.aborted).toBe(true)
|
||||
pending.resolve(waiting)
|
||||
await flush()
|
||||
expect(controller.snapshot().status).toBeUndefined()
|
||||
})
|
||||
|
||||
test("opening Desktop does not check status again", async () => {
|
||||
let checks = 0
|
||||
let opens = 0
|
||||
const controller = setup({
|
||||
status: async () => {
|
||||
checks += 1
|
||||
return waiting
|
||||
},
|
||||
open: async () => {
|
||||
opens += 1
|
||||
},
|
||||
})
|
||||
|
||||
controller.start()
|
||||
await flush()
|
||||
await controller.open()
|
||||
expect({ checks, opens }).toEqual({ checks: 1, opens: 1 })
|
||||
controller.stop()
|
||||
})
|
||||
|
||||
test("connects explicitly with the correct tool acknowledgement", async () => {
|
||||
for (const toolcall of ["supported", "unsupported", "unknown"] as const) {
|
||||
const acknowledgements: boolean[] = []
|
||||
const controller = setup({
|
||||
status: async () => ready(toolcall),
|
||||
sync: async (acknowledge) => {
|
||||
acknowledgements.push(acknowledge)
|
||||
return ready(toolcall)
|
||||
},
|
||||
})
|
||||
|
||||
controller.start()
|
||||
await flush()
|
||||
expect(acknowledgements).toEqual([])
|
||||
await controller.connect()
|
||||
expect(acknowledgements).toEqual([toolcall !== "supported"])
|
||||
controller.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { AnacondaDesktopStatus } from "@kilocode/sdk/v2"
|
||||
import { DOWNLOAD_URL } from "../../../../src/kilocode/anaconda-desktop/domain"
|
||||
import { setupView } from "../../../../src/kilocode/anaconda-desktop/tui/model"
|
||||
|
||||
const ready = (toolcall: "supported" | "unsupported" | "unknown" = "supported"): AnacondaDesktopStatus => ({
|
||||
type: "ready",
|
||||
serverID: "server-1",
|
||||
models: [{ id: "model-1", name: "Local Model" }],
|
||||
context: 8192,
|
||||
toolcall,
|
||||
})
|
||||
|
||||
test("maps setup states to their consequential actions", () => {
|
||||
const cases: Array<{ status: AnacondaDesktopStatus; action?: "download" | "open" | "connect" }> = [
|
||||
{ status: { type: "unsupported-platform", platform: "freebsd" } },
|
||||
{ status: { type: "not-installed", downloadURL: DOWNLOAD_URL }, action: "download" },
|
||||
{ status: { type: "not-running" }, action: "open" },
|
||||
{ status: { type: "invalid-config", reason: "missing-key" }, action: "open" },
|
||||
{ status: { type: "signed-out" }, action: "open" },
|
||||
{ status: { type: "management-unauthorized" }, action: "open" },
|
||||
{ status: { type: "management-unavailable", reason: "timeout" }, action: "open" },
|
||||
{ status: { type: "no-downloaded-model" }, action: "open" },
|
||||
{ status: { type: "no-running-server", downloadedModels: 2 }, action: "open" },
|
||||
{ status: { type: "inference-unhealthy", serverID: "server-1" }, action: "open" },
|
||||
{ status: ready(), action: "connect" },
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
const view = setupView(item.status)
|
||||
expect(view.actions.at(-1)?.type).toBe("refresh")
|
||||
expect(view.actions.some((action) => action.type === item.action)).toBe(item.action !== undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test("requires explicit continuation for limited tool support", () => {
|
||||
for (const toolcall of ["unsupported", "unknown"] as const) {
|
||||
const view = setupView(ready(toolcall))
|
||||
expect(view.warning).toBe(true)
|
||||
expect(view.actions.find((action) => action.type === "connect")?.label).toBe("connect anyway")
|
||||
}
|
||||
})
|
||||
@@ -8,6 +8,12 @@ import type {
|
||||
AgentBuilderSaveErrors,
|
||||
AgentBuilderSaveResponses,
|
||||
AgentPartInput,
|
||||
AnacondaDesktopOpenErrors,
|
||||
AnacondaDesktopOpenResponses,
|
||||
AnacondaDesktopStatusErrors,
|
||||
AnacondaDesktopStatusResponses,
|
||||
AnacondaDesktopSyncErrors,
|
||||
AnacondaDesktopSyncResponses,
|
||||
AppAgentsErrors,
|
||||
AppAgentsResponses,
|
||||
AppLogErrors,
|
||||
@@ -7572,6 +7578,113 @@ export class Kilocode extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class AnacondaDesktop extends HeyApiClient {
|
||||
/**
|
||||
* Get Anaconda Desktop setup status
|
||||
*
|
||||
* Discover the locally installed Anaconda Desktop and its active inference server.
|
||||
*/
|
||||
public status<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<
|
||||
AnacondaDesktopStatusResponses,
|
||||
AnacondaDesktopStatusErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/kilocode/anaconda-desktop/status",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open Anaconda Desktop
|
||||
*
|
||||
* Open the locally installed Anaconda Desktop application.
|
||||
*/
|
||||
public open<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<AnacondaDesktopOpenResponses, AnacondaDesktopOpenErrors, ThrowOnError>(
|
||||
{
|
||||
url: "/kilocode/anaconda-desktop/open",
|
||||
...options,
|
||||
...params,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize Anaconda Desktop provider
|
||||
*
|
||||
* Discover the active local inference server and replace Kilo provider authentication metadata.
|
||||
*/
|
||||
public sync<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
acknowledgeToolLimitations?: boolean
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "body", key: "acknowledgeToolLimitations" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<AnacondaDesktopSyncResponses, AnacondaDesktopSyncErrors, ThrowOnError>(
|
||||
{
|
||||
url: "/kilocode/anaconda-desktop/sync",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class Network extends HeyApiClient {
|
||||
/**
|
||||
* List pending network waits
|
||||
@@ -8189,6 +8302,11 @@ export class KiloClient extends HeyApiClient {
|
||||
return (this._kilocode ??= new Kilocode({ client: this.client }))
|
||||
}
|
||||
|
||||
private _anacondaDesktop?: AnacondaDesktop
|
||||
get anacondaDesktop(): AnacondaDesktop {
|
||||
return (this._anacondaDesktop ??= new AnacondaDesktop({ client: this.client }))
|
||||
}
|
||||
|
||||
private _network?: Network
|
||||
get network(): Network {
|
||||
return (this._network ??= new Network({ client: this.client }))
|
||||
|
||||
@@ -2587,6 +2587,66 @@ export type NotebookFailure = {
|
||||
currentRevision?: string
|
||||
}
|
||||
|
||||
export type AnacondaDesktopStatus =
|
||||
| {
|
||||
type: "unsupported-platform"
|
||||
platform: string
|
||||
}
|
||||
| {
|
||||
type: "not-installed"
|
||||
downloadURL: string
|
||||
}
|
||||
| {
|
||||
type: "not-running"
|
||||
}
|
||||
| {
|
||||
type: "invalid-config"
|
||||
reason: "missing" | "malformed" | "missing-key" | "invalid-port"
|
||||
}
|
||||
| {
|
||||
type: "signed-out"
|
||||
}
|
||||
| {
|
||||
type: "management-unauthorized"
|
||||
}
|
||||
| {
|
||||
type: "management-unavailable"
|
||||
reason: "timeout" | "unexpected-response"
|
||||
}
|
||||
| {
|
||||
type: "no-downloaded-model"
|
||||
}
|
||||
| {
|
||||
type: "no-running-server"
|
||||
downloadedModels: number
|
||||
}
|
||||
| {
|
||||
type: "inference-unhealthy"
|
||||
serverID: string
|
||||
}
|
||||
| {
|
||||
type: "ready"
|
||||
serverID: string
|
||||
serverName?: string
|
||||
models: Array<{
|
||||
id: string
|
||||
name: string
|
||||
}>
|
||||
context: number
|
||||
toolcall: "supported" | "unsupported" | "unknown"
|
||||
}
|
||||
|
||||
export type AnacondaDesktopConflictError = {
|
||||
code: "unsupported-platform" | "not-installed" | "not-ready" | "acknowledgement-required"
|
||||
message: string
|
||||
status?: AnacondaDesktopStatus
|
||||
}
|
||||
|
||||
export type AnacondaDesktopOperationError = {
|
||||
operation: "open" | "sync"
|
||||
message: string
|
||||
}
|
||||
|
||||
export type KilocodeSessionImportResult = {
|
||||
ok: boolean
|
||||
id: string
|
||||
@@ -10827,6 +10887,118 @@ export type KilocodeNotebookRejectResponses = {
|
||||
|
||||
export type KilocodeNotebookRejectResponse = KilocodeNotebookRejectResponses[keyof KilocodeNotebookRejectResponses]
|
||||
|
||||
export type AnacondaDesktopStatusData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/kilocode/anaconda-desktop/status"
|
||||
}
|
||||
|
||||
export type AnacondaDesktopStatusErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
}
|
||||
|
||||
export type AnacondaDesktopStatusError = AnacondaDesktopStatusErrors[keyof AnacondaDesktopStatusErrors]
|
||||
|
||||
export type AnacondaDesktopStatusResponses = {
|
||||
/**
|
||||
* Anaconda Desktop setup status
|
||||
*/
|
||||
200: AnacondaDesktopStatus
|
||||
}
|
||||
|
||||
export type AnacondaDesktopStatusResponse = AnacondaDesktopStatusResponses[keyof AnacondaDesktopStatusResponses]
|
||||
|
||||
export type AnacondaDesktopOpenData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/kilocode/anaconda-desktop/open"
|
||||
}
|
||||
|
||||
export type AnacondaDesktopOpenErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
/**
|
||||
* AnacondaDesktopConflictError
|
||||
*/
|
||||
409: AnacondaDesktopConflictError
|
||||
/**
|
||||
* AnacondaDesktopOperationError
|
||||
*/
|
||||
500: AnacondaDesktopOperationError
|
||||
}
|
||||
|
||||
export type AnacondaDesktopOpenError = AnacondaDesktopOpenErrors[keyof AnacondaDesktopOpenErrors]
|
||||
|
||||
export type AnacondaDesktopOpenResponses = {
|
||||
/**
|
||||
* Anaconda Desktop opened
|
||||
*/
|
||||
200: true
|
||||
}
|
||||
|
||||
export type AnacondaDesktopOpenResponse = AnacondaDesktopOpenResponses[keyof AnacondaDesktopOpenResponses]
|
||||
|
||||
export type AnacondaDesktopSyncData = {
|
||||
body?: {
|
||||
acknowledgeToolLimitations?: boolean
|
||||
}
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/kilocode/anaconda-desktop/sync"
|
||||
}
|
||||
|
||||
export type AnacondaDesktopSyncErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
/**
|
||||
* AnacondaDesktopConflictError
|
||||
*/
|
||||
409: AnacondaDesktopConflictError
|
||||
/**
|
||||
* AnacondaDesktopOperationError
|
||||
*/
|
||||
500: AnacondaDesktopOperationError
|
||||
}
|
||||
|
||||
export type AnacondaDesktopSyncError = AnacondaDesktopSyncErrors[keyof AnacondaDesktopSyncErrors]
|
||||
|
||||
export type AnacondaDesktopSyncResponses = {
|
||||
/**
|
||||
* Anaconda Desktop connection synchronized
|
||||
*/
|
||||
200: {
|
||||
type: "ready"
|
||||
serverID: string
|
||||
serverName?: string
|
||||
models: Array<{
|
||||
id: string
|
||||
name: string
|
||||
}>
|
||||
context: number
|
||||
toolcall: "supported" | "unsupported" | "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
export type AnacondaDesktopSyncResponse = AnacondaDesktopSyncResponses[keyof AnacondaDesktopSyncResponses]
|
||||
|
||||
export type NetworkListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
@@ -15016,6 +15016,270 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/kilocode/anaconda-desktop/status": {
|
||||
"get": {
|
||||
"tags": ["anaconda-desktop"],
|
||||
"operationId": "anacondaDesktop.status",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Anaconda Desktop setup status",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AnacondaDesktopStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Discover the locally installed Anaconda Desktop and its active inference server.",
|
||||
"summary": "Get Anaconda Desktop setup status",
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.anacondaDesktop.status({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/kilocode/anaconda-desktop/open": {
|
||||
"post": {
|
||||
"tags": ["anaconda-desktop"],
|
||||
"operationId": "anacondaDesktop.open",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Anaconda Desktop opened",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"enum": [true],
|
||||
"description": "Anaconda Desktop opened"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "AnacondaDesktopConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AnacondaDesktopConflictError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "AnacondaDesktopOperationError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AnacondaDesktopOperationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Open the locally installed Anaconda Desktop application.",
|
||||
"summary": "Open Anaconda Desktop",
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.anacondaDesktop.open({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/kilocode/anaconda-desktop/sync": {
|
||||
"post": {
|
||||
"tags": ["anaconda-desktop"],
|
||||
"operationId": "anacondaDesktop.sync",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Anaconda Desktop connection synchronized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["ready"]
|
||||
},
|
||||
"serverID": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"serverName": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"models": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": ["id", "name"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"context": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"toolcall": {
|
||||
"type": "string",
|
||||
"enum": ["supported", "unsupported", "unknown"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "serverID", "models", "context", "toolcall"],
|
||||
"additionalProperties": false,
|
||||
"description": "Anaconda Desktop connection synchronized"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "AnacondaDesktopConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AnacondaDesktopConflictError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "AnacondaDesktopOperationError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AnacondaDesktopOperationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Discover the active local inference server and replace Kilo provider authentication metadata.",
|
||||
"summary": "Synchronize Anaconda Desktop provider",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acknowledgeToolLimitations": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.anacondaDesktop.sync({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/network": {
|
||||
"get": {
|
||||
"tags": ["network"],
|
||||
@@ -24630,6 +24894,219 @@
|
||||
"required": ["code", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AnacondaDesktopStatus": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["unsupported-platform"]
|
||||
},
|
||||
"platform": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "platform"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["not-installed"]
|
||||
},
|
||||
"downloadURL": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "downloadURL"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["not-running"]
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["invalid-config"]
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"enum": ["missing", "malformed", "missing-key", "invalid-port"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "reason"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["signed-out"]
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["management-unauthorized"]
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["management-unavailable"]
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"enum": ["timeout", "unexpected-response"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "reason"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["no-downloaded-model"]
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["no-running-server"]
|
||||
},
|
||||
"downloadedModels": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["type", "downloadedModels"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["inference-unhealthy"]
|
||||
},
|
||||
"serverID": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": ["type", "serverID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["ready"]
|
||||
},
|
||||
"serverID": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"serverName": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"models": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": ["id", "name"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"context": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"toolcall": {
|
||||
"type": "string",
|
||||
"enum": ["supported", "unsupported", "unknown"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "serverID", "models", "context", "toolcall"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"AnacondaDesktopConflictError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"enum": ["unsupported-platform", "not-installed", "not-ready", "acknowledgement-required"]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/AnacondaDesktopStatus"
|
||||
}
|
||||
},
|
||||
"required": ["code", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AnacondaDesktopOperationError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["open", "sync"]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["operation", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"KilocodeSessionImportResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -31244,6 +31721,10 @@
|
||||
"name": "kilocode",
|
||||
"description": "Kilo-specific routes."
|
||||
},
|
||||
{
|
||||
"name": "anaconda-desktop",
|
||||
"description": "Local Anaconda Desktop provider setup routes."
|
||||
},
|
||||
{
|
||||
"name": "network",
|
||||
"description": "Kilo network routes."
|
||||
|
||||
Reference in New Issue
Block a user