From f5e5504a3a2fc4b44dce923dfb8a9b579e0b8e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Fri, 30 Oct 2026 12:37:55 +0100 Subject: [PATCH 1/5] fix(v2): read connect-dialog answers from credential metadata OpenCode 2.1 stores a key credential's form answers in `metadata` instead of `configuration`, so the Base URL and Provider ID typed into the connect dialog were ignored on current hosts. Read `metadata` and keep `configuration` as the fallback for OpenCode 2.0. --- CHANGELOG.md | 1 + src/plugin/integration.ts | 7 +++--- test/plugin-integration.test.ts | 38 +++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e89cdaf..13a5509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,7 @@ ### Bug Fixes +* **v2:** read the connect dialog's Base URL and Provider ID from the credential's `metadata`, where OpenCode 2.1+ stores them * 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 diff --git a/src/plugin/integration.ts b/src/plugin/integration.ts index f5090b9..d0c751a 100644 --- a/src/plugin/integration.ts +++ b/src/plugin/integration.ts @@ -110,11 +110,12 @@ export async function readConnectedConfig( if (!active) return undefined const credential = await connection.resolve(active) if (!credential || credential.type !== 'key') return undefined - const configuration = credential.configuration as Record | undefined + // OpenCode 2.1 moved the form answers from `configuration` to `metadata`. + const answers = (credential.metadata ?? credential.configuration) as Record | undefined return { apiKey: credential.key, - baseURL: readAnswer(configuration, 'baseURL'), - providerID: readAnswer(configuration, 'providerID'), + baseURL: readAnswer(answers, 'baseURL'), + providerID: readAnswer(answers, 'providerID'), } } catch { return undefined diff --git a/test/plugin-integration.test.ts b/test/plugin-integration.test.ts index 72a8bb1..f7c122f 100644 --- a/test/plugin-integration.test.ts +++ b/test/plugin-integration.test.ts @@ -252,6 +252,44 @@ describe('connect dialog integration', () => { await cleanup?.() }) + it('reads the form answers from credential metadata (OpenCode 2.1+)', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-metadata-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + globalThis.fetch = discoveryFetch([{ id: 'metadata-model' }]) + + const integration = makeIntegration() + integration.active.mockResolvedValue({ type: 'credential', id: 'cred-1' }) + integration.resolve.mockResolvedValue({ + type: 'key', + key: 'connected-key', + metadata: { baseURL: 'http://127.0.0.1:44509/v1', providerID: 'gateway' }, + }) + + const registered: RegisteredEntry[] = [] + const context = { + app: { name: 'OpenCode', version: '2.1.0', channel: 'stable' }, + options: {}, + provider: { + list: vi.fn(async () => ({ data: [] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + transform(makeStatefulEditor(registered, []) 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: 'gateway', integrationID: 'litellm' }) + expect(registered[0].info.settings).toMatchObject({ baseURL: 'http://127.0.0.1:44509/v1' }) + + 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 From 2011fffe390ee4c87ec7a61fd5fcc9d3736a7d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Fri, 30 Oct 2026 12:38:15 +0100 Subject: [PATCH 2/5] feat(v2): sign in to LiteLLM with SSO from the connect dialog Port the browser sign-ins of pi-provider-litellm to OpenCode 2's integration API. The LiteLLM integration gains two OAuth methods next to the API key: - LiteLLM SSO picks the newest flow the proxy offers: the CLI-auth contract (dynamic client registration, authorization code + PKCE on a 127.0.0.1 callback, rotating refresh), then CLI SSO with a confirmation code and an optional team, then a token pasted from the LiteLLM UI, exchanged for a virtual key when the proxy can mint one. - Identity provider (OIDC) signs in with the IdP directly as a public client and presents its id_token, checked for issuer, audience, authorized party, subject, expiry and nonce. OpenCode stores the session as a credential, calls the plugin's refresh when it nears expiry, and injects the access token into requests. The plugin uses the token for discovery, keys the model cache by the credential id because the token rotates, and resolves the connection before background discovery so it never runs with an expired token. A sign-in uses the configured provider's URL and gateway headers when Base URL is left empty, refuses a different URL because configuration wins in discovery, and needs https except on a loopback host. --- CHANGELOG.md | 1 + README.md | 18 +- src/plugin/integration.ts | 234 +++++++- src/plugin/v2.ts | 54 +- src/utils/sso.ts | 938 ++++++++++++++++++++++++++++++++ test/plugin-integration.test.ts | 179 +++++- test/plugin-v2.test.ts | 6 +- test/sso.test.ts | 460 ++++++++++++++++ 8 files changed, 1849 insertions(+), 41 deletions(-) create mode 100644 src/utils/sso.ts create mode 100644 test/sso.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 13a5509..32c7dce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,7 @@ ### Features +* **v2:** sign in from the connect-provider dialog with LiteLLM SSO (the CLI-auth PKCE contract, CLI SSO, or a token pasted from the LiteLLM UI) or directly with an OpenID Connect identity provider; OpenCode stores, refreshes, and injects the token, and the model cache of a sign-in is keyed by its credential id * 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) diff --git a/README.md b/README.md index b728811..03f5669 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ also shows how to configure a provider directly in `opencode.json`. | ๐Ÿงฉ **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 `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. | +| ๐Ÿชช **Single sign-on (OpenCode 2)** | Signs in from the connect-provider dialog through LiteLLM SSO (CLI-auth PKCE, CLI SSO, or a token pasted from the LiteLLM UI) or directly with an OpenID Connect identity provider. OpenCode stores, refreshes, and injects the token. | | ๐ŸŒ **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. | @@ -327,6 +328,7 @@ If your LiteLLM proxy requires a master key, expose it via any of these approach | 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 | +| Single sign-on (OpenCode 2) | `/connect` โ†’ **LiteLLM** โ†’ **LiteLLM SSO** or **Identity provider (OIDC)**; see [Single sign-on](#single-sign-on-opencode-2) | | 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-...` | @@ -344,7 +346,21 @@ With no configuration at all, `/connect` โ†’ **LiteLLM** is enough: 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. +#### Single sign-on (OpenCode 2) + +The **LiteLLM** entry in `/connect` also offers two browser sign-ins. OpenCode stores the session as a credential of the integration, refreshes it, and injects it into requests, as it does with a connected key. The sign-in returns to a temporary `http://127.0.0.1` callback, so the browser must run on the same machine as OpenCode, and the proxy needs an `https` base URL (plain `http` only on a loopback host). + +**LiteLLM SSO** signs in through the proxy. Leave **Base URL** empty to use the configured provider's URL or `LITELLM_BASE_URL`. A URL that differs from the configured one is refused, because explicit configuration wins in discovery and the token would go to a proxy that did not issue it. The plugin uses the newest flow the proxy offers: + +1. **CLI-auth contract** (`/.well-known/litellm-cli-auth`): authorization code + PKCE (`S256`) with a dynamically registered client. Advertised endpoints must stay on the proxy's origin, and these requests do not follow redirects. Access and refresh tokens rotate automatically; a temporary token-endpoint failure (network, 429, 5xx) keeps the current token until it actually expires. +2. **CLI SSO** (`/sso/cli/start`, when discovery returns 404): confirm the code OpenCode shows in the browser. When your account belongs to several teams, LiteLLM asks for one; the sign-in then fails with the list of teams, so connect again with the team's ID or alias as **Team**. The token lasts as long as the proxy reports (24 hours on proxies that report nothing), then you connect again. +3. **UI token** (when `/sso/cli/start` returns 404 or 405): sign in on the page OpenCode opens and paste the token from the LiteLLM UI as the code. The plugin exchanges it for a virtual key through `/key/generate` and uses the token itself when the proxy cannot mint one (`/key/generate` needs a database). + +**Identity provider (OIDC)** signs in with your identity provider directly, for proxies running LiteLLM JWT auth (`enable_jwt_auth`) that trust its issuer and accept the client ID as an audience. Enter the `https` issuer URL, a public client ID (no secret) registered for authorization code + PKCE with the redirect `http://127.0.0.1:/callback`, the scopes (default `openid`; some identity providers only issue a refresh token with `offline_access`), and, for identity providers that only accept exact redirect URIs, the ports to try. The `id_token` is the bearer token: the plugin checks its issuer, audience, authorized party, subject, expiry, and nonce, and the proxy verifies its signature. Signing in sends nothing to the proxy, and identity provider requests never carry custom headers. + +Disconnecting removes the local credential only; it does not revoke the session on the proxy or the identity provider. OpenCode 1 keeps API-key authentication. + +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 sign-in is keyed by its credential id instead, because its access token rotates on every refresh. 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/src/plugin/integration.ts b/src/plugin/integration.ts index d0c751a..92542e7 100644 --- a/src/plugin/integration.ts +++ b/src/plugin/integration.ts @@ -1,4 +1,15 @@ +import { Integration, type Credential } from '@opencode/plugin' +import type { IntegrationOAuthAuthorization } from '@opencode/plugin/promise/integration' import type { Context } from '@opencode/plugin/promise/plugin' +import { + authorizeLiteLLM, + authorizeOidc, + parseOidcConfig, + refreshSsoToken, + ssoRoot, + type SsoAuthorization, + type SsoToken, +} from '../utils/sso' // OpenCode 2 exposes no log API through the plugin context (see // initV2Logging); the host captures console output into its log files, so a @@ -19,6 +30,11 @@ function logInfo(message: string): void { */ export const LITELLM_INTEGRATION_ID = 'litellm' +const SSO_METHOD_ID = 'sso' +const OIDC_METHOD_ID = 'oidc' +// Only OAuth credentials from these methods carry a LiteLLM bearer token. +const OAUTH_METHOD_IDS: ReadonlySet = new Set([SSO_METHOD_ID, OIDC_METHOD_ID]) + /** A credential saved through the connect dialog, with its form answers. */ export interface ConnectedLiteLLM { /** Key resolved from the active connection's credential. */ @@ -27,8 +43,18 @@ export interface ConnectedLiteLLM { baseURL: string | undefined /** Provider id typed into the dialog's form, when present. */ providerID: string | undefined + /** Id of an OAuth credential, which stays put while its access token rotates. */ + credentialID?: string +} + +/** The configured proxy URL and gateway headers a sign-in for a provider id uses. */ +export interface SignInTarget { + baseURL: string | undefined + headers: Record } +type SignInTargetResolver = (providerID: string) => Promise + function readAnswer( configuration: Record | undefined, key: string, @@ -37,11 +63,89 @@ function readAnswer( return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined } +/** The bearer token a connection provides, or undefined when it is not a LiteLLM credential. */ +export function connectionApiKey(credential: Credential.Value | undefined): string | undefined { + if (credential?.type === 'key') return credential.key + if (credential?.type === 'oauth' && OAUTH_METHOD_IDS.has(credential.methodID)) + return credential.access + return undefined +} + +const providerIDField = { + 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, +} as const + +const signInBaseURLField = { + key: 'baseURL', + type: 'string', + format: 'uri', + title: 'Base URL', + description: + "The LiteLLM proxy to sign in to. Leave empty to use the configured provider's URL or LITELLM_BASE_URL.", + placeholder: 'https://llm.example.com', +} as const + +/** + * Proxy URL, provider id, and gateway headers for a sign-in. Explicit + * configuration wins in discovery, so a typed URL that differs from it is + * refused: the token would be sent to a proxy that did not issue it. + */ +async function signInTarget( + answer: Record, + resolveTarget: SignInTargetResolver, +): Promise<{ providerID: string; baseURL: string | undefined; headers: Record }> { + const providerID = readAnswer(answer, 'providerID') ?? LITELLM_INTEGRATION_ID + const target = await resolveTarget(providerID) + const typed = readAnswer(answer, 'baseURL') + if (typed && target.baseURL && ssoRoot(typed) !== ssoRoot(target.baseURL)) { + throw new Error( + `Provider "${providerID}" is configured for ${target.baseURL}; leave Base URL empty, or connect under another Provider ID`, + ) + } + const baseURL = typed ?? target.baseURL ?? (process.env.LITELLM_BASE_URL?.trim() || undefined) + return { providerID, baseURL: baseURL && ssoRoot(baseURL), headers: target.headers } +} + +/** Wraps a sign-in for the host, storing the session as an OAuth credential of `methodID`. */ +function toAuthorization( + authorization: SsoAuthorization, + methodID: string, + metadata: Record, +): IntegrationOAuthAuthorization { + const toCredential = (token: SsoToken): Credential.OAuth => ({ + type: 'oauth', + methodID: Integration.MethodID.make(methodID), + access: token.access, + refresh: token.refresh, + expires: token.expires, + metadata: { ...token.metadata, ...metadata }, + }) + const { url, instructions, expiresAt } = authorization + if (authorization.mode === 'code') { + return { + url, + instructions, + expiresAt, + mode: 'code', + callback: (code) => authorization.callback(code).then(toCredential), + } + } + const callback = authorization.callback.then(toCredential) + // The host awaits it once the attempt is recorded; an earlier rejection must not go unhandled. + callback.catch(() => {}) + return { url, instructions, expiresAt, mode: 'auto', callback } +} + /** * 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. + * web UI instead of hand-edited config: an API key with a small form for the + * proxy base URL and an optional provider id, or a browser sign-in through + * LiteLLM SSO or directly with an OpenID Connect identity provider. * * Returns the transform registration, or undefined when the host has no * integration API (OpenCode 1.18.x's embedded v2 host) or registration @@ -49,8 +153,18 @@ function readAnswer( */ export async function registerLiteLLMIntegration( context: Context, + resolveTarget: SignInTargetResolver, ): Promise<{ dispose(): Promise } | undefined> { if (!context.integration?.transform) return undefined + // Shared by both sign-in methods: the stored flow decides how a session renews. + const refresh = async (credential: Credential.OAuth): Promise => { + const metadata = credential.metadata ?? {} + const { headers } = await resolveTarget( + readAnswer(metadata, 'providerID') ?? LITELLM_INTEGRATION_ID, + ) + const token = await refreshSsoToken({ ...credential, metadata }, headers) + return { ...credential, access: token.access, refresh: token.refresh, expires: token.expires } + } try { return await context.integration.transform((editor) => { editor.update(LITELLM_INTEGRATION_ID, (integration) => { @@ -71,16 +185,98 @@ export async function registerLiteLLMIntegration( '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', }, + providerIDField, + ], + }, + }) + editor.method.update({ + integrationID: LITELLM_INTEGRATION_ID, + method: { + id: SSO_METHOD_ID, + type: 'oauth', + label: 'LiteLLM SSO', + form: [ + signInBaseURLField, + providerIDField, { - key: 'providerID', + key: 'team', type: 'string', - title: 'Provider ID', + title: 'Team', 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, + 'LiteLLM team ID or alias, for accounts in several teams. Leave empty unless sign-in asks for one.', }, ], }, + authorize: async (answer) => { + const { providerID, baseURL, headers } = await signInTarget(answer, resolveTarget) + if (!baseURL) + throw new Error('Enter the LiteLLM proxy URL as Base URL, or set LITELLM_BASE_URL') + const authorization = await authorizeLiteLLM(baseURL, headers, readAnswer(answer, 'team')) + return toAuthorization(authorization, SSO_METHOD_ID, { providerID }) + }, + refresh, + }) + editor.method.update({ + integrationID: LITELLM_INTEGRATION_ID, + method: { + id: OIDC_METHOD_ID, + type: 'oauth', + label: 'Identity provider (OIDC)', + form: [ + signInBaseURLField, + providerIDField, + { + key: 'issuer', + type: 'string', + format: 'uri', + title: 'Issuer', + description: + "Your identity provider's issuer URL; the proxy's JWT auth must trust it.", + placeholder: 'https://idp.example.com', + required: true, + }, + { + key: 'clientId', + type: 'string', + title: 'Client ID', + description: + 'A public client (no secret) for authorization code + PKCE with the redirect http://127.0.0.1:/callback. The proxy must accept it as an audience.', + required: true, + }, + { + key: 'scope', + type: 'string', + title: 'Scopes', + description: + 'Space-separated, including openid. Some identity providers only issue refresh tokens with offline_access.', + default: 'openid', + }, + { + key: 'redirectPorts', + type: 'string', + title: 'Redirect ports', + description: + 'Loopback ports to try in order, for identity providers that only accept exact registered redirect URIs. Leave empty for any free port.', + placeholder: '8400, 8401', + }, + ], + }, + authorize: async (answer) => { + // Nothing is sent to the proxy, so its URL is only recorded for discovery. + const { providerID, baseURL } = await signInTarget(answer, resolveTarget) + const config = parseOidcConfig({ + issuer: readAnswer(answer, 'issuer'), + clientId: readAnswer(answer, 'clientId'), + scope: readAnswer(answer, 'scope'), + redirectPorts: readAnswer(answer, 'redirectPorts'), + }) + const authorization = await authorizeOidc(config) + return toAuthorization(authorization, OIDC_METHOD_ID, { + providerID, + ...(baseURL ? { baseURL } : {}), + }) + }, + refresh, }) }) } catch (error) { @@ -92,12 +288,13 @@ export async function registerLiteLLMIntegration( /** * Resolves the active credential for an integration into the values the - * plugin needs: the API key plus the dialog's form answers. + * plugin needs: the API key (or a sign-in's access token) 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. + * is not a LiteLLM one, 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, @@ -108,14 +305,21 @@ export async function readConnectedConfig( try { const active = await connection.active(integrationID) if (!active) return undefined + // Resolving refreshes a sign-in whose access token is about to expire. const credential = await connection.resolve(active) - if (!credential || credential.type !== 'key') return undefined - // OpenCode 2.1 moved the form answers from `configuration` to `metadata`. - const answers = (credential.metadata ?? credential.configuration) as Record | undefined + const apiKey = connectionApiKey(credential) + if (!credential || apiKey === undefined) return undefined + // OpenCode 2.1 moved the form answers from `configuration` to `metadata`, + // where sign-ins store theirs too. + const answers = (credential.metadata ?? + (credential.type === 'key' ? credential.configuration : undefined)) as + Record | undefined return { - apiKey: credential.key, + apiKey, baseURL: readAnswer(answers, 'baseURL'), providerID: readAnswer(answers, 'providerID'), + credentialID: + credential.type === 'oauth' && active.type === 'credential' ? active.id : undefined, } } catch { return undefined diff --git a/src/plugin/v2.ts b/src/plugin/v2.ts index 4452be3..abce763 100644 --- a/src/plugin/v2.ts +++ b/src/plugin/v2.ts @@ -14,9 +14,11 @@ import { } from './index' import { LITELLM_INTEGRATION_ID, + connectionApiKey, readConnectedConfig, registerLiteLLMIntegration, type ConnectedLiteLLM, + type SignInTarget, } from './integration' import { buildCacheKey, @@ -317,6 +319,33 @@ function runtimeSettings(source: ProviderSource): Record { return settings } +function providerHeaders( + pluginOptions: Record, + settings: Record, + provider: ProviderInfo | undefined, +): Record { + return { + ...readHeaders(pluginOptions.customHeaders), + ...readCustomHeaders(settings), + ...readHeaders(provider?.headers), + } +} + +/** The proxy URL and headers a sign-in uses: the configured provider's, else the plugin options'. */ +async function signInTarget( + context: Context, + pluginOptions: Record, + providerID: string, +): Promise { + const providers = await listMatchingProviders(context) + const provider = providers.find((candidate) => candidate.id === providerID) + const settings = { ...pluginOptions, ...asRecord(provider?.settings) } + return { + baseURL: readString(settings.baseURL), + headers: providerHeaders(pluginOptions, settings, provider), + } +} + async function makeProviderSource( context: Context, provider: ProviderInfo | undefined, @@ -324,11 +353,7 @@ async function makeProviderSource( ): Promise { const providerSettings = asRecord(provider?.settings) const settings = { ...pluginOptions, ...providerSettings } - const customHeaders = { - ...readHeaders(pluginOptions.customHeaders), - ...readCustomHeaders(settings), - ...readHeaders(provider?.headers), - } + const customHeaders = providerHeaders(pluginOptions, settings, provider) // 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 @@ -382,10 +407,11 @@ 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. + // serving another credential's cached model list. A sign-in is keyed by its + // credential id, because its access token rotates on every refresh. const cacheKey = buildCacheKey(id, baseURL, filters, capabilities, { formatModelNames, - }, apiKey) + }, (usesConnectionCredential ? connected?.credentialID : undefined) ?? apiKey) const cacheEntry = readModelCacheEntry(cacheKey) let entries = cacheEntry?.models ?? null let responsesIds = cacheEntry?.responsesIds ?? [] @@ -477,10 +503,16 @@ async function refreshProviderSource( return } - const apiKey = source.apiKey let refresh!: Promise refresh = (async () => { try { + // A sign-in's access token rotates; resolving the connection refreshes an + // expiring one, so discovery does not run with a token that has expired. + if (source.usesConnectionCredential && source.integrationID) { + const connected = await readConnectedConfig(context, source.integrationID) + if (connected?.apiKey) source.apiKey = connected.apiKey + } + const apiKey = source.apiKey const discovered = await withTimeout( discoverModels( source.baseURL, @@ -552,7 +584,7 @@ async function refreshConnectionCredential( const credential = connection ? await context.integration.connection.resolve(connection) : undefined - const connectionKey = credential?.type === 'key' ? credential.key : undefined + const connectionKey = connectionApiKey(credential) const settingsApiKey = readString(source.settings.apiKey) let nextKey: string | undefined let nextUsesConnection: boolean @@ -731,7 +763,9 @@ const definition = Plugin.define({ // 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) + const integrationRegistration = await registerLiteLLMIntegration(context, (providerID) => + signInTarget(context, pluginOptions, providerID), + ) // First pass. On OpenCode 2 the host has not registered user-configured // providers yet, so this typically finds only built-ins; the event loop diff --git a/src/utils/sso.ts b/src/utils/sso.ts new file mode 100644 index 0000000..8db4353 --- /dev/null +++ b/src/utils/sso.ts @@ -0,0 +1,938 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' +import { createServer } from 'node:http' +import { isIP } from 'node:net' +import { setTimeout as delay } from 'node:timers/promises' +import { normalizeBaseURL } from './litellm-api' + +// Cap for each login and refresh request to the proxy or the identity provider. +const REQUEST_TIMEOUT_MS = 10_000 +// How long a browser sign-in may take; the connect attempt expires with it. +const CALLBACK_TIMEOUT_MS = 10 * 60 * 1000 +const CLI_SSO_POLL_INTERVAL_MS = 2_000 +const CLI_SSO_EXPIRES_IN_SECONDS = 600 +// LiteLLM's default CLI token lifetime, for proxies whose poll response omits `expires_in`. +const CLI_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000 +const CLI_AUTH_DISCOVERY_PATH = '/.well-known/litellm-cli-auth' +const OIDC_DISCOVERY_PATH = '/.well-known/openid-configuration' +/** Expiry of a token that carries none, such as a virtual key without `expires`. */ +const NEVER_EXPIRES = Number.MAX_SAFE_INTEGER +// Persisted in credential metadata; never rename. +const PKCE_FLOW = 'litellm_cli_pkce' +const OIDC_FLOW = 'oidc_pkce' + +/** A signed-in session; OpenCode stores it as the OAuth credential, metadata included. */ +export interface SsoToken { + access: string + refresh: string + /** Exact expiry in epoch milliseconds; OpenCode refreshes five minutes before it. */ + expires: number + metadata: Record +} + +/** A started sign-in, in the shape of OpenCode's OAuth authorization. */ +export type SsoAuthorization = { url: string; instructions: string; expiresAt?: number } & ( + | { mode: 'auto'; callback: Promise } + | { mode: 'code'; callback: (code: string) => Promise } +) + +export interface OidcConfig { + issuer: string + clientId: string + scope: string + redirectPorts: number[] +} + +type Json = Record +type ProxyHeaders = Record | undefined +type TokenFailure = { ok: false; transient: boolean; message: string } +type TokenResult = + { ok: true; token: { access: string; refresh: string; expires: number } } | TokenFailure +type CliAuthDiscovery = { + authorizationEndpoint: string + tokenEndpoint: string + registrationEndpoint: string + resource: string +} +type CliSsoStart = { + loginId: string + pollSecret: string + userCode: string + expiresInSeconds: number +} + +function isRecord(value: unknown): value is Json { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Tokens and client IDs: printable ASCII without spaces. */ +function isAuthToken(value: unknown): value is string { + return typeof value === 'string' && /^[\x21-\x7e]+$/.test(value) +} + +/** The OAuth `error` code when it is a plain code; descriptions and other fields are never echoed. */ +function oauthErrorCode(value: unknown): string | undefined { + return typeof value === 'string' && /^[a-z_]{1,64}$/.test(value) ? value : undefined +} + +function positiveNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined +} + +// A loop, not /\/+$/: that regex backtracks quadratically on a long slash run followed by another character. +function trimTrailingSlashes(value: string): string { + let end = value.length + while (end > 0 && value[end - 1] === '/') end-- + return value.slice(0, end) +} + +function expiresIn(seconds: number): number { + return Math.floor(Date.now() + seconds * 1_000) +} + +function requestSignal(): AbortSignal { + return AbortSignal.timeout(REQUEST_TIMEOUT_MS) +} + +function authHeaders(headers: ProxyHeaders, contentType?: string): Headers { + const result = new Headers(headers) + result.set('Accept', 'application/json') + if (contentType) result.set('Content-Type', contentType) + return result +} + +async function readJson(response: Response, stage: string): Promise { + try { + return await response.json() + } catch { + throw new Error(`${stage} returned invalid JSON`) + } +} + +// The host awaits a callback only once the attempt is recorded; until then a rejection must not crash the process. +function handled(promise: Promise): Promise { + promise.catch(() => {}) + return promise +} + +function constantTimeEqual(value: unknown, expected: string): boolean { + if (typeof value !== 'string') return false + const left = Buffer.from(value) + const right = Buffer.from(expected) + return left.length === right.length && timingSafeEqual(left, right) +} + +function jwtClaims(token: string): Json | undefined { + const [, payload, ...rest] = token.split('.') + if (rest.length !== 1 || !payload || !/^[\w-]+$/.test(payload)) return undefined + try { + const claims: unknown = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) + return isRecord(claims) ? claims : undefined + } catch { + return undefined + } +} + +/** A JWT's `exp` in milliseconds, or `fallback` for an opaque token. */ +function tokenExpiry(token: string, fallback: number): number { + const exp = positiveNumber(jwtClaims(token)?.exp) + return exp === undefined ? fallback : Math.min(Math.floor(exp * 1_000), NEVER_EXPIRES) +} + +/** + * The proxy root a sign-in talks to. SSO tokens are bearer credentials, so plain http is only + * accepted on a loopback host. + */ +export function ssoRoot(baseURL: string): string { + let url: URL + try { + url = new URL(baseURL) + } catch { + throw new Error(`LiteLLM base URL is not a valid URL: ${baseURL}`) + } + const host = url.hostname + const loopback = + host === 'localhost' || host === '[::1]' || (isIP(host) === 4 && host.startsWith('127.')) + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { + throw new Error('LiteLLM sign-in needs an https base URL (http only on a loopback host)') + } + if (url.username || url.password || url.search || url.hash) { + throw new Error('LiteLLM base URL must not carry credentials, a query, or a fragment') + } + return normalizeBaseURL(url.href) +} + +function canonicalIssuer(value: string): string { + const url = new URL(value) + if (url.username || url.password || url.search || url.hash) + throw new Error('LiteLLM CLI auth has invalid issuer') + return `${url.origin}${trimTrailingSlashes(url.pathname) || '/'}` +} + +function sameOriginUrl(value: unknown, issuer: URL, field: string): string { + if (typeof value !== 'string' || !value.trim()) + throw new Error(`LiteLLM CLI auth discovery has invalid ${field}`) + let url: URL + try { + url = new URL(value) + } catch { + throw new Error(`LiteLLM CLI auth discovery has invalid ${field}`) + } + if (url.origin !== issuer.origin) + throw new Error(`LiteLLM CLI auth discovery has cross-origin ${field}`) + if (url.username || url.password || url.hash) + throw new Error(`LiteLLM CLI auth discovery has invalid ${field}`) + return value.trim() +} + +/** An https URL without credentials or fragment, returned verbatim. */ +function httpsUrl(value: unknown, { allowQuery }: { allowQuery: boolean }): string | undefined { + if (typeof value !== 'string' || value.includes('#') || (!allowQuery && value.includes('?'))) + return undefined + try { + const url = new URL(value) + return url.protocol === 'https:' && !url.username && !url.password ? value : undefined + } catch { + return undefined + } +} + +/** POSTs a token form without following redirects; each flow validates its own success fields. */ +async function postTokenForm( + endpoint: string, + form: URLSearchParams, + headers: ProxyHeaders, + label: string, +): Promise<{ ok: true; data: Json } | TokenFailure> { + let response: Response + try { + response = await fetch(endpoint, { + method: 'POST', + headers: authHeaders(headers, 'application/x-www-form-urlencoded'), + body: form, + redirect: 'manual', + signal: requestSignal(), + }) + } catch { + return { ok: false, transient: true, message: `${label} token exchange failed (network error)` } + } + let data: Json | undefined + try { + const parsed: unknown = await response.json() + if (isRecord(parsed)) data = parsed + } catch (error) { + if (response.ok && !(error instanceof SyntaxError)) { + return { + ok: false, + transient: true, + message: `${label} token exchange failed (network error)`, + } + } + } + if (!response.ok) { + const code = oauthErrorCode(data?.error) + return { + ok: false, + transient: response.status === 429 || response.status >= 500, + message: code + ? `${label} token exchange rejected (${code})` + : `${label} token exchange failed (HTTP ${response.status})`, + } + } + return { ok: true, data: data ?? {} } +} + +async function requestPkceToken( + endpoint: string, + form: URLSearchParams, + headers: ProxyHeaders, + existingRefreshToken?: string, +): Promise { + const response = await postTokenForm(endpoint, form, headers, 'LiteLLM') + if (!response.ok) return response + const { data } = response + const lifetime = positiveNumber(data.expires_in) + const expires = lifetime === undefined ? NaN : expiresIn(lifetime) + if ( + !isAuthToken(data.access_token) || + (!isAuthToken(data.refresh_token) && !isAuthToken(existingRefreshToken)) || + typeof data.token_type !== 'string' || + data.token_type.toLowerCase() !== 'bearer' || + !Number.isSafeInteger(expires) + ) { + return { + ok: false, + transient: false, + message: 'LiteLLM token exchange returned an invalid response', + } + } + return { + ok: true, + token: { + access: data.access_token, + // A refresh may not rotate the refresh token; keep the existing one when the server omits it. + refresh: isAuthToken(data.refresh_token) ? data.refresh_token : existingRefreshToken!, + expires, + }, + } +} + +/** + * Exchanges a grant for an id_token and checks its claims (OIDC Core ยง3.1.3.7). The signature is + * deliberately not verified: the proxy verifies it against the IdP's keys before honouring the + * bearer, so these checks only stop OpenCode from storing a token meant for another client or login. + */ +async function requestOidcToken( + endpoint: string, + form: URLSearchParams, + expected: { issuer: string; clientId: string; nonce?: string; subject?: string }, + existingRefreshToken = '', +): Promise { + // IdP requests never carry the proxy's headers: they can hold gateway credentials. + const response = await postTokenForm(endpoint, form, undefined, 'OIDC') + if (!response.ok) return response + const { id_token: idToken, refresh_token: refreshToken } = response.data + const claims = isAuthToken(idToken) ? jwtClaims(idToken) : undefined + if (!isAuthToken(idToken) || !claims) { + return { ok: false, transient: false, message: 'OIDC token response has no valid id_token' } + } + const invalid = (claim: string): TokenFailure => ({ + ok: false, + transient: false, + message: `OIDC id_token has invalid ${claim}`, + }) + if (claims.iss !== expected.issuer) return invalid('iss') + if (!(Array.isArray(claims.aud) ? claims.aud : [claims.aud]).includes(expected.clientId)) + return invalid('aud') + // azp is optional, even with several audiences, but when present it must name this client. + if (claims.azp !== undefined && claims.azp !== expected.clientId) return invalid('azp') + if (typeof claims.sub !== 'string' || !claims.sub) return invalid('sub') + if (expected.subject !== undefined && claims.sub !== expected.subject) return invalid('sub') + const expires = typeof claims.exp === 'number' ? Math.floor(claims.exp * 1_000) : NaN + if (!Number.isSafeInteger(expires) || expires <= Date.now()) return invalid('exp') + if (expected.nonce !== undefined && !constantTimeEqual(claims.nonce, expected.nonce)) + return invalid('nonce') + return { + ok: true, + token: { + access: idToken, + // The refresh token is optional, and an IdP that does not rotate it keeps the existing one valid. + refresh: isAuthToken(refreshToken) ? refreshToken : existingRefreshToken, + expires, + }, + subject: claims.sub, + } +} + +interface Loopback { + redirectUri: string + state: string + expiresAt: number + /** The authorization code of the first callback carrying `state`. */ + code: Promise + close(): void +} + +/** + * Serves an RFC 8252 loopback redirect on 127.0.0.1 until `code` settles: on the first valid + * callback, an OAuth error, the sign-in timeout, or `close()`. + */ +async function listenForCallback(label: string, ports: readonly number[]): Promise { + const state = randomBytes(32).toString('base64url') + const expiresAt = Date.now() + CALLBACK_TIMEOUT_MS + const server = createServer() + let settle!: (result: { code?: string; error?: Error }) => void + const code = handled( + new Promise((resolve, reject) => { + const timeout = setTimeout( + () => settle({ error: new Error(`${label} sign-in timed out`) }), + CALLBACK_TIMEOUT_MS, + ) + settle = (result) => { + settle = () => {} + clearTimeout(timeout) + server.close() + if (result.error) reject(result.error) + else resolve(result.code!) + } + }), + ) + server.on('request', (request, response) => { + response.setHeader('Cache-Control', 'no-store') + let url: URL + try { + url = new URL(request.url ?? '/', 'http://127.0.0.1') + } catch { + response.writeHead(400).end('Invalid callback URL') + return + } + if (url.pathname !== '/callback') { + response.writeHead(404).end() + return + } + if (request.method !== 'GET') { + response.writeHead(405, { Allow: 'GET' }).end() + return + } + if (!constantTimeEqual(url.searchParams.get('state'), state)) { + response.writeHead(400).end('Invalid OAuth state') + return + } + const error = url.searchParams.get('error') + if (error) { + response.writeHead(400).end('OAuth login failed') + const errorCode = oauthErrorCode(error) + settle({ + error: new Error(`${label} sign-in was denied${errorCode ? ` (${errorCode})` : ''}`), + }) + return + } + const authorizationCode = url.searchParams.get('code') + if (!authorizationCode) { + response.writeHead(400).end('Missing OAuth code') + return + } + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + response.end( + 'LiteLLM login complete

You can close this window.

', + ) + settle({ code: authorizationCode }) + }) + // Try each pre-registered port in order; without any, the OS assigns one (RFC 8252 ยง7.3). + const candidates = ports.length > 0 ? ports : [0] + for (const [index, port] of candidates.entries()) { + try { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(port, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + break + } catch (error) { + if (index === candidates.length - 1) { + settle({ error: error as Error }) + throw error + } + } + } + const address = server.address() + if (!address || typeof address === 'string') { + settle({ error: new Error(`${label} callback failed to start`) }) + throw new Error(`${label} callback failed to start`) + } + return { + redirectUri: `http://127.0.0.1:${address.port}/callback`, + state, + expiresAt, + code, + close: () => settle({ error: new Error(`${label} sign-in cancelled`) }), + } +} + +function pkceChallenge(verifier: string): string { + return createHash('sha256').update(verifier).digest('base64url') +} + +async function discoverPkce( + root: string, + headers: ProxyHeaders, +): Promise { + const response = await fetch(`${root}${CLI_AUTH_DISCOVERY_PATH}`, { + headers: authHeaders(headers), + redirect: 'manual', + signal: requestSignal(), + }) + if (response.status === 404) return undefined + if (!response.ok) throw new Error(`LiteLLM CLI auth discovery failed (HTTP ${response.status})`) + const data = await readJson(response, 'LiteLLM CLI auth discovery') + if (!isRecord(data) || data.contract_version !== 1) { + throw new Error('LiteLLM CLI auth discovery has unsupported contract version') + } + if ( + !Array.isArray(data.code_challenge_methods_supported) || + !data.code_challenge_methods_supported.includes('S256') + ) { + throw new Error('LiteLLM CLI auth discovery does not support S256') + } + let issuer: URL + try { + issuer = new URL(String(data.issuer)) + if (canonicalIssuer(issuer.href) !== canonicalIssuer(root)) throw new Error('mismatch') + } catch { + throw new Error('LiteLLM CLI auth discovery issuer does not match the proxy URL') + } + return { + authorizationEndpoint: sameOriginUrl( + data.authorization_endpoint, + issuer, + 'authorization endpoint', + ), + tokenEndpoint: sameOriginUrl(data.token_endpoint, issuer, 'token endpoint'), + registrationEndpoint: sameOriginUrl( + data.registration_endpoint, + issuer, + 'registration endpoint', + ), + resource: sameOriginUrl(data.resource, issuer, 'resource'), + } +} + +/** LiteLLM's CLI-auth contract: dynamic client registration, then authorization code + PKCE. */ +async function authorizePkce( + root: string, + discovery: CliAuthDiscovery, + headers: ProxyHeaders, +): Promise { + const verifier = randomBytes(32).toString('base64url') + const callback = await listenForCallback('LiteLLM', []) + try { + const registrationResponse = await fetch(discovery.registrationEndpoint, { + method: 'POST', + headers: authHeaders(headers, 'application/json'), + body: JSON.stringify({ + client_name: 'opencode-litellm', + redirect_uris: [callback.redirectUri], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + }), + redirect: 'manual', + signal: requestSignal(), + }) + if (!registrationResponse.ok) { + throw new Error( + `LiteLLM PKCE client registration failed (HTTP ${registrationResponse.status})`, + ) + } + const registration = await readJson(registrationResponse, 'LiteLLM CLI auth registration') + if ( + !isRecord(registration) || + !isAuthToken(registration.client_id) || + !Array.isArray(registration.redirect_uris) || + !registration.redirect_uris.includes(callback.redirectUri) + ) { + throw new Error('LiteLLM PKCE client registration returned an invalid response') + } + const clientId = registration.client_id + const url = new URL(discovery.authorizationEndpoint) + for (const [key, value] of Object.entries({ + client_id: clientId, + redirect_uri: callback.redirectUri, + response_type: 'code', + resource: discovery.resource, + code_challenge: pkceChallenge(verifier), + code_challenge_method: 'S256', + state: callback.state, + })) { + url.searchParams.set(key, value) + } + return { + mode: 'auto', + url: url.toString(), + instructions: 'Sign in to LiteLLM in your browser, on the machine that runs OpenCode.', + expiresAt: callback.expiresAt, + callback: handled( + callback.code.then(async (code) => { + const result = await requestPkceToken( + discovery.tokenEndpoint, + new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: callback.redirectUri, + client_id: clientId, + code_verifier: verifier, + resource: discovery.resource, + }), + headers, + ) + if (!result.ok) throw new Error(result.message) + return { + ...result.token, + metadata: { + flow: PKCE_FLOW, + baseURL: root, + clientId, + tokenEndpoint: discovery.tokenEndpoint, + resource: discovery.resource, + }, + } + }), + ), + } + } catch (error) { + callback.close() + throw error + } +} + +async function startCliSso(root: string, headers: ProxyHeaders): Promise { + const response = await fetch(`${root}/sso/cli/start`, { + method: 'POST', + headers, + signal: requestSignal(), + }) + if (response.status === 404 || response.status === 405) return undefined + if (!response.ok) throw new Error(`LiteLLM CLI SSO start failed (HTTP ${response.status})`) + const data = await readJson(response, 'LiteLLM CLI SSO start') + if ( + !isRecord(data) || + typeof data.login_id !== 'string' || + !data.login_id || + typeof data.poll_secret !== 'string' || + !data.poll_secret || + typeof data.user_code !== 'string' || + !data.user_code + ) { + throw new Error('LiteLLM CLI SSO start returned an invalid response') + } + return { + loginId: data.login_id, + pollSecret: data.poll_secret, + userCode: data.user_code, + expiresInSeconds: positiveNumber(data.expires_in) ?? CLI_SSO_EXPIRES_IN_SECONDS, + } +} + +/** The teams a CLI SSO poll offers, as `id` plus a display label. */ +function cliSsoTeams(data: Json): Array<{ id: string; label: string }> { + const details = Array.isArray(data.team_details) ? data.team_details : [] + const teams = details.flatMap((team) => { + if (!isRecord(team)) return [] + const id = typeof team.team_id === 'string' ? team.team_id : team.id + const alias = team.team_alias + return typeof id === 'string' && id + ? [{ id, label: typeof alias === 'string' && alias ? alias : id }] + : [] + }) + if (teams.length === 0 && Array.isArray(data.teams)) { + teams.push( + ...data.teams.flatMap((id) => (typeof id === 'string' && id ? [{ id, label: id }] : [])), + ) + } + return teams +} + +async function pollCliSso( + root: string, + start: CliSsoStart, + headers: ProxyHeaders, + team?: string, +): Promise { + const deadline = Date.now() + start.expiresInSeconds * 1_000 + const pollUrl = new URL(`${root}/sso/cli/poll/${encodeURIComponent(start.loginId)}`) + let teamId: string | undefined + while (Date.now() < deadline) { + if (teamId) pollUrl.searchParams.set('team_id', teamId) + let response: Response + try { + response = await fetch(pollUrl, { + headers: { ...headers, 'x-litellm-cli-poll-secret': start.pollSecret }, + signal: requestSignal(), + }) + } catch { + await delay(CLI_SSO_POLL_INTERVAL_MS) + continue + } + if (response.status === 429 || response.status >= 500) { + await delay(CLI_SSO_POLL_INTERVAL_MS) + continue + } + if (response.status === 400) throw new Error('LiteLLM CLI SSO login expired or is invalid') + if (!response.ok) throw new Error(`LiteLLM CLI SSO polling failed (HTTP ${response.status})`) + const data = await readJson(response, 'LiteLLM CLI SSO polling') + if (!isRecord(data)) throw new Error('LiteLLM CLI SSO polling returned an invalid response') + if (data.status === 'ready' && isAuthToken(data.key)) { + const lifetime = positiveNumber(data.expires_in) + return { + access: data.key, + refresh: '', + expires: lifetime + ? expiresIn(lifetime) + : tokenExpiry(data.key, Date.now() + CLI_TOKEN_LIFETIME_MS), + metadata: { baseURL: root }, + } + } + if (data.status === 'ready' && data.requires_team_selection === true && !teamId) { + const teams = cliSsoTeams(data) + if (teams.length === 0) + throw new Error('LiteLLM CLI SSO requested team selection without any teams') + const selected = team + ? teams.find((candidate) => candidate.id === team || candidate.label === team) + : undefined + if (!selected) { + // The connect dialog cannot ask mid-sign-in, so the choice has to come from the Team field. + const choices = teams + .map(({ id, label }) => (label === id ? id : `${label} (${id})`)) + .join(', ') + throw new Error( + `${team ? `Your LiteLLM account has no team "${team}"` : 'LiteLLM asks which team to sign in with'}; connect again with one of these as Team: ${choices}`, + ) + } + teamId = selected.id + continue + } + if (data.status !== 'pending') + throw new Error('LiteLLM CLI SSO polling returned an invalid response') + await delay(CLI_SSO_POLL_INTERVAL_MS) + } + throw new Error('LiteLLM CLI SSO login expired') +} + +async function generateVirtualKey( + root: string, + userToken: string, + headers: ProxyHeaders, +): Promise<{ key: string; expires?: number }> { + const response = await fetch(`${root}/key/generate`, { + method: 'POST', + headers: { + ...headers, + Authorization: `Bearer ${userToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + signal: requestSignal(), + }) + if (!response.ok) throw new Error(`/key/generate failed (HTTP ${response.status})`) + const data = await readJson(response, '/key/generate') + if (!isRecord(data) || !isAuthToken(data.key)) throw new Error('/key/generate returned no key') + const expires = typeof data.expires === 'string' ? Date.parse(data.expires) : NaN + return { key: data.key, expires: Number.isSafeInteger(expires) ? expires : undefined } +} + +/** The oldest flow: the user copies a token from the LiteLLM UI, exchanged for a virtual key when the proxy allows it. */ +function authorizePastedToken(root: string, headers: ProxyHeaders): SsoAuthorization { + return { + mode: 'code', + url: `${root}/sso/key/generate`, + instructions: + 'Sign in to LiteLLM in your browser, then paste the token from the LiteLLM UI as the code.', + callback: async (code) => { + const token = code + .trim() + .replace(/^Bearer\s+/i, '') + .trim() + if (!isAuthToken(token)) throw new Error('Paste the token from the LiteLLM UI') + try { + const generated = await generateVirtualKey(root, token, headers) + return { + access: generated.key, + refresh: '', + expires: generated.expires ?? NEVER_EXPIRES, + metadata: { baseURL: root }, + } + } catch (error) { + // `/key/generate` needs a database and key-generation rights; the UI token works on its own. + const message = error instanceof Error ? error.message : String(error) + console.log( + `[opencode-litellm] Using the pasted LiteLLM token directly; no virtual key: ${message}`, + ) + return { + access: token, + refresh: '', + expires: tokenExpiry(token, NEVER_EXPIRES), + metadata: { baseURL: root }, + } + } + }, + } +} + +/** + * Starts a LiteLLM-hosted sign-in, preferring the newest flow the proxy offers: the CLI-auth + * contract (authorization code + PKCE), then CLI SSO with a confirmation code, then a token + * pasted from the LiteLLM UI. + */ +export async function authorizeLiteLLM( + baseURL: string, + headers?: Record, + team?: string, +): Promise { + const root = ssoRoot(baseURL) + const discovery = await discoverPkce(root, headers) + if (discovery) return authorizePkce(root, discovery, headers) + const start = await startCliSso(root, headers) + if (!start) return authorizePastedToken(root, headers) + return { + mode: 'auto', + url: `${root}/sso/key/generate?${new URLSearchParams({ source: 'litellm-cli', key: start.loginId })}`, + instructions: `Sign in to LiteLLM in your browser and confirm the code ${start.userCode}.`, + expiresAt: expiresIn(start.expiresInSeconds), + callback: handled(pollCliSso(root, start, headers, team)), + } +} + +/** Validates the identity provider fields of the connect form. */ +export function parseOidcConfig(raw: { + issuer?: string + clientId?: string + scope?: string + redirectPorts?: string +}): OidcConfig { + const invalid = (field: string, expected: string) => + new Error(`Invalid OIDC ${field}: expected ${expected}`) + const issuer = httpsUrl(raw.issuer?.trim(), { allowQuery: false }) + if (!issuer) throw invalid('issuer', 'an https URL without credentials, query, or fragment') + const clientId = raw.clientId?.trim() + if (!isAuthToken(clientId)) throw invalid('client ID', 'a non-empty value without spaces') + const scope = raw.scope?.trim() || 'openid' + if (!scope.split(/\s+/).includes('openid')) + throw invalid('scopes', 'a space-separated list that includes "openid"') + const redirectPorts = (raw.redirectPorts ?? '') + .split(/[\s,]+/) + .filter(Boolean) + .map(Number) + if (!redirectPorts.every((port) => Number.isInteger(port) && port >= 1 && port <= 65535)) { + throw invalid('redirect ports', 'port numbers from 1 to 65535') + } + return { issuer, clientId, scope, redirectPorts } +} + +async function discoverOidc( + issuer: string, +): Promise<{ issuer: string; authorizationEndpoint: string; tokenEndpoint: string }> { + const response = await fetch(`${trimTrailingSlashes(issuer)}${OIDC_DISCOVERY_PATH}`, { + headers: authHeaders(undefined), + redirect: 'manual', + signal: requestSignal(), + }) + if (!response.ok) throw new Error(`OIDC discovery failed (HTTP ${response.status})`) + const data = await readJson(response, 'OIDC discovery') + if ( + !isRecord(data) || + typeof data.issuer !== 'string' || + trimTrailingSlashes(data.issuer) !== trimTrailingSlashes(issuer) + ) { + throw new Error('OIDC discovery issuer does not match the configured issuer') + } + const methods = data.code_challenge_methods_supported + if (methods !== undefined && (!Array.isArray(methods) || !methods.includes('S256'))) { + throw new Error('OIDC discovery does not support S256') + } + const authorizationEndpoint = httpsUrl(data.authorization_endpoint, { allowQuery: true }) + if (!authorizationEndpoint) throw new Error('OIDC discovery has invalid authorization_endpoint') + const tokenEndpoint = httpsUrl(data.token_endpoint, { allowQuery: true }) + if (!tokenEndpoint) throw new Error('OIDC discovery has invalid token_endpoint') + return { issuer: data.issuer, authorizationEndpoint, tokenEndpoint } +} + +/** + * Signs in with the identity provider directly, as a public client, for proxies running LiteLLM + * JWT auth. The `id_token` becomes the bearer token; nothing is sent to the proxy. + */ +export async function authorizeOidc(config: OidcConfig): Promise { + const discovery = await discoverOidc(config.issuer) + const verifier = randomBytes(32).toString('base64url') + const nonce = randomBytes(32).toString('base64url') + const callback = await listenForCallback('OIDC', config.redirectPorts) + const url = new URL(discovery.authorizationEndpoint) + for (const [key, value] of Object.entries({ + response_type: 'code', + client_id: config.clientId, + redirect_uri: callback.redirectUri, + scope: config.scope, + state: callback.state, + nonce, + code_challenge: pkceChallenge(verifier), + code_challenge_method: 'S256', + })) { + url.searchParams.set(key, value) + } + return { + mode: 'auto', + url: url.toString(), + instructions: + 'Sign in with your identity provider in your browser, on the machine that runs OpenCode.', + expiresAt: callback.expiresAt, + callback: handled( + callback.code.then(async (code) => { + const result = await requestOidcToken( + discovery.tokenEndpoint, + new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: callback.redirectUri, + client_id: config.clientId, + code_verifier: verifier, + }), + { issuer: discovery.issuer, clientId: config.clientId, nonce }, + ) + if (!result.ok) throw new Error(result.message) + return { + ...result.token, + metadata: { + flow: OIDC_FLOW, + issuer: discovery.issuer, + clientId: config.clientId, + tokenEndpoint: discovery.tokenEndpoint, + subject: result.subject, + scope: config.scope, + }, + } + }), + ), + } +} + +/** + * Renews a session through its token endpoint. A transient failure keeps the current token until it + * actually expires; CLI SSO and UI tokens have no refresh grant and last until they expire. + */ +export async function refreshSsoToken( + token: SsoToken, + headers?: Record, +): Promise { + const { metadata } = token + let result: TokenResult + if (metadata.flow === PKCE_FLOW) { + if ( + typeof metadata.baseURL !== 'string' || + !isAuthToken(metadata.clientId) || + !isAuthToken(token.refresh) + ) { + throw new Error('Invalid LiteLLM PKCE credential; connect LiteLLM again') + } + const issuer = new URL(ssoRoot(metadata.baseURL)) + result = await requestPkceToken( + sameOriginUrl(metadata.tokenEndpoint, issuer, 'token endpoint'), + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: token.refresh, + client_id: metadata.clientId, + resource: sameOriginUrl(metadata.resource, issuer, 'resource'), + }), + headers, + token.refresh, + ) + } else if (metadata.flow === OIDC_FLOW && isAuthToken(token.refresh)) { + const tokenEndpoint = httpsUrl(metadata.tokenEndpoint, { allowQuery: true }) + const { issuer, clientId, subject } = metadata + if ( + !tokenEndpoint || + typeof issuer !== 'string' || + !isAuthToken(clientId) || + typeof subject !== 'string' || + !subject + ) { + throw new Error('Invalid LiteLLM OIDC credential; connect LiteLLM again') + } + result = await requestOidcToken( + tokenEndpoint, + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: token.refresh, + client_id: clientId, + }), + { issuer, clientId, subject }, + token.refresh, + ) + } else { + if (Date.now() < token.expires) return token + throw new Error('LiteLLM sign-in expired; connect LiteLLM again') + } + if (result.ok) return { ...token, ...result.token } + if (result.transient && Date.now() < token.expires) return token + // A transient failure leaves the session intact, and a new sign-in would need the same endpoint. + throw new Error(result.transient ? result.message : `${result.message}; connect LiteLLM again`) +} diff --git a/test/plugin-integration.test.ts b/test/plugin-integration.test.ts index f7c122f..43adf6c 100644 --- a/test/plugin-integration.test.ts +++ b/test/plugin-integration.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import plugin from '../src' +import { buildCacheKey, readModelCacheEntry } from '../src/utils/model-cache' import type { Context } from '@opencode/plugin/promise/plugin' function restoreEnv(name: string, value: string | undefined): void { @@ -126,7 +127,7 @@ describe('connect dialog integration', () => { vi.restoreAllMocks() }) - it('registers the LiteLLM integration with an API key form', async () => { + it('registers the LiteLLM integration with an API key form and two sign-in methods', 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 })) @@ -147,15 +148,31 @@ describe('connect dialog integration', () => { 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[] } }, + const methods = integration.methodUpdate.mock.calls.map( + (call) => + call[0] as { + integrationID: string + method: { type: string; id?: string; form?: Array<{ key: string; type: string }> } + authorize?: unknown + refresh?: 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) + expect(methods.map((entry) => entry.integrationID)).toEqual(['litellm', 'litellm', 'litellm']) + expect(methods.map((entry) => [entry.method.type, entry.method.id])).toEqual([ + ['key', undefined], + ['oauth', 'sso'], + ['oauth', 'oidc'], + ]) + expect(methods.map((entry) => entry.method.form?.map((field) => field.key))).toEqual([ + ['baseURL', 'providerID'], + ['baseURL', 'providerID', 'team'], + ['baseURL', 'providerID', 'issuer', 'clientId', 'scope', 'redirectPorts'], + ]) + expect(methods.flatMap((entry) => entry.method.form ?? []).every((field) => field.type === 'string')).toBe(true) + for (const entry of methods.slice(1)) { + expect(entry.authorize).toBeTypeOf('function') + expect(entry.refresh).toBeTypeOf('function') + } await cleanup?.() expect(integration.registrations[0].dispose).toHaveBeenCalledOnce() @@ -720,7 +737,7 @@ describe('connect dialog integration', () => { await cleanup?.() }) - it('ignores non-key credentials from the connection', async () => { + it('ignores OAuth credentials from methods this plugin does not register', async () => { cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-oauth-test-')) process.env.XDG_CACHE_HOME = cacheDirectory @@ -730,8 +747,16 @@ describe('connect dialog integration', () => { }) const integration = makeIntegration() - integration.active.mockResolvedValue({ type: 'oauth', id: 'cred-1' }) - integration.resolve.mockResolvedValue({ type: 'oauth', access: 'token' }) + integration.active.mockResolvedValue({ type: 'credential', id: 'cred-1', method: 'oauth', label: '' }) + // Usable if it counted: it names a proxy, so only the methodID keeps it out. + integration.resolve.mockResolvedValue({ + type: 'oauth', + methodID: 'another-plugin', + access: 'token', + refresh: '', + expires: Number.MAX_SAFE_INTEGER, + metadata: { baseURL: 'http://127.0.0.1:44512' }, + }) const registered: Array<{ info: Record }> = [] const editor = { @@ -764,9 +789,137 @@ describe('connect dialog integration', () => { 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. + // register; another method's oauth credential is never treated as an API key. expect(registered).toHaveLength(0) await cleanup?.() }) + + it('uses a LiteLLM sign-in for discovery and keys its cache by the credential', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-sso-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: 'sso-model' }])(input) + }) + + const integration = makeIntegration() + integration.active.mockResolvedValue({ type: 'credential', id: 'cred-sso', method: 'oauth', label: 'LiteLLM' }) + integration.resolve.mockResolvedValue({ + type: 'oauth', + methodID: 'sso', + access: 'example-access', + refresh: 'example-refresh', + expires: Date.now() + 3_600_000, + metadata: { flow: 'litellm_cli_pkce', baseURL: 'http://127.0.0.1:44510', providerID: 'litellm' }, + }) + const registered: RegisteredEntry[] = [] + const context = { + app: { name: 'OpenCode', version: '2.1.0', channel: 'stable' }, + options: {}, + provider: { + list: vi.fn(async () => ({ data: [] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + transform(makeStatefulEditor(registered, []) 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', integrationID: 'litellm' }) + expect(registered[0].info.settings).toMatchObject({ baseURL: 'http://127.0.0.1:44510/v1' }) + expect(registered[0].info.settings).not.toHaveProperty('apiKey') + expect(registered[0].models.map((model) => model.id)).toEqual(['sso-model']) + expect(authorizationHeaders).toContain('Bearer example-access') + // The access token rotates on refresh; the credential id does not. + const baseURL = 'http://127.0.0.1:44510' + expect(readModelCacheEntry(buildCacheKey('litellm', baseURL, {}, {}, {}, 'cred-sso'))).not.toBeNull() + expect(readModelCacheEntry(buildCacheKey('litellm', baseURL, {}, {}, {}, 'example-access'))).toBeNull() + + await cleanup?.() + }) + + it('signs in to the configured proxy with its headers, and refuses another one', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-sso-authorize-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + vi.spyOn(console, 'log').mockImplementation(() => {}) + const ssoRequests: Array<{ url: string; gateway: string | null }> = [] + globalThis.fetch = vi.fn(async (input, init) => { + const url = String(input) + if (url.includes('/sso/') || url.includes('/.well-known/') || url.endsWith('/key/generate')) { + ssoRequests.push({ url, gateway: new Headers(init?.headers).get('X-Gateway') }) + } + if (url.endsWith('/sso/cli/start')) return new Response('method not allowed', { status: 405 }) + return new Response('not found', { status: 404 }) + }) + + const integration = makeIntegration() + const configuredProvider = { + id: 'litellm', + name: 'LiteLLM', + activation: 'enabled', + package: '@opencode/ai/providers/openai-compatible', + settings: { baseURL: 'https://llm.example.com/v1' }, + headers: { 'X-Gateway': 'example-gateway' }, + } + const context = { + app: { name: 'OpenCode', version: '2.1.0', channel: 'stable' }, + options: {}, + provider: { + list: vi.fn(async () => ({ data: [configuredProvider] })), + 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) + const sso = integration.methodUpdate.mock.calls + .map((call) => call[0]) + .find((entry) => entry.method.id === 'sso') as { + authorize: (answer: Record) => Promise<{ + mode: string + url: string + callback: (code: string) => Promise> + }> + refresh: (credential: Record) => Promise> + } + + await expect(sso.authorize({ baseURL: 'https://other.example.com' })).rejects.toThrow( + 'Provider "litellm" is configured for https://llm.example.com/v1', + ) + + const authorization = await sso.authorize({}) + expect(authorization).toMatchObject({ mode: 'code', url: 'https://llm.example.com/sso/key/generate' }) + const credential = await authorization.callback('example-ui-token') + expect(credential).toEqual({ + type: 'oauth', + methodID: 'sso', + access: 'example-ui-token', + refresh: '', + expires: Number.MAX_SAFE_INTEGER, + metadata: { baseURL: 'https://llm.example.com', providerID: 'litellm' }, + }) + expect(ssoRequests.map((request) => request.url)).toEqual([ + 'https://llm.example.com/.well-known/litellm-cli-auth', + 'https://llm.example.com/sso/cli/start', + 'https://llm.example.com/key/generate', + ]) + expect(ssoRequests.every((request) => request.gateway === 'example-gateway')).toBe(true) + + await expect(sso.refresh(credential)).resolves.toEqual(credential) + await expect(sso.refresh({ ...credential, expires: Date.now() - 1 })).rejects.toThrow( + 'LiteLLM sign-in expired; connect LiteLLM again', + ) + + await cleanup?.() + }) }) diff --git a/test/plugin-v2.test.ts b/test/plugin-v2.test.ts index 9f56fce..14a4853 100644 --- a/test/plugin-v2.test.ts +++ b/test/plugin-v2.test.ts @@ -1618,7 +1618,7 @@ describe('OpenCode 2 plugin entrypoint', () => { const active = vi .fn() .mockResolvedValueOnce({ key: 'old-key' }) - .mockResolvedValueOnce({ key: 'new-key' }) + .mockResolvedValue({ key: 'new-key' }) const reload = vi.fn(async () => providerTransform?.(editor)) const context = { app: { name: 'OpenCode', version: '2.0.14', channel: 'stable' }, @@ -1655,7 +1655,9 @@ describe('OpenCode 2 plugin entrypoint', () => { const cleanup = await plugin.setup(context) await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(2)) - expect(active).toHaveBeenCalledTimes(2) + // Setup, the switch, and the rediscovery, which resolves the connection + // again so a rotating sign-in token is fresh. + expect(active).toHaveBeenCalledTimes(3) expect(authorizationHeaders).toContain('Bearer new-key') // The connection-managed credential is used for discovery but must not // be materialized into provider settings โ€” OpenCode injects it itself. diff --git a/test/sso.test.ts b/test/sso.test.ts new file mode 100644 index 0000000..9f9ecd2 --- /dev/null +++ b/test/sso.test.ts @@ -0,0 +1,460 @@ +import { createHash } from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + authorizeLiteLLM, + authorizeOidc, + parseOidcConfig, + refreshSsoToken, + ssoRoot, + type SsoAuthorization, + type SsoToken, +} from '../src/utils/sso' + +const ROOT = 'https://llm.example.com' +const ISSUER = 'https://idp.example.com' +const realFetch = globalThis.fetch + +type Handler = (request: { url: URL; body: string }) => Response | Promise +type Call = { key: string; url: URL; headers: Headers; body: string } + +/** Routes fetch by `METHOD origin+path`; unrouted requests get a 404. */ +function mockFetch(handlers: Record): Call[] { + const calls: Call[] = [] + globalThis.fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input)) + const key = `${init?.method ?? 'GET'} ${url.origin}${url.pathname}` + const body = init?.body === undefined ? '' : String(init.body) + calls.push({ key, url, headers: new Headers(init?.headers), body }) + return handlers[key]?.({ url, body }) ?? new Response('not found', { status: 404 }) + }) as typeof fetch + return calls +} + +function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function jwt(claims: Record): string { + const part = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url') + return `${part({ alg: 'none' })}.${part(claims)}.signature` +} + +function autoCallback(authorization: SsoAuthorization): Promise { + if (authorization.mode !== 'auto') + throw new Error(`expected an auto sign-in, got ${authorization.mode}`) + return authorization.callback +} + +/** What the browser does after sign-in: follow the redirect back to the loopback listener. */ +async function redirectBack( + authorization: SsoAuthorization, + params: Record, +): Promise { + const authorizationUrl = new URL(authorization.url) + const callback = new URL(authorizationUrl.searchParams.get('redirect_uri')!) + callback.searchParams.set('state', authorizationUrl.searchParams.get('state')!) + for (const [key, value] of Object.entries(params)) callback.searchParams.set(key, value) + return realFetch(callback) +} + +function cliAuthDiscovery(overrides: Record = {}) { + return json({ + contract_version: 1, + issuer: ROOT, + code_challenge_methods_supported: ['S256'], + authorization_endpoint: `${ROOT}/cli/authorize`, + token_endpoint: `${ROOT}/cli/token`, + registration_endpoint: `${ROOT}/cli/register`, + resource: `${ROOT}/`, + ...overrides, + }) +} + +const pkceToken = (overrides: Partial = {}): SsoToken => ({ + access: 'example-access', + refresh: 'example-refresh', + expires: Date.now() + 60_000, + metadata: { + flow: 'litellm_cli_pkce', + baseURL: ROOT, + clientId: 'example-client', + tokenEndpoint: `${ROOT}/cli/token`, + resource: `${ROOT}/`, + }, + ...overrides, +}) + +afterEach(() => { + globalThis.fetch = realFetch + vi.restoreAllMocks() +}) + +describe('ssoRoot', () => { + it('accepts https and loopback http, without the /v1 suffix', () => { + expect(ssoRoot('https://llm.example.com/v1/')).toBe('https://llm.example.com') + expect(ssoRoot('http://127.0.0.1:4000/v1')).toBe('http://127.0.0.1:4000') + expect(ssoRoot('http://localhost:4000')).toBe('http://localhost:4000') + }) + + it('refuses plain http to other hosts and URLs carrying credentials', () => { + expect(() => ssoRoot('http://llm.example.com')).toThrow(/https/) + expect(() => ssoRoot('http://127.example.com')).toThrow(/https/) + expect(() => ssoRoot('https://user:pass@llm.example.com')).toThrow(/credentials/) + }) +}) + +describe('LiteLLM CLI-auth (PKCE) sign-in', () => { + it('registers a client, waits for the browser, and exchanges the code', async () => { + const calls = mockFetch({ + [`GET ${ROOT}/.well-known/litellm-cli-auth`]: () => cliAuthDiscovery(), + [`POST ${ROOT}/cli/register`]: ({ body }) => + json({ client_id: 'example-client', redirect_uris: JSON.parse(body).redirect_uris }), + [`POST ${ROOT}/cli/token`]: () => + json({ + access_token: 'example-access', + refresh_token: 'example-refresh', + token_type: 'Bearer', + expires_in: 3600, + }), + }) + + const authorization = await authorizeLiteLLM(`${ROOT}/v1`, { 'X-Gateway': 'example-gateway' }) + const url = new URL(authorization.url) + expect(`${url.origin}${url.pathname}`).toBe(`${ROOT}/cli/authorize`) + expect(url.searchParams.get('client_id')).toBe('example-client') + expect(url.searchParams.get('code_challenge_method')).toBe('S256') + expect(url.searchParams.get('redirect_uri')).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/) + + // A callback with another state is refused and the listener keeps waiting. + const forged = new URL(url.searchParams.get('redirect_uri')!) + forged.searchParams.set('state', 'forged') + forged.searchParams.set('code', 'example-code') + expect((await realFetch(forged)).status).toBe(400) + + expect((await redirectBack(authorization, { code: 'example-code' })).status).toBe(200) + const token = await autoCallback(authorization) + expect(token).toMatchObject({ + access: 'example-access', + refresh: 'example-refresh', + metadata: { + flow: 'litellm_cli_pkce', + baseURL: ROOT, + clientId: 'example-client', + tokenEndpoint: `${ROOT}/cli/token`, + resource: `${ROOT}/`, + }, + }) + expect(token.expires).toBeGreaterThan(Date.now() + 3_500_000) + + const exchange = new URLSearchParams( + calls.find((call) => call.key === `POST ${ROOT}/cli/token`)!.body, + ) + expect(exchange.get('code')).toBe('example-code') + expect(createHash('sha256').update(exchange.get('code_verifier')!).digest('base64url')).toBe( + url.searchParams.get('code_challenge'), + ) + expect(calls.every((call) => call.headers.get('X-Gateway') === 'example-gateway')).toBe(true) + }) + + it('reports a denied sign-in', async () => { + mockFetch({ + [`GET ${ROOT}/.well-known/litellm-cli-auth`]: () => cliAuthDiscovery(), + [`POST ${ROOT}/cli/register`]: ({ body }) => + json({ client_id: 'example-client', redirect_uris: JSON.parse(body).redirect_uris }), + }) + const authorization = await authorizeLiteLLM(ROOT) + await redirectBack(authorization, { error: 'access_denied' }) + await expect(autoCallback(authorization)).rejects.toThrow( + 'LiteLLM sign-in was denied (access_denied)', + ) + }) + + it('stops at discovery that points outside the proxy', async () => { + mockFetch({ + [`GET ${ROOT}/.well-known/litellm-cli-auth`]: () => + cliAuthDiscovery({ token_endpoint: 'https://elsewhere.example.net/token' }), + }) + await expect(authorizeLiteLLM(ROOT)).rejects.toThrow('cross-origin token endpoint') + }) + + it('rotates tokens on refresh and keeps the refresh token the server does not replace', async () => { + const calls = mockFetch({ + [`POST ${ROOT}/cli/token`]: () => + json({ access_token: 'example-access-2', token_type: 'bearer', expires_in: 60 }), + }) + const refreshed = await refreshSsoToken(pkceToken(), { 'X-Gateway': 'example-gateway' }) + expect(refreshed).toMatchObject({ access: 'example-access-2', refresh: 'example-refresh' }) + const form = new URLSearchParams(calls[0].body) + expect(Object.fromEntries(form)).toEqual({ + grant_type: 'refresh_token', + refresh_token: 'example-refresh', + client_id: 'example-client', + resource: `${ROOT}/`, + }) + expect(calls[0].headers.get('X-Gateway')).toBe('example-gateway') + }) + + it('keeps the token through a transient refresh failure until it expires', async () => { + mockFetch({ [`POST ${ROOT}/cli/token`]: () => new Response('unavailable', { status: 503 }) }) + const token = pkceToken() + await expect(refreshSsoToken(token)).resolves.toBe(token) + await expect(refreshSsoToken(pkceToken({ expires: Date.now() - 1 }))).rejects.toThrow( + 'LiteLLM token exchange failed (HTTP 503)', + ) + }) + + it('asks for a new sign-in when the refresh token is rejected', async () => { + mockFetch({ [`POST ${ROOT}/cli/token`]: () => json({ error: 'invalid_grant' }, 400) }) + await expect(refreshSsoToken(pkceToken())).rejects.toThrow( + 'LiteLLM token exchange rejected (invalid_grant); connect LiteLLM again', + ) + }) +}) + +describe('LiteLLM CLI SSO sign-in', () => { + const start = () => + json({ + login_id: 'example-login', + poll_secret: 'example-poll', + user_code: 'ABCD-1234', + expires_in: 60, + }) + const teams = { + status: 'ready', + requires_team_selection: true, + team_details: [ + { team_id: 'team-1', team_alias: 'Platform' }, + { team_id: 'team-2', team_alias: 'Research' }, + ], + } + + it('polls with the confirmation code and signs in to the team the form names', async () => { + let polls = 0 + const calls = mockFetch({ + [`POST ${ROOT}/sso/cli/start`]: start, + [`GET ${ROOT}/sso/cli/poll/example-login`]: ({ url }) => { + polls += 1 + if (polls === 1) return json({ status: 'pending' }) + if (!url.searchParams.has('team_id')) return json(teams) + return json({ status: 'ready', key: 'example-cli-token', expires_in: 3600 }) + }, + }) + + const authorization = await authorizeLiteLLM(ROOT, undefined, 'Research') + expect(authorization.instructions).toContain('ABCD-1234') + expect(authorization.url).toBe(`${ROOT}/sso/key/generate?source=litellm-cli&key=example-login`) + const token = await autoCallback(authorization) + + expect(token).toMatchObject({ + access: 'example-cli-token', + refresh: '', + metadata: { baseURL: ROOT }, + }) + expect(token.expires).toBeGreaterThan(Date.now() + 3_500_000) + const pollCalls = calls.filter((call) => call.key === `GET ${ROOT}/sso/cli/poll/example-login`) + expect(pollCalls.map((call) => call.url.searchParams.get('team_id'))).toEqual([ + null, + null, + 'team-2', + ]) + expect( + pollCalls.every((call) => call.headers.get('x-litellm-cli-poll-secret') === 'example-poll'), + ).toBe(true) + }) + + it('lists the teams when sign-in needs one the form did not name', async () => { + mockFetch({ + [`POST ${ROOT}/sso/cli/start`]: start, + [`GET ${ROOT}/sso/cli/poll/example-login`]: () => json(teams), + }) + const authorization = await authorizeLiteLLM(ROOT) + await expect(autoCallback(authorization)).rejects.toThrow( + 'LiteLLM asks which team to sign in with; connect again with one of these as Team: Platform (team-1), Research (team-2)', + ) + }) + + it('takes the expiry from the token when the poll response has none', async () => { + const exp = Math.floor(Date.now() / 1000) + 600 + mockFetch({ + [`POST ${ROOT}/sso/cli/start`]: start, + [`GET ${ROOT}/sso/cli/poll/example-login`]: () => + json({ status: 'ready', key: jwt({ exp }) }), + }) + const token = await autoCallback(await authorizeLiteLLM(ROOT)) + expect(token.expires).toBe(exp * 1000) + }) +}) + +describe('LiteLLM UI token sign-in', () => { + it('exchanges the pasted token for a virtual key', async () => { + const calls = mockFetch({ + [`POST ${ROOT}/sso/cli/start`]: () => new Response('method not allowed', { status: 405 }), + [`POST ${ROOT}/key/generate`]: () => + json({ key: 'example-virtual-key', expires: '2099-01-01T00:00:00Z' }), + }) + const authorization = await authorizeLiteLLM(ROOT) + expect(authorization).toMatchObject({ mode: 'code', url: `${ROOT}/sso/key/generate` }) + if (authorization.mode !== 'code') throw new Error('expected a code sign-in') + + const token = await authorization.callback('Bearer example-ui-token ') + expect(token).toEqual({ + access: 'example-virtual-key', + refresh: '', + expires: Date.parse('2099-01-01T00:00:00Z'), + metadata: { baseURL: ROOT }, + }) + expect(calls.at(-1)?.headers.get('Authorization')).toBe('Bearer example-ui-token') + }) + + it('uses the pasted token directly when the proxy cannot mint a virtual key', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + const exp = Math.floor(Date.now() / 1000) + 600 + const pasted = jwt({ exp }) + mockFetch({ + [`POST ${ROOT}/key/generate`]: () => json({ error: 'no database' }, 500), + }) + const authorization = await authorizeLiteLLM(ROOT) + if (authorization.mode !== 'code') throw new Error('expected a code sign-in') + await expect(authorization.callback(pasted)).resolves.toMatchObject({ + access: pasted, + expires: exp * 1000, + }) + await expect(authorization.callback(' ')).rejects.toThrow( + 'Paste the token from the LiteLLM UI', + ) + }) + + it('lasts until expiry, then asks for a new sign-in', async () => { + const token: SsoToken = { + access: 'example-ui-token', + refresh: '', + expires: Date.now() + 60_000, + metadata: {}, + } + await expect(refreshSsoToken(token)).resolves.toBe(token) + await expect(refreshSsoToken({ ...token, expires: Date.now() - 1 })).rejects.toThrow( + 'LiteLLM sign-in expired; connect LiteLLM again', + ) + }) +}) + +describe('direct OIDC sign-in', () => { + const config = () => + parseOidcConfig({ issuer: ISSUER, clientId: 'example-client', scope: 'openid offline_access' }) + const discovery = () => + json({ + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/authorize`, + token_endpoint: `${ISSUER}/token`, + code_challenge_methods_supported: ['S256'], + }) + const exp = Math.floor(Date.now() / 1000) + 3600 + const claims = (overrides: Record = {}) => ({ + iss: ISSUER, + aud: 'example-client', + sub: 'example-user', + exp, + ...overrides, + }) + + it('validates the form fields', () => { + expect( + parseOidcConfig({ issuer: ISSUER, clientId: 'example-client', redirectPorts: '8400, 8401' }), + ).toEqual({ + issuer: ISSUER, + clientId: 'example-client', + scope: 'openid', + redirectPorts: [8400, 8401], + }) + expect(() => + parseOidcConfig({ issuer: 'http://idp.example.com', clientId: 'example-client' }), + ).toThrow('Invalid OIDC issuer') + expect(() => parseOidcConfig({ issuer: ISSUER })).toThrow('Invalid OIDC client ID') + expect(() => + parseOidcConfig({ issuer: ISSUER, clientId: 'example-client', scope: 'profile' }), + ).toThrow('Invalid OIDC scopes') + expect(() => + parseOidcConfig({ issuer: ISSUER, clientId: 'example-client', redirectPorts: '80000' }), + ).toThrow('Invalid OIDC redirect ports') + }) + + it('signs in with the identity provider and stores its id_token', async () => { + let nonce = '' + const idToken = () => jwt(claims({ nonce })) + const calls = mockFetch({ + [`GET ${ISSUER}/.well-known/openid-configuration`]: discovery, + [`POST ${ISSUER}/token`]: () => + json({ id_token: idToken(), refresh_token: 'example-refresh' }), + }) + + const authorization = await authorizeOidc(config()) + const url = new URL(authorization.url) + nonce = url.searchParams.get('nonce')! + expect(url.searchParams.get('scope')).toBe('openid offline_access') + expect(url.searchParams.get('code_challenge_method')).toBe('S256') + + await redirectBack(authorization, { code: 'example-code' }) + const token = await autoCallback(authorization) + expect(token).toMatchObject({ + access: idToken(), + refresh: 'example-refresh', + metadata: { + flow: 'oidc_pkce', + issuer: ISSUER, + clientId: 'example-client', + tokenEndpoint: `${ISSUER}/token`, + subject: 'example-user', + scope: 'openid offline_access', + }, + }) + expect(new URLSearchParams(calls.at(-1)!.body).get('code_verifier')).toBeTruthy() + }) + + it('refuses an id_token issued to another client or for another login', async () => { + let token = '' + mockFetch({ + [`GET ${ISSUER}/.well-known/openid-configuration`]: discovery, + [`POST ${ISSUER}/token`]: () => json({ id_token: token }), + }) + + const forOtherClient = await authorizeOidc(config()) + token = jwt( + claims({ aud: 'other-client', nonce: new URL(forOtherClient.url).searchParams.get('nonce') }), + ) + await redirectBack(forOtherClient, { code: 'example-code' }) + await expect(autoCallback(forOtherClient)).rejects.toThrow('OIDC id_token has invalid aud') + + const replayed = await authorizeOidc(config()) + token = jwt(claims({ nonce: 'another-login' })) + await redirectBack(replayed, { code: 'example-code' }) + await expect(autoCallback(replayed)).rejects.toThrow('OIDC id_token has invalid nonce') + }) + + it('refreshes the id_token for the same subject only', async () => { + let subject = 'example-user' + mockFetch({ [`POST ${ISSUER}/token`]: () => json({ id_token: jwt(claims({ sub: subject })) }) }) + const token: SsoToken = { + access: 'example-old-id-token', + refresh: 'example-refresh', + expires: Date.now() + 60_000, + metadata: { + flow: 'oidc_pkce', + issuer: ISSUER, + clientId: 'example-client', + tokenEndpoint: `${ISSUER}/token`, + subject: 'example-user', + }, + } + await expect(refreshSsoToken(token)).resolves.toMatchObject({ + access: jwt(claims({ sub: 'example-user' })), + refresh: 'example-refresh', + }) + subject = 'someone-else' + await expect(refreshSsoToken(token)).rejects.toThrow( + 'OIDC id_token has invalid sub; connect LiteLLM again', + ) + }) +}) From 2be071888289fbb535fe7c6d1e5a6901fe02f834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Fri, 30 Oct 2026 14:03:13 +0100 Subject: [PATCH 3/5] feat(v1): sign in to LiteLLM with SSO on OpenCode 1 and its Desktop app OpenCode 1 (and the 1.x Desktop app, whose connect dialog maps onto plugin auth hooks) now offers the same sign-ins as OpenCode 2 under the litellm provider: LiteLLM SSO, a direct OIDC sign-in, or an API key. OpenCode 1 neither refreshes plugin credentials nor keeps OAuth metadata, so the plugin keeps the sign-in's details in accountId, refreshes the session itself (one refresh at a time, a short back-off after a failure, adopting another process's refresh only for the same proxy, never overwriting a credential saved since) and sends the current token on every request through options.fetch, including the -responses sibling, which gets the placeholder key its runtime needs. The Desktop dialog cannot submit an empty text prompt, so optional answers sit behind selects. A sign-in beats the environment key, a configured key beats the sign-in, and a provider without a configured baseURL uses the sign-in's proxy. The model cache is keyed by the sign-in, so LiteLLM stays listed, and reconnectable, after a session ends. A sign-in is never sent to a provider configured for another proxy, nor to the local auto-detection probes. Also fixed on OpenCode 1: after a login rebuilds the plugin, it reads the new credential instead of a cached auth.json, and values it wrote into a provider's options on an earlier run no longer count as configuration, since OpenCode 1 keeps its global config for the whole process. --- CHANGELOG.md | 3 +- README.md | 21 +- src/plugin/auth.ts | 312 ++++++++++++++ src/plugin/index.ts | 128 +++++- src/plugin/integration.ts | 18 +- src/utils/opencode-auth.ts | 89 +++- src/utils/sso.ts | 39 +- test/auth-v1.test.ts | 819 +++++++++++++++++++++++++++++++++++++ test/plugin-v1.test.ts | 4 +- test/plugin-v2.test.ts | 8 +- 10 files changed, 1381 insertions(+), 60 deletions(-) create mode 100644 src/plugin/auth.ts create mode 100644 test/auth-v1.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 32c7dce..7aa6ed1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,7 @@ ### Bug Fixes * **v2:** read the connect dialog's Base URL and Provider ID from the credential's `metadata`, where OpenCode 2.1+ stores them +* **v1:** after a login rebuilds the plugin (as `/connect` and the Desktop app do), read the new credential instead of a cached `auth.json`, and stop treating values the plugin wrote into a provider's options on an earlier run as configuration, since OpenCode 1 keeps its global config for the whole process * 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 @@ -100,7 +101,7 @@ ### Features -* **v2:** sign in from the connect-provider dialog with LiteLLM SSO (the CLI-auth PKCE contract, CLI SSO, or a token pasted from the LiteLLM UI) or directly with an OpenID Connect identity provider; OpenCode stores, refreshes, and injects the token, and the model cache of a sign-in is keyed by its credential id +* sign in with LiteLLM SSO (the CLI-auth PKCE contract, CLI SSO, or a token pasted from the LiteLLM UI) or directly with an OpenID Connect identity provider, from the connect dialog of OpenCode 1 and 2, their Desktop apps, and `opencode auth login`; the token is refreshed and sent with every request, and the model cache of a sign-in survives its token rotating * 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) diff --git a/README.md b/README.md index 03f5669..08e4df3 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ also shows how to configure a provider directly in `opencode.json`. | ๐Ÿงฉ **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 `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. | -| ๐Ÿชช **Single sign-on (OpenCode 2)** | Signs in from the connect-provider dialog through LiteLLM SSO (CLI-auth PKCE, CLI SSO, or a token pasted from the LiteLLM UI) or directly with an OpenID Connect identity provider. OpenCode stores, refreshes, and injects the token. | +| ๐Ÿชช **Single sign-on** | Signs in through LiteLLM SSO (CLI-auth PKCE, CLI SSO, or a token pasted from the LiteLLM UI) or directly with an OpenID Connect identity provider, from the connect dialog of OpenCode 1 and 2, their Desktop apps, or `opencode auth login`. The token is refreshed and sent with every request. | | ๐ŸŒ **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. | @@ -328,7 +328,7 @@ If your LiteLLM proxy requires a master key, expose it via any of these approach | 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 | -| Single sign-on (OpenCode 2) | `/connect` โ†’ **LiteLLM** โ†’ **LiteLLM SSO** or **Identity provider (OIDC)**; see [Single sign-on](#single-sign-on-opencode-2) | +| Single sign-on | `/connect` (or the Desktop providers view) โ†’ **LiteLLM** โ†’ **LiteLLM SSO** or **Identity provider (OIDC)**; see [Single sign-on](#single-sign-on) | | 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-...` | @@ -346,9 +346,9 @@ With no configuration at all, `/connect` โ†’ **LiteLLM** is enough: 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. -#### Single sign-on (OpenCode 2) +#### Single sign-on -The **LiteLLM** entry in `/connect` also offers two browser sign-ins. OpenCode stores the session as a credential of the integration, refreshes it, and injects it into requests, as it does with a connected key. The sign-in returns to a temporary `http://127.0.0.1` callback, so the browser must run on the same machine as OpenCode, and the proxy needs an `https` base URL (plain `http` only on a loopback host). +The **LiteLLM** entry in `/connect` also offers two browser sign-ins. The sign-in returns to a temporary `http://127.0.0.1` callback, so the browser must run on the same machine as the OpenCode server (OpenCode Desktop's own server qualifies; a Desktop connected to a remote server does not), and the proxy needs an `https` base URL (plain `http` only on a loopback host). On OpenCode 2, OpenCode stores the session as a credential of the integration, refreshes it, and injects it into requests, as it does with a connected key; OpenCode 1 differences are [below](#opencode-1). **LiteLLM SSO** signs in through the proxy. Leave **Base URL** empty to use the configured provider's URL or `LITELLM_BASE_URL`. A URL that differs from the configured one is refused, because explicit configuration wins in discovery and the token would go to a proxy that did not issue it. The plugin uses the newest flow the proxy offers: @@ -358,7 +358,18 @@ The **LiteLLM** entry in `/connect` also offers two browser sign-ins. OpenCode s **Identity provider (OIDC)** signs in with your identity provider directly, for proxies running LiteLLM JWT auth (`enable_jwt_auth`) that trust its issuer and accept the client ID as an audience. Enter the `https` issuer URL, a public client ID (no secret) registered for authorization code + PKCE with the redirect `http://127.0.0.1:/callback`, the scopes (default `openid`; some identity providers only issue a refresh token with `offline_access`), and, for identity providers that only accept exact redirect URIs, the ports to try. The `id_token` is the bearer token: the plugin checks its issuer, audience, authorized party, subject, expiry, and nonce, and the proxy verifies its signature. Signing in sends nothing to the proxy, and identity provider requests never carry custom headers. -Disconnecting removes the local credential only; it does not revoke the session on the proxy or the identity provider. OpenCode 1 keeps API-key authentication. +Disconnecting removes the local credential only; it does not revoke the session on the proxy or the identity provider. + +If a sign-in fails before the browser opens (no proxy URL, a plain `http` URL, an unreachable proxy or identity provider, an invalid OIDC field), OpenCode shows only a generic error, such as "Unknown error" in the Desktop app: OpenCode drops the message of an error a plugin raises at that step. The reason is in OpenCode's log. Errors after the browser step, such as the team list, are shown. + +##### OpenCode 1 + +OpenCode 1 lists the sign-ins under the `litellm` provider, in `/connect`, its Desktop app's providers view, and `opencode auth login`. The answers that can be left out sit behind a choice instead of an empty field, since the Desktop app cannot submit one: the proxy (**Configured or previous proxy** or **Another URLโ€ฆ**), the team, the scopes, and the redirect ports. Without a configured or previous proxy, pick **Another URLโ€ฆ**. + +- **First sign-in:** run `opencode auth login -p litellm`. OpenCode 1 lists a provider in `/connect` and the Desktop app only once it has models, and the proxy hides them until you sign in. After that, reconnect from either; the model list stays cached for the sign-in, so LiteLLM keeps appearing even after the session ends. +- The session lives in OpenCode 1's `auth.json` like any login (the sign-in's details in `accountId`, the only field OpenCode 1 keeps). The plugin refreshes it shortly before it expires, sends the current token with every request (including the `-responses` sibling), and saves rotated tokens back; when another OpenCode process sharing the file refreshed first, it uses that one. +- A configured `apiKey` still wins over a sign-in, and a sign-in wins over `LITELLM_API_KEY` / `LITELLM_MASTER_KEY`, even once it expired: requests then ask you to connect again. Without a configured `baseURL`, the plugin uses the proxy the sign-in came from; a `baseURL` configured for another proxy turns the sign-in off (with a warning in the log), so its token never reaches that host. +- Only the default `litellm` provider id can sign in: OpenCode 1 gives a plugin one auth entry. OpenCode 2 imports OpenCode 1 logins under a method this plugin does not claim, so connect again after upgrading. 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 sign-in is keyed by its credential id instead, because its access token rotates on every refresh. A `credential.switched` event also recomputes the effective key and reloads the provider registry when it changed โ€” including the first credential saved after setup. diff --git a/src/plugin/auth.ts b/src/plugin/auth.ts new file mode 100644 index 0000000..985ed27 --- /dev/null +++ b/src/plugin/auth.ts @@ -0,0 +1,312 @@ +import type { AuthHook, AuthOAuthResult, PluginInput } from '@opencode-ai/plugin' +import { getOpenCodeStoredSso, toOpenCodeOAuthEntry } from '../utils/opencode-auth' +import { + authorizeLiteLLM, + authorizeOidc, + parseOidcConfig, + refreshSsoToken, + signInRoot, + type SsoAuthorization, + type SsoToken, +} from '../utils/sso' + +/** Where the config hook found the provider; a sign-in goes to its proxy, with its gateway headers. */ +export interface SignInTarget { + /** The provider's configured base URL, which discovery always uses. */ + baseURL: string | undefined + /** The proxy of the stored sign-in, the default for a provider configured nowhere. */ + previousURL: string | undefined + headers: Record +} + +// OpenCode 2 refreshes credentials this long before they expire; OpenCode 1 leaves it to plugins. +const REFRESH_LEAD_MS = 5 * 60 * 1000 +// After a refresh that did not happen (an unavailable token endpoint), requests use the current +// token for this long instead of each waiting on the token endpoint again. +const RETRY_MS = 30 * 1000 + +export interface SsoSession { + /** The stored sign-in, expired or not, or undefined without one. */ + stored(): Promise + /** A live access token; throws when the sign-in expired and cannot be refreshed. */ + access(headers?: Record): Promise +} + +interface SessionState { + token?: Promise + refreshing?: Promise + retryAt?: number +} + +// One state per provider for the whole process: OpenCode 1 can keep calling a fetch an earlier +// plugin instance wrote into its cached config, and every caller must share one refresh. +const sessions = new Map() + +/** + * The OpenCode 1 side of a LiteLLM sign-in: the session OpenCode stored in + * auth.json, refreshed when it nears expiry and saved back through the + * OpenCode client, because OpenCode 1 does not refresh plugin credentials. + */ +export function createSsoSession( + client: PluginInput['client'], + providerID: string, + warn: (message: string) => void, +): SsoSession { + const state = sessions.get(providerID) ?? {} + sessions.set(providerID, state) + // A new plugin instance follows a login, so it reads auth.json again. + state.token = undefined + state.retryAt = undefined + const stored = () => (state.token ??= getOpenCodeStoredSso(providerID)) + + async function refresh(current: SsoToken, headers?: Record): Promise { + const latest = await getOpenCodeStoredSso(providerID) + // Never overwrite a credential saved since: a logout, an API key, or another sign-in. + if (!latest) throw new Error('The LiteLLM sign-in was removed; connect LiteLLM again') + if (latest.access !== current.access) { + // Another OpenCode process refreshed it; a sign-in to another proxy must not inherit this one's requests. + if (latest.metadata.baseURL !== current.metadata.baseURL) { + throw new Error('The LiteLLM sign-in changed; connect LiteLLM again') + } + return latest + } + const next = await refreshSsoToken(current, headers) + if (next === current) { + state.retryAt = Date.now() + RETRY_MS + return current + } + // A credential saved during the refresh wins on disk; this process keeps its own tokens. + if ((await getOpenCodeStoredSso(providerID))?.access === current.access) { + const saved = await client.auth + .set({ path: { id: providerID }, body: toOpenCodeOAuthEntry(next) }) + .catch((error: unknown) => ({ error })) + if (saved.error) { + warn( + '[opencode-litellm] Could not save the refreshed LiteLLM sign-in; the next start may ask for a new one.', + ) + } + } + return next + } + + return { + stored, + async access(headers) { + const current = await stored() + if (!current) return undefined + const now = Date.now() + if (current.expires > now + REFRESH_LEAD_MS) return current.access + if (current.expires > now && state.retryAt !== undefined && now < state.retryAt) + return current.access + // One refresh at a time: a rotated refresh token cannot be spent twice. + state.refreshing ??= refresh(current, headers) + .then((next) => { + state.token = Promise.resolve(next) + return next + }) + .finally(() => { + state.refreshing = undefined + }) + return (await state.refreshing).access + }, + } +} + +/** Sends the sign-in's live token on every request; OpenCode 1 builds requests from static options. */ +export function ssoFetch(session: SsoSession, headers?: Record): typeof fetch { + return async (input, init) => { + const access = await session.access(headers) + const requestHeaders = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ) + if (access) requestHeaders.set('Authorization', `Bearer ${access}`) + return fetch(input, { ...init, headers: requestHeaders }) + } +} + +function text(value: string | undefined): string | undefined { + return value?.trim() || undefined +} + +/** Wraps a sign-in for OpenCode 1, which stores a successful result in auth.json. */ +function toAuthResult(authorization: SsoAuthorization, baseURL?: string): AuthOAuthResult { + const success = (token: SsoToken) => { + const { type: _, ...entry } = toOpenCodeOAuthEntry({ + ...token, + metadata: { ...token.metadata, ...(baseURL ? { baseURL } : {}) }, + }) + return { type: 'success' as const, ...entry } + } + const { url, instructions } = authorization + if (authorization.mode === 'code') { + return { + url, + instructions, + method: 'code', + callback: async (code) => success(await authorization.callback(code)), + } + } + return { + url, + instructions, + method: 'auto', + callback: async () => success(await authorization.callback), + } +} + +type Prompt = NonNullable< + Extract['prompts'] +>[number] + +// OpenCode Desktop cannot submit an empty text prompt, so each optional answer sits behind a select. +const proxyPrompts: Prompt[] = [ + { + type: 'select', + key: 'proxy', + message: 'LiteLLM proxy', + options: [ + { + label: 'Configured or previous proxy', + value: 'known', + hint: 'the provider baseURL, the last sign-in, or LITELLM_BASE_URL', + }, + { label: 'Another URLโ€ฆ', value: 'other' }, + ], + }, + { + type: 'text', + key: 'baseURL', + message: 'LiteLLM proxy URL', + placeholder: 'https://llm.example.com', + when: { key: 'proxy', op: 'eq', value: 'other' }, + }, +] + +/** The proxy a sign-in goes to: a typed URL, else the configured one, the previous sign-in's, or the environment's. */ +function proxyFor( + inputs: Record, + target: SignInTarget, + providerID: string, +): string | undefined { + const typed = inputs.proxy === 'other' ? text(inputs.baseURL) : undefined + if (inputs.proxy === 'other' && !typed) throw new Error('Enter the LiteLLM proxy URL') + // The previous sign-in's proxy is only a default: unlike configuration, signing in elsewhere replaces it. + const previous = target.baseURL ? undefined : target.previousURL + return signInRoot(typed ?? previous, target.baseURL, providerID) +} + +/** + * The `/connect` and `opencode auth login` entries for the LiteLLM provider: + * LiteLLM SSO, a direct sign-in with an OpenID Connect identity provider, or + * an API key. `target` reports where the config hook found the provider. + */ +export function liteLLMAuthHook(providerID: string, target: () => SignInTarget): AuthHook { + return { + provider: providerID, + methods: [ + { + type: 'oauth', + label: 'LiteLLM SSO', + prompts: [ + ...proxyPrompts, + { + type: 'select', + key: 'teamChoice', + message: 'LiteLLM team', + options: [ + { + label: 'Default', + value: 'default', + hint: 'LiteLLM asks only when your account has several', + }, + { label: 'A specific teamโ€ฆ', value: 'other' }, + ], + }, + { + type: 'text', + key: 'team', + message: 'LiteLLM team ID or alias', + when: { key: 'teamChoice', op: 'eq', value: 'other' }, + }, + ], + authorize: async (inputs = {}) => { + const baseURL = proxyFor(inputs, target(), providerID) + if (!baseURL) throw new Error('Enter the LiteLLM proxy URL, or set LITELLM_BASE_URL') + const team = inputs.teamChoice === 'other' ? text(inputs.team) : undefined + return toAuthResult(await authorizeLiteLLM(baseURL, target().headers, team)) + }, + }, + { + type: 'oauth', + label: 'Identity provider (OIDC)', + prompts: [ + ...proxyPrompts, + { + type: 'text', + key: 'issuer', + message: "Your identity provider's issuer URL", + placeholder: 'https://idp.example.com', + }, + { + type: 'text', + key: 'clientId', + message: + 'Public client ID (no secret) for the redirect http://127.0.0.1:/callback', + }, + { + type: 'select', + key: 'scope', + message: 'Scopes', + options: [ + { label: 'openid', value: 'openid' }, + { + label: 'openid offline_access', + value: 'openid offline_access', + hint: 'some identity providers only issue refresh tokens with it', + }, + { label: 'Other scopesโ€ฆ', value: 'other' }, + ], + }, + { + type: 'text', + key: 'customScope', + message: 'Scopes, space-separated, including openid', + when: { key: 'scope', op: 'eq', value: 'other' }, + }, + { + type: 'select', + key: 'ports', + message: 'Redirect port', + options: [ + { label: 'Any free port', value: 'any' }, + { + label: 'Specific portsโ€ฆ', + value: 'specific', + hint: 'for identity providers that only accept exact registered redirect URIs', + }, + ], + }, + { + type: 'text', + key: 'redirectPorts', + message: 'Ports to try, in order', + placeholder: '8400, 8401', + when: { key: 'ports', op: 'eq', value: 'specific' }, + }, + ], + authorize: async (inputs = {}) => { + // Nothing is sent to the proxy, so its URL is only recorded for discovery. + const baseURL = proxyFor(inputs, target(), providerID) + const config = parseOidcConfig({ + issuer: text(inputs.issuer), + clientId: text(inputs.clientId), + scope: inputs.scope === 'other' ? text(inputs.customScope) : text(inputs.scope), + redirectPorts: inputs.ports === 'specific' ? text(inputs.redirectPorts) : undefined, + }) + return toAuthResult(await authorizeOidc(config), baseURL) + }, + }, + { type: 'api', label: 'API key' }, + ], + } +} diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 4bb4a73..39b5ec9 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -13,7 +13,15 @@ import { } from '../utils/format-model-name' import { requiresResponsesAPI } from '../utils/transport' import type { LiteLLMModel, LiteLLMModelInfo } from '../types' -import { getOpenCodeStoredApiKey } from '../utils/opencode-auth' +import { getOpenCodeStoredApiKey, resetOpenCodeAuthCache } from '../utils/opencode-auth' +import { sameProxy, ssoIdentity } from '../utils/sso' +import { + createSsoSession, + liteLLMAuthHook, + ssoFetch, + type SignInTarget, + type SsoSession, +} from './auth' import type { ModelHints } from '../utils/model-cache' import { buildCacheKey, @@ -34,6 +42,8 @@ const CHAT_PROVIDER_ID = 'litellm' // with it so it never treats its own sibling as a chat provider. const RESPONSES_PROVIDER_SUFFIX = '-responses' const RESPONSES_NPM = '@ai-sdk/openai' +// `@ai-sdk/openai` throws without an API key, even when the sign-in's `fetch` sends the real token. +const SIGN_IN_PLACEHOLDER = 'litellm-sign-in' // Covers the 3 s health check plus the parallel models/model-info fetch // phase, with headroom. Scales with LITELLM_REQUEST_TIMEOUT_MS so slow // proxies aren't cut off by the overall cap either (issue #20). @@ -98,6 +108,8 @@ const injectedModelIds = new Map>() interface RefreshContext { baseURL: string apiKey?: string + /** A LiteLLM sign-in, whose rotating access token replaces `apiKey`. */ + session?: SsoSession customHeaders?: Record filters: ModelFilters capabilities: ModelCapabilities @@ -106,6 +118,23 @@ interface RefreshContext { } const refreshContexts = new Map() +/** + * OpenCode 1 caches the global config for the whole process, so after a + * login rebuilds the instance a provider's options come back still holding + * what this plugin wrote. Only the values the user configured count as + * configuration: this snapshot is taken before the plugin first writes. + */ +const configuredOptions = new WeakMap>() + +function userOptions(options: Record): Record { + let configured = configuredOptions.get(options) + if (!configured) { + configured = { ...options } + configuredOptions.set(options, configured) + } + return configured +} + /** Cache keys with an in-flight background refresh, to avoid pile-ups. */ const refreshInFlight = new Set() @@ -535,6 +564,7 @@ function ensureResponsesProvider( baseURL: string, apiKey: string | undefined, customHeaders: Record | undefined, + runtimeFetch: typeof fetch | undefined, ): Record { const id = `${baseProviderId}${RESPONSES_PROVIDER_SUFFIX}` if (!config.provider[id] || typeof config.provider[id] !== 'object') { @@ -553,8 +583,15 @@ function ensureResponsesProvider( provider.options = {} } const options = provider.options as Record - if (!options.baseURL) options.baseURL = `${baseURL}/v1` - if (!options.apiKey && apiKey) options.apiKey = apiKey + const configured = userOptions(options) + if (!configured.baseURL) options.baseURL = `${baseURL}/v1` + if (!configured.apiKey) { + const key = apiKey ?? (runtimeFetch ? SIGN_IN_PLACEHOLDER : undefined) + if (key) options.apiKey = key + else delete options.apiKey + } + if (runtimeFetch) options.fetch = runtimeFetch + else if (configured.fetch === undefined) delete options.fetch // OpenCode 1 reads provider headers from `options.headers` and hands them to // the runtime package (`@ai-sdk/openai` here); it has no `customHeaders` key. // Mirror the chat provider's native headers and promote the plugin's @@ -605,6 +642,7 @@ function injectDiscoveredModels( baseURL: string, apiKey: string | undefined, customHeaders: Record | undefined, + runtimeFetch?: typeof fetch, ): string[] { const responsesSet = new Set(responsesIds) const existingSibling = config.provider[`${baseProviderId}${RESPONSES_PROVIDER_SUFFIX}`] @@ -640,6 +678,7 @@ function injectDiscoveredModels( baseURL, apiKey, customHeaders, + runtimeFetch, ) added.push(...mergeModels(siblingModels, responsesEntries)) } @@ -664,10 +703,11 @@ async function backgroundRefresh(cacheKey: string): Promise { } refreshInFlight.add(cacheKey) try { + const apiKey = ctx.session ? await ctx.session.access(ctx.customHeaders) : ctx.apiKey const discovered = await withTimeout( discoverModels( ctx.baseURL, - ctx.apiKey, + apiKey, ctx.customHeaders, ctx.providerId, ctx.filters, @@ -716,7 +756,13 @@ async function backgroundRefresh(cacheKey: string): Promise { */ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { initLogging(input.client) + // OpenCode rebuilds the plugin after a login; this instance must read the new credential. + resetOpenCodeAuthCache() + const warn = (message: string) => log('warn', message) + const session = createSsoSession(input.client, CHAT_PROVIDER_ID, warn) + let signInTarget: SignInTarget = { baseURL: undefined, previousURL: undefined, headers: {} } return { + auth: liteLLMAuthHook(CHAT_PROVIDER_ID, () => signInTarget), config: async (config: any) => { // Ensure the provider entry exists if (!config.provider) config.provider = {} @@ -756,7 +802,9 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { // Process each matched provider for (const { id: providerId, provider } of liteLLMProviders) { - const options = (provider.options ?? {}) as Record + // Snapshot the object this plugin writes into, so a later run recognizes those writes. + if (!provider.options || typeof provider.options !== 'object') provider.options = {} + const options = userOptions(provider.options as Record) const configuredBase = typeof options.baseURL === 'string' ? options.baseURL : undefined const configuredKey = @@ -779,11 +827,46 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { override of a user's saved key. */ const storedKey = await getOpenCodeStoredApiKey(providerId) - 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. const customHeaders = readProviderHeaders(options) + // A LiteLLM sign-in for the provider the auth hook serves is a stored + // credential too: it beats the environment, and a configured key beats it. + let sso = + providerId === CHAT_PROVIDER_ID && !configuredKey ? await session.stored() : undefined + const signedInTo = sso?.metadata.baseURL + if (configuredBase && typeof signedInTo === 'string' && !sameProxy(configuredBase, signedInTo)) { + // The provider points elsewhere, and that host must not receive this sign-in's token. + log( + 'warn', + `[opencode-litellm] Not using the LiteLLM sign-in for ${signedInTo}: provider "${providerId}" is configured for ${configuredBase}.`, + ) + sso = undefined + } + if (providerId === CHAT_PROVIDER_ID) { + const previousURL = sso?.metadata.baseURL + signInTarget = { + baseURL: configuredBase, + previousURL: typeof previousURL === 'string' ? previousURL : undefined, + headers: customHeaders ?? {}, + } + } + const ssoKey = sso + ? await session.access(customHeaders).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + log('warn', `[opencode-litellm] LiteLLM sign-in unusable: ${message}`) + return undefined + }) + : undefined + // An unusable sign-in still outranks the environment: discovery goes without + // a key (an empty one also stops the discovery helpers' own environment + // fallback), and requests ask to connect again. + const apiKey = configuredKey ?? storedKey ?? (sso ? (ssoKey ?? '') : envKey) + // A sign-in's token rotates, so it goes out per request instead of + // sitting in `apiKey`. + const runtimeKey = sso ? undefined : apiKey + const runtimeFetch = sso ? ssoFetch(session, customHeaders) : undefined const filters = readModelFilters(options) const capabilities = parseModelCapabilities(options.modelCapabilities) const formatModelNames = readFormatModelNames(options) @@ -792,8 +875,12 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { let baseURL: string | null = null if (configuredBase) { baseURL = normalizeBaseURL(configuredBase) + } else if (typeof sso?.metadata.baseURL === 'string') { + // The proxy the sign-in was issued by. + baseURL = normalizeBaseURL(sso.metadata.baseURL) } else { - baseURL = await autoDetectLiteLLM(apiKey, customHeaders) + // Local port probes never carry a sign-in's token. + baseURL = await autoDetectLiteLLM(sso ? undefined : apiKey, customHeaders) } if (!baseURL) { @@ -818,7 +905,7 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { actualProvider.options = {} } const actualOptions = actualProvider.options as Record - if (!actualOptions.baseURL) { + if (!configuredBase) { actualOptions.baseURL = `${baseURL}/v1` } /* @@ -832,9 +919,13 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { go out with no Authorization header even though discovery succeeded using the same resolved key. */ - if (!actualOptions.apiKey && apiKey) { - actualOptions.apiKey = apiKey + // Recomputed every run, so an earlier credential never lingers (see userOptions). + if (!configuredKey) { + if (runtimeKey) actualOptions.apiKey = runtimeKey + else delete actualOptions.apiKey } + if (runtimeFetch) actualOptions.fetch = runtimeFetch + else if (options.fetch === undefined) delete actualOptions.fetch if (!actualProvider.models) { actualProvider.models = {} @@ -847,16 +938,23 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { // 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. + // instead of serving another credential's cached model list. A sign-in + // keeps one key while its token rotates, and even once it expired, so + // the provider keeps its models and can be reconnected from `/connect`. const cacheKey = buildCacheKey(providerId, baseURL, filters, capabilities, { formatModelNames, - }, apiKey) + }, sso ? ssoIdentity(sso) : apiKey) // Remember how to reach this proxy so the `event` hook can // revalidate its cache in the background on new sessions. + // Only this run's context refreshes in the background: an earlier one may hold another proxy. + for (const [key, context] of refreshContexts) { + if (context.providerId === providerId && key !== cacheKey) refreshContexts.delete(key) + } refreshContexts.set(cacheKey, { baseURL, apiKey, + session: sso ? session : undefined, customHeaders, filters, capabilities, @@ -904,8 +1002,9 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { cached.models, cached.responsesIds, baseURL, - apiKey, + runtimeKey, customHeaders, + runtimeFetch, ) injectedModelIds.set(cacheKey, new Set(added)) log( @@ -939,8 +1038,9 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { discovered.models, discovered.responsesIds, baseURL, - apiKey, + runtimeKey, customHeaders, + runtimeFetch, ) injectedModelIds.set(cacheKey, new Set(added)) writeModelCache(cacheKey, discovered.models, discovered.responsesIds, discovered.hints) diff --git a/src/plugin/integration.ts b/src/plugin/integration.ts index 92542e7..bd94e18 100644 --- a/src/plugin/integration.ts +++ b/src/plugin/integration.ts @@ -6,7 +6,7 @@ import { authorizeOidc, parseOidcConfig, refreshSsoToken, - ssoRoot, + signInRoot, type SsoAuthorization, type SsoToken, } from '../utils/sso' @@ -90,25 +90,15 @@ const signInBaseURLField = { placeholder: 'https://llm.example.com', } as const -/** - * Proxy URL, provider id, and gateway headers for a sign-in. Explicit - * configuration wins in discovery, so a typed URL that differs from it is - * refused: the token would be sent to a proxy that did not issue it. - */ +/** Proxy URL, provider id, and gateway headers for a sign-in. */ async function signInTarget( answer: Record, resolveTarget: SignInTargetResolver, ): Promise<{ providerID: string; baseURL: string | undefined; headers: Record }> { const providerID = readAnswer(answer, 'providerID') ?? LITELLM_INTEGRATION_ID const target = await resolveTarget(providerID) - const typed = readAnswer(answer, 'baseURL') - if (typed && target.baseURL && ssoRoot(typed) !== ssoRoot(target.baseURL)) { - throw new Error( - `Provider "${providerID}" is configured for ${target.baseURL}; leave Base URL empty, or connect under another Provider ID`, - ) - } - const baseURL = typed ?? target.baseURL ?? (process.env.LITELLM_BASE_URL?.trim() || undefined) - return { providerID, baseURL: baseURL && ssoRoot(baseURL), headers: target.headers } + const baseURL = signInRoot(readAnswer(answer, 'baseURL'), target.baseURL, providerID) + return { providerID, baseURL, headers: target.headers } } /** Wraps a sign-in for the host, storing the session as an OAuth credential of `methodID`. */ diff --git a/src/utils/opencode-auth.ts b/src/utils/opencode-auth.ts index e6ca729..37ddd4d 100644 --- a/src/utils/opencode-auth.ts +++ b/src/utils/opencode-auth.ts @@ -1,11 +1,12 @@ import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' +import type { SsoToken } from './sso' /** * Shape of an `api`-type entry in OpenCode's own credential store. OpenCode - * also stores `oauth` and `wellknown` entries there, but LiteLLM proxies - * only ever use a plain API key, so that's the only variant we care about. + * also stores `oauth` (a LiteLLM sign-in, see `getOpenCodeStoredSso`) and + * `wellknown` entries there. */ interface OpenCodeApiAuthEntry { type: 'api' @@ -33,22 +34,22 @@ function resolveAuthPath(): string { // (model discovery itself is cached per baseURL for the same reason). let cachedAuthFile: Promise | null = null -async function loadOpenCodeAuthFile(): Promise { - if (!cachedAuthFile) { - cachedAuthFile = (async () => { - try { - const raw = await readFile(resolveAuthPath(), 'utf-8') - const parsed: unknown = JSON.parse(raw) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as OpenCodeAuthFile - } - } catch { - // File doesn't exist, isn't readable, or isn't valid JSON โ€” treat - // as "no stored credentials" rather than failing plugin startup. - } - return null - })() +async function readOpenCodeAuthFile(): Promise { + try { + const raw = await readFile(resolveAuthPath(), 'utf-8') + const parsed: unknown = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as OpenCodeAuthFile + } + } catch { + // File doesn't exist, isn't readable, or isn't valid JSON โ€” treat + // as "no stored credentials" rather than failing plugin startup. } + return null +} + +async function loadOpenCodeAuthFile(): Promise { + cachedAuthFile ??= readOpenCodeAuthFile() return cachedAuthFile } @@ -77,7 +78,57 @@ export async function getOpenCodeStoredApiKey(providerId: string): Promise { + const entry = (await readOpenCodeAuthFile())?.[providerId] + if ( + entry?.type !== 'oauth' || + typeof entry.access !== 'string' || + typeof entry.refresh !== 'string' || + typeof entry.expires !== 'number' || + typeof entry.accountId !== 'string' + ) { + return undefined + } + try { + const metadata: unknown = JSON.parse(entry.accountId) + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return undefined + return { + access: entry.access, + refresh: entry.refresh, + expires: entry.expires, + metadata: metadata as Record, + } + } catch { + // Not one of this plugin's sign-ins. + return undefined + } +} diff --git a/src/utils/sso.ts b/src/utils/sso.ts index 8db4353..dc7fb63 100644 --- a/src/utils/sso.ts +++ b/src/utils/sso.ts @@ -161,6 +161,42 @@ export function ssoRoot(baseURL: string): string { return normalizeBaseURL(url.href) } +/** + * The proxy a sign-in for `providerID` goes to. Explicit configuration wins in discovery, so a typed + * URL that differs from it is refused: the token would be sent to a proxy that did not issue it. + */ +export function signInRoot( + typed: string | undefined, + configured: string | undefined, + providerID: string, +): string | undefined { + if (typed && configured && ssoRoot(typed) !== ssoRoot(configured)) { + throw new Error( + `Provider "${providerID}" is configured for ${configured}; leave Base URL empty to sign in there`, + ) + } + const baseURL = typed ?? configured ?? (process.env.LITELLM_BASE_URL?.trim() || undefined) + return baseURL && ssoRoot(baseURL) +} + +/** Whether two base URLs name the same proxy. */ +export function sameProxy(a: string, b: string): boolean { + try { + return ssoRoot(a) === ssoRoot(b) + } catch { + return false + } +} + +/** What a sign-in's model cache is keyed by: stable while its tokens rotate, new for a new sign-in. */ +export function ssoIdentity(token: SsoToken): string { + const { flow, clientId, issuer, subject } = token.metadata + if (flow === PKCE_FLOW) return `${flow} ${String(clientId)}` + if (flow === OIDC_FLOW) return `${flow} ${String(issuer)} ${String(subject)}` + // CLI SSO and UI tokens are never refreshed. + return token.access +} + function canonicalIssuer(value: string): string { const url = new URL(value) if (url.username || url.password || url.search || url.hash) @@ -757,7 +793,8 @@ export async function authorizeLiteLLM( return { mode: 'auto', url: `${root}/sso/key/generate?${new URLSearchParams({ source: 'litellm-cli', key: start.loginId })}`, - instructions: `Sign in to LiteLLM in your browser and confirm the code ${start.userCode}.`, + // OpenCode 1's connect dialog shows the text after the last colon as the code to confirm. + instructions: `Sign in to LiteLLM in your browser and confirm this code: ${start.userCode}`, expiresAt: expiresIn(start.expiresInSeconds), callback: handled(pollCliSso(root, start, headers, team)), } diff --git a/test/auth-v1.test.ts b/test/auth-v1.test.ts new file mode 100644 index 0000000..4aade95 --- /dev/null +++ b/test/auth-v1.test.ts @@ -0,0 +1,819 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import type { PluginInput } from '@opencode-ai/plugin' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { LiteLLMPlugin } from '../src/plugin' +import { buildCacheKey, readModelCacheEntry } from '../src/utils/model-cache' +import { resetOpenCodeAuthCache } from '../src/utils/opencode-auth' + +const BASE = 'http://127.0.0.1:44521' +const TOKEN_ENDPOINT = `${BASE}/cli/token` +const METADATA = { + flow: 'litellm_cli_pkce', + baseURL: BASE, + clientId: 'example-client', + tokenEndpoint: TOKEN_ENDPOINT, + resource: `${BASE}/`, +} + +interface Recorded { + url: string + method: string + authorization: string | null + headers: Headers + body: string +} + +interface ProviderConfig { + npm?: string + options: Record + models: Record +} + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name] + else process.env[name] = value +} + +describe('OpenCode 1 LiteLLM sign-in', () => { + const originalFetch = globalThis.fetch + let originalCacheHome: string | undefined + let originalHome: string | undefined + let originalLiteLLMApiKey: string | undefined + let originalLiteLLMBaseURL: string | undefined + let originalLiteLLMMasterKey: string | undefined + let cacheDirectory: string + let homeDirectory: string + let requests: Recorded[] + + beforeEach(() => { + originalCacheHome = process.env.XDG_CACHE_HOME + originalHome = process.env.HOME + originalLiteLLMApiKey = process.env.LITELLM_API_KEY + originalLiteLLMBaseURL = process.env.LITELLM_BASE_URL + delete process.env.LITELLM_BASE_URL + originalLiteLLMMasterKey = process.env.LITELLM_MASTER_KEY + delete process.env.LITELLM_API_KEY + delete process.env.LITELLM_MASTER_KEY + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-auth-cache-')) + homeDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-auth-home-')) + process.env.XDG_CACHE_HOME = cacheDirectory + process.env.HOME = homeDirectory + requests = [] + resetOpenCodeAuthCache() + }) + + afterEach(() => { + globalThis.fetch = originalFetch + restoreEnv('XDG_CACHE_HOME', originalCacheHome) + restoreEnv('HOME', originalHome) + restoreEnv('LITELLM_API_KEY', originalLiteLLMApiKey) + restoreEnv('LITELLM_BASE_URL', originalLiteLLMBaseURL) + restoreEnv('LITELLM_MASTER_KEY', originalLiteLLMMasterKey) + rmSync(cacheDirectory, { recursive: true, force: true }) + rmSync(homeDirectory, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + function writeAuthFile(litellm: Record | undefined): void { + const path = join(homeDirectory, '.local', 'share', 'opencode', 'auth.json') + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, JSON.stringify(litellm ? { litellm } : {})) + } + + function pkceEntry(overrides: { access?: string; refresh?: string; expires?: number } = {}) { + return { + type: 'oauth', + access: 'example-access', + refresh: 'example-refresh', + expires: Date.now() + 60 * 60 * 1000, + accountId: JSON.stringify(METADATA), + ...overrides, + } + } + + /** Records every request; `respond` may answer some, the rest get the proxy defaults. */ + function mockFetch(respond: (request: Recorded) => Response | undefined = () => undefined): void { + globalThis.fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const headers = new Headers(init?.headers) + const request: Recorded = { + url: String(input), + method: init?.method ?? 'GET', + authorization: headers.get('Authorization'), + headers, + body: init?.body === undefined ? '' : String(init.body), + } + requests.push(request) + const answer = respond(request) + if (answer) return answer + if (request.url.endsWith('/v1/model/info')) { + return new Response(JSON.stringify({ data: [] }), { status: 200 }) + } + if (request.url.endsWith('/v1/models')) { + return new Response(JSON.stringify({ data: [{ id: 'example-model' }] }), { status: 200 }) + } + return new Response('{}', { status: 404 }) + }) as typeof fetch + } + + function tokenSuccess(access: string, refresh: string): Response { + return new Response( + JSON.stringify({ + access_token: access, + refresh_token: refresh, + token_type: 'Bearer', + expires_in: 3600, + }), + { status: 200 }, + ) + } + + function createInput() { + const client = { + app: { log: vi.fn(async () => {}) }, + auth: { set: vi.fn(async () => ({ data: true })) }, + } + return { client, input: { client } as unknown as PluginInput } + } + + function createConfig(options: Record): { + provider: { litellm: ProviderConfig } + } { + return { + provider: { + litellm: { npm: '@ai-sdk/openai-compatible', options, models: {} }, + }, + } + } + + /** Moves past the 30 s in which a failed refresh is not retried. */ + function skipRetryWindow(): void { + const now = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(now + 31 * 1000) + } + + const discoveryRequests = () => requests.filter((request) => request.url.endsWith('/v1/models')) + const tokenRequests = () => requests.filter((request) => request.url === TOKEN_ENDPOINT) + + it('exposes the LiteLLM auth hook with its three sign-in methods', async () => { + mockFetch() + const hooks = await LiteLLMPlugin(createInput().input) + + expect(hooks.auth?.provider).toBe('litellm') + expect(hooks.auth?.methods.map((method) => method.label)).toEqual([ + 'LiteLLM SSO', + 'Identity provider (OIDC)', + 'API key', + ]) + expect( + hooks.auth?.methods.map((method) => + 'prompts' in method ? (method.prompts ?? []).map((prompt) => prompt.key) : [], + ), + ).toEqual([ + ['proxy', 'baseURL', 'teamChoice', 'team'], + ['proxy', 'baseURL', 'issuer', 'clientId', 'scope', 'customScope', 'ports', 'redirectPorts'], + [], + ]) + // OpenCode Desktop cannot submit an empty text prompt: every optional text sits behind a select. + for (const method of hooks.auth!.methods) { + const prompts = 'prompts' in method ? (method.prompts ?? []) : [] + const optional = prompts.filter((prompt) => prompt.type === 'text' && prompt.when) + for (const prompt of optional) { + expect(prompts.find((gate) => gate.key === prompt.when!.key)?.type).toBe('select') + } + } + }) + + it('signs in to the configured proxy with its gateway headers', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + mockFetch() + const hooks = await LiteLLMPlugin(createInput().input) + await hooks.config!( + createConfig({ + baseURL: 'https://llm.example.com/v1', + headers: { 'X-Gateway': 'example-gateway' }, + }) as never, + ) + + requests = [] + mockFetch((request) => { + if (request.url === 'https://llm.example.com/sso/cli/start') { + return new Response('', { status: 405 }) + } + if (request.url === 'https://llm.example.com/key/generate') { + return new Response('', { status: 403 }) + } + return undefined + }) + const method = hooks.auth!.methods[0] as Extract< + NonNullable['methods'][number], + { type: 'oauth' } + > + + const result = await method.authorize({}) + expect(result).toMatchObject({ + method: 'code', + url: 'https://llm.example.com/sso/key/generate', + }) + if (result.method !== 'code') throw new Error('expected the code method') + await expect(result.callback('example-ui-token')).resolves.toEqual({ + type: 'success', + access: 'example-ui-token', + refresh: '', + expires: Number.MAX_SAFE_INTEGER, + accountId: JSON.stringify({ baseURL: 'https://llm.example.com' }), + }) + + const start = requests.find((request) => request.url.endsWith('/sso/cli/start')) + expect(start?.method).toBe('POST') + expect(start?.headers.get('X-Gateway')).toBe('example-gateway') + await expect( + method.authorize({ proxy: 'other', baseURL: 'https://other.example.com' }), + ).rejects.toThrow('is configured for') + }) + + it('reconnects to the previous sign-in proxy when none is configured, and to another on request', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + writeAuthFile(pkceEntry()) + mockFetch() + const hooks = await LiteLLMPlugin(createInput().input) + await hooks.config!(createConfig({}) as never) + mockFetch((request) => + request.url.endsWith('/sso/cli/start') ? new Response('', { status: 405 }) : undefined, + ) + const method = hooks.auth!.methods[0] as Extract< + NonNullable['methods'][number], + { type: 'oauth' } + > + + await expect( + method.authorize({ proxy: 'known', teamChoice: 'default' }), + ).resolves.toMatchObject({ + url: `${BASE}/sso/key/generate`, + }) + await expect( + method.authorize({ + proxy: 'other', + baseURL: 'https://other.example.com', + teamChoice: 'default', + }), + ).resolves.toMatchObject({ url: 'https://other.example.com/sso/key/generate' }) + }) + + it('asks LiteLLM for a team only when one is chosen', async () => { + const polls: string[] = [] + mockFetch((request) => { + if (request.url.endsWith('/sso/cli/start')) { + return Response.json({ + login_id: 'example-login', + poll_secret: 'example-poll', + user_code: 'EXAMPLE-1', + }) + } + if (request.url.includes('/sso/cli/poll/')) { + const team = new URL(request.url).searchParams.get('team_id') + polls.push(team ?? '') + return team + ? Response.json({ status: 'ready', key: 'example-cli-token', expires_in: 60 }) + : Response.json({ + status: 'ready', + requires_team_selection: true, + team_details: [{ team_id: 'team-1', team_alias: 'Research' }], + }) + } + return undefined + }) + const hooks = await LiteLLMPlugin(createInput().input) + await hooks.config!(createConfig({ baseURL: `${BASE}/v1` }) as never) + const method = hooks.auth!.methods[0] as Extract< + NonNullable['methods'][number], + { type: 'oauth' } + > + + const withTeam = await method.authorize({ + proxy: 'known', + teamChoice: 'other', + team: 'Research', + }) + if (withTeam.method !== 'auto') throw new Error('expected the auto method') + await expect(withTeam.callback()).resolves.toMatchObject({ access: 'example-cli-token' }) + // A team typed earlier does not count once the user picks the default. + const withoutTeam = await method.authorize({ + proxy: 'known', + teamChoice: 'default', + team: 'Research', + }) + if (withoutTeam.method !== 'auto') throw new Error('expected the auto method') + await expect(withoutTeam.callback()).rejects.toThrow('LiteLLM asks which team to sign in with') + expect(polls).toEqual(['', 'team-1', '']) + }) + + it('maps the OIDC scope and port choices onto the sign-in', async () => { + const issuer = 'https://idp.example.com' + mockFetch((request) => + request.url === `${issuer}/.well-known/openid-configuration` + ? Response.json({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + }) + : undefined, + ) + const hooks = await LiteLLMPlugin(createInput().input) + const method = hooks.auth!.methods[1] as Extract< + NonNullable['methods'][number], + { type: 'oauth' } + > + const signIn = async (inputs: Record) => { + const result = await method.authorize({ + proxy: 'known', + issuer, + clientId: 'example-client', + ...inputs, + }) + const url = new URL(result.url) + // Settle the sign-in so its loopback listener closes. + const callback = new URL(url.searchParams.get('redirect_uri')!) + callback.searchParams.set('state', url.searchParams.get('state')!) + callback.searchParams.set('error', 'access_denied') + await originalFetch(callback) + if (result.method === 'auto') await result.callback().catch(() => {}) + return { scope: url.searchParams.get('scope'), port: new URL(callback).port } + } + + await expect(signIn({ scope: 'openid offline_access', ports: 'any' })).resolves.toMatchObject({ + scope: 'openid offline_access', + }) + const custom = await signIn({ + scope: 'other', + customScope: 'openid profile', + ports: 'specific', + redirectPorts: '45871', + }) + expect(custom).toEqual({ scope: 'openid profile', port: '45871' }) + // Ports typed earlier do not count once the user picks any free port. + const any = await signIn({ scope: 'openid', ports: 'any', redirectPorts: '45871' }) + expect(any.port).not.toBe('45871') + }) + + it('discovers from the stored sign-in when no baseURL is configured', async () => { + writeAuthFile(pkceEntry()) + mockFetch() + const hooks = await LiteLLMPlugin(createInput().input) + const config = createConfig({}) + + await hooks.config!(config as never) + + const options = config.provider.litellm.options + expect(discoveryRequests().length).toBeGreaterThan(0) + for (const request of discoveryRequests()) { + expect(request.url).toBe(`${BASE}/v1/models`) + expect(request.authorization).toBe('Bearer example-access') + } + expect(options.baseURL).toBe(`${BASE}/v1`) + expect(options.apiKey).toBeUndefined() + expect(typeof options.fetch).toBe('function') + + requests = [] + mockFetch() + await options.fetch(`${BASE}/v1/chat/completions`, { + headers: { Authorization: 'Bearer example-stale' }, + }) + expect(requests[0].authorization).toBe('Bearer example-access') + + const cacheKey = buildCacheKey('litellm', BASE, {}, {}, {}, 'litellm_cli_pkce example-client') + expect(readModelCacheEntry(cacheKey)).not.toBeNull() + }) + + it('prefers a configured apiKey over a stored sign-in', async () => { + writeAuthFile(pkceEntry()) + mockFetch() + const hooks = await LiteLLMPlugin(createInput().input) + const config = createConfig({ baseURL: `${BASE}/v1`, apiKey: 'example-configured-key' }) + + await hooks.config!(config as never) + + expect(discoveryRequests().length).toBeGreaterThan(0) + for (const request of discoveryRequests()) { + expect(request.authorization).toBe('Bearer example-configured-key') + } + expect(config.provider.litellm.options.fetch).toBeUndefined() + }) + + it('refreshes a nearly expired sign-in at startup and saves it through OpenCode', async () => { + writeAuthFile(pkceEntry({ expires: Date.now() + 60 * 1000 })) + mockFetch((request) => + request.url === TOKEN_ENDPOINT + ? tokenSuccess('example-access-2', 'example-refresh-2') + : undefined, + ) + const { client, input } = createInput() + const hooks = await LiteLLMPlugin(input) + + await hooks.config!(createConfig({}) as never) + + expect(tokenRequests()).toHaveLength(1) + expect(tokenRequests()[0].method).toBe('POST') + const form = new URLSearchParams(tokenRequests()[0].body) + expect(form.get('grant_type')).toBe('refresh_token') + expect(form.get('refresh_token')).toBe('example-refresh') + expect(form.get('client_id')).toBe('example-client') + expect(client.auth.set).toHaveBeenCalledTimes(1) + expect(client.auth.set).toHaveBeenCalledWith({ + path: { id: 'litellm' }, + body: expect.objectContaining({ + type: 'oauth', + access: 'example-access-2', + refresh: 'example-refresh-2', + expires: expect.any(Number), + accountId: JSON.stringify(METADATA), + }), + }) + expect(discoveryRequests()[0].authorization).toBe('Bearer example-access-2') + }) + + it('re-resolves credentials when OpenCode reuses one config object across plugin instances', async () => { + const envKey = 'example-env-key' + process.env.LITELLM_API_KEY = envKey + mockFetch() + const { input } = createInput() + const config = createConfig({ baseURL: `${BASE}/v1` }) + const options = config.provider.litellm.options + + const first = await LiteLLMPlugin(input) + await first.config!(config as never) + expect(options.apiKey).toBe(envKey) + expect(options.fetch).toBeUndefined() + + writeAuthFile(pkceEntry()) + const second = await LiteLLMPlugin(input) + await second.config!(config as never) + expect(options.apiKey).toBeUndefined() + expect(typeof options.fetch).toBe('function') + + writeAuthFile(undefined) + const third = await LiteLLMPlugin(input) + await third.config!(config as never) + expect(options.fetch).toBeUndefined() + expect(options.apiKey).toBe(envKey) + }) + + it('never overwrites a credential saved after the sign-in was read', async () => { + writeAuthFile(pkceEntry({ expires: Date.now() + 60 * 1000 })) + mockFetch((request) => + request.url === TOKEN_ENDPOINT ? new Response('{}', { status: 503 }) : undefined, + ) + const { client, input } = createInput() + const hooks = await LiteLLMPlugin(input) + const config = createConfig({}) + await hooks.config!(config as never) + expect(tokenRequests()).toHaveLength(1) + + writeAuthFile({ type: 'api', key: 'example-other-key' }) + skipRetryWindow() + + await expect( + config.provider.litellm.options.fetch(`${BASE}/v1/chat/completions`, {}), + ).rejects.toThrow('The LiteLLM sign-in was removed; connect LiteLLM again') + expect(client.auth.set).not.toHaveBeenCalled() + }) + + it('adopts a refresh another OpenCode process saved', async () => { + writeAuthFile(pkceEntry({ expires: Date.now() + 60 * 1000 })) + mockFetch((request) => + request.url === TOKEN_ENDPOINT ? new Response('{}', { status: 503 }) : undefined, + ) + const { client, input } = createInput() + const hooks = await LiteLLMPlugin(input) + const config = createConfig({}) + await hooks.config!(config as never) + expect(tokenRequests()).toHaveLength(1) + skipRetryWindow() + + writeAuthFile( + pkceEntry({ + access: 'example-access-other', + refresh: 'example-refresh-other', + expires: Date.now() + 60 * 60 * 1000, + }), + ) + requests = [] + await config.provider.litellm.options.fetch(`${BASE}/v1/chat/completions`, {}) + + expect(requests).toHaveLength(1) + expect(requests[0].authorization).toBe('Bearer example-access-other') + expect(tokenRequests()).toHaveLength(0) + expect(client.auth.set).not.toHaveBeenCalled() + }) + + it('refreshes once for concurrent requests, since a rotated refresh token is spent', async () => { + writeAuthFile(pkceEntry({ expires: Date.now() + 60 * 1000 })) + let tokenStatus = 503 + mockFetch((request) => { + if (request.url !== TOKEN_ENDPOINT) return undefined + return tokenStatus === 503 + ? new Response('{}', { status: 503 }) + : tokenSuccess('example-access-2', 'example-refresh-2') + }) + const { client, input } = createInput() + const hooks = await LiteLLMPlugin(input) + const config = createConfig({}) + await hooks.config!(config as never) + + tokenStatus = 200 + skipRetryWindow() + requests = [] + const send = () => config.provider.litellm.options.fetch(`${BASE}/v1/chat/completions`, {}) + await Promise.all([send(), send()]) + + expect(tokenRequests()).toHaveLength(1) + const chat = requests.filter((request) => request.url.endsWith('/chat/completions')) + expect(chat.map((request) => request.authorization)).toEqual([ + 'Bearer example-access-2', + 'Bearer example-access-2', + ]) + expect(client.auth.set).toHaveBeenCalledOnce() + }) + + it('reads a credential saved after an earlier plugin instance read auth.json', async () => { + const { input } = createInput() + mockFetch() + const config = createConfig({ baseURL: `${BASE}/v1` }) + await ( + await LiteLLMPlugin(input) + ).config!(config as never) + expect(config.provider.litellm.options.apiKey).toBeUndefined() + + // OpenCode 1 rebuilds the plugin after a login saved a key. + writeAuthFile({ type: 'api', key: 'example-stored-key' }) + requests = [] + await ( + await LiteLLMPlugin(input) + ).config!(config as never) + + expect(config.provider.litellm.options.apiKey).toBe('example-stored-key') + expect(discoveryRequests().length).toBeGreaterThan(0) + expect( + discoveryRequests().every((request) => request.authorization === 'Bearer example-stored-key'), + ).toBe(true) + }) + + it('does not send a sign-in to a provider configured for another proxy', async () => { + writeAuthFile(pkceEntry()) + mockFetch() + const { client, input } = createInput() + const hooks = await LiteLLMPlugin(input) + const config = createConfig({ baseURL: 'http://127.0.0.1:44599/v1' }) + await hooks.config!(config as never) + + expect(config.provider.litellm.options.fetch).toBeUndefined() + expect(requests.some((request) => request.authorization === 'Bearer example-access')).toBe( + false, + ) + expect(client.app.log).toHaveBeenCalledWith({ + body: expect.objectContaining({ + level: 'warn', + message: expect.stringContaining('Not using the LiteLLM sign-in'), + }), + }) + }) + + it('keeps an expired sign-in ahead of the environment key, so requests ask to connect again', async () => { + process.env.LITELLM_API_KEY = ['example', 'env', 'key'].join('-') + writeAuthFile({ + type: 'oauth', + access: 'example-cli-token', + refresh: '', + expires: Date.now() - 1000, + accountId: JSON.stringify({ baseURL: BASE }), + }) + mockFetch() + const hooks = await LiteLLMPlugin(createInput().input) + const config = createConfig({}) + await hooks.config!(config as never) + + expect(discoveryRequests().length).toBeGreaterThan(0) + expect( + discoveryRequests().every((request) => request.authorization !== 'Bearer example-env-key'), + ).toBe(true) + await expect( + config.provider.litellm.options.fetch(`${BASE}/v1/chat/completions`, {}), + ).rejects.toThrow('LiteLLM sign-in expired; connect LiteLLM again') + }) + + it('gives the Responses sibling the sign-in fetch and a placeholder key its runtime requires', async () => { + writeAuthFile(pkceEntry()) + mockFetch((request) => { + if (request.url.endsWith('/v1/models')) { + return Response.json({ data: [{ id: 'example-chat' }, { id: 'example-responses' }] }) + } + if (request.url.endsWith('/v1/model/info')) { + return Response.json({ + data: [ + { + model_name: 'example-responses', + model_info: { key: 'example-responses', mode: 'responses' }, + }, + ], + }) + } + return undefined + }) + const hooks = await LiteLLMPlugin(createInput().input) + const config = createConfig({}) as { provider: Record } + await hooks.config!(config as never) + + const sibling = config.provider['litellm-responses'] + expect(Object.keys(sibling.models ?? {})).toEqual(['example-responses']) + expect(sibling.options?.baseURL).toBe(`${BASE}/v1`) + // A placeholder the runtime package insists on; the fetch replaces it with the real token. + const placeholder = sibling.options?.apiKey + expect(typeof placeholder === 'string' && placeholder.length > 0).toBe(true) + requests = [] + await (sibling.options!.fetch as typeof fetch)(`${BASE}/v1/responses`, { + headers: { Authorization: `Bearer ${placeholder}` }, + }) + expect(requests[0].authorization).toBe('Bearer example-access') + }) + + it('waits before retrying a refresh the token endpoint failed', async () => { + writeAuthFile(pkceEntry({ expires: Date.now() + 60 * 1000 })) + mockFetch((request) => + request.url === TOKEN_ENDPOINT ? new Response('{}', { status: 503 }) : undefined, + ) + const hooks = await LiteLLMPlugin(createInput().input) + const config = createConfig({}) + await hooks.config!(config as never) + expect(tokenRequests()).toHaveLength(1) + + requests = [] + await config.provider.litellm.options.fetch(`${BASE}/v1/chat/completions`, {}) + expect(tokenRequests()).toHaveLength(0) + expect(requests[0].authorization).toBe('Bearer example-access') + + skipRetryWindow() + await config.provider.litellm.options.fetch(`${BASE}/v1/chat/completions`, {}) + expect(tokenRequests()).toHaveLength(1) + }) + + it('does not adopt a sign-in to another proxy that appeared in auth.json', async () => { + writeAuthFile(pkceEntry({ expires: Date.now() + 60 * 1000 })) + mockFetch((request) => + request.url === TOKEN_ENDPOINT ? new Response('{}', { status: 503 }) : undefined, + ) + const hooks = await LiteLLMPlugin(createInput().input) + const config = createConfig({}) + await hooks.config!(config as never) + skipRetryWindow() + + writeAuthFile({ + ...pkceEntry({ access: 'example-access-elsewhere' }), + accountId: JSON.stringify({ ...METADATA, baseURL: 'https://elsewhere.example.com' }), + }) + requests = [] + await expect( + config.provider.litellm.options.fetch(`${BASE}/v1/chat/completions`, {}), + ).rejects.toThrow('The LiteLLM sign-in changed; connect LiteLLM again') + expect( + requests.some((request) => request.authorization === 'Bearer example-access-elsewhere'), + ).toBe(false) + }) + + it('recognizes its own writes on a provider configured without options', async () => { + process.env.LITELLM_API_KEY = ['example', 'env', 'key'].join('-') + process.env.LITELLM_BASE_URL = `${BASE}/v1` + mockFetch() + const { input } = createInput() + const config = { provider: { litellm: { npm: '@ai-sdk/openai-compatible' } as ProviderConfig } } + await ( + await LiteLLMPlugin(input) + ).config!(config as never) + expect(config.provider.litellm.options).toMatchObject({ apiKey: 'example-env-key' }) + + writeAuthFile(pkceEntry()) + await ( + await LiteLLMPlugin(input) + ).config!(config as never) + + expect(config.provider.litellm.options?.apiKey).toBeUndefined() + expect(typeof config.provider.litellm.options?.fetch).toBe('function') + }) + + function mockResponsesProxy(): void { + mockFetch((request) => { + if (request.url.endsWith('/v1/models')) { + return Response.json({ data: [{ id: 'example-chat' }, { id: 'example-responses' }] }) + } + if (request.url.endsWith('/v1/model/info')) { + return Response.json({ + data: [ + { + model_name: 'example-responses', + model_info: { key: 'example-responses', mode: 'responses' }, + }, + ], + }) + } + return undefined + }) + } + + it('sends the current token through a Responses sibling an earlier instance set up', async () => { + writeAuthFile(pkceEntry()) + mockResponsesProxy() + const { input } = createInput() + const config = createConfig({}) as { provider: Record } + await ( + await LiteLLMPlugin(input) + ).config!(config as never) + const siblingFetch = config.provider['litellm-responses'].options!.fetch as typeof fetch + + // Another process refreshed the same sign-in; OpenCode rebuilds the plugin on the cached config. + writeAuthFile(pkceEntry({ access: 'example-access-2', refresh: 'example-refresh-2' })) + await ( + await LiteLLMPlugin(input) + ).config!(config as never) + + requests = [] + await siblingFetch(`${BASE}/v1/responses`, {}) + expect(requests[0].authorization).toBe('Bearer example-access-2') + }) + + it('never sends a sign-in to the local ports it probes for a proxy', async () => { + writeAuthFile({ + type: 'oauth', + access: 'example-oidc-token', + refresh: '', + expires: Date.now() + 60 * 60 * 1000, + accountId: JSON.stringify({ + flow: 'oidc_pkce', + issuer: 'https://idp.example.com', + clientId: 'example-client', + subject: 'example-user', + }), + }) + mockFetch((request) => + request.url.startsWith('http://localhost:') && + request.authorization !== 'Bearer example-oidc-token' + ? new Response('unauthorized', { status: 401 }) + : undefined, + ) + const hooks = await LiteLLMPlugin(createInput().input) + await hooks.config!(createConfig({}) as never) + + expect(requests.some((request) => request.url.startsWith('http://localhost:'))).toBe(true) + expect(requests.every((request) => request.authorization !== 'Bearer example-oidc-token')).toBe( + true, + ) + }) + + it('refreshes the model list in the background only for the current sign-in', async () => { + const other = 'http://127.0.0.1:44522' + writeAuthFile(pkceEntry()) + mockFetch() + const { input } = createInput() + const config = createConfig({}) + await ( + await LiteLLMPlugin(input) + ).config!(config as never) + + // Signed in again, to another proxy; OpenCode rebuilds the plugin. + writeAuthFile({ + ...pkceEntry({ access: 'example-access-other' }), + accountId: JSON.stringify({ ...METADATA, baseURL: other, clientId: 'example-client-other' }), + }) + const hooks = await LiteLLMPlugin(input) + await hooks.config!(config as never) + + const now = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(now + 6 * 60 * 1000) + requests = [] + await hooks.event!({ event: { type: 'session.created' } } as never) + await vi.waitFor(() => + expect(requests.some((request) => request.url.startsWith(other))).toBe(true), + ) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(requests.some((request) => request.url.startsWith(BASE))).toBe(false) + }) + + it('does not overwrite a credential saved while it refreshed', async () => { + writeAuthFile(pkceEntry({ expires: Date.now() + 60 * 1000 })) + mockFetch((request) => { + if (request.url !== TOKEN_ENDPOINT) return undefined + // Another process saves an API key while the token request is in flight. + writeAuthFile({ type: 'api', key: 'example-saved-meanwhile' }) + return tokenSuccess('example-access-2', 'example-refresh-2') + }) + const { client, input } = createInput() + const hooks = await LiteLLMPlugin(input) + const config = createConfig({}) + await hooks.config!(config as never) + + expect(tokenRequests()).toHaveLength(1) + expect(client.auth.set).not.toHaveBeenCalled() + requests = [] + await config.provider.litellm.options.fetch(`${BASE}/v1/chat/completions`, {}) + expect(requests[0].authorization).toBe('Bearer example-access-2') + }) +}) diff --git a/test/plugin-v1.test.ts b/test/plugin-v1.test.ts index d54ce52..30951b9 100644 --- a/test/plugin-v1.test.ts +++ b/test/plugin-v1.test.ts @@ -9,7 +9,7 @@ import { readModelCacheSavedAt, writeModelCache, } from '../src/utils/model-cache' -import { __resetOpenCodeAuthCacheForTests } from '../src/utils/opencode-auth' +import { resetOpenCodeAuthCache } from '../src/utils/opencode-auth' interface ProviderConfig { npm?: string @@ -47,7 +47,7 @@ describe('OpenCode 1 config hook', () => { // credentials, and stored-key fingerprints must be deterministic. homeDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-home-')) process.env.HOME = homeDirectory - __resetOpenCodeAuthCacheForTests() + resetOpenCodeAuthCache() }) afterEach(() => { diff --git a/test/plugin-v2.test.ts b/test/plugin-v2.test.ts index 14a4853..98027fb 100644 --- a/test/plugin-v2.test.ts +++ b/test/plugin-v2.test.ts @@ -10,7 +10,7 @@ import { readModelCacheSavedAt, writeModelCache, } from '../src/utils/model-cache' -import { __resetOpenCodeAuthCacheForTests } from '../src/utils/opencode-auth' +import { resetOpenCodeAuthCache } from '../src/utils/opencode-auth' function restoreEnv(name: string, value: string | undefined): void { if (value === undefined) delete process.env[name] @@ -51,7 +51,7 @@ describe('OpenCode 2 plugin entrypoint', () => { // credentials, and stored-key fingerprints must be deterministic. homeDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-home-')) process.env.HOME = homeDirectory - __resetOpenCodeAuthCacheForTests() + resetOpenCodeAuthCache() }) afterEach(() => { @@ -1760,7 +1760,7 @@ describe('OpenCode 2 plugin entrypoint', () => { join(authDirectory, 'auth.json'), JSON.stringify({ litellm: { type: 'api', key: 'legacy-stored-key' } }), ) - __resetOpenCodeAuthCacheForTests() + resetOpenCodeAuthCache() const baseURL = 'http://127.0.0.1:44449' const authorizationHeaders: string[] = [] @@ -2038,7 +2038,7 @@ describe('OpenCode 2 plugin entrypoint', () => { join(authDirectory, 'auth.json'), JSON.stringify({ litellm: { type: 'api', key: 'valid-stored-key' } }), ) - __resetOpenCodeAuthCacheForTests() + resetOpenCodeAuthCache() const baseURL = 'http://127.0.0.1:44452' const authorizationHeaders: string[] = [] From 6f137f0c29b569b58aa270f4af74fe75e9ebf55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Fri, 30 Oct 2026 21:36:44 +0100 Subject: [PATCH 4/5] fix(sso): do not follow redirects on CLI SSO and /key/generate requests The other proxy and identity-provider requests already use redirect: 'manual'. These three carried the poll secret and gateway headers, which fetch keeps across a cross-origin redirect. --- src/utils/sso.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/utils/sso.ts b/src/utils/sso.ts index dc7fb63..809fe45 100644 --- a/src/utils/sso.ts +++ b/src/utils/sso.ts @@ -606,6 +606,7 @@ async function startCliSso(root: string, headers: ProxyHeaders): Promise Date: Mon, 2 Nov 2026 12:21:57 +0100 Subject: [PATCH 5/5] fix(sso): keep a sign-in's token on its proxy and refine the flow fallbacks - OpenCode 2 turns off a sign-in for a provider configured for another proxy, as OpenCode 1 does - neither probes local ports for a sign-in that names no proxy, since discovery would then send the token to whatever answered - fall back from the CLI-auth contract and from CLI SSO on any client error or an answer the plugin does not understand, but fail on 429 and 5xx - keep a pasted UI token only when /key/generate answers 403, 404 or 500 - default an empty Base URL to the current sign-in's proxy on OpenCode 2 - merge the dialog answers from credential configuration and metadata - send the original scope on an OIDC refresh, accept only digits as redirect ports, keep an error handler on the loopback server, and build the form fields per method - tests: no fixed loopback port or sleep in the OpenCode 1 tests --- README.md | 12 +- src/plugin/index.ts | 5 +- src/plugin/integration.ts | 73 +++++++----- src/plugin/v2.ts | 46 ++++++- src/utils/litellm-api.ts | 14 ++- src/utils/sso.ts | 96 +++++++++------ test/auth-v1.test.ts | 81 +++++++++---- test/plugin-integration.test.ts | 205 ++++++++++++++++++++++++++++++++ test/plugin-v2.test.ts | 4 + test/sso.test.ts | 115 +++++++++++++++--- 10 files changed, 528 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index 08e4df3..0196359 100644 --- a/README.md +++ b/README.md @@ -350,14 +350,18 @@ Precedence when several sources are configured: explicit provider/plugin setting The **LiteLLM** entry in `/connect` also offers two browser sign-ins. The sign-in returns to a temporary `http://127.0.0.1` callback, so the browser must run on the same machine as the OpenCode server (OpenCode Desktop's own server qualifies; a Desktop connected to a remote server does not), and the proxy needs an `https` base URL (plain `http` only on a loopback host). On OpenCode 2, OpenCode stores the session as a credential of the integration, refreshes it, and injects it into requests, as it does with a connected key; OpenCode 1 differences are [below](#opencode-1). -**LiteLLM SSO** signs in through the proxy. Leave **Base URL** empty to use the configured provider's URL or `LITELLM_BASE_URL`. A URL that differs from the configured one is refused, because explicit configuration wins in discovery and the token would go to a proxy that did not issue it. The plugin uses the newest flow the proxy offers: +**LiteLLM SSO** signs in through the proxy. Leave **Base URL** empty to use the configured provider's URL, the current sign-in's proxy, or `LITELLM_BASE_URL`. A URL that differs from the configured one is refused, because explicit configuration wins in discovery and the token would go to a proxy that did not issue it. The plugin uses the newest flow the proxy offers: 1. **CLI-auth contract** (`/.well-known/litellm-cli-auth`): authorization code + PKCE (`S256`) with a dynamically registered client. Advertised endpoints must stay on the proxy's origin, and these requests do not follow redirects. Access and refresh tokens rotate automatically; a temporary token-endpoint failure (network, 429, 5xx) keeps the current token until it actually expires. -2. **CLI SSO** (`/sso/cli/start`, when discovery returns 404): confirm the code OpenCode shows in the browser. When your account belongs to several teams, LiteLLM asks for one; the sign-in then fails with the list of teams, so connect again with the team's ID or alias as **Team**. The token lasts as long as the proxy reports (24 hours on proxies that report nothing), then you connect again. -3. **UI token** (when `/sso/cli/start` returns 404 or 405): sign in on the page OpenCode opens and paste the token from the LiteLLM UI as the code. The plugin exchanges it for a virtual key through `/key/generate` and uses the token itself when the proxy cannot mint one (`/key/generate` needs a database). +2. **CLI SSO** (`/sso/cli/start`, when the discovery path serves no contract the plugin speaks: a 404, an access gateway's 401 or 403, a page from a catch-all route, or another contract version): confirm the code OpenCode shows in the browser. When your account belongs to several teams, LiteLLM asks for one; the sign-in then fails with the list of teams, so connect again with the team's ID or alias as **Team**. The token lasts as long as the proxy reports (24 hours on proxies that report nothing), then you connect again. +3. **UI token** (when `/sso/cli/start` answers with another error, such as 404 or 405 on older proxies, or 401 or 403 from an access gateway): sign in on the page OpenCode opens and paste the token from the LiteLLM UI as the code. The plugin exchanges it for a virtual key through `/key/generate`, and uses the token itself when the proxy cannot mint one: 403 (no key-generation rights), 404, or 500 (no database). Any other answer, such as 401 for an expired token, fails the sign-in. + +A rate limit (429) or server error (5xx) at discovery or `/sso/cli/start` fails the sign-in instead of falling back to an older flow, so trying again can still reach the newer one. **Identity provider (OIDC)** signs in with your identity provider directly, for proxies running LiteLLM JWT auth (`enable_jwt_auth`) that trust its issuer and accept the client ID as an audience. Enter the `https` issuer URL, a public client ID (no secret) registered for authorization code + PKCE with the redirect `http://127.0.0.1:/callback`, the scopes (default `openid`; some identity providers only issue a refresh token with `offline_access`), and, for identity providers that only accept exact redirect URIs, the ports to try. The `id_token` is the bearer token: the plugin checks its issuer, audience, authorized party, subject, expiry, and nonce, and the proxy verifies its signature. Signing in sends nothing to the proxy, and identity provider requests never carry custom headers. +A sign-in's token only goes to the proxy it is for. Without a configured base URL, the plugin uses the proxy the sign-in came from; a base URL configured for another proxy turns the sign-in off, with a warning in the log. A sign-in that names no proxy, such as an OIDC sign-in with an empty **Base URL**, uses the configured base URL or `LITELLM_BASE_URL`; the plugin does not probe local ports for it, because whatever answered there would get the token. On OpenCode 2 this covers the plugin's own requests, but OpenCode itself pairs the provider whose id is `litellm` with the **LiteLLM** connection and sends that connection's token with the provider's requests. After pointing `litellm` at another proxy, connect again to that proxy, or disconnect. + Disconnecting removes the local credential only; it does not revoke the session on the proxy or the identity provider. If a sign-in fails before the browser opens (no proxy URL, a plain `http` URL, an unreachable proxy or identity provider, an invalid OIDC field), OpenCode shows only a generic error, such as "Unknown error" in the Desktop app: OpenCode drops the message of an error a plugin raises at that step. The reason is in OpenCode's log. Errors after the browser step, such as the team list, are shown. @@ -368,7 +372,7 @@ OpenCode 1 lists the sign-ins under the `litellm` provider, in `/connect`, its D - **First sign-in:** run `opencode auth login -p litellm`. OpenCode 1 lists a provider in `/connect` and the Desktop app only once it has models, and the proxy hides them until you sign in. After that, reconnect from either; the model list stays cached for the sign-in, so LiteLLM keeps appearing even after the session ends. - The session lives in OpenCode 1's `auth.json` like any login (the sign-in's details in `accountId`, the only field OpenCode 1 keeps). The plugin refreshes it shortly before it expires, sends the current token with every request (including the `-responses` sibling), and saves rotated tokens back; when another OpenCode process sharing the file refreshed first, it uses that one. -- A configured `apiKey` still wins over a sign-in, and a sign-in wins over `LITELLM_API_KEY` / `LITELLM_MASTER_KEY`, even once it expired: requests then ask you to connect again. Without a configured `baseURL`, the plugin uses the proxy the sign-in came from; a `baseURL` configured for another proxy turns the sign-in off (with a warning in the log), so its token never reaches that host. +- A configured `apiKey` still wins over a sign-in, and a sign-in wins over `LITELLM_API_KEY` / `LITELLM_MASTER_KEY`, even once it expired: requests then ask you to connect again. - Only the default `litellm` provider id can sign in: OpenCode 1 gives a plugin one auth entry. OpenCode 2 imports OpenCode 1 logins under a method this plugin does not claim, so connect again after upgrading. 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 sign-in is keyed by its credential id instead, because its access token rotates on every refresh. A `credential.switched` event also recomputes the effective key and reloads the provider registry when it changed โ€” including the first credential saved after setup. diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 39b5ec9..7aa04ba 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -879,8 +879,9 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { // The proxy the sign-in was issued by. baseURL = normalizeBaseURL(sso.metadata.baseURL) } else { - // Local port probes never carry a sign-in's token. - baseURL = await autoDetectLiteLLM(sso ? undefined : apiKey, customHeaders) + // A sign-in only goes to a proxy named for it: probing local ports + // would hand its token to whatever answers there. + baseURL = await autoDetectLiteLLM(apiKey, customHeaders, sso ? [] : undefined) } if (!baseURL) { diff --git a/src/plugin/integration.ts b/src/plugin/integration.ts index bd94e18..e5b92c0 100644 --- a/src/plugin/integration.ts +++ b/src/plugin/integration.ts @@ -71,33 +71,43 @@ export function connectionApiKey(credential: Credential.Value | undefined): stri return undefined } -const providerIDField = { - 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, -} as const +// Fields are built per form: the host may decorate the fields of each method it registers. +const providerIDField = () => + ({ + 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, + }) as const -const signInBaseURLField = { - key: 'baseURL', - type: 'string', - format: 'uri', - title: 'Base URL', - description: - "The LiteLLM proxy to sign in to. Leave empty to use the configured provider's URL or LITELLM_BASE_URL.", - placeholder: 'https://llm.example.com', -} as const +const signInBaseURLField = () => + ({ + key: 'baseURL', + type: 'string', + format: 'uri', + title: 'Base URL', + description: + "The LiteLLM proxy to sign in to. Leave empty to use the configured provider's URL, the current sign-in's proxy, or LITELLM_BASE_URL.", + placeholder: 'https://llm.example.com', + }) as const /** Proxy URL, provider id, and gateway headers for a sign-in. */ async function signInTarget( + context: Context, answer: Record, resolveTarget: SignInTargetResolver, ): Promise<{ providerID: string; baseURL: string | undefined; headers: Record }> { const providerID = readAnswer(answer, 'providerID') ?? LITELLM_INTEGRATION_ID const target = await resolveTarget(providerID) - const baseURL = signInRoot(readAnswer(answer, 'baseURL'), target.baseURL, providerID) + const typed = readAnswer(answer, 'baseURL') + // Nothing typed or configured: the current sign-in's proxy, as on OpenCode 1, even before a + // provider is published for it. Unlike configuration, it is only a default. + const current = + typed || target.baseURL ? undefined : await readConnectedConfig(context, LITELLM_INTEGRATION_ID) + const previous = current?.credentialID ? current.baseURL : undefined + const baseURL = signInRoot(typed ?? previous, target.baseURL, providerID) return { providerID, baseURL, headers: target.headers } } @@ -175,7 +185,7 @@ export async function registerLiteLLMIntegration( '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', }, - providerIDField, + providerIDField(), ], }, }) @@ -186,8 +196,8 @@ export async function registerLiteLLMIntegration( type: 'oauth', label: 'LiteLLM SSO', form: [ - signInBaseURLField, - providerIDField, + signInBaseURLField(), + providerIDField(), { key: 'team', type: 'string', @@ -198,7 +208,11 @@ export async function registerLiteLLMIntegration( ], }, authorize: async (answer) => { - const { providerID, baseURL, headers } = await signInTarget(answer, resolveTarget) + const { providerID, baseURL, headers } = await signInTarget( + context, + answer, + resolveTarget, + ) if (!baseURL) throw new Error('Enter the LiteLLM proxy URL as Base URL, or set LITELLM_BASE_URL') const authorization = await authorizeLiteLLM(baseURL, headers, readAnswer(answer, 'team')) @@ -213,8 +227,8 @@ export async function registerLiteLLMIntegration( type: 'oauth', label: 'Identity provider (OIDC)', form: [ - signInBaseURLField, - providerIDField, + signInBaseURLField(), + providerIDField(), { key: 'issuer', type: 'string', @@ -253,7 +267,7 @@ export async function registerLiteLLMIntegration( }, authorize: async (answer) => { // Nothing is sent to the proxy, so its URL is only recorded for discovery. - const { providerID, baseURL } = await signInTarget(answer, resolveTarget) + const { providerID, baseURL } = await signInTarget(context, answer, resolveTarget) const config = parseOidcConfig({ issuer: readAnswer(answer, 'issuer'), clientId: readAnswer(answer, 'clientId'), @@ -300,10 +314,11 @@ export async function readConnectedConfig( const apiKey = connectionApiKey(credential) if (!credential || apiKey === undefined) return undefined // OpenCode 2.1 moved the form answers from `configuration` to `metadata`, - // where sign-ins store theirs too. - const answers = (credential.metadata ?? - (credential.type === 'key' ? credential.configuration : undefined)) as - Record | undefined + // where sign-ins store theirs too; metadata wins where both hold one. + const answers: Record = { + ...(credential.type === 'key' ? credential.configuration : undefined), + ...credential.metadata, + } return { apiKey, baseURL: readAnswer(answers, 'baseURL'), diff --git a/src/plugin/v2.ts b/src/plugin/v2.ts index abce763..1382c48 100644 --- a/src/plugin/v2.ts +++ b/src/plugin/v2.ts @@ -28,6 +28,7 @@ import { } from '../utils/model-cache' import { normalizeBaseURL, autoDetectLiteLLM } from '../utils/litellm-api' import { getOpenCodeStoredApiKey } from '../utils/opencode-auth' +import { sameProxy } from '../utils/sso' import { parseModelCapabilities } from '../utils/model-capabilities' import type { ModelCapabilities } from '../utils/model-capabilities' import { @@ -346,6 +347,18 @@ async function signInTarget( } } +/** + * Whether a sign-in may go to `baseURL`. Its token only goes to the proxy that issued it, as on + * OpenCode 1; a sign-in that names no proxy (OIDC) goes where the provider points. + */ +function signInReaches(signedInTo: string | undefined, baseURL: string | undefined): boolean { + if (!signedInTo || !baseURL || sameProxy(signedInTo, baseURL)) return true + logInfo( + `[opencode-litellm] Not using the LiteLLM sign-in for ${signedInTo}: ${baseURL} must not receive its token.`, + ) + return false +} + async function makeProviderSource( context: Context, provider: ProviderInfo | undefined, @@ -354,20 +367,24 @@ async function makeProviderSource( const providerSettings = asRecord(provider?.settings) const settings = { ...pluginOptions, ...providerSettings } const customHeaders = providerHeaders(pluginOptions, settings, provider) + const configuredBaseURL = readString(settings.baseURL) // 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( + let connected: ConnectedLiteLLM | undefined = await readConnectedConfig( context, provider?.integrationID ?? LITELLM_INTEGRATION_ID, ) + // A base URL configured for another proxy turns the sign-in off. + if (connected?.credentialID && !signInReaches(connected.baseURL, configuredBaseURL)) { + connected = undefined + } const id = provider?.id ?? readString(pluginOptions.providerID) ?? connected?.providerID ?? CHAT_PROVIDER_ID - const configuredBaseURL = readString(settings.baseURL) const connectedApiKey = connected?.apiKey // Precedence rule: an explicit settings/plugin-options apiKey is the // operator's deliberate config and stays first; then a credential the user @@ -398,7 +415,13 @@ async function makeProviderSource( ? normalizeBaseURL(configuredBaseURL) : connected?.baseURL ? normalizeBaseURL(connected.baseURL) - : await autoDetectLiteLLM(apiKey, customHeaders) + : await autoDetectLiteLLM( + apiKey, + customHeaders, + // A sign-in only goes to a proxy named for it: probing local ports + // would hand its token to whatever answers there. + usesConnectionCredential && connected?.credentialID ? [] : undefined, + ) if (!detectedBaseURL) { return null @@ -510,7 +533,12 @@ async function refreshProviderSource( // expiring one, so discovery does not run with a token that has expired. if (source.usesConnectionCredential && source.integrationID) { const connected = await readConnectedConfig(context, source.integrationID) - if (connected?.apiKey) source.apiKey = connected.apiKey + if ( + connected?.apiKey && + (!connected.credentialID || signInReaches(connected.baseURL, source.baseURL)) + ) { + source.apiKey = connected.apiKey + } } const apiKey = source.apiKey const discovered = await withTimeout( @@ -584,7 +612,15 @@ async function refreshConnectionCredential( const credential = connection ? await context.integration.connection.resolve(connection) : undefined - const connectionKey = connectionApiKey(credential) + let connectionKey = connectionApiKey(credential) + // A new sign-in may be for another proxy than the one this source points to. + if ( + connectionKey !== undefined && + credential?.type === 'oauth' && + !signInReaches(readString(credential.metadata?.baseURL), source.baseURL) + ) { + connectionKey = undefined + } const settingsApiKey = readString(source.settings.apiKey) let nextKey: string | undefined let nextUsesConnection: boolean diff --git a/src/utils/litellm-api.ts b/src/utils/litellm-api.ts index 0f908e1..522f798 100644 --- a/src/utils/litellm-api.ts +++ b/src/utils/litellm-api.ts @@ -157,15 +157,19 @@ export async function discoverLiteLLMModelInfo( } /** - * Use an explicit LITELLM_BASE_URL when set; otherwise try the most common - * ports. The default `litellm --port` is 4000, with 8000 and 8080 also common. + * Use an explicit LITELLM_BASE_URL when set; otherwise try `ports` on localhost, + * by default the most common ones. The default `litellm --port` is 4000, with + * 8000 and 8080 also common. */ -export async function autoDetectLiteLLM(apiKey?: string, customHeaders?: Record): Promise { +export async function autoDetectLiteLLM( + apiKey?: string, + customHeaders?: Record, + ports: readonly number[] = [4000, 8000, 8080], +): Promise { const configuredBaseURL = process.env.LITELLM_BASE_URL?.trim() if (configuredBaseURL) return normalizeBaseURL(configuredBaseURL) - const commonPorts = [4000, 8000, 8080] - for (const port of commonPorts) { + for (const port of ports) { const baseURL = `http://localhost:${port}` if (await checkLiteLLMHealth(baseURL, apiKey, customHeaders)) { return baseURL diff --git a/src/utils/sso.ts b/src/utils/sso.ts index 809fe45..4eff595 100644 --- a/src/utils/sso.ts +++ b/src/utils/sso.ts @@ -6,7 +6,8 @@ import { normalizeBaseURL } from './litellm-api' // Cap for each login and refresh request to the proxy or the identity provider. const REQUEST_TIMEOUT_MS = 10_000 -// How long a browser sign-in may take; the connect attempt expires with it. +// How long a browser sign-in may take, as long as OpenCode 2 gives an OAuth attempt by default; the +// connect attempt expires with it. const CALLBACK_TIMEOUT_MS = 10 * 60 * 1000 const CLI_SSO_POLL_INTERVAL_MS = 2_000 const CLI_SSO_EXPIRES_IN_SECONDS = 600 @@ -14,6 +15,9 @@ const CLI_SSO_EXPIRES_IN_SECONDS = 600 const CLI_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000 const CLI_AUTH_DISCOVERY_PATH = '/.well-known/litellm-cli-auth' const OIDC_DISCOVERY_PATH = '/.well-known/openid-configuration' +// Where a pasted UI token still works but cannot become a virtual key: no key-generation rights +// (403), no `/key/generate` route (404), or no database (500). +const KEEPS_PASTED_TOKEN: ReadonlySet = new Set([403, 404, 500]) /** Expiry of a token that carries none, such as a virtual key without `expires`. */ const NEVER_EXPIRES = Number.MAX_SAFE_INTEGER // Persisted in credential metadata; never rename. @@ -93,6 +97,11 @@ function requestSignal(): AbortSignal { return AbortSignal.timeout(REQUEST_TIMEOUT_MS) } +/** A rate limit or server error: retrying can still reach a flow that falling back would give up. */ +function transientStatus(status: number): boolean { + return status === 429 || status >= 500 +} + function authHeaders(headers: ProxyHeaders, contentType?: string): Headers { const result = new Headers(headers) result.set('Accept', 'application/json') @@ -457,6 +466,8 @@ async function listenForCallback(label: string, ports: readonly number[]): Promi settle({ error: new Error(`${label} callback failed to start`) }) throw new Error(`${label} callback failed to start`) } + // A later server error, such as EMFILE on accept, ends the sign-in instead of crashing OpenCode. + server.on('error', (error) => settle({ error })) return { redirectUri: `http://127.0.0.1:${address.port}/callback`, state, @@ -479,12 +490,14 @@ async function discoverPkce( redirect: 'manual', signal: requestSignal(), }) - if (response.status === 404) return undefined - if (!response.ok) throw new Error(`LiteLLM CLI auth discovery failed (HTTP ${response.status})`) - const data = await readJson(response, 'LiteLLM CLI auth discovery') - if (!isRecord(data) || data.contract_version !== 1) { - throw new Error('LiteLLM CLI auth discovery has unsupported contract version') - } + if (transientStatus(response.status)) { + throw new Error(`LiteLLM CLI auth discovery failed (HTTP ${response.status})`) + } + // Anything but a contract this plugin speaks means the proxy does not offer it here: a missing + // route, an access gateway's 401/403, a page from a catch-all route, or another contract version. + if (!response.ok) return undefined + const data: unknown = await response.json().catch(() => undefined) + if (!isRecord(data) || data.contract_version !== 1) return undefined if ( !Array.isArray(data.code_challenge_methods_supported) || !data.code_challenge_methods_supported.includes('S256') @@ -609,8 +622,12 @@ async function startCliSso(root: string, headers: ProxyHeaders): Promise { +): Promise<{ key: string; expires?: number } | undefined> { const response = await fetch(`${root}/key/generate`, { method: 'POST', headers: { @@ -733,6 +751,15 @@ async function generateVirtualKey( redirect: 'manual', signal: requestSignal(), }) + if (KEEPS_PASTED_TOKEN.has(response.status)) { + console.log( + `[opencode-litellm] Using the pasted LiteLLM token directly; /key/generate answered HTTP ${response.status}`, + ) + return undefined + } + if (response.status === 401) { + throw new Error('LiteLLM rejected the pasted token; sign in again and paste a current one') + } if (!response.ok) throw new Error(`/key/generate failed (HTTP ${response.status})`) const data = await readJson(response, '/key/generate') if (!isRecord(data) || !isAuthToken(data.key)) throw new Error('/key/generate returned no key') @@ -753,27 +780,20 @@ function authorizePastedToken(root: string, headers: ProxyHeaders): SsoAuthoriza .replace(/^Bearer\s+/i, '') .trim() if (!isAuthToken(token)) throw new Error('Paste the token from the LiteLLM UI') - try { - const generated = await generateVirtualKey(root, token, headers) - return { - access: generated.key, - refresh: '', - expires: generated.expires ?? NEVER_EXPIRES, - metadata: { baseURL: root }, - } - } catch (error) { - // `/key/generate` needs a database and key-generation rights; the UI token works on its own. - const message = error instanceof Error ? error.message : String(error) - console.log( - `[opencode-litellm] Using the pasted LiteLLM token directly; no virtual key: ${message}`, - ) - return { - access: token, - refresh: '', - expires: tokenExpiry(token, NEVER_EXPIRES), - metadata: { baseURL: root }, - } - } + const generated = await generateVirtualKey(root, token, headers) + return generated + ? { + access: generated.key, + refresh: '', + expires: generated.expires ?? NEVER_EXPIRES, + metadata: { baseURL: root }, + } + : { + access: token, + refresh: '', + expires: tokenExpiry(token, NEVER_EXPIRES), + metadata: { baseURL: root }, + } }, } } @@ -819,14 +839,12 @@ export function parseOidcConfig(raw: { const scope = raw.scope?.trim() || 'openid' if (!scope.split(/\s+/).includes('openid')) throw invalid('scopes', 'a space-separated list that includes "openid"') - const redirectPorts = (raw.redirectPorts ?? '') - .split(/[\s,]+/) - .filter(Boolean) - .map(Number) - if (!redirectPorts.every((port) => Number.isInteger(port) && port >= 1 && port <= 65535)) { + const ports = (raw.redirectPorts ?? '').split(/[\s,]+/).filter(Boolean) + // Digits only: Number() would also take forms like 0x10 or 1e3. + if (!ports.every((port) => /^\d+$/.test(port) && Number(port) >= 1 && Number(port) <= 65535)) { throw invalid('redirect ports', 'port numbers from 1 to 65535') } - return { issuer, clientId, scope, redirectPorts } + return { issuer, clientId, scope, redirectPorts: ports.map(Number) } } async function discoverOidc( @@ -947,7 +965,7 @@ export async function refreshSsoToken( ) } else if (metadata.flow === OIDC_FLOW && isAuthToken(token.refresh)) { const tokenEndpoint = httpsUrl(metadata.tokenEndpoint, { allowQuery: true }) - const { issuer, clientId, subject } = metadata + const { issuer, clientId, subject, scope } = metadata if ( !tokenEndpoint || typeof issuer !== 'string' || @@ -963,6 +981,8 @@ export async function refreshSsoToken( grant_type: 'refresh_token', refresh_token: token.refresh, client_id: clientId, + // Some identity providers want the original scope on a refresh; the others ignore it. + ...(typeof scope === 'string' && scope ? { scope } : {}), }), { issuer, clientId, subject }, token.refresh, diff --git a/test/auth-v1.test.ts b/test/auth-v1.test.ts index 4aade95..ac638e8 100644 --- a/test/auth-v1.test.ts +++ b/test/auth-v1.test.ts @@ -1,4 +1,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import type { PluginInput } from '@opencode-ai/plugin' @@ -36,6 +38,13 @@ function restoreEnv(name: string, value: string | undefined): void { else process.env[name] = value } +/** Listens on a port the OS picks, on the loopback address the sign-in listener uses. */ +async function listenOnFreePort(): Promise<{ server: Server; port: string }> { + const server = createServer() + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + return { server, port: String((server.address() as AddressInfo).port) } +} + describe('OpenCode 1 LiteLLM sign-in', () => { const originalFetch = globalThis.fetch let originalCacheHome: string | undefined @@ -345,16 +354,24 @@ describe('OpenCode 1 LiteLLM sign-in', () => { await expect(signIn({ scope: 'openid offline_access', ports: 'any' })).resolves.toMatchObject({ scope: 'openid offline_access', }) - const custom = await signIn({ - scope: 'other', - customScope: 'openid profile', - ports: 'specific', - redirectPorts: '45871', - }) - expect(custom).toEqual({ scope: 'openid profile', port: '45871' }) - // Ports typed earlier do not count once the user picks any free port. - const any = await signIn({ scope: 'openid', ports: 'any', redirectPorts: '45871' }) - expect(any.port).not.toBe('45871') + const busy = await listenOnFreePort() + const free = await listenOnFreePort() + await new Promise((resolve) => free.server.close(resolve)) + try { + // A busy port falls through to the next one. + const custom = await signIn({ + scope: 'other', + customScope: 'openid profile', + ports: 'specific', + redirectPorts: `${busy.port}, ${free.port}`, + }) + expect(custom).toEqual({ scope: 'openid profile', port: free.port }) + // Ports typed earlier do not count once the user picks any free port. + const any = await signIn({ scope: 'openid', ports: 'any', redirectPorts: busy.port }) + expect(any.port).not.toBe(busy.port) + } finally { + busy.server.close() + } }) it('discovers from the stored sign-in when no baseURL is configured', async () => { @@ -740,7 +757,7 @@ describe('OpenCode 1 LiteLLM sign-in', () => { expect(requests[0].authorization).toBe('Bearer example-access-2') }) - it('never sends a sign-in to the local ports it probes for a proxy', async () => { + it('does not probe local ports for a sign-in that names no proxy', async () => { writeAuthFile({ type: 'oauth', access: 'example-oidc-token', @@ -753,26 +770,33 @@ describe('OpenCode 1 LiteLLM sign-in', () => { subject: 'example-user', }), }) - mockFetch((request) => - request.url.startsWith('http://localhost:') && - request.authorization !== 'Bearer example-oidc-token' - ? new Response('unauthorized', { status: 401 }) - : undefined, - ) - const hooks = await LiteLLMPlugin(createInput().input) - await hooks.config!(createConfig({}) as never) + // Every port answers, even without a key, and discovery would send the token to the first. + mockFetch() + const { input } = createInput() + const config = createConfig({}) + await ( + await LiteLLMPlugin(input) + ).config!(config as never) - expect(requests.some((request) => request.url.startsWith('http://localhost:'))).toBe(true) - expect(requests.every((request) => request.authorization !== 'Bearer example-oidc-token')).toBe( - true, - ) + expect(requests).toEqual([]) + expect(config.provider.litellm.options.baseURL).toBeUndefined() + + process.env.LITELLM_BASE_URL = `${BASE}/v1` + await ( + await LiteLLMPlugin(input) + ).config!(config as never) + expect(discoveryRequests().length).toBeGreaterThan(0) + for (const request of discoveryRequests()) { + expect(request.url).toBe(`${BASE}/v1/models`) + expect(request.authorization).toBe('Bearer example-oidc-token') + } }) it('refreshes the model list in the background only for the current sign-in', async () => { const other = 'http://127.0.0.1:44522' writeAuthFile(pkceEntry()) mockFetch() - const { input } = createInput() + const { client, input } = createInput() const config = createConfig({}) await ( await LiteLLMPlugin(input) @@ -790,10 +814,15 @@ describe('OpenCode 1 LiteLLM sign-in', () => { vi.spyOn(Date, 'now').mockReturnValue(now + 6 * 60 * 1000) requests = [] await hooks.event!({ event: { type: 'session.created' } } as never) + // Refreshes start together, so once the current one is done a stale one would have asked too. await vi.waitFor(() => - expect(requests.some((request) => request.url.startsWith(other))).toBe(true), + expect(client.app.log).toHaveBeenCalledWith({ + body: expect.objectContaining({ + message: expect.stringContaining(`Background-refreshed model cache for ${other}`), + }), + }), ) - await new Promise((resolve) => setTimeout(resolve, 50)) + expect(requests.some((request) => request.url.startsWith(other))).toBe(true) expect(requests.some((request) => request.url.startsWith(BASE))).toBe(false) }) diff --git a/test/plugin-integration.test.ts b/test/plugin-integration.test.ts index 43adf6c..0ad293d 100644 --- a/test/plugin-integration.test.ts +++ b/test/plugin-integration.test.ts @@ -922,4 +922,209 @@ describe('connect dialog integration', () => { await cleanup?.() }) + + it('reads the form answers from both credential fields, preferring metadata', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-answers-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + globalThis.fetch = discoveryFetch([{ id: 'answers-model' }]) + + 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:44513/v1', providerID: 'from-configuration' }, + // Metadata that holds only some answers, or none, must not hide the others. + metadata: { providerID: 'gateway', label: 'example' }, + }) + + const registered: RegisteredEntry[] = [] + const context = { + app: { name: 'OpenCode', version: '2.1.0', channel: 'stable' }, + options: {}, + provider: { + list: vi.fn(async () => ({ data: [] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + transform(makeStatefulEditor(registered, []) 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: 'gateway', integrationID: 'litellm' }) + expect(registered[0].info.settings).toMatchObject({ baseURL: 'http://127.0.0.1:44513/v1' }) + + await cleanup?.() + }) + + it('does not probe local ports for a sign-in that names no proxy', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-sso-probe-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + vi.spyOn(console, 'log').mockImplementation(() => {}) + const requests: string[] = [] + // Every port answers, even without a key, and discovery would send the token to the first. + globalThis.fetch = vi.fn(async (input) => { + requests.push(String(input)) + return discoveryFetch([{ id: 'local-model' }])(input) + }) + + const integration = makeIntegration() + integration.active.mockResolvedValue({ type: 'credential', id: 'cred-oidc', method: 'oauth', label: 'LiteLLM' }) + integration.resolve.mockResolvedValue({ + type: 'oauth', + methodID: 'oidc', + access: 'example-id-token', + refresh: '', + expires: Date.now() + 3_600_000, + metadata: { + flow: 'oidc_pkce', + issuer: 'https://idp.example.com', + clientId: 'example-client', + subject: 'example-user', + providerID: 'litellm', + }, + }) + const registered: RegisteredEntry[] = [] + const context = { + app: { name: 'OpenCode', version: '2.1.0', channel: 'stable' }, + options: {}, + provider: { + list: vi.fn(async () => ({ data: [] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + transform(makeStatefulEditor(registered, []) 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(requests).toEqual([]) + expect(registered).toHaveLength(0) + + await cleanup?.() + }) + + it('does not send a sign-in to a provider configured for another proxy', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-sso-elsewhere-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + 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-sso', method: 'oauth', label: 'LiteLLM' }) + integration.resolve.mockResolvedValue({ + type: 'oauth', + methodID: 'sso', + access: 'example-access', + refresh: 'example-refresh', + expires: Date.now() + 3_600_000, + metadata: { flow: 'litellm_cli_pkce', baseURL: 'https://llm.example.com', providerID: 'litellm' }, + }) + // The provider was pointed at another proxy after the sign-in. + const configuredProvider = { + id: 'litellm', + name: 'LiteLLM', + activation: 'enabled', + package: '@opencode/ai/providers/openai-compatible', + settings: { baseURL: 'http://127.0.0.1:44514/v1' }, + } + const registered: RegisteredEntry[] = [{ info: { ...configuredProvider }, models: [] }] + const context = { + app: { name: 'OpenCode', version: '2.1.0', channel: 'stable' }, + options: {}, + provider: { + list: vi.fn(async () => ({ data: [configuredProvider] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + transform(makeStatefulEditor(registered, []) 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[0].models.map((model) => model.id)).toEqual(['configured-model']) + expect(authorizationHeaders.length).toBeGreaterThan(0) + expect(authorizationHeaders).not.toContain('Bearer example-access') + expect(registered[0].info).not.toHaveProperty('integrationID') + expect(log).toHaveBeenCalledWith( + expect.stringContaining('Not using the LiteLLM sign-in for https://llm.example.com'), + ) + + await cleanup?.() + }) + + it("signs in again to the current sign-in's proxy when none is configured", async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-sso-previous-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + vi.spyOn(console, 'log').mockImplementation(() => {}) + globalThis.fetch = vi.fn(async (input) => + String(input).endsWith('/sso/cli/start') + ? new Response('method not allowed', { status: 405 }) + : new Response('not found', { status: 404 }), + ) + + const integration = makeIntegration() + integration.active.mockResolvedValue({ type: 'credential', id: 'cred-sso', method: 'oauth', label: 'LiteLLM' }) + const credential = { + type: 'oauth', + methodID: 'sso', + access: 'example-ui-token', + refresh: '', + expires: Date.now() + 3_600_000, + metadata: { baseURL: 'https://llm.example.com', providerID: 'litellm' }, + } + integration.resolve.mockResolvedValue(credential) + const context = { + app: { name: 'OpenCode', version: '2.1.0', 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) + const sso = integration.methodUpdate.mock.calls + .map((call) => call[0]) + .find((entry) => entry.method.id === 'sso') as { + authorize: (answer: Record) => Promise<{ url: string }> + refresh: (credential: Record) => Promise> + } + + // Renewing a sign-in never resolves the connection it renews, which would refresh it again. + const resolved = integration.resolve.mock.calls.length + await expect(sso.refresh(credential)).resolves.toEqual(credential) + expect(integration.resolve).toHaveBeenCalledTimes(resolved) + // No provider is published for this id, as before the first publication or after a rename. + await expect(sso.authorize({ providerID: 'team' })).resolves.toMatchObject({ + url: 'https://llm.example.com/sso/key/generate', + }) + // The previous proxy is only a default. + await expect(sso.authorize({ baseURL: 'https://other.example.com' })).resolves.toMatchObject({ + url: 'https://other.example.com/sso/key/generate', + }) + + await cleanup?.() + }) }) diff --git a/test/plugin-v2.test.ts b/test/plugin-v2.test.ts index 98027fb..f746447 100644 --- a/test/plugin-v2.test.ts +++ b/test/plugin-v2.test.ts @@ -85,9 +85,11 @@ describe('OpenCode 2 plugin entrypoint', () => { }) // OpenCode 1.18.x embeds a v2 plugin host whose context exposes `catalog` // instead of `provider`; the legacy `server()` entrypoint serves that host. + const integration = { transform: vi.fn() } const context = { options: {}, catalog: { transform: vi.fn(), reload: vi.fn() }, + integration, } as unknown as Context const cleanup = await plugin.setup(context) @@ -95,6 +97,8 @@ describe('OpenCode 2 plugin entrypoint', () => { expect(cleanup).toBeUndefined() expect(log).not.toHaveBeenCalled() expect(globalThis.fetch).not.toHaveBeenCalled() + // The sign-in belongs to server()'s auth hook there; LiteLLM is never claimed twice. + expect(integration.transform).not.toHaveBeenCalled() }) it('registers discovered LiteLLM models through the provider transform', async () => { diff --git a/test/sso.test.ts b/test/sso.test.ts index 9f9ecd2..510988c 100644 --- a/test/sso.test.ts +++ b/test/sso.test.ts @@ -180,6 +180,37 @@ describe('LiteLLM CLI-auth (PKCE) sign-in', () => { await expect(authorizeLiteLLM(ROOT)).rejects.toThrow('cross-origin token endpoint') }) + it('falls back to CLI SSO when the proxy serves no contract it speaks', async () => { + const answers = [ + () => new Response('forbidden', { status: 403 }), + () => + new Response('LiteLLM', { + headers: { 'Content-Type': 'text/html' }, + }), + () => cliAuthDiscovery({ contract_version: 2 }), + ] + for (const discovery of answers) { + mockFetch({ + [`GET ${ROOT}/.well-known/litellm-cli-auth`]: discovery, + [`POST ${ROOT}/sso/cli/start`]: () => + json({ login_id: 'example-login', poll_secret: 'example-poll', user_code: 'ABCD-1234' }), + }) + const authorization = await authorizeLiteLLM(ROOT) + expect(authorization.instructions).toContain('ABCD-1234') + } + }) + + it('fails on a discovery blip instead of falling back to an older flow', async () => { + const calls = mockFetch({ + [`GET ${ROOT}/.well-known/litellm-cli-auth`]: () => + new Response('unavailable', { status: 503 }), + }) + await expect(authorizeLiteLLM(ROOT)).rejects.toThrow( + 'LiteLLM CLI auth discovery failed (HTTP 503)', + ) + expect(calls.map((call) => call.key)).toEqual([`GET ${ROOT}/.well-known/litellm-cli-auth`]) + }) + it('rotates tokens on refresh and keeps the refresh token the server does not replace', async () => { const calls = mockFetch({ [`POST ${ROOT}/cli/token`]: () => @@ -212,6 +243,28 @@ describe('LiteLLM CLI-auth (PKCE) sign-in', () => { 'LiteLLM token exchange rejected (invalid_grant); connect LiteLLM again', ) }) + + it('refuses malformed credential metadata without sending the refresh token', async () => { + const calls = mockFetch({}) + const { clientId: _, ...withoutClient } = pkceToken().metadata + await expect(refreshSsoToken(pkceToken({ metadata: withoutClient }))).rejects.toThrow( + 'Invalid LiteLLM PKCE credential; connect LiteLLM again', + ) + const elsewhere = { ...pkceToken().metadata, tokenEndpoint: 'https://elsewhere.example.net/token' } + await expect(refreshSsoToken(pkceToken({ metadata: elsewhere }))).rejects.toThrow( + 'cross-origin token endpoint', + ) + const oidcWithoutClient = { + flow: 'oidc_pkce', + issuer: ISSUER, + tokenEndpoint: `${ISSUER}/token`, + subject: 'example-user', + } + await expect(refreshSsoToken(pkceToken({ metadata: oidcWithoutClient }))).rejects.toThrow( + 'Invalid LiteLLM OIDC credential; connect LiteLLM again', + ) + expect(calls).toEqual([]) + }) }) describe('LiteLLM CLI SSO sign-in', () => { @@ -313,20 +366,43 @@ describe('LiteLLM UI token sign-in', () => { vi.spyOn(console, 'log').mockImplementation(() => {}) const exp = Math.floor(Date.now() / 1000) + 600 const pasted = jwt({ exp }) - mockFetch({ - [`POST ${ROOT}/key/generate`]: () => json({ error: 'no database' }, 500), - }) + // No key-generation rights, no route, no database. + for (const status of [403, 404, 500]) { + mockFetch({ + [`POST ${ROOT}/key/generate`]: () => json({ error: 'cannot mint a key' }, status), + }) + const authorization = await authorizeLiteLLM(ROOT) + if (authorization.mode !== 'code') throw new Error('expected a code sign-in') + await expect(authorization.callback(pasted)).resolves.toMatchObject({ + access: pasted, + expires: exp * 1000, + }) + await expect(authorization.callback(' ')).rejects.toThrow( + 'Paste the token from the LiteLLM UI', + ) + } + }) + + it('does not save a pasted token LiteLLM rejects', async () => { + mockFetch({ [`POST ${ROOT}/key/generate`]: () => json({ error: 'expired' }, 401) }) const authorization = await authorizeLiteLLM(ROOT) if (authorization.mode !== 'code') throw new Error('expected a code sign-in') - await expect(authorization.callback(pasted)).resolves.toMatchObject({ - access: pasted, - expires: exp * 1000, - }) - await expect(authorization.callback(' ')).rejects.toThrow( - 'Paste the token from the LiteLLM UI', + await expect(authorization.callback('example-expired-token')).rejects.toThrow( + 'LiteLLM rejected the pasted token; sign in again and paste a current one', ) }) + it('offers the UI token when an access gateway refuses CLI SSO, but not after a blip', async () => { + let status = 403 + mockFetch({ [`POST ${ROOT}/sso/cli/start`]: () => new Response('denied', { status }) }) + await expect(authorizeLiteLLM(ROOT)).resolves.toMatchObject({ + mode: 'code', + url: `${ROOT}/sso/key/generate`, + }) + status = 503 + await expect(authorizeLiteLLM(ROOT)).rejects.toThrow('LiteLLM CLI SSO start failed (HTTP 503)') + }) + it('lasts until expiry, then asks for a new sign-in', async () => { const token: SsoToken = { access: 'example-ui-token', @@ -376,9 +452,11 @@ describe('direct OIDC sign-in', () => { expect(() => parseOidcConfig({ issuer: ISSUER, clientId: 'example-client', scope: 'profile' }), ).toThrow('Invalid OIDC scopes') - expect(() => - parseOidcConfig({ issuer: ISSUER, clientId: 'example-client', redirectPorts: '80000' }), - ).toThrow('Invalid OIDC redirect ports') + for (const redirectPorts of ['80000', '0x10', '1e3']) { + expect(() => + parseOidcConfig({ issuer: ISSUER, clientId: 'example-client', redirectPorts }), + ).toThrow('Invalid OIDC redirect ports') + } }) it('signs in with the identity provider and stores its id_token', async () => { @@ -433,9 +511,11 @@ describe('direct OIDC sign-in', () => { await expect(autoCallback(replayed)).rejects.toThrow('OIDC id_token has invalid nonce') }) - it('refreshes the id_token for the same subject only', async () => { + it('refreshes the id_token for the same subject only, asking for the same scope', async () => { let subject = 'example-user' - mockFetch({ [`POST ${ISSUER}/token`]: () => json({ id_token: jwt(claims({ sub: subject })) }) }) + const calls = mockFetch({ + [`POST ${ISSUER}/token`]: () => json({ id_token: jwt(claims({ sub: subject })) }), + }) const token: SsoToken = { access: 'example-old-id-token', refresh: 'example-refresh', @@ -446,12 +526,19 @@ describe('direct OIDC sign-in', () => { clientId: 'example-client', tokenEndpoint: `${ISSUER}/token`, subject: 'example-user', + scope: 'openid offline_access', }, } await expect(refreshSsoToken(token)).resolves.toMatchObject({ access: jwt(claims({ sub: 'example-user' })), refresh: 'example-refresh', }) + expect(Object.fromEntries(new URLSearchParams(calls[0].body))).toEqual({ + grant_type: 'refresh_token', + refresh_token: 'example-refresh', + client_id: 'example-client', + scope: 'openid offline_access', + }) subject = 'someone-else' await expect(refreshSsoToken(token)).rejects.toThrow( 'OIDC id_token has invalid sub; connect LiteLLM again',