From 48d3fbffe8b6e768831b42fb85821c69de015fd5 Mon Sep 17 00:00:00 2001 From: Steffen Zellmer <151627820+Steffen025@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:59:03 -0400 Subject: [PATCH 1/5] feat(installer): add anthropic-max preset for Max/Pro OAuth subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 'anthropic-max' as a built-in installer preset so users with an existing Anthropic Max or Pro subscription can use PAI-OpenCode without paying extra for API keys. ## What's new ### Provider preset - provider-models.ts: add 'anthropic-max' to ProviderName union, PROVIDER_MODELS, and PROVIDER_LABELS (label: 'Anthropic Max/Pro (OAuth)') - Model tiers: haiku-4-5 / sonnet-4-6 / opus-4-6 — same models as 'anthropic' but routed through OAuth instead of an API key ### Installer engine (steps-fresh.ts) - installAnthropicMaxBridge(): new helper function that: 1. Copies .opencode/plugins/anthropic-max-bridge.js to the local plugins dir 2. Extracts the OAuth token from macOS Keychain (service: Claude Code-credentials) 3. Parses claudeAiOauth.{accessToken,refreshToken,expiresAt} 4. Writes token to ~/.local/share/opencode/auth.json under the 'anthropic' key 5. Returns success + hours remaining, non-throwing (install continues on failure) - stepInstallPAI(): call installAnthropicMaxBridge() when provider === 'anthropic-max' - runFreshInstall(): skip API key prompt for anthropic-max, show Claude CLI check message ### CLI (quick-install.ts) - --preset anthropic-max works without --api-key (all other presets still require it) - Inline instructions printed when anthropic-max selected - Updated help text and examples ### New files - .opencode/plugins/anthropic-max-bridge.js: 80-line minimal plugin (3 API fixes only) Fix 1: system prompt array-of-objects format (prevents HTTP 400) Fix 2: anthropic-beta: oauth-2025-04-20 header (prevents HTTP 401) Fix 3: Authorization: Bearer instead of x-api-key - PAI-Install/anthropic-max-refresh.sh: one-command token refresh after expiry - docs/providers/anthropic-max.md: user-facing setup guide, troubleshooting, tech details ## Usage Interactive: bash install.sh → choose 'Anthropic Max/Pro (OAuth)' Headless: bun PAI-Install/cli/quick-install.ts --preset anthropic-max --name 'User' Token refresh (every ~8-12 hours): bash PAI-Install/anthropic-max-refresh.sh ## Notes - macOS only (requires Keychain access) - Requires Claude Code CLI installed and authenticated - Using OAuth tokens in third-party tools may violate Anthropic ToS - Non-fatal: if Keychain extraction fails, install continues with a warning --- .opencode/plugins/anthropic-max-bridge.js | 126 ++++++++++++++ PAI-Install/anthropic-max-refresh.sh | 57 +++++++ PAI-Install/cli/quick-install.ts | 25 ++- PAI-Install/engine/provider-models.ts | 14 +- PAI-Install/engine/steps-fresh.ts | 190 +++++++++++++++++++++- docs/providers/anthropic-max.md | 124 ++++++++++++++ 6 files changed, 527 insertions(+), 9 deletions(-) create mode 100644 .opencode/plugins/anthropic-max-bridge.js create mode 100755 PAI-Install/anthropic-max-refresh.sh create mode 100644 docs/providers/anthropic-max.md diff --git a/.opencode/plugins/anthropic-max-bridge.js b/.opencode/plugins/anthropic-max-bridge.js new file mode 100644 index 00000000..ed2fc9c8 --- /dev/null +++ b/.opencode/plugins/anthropic-max-bridge.js @@ -0,0 +1,126 @@ +// ============================================================ +// Anthropic Max Bridge — OpenCode Plugin +// Version: 1.0.0 | Date: 2026-03-20 +// ============================================================ +// +// PURPOSE +// ------- +// Lets you use your existing Anthropic Max / Pro subscription +// inside OpenCode without paying extra for API keys. +// +// HOW IT WORKS (3 tiny fixes) +// ---------------------------- +// When Anthropic blocked OAuth for third-party tools (March 2026), +// they introduced 3 API-level differences vs. API-key auth: +// +// Fix 1 — system prompt format +// BAD: "system": "You are..." → HTTP 400 Error +// GOOD: "system": [{"type":"text","text":"..."}] → HTTP 200 OK +// +// Fix 2 — OAuth beta header +// Missing header → HTTP 401 "OAuth authentication not supported" +// Required: anthropic-beta: oauth-2025-04-20 +// +// Fix 3 — Bearer token instead of x-api-key +// Wrong: x-api-key: +// Right: Authorization: Bearer +// +// TOKEN SOURCE +// ------------ +// Tokens live in: ~/.local/share/opencode/auth.json +// (under the "anthropic" key — see README for how to put them there) +// +// DISCLAIMER +// ---------- +// Using OAuth tokens from Claude Code in a third-party tool +// may violate Anthropic's Terms of Service. +// Use at your own risk. +// ============================================================ + +export async function AnthropicMaxBridgePlugin() { + return { + // ── Fix 1 ────────────────────────────────────────────────── + // Convert system prompt strings to the array-of-objects format + // that the OAuth endpoint requires. + async "experimental.chat.system.transform"(input, output) { + if (input.model?.providerID !== "anthropic") return; + + output.system = output.system.map((s) => + typeof s === "string" ? { type: "text", text: s } : s, + ); + }, + + // ── Auth provider ────────────────────────────────────────── + auth: { + provider: "anthropic", + + async loader(getAuth, provider) { + const auth = await getAuth(); + + // Only activate for OAuth tokens (not plain API keys) + if (auth.type !== "oauth") return {}; + + // Show $0 cost in the model picker (Max = unlimited) + for (const model of Object.values(provider.models)) { + model.cost = { input: 0, output: 0, cache: { read: 0, write: 0 } }; + } + + return { + apiKey: "", // Not used — Bearer token replaces this + + // ── Fixes 2 & 3 applied to every API request ────────── + async fetch(input, init) { + const currentAuth = await getAuth(); + if (currentAuth.type !== "oauth") return fetch(input, init); + + const req = init ?? {}; + + // Merge all existing headers + const headers = new Headers( + input instanceof Request ? input.headers : undefined, + ); + new Headers(req.headers).forEach((v, k) => headers.set(k, v)); + + // Fix 2: Add OAuth beta header (merge with any existing betas) + const existing = headers.get("anthropic-beta") || ""; + const betas = new Set([ + "oauth-2025-04-20", + ...existing + .split(",") + .map((b) => b.trim()) + .filter(Boolean), + ]); + headers.set("anthropic-beta", [...betas].join(",")); + + // Fix 3: Bearer token instead of x-api-key + headers.set("authorization", `Bearer ${currentAuth.access}`); + headers.delete("x-api-key"); + + return fetch(input, { ...req, headers }); + }, + }; + }, + + // Auth method shown in /connect anthropic + methods: [ + { + // Primary method — token comes from auth.json (see README) + label: "Claude Pro/Max (OAuth)", + type: "oauth", + authorize: async () => { + // Tokens are injected manually or via the install script. + // The authorize flow is not used in normal operation. + return { type: "failed" }; + }, + }, + { + // Fallback — standard API key still works + label: "API Key", + type: "api", + }, + ], + }, + }; +} + +export default AnthropicMaxBridgePlugin; diff --git a/PAI-Install/anthropic-max-refresh.sh b/PAI-Install/anthropic-max-refresh.sh new file mode 100755 index 00000000..85cead5d --- /dev/null +++ b/PAI-Install/anthropic-max-refresh.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# ============================================================ +# PAI-OpenCode — Anthropic Max Token Refresh +# ============================================================ +# Run this when your Anthropic OAuth token has expired. +# Tokens last ~8-12 hours. Claude Code CLI refreshes its own +# token silently, so just run this script after using 'claude'. +# +# Usage: +# bash PAI-Install/anthropic-max-refresh.sh +# ============================================================ + +set -euo pipefail + +CYAN="\033[36m"; GREEN="\033[32m"; YELLOW="\033[33m"; RED="\033[31m"; RESET="\033[0m" +AUTH_FILE="$HOME/.local/share/opencode/auth.json" + +info() { echo -e "${CYAN}[INFO]${RESET} $*"; } +ok() { echo -e "${GREEN}[OK]${RESET} $*"; } +warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } +die() { echo -e "${RED}[ERROR]${RESET} $*" >&2; exit 1; } + +echo "" +info "Refreshing Anthropic OAuth token from macOS Keychain..." +echo "" + +KEYCHAIN_JSON=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null || true) +[[ -z "$KEYCHAIN_JSON" ]] && die "No credentials found. Run 'claude' to authenticate first." + +ACCESS_TOKEN=$(echo "$KEYCHAIN_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('claudeAiOauth',{}).get('accessToken',''))") +REFRESH_TOKEN=$(echo "$KEYCHAIN_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('claudeAiOauth',{}).get('refreshToken',''))") +EXPIRES_AT=$(echo "$KEYCHAIN_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('claudeAiOauth',{}).get('expiresAt',0))") + +[[ -z "$ACCESS_TOKEN" || "$ACCESS_TOKEN" != sk-ant-oat* ]] && \ + die "Token not fresh. Run 'claude' to re-authenticate, then retry." + +[[ ! -f "$AUTH_FILE" ]] && die "auth.json not found. Run the PAI-OpenCode installer first." + +python3 - < Provider preset: zen (default), anthropic, openrouter + --preset Provider preset: zen (default), anthropic, anthropic-max, openrouter, openai + anthropic-max: uses your existing Max/Pro subscription (no API key needed) --name Your name (principal) --ai-name AI assistant name --timezone Timezone (default: auto-detect) - --api-key API key for selected provider + --api-key API key for selected provider (not needed for anthropic-max) --elevenlabs-key ElevenLabs API key (optional) --skip-build Skip building OpenCode binary --no-voice Skip voice setup @@ -98,9 +99,13 @@ EXAMPLES: # Fresh install with Zen (FREE) bun cli/quick-install.ts --preset zen --name "Steffen" --ai-name "Jeremy" - # Fresh install with Anthropic + # Fresh install with Anthropic API key bun cli/quick-install.ts --preset anthropic --api-key "sk-ant-..." + # Fresh install with Anthropic Max/Pro subscription (no API key needed) + # Requires: Claude Code CLI installed and authenticated + bun cli/quick-install.ts --preset anthropic-max --name "Steffen" --ai-name "Jeremy" + # Migrate v2→v3 bun cli/quick-install.ts --migrate @@ -170,6 +175,20 @@ async function runFreshInstall(): Promise { ? (preset as ProviderName) : "zen"; + // anthropic-max does not require an API key — the token comes from the + // macOS Keychain via Claude Code CLI during the install step. + if (provider !== "anthropic-max" && !values["api-key"]) { + console.error(`❌ --api-key is required for provider "${provider}"`); + console.error(' (Only --preset anthropic-max works without an API key)'); + process.exit(1); + } + + if (provider === "anthropic-max") { + console.log("ℹ️ Anthropic Max/Pro preset — no API key needed."); + console.log(" Token will be extracted from macOS Keychain during install."); + console.log(" Make sure Claude Code CLI is installed and authenticated.\n"); + } + await stepProviderConfig( state, { diff --git a/PAI-Install/engine/provider-models.ts b/PAI-Install/engine/provider-models.ts index ef3b1bc9..a61f32a2 100644 --- a/PAI-Install/engine/provider-models.ts +++ b/PAI-Install/engine/provider-models.ts @@ -7,7 +7,7 @@ * To add a new provider: add an entry below and handle it in steps-fresh.ts. */ -export type ProviderName = "anthropic" | "zen" | "openrouter" | "openai"; +export type ProviderName = "anthropic" | "anthropic-max" | "zen" | "openrouter" | "openai"; export interface ModelTierMap { quick: string; @@ -25,6 +25,14 @@ export const PROVIDER_MODELS: Record = { standard: "anthropic/claude-sonnet-4-5", advanced: "anthropic/claude-opus-4-6", }, + // Anthropic Max/Pro OAuth — uses existing $20-200/month subscription. + // No API key required. Token is extracted from macOS Keychain via Claude Code CLI. + // The installer copies the anthropic-max-bridge plugin which handles auth automatically. + "anthropic-max": { + quick: "anthropic/claude-haiku-4-5", + standard: "anthropic/claude-sonnet-4-6", + advanced: "anthropic/claude-opus-4-6", + }, zen: { // OpenCode Zen — cost-optimised tiers (IDs verified against opencode.ai/docs/zen/) quick: "zen/minimax-m2.5-free", // FREE @@ -51,6 +59,10 @@ export const PROVIDER_LABELS: Record void, +): Promise { + // ── 1. Copy plugin file ────────────────────────────────── + onProgress(93, "Installing Anthropic Max Bridge plugin..."); + + // The plugin lives next to this engine file: + // PAI-Install/engine/../../../.opencode/plugins/anthropic-max-bridge.js + const repoRoot = join(dirname(import.meta.url.replace("file://", "")), "..", "..", ".."); + const pluginSrc = join(repoRoot, ".opencode", "plugins", "anthropic-max-bridge.js"); + const pluginDst = join(pluginsDir, "anthropic-max-bridge.js"); + + if (!existsSync(pluginSrc)) { + return { + success: false, + message: `Plugin source not found at ${pluginSrc}. Re-clone the repository and retry.`, + tokenHoursRemaining: 0, + }; + } + + mkdirSync(pluginsDir, { recursive: true }); + copyFileSync(pluginSrc, pluginDst); + + // ── 2. Extract token from macOS Keychain ───────────────── + onProgress(95, "Extracting OAuth token from macOS Keychain..."); + + let keychainJson: string; + try { + const proc = Bun.spawn( + ["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"], + { stdout: "pipe", stderr: "pipe" }, + ); + keychainJson = (await new Response(proc.stdout).text()).trim(); + await proc.exited; + if (!keychainJson) { + return { + success: false, + message: + "No Claude Code credentials in Keychain. Run 'claude' to authenticate, then re-run the installer.", + tokenHoursRemaining: 0, + }; + } + } catch { + return { + success: false, + message: + "Could not access macOS Keychain. This preset requires macOS with Claude Code CLI installed.", + tokenHoursRemaining: 0, + }; + } + + // ── 3. Parse credentials ────────────────────────────────── + let accessToken: string; + let refreshToken: string; + let expiresAt: number; + + try { + const creds = JSON.parse(keychainJson) as { + claudeAiOauth?: { + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + }; + }; + const oauth = creds.claudeAiOauth; + if (!oauth?.accessToken || !oauth.accessToken.startsWith("sk-ant-oat")) { + return { + success: false, + message: + "Unexpected token format in Keychain. Re-authenticate Claude Code CLI and retry.", + tokenHoursRemaining: 0, + }; + } + accessToken = oauth.accessToken; + refreshToken = oauth.refreshToken ?? ""; + expiresAt = oauth.expiresAt ?? Date.now() + 8 * 60 * 60 * 1000; + } catch { + return { + success: false, + message: "Failed to parse Keychain credentials JSON.", + tokenHoursRemaining: 0, + }; + } + + // ── 4. Write token to auth.json ─────────────────────────── + onProgress(97, "Writing OAuth token to auth.json..."); + + const authFile = join(homedir(), ".local", "share", "opencode", "auth.json"); + mkdirSync(dirname(authFile), { recursive: true }); + + let authData: Record = {}; + if (existsSync(authFile)) { + try { + authData = JSON.parse(readFileSync(authFile, "utf-8")) as Record; + } catch { + // Corrupt auth.json — start fresh, don't fail + } + } + + authData["anthropic"] = { + type: "oauth", + access: accessToken, + refresh: refreshToken, + expires: expiresAt, + }; + + writeFileSync(authFile, `${JSON.stringify(authData, null, 2)}\n`); + chmodSync(authFile, 0o600); + + const hoursRemaining = Math.max(0, Math.round((expiresAt - Date.now()) / 3_600_000)); + + return { + success: true, + message: `Token installed — valid for ~${hoursRemaining} hours. Run refresh-token.sh when it expires.`, + tokenHoursRemaining: hoursRemaining, + }; +} + // ═══════════════════════════════════════════════════════════ // Step 7: Install PAI Files // ═══════════════════════════════════════════════════════════ @@ -329,8 +477,19 @@ ${providerEnvVar}=${state.collected.apiKey || ""} join(localOpencodeDir, "opencode.json"), JSON.stringify(opencode, null, 2), ); - onProgress(97, "Generated opencode.json..."); - + onProgress(92, "Generated opencode.json..."); + + // ── Anthropic Max Bridge (only for anthropic-max preset) ── + if (state.collected.provider === "anthropic-max") { + const pluginsDir = join(localOpencodeDir, "plugins"); + const bridgeResult = await installAnthropicMaxBridge(pluginsDir, onProgress); + if (!bridgeResult.success) { + // Non-fatal: print warning but continue — user can fix token manually + console.error(`\n⚠️ Anthropic Max Bridge: ${bridgeResult.message}`); + console.error(" Run PAI-Install/anthropic-max-refresh.sh after fixing the issue.\n"); + } + } + // Create symlink from ~/.opencode to local .opencode onProgress(98, "Creating symlink ~/.opencode → ./.opencode..."); @@ -414,7 +573,28 @@ export async function runFreshInstall( description, })); const provider = (await requestChoice("provider", "Choose your AI provider:", providerChoices)) as ProviderName || "zen"; - const apiKey = await requestInput("api-key", `Enter your ${provider} API key:`, "key", "sk-..."); + + // anthropic-max uses an OAuth token from the macOS Keychain — no API key needed. + // Show a clear instruction instead of an API key prompt. + let apiKey = ""; + if (provider === "anthropic-max") { + await emit({ + event: "message", + content: + "Anthropic Max/Pro preset selected.\n\n" + + "Requirements:\n" + + " • macOS (Keychain access required)\n" + + " • Claude Code CLI installed and authenticated\n\n" + + "If Claude Code is not yet installed, get it at https://claude.ai/code\n" + + "then run 'claude' once to log in before continuing.\n\n" + + "Press Enter when Claude Code is authenticated.", + }); + // Pause so the user can read the message in interactive mode. + // In headless/CLI mode this resolves immediately. + await requestInput("anthropic-max-confirm", "Press Enter to continue", "text", ""); + } else { + apiKey = await requestInput("api-key", `Enter your ${provider} API key:`, "key", "sk-..."); + } await stepProviderConfig(state, { provider, diff --git a/docs/providers/anthropic-max.md b/docs/providers/anthropic-max.md new file mode 100644 index 00000000..b5d64653 --- /dev/null +++ b/docs/providers/anthropic-max.md @@ -0,0 +1,124 @@ +# Anthropic Max/Pro — OAuth Preset + +> Use your existing Anthropic Max or Pro subscription inside PAI-OpenCode. +> No API key required. No extra monthly cost. + +--- + +## What this is + +In March 2026, Anthropic blocked OAuth tokens from being used in third-party tools like +OpenCode. Users paying $20–200/month for Max or Pro subscriptions could no longer use +those subscriptions with OpenCode without also paying for separate API keys. + +The `anthropic-max` installer preset solves this with a minimal plugin that makes three +small request adjustments required by the OAuth endpoint — and nothing else. + +--- + +## Prerequisites + +| Requirement | Why | +|---|---| +| **macOS** | Token lives in macOS Keychain | +| **Claude Code CLI** installed | Source of the OAuth token | +| **Claude Code CLI authenticated** | Run `claude` and log in with your Anthropic account | + +Install Claude Code CLI: https://claude.ai/code + +--- + +## Install + +### Option A — PAI-OpenCode installer (recommended) + +```bash +# Interactive (GUI or CLI wizard) +bash install.sh + +# Headless / CI +bun PAI-Install/cli/quick-install.ts \ + --preset anthropic-max \ + --name "Your Name" \ + --ai-name "PAI" +``` + +The installer will: +1. Copy the `anthropic-max-bridge` plugin to `~/.opencode/plugins/` +2. Extract your OAuth token from the macOS Keychain automatically +3. Write it to `~/.local/share/opencode/auth.json` +4. Report how long the token is valid + +### Option B — Standalone (no full PAI install) + +See [`contrib/anthropic-max-bridge/`](../../contrib/anthropic-max-bridge/README.md) in the +[jeremy-opencode](https://github.com/Steffen025/jeremy-opencode) repository. + +--- + +## Models + +| Tier | Model | +|---|---| +| Quick | `anthropic/claude-haiku-4-5` | +| Standard | `anthropic/claude-sonnet-4-6` | +| Advanced | `anthropic/claude-opus-4-6` | + +All models show **$0 cost** in the OpenCode UI — they use your subscription quota. + +--- + +## Token expiry and refresh + +OAuth tokens expire after **8–12 hours**. + +When you get an auth error, run: + +```bash +bash PAI-Install/anthropic-max-refresh.sh +``` + +Then restart OpenCode. + +> **Tip:** Claude Code CLI silently refreshes its own token whenever you use it. +> So `bash PAI-Install/anthropic-max-refresh.sh` right after any `claude` session +> will always find a fresh token. + +--- + +## How it works (technical) + +Three fixes applied by the plugin on every API request: + +| # | Change | Without it | +|---|---|---| +| 1 | `system` prompt sent as **array of objects** `[{type,text}]` | HTTP 400 | +| 2 | `anthropic-beta: oauth-2025-04-20` header added | HTTP 401 "OAuth not supported" | +| 3 | `Authorization: Bearer ` instead of `x-api-key` | HTTP 401 | + +The plugin itself is 80 lines with zero spoofing, zero endpoint rewrites, and zero +User-Agent manipulation. Those approaches are unnecessary. + +Token path: `macOS Keychain → auth.json → plugin → Anthropic API` + +--- + +## Troubleshooting + +| Symptom | Fix | +|---|---| +| "No credentials in Keychain" | Run `claude` and log in | +| HTTP 401 in OpenCode | Token expired — run `anthropic-max-refresh.sh` | +| HTTP 400 in OpenCode | Plugin not loaded — check `~/.opencode/plugins/anthropic-max-bridge.js` exists | +| Model shows non-zero cost | Plugin not active — re-run installer | +| Linux / Windows | Not supported — Keychain is macOS-only | + +--- + +## Disclaimer + +Using an OAuth token from Claude Code in a third-party tool may violate Anthropic's +Terms of Service. This is a community workaround, not an official integration. +Anthropic can revoke tokens or block this approach at any time. + +Use at your own risk. From f674785c759e7c9966e2f2b409e1476c2ce9a03e Mon Sep 17 00:00:00 2001 From: Steffen Zellmer <151627820+Steffen025@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:27:19 -0400 Subject: [PATCH 2/5] fix(anthropic-max): address CodeRabbit findings + add standalone contrib package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit fixes (PR #84): - anthropic-max-refresh.sh: use quoted heredoc ('PYEOF') and export tokens as env vars to prevent shell injection via token values - anthropic-max-refresh.sh: add validation for REFRESH_TOKEN (non-empty) and EXPIRES_AT (numeric, >0) before writing auth.json - steps-fresh.ts: fix progress regression — onProgress(92) called after 95% steps; corrected to 96 - steps-fresh.ts: replace brittle import.meta.url.replace('file://', '') with fileURLToPath(new URL(import.meta.url)) from node:url - anthropic-max-bridge.js: add explicit comment explaining the authorize block returns { type: 'failed' } intentionally (tokens come from auth.json, not from an OAuth redirect flow) - docs/providers/anthropic-max.md: add YAML frontmatter (title, tags, published, type, summary) and convert tip block to Obsidian callout syntax Goal 2 — standalone extractable package: - contrib/anthropic-max-bridge/ — self-contained, no Bun/PAI required - install.sh (bash only, injection-safe heredoc, validates all tokens) - refresh-token.sh (same safety fixes as PAI-Install version) - plugins/anthropic-max-bridge.js (identical to .opencode/plugins/) - README.md (quick-start focused, team-sharing notes) - TECHNICAL.md (curl proof, token structure, what we ruled out) - docs/providers/anthropic-max.md: Option B now points to contrib/ in this repo instead of the external jeremy-opencode repository --- .opencode/plugins/anthropic-max-bridge.js | 15 +- PAI-Install/anthropic-max-refresh.sh | 31 ++- PAI-Install/engine/steps-fresh.ts | 5 +- contrib/anthropic-max-bridge/README.md | 192 +++++++++++++++ contrib/anthropic-max-bridge/TECHNICAL.md | 181 +++++++++++++++ contrib/anthropic-max-bridge/install.sh | 218 ++++++++++++++++++ .../plugins/anthropic-max-bridge.js | 131 +++++++++++ contrib/anthropic-max-bridge/refresh-token.sh | 116 ++++++++++ docs/providers/anthropic-max.md | 15 +- 9 files changed, 889 insertions(+), 15 deletions(-) create mode 100644 contrib/anthropic-max-bridge/README.md create mode 100644 contrib/anthropic-max-bridge/TECHNICAL.md create mode 100644 contrib/anthropic-max-bridge/install.sh create mode 100644 contrib/anthropic-max-bridge/plugins/anthropic-max-bridge.js create mode 100644 contrib/anthropic-max-bridge/refresh-token.sh diff --git a/.opencode/plugins/anthropic-max-bridge.js b/.opencode/plugins/anthropic-max-bridge.js index ed2fc9c8..6d272b91 100644 --- a/.opencode/plugins/anthropic-max-bridge.js +++ b/.opencode/plugins/anthropic-max-bridge.js @@ -107,11 +107,16 @@ export async function AnthropicMaxBridgePlugin() { // Primary method — token comes from auth.json (see README) label: "Claude Pro/Max (OAuth)", type: "oauth", - authorize: async () => { - // Tokens are injected manually or via the install script. - // The authorize flow is not used in normal operation. - return { type: "failed" }; - }, + authorize: async () => { + // INTENTIONAL: This OAuth authorize flow is deliberately disabled. + // Tokens are NOT obtained through a browser redirect here. + // Instead, they are extracted from the macOS Keychain (where + // Claude Code CLI stores them) and written to auth.json by the + // PAI-OpenCode installer or the refresh script. + // The plugin only reads from auth.json — it never triggers its own + // OAuth flow. + return { type: "failed" }; + }, }, { // Fallback — standard API key still works diff --git a/PAI-Install/anthropic-max-refresh.sh b/PAI-Install/anthropic-max-refresh.sh index 85cead5d..f4e9237e 100755 --- a/PAI-Install/anthropic-max-refresh.sh +++ b/PAI-Install/anthropic-max-refresh.sh @@ -34,18 +34,39 @@ EXPIRES_AT=$(echo "$KEYCHAIN_JSON" | python3 -c "import sys,json; print(json.loa [[ -z "$ACCESS_TOKEN" || "$ACCESS_TOKEN" != sk-ant-oat* ]] && \ die "Token not fresh. Run 'claude' to re-authenticate, then retry." +# Validate REFRESH_TOKEN is non-empty +[[ -z "$REFRESH_TOKEN" ]] && die "Refresh token is missing. Run 'claude' to re-authenticate." + +# Validate EXPIRES_AT is numeric and positive +if ! [[ "$EXPIRES_AT" =~ ^[0-9]+$ ]] || [[ "$EXPIRES_AT" -le 0 ]]; then + die "Invalid expiry timestamp ($EXPIRES_AT). Run 'claude' to re-authenticate." +fi + [[ ! -f "$AUTH_FILE" ]] && die "auth.json not found. Run the PAI-OpenCode installer first." -python3 - < **Use your Anthropic Max / Pro subscription in OpenCode — without paying extra for API keys.** +> +> **Standalone package** — no PAI-OpenCode installation required. Just bash and Python 3. + +--- + +## Background + +In March 2026, Anthropic blocked OAuth tokens from being used in third-party tools like OpenCode. +Teams paying $200–600/month for Max subscriptions were forced to either: +- Pay again for API keys, or +- Stop using OpenCode + +This package provides a minimal, working fix. + +--- + +## What this does + +Three tiny changes to how OpenCode talks to the Anthropic API: + +| # | What changes | Why it matters | +|---|---|---| +| 1 | `system` prompt sent as **array of objects** instead of a plain string | Plain string → HTTP 400 error | +| 2 | `anthropic-beta: oauth-2025-04-20` header added to every request | Without it → HTTP 401 "OAuth not supported" | +| 3 | `Authorization: Bearer ` instead of `x-api-key` | Required for OAuth auth flow | + +That's the entire fix. No spoofing. No endpoint rewrites. No User-Agent games. Just these three changes. + +--- + +## Requirements + +- **macOS** (the token lives in the macOS Keychain) +- **Claude Code CLI** installed and authenticated + → Download: https://claude.ai/code + → After install, run `claude` once and log in with your Anthropic account +- **OpenCode** installed + → Download: https://opencode.ai +- **Python 3** (pre-installed on macOS) + +--- + +## Quick Start (5 minutes) + +### Step 1 — Make sure Claude Code is authenticated + +Open Terminal and run: +```bash +claude +``` +Log in with the same Anthropic account that has your Max/Pro subscription. +You only need to do this once (or after your Claude Code session expires). + +### Step 2 — Run the install script + +From the directory containing this README: +```bash +bash install.sh +``` + +That's it. The script will: +1. Extract your OAuth token from the macOS Keychain +2. Copy the plugin to `~/.opencode/plugins/anthropic-max-bridge.js` +3. Write the token into `~/.local/share/opencode/auth.json` +4. Tell you how long the token is valid for + +### Step 3 — Start OpenCode and pick a model + +```bash +opencode +``` + +In the model picker, choose any `claude-*` model, e.g.: +- `anthropic/claude-sonnet-4-6` ← recommended +- `anthropic/claude-opus-4-6` + +You'll see **$0 input / $0 output** cost because it uses your subscription. + +--- + +## Token Expiry + +Anthropic OAuth tokens expire after **8–12 hours**. + +When you get an auth error, run: +```bash +bash refresh-token.sh +``` + +Then restart OpenCode. + +> [!tip] +> Claude Code silently refreshes its own token in the background whenever you use it. +> So if you use the `claude` CLI regularly, your token in the Keychain is usually fresh. +> Just re-run `refresh-token.sh` to pull it into OpenCode. + +--- + +## File Reference + +``` +contrib/anthropic-max-bridge/ +├── README.md ← You are here +├── install.sh ← One-time setup (run this first) +├── refresh-token.sh ← Run when token expires +├── TECHNICAL.md ← Deep dive: how the API fix works +└── plugins/ + └── anthropic-max-bridge.js ← The OpenCode plugin +``` + +--- + +## How tokens get from Claude Code into OpenCode + +``` +Claude Code CLI + └─ authenticates with Anthropic + └─ stores token in macOS Keychain + Service: "Claude Code-credentials" + +install.sh / refresh-token.sh + └─ reads token from Keychain + └─ writes to ~/.local/share/opencode/auth.json + +OpenCode + └─ reads auth.json on startup + └─ plugin applies 3 API fixes on every request + └─ Anthropic API accepts → response streams back +``` + +--- + +## Sharing with teammates + +Each person needs to run this on their own Mac because: +- The token is personal (tied to their Anthropic account) +- The token lives in their local Keychain + +Send them this folder and have them follow **Quick Start** above. + +--- + +## Troubleshooting + +### "No credentials found in Keychain" +→ Run `claude` and log in first. + +### "Token has already expired" +→ Run `claude` (to refresh Claude Code's own token), then `bash refresh-token.sh`. + +### HTTP 401 in OpenCode +→ Token expired. Run `bash refresh-token.sh`, then restart OpenCode. + +### HTTP 400 in OpenCode +→ Plugin not loaded. Check that `~/.opencode/plugins/anthropic-max-bridge.js` exists. + +### Model shows a non-zero cost +→ The plugin may not be active. Check OpenCode logs or re-run `install.sh`. + +### I don't see `claude-sonnet-4-6` in the model list +→ In OpenCode settings, make sure `anthropic` is an enabled provider. + +--- + +## Disclaimer + +Using your Max/Pro OAuth token in a third-party tool may violate Anthropic's Terms of Service. +Anthropic can revoke tokens or block this approach at any time. + +This is a temporary workaround, not an official solution. +Use at your own risk. + +--- + +## Technical details + +See [TECHNICAL.md](TECHNICAL.md) for: +- Exact API request format that works +- Why each fix is necessary +- curl command you can use to verify your token manually + +--- + +## PAI-OpenCode integration + +If you want the full PAI-OpenCode experience (preset models, installer, UI), see +[docs/providers/anthropic-max.md](../../docs/providers/anthropic-max.md). + +This `contrib/` package is for users who just want the OAuth fix for a plain OpenCode install. diff --git a/contrib/anthropic-max-bridge/TECHNICAL.md b/contrib/anthropic-max-bridge/TECHNICAL.md new file mode 100644 index 00000000..9fb2ff38 --- /dev/null +++ b/contrib/anthropic-max-bridge/TECHNICAL.md @@ -0,0 +1,181 @@ +# Technical Reference — Anthropic Max Bridge + +## The Problem (Discovered 2026-03-20) + +When Anthropic restricted OAuth tokens to first-party tools, they enforced three API-level differences between OAuth auth and API-key auth: + +### Difference 1: `system` prompt format + +OAuth endpoint **requires** the system field to be an array of content blocks: + +```json +// WRONG — works with API keys, fails with OAuth → HTTP 400 +"system": "You are a helpful assistant." + +// RIGHT — required for OAuth → HTTP 200 +"system": [ + { "type": "text", "text": "You are a helpful assistant." } +] +``` + +OpenCode (and most SDKs) send it as a string. The plugin transforms it before each request. + +### Difference 2: OAuth beta header + +Without this header, the API returns `401 "OAuth authentication is currently not supported"`: + +``` +anthropic-beta: oauth-2025-04-20 +``` + +### Difference 3: Bearer token vs. API key + +``` +# API key (wrong for OAuth) +x-api-key: sk-ant-api03-... + +# OAuth token (required) +Authorization: Bearer sk-ant-oat01-... +``` + +--- + +## Verified working curl command + +Use this to test your token manually: + +```bash +# First, get your token +TOKEN=$(security find-generic-password -s "Claude Code-credentials" -w \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['claudeAiOauth']['accessToken'])") + +# Then test it +curl https://api.anthropic.com/v1/messages \ + -H "Authorization: Bearer $TOKEN" \ + -H "anthropic-version: 2023-06-01" \ + -H "anthropic-beta: oauth-2025-04-20" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "claude-sonnet-4-6", + "max_tokens": 30, + "system": [{"type": "text", "text": "You are a helpful assistant."}], + "messages": [{"role": "user", "content": "Say hello in one word."}] + }' +``` + +Expected: `HTTP 200` with a streamed response. + +--- + +## Token structure in macOS Keychain + +**Keychain service name:** `Claude Code-credentials` + +**Raw value** (JSON string): +```json +{ + "claudeAiOauth": { + "accessToken": "sk-ant-oat01-...", + "refreshToken": "sk-ant-ort01-...", + "expiresAt": 1774040404027, + "subscriptionType": "max", + "rateLimitTier": "default_claude_max_20x" + } +} +``` + +**Extract access token:** +```bash +security find-generic-password -s "Claude Code-credentials" -w \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['claudeAiOauth']['accessToken'])" +``` + +--- + +## auth.json format + +OpenCode reads `~/.local/share/opencode/auth.json` on startup. + +The `anthropic` section should look like this for OAuth: +```json +{ + "anthropic": { + "type": "oauth", + "access": "sk-ant-oat01-...", + "refresh": "sk-ant-ort01-...", + "expires": 1774040404027 + } +} +``` + +`expires` is a Unix timestamp in **milliseconds**. + +--- + +## Plugin implementation (annotated) + +```javascript +// Fix 1: system prompt transformation +async "experimental.chat.system.transform"(input, output) { + if (input.model?.providerID !== "anthropic") return; + + // Convert any plain strings → { type: "text", text: "..." } + output.system = output.system.map((s) => + typeof s === "string" ? { type: "text", text: s } : s, + ); +}, + +// Fixes 2 & 3: request header injection +async fetch(input, init) { + const currentAuth = await getAuth(); + if (currentAuth.type !== "oauth") return fetch(input, init); // passthrough for API keys + + const headers = /* merge existing headers */; + + // Fix 2: add OAuth beta flag + headers.set("anthropic-beta", "oauth-2025-04-20, ..."); + + // Fix 3: Bearer token, remove API key header + headers.set("authorization", `Bearer ${currentAuth.access}`); + headers.delete("x-api-key"); + + return fetch(input, { ...init, headers }); +}, +``` + +--- + +## What we ruled out + +These changes are NOT necessary (tested and confirmed): + +| Approach | Verdict | +|---|---| +| Rewrite endpoint to `platform.claude.com` | Not needed — `api.anthropic.com` works | +| Spoof `User-Agent: claude-code/2.1.80` | Not needed | +| Replace "OpenCode" text with "Claude Code" in requests | Not needed | +| Prefix tool names with `mcp_` | Not needed | +| Add billing header (`x-anthropic-billing-header`) | Not needed | +| Add `?beta=true` URL param | Not needed | + +The gonzalosr Gist and other community approaches added all of these. None of them are required. + +--- + +## Token lifetime + +- Access tokens expire after **~8–12 hours** +- Refresh tokens are longer-lived but not currently used (would require hitting the Anthropic token endpoint) +- The simplest refresh path: re-run `refresh-token.sh` after using `claude` CLI + (Claude Code silently refreshes its own token, so the Keychain always has a fresh one after any `claude` use) + +--- + +## Models confirmed working + +- `claude-sonnet-4-6` ✅ (tested 2026-03-20) +- `claude-opus-4-6` ✅ (should work, same auth path) +- `claude-haiku-*` ✅ (expected to work) + +**Note:** Use bare model names in API calls, e.g. `claude-sonnet-4-6`. +In OpenCode's model picker, use the full ID: `anthropic/claude-sonnet-4-6`. diff --git a/contrib/anthropic-max-bridge/install.sh b/contrib/anthropic-max-bridge/install.sh new file mode 100644 index 00000000..c33563f2 --- /dev/null +++ b/contrib/anthropic-max-bridge/install.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +# ============================================================ +# Anthropic Max Bridge — Install Script +# ============================================================ +# This script sets up OpenCode to use your Anthropic Max / Pro +# subscription instead of a paid API key. +# +# Requirements: +# - macOS (uses Keychain) +# - Claude Code CLI installed and authenticated +# (install: https://claude.ai/code → run: claude) +# - OpenCode installed +# (install: https://opencode.ai) +# +# Usage: +# bash install.sh +# ============================================================ + +set -euo pipefail + +BOLD="\033[1m" +GREEN="\033[32m" +YELLOW="\033[33m" +RED="\033[31m" +CYAN="\033[36m" +RESET="\033[0m" + +PLUGIN_DIR="$HOME/.opencode/plugins" +AUTH_FILE="$HOME/.local/share/opencode/auth.json" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Helpers ────────────────────────────────────────────────── + +info() { echo -e "${CYAN}[INFO]${RESET} $*"; } +ok() { echo -e "${GREEN}[OK]${RESET} $*"; } +warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } +die() { echo -e "${RED}[ERROR]${RESET} $*" >&2; exit 1; } +sep() { echo -e "${BOLD}────────────────────────────────────────${RESET}"; } + +# ── Banner ─────────────────────────────────────────────────── + +echo "" +echo -e "${BOLD}Anthropic Max Bridge — OpenCode Installer${RESET}" +sep +echo "" + +# ── Step 1: Check dependencies ─────────────────────────────── + +info "Checking dependencies..." + +if ! command -v opencode &>/dev/null; then + warn "opencode not found in PATH." + warn "Install from https://opencode.ai and re-run this script." + # Non-fatal — user might have it elsewhere +fi + +if ! command -v claude &>/dev/null; then + die "Claude Code CLI not found. Install from https://claude.ai/code and run 'claude' to authenticate, then re-run this script." +fi + +ok "Claude Code CLI found: $(command -v claude)" + +# ── Step 2: Extract token from macOS Keychain ──────────────── + +sep +info "Extracting OAuth token from macOS Keychain..." +info "(You may see a Keychain permission prompt — click Allow)" +echo "" + +KEYCHAIN_JSON=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null || true) + +if [[ -z "$KEYCHAIN_JSON" ]]; then + echo "" + die "No Claude Code credentials found in Keychain. + + Please authenticate Claude Code first: + 1. Run: claude + 2. Log in with your Anthropic account + 3. Re-run this install script" +fi + +# Parse the JSON +ACCESS_TOKEN=$(echo "$KEYCHAIN_JSON" | python3 -c " +import sys, json +data = json.load(sys.stdin) +oauth = data.get('claudeAiOauth', {}) +token = oauth.get('accessToken', '') +print(token) +" 2>/dev/null || true) + +REFRESH_TOKEN=$(echo "$KEYCHAIN_JSON" | python3 -c " +import sys, json +data = json.load(sys.stdin) +oauth = data.get('claudeAiOauth', {}) +print(oauth.get('refreshToken', '')) +" 2>/dev/null || true) + +EXPIRES_AT=$(echo "$KEYCHAIN_JSON" | python3 -c " +import sys, json +data = json.load(sys.stdin) +oauth = data.get('claudeAiOauth', {}) +print(oauth.get('expiresAt', 0)) +" 2>/dev/null || true) + +if [[ -z "$ACCESS_TOKEN" || "$ACCESS_TOKEN" != sk-ant-oat* ]]; then + die "Token extraction failed or token format unexpected. + + Expected token starting with: sk-ant-oat01-... + Got: ${ACCESS_TOKEN:0:20}... + + Try re-authenticating: claude logout && claude" +fi + +# Validate REFRESH_TOKEN is non-empty +[[ -z "$REFRESH_TOKEN" ]] && die "Refresh token is missing. Run 'claude' to re-authenticate." + +# Validate EXPIRES_AT is numeric and positive +if ! [[ "$EXPIRES_AT" =~ ^[0-9]+$ ]] || [[ "$EXPIRES_AT" -le 0 ]]; then + die "Invalid expiry timestamp ($EXPIRES_AT). Run 'claude' to re-authenticate." +fi + +# Mask for display +MASKED="${ACCESS_TOKEN:0:16}...${ACCESS_TOKEN: -4}" +ok "Token found: $MASKED" + +# ── Step 3: Install the plugin ─────────────────────────────── + +sep +info "Installing plugin to $PLUGIN_DIR ..." + +mkdir -p "$PLUGIN_DIR" +cp "$SCRIPT_DIR/plugins/anthropic-max-bridge.js" "$PLUGIN_DIR/anthropic-max-bridge.js" + +ok "Plugin installed: $PLUGIN_DIR/anthropic-max-bridge.js" + +# ── Step 4: Write token to auth.json ───────────────────────── + +sep +info "Writing token to $AUTH_FILE ..." + +# Create parent directory if missing +mkdir -p "$(dirname "$AUTH_FILE")" + +# Read existing auth.json (or start fresh) +if [[ -f "$AUTH_FILE" ]]; then + EXISTING_JSON=$(cat "$AUTH_FILE") +else + EXISTING_JSON="{}" +fi + +# Export tokens as environment variables so the heredoc cannot be injected via token values +export PAI_ACCESS_TOKEN="$ACCESS_TOKEN" +export PAI_REFRESH_TOKEN="$REFRESH_TOKEN" +export PAI_EXPIRES_AT="$EXPIRES_AT" +export PAI_AUTH_FILE="$AUTH_FILE" +export PAI_EXISTING_JSON="$EXISTING_JSON" + +python3 - <<'PYEOF' +import json, os + +auth_file = os.environ["PAI_AUTH_FILE"] +access = os.environ["PAI_ACCESS_TOKEN"] +refresh = os.environ["PAI_REFRESH_TOKEN"] +expires_at = int(os.environ["PAI_EXPIRES_AT"]) +existing_raw = os.environ["PAI_EXISTING_JSON"] + +existing = json.loads(existing_raw) +existing["anthropic"] = { + "type": "oauth", + "access": access, + "refresh": refresh, + "expires": expires_at, +} + +with open(auth_file, "w") as f: + json.dump(existing, f, indent=2) + f.write("\n") + +print("auth.json updated") +PYEOF + +# Clean up exported vars — they are no longer needed +unset PAI_ACCESS_TOKEN PAI_REFRESH_TOKEN PAI_EXPIRES_AT PAI_AUTH_FILE PAI_EXISTING_JSON + +ok "Token written to auth.json" + +# ── Step 5: Verify token is not already expired ─────────────── + +NOW_MS=$(python3 -c "import time; print(int(time.time() * 1000))") + +if [[ "$EXPIRES_AT" -le "$NOW_MS" ]]; then + warn "Token has already expired!" + warn "Re-authenticate Claude Code: claude logout && claude" + warn "Then re-run this script." +else + HOURS_LEFT=$(python3 -c "print(round(($EXPIRES_AT - $NOW_MS) / 3600000, 1))") + ok "Token is valid for ~${HOURS_LEFT} hours" +fi + +# ── Done ───────────────────────────────────────────────────── + +sep +echo "" +echo -e "${BOLD}${GREEN}Installation complete!${RESET}" +echo "" +echo "Next steps:" +echo " 1. Start (or restart) OpenCode" +echo " 2. Select model: anthropic/claude-sonnet-4-6" +echo " (or any other claude-* model)" +echo "" +echo "Token refresh:" +echo " Tokens expire after ~8-12 hours." +echo " When expired, run: bash refresh-token.sh" +echo " (Claude Code auto-refreshes its own token in the background)" +echo "" +echo -e "${YELLOW}Note:${RESET} This uses your Max subscription quota." +echo " All models show \$0 cost in the UI (already paid for)." +echo "" diff --git a/contrib/anthropic-max-bridge/plugins/anthropic-max-bridge.js b/contrib/anthropic-max-bridge/plugins/anthropic-max-bridge.js new file mode 100644 index 00000000..6d272b91 --- /dev/null +++ b/contrib/anthropic-max-bridge/plugins/anthropic-max-bridge.js @@ -0,0 +1,131 @@ +// ============================================================ +// Anthropic Max Bridge — OpenCode Plugin +// Version: 1.0.0 | Date: 2026-03-20 +// ============================================================ +// +// PURPOSE +// ------- +// Lets you use your existing Anthropic Max / Pro subscription +// inside OpenCode without paying extra for API keys. +// +// HOW IT WORKS (3 tiny fixes) +// ---------------------------- +// When Anthropic blocked OAuth for third-party tools (March 2026), +// they introduced 3 API-level differences vs. API-key auth: +// +// Fix 1 — system prompt format +// BAD: "system": "You are..." → HTTP 400 Error +// GOOD: "system": [{"type":"text","text":"..."}] → HTTP 200 OK +// +// Fix 2 — OAuth beta header +// Missing header → HTTP 401 "OAuth authentication not supported" +// Required: anthropic-beta: oauth-2025-04-20 +// +// Fix 3 — Bearer token instead of x-api-key +// Wrong: x-api-key: +// Right: Authorization: Bearer +// +// TOKEN SOURCE +// ------------ +// Tokens live in: ~/.local/share/opencode/auth.json +// (under the "anthropic" key — see README for how to put them there) +// +// DISCLAIMER +// ---------- +// Using OAuth tokens from Claude Code in a third-party tool +// may violate Anthropic's Terms of Service. +// Use at your own risk. +// ============================================================ + +export async function AnthropicMaxBridgePlugin() { + return { + // ── Fix 1 ────────────────────────────────────────────────── + // Convert system prompt strings to the array-of-objects format + // that the OAuth endpoint requires. + async "experimental.chat.system.transform"(input, output) { + if (input.model?.providerID !== "anthropic") return; + + output.system = output.system.map((s) => + typeof s === "string" ? { type: "text", text: s } : s, + ); + }, + + // ── Auth provider ────────────────────────────────────────── + auth: { + provider: "anthropic", + + async loader(getAuth, provider) { + const auth = await getAuth(); + + // Only activate for OAuth tokens (not plain API keys) + if (auth.type !== "oauth") return {}; + + // Show $0 cost in the model picker (Max = unlimited) + for (const model of Object.values(provider.models)) { + model.cost = { input: 0, output: 0, cache: { read: 0, write: 0 } }; + } + + return { + apiKey: "", // Not used — Bearer token replaces this + + // ── Fixes 2 & 3 applied to every API request ────────── + async fetch(input, init) { + const currentAuth = await getAuth(); + if (currentAuth.type !== "oauth") return fetch(input, init); + + const req = init ?? {}; + + // Merge all existing headers + const headers = new Headers( + input instanceof Request ? input.headers : undefined, + ); + new Headers(req.headers).forEach((v, k) => headers.set(k, v)); + + // Fix 2: Add OAuth beta header (merge with any existing betas) + const existing = headers.get("anthropic-beta") || ""; + const betas = new Set([ + "oauth-2025-04-20", + ...existing + .split(",") + .map((b) => b.trim()) + .filter(Boolean), + ]); + headers.set("anthropic-beta", [...betas].join(",")); + + // Fix 3: Bearer token instead of x-api-key + headers.set("authorization", `Bearer ${currentAuth.access}`); + headers.delete("x-api-key"); + + return fetch(input, { ...req, headers }); + }, + }; + }, + + // Auth method shown in /connect anthropic + methods: [ + { + // Primary method — token comes from auth.json (see README) + label: "Claude Pro/Max (OAuth)", + type: "oauth", + authorize: async () => { + // INTENTIONAL: This OAuth authorize flow is deliberately disabled. + // Tokens are NOT obtained through a browser redirect here. + // Instead, they are extracted from the macOS Keychain (where + // Claude Code CLI stores them) and written to auth.json by the + // PAI-OpenCode installer or the refresh script. + // The plugin only reads from auth.json — it never triggers its own + // OAuth flow. + return { type: "failed" }; + }, + }, + { + // Fallback — standard API key still works + label: "API Key", + type: "api", + }, + ], + }, + }; +} + +export default AnthropicMaxBridgePlugin; diff --git a/contrib/anthropic-max-bridge/refresh-token.sh b/contrib/anthropic-max-bridge/refresh-token.sh new file mode 100644 index 00000000..b4f672ab --- /dev/null +++ b/contrib/anthropic-max-bridge/refresh-token.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# ============================================================ +# Anthropic Max Bridge — Token Refresh Script +# ============================================================ +# Run this when your token has expired (usually every 8-12 hours). +# Much faster than the full install — just syncs the Keychain token. +# +# Usage: +# bash refresh-token.sh +# ============================================================ + +set -euo pipefail + +CYAN="\033[36m" +GREEN="\033[32m" +YELLOW="\033[33m" +RED="\033[31m" +RESET="\033[0m" + +AUTH_FILE="$HOME/.local/share/opencode/auth.json" + +info() { echo -e "${CYAN}[INFO]${RESET} $*"; } +ok() { echo -e "${GREEN}[OK]${RESET} $*"; } +warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } +die() { echo -e "${RED}[ERROR]${RESET} $*" >&2; exit 1; } + +echo "" +info "Refreshing Anthropic OAuth token from Keychain..." +echo "" + +# Pull from Keychain +KEYCHAIN_JSON=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null || true) + +if [[ -z "$KEYCHAIN_JSON" ]]; then + die "No credentials in Keychain. Run 'claude' to authenticate first." +fi + +ACCESS_TOKEN=$(echo "$KEYCHAIN_JSON" | python3 -c " +import sys, json +data = json.load(sys.stdin) +print(data.get('claudeAiOauth', {}).get('accessToken', '')) +") + +REFRESH_TOKEN=$(echo "$KEYCHAIN_JSON" | python3 -c " +import sys, json +data = json.load(sys.stdin) +print(data.get('claudeAiOauth', {}).get('refreshToken', '')) +") + +EXPIRES_AT=$(echo "$KEYCHAIN_JSON" | python3 -c " +import sys, json +data = json.load(sys.stdin) +print(data.get('claudeAiOauth', {}).get('expiresAt', 0)) +") + +if [[ -z "$ACCESS_TOKEN" || "$ACCESS_TOKEN" != sk-ant-oat* ]]; then + warn "Token not fresh or Claude Code session expired." + warn "Run: claude" + warn "Then re-run: bash refresh-token.sh" + exit 1 +fi + +# Validate REFRESH_TOKEN is non-empty +[[ -z "$REFRESH_TOKEN" ]] && die "Refresh token is missing. Run 'claude' to re-authenticate." + +# Validate EXPIRES_AT is numeric and positive +if ! [[ "$EXPIRES_AT" =~ ^[0-9]+$ ]] || [[ "$EXPIRES_AT" -le 0 ]]; then + die "Invalid expiry timestamp ($EXPIRES_AT). Run 'claude' to re-authenticate." +fi + +# Read existing auth.json +if [[ ! -f "$AUTH_FILE" ]]; then + die "auth.json not found at $AUTH_FILE. Run install.sh first." +fi + +# Export tokens as environment variables so the heredoc cannot be injected via token values +export PAI_ACCESS_TOKEN="$ACCESS_TOKEN" +export PAI_REFRESH_TOKEN="$REFRESH_TOKEN" +export PAI_EXPIRES_AT="$EXPIRES_AT" +export PAI_AUTH_FILE="$AUTH_FILE" + +python3 - <<'PYEOF' +import json, os + +auth_file = os.environ["PAI_AUTH_FILE"] +access = os.environ["PAI_ACCESS_TOKEN"] +refresh = os.environ["PAI_REFRESH_TOKEN"] +expires_at = int(os.environ["PAI_EXPIRES_AT"]) + +with open(auth_file) as f: + data = json.load(f) + +data["anthropic"] = { + "type": "oauth", + "access": access, + "refresh": refresh, + "expires": expires_at, +} + +with open(auth_file, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") +PYEOF + +# Clean up exported vars — they are no longer needed +unset PAI_ACCESS_TOKEN PAI_REFRESH_TOKEN PAI_EXPIRES_AT PAI_AUTH_FILE + +NOW_MS=$(python3 -c "import time; print(int(time.time() * 1000))") +HOURS_LEFT=$(python3 -c "print(round(($EXPIRES_AT - $NOW_MS) / 3600000, 1))") +MASKED="${ACCESS_TOKEN:0:16}...${ACCESS_TOKEN: -4}" + +ok "Token refreshed: $MASKED" +ok "Valid for ~${HOURS_LEFT} more hours" +echo "" +info "Restart OpenCode to pick up the new token." +echo "" diff --git a/docs/providers/anthropic-max.md b/docs/providers/anthropic-max.md index b5d64653..d821c404 100644 --- a/docs/providers/anthropic-max.md +++ b/docs/providers/anthropic-max.md @@ -1,3 +1,11 @@ +--- +title: Anthropic Max/Pro — OAuth Preset +tags: [providers, anthropic] +published: true +type: guide +summary: Use your Anthropic Max or Pro subscription inside PAI-OpenCode via OAuth — no API key required. +--- + # Anthropic Max/Pro — OAuth Preset > Use your existing Anthropic Max or Pro subscription inside PAI-OpenCode. @@ -51,8 +59,8 @@ The installer will: ### Option B — Standalone (no full PAI install) -See [`contrib/anthropic-max-bridge/`](../../contrib/anthropic-max-bridge/README.md) in the -[jeremy-opencode](https://github.com/Steffen025/jeremy-opencode) repository. +See [`contrib/anthropic-max-bridge/`](../../contrib/anthropic-max-bridge/README.md) in this +repository. No Bun, no PAI installer — just bash and Python 3. --- @@ -80,7 +88,8 @@ bash PAI-Install/anthropic-max-refresh.sh Then restart OpenCode. -> **Tip:** Claude Code CLI silently refreshes its own token whenever you use it. +> [!tip] +> Claude Code CLI silently refreshes its own token whenever you use it. > So `bash PAI-Install/anthropic-max-refresh.sh` right after any `claude` session > will always find a fresh token. From c21923c52057a8913e194dad0e871eb41e26f281 Mon Sep 17 00:00:00 2001 From: Steffen Zellmer <151627820+Steffen025@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:52:45 -0400 Subject: [PATCH 3/5] fix(anthropic-max): address second-round CodeRabbit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - anthropic-max-bridge.js: guard experimental.chat.system.transform against output.system being undefined/null/non-array before calling .map(); normalise to [] if missing, wrap bare string/object into array, then apply existing string->{type,text} conversion - contrib/anthropic-max-bridge/install.sh: chmod auth.json to 0o600 (owner read/write only) immediately after writing via os.chmod + stat module; mirrors the chmodSync(authFile, 0o600) already present in steps-fresh.ts - steps-fresh.ts: add zero-case message when hoursRemaining === 0 ('Token installed — expired or valid for less than 1 hour. Run anthropic-max-refresh.sh now.'); Math.max(0, ...) clamp was already present --- .opencode/plugins/anthropic-max-bridge.js | 11 +++++++++++ PAI-Install/engine/steps-fresh.ts | 7 ++++++- contrib/anthropic-max-bridge/install.sh | 5 ++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.opencode/plugins/anthropic-max-bridge.js b/.opencode/plugins/anthropic-max-bridge.js index 6d272b91..90430623 100644 --- a/.opencode/plugins/anthropic-max-bridge.js +++ b/.opencode/plugins/anthropic-max-bridge.js @@ -45,6 +45,17 @@ export async function AnthropicMaxBridgePlugin() { async "experimental.chat.system.transform"(input, output) { if (input.model?.providerID !== "anthropic") return; + // Normalise output.system to an array before mapping so we never + // call .map() on undefined, null, or a bare string/object. + if (!Array.isArray(output.system)) { + if (output.system === undefined || output.system === null) { + output.system = []; + } else { + // Single string or single object — wrap it + output.system = [output.system]; + } + } + output.system = output.system.map((s) => typeof s === "string" ? { type: "text", text: s } : s, ); diff --git a/PAI-Install/engine/steps-fresh.ts b/PAI-Install/engine/steps-fresh.ts index 53d5a444..9c848d8b 100644 --- a/PAI-Install/engine/steps-fresh.ts +++ b/PAI-Install/engine/steps-fresh.ts @@ -328,9 +328,14 @@ async function installAnthropicMaxBridge( const hoursRemaining = Math.max(0, Math.round((expiresAt - Date.now()) / 3_600_000)); + const message = + hoursRemaining === 0 + ? "Token installed — expired or valid for less than 1 hour. Run anthropic-max-refresh.sh now." + : `Token installed — valid for ~${hoursRemaining} hours. Run anthropic-max-refresh.sh when it expires.`; + return { success: true, - message: `Token installed — valid for ~${hoursRemaining} hours. Run refresh-token.sh when it expires.`, + message, tokenHoursRemaining: hoursRemaining, }; } diff --git a/contrib/anthropic-max-bridge/install.sh b/contrib/anthropic-max-bridge/install.sh index c33563f2..d173d7b3 100644 --- a/contrib/anthropic-max-bridge/install.sh +++ b/contrib/anthropic-max-bridge/install.sh @@ -156,7 +156,7 @@ export PAI_AUTH_FILE="$AUTH_FILE" export PAI_EXISTING_JSON="$EXISTING_JSON" python3 - <<'PYEOF' -import json, os +import json, os, stat auth_file = os.environ["PAI_AUTH_FILE"] access = os.environ["PAI_ACCESS_TOKEN"] @@ -176,6 +176,9 @@ with open(auth_file, "w") as f: json.dump(existing, f, indent=2) f.write("\n") +# Restrict to owner read/write only — tokens must not be world-readable +os.chmod(auth_file, stat.S_IRUSR | stat.S_IWUSR) + print("auth.json updated") PYEOF From 05b07d56d1b0e2205a18fcae7f56a914accdbb06 Mon Sep 17 00:00:00 2001 From: Steffen Zellmer <151627820+Steffen025@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:37:52 -0400 Subject: [PATCH 4/5] fix(anthropic-max): check Bun.spawn exit code for Keychain lookup Previously proc.exited was awaited but its return value discarded, so a non-zero exit from the 'security' command (e.g. item not found) was only caught via an empty-stdout check. Stdout and stderr are now read concurrently with proc.exited via Promise.all to avoid pipe deadlock; the exit code is inspected before accepting keychainJson, and stderr is surfaced in the error message when present. --- PAI-Install/engine/steps-fresh.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/PAI-Install/engine/steps-fresh.ts b/PAI-Install/engine/steps-fresh.ts index 9c848d8b..554f134d 100644 --- a/PAI-Install/engine/steps-fresh.ts +++ b/PAI-Install/engine/steps-fresh.ts @@ -249,13 +249,20 @@ async function installAnthropicMaxBridge( ["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"], { stdout: "pipe", stderr: "pipe" }, ); - keychainJson = (await new Response(proc.stdout).text()).trim(); - await proc.exited; - if (!keychainJson) { + // Read both streams before awaiting exit to avoid deadlock on large output + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + keychainJson = stdout.trim(); + if (exitCode !== 0 || !keychainJson) { + const detail = stderr.trim(); return { success: false, - message: - "No Claude Code credentials in Keychain. Run 'claude' to authenticate, then re-run the installer.", + message: detail + ? `Keychain lookup failed (exit ${exitCode}): ${detail}. Run 'claude' to authenticate, then re-run the installer.` + : "No Claude Code credentials in Keychain. Run 'claude' to authenticate, then re-run the installer.", tokenHoursRemaining: 0, }; } From c721352a6fa7d9e5fa251bc8ba30c9eff01c1c13 Mon Sep 17 00:00:00 2001 From: Steffen Zellmer <151627820+Steffen025@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:37:24 -0400 Subject: [PATCH 5/5] nitpick(steps-fresh): add warning when refresh token is missing When extracting OAuth tokens from Keychain, warn users if the refresh token is undefined. This alerts them that they'll need to re-authenticate with 'claude' when the access token expires. --- PAI-Install/engine/steps-fresh.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/PAI-Install/engine/steps-fresh.ts b/PAI-Install/engine/steps-fresh.ts index 554f134d..5980c0d5 100644 --- a/PAI-Install/engine/steps-fresh.ts +++ b/PAI-Install/engine/steps-fresh.ts @@ -300,6 +300,13 @@ async function installAnthropicMaxBridge( accessToken = oauth.accessToken; refreshToken = oauth.refreshToken ?? ""; expiresAt = oauth.expiresAt ?? Date.now() + 8 * 60 * 60 * 1000; + + // Warn if refresh token is missing - user will need to re-authenticate when access token expires + if (!oauth.refreshToken) { + console.warn("\n⚠️ Warning: No refresh token found in Keychain."); + console.warn(" You will need to re-authenticate with 'claude' when the access token expires."); + console.warn(" Run 'claude' and then PAI-Install/anthropic-max-refresh.sh to refresh.\n"); + } } catch { return { success: false,