diff --git a/AiOverviewControlSettings.qml b/AiOverviewControlSettings.qml index 3033d48..25291f6 100644 --- a/AiOverviewControlSettings.qml +++ b/AiOverviewControlSettings.qml @@ -290,7 +290,7 @@ PluginSettings { { id:"perplexity", name:"Perplexity", icon:"auto_awesome", mode:"informational", requirement:"none", envVar:"", note:"No public read-only quota API" }, { id:"cursor", name:"Cursor", icon:"mouse", mode:"informational", requirement:"none", envVar:"", note:"No public read-only quota API" }, { id:"cline", name:"Cline", icon:"code_blocks", mode:"informational", requirement:"none", envVar:"", note:"No public read-only quota API" }, - { id:"opencode", name:"OpenCode", icon:"open_in_new", mode:"informational", requirement:"none", envVar:"", note:"Usage belongs to configured upstream providers" }, + { id:"opencode", name:"OpenCode Go", icon:"open_in_new", mode:"telemetry", requirement:"API key or CLI login", envVar:"OPENCODE_API_KEY", note:"Uses OPENCODE_API_KEY or the credential saved by `opencode auth login` to ~/.local/share/opencode/auth.json. Reads OpenCode Zen's /zen/go/v1/usage for live 5h/weekly/monthly quota; shows when balance fallback is enabled; falls back to /zen/go/v1/models on failure." }, { id:"kiro", name:"Kiro", icon:"tune", mode:"informational", requirement:"none", envVar:"", note:"Subscription-only IDE; no public API" }, { id:"warp", name:"Warp", icon:"speed", mode:"informational", requirement:"none", envVar:"", note:"No public read-only quota API" }, { id:"amp", name:"Amp", icon:"bolt", mode:"informational", requirement:"none", envVar:"", note:"No public read-only quota API" } diff --git a/AiOverviewControlWidget.qml b/AiOverviewControlWidget.qml index 7a3b697..9f4c03a 100644 --- a/AiOverviewControlWidget.qml +++ b/AiOverviewControlWidget.qml @@ -613,7 +613,7 @@ PluginComponent { grok: "xAI", perplexity: "Perplexity", cline: "Cline", - opencode: "OpenCode", + opencode: "OpenCode Go", kilo: "Kilo", kiro: "Kiro", amp: "Amp", @@ -850,6 +850,7 @@ PluginComponent { if (providerId === "glm" || providerId === "zhipu" || providerId === "zai") return Theme.primary; if (providerId === "minimax") return Theme.success; if (providerId === "commandcode" || providerId === "cmd" || providerId === "cmdcode") return Theme.primary; + if (providerId === "opencode") return Theme.secondary; if (providerId === "qwen" || providerId === "dashscope" || providerId === "alibaba") return Theme.warning; if (providerId === "nvidia" || providerId === "nim") return Theme.success; if (providerId === "cloudflare") return Theme.warning; diff --git a/CHANGELOG.md b/CHANGELOG.md index b829603..fa33171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### OpenCode Go provider + +- New **OpenCode Go** (opencode.ai) provider surfaces the live 5-hour, weekly, and monthly usage windows via OpenCode Zen's `/zen/go/v1/usage`, authenticated with `OPENCODE_API_KEY` or the CLI credential in `${XDG_DATA_HOME:-$HOME/.local/share}/opencode/auth.json`. The endpoint is young, so malformed or unavailable quota data degrades to the documented `/zen/go/v1/models` auth-only check — never a fabricated percentage. `useBalance: true` is shown as **balance fallback enabled**; the endpoint does not expose an amount, so none is claimed. `opencode` moves from the informational tier (no quota API) to a full telemetry provider, and `get-provider-health` gained a matching `opencode` case. + ## 1.13.3 - 2026-08-26 ### Hermetic test suites diff --git a/docs/providers.md b/docs/providers.md index faf5118..9a8acc7 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -13,7 +13,7 @@ This document is the authoritative reference for adapter authors. It records, fo - the **current flagship model(s)** and recent **changelog** highlights, - and the **official documentation source** used. -Reviewed 2026-08-25. Provider APIs change; re-check the linked sources before changing an adapter. +Reviewed 2026-08-26. Provider APIs change; re-check the linked sources before changing an adapter. ## Coverage levels @@ -21,12 +21,12 @@ Every provider maps to exactly one coverage level. The level dictates what the w | Level | Meaning | Example providers | | --- | --- | --- | -| **Quota** | Real `usedPercent` + reset window from a protocol/API. | `codex`, `copilot`, `antigravity`, `openrouter`, `zai`, `glm`, `fireworks` (with account ID) | +| **Quota** | Real `usedPercent` + reset window from a protocol/API. | `codex`, `copilot`, `antigravity`, `openrouter`, `zai`, `glm`, `fireworks` (with account ID), `commandcode`, `opencode` | | **Balance** | Remaining prepaid balance / credits in real currency. | `kimi`, `deepseek` | | **Analytics** | Consumption counters (requests/tokens/neurons/cost) with no remaining-quota value. | `cloudflare` (GraphQL), `9router`, `claude` (local), `pi` (local), `hermes` (local) | | **Auth / configured** | Validates credentials with a read-only endpoint when possible; otherwise reports only that a credential is configured and states the limitation. No usage numbers. | `gemini`, `mistral`, `nvidia`, `qwen`, `byteplus`, `groq`, `cohere`, `replicate`, `together`, `minimax`, `xai`, `kilo`, `ai21` | | **Local runtime** | Local process / installed models. | `ollama`, `vertexai` (gcloud) | -| **Informational** | No public read-only API at all; the card just links to the dashboard. | `perplexity`, `cursor`, `cline`, `opencode`, `kiro`, `warp`, `amp` | +| **Informational** | No public read-only API at all; the card just links to the dashboard. | `perplexity`, `cursor`, `cline`, `kiro`, `warp`, `amp` | ## Provider kinds @@ -189,6 +189,17 @@ The matrix below summarises the **authentication/billing surface** for every sup Provider API docs +opencode +Quota +✅ GET /zen/go/v1/models +✅ /zen/go/v1/usage (5h + weekly + monthly) +✅ OpenCode Go $10/mo +— +OPENCODE_API_KEY or CLI ~/.local/share/opencode/auth.json +opencode.ai/zen +opencode.ai/docs/go + + kimi Balance / Quota ✅ GET /v1/models @@ -432,17 +443,6 @@ The matrix below summarises the **authentication/billing surface** for every sup cline.bot -opencode -Informational -❌ -❌ -— -— -— -upstream providers -opencode.ai - - warp Informational ❌ @@ -601,6 +601,20 @@ Detailed adapter notes for the focus providers (Gemini, Cloudflare, Mistral, GLM | **Dashboard** | [commandcode.ai/billing](https://commandcode.ai/billing) — billing, plan, and credit top-ups. | | **Adapter** | `fetch_commandcode_native` — `/alpha/billing/credits` + `/alpha/whoami` + `/alpha/billing/subscriptions`, with documented-endpoint fallback. | +### OpenCode Go + +| | | +| --- | --- | +| **API base** | `https://opencode.ai/zen/go/v1` — OpenCode Zen's Go-plan quota surface, alongside the OpenAI-compatible `/zen/v1` inference API. | +| **Credentials** | `OPENCODE_API_KEY` (preferred when available to DMS), or the `key` in CLI-owned `${XDG_DATA_HOME:-$HOME/.local/share}/opencode/auth.json` (saved by `opencode auth login`). The adapter follows this XDG path on Linux, the plugin's supported platform. The same key created in OpenCode Studio/Zen drives both inference and quota introspection. | +| **Auth** | `Authorization: Bearer ***` | +| **Quota / balance** | `/zen/go/v1/usage` returns `rollingUsage` (5h window), `weeklyUsage`, and `monthlyUsage`, each `{status, resetInSec, usagePercent}` — percentages are computed server-side, so the adapter never derives them from raw byte/token counts. The adapter accepts a quota response only when all three windows have valid percentage/reset values; otherwise it falls back to auth-only status. `resetInSec` is seconds-from-now, converted to an absolute ISO 8601 timestamp. `useBalance: true` is rendered as **balance fallback enabled**; the endpoint does not return the balance amount, so none is invented. | +| **Plans** | **OpenCode Go** $10/mo — $12 of usage per 5-hour window, $30 weekly, $60 monthly. | +| **Billing** | Flat monthly subscription; no PAYG surfaced through this endpoint. | +| **Stability** | The `/zen/go/` namespace shipped alongside the Go plan (opencode PR #16513) and has no documented versioned contract. Adapter degrades to `/zen/go/v1/models` (auth-only) on failure and emits a clearly-labeled "quota endpoint unavailable" note; it never fabricates a percentage. A missing/invalid key returns `401 AuthError`; a valid key without a Go subscription returns `403 EntitlementError` — both surface as hard errors, not soft notes. | +| **Dashboard** | [opencode.ai/zen](https://opencode.ai/zen) — Zen usage and Go plan management. | +| **Adapter** | `fetch_opencode_native` — `/zen/go/v1/usage`, with `/zen/go/v1/models` fallback. | + ### Kimi (Moonshot AI) | | | diff --git a/providers/get-provider-health b/providers/get-provider-health index bdab874..33ba2e3 100755 --- a/providers/get-provider-health +++ b/providers/get-provider-health @@ -16,6 +16,14 @@ has_commandcode_credentials() { && jq -e '(.apiKey | type) == "string" and (.apiKey | length) > 0' "$auth_file" >/dev/null 2>&1 } +has_opencode_credentials() { + # Match the CLI's XDG data directory on Linux DMS sessions. + local auth_file="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/auth.json" + has_any_env OPENCODE_API_KEY && return 0 + [ -r "$auth_file" ] \ + && jq -e '((."opencode".key // ."opencode-go".key // ."opencode_go".key // .apiKey) | type) == "string" and (length > 0)' "$auth_file" >/dev/null 2>&1 +} + status_for() { local provider="$1" local status="ready" @@ -41,6 +49,7 @@ status_for() { kimi|moonshot) has_any_env MOONSHOT_API_KEY KIMI_API_KEY KIMI_CODING_API_KEY || { status="missing"; detail="MOONSHOT_API_KEY, KIMI_API_KEY, or KIMI_CODING_API_KEY"; } ;; minimax) has_any_env MINIMAX_API_KEY || { status="missing"; detail="MINIMAX_API_KEY"; } ;; commandcode|cmd|cmdcode) has_commandcode_credentials || { status="missing"; detail="COMMAND_CODE_API_KEY or ~/.commandcode/auth.json"; } ;; + opencode) has_opencode_credentials || { status="missing"; detail="OPENCODE_API_KEY or ~/.local/share/opencode/auth.json"; } ;; glm|zhipu) has_any_env GLM_API_KEY ZHIPU_API_KEY || { status="missing"; detail="GLM_API_KEY or ZHIPU_API_KEY"; } ;; zai|z.ai) has_any_env ZAI_API_KEY GLM_API_KEY ZHIPU_API_KEY || { status="missing"; detail="ZAI_API_KEY"; } ;; mistral) has_any_env MISTRAL_API_KEY || { status="missing"; detail="MISTRAL_API_KEY"; } ;; @@ -60,7 +69,7 @@ status_for() { ai21) has_any_env AI21_API_KEY || { status="missing"; detail="AI21_API_KEY"; } ;; pi) command -v pi >/dev/null 2>&1 || [ -d "$HOME/.pi/agent/sessions" ] || { status="missing"; detail="pi CLI or ~/.pi/agent/sessions"; } ;; hermes) [ -f "${HERMES_HOME:-$HOME/.hermes}/state.db" ] || command -v hermes >/dev/null 2>&1 || { status="missing"; detail="hermes CLI or ~/.hermes/state.db"; } ;; - perplexity|cursor|cline|opencode|kiro|warp|amp) + perplexity|cursor|cline|kiro|warp|amp) status="info" detail="No public read-only quota API" ;; diff --git a/providers/get-provider-usage b/providers/get-provider-usage index 507f2d4..8563d02 100755 --- a/providers/get-provider-usage +++ b/providers/get-provider-usage @@ -1606,6 +1606,113 @@ fetch_commandcode_native() { "commandcode.ai/billing" } +opencode_api_key() { + # OpenCode Zen issues one API key from OpenCode Studio; a local CLI login + # (opencode auth login) persists the same key to this CLI-owned file. Path + # and key shape verified against opencode's docs and the opencode CLI + # behavior on Linux. Honor XDG_DATA_HOME for non-default DMS sessions. + local auth_file="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/auth.json" + if [ -n "${OPENCODE_API_KEY:-}" ]; then + printf '%s' "$OPENCODE_API_KEY" + elif [ -r "$auth_file" ]; then + # Accept either the {"opencode":{"key":...}} shape (current CLI) or a + # legacy flat {"apiKey":...} shape so the bridge still works for users + # who hand-edited the file before the rename. + jq -r '."opencode".key // ."opencode-go".key // ."opencode_go".key // .apiKey // empty' "$auth_file" 2>/dev/null || true + fi +} + +fetch_opencode_native() { + # OpenCode Go (opencode.ai) — coding-agent subscription quota via OpenCode + # Zen. /zen/go/v1/usage returns percent-already-computed 5h/weekly/monthly + # windows. The endpoint is young (opencode PR #16513), so on failure we + # degrade to /zen/go/v1/models (auth-only check), matching the Command Code + # adapter's fallback shape. + local key + key="$(opencode_api_key)" + if [ -z "$key" ]; then + json_error opencode opencode-api 2 provider "OpenCode credentials are unavailable. Set OPENCODE_API_KEY or run 'opencode auth login' so ~/.local/share/opencode/auth.json has a key." + return 0 + fi + + local tmp_body http_status + tmp_body="$(mktemp)" + http_status="$(curl -sS --max-time 8 -o "$tmp_body" -w '%{http_code}' \ + -H "Authorization: Bearer ${key}" \ + -H "Accept: application/json" \ + "https://opencode.ai/zen/go/v1/usage" 2>/dev/null || true)" + + if [ "$http_status" = "401" ] || [ "$http_status" = "403" ]; then + local message + message="$(jq -r '.error.message // empty' "$tmp_body" 2>/dev/null || true)" + [ -n "$message" ] || message="OpenCode API key invalid (HTTP ${http_status})." + rm -f "$tmp_body" + json_error opencode opencode-api "${http_status}" provider "$message" + return 0 + fi + + if [ "$http_status" = "200" ] && jq -e ' + def valid_window: + type == "object" + and (.usagePercent | type == "number" and . >= 0 and . <= 100) + and (.resetInSec | type == "number" and . >= 0); + (.rollingUsage | valid_window) + and (.weeklyUsage | valid_window) + and (.monthlyUsage | valid_window) + ' "$tmp_body" >/dev/null 2>&1; then + jq -c ' + def make_window($w; $minutes; $label): { + usedPercent: $w.usagePercent, + windowMinutes: $minutes, + resetsAt: ((now + $w.resetInSec) | todate), + resetDescription: $label + }; + . as $body | + { + provider: "opencode", + source: "opencode-usage", + usage: { + identity: {providerID: "opencode", accountEmail: "OpenCode Go account", loginMethod: "api-key"}, + accountEmail: "OpenCode Go account", + loginMethod: "api-key", + primary: make_window($body.rollingUsage; 300; "5h"), + secondary: make_window($body.weeklyUsage; 10080; "weekly"), + tertiary: make_window($body.monthlyUsage; 43200; "monthly"), + updatedAt: (now | todate) + }, + credits: { + remaining: (if $body.useBalance == true + then "OpenCode Go · balance fallback enabled" + else "OpenCode Go" + end) + } + } + ' "$tmp_body" + rm -f "$tmp_body" + return 0 + fi + + # /zen/go/v1/usage failed (timeout, 5xx, parse error). Try the documented + # /zen/go/v1/models endpoint — auth-only confirmation that the key works. + rm -f "$tmp_body" + tmp_body="$(mktemp)" + http_status="$(curl -sS --max-time 8 -o "$tmp_body" -w '%{http_code}' \ + -H "Authorization: Bearer ${key}" \ + -H "Accept: application/json" \ + "https://opencode.ai/zen/go/v1/models" || true)" + rm -f "$tmp_body" + + if [ "$http_status" = "200" ]; then + json_note_usage opencode opencode-models "OpenCode Go account" "api-key" \ + "Authenticated — quota endpoint unavailable, usage at opencode.ai/zen" \ + "OpenCode Go" + return 0 + fi + json_note_usage opencode opencode-api "OpenCode Go account" "api-key" \ + "API key set; OpenCode Zen unreachable" \ + "OpenCode Go" +} + fetch_glm_native() { # GLM and Z.ai share one credential family. Honor ZAI_API_KEY here too so a # single key drives both cards (matches docs/providers.md credential matrix). @@ -2441,7 +2548,7 @@ fetch_provider() { json_note_usage cline cline-local "Cline account" "subscription" "No public quota API; usage is available in the Cline client" "app.cline.bot" ;; opencode) - json_note_usage opencode opencode-local "OpenCode" "proxy" "OpenCode routes to underlying providers directly — check each provider's card" "opencode.ai" + fetch_opencode_native ;; together) fetch_together_native diff --git a/tests/fixtures/opencode-error-401.json b/tests/fixtures/opencode-error-401.json new file mode 100644 index 0000000..20d7466 --- /dev/null +++ b/tests/fixtures/opencode-error-401.json @@ -0,0 +1 @@ +{ "type": "error", "error": { "type": "AuthError", "message": "Missing API key." } } diff --git a/tests/fixtures/opencode-error-403.json b/tests/fixtures/opencode-error-403.json new file mode 100644 index 0000000..08c77c1 --- /dev/null +++ b/tests/fixtures/opencode-error-403.json @@ -0,0 +1 @@ +{ "type": "error", "error": { "type": "EntitlementError", "message": "OpenCode Go subscription required." } } diff --git a/tests/fixtures/opencode-models.json b/tests/fixtures/opencode-models.json new file mode 100644 index 0000000..0c0af5f --- /dev/null +++ b/tests/fixtures/opencode-models.json @@ -0,0 +1 @@ +{ "object": "list", "data": [{"id": "claude-sonnet-5", "object": "model", "created": 1, "owned_by": "opencode", "name": "Claude Sonnet 5", "context_length": 1000000}] } diff --git a/tests/fixtures/opencode-usage-balance.json b/tests/fixtures/opencode-usage-balance.json new file mode 100644 index 0000000..0ca2ef9 --- /dev/null +++ b/tests/fixtures/opencode-usage-balance.json @@ -0,0 +1,6 @@ +{ + "useBalance": true, + "rollingUsage": { "status": "ok", "resetInSec": 9000, "usagePercent": 42 }, + "weeklyUsage": { "status": "ok", "resetInSec": 302400, "usagePercent": 18 }, + "monthlyUsage": { "status": "ok", "resetInSec": 1209600, "usagePercent": 7 } +} diff --git a/tests/fixtures/opencode-usage-malformed.json b/tests/fixtures/opencode-usage-malformed.json new file mode 100644 index 0000000..8ba71a5 --- /dev/null +++ b/tests/fixtures/opencode-usage-malformed.json @@ -0,0 +1,5 @@ +{ + "useBalance": false, + "rollingUsage": { "status": "ok", "resetInSec": 9000, "usagePercent": 42 }, + "weeklyUsage": { "status": "ok", "resetInSec": 302400, "usagePercent": 18 } +} diff --git a/tests/fixtures/opencode-usage.json b/tests/fixtures/opencode-usage.json new file mode 100644 index 0000000..208e982 --- /dev/null +++ b/tests/fixtures/opencode-usage.json @@ -0,0 +1,6 @@ +{ + "useBalance": false, + "rollingUsage": { "status": "ok", "resetInSec": 9000, "usagePercent": 42 }, + "weeklyUsage": { "status": "ok", "resetInSec": 302400, "usagePercent": 18 }, + "monthlyUsage": { "status": "ok", "resetInSec": 1209600, "usagePercent": 7 } +} diff --git a/tests/test-opencode.sh b/tests/test-opencode.sh new file mode 100755 index 0000000..56999a4 --- /dev/null +++ b/tests/test-opencode.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# OpenCode Go subscription quota tracking — fake-curl unit test. +# Verifies /zen/go/v1/usage maps to 5h (primary) + weekly (secondary) + +# monthly (tertiary) percentages, with graceful fallback to +# /zen/go/v1/models on usage failure, and hard errors on 401/403. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +HDR_LOG="$TMP/headers.log" +# The dispatcher appends non-zero quota snapshots; keep fixtures out of the +# developer's real history store, regardless of the calling environment. +export XDG_CACHE_HOME="$TMP/cache" +mkdir -p "$TMP/bin" + +# Fake curl that mimics real curl behavior: +# -o file → writes body to file, suppresses body on stdout +# -w FORMAT → writes FORMAT (here: the simulated HTTP status) to stdout +# OC_USAGE_MODE picks the fixture/status served for /zen/go/v1/usage; the +# stub always exits 0 so a simulated non-200 never looks like a curl network +# failure to the parent script (see test-commandcode.sh for the same note). +cat > "$TMP/bin/curl" <<'STUB' +#!/usr/bin/env bash +out="" +url="" +write_code=0 +args=("$@") +for ((i = 0; i < ${#args[@]}; i++)); do + case "${args[$i]}" in + -o) out="${args[$((i + 1))]}" ;; + -w) write_code=1 ;; + -H) printf '%s\n' "${args[$((i + 1))]}" >> "${HDR_LOG:-/dev/null}" ;; + http*) url="${args[$i]}" ;; + esac +done +status=200 +case "$url" in + *zen/go/v1/usage*) + case "${OC_USAGE_MODE:-ok}" in + ok) [ -n "$out" ] && cp "${OC_FIXTURE_DIR}/opencode-usage.json" "$out"; status=200 ;; + unauthorized) [ -n "$out" ] && cp "${OC_FIXTURE_DIR}/opencode-error-401.json" "$out"; status=401 ;; + forbidden) [ -n "$out" ] && cp "${OC_FIXTURE_DIR}/opencode-error-403.json" "$out"; status=403 ;; + down) [ -n "$out" ] && printf '' > "$out"; status=500 ;; + balance) [ -n "$out" ] && cp "${OC_FIXTURE_DIR}/opencode-usage-balance.json" "$out"; status=200 ;; + malformed) [ -n "$out" ] && cp "${OC_FIXTURE_DIR}/opencode-usage-malformed.json" "$out"; status=200 ;; + esac + ;; + *zen/go/v1/models*) + [ -n "$out" ] && cp "${OC_FIXTURE_DIR}/opencode-models.json" "$out" + status=200 + ;; +esac +if [ "$write_code" -eq 1 ]; then printf '%s' "$status"; fi +exit 0 +STUB +chmod +x "$TMP/bin/curl" + +run() { PATH="$TMP/bin:$PATH" HDR_LOG="$HDR_LOG" OC_FIXTURE_DIR="$ROOT/tests/fixtures" "$@" "$ROOT/providers/get-provider-usage" opencode 2>/dev/null; } +fail() { echo "FAIL: $1" >&2; exit 1; } + +# 1. Happy path — /zen/go/v1/usage returns 5h + weekly + monthly percentages. +out="$(OC_USAGE_MODE=ok run env -u OPENCODE_API_KEY OPENCODE_API_KEY=user_test)" +[ "$(jq -r '.[0].source' <<<"$out")" = "opencode-usage" ] || fail "source" +[ "$(jq -r '.[0].usage.primary.windowMinutes' <<<"$out")" = "300" ] || fail "5h minutes" +[ "$(jq -r '.[0].usage.primary.resetDescription' <<<"$out")" = "5h" ] || fail "5h label" +[ "$(jq -r '.[0].usage.primary.usedPercent' <<<"$out")" = "42" ] || fail "5h percent" +[ "$(jq -r '.[0].usage.secondary.windowMinutes' <<<"$out")" = "10080" ] || fail "weekly minutes" +[ "$(jq -r '.[0].usage.secondary.resetDescription' <<<"$out")" = "weekly" ] || fail "weekly label" +[ "$(jq -r '.[0].usage.secondary.usedPercent' <<<"$out")" = "18" ] || fail "weekly percent" +[ "$(jq -r '.[0].usage.tertiary.resetDescription' <<<"$out")" = "monthly" ] || fail "monthly label" +[ "$(jq -r '.[0].usage.tertiary.usedPercent' <<<"$out")" = "7" ] || fail "monthly percent" +[ "$(jq -r '.[0].credits.remaining' <<<"$out")" = "OpenCode Go" ] || fail "credits remaining" +[ "$(jq -r '.[0].usage.identity.accountEmail' <<<"$out")" = "OpenCode Go account" ] || fail "identity" +[ "$(grep -c '^Authorization: Bearer user_test$' "$HDR_LOG")" -ge 1 ] || fail "auth header forwarded" + +# 2. A local auth.json credential is a safe fallback for graphical sessions. +CLI_HOME="$TMP/cli-home" +mkdir -p "$CLI_HOME/.local/share/opencode" +printf '%s\n' '{"opencode":{"type":"api","key":"cli_test"}}' > "$CLI_HOME/.local/share/opencode/auth.json" +chmod 600 "$CLI_HOME/.local/share/opencode/auth.json" +: > "$HDR_LOG" +out="$(OC_USAGE_MODE=ok run env -u OPENCODE_API_KEY -u XDG_DATA_HOME HOME="$CLI_HOME")" +[ "$(jq -r '.[0].source' <<<"$out")" = "opencode-usage" ] || fail "CLI auth source" +grep -q '^Authorization: Bearer cli_test$' "$HDR_LOG" || fail "CLI auth header" +health="$(OC_USAGE_MODE=ok env -u OPENCODE_API_KEY -u XDG_DATA_HOME HOME="$CLI_HOME" "$ROOT/providers/get-provider-health" opencode 2>/dev/null)" +[ "$(jq -r '.[0].status' <<<"$health")" = "ready" ] || fail "CLI auth health" + +# 2b. Legacy flat {"apiKey": ...} shape (hand-edited or older opencode build) +# still resolves to a usable credential — same file path, just the old key name. +CLI_HOME_LEGACY="$TMP/cli-home-legacy" +mkdir -p "$CLI_HOME_LEGACY/.local/share/opencode" +printf '%s\n' '{"apiKey":"cli_legacy"}' > "$CLI_HOME_LEGACY/.local/share/opencode/auth.json" +chmod 600 "$CLI_HOME_LEGACY/.local/share/opencode/auth.json" +out="$(OC_USAGE_MODE=ok run env -u OPENCODE_API_KEY -u XDG_DATA_HOME HOME="$CLI_HOME_LEGACY")" +[ "$(jq -r '.[0].source' <<<"$out")" = "opencode-usage" ] || fail "legacy cli auth source" +health="$(OC_USAGE_MODE=ok env -u OPENCODE_API_KEY -u XDG_DATA_HOME HOME="$CLI_HOME_LEGACY" "$ROOT/providers/get-provider-health" opencode 2>/dev/null)" +[ "$(jq -r '.[0].status' <<<"$health")" = "ready" ] || fail "legacy cli auth health" + +# 3. No supported credential source. +EMPTY_HOME="$TMP/empty-home" +mkdir -p "$EMPTY_HOME" +out="$(OC_USAGE_MODE=ok run env -u OPENCODE_API_KEY -u XDG_DATA_HOME HOME="$EMPTY_HOME")" +[ "$(jq -r '.[0].error.kind' <<<"$out")" = "provider" ] || fail "no-key error kind" +health="$(env -u OPENCODE_API_KEY -u XDG_DATA_HOME HOME="$EMPTY_HOME" "$ROOT/providers/get-provider-health" opencode 2>/dev/null)" +[ "$(jq -r '.[0].status' <<<"$health")" = "missing" ] || fail "no-key health" + +# 4. A caller-provided XDG data directory is honored by both adapter and health check. +XDG_HOME="$TMP/xdg-data" +mkdir -p "$XDG_HOME/opencode" +printf '%s\n' '{"opencode":{"type":"api","key":"xdg_test"}}' > "$XDG_HOME/opencode/auth.json" +out="$(OC_USAGE_MODE=ok run env -u OPENCODE_API_KEY HOME="$EMPTY_HOME" XDG_DATA_HOME="$XDG_HOME")" +[ "$(jq -r '.[0].source' <<<"$out")" = "opencode-usage" ] || fail "XDG auth source" +grep -q '^Authorization: Bearer xdg_test$' "$HDR_LOG" || fail "XDG auth header" +health="$(env -u OPENCODE_API_KEY HOME="$EMPTY_HOME" XDG_DATA_HOME="$XDG_HOME" "$ROOT/providers/get-provider-health" opencode 2>/dev/null)" +[ "$(jq -r '.[0].status' <<<"$health")" = "ready" ] || fail "XDG auth health" + +# 5. useBalance is an enabled fallback, not a numerical balance the endpoint provides. +out="$(OC_USAGE_MODE=balance run env -u OPENCODE_API_KEY OPENCODE_API_KEY=user_test)" +[ "$(jq -r '.[0].credits.remaining' <<<"$out")" = "OpenCode Go · balance fallback enabled" ] || fail "balance fallback label" + +# 6. A malformed quota payload must degrade to auth-only rather than invent a value. +out="$(OC_USAGE_MODE=malformed run env -u OPENCODE_API_KEY OPENCODE_API_KEY=user_test)" +[ "$(jq -r '.[0].source' <<<"$out")" = "opencode-models" ] || fail "malformed payload fallback" + +# 7. 401 → hard error, no fabricated percentage. +out="$(OC_USAGE_MODE=unauthorized run env -u OPENCODE_API_KEY OPENCODE_API_KEY=user_test)" +[ "$(jq -r '.[0].error.code' <<<"$out")" = "401" ] || fail "401 code" +[ "$(jq -r '.[0].error.kind' <<<"$out")" = "provider" ] || fail "401 kind" +[ "$(jq -r '.[0].error.message' <<<"$out")" = "Missing API key." ] || fail "401 message" + +# 8. 403 (valid key, no Go subscription) → hard error. +out="$(OC_USAGE_MODE=forbidden run env -u OPENCODE_API_KEY OPENCODE_API_KEY=user_test)" +[ "$(jq -r '.[0].error.code' <<<"$out")" = "403" ] || fail "403 code" +[ "$(jq -r '.[0].error.message' <<<"$out")" = "OpenCode Go subscription required." ] || fail "403 message" + +# 9. /zen/go/v1/usage down (non-401/403 failure) → fallback to /zen/go/v1/models. +: > "$HDR_LOG" +out="$(OC_USAGE_MODE=down run env -u OPENCODE_API_KEY OPENCODE_API_KEY=user_test)" +[ "$(jq -r '.[0].source' <<<"$out")" = "opencode-models" ] || fail "fallback source" +grep -q '^Authorization: Bearer user_test$' "$HDR_LOG" || fail "auth header forwarded to models fallback" + +echo "OK: test-opencode"