Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions .opencode/plugins/anthropic-max-bridge.js
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;
78 changes: 78 additions & 0 deletions PAI-Install/anthropic-max-refresh.sh
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."

# 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 ""
25 changes: 22 additions & 3 deletions PAI-Install/cli/quick-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,12 @@ MODES:
--update Update v3.x to latest

FRESH INSTALL OPTIONS:
--preset <name> Provider preset: zen (default), anthropic, openrouter
--preset <name> Provider preset: zen (default), anthropic, anthropic-max, openrouter, openai
anthropic-max: uses your existing Max/Pro subscription (no API key needed)
--name <name> Your name (principal)
--ai-name <name> AI assistant name
--timezone <tz> Timezone (default: auto-detect)
--api-key <key> API key for selected provider
--api-key <key> API key for selected provider (not needed for anthropic-max)
--elevenlabs-key <k> ElevenLabs API key (optional)
--skip-build Skip building OpenCode binary
--no-voice Skip voice setup
Expand All @@ -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

Expand Down Expand Up @@ -170,6 +175,20 @@ async function runFreshInstall(): Promise<void> {
? (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,
{
Expand Down
14 changes: 13 additions & 1 deletion PAI-Install/engine/provider-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +25,14 @@ export const PROVIDER_MODELS: Record<ProviderName, ModelTierMap> = {
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
Expand All @@ -51,6 +59,10 @@ export const PROVIDER_LABELS: Record<ProviderName, { label: string; description:
label: "Anthropic (Claude)",
description: "Premium quality — requires Anthropic API key",
},
"anthropic-max": {
label: "Anthropic Max/Pro (OAuth)",
description: "Use your existing Max/Pro subscription — no API key needed, requires Claude Code CLI",
},
zen: {
label: "OpenCode Zen (recommended)",
description: "Free tier available — 60× cost optimisation vs direct Anthropic",
Expand Down
Loading
Loading