opencode-plugin-litellm
Advanced tools
+1
-1
| { | ||
| "$schema": "https://json.schemastore.org/package.json", | ||
| "name": "opencode-plugin-litellm", | ||
| "version": "0.3.1", | ||
| "version": "0.4.0", | ||
| "description": "OpenCode plugin for LiteLLM proxy support with auto-detection and dynamic model discovery", | ||
@@ -6,0 +6,0 @@ "type": "module", |
+48
-0
@@ -74,2 +74,3 @@ <div align="center"> | ||
| | 🔐 **Auth-aware** | Honours `LITELLM_API_KEY` / `LITELLM_MASTER_KEY` env vars or `provider.litellm.options.apiKey`. | | ||
| | 🌐 **Gateway-friendly** | Supports `customHeaders` for proxies behind Cloudflare Access or other API gateways requiring extra HTTP headers. | | ||
| | ⏱️ **Non-blocking startup** | Discovery is capped at **5 s** — a slow or offline proxy never delays OpenCode boot. | | ||
@@ -200,2 +201,25 @@ | 🤝 **Non-destructive merge** | Only adds models you don't already have configured. Hand-curated entries are preserved verbatim. | | ||
| ### Custom headers (Cloudflare Access, API gateways) | ||
| If your LiteLLM proxy is behind Cloudflare Access or another gateway that requires extra HTTP headers, use the `customHeaders` option: | ||
| ```jsonc | ||
| { | ||
| "provider": { | ||
| "litellm": { | ||
| "options": { | ||
| "baseURL": "https://litellm.internal.example.com/v1", | ||
| "apiKey": "{env:LITELLM_API_KEY}", | ||
| "customHeaders": { | ||
| "CF-Access-Client-Id": "{env:CF_ACCESS_CLIENT_ID}", | ||
| "CF-Access-Client-Secret": "{env:CF_ACCESS_CLIENT_SECRET}" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| These headers are included in every request the plugin makes during model discovery (health check and `/v1/models`). To obtain a Cloudflare Access Service Token, follow the [Cloudflare docs](https://developers.cloudflare.com/cloudflare-one/identity/service-tokens/). | ||
| ## 🔧 How it works | ||
@@ -291,2 +315,26 @@ | ||
| <details> | ||
| <summary><b>My LiteLLM proxy is behind Cloudflare Access — how do I authenticate?</b></summary> | ||
| Cloudflare Access intercepts requests before they reach LiteLLM, so a plain `Authorization: Bearer` header isn't enough. Create a [Cloudflare Access Service Token](https://developers.cloudflare.com/cloudflare-one/identity/service-tokens/) and pass the credentials via `customHeaders`: | ||
| ```jsonc | ||
| { | ||
| "provider": { | ||
| "litellm": { | ||
| "options": { | ||
| "baseURL": "https://litellm.your-company.com/v1", | ||
| "customHeaders": { | ||
| "CF-Access-Client-Id": "{env:CF_ACCESS_CLIENT_ID}", | ||
| "CF-Access-Client-Secret": "{env:CF_ACCESS_CLIENT_SECRET}" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| The `customHeaders` map works for any gateway that requires extra HTTP headers — not just Cloudflare. | ||
| </details> | ||
| <details> | ||
| <summary><b>I get <code>Function tools with reasoning_effort are not supported … in /v1/chat/completions</code> — what do I do?</b></summary> | ||
@@ -293,0 +341,0 @@ |
@@ -43,5 +43,24 @@ import type { Model as ModelV2, Provider as ProviderV2 } from '@opencode-ai/sdk/v2' | ||
| */ | ||
| /** | ||
| * Extract the `customHeaders` map from the provider options block. | ||
| * Returns `undefined` when no custom headers are configured. | ||
| */ | ||
| function readCustomHeaders( | ||
| provider: ProviderV2 | undefined, | ||
| ): Record<string, string> | undefined { | ||
| const options = (provider?.options ?? {}) as Record<string, unknown> | ||
| const raw = options.customHeaders | ||
| if (raw && typeof raw === 'object' && !Array.isArray(raw)) { | ||
| const out: Record<string, string> = {} | ||
| for (const [k, v] of Object.entries(raw as Record<string, unknown>)) { | ||
| if (typeof v === 'string') out[k] = v | ||
| } | ||
| return Object.keys(out).length > 0 ? out : undefined | ||
| } | ||
| return undefined | ||
| } | ||
| async function resolveEndpoint( | ||
| provider: ProviderV2 | undefined, | ||
| ): Promise<{ baseURL: string; apiKey?: string } | null> { | ||
| ): Promise<{ baseURL: string; apiKey?: string; customHeaders?: Record<string, string> } | null> { | ||
| const options = (provider?.options ?? {}) as Record<string, unknown> | ||
@@ -51,10 +70,11 @@ const configuredBase = typeof options.baseURL === 'string' ? options.baseURL : undefined | ||
| const envKey = process.env.LITELLM_API_KEY ?? process.env.LITELLM_MASTER_KEY | ||
| const customHeaders = readCustomHeaders(provider) | ||
| if (configuredBase) { | ||
| return { baseURL: normalizeBaseURL(configuredBase), apiKey: configuredKey ?? envKey } | ||
| return { baseURL: normalizeBaseURL(configuredBase), apiKey: configuredKey ?? envKey, customHeaders } | ||
| } | ||
| const detected = await autoDetectLiteLLM(configuredKey ?? envKey) | ||
| const detected = await autoDetectLiteLLM(configuredKey ?? envKey, customHeaders) | ||
| if (!detected) return null | ||
| return { baseURL: normalizeBaseURL(detected), apiKey: configuredKey ?? envKey } | ||
| return { baseURL: normalizeBaseURL(detected), apiKey: configuredKey ?? envKey, customHeaders } | ||
| } | ||
@@ -117,4 +137,4 @@ | ||
| const { baseURL, apiKey } = endpoint | ||
| if (!(await checkLiteLLMHealth(baseURL, apiKey))) { | ||
| const { baseURL, apiKey, customHeaders } = endpoint | ||
| if (!(await checkLiteLLMHealth(baseURL, apiKey, customHeaders))) { | ||
| console.warn(`[opencode-litellm] LiteLLM appears offline or unauthorized at ${baseURL}`) | ||
@@ -126,3 +146,3 @@ return | ||
| try { | ||
| models = await discoverLiteLLMModels(baseURL, apiKey) | ||
| models = await discoverLiteLLMModels(baseURL, apiKey, customHeaders) | ||
| } catch (error) { | ||
@@ -129,0 +149,0 @@ console.warn( |
+24
-1
@@ -1,2 +0,2 @@ | ||
| import type { Plugin, PluginInput } from '@opencode-ai/plugin' | ||
| import type { Plugin, PluginInput, Config } from '@opencode-ai/plugin' | ||
| import { discoverBucket } from './discover' | ||
@@ -8,2 +8,19 @@ | ||
| /** | ||
| * Ensure a provider entry has a `models` map so OpenCode registers it. | ||
| * | ||
| * OpenCode skips providers that have no models defined in the config, | ||
| * which means the `provider.models` hook would never be called. By | ||
| * seeding an empty `models` map we guarantee the provider is created | ||
| * and the hook has a chance to populate it with discovered models. | ||
| */ | ||
| function ensureProviderHasModels(config: Config, providerID: string): void { | ||
| if (!config.provider) return | ||
| const entry = config.provider[providerID] | ||
| if (!entry) return | ||
| if (!entry.models) { | ||
| entry.models = {} | ||
| } | ||
| } | ||
| /** | ||
| * LiteLLM Plugin for OpenCode. | ||
@@ -42,2 +59,5 @@ * | ||
| return { | ||
| config: async (config: Config) => { | ||
| ensureProviderHasModels(config, CHAT_PROVIDER_ID) | ||
| }, | ||
| provider: { | ||
@@ -93,2 +113,5 @@ id: CHAT_PROVIDER_ID, | ||
| return { | ||
| config: async (config: Config) => { | ||
| ensureProviderHasModels(config, RESPONSES_PROVIDER_ID) | ||
| }, | ||
| provider: { | ||
@@ -95,0 +118,0 @@ id: RESPONSES_PROVIDER_ID, |
+19
-0
@@ -81,2 +81,21 @@ // Core types for the LiteLLM OpenCode plugin | ||
| chatApiModels?: string[] | ||
| /** | ||
| * Arbitrary HTTP headers to include in every request to the LiteLLM | ||
| * proxy during model discovery (health check + `/v1/models`). | ||
| * | ||
| * Useful for proxies behind Cloudflare Access or other gateways that | ||
| * require extra authentication headers beyond the standard | ||
| * `Authorization: Bearer` token. | ||
| * | ||
| * Example (Cloudflare Access Service Token): | ||
| * ```json | ||
| * { | ||
| * "customHeaders": { | ||
| * "CF-Access-Client-Id": "{env:CF_ACCESS_CLIENT_ID}", | ||
| * "CF-Access-Client-Secret": "{env:CF_ACCESS_CLIENT_SECRET}" | ||
| * } | ||
| * } | ||
| * ``` | ||
| */ | ||
| customHeaders?: Record<string, string> | ||
| } |
@@ -24,3 +24,3 @@ import type { LiteLLMModel, LiteLLMModelsResponse } from '../types' | ||
| function buildHeaders(apiKey?: string): Record<string, string> { | ||
| function buildHeaders(apiKey?: string, customHeaders?: Record<string, string>): Record<string, string> { | ||
| const headers: Record<string, string> = { | ||
@@ -33,2 +33,5 @@ 'Content-Type': 'application/json', | ||
| } | ||
| if (customHeaders) { | ||
| Object.assign(headers, customHeaders) | ||
| } | ||
| return headers | ||
@@ -41,2 +44,3 @@ } | ||
| apiKey?: string, | ||
| customHeaders?: Record<string, string>, | ||
| ): Promise<boolean> { | ||
@@ -46,3 +50,3 @@ try { | ||
| method: 'GET', | ||
| headers: buildHeaders(apiKey), | ||
| headers: buildHeaders(apiKey, customHeaders), | ||
| signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), | ||
@@ -63,2 +67,3 @@ }) | ||
| apiKey?: string, | ||
| customHeaders?: Record<string, string>, | ||
| ): Promise<LiteLLMModel[]> { | ||
@@ -68,3 +73,3 @@ const url = buildAPIURL(baseURL) | ||
| method: 'GET', | ||
| headers: buildHeaders(apiKey), | ||
| headers: buildHeaders(apiKey, customHeaders), | ||
| signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), | ||
@@ -86,7 +91,7 @@ }) | ||
| */ | ||
| export async function autoDetectLiteLLM(apiKey?: string): Promise<string | null> { | ||
| export async function autoDetectLiteLLM(apiKey?: string, customHeaders?: Record<string, string>): Promise<string | null> { | ||
| const commonPorts = [4000, 8000, 8080] | ||
| for (const port of commonPorts) { | ||
| const baseURL = `http://localhost:${port}` | ||
| if (await checkLiteLLMHealth(baseURL, apiKey)) { | ||
| if (await checkLiteLLMHealth(baseURL, apiKey, customHeaders)) { | ||
| return baseURL | ||
@@ -93,0 +98,0 @@ } |
46815
10.2%718
9.95%417
13.01%