From e506fa59fe6b3b8041c0f69083fcf0fb5b67d552 Mon Sep 17 00:00:00 2001 From: makan Date: Sat, 8 Aug 2026 18:03:41 +0300 Subject: [PATCH] feat: add installation script for claude-opencode-proxy with Docker setup and shell integration --- README.md | 317 ++++++++++++++++++++++++++++++++++++-------- install.sh | 383 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 645 insertions(+), 55 deletions(-) create mode 100755 install.sh diff --git a/README.md b/README.md index 5d22bab..f7317cd 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,110 @@ A local proxy that lets **Claude Code** talk to **OpenCode Zen / Go / Free** backends. It speaks the Anthropic Messages API on the client side and translates to the backend's native format (`anthropic`, `oa-compat`/OpenAI chat-completions, `openai`/Responses, `google`/Gemini) on the upstream side — -with streaming, model discovery, and capability-aware request stripping. +with streaming, model discovery, reasoning passthrough, and capability-aware +request stripping. + +## Install + +```bash +curl -fsSL https://raw.githubusercontent.com/schiz0x00/claude-opencode-proxy/main/install.sh | bash +``` + +Then open a new shell and run `claude-oc`. + +That single command installs Docker if it is missing, pulls the published +container, starts it on `127.0.0.1:8787`, and adds a `claude-oc` function to +your shell. It is safe to re-run — it upgrades the image and rewrites its own +managed block in your rc files, touching nothing else. + +> **Read before piping to a shell.** `curl | bash` executes code from the +> internet as you. Review [`install.sh`](install.sh) first, or download it, +> read it, and run it locally. Docker installation needs `sudo`; the script +> asks before doing that. + +With an API key (paid lanes), or to change any default: + +```bash +curl -fsSL https://raw.githubusercontent.com/schiz0x00/claude-opencode-proxy/main/install.sh -o install.sh +bash install.sh --zen-key sk-... # Zen lane +bash install.sh --go-key sk-... --port 9000 # Go lane on another port +bash install.sh --dry-run # show what it would do +bash install.sh --uninstall # remove container + shell wiring +``` + +
+Installer options + +| Flag | Env | Default | Purpose | +| :-- | :-- | :-- | :-- | +| `--port ` | `COP_PORT` | `8787` | Host port (bound to `127.0.0.1` only) | +| `--model ` | `COP_MODEL` | `deepseek-v4-flash-free` | Default model for `claude-oc` | +| `--backend ` | `OPENCODE_BACKEND` | auto | `zen` \| `go` \| `free` | +| `--zen-key ` | `OPENCODE_ZEN_API_KEY` | — | Zen key (implies `--backend zen`) | +| `--go-key ` | `OPENCODE_GO_API_KEY` | — | Go key (implies `--backend go`) | +| `--image ` | `COP_IMAGE` | `ghcr.io/schiz0x00/claude-opencode-proxy:latest` | Image to run | +| `--container ` | `COP_CONTAINER` | `claude-opencode-proxy` | Container name | +| `--skip-docker` | — | — | Never install Docker; fail if absent | +| `--dry-run` | — | — | Print actions, change nothing | +| `--uninstall` | — | — | Remove container, env file, rc blocks | +| `-y`, `--yes` | — | — | No prompts (needed for unattended install) | + +
+ +### What the installer changes + +- **Container** `claude-opencode-proxy`, `--restart unless-stopped`, published + to `127.0.0.1:8787` only (never `0.0.0.0` — the proxy has no auth of its own). +- **`~/.claude-opencode-proxy/env.sh`** (mode `600`) with the shell functions. +- **`~/.bashrc`, `~/.bash_profile`, `~/.zshrc`** — whichever exist get a + three-line block between `# >>> claude-opencode-proxy >>>` markers that + sources the env file. Both bash and zsh are supported by the same file. + +### Shell commands + +| Command | Does | +| :-- | :-- | +| `claude-oc` | Claude Code through the proxy (takes all `claude` args, e.g. `claude-oc -p "hi"`) | +| `claude-oc-models` | List models the proxy serves | +| `claude-oc-logs` | Follow container logs | +| `claude-oc-restart` / `-stop` / `-start` | Container control | +| `claude-oc-update` | Pull the latest image and recreate the container | + +Override per run: `CLAUDE_OC_MODEL=glm-5-free claude-oc`, or +`CLAUDE_OC_PORT=9000 claude-oc`. + +## Manual setup + +
+Docker without the installer + +```bash +docker run -d --name claude-opencode-proxy --restart unless-stopped \ + -p 127.0.0.1:8787:8787 \ + -e OPENCODE_BACKEND=zen \ + -e OPENCODE_ZEN_API_KEY=... \ + ghcr.io/schiz0x00/claude-opencode-proxy:latest +``` + +Build locally instead: + +```bash +docker build -t claude-opencode-proxy . +docker run --rm -p 127.0.0.1:8787:8787 claude-opencode-proxy +``` + +
+ +
+From source (Node.js ≥ 20, LTS 24 recommended) + +```bash +npm install +npm run build +npm start # or: npm run dev (tsx watch) +``` + +
## Features @@ -20,27 +123,16 @@ with streaming, model discovery, and capability-aware request stripping. `anthropic`, `oa-compat`, `openai`, `google`. - **Streaming** with 30 s keep-alive pings, verbatim error passthrough, and `message_stop` termination (Claude Code's retry logic works). -- **Model registry** with live discovery (`GET /v1/models` on the backend), - catalog metadata, and a local cache (`~/.claude-opencode-proxy/models.json`). -- **Capability detection** — strips `thinking`, tools, `cache_control`, - image blocks, etc. when the model doesn't support them. +- **Thinking / reasoning passthrough** — upstream `reasoning_content` becomes + Anthropic `thinking` blocks and is echoed back on the next turn. +- **Reasoning effort** mapped from Claude Code's thinking budget onto whatever + knob each model actually accepts, read from the upstream catalog. +- **Model registry** with live discovery, catalog metadata, and a local cache + (`~/.claude-opencode-proxy/models.json`). +- **Capability detection** — strips `thinking`, tools, `cache_control`, image + blocks, etc. when the model doesn't support them. - **Retries** on transient network errors / 5xx / 429 (never 4xx). - **Optional cost pings** (`OPENCODE_EMIT_COST_PINGS=1`). -- **CORS** enabled for browser-based clients. - -## Requirements - -- Node.js ≥ 20 (LTS 24 recommended) - -## Quick start - -```bash -npm install -npm run build -npm start -``` - -The proxy listens on `http://127.0.0.1:8787` by default. ## Configuration @@ -52,7 +144,7 @@ All configuration is via environment variables (see `src/config.ts`). | `OPENCODE_ZEN_API_KEY` | — | Zen API key (also sent as `x-api-key` upstream) | | `OPENCODE_GO_API_KEY` | — | Go API key | | `OPENCODE_BASE_URL` | `https://opencode.ai/zen/v1` | Upstream base URL override | -| `OPENCODE_HOST` | `127.0.0.1` | Listen host | +| `OPENCODE_HOST` | `127.0.0.1` | Listen host (the container sets `0.0.0.0`) | | `OPENCODE_PORT` | `8787` | Listen port | | `OPENCODE_LOG_LEVEL` | `info` | `debug` \| `info` \| `warn` \| `error` | | `OPENCODE_REQUEST_TIMEOUT_MS` | `600000` | Timeout for the upstream response | @@ -65,14 +157,30 @@ All configuration is via environment variables (see `src/config.ts`). ## Using with Claude Code -Point Claude Code at the proxy and give it a credential: +The installer's `claude-oc` sets all of this for you. To wire it up by hand: ```bash export ANTHROPIC_BASE_URL=http://127.0.0.1:8787 -export ANTHROPIC_AUTH_TOKEN= # or ANTHROPIC_API_KEY +export ANTHROPIC_AUTH_TOKEN=opencode-free # any non-empty value on the free lane export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 + +# Keep background calls (titles, summaries) on a model the proxy serves — +# otherwise they go out as claude-sonnet-* / claude-3-5-haiku and 404. +export ANTHROPIC_MODEL=deepseek-v4-flash-free +export ANTHROPIC_SMALL_FAST_MODEL=deepseek-v4-flash-free +export ANTHROPIC_DEFAULT_HAIKU_MODEL=deepseek-v4-flash-free +export ANTHROPIC_DEFAULT_SONNET_MODEL=deepseek-v4-flash-free +export ANTHROPIC_DEFAULT_OPUS_MODEL=deepseek-v4-flash-free +export ANTHROPIC_DEFAULT_FABLE_MODEL=deepseek-v4-flash-free + +# Claude Code doesn't know these models' context windows and assumes 200k. +export CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000 ``` +`ANTHROPIC_AUTH_TOKEN` is required **even on the free lane**. Without a +credential Claude Code falls back to its own login and skips gateway model +discovery entirely. + Optional hardening for strict backends: ```bash @@ -83,49 +191,140 @@ export CLAUDE_CODE_ATTRIBUTION_HEADER=0 ### Model picker -With `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` and a credential set, the -proxy's `GET /v1/models` populates Claude Code's `/model` picker with -**"From gateway"** rows. Each backend model is exposed under an alias id -(`claude-ocx---`) with `display_name` set to the real OpenCode -name; the proxy maps the alias back to the real id on inference. Models with a -1M context window get an extra `[1m]` variant row. +With discovery enabled, `GET /v1/models` populates Claude Code's `/model` +picker with a **"From gateway"** section. Each backend model is exposed under +an alias id (`claude-ocx---`) with `display_name` set to the +real OpenCode name; the proxy rewrites the alias back to the real id before +forwarding. Models with a 1M context window get an extra `[1m]` row. + +Claude's own models still appear alongside the gateway ones — discovery adds a +section, it does not replace the built-in list. -Without discovery, you can pin models directly: +Without discovery, pin models directly: ```bash export ANTHROPIC_CUSTOM_MODEL_OPTION=deepseek-v4-flash-free export ANTHROPIC_CUSTOM_MODEL_OPTION_NAME='DeepSeek V4 Flash (Free)' -export ANTHROPIC_DEFAULT_SONNET_MODEL=mimo-v2.5-free ``` -## Docker +### Thinking and reasoning effort -Prebuilt image (published on every merge to `main`): +Claude Code expresses reasoning effort only as `thinking.budget_tokens` +(`think` ≈ 4k, `megathink` ≈ 10k, `ultrathink` ≈ 32k). Non-Anthropic backends +want a different knob, and which knob varies per model. The proxy reads +`reasoning_options` from the OpenCode catalog and maps the budget onto what +each model actually accepts: -```bash -docker run --rm -p 8787:8787 \ - -e OPENCODE_BACKEND=zen \ - -e OPENCODE_ZEN_API_KEY=... \ - ghcr.io/schiz0x00/claude-opencode-proxy:latest -``` +| Catalog advertises | Proxy sends | +| :-- | :-- | +| `budget_tokens` | `thinking.budget_tokens`, clamped to the documented min/max | +| `effort` | `reasoning_effort`, bucketed onto that model's own ladder | +| `toggle` only | `thinking: {type: "enabled"}` | +| nothing | nothing — the field never reaches the backend | -Or build locally: +So `deepseek-v4-flash-free` (which advertises `low`/`high`/`max`) receives +`low` / `high` / `max` for the three tiers, while `laguna-s-2.1-free` receives +`low` / `medium` / `high`. `none` and `minimal` are never selected for an +enabled thinking block. -```bash -docker build -t claude-opencode-proxy . -docker run --rm -p 8787:8787 \ - -e OPENCODE_BACKEND=zen \ - -e OPENCODE_ZEN_API_KEY=... \ - claude-opencode-proxy -``` +In the other direction, upstream `reasoning_content` is streamed to the client +as Anthropic `thinking` blocks and translated back to `reasoning_content` when +the client echoes it on the next turn — required by providers that reject a +follow-up turn whose assistant message lost its reasoning trace. -## Development +## Troubleshooting -```bash -npm run typecheck # tsc --noEmit -npm test # vitest run -npm run dev # tsx watch src/index.ts -``` +
+404 Unknown model: claude-sonnet-5 (or claude-3-5-haiku-*) + +Claude Code is making background calls with its built-in model ids, which the +proxy does not serve. Set the `ANTHROPIC_DEFAULT_*_MODEL` and +`ANTHROPIC_SMALL_FAST_MODEL` variables above (`claude-oc` does this already). +Left unset, those calls 404 and get retried, adding latency to every turn. + +
+ +
+401 / Model claude-ocx-... is not supported + +The alias id reached the backend instead of the real model id. Fixed in +current versions — update with `claude-oc-update`. + +
+ +
+Responses are slow (10–40 s), but fast in the OpenCode TUI + +Not the proxy. The **free lane is queued** and has a fat latency tail — +measured directly against the upstream, bypassing the proxy entirely, the same +5-token prompt returns in anywhere from 0.6 s to 23 s, with roughly 40% of +requests landing in a 12–20 s queue. `curl` over HTTP/2 and Node over HTTP/1.1 +show the same spread, so it is not a client or transport issue. + +The reason it feels worse in Claude Code than in the OpenCode TUI is that one +Claude Code turn is several sequential upstream calls (background work, the +tool loop, then the answer), and each call draws again from that distribution. +The TUI sends one. Three serial draws is where a 40 s turn comes from. + +What actually helps: set the `ANTHROPIC_DEFAULT_*`/`SMALL_FAST` vars so no call +is wasted on a 404-and-retry, or use a paid lane with `--zen-key`. + +
+ +
+The TUI output is jumbled — one word per line + +Older versions emitted a separate content block per streamed token. Fixed — +update with `claude-oc-update`. + +
+ +
+parse error near `()' after installing + +Your current shell still holds an alias with the same name as one of the +installed functions; zsh expands it while parsing the function definition. The +env file guards against this with `unalias`, but a shell that sourced an older +rc first can still trip. Open a new shell, or run `unalias claude-oc` and +re-source. + +
+ +
+claude.ai connectors are disabled because ANTHROPIC_API_KEY … is set + +Expected and harmless. Pointing Claude Code at any gateway means the auth +token displaces your claude.ai login for connector purposes. + +
+ +
+"…" is not a model this version of Claude Code recognizes + +Claude Code doesn't know the context window of third-party models and assumes +200k for auto-compact. Set `CLAUDE_CODE_MAX_CONTEXT_TOKENS` to the real window +(`claude-oc` sets 200000, which is correct for `deepseek-v4-flash-free`), or +append `[1m]` to the model name for 1M-context models. + +
+ +
+Docker permission denied after install + +The installer adds you to the `docker` group, which only takes effect on your +next login. Log out and back in, or use `newgrp docker` in the current shell. + +
+ +## Privacy + +The **free lane retains prompts** and may use them to improve models — the +proxy logs a warning about this at startup. Do not send confidential data +through `OPENCODE_BACKEND=free`. Paid lanes (`zen`, `go`) follow OpenCode's +own terms. + +The proxy binds to `127.0.0.1` and has no authentication of its own; anything +that can reach the port can use your key. Do not expose it publicly. ## Architecture @@ -142,7 +341,15 @@ Claude Code ── Anthropic Messages API ──▶ proxy ──▶ OpenCode bac - `src/translate/` — per-format helpers and canonical-IR converters - `src/stream.ts` — SSE pump (keep-alive, error passthrough, cost pings) - `src/modelRegistry.ts` — static + discovered + catalog model metadata -- `src/capability.ts` — capability-aware request stripping +- `src/capability.ts` — capability stripping and reasoning-effort mapping + +## Development + +```bash +npm run typecheck # tsc --noEmit +npm test # vitest run +npm run dev # tsx watch src/index.ts +``` ## Contributing @@ -151,4 +358,4 @@ PRs against `dev`; merges to `main` publish the container image. ## License -[MIT](LICENSE) \ No newline at end of file +[MIT](LICENSE) diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b5a7633 --- /dev/null +++ b/install.sh @@ -0,0 +1,383 @@ +#!/usr/bin/env bash +# claude-opencode-proxy installer. +# +# curl -fsSL https://raw.githubusercontent.com/schiz0x00/claude-opencode-proxy/main/install.sh | bash +# +# Installs Docker (if missing), pulls the published container, runs it, and +# wires up a `claude-oc` shell function for bash and zsh. +# +# Everything is idempotent: re-running upgrades the image, recreates the +# container, and rewrites the managed block in your shell rc files. + +set -euo pipefail + +IMAGE="${COP_IMAGE:-ghcr.io/schiz0x00/claude-opencode-proxy:latest}" +CONTAINER="${COP_CONTAINER:-claude-opencode-proxy}" +PORT="${COP_PORT:-8787}" +MODEL="${COP_MODEL:-deepseek-v4-flash-free}" +CONTEXT="${COP_CONTEXT:-200000}" +HOME_DIR="${HOME}/.claude-opencode-proxy" +ENV_FILE="${HOME_DIR}/env.sh" +BEGIN_MARK="# >>> claude-opencode-proxy >>>" +END_MARK="# <<< claude-opencode-proxy <<<" + +# Backend selection: a key implies a paid lane, otherwise the free lane. +BACKEND="${OPENCODE_BACKEND:-}" +ZEN_KEY="${OPENCODE_ZEN_API_KEY:-}" +GO_KEY="${OPENCODE_GO_API_KEY:-}" + +ASSUME_YES=0 +SKIP_DOCKER_INSTALL=0 +DRY_RUN=0 +UNINSTALL=0 + +# --- output ------------------------------------------------------------------ + +if [ -t 1 ]; then + B=$'\033[1m'; G=$'\033[32m'; Y=$'\033[33m'; R=$'\033[31m'; N=$'\033[0m' +else + B=""; G=""; Y=""; R=""; N="" +fi + +say() { printf '%s==>%s %s\n' "$G" "$N" "$*"; } +warn() { printf '%s warn%s %s\n' "$Y" "$N" "$*" >&2; } +die() { printf '%serror%s %s\n' "$R" "$N" "$*" >&2; exit 1; } +run() { if [ "$DRY_RUN" = 1 ]; then printf ' [dry-run] %s\n' "$*"; else "$@"; fi; } + +usage() { + cat < Host port for the proxy (default: ${PORT}) + --model Default model for the shell function (default: ${MODEL}) + --backend zen | go | free (default: auto — key present means paid) + --zen-key OpenCode Zen API key (implies --backend zen) + --go-key OpenCode Go API key (implies --backend go) + --image Container image (default: ${IMAGE}) + --uninstall Remove the container, the env file, and the rc blocks + --skip-docker Never try to install Docker; fail if it is missing + --dry-run Print what would happen, change nothing + -y, --yes Do not prompt (required for unattended Docker install) + -h, --help This message + +Environment equivalents: COP_PORT, COP_MODEL, COP_IMAGE, COP_CONTAINER, +COP_CONTEXT, OPENCODE_BACKEND, OPENCODE_ZEN_API_KEY, OPENCODE_GO_API_KEY. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --port) PORT="${2:?--port needs a value}"; shift 2 ;; + --model) MODEL="${2:?--model needs a value}"; shift 2 ;; + --backend) BACKEND="${2:?--backend needs a value}"; shift 2 ;; + --zen-key) ZEN_KEY="${2:?--zen-key needs a value}"; BACKEND="${BACKEND:-zen}"; shift 2 ;; + --go-key) GO_KEY="${2:?--go-key needs a value}"; BACKEND="${BACKEND:-go}"; shift 2 ;; + --image) IMAGE="${2:?--image needs a value}"; shift 2 ;; + --container) CONTAINER="${2:?--container needs a value}"; shift 2 ;; + --uninstall) UNINSTALL=1; shift ;; + --skip-docker) SKIP_DOCKER_INSTALL=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + -y|--yes) ASSUME_YES=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1 (try --help)" ;; + esac +done + +if [ -z "$BACKEND" ]; then + if [ -n "$GO_KEY" ]; then BACKEND="go" + elif [ -n "$ZEN_KEY" ]; then BACKEND="zen" + else BACKEND="free"; fi +fi + +# Piped into bash (`curl ... | bash`) there is no stdin to prompt on, so read +# answers from the terminal directly when one exists. +confirm() { + [ "$ASSUME_YES" = 1 ] && return 0 + local reply="" + if [ -r /dev/tty ]; then + printf '%s [y/N] ' "$1" > /dev/tty + read -r reply < /dev/tty || reply="" + else + return 1 + fi + case "$reply" in [yY]*) return 0 ;; *) return 1 ;; esac +} + +# --- docker ------------------------------------------------------------------ + +SUDO="" +need_sudo() { + if [ "$(id -u)" = 0 ]; then SUDO=""; return 0; fi + command -v sudo >/dev/null 2>&1 || die "need root or sudo for this step" + SUDO="sudo" +} + +install_docker_linux() { + say "installing Docker via the official convenience script (get.docker.com)" + need_sudo + local tmp + tmp="$(mktemp)" + curl -fsSL https://get.docker.com -o "$tmp" || die "could not download the Docker install script" + run $SUDO sh "$tmp" + rm -f "$tmp" + # systemd hosts need the daemon enabled; containers/WSL without systemd do not. + if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then + run $SUDO systemctl enable --now docker || warn "could not enable the docker service" + fi + if [ "$(id -u)" != 0 ]; then + run $SUDO usermod -aG docker "$USER" || warn "could not add $USER to the docker group" + warn "added $USER to the 'docker' group — log out and back in for it to apply" + warn "this run will keep using sudo for docker commands" + fi +} + +ensure_docker() { + if command -v docker >/dev/null 2>&1; then + say "Docker found: $(docker --version 2>/dev/null || echo unknown)" + else + [ "$SKIP_DOCKER_INSTALL" = 1 ] && die "Docker is not installed (--skip-docker was given)" + case "$(uname -s)" in + Linux) + confirm "Docker is not installed. Install it now (runs get.docker.com with sudo)?" \ + || die "Docker is required — install it and re-run" + install_docker_linux + ;; + Darwin) + die "Docker is not installed. On macOS install Docker Desktop first: + brew install --cask docker (then launch Docker.app) +Re-run this installer afterwards." + ;; + *) + die "unsupported platform $(uname -s) — install Docker manually and re-run" + ;; + esac + fi + + # The daemon must actually answer, and we may need sudo to reach its socket. + if ! docker info >/dev/null 2>&1; then + if command -v sudo >/dev/null 2>&1 && sudo -n docker info >/dev/null 2>&1; then + SUDO="sudo" + warn "using sudo for docker (your user is not in the 'docker' group yet)" + elif [ "$DRY_RUN" = 1 ]; then + warn "docker daemon not reachable (ignored for --dry-run)" + else + die "the Docker daemon is not reachable. Start it (e.g. 'sudo systemctl start docker'), +or if you were just added to the 'docker' group, log out and back in." + fi + fi +} + +DOCKER() { run $SUDO docker "$@"; } + +# --- container --------------------------------------------------------------- + +start_container() { + say "pulling ${IMAGE}" + DOCKER pull "$IMAGE" || die "could not pull ${IMAGE}" + + # Only ever touch a container with our own name. + if [ "$DRY_RUN" != 1 ] && $SUDO docker inspect "$CONTAINER" >/dev/null 2>&1; then + say "replacing the existing '${CONTAINER}' container" + DOCKER rm -f "$CONTAINER" >/dev/null + fi + + local args=( + run -d + --name "$CONTAINER" + --restart unless-stopped + -p "127.0.0.1:${PORT}:8787" + -e "OPENCODE_BACKEND=${BACKEND}" + ) + [ -n "$ZEN_KEY" ] && args+=(-e "OPENCODE_ZEN_API_KEY=${ZEN_KEY}") + [ -n "$GO_KEY" ] && args+=(-e "OPENCODE_GO_API_KEY=${GO_KEY}") + args+=("$IMAGE") + + say "starting '${CONTAINER}' on 127.0.0.1:${PORT} (backend: ${BACKEND})" + DOCKER "${args[@]}" >/dev/null || die "could not start the container" + + [ "$DRY_RUN" = 1 ] && return 0 + + # Wait for the model registry to come up rather than declaring success early. + local _attempt + for _attempt in $(seq 1 30); do + if curl -fsS -m 2 "http://127.0.0.1:${PORT}/v1/models" >/dev/null 2>&1; then + local count + count="$(curl -fsS -m 5 "http://127.0.0.1:${PORT}/v1/models" \ + | tr ',' '\n' | grep -c '"object":"model"' || true)" + say "proxy is up — ${count:-?} models available" + return 0 + fi + sleep 1 + done + warn "the container started but did not answer on port ${PORT} within 30s" + warn "check it with: docker logs ${CONTAINER}" +} + +# --- shell integration ------------------------------------------------------- + +write_env_file() { + say "writing ${ENV_FILE}" + [ "$DRY_RUN" = 1 ] && return 0 + mkdir -p "$HOME_DIR" + cat > "$ENV_FILE" </dev/null || true + +# Run Claude Code against the local proxy. +# claude-oc interactive +# claude-oc -p "hi" one-shot +# CLAUDE_OC_MODEL=glm-5-free claude-oc +claude-oc() { + local model="\${CLAUDE_OC_MODEL}" + # ANTHROPIC_AUTH_TOKEN must be set even on the free lane: without a + # credential Claude Code uses its own login and skips gateway discovery. + # The DEFAULT_*/SMALL_FAST vars keep background calls (titles, summaries) + # on a model the proxy serves — otherwise they go out as claude-sonnet-* + # or claude-3-5-haiku and 404. + ANTHROPIC_BASE_URL="http://127.0.0.1:\${CLAUDE_OC_PORT}" \\ + ANTHROPIC_AUTH_TOKEN="\${OPENCODE_ZEN_API_KEY:-\${OPENCODE_GO_API_KEY:-opencode-free}}" \\ + ANTHROPIC_MODEL="\$model" \\ + ANTHROPIC_SMALL_FAST_MODEL="\$model" \\ + ANTHROPIC_DEFAULT_HAIKU_MODEL="\$model" \\ + ANTHROPIC_DEFAULT_SONNET_MODEL="\$model" \\ + ANTHROPIC_DEFAULT_OPUS_MODEL="\$model" \\ + ANTHROPIC_DEFAULT_FABLE_MODEL="\$model" \\ + CLAUDE_CODE_MAX_CONTEXT_TOKENS="\${CLAUDE_OC_CONTEXT}" \\ + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 \\ + claude "\$@" +} + +# List the models the proxy is serving. +claude-oc-models() { + curl -fsS "http://127.0.0.1:\${CLAUDE_OC_PORT}/v1/models" \\ + | tr '{' '\\n' | sed -n 's/.*"id":"\\([^"]*\\)".*"display_name":"\\([^"]*\\)".*/\\2\\t\\1/p' +} + +# Container controls. +claude-oc-logs() { docker logs -f ${CONTAINER}; } +claude-oc-restart() { docker restart ${CONTAINER}; } +claude-oc-stop() { docker stop ${CONTAINER}; } +claude-oc-start() { docker start ${CONTAINER}; } +claude-oc-update() { + docker pull ${IMAGE} && docker rm -f ${CONTAINER} >/dev/null 2>&1 + docker run -d --name ${CONTAINER} --restart unless-stopped \\ + -p 127.0.0.1:\${CLAUDE_OC_PORT}:8787 -e OPENCODE_BACKEND=${BACKEND} ${IMAGE} +} +EOF + chmod 600 "$ENV_FILE" +} + +# Replace the managed block in an rc file, or append it if absent. Never +# touches anything outside the markers. +wire_rc() { + local rc="$1" + [ -e "$rc" ] || { [ "$DRY_RUN" = 1 ] && return 0; touch "$rc"; } + + if [ "$DRY_RUN" = 1 ]; then + say "would wire ${rc}" + return 0 + fi + + if grep -qF "$BEGIN_MARK" "$rc" 2>/dev/null; then + local tmp + tmp="$(mktemp)" + # Drop the old managed block, keep everything else byte-for-byte. + awk -v b="$BEGIN_MARK" -v e="$END_MARK" ' + index($0, b) { skip = 1 } + !skip { print } + index($0, e) { skip = 0 } + ' "$rc" > "$tmp" + cat "$tmp" > "$rc" + rm -f "$tmp" + fi + + { + printf '%s\n' "$BEGIN_MARK" + printf '[ -f "%s" ] && . "%s"\n' "$ENV_FILE" "$ENV_FILE" + printf '%s\n' "$END_MARK" + } >> "$rc" + say "wired ${rc}" +} + +wire_shells() { + local wired=0 + # bash reads .bash_profile (login) or .bashrc (interactive); on macOS the + # login file is the one that matters, so wire whichever already exist. + for rc in "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.zshrc"; do + if [ -e "$rc" ]; then wire_rc "$rc"; wired=1; fi + done + if [ "$wired" = 0 ]; then + # No rc file at all: create the one matching the login shell. + case "${SHELL:-}" in + *zsh) wire_rc "$HOME/.zshrc" ;; + *) wire_rc "$HOME/.bashrc" ;; + esac + fi +} + +# --- main -------------------------------------------------------------------- + +printf '%s\n' "${B}claude-opencode-proxy installer${N}" +[ "$DRY_RUN" = 1 ] && warn "dry run — nothing will be changed" + +if [ "$UNINSTALL" = 1 ]; then + if command -v docker >/dev/null 2>&1; then + docker info >/dev/null 2>&1 || SUDO="sudo" + if $SUDO docker inspect "$CONTAINER" >/dev/null 2>&1; then + say "removing container ${CONTAINER}" + DOCKER rm -f "$CONTAINER" >/dev/null + fi + fi + for rc in "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.zshrc"; do + [ -e "$rc" ] || continue + grep -qF "$BEGIN_MARK" "$rc" 2>/dev/null || continue + if [ "$DRY_RUN" = 1 ]; then say "would unwire ${rc}"; continue; fi + tmp="$(mktemp)" + awk -v b="$BEGIN_MARK" -v e="$END_MARK" ' + index($0, b) { skip = 1 } + !skip { print } + index($0, e) { skip = 0 } + ' "$rc" > "$tmp" + cat "$tmp" > "$rc" + rm -f "$tmp" + say "unwired ${rc}" + done + run rm -f "$ENV_FILE" + say "done — the image and ~/.claude-opencode-proxy/models.json were left in place" + exit 0 +fi + +command -v curl >/dev/null 2>&1 || die "curl is required" +ensure_docker +start_container +write_env_file +wire_shells + +cat <