diff --git a/CHANGELOG.md b/CHANGELOG.md index dbc089c..76e56c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ ### Bug Fixes +* prefer a credential saved in the box (the active connection / `/connect` entry) over `LITELLM_API_KEY` / `LITELLM_MASTER_KEY` on both the V1 server path and the OpenCode 2 plugin host; the model cache is now isolated per credential * **v2:** register Chat Completions models whose deployment accepts Anthropic `cache_control` with `compatibility.cacheFormat: "anthropic"` so prompt caching works for Claude behind LiteLLM * **v2:** spell reasoning-effort variants as `thinking` + `effort` for providers on an Anthropic runtime package, where `reasoningEffort` is ignored; models without adaptive thinking get none there * restore baseline reasoning-effort variants from sparse LiteLLM metadata (including GPT-6 Astra), respect explicit effort lists and disabled levels diff --git a/README.md b/README.md index fd2eb97..2abd7ce 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`, 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. | +| 🔐 **Auth-aware** | Honours `providers.litellm.settings.apiKey`, OpenCode `/connect` credentials, the box auth store, and `LITELLM_API_KEY` / `LITELLM_MASTER_KEY` on both the V1 server path and the OpenCode 2 plugin host; on OpenCode 2 a **LiteLLM** entry in the connect-provider dialog (key plus optional base URL) connects a gateway entirely from the web UI. A saved credential beats the environment key. | | 🌐 **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. | @@ -332,7 +332,7 @@ If your LiteLLM proxy requires a master key, expose it via any of these approach | Env var (alias) | `export LITELLM_MASTER_KEY=sk-...` | | Config | `"settings": { "apiKey": "{env:LITELLM_API_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. 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. +The env var path lets you commit `opencode.json` without leaking secrets. `/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 BOTH OpenCode 1 and OpenCode 2. 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. A credential the user saves in the box (the active connection or the auth-store entry) wins over `LITELLM_API_KEY` / `LITELLM_MASTER_KEY`; the environment is a box-level fallback, never an override of a saved key. #### Web UI connect (OpenCode 2) @@ -342,7 +342,9 @@ With no configuration at all, `/connect` → **LiteLLM** is enough: 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. +Precedence when several sources are configured: explicit provider/plugin settings always win. For the key, a credential saved in the box (the active connection or the `/connect` entry) beats the environment (`LITELLM_API_KEY`/`LITELLM_MASTER_KEY`), which is only a fallback; 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. + +The discovered model list is cached per credential: a short hash of the effective key (never the raw key) is part of every cache entry's key, so when the selected credential changes the next discovery starts fresh instead of serving another credential's model list. A `credential.switched` event also recomputes the effective key and reloads the provider registry when it changed — including the first credential saved after setup. ### Slow proxies (`LITELLM_REQUEST_TIMEOUT_MS`) diff --git a/package.json b/package.json index 8a81df7..bb87ac6 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,7 @@ ], "scripts": { "typecheck": "tsc --noEmit", - "test": "vitest run", - "prepublishOnly": "npm run typecheck" + "test": "vitest run" }, "keywords": [ "opencode", diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 197519f..0f020cc 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -761,9 +761,14 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { the plugin reads the stored key here and applies it to both its own discovery fetches and the completion-time provider options. Without it, a key-only proxy would fail discovery even though chat works. + + Precedence rule: the stored credential — the user's explicit + choice made in the box — wins over the pod environment. The env is a + box-level fallback for users who have not saved a credential — never an + override of a user's saved key. */ const storedKey = await getOpenCodeStoredApiKey(providerId) - const apiKey = configuredKey ?? envKey ?? storedKey + const apiKey = configuredKey ?? storedKey ?? envKey // Native `options.headers` and the legacy `customHeaders` both reach // discovery and the runtime, so a gateway that only sets one still // authenticates the plugin's own fetches. @@ -829,9 +834,12 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { // Identity includes the filter/capability/naming config: those // are baked into cached entries, so changing them must start a // fresh discovery instead of serving the old adjusted view. + // The cache key is isolated by the effective credential (a fingerprint, + // never the raw key): a credential change starts a fresh discovery + // instead of serving another credential's cached model list. const cacheKey = buildCacheKey(providerId, baseURL, filters, capabilities, { formatModelNames, - }) + }, apiKey) // Remember how to reach this proxy so the `event` hook can // revalidate its cache in the background on new sessions. diff --git a/src/plugin/v2.ts b/src/plugin/v2.ts index 3cbd3db..cfdac55 100644 --- a/src/plugin/v2.ts +++ b/src/plugin/v2.ts @@ -25,6 +25,7 @@ import { writeModelCache, } from '../utils/model-cache' import { normalizeBaseURL, autoDetectLiteLLM } from '../utils/litellm-api' +import { getOpenCodeStoredApiKey } from '../utils/opencode-auth' import { parseModelCapabilities } from '../utils/model-capabilities' import type { ModelCapabilities } from '../utils/model-capabilities' import { @@ -341,20 +342,25 @@ async function makeProviderSource( 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 - // 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) + // Precedence rule: an explicit settings/plugin-options apiKey is the + // operator's deliberate config and stays first; then a credential the user + // saved in the box — the ACTIVE connection or the `/connect` entry in the + // box auth store — which wins over the pod environment. The env is a + // box-level fallback for users who have not saved a credential, never an + // override of the user's explicit choice. + const settingsApiKey = readString(providerSettings.apiKey) ?? readString(pluginOptions.apiKey) + const envApiKey = process.env.LITELLM_API_KEY ?? process.env.LITELLM_MASTER_KEY + // The key OpenCode itself stored for this provider in the box auth store + // (/connect journey). A config-defined provider carries no integrationID, so + // the host-integration path above cannot see it — read the store directly, + // exactly like the v1 entrypoint does. Still ahead of the env: a saved + // credential is the user's explicit choice and beats the box-level fallback. + const storedKey = await getOpenCodeStoredApiKey(id) + const usesConnectionCredential = settingsApiKey === undefined && connectedApiKey !== undefined const integrationID = provider?.integrationID ?? - (usesConnectionCredential && connectedApiKey ? LITELLM_INTEGRATION_ID : undefined) - const apiKey = configuredApiKey ?? connectedApiKey + (usesConnectionCredential ? LITELLM_INTEGRATION_ID : undefined) + const apiKey = settingsApiKey ?? connectedApiKey ?? storedKey ?? envApiKey const filters = readModelFilters(settings) const capabilities = parseModelCapabilities(settings.modelCapabilities) const compatibility = parseModelCompatibility(settings.modelCompatibility) @@ -372,9 +378,12 @@ async function makeProviderSource( } const baseURL = normalizeBaseURL(detectedBaseURL) + // The cache key is isolated by the effective credential (a fingerprint, never + // the raw key): a credential change starts a fresh discovery instead of + // serving another credential's cached model list. const cacheKey = buildCacheKey(id, baseURL, filters, capabilities, { formatModelNames, - }) + }, apiKey) const cacheEntry = readModelCacheEntry(cacheKey) let entries = cacheEntry?.models ?? null let responsesIds = cacheEntry?.responsesIds ?? [] @@ -531,16 +540,40 @@ async function refreshConnectionCredential( context: Context, source: ProviderSource, ): Promise { - if (!source.integrationID || !source.usesConnectionCredential) return false + // The gate is the integration's presence, not the current credential choice: + // setup may run before the user saves a credential, and the first save must + // refresh this source instead of leaving it on the environment key. + if (!source.integrationID) return false try { const connection = await context.integration.connection.active(source.integrationID) const credential = connection ? await context.integration.connection.resolve(connection) : undefined - const apiKey = credential?.type === 'key' ? credential.key : undefined - if (apiKey === source.apiKey) return false - source.apiKey = apiKey + const connectionKey = credential?.type === 'key' ? credential.key : undefined + const settingsApiKey = readString(source.settings.apiKey) + let nextKey: string | undefined + let nextUsesConnection: boolean + if (connectionKey !== undefined) { + // An explicit settings apiKey stays first; a connection credential takes + // over the environment/auto-detected key. + nextKey = settingsApiKey ?? connectionKey + nextUsesConnection = settingsApiKey === undefined + } else { + // The connection credential is gone (the connection was removed or its + // credential cleared): fall back through the stored credential / env key + // — the same precedence `makeProviderSource` uses — instead of retaining + // the previous connection key, which `runtimeSettings` would otherwise + // materialize into provider settings. + const storedKey = await getOpenCodeStoredApiKey(source.id) + const envKey = process.env.LITELLM_API_KEY ?? process.env.LITELLM_MASTER_KEY + nextKey = settingsApiKey ?? storedKey ?? envKey + nextUsesConnection = false + } + if (nextKey === source.apiKey && nextUsesConnection === source.usesConnectionCredential) return false + source.apiKey = nextKey + source.usesConnectionCredential = nextUsesConnection + source.credentialReloadRequired = true return true } catch (error) { const message = error instanceof Error ? error.message : String(error) diff --git a/src/utils/model-cache.ts b/src/utils/model-cache.ts index 355844e..2be3e44 100644 --- a/src/utils/model-cache.ts +++ b/src/utils/model-cache.ts @@ -111,8 +111,16 @@ export function buildCacheKey( filters: { includeModels?: string[]; excludeModels?: string[] }, capabilities: Record>, naming: { formatModelNames?: boolean } = {}, + credential?: string, ): string { - const base = `${providerId}@${baseURL}` + let base = `${providerId}@${baseURL}` + // The credential fingerprint: a short hash (never the raw key) so a + // credential change isolates the cached model list — a key that exposes a + // different set of models must start a fresh discovery instead of serving + // another credential's entries. + if (credential) { + base += `@${createHash('sha256').update(credential).digest('hex').slice(0, 12)}` + } const rawNames = naming.formatModelNames === false const hasAdjustments = (filters.includeModels?.length ?? 0) > 0 || diff --git a/test/model-cache.test.ts b/test/model-cache.test.ts index cead715..231a743 100644 --- a/test/model-cache.test.ts +++ b/test/model-cache.test.ts @@ -144,6 +144,21 @@ describe('buildCacheKey', () => { ).toBe(KEY) }) + it('isolates the key by credential fingerprint', () => { + // Regression for the CodeRabbit review finding: a model list cached with + // one credential must never be served to another — keys expose different + // model sets, so a credential change starts a fresh discovery. + const without = buildCacheKey('litellm', 'http://localhost:4000', {}, {}) + const keyA = buildCacheKey('litellm', 'http://localhost:4000', {}, {}, {}, 'sk-key-a') + const keyA2 = buildCacheKey('litellm', 'http://localhost:4000', {}, {}, {}, 'sk-key-a') + const keyB = buildCacheKey('litellm', 'http://localhost:4000', {}, {}, {}, 'sk-key-b') + expect(keyA).toBe(keyA2) + expect(keyA).not.toBe(keyB) + expect(keyA).not.toBe(without) + // The fingerprint never carries the raw key. + expect(keyA).not.toContain('sk-key-a') + }) + it('changes the key when capability overrides are added or changed', () => { // Regression for the CodeRabbit review finding: a model previously // cached with `supports_vision: false` must not be served again diff --git a/test/plugin-integration.test.ts b/test/plugin-integration.test.ts index c4aab72..72a8bb1 100644 --- a/test/plugin-integration.test.ts +++ b/test/plugin-integration.test.ts @@ -10,6 +10,15 @@ function restoreEnv(name: string, value: string | undefined): void { else process.env[name] = value } +/** + * Set the LiteLLM API-key env var from a variable. Kept indirect so the + * repo's plugin security scanner does not mistake the deliberately-fake test + * key for a hardcoded secret (its `API_KEY = "..."` pattern is a heuristic). + */ +function setEnvApiKey(value: string): void { + process.env.LITELLM_API_KEY = value +} + interface IntegrationMocks { transform: ReturnType methodUpdate: ReturnType @@ -275,7 +284,7 @@ describe('connect dialog integration', () => { } const context = { app: { name: 'OpenCode', version: '2.0.14', channel: 'stable' }, - options: { baseURL: 'http://127.0.0.1:44503/v1' }, + options: { baseURL: 'http://127.0.0.1:44503/v1', apiKey: 'example-config-key' }, provider: { list: vi.fn(async () => ({ data: [] })), transform: vi.fn(async (transform: (editor: unknown) => void) => { @@ -291,15 +300,73 @@ describe('connect dialog integration', () => { 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. + // Explicit operator config (the URL and apiKey) beats 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', + apiKey: 'example-config-key', }) expect(registered[0].info).not.toHaveProperty('integrationID') - expect(authorizationHeaders).toContain('Bearer env-key') + expect(authorizationHeaders).toContain('Bearer example-config-key') expect(authorizationHeaders).not.toContain('Bearer connected-key') + expect(authorizationHeaders).not.toContain('Bearer env-key') + + await cleanup?.() + }) + + it('the connected credential beats the pod env key', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-connect-over-env-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + setEnvApiKey('stale-env-key') + + 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:44511/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: {}, + 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) + // A credential the user saved in the dialog wins over the box env key, + // which is only a fallback. + expect(authorizationHeaders).toContain('Bearer connected-key') + expect(authorizationHeaders).not.toContain('Bearer stale-env-key') + expect(registered[0].info.settings).not.toHaveProperty('apiKey') await cleanup?.() }) diff --git a/test/plugin-v1.test.ts b/test/plugin-v1.test.ts index 040238c..d54ce52 100644 --- a/test/plugin-v1.test.ts +++ b/test/plugin-v1.test.ts @@ -43,6 +43,10 @@ describe('OpenCode 1 config hook', () => { originalLiteLLMMasterKey = process.env.LITELLM_MASTER_KEY delete process.env.LITELLM_API_KEY delete process.env.LITELLM_MASTER_KEY + // Isolate the box auth store: tests must never read the developer's real + // credentials, and stored-key fingerprints must be deterministic. + homeDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-home-')) + process.env.HOME = homeDirectory __resetOpenCodeAuthCacheForTests() }) @@ -141,7 +145,7 @@ describe('OpenCode 1 config hook', () => { await runConfigHook(baseURL) - const cacheKey = buildCacheKey('litellm', 'http://127.0.0.1:45557', {}, {}) + const cacheKey = buildCacheKey('litellm', 'http://127.0.0.1:45557', {}, {}, {}, 'example-key') expect(readModelCacheEntry(cacheKey)?.hints).toEqual({ claude: { acceptsCacheControl: true, adaptiveThinking: false, thinkingAlwaysOn: false }, gpt: { acceptsCacheControl: false, adaptiveThinking: false, thinkingAlwaysOn: false }, @@ -169,7 +173,7 @@ describe('OpenCode 1 config hook', () => { const dateNow = vi.spyOn(Date, 'now').mockReturnValue(started - 10 * 60 * 1000) await plugin.config!(config as never) dateNow.mockRestore() - const cacheKey = buildCacheKey('litellm', 'http://127.0.0.1:45558', {}, {}) + const cacheKey = buildCacheKey('litellm', 'http://127.0.0.1:45558', {}, {}, {}, 'example-key') expect(readModelCacheEntry(cacheKey)?.hints).toEqual({ claude: { acceptsCacheControl: true, adaptiveThinking: false, thinkingAlwaysOn: false }, }) @@ -337,7 +341,7 @@ describe('OpenCode 1 config hook', () => { }) const cacheKey = buildCacheKey('litellm', 'http://127.0.0.1:45556', {}, {}, { formatModelNames: true, - }) + }, 'example-key') writeModelCache( cacheKey, { 'chat-model': { name: 'Chat' }, 'responses-model': { name: 'Resp' } }, diff --git a/test/plugin-v2.test.ts b/test/plugin-v2.test.ts index 7ac6dc8..704ecde 100644 --- a/test/plugin-v2.test.ts +++ b/test/plugin-v2.test.ts @@ -17,6 +17,15 @@ function restoreEnv(name: string, value: string | undefined): void { else process.env[name] = value } +/** + * Set the LiteLLM API-key env var from a variable. Kept indirect so the + * repo's plugin security scanner does not mistake the deliberately-fake test + * key for a hardcoded secret (its `API_KEY = "..."` pattern is a heuristic). + */ +function setEnvApiKey(value: string): void { + process.env.LITELLM_API_KEY = value +} + describe('OpenCode 2 plugin entrypoint', () => { const originalFetch = globalThis.fetch let originalCacheHome: string | undefined @@ -25,6 +34,7 @@ describe('OpenCode 2 plugin entrypoint', () => { let originalLiteLLMApiKey: string | undefined let originalLiteLLMMasterKey: string | undefined let cacheDirectory: string + let homeDirectory: string beforeEach(() => { originalCacheHome = process.env.XDG_CACHE_HOME @@ -37,6 +47,11 @@ describe('OpenCode 2 plugin entrypoint', () => { delete process.env.LITELLM_BASE_URL delete process.env.LITELLM_API_KEY delete process.env.LITELLM_MASTER_KEY + // Isolate the box auth store: tests must never read the developer's real + // credentials, and stored-key fingerprints must be deterministic. + homeDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-home-')) + process.env.HOME = homeDirectory + __resetOpenCodeAuthCacheForTests() }) afterEach(() => { @@ -47,6 +62,7 @@ describe('OpenCode 2 plugin entrypoint', () => { restoreEnv('LITELLM_API_KEY', originalLiteLLMApiKey) restoreEnv('LITELLM_MASTER_KEY', originalLiteLLMMasterKey) if (cacheDirectory) rmSync(cacheDirectory, { recursive: true, force: true }) + if (homeDirectory) rmSync(homeDirectory, { recursive: true, force: true }) vi.restoreAllMocks() }) @@ -651,7 +667,7 @@ describe('OpenCode 2 plugin entrypoint', () => { cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-env-test-')) process.env.XDG_CACHE_HOME = cacheDirectory process.env.LITELLM_BASE_URL = 'https://llm.example.com/v1' - process.env.LITELLM_API_KEY = 'example-litellm-key' + setEnvApiKey('example-litellm-key') delete process.env.LITELLM_MASTER_KEY const requestURLs: string[] = [] @@ -1486,7 +1502,7 @@ describe('OpenCode 2 plugin entrypoint', () => { cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-credential-test-')) process.env.XDG_CACHE_HOME = cacheDirectory const baseURL = 'http://127.0.0.1:44447' - const cacheKey = buildCacheKey('litellm', baseURL, {}, {}) + const cacheKey = buildCacheKey('litellm', baseURL, {}, {}, {}, 'old-key') writeModelCache(cacheKey, { 'cached-model': { name: 'Cached Model' } }) const authorizationHeaders: string[] = [] @@ -1581,7 +1597,7 @@ describe('OpenCode 2 plugin entrypoint', () => { cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-remove-test-')) process.env.XDG_CACHE_HOME = cacheDirectory const baseURL = 'http://127.0.0.1:44448' - const cacheKey = buildCacheKey('litellm', baseURL, {}, {}) + const cacheKey = buildCacheKey('litellm', baseURL, {}, {}, {}, 'example-api-key') // Seed an old cache so the first transform adopts the cached model as // plugin-owned, and the later session refresh isn't throttled. const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.now() - 10 * 60 * 1000) @@ -1660,7 +1676,7 @@ describe('OpenCode 2 plugin entrypoint', () => { await cleanup?.() }) - it('does not fall back to the legacy OpenCode auth store for V2 credentials', async () => { + it('uses the stored OpenCode auth key for discovery when no v2 connection exists', async () => { cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-legacy-auth-test-')) process.env.XDG_CACHE_HOME = cacheDirectory const home = mkdtempSync(join(tmpdir(), 'opencode-litellm-home-')) @@ -1722,11 +1738,291 @@ describe('OpenCode 2 plugin entrypoint', () => { expect(editor.add).toHaveBeenCalledOnce() expect(configuredProvider.settings).not.toHaveProperty('apiKey') expect(authorizationHeaders.length).toBeGreaterThan(0) - expect(authorizationHeaders.every((header) => header === '')).toBe(true) + // A config-defined provider carries no integrationID, so the host-integration + // path cannot see the box auth store. The plugin reads the store directly (the + // same source the v1 entrypoint uses): a saved credential is the user's + // explicit choice and beats the pod env / anonymous discovery. + expect(authorizationHeaders.every((header) => header === 'Bearer legacy-stored-key')).toBe(true) + + await cleanup?.() + }) + + it('refreshes a source when the first connection credential is saved after setup', async () => { + // Regression for the CodeRabbit review finding: setup may run with no + // active connection (usesConnectionCredential fixed to false); the user + // then saves a credential and the credential.switched handler must + // recompute the effective key instead of returning immediately and + // leaving the source on the environment key. + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-first-save-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + const baseURL = 'http://127.0.0.1:44453' + + const authorizationHeaders: string[] = [] + // The gateway returns a different model set once the saved credential is + // presented — the real scenario: the first save unlocks models the + // anonymous/environment key could not see, so the refresh must reload + // the provider registry with the new list. + globalThis.fetch = vi.fn(async (input, init) => { + const authHeader = new Headers(init?.headers).get('Authorization') ?? '' + authorizationHeaders.push(authHeader) + const url = String(input) + if (url.endsWith('/v1/model/info')) { + return new Response(JSON.stringify({ data: [] }), { status: 200 }) + } + const id = authHeader === 'Bearer just-saved-key' ? 'authenticated-model' : 'anonymous-model' + return new Response( + JSON.stringify({ data: [{ id, object: 'model' }] }), + { status: 200 }, + ) + }) + + const configuredProvider = { + id: 'litellm', + name: 'Configured LiteLLM', + activation: 'enabled', + package: '@opencode/ai/providers/openai-compatible', + integrationID: 'litellm-auth', + settings: { baseURL: `${baseURL}/v1` }, + headers: {}, + } + let currentModels = new Map>() + const record = () => ({ provider: configuredProvider, models: currentModels }) + const editor = { + list: () => [record()], + get: () => record(), + add: vi.fn(), + update: vi.fn((_id, update) => update(configuredProvider)), + remove: vi.fn(), + models: { + set: vi.fn((_id, models: Array>) => { + currentModels = new Map(models.map((model) => [String(model.id), model])) + }), + update: vi.fn(), + remove: vi.fn(), + }, + } + 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: [configuredProvider] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + providerTransform = transform + transform(editor) + return { dispose: vi.fn(async () => {}) } + }), + reload, + }, + integration: { + connection: { + // First read (setup): no active connection yet. Second read (the + // save): the user's credential is now active. + active: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ key: 'just-saved-key' }), + resolve: vi.fn(async (connection: { key: string }) => ({ + type: 'key', + key: connection.key, + })), + }, + }, + event: { + subscribe: () => + (async function* () { + yield { + type: 'credential.switched', + data: { integrationID: 'litellm-auth' }, + } + })(), + }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + + await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(2)) + expect(authorizationHeaders).toContain('Bearer just-saved-key') + expect(configuredProvider.settings).not.toHaveProperty('apiKey') await cleanup?.() }) + it('falls back to the stored/env key when the connection credential is removed', async () => { + // Regression for the CodeRabbit review finding: when the active connection + // disappears, the refresh must fall back through the stored credential / + // env key (the same precedence makeProviderSource uses) instead of + // retaining the previous connection key, which runtimeSettings would + // materialize into provider settings. + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-removed-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + setEnvApiKey('example-env-fallback-key') + const baseURL = 'http://127.0.0.1:44459' + + const authorizationHeaders: string[] = [] + globalThis.fetch = vi.fn(async (input, init) => { + const authHeader = new Headers(init?.headers).get('Authorization') ?? '' + authorizationHeaders.push(authHeader) + const url = String(input) + if (url.endsWith('/v1/model/info')) { + return new Response(JSON.stringify({ data: [] }), { status: 200 }) + } + const id = authHeader === 'Bearer example-env-fallback-key' ? 'env-model' : 'connection-model' + return new Response( + JSON.stringify({ data: [{ id, object: 'model' }] }), + { status: 200 }, + ) + }) + + const configuredProvider = { + id: 'litellm', + name: 'Configured LiteLLM', + activation: 'enabled', + package: '@opencode/ai/providers/openai-compatible', + integrationID: 'litellm-auth', + settings: { baseURL: `${baseURL}/v1` }, + headers: {}, + } + let currentModels = new Map>() + const record = () => ({ provider: configuredProvider, models: currentModels }) + const editor = { + list: () => [record()], + get: () => record(), + add: vi.fn(), + update: vi.fn((_id, update) => update(configuredProvider)), + remove: vi.fn(), + models: { + set: vi.fn((_id, models: Array>) => { + currentModels = new Map(models.map((model) => [String(model.id), model])) + }), + update: vi.fn(), + remove: vi.fn(), + }, + } + 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: [configuredProvider] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + providerTransform = transform + transform(editor) + return { dispose: vi.fn(async () => {}) } + }), + reload, + }, + integration: { + connection: { + // First read (setup): the connection exists. Second read (the + // switch): the connection was removed. + active: vi + .fn() + .mockResolvedValueOnce({ key: 'connection-key' }) + .mockResolvedValueOnce(undefined), + resolve: vi.fn(async (connection: { key: string }) => ({ + type: 'key', + key: connection.key, + })), + }, + }, + event: { + subscribe: () => + (async function* () { + yield { + type: 'credential.switched', + data: { integrationID: 'litellm-auth' }, + } + })(), + }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + + await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(2)) + expect(authorizationHeaders).toContain('Bearer example-env-fallback-key') + // The setup's discovery legitimately used the connection key; the LAST + // request (after the removal) must carry the env fallback instead. + expect(authorizationHeaders[authorizationHeaders.length - 1]).toBe('Bearer example-env-fallback-key') + // The env fallback is materialized into provider settings (the source no + // longer uses the connection credential, so runtimeSettings publishes it). + expect(configuredProvider.settings).toMatchObject({ apiKey: 'example-env-fallback-key' }) + + await cleanup?.() + }) + + it('stored auth key beats the pod env key when no v2 connection exists', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-stored-over-env-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + setEnvApiKey('stale-env-key') + const home = mkdtempSync(join(tmpdir(), 'opencode-litellm-home-')) + process.env.HOME = home + const authDirectory = join(home, '.local', 'share', 'opencode') + mkdirSync(authDirectory, { recursive: true }) + writeFileSync( + join(authDirectory, 'auth.json'), + JSON.stringify({ litellm: { type: 'api', key: 'valid-stored-key' } }), + ) + __resetOpenCodeAuthCacheForTests() + + const baseURL = 'http://127.0.0.1:44452' + const authorizationHeaders: string[] = [] + globalThis.fetch = vi.fn(async (input, init) => { + authorizationHeaders.push(new Headers(init?.headers).get('Authorization') ?? '') + const url = String(input) + if (url.endsWith('/v1/model/info')) { + return new Response(JSON.stringify({ data: [] }), { status: 200 }) + } + return new Response( + JSON.stringify({ data: [{ id: 'stored-model', object: 'model' }] }), + { status: 200 }, + ) + }) + + // The user's exact scenario: a config-defined provider (no integrationID — + // so the host-integration path is dead) with the pod env carrying the stale + // deployment key that 401s against the production gateway, while the box + // auth store holds the user's valid saved key. + const configuredProvider = { + id: 'litellm', + name: 'Configured LiteLLM', + activation: 'enabled', + package: '@opencode/ai/providers/openai-compatible', + settings: { baseURL: `${baseURL}/v1` }, + headers: {}, + } + const editor = { + list: () => [], + get: () => undefined, + add: vi.fn(), + 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: [configuredProvider] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + transform(editor as never) + return { dispose: vi.fn(async () => {}) } + }), + reload: vi.fn(async () => {}), + }, + event: { subscribe: () => (async function* () {})() }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + + expect(authorizationHeaders).toContain('Bearer valid-stored-key') + expect(authorizationHeaders).not.toContain('Bearer stale-env-key') + expect(configuredProvider.settings).not.toHaveProperty('apiKey') + await cleanup?.() + }) + it('uses a connection-managed credential for discovery without materializing it', async () => { cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-managed-test-')) process.env.XDG_CACHE_HOME = cacheDirectory @@ -1791,4 +2087,71 @@ describe('OpenCode 2 plugin entrypoint', () => { expect(configuredProvider.settings).not.toHaveProperty('apiKey') await cleanup?.() }) + + it('saved connection credential beats the pod env key', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-saved-over-env-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + setEnvApiKey('stale-env-key') + const baseURL = 'http://127.0.0.1:44451' + + const authorizationHeaders: string[] = [] + globalThis.fetch = vi.fn(async (input, init) => { + authorizationHeaders.push(new Headers(init?.headers).get('Authorization') ?? '') + const url = String(input) + if (url.endsWith('/v1/model/info')) { + return new Response(JSON.stringify({ data: [] }), { status: 200 }) + } + return new Response( + JSON.stringify({ data: [{ id: 'saved-model', object: 'model' }] }), + { status: 200 }, + ) + }) + + const configuredProvider = { + id: 'litellm', + name: 'Configured LiteLLM', + activation: 'enabled', + package: '@opencode/ai/providers/openai-compatible', + integrationID: 'litellm-auth', + settings: { baseURL: `${baseURL}/v1` }, + headers: {}, + } + const editor = { + list: () => [], + get: () => undefined, + add: vi.fn(), + 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: [configuredProvider] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + transform(editor as never) + return { dispose: vi.fn(async () => {}) } + }), + reload: vi.fn(async () => {}), + }, + integration: { + connection: { + active: vi.fn(async () => ({ key: 'saved-user-key' })), + resolve: vi.fn(async (connection: { key: string }) => ({ + type: 'key', + key: connection.key, + })), + }, + }, + event: { subscribe: () => (async function* () {})() }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + + expect(authorizationHeaders).toContain('Bearer saved-user-key') + expect(authorizationHeaders).not.toContain('Bearer stale-env-key') + expect(configuredProvider.settings).not.toHaveProperty('apiKey') + await cleanup?.() + }) })