#!/usr/bin/env bash # onboard-litellm.sh — point ANY machine's OpenCode at the ai-stack LiteLLM gateway as # an OpenAI-compatible model provider, using a PER-USER key granted by Pocket ID group # membership (docs/PLAN-litellm-oauth.md). # # FIRST-TIMER ONE-LINER (get your key from the SSO portal, then): # curl -fsSL https://get-litellm-ai-stack.dc7.io | bash -s -- install --key sk-usr-... # # install [--global] [--key KEY] [--url URL] [--project DIR] # status [--global] [--project DIR] # uninstall [--global] [--project DIR] # # HOW ACCESS WORKS: # LiteLLM's /v1/* is a plain OpenAI-compatible API and can't do interactive OAuth, so # auth is a per-user virtual KEY. You are auto-issued one when you're a member of the # Pocket ID group (litellm-users); leave the group and it's revoked. Get your key from # the SSO portal (https://litellm-portal-ai-stack.dc7.io) — sign in with your passkey, # copy the key, and pass it here with --key (or paste it when prompted). # # DESIGN GUARANTEES (mirror of onboard-agentmemory.sh): # • Idempotent; surgical (only OUR provider key is added); a pre-existing opencode.json # is preserved + backed up; uninstall restores it byte-for-byte when possible. # • Per-project by default (writes ./.opencode/); --global writes ~/.config/opencode/. # # Requires: bash, jq, curl. if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1 \ && [ -f "$0" ] && [ "$0" != "sh" ] && [ "$0" != "-" ]; then exec bash "$0" "$@" fi set -euo pipefail # ── defaults ────────────────────────────────────────────────────────────────── LITELLM_URL_DEFAULT="https://litellm-ai-stack.dc7.io/v1" PORTAL_URL="https://litellm-portal-ai-stack.dc7.io" PROVIDER_ID="ai-stack" # ── colors / logging ──────────────────────────────────────────────────────────── if [ -t 1 ]; then C_G=$'\033[32m'; C_Y=$'\033[33m'; C_R=$'\033[31m'; C_B=$'\033[1m'; C_0=$'\033[0m'; else C_G= C_Y= C_R= C_B= C_0=; fi log() { printf '%s%s%s\n' "$C_B" "$*" "$C_0"; } ok() { printf '%s✓%s %s\n' "$C_G" "$C_0" "$*"; } warn() { printf '%s!%s %s\n' "$C_Y" "$C_0" "$*" >&2; } die() { printf '%s✗%s %s\n' "$C_R" "$C_0" "$*" >&2; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1"; } # ── arg parsing ────────────────────────────────────────────────────────────────── CMD="${1:-}"; shift || true SCOPE="project"; PROJECT_DIR="$PWD" LL_URL="$LITELLM_URL_DEFAULT"; LL_KEY="" while [ $# -gt 0 ]; do case "$1" in --global) SCOPE="global" ;; --project) PROJECT_DIR="${2:?}"; shift ;; --url) LL_URL="${2:?}"; shift ;; --key) LL_KEY="${2:?}"; shift ;; -h|--help) CMD="help" ;; *) die "unknown arg: $1" ;; esac shift done cfg_dir() { if [ "$SCOPE" = "global" ]; then echo "$HOME/.config/opencode"; else echo "$PROJECT_DIR/.opencode"; fi; } CFG_DIR="$(cfg_dir)" CFG_JSON="$CFG_DIR/opencode.json" BACKUP="$CFG_DIR/.opencode.json.litellm-backup" OURS_MARKER="$CFG_DIR/.litellm-managed" # ── reachability: the gateway's public model list (needs a key, so just check TLS) ─ ll_reachable() { local base code base="${LL_URL%/v1}" code="$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 8 "$base/health/liveliness" 2>/dev/null || echo 000)" # 200 (open liveness) or 401 (key-gated) both mean the gateway is up. [ "$code" = "200" ] || [ "$code" = "401" ] } # ── fetch the served model ids (best-effort; used to populate provider.models) ──── fetch_models() { [ -n "$LL_KEY" ] || return 1 curl -fsS --max-time 8 -H "Authorization: Bearer $LL_KEY" "$LL_URL/models" 2>/dev/null \ | jq -r '[.data[].id]' 2>/dev/null } prompt_key() { [ -n "$LL_KEY" ] && return 0 warn "No --key provided. Get your key from the SSO portal: $PORTAL_URL" if [ -t 0 ]; then printf 'Paste your LiteLLM key (sk-...): ' read -r LL_KEY fi [ -n "$LL_KEY" ] || die "a key is required (curl ... | bash -s -- install --key sk-...)." } write_config() { mkdir -p "$CFG_DIR" if [ ! -f "$OURS_MARKER" ]; then if [ -f "$CFG_JSON" ]; then cp "$CFG_JSON" "$BACKUP"; else echo '__none__' > "$BACKUP"; fi fi local base='{}' [ -f "$CFG_JSON" ] && base="$(cat "$CFG_JSON")" printf '%s' "$base" | jq -e . >/dev/null 2>&1 || die "existing $CFG_JSON is not valid JSON; aborting" # Model list (fallback to a sane default if we can't query the gateway). local models_json models_json="$(fetch_models || true)" if [ -z "$models_json" ] || [ "$models_json" = "null" ]; then models_json='["cliproxy/claude-sonnet-5","cliproxy/claude-opus-4-6-thinking"]' warn "could not query $LL_URL/models — wrote a default model list (edit later)." fi # Surgical merge: add provider. (@ai-sdk/openai-compatible) only. printf '%s' "$base" | jq \ --arg pid "$PROVIDER_ID" \ --arg url "$LL_URL" \ --arg key "$LL_KEY" \ --argjson models "$models_json" ' .provider = (.provider // {}) | .provider[$pid] = { "name": "ai-stack LiteLLM", "npm": "@ai-sdk/openai-compatible", "options": { "baseURL": $url, "apiKey": $key }, "models": ($models | map({ (.): {"name": .} }) | add) } ' > "$CFG_JSON.tmp" mv "$CFG_JSON.tmp" "$CFG_JSON" cat > "$OURS_MARKER" </dev/null; then rm -f "$CFG_JSON"; ok "removed $CFG_JSON (we created it)" else cp "$BACKUP" "$CFG_JSON"; ok "restored $CFG_JSON from pre-install backup" fi rm -f "$BACKUP" elif [ -f "$CFG_JSON" ]; then jq --arg pid "$PROVIDER_ID" ' del(.provider[$pid]) | if (.provider == {}) then del(.provider) else . end' \ "$CFG_JSON" > "$CFG_JSON.tmp" && mv "$CFG_JSON.tmp" "$CFG_JSON" ok "surgically removed provider.$PROVIDER_ID from $CFG_JSON" fi rm -f "$OURS_MARKER" [ "$SCOPE" = "project" ] && rmdir "$CFG_DIR" 2>/dev/null && ok "removed empty $CFG_DIR" || true } cmd_install() { need jq; need curl log "== install ($SCOPE) → $CFG_DIR ==" if ll_reachable; then ok "gateway reachable (${LL_URL%/v1})"; else warn "gateway not reachable at ${LL_URL%/v1}. Writing config anyway; verify DNS/TLS + roles/litellm + Caddy." fi prompt_key # Validate the key against the gateway (a 200 model list = the key works + is in-group). if curl -fsS -o /dev/null --max-time 8 -H "Authorization: Bearer $LL_KEY" "$LL_URL/models" 2>/dev/null; then ok "key accepted by the gateway (you're in the litellm group)" else warn "key did NOT authenticate. If you were just added to the group, wait a minute (reconcile) and retry, or re-copy it from $PORTAL_URL." fi command -v opencode >/dev/null 2>&1 || warn "opencode not found — install it to use the provider." write_config log "next: use it in opencode, e.g.:" log " opencode run -m $PROVIDER_ID/cliproxy/claude-sonnet-5 \"say hi\"" cmd_status } cmd_status() { need jq log "== status ($SCOPE) ==" if ll_reachable; then printf ' gateway : reachable at %s ✓\n' "${LL_URL%/v1}" else printf ' gateway : NOT reachable at %s ✗\n' "${LL_URL%/v1}"; fi if [ -f "$OURS_MARKER" ]; then printf ' config : managed at %s\n' "$CFG_JSON" jq -e --arg pid "$PROVIDER_ID" '.provider[$pid].options.baseURL' "$CFG_JSON" >/dev/null 2>&1 \ && printf ' provider.%s present ✓\n' "$PROVIDER_ID" \ || printf ' provider.%s MISSING\n' "$PROVIDER_ID" else printf ' config : not installed in %s\n' "$CFG_DIR" fi } cmd_uninstall() { need jq; log "== uninstall ($SCOPE) → $CFG_DIR =="; restore_config; ok "uninstall complete. (Revoke access anytime by leaving the Pocket ID group.)"; } cmd_help() { sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'; } case "$CMD" in install) cmd_install ;; status) cmd_status ;; uninstall) cmd_uninstall ;; help|"") cmd_help ;; *) die "unknown command: $CMD (use install|status|uninstall|help)" ;; esac