Symptom-keyed. Each entry covers what's happening internally, where to look in logs, and a diagnostic you can run.
The plugin emits structured JSON logs to stderr (and through client.app.log() when running under OpenCode). Anywhere this guide says "look for <event> in the logs", that's an event name in the event field of those entries — see architecture.md → Logging for the full table.
This page covers
@vymalo/opencode-oauth2. For the companion plugins, see the failure-mode tables in models-info.md (metadata enrichment) and ratelimit.md (rate-limit throttling).
What's happening. The plugin loaded fine, but warmup at config-hook time ran non-interactively (CI, no TTY, or interactive: false) and the cache was empty. ensureToken threw interactive authentication required; syncServer caught it, logged sync_startup_failed, and preserved the empty cache. The provider stays registered in OpenCode's config but with no models attached, so the model list is empty.
Look for.
plugin_initialized— confirms the plugin loaded.sync_startup_failedwitherror: "interactive authentication required for server ..."— confirms warmup gave up non-interactively.- Absence of
sync_successandmodel_discovery_*.
Fix. Trigger auth via an actual opencode run (the chat path calls ensureToken with interactive: true by default), or override warmup interactivity at start time. There's no pluginConfig knob for this — if you're embedding the runtime yourself, pass interactive: true to start(). From the OpenCode-hosted path, you can't override; just run a one-shot to bootstrap.
# Bootstrap auth interactively — completes the PKCE browser dance once,
# leaves a refresh token in cache for subsequent non-interactive runs.
opencode run --model "miaou/glm-5" "hello"After that, the model list should populate within one scheduler tick (default 60 minutes) or on the next opencode restart (warmup picks up the cached refresh token and refreshes silently).
What's happening. The plugin started a loopback callback server on some 127.0.0.1:<port>, set redirect_uri=http://127.0.0.1:<port>/oauth2/callback in the authorize URL, and the IdP rejected the value because it's not in the client's allowed-redirect-URI list.
Look for. The error surfaces in the browser, not the logs — the IdP returns it to the user before the callback runs. The plugin's oauth_login_started event will be present; oauth_login_success will not.
Fix per IdP.
| IdP | Add to allowed redirect URIs |
|---|---|
| Keycloak | http://127.0.0.1:*/oauth2/callback (with + wildcard enabled at realm level) or pin redirectPort: 8765 and add http://127.0.0.1:8765/oauth2/callback literally |
| Auth0 | http://localhost is implicitly allowed for native apps; for explicit registration, pin redirectPort and add http://127.0.0.1:<port>/oauth2/callback to Allowed Callback URLs |
| Okta | Pin redirectPort and add http://127.0.0.1:<port>/oauth2/callback to Sign-in redirect URIs on the OIDC app |
The plugin's callback path is hard-coded as /oauth2/callback. The host is always 127.0.0.1 (not localhost) — register the literal 127.0.0.1, not its DNS alias.
See local-development.md → Fixed redirectPort vs random for the tradeoffs.
What's happening. The IdP requires PKCE on the flow you're using, and the request reached it without a code_challenge. Most common on Keycloak clients where Advanced → Proof Key for Code Exchange Code Challenge Method is set to S256 — Keycloak then enforces PKCE on both the authorize endpoint (authorization_code) and the device-authorization endpoint (device_code).
Look for.
oauth_device_authorization_failedwithstatus: 400andbodyPreviewcontaining"error":"invalid_request","error_description":"Missing parameter: code_challenge_method", followed bysync_failed(device authorization request failed (400)).- The equivalent on
authorization_codesurfaces in the browser at the authorize step.
Fix. The plugin sends PKCE on both interactive flows by default, so on a current version this just works — upgrade if you're on an older build that omitted it. The code_verifier is generated per login and replayed on the token exchange/poll automatically; no configuration is needed.
If you're hitting the opposite problem — a non-compliant IdP that rejects the extra code_challenge / code_verifier parameters — set pkce: false on that server's oauth2 options to opt out. Leave it on (the default) for everything else; compliant servers that don't require PKCE simply ignore it.
What's happening. Setting responseApi: true swaps the provider's package from @ai-sdk/openai-compatible to the native @ai-sdk/openai, so OpenCode routes inference through the Responses API (/v1/responses) instead of Chat Completions (/v1/chat/completions). Two things change with that swap:
- The native provider throws
OpenAI API key is missingat construction if noapiKeyis set. The plugin handles this for you by stamping an inert placeholder key (oauth2-managed-bearer) when you haven't supplied one; the real OAuth bearer is still injected per request bychat.headers, so the placeholder is never actually sent. You should not see this error from the plugin's own providers — if you do, you likely hand-rolled an@ai-sdk/openaiprovider inopencode.jsonwithout anapiKeyand without this plugin managing it. - The native provider speaks the OpenAI Responses wire format, which is not the same as Chat Completions. The route must exist and the chosen model must be served on it — gateways often expose
/v1/responsesfor only a subset of models. A model that 404s on/v1/responses(while working on/v1/chat/completions) is a model-routing gap, not a missing route.
Look for.
oauth2_provider_response_api_enabled(debug) confirms the toggle was read for that provider, and the registered provider showsnpm: "@ai-sdk/openai".- Inference 404 / 400 from the gateway despite successful auth and model discovery → either the gateway doesn't serve
/v1/responses, or that specific model isn't routed there. Try another model.
Fix. Only enable responseApi when the gateway implements the OpenAI Responses contract at <baseURL>/responses for the model you're using. Otherwise leave it unset (the default) to stay on Chat Completions via @ai-sdk/openai-compatible.
What's happening. Inference reaches the gateway and the model responds, but OpenCode aborts with text part <msg_id> not found. The gateway's Responses SSE stream omits the output_index / content_index fields that the canonical OpenAI Responses API always includes. AI-SDK / OpenCode key each streamed message part by those indices, so when they're absent the text part is never associated with its deltas. Observed against Envoy AI Gateway fronting a local model server.
Look for.
- The error fires after successful auth + model discovery, only with
responseApi: true, and only when the model emits a reasoning item before the text (the missing indices desync part bookkeeping there). - A raw
curlof<baseURL>/responseswith"stream":trueshows events like{"type":"response.output_text.delta","item_id":"msg_…","delta":"…"}with nooutput_index/content_index.
Fix. The plugin repairs this automatically: when responseApi is on, it wraps the provider's fetch and injects the missing output_index (per item, in output_item.added order) and content_index: 0 into the SSE before OpenCode parses it (see src/responses-repair.ts). The repair never overwrites indices a conformant gateway already sends, so it's a safe no-op there. The cleaner long-term fix is gateway-side — emit the indices so every OpenAI Responses client works.
What's happening. OAuth succeeded — you have a valid access token — but the upstream /v1/models endpoint returned 403. The access token is missing the scope or audience the gateway expects.
Look for.
oauth_*_successevents present (proves auth worked).sync_failedwitherror: "model discovery failed (403) at https://api.example.com/v1/models".model_discovery_error_bodywithstatus: 403and abodyPreview(token-shaped substrings are masked byscrubSecrets).
Diagnose. Copy the access token from the cache and curl /v1/models directly:
# Pull the access token from the cache (replace with your platform's path).
token=$(jq -r .token.accessToken \
~/Library/Caches/opencode-oauth2/opencode-oauth2-model-sync/miaou.json)
# Hit /v1/models with it.
curl -i \
-H "Authorization: Bearer $token" \
-H "Accept: application/json" \
https://api.example.com/v1/modelsIf you get the same 403, decode the JWT to inspect the claims:
# Decode the payload (middle segment).
echo "$token" | cut -d. -f2 | base64 -d 2>/dev/null | jq .Compare:
scope(orscp— depends on IdP) against what your gateway requires. Adjustscopes:inopencode.jsonand force re-auth (see local-development.md → Force re-auth).audagainst what your gateway validates. Fortoken_exchange, settokenExchangeAudienceto match.
What's happening. @vymalo/opencode-models-info tried to fetch meta.modelsInfoUrl, the endpoint returned 401, and there was no previously-cached catalog to fall back to — so model metadata (context window, cost, modalities) is left un-enriched. The metadata endpoint is auth-protected and no Authorization header reached it.
Look for.
models_info_fetch_failed_no_cachewith theurlanderror: "HTTP 401".- For an oauth2-backed provider:
sync_successfor the provider did fire (so inference auth works), but the metadata fetch still 401'd.
Fix.
- List
@vymalo/opencode-oauth2before@vymalo/opencode-models-infoin yourpluginarray. oauth2'sconfighook stamps the bearer ontooptions.headersand must run first. The bearer is now produced by a refresh-backed ensure, so a freshly-minted (even short-lived) token is propagated rather than skipped. - On a one-off first-login 401, just re-run the command. The token is on disk after the first login, so the next run stamps it before models-info's hook runs.
- Different credential for metadata? Set
meta.modelsInfoHeaders.Authorization— it overrides the inherited provider header. - Endpoint not actually OpenRouter-shaped? A vanilla
/v1/modelsreturns no mappable fields; pointmodelsInfoUrlat the richer metadata route. See models-info.md → URL resolution.
The cache TTL is already 24h by default (meta.modelsInfoTtlSeconds); once the fetch 200s once, reboots stay offline for the day. There's no way to pre-seed a placeholder cache for an endpoint that has never succeeded — see models-info.md → "There's no cache yet".
What's happening. Enrichment worked — models_info_enriched reports enrichedCount > 0 and the cost is present in OpenCode's resolved config — but the TUI shows no price or context window. The usual cause is a missing limit: your metadata endpoint returns pricing but no context_length / top_provider, so the plugin emits no limit (it never fakes one), and OpenCode backfills the runtime model's required limit to { context: 0, output: 0 }. OpenCode's model UI is built around its models.dev catalog where every model has a real limit, so a 0/0 model is treated as incomplete and may suppress the cost line along with the limit.
Confirm. Start the headless server (opencode serve — it prints a base URL) and inspect the resolved config:
curl -s http://127.0.0.1:<port>/config/providers \
| jq '.providers[] | select(.id=="<provider-id>") | .models["<model-id>"] | {cost, limit}'cost populated + limit: { context: 0, output: 0 } is the signature of this case — proof the plugin did its job and the gap is the source data.
Fix (server-side). This is not a plugin issue — the metadata endpoint must include the size fields the mapper reads (mapping.ts — context ← top_provider.context_length ?? context_length, output ← top_provider.max_completion_tokens):
Once both context and output are known, the plugin emits a real limit, and the cost/limit render in the UI. (cost was already correct — adding the limit is what makes both visible.)
What's happening. With @vymalo/opencode-oauth2 + @vymalo/opencode-models-info stacked, the UI shows e.g. Kimi K2.6 even though your models/info endpoint returns "name": "kimi-k2.6". oauth2's model discovery stamps a normalized display name onto every model entry before models-info runs (mergeDiscoveredModels → normalizeModelId). models-info's merge is upstream-wins, so it sees name already set and won't overwrite it — the endpoint's name never lands.
Fix. Opt name out of upstream-wins for that provider:
{
"options": {
"meta": {
"modelsInfoUrl": "models/info",
"modelsInfoOverwrite": ["name"]
}
}
}See models-info.md → Overriding upstream-wins. The same applies to any field oauth2 (or another plugin) pre-stamps; name is the only one oauth2 currently sets.
What's happening. A model your models/info endpoint reports with architecture.input_modalities: ["text", "image"] shows no image/attachment support in OpenCode. The mapper does derive both modalities.input and attachment: true from that field (mapping.ts), and — unlike name — oauth2 does not pre-stamp modalities/attachment, so upstream-wins doesn't block them. So if image input is missing, the enrichment isn't reaching the entry with current data. In order of likelihood:
- Stale cache. The catalog is cached for
meta.modelsInfoTtlSeconds(default 24h). Ifimagewas added to the endpoint within that window, the on-disk copy predates it. Clear it and relaunch:rm -rf ~/Library/Caches/opencode-models-info/(macOS; see Caching for Linux/Windows paths). - The fetch is failing. Look for
models_info_fetch_failed_no_cache/..._using_stale— usually a 401 on an auth-protected metadata endpoint (see the 401 section). - id mismatch between the discovered model (
/v1/models, which becomes the config key) and themodels/infoentry — only matching ids get enriched.
Confirm. Start opencode serve and inspect the resolved config:
curl -s http://127.0.0.1:<port>/config/providers \
| jq '.providers[] | select(.id=="<provider-id>") | .models["<model-id>"] | {attachment, modalities}'attachment: true + modalities.input containing image means enrichment worked and the gap is elsewhere; their absence points back to one of the three causes above.
What's happening. The IdP rejected the client_id + client_secret combination. Three common root causes for Keycloak; similar elsewhere.
Look for.
oauth_client_credentials_failedwithstatus: 401andbodyPreviewcontaininginvalid_clientorunauthorized_client.- The
bodyPreviewwill have token-shaped values masked, but theerroranderror_descriptionfields are usually preserved.
Diagnose (Keycloak).
- Service accounts disabled. In Keycloak admin: Clients → <your client> → Capability config. Ensure Service accounts roles is on. Without it, the client cannot use
client_credentialsregardless of secret validity. - Wrong secret. Credentials tab → confirm the secret matches
clientSecretin your config. Rotated secrets in Keycloak invalidate the previous one immediately. - Client type mismatch. A Public client (no secret) cannot use
client_credentials. Convert to Confidential in Capability config → Client authentication: ON.
Reproduce manually:
curl -i -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=YOUR_CLIENT&client_secret=YOUR_SECRET" \
https://auth.example.com/realms/your-realm/protocol/openid-connect/tokenA 200 here with an access token means the issue is in your config (typo in clientId/tokenEndpoint); a 401 with the same body confirms it's an IdP-side misconfig.
What's happening. The IdP received the JWT (subject token) but rejected it. Causes from most to least common:
audmismatch. The IdP expects a specific audience in the assertion; what you sent doesn't match.- IdP-trust client misconfigured. Keycloak's GitHub Actions identity provider has
Issuer URL≠ the literalissin the JWT, or a Token Exchange permission policy that doesn't allow the requesting client. - JWT expired. GitHub Actions OIDC tokens are valid for ~10 minutes; if the plugin caches an expired one (shouldn't happen —
resolveSubjectTokenalways re-fetches) or your clock skew is severe, the assertion fails signature validation.
Look for.
oauth_jwt_bearer_failedwithstatus: 401andbodyPreviewcontaininginvalid_grantorinvalid_tokenand an error description likeassertion is expired/audience does not match.
Diagnose. Look up the configured audience and the JWT's aud:
# In a GHA job — manually fetch the OIDC token and decode it.
oidc_token=$(curl -sS \
-H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://your-expected-audience" \
| jq -r .value)
echo "$oidc_token" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{aud, iss, sub, repository, workflow}'For kubernetes_sa:
# From inside the pod.
jwt=$(cat /var/run/secrets/tokens/oauth2/token)
echo "$jwt" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{aud, iss, sub}'aud must match the IdP's expected audience exactly. See github-actions.md → audience pinning and kubernetes.md → IdP setup.
What's happening. Stdin or stdout reports as a TTY when it isn't (some terminal multiplexers, broken PTY libs, certain CI runners with tty: true set). Warmup believes it's interactive, tries to open a browser or start device-code polling, and waits forever for a callback that never arrives.
Look for. oauth_login_started event but no oauth_login_success for several minutes. Process hangs at startup.
Fix. Currently no environment-variable override. If you're embedding the runtime, pass interactive: false to start(). If you're running under OpenCode-hosted mode and can't avoid the misdetection, set CI=true in the environment — most TTY-detection libs treat that as a signal to fake non-TTY behavior, though the plugin itself reads process.stdin.isTTY directly so this is only effective insofar as your shell or process supervisor honors it.
The principled fix on the embedder side:
import { OAuth2ModelSyncPlugin } from "@vymalo/opencode-oauth2/lib";
const runtime = new OAuth2ModelSyncPlugin(cfg, { /* ... */ });
await runtime.initialize();
await runtime.start({ warmup: true, interactive: false });If you're stuck on the OpenCode-hosted path, the workaround is "delete the cache and run a one-shot opencode run from a real terminal to populate it, then resume the headless context which will refresh silently".
What's happening. The pod's projected SA token rotates fine (kubelet refreshes the file), but the access token from your IdP isn't rotating — same access token keeps being used past its expiry, eventually 401-ing against the upstream.
Look for.
oauth_jwt_bearer_successwithhasExpiry: false— the IdP isn't returningexpires_in, so the plugin treats undefined-expiry as INVALID for machine flows (it should re-acquire every call). If you see this and persistent 401s, the problem is downstream.oauth_jwt_bearer_failedshortly after a successful auth: confirms re-auth is being attempted and failing.
Most common root causes.
- Missing
audienceon the projected token volume. Without it, the SA token'sauddefaults to the apiserver, not your IdP. The IdP rejects withaudience mismatch. Fix: addaudience: <idp-audience>to theserviceAccountTokensource — see kubernetes.md. - Audience mismatch between
serviceAccountToken.audienceandsubjectTokenSource.audience. Forkubernetes_sa, the plugin doesn't pass anaudienceto the IdP — the IdP reads it from the JWT itself. Make sure the SA-token's audience equals the IdP's expected audience. - For GHA: missing
audienceinsubjectTokenSource. Thegithub_actionssource does setaudienceon the OIDC request URL — but if you've configured a differentaudiencethan your IdP expects, the resulting JWT will have the wrongaud. Both sides need to agree.
Diagnose by tailing logs and counting:
kubectl logs deploy/opencode-bot --tail=200 \
| jq -Rr 'fromjson? // empty' \
| jq -s 'group_by(.event) | map({event: .[0].event, count: length})'If oauth_jwt_bearer_started count grows steadily but oauth_jwt_bearer_success plateaus, you have a failing re-auth.
What's happening. Several OpenCode instances started at nearly the same moment and all ran the oauth2 model sync for the same provider. The desktop app triggers this whenever it restores more than one project window at launch — each window is its own OpenCode process, and they all write the same cache file.
The model-sync cache writes atomically: write a temp file, then rename it onto the real path. Before this fix every writer used the same temp name (<serverId>.json.tmp), so two concurrent processes raced — process A's rename consumed the temp file process B had just written, and B's rename then failed with ENOENT on a temp file that no longer existed.
Impact. Cosmetic-but-noisy: the other instance's atomic write still lands, so the cache file is never corrupt — but the losing instance logs ERROR sync_failed and falls back to its in-memory/stale model list for that boot instead of the fresh sync.
Look for.
- An
ERROR sync_failedwhoseerroris anENOENTon a*.json.tmp -> *.jsonrename. - Multiple
creating instance/ratelimit_plugin_initializedlines clustered within the same second just before it (the tell-tale parallel boot).
Fix. Upgrade to a build that includes the per-writer temp-name fix (temp files are now suffixed with pid + a uuid and unlinked on failure, so concurrent writers can't collide — see ADR-0005). No config change needed. If you're pinned to an older version and can't upgrade, launching projects one at a time avoids the race.
What's happening. The published package on npmjs.com is missing the "Built and signed on GitHub Actions" badge. Either the publish workflow didn't request OIDC, or npm rejected the provenance attestation.
Look for.
- In the workflow run logs (
Publish to npmstep): any line mentioningprovenanceanderror. - On the npm package page: the badge near the version.
Causes.
-
id-token: writenot granted to the job. npm provenance generation requires OIDC. Confirm thepermissions:block on the publish job includesid-token: write. The shipped publish.yml sets it at the workflow level — if you derived your own from an earlier version, double-check. -
repositoryfield inpackage.jsondoesn't match the publishing workflow's repo. npm validates the provenance attestation's repo URL againstpackage.json"repository". Mismatch → rejected. Fix:{ "repository": { "type": "git", "url": "git+https://github.com/vymalo/opencode-oauth2.git" } } -
NPM_CONFIG_PROVENANCEnot set. The shipped workflow sets it as a belt-and-braces — if you removed the env var, pnpm'spublish --provenanceflag should still work, but some pnpm/npm version combinations silently drop the flag.
Once fixed, the badge appears on the next published version (you can't backfill provenance for an already-published tarball).
What's happening. pnpm 11 enforces an opt-in allowBuilds list for native build scripts. If your project pulls in a transitive dep with a postinstall build (esbuild, msgpackr-extract, etc.) and pnpm-workspace.yaml doesn't allow it, pnpm fails the install on CI with this error.
Look for. Install step output:
ERR_PNPM_IGNORED_BUILDS: Ignored build scripts: esbuild.
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.
Fix. Add the package name to allowBuilds in pnpm-workspace.yaml:
allowBuilds:
esbuild: true
msgpackr-extract: trueThe shipped pnpm-workspace.yaml allows these two by default (they're dependencies of vitest/vite + msgpack speedups). If you add a new dep that triggers the same error, audit the package's postinstall script before adding it — allowBuilds is the supply-chain-security gate.
Why pnpm 11's behavior matters. The legacy onlyBuiltDependencies array is silently ignored in --frozen-lockfile mode. A local pnpm install succeeds because pnpm interactively prompts; CI fails because there's no TTY. Always use allowBuilds (the explicit object shape) to keep dev and CI consistent.
Pretty-print and filter the JSON logs:
opencode run --model "miaou/glm-5" "say hi" 2>&1 \
| jq -Rr 'fromjson? // .' \
| jq 'select(.event | test("oauth|sync|model"))'If you don't see anything OAuth-related at all, the plugin isn't loading. Confirm:
opencode --version
npm ls -g | grep opencode-oauth2
cat $OPENCODE_CONFIG_DIR/opencode.json | jq '.plugin'The plugin array must include "@vymalo/opencode-oauth2" (or a local path that re-exports it — see local-development.md).
{ "id": "your-model", "pricing": { "prompt": "0.0000006", "completion": "0.0000021" }, "context_length": 128000, "top_provider": { "context_length": 128000, "max_completion_tokens": 8192 } }