Bring your own OAuth-protected LLM gateway to OpenCode.
An OpenCode plugin that lets you wire up OpenAI-compatible model providers sitting behind OAuth2 / OIDC — without baking long-lived API keys into your config. Discover models dynamically, refresh tokens automatically, and let OpenCode talk to your gateway as if it were any other provider.
flowchart LR
OC[opencode] -->|chat.headers| Plugin[opencode-oauth2]
Plugin -->|cached token?| Cache[(~/.cache/opencode-oauth2)]
Plugin -->|acquire / refresh| IdP[OAuth server]
Plugin -->|Authorization: Bearer …| Upstream[Provider API]
Most OpenCode providers assume a static bearer key. That works for hosted SaaS, but breaks down the moment you put your models behind:
- a corporate Identity Provider (Keycloak, Auth0, Okta, Azure AD, …)
- a self-hosted gateway with short-lived tokens
- a multi-tenant setup where each user authenticates as themselves
- a CI runner that has no business carrying a long-lived secret
This plugin closes that gap. It handles the OAuth dance for the flow you need, caches tokens, refreshes silently, and feeds OpenCode a normal-looking provider with a fresh Authorization header on every request.
- Five auth flows, pick what matches your runtime:
authorization_code— interactive PKCE login (default)device_code— RFC 8628, for browserless user authclient_credentials— machine-to-machine with aclientSecretjwt_bearer— RFC 7523 federated identity (GitHub Actions OIDC, Kubernetes SA tokens) — no long-lived secret in CItoken_exchange— RFC 8693 federated identity with explicit audience targeting
- Dynamic model discovery from
/v1/models(no hand-maintained model lists) - Display-name normalization so
glm-5shows up asGLM 5 - Persistent token cache with automatic refresh
chat.headershook injects bearer tokens per request- Two configuration styles: per-provider options or a top-level plugin block
Then declare a provider:
{
"plugin": ["@vymalo/opencode-oauth2"],
"provider": {
"example-ai": {
"name": "Example AI",
"options": {
"baseURL": "https://api.example.com/v1",
"oauth2": {
"issuer": "https://auth.example.com",
"clientId": "opencode-client",
"scopes": ["openid", "profile", "offline_access"],
"syncIntervalMinutes": 60
}
}
}
}
}See packages/opencode-oauth2/README.md for the full configuration reference (including the alternative pluginConfig.oauth2ModelSync.servers layout and every optional field).
| Page | When you need it |
|---|---|
docs/architecture.md |
Understand the hooks, token lifecycle per flow, cache layout, sync scheduler, logging |
docs/models-info.md |
The companion metadata-enrichment plugin — how it composes with any auth scheme, caching, failure modes |
docs/ratelimit.md |
The companion rate-limit-aware plugin — reading Envoy x-ratelimit-* headers, the throttle/backoff state machine, the fetch-wrapping interception point, the timeout caveat |
docs/well-known.md |
How .well-known/opencode distributes a provider + plugin setup to clients — auth login, the placeholder-key pattern, where config and tokens actually live |
docs/github-actions.md |
CI without stored secrets — Keycloak/Auth0/Okta setup, reusable workflow, matrix, fork-PR limits |
docs/kubernetes.md |
CronJob / Job / Deployment with projected SA tokens, multi-provider pods, RBAC |
docs/local-development.md |
Sandbox setup, plugin re-export trick, forcing re-auth, dev-only env subject token |
docs/troubleshooting.md |
Symptom-keyed fixes — redirect_uri_mismatch, model discovery 403, invalid_client, projected-token rotation |
This workspace also ships @vymalo/opencode-models-info — a separate, auth-agnostic plugin that enriches your model entries with full metadata (context length, output limit, USD/M-token cost, modalities, and tool_call / reasoning / attachment flags).
meta.modelsInfoUrl is the HTTP(S) endpoint that returns the metadata JSON — { "data": [ { "id", "context_length", "pricing", … } ] }. Point it at your provider's metadata endpoint (an absolute URL, or a path resolved against baseURL):
{
"plugin": ["@vymalo/opencode-models-info"],
"provider": {
"my-provider": {
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "https://api.example.com/v1",
"meta": { "modelsInfoUrl": "https://api.example.com/v1/models" }
},
"models": { "my-model-large": {} }
}
}
}The expected JSON is commonly called the OpenRouter shape (it's what OpenRouter's /models returns), but the plugin has no dependency on OpenRouter — any endpoint serving that shape works. A plain OpenAI-compatible /v1/models returns sparse data (id, object, owned_by) — not context_length / pricing — so the endpoint must actually carry the richer fields.
It doesn't depend on the oauth2 plugin — it runs as a config hook after other plugins, composing with oauth2, static API keys, or no auth. When paired with @vymalo/opencode-oauth2 ≥ 0.4.0, an OAuth2-protected metadata endpoint works with zero extra config: the oauth2 plugin stamps the cached bearer onto the provider's headers at config time and the metadata fetch inherits it.
One provider, authenticated by oauth2 and enriched by models-info. List @vymalo/opencode-oauth2 first so its config hook runs before models-info and the bearer is already in place when the metadata fetch happens:
{
"plugin": ["@vymalo/opencode-oauth2", "@vymalo/opencode-models-info"],
"provider": {
"my-provider": {
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "https://api.example.com/v1",
"oauth2": {
"issuer": "https://auth.example.com",
"clientId": "opencode-client",
"scopes": ["openid", "profile", "offline_access"]
},
"meta": { "modelsInfoUrl": "https://api.example.com/v1/models" }
}
}
}
}What happens on boot: oauth2 authenticates, discovers models from /v1/models, and stamps the access token onto the provider's headers; models-info then fetches modelsInfoUrl with that token and merges the richer metadata onto the discovered models. No models block needed — oauth2 populates it. No Authorization header to manage — it's automatic.
Full reference: packages/opencode-models-info/README.md. Behavior, caching, and composition details: docs/models-info.md.
This workspace also ships @vymalo/opencode-ratelimit — a separate, auth-agnostic plugin that makes a provider respect the rate-limit headers your gateway already sends. It reads the IETF draft-03 triple emitted by Envoy Gateway's global rate limiting (x-ratelimit-limit / x-ratelimit-remaining / x-ratelimit-reset), proactively pauses new requests once the window is exhausted, and backs off + retries on HTTP 429 — so a burst of requests cooperates with the gateway instead of earning a wall of 429s.
OpenCode has no post-response hook, so the only way to observe response status/headers is to wrap the provider's fetch. The plugin does exactly that during its config hook, for any provider that opts in via options.meta.rateLimit:
{
"plugin": ["@vymalo/opencode-ratelimit"],
"provider": {
"my-provider": {
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "https://api.example.com/v1",
"meta": { "rateLimit": { "maxWaitMs": 0, "maxRetries": 5 } }
}
}
}
}It never reads or sets Authorization, so it composes with oauth2, static keys, or no auth. For gateways with multiple windows it supports tiers (wait through short burst resets, error fast on multi-day budget resets) and scope: "model" (per-model cooldown buckets). Full reference: packages/opencode-ratelimit/README.md. Mechanism, concurrency model, and the timeout caveat: docs/ratelimit.md.
The plugins stack cleanly on one provider. List them in this order so each config hook sees what it needs — oauth2 stamps the bearer first, models-info enriches with it, and ratelimit wraps the fetch (its position is cosmetic since it's auth-independent):
{
"plugin": [
"@vymalo/opencode-oauth2",
"@vymalo/opencode-models-info",
"@vymalo/opencode-ratelimit"
],
"provider": {
"my-provider": {
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "https://api.example.com/v1",
"oauth2": {
"issuer": "https://auth.example.com",
"clientId": "opencode-client",
"scopes": ["openid", "profile", "offline_access"]
},
"meta": {
"modelsInfoUrl": "https://api.example.com/v1/models",
"rateLimit": { "maxWaitMs": 0, "maxRetries": 5 }
}
}
}
}
}For GitHub Actions and Kubernetes workloads, use jwt_bearer (or token_exchange) with the platform's own short-lived OIDC token as the subject. The plugin re-fetches it on every access-token expiry; nothing long-lived gets cached.
End-to-end recipes live in docs/github-actions.md and docs/kubernetes.md. The shipped reusable workflow at .github/workflows/opencode-run.yml covers the common opencode run case.
Refresh tokens are mandatory for the flows that issue them.
authorization_code/device_codeexchanges that don't returnrefresh_tokenare rejected.- Cached tokens missing
refreshTokenare evicted on load (unless they're fromclient_credentials/jwt_bearer/token_exchange, which don't issue one). - Refresh responses that omit a new
refresh_tokenre-use the existing one.
The intent: a user-flow session is either fully renewable or it doesn't get cached. Machine flows re-acquire on every expiry; refresh tokens have no role there.
This is a pnpm monorepo.
| Package | Purpose |
|---|---|
packages/opencode-oauth2 |
OAuth2/OIDC auth + model discovery — published as @vymalo/opencode-oauth2 |
packages/opencode-models-info |
Auth-agnostic model metadata enrichment — published as @vymalo/opencode-models-info |
packages/opencode-ratelimit |
Auth-agnostic rate-limit awareness (Envoy x-ratelimit-* throttle + 429 backoff) — published as @vymalo/opencode-ratelimit |
packages/plugin-bundle |
Rolldown-based bundling for distribution |
plans/prd.md |
Product requirements and phased roadmap |
pnpm install
pnpm build
pnpm typecheck
pnpm testPlugin-only iteration:
pnpm --filter @vymalo/opencode-oauth2 test
pnpm --filter @vymalo/opencode-oauth2 buildFor end-to-end usage against a local OpenCode install, see GETTING_STARTED.md.
Early but functional. The Phase 1 scaffold and Phase 2 runtime core are in; bundling (Phase 3) has landed. Public API may still shift before 1.0.
Roadmap and phase breakdown live in plans/prd.md.
Issues and PRs are welcome. Please open an issue first for substantial changes so we can align on scope before code review.
MIT © vymalo contributors
{ "$schema": "https://opencode.ai/config.json", "plugin": ["@vymalo/opencode-oauth2"] }