feat: add ClaudeCodeProvider for subscription users

Wraps @anthropic-ai/claude-agent-sdk so Claude Pro/Max subscribers (who
do not have a console API key) can use this library by authenticating
through the local `claude` CLI. Subscribers run `claude login` once and
the provider picks up the credentials automatically.

Provider behavior:
- query() is invoked in single-turn mode (maxTurns: 1, allowedTools: [])
  to behave as plain text completion by default. Both knobs are
  configurable for agentic use cases.
- Conversation history is flattened to a single prompt with role labels.
- doStream() emits text deltas as the SDK yields successive assistant
  messages. doComplete() accumulates and returns the final result.
- SDKAssistantMessageError codes (authentication_failed, billing_error,
  rate_limit, model_not_found, ...) map to AIErrorType variants.
- ClaudeCodeConfig.apiKey is optional — when omitted the SDK falls back
  to ANTHROPIC_API_KEY or local subscription credentials. The base
  validator is overridden to allow this.

Tradeoffs:
- Requires the `claude` CLI installed and logged in on the host. This
  is not suitable for typical server-side production deployments; it is
  the official path for subscription accounts.
- Higher latency (CLI process spawn) than the direct Anthropic API.
- The agent SDK is heavy. Bundle grows ~750KB; bun build now uses
  --target node so the SDK's Node built-in imports resolve correctly.

Wired into PROVIDER_REGISTRY ('claude-code'), createClaudeCodeProvider,
SUPPORTED_PROVIDERS, and re-exported from the package entry.
This commit is contained in:
2026-05-21 14:16:11 +02:00
parent 6298a0027d
commit e10ce7f53a
7 changed files with 590 additions and 6 deletions

View File

@@ -0,0 +1,371 @@
/**
* Claude Code Provider
*
* Wraps `@anthropic-ai/claude-agent-sdk` so consumers can use a local
* Claude Code installation — including Claude Pro/Max subscription
* accounts authenticated via `claude login` — through the same
* BaseAIProvider interface as the other providers.
*
* Tradeoffs vs the direct Anthropic API provider:
* - Authenticates via the local CLI, so subscription users (no API key)
* can use it.
* - Requires `claude` to be installed and logged in on the host.
* - Higher latency (shells out to a CLI process per request).
* - Designed for agent workflows, but used here in single-turn mode
* (maxTurns: 1, no tools) for plain text completion.
*
* @see https://docs.anthropic.com/claude/docs/claude-code-sdk
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
import type {
AIMessage,
AIProviderConfig,
CompletionChunk,
CompletionParams,
CompletionResponse,
ProviderInfo
} from '../types/index.js';
import { BaseAIProvider } from './base.js';
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.
*/
export interface ClaudeCodeConfig extends Omit<AIProviderConfig, 'apiKey'> {
apiKey?: string;
/**
* Default model. Accepts SDK aliases (`'sonnet'`, `'opus'`, `'haiku'`,
* `'inherit'`) or any full Claude model ID.
* @default 'sonnet'
*/
defaultModel?: string;
/**
* Working directory for the Claude Code session.
* @default process.cwd()
*/
cwd?: string;
/**
* Tool names the model is allowed to call. Defaults to none, which
* makes the provider behave as a pure text completion endpoint.
*/
allowedTools?: string[];
/**
* Maximum agent turns. Defaults to 1 for plain completion; raise this
* if you also pass `allowedTools` and want tool-use loops.
* @default 1
*/
maxTurns?: number;
}
export class ClaudeCodeProvider extends BaseAIProvider {
private readonly defaultModel: string;
private readonly cwd: string | undefined;
private readonly allowedTools: string[];
private readonly maxTurns: number;
constructor(config: ClaudeCodeConfig) {
super(config as AIProviderConfig);
this.defaultModel = config.defaultModel || DEFAULT_MODELS['claude-code'];
this.cwd = config.cwd;
this.allowedTools = config.allowedTools ?? [];
this.maxTurns = config.maxTurns ?? 1;
}
/**
* apiKey is optional for this provider — the SDK reads
* ANTHROPIC_API_KEY from the environment or falls back to the local
* Claude Code credentials. We override the base validator to skip the
* non-empty apiKey requirement.
*/
protected override validateAndNormalizeConfig(config: AIProviderConfig): AIProviderConfig {
if (!config) {
throw new AIProviderError(
'Configuration object is required',
AIErrorType.INVALID_REQUEST
);
}
if (config.apiKey !== undefined && (typeof config.apiKey !== 'string')) {
throw new AIProviderError(
'API key, when provided, must be a string',
AIErrorType.INVALID_REQUEST
);
}
return {
...config,
apiKey: config.apiKey ?? '',
timeout: config.timeout,
maxRetries: config.maxRetries
};
}
protected async doInitialize(): Promise<void> {
// 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.config.apiKey) {
process.env.ANTHROPIC_API_KEY = this.config.apiKey;
}
}
protected async doComplete(params: CompletionParams): Promise<CompletionResponse<string>> {
const { systemPrompt, prompt } = this.buildPrompt(params.messages);
const model = params.model || this.defaultModel;
let collectedText = '';
let sessionId = '';
let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
for await (const message of query({
prompt,
options: this.buildOptions(model, systemPrompt)
})) {
if (message.type === 'system' && message.subtype === 'init') {
sessionId = message.session_id;
continue;
}
if (message.type === 'assistant') {
if (message.error) {
throw this.errorForSdkAssistantError(message.error);
}
collectedText += extractText(message.message.content);
continue;
}
if (message.type === 'result') {
usage = {
promptTokens: message.usage?.input_tokens ?? 0,
completionTokens: message.usage?.output_tokens ?? 0,
totalTokens: (message.usage?.input_tokens ?? 0) + (message.usage?.output_tokens ?? 0)
};
if (message.subtype !== 'success') {
throw new AIProviderError(
`Claude Code session ended without a successful result: ${message.subtype}`,
AIErrorType.UNKNOWN
);
}
return {
content: collectedText || message.result,
model,
usage,
id: sessionId || message.uuid,
metadata: {
costUsd: message.total_cost_usd,
durationMs: message.duration_ms,
numTurns: message.num_turns,
stopReason: message.stop_reason
}
};
}
}
throw new AIProviderError(
'Claude Code stream ended without a result message',
AIErrorType.UNKNOWN
);
}
protected async *doStream<T = any>(
params: CompletionParams<T>
): AsyncIterable<CompletionChunk> {
const { systemPrompt, prompt } = this.buildPrompt(params.messages);
const model = params.model || this.defaultModel;
let sessionId = '';
let yieldedText = '';
for await (const message of query({
prompt,
options: this.buildOptions(model, systemPrompt)
})) {
if (message.type === 'system' && message.subtype === 'init') {
sessionId = message.session_id;
continue;
}
if (message.type === 'assistant') {
if (message.error) {
throw this.errorForSdkAssistantError(message.error);
}
const fullText = extractText(message.message.content);
const delta = fullText.slice(yieldedText.length);
if (delta) {
yield { content: delta, isComplete: false, id: sessionId };
yieldedText = fullText;
}
continue;
}
if (message.type === 'result') {
const usage = {
promptTokens: message.usage?.input_tokens ?? 0,
completionTokens: message.usage?.output_tokens ?? 0,
totalTokens: (message.usage?.input_tokens ?? 0) + (message.usage?.output_tokens ?? 0)
};
if (message.subtype !== 'success') {
throw new AIProviderError(
`Claude Code session ended without a successful result: ${message.subtype}`,
AIErrorType.UNKNOWN
);
}
yield { content: '', isComplete: true, id: sessionId, usage };
return;
}
}
}
public getInfo(): ProviderInfo {
return {
name: 'Claude Code',
version: '1.0.0',
models: ['sonnet', 'opus', 'haiku', 'inherit'],
maxContextLength: 200000,
supportsStreaming: true,
capabilities: {
vision: true,
functionCalling: true,
systemMessages: true,
reasoning: true,
codeGeneration: true,
subscriptionAuth: true,
requiresLocalCli: true
}
};
}
protected override mapProviderError(error: any): AIProviderError | null {
if (!error) return null;
const message: string = error.message || '';
if (message.includes('ENOENT') && message.toLowerCase().includes('claude')) {
return new AIProviderError(
'Claude Code CLI not found. Install it from https://docs.anthropic.com/claude/docs/claude-code and run `claude login` for subscription accounts.',
AIErrorType.INVALID_REQUEST,
undefined,
error
);
}
if (message.includes('not logged in') || message.includes('authentication')) {
return new AIProviderError(
'Claude Code is not authenticated. Run `claude login` or set ANTHROPIC_API_KEY in the environment.',
AIErrorType.AUTHENTICATION,
undefined,
error
);
}
return null;
}
private buildPrompt(messages: AIMessage[]): { systemPrompt: string | undefined; prompt: string } {
let systemPrompt: string | undefined;
const turns: AIMessage[] = [];
for (const msg of messages) {
if (msg.role === 'system') {
systemPrompt = systemPrompt ? `${systemPrompt}\n\n${msg.content}` : msg.content;
} else {
turns.push(msg);
}
}
if (turns.length === 0) {
throw new AIProviderError(
'At least one user or assistant message is required',
AIErrorType.INVALID_REQUEST
);
}
// Single user turn: pass the content directly. Multi-turn history is
// flattened with role labels — the Agent SDK doesn't accept a
// messages array in stateless mode, so this is the simplest faithful
// representation of the conversation.
if (turns.length === 1 && turns[0]!.role === 'user') {
return { systemPrompt, prompt: turns[0]!.content };
}
const flattened = turns
.map(t => `${t.role === 'assistant' ? 'Assistant' : 'Human'}: ${t.content}`)
.join('\n\n');
return { systemPrompt, prompt: `${flattened}\n\nAssistant:` };
}
private buildOptions(model: string, systemPrompt: string | undefined) {
const options: Record<string, unknown> = {
model,
maxTurns: this.maxTurns,
allowedTools: this.allowedTools
};
if (systemPrompt) options.systemPrompt = systemPrompt;
if (this.cwd) options.cwd = this.cwd;
return options as any;
}
private errorForSdkAssistantError(code: string): AIProviderError {
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.'
},
oauth_org_not_allowed: {
type: AIErrorType.AUTHENTICATION,
message: 'Your organization is not allowed by the Claude Code OAuth policy.'
},
billing_error: {
type: AIErrorType.AUTHENTICATION,
message: 'Billing error. Check your Anthropic account billing or subscription status.'
},
rate_limit: {
type: AIErrorType.RATE_LIMIT,
message: 'Rate limit exceeded.'
},
invalid_request: {
type: AIErrorType.INVALID_REQUEST,
message: 'Invalid request to Claude Code.'
},
model_not_found: {
type: AIErrorType.MODEL_NOT_FOUND,
message: 'Requested model is not available to this Claude Code installation.'
},
server_error: {
type: AIErrorType.NETWORK,
message: 'Claude Code server error. Try again shortly.'
},
max_output_tokens: {
type: AIErrorType.INVALID_REQUEST,
message: 'Response exceeded the configured maxOutputTokens.'
}
};
const entry = map[code] ?? { type: AIErrorType.UNKNOWN, message: `Claude Code error: ${code}` };
return new AIProviderError(entry.message, entry.type);
}
}
function extractText(content: unknown): string {
if (!Array.isArray(content)) return '';
let out = '';
for (const block of content) {
if (block && typeof block === 'object' && (block as any).type === 'text') {
out += (block as any).text ?? '';
}
}
return out;
}