diff --git a/CLAUDE.md b/CLAUDE.md index e31eebfd..6096326e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,9 +32,8 @@ src/ │ ├── media-store.ts # Media file persistence │ └── types.ts # Plugin, EngineContext interfaces ├── ai-providers/ -│ ├── claude-code/ # Claude Code CLI subprocess │ ├── vercel-ai-sdk/ # Vercel AI SDK ToolLoopAgent -│ └── agent-sdk/ # Agent SDK (@anthropic-ai/claude-agent-sdk) +│ └── agent-sdk/ # Claude backend (@anthropic-ai/claude-agent-sdk, supports OAuth + API key) ├── domain/ │ ├── market-data/ # Structured data layer (typebb in-process + OpenBB API remote) │ ├── trading/ # Unified multi-account trading, guard pipeline, git-like commits @@ -72,10 +71,9 @@ Two layers (Engine was removed): 1. **AgentCenter** (`core/agent-center.ts`) — top-level orchestration. Manages sessions, compaction, and routes calls through GenerateRouter. Exposes `ask()` (stateless) and `askWithSession()` (with history). -2. **GenerateRouter** (`core/ai-provider-manager.ts`) — reads `ai-provider.json` on each call, resolves to active provider. Three backends: - - Claude Code CLI (`inputKind: 'text'`) - - Vercel AI SDK (`inputKind: 'messages'`) - - Agent SDK (`inputKind: 'text'`) +2. **GenerateRouter** (`core/ai-provider-manager.ts`) — reads `ai-provider.json` on each call, resolves to active provider. Two backends: + - Agent SDK (`inputKind: 'text'`) — Claude via @anthropic-ai/claude-agent-sdk, tools via in-process MCP + - Vercel AI SDK (`inputKind: 'messages'`) — direct API calls, tools via Vercel tool system **AIProvider interface**: `ask(prompt)` for one-shot, `generate(input, opts)` for streaming `ProviderEvent` (tool_use / tool_result / text / done). Optional `compact()` for provider-native compaction. diff --git a/README.md b/README.md index 61dd70f2..112e88d1 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Your one-person Wall Street. Alice is an AI trading agent that gives you your ow ## Features -- **Multi-provider AI** — switch between Claude Code CLI, Vercel AI SDK, and Agent SDK at runtime, no restart needed +- **Multi-provider AI** — switch between Claude (via Agent SDK with OAuth or API key) and Vercel AI SDK at runtime, no restart needed - **Unified Trading Account (UTA)** — each trading account is a self-contained entity that owns its broker connection, git-like operation history, and guard pipeline. AI interacts with UTAs, never with brokers directly. All order types use IBKR's type system (`@traderalice/ibkr`) as the single source of truth, with Alpaca and CCXT adapting to it - **Trading-as-Git** — stage orders, commit with a message, push to execute. Every commit gets an 8-char hash. Full history reviewable via `tradingLog` / `tradingShow` - **Guard pipeline** — pre-execution safety checks (max position size, cooldown, symbol whitelist) that run inside each UTA before orders reach the broker @@ -39,7 +39,7 @@ Your one-person Wall Street. Alice is an AI trading agent that gives you your ow ## Key Concepts -**Provider** — The AI backend that powers Alice. Claude Code (subprocess), Vercel AI SDK (in-process), or Agent SDK (`@anthropic-ai/claude-agent-sdk`). Switchable at runtime via `ai-provider.json`. +**Provider** — The AI backend that powers Alice. Claude (via `@anthropic-ai/claude-agent-sdk`, supports OAuth login or API key) or Vercel AI SDK (direct API calls to Anthropic, OpenAI, Google). Switchable at runtime via `ai-provider.json`. **Extension** — A self-contained tool package registered in ToolCenter. Each extension owns its tools, state, and persistence. Examples: trading, brain, analysis-kit. @@ -64,9 +64,8 @@ Your one-person Wall Street. Alice is an AI trading agent that gives you your ow ```mermaid graph LR subgraph Providers - CC[Claude Code CLI] + AS[Claude / Agent SDK] VS[Vercel AI SDK] - AS[Agent SDK] end subgraph Core @@ -102,12 +101,12 @@ graph LR MCP[MCP Server] end - CC --> PR - VS --> PR AS --> PR + VS --> PR PR --> AC AC --> S TC -->|Vercel tools| VS + TC -->|in-process MCP| AS TC -->|MCP tools| MCP OBB --> AK OBB --> NC @@ -128,7 +127,7 @@ graph LR MCP --> AC ``` -**Providers** — interchangeable AI backends. Claude Code spawns `claude -p` as a subprocess; Vercel AI SDK runs a `ToolLoopAgent` in-process; Agent SDK uses `@anthropic-ai/claude-agent-sdk`. `ProviderRouter` reads `ai-provider.json` on each call to select the active backend at runtime. +**Providers** — interchangeable AI backends. Claude (Agent SDK) uses `@anthropic-ai/claude-agent-sdk` with tools delivered via in-process MCP — supports Claude Pro/Max OAuth login or API key. Vercel AI SDK runs a `ToolLoopAgent` in-process with direct API calls. `ProviderRouter` reads `ai-provider.json` on each call to select the active backend at runtime. **Core** — `AgentCenter` is the top-level orchestration center that routes all calls (both stateless and session-aware) through `ProviderRouter`. `ToolCenter` is a centralized tool registry — extensions register tools there, and it exports them in Vercel AI SDK and MCP formats. `EventLog` provides persistent append-only event storage (JSONL) with real-time subscriptions and crash recovery. `ConnectorCenter` tracks which channel the user last spoke through. @@ -149,7 +148,7 @@ pnpm install && pnpm build pnpm dev ``` -Open [localhost:3002](http://localhost:3002) and start chatting. No API keys or config needed — the default setup uses Claude Code as the AI backend with your existing login. +Open [localhost:3002](http://localhost:3002) and start chatting. No API keys or config needed — the default setup uses your local Claude Code login (Claude Pro/Max subscription). ```bash pnpm dev # start backend (port 3002) with watch mode @@ -164,7 +163,7 @@ pnpm test # run tests All config lives in `data/config/` as JSON files with Zod validation. Missing files fall back to sensible defaults. You can edit these files directly or use the Web UI. -**AI Provider** — The default provider is Claude Code (`claude -p` subprocess). To use the [Vercel AI SDK](https://sdk.vercel.ai/docs) instead (Anthropic, OpenAI, Google, etc.), switch `ai-provider.json` to `vercel-ai-sdk` and add your API key to `api-keys.json`. A third option, Agent SDK (`@anthropic-ai/claude-agent-sdk`), is also available via `agent-sdk`. +**AI Provider** — The default provider is Claude (Agent SDK), which uses your local Claude Code login — no API key needed. To use the [Vercel AI SDK](https://sdk.vercel.ai/docs) instead (Anthropic, OpenAI, Google, etc.), switch `ai-provider.json` to `vercel-ai-sdk` and add your API key. Both can be switched at runtime via the Web UI. **Trading** — Unified Trading Account (UTA) architecture. Define platforms in `platforms.json` (CCXT exchanges, Alpaca), then create accounts in `accounts.json` referencing a platform. Each account becomes a UTA with its own git history and guard config. Legacy `crypto.json` and `securities.json` are still supported. @@ -172,8 +171,7 @@ All config lives in `data/config/` as JSON files with Zod validation. Missing fi |------|---------| | `engine.json` | Trading pairs, tick interval, timeframe | | `agent.json` | Max agent steps, evolution mode toggle, Claude Code tool permissions | -| `ai-provider.json` | Active AI provider (`claude-code`, `vercel-ai-sdk`, or `agent-sdk`), switchable at runtime | -| `api-keys.json` | AI provider API keys (Anthropic, OpenAI, Google) — only needed for Vercel AI SDK mode | +| `ai-provider.json` | Active AI provider (`agent-sdk` or `vercel-ai-sdk`), login method, switchable at runtime | | `platforms.json` | Trading platform definitions (CCXT exchanges, Alpaca) | | `accounts.json` | Trading account credentials and guard config, references platforms | | `crypto.json` | CCXT exchange config + API keys, allowed symbols, guards | @@ -218,9 +216,8 @@ src/ media-store.ts # Media file persistence types.ts # Plugin, EngineContext interfaces ai-providers/ - claude-code/ # Claude Code CLI subprocess wrapper vercel-ai-sdk/ # Vercel AI SDK ToolLoopAgent wrapper - agent-sdk/ # Agent SDK (@anthropic-ai/claude-agent-sdk) wrapper + agent-sdk/ # Claude backend (@anthropic-ai/claude-agent-sdk, OAuth + API key) extension/ analysis-kit/ # Indicator calculator and market data tools equity/ # Equity fundamentals and data adapter 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/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 e64b21a5..75901fb8 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({ @@ -318,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/src/tool/trading.ts b/src/tool/trading.ts index 01dbb2e2..4917ab8e 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -254,7 +254,7 @@ NOTE: This stages the operation. Call tradingCommit + tradingPush to execute.`, }), modifyOrder: tool({ - description: 'Stage an order modification (will execute on tradingPush).', + description: 'Stage an order modification.\nNOTE: This stages the operation. Call tradingCommit + tradingPush to execute.', inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), orderId: z.string().describe('Order ID to modify'), @@ -271,7 +271,7 @@ NOTE: This stages the operation. Call tradingCommit + tradingPush to execute.`, }), closePosition: tool({ - description: 'Stage a position close (will execute on tradingPush).', + description: 'Stage a position close.\nNOTE: This stages the operation. Call tradingCommit + tradingPush to execute.', inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), aliceId: z.string().describe('Contract identifier'), @@ -282,7 +282,7 @@ NOTE: This stages the operation. Call tradingCommit + tradingPush to execute.`, }), cancelOrder: tool({ - description: 'Stage an order cancellation (will execute on tradingPush).', + description: 'Stage an order cancellation.\nNOTE: This stages the operation. Call tradingCommit + tradingPush to execute.', inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), orderId: z.string().describe('Order ID to cancel'), @@ -316,7 +316,16 @@ NOTE: This stages the operation. Call tradingCommit + tradingPush to execute.`, execute: async ({ source }) => { const targets = manager.resolve(source) const pending = targets.filter(uta => uta.status().pendingMessage) - if (pending.length === 0) return { message: 'No committed operations to push.' } + if (pending.length === 0) { + const uncommitted = targets.filter(uta => uta.status().staged.length > 0) + if (uncommitted.length > 0) { + return { + error: 'You have staged operations that are NOT committed yet. Call tradingCommit first, then tradingPush.', + uncommitted: uncommitted.map(uta => ({ source: uta.id, staged: uta.status().staged })), + } + } + return { message: 'No committed operations to push.' } + } return { message: 'Push requires manual approval. The user can approve pending operations in the UI.', pending: pending.map(uta => ({ 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..02d65753 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,6 +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 showVercelConfig = provider === 'vercel-ai-sdk' const showAgentSdkConfig = provider === 'agent-sdk' @@ -49,11 +51,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 } : {}), } : undefined @@ -146,9 +149,8 @@ export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigM className={inputClass} > + - - @@ -215,7 +217,20 @@ 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/components/PushApprovalPanel.tsx b/ui/src/components/PushApprovalPanel.tsx index 85e6698a..922b9352 100644 --- a/ui/src/components/PushApprovalPanel.tsx +++ b/ui/src/components/PushApprovalPanel.tsx @@ -4,6 +4,11 @@ import type { TradingAccount, WalletStatus, WalletPushResult, WalletCommitLog } // ==================== Types ==================== +interface StagedAccount { + account: TradingAccount + status: WalletStatus +} + interface PendingAccount { account: TradingAccount status: WalletStatus @@ -82,6 +87,7 @@ function statusColor(status: string): string { export function PushApprovalPanel() { const [accounts, setAccounts] = useState([]) + const [staged, setStaged] = useState([]) const [pending, setPending] = useState([]) const [history, setHistory] = useState([]) const [pushing, setPushing] = useState(null) @@ -95,6 +101,7 @@ export function PushApprovalPanel() { const { accounts: accts } = await api.trading.listAccounts() setAccounts(accts) + const stagedResults: StagedAccount[] = [] const pendingResults: PendingAccount[] = [] const historyResults: AccountHistory[] = [] @@ -106,6 +113,8 @@ export function PushApprovalPanel() { ]) if (status.pendingMessage) { pendingResults.push({ account, status }) + } else if (status.staged.length > 0) { + stagedResults.push({ account, status }) } if (commits.length > 0) { historyResults.push({ accountId: account.id, label: account.label || account.id, commits }) @@ -113,6 +122,7 @@ export function PushApprovalPanel() { } catch { /* skip unreachable */ } } + setStaged(stagedResults) setPending(pendingResults) setHistory(historyResults) } catch { /* ignore */ } @@ -156,6 +166,7 @@ export function PushApprovalPanel() { // No trading accounts configured — hide panel entirely if (accounts.length === 0) return null + const hasStaged = staged.length > 0 const hasPending = pending.length > 0 const hasHistory = history.length > 0 @@ -170,9 +181,45 @@ export function PushApprovalPanel() { {hasPending && ( )} + {!hasPending && hasStaged && ( + + )}
+ {/* ==================== Staged (uncommitted) Section ==================== */} + {hasStaged && ( +
+ {staged.map(({ account, status }) => ( +
+
+ {account.label || account.id} +
+ +
+ Staged — waiting for AI to commit +
+ +
+ {status.staged.map((op, i) => { + const { text, side } = formatOp(op) + return ( +
+ {text} +
+ ) + })} +
+
+ ))} +
+ )} + {/* ==================== Pending Section ==================== */} {hasPending ? (
@@ -274,11 +321,11 @@ export function PushApprovalPanel() {
)}
- ) : ( + ) : !hasStaged ? (
No pending operations
- )} + ) : null} {/* ==================== History Section ==================== */} {hasHistory && ( diff --git a/ui/src/pages/AIProviderPage.tsx b/ui/src/pages/AIProviderPage.tsx index b44b0f76..9d33118b 100644 --- a/ui/src/pages/AIProviderPage.tsx +++ b/ui/src/pages/AIProviderPage.tsx @@ -1,11 +1,16 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react' -import { api, type AppConfig, type AIProviderConfig } from '../api' +import { api, type AppConfig, type AIProviderConfig, type LoginMethod } from '../api' import { SaveIndicator } from '../components/SaveIndicator' import { Section, Field, inputClass } from '../components/form' import { useAutoSave, type SaveStatus } from '../hooks/useAutoSave' import { PageHeader } from '../components/PageHeader' import { PageLoading } from '../components/StateViews' +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 = { anthropic: [ { label: 'Claude Opus 4.6', value: 'claude-opus-4-6' }, @@ -44,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) @@ -71,24 +101,32 @@ export function AIProviderPage() {
{/* Backend */} -
-
- {(['claude-code', 'vercel-ai-sdk', 'agent-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)} /> +
+ )} + {/* Model (only for Vercel AI SDK) */} {config.aiProvider.backend === 'vercel-ai-sdk' && (
@@ -356,3 +394,85 @@ 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) => ( + handleLoginMethodChange(m.value)} + icon={m.value === 'claudeai' + ? + : } + title={m.label} + description={m.subtitle} + /> + ))} +
+

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

+ + {loginMethod === 'api-key' && ( + +
+ setApiKey(e.target.value)} + placeholder={aiProvider.apiKeys?.anthropic ? '(configured)' : 'sk-ant-...'} + /> + {aiProvider.apiKeys?.anthropic && ( + active + )} +
+
+ + +
+
+ )} + + ) +}