Merge pull request 'fix(claude-code): add oauthToken config for subscription SDK auth' (#12) from fix/claude-code-oauth-token into main

Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
2026-05-21 12:56:28 +00:00
committed by Gitea
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
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)

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';
/**
* 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<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;
/**
@@ -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<string, { type: AIErrorType; message: string }> = {
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,