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:
371
src/providers/claude-code.ts
Normal file
371
src/providers/claude-code.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user