From 9bb2987724977fe6667e8e0cd68ec9765375111d Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 18 Mar 2026 21:01:48 +0800 Subject: [PATCH 1/5] refactor: MCP server create-once + auto-restart on config change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit McpServer and transport are now created once at startup instead of per-request, following the official MCP SDK pattern. Fixes intermittent "No such tool available" errors caused by transient failures during per-request server recreation. Add setOnConfigChange() in config layer — all config write paths (writeConfigSection, writePlatformsConfig, writeAccountsConfig) now trigger a callback. main.ts registers mcpPlugin.restart() so MCP tool list stays in sync after any config change. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/config.ts | 12 +++++ src/main.ts | 11 ++++- src/server/mcp.ts | 106 ++++++++++++++++++++++++++------------------- 3 files changed, 83 insertions(+), 46 deletions(-) diff --git a/src/core/config.ts b/src/core/config.ts index e64b21a5..f2ab4af0 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -445,6 +445,15 @@ async function migrateLegacyTradingConfig(): Promise<{ return { platforms, accounts } } +// ==================== Config change notification ==================== + +let onConfigChange: (() => void) | null = null + +/** Register a callback invoked after any config write (writeConfigSection, writePlatformsConfig, writeAccountsConfig). */ +export function setOnConfigChange(cb: () => void): void { + onConfigChange = cb +} + // ==================== Platform / Account file helpers ==================== export async function readPlatformsConfig(): Promise { @@ -456,6 +465,7 @@ export async function writePlatformsConfig(platforms: PlatformConfig[]): Promise const validated = platformsFileSchema.parse(platforms) await mkdir(CONFIG_DIR, { recursive: true }) await writeFile(resolve(CONFIG_DIR, 'platforms.json'), JSON.stringify(validated, null, 2) + '\n') + onConfigChange?.() } export async function readAccountsConfig(): Promise { @@ -467,6 +477,7 @@ export async function writeAccountsConfig(accounts: AccountConfig[]): Promise { + mcpPlugin?.restart().catch(err => console.error('mcp: restart after config change failed:', err)) + }) + // Web UI is always active (no enabled flag) if (config.connectors.web.port) { corePlugins.push(new WebPlugin({ port: config.connectors.web.port })) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index c87551b6..bd9df2f7 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -3,7 +3,6 @@ import { cors } from 'hono/cors' import { serve } from '@hono/node-server' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' -import type { Tool } from 'ai' import type { Plugin, EngineContext } from '../core/types.js' import type { ToolCenter } from '../core/tool-center.js' @@ -50,53 +49,65 @@ function toMcpContent(result: unknown): McpContent[] { /** * MCP Plugin — exposes tools via Streamable HTTP. * - * Holds a reference to ToolCenter and queries it per-request, so tool - * changes (reconnect, disable/enable) are picked up automatically. + * Creates McpServer + transport once at startup. Supports restart() + * for config changes — tears down and rebuilds with fresh tool list. */ export class McpPlugin implements Plugin { name = 'mcp' - private server: ReturnType | null = null + private httpServer: ReturnType | null = null + private mcp: McpServer | null = null + private transport: WebStandardStreamableHTTPServerTransport | null = null + private ctx: EngineContext | null = null constructor( private toolCenter: ToolCenter, private port: number, ) {} - async start(_ctx: EngineContext) { - const toolCenter = this.toolCenter - - const createMcpServer = async () => { - const tools = await toolCenter.getMcpTools() - const mcp = new McpServer({ name: 'open-alice', version: '1.0.0' }) - - for (const [name, t] of Object.entries(tools)) { - if (!t.execute) continue - - // Extract raw shape from z.object() for MCP's inputSchema - const shape = (t.inputSchema as any)?.shape ?? {} - - mcp.registerTool(name, { - description: t.description, - inputSchema: shape, - }, async (args: any) => { - try { - const result = await t.execute!(args, { - toolCallId: crypto.randomUUID(), - messages: [], - }) - return { content: toMcpContent(result) } - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, - } - } - }) - } + async start(ctx: EngineContext) { + this.ctx = ctx + + // 1. Create McpServer and register all tools + const tools = await this.toolCenter.getMcpTools() + const mcp = new McpServer({ name: 'open-alice', version: '1.0.0' }) + + for (const [name, t] of Object.entries(tools)) { + if (!t.execute) continue - return mcp + const shape = (t.inputSchema as any)?.shape ?? {} + + mcp.registerTool(name, { + description: t.description, + inputSchema: shape, + }, async (args: any) => { + try { + const result = await t.execute!(args, { + toolCallId: crypto.randomUUID(), + messages: [], + }) + return { content: toMcpContent(result) } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }) } + // 2. Create transport (stateful with session management) + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + }) + + // 3. Connect server ↔ transport + await mcp.connect(transport) + this.mcp = mcp + this.transport = transport + + console.log(`mcp: ${Object.keys(tools).length} tools registered`) + + // 4. Route all requests to the shared transport const app = new Hono() app.use('*', cors({ @@ -106,19 +117,26 @@ export class McpPlugin implements Plugin { exposeHeaders: ['mcp-session-id', 'mcp-protocol-version'], })) - app.all('/mcp', async (c) => { - const transport = new WebStandardStreamableHTTPServerTransport() - const mcp = await createMcpServer() - await mcp.connect(transport) - return transport.handleRequest(c.req.raw) - }) + app.all('/mcp', async (c) => transport.handleRequest(c.req.raw)) - this.server = serve({ fetch: app.fetch, port: this.port }, (info) => { + this.httpServer = serve({ fetch: app.fetch, port: this.port }, (info) => { console.log(`mcp plugin listening on http://localhost:${info.port}/mcp`) }) } + /** Tear down and rebuild with fresh tool list from ToolCenter. */ + async restart() { + if (!this.ctx) return + console.log('mcp: restarting (config change)') + await this.stop() + await this.start(this.ctx) + } + async stop() { - this.server?.close() + this.httpServer?.close() + this.httpServer = null + await this.transport?.close() + this.transport = null + this.mcp = null } } From 5ad9930cca6a6e6cb0acb873faf1130fcd4cd7bf Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 18 Mar 2026 23:50:23 +0800 Subject: [PATCH 2/5] feat: Agent SDK OAuth login + revert MCP server to per-request mode Revert MCP server to per-request transport (stateless SDK transport cannot be reused across requests). Fixes "No such tool" errors. Add loginMethod field to AI provider config: 'api-key' (default) or 'claudeai' (Claude Pro/Max via local Claude Code OAuth login). When claudeai is selected, Agent SDK strips ANTHROPIC_API_KEY from env and passes forceLoginMethod to the SDK, enabling API-key-free operation. Frontend: auth mode selector on AIProviderPage when agent-sdk backend is active, per-channel loginMethod override in ChannelConfigModal. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai-providers/agent-sdk/query.ts | 19 +++- src/core/config.ts | 17 ++-- src/main.ts | 11 +-- src/server/mcp.ts | 106 ++++++++++------------- ui/src/api/index.ts | 1 + ui/src/api/types.ts | 4 + ui/src/components/ChannelConfigModal.tsx | 19 +++- ui/src/pages/AIProviderPage.tsx | 99 ++++++++++++++++++++- 8 files changed, 186 insertions(+), 90 deletions(-) diff --git a/src/ai-providers/agent-sdk/query.ts b/src/ai-providers/agent-sdk/query.ts index 0d7b64e3..c72bd1ca 100644 --- a/src/ai-providers/agent-sdk/query.ts +++ b/src/ai-providers/agent-sdk/query.ts @@ -9,6 +9,7 @@ import { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk' import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent-sdk' import { pino } from 'pino' import type { ContentBlock } from '../../core/session.js' + import { readAIProviderConfig } from '../../core/config.js' const logger = pino({ @@ -37,6 +38,7 @@ export interface AgentSdkOverride { model?: string apiKey?: string baseUrl?: string + loginMethod?: 'api-key' | 'claudeai' } export interface AgentSdkMessage { @@ -115,12 +117,20 @@ export async function askAgentSdk( const finalAllowed = allowedTools.length > 0 ? allowedTools : modeAllowed const finalDisallowed = [...disallowedTools, ...modeDisallowed] - // Build env with API key injection + // Build env with authentication const aiConfig = await readAIProviderConfig() - const apiKey = override?.apiKey ?? aiConfig.apiKeys.anthropic - const baseUrl = override?.baseUrl ?? aiConfig.baseUrl + const loginMethod = override?.loginMethod ?? aiConfig.loginMethod ?? 'api-key' + const isOAuthMode = loginMethod === 'claudeai' + const env: Record = { ...process.env } - if (apiKey) env.ANTHROPIC_API_KEY = apiKey + if (isOAuthMode) { + // Force OAuth by removing any inherited API key + delete env.ANTHROPIC_API_KEY + } else { + const apiKey = override?.apiKey ?? aiConfig.apiKeys.anthropic + if (apiKey) env.ANTHROPIC_API_KEY = apiKey + } + const baseUrl = override?.baseUrl ?? aiConfig.baseUrl if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl // MCP servers @@ -148,6 +158,7 @@ export async function askAgentSdk( permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, persistSession: false, + ...(loginMethod === 'claudeai' ? { forceLoginMethod: 'claudeai' as const } : {}), }, })) { // assistant message — extract tool_use + text blocks diff --git a/src/core/config.ts b/src/core/config.ts index f2ab4af0..d1d9f4bb 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -13,11 +13,15 @@ const engineSchema = z.object({ port: z.number().int().positive().default(3000), }) +const loginMethodSchema = z.enum(['api-key', 'claudeai']) + export const aiProviderSchema = z.object({ backend: z.enum(['claude-code', 'vercel-ai-sdk', 'agent-sdk']).default('claude-code'), provider: z.string().default('anthropic'), model: z.string().default('claude-sonnet-4-6'), baseUrl: z.string().min(1).optional(), + /** Authentication method for Agent SDK: api-key (default), oauth (Console), claudeai (Pro/Max). */ + loginMethod: loginMethodSchema.default('api-key'), apiKeys: z.object({ anthropic: z.string().optional(), openai: z.string().optional(), @@ -180,6 +184,7 @@ export const agentSdkOverrideSchema = z.object({ model: z.string().optional(), apiKey: z.string().optional(), baseUrl: z.string().optional(), + loginMethod: loginMethodSchema.optional(), }) export const webSubchannelSchema = z.object({ @@ -445,15 +450,6 @@ async function migrateLegacyTradingConfig(): Promise<{ return { platforms, accounts } } -// ==================== Config change notification ==================== - -let onConfigChange: (() => void) | null = null - -/** Register a callback invoked after any config write (writeConfigSection, writePlatformsConfig, writeAccountsConfig). */ -export function setOnConfigChange(cb: () => void): void { - onConfigChange = cb -} - // ==================== Platform / Account file helpers ==================== export async function readPlatformsConfig(): Promise { @@ -465,7 +461,6 @@ export async function writePlatformsConfig(platforms: PlatformConfig[]): Promise const validated = platformsFileSchema.parse(platforms) await mkdir(CONFIG_DIR, { recursive: true }) await writeFile(resolve(CONFIG_DIR, 'platforms.json'), JSON.stringify(validated, null, 2) + '\n') - onConfigChange?.() } export async function readAccountsConfig(): Promise { @@ -477,7 +472,6 @@ export async function writeAccountsConfig(accounts: AccountConfig[]): Promise { - mcpPlugin?.restart().catch(err => console.error('mcp: restart after config change failed:', err)) - }) - // Web UI is always active (no enabled flag) if (config.connectors.web.port) { corePlugins.push(new WebPlugin({ port: config.connectors.web.port })) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index bd9df2f7..c87551b6 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -3,6 +3,7 @@ import { cors } from 'hono/cors' import { serve } from '@hono/node-server' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import type { Tool } from 'ai' import type { Plugin, EngineContext } from '../core/types.js' import type { ToolCenter } from '../core/tool-center.js' @@ -49,65 +50,53 @@ function toMcpContent(result: unknown): McpContent[] { /** * MCP Plugin — exposes tools via Streamable HTTP. * - * Creates McpServer + transport once at startup. Supports restart() - * for config changes — tears down and rebuilds with fresh tool list. + * Holds a reference to ToolCenter and queries it per-request, so tool + * changes (reconnect, disable/enable) are picked up automatically. */ export class McpPlugin implements Plugin { name = 'mcp' - private httpServer: ReturnType | null = null - private mcp: McpServer | null = null - private transport: WebStandardStreamableHTTPServerTransport | null = null - private ctx: EngineContext | null = null + private server: ReturnType | null = null constructor( private toolCenter: ToolCenter, private port: number, ) {} - async start(ctx: EngineContext) { - this.ctx = ctx - - // 1. Create McpServer and register all tools - const tools = await this.toolCenter.getMcpTools() - const mcp = new McpServer({ name: 'open-alice', version: '1.0.0' }) - - for (const [name, t] of Object.entries(tools)) { - if (!t.execute) continue - - const shape = (t.inputSchema as any)?.shape ?? {} - - mcp.registerTool(name, { - description: t.description, - inputSchema: shape, - }, async (args: any) => { - try { - const result = await t.execute!(args, { - toolCallId: crypto.randomUUID(), - messages: [], - }) - return { content: toMcpContent(result) } - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, + async start(_ctx: EngineContext) { + const toolCenter = this.toolCenter + + const createMcpServer = async () => { + const tools = await toolCenter.getMcpTools() + const mcp = new McpServer({ name: 'open-alice', version: '1.0.0' }) + + for (const [name, t] of Object.entries(tools)) { + if (!t.execute) continue + + // Extract raw shape from z.object() for MCP's inputSchema + const shape = (t.inputSchema as any)?.shape ?? {} + + mcp.registerTool(name, { + description: t.description, + inputSchema: shape, + }, async (args: any) => { + try { + const result = await t.execute!(args, { + toolCallId: crypto.randomUUID(), + messages: [], + }) + return { content: toMcpContent(result) } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } } - } - }) - } - - // 2. Create transport (stateful with session management) - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: () => crypto.randomUUID(), - }) - - // 3. Connect server ↔ transport - await mcp.connect(transport) - this.mcp = mcp - this.transport = transport + }) + } - console.log(`mcp: ${Object.keys(tools).length} tools registered`) + return mcp + } - // 4. Route all requests to the shared transport const app = new Hono() app.use('*', cors({ @@ -117,26 +106,19 @@ export class McpPlugin implements Plugin { exposeHeaders: ['mcp-session-id', 'mcp-protocol-version'], })) - app.all('/mcp', async (c) => transport.handleRequest(c.req.raw)) + app.all('/mcp', async (c) => { + const transport = new WebStandardStreamableHTTPServerTransport() + const mcp = await createMcpServer() + await mcp.connect(transport) + return transport.handleRequest(c.req.raw) + }) - this.httpServer = serve({ fetch: app.fetch, port: this.port }, (info) => { + this.server = serve({ fetch: app.fetch, port: this.port }, (info) => { console.log(`mcp plugin listening on http://localhost:${info.port}/mcp`) }) } - /** Tear down and rebuild with fresh tool list from ToolCenter. */ - async restart() { - if (!this.ctx) return - console.log('mcp: restarting (config change)') - await this.stop() - await this.start(this.ctx) - } - async stop() { - this.httpServer?.close() - this.httpServer = null - await this.transport?.close() - this.transport = null - this.mcp = null + this.server?.close() } } diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index c378dd13..f71b3ce3 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -51,6 +51,7 @@ export type { NewsCollectorConfig, NewsCollectorFeed, ToolCallRecord, + LoginMethod, } from './types' export type { EventQueryResult } from './events' export type { ToolCallQueryResult } from './agentStatus' diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index c650a4c2..ba752935 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -7,10 +7,13 @@ export interface VercelAiSdkOverride { apiKey?: string } +export type LoginMethod = 'api-key' | 'claudeai' + export interface AgentSdkOverride { model?: string baseUrl?: string apiKey?: string + loginMethod?: LoginMethod } export interface WebChannel { @@ -61,6 +64,7 @@ export interface AIProviderConfig { provider: string model: string baseUrl?: string + loginMethod?: LoginMethod apiKeys: { anthropic?: string; openai?: string; google?: string } } diff --git a/ui/src/components/ChannelConfigModal.tsx b/ui/src/components/ChannelConfigModal.tsx index a690ded4..a632b93c 100644 --- a/ui/src/components/ChannelConfigModal.tsx +++ b/ui/src/components/ChannelConfigModal.tsx @@ -28,6 +28,7 @@ export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigM const [aModel, setAModel] = useState(channel.agentSdk?.model ?? '') const [aBaseUrl, setABaseUrl] = useState(channel.agentSdk?.baseUrl ?? '') const [aApiKey, setAApiKey] = useState(channel.agentSdk?.apiKey ?? '') + const [aLoginMethod, setALoginMethod] = useState(channel.agentSdk?.loginMethod ?? '') const showVercelConfig = provider === 'vercel-ai-sdk' const showAgentSdkConfig = provider === 'agent-sdk' @@ -49,11 +50,12 @@ export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigM } : undefined - const agentSdk = showAgentSdkConfig && aModel + const agentSdk = showAgentSdkConfig && (aModel || aLoginMethod) ? { - model: aModel, + ...(aModel ? { model: aModel } : {}), ...(aBaseUrl ? { baseUrl: aBaseUrl } : {}), ...(aApiKey ? { apiKey: aApiKey } : {}), + ...(aLoginMethod ? { loginMethod: aLoginMethod as 'api-key' | 'oauth' | 'claudeai' } : {}), } : undefined @@ -217,6 +219,19 @@ export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigM

Agent SDK Override

+
+ + +
+
= { anthropic: [ { label: 'Claude Opus 4.6', value: 'claude-opus-4-6' }, @@ -89,6 +94,13 @@ export function AIProviderPage() {
+ {/* Auth mode (only for Agent SDK) */} + {config.aiProvider.backend === 'agent-sdk' && ( +
+ setConfig((c) => c ? { ...c, aiProvider: { ...c.aiProvider, ...patch } } : c)} /> +
+ )} + {/* Model (only for Vercel AI SDK) */} {config.aiProvider.backend === 'vercel-ai-sdk' && (
@@ -356,3 +368,88 @@ function ModelForm({ aiProvider }: { aiProvider: AIProviderConfig }) { ) } + +// ==================== Agent SDK Auth Form ==================== + +function AgentSdkAuthForm({ aiProvider, onUpdate }: { aiProvider: AIProviderConfig; onUpdate: (patch: Partial) => void }) { + const [loginMethod, setLoginMethod] = useState(aiProvider.loginMethod ?? 'api-key') + const [apiKey, setApiKey] = useState('') + const [keySaveStatus, setKeySaveStatus] = useState('idle') + const savedTimer = useRef | null>(null) + + useEffect(() => () => { if (savedTimer.current) clearTimeout(savedTimer.current) }, []) + + const handleLoginMethodChange = async (method: LoginMethod) => { + setLoginMethod(method) + try { + await api.config.updateSection('aiProvider', { ...aiProvider, loginMethod: method }) + onUpdate({ loginMethod: method }) + } catch { /* revert on failure */ setLoginMethod(loginMethod) } + } + + const handleSaveKey = async () => { + if (!apiKey) return + setKeySaveStatus('saving') + try { + const updatedKeys = { ...aiProvider.apiKeys, anthropic: apiKey } + await api.config.updateSection('aiProvider', { ...aiProvider, apiKeys: updatedKeys }) + onUpdate({ apiKeys: updatedKeys }) + setApiKey('') + setKeySaveStatus('saved') + if (savedTimer.current) clearTimeout(savedTimer.current) + savedTimer.current = setTimeout(() => setKeySaveStatus('idle'), 2000) + } catch { setKeySaveStatus('error') } + } + + return ( + <> + +
+ {LOGIN_METHODS.map((m, i) => ( + + ))} +
+

+ {LOGIN_METHODS.find((m) => m.value === loginMethod)?.description} +

+
+ + {loginMethod === 'api-key' && ( + +
+ setApiKey(e.target.value)} + placeholder={aiProvider.apiKeys?.anthropic ? '(configured)' : 'sk-ant-...'} + /> + {aiProvider.apiKeys?.anthropic && ( + active + )} +
+
+ + +
+
+ )} + + ) +} From f439939f50384d650917e2a1b2a1a2379225ee4a Mon Sep 17 00:00:00 2001 From: Ame Date: Thu, 19 Mar 2026 00:00:40 +0800 Subject: [PATCH 3/5] refactor: remove Claude Code CLI provider, unify on Agent SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete src/ai-providers/claude-code/ — Agent SDK replaces it entirely. Both spawn the same claude CLI under the hood, but Agent SDK delivers tools via in-process MCP (no external HTTP server needed). GenerateRouter drops the claudeCode parameter; 'claude-code' in config or per-request override is now an alias for 'agent-sdk'. Config migration in loadConfig() auto-converts backend: 'claude-code' → 'agent-sdk' + loginMethod: 'claudeai' on startup. Frontend reduced to two backend tabs: Agent SDK | Vercel AI SDK. Telegram compaction switched from askClaudeCode to askAgentSdk. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../claude-code/claude-code-provider.ts | 81 ------- src/ai-providers/claude-code/index.ts | 3 - src/ai-providers/claude-code/provider.ts | 211 ------------------ src/ai-providers/claude-code/types.ts | 47 ---- src/connectors/telegram/telegram-plugin.ts | 16 +- src/core/ai-provider-manager.spec.ts | 14 +- src/core/ai-provider-manager.ts | 8 +- src/core/config.ts | 8 + src/main.ts | 4 +- ui/src/components/ChannelConfigModal.tsx | 10 +- ui/src/pages/AIProviderPage.tsx | 6 +- 11 files changed, 35 insertions(+), 373 deletions(-) delete mode 100644 src/ai-providers/claude-code/claude-code-provider.ts delete mode 100644 src/ai-providers/claude-code/index.ts delete mode 100644 src/ai-providers/claude-code/provider.ts delete mode 100644 src/ai-providers/claude-code/types.ts diff --git a/src/ai-providers/claude-code/claude-code-provider.ts b/src/ai-providers/claude-code/claude-code-provider.ts deleted file mode 100644 index dd3a1f01..00000000 --- a/src/ai-providers/claude-code/claude-code-provider.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * ClaudeCodeProvider — GenerateProvider backed by the Claude Code CLI. - * - * Slim data-source adapter: only calls the CLI and yields ProviderEvents. - * Session management (append, compact, persist) lives in AgentCenter. - * - * Agent config (evolutionMode, allowedTools, disallowedTools) is re-read from - * disk on every request so that Web UI changes take effect without restart. - */ - -import { resolve } from 'node:path' -import type { ProviderResult, ProviderEvent, AIProvider, GenerateOpts } from '../types.js' -import type { SessionEntry } from '../../core/session.js' -import type { ClaudeCodeConfig } from './types.js' -import { toTextHistory } from '../../core/session.js' -import { buildChatHistoryPrompt, DEFAULT_MAX_HISTORY } from '../utils.js' -import { readAgentConfig } from '../../core/config.js' -import { createChannel } from '../../core/async-channel.js' -import { askClaudeCode } from './provider.js' - -export class ClaudeCodeProvider implements AIProvider { - readonly providerTag = 'claude-code' as const - - constructor( - private systemPrompt?: string, - ) {} - - /** Re-read agent config from disk to pick up hot-reloaded settings. */ - private async resolveConfig(): Promise { - const agent = await readAgentConfig() - return { - ...agent.claudeCode, - evolutionMode: agent.evolutionMode, - cwd: agent.evolutionMode ? process.cwd() : resolve('data/brain'), - } - } - - async ask(prompt: string): Promise { - const config = await this.resolveConfig() - const result = await askClaudeCode(prompt, config) - return { text: result.text, media: [] } - } - - async *generate(entries: SessionEntry[], prompt: string, opts?: GenerateOpts): AsyncGenerator { - const maxHistory = opts?.maxHistoryEntries ?? DEFAULT_MAX_HISTORY - const textHistory = toTextHistory(entries).slice(-maxHistory) - const fullPrompt = buildChatHistoryPrompt(prompt, textHistory, opts?.historyPreamble) - - const config = await this.resolveConfig() - const claudeCode: ClaudeCodeConfig = { - ...config, - ...(opts?.disabledTools?.length - ? { disallowedTools: [...(config.disallowedTools ?? []), ...opts.disabledTools] } - : {}), - systemPrompt: opts?.systemPrompt ?? this.systemPrompt, - } - - const channel = createChannel() - - const resultPromise = askClaudeCode(fullPrompt, { - ...claudeCode, - onToolUse: ({ id, name, input: toolInput }) => { - channel.push({ type: 'tool_use', id, name, input: toolInput }) - }, - onToolResult: ({ toolUseId, content }) => { - channel.push({ type: 'tool_result', tool_use_id: toolUseId, content }) - }, - onText: (text) => { - channel.push({ type: 'text', text }) - }, - }) - - resultPromise.then(() => channel.close()).catch((err) => channel.error(err instanceof Error ? err : new Error(String(err)))) - yield* channel - - const result = await resultPromise - const prefix = result.ok ? '' : '[error] ' - yield { type: 'done', result: { text: prefix + result.text, media: [] } } - } - -} diff --git a/src/ai-providers/claude-code/index.ts b/src/ai-providers/claude-code/index.ts deleted file mode 100644 index 0876c909..00000000 --- a/src/ai-providers/claude-code/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { askClaudeCode } from './provider.js' -export type { ClaudeCodeConfig, ClaudeCodeResult, ClaudeCodeMessage } from './types.js' -export { ClaudeCodeProvider } from './claude-code-provider.js' diff --git a/src/ai-providers/claude-code/provider.ts b/src/ai-providers/claude-code/provider.ts deleted file mode 100644 index 5b5868c7..00000000 --- a/src/ai-providers/claude-code/provider.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { spawn } from 'node:child_process' -import { pino } from 'pino' -import type { ClaudeCodeConfig, ClaudeCodeResult, ClaudeCodeMessage } from './types.js' -import type { ContentBlock } from '../../core/session.js' - - -const logger = pino({ - transport: { target: 'pino/file', options: { destination: 'logs/claude-code.log', mkdir: true } }, -}) - -/** Strip base64 image data from tool_result content before persisting to session. */ -function stripImageData(raw: string): string { - try { - const parsed = JSON.parse(raw) - if (!Array.isArray(parsed)) return raw - let changed = false - const cleaned = parsed.map((item: Record) => { - if (item.type === 'image' && (item.source as Record)?.data) { - changed = true - return { type: 'text', text: '[Image saved to disk — use Read tool to view the file]' } - } - return item - }) - return changed ? JSON.stringify(cleaned) : raw - } catch { return raw } -} - -/** Tools pre-approved in normal mode (no Bash). */ -const NORMAL_ALLOWED_TOOLS = [ - 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebSearch', 'WebFetch', - 'mcp__open-alice__*', -] - -/** Tools pre-approved in evolution mode (includes Bash). */ -const EVOLUTION_ALLOWED_TOOLS = [ - 'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep', 'WebSearch', 'WebFetch', - 'mcp__open-alice__*', -] - -/** Extra tools to disallow in normal mode. */ -const NORMAL_EXTRA_DISALLOWED = ['Bash'] - -/** Extra tools to disallow in evolution mode. */ -const EVOLUTION_EXTRA_DISALLOWED: string[] = [] - -/** - * Spawn `claude -p` as a stateless child process and collect the result. - * - * Each invocation is independent — no --resume. The caller is responsible - * for building the prompt with any conversation context and persisting - * the result to the session store. - */ -export async function askClaudeCode( - prompt: string, - config: ClaudeCodeConfig = {}, -): Promise { - const { - allowedTools = [], - disallowedTools = [], - evolutionMode = false, - maxTurns = 20, - cwd = process.cwd(), - systemPrompt, - appendSystemPrompt, - onToolUse, - onToolResult, - onText, - } = config - - // Merge: explicit config overrides mode defaults - const modeAllowed = evolutionMode ? EVOLUTION_ALLOWED_TOOLS : NORMAL_ALLOWED_TOOLS - const modeDisallowed = evolutionMode ? EVOLUTION_EXTRA_DISALLOWED : NORMAL_EXTRA_DISALLOWED - const finalAllowed = allowedTools.length > 0 ? allowedTools : modeAllowed - const finalDisallowed = [...disallowedTools, ...modeDisallowed] - - const args = [ - '-p', prompt, - '--output-format', 'stream-json', - '--verbose', - '--max-turns', String(maxTurns), - ] - - if (finalAllowed.length > 0) { - args.push('--allowedTools', ...finalAllowed) - } - - if (finalDisallowed.length > 0) { - args.push('--disallowedTools', ...finalDisallowed) - } - - if (systemPrompt) { - args.push('--system-prompt', systemPrompt) - } - - if (appendSystemPrompt) { - args.push('--append-system-prompt', appendSystemPrompt) - } - - return new Promise((resolve, reject) => { - const child = spawn('claude', args, { - cwd, - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env }, - }) - - let buffer = '' - let stderr = '' - let resultText = '' - const messages: ClaudeCodeMessage[] = [] - - child.stdout.on('data', (chunk: Buffer) => { - buffer += chunk.toString() - - // Parse complete lines from the JSONL stream - let newlineIdx: number - while ((newlineIdx = buffer.indexOf('\n')) !== -1) { - const line = buffer.slice(0, newlineIdx).trim() - buffer = buffer.slice(newlineIdx + 1) - if (!line) continue - - try { - const event = JSON.parse(line) - - // assistant message — extract tool_use blocks - if (event.type === 'assistant' && event.message?.content) { - const blocks: ContentBlock[] = [] - for (const block of event.message.content) { - if (block.type === 'tool_use') { - logger.info({ tool: block.name, input: block.input }, 'tool_use') - blocks.push({ type: 'tool_use', id: block.id, name: block.name, input: block.input }) - onToolUse?.({ id: block.id, name: block.name, input: block.input }) - } else if (block.type === 'text') { - blocks.push({ type: 'text', text: block.text }) - onText?.(block.text) - } - } - if (blocks.length > 0) { - messages.push({ role: 'assistant', content: blocks }) - } - } - - // user message — extract tool_result blocks - else if (event.type === 'user' && event.message?.content) { - const blocks: ContentBlock[] = [] - for (const block of event.message.content) { - if (block.type === 'tool_result') { - const content = typeof block.content === 'string' - ? block.content - : JSON.stringify(block.content ?? '') - const sessionContent = stripImageData(content) - logger.info({ toolUseId: block.tool_use_id, content: sessionContent.slice(0, 500) }, 'tool_result') - blocks.push({ type: 'tool_result', tool_use_id: block.tool_use_id, content: sessionContent }) - onToolResult?.({ toolUseId: block.tool_use_id, content }) - } - } - if (blocks.length > 0) { - messages.push({ role: 'user', content: blocks }) - } - } - - // final result - else if (event.type === 'result') { - resultText = event.result ?? '' - logger.info({ subtype: event.subtype, turns: event.num_turns, durationMs: event.duration_ms }, 'result') - } - } catch (err) { - logger.warn({ line: line.slice(0, 200), error: String(err) }, 'jsonl_parse_error') - } - } - }) - - child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) - - child.on('error', (err) => { - logger.error({ error: err.message }, 'spawn_error') - reject(new Error(`Failed to spawn claude CLI: ${err.message}`)) - }) - - child.on('close', (code) => { - if (code !== 0) { - logger.error({ code, stderr: stderr.slice(0, 500) }, 'exit_error') - return resolve({ - text: `Claude Code exited with code ${code}:\n${stderr || resultText}`, - ok: false, - messages, - }) - } - - // When the final turn is a tool call with no standalone text output, - // resultText is empty. Fall back to the last assistant text blocks. - let text = resultText - if (!text) { - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === 'assistant') { - text = messages[i].content - .filter((b): b is { type: 'text'; text: string } => b.type === 'text') - .map(b => b.text) - .join('\n') - if (text) break - } - } - } - - resolve({ - text: text || '(no output)', - ok: true, - messages, - }) - }) - }) -} diff --git a/src/ai-providers/claude-code/types.ts b/src/ai-providers/claude-code/types.ts deleted file mode 100644 index e5d5e976..00000000 --- a/src/ai-providers/claude-code/types.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { ContentBlock } from '../../core/session.js' - -export interface ClaudeCodeConfig { - /** Tools pre-approved for use in non-interactive (-p) mode. */ - allowedTools?: string[] - /** Tools removed from the model's context entirely (not just denied). */ - disallowedTools?: string[] - /** When true, grants Bash access and broader permissions. */ - evolutionMode?: boolean - /** Max agentic turns before Claude Code exits. Default: 20 */ - maxTurns?: number - /** Working directory for Claude Code. Default: process.cwd() */ - cwd?: string - /** Custom system prompt (replaces Claude Code default). */ - systemPrompt?: string - /** Append to Claude Code's default system prompt. */ - appendSystemPrompt?: string - /** - * Called for each tool_use block in the JSONL stream. - * Use this to stream tool call events to consumers. - */ - onToolUse?: (toolUse: { id: string; name: string; input: unknown }) => void - /** - * Called for each tool_result block in the JSONL stream. - * Use this to extract side-channel data (e.g. images) from tool results. - */ - onToolResult?: (toolResult: { toolUseId: string; content: string }) => void - /** - * Called for each intermediate text block in the JSONL stream. - * Use this to stream thinking/reasoning text to consumers during tool loops. - */ - onText?: (text: string) => void -} - -export interface ClaudeCodeMessage { - role: 'assistant' | 'user' - content: ContentBlock[] -} - -export interface ClaudeCodeResult { - /** The text response from Claude Code. */ - text: string - /** Whether the run was successful. */ - ok: boolean - /** All intermediate messages (assistant tool_use + user tool_result) from the stream. */ - messages: ClaudeCodeMessage[] -} diff --git a/src/connectors/telegram/telegram-plugin.ts b/src/connectors/telegram/telegram-plugin.ts index d7f7b337..ad6756d7 100644 --- a/src/connectors/telegram/telegram-plugin.ts +++ b/src/connectors/telegram/telegram-plugin.ts @@ -6,8 +6,8 @@ import type { Plugin, EngineContext, MediaAttachment } from '../../core/types.js import type { TelegramConfig, ParsedMessage } from './types.js' import { buildParsedMessage } from './helpers.js' import { MediaGroupMerger } from './media-group.js' -import { askClaudeCode } from '../../ai-providers/claude-code/index.js' -import type { ClaudeCodeConfig } from '../../ai-providers/claude-code/index.js' +import { askAgentSdk } from '../../ai-providers/agent-sdk/query.js' +import type { AgentSdkConfig } from '../../ai-providers/agent-sdk/query.js' import { SessionStore } from '../../core/session' import { forceCompact } from '../../core/compaction' import { readAIBackend, writeAIBackend, type AIBackend } from '../../core/config' @@ -23,7 +23,7 @@ const BACKEND_LABELS: Record = { export class TelegramPlugin implements Plugin { name = 'telegram' private config: TelegramConfig - private claudeCodeConfig: ClaudeCodeConfig + private agentSdkConfig: AgentSdkConfig private bot: Bot | null = null private connectorCenter: ConnectorCenter | null = null private merger: MediaGroupMerger | null = null @@ -37,20 +37,20 @@ export class TelegramPlugin implements Plugin { constructor( config: Omit & { pollingTimeout?: number }, - claudeCodeConfig: ClaudeCodeConfig = {}, + agentSdkConfig: AgentSdkConfig = {}, ) { this.config = { pollingTimeout: 30, ...config } - this.claudeCodeConfig = claudeCodeConfig + this.agentSdkConfig = agentSdkConfig } async start(engineCtx: EngineContext) { this.connectorCenter = engineCtx.connectorCenter // Inject agent config into Claude Code config (used by /compact command) - this.claudeCodeConfig = { + this.agentSdkConfig = { disallowedTools: engineCtx.config.agent.claudeCode.disallowedTools, maxTurns: engineCtx.config.agent.claudeCode.maxTurns, - ...this.claudeCodeConfig, + ...this.agentSdkConfig, } const bot = new Bot(this.config.token) @@ -275,7 +275,7 @@ export class TelegramPlugin implements Plugin { const result = await forceCompact( session, async (summarizePrompt) => { - const r = await askClaudeCode(summarizePrompt, { ...this.claudeCodeConfig, maxTurns: 1 }) + const r = await askAgentSdk(summarizePrompt, { ...this.agentSdkConfig, maxTurns: 1 }) return r.text }, ) diff --git a/src/core/ai-provider-manager.spec.ts b/src/core/ai-provider-manager.spec.ts index 39b39775..f611d5b4 100644 --- a/src/core/ai-provider-manager.spec.ts +++ b/src/core/ai-provider-manager.spec.ts @@ -140,26 +140,26 @@ describe('GenerateRouter', () => { it('should resolve to vercel when no override and config fallback', async () => { const vercel = makeProvider('vercel-ai') - const router = new GenerateRouter(vercel, null, null) + const router = new GenerateRouter(vercel, null) - // Without override, reads config — but claudeCode/agentSdk are null so falls back to vercel + // Without override, reads config — agentSdk is null so falls back to vercel const provider = await router.resolve() expect(provider).toBe(vercel) }) - it('should resolve override claude-code when available', async () => { + it('should resolve override claude-code as alias for agent-sdk', async () => { const vercel = makeProvider('vercel-ai') - const cc = makeProvider('claude-code') - const router = new GenerateRouter(vercel, cc) + const agentSdk = makeProvider('agent-sdk') + const router = new GenerateRouter(vercel, agentSdk) const provider = await router.resolve('claude-code') - expect(provider).toBe(cc) + expect(provider).toBe(agentSdk) }) it('should resolve override agent-sdk when available', async () => { const vercel = makeProvider('vercel-ai') const agentSdk = makeProvider('agent-sdk') - const router = new GenerateRouter(vercel, null, agentSdk) + const router = new GenerateRouter(vercel, agentSdk) const provider = await router.resolve('agent-sdk') expect(provider).toBe(agentSdk) diff --git a/src/core/ai-provider-manager.ts b/src/core/ai-provider-manager.ts index 7d7da37a..7b036e9e 100644 --- a/src/core/ai-provider-manager.ts +++ b/src/core/ai-provider-manager.ts @@ -137,19 +137,17 @@ export interface AskOptions { export class GenerateRouter { constructor( private vercel: AIProvider, - private claudeCode: AIProvider | null, private agentSdk: AIProvider | null = null, ) {} /** Resolve the active provider, optionally overridden per-request. */ async resolve(override?: string): Promise { - if (override === 'agent-sdk' && this.agentSdk) return this.agentSdk - if (override === 'claude-code' && this.claudeCode) return this.claudeCode + // 'claude-code' is a legacy alias for 'agent-sdk' + if ((override === 'agent-sdk' || override === 'claude-code') && this.agentSdk) return this.agentSdk if (override === 'vercel-ai-sdk') return this.vercel const config = await readAIProviderConfig() - if (config.backend === 'agent-sdk' && this.agentSdk) return this.agentSdk - if (config.backend === 'claude-code' && this.claudeCode) return this.claudeCode + if ((config.backend === 'agent-sdk' || config.backend === 'claude-code') && this.agentSdk) return this.agentSdk return this.vercel } diff --git a/src/core/config.ts b/src/core/config.ts index d1d9f4bb..75901fb8 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -323,6 +323,14 @@ export async function loadConfig(): Promise { await removeJsonFile('api-keys.json') } + // ---------- Migration: claude-code backend → agent-sdk + claudeai ---------- + if (aiProviderRaw && (aiProviderRaw as Record).backend === 'claude-code') { + const patched = { ...(aiProviderRaw as Record), backend: 'agent-sdk', loginMethod: 'claudeai' } + raws[6] = patched + await mkdir(CONFIG_DIR, { recursive: true }) + await writeFile(resolve(CONFIG_DIR, 'ai-provider-manager.json'), JSON.stringify(patched, null, 2) + '\n') + } + // ---------- Migration: consolidate old telegram.json + engine port fields ---------- const connectorsRaw = raws[8] as Record | undefined if (connectorsRaw === undefined) { diff --git a/src/main.ts b/src/main.ts index 37423e27..b726451e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -40,7 +40,6 @@ import { ToolCenter } from './core/tool-center.js' import { AgentCenter } from './core/agent-center.js' import { GenerateRouter } from './core/ai-provider-manager.js' import { VercelAIProvider } from './ai-providers/vercel-ai-sdk/vercel-provider.js' -import { ClaudeCodeProvider } from './ai-providers/claude-code/claude-code-provider.js' import { AgentSdkProvider } from './ai-providers/agent-sdk/agent-sdk-provider.js' import { createEventLog } from './core/event-log.js' import { createToolCallLog } from './core/tool-call-log.js' @@ -282,12 +281,11 @@ async function main() { instructions, config.agent.maxSteps, ) - const claudeCodeProvider = new ClaudeCodeProvider(instructions) const agentSdkProvider = new AgentSdkProvider( () => toolCenter.getVercelTools(), instructions, ) - const router = new GenerateRouter(vercelProvider, claudeCodeProvider, agentSdkProvider) + const router = new GenerateRouter(vercelProvider, agentSdkProvider) const agentCenter = new AgentCenter({ router, diff --git a/ui/src/components/ChannelConfigModal.tsx b/ui/src/components/ChannelConfigModal.tsx index a632b93c..34cc675a 100644 --- a/ui/src/components/ChannelConfigModal.tsx +++ b/ui/src/components/ChannelConfigModal.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import { api } from '../api' +import type { LoginMethod } from '../api/types' import type { ChannelListItem } from '../api/channels' import type { ToolInfo } from '../api/tools' @@ -28,7 +29,7 @@ export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigM const [aModel, setAModel] = useState(channel.agentSdk?.model ?? '') const [aBaseUrl, setABaseUrl] = useState(channel.agentSdk?.baseUrl ?? '') const [aApiKey, setAApiKey] = useState(channel.agentSdk?.apiKey ?? '') - const [aLoginMethod, setALoginMethod] = useState(channel.agentSdk?.loginMethod ?? '') + const [aLoginMethod, setALoginMethod] = useState(channel.agentSdk?.loginMethod ?? '') const showVercelConfig = provider === 'vercel-ai-sdk' const showAgentSdkConfig = provider === 'agent-sdk' @@ -55,7 +56,7 @@ export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigM ...(aModel ? { model: aModel } : {}), ...(aBaseUrl ? { baseUrl: aBaseUrl } : {}), ...(aApiKey ? { apiKey: aApiKey } : {}), - ...(aLoginMethod ? { loginMethod: aLoginMethod as 'api-key' | 'oauth' | 'claudeai' } : {}), + ...(aLoginMethod ? { loginMethod: aLoginMethod } : {}), } : undefined @@ -148,9 +149,8 @@ export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigM className={inputClass} > - - +
@@ -223,7 +223,7 @@ export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigM @@ -217,7 +217,7 @@ export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigM {/* Agent SDK config — only when provider is agent-sdk */} {showAgentSdkConfig && (
-

Agent SDK Override

+

Claude Override

diff --git a/ui/src/pages/AIProviderPage.tsx b/ui/src/pages/AIProviderPage.tsx index 917ba95a..9d33118b 100644 --- a/ui/src/pages/AIProviderPage.tsx +++ b/ui/src/pages/AIProviderPage.tsx @@ -6,9 +6,9 @@ import { useAutoSave, type SaveStatus } from '../hooks/useAutoSave' import { PageHeader } from '../components/PageHeader' import { PageLoading } from '../components/StateViews' -const LOGIN_METHODS: { value: LoginMethod; label: string; description: string }[] = [ - { value: 'api-key', label: 'API Key', description: 'Use Anthropic API key' }, - { value: 'claudeai', label: 'Claude Pro/Max', description: 'Claude.ai subscription billing via Claude Code login' }, +const LOGIN_METHODS: { value: LoginMethod; label: string; subtitle: string; hint: string }[] = [ + { value: 'claudeai', label: 'Claude Pro/Max', subtitle: 'Use your Claude subscription', hint: 'Requires local Claude Code login (run claude login in terminal). No API key needed.' }, + { value: 'api-key', label: 'API Key', subtitle: 'Pay per token', hint: 'Enter your Anthropic API key below. Billed per token to your API account.' }, ] const PROVIDER_MODELS: Record = { @@ -49,6 +49,31 @@ function detectCustomMode(provider: string, model: string): boolean { return !presets.some((p) => p.value === model) } +function BackendCard({ selected, onClick, icon, title, description }: { + selected: boolean + onClick: () => void + icon: React.ReactNode + title: string + description: string +}) { + return ( + + ) +} + export function AIProviderPage() { const [config, setConfig] = useState(null) @@ -76,27 +101,28 @@ export function AIProviderPage() {
{/* Backend */} -
-
- {(['agent-sdk', 'vercel-ai-sdk'] as const).map((b, i) => ( - - ))} +
+
+ handleBackendSwitch('agent-sdk')} + icon={} + title="Claude" + description="Local Claude Code login with full tool access" + /> + handleBackendSwitch('vercel-ai-sdk')} + icon={} + title="Vercel AI SDK" + description="Direct API calls to Anthropic, OpenAI, Google" + />
{/* Auth mode (only for Agent SDK) */} {config.aiProvider.backend === 'agent-sdk' && ( -
+
setConfig((c) => c ? { ...c, aiProvider: { ...c.aiProvider, ...patch } } : c)} />
)} @@ -403,26 +429,23 @@ function AgentSdkAuthForm({ aiProvider, onUpdate }: { aiProvider: AIProviderConf return ( <> - -
- {LOGIN_METHODS.map((m, i) => ( - - ))} -
-

- {LOGIN_METHODS.find((m) => m.value === loginMethod)?.description} -

-
+
+ {LOGIN_METHODS.map((m) => ( + handleLoginMethodChange(m.value)} + icon={m.value === 'claudeai' + ? + : } + title={m.label} + description={m.subtitle} + /> + ))} +
+

+ {LOGIN_METHODS.find((m) => m.value === loginMethod)?.hint} +

{loginMethod === 'api-key' && (