From acf30b0ab5330d708fb4d0f35f68d38098e6c2c1 Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Sun, 5 Apr 2026 20:06:10 +0200 Subject: [PATCH 1/5] feat: add anthropic provider --- config.example.json | 5 + container/shared/provider-context.js | 1 + container/src/browser-tools.ts | 2 + container/src/index.ts | 2 + container/src/providers/anthropic.ts | 599 ++++++++++++++++++++++++ container/src/providers/provider-ids.ts | 1 + container/src/providers/router.ts | 11 + container/src/tools.ts | 2 + container/src/types.ts | 2 + src/auth/anthropic-auth.ts | 261 +++++++++++ src/cli.ts | 2 + src/cli/auth-command.ts | 270 ++++++++++- src/cli/help.ts | 28 +- src/config/config.ts | 12 + src/config/runtime-config.ts | 27 ++ src/doctor/checks/credentials.ts | 1 + src/doctor/checks/providers.ts | 29 ++ src/doctor/provider-probes.ts | 51 ++ src/onboarding.ts | 246 ++++++++-- src/providers/anthropic-utils.ts | 70 +++ src/providers/anthropic.ts | 33 +- src/providers/model-catalog.ts | 11 + src/providers/provider-ids.ts | 3 +- src/providers/task-routing.ts | 7 + src/security/runtime-secrets.ts | 1 + tests/cli.test.ts | 150 ++++++ tests/container.model-router.test.ts | 58 +++ tests/providers.factory.test.ts | 34 +- tests/providers.task-routing.test.ts | 43 +- 29 files changed, 1874 insertions(+), 88 deletions(-) create mode 100644 container/src/providers/anthropic.ts create mode 100644 src/auth/anthropic-auth.ts create mode 100644 src/providers/anthropic-utils.ts diff --git a/config.example.json b/config.example.json index d553cc61..18cd46fc 100644 --- a/config.example.json +++ b/config.example.json @@ -186,6 +186,11 @@ "openai-codex/gpt-5.1-codex-mini" ] }, + "anthropic": { + "enabled": false, + "baseUrl": "https://api.anthropic.com/v1", + "models": ["anthropic/claude-sonnet-4-6"] + }, "openrouter": { "enabled": false, "baseUrl": "https://openrouter.ai/api/v1", diff --git a/container/shared/provider-context.js b/container/shared/provider-context.js index a0a34b5a..ccd63bc8 100644 --- a/container/shared/provider-context.js +++ b/container/shared/provider-context.js @@ -1,6 +1,7 @@ const API_KEY_REQUIRED_PROVIDERS = new Set([ 'hybridai', 'openai-codex', + 'anthropic', 'openrouter', 'mistral', 'huggingface', diff --git a/container/src/browser-tools.ts b/container/src/browser-tools.ts index 766402c3..bbcd6c60 100644 --- a/container/src/browser-tools.ts +++ b/container/src/browser-tools.ts @@ -170,6 +170,7 @@ type BrowserModelContext = { provider: | 'hybridai' | 'openai-codex' + | 'anthropic' | 'openrouter' | 'mistral' | 'huggingface' @@ -238,6 +239,7 @@ export function setBrowserModelContext( provider: | 'hybridai' | 'openai-codex' + | 'anthropic' | 'openrouter' | 'mistral' | 'huggingface' diff --git a/container/src/index.ts b/container/src/index.ts index 7f7b7c86..bf0af778 100644 --- a/container/src/index.ts +++ b/container/src/index.ts @@ -690,6 +690,7 @@ async function callHybridAIWithRetry(params: { provider?: | 'hybridai' | 'openai-codex' + | 'anthropic' | 'openrouter' | 'mistral' | 'huggingface' @@ -839,6 +840,7 @@ async function processRequest( provider: | 'hybridai' | 'openai-codex' + | 'anthropic' | 'openrouter' | 'mistral' | 'huggingface' diff --git a/container/src/providers/anthropic.ts b/container/src/providers/anthropic.ts new file mode 100644 index 00000000..62dd4688 --- /dev/null +++ b/container/src/providers/anthropic.ts @@ -0,0 +1,599 @@ +import { TextDecoder } from 'node:util'; +import { collapseSystemMessages } from '../system-messages.js'; +import type { + ChatCompletionResponse, + ChatContentPart, + ChatMessage, + ToolCall, + ToolDefinition, +} from '../types.js'; +import { + HybridAIRequestError, + isRecord, + type NormalizedCallArgs, + type NormalizedStreamCallArgs, +} from './shared.js'; + +interface ServerSentEvent { + event: string | null; + data: string; +} + +interface AnthropicTextStreamBlock { + type: 'text'; + index: number; + text: string; +} + +interface AnthropicToolUseStreamBlock { + type: 'tool_use'; + index: number; + id: string; + name: string; + inputJson: string; +} + +type AnthropicStreamBlock = + | AnthropicTextStreamBlock + | AnthropicToolUseStreamBlock; + +function normalizeAnthropicModelName(model: string): string { + const trimmed = String(model || '').trim(); + if (!trimmed.toLowerCase().startsWith('anthropic/')) return trimmed; + return trimmed.slice('anthropic/'.length) || trimmed; +} + +function normalizeBaseUrl(baseUrl: string): string { + return String(baseUrl || '') + .trim() + .replace(/\/+$/g, ''); +} + +function isAnthropicOAuthToken(apiKey: string): boolean { + return String(apiKey || '').includes('sk-ant-oat'); +} + +function buildHeaders(args: { + apiKey: string; + requestHeaders?: Record; + stream?: boolean; +}): Record { + return { + 'Content-Type': 'application/json', + ...(isAnthropicOAuthToken(args.apiKey) + ? { Authorization: `Bearer ${args.apiKey}` } + : { 'x-api-key': args.apiKey }), + ...(args.stream ? { Accept: 'text/event-stream' } : {}), + ...(args.requestHeaders || {}), + }; +} + +function normalizeMessageText(content: ChatMessage['content']): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join('\n'); +} + +function parseDataUrlImage( + url: string, +): { mediaType: string; data: string } | null { + const match = String(url || '').match(/^data:([^;]+);base64,(.+)$/i); + if (!match) return null; + return { + mediaType: match[1] || 'image/png', + data: match[2] || '', + }; +} + +function convertContentPart( + part: ChatContentPart, +): Record | null { + if (part.type === 'text') { + const text = part.text.trim(); + return text ? { type: 'text', text } : null; + } + if (part.type === 'image_url') { + const parsed = parseDataUrlImage(part.image_url.url); + if (!parsed) return null; + return { + type: 'image', + source: { + type: 'base64', + media_type: parsed.mediaType, + data: parsed.data, + }, + }; + } + return null; +} + +function parseToolArguments(raw: string): Record { + const trimmed = String(raw || '').trim(); + if (!trimmed) return {}; + try { + const parsed = JSON.parse(trimmed) as unknown; + return isRecord(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function convertMessageContent( + message: ChatMessage, +): string | Array> | null { + if (!Array.isArray(message.content)) { + const text = normalizeMessageText(message.content).trim(); + return text || null; + } + + const blocks = message.content + .map(convertContentPart) + .filter((part): part is Record => part !== null); + return blocks.length > 0 ? blocks : null; +} + +function convertMessages( + messages: ChatMessage[], +): Array> { + const normalized = collapseSystemMessages(messages); + const converted: Array> = []; + + for (const message of normalized) { + if (message.role === 'system') continue; + + if (message.role === 'tool') { + converted.push({ + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: message.tool_call_id || '', + content: normalizeMessageText(message.content), + }, + ], + }); + continue; + } + + const blocks: Array> = []; + const content = convertMessageContent(message); + if (typeof content === 'string' && content.trim()) { + blocks.push({ + type: 'text', + text: content, + }); + } else if (Array.isArray(content)) { + blocks.push(...content); + } + + if (message.role === 'assistant' && Array.isArray(message.tool_calls)) { + for (const toolCall of message.tool_calls) { + blocks.push({ + type: 'tool_use', + id: toolCall.id, + name: toolCall.function.name, + input: parseToolArguments(toolCall.function.arguments), + }); + } + } + + if (blocks.length === 0) continue; + converted.push({ + role: message.role, + content: blocks, + }); + } + + return converted; +} + +function extractSystemPrompt(messages: ChatMessage[]): string | undefined { + const normalized = collapseSystemMessages(messages); + const system = normalized[0]; + if (system?.role !== 'system') return undefined; + const text = normalizeMessageText(system.content).trim(); + return text || undefined; +} + +function convertTools( + tools: ToolDefinition[], +): Array> | undefined { + if (!Array.isArray(tools) || tools.length === 0) return undefined; + return tools.map((tool) => ({ + name: tool.function.name, + description: tool.function.description, + input_schema: tool.function.parameters, + })); +} + +function buildRequestBody( + args: NormalizedCallArgs, + stream: boolean, +): Record { + const request: Record = { + model: normalizeAnthropicModelName(args.model), + max_tokens: + typeof args.maxTokens === 'number' && args.maxTokens > 0 + ? Math.floor(args.maxTokens) + : 4096, + messages: convertMessages(args.messages), + stream, + }; + + const system = extractSystemPrompt(args.messages); + if (system) { + request.system = system; + } + + const tools = convertTools(args.tools); + if (tools) { + request.tools = tools; + request.tool_choice = { type: 'auto' }; + } + + return request; +} + +function mapStopReason(stopReason: string | null | undefined): string { + if (stopReason === 'tool_use') return 'tool_calls'; + if (stopReason === 'max_tokens') return 'length'; + if (stopReason === 'end_turn' || stopReason === 'pause_turn') return 'stop'; + return stopReason || 'stop'; +} + +function parseUsage(value: unknown): ChatCompletionResponse['usage'] | undefined { + if (!isRecord(value)) return undefined; + + const inputTokens = + typeof value.input_tokens === 'number' ? value.input_tokens : undefined; + const outputTokens = + typeof value.output_tokens === 'number' ? value.output_tokens : undefined; + const cacheRead = + typeof value.cache_read_input_tokens === 'number' + ? value.cache_read_input_tokens + : undefined; + const cacheWrite = + typeof value.cache_creation_input_tokens === 'number' + ? value.cache_creation_input_tokens + : undefined; + const totalTokens = + [inputTokens, outputTokens, cacheRead, cacheWrite].filter( + (token): token is number => typeof token === 'number', + ).length > 0 + ? (inputTokens || 0) + + (outputTokens || 0) + + (cacheRead || 0) + + (cacheWrite || 0) + : undefined; + + if ( + inputTokens === undefined && + outputTokens === undefined && + cacheRead === undefined && + cacheWrite === undefined && + totalTokens === undefined + ) { + return undefined; + } + + return { + prompt_tokens: inputTokens, + completion_tokens: outputTokens, + total_tokens: totalTokens, + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_read_input_tokens: cacheRead, + cache_creation_input_tokens: cacheWrite, + cache_read_tokens: cacheRead, + cache_write_tokens: cacheWrite, + cacheRead, + cacheWrite, + ...(cacheRead !== undefined + ? { prompt_tokens_details: { cached_tokens: cacheRead } } + : {}), + }; +} + +function adaptAnthropicResponse( + payload: unknown, + fallbackModel: string, +): ChatCompletionResponse { + const record = payload as Record; + const content = Array.isArray(record.content) ? record.content : []; + const textParts: Array<{ type: 'text'; text: string }> = []; + const toolCalls: ToolCall[] = []; + + for (const block of content) { + if (!isRecord(block)) continue; + if (block.type === 'text' && typeof block.text === 'string') { + if (block.text) textParts.push({ type: 'text', text: block.text }); + continue; + } + if (block.type === 'tool_use') { + toolCalls.push({ + id: typeof block.id === 'string' ? block.id : '', + type: 'function', + function: { + name: typeof block.name === 'string' ? block.name : '', + arguments: JSON.stringify(block.input || {}), + }, + }); + } + } + + const usage = parseUsage(record.usage); + const contentValue = + textParts.length === 0 + ? null + : textParts.length === 1 + ? textParts[0].text + : textParts; + + return { + id: typeof record.id === 'string' ? record.id : 'message', + model: + typeof record.model === 'string' && record.model + ? record.model + : normalizeAnthropicModelName(fallbackModel), + choices: [ + { + message: { + role: typeof record.role === 'string' ? record.role : 'assistant', + content: contentValue, + ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), + }, + finish_reason: mapStopReason( + typeof record.stop_reason === 'string' ? record.stop_reason : null, + ), + }, + ], + ...(usage ? { usage } : {}), + }; +} + +function parseServerSentEventBlock(block: string): ServerSentEvent | null { + const lines = block.split(/\r?\n/); + const dataLines: string[] = []; + let event: string | null = null; + + for (const rawLine of lines) { + if (!rawLine || rawLine.startsWith(':')) continue; + if (rawLine.startsWith('event:')) { + event = rawLine.slice('event:'.length).trim() || null; + continue; + } + if (rawLine.startsWith('data:')) { + dataLines.push(rawLine.slice('data:'.length).trimStart()); + } + } + + if (dataLines.length === 0) return null; + return { + event, + data: dataLines.join('\n'), + }; +} + +function findStreamBlock( + blocks: AnthropicStreamBlock[], + index: number, +): AnthropicStreamBlock | undefined { + return blocks.find((block) => block.index === index); +} + +function parseJsonObject(value: string): Record { + const trimmed = String(value || '').trim(); + if (!trimmed) return {}; + try { + const parsed = JSON.parse(trimmed) as unknown; + return isRecord(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function buildStreamResponse(params: { + id: string; + model: string; + blocks: AnthropicStreamBlock[]; + finishReason: string; + usage?: ChatCompletionResponse['usage']; +}): ChatCompletionResponse { + return adaptAnthropicResponse( + { + id: params.id, + model: params.model, + role: 'assistant', + stop_reason: params.finishReason, + usage: params.usage, + content: params.blocks + .sort((left, right) => left.index - right.index) + .map((block) => + block.type === 'text' + ? { type: 'text', text: block.text } + : { + type: 'tool_use', + id: block.id, + name: block.name, + input: parseJsonObject(block.inputJson), + }, + ), + }, + params.model, + ); +} + +async function readErrorBody(response: Response): Promise { + try { + return await response.text(); + } catch { + return ''; + } +} + +export async function callAnthropicProvider( + args: NormalizedCallArgs, +): Promise { + const response = await fetch(`${normalizeBaseUrl(args.baseUrl)}/messages`, { + method: 'POST', + headers: buildHeaders(args), + body: JSON.stringify(buildRequestBody(args, false)), + }); + + if (!response.ok) { + throw new HybridAIRequestError(response.status, await readErrorBody(response)); + } + + return adaptAnthropicResponse(await response.json(), args.model); +} + +export async function callAnthropicProviderStream( + args: NormalizedStreamCallArgs, +): Promise { + const response = await fetch(`${normalizeBaseUrl(args.baseUrl)}/messages`, { + method: 'POST', + headers: buildHeaders({ ...args, stream: true }), + body: JSON.stringify(buildRequestBody(args, true)), + }); + + if (!response.ok) { + throw new HybridAIRequestError(response.status, await readErrorBody(response)); + } + if (!response.body) { + throw new Error('Anthropic stream response body is missing.'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const blocks: AnthropicStreamBlock[] = []; + let usage: ChatCompletionResponse['usage'] | undefined; + let responseId = 'message'; + let responseModel = normalizeAnthropicModelName(args.model); + let finishReason = 'stop'; + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); + + while (true) { + const boundary = buffer.indexOf('\n\n'); + if (boundary === -1) break; + const rawBlock = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + + const sse = parseServerSentEventBlock(rawBlock); + if (!sse || !sse.data || sse.data === '[DONE]') continue; + + const event = JSON.parse(sse.data) as Record; + args.onActivity?.(); + + if (event.type === 'error') { + const error = + isRecord(event.error) && typeof event.error.message === 'string' + ? event.error.message + : sse.data; + throw new Error(error); + } + + if (event.type === 'message_start' && isRecord(event.message)) { + if (typeof event.message.id === 'string' && event.message.id) { + responseId = event.message.id; + } + if (typeof event.message.model === 'string' && event.message.model) { + responseModel = event.message.model; + } + usage = parseUsage(event.message.usage) || usage; + continue; + } + + if (event.type === 'content_block_start') { + const index = typeof event.index === 'number' ? event.index : -1; + if (!isRecord(event.content_block)) continue; + if (event.content_block.type === 'text') { + blocks.push({ + type: 'text', + index, + text: + typeof event.content_block.text === 'string' + ? event.content_block.text + : '', + }); + continue; + } + if (event.content_block.type === 'tool_use') { + const input = + isRecord(event.content_block.input) && + Object.keys(event.content_block.input).length > 0 + ? JSON.stringify(event.content_block.input) + : ''; + blocks.push({ + type: 'tool_use', + index, + id: + typeof event.content_block.id === 'string' + ? event.content_block.id + : '', + name: + typeof event.content_block.name === 'string' + ? event.content_block.name + : '', + inputJson: input, + }); + } + continue; + } + + if (event.type === 'content_block_delta') { + const index = typeof event.index === 'number' ? event.index : -1; + const block = findStreamBlock(blocks, index); + if (!block || !isRecord(event.delta)) continue; + + if ( + block.type === 'text' && + event.delta.type === 'text_delta' && + typeof event.delta.text === 'string' + ) { + block.text += event.delta.text; + if (event.delta.text) { + args.onTextDelta(event.delta.text); + } + continue; + } + + if ( + block.type === 'tool_use' && + event.delta.type === 'input_json_delta' && + typeof event.delta.partial_json === 'string' + ) { + block.inputJson += event.delta.partial_json; + } + continue; + } + + if (event.type === 'message_delta') { + usage = parseUsage(event.usage) || usage; + if ( + isRecord(event.delta) && + typeof event.delta.stop_reason === 'string' && + event.delta.stop_reason + ) { + finishReason = event.delta.stop_reason; + } + } + } + + if (done) break; + } + + return buildStreamResponse({ + id: responseId, + model: responseModel, + blocks, + finishReason, + usage, + }); +} diff --git a/container/src/providers/provider-ids.ts b/container/src/providers/provider-ids.ts index 49bdf9e2..e04e3676 100644 --- a/container/src/providers/provider-ids.ts +++ b/container/src/providers/provider-ids.ts @@ -1,6 +1,7 @@ export const RUNTIME_PROVIDER_IDS = [ 'hybridai', 'openai-codex', + 'anthropic', 'openrouter', 'mistral', 'huggingface', diff --git a/container/src/providers/router.ts b/container/src/providers/router.ts index cbc4dcc3..46cc3e0a 100644 --- a/container/src/providers/router.ts +++ b/container/src/providers/router.ts @@ -8,6 +8,10 @@ import type { export { extractResponseTextContent } from '../../shared/response-text.js'; +import { + callAnthropicProvider, + callAnthropicProviderStream, +} from './anthropic.js'; import { callHybridAIProvider, callHybridAIProviderStream, @@ -98,6 +102,9 @@ function buildStreamCallArgs( export async function callProviderModel( args: NormalizedCallArgs, ): Promise { + if (args.provider === 'anthropic') { + return callAnthropicProvider(args); + } if (args.provider === 'openai-codex') { return callOpenAICodexProvider(args); } @@ -113,6 +120,9 @@ export async function callProviderModel( export async function callProviderModelStream( args: NormalizedStreamCallArgs, ): Promise { + if (args.provider === 'anthropic') { + return callAnthropicProviderStream(args); + } if (args.provider === 'openai-codex') { return callOpenAICodexProviderStream(args); } @@ -157,6 +167,7 @@ function shouldStreamVisionRequest( return ( provider === undefined || provider === 'hybridai' || + provider === 'anthropic' || provider === 'openai-codex' ); } diff --git a/container/src/tools.ts b/container/src/tools.ts index 8280ab76..6590b7e7 100644 --- a/container/src/tools.ts +++ b/container/src/tools.ts @@ -100,6 +100,7 @@ let gatewayChannelId = ''; let currentModelProvider: | 'hybridai' | 'openai-codex' + | 'anthropic' | 'openrouter' | 'mistral' | 'huggingface' @@ -492,6 +493,7 @@ export function setModelContext( provider: | 'hybridai' | 'openai-codex' + | 'anthropic' | 'openrouter' | 'mistral' | 'huggingface' diff --git a/container/src/types.ts b/container/src/types.ts index 75947de9..b4009af4 100644 --- a/container/src/types.ts +++ b/container/src/types.ts @@ -115,6 +115,7 @@ export interface TaskModelPolicy { provider?: | 'hybridai' | 'openai-codex' + | 'anthropic' | 'openrouter' | 'mistral' | 'huggingface' @@ -201,6 +202,7 @@ export interface ContainerInput { provider?: | 'hybridai' | 'openai-codex' + | 'anthropic' | 'openrouter' | 'mistral' | 'huggingface' diff --git a/src/auth/anthropic-auth.ts b/src/auth/anthropic-auth.ts new file mode 100644 index 00000000..5fb8e8fe --- /dev/null +++ b/src/auth/anthropic-auth.ts @@ -0,0 +1,261 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { + buildAnthropicRequestHeaders, + isAnthropicOAuthToken, +} from '../providers/anthropic-utils.js'; +import { + readStoredRuntimeSecret, + runtimeSecretsPath, +} from '../security/runtime-secrets.js'; + +const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = '.claude/.credentials.json'; +const CLAUDE_CLI_KEYCHAIN_SERVICE = 'Claude Code-credentials'; +const CLAUDE_CLI_KEYCHAIN_ACCOUNT = 'Claude Code'; + +type CliSource = 'claude-cli-keychain' | 'claude-cli-file'; +type ApiKeySource = 'env' | 'runtime-secrets'; + +export type ClaudeCliCredential = + | { + type: 'oauth'; + provider: 'anthropic'; + accessToken: string; + refreshToken: string; + expiresAt: number; + source: CliSource; + } + | { + type: 'token'; + provider: 'anthropic'; + token: string; + expiresAt: number; + source: CliSource; + }; + +export interface AnthropicResolvedAuth { + method: 'api-key' | 'cli'; + source: ApiKeySource | CliSource; + apiKey: string; + headers: Record; + path: string; + expiresAt: number | null; +} + +export interface AnthropicAuthStatus { + authenticated: boolean; + method: 'api-key' | 'cli' | null; + source: ApiKeySource | CliSource | null; + path: string; + maskedValue: string | null; + expiresAt: number | null; + isOauthToken: boolean; +} + +export function claudeCliCredentialsPath(): string { + return path.join(homedir(), CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function normalizeString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeTimestamp(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + const numeric = Number(value); + if (Number.isFinite(numeric)) return numeric; + } + return 0; +} + +function maskValue(value: string): string { + const normalized = value.trim(); + if (!normalized) return ''; + if (normalized.length <= 12) return `${normalized.slice(0, 4)}...`; + return `${normalized.slice(0, 6)}...${normalized.slice(-4)}`; +} + +function parseClaudeCliCredential( + value: unknown, + source: CliSource, +): ClaudeCliCredential | null { + if (!isRecord(value)) return null; + const accessToken = normalizeString(value.accessToken); + const refreshToken = normalizeString(value.refreshToken); + const expiresAt = normalizeTimestamp(value.expiresAt); + if (!accessToken || expiresAt <= 0) return null; + if (refreshToken) { + return { + type: 'oauth', + provider: 'anthropic', + accessToken, + refreshToken, + expiresAt, + source, + }; + } + return { + type: 'token', + provider: 'anthropic', + token: accessToken, + expiresAt, + source, + }; +} + +function readClaudeCliKeychainCredentials(): ClaudeCliCredential | null { + if (process.platform !== 'darwin') return null; + try { + const raw = execFileSync( + 'security', + [ + 'find-generic-password', + '-s', + CLAUDE_CLI_KEYCHAIN_SERVICE, + '-a', + CLAUDE_CLI_KEYCHAIN_ACCOUNT, + '-w', + ], + { + encoding: 'utf8', + timeout: 5_000, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ).trim(); + const parsed = JSON.parse(raw) as Record; + return parseClaudeCliCredential( + parsed.claudeAiOauth, + 'claude-cli-keychain', + ); + } catch { + return null; + } +} + +export function readClaudeCliCredentials(): ClaudeCliCredential | null { + const keychain = readClaudeCliKeychainCredentials(); + if (keychain) return keychain; + + try { + const raw = JSON.parse( + fs.readFileSync(claudeCliCredentialsPath(), 'utf-8'), + ) as Record; + return parseClaudeCliCredential(raw.claudeAiOauth, 'claude-cli-file'); + } catch { + return null; + } +} + +function resolveStoredAnthropicApiKey(): { + apiKey: string; + source: ApiKeySource | null; +} { + const envApiKey = process.env.ANTHROPIC_API_KEY?.trim() || ''; + const storedApiKey = readStoredRuntimeSecret('ANTHROPIC_API_KEY') || ''; + if (envApiKey) { + return { + apiKey: envApiKey, + source: + storedApiKey && storedApiKey === envApiKey ? 'runtime-secrets' : 'env', + }; + } + if (storedApiKey) { + return { + apiKey: storedApiKey, + source: 'runtime-secrets', + }; + } + return { + apiKey: '', + source: null, + }; +} + +export function getAnthropicAuthStatus(): AnthropicAuthStatus { + const storedApiKey = resolveStoredAnthropicApiKey(); + if (storedApiKey.apiKey) { + return { + authenticated: true, + method: 'api-key', + source: storedApiKey.source, + path: runtimeSecretsPath(), + maskedValue: maskValue(storedApiKey.apiKey), + expiresAt: null, + isOauthToken: isAnthropicOAuthToken(storedApiKey.apiKey), + }; + } + + const credential = readClaudeCliCredentials(); + const expiresAt = credential?.expiresAt ?? null; + const authenticated = + Boolean(credential) && + Boolean(expiresAt && Number.isFinite(expiresAt) && expiresAt > Date.now()); + const token = + credential?.type === 'oauth' + ? credential.accessToken + : credential?.token || ''; + + return { + authenticated, + method: credential ? 'cli' : null, + source: credential?.source || null, + path: claudeCliCredentialsPath(), + maskedValue: token ? maskValue(token) : null, + expiresAt, + isOauthToken: token ? isAnthropicOAuthToken(token) : false, + }; +} + +export function requireAnthropicCliCredentials(): AnthropicResolvedAuth { + const credential = readClaudeCliCredentials(); + if (!credential) { + throw new Error( + [ + 'Claude CLI is not authenticated on this host.', + 'Run `claude auth login`, then rerun `hybridclaw auth login anthropic --method cli --set-default`.', + ].join('\n'), + ); + } + if (credential.expiresAt <= Date.now()) { + throw new Error( + [ + 'Claude CLI credentials on this host are expired.', + 'Run `claude auth login` to refresh them, then rerun the HybridClaw auth command.', + ].join('\n'), + ); + } + const apiKey = + credential.type === 'oauth' ? credential.accessToken : credential.token; + return { + method: 'cli', + source: credential.source, + apiKey, + headers: buildAnthropicRequestHeaders({ apiKey }), + path: claudeCliCredentialsPath(), + expiresAt: credential.expiresAt, + }; +} + +export function resolveAnthropicAuth(): AnthropicResolvedAuth { + const storedApiKey = resolveStoredAnthropicApiKey(); + if (storedApiKey.apiKey) { + return { + method: 'api-key', + source: storedApiKey.source || 'runtime-secrets', + apiKey: storedApiKey.apiKey, + headers: buildAnthropicRequestHeaders({ apiKey: storedApiKey.apiKey }), + path: runtimeSecretsPath(), + expiresAt: null, + }; + } + return requireAnthropicCliCredentials(); +} diff --git a/src/cli.ts b/src/cli.ts index 4dac257d..fbb2e166 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1642,12 +1642,14 @@ function isWhatsAppAuthLockError(err: unknown): err is Error { function printMissingEnvVarError(message: string, envVar?: string): void { const envVarMessage: Record = { HYBRIDAI_API_KEY: 'HybridAI provider is not configured.', + ANTHROPIC_API_KEY: 'Anthropic provider is not configured.', OPENROUTER_API_KEY: 'OpenRouter provider is not configured.', MISTRAL_API_KEY: 'Mistral provider is not configured.', HF_TOKEN: 'Hugging Face provider is not configured.', }; const envVarHint: Record = { HYBRIDAI_API_KEY: `Run \`hybridclaw auth login hybridai\`, or set HYBRIDAI_API_KEY in ${runtimeSecretsPath()} or your shell, then run the command again.`, + ANTHROPIC_API_KEY: `Run \`claude auth login\` and then \`hybridclaw auth login anthropic --method cli\`, or set ANTHROPIC_API_KEY in ${runtimeSecretsPath()} or your shell, then run the command again.`, OPENROUTER_API_KEY: `Run \`hybridclaw auth login openrouter\`, or set OPENROUTER_API_KEY in ${runtimeSecretsPath()} or your shell, then run the command again.`, MISTRAL_API_KEY: `Run \`hybridclaw auth login mistral\`, or set MISTRAL_API_KEY in ${runtimeSecretsPath()} or your shell, then run the command again.`, HF_TOKEN: `Run \`hybridclaw auth login huggingface\`, or set HF_TOKEN in ${runtimeSecretsPath()} or your shell, then run the command again.`, diff --git a/src/cli/auth-command.ts b/src/cli/auth-command.ts index edbc5de2..5d1b3602 100644 --- a/src/cli/auth-command.ts +++ b/src/cli/auth-command.ts @@ -8,6 +8,11 @@ import { updateRuntimeConfig, } from '../config/runtime-config.js'; import { resolveModelProvider } from '../providers/factory.js'; +import { + ANTHROPIC_DEFAULT_MODEL, + normalizeAnthropicBaseUrl, + normalizeAnthropicModelName, +} from '../providers/anthropic-utils.js'; import type { LocalBackendType } from '../providers/local-types.js'; import { formatModelForDisplay } from '../providers/model-names.js'; import { @@ -24,6 +29,7 @@ import { promptForSecretInput } from '../utils/secret-prompt.js'; import { makeLazyApi, normalizeArgs, parseValueFlag } from './common.js'; import { isHelpRequest, + printAnthropicUsage, printAuthUsage, printCodexUsage, printHuggingFaceUsage, @@ -39,6 +45,7 @@ import { ensureWhatsAppAuthApi, getWhatsAppAuthApi } from './whatsapp-api.js'; type HybridAIAuthApi = typeof import('../auth/hybridai-auth.js'); type CodexAuthApi = typeof import('../auth/codex-auth.js'); +type AnthropicAuthApi = typeof import('../auth/anthropic-auth.js'); const hybridAIAuthApiState = makeLazyApi( () => import('../auth/hybridai-auth.js'), @@ -49,6 +56,10 @@ const codexAuthApiState = makeLazyApi( 'Codex auth API accessed before it was initialized. Call ensureCodexAuthApi() first.', ); const CONFIGURED_SECRET_STATUS = 'configured'; +const anthropicAuthApiState = makeLazyApi( + () => import('../auth/anthropic-auth.js'), + 'Anthropic auth API accessed before it was initialized. Call ensureAnthropicAuthApi() first.', +); async function ensureHybridAIAuthApi(): Promise { return hybridAIAuthApiState.ensure(); @@ -66,6 +77,14 @@ function getCodexAuthApi(): CodexAuthApi { return codexAuthApiState.get(); } +async function ensureAnthropicAuthApi(): Promise { + return anthropicAuthApiState.ensure(); +} + +function getAnthropicAuthApi(): AnthropicAuthApi { + return anthropicAuthApiState.get(); +} + function parseExclusiveLoginMethodFlag( args: string[], params: { @@ -223,6 +242,93 @@ function parseHuggingFaceLoginArgs(args: string[]): ParsedOpenRouterLoginArgs { return parseOpenRouterLoginArgs(args); } +interface ParsedAnthropicLoginArgs { + modelId?: string; + baseUrl?: string; + apiKey?: string; + method: 'cli' | 'api-key'; + setDefault: boolean; +} + +function parseAnthropicLoginArgs(args: string[]): ParsedAnthropicLoginArgs { + const positional: string[] = []; + const { baseUrl, remaining } = extractBaseUrlArg(args); + let apiKey: string | undefined; + let method: 'cli' | 'api-key' | undefined; + let setDefault = true; + + for (let index = 0; index < remaining.length; index += 1) { + const arg = remaining[index] || ''; + if (arg === '--no-default') { + setDefault = false; + continue; + } + if (arg === '--set-default') { + setDefault = true; + continue; + } + const methodFlag = parseValueFlag({ + arg, + args: remaining, + index, + name: '--method', + placeholder: '', + allowEmptyEquals: true, + }); + if (methodFlag) { + const normalizedMethod = methodFlag.value.trim().toLowerCase(); + if (normalizedMethod === 'cli') { + method = 'cli'; + } else if ( + normalizedMethod === 'api-key' || + normalizedMethod === 'apikey' || + normalizedMethod === 'token' + ) { + method = 'api-key'; + } else { + throw new Error( + `Unknown Anthropic auth method "${methodFlag.value}". Use \`cli\` or \`api-key\`.`, + ); + } + index = methodFlag.nextIndex; + continue; + } + const apiKeyFlag = parseValueFlag({ + arg, + args: remaining, + index, + name: '--api-key', + placeholder: '', + allowEmptyEquals: true, + }); + if (apiKeyFlag) { + apiKey = apiKeyFlag.value; + method ||= 'api-key'; + index = apiKeyFlag.nextIndex; + continue; + } + if (arg.startsWith('-')) { + throw new Error(`Unknown flag: ${arg}`); + } + positional.push(arg); + } + + const resolvedMethod = method || 'cli'; + if (resolvedMethod === 'cli' && apiKey !== undefined) { + throw new Error( + '`--api-key` cannot be used with `--method cli`. Use `--method api-key` or omit `--method` when passing an API key.', + ); + } + + return { + modelId: positional.length > 0 ? positional.join(' ') : undefined, + baseUrl, + apiKey, + method: resolvedMethod, + setDefault, + }; +} + function normalizeProviderModelId(prefix: string, rawModelId: string): string { const trimmed = rawModelId.trim(); if (!trimmed) return ''; @@ -352,6 +458,29 @@ async function resolveHuggingFaceApiKey( ); } +async function promptForAnthropicApiKey(): Promise { + return await promptForSecretInput({ + prompt: '🔒 Paste Anthropic API key: ', + missingMessage: + 'Missing Anthropic API key. Pass `--api-key `, set `ANTHROPIC_API_KEY`, or run this command in an interactive terminal to paste it.', + }); +} + +async function resolveAnthropicApiKey( + explicitApiKey: string | undefined, +): Promise { + const configuredApiKey = + explicitApiKey?.trim() || process.env.ANTHROPIC_API_KEY?.trim() || ''; + if (configuredApiKey) return configuredApiKey; + + const promptedApiKey = await promptForAnthropicApiKey(); + if (promptedApiKey) return promptedApiKey; + + throw new Error( + 'Anthropic API key cannot be empty. Pass `--api-key `, set `ANTHROPIC_API_KEY`, or paste it when prompted.', + ); +} + interface RouterProviderConfigFlowOptions { args: string[]; providerId: 'openrouter' | 'mistral' | 'huggingface'; @@ -489,9 +618,79 @@ async function configureHuggingFace(args: string[]): Promise { }); } +async function configureAnthropic(args: string[]): Promise { + ensureRuntimeConfigFile(); + await ensureAnthropicAuthApi(); + + const parsed = parseAnthropicLoginArgs(args); + const currentProviderConfig = getRuntimeConfig().anthropic; + const configuredModel = + parsed.modelId || + currentProviderConfig.models[0] || + ANTHROPIC_DEFAULT_MODEL; + const fullModelName = normalizeAnthropicModelName(configuredModel); + if (!fullModelName) { + throw new Error('Anthropic model ID cannot be empty.'); + } + + const normalizedBaseUrl = normalizeAnthropicBaseUrl( + parsed.baseUrl || currentProviderConfig.baseUrl, + ); + let savedSecretsPath: string | null = null; + let cliCredentialPath: string | null = null; + let expiresAt: number | null = null; + + if (parsed.method === 'cli') { + const auth = getAnthropicAuthApi().requireAnthropicCliCredentials(); + cliCredentialPath = auth.path; + expiresAt = auth.expiresAt; + } else { + const apiKey = await resolveAnthropicApiKey(parsed.apiKey); + savedSecretsPath = saveRuntimeSecrets({ ANTHROPIC_API_KEY: apiKey }); + process.env.ANTHROPIC_API_KEY = apiKey; + } + + const nextConfig = updateRuntimeConfig((draft) => { + draft.anthropic.enabled = true; + draft.anthropic.baseUrl = normalizedBaseUrl; + draft.anthropic.models = Array.from( + new Set([fullModelName, ...draft.anthropic.models]), + ); + if (parsed.setDefault) { + draft.hybridai.defaultModel = fullModelName; + } + }); + + if (savedSecretsPath) { + console.log(`Saved Anthropic credentials to ${savedSecretsPath}.`); + } + if (cliCredentialPath) { + console.log(`Using Claude Code credentials from ${cliCredentialPath}.`); + } + console.log(`Updated runtime config at ${runtimeConfigPath()}.`); + console.log('Provider: anthropic'); + console.log(`Auth method: ${parsed.method}`); + if (expiresAt) { + console.log(`Expires: ${new Date(expiresAt).toISOString()}`); + } + console.log(`Base URL: ${normalizedBaseUrl}`); + console.log(`Configured model: ${fullModelName}`); + if (parsed.setDefault) { + console.log(`Default model: ${fullModelName}`); + } else { + console.log( + `Default model unchanged: ${formatModelForDisplay(nextConfig.hybridai.defaultModel)}`, + ); + } + console.log('Next:'); + console.log(' hybridclaw tui'); + console.log(` /model set ${fullModelName}`); +} + type UnifiedProvider = | 'hybridai' | 'codex' + | 'anthropic' | 'openrouter' | 'mistral' | 'huggingface' @@ -515,6 +714,9 @@ function normalizeUnifiedProvider( if (normalized === 'codex' || normalized === 'openai-codex') { return 'codex'; } + if (normalized === 'anthropic' || normalized === 'claude') { + return 'anthropic'; + } if (normalized === 'openrouter' || normalized === 'or') { return 'openrouter'; } @@ -562,7 +764,7 @@ function parseUnifiedProviderArgs(args: string[]): { const provider = normalizeUnifiedProvider(rawProvider); if (!provider) { throw new Error( - `Unknown provider "${rawProvider}". Use \`hybridai\`, \`codex\`, \`openrouter\`, \`mistral\`, \`huggingface\`, \`local\`, or \`msteams\`.`, + `Unknown provider "${rawProvider}". Use \`hybridai\`, \`codex\`, \`anthropic\`, \`openrouter\`, \`mistral\`, \`huggingface\`, \`local\`, or \`msteams\`.`, ); } return { @@ -576,7 +778,7 @@ function parseUnifiedProviderArgs(args: string[]): { const provider = normalizeUnifiedProvider(rawProvider); if (!provider) { throw new Error( - `Unknown provider "${rawProvider}". Use \`hybridai\`, \`codex\`, \`openrouter\`, \`mistral\`, \`huggingface\`, \`local\`, or \`msteams\`.`, + `Unknown provider "${rawProvider}". Use \`hybridai\`, \`codex\`, \`anthropic\`, \`openrouter\`, \`mistral\`, \`huggingface\`, \`local\`, or \`msteams\`.`, ); } return { @@ -627,6 +829,38 @@ function printOpenRouterStatus(): void { console.log('Catalog: auto-discovered'); } +function printAnthropicStatus(): void { + ensureRuntimeConfigFile(); + const config = getRuntimeConfig(); + const status = getAnthropicAuthApi().getAnthropicAuthStatus(); + + console.log(`Path: ${status.path}`); + console.log(`Authenticated: ${status.authenticated ? 'yes' : 'no'}`); + if (status.method) { + console.log(`Method: ${status.method}`); + } + if (status.source) { + console.log(`Source: ${status.source}`); + } + if (status.maskedValue) { + console.log( + `${status.method === 'api-key' ? 'API key' : 'Credential'}: ${status.maskedValue}`, + ); + } + if (status.expiresAt) { + console.log(`Expires: ${new Date(status.expiresAt).toISOString()}`); + } + console.log(`Config: ${runtimeConfigPath()}`); + console.log(`Enabled: ${config.anthropic.enabled ? 'yes' : 'no'}`); + console.log(`Base URL: ${config.anthropic.baseUrl}`); + console.log( + `Default model: ${formatModelForDisplay(config.hybridai.defaultModel)}`, + ); + console.log( + `Models: ${config.anthropic.models.length > 0 ? config.anthropic.models.join(', ') : '(none configured)'}`, + ); +} + function printMistralStatus(): void { ensureRuntimeConfigFile(); const config = getRuntimeConfig(); @@ -700,6 +934,17 @@ function clearOpenRouterCredentials(): void { ); } +function clearAnthropicCredentials(): void { + const filePath = saveRuntimeSecrets({ ANTHROPIC_API_KEY: null }); + console.log(`Cleared stored Anthropic API key in ${filePath}.`); + console.log( + 'If Claude Code credentials are still present on this host, HybridClaw will keep using them. Run `claude auth logout` separately if you also want to remove the Claude CLI session.', + ); + console.log( + 'If ANTHROPIC_API_KEY is still exported in your shell, unset it separately.', + ); +} + function clearMistralCredentials(): void { const filePath = saveRuntimeSecrets({ MISTRAL_API_KEY: null }); console.log(`Cleared Mistral credentials in ${filePath}.`); @@ -860,6 +1105,10 @@ function printUnifiedProviderUsage(provider: UnifiedProvider): void { printCodexUsage(); return; } + if (provider === 'anthropic') { + printAnthropicUsage(); + return; + } if (provider === 'openrouter') { printOpenRouterUsage(); return; @@ -1116,7 +1365,7 @@ async function handleAuthLoginCommand(normalizedArgs: string[]): Promise { const parsed = parseUnifiedProviderArgs(normalizedArgs); if (!parsed.provider) { throw new Error( - `Unknown auth login provider "${normalizedArgs[0]}". Use \`hybridai\`, \`codex\`, \`openrouter\`, \`mistral\`, \`huggingface\`, \`local\`, or \`msteams\`.`, + `Unknown auth login provider "${normalizedArgs[0]}". Use \`hybridai\`, \`codex\`, \`anthropic\`, \`openrouter\`, \`mistral\`, \`huggingface\`, \`local\`, or \`msteams\`.`, ); } if (isHelpRequest(parsed.remaining)) { @@ -1132,6 +1381,10 @@ async function handleAuthLoginCommand(normalizedArgs: string[]): Promise { await handleCodexCommand(['login', ...parsed.remaining]); return; } + if (parsed.provider === 'anthropic') { + await configureAnthropic(parsed.remaining); + return; + } if (parsed.provider === 'openrouter') { await configureOpenRouter(parsed.remaining); return; @@ -1247,6 +1500,15 @@ async function dispatchProviderAction( await handleCodexCommand([action]); return; } + if (provider === 'anthropic') { + if (action === 'status') { + await ensureAnthropicAuthApi(); + printAnthropicStatus(); + return; + } + clearAnthropicCredentials(); + return; + } if (provider === 'openrouter') { if (action === 'status') { printOpenRouterStatus(); @@ -1299,7 +1561,7 @@ async function handleProviderActionCommand( const parsed = parseUnifiedProviderArgs(normalizedArgs); if (!parsed.provider) { throw new Error( - `Unknown ${action} provider "${normalizedArgs[0]}". Use \`hybridai\`, \`codex\`, \`openrouter\`, \`mistral\`, \`huggingface\`, \`local\`, or \`msteams\`.`, + `Unknown ${action} provider "${normalizedArgs[0]}". Use \`hybridai\`, \`codex\`, \`anthropic\`, \`openrouter\`, \`mistral\`, \`huggingface\`, \`local\`, or \`msteams\`.`, ); } if (parsed.remaining.length > 0) { diff --git a/src/cli/help.ts b/src/cli/help.ts index 1e259017..d0f170aa 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -136,7 +136,7 @@ export function printOnboardingUsage(): void { Runs the HybridClaw onboarding flow: 1) trust-model acceptance 2) auth provider selection - 3) HybridAI API key setup, OpenAI Codex OAuth login, OpenRouter API key setup, Mistral API key setup, or Hugging Face token setup + 3) HybridAI API key setup, OpenAI Codex OAuth login, Anthropic Claude Code / API key setup, OpenRouter API key setup, Mistral API key setup, or Hugging Face token setup 4) default model/bot persistence`); } @@ -173,9 +173,9 @@ export function printAuthUsage(): void { Commands: hybridclaw auth login - hybridclaw auth login ... - hybridclaw auth status - hybridclaw auth logout + hybridclaw auth login ... + hybridclaw auth status + hybridclaw auth logout hybridclaw auth whatsapp reset Examples: @@ -183,6 +183,8 @@ Examples: hybridclaw auth login hybridai --browser hybridclaw auth login hybridai --base-url http://localhost:5000 hybridclaw auth login codex --import + hybridclaw auth login anthropic --method cli --set-default + hybridclaw auth login anthropic anthropic/claude-sonnet-4-6 --method api-key --api-key sk-ant-... hybridclaw auth login openrouter anthropic/claude-sonnet-4 --api-key sk-or-... hybridclaw auth login mistral mistral-large-latest --api-key mistral_... hybridclaw auth login huggingface meta-llama/Llama-3.1-8B-Instruct --api-key hf_... @@ -191,10 +193,12 @@ Examples: hybridclaw auth login local llamacpp Meta-Llama-3-8B-Instruct --base-url http://127.0.0.1:8081 hybridclaw auth login msteams --app-id 00000000-0000-0000-0000-000000000000 --tenant-id 11111111-1111-1111-1111-111111111111 --app-password secret hybridclaw auth whatsapp reset + hybridclaw auth status anthropic hybridclaw auth status openrouter hybridclaw auth status mistral hybridclaw auth status huggingface hybridclaw auth status msteams + hybridclaw auth logout anthropic hybridclaw auth logout codex hybridclaw auth logout mistral hybridclaw auth logout huggingface @@ -205,6 +209,8 @@ Notes: - \`local logout\` disables configured local backends and clears any saved vLLM API key. - \`auth login msteams\` enables Microsoft Teams and stores \`MSTEAMS_APP_PASSWORD\` in ${runtimeSecretsPath()}. - \`auth whatsapp reset\` clears linked WhatsApp Web auth so you can re-pair cleanly. + - \`auth login anthropic --method cli\` reuses your local Claude Code login from \`claude auth login\`. + - \`auth login anthropic --method api-key\` stores \`ANTHROPIC_API_KEY\` in ${runtimeSecretsPath()}. - \`auth login openrouter\` prompts for the API key when \`--api-key\` and \`OPENROUTER_API_KEY\` are both absent. - \`auth login mistral\` prompts for the API key when \`--api-key\` and \`MISTRAL_API_KEY\` are both absent. - \`auth login huggingface\` prompts for the token when \`--api-key\` and \`HF_TOKEN\` are both absent. @@ -389,6 +395,20 @@ Notes: - \`auth logout openrouter\` clears the stored API key but leaves runtime config unchanged.`); } +export function printAnthropicUsage(): void { + console.log(`Usage: + hybridclaw auth login anthropic [model-id] [--method ] [--api-key ] [--base-url ] [--no-default] + hybridclaw auth status anthropic + hybridclaw auth logout anthropic + +Notes: + - Model IDs use the \`anthropic/\` prefix in HybridClaw, for example \`anthropic/claude-sonnet-4-6\`. + - \`auth login anthropic --method cli\` reuses your local Claude Code session from \`claude auth login\`. + - \`auth login anthropic --method api-key\` stores \`ANTHROPIC_API_KEY\` and can set the global default model. + - If \`--api-key\` is omitted for \`--method api-key\`, HybridClaw prompts you to paste the key. + - \`auth logout anthropic\` clears the stored API key, but Claude Code credentials are managed separately by the \`claude\` CLI.`); +} + export function printHuggingFaceUsage(): void { console.log(`Usage: hybridclaw auth login huggingface [model-id] [--api-key ] [--base-url ] [--no-default] diff --git a/src/config/config.ts b/src/config/config.ts index c6471d9b..0aeb86c5 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -43,6 +43,8 @@ export class MissingRequiredEnvVarError extends Error { 'Mistral provider is not configured. Use `/auth login mistral` in the TUI, or switch to a model from another configured provider.', HF_TOKEN: 'Hugging Face provider is not configured. Use `/auth login huggingface` in the TUI, or switch to a model from another configured provider.', + ANTHROPIC_API_KEY: + 'Anthropic provider is not configured. Use `/auth login anthropic --method cli` in the TUI, or switch to a model from another configured provider.', }; super( messageByEnvVar[envVar] || @@ -159,6 +161,11 @@ function syncRuntimeSecretExports(): void { 'MISTRAL_API_KEY', storedSecrets, ); + ANTHROPIC_API_KEY = readRuntimeSecretValue( + ['ANTHROPIC_API_KEY'], + 'ANTHROPIC_API_KEY', + storedSecrets, + ); HUGGINGFACE_API_KEY = readRuntimeSecretValue( ['HF_TOKEN', 'HUGGINGFACE_API_KEY'], 'HF_TOKEN', @@ -175,6 +182,7 @@ export let MSTEAMS_APP_PASSWORD = ''; export let HYBRIDAI_API_KEY = ''; export let OPENROUTER_API_KEY = ''; export let MISTRAL_API_KEY = ''; +export let ANTHROPIC_API_KEY = ''; export let HUGGINGFACE_API_KEY = ''; syncRuntimeSecretExports(); @@ -309,6 +317,8 @@ export let HYBRIDAI_CHATBOT_ID = ''; export let HYBRIDAI_MAX_TOKENS = 4_096; export let HYBRIDAI_ENABLE_RAG = true; export let CODEX_BASE_URL = CODEX_DEFAULT_BASE_URL; +export let ANTHROPIC_ENABLED = false; +export let ANTHROPIC_BASE_URL = 'https://api.anthropic.com/v1'; export let OPENROUTER_ENABLED = false; export let OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; export let MISTRAL_ENABLED = false; @@ -634,6 +644,8 @@ function applyRuntimeConfig(config: RuntimeConfig): void { ); HYBRIDAI_ENABLE_RAG = config.hybridai.enableRag; CODEX_BASE_URL = config.codex.baseUrl; + ANTHROPIC_ENABLED = config.anthropic.enabled; + ANTHROPIC_BASE_URL = config.anthropic.baseUrl; OPENROUTER_ENABLED = config.openrouter.enabled; OPENROUTER_BASE_URL = config.openrouter.baseUrl; MISTRAL_ENABLED = config.mistral.enabled; diff --git a/src/config/runtime-config.ts b/src/config/runtime-config.ts index d9cdb19e..33c78bca 100644 --- a/src/config/runtime-config.ts +++ b/src/config/runtime-config.ts @@ -455,6 +455,11 @@ export interface RuntimeConfig { baseUrl: string; models: string[]; }; + anthropic: { + enabled: boolean; + baseUrl: string; + models: string[]; + }; openrouter: { enabled: boolean; baseUrl: string; @@ -665,6 +670,7 @@ const DEFAULT_CODEX_MODEL_LIST = [ 'openai-codex/gpt-5.2', 'openai-codex/gpt-5.1-codex-mini', ] as const; +const DEFAULT_ANTHROPIC_MODEL_LIST = ['anthropic/claude-sonnet-4-6'] as const; const DEFAULT_OPENROUTER_MODEL_LIST = [ 'openrouter/anthropic/claude-sonnet-4', ] as const; @@ -865,6 +871,11 @@ const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { baseUrl: CODEX_DEFAULT_BASE_URL, models: [...DEFAULT_CODEX_MODEL_LIST], }, + anthropic: { + enabled: false, + baseUrl: 'https://api.anthropic.com/v1', + models: [...DEFAULT_ANTHROPIC_MODEL_LIST], + }, openrouter: { enabled: false, baseUrl: 'https://openrouter.ai/api/v1', @@ -3198,6 +3209,7 @@ function normalizeRuntimeConfig( const rawEmail = isRecord(raw.email) ? raw.email : {}; const rawHybridAi = isRecord(raw.hybridai) ? raw.hybridai : {}; const rawCodex = isRecord(raw.codex) ? raw.codex : {}; + const rawAnthropic = isRecord(raw.anthropic) ? raw.anthropic : {}; const rawOpenRouter = isRecord(raw.openrouter) ? raw.openrouter : {}; const rawMistral = isRecord(raw.mistral) ? raw.mistral : {}; const rawHuggingFace = isRecord(raw.huggingface) ? raw.huggingface : {}; @@ -3395,6 +3407,10 @@ function normalizeRuntimeConfig( rawCodex.models, DEFAULT_RUNTIME_CONFIG.codex.models, ); + const anthropicModelList = normalizeStringArray( + rawAnthropic.models, + DEFAULT_RUNTIME_CONFIG.anthropic.models, + ); const openRouterModelList = normalizeStringArray( rawOpenRouter.models, DEFAULT_RUNTIME_CONFIG.openrouter.models, @@ -3676,6 +3692,17 @@ function normalizeRuntimeConfig( ), models: codexModelList, }, + anthropic: { + enabled: normalizeBoolean( + rawAnthropic.enabled, + DEFAULT_RUNTIME_CONFIG.anthropic.enabled, + ), + baseUrl: normalizeBaseUrl( + rawAnthropic.baseUrl, + DEFAULT_RUNTIME_CONFIG.anthropic.baseUrl, + ), + models: anthropicModelList, + }, openrouter: { enabled: normalizeBoolean( rawOpenRouter.enabled, diff --git a/src/doctor/checks/credentials.ts b/src/doctor/checks/credentials.ts index 255ba4cc..d89a98ae 100644 --- a/src/doctor/checks/credentials.ts +++ b/src/doctor/checks/credentials.ts @@ -18,6 +18,7 @@ export async function checkCredentials(): Promise { if (!fs.existsSync(filePath)) { const sharedEnvSecrets = [ process.env.HYBRIDAI_API_KEY, + process.env.ANTHROPIC_API_KEY, process.env.OPENROUTER_API_KEY, process.env.MISTRAL_API_KEY, process.env.DISCORD_TOKEN, diff --git a/src/doctor/checks/providers.ts b/src/doctor/checks/providers.ts index e23bca18..d19f1644 100644 --- a/src/doctor/checks/providers.ts +++ b/src/doctor/checks/providers.ts @@ -1,8 +1,10 @@ +import { getAnthropicAuthStatus } from '../../auth/anthropic-auth.js'; import { getCodexAuthStatus } from '../../auth/codex-auth.js'; import { getRuntimeConfig } from '../../config/runtime-config.js'; import { resolveModelProvider } from '../../providers/factory.js'; import { type ProviderProbeResult, + probeAnthropic, probeCodex, probeHuggingFace, probeHybridAI, @@ -15,6 +17,7 @@ import { makeResult, severityFrom, toErrorMessage } from '../utils.js'; type ProviderKey = | 'hybridai' | 'codex' + | 'anthropic' | 'openrouter' | 'mistral' | 'huggingface'; @@ -112,8 +115,11 @@ function formatProbeSegment( export async function checkProviders(): Promise { const config = getRuntimeConfig(); const defaultProvider = resolveModelProvider(config.hybridai.defaultModel); + const anthropicStatus = getAnthropicAuthStatus(); const codexStatus = getCodexAuthStatus(); const discoveredModels = await readDiscoveredModelNamesSafely(); + const anthropicEnabled = config.anthropic?.enabled === true; + const discoveredModels = await readDiscoveredModelNamesSafely(); const openRouterEnabled = config.openrouter?.enabled === true; const hybridaiModels = dedupeStrings([ ...discoveredModels.hybridai, @@ -123,6 +129,10 @@ export async function checkProviders(): Promise { ...discoveredModels.codex, defaultProvider === 'openai-codex' ? config.hybridai.defaultModel : '', ]); + const anthropicModels = dedupeStrings([ + ...(config.anthropic?.models ?? []), + defaultProvider === 'anthropic' ? config.hybridai.defaultModel : '', + ]); const openRouterModels = dedupeStrings(discoveredModels.openrouter); const mistralEnabled = config.mistral?.enabled === true; const mistralModels = dedupeStrings(discoveredModels.mistral); @@ -149,6 +159,25 @@ export async function checkProviders(): Promise { ? 'Login required' : 'Not authenticated', }, + { + key: 'anthropic', + label: 'Anthropic', + active: defaultProvider === 'anthropic', + configured: + anthropicEnabled || + defaultProvider === 'anthropic' || + anthropicStatus.authenticated, + configuredModelCount: anthropicModels.length, + probe: + anthropicEnabled || + defaultProvider === 'anthropic' || + anthropicStatus.authenticated + ? () => probeAnthropic() + : null, + inactiveMessage: anthropicStatus.authenticated + ? 'Provider disabled' + : 'Not authenticated', + }, { key: 'openrouter', label: 'OpenRouter', diff --git a/src/doctor/provider-probes.ts b/src/doctor/provider-probes.ts index 2bf9f0d1..0d392871 100644 --- a/src/doctor/provider-probes.ts +++ b/src/doctor/provider-probes.ts @@ -1,6 +1,9 @@ +import { resolveAnthropicAuth } from '../auth/anthropic-auth.js'; import { resolveCodexCredentials } from '../auth/codex-auth.js'; import { getHybridAIAuthStatus } from '../auth/hybridai-auth.js'; import { + ANTHROPIC_BASE_URL, + ANTHROPIC_ENABLED, CODEX_BASE_URL, HUGGINGFACE_BASE_URL, HUGGINGFACE_ENABLED, @@ -9,6 +12,7 @@ import { OPENROUTER_BASE_URL, OPENROUTER_ENABLED, } from '../config/config.js'; +import { normalizeAnthropicBaseUrl } from '../providers/anthropic-utils.js'; import { readHuggingFaceApiKey } from '../providers/huggingface-utils.js'; import { fetchHybridAIBots } from '../providers/hybridai-bots.js'; import { readMistralApiKey } from '../providers/mistral-utils.js'; @@ -82,6 +86,53 @@ export async function probeOpenRouter(): Promise { }; } +export async function probeAnthropic(): Promise { + if (!ANTHROPIC_ENABLED) { + return { + reachable: false, + detail: 'Provider disabled', + }; + } + + let auth: ReturnType; + try { + auth = resolveAnthropicAuth(); + } catch (error) { + return { + reachable: false, + detail: error instanceof Error ? error.message : String(error), + }; + } + + const headers: Record = { + ...auth.headers, + }; + if (auth.method === 'cli') { + headers.Authorization = `Bearer ${auth.apiKey}`; + } else { + headers['x-api-key'] = auth.apiKey; + } + + const startedAt = Date.now(); + const response = await fetch( + `${normalizeAnthropicBaseUrl(ANTHROPIC_BASE_URL)}/models`, + { + headers, + signal: AbortSignal.timeout(5_000), + }, + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const payload = (await response.json()) as { data?: unknown[] }; + return { + reachable: true, + detail: `${Date.now() - startedAt}ms`, + modelCount: Array.isArray(payload.data) ? payload.data.length : 0, + }; +} + export async function probeHuggingFace(): Promise { if (!HUGGINGFACE_ENABLED) { return { diff --git a/src/onboarding.ts b/src/onboarding.ts index 1ca74e7b..68918dcc 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -2,6 +2,10 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import readline from 'node:readline/promises'; +import { + getAnthropicAuthStatus, + requireAnthropicCliCredentials, +} from './auth/anthropic-auth.js'; import { getCodexAuthStatus, loginCodexInteractive, @@ -54,6 +58,7 @@ interface OnboardingOptions { requireCredentials?: boolean; preferredAuth?: | 'hybridai' + | 'anthropic' | 'openai-codex' | 'openrouter' | 'mistral' @@ -388,6 +393,16 @@ function defaultCodexModel(): string { return 'openai-codex/gpt-5-codex'; } +function defaultAnthropicModel(): string { + const config = getRuntimeConfig(); + const current = config.hybridai.defaultModel.trim(); + if (current && resolveModelProvider(current) === 'anthropic') return current; + const first = config.anthropic.models.find( + (model) => resolveModelProvider(model) === 'anthropic', + ); + return (first || '').trim(); +} + function defaultOpenRouterModel(): string { const config = getRuntimeConfig(); const current = config.hybridai.defaultModel.trim(); @@ -554,6 +569,7 @@ async function promptAuthMethod( currentModel: string, ): Promise< | 'hybridai' + | 'anthropic' | 'openai-codex' | 'openrouter' | 'mistral' @@ -562,23 +578,26 @@ async function promptAuthMethod( > { const currentProvider = resolveModelProvider(currentModel); const defaultChoice = - currentProvider === 'openai-codex' + currentProvider === 'anthropic' ? '2' - : currentProvider === 'openrouter' + : currentProvider === 'openai-codex' ? '3' - : currentProvider === 'mistral' + : currentProvider === 'openrouter' ? '4' - : currentProvider === 'huggingface' + : currentProvider === 'mistral' ? '5' - : '1'; + : currentProvider === 'huggingface' + ? '6' + : '1'; console.log(`${TEAL}${ICON_TITLE}${RESET} Auth methods:`); console.log(` ${TEAL}1.${RESET} HybridAI API key`); - console.log(` ${TEAL}2.${RESET} OpenAI Codex (OAuth login)`); - console.log(` ${TEAL}3.${RESET} OpenRouter API key`); - console.log(` ${TEAL}4.${RESET} Mistral API key`); - console.log(` ${TEAL}5.${RESET} Hugging Face token`); - console.log(` ${TEAL}6.${RESET} Skip for now (for local models)`); + console.log(` ${TEAL}2.${RESET} Anthropic Claude Code / API key`); + console.log(` ${TEAL}3.${RESET} OpenAI Codex (OAuth login)`); + console.log(` ${TEAL}4.${RESET} OpenRouter API key`); + console.log(` ${TEAL}5.${RESET} Mistral API key`); + console.log(` ${TEAL}6.${RESET} Hugging Face token`); + console.log(` ${TEAL}7.${RESET} Skip for now (for local models)`); while (true) { const choice = await promptOptional( @@ -590,29 +609,36 @@ async function promptAuthMethod( if (normalized === '1' || normalized === 'hybridai') return 'hybridai'; if ( normalized === '2' || + normalized === 'anthropic' || + normalized === 'claude' + ) { + return 'anthropic'; + } + if ( + normalized === '3' || normalized === 'codex' || normalized === 'openai-codex' ) { return 'openai-codex'; } - if (normalized === '3' || normalized === 'openrouter') { + if (normalized === '4' || normalized === 'openrouter') { return 'openrouter'; } - if (normalized === '4' || normalized === 'mistral') { + if (normalized === '5' || normalized === 'mistral') { return 'mistral'; } if ( - normalized === '5' || + normalized === '6' || normalized === 'huggingface' || normalized === 'hf' ) { return 'huggingface'; } - if (normalized === '6' || normalized === 'skip' || normalized === 'local') { + if (normalized === '7' || normalized === 'skip' || normalized === 'local') { return 'skip'; } printWarn( - 'Enter 1 for HybridAI, 2 for OpenAI Codex, 3 for OpenRouter, 4 for Mistral, 5 for Hugging Face, or 6 to skip for now.', + 'Enter 1 for HybridAI, 2 for Anthropic, 3 for OpenAI Codex, 4 for OpenRouter, 5 for Mistral, 6 for Hugging Face, or 7 to skip for now.', ); } } @@ -930,6 +956,98 @@ async function runCodexOnboarding(params: { console.log(); } +async function runAnthropicOnboarding(params: { + rl: readline.Interface; + commandLabel: string; + existingApiKey: string; +}): Promise { + const { rl, commandLabel, existingApiKey } = params; + const runtimeConfig = getRuntimeConfig(); + const existingStatus = getAnthropicAuthStatus(); + printMeta('ANTHROPIC_BASE_URL', runtimeConfig.anthropic.baseUrl); + if (existingStatus.authenticated) { + printSetup('Anthropic credentials already detected on this host.'); + } else { + printInfo( + 'Anthropic can reuse your local Claude Code login or a direct Anthropic API key.', + ); + } + console.log(); + + const defaultMethod = existingStatus.method === 'cli' ? 'cli' : 'api-key'; + const methodChoice = ( + await promptOptional( + rl, + `Auth method [cli/api-key] (Enter for ${defaultMethod}): `, + ICON_AUTH, + ) + ) + .trim() + .toLowerCase(); + const method = + !methodChoice || methodChoice === defaultMethod + ? defaultMethod + : methodChoice === 'cli' || methodChoice === 'claude' + ? 'cli' + : methodChoice === 'api-key' || methodChoice === 'apikey' + ? 'api-key' + : null; + if (!method) { + throw new Error('Anthropic onboarding expected `cli` or `api-key`.'); + } + + let resultMessage = ''; + if (method === 'cli') { + if (!existingStatus.authenticated || existingStatus.method !== 'cli') { + printInfo( + 'Run `claude auth login` in another terminal if you have not already done so, then press Enter here.', + ); + await promptOptional( + rl, + 'Press Enter after Claude Code login is complete: ', + ICON_KEYBOARD, + ); + } + const resolved = requireAnthropicCliCredentials(); + resultMessage = `Using Claude Code credentials from ${resolved.path}.`; + } else { + const entered = await promptOptional( + rl, + existingApiKey + ? 'Anthropic API key (Enter to keep current): ' + : 'Anthropic API key: ', + ICON_KEY, + ); + const apiKey = (entered || existingApiKey).trim(); + if (!apiKey) { + throw new Error('Anthropic onboarding requires a non-empty API key.'); + } + const secretsPath = saveRuntimeSecrets({ ANTHROPIC_API_KEY: apiKey }); + refreshRuntimeSecretsFromEnv(); + resultMessage = `Saved credentials to ${secretsPath}.`; + } + + const nextAnthropicModel = defaultAnthropicModel(); + const switchedModel = await maybeSwitchDefaultModel( + rl, + nextAnthropicModel, + 'Anthropic auth works only with Anthropic models.', + ); + + console.log(); + printSuccess(resultMessage); + printSuccess(`Saved runtime settings to ${runtimeConfigPath()}.`); + if (switchedModel) { + printSuccess(`Default model set to: ${nextAnthropicModel}`); + } else if (!nextAnthropicModel) { + printInfo( + `No Anthropic default model is configured. Set hybridai.defaultModel to an anthropic/... model in ${runtimeConfigPath()} if needed.`, + ); + } + printTuiStartHint(commandLabel); + console.log(); +} + async function runOpenRouterOnboarding(params: { rl: readline.Interface; commandLabel: string; @@ -1190,36 +1308,41 @@ export async function ensureRuntimeCredentials( readStoredRuntimeSecret('HF_TOKEN') || '' ).trim(); + const anthropicStatus = getAnthropicAuthStatus(); const codexStatus = getCodexAuthStatus(); const currentModel = runtimeConfig.hybridai.defaultModel.trim(); const resolvedCurrentProvider = resolveModelProvider(currentModel); const currentProviderIsLocal = isLocalProvider(resolvedCurrentProvider); const currentAuth = options.preferredAuth || - (resolvedCurrentProvider === 'openai-codex' - ? 'openai-codex' - : resolvedCurrentProvider === 'openrouter' - ? 'openrouter' - : resolvedCurrentProvider === 'mistral' - ? 'mistral' - : resolvedCurrentProvider === 'huggingface' - ? 'huggingface' - : 'hybridai'); + (resolvedCurrentProvider === 'anthropic' + ? 'anthropic' + : resolvedCurrentProvider === 'openai-codex' + ? 'openai-codex' + : resolvedCurrentProvider === 'openrouter' + ? 'openrouter' + : resolvedCurrentProvider === 'mistral' + ? 'mistral' + : resolvedCurrentProvider === 'huggingface' + ? 'huggingface' + : 'hybridai'); const force = options.force === true; const requireCredentials = options.requireCredentials !== false; let securityAccepted = isSecurityTrustAccepted(runtimeConfig); const needsSecurityAcceptance = !securityAccepted || force; const hasRequiredCredentials = currentProviderIsLocal ? true - : currentAuth === 'openai-codex' - ? codexStatus.authenticated - : currentAuth === 'openrouter' - ? !!existingOpenRouterKey - : currentAuth === 'mistral' - ? !!existingMistralKey - : currentAuth === 'huggingface' - ? !!existingHuggingFaceKey - : !!existingKey; + : currentAuth === 'anthropic' + ? anthropicStatus.authenticated + : currentAuth === 'openai-codex' + ? codexStatus.authenticated + : currentAuth === 'openrouter' + ? !!existingOpenRouterKey + : currentAuth === 'mistral' + ? !!existingMistralKey + : currentAuth === 'huggingface' + ? !!existingHuggingFaceKey + : !!existingKey; if ( !needsSecurityAcceptance && (hasRequiredCredentials || !requireCredentials) @@ -1256,6 +1379,11 @@ export async function ensureRuntimeCredentials( // After accepting trust via env var, credentials may already be present. if (hasRequiredCredentials) return; if (!requireCredentials) return; + if (currentAuth === 'anthropic') { + throw new Error( + `Anthropic credentials are missing. Run \`claude auth login\` and then \`hybridclaw auth login anthropic --method cli --set-default\`, or store ANTHROPIC_API_KEY in ${runtimeSecretsPath()}.`, + ); + } if (currentAuth === 'openai-codex') { throw new Error( 'OpenAI Codex credentials are missing. Run `hybridclaw codex login` or `hybridclaw onboarding` in an interactive terminal.', @@ -1309,6 +1437,7 @@ export async function ensureRuntimeCredentials( process.env.HUGGINGFACE_API_KEY || '' ).trim(); + const refreshedAnthropicStatus = getAnthropicAuthStatus(); const refreshedCurrentModel = refreshedRuntimeConfig.hybridai.defaultModel.trim(); const refreshedResolvedProvider = resolveModelProvider( @@ -1317,27 +1446,31 @@ export async function ensureRuntimeCredentials( const refreshedProviderIsLocal = isLocalProvider(refreshedResolvedProvider); const refreshedAuth = options.preferredAuth || - (refreshedResolvedProvider === 'openai-codex' - ? 'openai-codex' - : refreshedResolvedProvider === 'openrouter' - ? 'openrouter' - : refreshedResolvedProvider === 'mistral' - ? 'mistral' - : refreshedResolvedProvider === 'huggingface' - ? 'huggingface' - : 'hybridai'); + (refreshedResolvedProvider === 'anthropic' + ? 'anthropic' + : refreshedResolvedProvider === 'openai-codex' + ? 'openai-codex' + : refreshedResolvedProvider === 'openrouter' + ? 'openrouter' + : refreshedResolvedProvider === 'mistral' + ? 'mistral' + : refreshedResolvedProvider === 'huggingface' + ? 'huggingface' + : 'hybridai'); const refreshedCodexStatus = getCodexAuthStatus(); const refreshedHasRequiredCredentials = refreshedProviderIsLocal ? true - : refreshedAuth === 'openai-codex' - ? refreshedCodexStatus.authenticated - : refreshedAuth === 'openrouter' - ? !!refreshedExistingOpenRouterKey - : refreshedAuth === 'mistral' - ? !!refreshedExistingMistralKey - : refreshedAuth === 'huggingface' - ? !!refreshedExistingHuggingFaceKey - : !!refreshedExistingKey; + : refreshedAuth === 'anthropic' + ? refreshedAnthropicStatus.authenticated + : refreshedAuth === 'openai-codex' + ? refreshedCodexStatus.authenticated + : refreshedAuth === 'openrouter' + ? !!refreshedExistingOpenRouterKey + : refreshedAuth === 'mistral' + ? !!refreshedExistingMistralKey + : refreshedAuth === 'huggingface' + ? !!refreshedExistingHuggingFaceKey + : !!refreshedExistingKey; if (refreshedProviderIsLocal && !options.preferredAuth) { printSuccess( @@ -1371,6 +1504,17 @@ export async function ensureRuntimeCredentials( await runCodexOnboarding({ rl, commandLabel }); return; } + if (authMethod === 'anthropic') { + await runAnthropicOnboarding({ + rl, + commandLabel, + existingApiKey: + process.env.ANTHROPIC_API_KEY?.trim() || + readStoredRuntimeSecret('ANTHROPIC_API_KEY') || + '', + }); + return; + } if (authMethod === 'openrouter') { await runOpenRouterOnboarding({ rl, diff --git a/src/providers/anthropic-utils.ts b/src/providers/anthropic-utils.ts new file mode 100644 index 00000000..eb97af41 --- /dev/null +++ b/src/providers/anthropic-utils.ts @@ -0,0 +1,70 @@ +import { ANTHROPIC_API_KEY } from '../config/config.js'; +import { readProviderApiKey } from './provider-api-key-utils.js'; +import { normalizeBaseUrl } from './utils.js'; + +export const ANTHROPIC_MODEL_PREFIX = 'anthropic/'; +export const ANTHROPIC_DEFAULT_BASE_URL = 'https://api.anthropic.com/v1'; +export const ANTHROPIC_DEFAULT_MODEL = 'anthropic/claude-sonnet-4-6'; +export const ANTHROPIC_VERSION = '2023-06-01'; +export const ANTHROPIC_TOOL_STREAMING_BETA = + 'fine-grained-tool-streaming-2025-05-14'; +export const ANTHROPIC_CLAUDE_CODE_BETAS = `claude-code-20250219,oauth-2025-04-20,${ANTHROPIC_TOOL_STREAMING_BETA}`; +export const ANTHROPIC_CLAUDE_CODE_USER_AGENT = 'claude-cli/2.1.75'; + +export function isAnthropicModel(model: string): boolean { + return String(model || '') + .trim() + .toLowerCase() + .startsWith(ANTHROPIC_MODEL_PREFIX); +} + +export function normalizeAnthropicModelName(modelId: string): string { + const normalized = String(modelId || '').trim(); + if (!normalized) return ''; + if (normalized.toLowerCase().startsWith(ANTHROPIC_MODEL_PREFIX)) { + return normalized; + } + return `${ANTHROPIC_MODEL_PREFIX}${normalized}`; +} + +export function stripAnthropicModelPrefix(modelId: string): string { + const normalized = String(modelId || '').trim(); + if (!normalized.toLowerCase().startsWith(ANTHROPIC_MODEL_PREFIX)) { + return normalized; + } + return normalized.slice(ANTHROPIC_MODEL_PREFIX.length) || normalized; +} + +export function normalizeAnthropicBaseUrl(rawBaseUrl: string): string { + const normalized = normalizeBaseUrl(rawBaseUrl); + if (!normalized) return ANTHROPIC_DEFAULT_BASE_URL; + return /\/v1$/i.test(normalized) ? normalized : `${normalized}/v1`; +} + +export function isAnthropicOAuthToken(value: string): boolean { + return String(value || '').includes('sk-ant-oat'); +} + +export function buildAnthropicRequestHeaders(params: { + apiKey: string; +}): Record { + return isAnthropicOAuthToken(params.apiKey) + ? { + 'anthropic-version': ANTHROPIC_VERSION, + 'anthropic-beta': ANTHROPIC_CLAUDE_CODE_BETAS, + 'user-agent': ANTHROPIC_CLAUDE_CODE_USER_AGENT, + 'x-app': 'cli', + } + : { + 'anthropic-version': ANTHROPIC_VERSION, + 'anthropic-beta': ANTHROPIC_TOOL_STREAMING_BETA, + }; +} + +export function readAnthropicApiKey(opts?: { required?: boolean }): string { + return readProviderApiKey( + () => [process.env.ANTHROPIC_API_KEY, ANTHROPIC_API_KEY], + 'ANTHROPIC_API_KEY', + opts, + ); +} diff --git a/src/providers/anthropic.ts b/src/providers/anthropic.ts index 1c40fb65..22fa30eb 100644 --- a/src/providers/anthropic.ts +++ b/src/providers/anthropic.ts @@ -1,24 +1,33 @@ +import { DEFAULT_AGENT_ID } from '../agents/agent-types.js'; +import { resolveAnthropicAuth } from '../auth/anthropic-auth.js'; +import { ANTHROPIC_BASE_URL } from '../config/config.js'; +import { + isAnthropicModel, + normalizeAnthropicBaseUrl, +} from './anthropic-utils.js'; +import { resolveModelContextWindowFallback } from './hybridai-models.js'; import type { AIProvider, ResolvedModelRuntimeCredentials, ResolveProviderRuntimeParams, } from './types.js'; -const ANTHROPIC_MODEL_PREFIX = 'anthropic/'; - -export function isAnthropicModel(model: string): boolean { - return String(model || '') - .trim() - .toLowerCase() - .startsWith(ANTHROPIC_MODEL_PREFIX); -} - async function resolveAnthropicRuntimeCredentials( params: ResolveProviderRuntimeParams, ): Promise { - throw new Error( - `Anthropic provider is not implemented yet for model "${params.model}".`, - ); + const auth = resolveAnthropicAuth(); + const agentId = String(params.agentId || '').trim() || DEFAULT_AGENT_ID; + return { + provider: 'anthropic', + apiKey: auth.apiKey, + baseUrl: normalizeAnthropicBaseUrl(ANTHROPIC_BASE_URL), + chatbotId: '', + enableRag: false, + requestHeaders: auth.headers, + agentId, + isLocal: false, + contextWindow: resolveModelContextWindowFallback(params.model) ?? undefined, + }; } export const anthropicProvider: AIProvider = { diff --git a/src/providers/model-catalog.ts b/src/providers/model-catalog.ts index a9a59d36..07b15395 100644 --- a/src/providers/model-catalog.ts +++ b/src/providers/model-catalog.ts @@ -1,8 +1,10 @@ import { HYBRIDAI_MODEL } from '../config/config.js'; +import { getRuntimeConfig } from '../config/runtime-config.js'; import { discoverCodexModels, getDiscoveredCodexModelNames, } from './codex-discovery.js'; +import { ANTHROPIC_MODEL_PREFIX } from './anthropic-utils.js'; import { resolveModelProvider } from './factory.js'; import { discoverHuggingFaceModels, @@ -50,6 +52,7 @@ const PREFIX_BY_PROVIDER: Record< Extract< ModelCatalogProviderFilter, | 'openai-codex' + | 'anthropic' | 'openrouter' | 'mistral' | 'huggingface' @@ -61,6 +64,7 @@ const PREFIX_BY_PROVIDER: Record< string > = { 'openai-codex': OPENAI_CODEX_MODEL_PREFIX, + anthropic: ANTHROPIC_MODEL_PREFIX, openrouter: OPENROUTER_MODEL_PREFIX, mistral: MISTRAL_MODEL_PREFIX, huggingface: HUGGINGFACE_MODEL_PREFIX, @@ -184,8 +188,15 @@ export function getAvailableModelListWithOptions( provider?: string, _opts?: { expanded?: boolean }, ): string[] { + const config = getRuntimeConfig(); const models = dedupeModelList([ HYBRIDAI_MODEL, + ...config.hybridai.models, + ...config.codex.models, + ...(config.anthropic.enabled ? config.anthropic.models : []), + ...(config.openrouter.enabled ? config.openrouter.models : []), + ...(config.mistral.enabled ? config.mistral.models : []), + ...(config.huggingface.enabled ? config.huggingface.models : []), ...getDiscoveredCodexModelNames(), ...getDiscoveredHuggingFaceModelNames(), ...getDiscoveredHybridAIModelNames(), diff --git a/src/providers/provider-ids.ts b/src/providers/provider-ids.ts index b965260b..e81e5c33 100644 --- a/src/providers/provider-ids.ts +++ b/src/providers/provider-ids.ts @@ -8,13 +8,14 @@ export const LOCAL_BACKEND_IDS = [ export const RUNTIME_PROVIDER_IDS = [ 'hybridai', 'openai-codex', + 'anthropic', 'openrouter', 'mistral', 'huggingface', ...LOCAL_BACKEND_IDS, ] as const; -export const AI_PROVIDER_IDS = [...RUNTIME_PROVIDER_IDS, 'anthropic'] as const; +export const AI_PROVIDER_IDS = [...RUNTIME_PROVIDER_IDS] as const; export const OPENAI_COMPAT_PROVIDER_IDS = [ 'openrouter', diff --git a/src/providers/task-routing.ts b/src/providers/task-routing.ts index 9743fbeb..52a539bb 100644 --- a/src/providers/task-routing.ts +++ b/src/providers/task-routing.ts @@ -37,6 +37,7 @@ const ENV_OVERRIDE_PREFIXES = ['AUXILIARY_', 'CONTEXT_'] as const; const RUNTIME_PROVIDER_PREFIXES: Record = { hybridai: '', 'openai-codex': 'openai-codex/', + anthropic: 'anthropic/', openrouter: 'openrouter/', mistral: 'mistral/', huggingface: 'huggingface/', @@ -122,6 +123,7 @@ export function detectRuntimeProviderPrefix( const normalized = model.trim().toLowerCase(); if (!normalized) return undefined; if (normalized.startsWith('openai-codex/')) return 'openai-codex'; + if (normalized.startsWith('anthropic/')) return 'anthropic'; if (normalized.startsWith('openrouter/')) return 'openrouter'; if (normalized.startsWith('mistral/')) return 'mistral'; if (normalized.startsWith('huggingface/')) return 'huggingface'; @@ -167,6 +169,11 @@ export function resolveDefaultAuxiliaryModelForProvider( ]); } + if (provider === 'anthropic') { + if (!config.anthropic.enabled) return undefined; + return selectFirstNonEmpty(config.anthropic.models); + } + if (provider === 'openrouter') { if (!config.openrouter.enabled) return undefined; return selectFirstNonEmpty([ diff --git a/src/security/runtime-secrets.ts b/src/security/runtime-secrets.ts index b0d32496..eba0eedd 100644 --- a/src/security/runtime-secrets.ts +++ b/src/security/runtime-secrets.ts @@ -24,6 +24,7 @@ const RUNTIME_SECRETS_MODE = 0o600; const SECRET_KEYS = [ 'HYBRIDAI_API_KEY', + 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY', 'MISTRAL_API_KEY', 'HF_TOKEN', diff --git a/tests/cli.test.ts b/tests/cli.test.ts index e02c5ee2..f48c3ad4 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -12,6 +12,7 @@ const ORIGINAL_EMAIL_PASSWORD = process.env.EMAIL_PASSWORD; const ORIGINAL_MSTEAMS_APP_ID = process.env.MSTEAMS_APP_ID; const ORIGINAL_MSTEAMS_APP_PASSWORD = process.env.MSTEAMS_APP_PASSWORD; const ORIGINAL_MSTEAMS_TENANT_ID = process.env.MSTEAMS_TENANT_ID; +const ORIGINAL_ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; const ORIGINAL_OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; const ORIGINAL_MISTRAL_API_KEY = process.env.MISTRAL_API_KEY; const ORIGINAL_HF_TOKEN = process.env.HF_TOKEN; @@ -201,6 +202,28 @@ async function importFreshCli(options?: { method: 'browser' | 'device-code' | 'env-import'; validated: boolean; }; + anthropicStatus?: { + authenticated: boolean; + method: 'api-key' | 'cli' | null; + source: + | 'env' + | 'runtime-secrets' + | 'claude-cli-file' + | 'claude-cli-keychain' + | null; + path: string; + maskedValue: string | null; + expiresAt: number | null; + isOauthToken: boolean; + }; + anthropicCliAuthResult?: { + method: 'cli'; + source: 'claude-cli-file' | 'claude-cli-keychain'; + apiKey: string; + headers: Record; + path: string; + expiresAt: number | null; + }; codexStatus?: { authenticated: boolean; path: string; @@ -471,6 +494,35 @@ async function importFreshCli(options?: { validated: true, }, ); + const getAnthropicAuthStatus = vi.fn( + () => + options?.anthropicStatus || { + authenticated: false, + method: null, + source: null, + path: '/tmp/.claude/.credentials.json', + maskedValue: null, + expiresAt: null, + isOauthToken: false, + }, + ); + const requireAnthropicCliCredentials = vi.fn( + () => + options?.anthropicCliAuthResult || { + method: 'cli' as const, + source: 'claude-cli-file' as const, + apiKey: 'sk-ant-oat-cli-test', + headers: { + 'anthropic-version': '2023-06-01', + 'anthropic-beta': + 'claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14', + 'user-agent': 'claude-cli/2.1.75', + 'x-app': 'cli', + }, + path: '/tmp/.claude/.credentials.json', + expiresAt: Date.parse('2026-03-13T12:00:00.000Z'), + }, + ); const clearCodexCredentials = vi.fn(() => '/tmp/codex-auth.json'); const getCodexAuthStatus = vi.fn( () => @@ -1074,6 +1126,10 @@ async function importFreshCli(options?: { getHybridAIAuthStatus, loginHybridAIInteractive, })); + vi.doMock('../src/auth/anthropic-auth.ts', () => ({ + getAnthropicAuthStatus, + requireAnthropicCliCredentials, + })); vi.doMock('../src/auth/codex-auth.ts', () => ({ CodexAuthError, clearCodexCredentials, @@ -1268,11 +1324,13 @@ async function importFreshCli(options?: { return { cli, clearHybridAICredentials, + getAnthropicAuthStatus, clearCodexCredentials, getCodexAuthStatus, getHybridAIAuthStatus, loginCodexInteractive, loginHybridAIInteractive, + requireAnthropicCliCredentials, printUpdateUsage, runUpdateCommand, runDoctorCli, @@ -1347,6 +1405,7 @@ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.doUnmock('../src/auth/hybridai-auth.ts'); + vi.doUnmock('../src/auth/anthropic-auth.ts'); vi.doUnmock('../src/auth/codex-auth.ts'); vi.doUnmock('../src/config/cli-flags.ts'); vi.doUnmock('../src/config/config.ts'); @@ -1380,6 +1439,11 @@ afterEach(() => { process.env.HYBRIDCLAW_WHATSAPP_SETUP_SETTLE_MS = ORIGINAL_WHATSAPP_SETUP_SETTLE_MS; } + if (ORIGINAL_ANTHROPIC_API_KEY === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = ORIGINAL_ANTHROPIC_API_KEY; + } if (ORIGINAL_OPENROUTER_API_KEY === undefined) { delete process.env.OPENROUTER_API_KEY; } else { @@ -3698,6 +3762,92 @@ describe('CLI hybridai commands', () => { ); }); + it('routes auth login anthropic with cli credentials to the Anthropic auth flow', async () => { + const { cli, requireAnthropicCliCredentials, updateRuntimeConfig } = + await importFreshCli(); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await cli.main(['auth', 'login', 'anthropic', '--method', 'cli']); + + expect(requireAnthropicCliCredentials).toHaveBeenCalled(); + expect(updateRuntimeConfig).toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith('Provider: anthropic'); + expect(logSpy).toHaveBeenCalledWith('Auth method: cli'); + expect(logSpy).toHaveBeenCalledWith( + 'Using Claude Code credentials from /tmp/.claude/.credentials.json.', + ); + expect(logSpy).toHaveBeenCalledWith( + 'Configured model: anthropic/claude-sonnet-4-6', + ); + }); + + it('configures Anthropic from auth login with --method api-key', async () => { + const { cli, saveRuntimeSecrets, updateRuntimeConfig } = + await importFreshCli(); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await cli.main([ + 'auth', + 'login', + 'anthropic', + 'anthropic/claude-sonnet-4-6', + '--method', + 'api-key', + '--api-key', + 'sk-ant-api-test', + ]); + + expect(saveRuntimeSecrets).toHaveBeenCalledWith({ + ANTHROPIC_API_KEY: 'sk-ant-api-test', + }); + expect(updateRuntimeConfig).toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith('Auth method: api-key'); + expect(logSpy).toHaveBeenCalledWith( + 'Saved Anthropic credentials to /tmp/credentials.json.', + ); + expect(logSpy).toHaveBeenCalledWith( + 'Configured model: anthropic/claude-sonnet-4-6', + ); + }); + + it('prints Anthropic status through auth status', async () => { + const { cli } = await importFreshCli({ + anthropicStatus: { + authenticated: true, + method: 'cli', + source: 'claude-cli-file', + path: '/tmp/.claude/.credentials.json', + maskedValue: 'sk-ant...test', + expiresAt: Date.parse('2026-03-13T12:00:00.000Z'), + isOauthToken: true, + }, + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await cli.main(['auth', 'status', 'anthropic']); + + expect(logSpy).toHaveBeenCalledWith('Authenticated: yes'); + expect(logSpy).toHaveBeenCalledWith('Method: cli'); + expect(logSpy).toHaveBeenCalledWith('Enabled: no'); + expect(logSpy).toHaveBeenCalledWith( + 'Config: /tmp/config.json', + ); + }); + + it('clears Anthropic credentials through auth logout', async () => { + const { cli, saveRuntimeSecrets } = await importFreshCli(); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await cli.main(['auth', 'logout', 'anthropic']); + + expect(saveRuntimeSecrets).toHaveBeenCalledWith({ + ANTHROPIC_API_KEY: null, + }); + expect(logSpy).toHaveBeenCalledWith( + 'Cleared stored Anthropic API key in /tmp/credentials.json.', + ); + }); + it('routes auth login local to local backend configuration', async () => { const { cli, updateRuntimeConfig } = await importFreshCli(); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); diff --git a/tests/container.model-router.test.ts b/tests/container.model-router.test.ts index 60877193..8814c267 100644 --- a/tests/container.model-router.test.ts +++ b/tests/container.model-router.test.ts @@ -14,6 +14,64 @@ afterEach(() => { }); describe('container model router', () => { + test('routes Anthropic text calls through the direct messages API path', async () => { + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + expect(input).toBe('https://api.anthropic.com/v1/messages'); + expect(init?.headers).toMatchObject({ + 'x-api-key': 'anthropic-test-key', + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14', + }); + const body = JSON.parse(String(init?.body || '{}')) as Record< + string, + unknown + >; + expect(body.model).toBe('claude-sonnet-4-6'); + expect(body.stream).toBe(false); + expect(body.max_tokens).toBe(128); + expect(body.messages).toEqual([ + { + role: 'user', + content: [{ type: 'text', text: 'hello' }], + }, + ]); + return new Response( + JSON.stringify({ + id: 'msg_1', + model: 'claude-sonnet-4-6', + role: 'assistant', + stop_reason: 'end_turn', + content: [{ type: 'text', text: 'ok' }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + }, + ); + vi.stubGlobal('fetch', fetchMock); + + const response = await callRoutedModel({ + provider: 'anthropic', + baseUrl: 'https://api.anthropic.com/v1', + apiKey: 'anthropic-test-key', + model: 'anthropic/claude-sonnet-4-6', + chatbotId: '', + requestHeaders: { + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14', + }, + messages: baseMessages, + tools: [], + maxTokens: 128, + }); + + expect(response.choices[0]?.message.content).toBe('ok'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + test('routes OpenRouter text calls through the OpenAI-compatible provider path', async () => { const fetchMock = vi.fn( async (input: RequestInfo | URL, init?: RequestInit) => { diff --git a/tests/providers.factory.test.ts b/tests/providers.factory.test.ts index 3f76c5d7..f6476939 100644 --- a/tests/providers.factory.test.ts +++ b/tests/providers.factory.test.ts @@ -9,6 +9,7 @@ const ORIGINAL_HOME = process.env.HOME; const ORIGINAL_DISABLE_CONFIG_WATCHER = process.env.HYBRIDCLAW_DISABLE_CONFIG_WATCHER; const ORIGINAL_HYBRIDAI_API_KEY = process.env.HYBRIDAI_API_KEY; +const ORIGINAL_ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; const ORIGINAL_OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; const ORIGINAL_MISTRAL_API_KEY = process.env.MISTRAL_API_KEY; const ORIGINAL_HF_TOKEN = process.env.HF_TOKEN; @@ -70,6 +71,7 @@ afterEach(() => { ORIGINAL_DISABLE_CONFIG_WATCHER, ); restoreEnvVar('HYBRIDAI_API_KEY', ORIGINAL_HYBRIDAI_API_KEY); + restoreEnvVar('ANTHROPIC_API_KEY', ORIGINAL_ANTHROPIC_API_KEY); restoreEnvVar('OPENROUTER_API_KEY', ORIGINAL_OPENROUTER_API_KEY); restoreEnvVar('MISTRAL_API_KEY', ORIGINAL_MISTRAL_API_KEY); restoreEnvVar('HF_TOKEN', ORIGINAL_HF_TOKEN); @@ -307,15 +309,31 @@ test('provider factory hot-reloads Hugging Face credentials from runtime secrets }); }); -test('provider factory fails early for unsupported anthropic runtime execution', async () => { +test('provider factory resolves Anthropic runtime credentials', async () => { const homeDir = makeTempHome(); + writeRuntimeConfig(homeDir, (config) => { + config.anthropic.enabled = true; + config.anthropic.baseUrl = 'https://api.anthropic.com/v1/'; + }); + process.env.ANTHROPIC_API_KEY = 'anthropic-provider-test'; const factory = await importFreshFactory(homeDir); - await expect( - factory.resolveModelRuntimeCredentials({ - model: 'anthropic/claude-3-7-sonnet', - }), - ).rejects.toThrow( - 'Anthropic provider is not implemented yet for model "anthropic/claude-3-7-sonnet".', - ); + const credentials = await factory.resolveModelRuntimeCredentials({ + model: 'anthropic/claude-3-7-sonnet', + agentId: 'main', + }); + + expect(credentials).toMatchObject({ + provider: 'anthropic', + apiKey: 'anthropic-provider-test', + baseUrl: 'https://api.anthropic.com/v1', + chatbotId: '', + enableRag: false, + requestHeaders: { + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14', + }, + agentId: 'main', + isLocal: false, + }); }); diff --git a/tests/providers.task-routing.test.ts b/tests/providers.task-routing.test.ts index 129945fa..4925eb28 100644 --- a/tests/providers.task-routing.test.ts +++ b/tests/providers.task-routing.test.ts @@ -12,6 +12,7 @@ const ORIGINAL_AUXILIARY_COMPRESSION_PROVIDER = process.env.AUXILIARY_COMPRESSION_PROVIDER; const ORIGINAL_AUXILIARY_COMPRESSION_MODEL = process.env.AUXILIARY_COMPRESSION_MODEL; +const ORIGINAL_ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; const ORIGINAL_OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; function makeTempHome(): string { @@ -55,6 +56,7 @@ async function importFreshTaskRouting(homeDir: string) { afterEach(() => { vi.restoreAllMocks(); vi.resetModules(); + vi.doUnmock('../src/auth/anthropic-auth.js'); vi.doUnmock('../src/logger.js'); vi.doUnmock('../src/providers/factory.js'); restoreEnvVar('HOME', ORIGINAL_HOME); @@ -70,6 +72,7 @@ afterEach(() => { 'AUXILIARY_COMPRESSION_MODEL', ORIGINAL_AUXILIARY_COMPRESSION_MODEL, ); + restoreEnvVar('ANTHROPIC_API_KEY', ORIGINAL_ANTHROPIC_API_KEY); restoreEnvVar('OPENROUTER_API_KEY', ORIGINAL_OPENROUTER_API_KEY); }); @@ -200,9 +203,12 @@ test('captures env overrides at module load', async () => { expect(policy?.maxTokens).toBeUndefined(); }); -test('captures unsupported vision task model config as a deferred policy error', async () => { +test('resolves configured Anthropic task models on the host', async () => { const homeDir = makeTempHome(); + process.env.ANTHROPIC_API_KEY = 'anthropic-task-routing-test'; writeRuntimeConfig(homeDir, (config) => { + config.anthropic.enabled = true; + config.anthropic.baseUrl = 'https://api.anthropic.com/v1/'; config.auxiliaryModels.vision.model = 'anthropic/claude-3-7-sonnet'; config.auxiliaryModels.vision.maxTokens = 512; config.auxiliaryModels.compression.model = 'anthropic/claude-3-7-sonnet'; @@ -217,25 +223,44 @@ test('captures unsupported vision task model config as a deferred policy error', expect(taskModels).toMatchObject({ vision: { + provider: 'anthropic', + baseUrl: 'https://api.anthropic.com/v1', + apiKey: 'anthropic-task-routing-test', model: 'anthropic/claude-3-7-sonnet', + chatbotId: '', + requestHeaders: { + 'anthropic-version': '2023-06-01', + }, maxTokens: 512, - error: expect.stringContaining( - 'Anthropic provider is not implemented yet', - ), }, compression: { + provider: 'anthropic', + baseUrl: 'https://api.anthropic.com/v1', + apiKey: 'anthropic-task-routing-test', model: 'anthropic/claude-3-7-sonnet', + chatbotId: '', + requestHeaders: { + 'anthropic-version': '2023-06-01', + }, maxTokens: 256, - error: expect.stringContaining( - 'Anthropic provider is not implemented yet', - ), }, }); }); test('warns when task model policy resolution fails and returns a deferred error', async () => { const homeDir = makeTempHome(); + vi.doMock('../src/auth/anthropic-auth.js', () => ({ + resolveAnthropicAuth: vi.fn(() => { + throw new Error( + [ + 'Claude CLI is not authenticated on this host.', + 'Run `claude auth login`, then rerun `hybridclaw auth login anthropic --method cli --set-default`.', + ].join('\n'), + ); + }), + })); writeRuntimeConfig(homeDir, (config) => { + config.anthropic.enabled = true; config.auxiliaryModels.vision.model = 'anthropic/claude-3-7-sonnet'; config.auxiliaryModels.vision.maxTokens = 512; }); @@ -255,7 +280,9 @@ test('warns when task model policy resolution fails and returns a deferred error expect(policy).toMatchObject({ model: 'anthropic/claude-3-7-sonnet', maxTokens: 512, - error: expect.stringContaining('Anthropic provider is not implemented yet'), + error: expect.stringContaining( + 'Claude CLI is not authenticated on this host', + ), }); expect(warn).toHaveBeenCalledWith( expect.objectContaining({ From 0ff532bd8dcf8c2b97d918204bc4ee533e35b976 Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Sun, 5 Apr 2026 20:06:40 +0200 Subject: [PATCH 2/5] chore: apply formatting fixes --- container/src/providers/anthropic.ts | 14 +++++++++++--- tests/cli.test.ts | 4 +--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/container/src/providers/anthropic.ts b/container/src/providers/anthropic.ts index 62dd4688..21428d59 100644 --- a/container/src/providers/anthropic.ts +++ b/container/src/providers/anthropic.ts @@ -244,7 +244,9 @@ function mapStopReason(stopReason: string | null | undefined): string { return stopReason || 'stop'; } -function parseUsage(value: unknown): ChatCompletionResponse['usage'] | undefined { +function parseUsage( + value: unknown, +): ChatCompletionResponse['usage'] | undefined { if (!isRecord(value)) return undefined; const inputTokens = @@ -444,7 +446,10 @@ export async function callAnthropicProvider( }); if (!response.ok) { - throw new HybridAIRequestError(response.status, await readErrorBody(response)); + throw new HybridAIRequestError( + response.status, + await readErrorBody(response), + ); } return adaptAnthropicResponse(await response.json(), args.model); @@ -460,7 +465,10 @@ export async function callAnthropicProviderStream( }); if (!response.ok) { - throw new HybridAIRequestError(response.status, await readErrorBody(response)); + throw new HybridAIRequestError( + response.status, + await readErrorBody(response), + ); } if (!response.body) { throw new Error('Anthropic stream response body is missing.'); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index f48c3ad4..213120c6 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -3829,9 +3829,7 @@ describe('CLI hybridai commands', () => { expect(logSpy).toHaveBeenCalledWith('Authenticated: yes'); expect(logSpy).toHaveBeenCalledWith('Method: cli'); expect(logSpy).toHaveBeenCalledWith('Enabled: no'); - expect(logSpy).toHaveBeenCalledWith( - 'Config: /tmp/config.json', - ); + expect(logSpy).toHaveBeenCalledWith('Config: /tmp/config.json'); }); it('clears Anthropic credentials through auth logout', async () => { From f6badeba4f0b5b51b1f2596ac2899a0d85e065e4 Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Sun, 5 Apr 2026 21:35:02 +0200 Subject: [PATCH 3/5] fix: simplify anthropic claude cli transport --- config.example.json | 1 + container/shared/provider-context.d.ts | 1 + container/shared/provider-context.js | 6 +- container/src/browser-tools.ts | 3 + container/src/index.ts | 16 +- container/src/providers/anthropic.ts | 228 +++++++++++++++++++ container/src/providers/auxiliary.ts | 4 + container/src/providers/router.ts | 4 + container/src/providers/shared.ts | 43 ++-- container/src/tools.ts | 2 + container/src/types.ts | 8 +- src/auth/anthropic-auth.ts | 54 ++--- src/cli.ts | 2 +- src/cli/auth-command.ts | 43 ++-- src/cli/help.ts | 13 +- src/config/config.ts | 4 +- src/config/runtime-config.ts | 36 ++- src/doctor/checks/providers.ts | 16 +- src/doctor/provider-probes.ts | 56 ++++- src/infra/container-runner.ts | 4 + src/infra/host-runner.ts | 3 + src/infra/worker-signature.ts | 4 + src/onboarding.ts | 51 +++-- src/providers/anthropic.ts | 27 ++- src/providers/auxiliary.ts | 5 + src/providers/task-routing.ts | 2 + src/providers/types.ts | 1 + src/types/container.ts | 1 + src/types/models.ts | 3 + tests/cli.test.ts | 51 ++--- tests/container.anthropic-claude-cli.test.ts | 215 +++++++++++++++++ tests/providers.task-routing.test.ts | 8 +- 32 files changed, 775 insertions(+), 140 deletions(-) create mode 100644 tests/container.anthropic-claude-cli.test.ts diff --git a/config.example.json b/config.example.json index 18cd46fc..295bb820 100644 --- a/config.example.json +++ b/config.example.json @@ -189,6 +189,7 @@ "anthropic": { "enabled": false, "baseUrl": "https://api.anthropic.com/v1", + "method": "api-key", "models": ["anthropic/claude-sonnet-4-6"] }, "openrouter": { diff --git a/container/shared/provider-context.d.ts b/container/shared/provider-context.d.ts index a4a33e43..8aa174c7 100644 --- a/container/shared/provider-context.d.ts +++ b/container/shared/provider-context.d.ts @@ -1,5 +1,6 @@ export interface ProviderContextValidationParams { provider?: string; + providerMethod?: string; baseUrl?: string; apiKey?: string; model?: string; diff --git a/container/shared/provider-context.js b/container/shared/provider-context.js index ccd63bc8..4bae270c 100644 --- a/container/shared/provider-context.js +++ b/container/shared/provider-context.js @@ -16,6 +16,9 @@ function buildMissingContextError(params) { export function getProviderContextError(params) { const provider = String(params.provider || 'hybridai').trim() || 'hybridai'; + const anthropicUsesClaudeCli = + provider === 'anthropic' && + String(params.providerMethod || '') === 'claude-cli'; if (!String(params.baseUrl || '').trim()) { return buildMissingContextError({ toolName: params.toolName, @@ -32,7 +35,8 @@ export function getProviderContextError(params) { } if ( API_KEY_REQUIRED_PROVIDERS.has(provider) && - !String(params.apiKey || '').trim() + !String(params.apiKey || '').trim() && + !anthropicUsesClaudeCli ) { return buildMissingContextError({ toolName: params.toolName, diff --git a/container/src/browser-tools.ts b/container/src/browser-tools.ts index bbcd6c60..880b21ba 100644 --- a/container/src/browser-tools.ts +++ b/container/src/browser-tools.ts @@ -178,6 +178,7 @@ type BrowserModelContext = { | 'lmstudio' | 'llamacpp' | 'vllm'; + providerMethod?: string; baseUrl: string; apiKey: string; model: string; @@ -248,6 +249,7 @@ export function setBrowserModelContext( | 'llamacpp' | 'vllm' | undefined, + providerMethod: string | undefined, baseUrl: string, apiKey: string, model: string, @@ -257,6 +259,7 @@ export function setBrowserModelContext( ): void { currentBrowserModelContext = { provider: provider || 'hybridai', + providerMethod, baseUrl: String(baseUrl || '') .trim() .replace(/\/+$/, ''), diff --git a/container/src/index.ts b/container/src/index.ts index bf0af778..edeb8047 100644 --- a/container/src/index.ts +++ b/container/src/index.ts @@ -183,6 +183,8 @@ function resolveTaskModelsForRequest( !incomingTaskModel.error && String(incomingTaskModel.provider || '') === String(storedTaskModel?.provider || '') && + String(incomingTaskModel.providerMethod || '') === + String(storedTaskModel?.providerMethod || '') && normalizeTaskModelBaseUrl(incomingTaskModel.baseUrl) === normalizeTaskModelBaseUrl(storedTaskModel?.baseUrl) && String(incomingTaskModel.model || '').trim() === @@ -698,6 +700,7 @@ async function callHybridAIWithRetry(params: { | 'lmstudio' | 'llamacpp' | 'vllm'; + providerMethod?: string; baseUrl: string; apiKey: string; model: string; @@ -715,6 +718,7 @@ async function callHybridAIWithRetry(params: { }): Promise { const { provider, + providerMethod, baseUrl, apiKey, model, @@ -746,6 +750,7 @@ async function callHybridAIWithRetry(params: { try { response = await callRoutedModelStream({ provider, + providerMethod, baseUrl, apiKey, model, @@ -769,6 +774,7 @@ async function callHybridAIWithRetry(params: { if (!fallbackEligible) throw streamErr; response = await callRoutedModel({ provider, + providerMethod, baseUrl, apiKey, model, @@ -837,7 +843,7 @@ async function processRequest( messages: ChatMessage[], apiKey: string, baseUrl: string, - provider: + provider: | 'hybridai' | 'openai-codex' | 'anthropic' @@ -849,6 +855,7 @@ async function processRequest( | 'llamacpp' | 'vllm' | undefined, + providerMethod: string | undefined, isLocal: boolean | undefined, contextWindow: number | undefined, thinkingFormat: 'qwen' | undefined, @@ -995,6 +1002,7 @@ async function processRequest( try { response = await callHybridAIWithRetry({ provider, + providerMethod, baseUrl, apiKey, model, @@ -1553,6 +1561,7 @@ async function main(): Promise { setWebSearchConfig(firstInput.webSearch); setModelContext( firstInput.provider, + firstInput.providerMethod, firstInput.baseUrl, storedApiKey, firstInput.model, @@ -1600,6 +1609,7 @@ async function main(): Promise { storedApiKey, firstInput.baseUrl, firstInput.provider, + firstInput.providerMethod, firstInput.isLocal, firstInput.contextWindow, firstInput.thinkingFormat, @@ -1634,6 +1644,7 @@ async function main(): Promise { storedApiKey, firstInput.baseUrl, firstInput.provider, + firstInput.providerMethod, firstInput.isLocal, firstInput.contextWindow, firstInput.thinkingFormat, @@ -1699,6 +1710,7 @@ async function main(): Promise { setWebSearchConfig(input.webSearch); setModelContext( input.provider, + input.providerMethod, input.baseUrl, apiKey, input.model, @@ -1750,6 +1762,7 @@ async function main(): Promise { apiKey, input.baseUrl, input.provider, + input.providerMethod, input.isLocal, input.contextWindow, input.thinkingFormat, @@ -1783,6 +1796,7 @@ async function main(): Promise { apiKey, input.baseUrl, input.provider, + input.providerMethod, input.isLocal, input.contextWindow, input.thinkingFormat, diff --git a/container/src/providers/anthropic.ts b/container/src/providers/anthropic.ts index 21428d59..975fd8ff 100644 --- a/container/src/providers/anthropic.ts +++ b/container/src/providers/anthropic.ts @@ -1,3 +1,4 @@ +import { spawn } from 'node:child_process'; import { TextDecoder } from 'node:util'; import { collapseSystemMessages } from '../system-messages.js'; import type { @@ -37,6 +38,11 @@ type AnthropicStreamBlock = | AnthropicTextStreamBlock | AnthropicToolUseStreamBlock; +interface ClaudeCliResult { + responseId: string; + text: string; +} + function normalizeAnthropicModelName(model: string): string { const trimmed = String(model || '').trim(); if (!trimmed.toLowerCase().startsWith('anthropic/')) return trimmed; @@ -49,6 +55,12 @@ function normalizeBaseUrl(baseUrl: string): string { .replace(/\/+$/g, ''); } +function usesClaudeCliTransport( + args: Pick, +): boolean { + return args.providerMethod === 'claude-cli'; +} + function isAnthropicOAuthToken(apiKey: string): boolean { return String(apiKey || '').includes('sk-ant-oat'); } @@ -77,6 +89,188 @@ function normalizeMessageText(content: ChatMessage['content']): string { .join('\n'); } +function summarizeMessageForClaudeCli(message: ChatMessage): string { + const text = normalizeMessageText(message.content).trim(); + const imageCount = Array.isArray(message.content) + ? message.content.filter((part) => part.type === 'image_url').length + : 0; + const toolCallSummary = + message.role === 'assistant' && Array.isArray(message.tool_calls) + ? message.tool_calls + .map((toolCall) => { + const args = toolCall.function.arguments.trim(); + return `- ${toolCall.function.name}${args ? ` ${args}` : ''}`; + }) + .join('\n') + : ''; + const parts = [text]; + if (imageCount > 0) { + parts.push(`[${imageCount} image input${imageCount === 1 ? '' : 's'} omitted in claude-cli transport]`); + } + if (toolCallSummary) { + parts.push(`Assistant tool calls:\n${toolCallSummary}`); + } + const combined = parts.filter(Boolean).join('\n\n').trim(); + return combined || '[no text content]'; +} + +function buildClaudeCliPrompt(messages: ChatMessage[]): string { + const normalized = collapseSystemMessages(messages); + const transcript = normalized + .filter((message) => message.role !== 'system') + .map((message) => { + const label = + message.role === 'tool' + ? `Tool result (${message.tool_call_id || 'unknown'})` + : message.role[0]?.toUpperCase() + message.role.slice(1); + return `${label}:\n${summarizeMessageForClaudeCli(message)}`; + }) + .join('\n\n'); + return [ + 'Continue this conversation transcript.', + transcript, + 'Reply to the latest user request.', + ] + .filter(Boolean) + .join('\n\n') + .trim(); +} + +function isHostSandboxRuntime(): boolean { + return process.env.HYBRIDCLAW_AGENT_SANDBOX_MODE === 'host'; +} + +function extractClaudeCliText(value: unknown): string { + if (typeof value === 'string') return value; + if (!isRecord(value)) return ''; + if (typeof value.result === 'string') return value.result; + if (typeof value.text === 'string') return value.text; + if (Array.isArray(value.content)) { + return value.content.map(extractClaudeCliText).filter(Boolean).join(''); + } + if (typeof value.type === 'string' && value.type === 'text') { + return typeof value.text === 'string' ? value.text : ''; + } + if (isRecord(value.message)) { + return extractClaudeCliText(value.message); + } + return ''; +} + +async function runClaudeCliCommand( + args: Pick< + NormalizedCallArgs, + 'model' | 'messages' | 'providerMethod' + > & { + onTextDelta?: (delta: string) => void; + }, +): Promise { + if (!isHostSandboxRuntime()) { + throw new Error( + 'Anthropic `--method claude-cli` requires `--sandbox=host`. Switch HybridClaw to host sandbox mode, or use `--method api-key` for container mode.', + ); + } + let responseId = 'claude-cli'; + let finalText = ''; + let streamedText = ''; + let stderr = ''; + let buffer = ''; + + const systemPrompt = extractSystemPrompt(args.messages); + const prompt = buildClaudeCliPrompt(args.messages); + const commandArgs = [ + '-p', + prompt, + '--verbose', + '--output-format', + 'stream-json', + '--permission-mode', + 'bypassPermissions', + '--model', + normalizeAnthropicModelName(args.model), + ...(systemPrompt ? ['--append-system-prompt', systemPrompt] : []), + ]; + + const child = spawn('claude', commandArgs, { + cwd: process.cwd(), + env: { ...process.env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString('utf8'); + }); + + child.stdout.on('data', (chunk) => { + buffer += chunk.toString('utf8'); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() || ''; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + try { + const payload = JSON.parse(line) as Record; + if (typeof payload.session_id === 'string' && payload.session_id) { + responseId = payload.session_id; + } + const text = extractClaudeCliText(payload); + if (typeof payload.type === 'string' && payload.type === 'result') { + finalText = text; + continue; + } + if (!text) continue; + if (text.startsWith(streamedText)) { + const delta = text.slice(streamedText.length); + if (delta) args.onTextDelta?.(delta); + streamedText = text; + continue; + } + streamedText += text; + args.onTextDelta?.(text); + } catch { + streamedText += line; + args.onTextDelta?.(line); + } + } + }); + + const exitCode = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code) => resolve(code ?? 1)); + }); + + if (buffer.trim()) { + try { + const payload = JSON.parse(buffer.trim()) as Record; + const text = extractClaudeCliText(payload); + if (typeof payload.type === 'string' && payload.type === 'result') { + finalText = text || finalText; + } else if (text) { + if (text.startsWith(streamedText)) { + const delta = text.slice(streamedText.length); + if (delta) args.onTextDelta?.(delta); + streamedText = text; + } else { + streamedText += text; + args.onTextDelta?.(text); + } + } + } catch { + streamedText += buffer.trim(); + args.onTextDelta?.(buffer.trim()); + } + } + + if (exitCode !== 0) { + throw new Error(stderr.trim() || `claude exited with status ${exitCode}.`); + } + + return { + responseId, + text: finalText || streamedText, + }; +} + function parseDataUrlImage( url: string, ): { mediaType: string; data: string } | null { @@ -439,6 +633,23 @@ async function readErrorBody(response: Response): Promise { export async function callAnthropicProvider( args: NormalizedCallArgs, ): Promise { + if (usesClaudeCliTransport(args)) { + const result = await runClaudeCliCommand(args); + return { + id: result.responseId, + model: normalizeAnthropicModelName(args.model), + choices: [ + { + message: { + role: 'assistant', + content: result.text, + }, + finish_reason: 'stop', + }, + ], + }; + } + const response = await fetch(`${normalizeBaseUrl(args.baseUrl)}/messages`, { method: 'POST', headers: buildHeaders(args), @@ -458,6 +669,23 @@ export async function callAnthropicProvider( export async function callAnthropicProviderStream( args: NormalizedStreamCallArgs, ): Promise { + if (usesClaudeCliTransport(args)) { + const result = await runClaudeCliCommand(args); + return { + id: result.responseId, + model: normalizeAnthropicModelName(args.model), + choices: [ + { + message: { + role: 'assistant', + content: result.text, + }, + finish_reason: 'stop', + }, + ], + }; + } + const response = await fetch(`${normalizeBaseUrl(args.baseUrl)}/messages`, { method: 'POST', headers: buildHeaders({ ...args, stream: true }), diff --git a/container/src/providers/auxiliary.ts b/container/src/providers/auxiliary.ts index e99fe3ff..ba4b9673 100644 --- a/container/src/providers/auxiliary.ts +++ b/container/src/providers/auxiliary.ts @@ -69,6 +69,7 @@ function getAuxiliaryContextError(params: { }): string | null { return getProviderContextError({ provider: params.context.provider, + providerMethod: params.context.providerMethod, baseUrl: params.context.baseUrl, apiKey: params.context.apiKey, model: params.context.model, @@ -94,6 +95,7 @@ export function resolveAuxiliaryTaskContext(params: { } return { provider: taskOverride.provider, + providerMethod: taskOverride.providerMethod, baseUrl: taskOverride.baseUrl?.trim() ?? '', apiKey: taskOverride.apiKey?.trim() ?? '', model: taskOverride.model.trim(), @@ -158,6 +160,7 @@ export async function callAuxiliaryModel( }); const response = await callRoutedModel({ provider: context.provider, + providerMethod: context.providerMethod, baseUrl: context.baseUrl, apiKey: context.apiKey, model: context.model, @@ -191,6 +194,7 @@ export async function callAuxiliaryModel( }); return await callVisionProviderModel({ provider: context.provider, + providerMethod: context.providerMethod, baseUrl: context.baseUrl, apiKey: context.apiKey, model: context.model, diff --git a/container/src/providers/router.ts b/container/src/providers/router.ts index 46cc3e0a..19d4efeb 100644 --- a/container/src/providers/router.ts +++ b/container/src/providers/router.ts @@ -40,6 +40,7 @@ const DEFAULT_VISION_INSTRUCTIONS = export interface RoutedModelContext { provider: RuntimeProvider | undefined; + providerMethod?: string; baseUrl: string; apiKey: string; model: string; @@ -72,6 +73,7 @@ export interface RoutedVisionCallParams extends RoutedModelContext { function buildCallArgs(params: RoutedModelCallParams): NormalizedCallArgs { return { provider: params.provider, + providerMethod: params.providerMethod, baseUrl: params.baseUrl.trim(), apiKey: params.apiKey.trim(), model: params.model.trim(), @@ -198,6 +200,7 @@ export function getVisionModelContextError( ): string | null { return getProviderContextError({ provider: params.provider, + providerMethod: params.providerMethod, baseUrl: params.baseUrl, apiKey: params.apiKey, model: params.model, @@ -218,6 +221,7 @@ export async function callVisionProviderModel( const request = { provider: params.provider, + providerMethod: params.providerMethod, baseUrl: normalizeVisionBaseUrl(params.provider, params.baseUrl), apiKey: params.apiKey, model: params.model, diff --git a/container/src/providers/shared.ts b/container/src/providers/shared.ts index a03c87a1..778c6f20 100644 --- a/container/src/providers/shared.ts +++ b/container/src/providers/shared.ts @@ -10,6 +10,7 @@ export type { RuntimeProvider } from './provider-ids.js'; export interface NormalizedCallArgs { provider: RuntimeProvider | undefined; + providerMethod?: string; baseUrl: string; apiKey: string; model: string; @@ -187,16 +188,17 @@ export function normalizeCallArgs(rawArgs: unknown[]): NormalizedCallArgs { provider: rawArgs[0], baseUrl: String(rawArgs[1] || ''), apiKey: String(rawArgs[2] || ''), - model: String(rawArgs[3] || ''), - chatbotId: String(rawArgs[4] || ''), - enableRag: Boolean(rawArgs[5]), - requestHeaders: isStringRecord(rawArgs[6]) ? rawArgs[6] : undefined, - messages: (rawArgs[7] as ChatMessage[]) || [], - tools: (rawArgs[8] as ToolDefinition[]) || [], - maxTokens: typeof rawArgs[9] === 'number' ? rawArgs[9] : undefined, - isLocal: Boolean(rawArgs[10]), - contextWindow: typeof rawArgs[11] === 'number' ? rawArgs[11] : undefined, - thinkingFormat: rawArgs[12] === 'qwen' ? 'qwen' : undefined, + providerMethod: typeof rawArgs[3] === 'string' ? rawArgs[3] : undefined, + model: String(rawArgs[4] || ''), + chatbotId: String(rawArgs[5] || ''), + enableRag: Boolean(rawArgs[6]), + requestHeaders: isStringRecord(rawArgs[7]) ? rawArgs[7] : undefined, + messages: (rawArgs[8] as ChatMessage[]) || [], + tools: (rawArgs[9] as ToolDefinition[]) || [], + maxTokens: typeof rawArgs[10] === 'number' ? rawArgs[10] : undefined, + isLocal: Boolean(rawArgs[11]), + contextWindow: typeof rawArgs[12] === 'number' ? rawArgs[12] : undefined, + thinkingFormat: rawArgs[13] === 'qwen' ? 'qwen' : undefined, }; } @@ -222,10 +224,10 @@ export function normalizeStreamCallArgs( ): NormalizedStreamCallArgs { if (isRuntimeProvider(rawArgs[0])) { const onActivity = - typeof rawArgs[10] === 'function' - ? (rawArgs[10] as () => void) + typeof rawArgs[11] === 'function' + ? (rawArgs[11] as () => void) : () => undefined; - const maxTokensIndex = typeof rawArgs[10] === 'function' ? 11 : 10; + const maxTokensIndex = typeof rawArgs[11] === 'function' ? 12 : 11; const isLocalIndex = maxTokensIndex + 1; const contextWindowIndex = maxTokensIndex + 2; const thinkingFormatIndex = maxTokensIndex + 3; @@ -233,13 +235,14 @@ export function normalizeStreamCallArgs( provider: rawArgs[0], baseUrl: String(rawArgs[1] || ''), apiKey: String(rawArgs[2] || ''), - model: String(rawArgs[3] || ''), - chatbotId: String(rawArgs[4] || ''), - enableRag: Boolean(rawArgs[5]), - requestHeaders: isStringRecord(rawArgs[6]) ? rawArgs[6] : undefined, - messages: (rawArgs[7] as ChatMessage[]) || [], - tools: (rawArgs[8] as ToolDefinition[]) || [], - onTextDelta: (rawArgs[9] as (delta: string) => void) || (() => {}), + providerMethod: typeof rawArgs[3] === 'string' ? rawArgs[3] : undefined, + model: String(rawArgs[4] || ''), + chatbotId: String(rawArgs[5] || ''), + enableRag: Boolean(rawArgs[6]), + requestHeaders: isStringRecord(rawArgs[7]) ? rawArgs[7] : undefined, + messages: (rawArgs[8] as ChatMessage[]) || [], + tools: (rawArgs[9] as ToolDefinition[]) || [], + onTextDelta: (rawArgs[10] as (delta: string) => void) || (() => {}), onActivity, maxTokens: typeof rawArgs[maxTokensIndex] === 'number' diff --git a/container/src/tools.ts b/container/src/tools.ts index 6590b7e7..bc18b372 100644 --- a/container/src/tools.ts +++ b/container/src/tools.ts @@ -502,6 +502,7 @@ export function setModelContext( | 'llamacpp' | 'vllm' | undefined, + providerMethod: string | undefined, baseUrl: string, apiKey: string, model: string, @@ -521,6 +522,7 @@ export function setModelContext( : undefined; setBrowserModelContext( provider, + providerMethod, baseUrl, apiKey, model, diff --git a/container/src/types.ts b/container/src/types.ts index b4009af4..336e12dc 100644 --- a/container/src/types.ts +++ b/container/src/types.ts @@ -120,9 +120,10 @@ export interface TaskModelPolicy { | 'mistral' | 'huggingface' | 'ollama' - | 'lmstudio' - | 'llamacpp' - | 'vllm'; + | 'lmstudio' + | 'llamacpp' + | 'vllm'; + providerMethod?: string; baseUrl?: string; apiKey?: string; requestHeaders?: Record; @@ -210,6 +211,7 @@ export interface ContainerInput { | 'lmstudio' | 'llamacpp' | 'vllm'; + providerMethod?: string; requestHeaders?: Record; isLocal?: boolean; contextWindow?: number; diff --git a/src/auth/anthropic-auth.ts b/src/auth/anthropic-auth.ts index 5fb8e8fe..a5e9fc0f 100644 --- a/src/auth/anthropic-auth.ts +++ b/src/auth/anthropic-auth.ts @@ -10,10 +10,10 @@ import { readStoredRuntimeSecret, runtimeSecretsPath, } from '../security/runtime-secrets.js'; +import type { AnthropicMethod } from '../types/models.js'; const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = '.claude/.credentials.json'; const CLAUDE_CLI_KEYCHAIN_SERVICE = 'Claude Code-credentials'; -const CLAUDE_CLI_KEYCHAIN_ACCOUNT = 'Claude Code'; type CliSource = 'claude-cli-keychain' | 'claude-cli-file'; type ApiKeySource = 'env' | 'runtime-secrets'; @@ -36,8 +36,8 @@ export type ClaudeCliCredential = }; export interface AnthropicResolvedAuth { - method: 'api-key' | 'cli'; - source: ApiKeySource | CliSource; + method: 'api-key'; + source: ApiKeySource; apiKey: string; headers: Record; path: string; @@ -46,7 +46,7 @@ export interface AnthropicResolvedAuth { export interface AnthropicAuthStatus { authenticated: boolean; - method: 'api-key' | 'cli' | null; + method: AnthropicMethod | null; source: ApiKeySource | CliSource | null; path: string; maskedValue: string | null; @@ -58,6 +58,10 @@ export function claudeCliCredentialsPath(): string { return path.join(homedir(), CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH); } +export function claudeCliKeychainLabel(): string { + return `macOS Keychain (${CLAUDE_CLI_KEYCHAIN_SERVICE})`; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -117,14 +121,7 @@ function readClaudeCliKeychainCredentials(): ClaudeCliCredential | null { try { const raw = execFileSync( 'security', - [ - 'find-generic-password', - '-s', - CLAUDE_CLI_KEYCHAIN_SERVICE, - '-a', - CLAUDE_CLI_KEYCHAIN_ACCOUNT, - '-w', - ], + ['find-generic-password', '-s', CLAUDE_CLI_KEYCHAIN_SERVICE, '-w'], { encoding: 'utf8', timeout: 5_000, @@ -206,22 +203,25 @@ export function getAnthropicAuthStatus(): AnthropicAuthStatus { return { authenticated, - method: credential ? 'cli' : null, + method: credential ? 'claude-cli' : null, source: credential?.source || null, - path: claudeCliCredentialsPath(), + path: + credential?.source === 'claude-cli-keychain' + ? claudeCliKeychainLabel() + : claudeCliCredentialsPath(), maskedValue: token ? maskValue(token) : null, expiresAt, isOauthToken: token ? isAnthropicOAuthToken(token) : false, }; } -export function requireAnthropicCliCredentials(): AnthropicResolvedAuth { +export function requireAnthropicClaudeCliCredential(): ClaudeCliCredential { const credential = readClaudeCliCredentials(); if (!credential) { throw new Error( [ 'Claude CLI is not authenticated on this host.', - 'Run `claude auth login`, then rerun `hybridclaw auth login anthropic --method cli --set-default`.', + 'Run `claude auth login`, then rerun `hybridclaw auth login anthropic --method claude-cli --set-default`.', ].join('\n'), ); } @@ -229,23 +229,14 @@ export function requireAnthropicCliCredentials(): AnthropicResolvedAuth { throw new Error( [ 'Claude CLI credentials on this host are expired.', - 'Run `claude auth login` to refresh them, then rerun the HybridClaw auth command.', + 'Run `claude auth login` to refresh them, then rerun the HybridClaw Anthropic auth command.', ].join('\n'), ); } - const apiKey = - credential.type === 'oauth' ? credential.accessToken : credential.token; - return { - method: 'cli', - source: credential.source, - apiKey, - headers: buildAnthropicRequestHeaders({ apiKey }), - path: claudeCliCredentialsPath(), - expiresAt: credential.expiresAt, - }; + return credential; } -export function resolveAnthropicAuth(): AnthropicResolvedAuth { +export function requireAnthropicApiKey(): AnthropicResolvedAuth { const storedApiKey = resolveStoredAnthropicApiKey(); if (storedApiKey.apiKey) { return { @@ -257,5 +248,10 @@ export function resolveAnthropicAuth(): AnthropicResolvedAuth { expiresAt: null, }; } - return requireAnthropicCliCredentials(); + throw new Error( + [ + `ANTHROPIC_API_KEY is missing from your shell and ${runtimeSecretsPath()}.`, + 'Run `hybridclaw auth login anthropic --method api-key --set-default` to configure the direct Anthropic API provider.', + ].join('\n'), + ); } diff --git a/src/cli.ts b/src/cli.ts index fbb2e166..afca84de 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1649,7 +1649,7 @@ function printMissingEnvVarError(message: string, envVar?: string): void { }; const envVarHint: Record = { HYBRIDAI_API_KEY: `Run \`hybridclaw auth login hybridai\`, or set HYBRIDAI_API_KEY in ${runtimeSecretsPath()} or your shell, then run the command again.`, - ANTHROPIC_API_KEY: `Run \`claude auth login\` and then \`hybridclaw auth login anthropic --method cli\`, or set ANTHROPIC_API_KEY in ${runtimeSecretsPath()} or your shell, then run the command again.`, + ANTHROPIC_API_KEY: `Run \`hybridclaw auth login anthropic --method api-key\`, or run \`claude auth login\` and then \`hybridclaw auth login anthropic --method claude-cli\`, or set ANTHROPIC_API_KEY in ${runtimeSecretsPath()} or your shell, then run the command again.`, OPENROUTER_API_KEY: `Run \`hybridclaw auth login openrouter\`, or set OPENROUTER_API_KEY in ${runtimeSecretsPath()} or your shell, then run the command again.`, MISTRAL_API_KEY: `Run \`hybridclaw auth login mistral\`, or set MISTRAL_API_KEY in ${runtimeSecretsPath()} or your shell, then run the command again.`, HF_TOKEN: `Run \`hybridclaw auth login huggingface\`, or set HF_TOKEN in ${runtimeSecretsPath()} or your shell, then run the command again.`, diff --git a/src/cli/auth-command.ts b/src/cli/auth-command.ts index 5d1b3602..4fd499f5 100644 --- a/src/cli/auth-command.ts +++ b/src/cli/auth-command.ts @@ -246,7 +246,7 @@ interface ParsedAnthropicLoginArgs { modelId?: string; baseUrl?: string; apiKey?: string; - method: 'cli' | 'api-key'; + method: 'claude-cli' | 'api-key'; setDefault: boolean; } @@ -254,7 +254,7 @@ function parseAnthropicLoginArgs(args: string[]): ParsedAnthropicLoginArgs { const positional: string[] = []; const { baseUrl, remaining } = extractBaseUrlArg(args); let apiKey: string | undefined; - let method: 'cli' | 'api-key' | undefined; + let method: 'claude-cli' | 'api-key' | undefined; let setDefault = true; for (let index = 0; index < remaining.length; index += 1) { @@ -272,13 +272,19 @@ function parseAnthropicLoginArgs(args: string[]): ParsedAnthropicLoginArgs { args: remaining, index, name: '--method', - placeholder: '', + placeholder: '', allowEmptyEquals: true, }); if (methodFlag) { const normalizedMethod = methodFlag.value.trim().toLowerCase(); - if (normalizedMethod === 'cli') { - method = 'cli'; + if ( + normalizedMethod === 'claude-cli' || + normalizedMethod === 'claude_cli' || + normalizedMethod === 'claudecli' || + normalizedMethod === 'cli' || + normalizedMethod === 'claude' + ) { + method = 'claude-cli'; } else if ( normalizedMethod === 'api-key' || normalizedMethod === 'apikey' || @@ -287,7 +293,7 @@ function parseAnthropicLoginArgs(args: string[]): ParsedAnthropicLoginArgs { method = 'api-key'; } else { throw new Error( - `Unknown Anthropic auth method "${methodFlag.value}". Use \`cli\` or \`api-key\`.`, + `Unknown Anthropic auth method "${methodFlag.value}". Use \`api-key\` or \`claude-cli\`.`, ); } index = methodFlag.nextIndex; @@ -313,10 +319,10 @@ function parseAnthropicLoginArgs(args: string[]): ParsedAnthropicLoginArgs { positional.push(arg); } - const resolvedMethod = method || 'cli'; - if (resolvedMethod === 'cli' && apiKey !== undefined) { + const resolvedMethod = method || 'api-key'; + if (resolvedMethod === 'claude-cli' && apiKey !== undefined) { throw new Error( - '`--api-key` cannot be used with `--method cli`. Use `--method api-key` or omit `--method` when passing an API key.', + '`--api-key` cannot be used with `--method claude-cli`. Use `--method api-key` or omit `--method` when passing an API key.', ); } @@ -640,10 +646,11 @@ async function configureAnthropic(args: string[]): Promise { let cliCredentialPath: string | null = null; let expiresAt: number | null = null; - if (parsed.method === 'cli') { - const auth = getAnthropicAuthApi().requireAnthropicCliCredentials(); - cliCredentialPath = auth.path; - expiresAt = auth.expiresAt; + if (parsed.method === 'claude-cli') { + const credential = + getAnthropicAuthApi().requireAnthropicClaudeCliCredential(); + cliCredentialPath = getAnthropicAuthApi().getAnthropicAuthStatus().path; + expiresAt = credential.expiresAt; } else { const apiKey = await resolveAnthropicApiKey(parsed.apiKey); savedSecretsPath = saveRuntimeSecrets({ ANTHROPIC_API_KEY: apiKey }); @@ -653,6 +660,7 @@ async function configureAnthropic(args: string[]): Promise { const nextConfig = updateRuntimeConfig((draft) => { draft.anthropic.enabled = true; draft.anthropic.baseUrl = normalizedBaseUrl; + draft.anthropic.method = parsed.method; draft.anthropic.models = Array.from( new Set([fullModelName, ...draft.anthropic.models]), ); @@ -665,7 +673,7 @@ async function configureAnthropic(args: string[]): Promise { console.log(`Saved Anthropic credentials to ${savedSecretsPath}.`); } if (cliCredentialPath) { - console.log(`Using Claude Code credentials from ${cliCredentialPath}.`); + console.log(`Using Claude Code login from ${cliCredentialPath}.`); } console.log(`Updated runtime config at ${runtimeConfigPath()}.`); console.log('Provider: anthropic'); @@ -836,15 +844,16 @@ function printAnthropicStatus(): void { console.log(`Path: ${status.path}`); console.log(`Authenticated: ${status.authenticated ? 'yes' : 'no'}`); + console.log(`Configured method: ${config.anthropic.method}`); if (status.method) { - console.log(`Method: ${status.method}`); + console.log(`Detected auth source: ${status.method}`); } if (status.source) { console.log(`Source: ${status.source}`); } if (status.maskedValue) { console.log( - `${status.method === 'api-key' ? 'API key' : 'Credential'}: ${status.maskedValue}`, + `${status.method === 'api-key' ? 'API key' : 'Claude login'}: ${status.maskedValue}`, ); } if (status.expiresAt) { @@ -938,7 +947,7 @@ function clearAnthropicCredentials(): void { const filePath = saveRuntimeSecrets({ ANTHROPIC_API_KEY: null }); console.log(`Cleared stored Anthropic API key in ${filePath}.`); console.log( - 'If Claude Code credentials are still present on this host, HybridClaw will keep using them. Run `claude auth logout` separately if you also want to remove the Claude CLI session.', + 'If Anthropic is configured with `--method claude-cli`, HybridClaw will keep using the official Claude CLI while local Claude login material is still present. Run `claude auth logout` separately if you also want to remove that session.', ); console.log( 'If ANTHROPIC_API_KEY is still exported in your shell, unset it separately.', diff --git a/src/cli/help.ts b/src/cli/help.ts index d0f170aa..bcf6de63 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -183,7 +183,7 @@ Examples: hybridclaw auth login hybridai --browser hybridclaw auth login hybridai --base-url http://localhost:5000 hybridclaw auth login codex --import - hybridclaw auth login anthropic --method cli --set-default + hybridclaw auth login anthropic --method claude-cli --set-default hybridclaw auth login anthropic anthropic/claude-sonnet-4-6 --method api-key --api-key sk-ant-... hybridclaw auth login openrouter anthropic/claude-sonnet-4 --api-key sk-or-... hybridclaw auth login mistral mistral-large-latest --api-key mistral_... @@ -209,8 +209,8 @@ Notes: - \`local logout\` disables configured local backends and clears any saved vLLM API key. - \`auth login msteams\` enables Microsoft Teams and stores \`MSTEAMS_APP_PASSWORD\` in ${runtimeSecretsPath()}. - \`auth whatsapp reset\` clears linked WhatsApp Web auth so you can re-pair cleanly. - - \`auth login anthropic --method cli\` reuses your local Claude Code login from \`claude auth login\`. - - \`auth login anthropic --method api-key\` stores \`ANTHROPIC_API_KEY\` in ${runtimeSecretsPath()}. + - \`auth login anthropic --method api-key\` stores \`ANTHROPIC_API_KEY\` in ${runtimeSecretsPath()} and uses the direct Anthropic Messages API. + - \`auth login anthropic --method claude-cli\` uses the official \`claude -p\` transport after \`claude auth login\`, and currently requires host sandbox mode. - \`auth login openrouter\` prompts for the API key when \`--api-key\` and \`OPENROUTER_API_KEY\` are both absent. - \`auth login mistral\` prompts for the API key when \`--api-key\` and \`MISTRAL_API_KEY\` are both absent. - \`auth login huggingface\` prompts for the token when \`--api-key\` and \`HF_TOKEN\` are both absent. @@ -397,14 +397,15 @@ Notes: export function printAnthropicUsage(): void { console.log(`Usage: - hybridclaw auth login anthropic [model-id] [--method ] [--api-key ] [--base-url ] [--no-default] + hybridclaw auth login anthropic [model-id] [--method ] [--api-key ] [--base-url ] [--no-default] hybridclaw auth status anthropic hybridclaw auth logout anthropic Notes: - Model IDs use the \`anthropic/\` prefix in HybridClaw, for example \`anthropic/claude-sonnet-4-6\`. - - \`auth login anthropic --method cli\` reuses your local Claude Code session from \`claude auth login\`. - - \`auth login anthropic --method api-key\` stores \`ANTHROPIC_API_KEY\` and can set the global default model. + - \`auth login anthropic --method api-key\` stores \`ANTHROPIC_API_KEY\`, uses the direct Anthropic API transport, and can set the global default model. + - \`auth login anthropic --method claude-cli\` uses the official \`claude -p\` transport after \`claude auth login\`, and currently requires host sandbox mode. + - If \`--method\` is omitted, HybridClaw defaults to \`api-key\`. - If \`--api-key\` is omitted for \`--method api-key\`, HybridClaw prompts you to paste the key. - \`auth logout anthropic\` clears the stored API key, but Claude Code credentials are managed separately by the \`claude\` CLI.`); } diff --git a/src/config/config.ts b/src/config/config.ts index 0aeb86c5..28ce0690 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -44,7 +44,7 @@ export class MissingRequiredEnvVarError extends Error { HF_TOKEN: 'Hugging Face provider is not configured. Use `/auth login huggingface` in the TUI, or switch to a model from another configured provider.', ANTHROPIC_API_KEY: - 'Anthropic provider is not configured. Use `/auth login anthropic --method cli` in the TUI, or switch to a model from another configured provider.', + 'Anthropic provider is not configured. Use `/auth login anthropic --method api-key` in the TUI, or switch to a model from another configured provider.', }; super( messageByEnvVar[envVar] || @@ -319,6 +319,7 @@ export let HYBRIDAI_ENABLE_RAG = true; export let CODEX_BASE_URL = CODEX_DEFAULT_BASE_URL; export let ANTHROPIC_ENABLED = false; export let ANTHROPIC_BASE_URL = 'https://api.anthropic.com/v1'; +export let ANTHROPIC_METHOD: RuntimeConfig['anthropic']['method'] = 'api-key'; export let OPENROUTER_ENABLED = false; export let OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; export let MISTRAL_ENABLED = false; @@ -646,6 +647,7 @@ function applyRuntimeConfig(config: RuntimeConfig): void { CODEX_BASE_URL = config.codex.baseUrl; ANTHROPIC_ENABLED = config.anthropic.enabled; ANTHROPIC_BASE_URL = config.anthropic.baseUrl; + ANTHROPIC_METHOD = config.anthropic.method; OPENROUTER_ENABLED = config.openrouter.enabled; OPENROUTER_BASE_URL = config.openrouter.baseUrl; MISTRAL_ENABLED = config.mistral.enabled; diff --git a/src/config/runtime-config.ts b/src/config/runtime-config.ts index 33c78bca..735f3adb 100644 --- a/src/config/runtime-config.ts +++ b/src/config/runtime-config.ts @@ -37,7 +37,7 @@ import { type SessionDmScope, } from '../session/session-routing.js'; import type { AdaptiveSkillsConfig } from '../skills/adaptive-skills-types.js'; -import type { McpServerConfig } from '../types/models.js'; +import type { AnthropicMethod, McpServerConfig } from '../types/models.js'; import { normalizeTrimmedStringSet } from '../utils/normalized-strings.js'; import { clearRuntimeConfigRevisions as clearTrackedRuntimeConfigRevisions, @@ -55,7 +55,7 @@ import { import { DEFAULT_RUNTIME_HOME_DIR } from './runtime-paths.js'; export const CONFIG_FILE_NAME = 'config.json'; -export const CONFIG_VERSION = 18; +export const CONFIG_VERSION = 19; export const SECURITY_POLICY_VERSION = '2026-02-28'; const LEGACY_DEFAULT_DB_PATH = 'data/hybridclaw.db'; const DEFAULT_DB_PATH = path.join( @@ -458,6 +458,7 @@ export interface RuntimeConfig { anthropic: { enabled: boolean; baseUrl: string; + method: AnthropicMethod; models: string[]; }; openrouter: { @@ -671,6 +672,7 @@ const DEFAULT_CODEX_MODEL_LIST = [ 'openai-codex/gpt-5.1-codex-mini', ] as const; const DEFAULT_ANTHROPIC_MODEL_LIST = ['anthropic/claude-sonnet-4-6'] as const; +const DEFAULT_ANTHROPIC_METHOD: AnthropicMethod = 'api-key'; const DEFAULT_OPENROUTER_MODEL_LIST = [ 'openrouter/anthropic/claude-sonnet-4', ] as const; @@ -874,6 +876,7 @@ const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { anthropic: { enabled: false, baseUrl: 'https://api.anthropic.com/v1', + method: DEFAULT_ANTHROPIC_METHOD, models: [...DEFAULT_ANTHROPIC_MODEL_LIST], }, openrouter: { @@ -3193,6 +3196,31 @@ function parseConfigPatch(payload: unknown): DeepPartial { function normalizeRuntimeConfig( patch?: DeepPartial, ): RuntimeConfig { + const normalizeAnthropicMethodValue = ( + value: unknown, + fallback: AnthropicMethod, + ): AnthropicMethod => { + const normalized = String(value || '') + .trim() + .toLowerCase(); + if ( + normalized === 'claude-cli' || + normalized === 'claude_cli' || + normalized === 'claudecli' + ) { + return 'claude-cli'; + } + if ( + normalized === 'api-key' || + normalized === 'apikey' || + normalized === 'api_key' || + normalized === 'token' + ) { + return 'api-key'; + } + return fallback; + }; + const raw = patch ?? {}; const rawSecurity = isRecord(raw.security) ? raw.security : {}; @@ -3701,6 +3729,10 @@ function normalizeRuntimeConfig( rawAnthropic.baseUrl, DEFAULT_RUNTIME_CONFIG.anthropic.baseUrl, ), + method: normalizeAnthropicMethodValue( + rawAnthropic.method, + DEFAULT_RUNTIME_CONFIG.anthropic.method, + ), models: anthropicModelList, }, openrouter: { diff --git a/src/doctor/checks/providers.ts b/src/doctor/checks/providers.ts index d19f1644..1ef1ddae 100644 --- a/src/doctor/checks/providers.ts +++ b/src/doctor/checks/providers.ts @@ -117,7 +117,11 @@ export async function checkProviders(): Promise { const defaultProvider = resolveModelProvider(config.hybridai.defaultModel); const anthropicStatus = getAnthropicAuthStatus(); const codexStatus = getCodexAuthStatus(); - const discoveredModels = await readDiscoveredModelNamesSafely(); + const anthropicConfiguredMethod = config.anthropic.method; + const anthropicMethodReady = + anthropicConfiguredMethod === 'claude-cli' + ? anthropicStatus.method === 'claude-cli' + : anthropicStatus.method === 'api-key'; const anthropicEnabled = config.anthropic?.enabled === true; const discoveredModels = await readDiscoveredModelNamesSafely(); const openRouterEnabled = config.openrouter?.enabled === true; @@ -166,17 +170,19 @@ export async function checkProviders(): Promise { configured: anthropicEnabled || defaultProvider === 'anthropic' || - anthropicStatus.authenticated, + anthropicMethodReady, configuredModelCount: anthropicModels.length, probe: anthropicEnabled || defaultProvider === 'anthropic' || - anthropicStatus.authenticated + anthropicMethodReady ? () => probeAnthropic() : null, - inactiveMessage: anthropicStatus.authenticated + inactiveMessage: anthropicMethodReady ? 'Provider disabled' - : 'Not authenticated', + : anthropicConfiguredMethod === 'claude-cli' + ? 'Claude CLI login missing' + : 'API key missing', }, { key: 'openrouter', diff --git a/src/doctor/provider-probes.ts b/src/doctor/provider-probes.ts index 0d392871..4a4c6043 100644 --- a/src/doctor/provider-probes.ts +++ b/src/doctor/provider-probes.ts @@ -1,10 +1,16 @@ -import { resolveAnthropicAuth } from '../auth/anthropic-auth.js'; +import { spawnSync } from 'node:child_process'; +import { + requireAnthropicApiKey, + requireAnthropicClaudeCliCredential, +} from '../auth/anthropic-auth.js'; import { resolveCodexCredentials } from '../auth/codex-auth.js'; import { getHybridAIAuthStatus } from '../auth/hybridai-auth.js'; import { ANTHROPIC_BASE_URL, ANTHROPIC_ENABLED, + ANTHROPIC_METHOD, CODEX_BASE_URL, + CONTAINER_SANDBOX_MODE, HUGGINGFACE_BASE_URL, HUGGINGFACE_ENABLED, MISTRAL_BASE_URL, @@ -94,9 +100,49 @@ export async function probeAnthropic(): Promise { }; } - let auth: ReturnType; + if (ANTHROPIC_METHOD === 'claude-cli') { + if (CONTAINER_SANDBOX_MODE !== 'host') { + return { + reachable: false, + detail: + 'Claude CLI transport requires `container.sandboxMode=host` or `--sandbox=host`.', + }; + } + + try { + requireAnthropicClaudeCliCredential(); + } catch (error) { + return { + reachable: false, + detail: error instanceof Error ? error.message : String(error), + }; + } + + const startedAt = Date.now(); + const result = spawnSync('claude', ['--version'], { + encoding: 'utf8', + timeout: 5_000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status !== 0) { + return { + reachable: false, + detail: + result.stderr?.trim() || + result.stdout?.trim() || + 'Failed to execute `claude --version`.', + }; + } + + return { + reachable: true, + detail: `${Date.now() - startedAt}ms`, + }; + } + + let auth: ReturnType; try { - auth = resolveAnthropicAuth(); + auth = requireAnthropicApiKey(); } catch (error) { return { reachable: false, @@ -107,9 +153,7 @@ export async function probeAnthropic(): Promise { const headers: Record = { ...auth.headers, }; - if (auth.method === 'cli') { - headers.Authorization = `Bearer ${auth.apiKey}`; - } else { + if (auth.method === 'api-key') { headers['x-api-key'] = auth.apiKey; } diff --git a/src/infra/container-runner.ts b/src/infra/container-runner.ts index 9d25f037..a19b7c09 100644 --- a/src/infra/container-runner.ts +++ b/src/infra/container-runner.ts @@ -564,6 +564,8 @@ function getOrSpawnContainer( `SEARXNG_BASE_URL=${WEB_SEARCH_SEARXNG_BASE_URL}`, '-e', 'PLAYWRIGHT_BROWSERS_PATH=/ms-playwright', + '-e', + 'HYBRIDCLAW_AGENT_SANDBOX_MODE=container', ]; for (const [name, value] of [ @@ -774,6 +776,7 @@ export async function runContainer( apiKey: modelRuntime.apiKey, baseUrl: remapHostBaseUrlForContainer(modelRuntime.baseUrl), provider: modelRuntime.provider, + providerMethod: modelRuntime.providerMethod, requestHeaders: modelRuntime.requestHeaders, isLocal: modelRuntime.isLocal, contextWindow: modelRuntime.contextWindow, @@ -830,6 +833,7 @@ export async function runContainer( const workerSignature = computeWorkerSignature({ agentId, provider: input.provider, + providerMethod: input.providerMethod, baseUrl: input.baseUrl, apiKey: input.apiKey, requestHeaders: input.requestHeaders, diff --git a/src/infra/host-runner.ts b/src/infra/host-runner.ts index 600a00c8..c704a22a 100644 --- a/src/infra/host-runner.ts +++ b/src/infra/host-runner.ts @@ -453,6 +453,7 @@ function getOrSpawnHostProcess( const agentBrowserBin = resolveHostAgentBrowserBinary(); const env: NodeJS.ProcessEnv = { ...process.env, + HYBRIDCLAW_AGENT_SANDBOX_MODE: 'host', HYBRIDAI_BASE_URL, HYBRIDAI_MODEL, CONTAINER_IDLE_TIMEOUT: String(IDLE_TIMEOUT_MS), @@ -678,6 +679,7 @@ export async function runHostProcess( apiKey: modelRuntime.apiKey, baseUrl: modelRuntime.baseUrl, provider: modelRuntime.provider, + providerMethod: modelRuntime.providerMethod, requestHeaders: modelRuntime.requestHeaders, isLocal: modelRuntime.isLocal, contextWindow: modelRuntime.contextWindow, @@ -734,6 +736,7 @@ export async function runHostProcess( const workerSignature = computeWorkerSignature({ agentId, provider: input.provider, + providerMethod: input.providerMethod, baseUrl: input.baseUrl, apiKey: input.apiKey, requestHeaders: input.requestHeaders, diff --git a/src/infra/worker-signature.ts b/src/infra/worker-signature.ts index 52ba7ffd..068627c9 100644 --- a/src/infra/worker-signature.ts +++ b/src/infra/worker-signature.ts @@ -2,6 +2,7 @@ import { TASK_MODEL_KEYS, type TaskModelKey } from '../types/models.js'; interface WorkerSignatureTaskModel { provider?: string; + providerMethod?: string; baseUrl?: string; apiKey?: string; requestHeaders?: Record; @@ -17,6 +18,7 @@ interface WorkerSignatureTaskModel { export interface WorkerSignatureInput { agentId: string; provider: string | undefined; + providerMethod?: string; baseUrl: string; apiKey: string; requestHeaders: Record | undefined; @@ -47,6 +49,7 @@ function normalizeTaskModel( return { provider: String(input.provider || '').trim(), + providerMethod: String(input.providerMethod || '').trim(), baseUrl: String(input.baseUrl || '') .trim() .replace(/\/+$/g, ''), @@ -78,6 +81,7 @@ export function computeWorkerSignature(input: WorkerSignatureInput): string { return JSON.stringify({ agentId: String(input.agentId || '').trim(), provider: String(input.provider || '').trim(), + providerMethod: String(input.providerMethod || '').trim(), baseUrl: String(input.baseUrl || '') .trim() .replace(/\/+$/g, ''), diff --git a/src/onboarding.ts b/src/onboarding.ts index 68918dcc..b8c12d24 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import readline from 'node:readline/promises'; import { getAnthropicAuthStatus, - requireAnthropicCliCredentials, + requireAnthropicClaudeCliCredential, } from './auth/anthropic-auth.js'; import { getCodexAuthStatus, @@ -969,16 +969,16 @@ async function runAnthropicOnboarding(params: { printSetup('Anthropic credentials already detected on this host.'); } else { printInfo( - 'Anthropic can reuse your local Claude Code login or a direct Anthropic API key.', + 'Anthropic can use a direct Anthropic API key or the official Claude CLI transport.', ); } console.log(); - const defaultMethod = existingStatus.method === 'cli' ? 'cli' : 'api-key'; + const defaultMethod = runtimeConfig.anthropic.method; const methodChoice = ( await promptOptional( rl, - `Auth method [cli/api-key] (Enter for ${defaultMethod}): `, + `Auth method [api-key/claude-cli] (Enter for ${defaultMethod}): `, ICON_AUTH, ) ) @@ -987,18 +987,25 @@ async function runAnthropicOnboarding(params: { const method = !methodChoice || methodChoice === defaultMethod ? defaultMethod - : methodChoice === 'cli' || methodChoice === 'claude' - ? 'cli' + : methodChoice === 'claude-cli' || + methodChoice === 'claude_cli' || + methodChoice === 'claudecli' || + methodChoice === 'cli' || + methodChoice === 'claude' + ? 'claude-cli' : methodChoice === 'api-key' || methodChoice === 'apikey' ? 'api-key' : null; if (!method) { - throw new Error('Anthropic onboarding expected `cli` or `api-key`.'); + throw new Error('Anthropic onboarding expected `api-key` or `claude-cli`.'); } let resultMessage = ''; - if (method === 'cli') { - if (!existingStatus.authenticated || existingStatus.method !== 'cli') { + if (method === 'claude-cli') { + if ( + !existingStatus.authenticated || + existingStatus.method !== 'claude-cli' + ) { printInfo( 'Run `claude auth login` in another terminal if you have not already done so, then press Enter here.', ); @@ -1008,8 +1015,8 @@ async function runAnthropicOnboarding(params: { ICON_KEYBOARD, ); } - const resolved = requireAnthropicCliCredentials(); - resultMessage = `Using Claude Code credentials from ${resolved.path}.`; + requireAnthropicClaudeCliCredential(); + resultMessage = `Using Claude Code login from ${getAnthropicAuthStatus().path}.`; } else { const entered = await promptOptional( rl, @@ -1027,6 +1034,11 @@ async function runAnthropicOnboarding(params: { resultMessage = `Saved credentials to ${secretsPath}.`; } + updateRuntimeConfig((draft) => { + draft.anthropic.enabled = true; + draft.anthropic.method = method; + }); + const nextAnthropicModel = defaultAnthropicModel(); const switchedModel = await maybeSwitchDefaultModel( rl, @@ -1310,6 +1322,11 @@ export async function ensureRuntimeCredentials( ).trim(); const anthropicStatus = getAnthropicAuthStatus(); const codexStatus = getCodexAuthStatus(); + const anthropicConfiguredMethod = runtimeConfig.anthropic.method; + const anthropicReady = + anthropicConfiguredMethod === 'claude-cli' + ? anthropicStatus.method === 'claude-cli' + : anthropicStatus.method === 'api-key'; const currentModel = runtimeConfig.hybridai.defaultModel.trim(); const resolvedCurrentProvider = resolveModelProvider(currentModel); const currentProviderIsLocal = isLocalProvider(resolvedCurrentProvider); @@ -1333,7 +1350,7 @@ export async function ensureRuntimeCredentials( const hasRequiredCredentials = currentProviderIsLocal ? true : currentAuth === 'anthropic' - ? anthropicStatus.authenticated + ? anthropicReady : currentAuth === 'openai-codex' ? codexStatus.authenticated : currentAuth === 'openrouter' @@ -1381,7 +1398,7 @@ export async function ensureRuntimeCredentials( if (!requireCredentials) return; if (currentAuth === 'anthropic') { throw new Error( - `Anthropic credentials are missing. Run \`claude auth login\` and then \`hybridclaw auth login anthropic --method cli --set-default\`, or store ANTHROPIC_API_KEY in ${runtimeSecretsPath()}.`, + `Anthropic credentials are missing. Run \`hybridclaw auth login anthropic --method api-key --set-default\` for direct API access, or run \`claude auth login\` and then \`hybridclaw auth login anthropic --method claude-cli --set-default\` for the official Claude CLI transport in host sandbox mode.`, ); } if (currentAuth === 'openai-codex') { @@ -1438,6 +1455,12 @@ export async function ensureRuntimeCredentials( '' ).trim(); const refreshedAnthropicStatus = getAnthropicAuthStatus(); + const refreshedAnthropicConfiguredMethod = + refreshedRuntimeConfig.anthropic.method; + const refreshedAnthropicReady = + refreshedAnthropicConfiguredMethod === 'claude-cli' + ? refreshedAnthropicStatus.method === 'claude-cli' + : refreshedAnthropicStatus.method === 'api-key'; const refreshedCurrentModel = refreshedRuntimeConfig.hybridai.defaultModel.trim(); const refreshedResolvedProvider = resolveModelProvider( @@ -1461,7 +1484,7 @@ export async function ensureRuntimeCredentials( const refreshedHasRequiredCredentials = refreshedProviderIsLocal ? true : refreshedAuth === 'anthropic' - ? refreshedAnthropicStatus.authenticated + ? refreshedAnthropicReady : refreshedAuth === 'openai-codex' ? refreshedCodexStatus.authenticated : refreshedAuth === 'openrouter' diff --git a/src/providers/anthropic.ts b/src/providers/anthropic.ts index 22fa30eb..fe1c06d0 100644 --- a/src/providers/anthropic.ts +++ b/src/providers/anthropic.ts @@ -1,6 +1,9 @@ import { DEFAULT_AGENT_ID } from '../agents/agent-types.js'; -import { resolveAnthropicAuth } from '../auth/anthropic-auth.js'; -import { ANTHROPIC_BASE_URL } from '../config/config.js'; +import { + requireAnthropicApiKey, + requireAnthropicClaudeCliCredential, +} from '../auth/anthropic-auth.js'; +import { ANTHROPIC_BASE_URL, ANTHROPIC_METHOD } from '../config/config.js'; import { isAnthropicModel, normalizeAnthropicBaseUrl, @@ -15,10 +18,28 @@ import type { async function resolveAnthropicRuntimeCredentials( params: ResolveProviderRuntimeParams, ): Promise { - const auth = resolveAnthropicAuth(); const agentId = String(params.agentId || '').trim() || DEFAULT_AGENT_ID; + if (ANTHROPIC_METHOD === 'claude-cli') { + requireAnthropicClaudeCliCredential(); + return { + provider: 'anthropic', + providerMethod: 'claude-cli', + apiKey: '', + baseUrl: normalizeAnthropicBaseUrl(ANTHROPIC_BASE_URL), + chatbotId: '', + enableRag: false, + requestHeaders: {}, + agentId, + isLocal: false, + contextWindow: + resolveModelContextWindowFallback(params.model) ?? undefined, + }; + } + + const auth = requireAnthropicApiKey(); return { provider: 'anthropic', + providerMethod: 'api-key', apiKey: auth.apiKey, baseUrl: normalizeAnthropicBaseUrl(ANTHROPIC_BASE_URL), chatbotId: '', diff --git a/src/providers/auxiliary.ts b/src/providers/auxiliary.ts index 1b747e6c..c7fe0bb9 100644 --- a/src/providers/auxiliary.ts +++ b/src/providers/auxiliary.ts @@ -24,6 +24,7 @@ type RuntimeProvider = RuntimeProviderId; interface AuxiliaryTextCallContext { provider: RuntimeProvider; + providerMethod?: string; baseUrl: string; apiKey: string; model: string; @@ -116,6 +117,7 @@ function validateContext( ): asserts context is AuxiliaryTextCallContext { const contextError = getProviderContextError({ provider: context.provider, + providerMethod: context.providerMethod, baseUrl: context.baseUrl, apiKey: context.apiKey, model: context.model, @@ -128,6 +130,7 @@ function validateContext( function buildResolvedContext(params: { task: AuxiliaryTextTask; provider: RuntimeProvider; + providerMethod?: string; baseUrl: string; apiKey: string; model: string; @@ -140,6 +143,7 @@ function buildResolvedContext(params: { }): AuxiliaryTextCallContext { const context: Partial = { provider: params.provider, + providerMethod: params.providerMethod, baseUrl: params.baseUrl.trim(), apiKey: params.apiKey.trim(), model: params.model.trim(), @@ -185,6 +189,7 @@ async function resolveContextFromModel(params: { return buildResolvedContext({ task: params.task, provider: resolved.provider, + providerMethod: resolved.providerMethod, baseUrl: resolved.baseUrl, apiKey: resolved.apiKey, model, diff --git a/src/providers/task-routing.ts b/src/providers/task-routing.ts index 52a539bb..3a88fca6 100644 --- a/src/providers/task-routing.ts +++ b/src/providers/task-routing.ts @@ -283,6 +283,7 @@ export async function resolveTaskModelPolicy( }); return { provider: resolved.provider, + providerMethod: resolved.providerMethod, baseUrl: resolved.baseUrl, apiKey: resolved.apiKey, requestHeaders: { ...resolved.requestHeaders }, @@ -368,6 +369,7 @@ export async function resolveTaskModelPolicy( } return { provider: resolved.provider, + providerMethod: resolved.providerMethod, baseUrl: resolved.baseUrl, apiKey: resolved.apiKey, requestHeaders: { ...resolved.requestHeaders }, diff --git a/src/providers/types.ts b/src/providers/types.ts index 9719c282..90a3c438 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -5,6 +5,7 @@ export type { AIProviderId, RuntimeProviderId } from './provider-ids.js'; export interface ResolvedModelRuntimeCredentials { provider: RuntimeProviderId; + providerMethod?: string; apiKey: string; baseUrl: string; chatbotId: string; diff --git a/src/types/container.ts b/src/types/container.ts index c93bb45c..3c5f3200 100644 --- a/src/types/container.ts +++ b/src/types/container.ts @@ -57,6 +57,7 @@ export interface ContainerInput { apiKey: string; baseUrl: string; provider?: ProviderKind; + providerMethod?: string; requestHeaders?: Record; isLocal?: boolean; contextWindow?: number; diff --git a/src/types/models.ts b/src/types/models.ts index 313d649b..9dcf4114 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -2,6 +2,8 @@ import type { RuntimeProviderId } from '../providers/provider-ids.js'; export type ProviderKind = RuntimeProviderId; +export type AnthropicMethod = 'api-key' | 'claude-cli'; + export interface McpServerConfig { transport: 'stdio' | 'http' | 'sse'; command?: string; @@ -15,6 +17,7 @@ export interface McpServerConfig { export interface TaskModelPolicy { provider?: ProviderKind; + providerMethod?: string; baseUrl?: string; apiKey?: string; requestHeaders?: Record; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 213120c6..a4260009 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -204,7 +204,7 @@ async function importFreshCli(options?: { }; anthropicStatus?: { authenticated: boolean; - method: 'api-key' | 'cli' | null; + method: 'api-key' | 'claude-cli' | null; source: | 'env' | 'runtime-secrets' @@ -217,12 +217,13 @@ async function importFreshCli(options?: { isOauthToken: boolean; }; anthropicCliAuthResult?: { - method: 'cli'; + type: 'oauth' | 'token'; + provider: 'anthropic'; source: 'claude-cli-file' | 'claude-cli-keychain'; - apiKey: string; - headers: Record; - path: string; - expiresAt: number | null; + expiresAt: number; + accessToken?: string; + refreshToken?: string; + token?: string; }; codexStatus?: { authenticated: boolean; @@ -506,20 +507,14 @@ async function importFreshCli(options?: { isOauthToken: false, }, ); - const requireAnthropicCliCredentials = vi.fn( + const requireAnthropicClaudeCliCredential = vi.fn( () => options?.anthropicCliAuthResult || { - method: 'cli' as const, + type: 'oauth' as const, + provider: 'anthropic' as const, source: 'claude-cli-file' as const, - apiKey: 'sk-ant-oat-cli-test', - headers: { - 'anthropic-version': '2023-06-01', - 'anthropic-beta': - 'claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14', - 'user-agent': 'claude-cli/2.1.75', - 'x-app': 'cli', - }, - path: '/tmp/.claude/.credentials.json', + accessToken: 'sk-ant-oat-cli-test', + refreshToken: 'refresh-test', expiresAt: Date.parse('2026-03-13T12:00:00.000Z'), }, ); @@ -1127,8 +1122,9 @@ async function importFreshCli(options?: { loginHybridAIInteractive, })); vi.doMock('../src/auth/anthropic-auth.ts', () => ({ + claudeCliCredentialsPath: vi.fn(() => '/tmp/.claude/.credentials.json'), getAnthropicAuthStatus, - requireAnthropicCliCredentials, + requireAnthropicClaudeCliCredential, })); vi.doMock('../src/auth/codex-auth.ts', () => ({ CodexAuthError, @@ -1330,7 +1326,7 @@ async function importFreshCli(options?: { getHybridAIAuthStatus, loginCodexInteractive, loginHybridAIInteractive, - requireAnthropicCliCredentials, + requireAnthropicClaudeCliCredential, printUpdateUsage, runUpdateCommand, runDoctorCli, @@ -3762,19 +3758,19 @@ describe('CLI hybridai commands', () => { ); }); - it('routes auth login anthropic with cli credentials to the Anthropic auth flow', async () => { - const { cli, requireAnthropicCliCredentials, updateRuntimeConfig } = + it('routes auth login anthropic with claude-cli credentials to the Anthropic auth flow', async () => { + const { cli, requireAnthropicClaudeCliCredential, updateRuntimeConfig } = await importFreshCli(); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - await cli.main(['auth', 'login', 'anthropic', '--method', 'cli']); + await cli.main(['auth', 'login', 'anthropic', '--method', 'claude-cli']); - expect(requireAnthropicCliCredentials).toHaveBeenCalled(); + expect(requireAnthropicClaudeCliCredential).toHaveBeenCalled(); expect(updateRuntimeConfig).toHaveBeenCalled(); expect(logSpy).toHaveBeenCalledWith('Provider: anthropic'); - expect(logSpy).toHaveBeenCalledWith('Auth method: cli'); + expect(logSpy).toHaveBeenCalledWith('Auth method: claude-cli'); expect(logSpy).toHaveBeenCalledWith( - 'Using Claude Code credentials from /tmp/.claude/.credentials.json.', + 'Using Claude Code login from /tmp/.claude/.credentials.json.', ); expect(logSpy).toHaveBeenCalledWith( 'Configured model: anthropic/claude-sonnet-4-6', @@ -3814,7 +3810,7 @@ describe('CLI hybridai commands', () => { const { cli } = await importFreshCli({ anthropicStatus: { authenticated: true, - method: 'cli', + method: 'claude-cli', source: 'claude-cli-file', path: '/tmp/.claude/.credentials.json', maskedValue: 'sk-ant...test', @@ -3827,7 +3823,8 @@ describe('CLI hybridai commands', () => { await cli.main(['auth', 'status', 'anthropic']); expect(logSpy).toHaveBeenCalledWith('Authenticated: yes'); - expect(logSpy).toHaveBeenCalledWith('Method: cli'); + expect(logSpy).toHaveBeenCalledWith('Configured method: api-key'); + expect(logSpy).toHaveBeenCalledWith('Detected auth source: claude-cli'); expect(logSpy).toHaveBeenCalledWith('Enabled: no'); expect(logSpy).toHaveBeenCalledWith('Config: /tmp/config.json'); }); diff --git a/tests/container.anthropic-claude-cli.test.ts b/tests/container.anthropic-claude-cli.test.ts new file mode 100644 index 00000000..41262039 --- /dev/null +++ b/tests/container.anthropic-claude-cli.test.ts @@ -0,0 +1,215 @@ +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { afterEach, expect, test, vi } from 'vitest'; + +afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + vi.doUnmock('node:child_process'); +}); + +test('routes Anthropic claude-cli calls through `claude -p`', async () => { + const originalSandboxMode = process.env.HYBRIDCLAW_AGENT_SANDBOX_MODE; + process.env.HYBRIDCLAW_AGENT_SANDBOX_MODE = 'host'; + const spawnMock = vi.fn(() => { + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough; + stderr: PassThrough; + }; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + + queueMicrotask(() => { + child.stdout.write( + `${JSON.stringify({ + type: 'result', + session_id: 'session_123', + result: 'cli response', + })}\n`, + ); + child.stdout.end(); + child.emit('close', 0); + }); + + return child; + }); + + vi.doMock('node:child_process', () => ({ + spawn: spawnMock, + })); + + const { callAnthropicProvider } = await import( + '../container/src/providers/anthropic.js' + ); + + const response = await callAnthropicProvider({ + provider: 'anthropic', + providerMethod: 'claude-cli', + baseUrl: 'https://api.anthropic.com/v1', + apiKey: '', + model: 'anthropic/claude-sonnet-4-6', + chatbotId: '', + enableRag: false, + requestHeaders: {}, + messages: [{ role: 'user', content: 'hello' }], + tools: [], + maxTokens: 128, + isLocal: false, + contextWindow: undefined, + thinkingFormat: undefined, + }); + + expect(spawnMock).toHaveBeenCalledWith( + 'claude', + expect.arrayContaining([ + '-p', + expect.any(String), + '--verbose', + '--output-format', + 'stream-json', + '--permission-mode', + 'bypassPermissions', + '--model', + 'claude-sonnet-4-6', + ]), + expect.objectContaining({ + cwd: process.cwd(), + env: expect.objectContaining({ + HOME: expect.any(String), + }), + }), + ); + expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--cwd'); + expect(response).toMatchObject({ + id: 'session_123', + model: 'claude-sonnet-4-6', + choices: [ + { + message: { + role: 'assistant', + content: 'cli response', + }, + finish_reason: 'stop', + }, + ], + }); + if (originalSandboxMode == null) { + delete process.env.HYBRIDCLAW_AGENT_SANDBOX_MODE; + } else { + process.env.HYBRIDCLAW_AGENT_SANDBOX_MODE = originalSandboxMode; + } +}); + +test('uses the real host HOME for Anthropic claude-cli in host sandbox mode', async () => { + const originalSandboxMode = process.env.HYBRIDCLAW_AGENT_SANDBOX_MODE; + const originalHome = process.env.HOME; + process.env.HYBRIDCLAW_AGENT_SANDBOX_MODE = 'host'; + process.env.HOME = '/tmp/hybridclaw-host-home'; + + const spawnMock = vi.fn(() => { + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough; + stderr: PassThrough; + }; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + + queueMicrotask(() => { + child.stdout.write( + `${JSON.stringify({ + type: 'result', + session_id: 'session_host', + result: 'host cli response', + })}\n`, + ); + child.stdout.end(); + child.emit('close', 0); + }); + + return child; + }); + + vi.doMock('node:child_process', () => ({ + spawn: spawnMock, + })); + + try { + const { callAnthropicProvider } = await import( + '../container/src/providers/anthropic.js' + ); + + await callAnthropicProvider({ + provider: 'anthropic', + providerMethod: 'claude-cli', + baseUrl: 'https://api.anthropic.com/v1', + apiKey: '', + model: 'anthropic/claude-sonnet-4-6', + chatbotId: '', + enableRag: false, + requestHeaders: {}, + messages: [{ role: 'user', content: 'hello from host mode' }], + tools: [], + maxTokens: 128, + isLocal: false, + contextWindow: undefined, + thinkingFormat: undefined, + }); + + expect(spawnMock).toHaveBeenCalledWith( + 'claude', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + HOME: '/tmp/hybridclaw-host-home', + }), + }), + ); + } finally { + if (originalSandboxMode == null) { + delete process.env.HYBRIDCLAW_AGENT_SANDBOX_MODE; + } else { + process.env.HYBRIDCLAW_AGENT_SANDBOX_MODE = originalSandboxMode; + } + if (originalHome == null) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + } +}); + +test('rejects Anthropic claude-cli in container sandbox mode', async () => { + const originalSandboxMode = process.env.HYBRIDCLAW_AGENT_SANDBOX_MODE; + process.env.HYBRIDCLAW_AGENT_SANDBOX_MODE = 'container'; + + try { + const { callAnthropicProvider } = await import( + '../container/src/providers/anthropic.js' + ); + + await expect( + callAnthropicProvider({ + provider: 'anthropic', + providerMethod: 'claude-cli', + baseUrl: 'https://api.anthropic.com/v1', + apiKey: '', + model: 'anthropic/claude-sonnet-4-6', + chatbotId: '', + enableRag: false, + requestHeaders: {}, + messages: [{ role: 'user', content: 'hello from container mode' }], + tools: [], + maxTokens: 128, + isLocal: false, + contextWindow: undefined, + thinkingFormat: undefined, + }), + ).rejects.toThrow('requires `--sandbox=host`'); + } finally { + if (originalSandboxMode == null) { + delete process.env.HYBRIDCLAW_AGENT_SANDBOX_MODE; + } else { + process.env.HYBRIDCLAW_AGENT_SANDBOX_MODE = originalSandboxMode; + } + } +}); diff --git a/tests/providers.task-routing.test.ts b/tests/providers.task-routing.test.ts index 4925eb28..a6e6720f 100644 --- a/tests/providers.task-routing.test.ts +++ b/tests/providers.task-routing.test.ts @@ -250,11 +250,11 @@ test('resolves configured Anthropic task models on the host', async () => { test('warns when task model policy resolution fails and returns a deferred error', async () => { const homeDir = makeTempHome(); vi.doMock('../src/auth/anthropic-auth.js', () => ({ - resolveAnthropicAuth: vi.fn(() => { + requireAnthropicApiKey: vi.fn(() => { throw new Error( [ - 'Claude CLI is not authenticated on this host.', - 'Run `claude auth login`, then rerun `hybridclaw auth login anthropic --method cli --set-default`.', + 'ANTHROPIC_API_KEY is missing from your shell and /tmp/.hybridclaw/credentials.json.', + 'Run `hybridclaw auth login anthropic --method api-key --set-default` to configure the direct Anthropic API provider.', ].join('\n'), ); }), @@ -281,7 +281,7 @@ test('warns when task model policy resolution fails and returns a deferred error model: 'anthropic/claude-3-7-sonnet', maxTokens: 512, error: expect.stringContaining( - 'Claude CLI is not authenticated on this host', + 'ANTHROPIC_API_KEY is missing from your shell', ), }); expect(warn).toHaveBeenCalledWith( From 88cb186e6bc2e8bde3bf1d09a0a0c9b479c75bee Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Sun, 5 Apr 2026 21:35:23 +0200 Subject: [PATCH 4/5] chore: apply formatting fixes --- container/src/index.ts | 2 +- container/src/providers/anthropic.ts | 9 ++++----- container/src/types.ts | 6 +++--- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/container/src/index.ts b/container/src/index.ts index edeb8047..a4ab1a47 100644 --- a/container/src/index.ts +++ b/container/src/index.ts @@ -843,7 +843,7 @@ async function processRequest( messages: ChatMessage[], apiKey: string, baseUrl: string, - provider: + provider: | 'hybridai' | 'openai-codex' | 'anthropic' diff --git a/container/src/providers/anthropic.ts b/container/src/providers/anthropic.ts index 975fd8ff..6e1a95ed 100644 --- a/container/src/providers/anthropic.ts +++ b/container/src/providers/anthropic.ts @@ -105,7 +105,9 @@ function summarizeMessageForClaudeCli(message: ChatMessage): string { : ''; const parts = [text]; if (imageCount > 0) { - parts.push(`[${imageCount} image input${imageCount === 1 ? '' : 's'} omitted in claude-cli transport]`); + parts.push( + `[${imageCount} image input${imageCount === 1 ? '' : 's'} omitted in claude-cli transport]`, + ); } if (toolCallSummary) { parts.push(`Assistant tool calls:\n${toolCallSummary}`); @@ -158,10 +160,7 @@ function extractClaudeCliText(value: unknown): string { } async function runClaudeCliCommand( - args: Pick< - NormalizedCallArgs, - 'model' | 'messages' | 'providerMethod' - > & { + args: Pick & { onTextDelta?: (delta: string) => void; }, ): Promise { diff --git a/container/src/types.ts b/container/src/types.ts index 336e12dc..236295db 100644 --- a/container/src/types.ts +++ b/container/src/types.ts @@ -120,9 +120,9 @@ export interface TaskModelPolicy { | 'mistral' | 'huggingface' | 'ollama' - | 'lmstudio' - | 'llamacpp' - | 'vllm'; + | 'lmstudio' + | 'llamacpp' + | 'vllm'; providerMethod?: string; baseUrl?: string; apiKey?: string; From e12ad8174b7b8b416b7cee207dc8175f43476e7a Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Fri, 10 Apr 2026 09:08:52 +0200 Subject: [PATCH 5/5] fix: align anthropic task routing after rebase --- src/providers/task-routing.ts | 4 ++++ tests/providers.task-routing.test.ts | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/providers/task-routing.ts b/src/providers/task-routing.ts index 3a88fca6..454fa32f 100644 --- a/src/providers/task-routing.ts +++ b/src/providers/task-routing.ts @@ -223,6 +223,10 @@ export function normalizeAuxiliaryProviderModel(params: { return trimmed; } + if (params.provider === 'openrouter' && explicitPrefix !== 'openrouter') { + return `${RUNTIME_PROVIDER_PREFIXES.openrouter}${trimmed}`; + } + if (explicitPrefix && explicitPrefix !== params.provider) { throw new Error( `${params.provider} provider override cannot be used with model "${trimmed}".`, diff --git a/tests/providers.task-routing.test.ts b/tests/providers.task-routing.test.ts index a6e6720f..fdc38f85 100644 --- a/tests/providers.task-routing.test.ts +++ b/tests/providers.task-routing.test.ts @@ -231,7 +231,7 @@ test('resolves configured Anthropic task models on the host', async () => { requestHeaders: { 'anthropic-version': '2023-06-01', }, - maxTokens: 512, + maxTokens: 32_000, }, compression: { provider: 'anthropic', @@ -242,7 +242,7 @@ test('resolves configured Anthropic task models on the host', async () => { requestHeaders: { 'anthropic-version': '2023-06-01', }, - maxTokens: 256, + maxTokens: 32_000, }, }); }); @@ -536,18 +536,19 @@ test('returns a deferred policy error when fallback credential resolution fails' }); expect(policy).toMatchObject({ - provider: undefined, - model: 'gpt-5-nano', - error: - 'Session model "gpt-5-nano" does not support vision/image inputs, and no vision-capable fallback model is available.', + provider: 'openai-codex', + model: 'openai-codex/gpt-5.1-codex-max', + error: expect.stringContaining( + 'fallback model "openai-codex/gpt-5.1-codex-max" could not be resolved', + ), }); expect(warn).toHaveBeenCalledWith( expect.objectContaining({ task: 'vision', - sessionModel: 'gpt-5-nano', - openrouterDiscoveredModels: 0, + visionFallback: 'openai-codex/gpt-5.1-codex-max', + err: expect.any(Error), }), - 'Session model lacks vision support and no capable fallback model is available', + 'Failed to resolve vision fallback model credentials', ); });