Skip to content

OpenCode

OpenCode reads models through a provider: a named block in its configuration that gives a base URL, a client package to speak to it, and the model ids available there. A provider using @ai-sdk/openai-compatible talks the OpenAI wire format, which is what the GRN.CLOUD gateway at https://api.grn.cloud/v1 serves, so connecting the two is a configuration entry and a credential rather than an integration.

The credential is the part worth reading carefully. The gateway does not hand a request to a model until it can attribute it to someone, and it accepts two different proofs of who you are. Which one you hold changes what you configure and what you can see afterwards.

The step-by-step guide covers the same three values for any agent. This page is OpenCode specifically: its configuration file, its credential store, and its own token accounting.

Prerequisites

  • OpenCode installed locally - the paths and commands here were checked against 1.18
  • A virtual key for api.grn.cloud, issued to you by whoever administers the gateway
  • Network access to api.grn.cloud from your machine

An account on the identity provider is not on its own enough to reach a model. The gateway resolves your identity to a virtual key through a mapping an administrator maintains, and refuses the request when your account is not in it. Getting access means being added to that mapping, not just being able to sign in.

Nothing on this page needs cluster access. You never run oc, and the key is not something you can retrieve yourself - it is held in the gateway's own namespace, which your account cannot read. If you do not have a key, ask for one rather than looking for it.

What the gateway accepts

Every request to /v1 is authorized at the edge, before any model is chosen. The gateway requires either a virtual key or a valid OAuth token, and rejects anything else with a plain-text 403 authorization failed.

flowchart LR
    OC["OpenCode<br/>on your machine"]
    AUTH{"what is in the<br/>Authorization header?"}
    SWAP["look up the virtual key<br/>bound to that identity"]
    GW["gateway routing"]
    DENY["403 authorization failed"]
    M1["self-hosted models"]
    M2["external providers"]

    OC -->|"Authorization: Bearer ..."| AUTH
    AUTH -->|"a known virtual key"| GW
    AUTH -->|"a valid OAuth token"| SWAP
    SWAP --> GW
    AUTH -->|"anything else"| DENY
    GW --> M1
    GW --> M2

The two paths converge: an OAuth token is exchanged for the virtual key bound to your identity, so by the time the request reaches routing it carries a virtual key either way. That is why budgets, rate limits and per-key request logs behave the same however you authenticated.

A placeholder credential fails at the edge, not at the model

The gateway checks the credential before it looks at the model name. A request carrying a placeholder such as dummy is refused with 403 authorization failed whatever model it names, so an error mentioning authorization is never evidence that the model id is wrong.

Configure the provider

OpenCode reads ~/.config/opencode/opencode.json for global configuration. Add a provider block:

{
  "$schema": "https://opencode.ai/config.json",
  "provider": {
    "grn": {
      "name": "GRN.CLOUD",
      "npm": "@ai-sdk/openai-compatible",
      "options": {
        "baseURL": "https://api.grn.cloud/v1"
      },
      "models": {
        "<model-id>": { "name": "<label shown in the model picker>" }
      }
    }
  }
}

grn is the provider id used in logs and in opencode auth - any name works, but it is worth matching whatever you call the gateway elsewhere. The models map is a fallback listing: OpenCode uses it when it cannot read the gateway's own model list, which it will not be able to do until the credential is in place.

Note that options takes a baseURL but no key. The credential is stored separately, in OpenCode's own credential file, so that configuration can be shared or committed without carrying a secret.

Store the virtual key

Credentials live in ~/.local/share/opencode/auth.json, keyed by provider id. It is plain JSON, so writing the entry by hand is the option with no dependencies; the other tabs do the same thing without an editor. Use the same id you gave the provider above.

Add a grn entry alongside any that are already there:

{
  "grn": {
    "type": "api",
    "key": "<your-virtual-key>"
  }
}

Create the file, and the directory above it, if they do not exist yet.

f=~/.local/share/opencode/auth.json
mkdir -p "$(dirname "$f")"
[ -f "$f" ] || echo '{}' > "$f"
jq '.grn = {"type": "api", "key": "<your-virtual-key>"}' "$f" > "$f.new" && mv "$f.new" "$f"
python3 -c '
import json, pathlib
p = pathlib.Path.home() / ".local/share/opencode/auth.json"
p.parent.mkdir(parents=True, exist_ok=True)
d = json.loads(p.read_text()) if p.exists() else {}
d["grn"] = {"type": "api", "key": "<your-virtual-key>"}
p.write_text(json.dumps(d, indent=2))
'

OpenCode then sends it as a bearer token on every request to that provider's base URL. Confirm what is stored - this lists the providers holding credentials without printing the values:

opencode auth list

opencode auth login cannot select a provider you defined yourself

Its provider picker lists the models it ships with, not the ones in your configuration, so a custom provider never appears in it however you search. Passing the id explicitly fails the same way:

$ opencode auth login --provider grn
Error: Unknown provider "grn"

Writing the file, as above, is the way to give a custom provider a credential.

The gateway's virtual key is not an OpenAI key

It is valid only at api.grn.cloud. Do not export it into OPENAI_API_KEY, where any other tool on the machine would send it to a vendor endpoint. See step 3 of the step-by-step guide.

Find the model ids

With the credential in place, ask the gateway what it serves. Configure the ids exactly as returned rather than an abbreviation of them:

curl -sS https://api.grn.cloud/v1/models \
  -H "Authorization: Bearer <your-virtual-key>"

A listed model is not necessarily a working one

This endpoint returns every model name the gateway is configured with, whether or not the backend behind it can currently serve. A name whose upstream credential is missing, or whose backend is down, is listed exactly like a working one and fails only when you select it. Send one completion against a name before configuring an agent around it.

Connecting over OAuth

The gateway publishes OAuth Protected Resource Metadata, so a client can discover where to authenticate without being told:

curl -sS https://api.grn.cloud/.well-known/oauth-protected-resource

It names https://api.grn.cloud/v1 as the protected resource and https://login.openshift.eu/realms/public as the authorization server that issues tokens for it.

Presenting a token from that authorization server, rather than a virtual key, makes the gateway do the mapping for you:

sequenceDiagram
    autonumber
    participant A as OpenCode
    participant G as api.grn.cloud
    participant K as login.openshift.eu
    participant M as model backend

    A->>G: GET /.well-known/oauth-protected-resource
    G-->>A: resource + authorization server
    A->>K: authenticate, obtain an access token
    K-->>A: access token
    A->>G: POST /v1/chat/completions, Bearer access token
    G->>K: fetch signing keys, validate issuer
    G->>G: map the token's email claim to your virtual key
    G->>M: same request, Bearer virtual key
    M-->>A: completion and usage

The consequence is that your OAuth identity, not a copied secret, decides which virtual key the request spends against. Your account has to be present in the gateway's identity map for step 7 to resolve; if it is not, a perfectly valid token is still refused.

OpenCode cannot obtain a token for you yet

opencode auth login <url> expects to discover an authorization server and then register itself as a client dynamically. Neither half completes here: the resource origin does not publish authorization-server metadata, and the authorization server does not accept dynamic client registration. Registering a client in advance does not help either, because OpenCode accepts a pre-configured clientId for MCP servers but not for model providers.

So the gateway accepts both credentials, and OpenCode can present only one of them. Use a virtual key, as above. A request is attributed to the same identity, and spends against the same budget, whichever of the two it arrives with.

Seeing your token usage

Token counts exist in two places, and they answer different questions.

What OpenCode recorded locally

opencode stats totals every session it has recorded:

opencode stats

It reports sessions, messages, input, output and cache tokens, and a tool-usage breakdown. That is the fastest answer to what you have spent, and it needs nothing but OpenCode. It is also the fastest check of whether the gateway served anything at all: a session that recorded zero input and zero output tokens never reached a model, whatever the interface appeared to do.

What the gateway recorded against your key

The gateway attributes every request to a virtual key and keeps its own request log, which is the authoritative record for budgets and rate limits - and the only one that survives you clearing local state.

You cannot read it yourself today. It takes an administrative credential rather than your virtual key, and that credential is not scoped to one tenant, so there is no per-tenant view to hand out. Ask your gateway administrator for your own figures, and treat the local counts above as the self-service answer.

Verify

Work outwards, so that a failure tells you where it is.

1. The gateway answers and the credential is accepted. This separates a network problem from an authorization problem:

curl -sS -o /dev/null -w '%{http_code}\n' https://api.grn.cloud/v1/models \
  -H "Authorization: Bearer <your-virtual-key>"

403 means the credential was not recognised. A connection error means the host is unreachable.

2. A model id resolves and completes:

curl -sS https://api.grn.cloud/v1/chat/completions \
  -H "Authorization: Bearer <your-virtual-key>" \
  -H "Content-Type: application/json" \
  -d '{"model":"<model-id>","messages":[{"role":"user","content":"Say hello"}]}'

3. The model can call a tool. A coding agent is a loop of tool calls, so a model that answers a chat prompt perfectly can still be useless as an agent. Confirm the response carries a tool_calls array rather than prose describing one:

curl -sS https://api.grn.cloud/v1/chat/completions \
  -H "Authorization: Bearer <your-virtual-key>" \
  -H "Content-Type: application/json" \
  -d '{"model":"<model-id>","messages":[{"role":"user","content":"What is the weather in Amsterdam?"}],"tools":[{"type":"function","function":{"name":"get_weather","description":"Get weather for a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]}'

4. OpenCode itself works. Start it only once steps 1 to 3 pass. If they passed and OpenCode fails, the problem is in its configuration rather than the gateway.

5. The tokens were counted. Run opencode stats and confirm the session recorded non-zero counts.

When it connects but never serves

OpenCode starts, shows the model picker, accepts a prompt, and then fails on every response. This is the expected appearance of a rejected credential, because nothing in starting up requires one:

What you see What it means
Provider model discovery failed in the log, provider still listed the gateway refused /v1/models, and OpenCode fell back to the models map in your configuration - which is why the picker still looks correct
AI_APICallError: Unauthorized, or a 403 on send the credential is missing, a placeholder, or not recognised by the gateway
Sessions recorded with zero input and output tokens no request ever reached a model

All three have the same cause more often than not: the provider is configured but the credential is not. Check what is actually stored before changing anything else:

opencode auth list
opencode --log-level DEBUG

The log is at ~/.local/share/opencode/log/opencode.log.

A credential that used to work can stop working without being changed

An endpoint that enforced no authorization will serve a placeholder credential quite happily. When authorization is later turned on in front of it, the same unchanged configuration begins failing - and the local file still holds whatever was put there when it did not matter. Re-check the stored credential before assuming the gateway broke.