-
Notifications
You must be signed in to change notification settings - Fork 14
feat(installer): add anthropic-max preset for Max/Pro OAuth subscription #84
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
48d3fbf
feat(installer): add anthropic-max preset for Max/Pro OAuth subscription
Steffen025 f674785
fix(anthropic-max): address CodeRabbit findings + add standalone cont…
Steffen025 c21923c
fix(anthropic-max): address second-round CodeRabbit findings
Steffen025 05b07d5
fix(anthropic-max): check Bun.spawn exit code for Keychain lookup
Steffen025 c721352
nitpick(steps-fresh): add warning when refresh token is missing
Steffen025 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| // ============================================================ | ||
| // 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: <token> | ||
| // Right: Authorization: Bearer <token> | ||
| // | ||
| // 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; | ||
|
|
||
| // 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, | ||
| ); | ||
| }, | ||
|
|
||
| // ── 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; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| #!/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." | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| # 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." | ||
|
|
||
| # 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=$(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} more hours" | ||
| echo "" | ||
| info "Restart OpenCode to pick up the new token." | ||
| echo "" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.