fix(claude-code): add oauthToken config for subscription SDK auth #12

Merged
jleibl merged 1 commits from fix/claude-code-oauth-token into main 2026-05-21 12:56:28 +00:00
3 changed files with 125 additions and 13 deletions

View File

@@ -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 ```typescript
import { ClaudeCodeProvider } from 'simple-ai-provider'; import { ClaudeCodeProvider } from 'simple-ai-provider';
const claude = new ClaudeCodeProvider({}); // uses local credentials const claude = new ClaudeCodeProvider({
await claude.initialize(); oauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN // from `claude setup-token`
});
await claude.initialize();
const response = await claude.complete({ const response = await claude.complete({
messages: [{ role: 'user', content: 'Hello!' }] 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 ```typescript
new ClaudeCodeProvider({ new ClaudeCodeProvider({
oauthToken: '...',
defaultModel: 'sonnet', // 'sonnet' | 'opus' | 'haiku' | 'inherit' | full model ID defaultModel: 'sonnet', // 'sonnet' | 'opus' | 'haiku' | 'inherit' | full model ID
maxTurns: 1, // 1 for plain completion; raise for agent/tool loops maxTurns: 1, // 1 for plain completion; raise for agent/tool loops
allowedTools: [], // tool names to enable (default: none) allowedTools: [], // tool names to enable (default: none)

61
examples/claude-code.ts Normal file
View File

@@ -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=<the token it prints> 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);
});

View File

@@ -31,12 +31,31 @@ import { AIProviderError, AIErrorType } from '../types/index.js';
import { DEFAULT_MODELS } from '../constants.js'; import { DEFAULT_MODELS } from '../constants.js';
/** /**
* Configuration for the Claude Code provider. `apiKey` is optional — * Configuration for the Claude Code provider.
* when omitted the SDK falls back to ANTHROPIC_API_KEY in the *
* environment or the local subscription credentials managed by the * Authentication options, in priority order:
* `claude` CLI. *
* 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<AIProviderConfig, 'apiKey'> { export interface ClaudeCodeConfig extends Omit<AIProviderConfig, 'apiKey'> {
/**
* 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; apiKey?: string;
/** /**
@@ -71,6 +90,7 @@ export class ClaudeCodeProvider extends BaseAIProvider {
private readonly cwd: string | undefined; private readonly cwd: string | undefined;
private readonly allowedTools: string[]; private readonly allowedTools: string[];
private readonly maxTurns: number; private readonly maxTurns: number;
private readonly oauthToken: string | undefined;
constructor(config: ClaudeCodeConfig) { constructor(config: ClaudeCodeConfig) {
super(config as AIProviderConfig); super(config as AIProviderConfig);
@@ -79,6 +99,7 @@ export class ClaudeCodeProvider extends BaseAIProvider {
this.cwd = config.cwd; this.cwd = config.cwd;
this.allowedTools = config.allowedTools ?? []; this.allowedTools = config.allowedTools ?? [];
this.maxTurns = config.maxTurns ?? 1; 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( throw new AIProviderError(
'API key, when provided, must be a string', 'API key, when provided, must be a string',
AIErrorType.INVALID_REQUEST 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 { return {
...config, ...config,
apiKey: config.apiKey ?? '', apiKey: config.apiKey ?? '',
@@ -114,6 +143,9 @@ export class ClaudeCodeProvider extends BaseAIProvider {
// The SDK lazy-spawns the CLI per query; no connection probe is // The SDK lazy-spawns the CLI per query; no connection probe is
// necessary (and a probe would cost a real billed turn). We defer // necessary (and a probe would cost a real billed turn). We defer
// auth/model errors to the first complete()/stream() call. // auth/model errors to the first complete()/stream() call.
if (this.oauthToken) {
process.env.CLAUDE_CODE_OAUTH_TOKEN = this.oauthToken;
}
if (this.config.apiKey) { if (this.config.apiKey) {
process.env.ANTHROPIC_API_KEY = this.config.apiKey; process.env.ANTHROPIC_API_KEY = this.config.apiKey;
} }
@@ -322,7 +354,7 @@ export class ClaudeCodeProvider extends BaseAIProvider {
const map: Record<string, { type: AIErrorType; message: string }> = { const map: Record<string, { type: AIErrorType; message: string }> = {
authentication_failed: { authentication_failed: {
type: AIErrorType.AUTHENTICATION, 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: { oauth_org_not_allowed: {
type: AIErrorType.AUTHENTICATION, type: AIErrorType.AUTHENTICATION,
@@ -330,7 +362,7 @@ export class ClaudeCodeProvider extends BaseAIProvider {
}, },
billing_error: { billing_error: {
type: AIErrorType.AUTHENTICATION, 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: { rate_limit: {
type: AIErrorType.RATE_LIMIT, type: AIErrorType.RATE_LIMIT,