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.
372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|