diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ae434..fcf422c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `-responses` sibling on OpenCode 1) * support the OpenCode 2 API with the OpenCode 1 server entrypoint diff --git a/README.md b/README.md index 6f44b05..fd2eb97 100644 --- a/README.md +++ b/README.md @@ -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 `-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. | @@ -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`) diff --git a/package.json b/package.json index ca0406b..8987ad5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/plugin/integration.ts b/src/plugin/integration.ts new file mode 100644 index 0000000..f5090b9 --- /dev/null +++ b/src/plugin/integration.ts @@ -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 | 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 } | 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 { + 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 | undefined + return { + apiKey: credential.key, + baseURL: readAnswer(configuration, 'baseURL'), + providerID: readAnswer(configuration, 'providerID'), + } + } catch { + return undefined + } +} diff --git a/src/plugin/v2.ts b/src/plugin/v2.ts index e19f400..3cbd3db 100644 --- a/src/plugin/v2.ts +++ b/src/plugin/v2.ts @@ -1,4 +1,4 @@ -import { Model, Plugin, Provider } from '@opencode/plugin' +import { Integration, Model, Plugin, Provider } from '@opencode/plugin' import type { Context } from '@opencode/plugin/promise/plugin' import type { PluginInput } from '@opencode-ai/plugin' import { @@ -12,6 +12,12 @@ import { withTimeout, LiteLLMPlugin, } from './index' +import { + LITELLM_INTEGRATION_ID, + readConnectedConfig, + registerLiteLLMIntegration, + type ConnectedLiteLLM, +} from './integration' import { buildCacheKey, readModelCacheEntry, @@ -313,41 +319,53 @@ async function makeProviderSource( provider: ProviderInfo | undefined, pluginOptions: Record, ): Promise { - const id = provider?.id ?? readString(pluginOptions.providerID) ?? CHAT_PROVIDER_ID const providerSettings = asRecord(provider?.settings) const settings = { ...pluginOptions, ...providerSettings } - const configuredBaseURL = readString(settings.baseURL) const customHeaders = { ...readHeaders(pluginOptions.customHeaders), ...readCustomHeaders(settings), ...readHeaders(provider?.headers), } - let connectedApiKey: string | undefined - if (provider?.integrationID) { - try { - const connection = await context.integration.connection.active(provider.integrationID) - const credential = connection - ? await context.integration.connection.resolve(connection) - : undefined - if (credential?.type === 'key') connectedApiKey = credential.key - } catch { - // A missing or unrelated integration does not prevent other auth sources. - } - } + // Credential plus dialog form answers. A provider wired to its own + // integration keeps using exactly that one; otherwise the plugin's + // registered integration is consulted, so a gateway can be connected + // entirely from the web UI with no hand-written config. + const connected: ConnectedLiteLLM | undefined = await readConnectedConfig( + context, + provider?.integrationID ?? LITELLM_INTEGRATION_ID, + ) + const id = + provider?.id ?? + readString(pluginOptions.providerID) ?? + connected?.providerID ?? + CHAT_PROVIDER_ID + const configuredBaseURL = readString(settings.baseURL) + const connectedApiKey = connected?.apiKey const configuredApiKey = readString(providerSettings.apiKey) ?? readString(pluginOptions.apiKey) ?? process.env.LITELLM_API_KEY ?? process.env.LITELLM_MASTER_KEY - const usesConnectionCredential = !configuredApiKey && Boolean(provider?.integrationID) + // A provider bound to an integration relies on OpenCode for its credential + // even before one is connected. The plugin's own integration only counts + // once it actually resolved a key, so env/config setups are unaffected. + const usesConnectionCredential = + !configuredApiKey && Boolean(provider?.integrationID ?? connectedApiKey) + const integrationID = + provider?.integrationID ?? + (usesConnectionCredential && connectedApiKey ? LITELLM_INTEGRATION_ID : undefined) const apiKey = configuredApiKey ?? connectedApiKey const filters = readModelFilters(settings) const capabilities = parseModelCapabilities(settings.modelCapabilities) const compatibility = parseModelCompatibility(settings.modelCompatibility) const formatModelNames = readFormatModelNames(settings) + // Explicit config beats the URL typed into the dialog, which beats the + // environment and local port probing. const detectedBaseURL = configuredBaseURL ? normalizeBaseURL(configuredBaseURL) - : await autoDetectLiteLLM(apiKey, customHeaders) + : connected?.baseURL + ? normalizeBaseURL(connected.baseURL) + : await autoDetectLiteLLM(apiKey, customHeaders) if (!detectedBaseURL) { return null @@ -399,7 +417,7 @@ async function makeProviderSource( package: providerPackage, settings, headers: customHeaders, - integrationID: provider?.integrationID, + integrationID, usesConnectionCredential, credentialReloadRequired: false, refreshRequired: false, @@ -602,6 +620,62 @@ async function reconcileProviderSources( return changed } +/** + * Build and publish a source from the dialog's active credential. + * + * Called for the first connect with nothing bound yet, and to rebuild + * dialog-built sources after a credential switch — a reconnect may move the + * gateway to a new base URL or provider id, so those sources are re-derived + * wholesale instead of key-refreshed. + * + * An existing source for the same id is left untouched unless it is a pure + * auto-detection/dialog-built fallback (no explicit baseURL in + * options/config): a fallback only stands in until a real endpoint is + * known, so the dialog's answers take over from it. Explicit option/env + * baseURLs — and host-registered providers — keep winning and are never + * replaced. Replaced sources' owned model ids carry over so models the + * gateway no longer serves are dropped on the next merge, and sources + * replaced under a different id are dropped from `sources` and marked in + * `staleProviderIds` for removal from the provider registry. + */ +async function publishConnectedSource( + context: Context, + pluginOptions: Record, + sources: Map, + staleProviderIds: Set, + replacing: ProviderSource[] = [], +): Promise { + try { + const source = await makeProviderSource(context, undefined, pluginOptions) + if (!source) return + const existing = sources.get(source.id) + if (existing && !(existing.fromFallback && !readString(existing.settings.baseURL))) return + const previous = existing ?? replacing.find((candidate) => candidate.id === source.id) + if (previous) source.ownedModelIds = new Set(previous.ownedModelIds) + for (const previousSource of replacing) { + if (previousSource.id === source.id) continue + const mapped = sources.get(previousSource.id) + if (mapped === previousSource) sources.delete(previousSource.id) + // Absent from the map means an earlier attempt already removed it but + // failed before reloading — still owed a registry removal. A different + // live source under the id (e.g. a host-registered provider took over) + // is left entirely alone. + if (mapped === previousSource || mapped === undefined) { + staleProviderIds.add(previousSource.id) + } + } + sources.set(source.id, source) + await context.provider.reload() + source.pendingPublication = false + logInfo( + `[opencode-litellm] Registered provider "${source.id}" from the connected LiteLLM credential.`, + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logInfo(`[opencode-litellm] Could not register the connected provider: ${message}`) + } +} + const definition = Plugin.define({ id: 'opencode-litellm', async setup(context) { @@ -615,6 +689,14 @@ const definition = Plugin.define({ const pluginOptions = asRecord(context.options) const sources = new Map() + // Providers the dialog replaced under a new id; the transform below + // removes them from the registry on the next reload. + const staleProviderIds = new Set() + + // Surface LiteLLM in the connect-provider dialog so gateways can be + // connected from the web UI. Purely additive: a host without the + // integration API, or a failed registration, keeps every other auth path. + const integrationRegistration = await registerLiteLLMIntegration(context) // First pass. On OpenCode 2 the host has not registered user-configured // providers yet, so this typically finds only built-ins; the event loop @@ -633,12 +715,21 @@ const definition = Plugin.define({ } const providerRegistration = await context.provider.transform((editor) => { + // Sources replaced under a different id leave their registry entries + // behind; drop them before publishing the current sources. + for (const id of staleProviderIds) editor.remove(id) + staleProviderIds.clear() for (const source of sources.values()) { const current = editor.get(source.id) if (current) { editor.update(source.id, (provider) => { provider.package = source.package provider.name = source.name + // Bind the provider to the integration its credential comes from, + // so OpenCode keeps injecting the key after a credential switch. + if (source.usesConnectionCredential && source.integrationID) { + provider.integrationID = Integration.ID.make(source.integrationID) + } const settings = { ...(provider.settings ?? {}), ...runtimeSettings(source), @@ -668,6 +759,12 @@ const definition = Plugin.define({ package: source.package, settings: runtimeSettings(source), headers: source.headers, + // With `integrationID` set, OpenCode resolves the API key from + // the connected credential at request time — the key itself + // never enters provider settings. + ...(source.usesConnectionCredential && source.integrationID + ? { integrationID: Integration.ID.make(source.integrationID) } + : {}), }, models: source.models, }) @@ -744,6 +841,18 @@ const definition = Plugin.define({ continue } if (event.type === 'credential.switched') { + // Sources the plugin built from the dialog re-derive everything + // (key, baseURL, provider id) from the active connection, so a + // reconnect can legitimately move them to a different gateway. + // Configured sources only refresh their key: config outranks the + // dialog for everything but the key. + const dialogBuilt = [...sources.values()].filter( + (source) => + source.integrationID === event.data.integrationID && + source.fromFallback && + source.usesConnectionCredential && + !readString(source.settings.baseURL), + ) const changedSources = (await Promise.all( [...sources.values()] .filter((source) => source.integrationID === event.data.integrationID) @@ -755,8 +864,24 @@ const definition = Plugin.define({ source.credentialReloadRequired = true source.refreshRequired = true } - if (changedSources.length > 0) { - await refreshProviderSources(context, changedSources, inFlight) + const keyOnlySources = changedSources.filter((source) => !dialogBuilt.includes(source)) + if (keyOnlySources.length > 0) { + await refreshProviderSources(context, keyOnlySources, inFlight) + } + if ( + event.data.integrationID === LITELLM_INTEGRATION_ID && + (dialogBuilt.length > 0 || changedSources.length === 0) + ) { + // Rebuild dialog-built sources from the fresh credential — or, + // when no source is bound yet, publish the first one, taking + // over from a keyless auto-detection fallback if one exists. + await publishConnectedSource( + context, + pluginOptions, + sources, + staleProviderIds, + dialogBuilt, + ) } continue } @@ -782,6 +907,7 @@ const definition = Plugin.define({ return async () => { controller.abort() await providerRegistration.dispose() + await integrationRegistration?.dispose() } }, }) diff --git a/test/plugin-integration.test.ts b/test/plugin-integration.test.ts new file mode 100644 index 0000000..c4aab72 --- /dev/null +++ b/test/plugin-integration.test.ts @@ -0,0 +1,667 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import plugin from '../src' +import type { Context } from '@opencode/plugin/promise/plugin' + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name] + else process.env[name] = value +} + +interface IntegrationMocks { + transform: ReturnType + methodUpdate: ReturnType + active: ReturnType + resolve: ReturnType + registrations: Array<{ dispose: () => Promise }> + connection: { active: ReturnType; resolve: ReturnType } +} + +function makeIntegration(): IntegrationMocks { + const methodUpdate = vi.fn() + const registrations: Array<{ dispose: () => Promise }> = [] + const active = vi.fn(async () => undefined) + const resolve = vi.fn(async () => undefined) + return { + transform: vi.fn(async (callback: (editor: unknown) => void) => { + callback({ + list: () => [], + get: () => undefined, + update: vi.fn(), + remove: vi.fn(), + method: { list: () => [], update: methodUpdate, remove: vi.fn() }, + }) + const registration = { dispose: vi.fn(async () => {}) } + registrations.push(registration) + return registration + }), + methodUpdate, + active, + resolve, + registrations, + connection: { active, resolve }, + } +} + +function discoveryFetch(models: Array<{ id: string }>) { + return vi.fn(async (input: unknown) => { + const url = String(input) + if (url.endsWith('/v1/model/info')) { + return new Response(JSON.stringify({ data: [] }), { status: 200 }) + } + return new Response(JSON.stringify({ data: models }), { status: 200 }) + }) +} + +interface RegisteredEntry { + info: Record + models: Array> +} + +/** Editor mock that keeps registry state, so update/remove flows are observable. */ +function makeStatefulEditor(registered: RegisteredEntry[], removed: string[]) { + const find = (id: string) => registered.find((entry) => entry.info.id === id) + return { + list: () => registered.map((entry) => entry.info), + get: (id: string) => find(id), + add: (entry: RegisteredEntry) => { + registered.push(entry) + }, + update: (id: string, update: (provider: Record) => void) => { + const entry = find(id) + if (entry) update(entry.info) + }, + remove: (id: string) => { + removed.push(id) + const index = registered.findIndex((entry) => entry.info.id === id) + if (index >= 0) registered.splice(index, 1) + }, + models: { + set: (id: string, models: Array>) => { + const entry = find(id) + if (entry) entry.models = models + }, + update: vi.fn(), + remove: vi.fn(), + }, + } +} + +describe('connect dialog integration', () => { + const originalFetch = globalThis.fetch + let originalCacheHome: string | undefined + let originalLiteLLMBaseURL: string | undefined + let originalLiteLLMApiKey: string | undefined + let originalLiteLLMMasterKey: string | undefined + let cacheDirectory: string + + beforeEach(() => { + originalCacheHome = process.env.XDG_CACHE_HOME + originalLiteLLMBaseURL = process.env.LITELLM_BASE_URL + originalLiteLLMApiKey = process.env.LITELLM_API_KEY + originalLiteLLMMasterKey = process.env.LITELLM_MASTER_KEY + delete process.env.LITELLM_BASE_URL + delete process.env.LITELLM_API_KEY + delete process.env.LITELLM_MASTER_KEY + }) + + afterEach(() => { + globalThis.fetch = originalFetch + restoreEnv('XDG_CACHE_HOME', originalCacheHome) + restoreEnv('LITELLM_BASE_URL', originalLiteLLMBaseURL) + restoreEnv('LITELLM_API_KEY', originalLiteLLMApiKey) + restoreEnv('LITELLM_MASTER_KEY', originalLiteLLMMasterKey) + if (cacheDirectory) rmSync(cacheDirectory, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('registers the LiteLLM integration with an API key form', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-integration-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + globalThis.fetch = vi.fn(async () => new Response('not found', { status: 503 })) + + const integration = makeIntegration() + const context = { + app: { name: 'OpenCode', version: '2.0.14', channel: 'stable' }, + options: {}, + provider: { + list: vi.fn(async () => ({ data: [] })), + transform: vi.fn(async () => ({ dispose: vi.fn(async () => {}) })), + reload: vi.fn(async () => {}), + }, + integration, + event: { subscribe: () => (async function* () {})() }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + + expect(integration.transform).toHaveBeenCalledOnce() + const keyMethod = integration.methodUpdate.mock.calls.map( + (call) => call[0] as { integrationID: string; method: { type: string; form?: unknown[] } }, + ) + expect(keyMethod).toHaveLength(1) + expect(keyMethod[0].integrationID).toBe('litellm') + expect(keyMethod[0].method.type).toBe('key') + const form = keyMethod[0].method.form as Array<{ key: string; type: string }> + expect(form.map((field) => field.key)).toEqual(['baseURL', 'providerID']) + expect(form.every((field) => field.type === 'string')).toBe(true) + + await cleanup?.() + expect(integration.registrations[0].dispose).toHaveBeenCalledOnce() + }) + + it('does not register an integration when the host has no integration API', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-no-integration-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + globalThis.fetch = vi.fn(async () => new Response('not found', { status: 503 })) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + const context = { + app: { name: 'OpenCode', version: '2.0.14', channel: 'stable' }, + options: {}, + provider: { + list: vi.fn(async () => ({ data: [] })), + transform: vi.fn(async () => ({ dispose: vi.fn(async () => {}) })), + reload: vi.fn(async () => {}), + }, + event: { subscribe: () => (async function* () {})() }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + + // A missing integration API is a silent skip — only a failed registration + // may log ("Could not register the connect dialog integration"). + expect(log).not.toHaveBeenCalledWith(expect.stringContaining('connect dialog integration')) + await cleanup?.() + }) + + it('registers a zero-config provider from a connected credential and its form answers', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-connected-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + + const authorizationHeaders: string[] = [] + globalThis.fetch = vi.fn(async (input, init) => { + authorizationHeaders.push(new Headers(init?.headers).get('Authorization') ?? '') + return discoveryFetch([{ id: 'connected-model' }])(input) + }) + + const integration = makeIntegration() + integration.active.mockResolvedValue({ type: 'credential', id: 'cred-1' }) + integration.resolve.mockResolvedValue({ + type: 'key', + key: 'connected-key', + configuration: { baseURL: 'http://127.0.0.1:44501/v1' }, + }) + + const registered: Array<{ info: Record; models: Array> }> = [] + const editor = { + list: () => [], + get: () => undefined, + add: (entry: { info: Record; models: Array> }) => { + registered.push(entry) + }, + update: vi.fn(), + remove: vi.fn(), + models: { set: vi.fn(), update: vi.fn(), remove: vi.fn() }, + } + const context = { + app: { name: 'OpenCode', version: '2.0.14', channel: 'stable' }, + options: {}, + provider: { + list: vi.fn(async () => ({ data: [] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + transform(editor as never) + return { dispose: vi.fn(async () => {}) } + }), + reload: vi.fn(async () => {}), + }, + integration, + event: { subscribe: () => (async function* () {})() }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + + expect(registered).toHaveLength(1) + expect(registered[0].info).toMatchObject({ + id: 'litellm', + activation: 'enabled', + // Binding the provider to the integration is what makes OpenCode + // inject the connected key at request time. + integrationID: 'litellm', + }) + expect(registered[0].info.settings).toMatchObject({ + baseURL: 'http://127.0.0.1:44501/v1', + }) + // The connection-managed key is used for discovery but must not be + // materialized into provider settings. + expect(registered[0].info.settings).not.toHaveProperty('apiKey') + expect(registered[0].models.map((model) => model.id)).toEqual(['connected-model']) + expect(authorizationHeaders).toContain('Bearer connected-key') + + await cleanup?.() + }) + + it('prefers explicit configuration over the connected credential', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-precedence-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + process.env.LITELLM_API_KEY = 'env-key' + + const authorizationHeaders: string[] = [] + globalThis.fetch = vi.fn(async (input, init) => { + authorizationHeaders.push(new Headers(init?.headers).get('Authorization') ?? '') + return discoveryFetch([{ id: 'configured-model' }])(input) + }) + + const integration = makeIntegration() + integration.active.mockResolvedValue({ type: 'credential', id: 'cred-1' }) + integration.resolve.mockResolvedValue({ + type: 'key', + key: 'connected-key', + configuration: { baseURL: 'http://127.0.0.1:44502/v1' }, + }) + + const registered: Array<{ info: Record }> = [] + const editor = { + list: () => [], + get: () => undefined, + add: (entry: { info: Record }) => { + registered.push(entry) + }, + update: vi.fn(), + remove: vi.fn(), + models: { set: vi.fn(), update: vi.fn(), remove: vi.fn() }, + } + const context = { + app: { name: 'OpenCode', version: '2.0.14', channel: 'stable' }, + options: { baseURL: 'http://127.0.0.1:44503/v1' }, + provider: { + list: vi.fn(async () => ({ data: [] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + transform(editor as never) + return { dispose: vi.fn(async () => {}) } + }), + reload: vi.fn(async () => {}), + }, + integration, + event: { subscribe: () => (async function* () {})() }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + + expect(registered).toHaveLength(1) + // The configured URL and env key both beat the dialog answers; with an + // explicit key the provider is not bound to the integration. + expect(registered[0].info.settings).toMatchObject({ + baseURL: 'http://127.0.0.1:44503/v1', + apiKey: 'env-key', + }) + expect(registered[0].info).not.toHaveProperty('integrationID') + expect(authorizationHeaders).toContain('Bearer env-key') + expect(authorizationHeaders).not.toContain('Bearer connected-key') + + await cleanup?.() + }) + + it('registers a provider when the first credential is connected mid-session', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-late-connect-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + + globalThis.fetch = vi.fn(async (input) => { + const url = String(input) + // Only the connected gateway answers; the localhost auto-detect probes + // must fail so nothing is registered before the credential arrives. + if (!url.startsWith('http://127.0.0.1:44504')) { + return new Response('not found', { status: 503 }) + } + return discoveryFetch([{ id: 'late-connected-model' }])(input) + }) + + const integration = makeIntegration() + // Nothing is connected during setup; the credential arrives with the event. + let connected = false + integration.active.mockImplementation(async (integrationID: string) => { + if (integrationID !== 'litellm' || !connected) return undefined + return { type: 'credential', id: 'cred-1' } + }) + integration.resolve.mockResolvedValue({ + type: 'key', + key: 'late-key', + configuration: { baseURL: 'http://127.0.0.1:44504/v1' }, + }) + + const registered: Array<{ info: Record; models: Array> }> = [] + let providerTransform: ((editor: unknown) => void) | undefined + const editor = { + list: () => [], + get: () => undefined, + add: (entry: { info: Record; models: Array> }) => { + registered.push(entry) + }, + update: vi.fn(), + remove: vi.fn(), + models: { set: vi.fn(), update: vi.fn(), remove: vi.fn() }, + } + const reload = vi.fn(async () => providerTransform?.(editor)) + const context = { + app: { name: 'OpenCode', version: '2.0.14', channel: 'stable' }, + options: {}, + provider: { + list: vi.fn(async () => ({ data: [] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + providerTransform = transform + transform(editor) + return { dispose: vi.fn(async () => {}) } + }), + reload, + }, + integration, + event: { + subscribe: () => + (async function* () { + connected = true + yield { type: 'credential.switched', data: { integrationID: 'litellm' } } + })(), + }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + // Nothing was reachable or connected at setup time. + expect(registered).toHaveLength(0) + + await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce()) + expect(registered).toHaveLength(1) + expect(registered[0].info).toMatchObject({ + id: 'litellm', + integrationID: 'litellm', + }) + expect(registered[0].info.settings).toMatchObject({ + baseURL: 'http://127.0.0.1:44504/v1', + }) + expect(registered[0].info.settings).not.toHaveProperty('apiKey') + expect(registered[0].models.map((model) => model.id)).toEqual([ + 'late-connected-model', + ]) + + await cleanup?.() + }) + + it('replaces a keyless auto-detected fallback when the first credential is connected mid-session', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-fallback-takeover-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + + const authorizationHeaders: string[] = [] + globalThis.fetch = vi.fn(async (input, init) => { + authorizationHeaders.push(new Headers(init?.headers).get('Authorization') ?? '') + const url = String(input) + // A local proxy answers on the default port, so setup registers a + // keyless fallback provider before any credential exists. + if (url.startsWith('http://localhost:4000')) { + return discoveryFetch([{ id: 'local-model' }])(input) + } + if (url.startsWith('http://127.0.0.1:44505')) { + return discoveryFetch([{ id: 'gateway-model' }])(input) + } + return new Response('not found', { status: 503 }) + }) + + const integration = makeIntegration() + // Nothing is connected during setup; the credential arrives with the event. + let connected = false + integration.active.mockImplementation(async (integrationID: string) => { + if (integrationID !== 'litellm' || !connected) return undefined + return { type: 'credential', id: 'cred-1' } + }) + integration.resolve.mockResolvedValue({ + type: 'key', + key: 'gateway-key', + configuration: { baseURL: 'http://127.0.0.1:44505/v1' }, + }) + + // Stateful editor so the replacement flows through the update branch. + const registered: RegisteredEntry[] = [] + const removed: string[] = [] + const editor = makeStatefulEditor(registered, removed) + let providerTransform: ((editor: unknown) => void) | undefined + const reload = vi.fn(async () => providerTransform?.(editor)) + const context = { + app: { name: 'OpenCode', version: '2.0.14', channel: 'stable' }, + options: {}, + provider: { + list: vi.fn(async () => ({ data: [] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + providerTransform = transform + transform(editor) + return { dispose: vi.fn(async () => {}) } + }), + reload, + }, + integration, + event: { + subscribe: () => + (async function* () { + connected = true + yield { type: 'credential.switched', data: { integrationID: 'litellm' } } + })(), + }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + expect(registered).toHaveLength(1) + expect(registered[0].info.settings).toMatchObject({ baseURL: 'http://localhost:4000/v1' }) + + await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce()) + // The dialog's URL and key take over from the auto-detected fallback: + // the provider is re-bound to the integration and the local-only model + // is dropped in favour of the gateway's catalog. + expect(registered[0].info.settings).toMatchObject({ baseURL: 'http://127.0.0.1:44505/v1' }) + expect(registered[0].info).toMatchObject({ integrationID: 'litellm' }) + expect(registered[0].info.settings).not.toHaveProperty('apiKey') + expect(registered[0].models.map((model) => model.id)).toEqual(['gateway-model']) + expect(authorizationHeaders).toContain('Bearer gateway-key') + + await cleanup?.() + }) + + it('re-points a dialog-created provider when reconnecting to a different gateway', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-reconnect-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + + const authorizationHeaders: string[] = [] + globalThis.fetch = vi.fn(async (input, init) => { + authorizationHeaders.push(new Headers(init?.headers).get('Authorization') ?? '') + const url = String(input) + if (url.startsWith('http://127.0.0.1:44506')) { + return discoveryFetch([{ id: 'model-a' }])(input) + } + if (url.startsWith('http://127.0.0.1:44507')) { + return discoveryFetch([{ id: 'model-b' }])(input) + } + return new Response('not found', { status: 503 }) + }) + + const integration = makeIntegration() + // The active connection moves from gateway A to gateway B mid-session. + const credentials = [ + { key: 'key-a', configuration: { baseURL: 'http://127.0.0.1:44506/v1' } }, + { key: 'key-b', configuration: { baseURL: 'http://127.0.0.1:44507/v1' } }, + ] + let generation = 0 + integration.active.mockImplementation(async (integrationID: string) => + integrationID === 'litellm' ? { type: 'credential', id: `cred-${generation}` } : undefined, + ) + integration.resolve.mockImplementation(async () => ({ + type: 'key', + ...credentials[generation], + })) + + const registered: RegisteredEntry[] = [] + const removed: string[] = [] + const editor = makeStatefulEditor(registered, removed) + let providerTransform: ((editor: unknown) => void) | undefined + const reload = vi.fn(async () => providerTransform?.(editor)) + const context = { + app: { name: 'OpenCode', version: '2.0.14', channel: 'stable' }, + options: {}, + provider: { + list: vi.fn(async () => ({ data: [] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + providerTransform = transform + transform(editor) + return { dispose: vi.fn(async () => {}) } + }), + reload, + }, + integration, + event: { + subscribe: () => + (async function* () { + generation = 1 + yield { type: 'credential.switched', data: { integrationID: 'litellm' } } + })(), + }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + expect(registered).toHaveLength(1) + expect(registered[0].info.settings).toMatchObject({ baseURL: 'http://127.0.0.1:44506/v1' }) + + await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce()) + // A key-only refresh would send the new key to the old gateway; the + // source must be rebuilt from the new credential instead. + expect(registered).toHaveLength(1) + expect(registered[0].info.settings).toMatchObject({ baseURL: 'http://127.0.0.1:44507/v1' }) + expect(registered[0].info).toMatchObject({ integrationID: 'litellm' }) + expect(registered[0].info.settings).not.toHaveProperty('apiKey') + expect(registered[0].models.map((model) => model.id)).toEqual(['model-b']) + expect(removed).toEqual([]) + expect(authorizationHeaders).toContain('Bearer key-b') + + await cleanup?.() + }) + + it('moves a dialog-created provider to a new id on reconnect and retires the old one', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-reid-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + + globalThis.fetch = vi.fn(async (input) => { + const url = String(input) + if (url.startsWith('http://127.0.0.1:44509')) { + return discoveryFetch([{ id: 'old-model' }])(input) + } + if (url.startsWith('http://127.0.0.1:44510')) { + return discoveryFetch([{ id: 'team-model' }])(input) + } + return new Response('not found', { status: 503 }) + }) + + const integration = makeIntegration() + const credentials = [ + { key: 'key-a', configuration: { baseURL: 'http://127.0.0.1:44509/v1' } }, + { + key: 'key-b', + configuration: { baseURL: 'http://127.0.0.1:44510/v1', providerID: 'team' }, + }, + ] + let generation = 0 + integration.active.mockImplementation(async (integrationID: string) => + integrationID === 'litellm' ? { type: 'credential', id: `cred-${generation}` } : undefined, + ) + integration.resolve.mockImplementation(async () => ({ + type: 'key', + ...credentials[generation], + })) + + const registered: RegisteredEntry[] = [] + const removed: string[] = [] + const editor = makeStatefulEditor(registered, removed) + let providerTransform: ((editor: unknown) => void) | undefined + const context = { + app: { name: 'OpenCode', version: '2.0.14', channel: 'stable' }, + options: {}, + provider: { + list: vi.fn(async () => ({ data: [] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + providerTransform = transform + transform(editor) + return { dispose: vi.fn(async () => {}) } + }), + reload: vi.fn(async () => providerTransform?.(editor)), + }, + integration, + event: { + subscribe: () => + (async function* () { + generation = 1 + yield { type: 'credential.switched', data: { integrationID: 'litellm' } } + })(), + }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + expect(registered).toHaveLength(1) + expect(registered[0].info.id).toBe('litellm') + + await vi.waitFor(() => expect(registered[0]?.info.id).toBe('team')) + // The dialog-built provider moved to the new id; the old registry entry + // must be gone, not left behind bound to the same integration. + expect(registered).toHaveLength(1) + expect(registered[0].info).toMatchObject({ id: 'team', integrationID: 'litellm' }) + expect(registered[0].info.settings).toMatchObject({ baseURL: 'http://127.0.0.1:44510/v1' }) + expect(registered[0].models.map((model) => model.id)).toEqual(['team-model']) + expect(removed).toEqual(['litellm']) + + await cleanup?.() + }) + + it('ignores non-key credentials from the connection', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-oauth-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + + globalThis.fetch = vi.fn(async (input) => { + // Nothing is reachable; only the (unusable) oauth connection exists. + return new Response('not found', { status: 503 }) + }) + + const integration = makeIntegration() + integration.active.mockResolvedValue({ type: 'oauth', id: 'cred-1' }) + integration.resolve.mockResolvedValue({ type: 'oauth', access: 'token' }) + + const registered: Array<{ info: Record }> = [] + const editor = { + list: () => [], + get: () => undefined, + add: (entry: { info: Record }) => { + registered.push(entry) + }, + update: vi.fn(), + remove: vi.fn(), + models: { set: vi.fn(), update: vi.fn(), remove: vi.fn() }, + } + const context = { + app: { name: 'OpenCode', version: '2.0.14', channel: 'stable' }, + // The auto-detect probe fails, so only the (oauth) connection exists — + // which the plugin must not use, leaving no provider at all. + options: {}, + provider: { + list: vi.fn(async () => ({ data: [] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + transform(editor as never) + return { dispose: vi.fn(async () => {}) } + }), + reload: vi.fn(async () => {}), + }, + integration, + event: { subscribe: () => (async function* () {})() }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + + // Without a key and without any reachable baseURL there is nothing to + // register; the oauth credential is never treated as an API key. + expect(registered).toHaveLength(0) + + await cleanup?.() + }) +})