From b93520914210a3144f5753d47bef8ed1fff2bcf9 Mon Sep 17 00:00:00 2001 From: Jan-Marlon Leibl Date: Thu, 21 May 2026 14:56:14 +0200 Subject: [PATCH] fix(claude-code): add oauthToken config for subscription SDK auth `claude login` alone does not authorize SDK / non-interactive (`claude -p`) usage. Anthropic gates that behind a separate long-lived token minted by `claude setup-token`. Without it, subscription users hit `billing_error` from the SDK even though interactive Claude Code works fine. Changes: - ClaudeCodeConfig.oauthToken: new optional field. When set, the provider exports it as CLAUDE_CODE_OAUTH_TOKEN before invoking the SDK, which makes the SDK bill against the user's Pro/Max subscription instead of API credits. - apiKey continues to work for users with a console API key. If both are present, oauthToken takes precedence. - billing_error / authentication_failed messages now point users at `claude setup-token` so the fix is obvious from the error alone. - README: rewrite the Claude Code section to document both modes and the `claude setup-token` step explicitly. - examples/claude-code.ts: read CLAUDE_CODE_OAUTH_TOKEN from env so the smoke test actually works for subscribers. --- README.md | 31 ++++++++++++++---- examples/claude-code.ts | 61 ++++++++++++++++++++++++++++++++++++ src/providers/claude-code.ts | 46 ++++++++++++++++++++++----- 3 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 examples/claude-code.ts diff --git a/README.md b/README.md index 4b252ca..0c786c8 100644 --- a/README.md +++ b/README.md @@ -52,27 +52,46 @@ const claude = new ClaudeProvider({ }); ``` -### Claude Code (subscription via local CLI) +### Claude Code (subscription or API key via local CLI) -`ClaudeCodeProvider` wraps `@anthropic-ai/claude-agent-sdk`, which authenticates through the local `claude` CLI. This is the supported path for **Claude Pro / Max subscribers** who don't have a console API key. +`ClaudeCodeProvider` wraps `@anthropic-ai/claude-agent-sdk`, which spawns the local `claude` CLI under the hood. It supports two billing modes: -**Setup:** install the CLI and run `claude login` once. No API key required. +**Mode A — Claude Pro/Max subscription** (billed against subscription credits): + +```bash +# One-time: install the CLI, then mint a long-lived OAuth token. +# `claude login` alone is NOT enough — Anthropic gates SDK use behind setup-token. +claude setup-token +``` ```typescript import { ClaudeCodeProvider } from 'simple-ai-provider'; -const claude = new ClaudeCodeProvider({}); // uses local credentials -await claude.initialize(); +const claude = new ClaudeCodeProvider({ + oauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN // from `claude setup-token` +}); +await claude.initialize(); const response = await claude.complete({ messages: [{ role: 'user', content: 'Hello!' }] }); ``` -You can still pass `apiKey` to override (it's set as `ANTHROPIC_API_KEY` for the SDK). Optional config: +**Mode B — Console API key** (billed per-token): + +```typescript +const claude = new ClaudeCodeProvider({ + apiKey: process.env.ANTHROPIC_API_KEY +}); +``` + +If both are present, `oauthToken` wins. Either field can also be picked up from the environment (`CLAUDE_CODE_OAUTH_TOKEN` / `ANTHROPIC_API_KEY`) without being passed explicitly. + +Optional config: ```typescript new ClaudeCodeProvider({ + oauthToken: '...', defaultModel: 'sonnet', // 'sonnet' | 'opus' | 'haiku' | 'inherit' | full model ID maxTurns: 1, // 1 for plain completion; raise for agent/tool loops allowedTools: [], // tool names to enable (default: none) diff --git a/examples/claude-code.ts b/examples/claude-code.ts new file mode 100644 index 0000000..b397e1d --- /dev/null +++ b/examples/claude-code.ts @@ -0,0 +1,61 @@ +/** + * Quick smoke test for ClaudeCodeProvider. + * + * Prereqs (pick one): + * - Claude Pro/Max subscription: run `claude setup-token`, then set + * CLAUDE_CODE_OAUTH_TOKEN= in your environment + * (or pass it as `oauthToken` below). + * - Console API key: set ANTHROPIC_API_KEY (or pass `apiKey`). + * + * `claude login` alone is NOT sufficient for SDK use — Anthropic gates + * non-interactive (`claude -p`) invocations behind a separate token. + * + * Run: bun run examples/claude-code.ts + */ + +import { ClaudeCodeProvider } from '../src/index.js'; + +async function main() { + const claude = new ClaudeCodeProvider({ + defaultModel: 'sonnet', + oauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN + }); + + console.log('Initializing…'); + await claude.initialize(); + console.log('Ready.\n'); + + // ── Non-streaming completion ─────────────────────────────────────────── + console.log('--- complete() ---'); + const response = await claude.complete({ + messages: [ + { role: 'system', content: 'You are concise. Reply in one sentence.' }, + { role: 'user', content: 'What is the capital of Japan?' } + ], + maxTokens: 100 + }); + + console.log(response.content); + console.log(`tokens: ${response.usage.totalTokens} | cost: $${response.metadata?.costUsd ?? '?'}\n`); + + // ── Streaming ────────────────────────────────────────────────────────── + console.log('--- stream() ---'); + let totalTokens = 0; + for await (const chunk of claude.stream({ + messages: [{ role: 'user', content: 'Count from 1 to 5, one per line.' }], + maxTokens: 100 + })) { + if (!chunk.isComplete) { + process.stdout.write(chunk.content); + } else { + totalTokens = chunk.usage?.totalTokens ?? 0; + } + } + console.log(`\ntokens: ${totalTokens}`); +} + +main().catch(err => { + console.error('Failed:', err.message); + if (err.type) console.error('Type:', err.type); + process.exit(1); +}); diff --git a/src/providers/claude-code.ts b/src/providers/claude-code.ts index d5f0303..277313b 100644 --- a/src/providers/claude-code.ts +++ b/src/providers/claude-code.ts @@ -31,12 +31,31 @@ import { AIProviderError, AIErrorType } from '../types/index.js'; import { DEFAULT_MODELS } from '../constants.js'; /** - * Configuration for the Claude Code provider. `apiKey` is optional — - * when omitted the SDK falls back to ANTHROPIC_API_KEY in the - * environment or the local subscription credentials managed by the - * `claude` CLI. + * Configuration for the Claude Code provider. + * + * Authentication options, in priority order: + * + * 1. `oauthToken` — long-lived subscription token generated by + * `claude setup-token` (recommended for Pro/Max subscribers using + * the SDK programmatically). Billed against the subscription. + * 2. `apiKey` — standard Anthropic console API key. Billed per-token. + * 3. Environment fallback — the SDK reads `CLAUDE_CODE_OAUTH_TOKEN` or + * `ANTHROPIC_API_KEY` from the environment if neither field is set. + * + * Note: `claude login` alone is not sufficient for SDK use because + * Anthropic gates non-interactive (`claude -p`) invocations behind a + * separate token. Run `claude setup-token` once to mint one. */ export interface ClaudeCodeConfig extends Omit { + /** + * Long-lived OAuth token from `claude setup-token`. This is the + * supported path for Claude Pro/Max subscribers to use the SDK + * programmatically — billed against the subscription instead of + * per-token. + */ + oauthToken?: string; + + /** Standard Anthropic console API key. Mutually compatible with oauthToken (oauthToken takes precedence). */ apiKey?: string; /** @@ -71,6 +90,7 @@ export class ClaudeCodeProvider extends BaseAIProvider { private readonly cwd: string | undefined; private readonly allowedTools: string[]; private readonly maxTurns: number; + private readonly oauthToken: string | undefined; constructor(config: ClaudeCodeConfig) { super(config as AIProviderConfig); @@ -79,6 +99,7 @@ export class ClaudeCodeProvider extends BaseAIProvider { this.cwd = config.cwd; this.allowedTools = config.allowedTools ?? []; this.maxTurns = config.maxTurns ?? 1; + this.oauthToken = config.oauthToken; } /** @@ -95,13 +116,21 @@ export class ClaudeCodeProvider extends BaseAIProvider { ); } - if (config.apiKey !== undefined && (typeof config.apiKey !== 'string')) { + if (config.apiKey !== undefined && typeof config.apiKey !== 'string') { throw new AIProviderError( 'API key, when provided, must be a string', AIErrorType.INVALID_REQUEST ); } + const oauthToken = (config as ClaudeCodeConfig).oauthToken; + if (oauthToken !== undefined && typeof oauthToken !== 'string') { + throw new AIProviderError( + 'oauthToken, when provided, must be a string', + AIErrorType.INVALID_REQUEST + ); + } + return { ...config, apiKey: config.apiKey ?? '', @@ -114,6 +143,9 @@ export class ClaudeCodeProvider extends BaseAIProvider { // The SDK lazy-spawns the CLI per query; no connection probe is // necessary (and a probe would cost a real billed turn). We defer // auth/model errors to the first complete()/stream() call. + if (this.oauthToken) { + process.env.CLAUDE_CODE_OAUTH_TOKEN = this.oauthToken; + } if (this.config.apiKey) { process.env.ANTHROPIC_API_KEY = this.config.apiKey; } @@ -322,7 +354,7 @@ export class ClaudeCodeProvider extends BaseAIProvider { const map: Record = { authentication_failed: { type: AIErrorType.AUTHENTICATION, - message: 'Claude Code authentication failed. Run `claude login` or set ANTHROPIC_API_KEY.' + message: 'Claude Code authentication failed. For subscriptions run `claude setup-token` and pass the result as `oauthToken`. Otherwise set ANTHROPIC_API_KEY or pass `apiKey`.' }, oauth_org_not_allowed: { type: AIErrorType.AUTHENTICATION, @@ -330,7 +362,7 @@ export class ClaudeCodeProvider extends BaseAIProvider { }, billing_error: { type: AIErrorType.AUTHENTICATION, - message: 'Billing error. Check your Anthropic account billing or subscription status.' + message: 'Billing error. For Claude Pro/Max subscribers using the SDK: run `claude setup-token` and pass the resulting token as `oauthToken` (interactive `claude login` alone is not sufficient for non-interactive SDK calls).' }, rate_limit: { type: AIErrorType.RATE_LIMIT, -- 2.49.1