Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@

### Features

* register a LiteLLM integration with OpenCode 2's connect-provider dialog so gateways can be connected entirely from the web UI (API key plus an optional base URL/provider id form), with the stored key used for discovery and injected by OpenCode at request time
* **v2:** add the `modelCompatibility` option to set or remove the inferred `cacheFormat` per model
* route models LiteLLM reports with `mode: "responses"` through the OpenAI Responses API (per-model runtime package on OpenCode 2; a `<provider>-responses` sibling on OpenCode 1)
* support the OpenCode 2 API with the OpenCode 1 server entrypoint
Expand Down
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ also shows how to configure a provider directly in `opencode.json`.
| 💵 **Real pricing** | Maps `input_cost_per_token` / `output_cost_per_token` (and cache read/write costs) from `/v1/model/info` into OpenCode's `cost` field, so the picker and `/cost` show what the proxy actually bills instead of `$0.00`. Models LiteLLM has no price for are left unpriced, not falsely marked free. |
| 🧩 **Reasoning-effort variants** | When LiteLLM reports per-model effort support (`supports_low_reasoning_effort`, …), the plugin surfaces each level as a picker variant automatically. |
| 🧭 **Responses-API routing** | Models LiteLLM reports with `mode: "responses"` are invoked through `/v1/responses` instead of `/v1/chat/completions`, with no extra configuration. OpenCode 2 overrides the runtime package per model; OpenCode 1 gets a `<provider>-responses` sibling. |
| 🔐 **Auth-aware** | Honours `LITELLM_API_KEY` / `LITELLM_MASTER_KEY`, `providers.litellm.settings.apiKey`, and OpenCode `/connect` credentials on the V1 server path. |
| 🔐 **Auth-aware** | Honours `LITELLM_API_KEY` / `LITELLM_MASTER_KEY`, `providers.litellm.settings.apiKey`, OpenCode `/connect` credentials, and (OpenCode 2) a **LiteLLM** entry in the connect-provider dialog — key plus optional base URL, entered entirely from the web UI. |
| 🌐 **Gateway-friendly** | Supports provider `headers` (and the legacy `customHeaders` option) for proxies behind Cloudflare Access or other API gateways. |
| 🧩 **Splittable catalog** | `includeModels` / `excludeModels` (glob patterns) let one LiteLLM proxy be divided into several OpenCode providers — e.g. by naming prefix — without hand-maintaining a model list. |
| 🎚️ **Capability overrides** | `modelCapabilities` forces or retracts per-model capability flags (`supports_vision`, `supports_function_calling`, …) when `/v1/model/info` is unavailable or disagrees with your deployment. |
Expand Down Expand Up @@ -322,17 +322,27 @@ Selecting a Responses model on OpenCode 1 therefore means picking

### Authentication

If your LiteLLM proxy requires a master key, expose it via either approach:
If your LiteLLM proxy requires a master key, expose it via any of these approaches:

| Method | Example |
|---|---|
| Web UI / `/connect` | Run `/connect` (or use the Desktop providers view), pick **LiteLLM**, paste the key, and optionally fill in the base URL and provider id |
| Env var (base URL) | `LITELLM_BASE_URL=https://litellm.internal.example.com/v1` |
| Env var | `export LITELLM_API_KEY=sk-...` |
| Env var (alias) | `export LITELLM_MASTER_KEY=sk-...` |
| Config | `"settings": { "apiKey": "{env:LITELLM_API_KEY}" }` |
| OpenCode `/connect` | Run `/connect`, search for your `litellm` provider entry, and paste the key |

The env var path lets you commit `opencode.json` without leaking secrets. On OpenCode 1, `/connect` credentials are read from OpenCode's auth store (`~/.local/share/opencode/auth.json`) and applied to health checks, model discovery, and completion-time requests. OpenCode 2 users can use `settings.apiKey` with an environment substitution or set `LITELLM_API_KEY` on the background service as shown in the Quickstart.
The env var path lets you commit `opencode.json` without leaking secrets. On OpenCode 1, `/connect` credentials are read from OpenCode's auth store (`~/.local/share/opencode/auth.json`) and applied to health checks, model discovery, and completion-time requests. On OpenCode 2, a credential saved through `/connect` is a first-class integration connection: the plugin registers the **LiteLLM** integration with an API-key form, uses the stored key for discovery, and lets OpenCode inject it into completion requests — the key never appears in `opencode.json` or provider settings. OpenCode 2 users can still use `settings.apiKey` with an environment substitution or set `LITELLM_API_KEY` on the background service as shown in the Quickstart.

#### Web UI connect (OpenCode 2)

With no configuration at all, `/connect` → **LiteLLM** is enough:

1. Enter the API key when prompted.
2. Optionally fill the form: **Base URL** (e.g. `https://llm.example.com/v1`) and **Provider ID** (defaults to `litellm`; pick a different id when your config already defines a `litellm` provider, so the connected gateway gets its own entry). Reconnecting with a different id moves the gateway to that provider.
3. Models appear in the picker without editing `opencode.json` or restarting.

Precedence when several sources are configured: explicit provider/plugin settings always win. For the key, the environment (`LITELLM_API_KEY`/`LITELLM_MASTER_KEY`) is consulted before the dialog credential; for the base URL, the dialog answer beats `LITELLM_BASE_URL` and local port auto-detection. A provider defined in `opencode.json` (without its own key) picks the saved credential up on the next start, while providers created from the dialog update immediately — reconnecting re-points them in place, including a changed base URL or provider id.

### Slow proxies (`LITELLM_REQUEST_TIMEOUT_MS`)

Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",
"build": "npm run typecheck",
"prepublishOnly": "npm run build"
"prepublishOnly": "npm run typecheck"
},
"keywords": [
"opencode",
Expand Down
122 changes: 122 additions & 0 deletions src/plugin/integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { Context } from '@opencode/plugin/promise/plugin'

// OpenCode 2 exposes no log API through the plugin context (see
// initV2Logging); the host captures console output into its log files, so a
// plain console write with the `[opencode-litellm]` prefix is the house
// pattern for V2 modules (same as logInfo in plugin/v2.ts).
function logInfo(message: string): void {
console.log(message)
}

/**
* Integration id the plugin registers with OpenCode's connect-provider dialog.
*
* Deliberately identical to the default provider id (`CHAT_PROVIDER_ID` in
* `plugin/v2.ts`) so a credential saved through `/connect` lines up with the
* `litellm` provider without extra wiring; providers registered under other
* ids still resolve the same credential because the plugin stamps
* `info.integrationID` on them.
*/
export const LITELLM_INTEGRATION_ID = 'litellm'

/** A credential saved through the connect dialog, with its form answers. */
export interface ConnectedLiteLLM {
/** Key resolved from the active connection's credential. */
apiKey: string | undefined
/** Base URL typed into the dialog's form, when present. */
baseURL: string | undefined
/** Provider id typed into the dialog's form, when present. */
providerID: string | undefined
}

function readAnswer(
configuration: Record<string, unknown> | undefined,
key: string,
): string | undefined {
const value = configuration?.[key]
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined
}

/**
* Registers the LiteLLM integration so gateways can be connected from the
* web UI instead of hand-edited config: the dialog collects the API key as a
* credential plus a small form for the proxy base URL and an optional
* provider id.
*
* Returns the transform registration, or undefined when the host has no
* integration API (OpenCode 1.18.x's embedded v2 host) or registration
* fails — model discovery must keep working without the dialog entry.
*/
export async function registerLiteLLMIntegration(
context: Context,
): Promise<{ dispose(): Promise<void> } | undefined> {
if (!context.integration?.transform) return undefined
try {
return await context.integration.transform((editor) => {
editor.update(LITELLM_INTEGRATION_ID, (integration) => {
integration.name = 'LiteLLM'
})
editor.method.update({
integrationID: LITELLM_INTEGRATION_ID,
method: {
type: 'key',
label: 'API Key',
form: [
{
key: 'baseURL',
type: 'string',
format: 'uri',
title: 'Base URL',
description:
'Where the LiteLLM proxy runs, including the /v1 suffix. Leave empty to use LITELLM_BASE_URL or auto-detect a local proxy.',
placeholder: 'https://llm.example.com/v1',
},
{
key: 'providerID',
type: 'string',
title: 'Provider ID',
description:
'Provider the discovered models appear under. Pick a different id when your config already defines a "litellm" provider, so the connected gateway gets its own entry. Reconnecting with a different id moves the gateway there.',
default: LITELLM_INTEGRATION_ID,
},
],
},
})
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logInfo(`[opencode-litellm] Could not register the connect dialog integration: ${message}`)
return undefined
}
}

/**
* Resolves the active credential for an integration into the values the
* plugin needs: the API key plus the dialog's form answers.
*
* Returns undefined when the integration has no connection, the credential
* is not a key, or the host lacks the connection API. Errors are swallowed
* on purpose — a missing or unrelated integration must never prevent other
* auth sources from working.
*/
export async function readConnectedConfig(
context: Context,
integrationID: string,
): Promise<ConnectedLiteLLM | undefined> {
const connection = context.integration?.connection
if (!connection) return undefined
try {
const active = await connection.active(integrationID)
if (!active) return undefined
const credential = await connection.resolve(active)
if (!credential || credential.type !== 'key') return undefined
const configuration = credential.configuration as Record<string, unknown> | undefined
return {
apiKey: credential.key,
baseURL: readAnswer(configuration, 'baseURL'),
providerID: readAnswer(configuration, 'providerID'),
}
} catch {
return undefined
}
}
Loading
Loading