diff --git a/README.md b/README.md index 3f93fb8a..bb66fd77 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

- CI · License: AGPL-3.0 · Ask DeepWiki · traderalice.com + CI · License: AGPL-3.0 · openalice.ai · Docs · Ask DeepWiki

# Open Alice @@ -196,85 +196,93 @@ On first run, defaults are auto-copied to the user override path. Edit the user ## Project Structure +Open Alice is a pnpm monorepo with Turborepo build orchestration. + ``` +packages/ +├── ibkr/ # @traderalice/ibkr — IBKR TWS API TypeScript port +└── opentypebb/ # @traderalice/opentypebb — OpenBB platform TS port +ui/ # React frontend (Vite, 13 pages) src/ - main.ts # Composition root — wires everything together - core/ - agent-center.ts # Top-level AI orchestration, owns ProviderRouter - ai-provider-manager.ts # GenerateRouter + StreamableResult + AskOptions - tool-center.ts # Centralized tool registry (Vercel + MCP export) - session.ts # JSONL session store + format converters - compaction.ts # Auto-summarize long context windows - config.ts # Zod-validated config loader (generic account schema with brokerConfig) - ai-config.ts # Runtime AI provider selection - event-log.ts # Append-only JSONL event log - connector-center.ts # ConnectorCenter — push delivery + last-interacted tracking - async-channel.ts # AsyncChannel for streaming provider events to SSE - model-factory.ts # Model instance factory for Vercel AI SDK - media.ts # MediaAttachment extraction - media-store.ts # Media file persistence - types.ts # Plugin, EngineContext interfaces - ai-providers/ - vercel-ai-sdk/ # Vercel AI SDK ToolLoopAgent wrapper - agent-sdk/ # Claude backend (@anthropic-ai/claude-agent-sdk, OAuth + API key) - domain/ - trading/ # Unified multi-account trading, guard pipeline, git-like commits - UnifiedTradingAccount.ts # UTA class — owns broker + git + guards + snapshots - account-manager.ts # UTA lifecycle (init, reconnect, enable/disable) + registry - git-persistence.ts # Git state load/save - brokers/ - registry.ts # Broker self-registration (configSchema + configFields + fromConfig) - alpaca/ # Alpaca (US equities) - ccxt/ # CCXT (100+ crypto exchanges) - ibkr/ # Interactive Brokers (TWS/Gateway) - mock/ # In-memory test broker - git/ # Trading-as-Git engine (stage → commit → push) - guards/ # Pre-execution safety checks (position size, cooldown, whitelist) - snapshot/ # Periodic + event-driven account state capture, equity curve - market-data/ # Structured data layer (typebb in-process + OpenBB API remote) - equity/ # Equity data + SymbolIndex (SEC/TMX local cache) - crypto/ # Crypto data layer - currency/ # Currency/forex data layer - commodity/ # Commodity data layer (EIA, spot prices) - economy/ # Macro economy data layer - client/ # Data backend clients (typebb SDK, openbb-api) - analysis/ # Indicators, technical analysis - news/ # RSS collector + archive search - brain/ # Cognitive state (memory, emotion) - thinking/ # Safe expression evaluator - tool/ # AI tool definitions — thin bridge from domain to ToolCenter - trading.ts # Trading tools (delegates to domain/trading) - equity.ts # Equity fundamental tools (uses domain/market-data) - market.ts # Symbol search tools (uses domain/market-data) - analysis.ts # Indicator calculation tools (uses domain/analysis) - news.ts # News archive tools (uses domain/news) - brain.ts # Cognition tools (uses domain/brain) - thinking.ts # Reasoning tools (uses domain/thinking) - browser.ts # Browser automation tools (wraps openclaw) - server/ - mcp.ts # MCP protocol server - opentypebb.ts # Embedded OpenBB-compatible HTTP API (optional) - connectors/ - web/ # Web UI chat (Hono, SSE streaming, sub-channels) - telegram/ # Telegram bot (grammY, polling, commands) - mcp-ask/ # MCP Ask connector (external agent conversation) - task/ - cron/ # Cron scheduling (engine, listener, AI tools) - heartbeat/ # Periodic heartbeat with structured response protocol - openclaw/ # ⚠️ Frozen — DO NOT MODIFY +├── main.ts # Composition root — wires everything together +├── core/ +│ ├── agent-center.ts # Top-level AI orchestration, owns ProviderRouter +│ ├── ai-provider-manager.ts # GenerateRouter + StreamableResult + AskOptions +│ ├── tool-center.ts # Centralized tool registry (Vercel + MCP export) +│ ├── mcp-export.ts # Shared MCP export layer with type coercion +│ ├── session.ts # JSONL session store + format converters +│ ├── compaction.ts # Auto-summarize long context windows +│ ├── config.ts # Zod-validated config loader +│ ├── event-log.ts # Append-only JSONL event log +│ ├── connector-center.ts # ConnectorCenter — push delivery + last-interacted tracking +│ ├── async-channel.ts # AsyncChannel for streaming provider events to SSE +│ ├── tool-call-log.ts # Tool invocation logging +│ ├── media.ts # MediaAttachment extraction +│ ├── media-store.ts # Media file persistence +│ └── types.ts # Plugin, EngineContext interfaces +├── ai-providers/ +│ ├── vercel-ai-sdk/ # Vercel AI SDK ToolLoopAgent wrapper +│ ├── agent-sdk/ # Claude backend (@anthropic-ai/claude-agent-sdk, OAuth + API key) +│ └── mock/ # Mock provider (testing) +├── domain/ +│ ├── trading/ # Unified multi-account trading, guard pipeline, git-like commits +│ │ ├── account-manager.ts # UTA lifecycle (init, reconnect, enable/disable) + registry +│ │ ├── git-persistence.ts # Git state load/save +│ │ ├── brokers/ +│ │ │ ├── registry.ts # Broker self-registration (configSchema + configFields + fromConfig) +│ │ │ ├── alpaca/ # Alpaca (US equities) +│ │ │ ├── ccxt/ # CCXT (100+ crypto exchanges) +│ │ │ ├── ibkr/ # Interactive Brokers (TWS/Gateway) +│ │ │ └── mock/ # In-memory test broker +│ │ ├── git/ # Trading-as-Git engine (stage → commit → push) +│ │ ├── guards/ # Pre-execution safety checks (position size, cooldown, whitelist) +│ │ └── snapshot/ # Periodic + event-driven account state capture, equity curve +│ ├── market-data/ # Structured data layer (opentypebb in-process + OpenBB API remote) +│ │ ├── equity/ # Equity data + SymbolIndex (SEC/TMX local cache) +│ │ ├── crypto/ # Crypto data layer +│ │ ├── currency/ # Currency/forex data layer +│ │ ├── commodity/ # Commodity data layer (EIA, spot prices) +│ │ ├── economy/ # Macro economy data layer +│ │ └── client/ # Data backend clients (opentypebb SDK, openbb-api) +│ ├── analysis/ # Indicators, technical analysis +│ ├── news/ # RSS collector + archive search +│ ├── brain/ # Cognitive state (memory, emotion) +│ └── thinking/ # Safe expression evaluator +├── tool/ # AI tool definitions — thin bridge from domain to ToolCenter +│ ├── trading.ts # Trading tools (delegates to domain/trading) +│ ├── equity.ts # Equity fundamental tools +│ ├── market.ts # Symbol search tools +│ ├── analysis.ts # Indicator calculation tools +│ ├── news.ts # News archive tools +│ ├── brain.ts # Cognition tools +│ ├── thinking.ts # Reasoning tools +│ ├── browser.ts # Browser automation tools (wraps openclaw) +│ └── session.ts # Session awareness tools +├── server/ +│ ├── mcp.ts # MCP protocol server +│ └── opentypebb.ts # Embedded OpenBB-compatible HTTP API (optional) +├── connectors/ +│ ├── web/ # Web UI (Hono, SSE streaming, sub-channels) +│ ├── telegram/ # Telegram bot (grammY, magic link auth, /trading panel) +│ ├── mcp-ask/ # MCP Ask connector (external agent conversation) +│ └── mock/ # Mock connector (testing) +├── task/ +│ ├── cron/ # Cron scheduling (engine, listener, AI tools) +│ └── heartbeat/ # Periodic heartbeat with structured response protocol +└── openclaw/ # ⚠️ Frozen — DO NOT MODIFY data/ - config/ # JSON configuration files - sessions/ # JSONL conversation histories - brain/ # Agent memory and emotion logs - cache/ # API response caches - trading/ # Trading commit history + snapshots (per-account) - news-collector/ # Persistent news archive (JSONL) - cron/ # Cron job definitions (jobs.json) - event-log/ # Persistent event log (events.jsonl) - tool-calls/ # Tool invocation logs - media/ # Uploaded attachments -default/ # Factory defaults (persona, heartbeat prompts) -docs/ # Architecture documentation +├── config/ # JSON configuration files +├── sessions/ # JSONL conversation histories (web/, telegram/, cron/) +├── brain/ # Agent memory and emotion logs +├── cache/ # API response caches +├── trading/ # Trading commit history + snapshots (per-account) +├── news-collector/ # Persistent news archive (JSONL) +├── cron/ # Cron job definitions (jobs.json) +├── event-log/ # Persistent event log (events.jsonl) +├── tool-calls/ # Tool invocation logs +└── media/ # Uploaded attachments +default/ # Factory defaults (persona, heartbeat, skills) +docs/ # Documentation ``` ## Roadmap to v1 diff --git a/package.json b/package.json index 5b24a59a..676fb8db 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "grammy": "^1.40.0", "hono": "^4.12.7", "json5": "^2.2.3", + "openai": "^6.33.0", "pino": "^10.3.1", "playwright-core": "1.58.2", "sharp": "^0.34.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62877d53..4d061825 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: json5: specifier: ^2.2.3 version: 2.2.3 + openai: + specifier: ^6.33.0 + version: 6.33.0(ws@8.19.0)(zod@4.3.6) pino: specifier: ^10.3.1 version: 10.3.1 @@ -2376,6 +2379,18 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openai@6.33.0: + resolution: {integrity: sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} @@ -5030,6 +5045,11 @@ snapshots: dependencies: wrappy: 1.0.2 + openai@6.33.0(ws@8.19.0)(zod@4.3.6): + optionalDependencies: + ws: 8.19.0 + zod: 4.3.6 + parse5@8.0.0: dependencies: entities: 6.0.1 diff --git a/src/ai-providers/agent-sdk/agent-sdk-provider.ts b/src/ai-providers/agent-sdk/agent-sdk-provider.ts index ec50fcbb..723e9369 100644 --- a/src/ai-providers/agent-sdk/agent-sdk-provider.ts +++ b/src/ai-providers/agent-sdk/agent-sdk-provider.ts @@ -15,7 +15,7 @@ import type { SessionEntry } from '../../core/session.js' import type { AgentSdkConfig, AgentSdkOverride } from './query.js' import { toTextHistory } from '../../core/session.js' import { buildChatHistoryPrompt, DEFAULT_MAX_HISTORY } from '../utils.js' -import { readAgentConfig } from '../../core/config.js' +import { readAgentConfig, resolveProfile } from '../../core/config.js' import { createChannel } from '../../core/async-channel.js' import { askAgentSdk } from './query.js' import { buildAgentSdkMcpServer } from './tool-bridge.js' @@ -25,7 +25,7 @@ export class AgentSdkProvider implements AIProvider { constructor( private getTools: () => Promise>, - private systemPrompt?: string, + private getSystemPrompt: () => Promise, ) {} /** Re-read agent config from disk to pick up hot-reloaded settings. */ @@ -46,8 +46,14 @@ export class AgentSdkProvider implements AIProvider { async ask(prompt: string): Promise { const config = await this.resolveConfig() + config.systemPrompt = await this.getSystemPrompt() + const profile = await resolveProfile() + const override: AgentSdkOverride = { + model: profile.model, apiKey: profile.apiKey, baseUrl: profile.baseUrl, + loginMethod: profile.loginMethod as 'api-key' | 'claudeai' | undefined, + } const mcpServer = await this.buildMcpServer() - const result = await askAgentSdk(prompt, config, undefined, mcpServer) + const result = await askAgentSdk(prompt, config, override, mcpServer) return { text: result.text, media: [] } } @@ -62,10 +68,14 @@ export class AgentSdkProvider implements AIProvider { ...(opts?.disabledTools?.length ? { disallowedTools: [...(config.disallowedTools ?? []), ...opts.disabledTools] } : {}), - systemPrompt: opts?.systemPrompt ?? this.systemPrompt, + systemPrompt: opts?.systemPrompt ?? await this.getSystemPrompt(), } - const override: AgentSdkOverride | undefined = opts?.agentSdk + // Build override from resolved profile + const profile = opts?.profile + const override: AgentSdkOverride | undefined = profile + ? { model: profile.model, apiKey: profile.apiKey, baseUrl: profile.baseUrl, loginMethod: profile.loginMethod as 'api-key' | 'claudeai' | undefined } + : undefined const mcpServer = await this.buildMcpServer(opts?.disabledTools) const channel = createChannel() diff --git a/src/ai-providers/agent-sdk/query.ts b/src/ai-providers/agent-sdk/query.ts index 6136e33c..d4ce05d1 100644 --- a/src/ai-providers/agent-sdk/query.ts +++ b/src/ai-providers/agent-sdk/query.ts @@ -10,7 +10,7 @@ import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent- import { pino } from 'pino' import type { ContentBlock } from '../../core/session.js' -import { readAIProviderConfig } from '../../core/config.js' +// Config is now resolved via profile system — override carries all needed values const logger = pino({ transport: { target: 'pino/file', options: { destination: 'logs/agent-sdk.log', mkdir: true } }, @@ -117,9 +117,8 @@ export async function askAgentSdk( const finalAllowed = allowedTools.length > 0 ? allowedTools : modeAllowed const finalDisallowed = [...disallowedTools, ...modeDisallowed] - // Build env with authentication - const aiConfig = await readAIProviderConfig() - const loginMethod = override?.loginMethod ?? aiConfig.loginMethod ?? 'api-key' + // Build env with authentication — override carries resolved profile values + const loginMethod = override?.loginMethod ?? 'api-key' const isOAuthMode = loginMethod === 'claudeai' const env: Record = { ...process.env } @@ -127,10 +126,10 @@ export async function askAgentSdk( // Force OAuth by removing any inherited API key delete env.ANTHROPIC_API_KEY } else { - const apiKey = override?.apiKey ?? aiConfig.apiKeys.anthropic + const apiKey = override?.apiKey if (apiKey) env.ANTHROPIC_API_KEY = apiKey } - const baseUrl = override?.baseUrl ?? aiConfig.baseUrl + const baseUrl = override?.baseUrl if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl // MCP servers @@ -149,7 +148,7 @@ export async function askAgentSdk( options: { cwd, env, - model: override?.model ?? aiConfig.model, + model: override?.model ?? 'claude-sonnet-4-6', maxTurns, allowedTools: finalAllowed, disallowedTools: finalDisallowed, diff --git a/src/ai-providers/codex/auth.ts b/src/ai-providers/codex/auth.ts new file mode 100644 index 00000000..975c3a79 --- /dev/null +++ b/src/ai-providers/codex/auth.ts @@ -0,0 +1,208 @@ +/** + * Codex OAuth authentication — reads ~/.codex/auth.json and manages token refresh. + * + * Users authenticate via `codex login` (OpenAI Codex CLI). This module reads + * the cached OAuth tokens and refreshes them when expired, writing updates back + * to disk so the Codex CLI stays in sync. + */ + +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { join, dirname } from 'node:path' +import { homedir } from 'node:os' +import { pino } from 'pino' + +const logger = pino({ + transport: { target: 'pino/file', options: { destination: 'logs/codex.log', mkdir: true } }, +}) + +// ==================== Constants ==================== + +const REFRESH_URL = 'https://auth.openai.com/oauth/token' +const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' + +// ==================== Types ==================== + +export interface CodexAuthFile { + auth_mode?: string + OPENAI_API_KEY?: string | null + tokens?: { + id_token: string + access_token: string + refresh_token: string + account_id?: string + } + last_refresh?: string +} + +// ==================== Helpers ==================== + +/** Resolve the Codex home directory ($CODEX_HOME or ~/.codex). */ +export function resolveCodexHome(): string { + const env = process.env.CODEX_HOME + if (env) return env + return join(homedir(), '.codex') +} + +function authFilePath(): string { + return join(resolveCodexHome(), 'auth.json') +} + +/** + * Decode a JWT payload without signature verification. + * Returns the parsed claims object. + */ +function decodeJwtPayload(token: string): Record { + const parts = token.split('.') + if (parts.length !== 3) throw new Error('Invalid JWT format') + return JSON.parse(Buffer.from(parts[1]!, 'base64url').toString()) +} + +/** Extract the `exp` (expiration) claim from a JWT as epoch seconds. */ +function getJwtExpiration(token: string): number | null { + try { + const claims = decodeJwtPayload(token) + return typeof claims.exp === 'number' ? claims.exp : null + } catch { + return null + } +} + +// ==================== Core ==================== + +/** Read and parse the auth.json file. */ +export async function readAuthFile(): Promise { + const path = authFilePath() + try { + const raw = await readFile(path, 'utf-8') + return JSON.parse(raw) + } catch (err: any) { + if (err?.code === 'ENOENT') { + throw new Error( + `Codex auth not found at ${path}. Run \`codex login\` to authenticate.`, + ) + } + throw new Error(`Failed to read Codex auth file: ${err?.message}`) + } +} + +/** Write the auth file back to disk (0o600 permissions on unix). */ +async function writeAuthFile(auth: CodexAuthFile): Promise { + const path = authFilePath() + await mkdir(dirname(path), { recursive: true }) + const json = JSON.stringify(auth, null, 2) + await writeFile(path, json, { mode: 0o600 }) +} + +/** Check whether the access token is expired or stale. */ +function isTokenExpired(auth: CodexAuthFile): boolean { + const token = auth.tokens?.access_token + if (!token) return true + + // Check JWT exp claim + const exp = getJwtExpiration(token) + if (exp != null && exp <= Date.now() / 1000) return true + + // Proactive refresh: 8+ days since last refresh + if (auth.last_refresh) { + const lastRefresh = new Date(auth.last_refresh).getTime() + const eightDays = 8 * 24 * 60 * 60 * 1000 + if (Date.now() - lastRefresh > eightDays) return true + } + + return false +} + +/** Request new tokens from the OpenAI auth service. */ +async function refreshTokens( + refreshToken: string, +): Promise<{ id_token?: string; access_token?: string; refresh_token?: string }> { + const res = await fetch(REFRESH_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), + }) + + if (!res.ok) { + const body = await res.text().catch(() => '') + logger.error({ status: res.status, body: body.slice(0, 500) }, 'token_refresh_failed') + throw new Error( + `Codex token refresh failed (${res.status}). You may need to run \`codex login\` again.`, + ) + } + + return await res.json() as { id_token?: string; access_token?: string; refresh_token?: string } +} + +// ==================== In-memory cache ==================== + +let cachedToken: { token: string; expiresAt: number } | null = null +let refreshPromise: Promise | null = null + +/** + * Get a valid access token, refreshing if necessary. + * + * Uses an in-memory cache to avoid repeated disk reads. A promise-based + * mutex prevents concurrent refreshes from racing on disk writes. + */ +export async function getAccessToken(): Promise { + // Fast path: cached and not expired + if (cachedToken && cachedToken.expiresAt > Date.now() / 1000) { + return cachedToken.token + } + + // Mutex: if a refresh is already in progress, wait for it + if (refreshPromise) return refreshPromise + + refreshPromise = (async () => { + try { + const auth = await readAuthFile() + + if (!auth.tokens?.access_token) { + throw new Error('Codex auth.json has no tokens. Run `codex login` to authenticate.') + } + + if (!isTokenExpired(auth)) { + // Token is still valid — cache it + const exp = getJwtExpiration(auth.tokens.access_token) + cachedToken = { + token: auth.tokens.access_token, + expiresAt: exp ?? Date.now() / 1000 + 3600, + } + return cachedToken.token + } + + // Token expired — refresh + logger.info('refreshing_codex_token') + const refreshed = await refreshTokens(auth.tokens.refresh_token) + + // Merge refreshed tokens + if (refreshed.access_token) auth.tokens.access_token = refreshed.access_token + if (refreshed.refresh_token) auth.tokens.refresh_token = refreshed.refresh_token + if (refreshed.id_token) auth.tokens.id_token = refreshed.id_token + auth.last_refresh = new Date().toISOString() + + await writeAuthFile(auth) + logger.info('codex_token_refreshed') + + const exp = getJwtExpiration(auth.tokens.access_token) + cachedToken = { + token: auth.tokens.access_token, + expiresAt: exp ?? Date.now() / 1000 + 3600, + } + return cachedToken.token + } finally { + refreshPromise = null + } + })() + + return refreshPromise +} + +/** Clear the in-memory token cache (useful after auth errors). */ +export function clearTokenCache(): void { + cachedToken = null +} diff --git a/src/ai-providers/codex/codex-provider.ts b/src/ai-providers/codex/codex-provider.ts new file mode 100644 index 00000000..3cacde5f --- /dev/null +++ b/src/ai-providers/codex/codex-provider.ts @@ -0,0 +1,272 @@ +/** + * CodexProvider — AIProvider backed by OpenAI Codex models via ChatGPT subscription OAuth. + * + * Calls the Responses API at chatgpt.com/backend-api/codex/responses using + * the standard OpenAI TypeScript SDK. Auth tokens are read from ~/.codex/auth.json + * (created by `codex login`). + * + * Context is managed by us — each call starts fresh (no previous_response_id). + * Tools are injected via the Responses API `tools` field. + */ + +import OpenAI from 'openai' +import type { Tool } from 'ai' +import { pino } from 'pino' + +import type { ProviderResult, ProviderEvent, AIProvider, GenerateOpts } from '../types.js' +import type { SessionEntry } from '../../core/session.js' +import type { ResolvedProfile } from '../../core/config.js' +import { toResponsesInput } from '../../core/session.js' +import { readAgentConfig } from '../../core/config.js' +import { getAccessToken, clearTokenCache } from './auth.js' +import { convertTools } from './tool-bridge.js' + +const logger = pino({ + transport: { target: 'pino/file', options: { destination: 'logs/codex.log', mkdir: true } }, +}) + +const DEFAULT_OAUTH_BASE_URL = 'https://chatgpt.com/backend-api/codex' +const DEFAULT_API_BASE_URL = 'https://api.openai.com/v1' +const DEFAULT_MODEL = 'gpt-5.4' + +// ==================== Provider ==================== + +export class CodexProvider implements AIProvider { + readonly providerTag = 'codex' as const + + constructor( + private getTools: () => Promise>, + private getSystemPrompt: () => Promise, + ) {} + + /** + * Create an OpenAI client from a resolved profile. + * + * - loginMethod 'codex-oauth' (default): reads ~/.codex/auth.json, hits + * ChatGPT subscription endpoint. Usage billed to ChatGPT plan. + * - loginMethod 'api-key': uses profile apiKey. + * Standard OpenAI API billing, or compatible third-party endpoint. + */ + private async createClient(profile?: ResolvedProfile): Promise<{ client: OpenAI; model: string }> { + const model = profile?.model ?? DEFAULT_MODEL + const loginMethod = profile?.loginMethod ?? 'codex-oauth' + + if (loginMethod === 'api-key') { + const apiKey = profile?.apiKey + if (!apiKey) throw new Error('Codex api-key mode requires an API key. Configure it in your profile.') + const baseURL = profile?.baseUrl ?? DEFAULT_API_BASE_URL + return { client: new OpenAI({ apiKey, baseURL }), model } + } + + // OAuth mode + const token = await getAccessToken() + const baseURL = profile?.baseUrl ?? DEFAULT_OAUTH_BASE_URL + return { client: new OpenAI({ apiKey: token, baseURL }), model } + } + + async ask(prompt: string): Promise { + const { client, model } = await this.createClient() + const instructions = await this.getSystemPrompt() + + try { + const response = await client.responses.create({ + model, + instructions, + input: [{ role: 'user' as const, content: prompt }], + store: false, + }) + + const text = response.output + .filter((item): item is OpenAI.Responses.ResponseOutputMessage => item.type === 'message') + .flatMap(msg => msg.content) + .filter((c): c is OpenAI.Responses.ResponseOutputText => c.type === 'output_text') + .map(c => c.text) + .join('') + + return { text: text || '(no output)', media: [] } + } catch (err) { + logger.error({ err }, 'ask_error') + throw err + } + } + + async *generate( + entries: SessionEntry[], + prompt: string, + opts?: GenerateOpts, + ): AsyncGenerator { + const { client, model } = await this.createClient(opts?.profile) + const instructions = opts?.systemPrompt ?? await this.getSystemPrompt() + const agentConfig = await readAgentConfig() + const maxSteps = agentConfig.maxSteps + + // Build tools + const allTools = await this.getTools() + const tools = convertTools(allTools, opts?.disabledTools) + + // Build structured input from session history + current prompt + const history = toResponsesInput(entries) + const input: OpenAI.Responses.ResponseInputItem[] = [ + ...history as OpenAI.Responses.ResponseInputItem[], + { role: 'user', content: prompt }, + ] + + yield* this.toolLoop(client, model, instructions, input, tools, allTools, maxSteps) + } + + /** + * The manual tool loop — sends requests to the Responses API and executes + * function calls until the model responds with text only or we hit maxSteps. + */ + private async *toolLoop( + client: OpenAI, + model: string, + instructions: string, + input: OpenAI.Responses.ResponseInputItem[], + tools: ReturnType, + vercelTools: Record, + maxSteps: number, + ): AsyncGenerator { + let accumulatedText = '' + + for (let step = 0; step < maxSteps; step++) { + const functionCalls: Array<{ + call_id: string + name: string + arguments: string + }> = [] + let stepText = '' + + try { + const stream = client.responses.stream({ + model, + instructions, + input, + tools: tools.length > 0 ? tools : undefined, + store: false, // Required by ChatGPT subscription endpoint + }) + + for await (const event of stream) { + if (event.type === 'response.output_text.delta') { + yield { type: 'text', text: event.delta } + stepText += event.delta + } else if (event.type === 'response.output_item.done') { + // function_call_arguments.done lacks call_id and name; + // output_item.done carries the complete function call object. + const item = (event as any).item + if (item?.type === 'function_call') { + functionCalls.push({ + call_id: item.call_id, + name: item.name, + arguments: item.arguments, + }) + } + } + } + } catch (err: any) { + // On 401, clear token cache and surface auth error + if (err?.status === 401) { + clearTokenCache() + const errorText = accumulatedText + stepText + + '\n\n[Codex auth expired. Run `codex login` to re-authenticate.]' + yield { type: 'done', result: { text: errorText, media: [] } } + return + } + // Extract all available error detail from OpenAI SDK error + const errDetail: Record = { + message: err?.message, + status: err?.status, + type: err?.type, + code: err?.code, + param: err?.param, + } + // The SDK stores the parsed error body in err.error + if (err?.error) errDetail.errorBody = err.error + // Raw response headers can help debug + if (err?.headers) { + const h: Record = {} + try { for (const [k, v] of Object.entries(err.headers)) h[k] = String(v) } catch {} + if (Object.keys(h).length > 0) errDetail.headers = h + } + errDetail.model = model + errDetail.inputItems = input.length + errDetail.toolCount = tools.length + logger.error(errDetail, 'responses_api_error') + const errorText = accumulatedText + stepText + + `\n\n[Codex API error: ${err?.message ?? 'unknown error'}]` + yield { type: 'done', result: { text: errorText, media: [] } } + return + } + + accumulatedText += stepText + + // No function calls — model is done + if (functionCalls.length === 0) { + yield { type: 'done', result: { text: accumulatedText, media: [] } } + return + } + + // Execute function calls and build follow-up input + const toolResults: Array<{ call_id: string; output: string }> = [] + + for (const fc of functionCalls) { + let parsedInput: unknown + try { + parsedInput = JSON.parse(fc.arguments) + } catch { + parsedInput = {} + } + + // Yield tool_use event + yield { type: 'tool_use', id: fc.call_id, name: fc.name, input: parsedInput } + logger.info({ tool: fc.name, call_id: fc.call_id }, 'tool_use') + + // Execute the tool + const tool = vercelTools[fc.name] + let resultContent: string + if (!tool?.execute) { + resultContent = JSON.stringify({ error: `Unknown tool: ${fc.name}` }) + } else { + try { + const result = await tool.execute(parsedInput, { + toolCallId: fc.call_id, + messages: [], + }) + resultContent = typeof result === 'string' ? result : JSON.stringify(result ?? '') + } catch (err) { + resultContent = JSON.stringify({ error: `Tool execution failed: ${err}` }) + } + } + + // Yield tool_result event + yield { type: 'tool_result', tool_use_id: fc.call_id, content: resultContent } + logger.info({ tool: fc.name, call_id: fc.call_id, content: resultContent.slice(0, 300) }, 'tool_result') + + toolResults.push({ call_id: fc.call_id, output: resultContent }) + } + + // Append function calls + outputs to input for next round + for (const fc of functionCalls) { + input.push({ + type: 'function_call', + call_id: fc.call_id, + name: fc.name, + arguments: fc.arguments, + } as OpenAI.Responses.ResponseInputItem) + } + for (const tr of toolResults) { + input.push({ + type: 'function_call_output', + call_id: tr.call_id, + output: tr.output, + } as OpenAI.Responses.ResponseInputItem) + } + } + + // Max steps reached + yield { + type: 'done', + result: { text: accumulatedText + '\n\n[Max tool iterations reached]', media: [] }, + } + } +} diff --git a/src/ai-providers/codex/index.ts b/src/ai-providers/codex/index.ts new file mode 100644 index 00000000..f051eb5e --- /dev/null +++ b/src/ai-providers/codex/index.ts @@ -0,0 +1 @@ +export { CodexProvider } from './codex-provider.js' diff --git a/src/ai-providers/codex/tool-bridge.ts b/src/ai-providers/codex/tool-bridge.ts new file mode 100644 index 00000000..e1a727da --- /dev/null +++ b/src/ai-providers/codex/tool-bridge.ts @@ -0,0 +1,51 @@ +/** + * Tool bridge — converts ToolCenter's Vercel AI SDK tools to OpenAI Responses API format. + * + * Much simpler than the Agent SDK bridge (no MCP server needed) — just JSON Schema objects. + */ + +import { z } from 'zod' +import type { Tool } from 'ai' + +// ==================== Types ==================== + +export interface ResponsesApiTool { + type: 'function' + name: string + description: string + parameters: Record + strict: boolean | null +} + +// ==================== Conversion ==================== + +/** + * Convert Vercel AI SDK tools to OpenAI Responses API tool definitions. + * + * @param tools Record from ToolCenter.getVercelTools() + * @param disabledTools Optional list of tool names to exclude + */ +export function convertTools( + tools: Record, + disabledTools?: string[], +): ResponsesApiTool[] { + const disabledSet = new Set(disabledTools ?? []) + + return Object.entries(tools) + .filter(([name, t]) => t.execute && !disabledSet.has(name)) + .map(([name, t]) => { + let parameters: Record + try { + parameters = z.toJSONSchema(t.inputSchema as z.ZodType) + } catch { + parameters = { type: 'object', properties: {} } + } + return { + type: 'function' as const, + name, + description: t.description ?? name, + parameters, + strict: null, + } + }) +} diff --git a/src/ai-providers/mock/index.ts b/src/ai-providers/mock/index.ts index 6a09a762..38c59c33 100644 --- a/src/ai-providers/mock/index.ts +++ b/src/ai-providers/mock/index.ts @@ -33,7 +33,7 @@ export interface MockAIProviderCall { // ==================== Options ==================== export interface MockAIProviderOpts { - providerTag?: 'vercel-ai' | 'claude-code' | 'agent-sdk' + providerTag?: 'vercel-ai' | 'claude-code' | 'agent-sdk' | 'codex' /** Text returned by ask(). Default: 'mock-ask-result'. */ askResult?: string } @@ -41,7 +41,7 @@ export interface MockAIProviderOpts { // ==================== MockAIProvider ==================== export class MockAIProvider implements AIProvider { - readonly providerTag: 'vercel-ai' | 'claude-code' | 'agent-sdk' + readonly providerTag: 'vercel-ai' | 'claude-code' | 'agent-sdk' | 'codex' readonly generateCalls: MockAIProviderCall[] = [] readonly askCalls: string[] = [] private _askResult: string diff --git a/src/ai-providers/types.ts b/src/ai-providers/types.ts index 0dd007df..873c92ee 100644 --- a/src/ai-providers/types.ts +++ b/src/ai-providers/types.ts @@ -1,6 +1,7 @@ import type { ISessionStore, SessionEntry } from '../core/session.js' import type { CompactionConfig, CompactionResult } from '../core/compaction.js' import type { MediaAttachment } from '../core/types.js' +import type { ResolvedProfile } from '../core/config.js' // ==================== Provider Events ==================== @@ -30,8 +31,8 @@ export interface GenerateOpts { /** Max history entries to include (text providers only). */ maxHistoryEntries?: number disabledTools?: string[] - vercelAiSdk?: { provider: string; model: string; baseUrl?: string; apiKey?: string } - agentSdk?: { model?: string; apiKey?: string; baseUrl?: string } + /** Resolved profile — contains model, apiKey, baseUrl, etc. */ + profile?: ResolvedProfile } // ==================== AIProvider ==================== @@ -44,7 +45,7 @@ export interface GenerateOpts { */ export interface AIProvider { /** Session log provenance tag. */ - readonly providerTag: 'vercel-ai' | 'claude-code' | 'agent-sdk' + readonly providerTag: 'vercel-ai' | 'claude-code' | 'agent-sdk' | 'codex' /** Stateless one-shot prompt (used for compaction summarization, etc.). */ ask(prompt: string): Promise /** Stream events from the backend. Yields tool_use/tool_result/text, then done. */ diff --git a/src/ai-providers/vercel-ai-sdk/agent.ts b/src/ai-providers/vercel-ai-sdk/agent.ts index 5528477d..4bbe0365 100644 --- a/src/ai-providers/vercel-ai-sdk/agent.ts +++ b/src/ai-providers/vercel-ai-sdk/agent.ts @@ -1,24 +1,9 @@ -import { ToolLoopAgent, stepCountIs } from 'ai' -import type { LanguageModel, Tool } from 'ai' - /** - * Create a generic ToolLoopAgent with externally-provided tools. + * Re-export generateText as the primary tool-loop entry point. * - * The caller decides what tools the agent has — Engine wires in - * sandbox-analysis tools (market data, trading, cognition, etc.). + * Previously wrapped Vercel AI SDK's ToolLoopAgent, but that was a thin + * wrapper around generateText with no meaningful extras. Using generateText + * directly is simpler and avoids caching agent instances. */ -export function createAgent( - model: LanguageModel, - tools: Record, - instructions: string, - maxSteps = 20, -) { - return new ToolLoopAgent({ - model, - tools, - instructions, - stopWhen: stepCountIs(maxSteps), - }) -} - -export type Agent = ReturnType +export { generateText, stepCountIs } from 'ai' +export type { LanguageModel, Tool, StepResult } from 'ai' diff --git a/src/ai-providers/vercel-ai-sdk/index.ts b/src/ai-providers/vercel-ai-sdk/index.ts index f981f7f9..dd3df74d 100644 --- a/src/ai-providers/vercel-ai-sdk/index.ts +++ b/src/ai-providers/vercel-ai-sdk/index.ts @@ -1,3 +1 @@ -export { createAgent } from './agent.js' -export type { Agent } from './agent.js' export { VercelAIProvider } from './vercel-provider.js' diff --git a/src/ai-providers/vercel-ai-sdk/model-factory.ts b/src/ai-providers/vercel-ai-sdk/model-factory.ts index 0e42c3f2..dd473c77 100644 --- a/src/ai-providers/vercel-ai-sdk/model-factory.ts +++ b/src/ai-providers/vercel-ai-sdk/model-factory.ts @@ -1,13 +1,11 @@ /** - * Model factory — creates Vercel AI SDK LanguageModel instances from config. + * Model factory — creates Vercel AI SDK LanguageModel instances from a resolved profile. * - * Reads ai-provider-manager.json from disk on each call so that model - * changes take effect without a restart. Uses dynamic imports so unused - * provider packages don't prevent startup. + * Uses dynamic imports so unused provider packages don't prevent startup. */ import type { LanguageModel } from 'ai' -import { readAIProviderConfig } from '../../core/config.js' +import type { ResolvedProfile } from '../../core/config.js' /** Result includes the model plus a cache key for change detection. */ export interface ModelFromConfig { @@ -16,42 +14,27 @@ export interface ModelFromConfig { key: string } -/** Per-request model override (e.g. from a sub-channel's vercelAiSdk config). */ -export interface ModelOverride { - provider: string - model: string - baseUrl?: string - apiKey?: string -} - -export async function createModelFromConfig(override?: ModelOverride): Promise { - // Resolve effective values: override takes precedence over global config - const config = await readAIProviderConfig() - const p = override?.provider ?? config.provider - const m = override?.model ?? config.model - const url = override?.baseUrl ?? config.baseUrl +export async function createModelFromProfile(profile: ResolvedProfile): Promise { + const p = profile.provider ?? 'anthropic' + const m = profile.model + const url = profile.baseUrl + const apiKey = profile.apiKey const key = `${p}:${m}:${url ?? ''}` - // Resolve API key: override.apiKey > global config.apiKeys[provider] - const resolveApiKey = (provider: string) => { - if (override?.apiKey) return override.apiKey - return (config.apiKeys as Record)[provider] || undefined - } - switch (p) { case 'anthropic': { const { createAnthropic } = await import('@ai-sdk/anthropic') - const client = createAnthropic({ apiKey: resolveApiKey('anthropic'), baseURL: url || undefined }) + const client = createAnthropic({ apiKey: apiKey || undefined, baseURL: url || undefined }) return { model: client(m), key } } case 'openai': { const { createOpenAI } = await import('@ai-sdk/openai') - const client = createOpenAI({ apiKey: resolveApiKey('openai'), baseURL: url || undefined }) + const client = createOpenAI({ apiKey: apiKey || undefined, baseURL: url || undefined }) return { model: client(m), key } } case 'google': { const { createGoogleGenerativeAI } = await import('@ai-sdk/google') - const client = createGoogleGenerativeAI({ apiKey: resolveApiKey('google'), baseURL: url || undefined }) + const client = createGoogleGenerativeAI({ apiKey: apiKey || undefined, baseURL: url || undefined }) return { model: client(m), key } } default: diff --git a/src/ai-providers/vercel-ai-sdk/vercel-provider.spec.ts b/src/ai-providers/vercel-ai-sdk/vercel-provider.spec.ts index 22f2af56..9b77839f 100644 --- a/src/ai-providers/vercel-ai-sdk/vercel-provider.spec.ts +++ b/src/ai-providers/vercel-ai-sdk/vercel-provider.spec.ts @@ -2,137 +2,119 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { VercelAIProvider } from './vercel-provider.js' vi.mock('./model-factory.js', () => ({ - createModelFromConfig: vi.fn(), + createModelFromProfile: vi.fn(), +})) + +vi.mock('../../core/config.js', () => ({ + resolveProfile: vi.fn().mockResolvedValue({ backend: 'vercel-ai-sdk', label: 'Test', model: 'mock-model', provider: 'anthropic' }), })) vi.mock('./agent.js', () => ({ - createAgent: vi.fn(), + generateText: vi.fn(), + stepCountIs: vi.fn().mockReturnValue(() => false), })) vi.mock('../../core/media.js', () => ({ extractMediaFromToolOutput: vi.fn().mockReturnValue([]), })) -import { createModelFromConfig } from './model-factory.js' -import { createAgent } from './agent.js' +import { createModelFromProfile } from './model-factory.js' +import { generateText } from './agent.js' -const mockCreateModelFromConfig = vi.mocked(createModelFromConfig) -const mockCreateAgent = vi.mocked(createAgent) +const mockCreateModelFromProfile = vi.mocked(createModelFromProfile) +const mockGenerateText = vi.mocked(generateText) // ==================== Helpers ==================== -function makeAgent(text = 'ok', steps: any[] = []) { - return { - generate: vi.fn().mockResolvedValue({ text, steps }), - } -} - function makeProvider(overrides?: { getTools?: () => Promise> }) { const getTools = overrides?.getTools ?? (async () => ({ toolA: {}, toolB: {} })) - return new VercelAIProvider(getTools as any, 'You are a trading assistant.', 10) + return new VercelAIProvider(getTools as any, async () => 'You are a trading assistant.', 10) } -// ==================== resolveAgent caching ==================== +// ==================== ask() ==================== -describe('VercelAIProvider — agent caching', () => { +describe('VercelAIProvider — ask()', () => { beforeEach(() => { vi.clearAllMocks() - mockCreateModelFromConfig.mockResolvedValue({ model: {} as any, key: 'gpt-4o' }) - mockCreateAgent.mockReturnValue(makeAgent() as any) + mockCreateModelFromProfile.mockResolvedValue({ model: {} as any, key: 'gpt-4o' }) + mockGenerateText.mockResolvedValue({ text: 'ok', steps: [] } as any) }) - it('creates agent on first ask()', async () => { + it('calls generateText with model, tools, system, and prompt', async () => { const provider = makeProvider() await provider.ask('hello') - expect(mockCreateAgent).toHaveBeenCalledOnce() - }) - it('reuses cached agent on second ask() when nothing changes', async () => { - const provider = makeProvider() - await provider.ask('first') - await provider.ask('second') - expect(mockCreateAgent).toHaveBeenCalledOnce() + expect(mockGenerateText).toHaveBeenCalledOnce() + const call = mockGenerateText.mock.calls[0][0] + expect(call).toHaveProperty('prompt', 'hello') + expect(call).toHaveProperty('system', 'You are a trading assistant.') + expect(call.tools).toHaveProperty('toolA') + expect(call.tools).toHaveProperty('toolB') }) - it('recreates agent when config key changes', async () => { + it('returns text from generateText result', async () => { + mockGenerateText.mockResolvedValue({ text: 'the answer', steps: [] } as any) const provider = makeProvider() - await provider.ask('first') - // Simulate config key change - mockCreateModelFromConfig.mockResolvedValue({ model: {} as any, key: 'claude-3-5-sonnet' }) - await provider.ask('second') - expect(mockCreateAgent).toHaveBeenCalledTimes(2) + const result = await provider.ask('question') + expect(result.text).toBe('the answer') }) - it('recreates agent when tool count changes', async () => { - let toolSet: Record = { toolA: {}, toolB: {} } - const provider = new VercelAIProvider(async () => toolSet as any, 'prompt', 5) - await provider.ask('first') - toolSet = { toolA: {}, toolB: {}, toolC: {} } - await provider.ask('second') - expect(mockCreateAgent).toHaveBeenCalledTimes(2) + it('returns empty string when text is null', async () => { + mockGenerateText.mockResolvedValue({ text: null, steps: [] } as any) + const provider = makeProvider() + const result = await provider.ask('question') + expect(result.text).toBe('') }) }) -// ==================== per-request overrides ==================== +// ==================== generate() — tool filtering ==================== -describe('VercelAIProvider — per-request overrides', () => { +describe('VercelAIProvider — generate() tool filtering', () => { beforeEach(() => { vi.clearAllMocks() - mockCreateModelFromConfig.mockResolvedValue({ model: {} as any, key: 'gpt-4o' }) - mockCreateAgent.mockReturnValue(makeAgent() as any) + mockCreateModelFromProfile.mockResolvedValue({ model: {} as any, key: 'gpt-4o' }) + mockGenerateText.mockResolvedValue({ text: 'ok', steps: [] } as any) }) - it('skips cache and uses filtered tools when disabledTools provided', async () => { + it('filters disabled tools from generateText call', async () => { const getTools = async () => ({ toolA: {} as any, toolB: {} as any, toolC: {} as any }) - const provider = new VercelAIProvider(getTools, 'prompt', 5) - // warm cache - await provider.ask('warm') - vi.clearAllMocks() - mockCreateModelFromConfig.mockResolvedValue({ model: {} as any, key: 'gpt-4o' }) - mockCreateAgent.mockReturnValue(makeAgent() as any) + const provider = new VercelAIProvider(getTools, async () => 'prompt', 5) - // generate with disabledTools const events = [] for await (const e of provider.generate([], 'test', { disabledTools: ['toolB'] })) { events.push(e) } - expect(mockCreateAgent).toHaveBeenCalledOnce() - // The tools passed to createAgent should exclude toolB - const toolsArg = mockCreateAgent.mock.calls[0][1] - expect(Object.keys(toolsArg)).toContain('toolA') - expect(Object.keys(toolsArg)).not.toContain('toolB') - expect(Object.keys(toolsArg)).toContain('toolC') + const call = mockGenerateText.mock.calls[0][0] + const toolNames = Object.keys(call.tools!) + expect(toolNames).toContain('toolA') + expect(toolNames).not.toContain('toolB') + expect(toolNames).toContain('toolC') }) - it('skips cache when modelOverride is provided', async () => { + it('passes profile to createModelFromProfile', async () => { const provider = makeProvider() - await provider.ask('warm') - vi.clearAllMocks() - mockCreateModelFromConfig.mockResolvedValue({ model: {} as any, key: 'gpt-4o' }) - mockCreateAgent.mockReturnValue(makeAgent() as any) + const profile = { backend: 'vercel-ai-sdk' as const, label: 'Test', model: 'claude-3-7', provider: 'anthropic' } - for await (const _ of provider.generate([], 'test', { vercelAiSdk: { modelId: 'claude-3-7' } as any })) { + for await (const _ of provider.generate([], 'test', { profile })) { // drain } - expect(mockCreateAgent).toHaveBeenCalledOnce() + expect(mockCreateModelFromProfile).toHaveBeenCalledWith(profile) }) }) -// ==================== generate() behavior ==================== +// ==================== generate() — events ==================== -describe('VercelAIProvider — generate()', () => { +describe('VercelAIProvider — generate() events', () => { beforeEach(() => { vi.clearAllMocks() - mockCreateModelFromConfig.mockResolvedValue({ model: {} as any, key: 'gpt-4o' }) - mockCreateAgent.mockReturnValue(makeAgent() as any) + mockCreateModelFromProfile.mockResolvedValue({ model: {} as any, key: 'gpt-4o' }) }) it('yields done event with text from result', async () => { + mockGenerateText.mockResolvedValue({ text: 'final answer', steps: [] } as any) const provider = makeProvider() - const agent = makeAgent('final answer') - mockCreateAgent.mockReturnValue(agent as any) const events = [] for await (const e of provider.generate([], 'test')) { @@ -143,9 +125,8 @@ describe('VercelAIProvider — generate()', () => { expect(done?.result.text).toBe('final answer') }) - it('propagates agent error through channel', async () => { - const agent = { generate: vi.fn().mockRejectedValue(new Error('model error')) } - mockCreateAgent.mockReturnValue(agent as any) + it('propagates error through channel', async () => { + mockGenerateText.mockRejectedValue(new Error('model error')) const provider = makeProvider() await expect(async () => { @@ -154,4 +135,16 @@ describe('VercelAIProvider — generate()', () => { } }).rejects.toThrow('model error') }) + + it('uses per-channel systemPrompt override', async () => { + mockGenerateText.mockResolvedValue({ text: 'ok', steps: [] } as any) + const provider = makeProvider() + + for await (const _ of provider.generate([], 'test', { systemPrompt: 'custom prompt' })) { + // drain + } + + const call = mockGenerateText.mock.calls[0][0] + expect(call.system).toBe('custom prompt') + }) }) diff --git a/src/ai-providers/vercel-ai-sdk/vercel-provider.ts b/src/ai-providers/vercel-ai-sdk/vercel-provider.ts index a4ba59ea..b09da3be 100644 --- a/src/ai-providers/vercel-ai-sdk/vercel-provider.ts +++ b/src/ai-providers/vercel-ai-sdk/vercel-provider.ts @@ -1,85 +1,81 @@ /** - * VercelAIProvider — GenerateProvider backed by Vercel AI SDK's ToolLoopAgent. + * VercelAIProvider — GenerateProvider backed by Vercel AI SDK's generateText. * - * The model is lazily created from config and cached. When model.json or - * api-keys.json changes on disk, the next request picks up the new model - * automatically (hot-reload). + * The model is lazily created from the resolved profile on each call. + * Instructions (persona + brain state) are also re-read per request. */ import type { ModelMessage, Tool } from 'ai' import type { ProviderResult, ProviderEvent, AIProvider, GenerateOpts } from '../types.js' import type { SessionEntry } from '../../core/session.js' -import type { Agent } from './agent.js' import type { MediaAttachment } from '../../core/types.js' +import type { ResolvedProfile } from '../../core/config.js' +import { resolveProfile } from '../../core/config.js' import { toModelMessages } from '../../core/session.js' import { extractMediaFromToolOutput } from '../../core/media.js' -import { createModelFromConfig, type ModelOverride } from './model-factory.js' -import { createAgent } from './agent.js' +import { createModelFromProfile } from './model-factory.js' +import { generateText, stepCountIs } from './agent.js' import { createChannel } from '../../core/async-channel.js' export class VercelAIProvider implements AIProvider { readonly providerTag = 'vercel-ai' as const - private cachedKey: string | null = null - private cachedToolCount: number = 0 - private cachedSystemPrompt: string | null = null - private cachedAgent: Agent | null = null constructor( private getTools: () => Promise>, - private instructions: string, + private getInstructions: () => Promise, private maxSteps: number, ) {} - /** Lazily create or return the cached agent, re-creating when config, tools, or system prompt change. */ - private async resolveAgent(systemPrompt?: string, disabledTools?: string[], modelOverride?: ModelOverride): Promise { - const { model, key } = await createModelFromConfig(modelOverride) - const allTools = await this.getTools() + /** Resolve model, tools, and instructions for a single request. */ + private async resolve(disabledTools?: string[], profile?: ResolvedProfile) { + // If no profile provided (e.g. ask()), resolve the active one + const effectiveProfile = profile ?? await resolveProfile() + const [{ model }, allTools, instructions] = await Promise.all([ + createModelFromProfile(effectiveProfile), + this.getTools(), + this.getInstructions(), + ]) - // Per-channel overrides: skip cache and create a fresh agent - if (disabledTools?.length || modelOverride) { - const disabledSet = disabledTools?.length ? new Set(disabledTools) : null - const tools = disabledSet - ? Object.fromEntries(Object.entries(allTools).filter(([name]) => !disabledSet.has(name))) - : allTools - return createAgent(model, tools, systemPrompt ?? this.instructions, this.maxSteps) - } + const tools = disabledTools?.length + ? Object.fromEntries(Object.entries(allTools).filter(([name]) => !new Set(disabledTools).has(name))) + : allTools - const toolCount = Object.keys(allTools).length - const effectivePrompt = systemPrompt ?? null - if (key !== this.cachedKey || toolCount !== this.cachedToolCount || effectivePrompt !== this.cachedSystemPrompt) { - this.cachedAgent = createAgent(model, allTools, systemPrompt ?? this.instructions, this.maxSteps) - this.cachedKey = key - this.cachedToolCount = toolCount - this.cachedSystemPrompt = effectivePrompt - console.log(`vercel-ai: model loaded → ${key} (${toolCount} tools)`) - } - return this.cachedAgent! + return { model, tools, instructions } } async ask(prompt: string): Promise { - const agent = await this.resolveAgent(undefined) + const { model, tools, instructions } = await this.resolve() const media: MediaAttachment[] = [] - const result = await agent.generate({ + + const result = await generateText({ + model, + tools, + system: instructions, prompt, + stopWhen: stepCountIs(this.maxSteps), onStepFinish: (step) => { for (const tr of step.toolResults) { media.push(...extractMediaFromToolOutput(tr.output)) } }, }) + return { text: result.text ?? '', media } } async *generate(entries: SessionEntry[], _prompt: string, opts?: GenerateOpts): AsyncGenerator { + const { model, tools, instructions } = await this.resolve(opts?.disabledTools, opts?.profile) const messages = toModelMessages(entries) - const agent = await this.resolveAgent(opts?.systemPrompt, opts?.disabledTools, opts?.vercelAiSdk) - const channel = createChannel() const media: MediaAttachment[] = [] - const resultPromise = agent.generate({ + const resultPromise = generateText({ + model, + tools, + system: opts?.systemPrompt ?? instructions, messages: messages as ModelMessage[], + stopWhen: stepCountIs(this.maxSteps), onStepFinish: (step) => { for (const tc of step.toolCalls) { channel.push({ type: 'tool_use', id: tc.toolCallId, name: tc.toolName, input: tc.input }) diff --git a/src/connectors/telegram/telegram-plugin.ts b/src/connectors/telegram/telegram-plugin.ts index 808a6700..1293c675 100644 --- a/src/connectors/telegram/telegram-plugin.ts +++ b/src/connectors/telegram/telegram-plugin.ts @@ -10,17 +10,16 @@ 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, readConnectorsConfig, type AIBackend } from '../../core/config' +import { readAIProviderConfig, setActiveProfile, readConnectorsConfig } from '../../core/config' import type { ConnectorCenter } from '../../core/connector-center.js' import { TelegramConnector, splitMessage, MAX_MESSAGE_LENGTH } from './telegram-connector.js' import type { AccountManager } from '../../domain/trading/index.js' import type { Operation } from '../../domain/trading/git/types.js' import { getOperationSymbol } from '../../domain/trading/git/types.js' -const BACKEND_LABELS: Record = { - 'claude-code': 'Claude Code', - 'vercel-ai-sdk': 'Vercel AI SDK', - 'agent-sdk': 'Agent SDK', +/** Build a display label for a profile. */ +function profileLabel(slug: string, profile: { label: string; backend: string; model: string }): string { + return `${profile.label} (${profile.model})` } export class TelegramPlugin implements Plugin { @@ -88,8 +87,10 @@ export class TelegramPlugin implements Plugin { // ── Commands ── bot.command('status', async (ctx) => { - const aiConfig = await readAIBackend() - await this.sendReply(ctx.chat.id, `Engine is running. Provider: ${BACKEND_LABELS[aiConfig.backend]}`) + const aiConfig = await readAIProviderConfig() + const profile = aiConfig.profiles[aiConfig.activeProfile] + const label = profile ? profileLabel(aiConfig.activeProfile, profile) : aiConfig.activeProfile + await this.sendReply(ctx.chat.id, `Engine is running. Profile: ${label}`) }) bot.command('settings', async (ctx) => { @@ -114,21 +115,22 @@ export class TelegramPlugin implements Plugin { bot.on('callback_query:data', async (ctx) => { const data = ctx.callbackQuery.data try { - if (data.startsWith('provider:')) { - const backend = data.slice('provider:'.length) as AIBackend - await writeAIBackend(backend) - await ctx.answerCallbackQuery({ text: `Switched to ${BACKEND_LABELS[backend]}` }) + if (data.startsWith('profile:')) { + const slug = data.slice('profile:'.length) + await setActiveProfile(slug) + const config = await readAIProviderConfig() + const profile = config.profiles[slug] + const label = profile ? profileLabel(slug, profile) : slug + await ctx.answerCallbackQuery({ text: `Switched to ${label}` }) // Edit the original settings message in-place - const ccLabel = backend === 'claude-code' ? '> Claude Code' : 'Claude Code' - const aiLabel = backend === 'vercel-ai-sdk' ? '> Vercel AI SDK' : 'Vercel AI SDK' - const sdkLabel = backend === 'agent-sdk' ? '> Agent SDK' : 'Agent SDK' const keyboard = new InlineKeyboard() - .text(ccLabel, 'provider:claude-code') - .text(aiLabel, 'provider:vercel-ai-sdk') - .text(sdkLabel, 'provider:agent-sdk') + for (const [s, p] of Object.entries(config.profiles)) { + const prefix = s === slug ? '> ' : '' + keyboard.text(`${prefix}${p.label}`, `profile:${s}`) + } await ctx.editMessageText( - `Current provider: ${BACKEND_LABELS[backend]}\n\nChoose default AI provider:`, + `Current profile: ${label}\n\nChoose AI profile:`, { reply_markup: keyboard }, ) } else if (data.startsWith('trading:')) { @@ -217,8 +219,8 @@ export class TelegramPlugin implements Plugin { // ── Initialize and get bot info ── await bot.init() - const aiConfig = await readAIBackend() - console.log(`telegram plugin: connected as @${bot.botInfo.username} (backend: ${aiConfig.backend})`) + const initConfig = await readAIProviderConfig() + console.log(`telegram plugin: connected as @${bot.botInfo.username} (profile: ${initConfig.activeProfile})`) // ── Register connector for outbound delivery (heartbeat / cron responses) ── if (this.config.allowedChatIds.length > 0) { @@ -336,19 +338,19 @@ export class TelegramPlugin implements Plugin { } private async sendSettingsMenu(chatId: number) { - const aiConfig = await readAIBackend() - const ccLabel = aiConfig.backend === 'claude-code' ? '> Claude Code' : 'Claude Code' - const aiLabel = aiConfig.backend === 'vercel-ai-sdk' ? '> Vercel AI SDK' : 'Vercel AI SDK' - const sdkLabel = aiConfig.backend === 'agent-sdk' ? '> Agent SDK' : 'Agent SDK' + const config = await readAIProviderConfig() + const activeProfile = config.profiles[config.activeProfile] + const activeLabel = activeProfile ? profileLabel(config.activeProfile, activeProfile) : config.activeProfile const keyboard = new InlineKeyboard() - .text(ccLabel, 'provider:claude-code') - .text(aiLabel, 'provider:vercel-ai-sdk') - .text(sdkLabel, 'provider:agent-sdk') + for (const [slug, profile] of Object.entries(config.profiles)) { + const prefix = slug === config.activeProfile ? '> ' : '' + keyboard.text(`${prefix}${profile.label}`, `profile:${slug}`) + } await this.bot!.api.sendMessage( chatId, - `Current provider: ${BACKEND_LABELS[aiConfig.backend]}\n\nChoose default AI provider:`, + `Current profile: ${activeLabel}\n\nChoose AI profile:`, { reply_markup: keyboard }, ) } diff --git a/src/connectors/web/__tests__/chat-streaming.spec.ts b/src/connectors/web/__tests__/chat-streaming.spec.ts index 70e909f9..fc4358aa 100644 --- a/src/connectors/web/__tests__/chat-streaming.spec.ts +++ b/src/connectors/web/__tests__/chat-streaming.spec.ts @@ -25,6 +25,11 @@ import type { SSEClient } from '../routes/chat.js' // ==================== Module Mocks ==================== +vi.mock('../../../core/config.js', () => ({ + resolveProfile: vi.fn().mockResolvedValue({ backend: 'vercel-ai-sdk', label: 'Test', model: 'mock', provider: 'anthropic' }), + readAgentConfig: vi.fn().mockResolvedValue({ maxSteps: 20, evolutionMode: false, claudeCode: { disallowedTools: [], maxTurns: 20 } }), +})) + vi.mock('../../../core/compaction.js', async (importOriginal) => { const actual = await importOriginal() return { diff --git a/src/connectors/web/routes/channels.ts b/src/connectors/web/routes/channels.ts index 120aaf01..5f1fa86b 100644 --- a/src/connectors/web/routes/channels.ts +++ b/src/connectors/web/routes/channels.ts @@ -29,9 +29,7 @@ export function createChannelsRoutes({ sessions, sseByChannel }: ChannelsDeps) { id?: string label?: string systemPrompt?: string - provider?: string - vercelAiSdk?: { provider: string; model: string; baseUrl?: string; apiKey?: string } - agentSdk?: { model?: string; apiKey?: string; baseUrl?: string } + profile?: string disabledTools?: string[] } @@ -54,13 +52,7 @@ export function createChannelsRoutes({ sessions, sseByChannel }: ChannelsDeps) { id: body.id, label: body.label.trim(), ...(body.systemPrompt ? { systemPrompt: body.systemPrompt } : {}), - ...(body.provider === 'claude-code' || body.provider === 'vercel-ai-sdk' || body.provider === 'agent-sdk' - ? { provider: body.provider } - : {}), - ...(body.vercelAiSdk?.provider && body.vercelAiSdk?.model - ? { vercelAiSdk: body.vercelAiSdk } - : {}), - ...(body.agentSdk ? { agentSdk: body.agentSdk } : {}), + ...(body.profile ? { profile: body.profile } : {}), ...(body.disabledTools?.length ? { disabledTools: body.disabledTools } : {}), } @@ -83,9 +75,7 @@ export function createChannelsRoutes({ sessions, sseByChannel }: ChannelsDeps) { const body = await c.req.json() as { label?: string systemPrompt?: string - provider?: string - vercelAiSdk?: { provider: string; model: string; baseUrl?: string; apiKey?: string } | null - agentSdk?: { model?: string; apiKey?: string; baseUrl?: string } | null + profile?: string | null disabledTools?: string[] } @@ -97,17 +87,7 @@ export function createChannelsRoutes({ sessions, sseByChannel }: ChannelsDeps) { ...existing[idx], ...(body.label !== undefined ? { label: body.label } : {}), ...(body.systemPrompt !== undefined ? { systemPrompt: body.systemPrompt || undefined } : {}), - ...(body.provider === 'claude-code' || body.provider === 'vercel-ai-sdk' || body.provider === 'agent-sdk' - ? { provider: body.provider } - : body.provider === null || body.provider === '' - ? { provider: undefined } - : {}), - ...(body.vercelAiSdk !== undefined - ? { vercelAiSdk: body.vercelAiSdk?.provider && body.vercelAiSdk?.model ? body.vercelAiSdk : undefined } - : {}), - ...(body.agentSdk !== undefined - ? { agentSdk: body.agentSdk ?? undefined } - : {}), + ...(body.profile !== undefined ? { profile: body.profile || undefined } : {}), ...(body.disabledTools !== undefined ? { disabledTools: body.disabledTools?.length ? body.disabledTools : undefined } : {}), } existing[idx] = updated diff --git a/src/connectors/web/routes/chat.ts b/src/connectors/web/routes/chat.ts index 30bbf35d..3b9fa18d 100644 --- a/src/connectors/web/routes/chat.ts +++ b/src/connectors/web/routes/chat.ts @@ -43,9 +43,7 @@ export function createChatRoutes({ ctx, sessions, sseByChannel }: ChatDeps) { if (channel) { if (channel.systemPrompt) opts.systemPrompt = channel.systemPrompt if (channel.disabledTools?.length) opts.disabledTools = channel.disabledTools - if (channel.provider) opts.provider = channel.provider - if (channel.vercelAiSdk) opts.vercelAiSdk = channel.vercelAiSdk - if (channel.agentSdk) opts.agentSdk = channel.agentSdk + if (channel.profile) opts.profileSlug = channel.profile } } diff --git a/src/connectors/web/routes/config.ts b/src/connectors/web/routes/config.ts index 0a9738fd..b221b685 100644 --- a/src/connectors/web/routes/config.ts +++ b/src/connectors/web/routes/config.ts @@ -1,12 +1,16 @@ import { Hono } from 'hono' -import { loadConfig, writeConfigSection, readAIProviderConfig, validSections, writeAIBackend, type ConfigSection, type AIBackend } from '../../../core/config.js' +import { + loadConfig, writeConfigSection, readAIProviderConfig, validSections, + writeProfile, deleteProfile, setActiveProfile, writeApiKeys, + profileSchema, type ConfigSection, type Profile, +} from '../../../core/config.js' import type { EngineContext } from '../../../core/types.js' interface ConfigRouteOpts { onConnectorsChange?: () => Promise } -/** Config routes: GET /, PUT /ai-provider, PUT /:section, GET /api-keys/status */ +/** Config routes: GET /, PUT /:section, profile CRUD, api-keys */ export function createConfigRoutes(opts?: ConfigRouteOpts) { const app = new Hono() @@ -19,33 +23,32 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) { } }) - app.put('/ai-provider', async (c) => { + // ==================== Profile CRUD ==================== + + /** GET /profiles — list all profiles */ + app.get('/profiles', async (c) => { try { - const body = await c.req.json<{ backend?: string }>() - const backend = body.backend - if (backend !== 'claude-code' && backend !== 'vercel-ai-sdk' && backend !== 'agent-sdk') { - return c.json({ error: 'Invalid backend. Must be "claude-code", "vercel-ai-sdk", or "agent-sdk".' }, 400) - } - await writeAIBackend(backend as AIBackend) - return c.json({ backend }) + const config = await readAIProviderConfig() + return c.json({ profiles: config.profiles, activeProfile: config.activeProfile }) } catch (err) { return c.json({ error: String(err) }, 500) } }) - app.put('/:section', async (c) => { + /** POST /profiles — create a new profile */ + app.post('/profiles', async (c) => { try { - const section = c.req.param('section') as ConfigSection - if (!validSections.includes(section)) { - return c.json({ error: `Invalid section "${section}". Valid: ${validSections.join(', ')}` }, 400) + const body = await c.req.json<{ slug: string; profile: Profile }>() + if (!body.slug || !/^[a-z0-9-]+$/.test(body.slug)) { + return c.json({ error: 'slug must be lowercase alphanumeric with hyphens' }, 400) } - const body = await c.req.json() - const validated = await writeConfigSection(section, body) - // Hot-reload connectors / OpenBB server when their config changes - if (section === 'connectors' || section === 'marketData') { - await opts?.onConnectorsChange?.() + const config = await readAIProviderConfig() + if (config.profiles[body.slug]) { + return c.json({ error: 'profile slug already exists' }, 409) } - return c.json(validated) + const validated = profileSchema.parse(body.profile) + await writeProfile(body.slug, validated) + return c.json({ slug: body.slug, profile: validated }, 201) } catch (err) { if (err instanceof Error && err.name === 'ZodError') { return c.json({ error: 'Validation failed', details: JSON.parse(err.message) }, 400) @@ -54,6 +57,57 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) { } }) + /** PUT /profiles/:slug — update a profile */ + app.put('/profiles/:slug', async (c) => { + try { + const slug = c.req.param('slug') + const body = await c.req.json() + const validated = profileSchema.parse(body) + await writeProfile(slug, validated) + return c.json({ slug, profile: validated }) + } catch (err) { + if (err instanceof Error && err.name === 'ZodError') { + return c.json({ error: 'Validation failed', details: JSON.parse(err.message) }, 400) + } + return c.json({ error: String(err) }, 500) + } + }) + + /** DELETE /profiles/:slug — delete a profile */ + app.delete('/profiles/:slug', async (c) => { + try { + const slug = c.req.param('slug') + await deleteProfile(slug) + return c.json({ success: true }) + } catch (err) { + return c.json({ error: String(err) }, 400) + } + }) + + /** PUT /active-profile — set the active profile */ + app.put('/active-profile', async (c) => { + try { + const { slug } = await c.req.json<{ slug: string }>() + await setActiveProfile(slug) + return c.json({ activeProfile: slug }) + } catch (err) { + return c.json({ error: String(err) }, 400) + } + }) + + // ==================== API Keys ==================== + + /** PUT /api-keys — update global API keys */ + app.put('/api-keys', async (c) => { + try { + const body = await c.req.json<{ anthropic?: string; openai?: string; google?: string }>() + await writeApiKeys(body) + return c.json({ success: true }) + } catch (err) { + return c.json({ error: String(err) }, 500) + } + }) + app.get('/api-keys/status', async (c) => { try { const config = await readAIProviderConfig() @@ -67,6 +121,29 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) { } }) + // ==================== Generic Section Writer ==================== + + app.put('/:section', async (c) => { + try { + const section = c.req.param('section') as ConfigSection + if (!validSections.includes(section)) { + return c.json({ error: `Invalid section "${section}". Valid: ${validSections.join(', ')}` }, 400) + } + const body = await c.req.json() + const validated = await writeConfigSection(section, body) + // Hot-reload connectors / OpenBB server when their config changes + if (section === 'connectors' || section === 'marketData') { + await opts?.onConnectorsChange?.() + } + return c.json(validated) + } catch (err) { + if (err instanceof Error && err.name === 'ZodError') { + return c.json({ error: 'Validation failed', details: JSON.parse(err.message) }, 400) + } + return c.json({ error: String(err) }, 500) + } + }) + return app } diff --git a/src/connectors/web/routes/heartbeat.ts b/src/connectors/web/routes/heartbeat.ts index ec374d48..64090835 100644 --- a/src/connectors/web/routes/heartbeat.ts +++ b/src/connectors/web/routes/heartbeat.ts @@ -4,6 +4,7 @@ import { dirname } from 'node:path' import type { EngineContext } from '../../../core/types.js' const PROMPT_FILE = 'data/brain/heartbeat.md' +const PROMPT_DEFAULT = 'default/heartbeat.default.md' /** Heartbeat routes: GET /status, POST /trigger, PUT /enabled, GET/PUT /prompt-file */ export function createHeartbeatRoutes(ctx: EngineContext) { @@ -43,6 +44,10 @@ export function createHeartbeatRoutes(ctx: EngineContext) { return c.json({ content, path: PROMPT_FILE }) } catch (err: unknown) { if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') { + try { + const fallback = await readFile(PROMPT_DEFAULT, 'utf-8') + return c.json({ content: fallback, path: PROMPT_FILE }) + } catch { /* default also missing */ } return c.json({ content: '', path: PROMPT_FILE }) } return c.json({ error: String(err) }, 500) diff --git a/src/connectors/web/routes/persona.ts b/src/connectors/web/routes/persona.ts new file mode 100644 index 00000000..eb460829 --- /dev/null +++ b/src/connectors/web/routes/persona.ts @@ -0,0 +1,40 @@ +import { Hono } from 'hono' +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { dirname } from 'node:path' + +const PERSONA_FILE = 'data/brain/persona.md' +const PERSONA_DEFAULT = 'default/persona.default.md' + +/** Persona routes: GET / (read), PUT / (write) */ +export function createPersonaRoutes() { + const app = new Hono() + + app.get('/', async (c) => { + try { + const content = await readFile(PERSONA_FILE, 'utf-8') + return c.json({ content, path: PERSONA_FILE }) + } catch (err: unknown) { + if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') { + try { + const fallback = await readFile(PERSONA_DEFAULT, 'utf-8') + return c.json({ content: fallback, path: PERSONA_FILE }) + } catch { /* default also missing */ } + return c.json({ content: '', path: PERSONA_FILE }) + } + return c.json({ error: String(err) }, 500) + } + }) + + app.put('/', async (c) => { + try { + const { content } = await c.req.json<{ content: string }>() + await mkdir(dirname(PERSONA_FILE), { recursive: true }) + await writeFile(PERSONA_FILE, content, 'utf-8') + return c.json({ ok: true }) + } catch (err) { + return c.json({ error: String(err) }, 500) + } + }) + + return app +} diff --git a/src/connectors/web/web-plugin.ts b/src/connectors/web/web-plugin.ts index 4dbdcfdb..0243422e 100644 --- a/src/connectors/web/web-plugin.ts +++ b/src/connectors/web/web-plugin.ts @@ -18,6 +18,7 @@ import { createTradingConfigRoutes } from './routes/trading-config.js' import { createDevRoutes } from './routes/dev.js' import { createToolsRoutes } from './routes/tools.js' import { createAgentStatusRoutes } from './routes/agent-status.js' +import { createPersonaRoutes } from './routes/persona.js' export interface WebConfig { port: number @@ -83,6 +84,7 @@ export class WebPlugin implements Plugin { app.route('/api/dev', createDevRoutes(ctx.connectorCenter)) app.route('/api/tools', createToolsRoutes(ctx.toolCenter)) app.route('/api/agent-status', createAgentStatusRoutes(ctx)) + app.route('/api/persona', createPersonaRoutes()) // ==================== Serve UI (Vite build output) ==================== const uiRoot = resolve('dist/ui') diff --git a/src/core/__tests__/pipeline/helpers.ts b/src/core/__tests__/pipeline/helpers.ts index cb1806bb..d8566428 100644 --- a/src/core/__tests__/pipeline/helpers.ts +++ b/src/core/__tests__/pipeline/helpers.ts @@ -6,10 +6,17 @@ * imported from their respective modules (MemorySessionStore, MockConnector). */ +import { vi } from 'vitest' import { AgentCenter } from '../../agent-center.js' import { GenerateRouter, StreamableResult, type ProviderEvent } from '../../ai-provider-manager.js' import { DEFAULT_COMPACTION_CONFIG } from '../../compaction.js' +// Mock resolveProfile so GenerateRouter.resolve() works without disk I/O +vi.mock('../../config.js', () => ({ + resolveProfile: vi.fn().mockResolvedValue({ backend: 'vercel-ai-sdk', label: 'Test', model: 'mock', provider: 'anthropic' }), + readAgentConfig: vi.fn().mockResolvedValue({ maxSteps: 20, evolutionMode: false, claudeCode: { disallowedTools: [], maxTurns: 20 } }), +})) + // Re-export test doubles for convenience export { MemorySessionStore } from '../../session.js' export type { SessionEntry, ContentBlock } from '../../session.js' diff --git a/src/core/__tests__/pipeline/streaming.spec.ts b/src/core/__tests__/pipeline/streaming.spec.ts index 60057764..0bc582d0 100644 --- a/src/core/__tests__/pipeline/streaming.spec.ts +++ b/src/core/__tests__/pipeline/streaming.spec.ts @@ -19,6 +19,11 @@ import { // ==================== Module Mocks ==================== +vi.mock('../../config.js', () => ({ + resolveProfile: vi.fn().mockResolvedValue({ backend: 'vercel-ai-sdk', label: 'Test', model: 'mock', provider: 'anthropic' }), + readAgentConfig: vi.fn().mockResolvedValue({ maxSteps: 20, evolutionMode: false, claudeCode: { disallowedTools: [], maxTurns: 20 } }), +})) + vi.mock('../../compaction.js', async (importOriginal) => { const actual = await importOriginal() return { diff --git a/src/core/agent-center.spec.ts b/src/core/agent-center.spec.ts index 0da85eec..c8406c38 100644 --- a/src/core/agent-center.spec.ts +++ b/src/core/agent-center.spec.ts @@ -5,7 +5,7 @@ import { AgentCenter } from './agent-center.js' import { GenerateRouter } from './ai-provider-manager.js' import { DEFAULT_COMPACTION_CONFIG, type CompactionConfig } from './compaction.js' import { VercelAIProvider } from '../ai-providers/vercel-ai-sdk/vercel-provider.js' -import { createModelFromConfig } from '../ai-providers/vercel-ai-sdk/model-factory.js' +import { createModelFromProfile } from '../ai-providers/vercel-ai-sdk/model-factory.js' import { MemorySessionStore, type SessionEntry } from './session.js' // ==================== Helpers ==================== @@ -42,8 +42,8 @@ function makeAgentCenter(overrides: MakeAgentCenterOpts = {}): AgentCenter { const maxSteps = overrides.maxSteps ?? 1 const compaction = overrides.compaction ?? DEFAULT_COMPACTION_CONFIG - vi.mocked(createModelFromConfig).mockResolvedValue({ model, key: 'test:mock-model' }) - const provider = new VercelAIProvider(async () => tools, instructions, maxSteps) + vi.mocked(createModelFromProfile).mockResolvedValue({ model, key: 'test:mock-model' }) + const provider = new VercelAIProvider(async () => tools, async () => instructions, maxSteps) const router = new GenerateRouter(provider, null) return new AgentCenter({ router, compaction }) @@ -52,7 +52,12 @@ function makeAgentCenter(overrides: MakeAgentCenterOpts = {}): AgentCenter { // ==================== Mock model-factory ==================== vi.mock('../ai-providers/vercel-ai-sdk/model-factory.js', () => ({ - createModelFromConfig: vi.fn(), + createModelFromProfile: vi.fn(), +})) + +vi.mock('./config.js', () => ({ + resolveProfile: vi.fn().mockResolvedValue({ backend: 'vercel-ai-sdk', label: 'Test', model: 'mock-model', provider: 'anthropic' }), + readAgentConfig: vi.fn().mockResolvedValue({ maxSteps: 20, evolutionMode: false, claudeCode: { disallowedTools: [], maxTurns: 20 } }), })) // ==================== Mock compaction ==================== diff --git a/src/core/agent-center.ts b/src/core/agent-center.ts index 2a3bf598..bd2830f3 100644 --- a/src/core/agent-center.ts +++ b/src/core/agent-center.ts @@ -73,8 +73,8 @@ export class AgentCenter { // 1. Append user message to session await session.appendUser(prompt, 'human') - // 2. Resolve provider (may be overridden per-request) - const provider = await this.router.resolve(opts?.provider) + // 2. Resolve provider + profile (may be overridden per-request via profileSlug) + const { provider, profile } = await this.router.resolve(opts?.profileSlug) // 3. Compact if needed (provider can override with custom strategy) const compactionResult = provider.compact @@ -94,8 +94,7 @@ export class AgentCenter { historyPreamble: opts?.historyPreamble ?? this.defaultPreamble, maxHistoryEntries: opts?.maxHistoryEntries ?? this.defaultMaxHistory, disabledTools: opts?.disabledTools, - vercelAiSdk: opts?.vercelAiSdk, - agentSdk: opts?.agentSdk, + profile, } const source = provider.generate(entries, prompt, genOpts) diff --git a/src/core/ai-provider-manager.spec.ts b/src/core/ai-provider-manager.spec.ts index f611d5b4..ed665a4b 100644 --- a/src/core/ai-provider-manager.spec.ts +++ b/src/core/ai-provider-manager.spec.ts @@ -129,6 +129,13 @@ describe('StreamableResult', () => { // ==================== GenerateRouter ==================== +vi.mock('./config.js', () => ({ + resolveProfile: vi.fn(), +})) + +import { resolveProfile } from './config.js' +const mockResolveProfile = vi.mocked(resolveProfile) + describe('GenerateRouter', () => { function makeProvider(tag: AIProvider['providerTag']): AIProvider { return { @@ -138,56 +145,49 @@ 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) - - // 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 as alias for agent-sdk', async () => { + it('should resolve profile and pick matching provider', async () => { const vercel = makeProvider('vercel-ai') const agentSdk = makeProvider('agent-sdk') const router = new GenerateRouter(vercel, agentSdk) - const provider = await router.resolve('claude-code') + mockResolveProfile.mockResolvedValue({ backend: 'agent-sdk', label: 'Claude', model: 'claude-sonnet-4-6' }) + const { provider } = await router.resolve('claude-main') expect(provider).toBe(agentSdk) }) - it('should resolve override agent-sdk when available', async () => { + it('should resolve active profile when no slug given', async () => { const vercel = makeProvider('vercel-ai') - const agentSdk = makeProvider('agent-sdk') - const router = new GenerateRouter(vercel, agentSdk) + const router = new GenerateRouter(vercel, null) - const provider = await router.resolve('agent-sdk') - expect(provider).toBe(agentSdk) + mockResolveProfile.mockResolvedValue({ backend: 'vercel-ai-sdk', label: 'Vercel', model: 'claude-sonnet-4-6', provider: 'anthropic' }) + const { provider } = await router.resolve() + expect(provider).toBe(vercel) }) - it('should fallback to vercel when override claude-code but not available', async () => { + it('should throw when backend has no registered provider', async () => { const vercel = makeProvider('vercel-ai') - const router = new GenerateRouter(vercel, null) + const router = new GenerateRouter(vercel, null) // no agent-sdk - // Override requests claude-code but it's null — falls through to config read - const provider = await router.resolve('claude-code') - // Config read will happen, but since claudeCode is null, falls to vercel - expect(provider).toBe(vercel) + mockResolveProfile.mockResolvedValue({ backend: 'agent-sdk', label: 'Claude', model: 'x' }) + await expect(router.resolve('test')).rejects.toThrow('No provider registered for backend') }) - it('should resolve override vercel-ai-sdk directly', async () => { + it('should resolve codex provider', async () => { const vercel = makeProvider('vercel-ai') - const cc = makeProvider('claude-code') - const router = new GenerateRouter(vercel, cc) + const codex = makeProvider('codex') + const router = new GenerateRouter(vercel, null, codex) - const provider = await router.resolve('vercel-ai-sdk') - expect(provider).toBe(vercel) + mockResolveProfile.mockResolvedValue({ backend: 'codex', label: 'GPT', model: 'gpt-5.4' }) + const { provider, profile } = await router.resolve('gpt-main') + expect(provider).toBe(codex) + expect(profile.model).toBe('gpt-5.4') }) - it('should delegate ask to resolved provider', async () => { + it('should delegate ask to active profile provider', async () => { const vercel = makeProvider('vercel-ai') const router = new GenerateRouter(vercel, null) + mockResolveProfile.mockResolvedValue({ backend: 'vercel-ai-sdk', label: 'V', model: 'x', provider: 'anthropic' }) const result = await router.ask('test prompt') expect(result.text).toBe('from-vercel-ai') expect(vercel.ask).toHaveBeenCalledWith('test prompt') diff --git a/src/core/ai-provider-manager.ts b/src/core/ai-provider-manager.ts index 7b036e9e..b37f14fd 100644 --- a/src/core/ai-provider-manager.ts +++ b/src/core/ai-provider-manager.ts @@ -6,7 +6,8 @@ * core infrastructure that orchestrates providers. */ -import { readAIProviderConfig } from './config.js' +import { resolveProfile } from './config.js' +import type { ResolvedProfile } from './config.js' import type { ProviderEvent, ProviderResult, AIProvider } from '../ai-providers/types.js' export type { @@ -81,79 +82,45 @@ export class StreamableResult implements PromiseLike, AsyncItera // ==================== Types ==================== export interface AskOptions { - /** - * Preamble text describing the conversation context. - * Claude Code: injected inside the `` text block. - * Vercel AI SDK: not used (native ModelMessage[] carries the history directly). - */ + /** Preamble text describing the conversation context. */ historyPreamble?: string - /** - * System prompt override for this call. - * Claude Code: passed as `--system-prompt` to the CLI. - * Vercel AI SDK: replaces the agent's `instructions` for this call (triggers agent re-creation if changed). - */ + /** System prompt override for this call. */ systemPrompt?: string - /** - * Max text history entries to include in context. - * Claude Code: limits entries in the `` block. Default: 50. - * Vercel AI SDK: not used (compaction via `compactIfNeeded` controls context size). - */ + /** Max text history entries to include in context (text providers only). */ maxHistoryEntries?: number - /** - * Tool names to disable for this call, in addition to the global disabled list. - * Claude Code: merged into `disallowedTools` CLI option. - * Vercel AI SDK: filtered out from the tool map before the agent is created. - */ + /** Tool names to disable for this call. */ disabledTools?: string[] - /** - * AI provider to use for this call, overriding the global ai-provider-manager.json config. - * Falls back to global config if not specified. - */ - provider?: 'claude-code' | 'vercel-ai-sdk' | 'agent-sdk' - /** - * Vercel AI SDK model override — per-request provider/model/baseUrl/apiKey. - * Only used when the active backend is 'vercel-ai-sdk'. - */ - vercelAiSdk?: { - provider: string - model: string - baseUrl?: string - apiKey?: string - } - /** - * Agent SDK model override — per-request model/apiKey/baseUrl. - * Only used when the active backend is 'agent-sdk'. - */ - agentSdk?: { - model?: string - apiKey?: string - baseUrl?: string - } + /** Profile slug override. Falls back to global activeProfile if omitted. */ + profileSlug?: string } // ==================== GenerateRouter ==================== -/** Reads runtime AI config and resolves to the correct AIProvider. */ +/** Resolves profile → AIProvider instance + resolved config. */ export class GenerateRouter { + private providers: Record + constructor( - private vercel: AIProvider, - private agentSdk: AIProvider | null = null, - ) {} - - /** Resolve the active provider, optionally overridden per-request. */ - async resolve(override?: string): Promise { - // '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' || config.backend === 'claude-code') && this.agentSdk) return this.agentSdk - return this.vercel + vercel: AIProvider, + agentSdk: AIProvider | null = null, + codex: AIProvider | null = null, + ) { + this.providers = { 'vercel-ai-sdk': vercel } + if (agentSdk) this.providers['agent-sdk'] = agentSdk + if (codex) this.providers['codex'] = codex + } + + /** Resolve profile and pick the matching provider. */ + async resolve(profileSlug?: string): Promise<{ provider: AIProvider; profile: ResolvedProfile }> { + const profile = await resolveProfile(profileSlug) + const provider = this.providers[profile.backend] + if (!provider) throw new Error(`No provider registered for backend: ${profile.backend}`) + return { provider, profile } } - /** Stateless ask — delegates to the resolved provider. */ + /** Stateless ask — delegates to the active profile's provider. */ async ask(prompt: string): Promise { - const provider = await this.resolve() + const { provider } = await this.resolve() return provider.ask(prompt) } } diff --git a/src/core/config.spec.ts b/src/core/config.spec.ts index 1b06d97e..798ac14f 100644 --- a/src/core/config.spec.ts +++ b/src/core/config.spec.ts @@ -18,8 +18,7 @@ vi.mock('fs/promises', () => ({ import { readFile, writeFile, mkdir } from 'fs/promises' import { readAIProviderConfig, - readAIBackend, - writeAIBackend, + setActiveProfile, readToolsConfig, readAgentConfig, readMarketDataConfig, @@ -27,6 +26,7 @@ import { readAccountsConfig, writeAccountsConfig, aiProviderSchema, + profileSchema, } from './config.js' const mockReadFile = vi.mocked(readFile) @@ -62,74 +62,55 @@ describe('readAIProviderConfig', () => { it('returns schema defaults when file is missing', async () => { fileNotFound() const cfg = await readAIProviderConfig() - expect(cfg.backend).toBe('claude-code') - expect(cfg.provider).toBe('anthropic') - expect(cfg.model).toBe('claude-sonnet-4-6') + expect(cfg.activeProfile).toBe('default') + expect(cfg.profiles.default).toBeDefined() + expect(cfg.profiles.default.backend).toBe('agent-sdk') }) - it('parses valid file content', async () => { - fileReturns({ backend: 'vercel-ai-sdk', provider: 'openai', model: 'gpt-4o' }) + it('parses valid profile-based content', async () => { + fileReturns({ + apiKeys: { openai: 'sk-test' }, + profiles: { main: { backend: 'codex', label: 'GPT', model: 'gpt-5.4', loginMethod: 'codex-oauth' } }, + activeProfile: 'main', + }) const cfg = await readAIProviderConfig() - expect(cfg.backend).toBe('vercel-ai-sdk') - expect(cfg.provider).toBe('openai') - expect(cfg.model).toBe('gpt-4o') + expect(cfg.activeProfile).toBe('main') + expect(cfg.profiles.main.backend).toBe('codex') + expect(cfg.profiles.main.model).toBe('gpt-5.4') }) it('returns defaults when file contains invalid JSON (parse error)', async () => { fileReadError('Unexpected token') const cfg = await readAIProviderConfig() - expect(cfg.backend).toBe('claude-code') - }) - - it('fills in missing fields with schema defaults', async () => { - fileReturns({ backend: 'agent-sdk' }) - const cfg = await readAIProviderConfig() - expect(cfg.backend).toBe('agent-sdk') - expect(cfg.provider).toBe('anthropic') // default - expect(cfg.model).toBe('claude-sonnet-4-6') // default + expect(cfg.activeProfile).toBe('default') }) }) -// ==================== readAIBackend ==================== - -describe('readAIBackend', () => { - it('returns claude-code backend by default', async () => { - fileNotFound() - const { backend } = await readAIBackend() - expect(backend).toBe('claude-code') - }) - - it('returns the backend stored in file', async () => { - fileReturns({ backend: 'vercel-ai-sdk' }) - const { backend } = await readAIBackend() - expect(backend).toBe('vercel-ai-sdk') - }) -}) - -// ==================== writeAIBackend ==================== - -describe('writeAIBackend', () => { - it('reads current config and overwrites only the backend field', async () => { - // First read: return existing config with custom model - fileReturns({ backend: 'claude-code', provider: 'anthropic', model: 'my-custom-model' }) +// ==================== setActiveProfile ==================== + +describe('setActiveProfile', () => { + it('updates activeProfile and writes to disk', async () => { + const config = { + apiKeys: {}, + profiles: { + a: { backend: 'agent-sdk', label: 'A', model: 'claude-sonnet-4-6', loginMethod: 'api-key' }, + b: { backend: 'codex', label: 'B', model: 'gpt-5.4', loginMethod: 'codex-oauth' }, + }, + activeProfile: 'a', + } + fileReturns(config) - await writeAIBackend('vercel-ai-sdk') + await setActiveProfile('b') - expect(mockMkdir).toHaveBeenCalled() expect(mockWriteFile).toHaveBeenCalled() - const written = JSON.parse((mockWriteFile.mock.calls[0][1] as string)) - expect(written.backend).toBe('vercel-ai-sdk') - expect(written.model).toBe('my-custom-model') // preserved - expect(written.provider).toBe('anthropic') // preserved + expect(written.activeProfile).toBe('b') + expect(written.profiles.a).toBeDefined() // preserved }) - it('writes to ai-provider-manager.json', async () => { - fileReturns({ backend: 'agent-sdk' }) - await writeAIBackend('claude-code') - - const filePath = mockWriteFile.mock.calls[0][0] as string - expect(filePath).toMatch(/ai-provider-manager\.json$/) + it('throws on unknown profile slug', async () => { + fileReturns({ apiKeys: {}, profiles: { a: { backend: 'agent-sdk', label: 'A', model: 'x' } }, activeProfile: 'a' }) + await expect(setActiveProfile('nonexistent')).rejects.toThrow('Unknown profile') }) }) @@ -211,7 +192,7 @@ describe('writeConfigSection', () => { it('throws ZodError for invalid data (does not write file)', async () => { await expect( - writeConfigSection('aiProvider', { backend: 'invalid-backend-name' }) + writeConfigSection('aiProvider', { profiles: { bad: { backend: 'invalid-backend', label: 'X' } } }) ).rejects.toThrow() // writeFile should not have been called expect(mockWriteFile).not.toHaveBeenCalled() @@ -270,22 +251,40 @@ describe('writeAccountsConfig', () => { // ==================== aiProviderSchema (Zod schema validation) ==================== -describe('aiProviderSchema', () => { - it('accepts valid backends', () => { - for (const backend of ['claude-code', 'vercel-ai-sdk', 'agent-sdk'] as const) { - expect(() => aiProviderSchema.parse({ backend })).not.toThrow() - } +describe('aiProviderSchema (profile-based)', () => { + it('uses defaults for empty object', () => { + const result = aiProviderSchema.parse({}) + expect(result.activeProfile).toBe('default') + expect(result.profiles.default).toBeDefined() + expect(result.apiKeys).toEqual({}) }) - it('rejects unknown backend', () => { - expect(() => aiProviderSchema.parse({ backend: 'unknown-backend' })).toThrow() + it('accepts valid profile-based config', () => { + expect(() => aiProviderSchema.parse({ + profiles: { test: { backend: 'codex', label: 'Test', model: 'gpt-5.4', loginMethod: 'codex-oauth' } }, + activeProfile: 'test', + })).not.toThrow() }) +}) - it('uses defaults for missing fields', () => { - const result = aiProviderSchema.parse({}) - expect(result.backend).toBe('claude-code') - expect(result.provider).toBe('anthropic') - expect(result.model).toBe('claude-sonnet-4-6') - expect(result.apiKeys).toEqual({}) +describe('profileSchema', () => { + it('validates agent-sdk profile', () => { + const result = profileSchema.parse({ backend: 'agent-sdk', label: 'Claude', model: 'claude-opus-4-6', loginMethod: 'claudeai' }) + expect(result.backend).toBe('agent-sdk') + }) + + it('validates codex profile', () => { + const result = profileSchema.parse({ backend: 'codex', label: 'GPT', model: 'gpt-5.4' }) + expect(result.backend).toBe('codex') + if (result.backend === 'codex') expect(result.loginMethod).toBe('codex-oauth') // default + }) + + it('validates vercel profile', () => { + const result = profileSchema.parse({ backend: 'vercel-ai-sdk', label: 'Gemini', provider: 'google', model: 'gemini-2.5-flash' }) + expect(result.backend).toBe('vercel-ai-sdk') + }) + + it('rejects unknown backend', () => { + expect(() => profileSchema.parse({ backend: 'unknown', label: 'X', model: 'y' })).toThrow() }) }) diff --git a/src/core/config.ts b/src/core/config.ts index 0a496dd7..9dbb477a 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -13,15 +13,17 @@ const engineSchema = z.object({ port: z.number().int().positive().default(3000), }) -const loginMethodSchema = z.enum(['api-key', 'claudeai']) +// ==================== AI Provider: Legacy Schema (kept for migration) ==================== -export const aiProviderSchema = z.object({ - backend: z.enum(['claude-code', 'vercel-ai-sdk', 'agent-sdk']).default('claude-code'), +const legacyLoginMethodSchema = z.enum(['api-key', 'claudeai', 'codex-oauth']) + +/** @deprecated Legacy flat schema — used only for migration detection. */ +export const aiProviderLegacySchema = z.object({ + backend: z.enum(['claude-code', 'vercel-ai-sdk', 'agent-sdk', 'codex']).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'), + loginMethod: legacyLoginMethodSchema.default('api-key'), apiKeys: z.object({ anthropic: z.string().optional(), openai: z.string().optional(), @@ -29,6 +31,62 @@ export const aiProviderSchema = z.object({ }).default({}), }) +// ==================== AI Provider: Profile-based Schema ==================== + +export type AIBackend = 'agent-sdk' | 'codex' | 'vercel-ai-sdk' + +const apiKeysSchema = z.object({ + anthropic: z.string().optional(), + openai: z.string().optional(), + google: z.string().optional(), +}) + +const baseProfileFields = { + label: z.string().min(1), + baseUrl: z.string().optional(), + apiKey: z.string().optional(), +} + +export const agentSdkProfileSchema = z.object({ + ...baseProfileFields, + backend: z.literal('agent-sdk'), + model: z.string().default('claude-sonnet-4-6'), + loginMethod: z.enum(['api-key', 'claudeai']).default('api-key'), +}) + +export const codexProfileSchema = z.object({ + ...baseProfileFields, + backend: z.literal('codex'), + model: z.string().default('gpt-5.4'), + loginMethod: z.enum(['api-key', 'codex-oauth']).default('codex-oauth'), +}) + +export const vercelProfileSchema = z.object({ + ...baseProfileFields, + backend: z.literal('vercel-ai-sdk'), + provider: z.string().default('anthropic'), + model: z.string().default('claude-sonnet-4-6'), +}) + +export const profileSchema = z.discriminatedUnion('backend', [ + agentSdkProfileSchema, codexProfileSchema, vercelProfileSchema, +]) + +export type Profile = z.infer + +export const aiProviderSchema = z.object({ + apiKeys: apiKeysSchema.default({}), + profiles: z.record( + z.string(), + profileSchema, + ).default({ + default: { backend: 'agent-sdk', label: 'Claude Sonnet', model: 'claude-sonnet-4-6', loginMethod: 'claudeai' }, + }), + activeProfile: z.string().default('default'), +}) + +export type AIProviderConfig = z.infer + const agentSchema = z.object({ maxSteps: z.number().int().positive().default(20), evolutionMode: z.boolean().default(false), @@ -176,34 +234,14 @@ export const toolsSchema = z.object({ disabled: z.array(z.string()).default([]), }) -/** Vercel AI SDK model override — per-channel provider/model/key/endpoint. */ -export const vercelAiSdkOverrideSchema = z.object({ - provider: z.string(), - model: z.string(), - baseUrl: z.string().optional(), - apiKey: z.string().optional(), -}) - -/** Agent SDK model override — per-channel model/key/endpoint. */ -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({ /** URL-safe identifier. Used as session path segment: data/sessions/web/{id}.jsonl */ id: z.string().regex(/^[a-z0-9-_]+$/, 'id must be lowercase alphanumeric with hyphens/underscores'), label: z.string().min(1), /** System prompt override for this channel. */ systemPrompt: z.string().optional(), - /** AI backend override. Falls back to global config if omitted. */ - provider: z.enum(['claude-code', 'vercel-ai-sdk', 'agent-sdk']).optional(), - /** Vercel AI SDK model override. Only used when provider is 'vercel-ai-sdk'. */ - vercelAiSdk: vercelAiSdkOverrideSchema.optional(), - /** Agent SDK model override. Only used when provider is 'agent-sdk'. */ - agentSdk: agentSdkOverrideSchema.optional(), + /** AI provider profile slug. Falls back to global activeProfile if omitted. */ + profile: z.string().optional(), /** Tool names to disable in addition to the global disabled list. */ disabledTools: z.array(z.string()).optional(), }) @@ -283,18 +321,99 @@ export async function loadConfig(): Promise { const raws = await Promise.all(files.map((f) => loadJsonFile(f))) // TODO: remove all migration blocks before v1.0 — no stable release yet, breaking changes are fine - // ---------- Migration: consolidate old ai-provider + model + api-keys → ai-provider ---------- + // ---------- Migration: flat ai-provider config → profile-based ---------- const aiProviderRaw = raws[6] as Record | undefined - if (aiProviderRaw && !('backend' in aiProviderRaw)) { - // Old format detected — merge model.json + api-keys.json into ai-provider-manager.json + if (aiProviderRaw && 'backend' in aiProviderRaw && !('profiles' in aiProviderRaw)) { + // Legacy flat format detected — convert to profile-based + + // Step 1: handle very old format (model.json + api-keys.json) + if (!('model' in aiProviderRaw)) { + const oldModel = await loadJsonFile('model.json') as Record | undefined + const oldKeys = await loadJsonFile('api-keys.json') as Record | undefined + if (oldModel) Object.assign(aiProviderRaw, { provider: oldModel.provider, model: oldModel.model, ...(oldModel.baseUrl ? { baseUrl: oldModel.baseUrl } : {}) }) + if (oldKeys) aiProviderRaw.apiKeys = oldKeys + await removeJsonFile('model.json') + await removeJsonFile('api-keys.json') + } + + // Step 2: handle claude-code → agent-sdk alias + if (aiProviderRaw.backend === 'claude-code') { + aiProviderRaw.backend = 'agent-sdk' + aiProviderRaw.loginMethod = aiProviderRaw.loginMethod ?? 'claudeai' + } + + // Step 3: build default profile from flat config + const legacy = aiProviderLegacySchema.parse(aiProviderRaw) + const defaultProfile: Record = { label: 'Default' } + if (legacy.backend === 'agent-sdk') { + defaultProfile.backend = 'agent-sdk' + defaultProfile.model = legacy.model + defaultProfile.loginMethod = legacy.loginMethod === 'codex-oauth' ? 'api-key' : legacy.loginMethod + } else if (legacy.backend === 'codex') { + defaultProfile.backend = 'codex' + defaultProfile.model = legacy.model + defaultProfile.loginMethod = legacy.loginMethod === 'claudeai' ? 'codex-oauth' : legacy.loginMethod + } else { + defaultProfile.backend = 'vercel-ai-sdk' + defaultProfile.provider = legacy.provider + defaultProfile.model = legacy.model + } + if (legacy.baseUrl) defaultProfile.baseUrl = legacy.baseUrl + + // Step 4: migrate subchannel inline overrides → named profiles + const oldSubchannels = await loadJsonFile('web-subchannels.json') as Array> | undefined + const profiles: Record = { default: defaultProfile } + const newSubchannels: Array> = [] + + if (oldSubchannels) { + for (const ch of oldSubchannels) { + const sub: Record = { id: ch.id, label: ch.label } + if (ch.systemPrompt) sub.systemPrompt = ch.systemPrompt + if (ch.disabledTools) sub.disabledTools = ch.disabledTools + + const provider = ch.provider as string | undefined + const override = provider === 'vercel-ai-sdk' ? ch.vercelAiSdk + : provider === 'agent-sdk' ? ch.agentSdk + : provider === 'codex' ? ch.codex + : undefined + + if (provider && override) { + const slug = `${ch.id}-${provider}` + profiles[slug] = { backend: provider, label: `${ch.label}`, ...(override as object) } + sub.profile = slug + } else if (provider) { + // Provider set but no override — create a profile with just the backend + const slug = `${ch.id}-${provider}` + profiles[slug] = { ...defaultProfile, backend: provider, label: `${ch.label}` } + sub.profile = slug + } + + newSubchannels.push(sub) + } + await writeFile(resolve(CONFIG_DIR, 'web-subchannels.json'), JSON.stringify(newSubchannels, null, 2) + '\n') + } + + // Step 5: write new format + const migrated = { apiKeys: legacy.apiKeys, profiles, activeProfile: 'default' } + raws[6] = migrated + await mkdir(CONFIG_DIR, { recursive: true }) + await writeFile(resolve(CONFIG_DIR, 'ai-provider-manager.json'), JSON.stringify(migrated, null, 2) + '\n') + } else if (aiProviderRaw && !('backend' in aiProviderRaw) && !('profiles' in aiProviderRaw)) { + // Very old format (no backend, no profiles) — handle model.json merge first const oldModel = await loadJsonFile('model.json') as Record | undefined const oldKeys = await loadJsonFile('api-keys.json') as Record | undefined const migrated = { - backend: aiProviderRaw.provider ?? 'claude-code', - provider: oldModel?.provider ?? 'anthropic', - model: oldModel?.model ?? 'claude-sonnet-4-6', - ...(oldModel?.baseUrl ? { baseUrl: oldModel.baseUrl } : {}), apiKeys: oldKeys ?? {}, + profiles: { + default: { + backend: 'agent-sdk', + label: 'Default', + model: (oldModel?.model as string) ?? 'claude-sonnet-4-6', + loginMethod: 'claudeai', + provider: (oldModel?.provider as string) ?? 'anthropic', + }, + }, + activeProfile: 'default', } raws[6] = migrated await mkdir(CONFIG_DIR, { recursive: true }) @@ -303,14 +422,6 @@ 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[9] as Record | undefined if (connectorsRaw === undefined) { @@ -443,24 +554,74 @@ export async function readConnectorsConfig() { } } -// ==================== AI Backend Helpers ==================== +// ==================== Profile Helpers ==================== + +/** Resolved profile with apiKey filled from global keys. */ +export interface ResolvedProfile { + backend: AIBackend + label: string + model: string + apiKey?: string + baseUrl?: string + loginMethod?: string + provider?: string +} -export type AIBackend = 'claude-code' | 'vercel-ai-sdk' | 'agent-sdk' +/** Resolve a profile by slug, filling in global apiKey fallback. */ +export async function resolveProfile(slug?: string): Promise { + const config = await readAIProviderConfig() + const key = slug ?? config.activeProfile + const profile = config.profiles[key] + if (!profile) throw new Error(`Unknown AI provider profile: "${key}"`) + const vendor = profile.backend === 'codex' ? 'openai' + : profile.backend === 'agent-sdk' ? 'anthropic' + : (profile as { provider?: string }).provider ?? 'anthropic' + return { + ...profile, + apiKey: profile.apiKey ?? (config.apiKeys as Record)[vendor], + } +} -/** Read the current AI backend from ai-provider-manager.json. */ -export async function readAIBackend(): Promise<{ backend: AIBackend }> { +/** Get the active profile slug. */ +export async function getActiveProfileSlug(): Promise { const config = await readAIProviderConfig() - return { backend: config.backend } + return config.activeProfile } -/** Switch the AI backend in ai-provider-manager.json (preserves other fields). */ -export async function writeAIBackend(backend: AIBackend): Promise { - const current = await readAIProviderConfig() - const updated = { ...current, backend } +/** Set the active profile. */ +export async function setActiveProfile(slug: string): Promise { + const config = await readAIProviderConfig() + if (!config.profiles[slug]) throw new Error(`Unknown profile: "${slug}"`) + const updated = { ...config, activeProfile: slug } await mkdir(CONFIG_DIR, { recursive: true }) await writeFile(resolve(CONFIG_DIR, 'ai-provider-manager.json'), JSON.stringify(updated, null, 2) + '\n') } +/** Write a single profile (create or update). */ +export async function writeProfile(slug: string, profile: Profile): Promise { + const config = await readAIProviderConfig() + config.profiles[slug] = profile + await mkdir(CONFIG_DIR, { recursive: true }) + await writeFile(resolve(CONFIG_DIR, 'ai-provider-manager.json'), JSON.stringify(config, null, 2) + '\n') +} + +/** Delete a profile. Cannot delete the active profile. */ +export async function deleteProfile(slug: string): Promise { + const config = await readAIProviderConfig() + if (config.activeProfile === slug) throw new Error('Cannot delete the active profile') + delete config.profiles[slug] + await mkdir(CONFIG_DIR, { recursive: true }) + await writeFile(resolve(CONFIG_DIR, 'ai-provider-manager.json'), JSON.stringify(config, null, 2) + '\n') +} + +/** Update global API keys. */ +export async function writeApiKeys(keys: { anthropic?: string; openai?: string; google?: string }): Promise { + const config = await readAIProviderConfig() + config.apiKeys = { ...config.apiKeys, ...keys } + await mkdir(CONFIG_DIR, { recursive: true }) + await writeFile(resolve(CONFIG_DIR, 'ai-provider-manager.json'), JSON.stringify(config, null, 2) + '\n') +} + // ==================== Writer ==================== export type ConfigSection = keyof Config diff --git a/src/core/session.ts b/src/core/session.ts index cb2e8499..2b0484c2 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -36,7 +36,7 @@ export interface SessionEntry { sessionId: string timestamp: string /** Which provider generated this entry. */ - provider?: 'vercel-ai' | 'claude-code' | 'agent-sdk' | 'human' | 'compaction' | 'notification' + provider?: 'vercel-ai' | 'claude-code' | 'agent-sdk' | 'codex' | 'human' | 'compaction' | 'notification' cwd?: string /** Arbitrary metadata attached to the entry (e.g. { kind: 'notification', source: 'heartbeat' }). */ metadata?: Record @@ -466,6 +466,76 @@ export function toTextHistory(entries: SessionEntry[]): Array<{ role: 'user' | ' return history } +// ==================== Responses API Input (for Codex provider) ==================== + +/** + * Input item types for OpenAI's Responses API. + * Mirrors the subset of ResponseInputItem that we actually use. + */ +export type ResponsesInputItem = + | { role: 'user' | 'assistant'; content: string } + | { type: 'function_call'; call_id: string; name: string; arguments: string } + | { type: 'function_call_output'; call_id: string; output: string } + +/** + * Convert session entries → OpenAI Responses API input items. + * + * Similar to toModelMessages() but targets the Responses API format. + * Handles orphaned tool calls (compaction truncation) by stripping + * function_call items that have no matching function_call_output. + */ +export function toResponsesInput(entries: SessionEntry[]): ResponsesInputItem[] { + const items: ResponsesInputItem[] = [] + + for (const entry of entries) { + if (entry.type === 'system' && entry.subtype === 'compact_boundary') continue + + const { message } = entry + + if (message.role === 'user') { + if (typeof message.content === 'string') { + items.push({ role: 'user', content: message.content }) + } else { + // tool_result blocks → function_call_output items + for (const block of message.content) { + if (block.type === 'tool_result') { + items.push({ type: 'function_call_output', call_id: block.tool_use_id, output: block.content }) + } else if (block.type === 'text') { + items.push({ role: 'user', content: block.text }) + } + } + } + } else if (message.role === 'assistant') { + if (typeof message.content === 'string') { + items.push({ role: 'assistant', content: message.content }) + } else { + for (const block of message.content) { + if (block.type === 'text') { + items.push({ role: 'assistant', content: block.text }) + } else if (block.type === 'tool_use') { + items.push({ + type: 'function_call', + call_id: block.id, + name: block.name, + arguments: typeof block.input === 'string' ? block.input : JSON.stringify(block.input), + }) + } + } + } + } + } + + // Sanitize: strip function_call items without a matching function_call_output + const outputIds = new Set() + for (const item of items) { + if ('type' in item && item.type === 'function_call_output') outputIds.add(item.call_id) + } + return items.filter((item) => { + if ('type' in item && item.type === 'function_call') return outputIds.has(item.call_id) + return true + }) +} + // ==================== Chat History (for Web UI) ==================== /** A display-ready chat history item — either plain text or a group of paired tool calls. */ diff --git a/src/domain/market-data/__tests__/bbProviders/fmp.bbProvider.spec.ts b/src/domain/market-data/__tests__/bbProviders/fmp.bbProvider.spec.ts index a93d300a..692993a0 100644 --- a/src/domain/market-data/__tests__/bbProviders/fmp.bbProvider.spec.ts +++ b/src/domain/market-data/__tests__/bbProviders/fmp.bbProvider.spec.ts @@ -13,7 +13,7 @@ let ctx: TestContext beforeAll(async () => { ctx = await getTestContext() }) const exec = (model: string, params: Record = {}) => - ctx.executor.execute('fmp', model, params, ctx.credentials) + ctx.executor.execute('fmp', model, params, ctx.credentials) as Promise describe('fmp — equity', () => { beforeEach(({ skip }) => { if (!hasCredential(ctx.credentials, 'fmp')) skip('no fmp_api_key') }) diff --git a/src/domain/market-data/__tests__/bbProviders/yfinance.bbProvider.spec.ts b/src/domain/market-data/__tests__/bbProviders/yfinance.bbProvider.spec.ts index a4bdaadf..55413f1d 100644 --- a/src/domain/market-data/__tests__/bbProviders/yfinance.bbProvider.spec.ts +++ b/src/domain/market-data/__tests__/bbProviders/yfinance.bbProvider.spec.ts @@ -13,7 +13,7 @@ let ctx: TestContext beforeAll(async () => { ctx = await getTestContext() }) const exec = (model: string, params: Record = {}) => - ctx.executor.execute('yfinance', model, params, ctx.credentials) + ctx.executor.execute('yfinance', model, params, ctx.credentials) as Promise describe('yfinance — equity', () => { it('EquityQuote', async () => { expect((await exec('EquityQuote', { symbol: 'AAPL' })).length).toBeGreaterThan(0) }) diff --git a/src/domain/trading/brokers/mock/MockBroker.ts b/src/domain/trading/brokers/mock/MockBroker.ts index acf47478..9026c222 100644 --- a/src/domain/trading/brokers/mock/MockBroker.ts +++ b/src/domain/trading/brokers/mock/MockBroker.ts @@ -318,7 +318,7 @@ export class MockBroker implements IBroker { order.orderType = 'MKT' order.totalQuantity = quantity ?? pos.quantity - return this.placeOrder(pos.contract, order, { reduceOnly: true }) + return this.placeOrder(pos.contract, order) } // ---- Queries ---- diff --git a/src/main.ts b/src/main.ts index 85d170e2..9f3421ce 100644 --- a/src/main.ts +++ b/src/main.ts @@ -34,6 +34,7 @@ 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 { AgentSdkProvider } from './ai-providers/agent-sdk/agent-sdk-provider.js' +import { CodexProvider } from './ai-providers/codex/index.js' import { createEventLog } from './core/event-log.js' import { createToolCallLog } from './core/tool-call-log.js' import { createCronEngine, createCronListener, createCronTools } from './task/cron/index.js' @@ -49,6 +50,8 @@ const FRONTAL_LOBE_FILE = resolve('data/brain/frontal-lobe.md') const EMOTION_LOG_FILE = resolve('data/brain/emotion-log.md') const PERSONA_FILE = resolve('data/brain/persona.md') const PERSONA_DEFAULT = resolve('default/persona.default.md') +const HEARTBEAT_FILE = resolve('data/brain/heartbeat.md') +const HEARTBEAT_DEFAULT = resolve('default/heartbeat.default.md') const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) @@ -96,9 +99,10 @@ async function main() { // ==================== Brain ==================== - const [brainExport, persona] = await Promise.all([ + const [brainExport] = await Promise.all([ readFile(BRAIN_FILE, 'utf-8').then((r) => JSON.parse(r) as BrainExportState).catch(() => undefined), readWithDefault(PERSONA_FILE, PERSONA_DEFAULT), + readWithDefault(HEARTBEAT_FILE, HEARTBEAT_DEFAULT), ]) const brainDir = resolve('data/brain') @@ -120,17 +124,21 @@ async function main() { ? Brain.restore(brainExport, { onCommit: brainOnCommit }) : new Brain({ onCommit: brainOnCommit }) - const frontalLobe = brain.getFrontalLobe() - const emotion = brain.getEmotion().current - const instructions = [ - persona, - '---', - '## Current Brain State', - '', - `**Frontal Lobe:** ${frontalLobe || '(empty)'}`, - '', - `**Emotion:** ${emotion}`, - ].join('\n') + /** Re-read persona from disk + live brain state on each request. */ + const getInstructions = async () => { + const persona = await readFile(PERSONA_FILE, 'utf-8').catch(() => '') + const frontalLobe = brain.getFrontalLobe() + const emotion = brain.getEmotion().current + return [ + persona, + '---', + '## Current Brain State', + '', + `**Frontal Lobe:** ${frontalLobe || '(empty)'}`, + '', + `**Emotion:** ${emotion}`, + ].join('\n') + } // ==================== Cron ==================== @@ -209,14 +217,18 @@ async function main() { const vercelProvider = new VercelAIProvider( () => toolCenter.getVercelTools(), - instructions, + getInstructions, config.agent.maxSteps, ) const agentSdkProvider = new AgentSdkProvider( () => toolCenter.getVercelTools(), - instructions, + getInstructions, + ) + const codexProvider = new CodexProvider( + () => toolCenter.getVercelTools(), + getInstructions, ) - const router = new GenerateRouter(vercelProvider, agentSdkProvider) + const router = new GenerateRouter(vercelProvider, agentSdkProvider, codexProvider) const agentCenter = new AgentCenter({ router, diff --git a/ui/src/api/channels.ts b/ui/src/api/channels.ts index 1627a69a..ea91da3b 100644 --- a/ui/src/api/channels.ts +++ b/ui/src/api/channels.ts @@ -1,13 +1,11 @@ import { headers } from './client' -import type { WebChannel, VercelAiSdkOverride, AgentSdkOverride } from './types' +import type { WebChannel } from './types' export interface ChannelListItem { id: string label: string systemPrompt?: string - provider?: 'claude-code' | 'vercel-ai-sdk' | 'agent-sdk' - vercelAiSdk?: VercelAiSdkOverride - agentSdk?: AgentSdkOverride + profile?: string disabledTools?: string[] } @@ -32,7 +30,7 @@ export const channelsApi = { }, async update(id: string, data: Partial>): Promise<{ channel: ChannelListItem }> { - const res = await fetch(`/api/channels/${encodeURIComponent(id)}`, { + const res = await fetch(`/api/channels/${id}`, { method: 'PUT', headers, body: JSON.stringify(data), @@ -45,13 +43,7 @@ export const channelsApi = { }, async remove(id: string): Promise { - const res = await fetch(`/api/channels/${encodeURIComponent(id)}`, { - method: 'DELETE', - headers, - }) - if (!res.ok) { - const err = await res.json().catch(() => ({ error: res.statusText })) - throw new Error(err.error || res.statusText) - } + const res = await fetch(`/api/channels/${id}`, { method: 'DELETE' }) + if (!res.ok) throw new Error('Failed to delete channel') }, } diff --git a/ui/src/api/config.ts b/ui/src/api/config.ts index cf1c2285..08ff00c7 100644 --- a/ui/src/api/config.ts +++ b/ui/src/api/config.ts @@ -1,5 +1,5 @@ import { headers } from './client' -import type { AppConfig } from './types' +import type { AppConfig, Profile } from './types' export const configApi = { async load(): Promise { @@ -8,15 +8,6 @@ export const configApi = { return res.json() }, - async setBackend(backend: string): Promise { - const res = await fetch('/api/config/ai-provider', { - method: 'PUT', - headers, - body: JSON.stringify({ backend }), - }) - if (!res.ok) throw new Error('Failed to switch backend') - }, - async updateSection(section: string, data: unknown): Promise { const res = await fetch(`/api/config/${section}`, { method: 'PUT', @@ -29,4 +20,75 @@ export const configApi = { } return res.json() }, + + // ==================== Profile CRUD ==================== + + async getProfiles(): Promise<{ profiles: Record; activeProfile: string }> { + const res = await fetch('/api/config/profiles') + if (!res.ok) throw new Error('Failed to load profiles') + return res.json() + }, + + async createProfile(slug: string, profile: Profile): Promise<{ slug: string; profile: Profile }> { + const res = await fetch('/api/config/profiles', { + method: 'POST', + headers, + body: JSON.stringify({ slug, profile }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Failed to create profile' })) + throw new Error(err.error || 'Failed to create profile') + } + return res.json() + }, + + async updateProfile(slug: string, profile: Profile): Promise<{ slug: string; profile: Profile }> { + const res = await fetch(`/api/config/profiles/${slug}`, { + method: 'PUT', + headers, + body: JSON.stringify(profile), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Failed to update profile' })) + throw new Error(err.error || 'Failed to update profile') + } + return res.json() + }, + + async deleteProfile(slug: string): Promise { + const res = await fetch(`/api/config/profiles/${slug}`, { method: 'DELETE' }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Failed to delete profile' })) + throw new Error(err.error || 'Failed to delete profile') + } + }, + + async setActiveProfile(slug: string): Promise { + const res = await fetch('/api/config/active-profile', { + method: 'PUT', + headers, + body: JSON.stringify({ slug }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Failed to set active profile' })) + throw new Error(err.error || 'Failed to set active profile') + } + }, + + // ==================== API Keys ==================== + + async updateApiKeys(keys: { anthropic?: string; openai?: string; google?: string }): Promise { + const res = await fetch('/api/config/api-keys', { + method: 'PUT', + headers, + body: JSON.stringify(keys), + }) + if (!res.ok) throw new Error('Failed to update API keys') + }, + + async getApiKeysStatus(): Promise<{ anthropic: boolean; openai: boolean; google: boolean }> { + const res = await fetch('/api/config/api-keys/status') + if (!res.ok) throw new Error('Failed to load API key status') + return res.json() + }, } diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 84fcdbad..dbe1f73f 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -13,6 +13,7 @@ import { devApi } from './dev' import { toolsApi } from './tools' import { channelsApi } from './channels' import { agentStatusApi } from './agentStatus' +import { personaApi } from './persona' export const api = { chat: chatApi, config: configApi, @@ -25,12 +26,14 @@ export const api = { tools: toolsApi, channels: channelsApi, agentStatus: agentStatusApi, + persona: personaApi, } // Re-export all types for convenience export type { WebChannel, - VercelAiSdkOverride, + Profile, + AIBackend, ChatMessage, ChatResponse, ToolCall, @@ -51,7 +54,6 @@ export type { NewsCollectorConfig, NewsCollectorFeed, ToolCallRecord, - LoginMethod, UTASnapshotSummary, EquityCurvePoint, } from './types' diff --git a/ui/src/api/persona.ts b/ui/src/api/persona.ts new file mode 100644 index 00000000..0e665664 --- /dev/null +++ b/ui/src/api/persona.ts @@ -0,0 +1,18 @@ +import { headers } from './client' + +export const personaApi = { + async get(): Promise<{ content: string; path: string }> { + const res = await fetch('/api/persona') + if (!res.ok) throw new Error('Failed to load persona') + return res.json() + }, + + async update(content: string): Promise { + const res = await fetch('/api/persona', { + method: 'PUT', + headers, + body: JSON.stringify({ content }), + }) + if (!res.ok) throw new Error('Failed to save persona') + }, +} diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 7d9ab8db..ba3848a8 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -1,28 +1,24 @@ -// ==================== Channels ==================== +// ==================== AI Provider Profiles ==================== -export interface VercelAiSdkOverride { - provider: string +export type AIBackend = 'agent-sdk' | 'codex' | 'vercel-ai-sdk' + +export interface Profile { + backend: AIBackend + label: string model: string + loginMethod?: string + provider?: string // vercel-ai-sdk only baseUrl?: string apiKey?: string } -export type LoginMethod = 'api-key' | 'claudeai' - -export interface AgentSdkOverride { - model?: string - baseUrl?: string - apiKey?: string - loginMethod?: LoginMethod -} +// ==================== Channels ==================== export interface WebChannel { id: string label: string systemPrompt?: string - provider?: 'claude-code' | 'vercel-ai-sdk' | 'agent-sdk' - vercelAiSdk?: VercelAiSdkOverride - agentSdk?: AgentSdkOverride + profile?: string // slug reference to a profile disabledTools?: string[] } @@ -60,12 +56,9 @@ export type ChatHistoryItem = // ==================== Config ==================== export interface AIProviderConfig { - backend: string - provider: string - model: string - baseUrl?: string - loginMethod?: LoginMethod apiKeys: { anthropic?: string; openai?: string; google?: string } + profiles: Record + activeProfile: string } export interface AppConfig { diff --git a/ui/src/components/ChannelConfigModal.tsx b/ui/src/components/ChannelConfigModal.tsx index 46de7728..9f2d18a6 100644 --- a/ui/src/components/ChannelConfigModal.tsx +++ b/ui/src/components/ChannelConfigModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { api } from '../api' -import type { LoginMethod } from '../api/types' +import type { Profile } from '../api/types' import type { ChannelListItem } from '../api/channels' import type { ToolInfo } from '../api/tools' @@ -13,59 +13,26 @@ interface ChannelConfigModalProps { export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigModalProps) { const [label, setLabel] = useState(channel.label) const [systemPrompt, setSystemPrompt] = useState(channel.systemPrompt ?? '') - const [provider, setProvider] = useState(channel.provider ?? '') + const [profile, setProfile] = useState(channel.profile ?? '') const [disabledTools, setDisabledTools] = useState>(new Set(channel.disabledTools ?? [])) const [tools, setTools] = useState([]) + const [profiles, setProfiles] = useState>({}) const [saving, setSaving] = useState(false) const [error, setError] = useState('') - // Vercel AI SDK override state - const [vModelProvider, setVModelProvider] = useState(channel.vercelAiSdk?.provider ?? '') - const [vModel, setVModel] = useState(channel.vercelAiSdk?.model ?? '') - const [vBaseUrl, setVBaseUrl] = useState(channel.vercelAiSdk?.baseUrl ?? '') - const [vApiKey, setVApiKey] = useState(channel.vercelAiSdk?.apiKey ?? '') - - // Agent SDK override state - 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' - useEffect(() => { api.tools.load().then(({ inventory }) => setTools(inventory)).catch(() => {}) + api.config.getProfiles().then(({ profiles: p }) => setProfiles(p)).catch(() => {}) }, []) const handleSave = async () => { setSaving(true) setError('') try { - const vercelAiSdk = showVercelConfig && vModelProvider && vModel - ? { - provider: vModelProvider, - model: vModel, - ...(vBaseUrl ? { baseUrl: vBaseUrl } : {}), - ...(vApiKey ? { apiKey: vApiKey } : {}), - } - : undefined - - const agentSdk = showAgentSdkConfig && (aModel || aLoginMethod) - ? { - ...(aModel ? { model: aModel } : {}), - ...(aBaseUrl ? { baseUrl: aBaseUrl } : {}), - ...(aApiKey ? { apiKey: aApiKey } : {}), - ...(aLoginMethod ? { loginMethod: aLoginMethod } : {}), - } - : undefined - const { channel: updated } = await api.channels.update(channel.id, { label: label.trim() || channel.label, systemPrompt: systemPrompt.trim() || undefined, - provider: (provider as 'claude-code' | 'vercel-ai-sdk' | 'agent-sdk') || undefined, - vercelAiSdk: vercelAiSdk ?? (null as unknown as undefined), - agentSdk: agentSdk ?? (null as unknown as undefined), + profile: profile || undefined, disabledTools: disabledTools.size > 0 ? [...disabledTools] : undefined, }) onSaved(updated) @@ -85,43 +52,28 @@ export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigM }) } - // Group tools by group name - const toolGroups = tools.reduce>((acc, t) => { - ;(acc[t.group] ??= []).push(t) - return acc - }, {}) - - const inputClass = 'w-full text-sm px-3 py-2 rounded-lg border border-border bg-bg-secondary text-text placeholder:text-text-muted focus:outline-none focus:border-accent' + const inputClass = 'w-full px-3 py-2 text-[13px] rounded-lg border border-border bg-bg-secondary text-text focus:outline-none focus:ring-1 focus:ring-accent' return ( -
+
e.stopPropagation()} > {/* Header */} -
-

- # - {channel.id} -

-
{/* Body */} -
+
{/* Label */}
- + setLabel(e.target.value)} className={inputClass} @@ -140,168 +92,48 @@ export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigM />
- {/* AI Backend */} + {/* AI Profile */}
- +
- {/* Vercel AI SDK config — only when provider is vercel-ai-sdk */} - {showVercelConfig && ( -
-

Vercel AI SDK Model Config

- -
-
- - -
-
- - setVModel(e.target.value)} - placeholder="e.g. gpt-4o" - className={inputClass} - /> -
-
- -
- - setVBaseUrl(e.target.value)} - placeholder="https://api.openai.com/v1" - className={inputClass} - /> - {vModelProvider === 'anthropic' && ( -

- Anthropic-compatible APIs: URL must end with /v1 — e.g. https://example.com/anthropic/v1 -

- )} -
- -
- - setVApiKey(e.target.value)} - placeholder="sk-..." - className={inputClass} - /> -
-
- )} - - {/* Agent SDK config — only when provider is agent-sdk */} - {showAgentSdkConfig && ( -
-

Claude Override

- -
- - -
- -
- - setAModel(e.target.value)} - placeholder="e.g. claude-opus-4-6" - className={inputClass} - /> -
- -
- - setABaseUrl(e.target.value)} - placeholder="Leave empty for default" - className={inputClass} - /> -
- -
- - setAApiKey(e.target.value)} - placeholder="sk-ant-..." - className={inputClass} - /> -
-
- )} - {/* Disabled Tools */}
{tools.length === 0 ? ( -

Loading tools...

+

Loading tools...

) : ( -
- {Object.entries(toolGroups).map(([group, groupTools]) => ( -
-

{group}

-
- {groupTools.map((t) => { - const isDisabled = disabledTools.has(t.name) - return ( - - ) - })} -
-
+
+ {tools.map((tool) => ( + ))}
)} @@ -309,20 +141,11 @@ export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigM
{/* Footer */} -
- {error ?

{error}

:
} -
- - +
diff --git a/ui/src/pages/AIProviderPage.tsx b/ui/src/pages/AIProviderPage.tsx index 13a474eb..9f1b8d51 100644 --- a/ui/src/pages/AIProviderPage.tsx +++ b/ui/src/pages/AIProviderPage.tsx @@ -1,642 +1,432 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from 'react' -import { api, type AppConfig, type AIProviderConfig, type LoginMethod } from '../api' +import { useState, useEffect, useRef } from 'react' +import { api, type Profile, type AIBackend } from '../api' import { SaveIndicator } from '../components/SaveIndicator' import { ConfigSection, Field, inputClass } from '../components/form' -import { useAutoSave, type SaveStatus } from '../hooks/useAutoSave' +import 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' }, - { label: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' }, - { label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5' }, - ], - openai: [ - { label: 'GPT-5.2 Pro', value: 'gpt-5.2-pro' }, - { label: 'GPT-5.2', value: 'gpt-5.2' }, - { label: 'GPT-5 Mini', value: 'gpt-5-mini' }, - ], - google: [ - { label: 'Gemini 3.1 Pro', value: 'gemini-3.1-pro-preview' }, - { label: 'Gemini 3 Flash', value: 'gemini-3-flash-preview' }, - { label: 'Gemini 2.5 Pro', value: 'gemini-2.5-pro' }, - ], +// ==================== Constants ==================== + +const BACKEND_INFO: Record = { + 'agent-sdk': { + label: 'Claude', + icon: , + }, + 'codex': { + label: 'OpenAI / Codex', + icon: , + }, + 'vercel-ai-sdk': { + label: 'Vercel AI SDK', + icon: , + }, } -const PROVIDERS = [ - { value: 'anthropic', label: 'Anthropic' }, - { value: 'openai', label: 'OpenAI' }, - { value: 'google', label: 'Google' }, - { value: 'custom', label: 'Custom' }, -] - -const SDK_FORMATS = [ - { value: 'openai', label: 'OpenAI Compatible' }, - { value: 'anthropic', label: 'Anthropic Compatible' }, - { value: 'google', label: 'Google Compatible' }, -] - -/** Detect whether saved config should show as "Custom" in the UI. */ -function detectCustomMode(provider: string, model: string): boolean { - const presets = PROVIDER_MODELS[provider] - if (!presets) return true - return !presets.some((p) => p.value === model) +const NEW_PROFILE_DEFAULTS: Record> = { + 'agent-sdk': { backend: 'agent-sdk', model: 'claude-sonnet-4-6', loginMethod: 'claudeai' }, + 'codex': { backend: 'codex', model: 'gpt-5.4', loginMethod: 'codex-oauth' }, + 'vercel-ai-sdk': { backend: 'vercel-ai-sdk', model: 'claude-sonnet-4-6', provider: 'anthropic' }, } -/** UI-level backend. 'openai' is a facade over vercel-ai-sdk with provider=openai. */ -type UIBackend = 'agent-sdk' | 'openai' | 'vercel-ai-sdk' - -/** Default provider/model per UI backend — applied on every switch to avoid stale config. */ -const BACKEND_DEFAULTS: Record = { - 'agent-sdk': { backend: 'agent-sdk', provider: 'anthropic', model: 'claude-sonnet-4-6' }, - 'openai': { backend: 'vercel-ai-sdk', provider: 'openai', model: PROVIDER_MODELS.openai[0].value }, - 'vercel-ai-sdk': { backend: 'vercel-ai-sdk', provider: 'anthropic', model: PROVIDER_MODELS.anthropic[0].value }, -} - -/** Derive initial UI backend from config. */ -function detectUIBackend(config: AIProviderConfig): UIBackend { - if (config.backend === 'vercel-ai-sdk' && config.provider === 'openai') return 'openai' - if (config.backend === 'vercel-ai-sdk') return 'vercel-ai-sdk' - return 'agent-sdk' -} - -function BackendCard({ selected, onClick, icon, title, description }: { - selected: boolean - onClick: () => void - icon: React.ReactNode - title: string - description: string -}) { - return ( - - ) -} +// ==================== Main Page ==================== export function AIProviderPage() { - const [config, setConfig] = useState(null) + const [profiles, setProfiles] = useState | null>(null) + const [activeProfile, setActiveProfile] = useState('') + const [apiKeys, setApiKeys] = useState<{ anthropic?: string; openai?: string; google?: string }>({}) + const [selectedSlug, setSelectedSlug] = useState(null) + const [creating, setCreating] = useState(null) useEffect(() => { - api.config.load().then(setConfig).catch(() => {}) + api.config.getProfiles().then(({ profiles: p, activeProfile: a }) => { + setProfiles(p) + setActiveProfile(a) + setSelectedSlug(a) + }).catch(() => {}) + api.config.getApiKeysStatus().then((status) => { + setApiKeys({ + ...(status.anthropic ? { anthropic: '(set)' } : {}), + ...(status.openai ? { openai: '(set)' } : {}), + ...(status.google ? { google: '(set)' } : {}), + }) + }).catch(() => {}) }, []) - const uiBackend: UIBackend = config ? detectUIBackend(config.aiProvider) : 'agent-sdk' - - const handleBackendSwitch = useCallback( - async (target: UIBackend) => { - if (!config) return - try { - const defaults = BACKEND_DEFAULTS[target] - const updated = { ...config.aiProvider, ...defaults } - await api.config.updateSection('aiProvider', updated) - setConfig((c) => c ? { ...c, aiProvider: updated } : c) - } catch { - // Button state reflects actual saved state - } - }, - [config], - ) + const handleSetActive = async (slug: string) => { + try { + await api.config.setActiveProfile(slug) + setActiveProfile(slug) + } catch { /* keep old state */ } + } + + const handleDelete = async (slug: string) => { + if (!profiles) return + try { + await api.config.deleteProfile(slug) + const updated = { ...profiles } + delete updated[slug] + setProfiles(updated) + if (selectedSlug === slug) setSelectedSlug(activeProfile) + } catch { /* keep old state */ } + } + + const handleCreateStart = (backend: AIBackend) => { + setCreating(backend) + setSelectedSlug(null) + } + + const handleCreateSave = async (slug: string, profile: Profile) => { + try { + await api.config.createProfile(slug, profile) + setProfiles((p) => p ? { ...p, [slug]: profile } : p) + setCreating(null) + setSelectedSlug(slug) + } catch { /* form handles error */ } + } + + const handleProfileUpdate = async (slug: string, profile: Profile) => { + try { + await api.config.updateProfile(slug, profile) + setProfiles((p) => p ? { ...p, [slug]: profile } : p) + } catch { /* form handles error */ } + } + + if (!profiles) return
+ + const selectedProfile = selectedSlug ? profiles[selectedSlug] : null return (
- - - {config ? ( +
-
- {/* Backend */} - -
- handleBackendSwitch('agent-sdk')} - icon={} - title="Claude" - description="Claude Code login or Anthropic API key" - /> - handleBackendSwitch('openai')} - icon={} - title="OpenAI" - description="GPT models via OpenAI API" - /> - handleBackendSwitch('vercel-ai-sdk')} - icon={} - title="Vercel AI SDK" - description="Multi-provider, custom endpoints" - /> -
+
+ + {/* Profile List */} + +
+ {Object.entries(profiles).map(([slug, profile]) => { + const info = BACKEND_INFO[profile.backend] + const isActive = slug === activeProfile + const isSelected = slug === selectedSlug + return ( + + ) + })} +
+ + {/* New Profile Button */} +
+ {(Object.keys(BACKEND_INFO) as AIBackend[]).map((backend) => ( + + ))} +
+
+ + {/* Create Form */} + {creating && ( + + setCreating(null)} + /> + )} - {/* Auth mode (only for Agent SDK) */} - {uiBackend === 'agent-sdk' && ( - - setConfig((c) => c ? { ...c, aiProvider: { ...c.aiProvider, ...patch } } : c)} /> - - )} + {/* Edit Form */} + {selectedProfile && selectedSlug && !creating && ( + + handleProfileUpdate(selectedSlug, p)} + onSetActive={() => handleSetActive(selectedSlug)} + onDelete={() => handleDelete(selectedSlug)} + /> + + )} - {/* OpenAI simplified form */} - {uiBackend === 'openai' && ( - - - - )} + {/* Global API Keys */} + + + - {/* Full model form (only for Vercel AI SDK) */} - {uiBackend === 'vercel-ai-sdk' && ( - - - - )} -
+
- ) : ( - - )}
) } -// ==================== OpenAI Form (simplified) ==================== - -function OpenAIForm({ aiProvider }: { aiProvider: AIProviderConfig }) { - const presets = PROVIDER_MODELS.openai - const initModel = aiProvider.provider === 'openai' && aiProvider.model ? aiProvider.model : presets[0].value - const isPreset = presets.some((p) => p.value === initModel) - - const [model, setModel] = useState(isPreset ? initModel : '') - const [customModel, setCustomModel] = useState(isPreset ? '' : initModel) - const [baseUrl, setBaseUrl] = useState(aiProvider.baseUrl || '') - const [apiKey, setApiKey] = useState('') - const [keySaveStatus, setKeySaveStatus] = useState('idle') - const savedTimer = useRef | null>(null) - - const effectiveModel = model || customModel - - // Auto-save model + baseUrl - const modelData = useMemo( - () => ({ - ...aiProvider, - provider: 'openai', - model: effectiveModel, - ...(baseUrl ? { baseUrl } : { baseUrl: undefined }), - }), - [aiProvider, effectiveModel, baseUrl], - ) - - const saveModel = useCallback(async (data: Record) => { - await api.config.updateSection('aiProvider', data) - }, []) - - const { status: modelStatus, retry: modelRetry } = useAutoSave({ - data: modelData, - save: saveModel, - }) +// ==================== Profile Form (Create) ==================== - const hasKey = !!aiProvider.apiKeys?.openai - - useEffect(() => () => { if (savedTimer.current) clearTimeout(savedTimer.current) }, []) - - const handleSaveKey = async () => { - if (!apiKey) return - setKeySaveStatus('saving') +function ProfileForm({ backend, onSave, onCancel }: { + backend: AIBackend + onSave: (slug: string, profile: Profile) => Promise + onCancel: () => void +}) { + const defaults = NEW_PROFILE_DEFAULTS[backend] + const [label, setLabel] = useState('') + const [model, setModel] = useState(defaults.model) + const [loginMethod, setLoginMethod] = useState(defaults.loginMethod ?? '') + const [provider, setProvider] = useState(defaults.provider ?? 'anthropic') + const [baseUrl, setBaseUrl] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + const handleSave = async () => { + if (!label.trim()) { setError('Label is required'); return } + setSaving(true) + setError('') + const slug = label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + if (!slug) { setError('Invalid label for slug generation'); setSaving(false); return } + const profile: Profile = { + backend, + label: label.trim(), + model, + ...(loginMethod ? { loginMethod } : {}), + ...(backend === 'vercel-ai-sdk' ? { provider } : {}), + ...(baseUrl ? { baseUrl } : {}), + } try { - const updatedKeys = { ...aiProvider.apiKeys, openai: apiKey } - await api.config.updateSection('aiProvider', { ...aiProvider, apiKeys: updatedKeys }) - setApiKey('') - setKeySaveStatus('saved') - if (savedTimer.current) clearTimeout(savedTimer.current) - savedTimer.current = setTimeout(() => setKeySaveStatus('idle'), 2000) - } catch { setKeySaveStatus('error') } - } - - const handleModelSelect = (value: string) => { - if (value === '__custom__') { - setModel('') - setCustomModel('') - } else { - setModel(value) - setCustomModel('') + await onSave(slug, profile) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save') + } finally { + setSaving(false) } } return ( - <> - - - - - {!model && ( - - setCustomModel(e.target.value)} - placeholder="e.g. gpt-4o, o3-pro" - /> - - )} - - -
- setApiKey(e.target.value)} - placeholder={hasKey ? '(configured)' : 'sk-...'} - /> - {hasKey && ( - active - )} -
-
- - -
-
- - - setBaseUrl(e.target.value)} - placeholder="https://api.openai.com/v1" - /> +
+ + setLabel(e.target.value)} placeholder="e.g. Claude Main, GPT Fast" /> - - - + + {error &&

{error}

} +
+ + +
+
) } -// ==================== Model Form (full Vercel AI SDK) ==================== - -function ModelForm({ aiProvider }: { aiProvider: AIProviderConfig }) { - // Detect whether saved config should render as "Custom" in the UI - const initCustom = detectCustomMode(aiProvider.provider || 'anthropic', aiProvider.model || '') - const [uiProvider, setUiProvider] = useState(initCustom ? 'custom' : (aiProvider.provider || 'anthropic')) - const [sdkProvider, setSdkProvider] = useState(aiProvider.provider || 'openai') - const [model, setModel] = useState(aiProvider.model || '') - const [customModel, setCustomModel] = useState(initCustom ? (aiProvider.model || '') : '') - const [baseUrl, setBaseUrl] = useState(aiProvider.baseUrl || '') - const [showKeys, setShowKeys] = useState(false) - const [keys, setKeys] = useState({ anthropic: '', openai: '', google: '' }) - const [keySaveStatus, setKeySaveStatus] = useState('idle') - const keySavedTimer = useRef | null>(null) - - const isCustomMode = uiProvider === 'custom' - const effectiveProvider = isCustomMode ? sdkProvider : uiProvider - const presets = PROVIDER_MODELS[uiProvider] || [] - const isCustomModelInStandard = !isCustomMode && model !== '' && !presets.some((p) => p.value === model) - const effectiveModel = isCustomMode - ? customModel - : (isCustomModelInStandard ? customModel || model : model) - - // Auto-save model/provider/baseUrl (but NOT apiKeys — those use manual save) - const modelData = useMemo( - () => ({ - ...aiProvider, - provider: effectiveProvider, - model: effectiveModel, - ...(baseUrl ? { baseUrl } : { baseUrl: undefined }), - }), - [aiProvider, effectiveProvider, effectiveModel, baseUrl], - ) - - const saveModel = useCallback(async (data: Record) => { - await api.config.updateSection('aiProvider', data) - }, []) +// ==================== Profile Editor (Edit existing) ==================== - const { status: modelStatus, retry: modelRetry } = useAutoSave({ - data: modelData, - save: saveModel, - }) - - // Derive key status from aiProvider config - const keyStatus = useMemo(() => ({ - anthropic: !!aiProvider.apiKeys?.anthropic, - openai: !!aiProvider.apiKeys?.openai, - google: !!aiProvider.apiKeys?.google, - }), [aiProvider.apiKeys]) - - const [liveKeyStatus, setLiveKeyStatus] = useState(keyStatus) - - useEffect(() => setLiveKeyStatus(keyStatus), [keyStatus]) +function ProfileEditor({ slug, profile, isActive, onUpdate, onSetActive, onDelete }: { + slug: string + profile: Profile + isActive: boolean + onUpdate: (profile: Profile) => Promise + onSetActive: () => void + onDelete: () => void +}) { + const [label, setLabel] = useState(profile.label) + const [model, setModel] = useState(profile.model) + const [loginMethod, setLoginMethod] = useState(profile.loginMethod ?? '') + const [provider, setProvider] = useState(profile.provider ?? 'anthropic') + const [baseUrl, setBaseUrl] = useState(profile.baseUrl ?? '') + const [status, setStatus] = useState('idle') + const savedTimer = useRef | null>(null) - useEffect(() => () => { - if (keySavedTimer.current) clearTimeout(keySavedTimer.current) - }, []) + // Reset form when selected profile changes + useEffect(() => { + setLabel(profile.label) + setModel(profile.model) + setLoginMethod(profile.loginMethod ?? '') + setProvider(profile.provider ?? 'anthropic') + setBaseUrl(profile.baseUrl ?? '') + setStatus('idle') + }, [slug, profile]) - const handleProviderChange = (newUiProvider: string) => { - setUiProvider(newUiProvider) - setBaseUrl('') - if (newUiProvider === 'custom') { - setSdkProvider('openai') - setModel('') - setCustomModel('') - } else { - setSdkProvider(newUiProvider) - const defaults = PROVIDER_MODELS[newUiProvider] - if (defaults?.length) { - setModel(defaults[0].value) - setCustomModel('') - } else { - setModel('') - } - } - } + useEffect(() => () => { if (savedTimer.current) clearTimeout(savedTimer.current) }, []) - const handleModelSelect = (value: string) => { - if (value === '__custom__') { - setModel('') - setCustomModel('') - } else { - setModel(value) - setCustomModel('') + const handleSave = async () => { + setStatus('saving') + const updated: Profile = { + backend: profile.backend, + label: label.trim() || profile.label, + model, + ...(loginMethod ? { loginMethod } : {}), + ...(profile.backend === 'vercel-ai-sdk' ? { provider } : {}), + ...(baseUrl ? { baseUrl } : {}), + ...(profile.apiKey ? { apiKey: profile.apiKey } : {}), } - } - - const handleSaveKeys = async () => { - setKeySaveStatus('saving') try { - // Merge new keys into current aiProvider config - const updatedKeys = { ...aiProvider.apiKeys } - if (keys.anthropic) updatedKeys.anthropic = keys.anthropic - if (keys.openai) updatedKeys.openai = keys.openai - if (keys.google) updatedKeys.google = keys.google - await api.config.updateSection('aiProvider', { ...aiProvider, apiKeys: updatedKeys }) - setLiveKeyStatus({ - anthropic: !!updatedKeys.anthropic, - openai: !!updatedKeys.openai, - google: !!updatedKeys.google, - }) - setKeys({ anthropic: '', openai: '', google: '' }) - setKeySaveStatus('saved') - if (keySavedTimer.current) clearTimeout(keySavedTimer.current) - keySavedTimer.current = setTimeout(() => setKeySaveStatus('idle'), 2000) + await onUpdate(updated) + setStatus('saved') + if (savedTimer.current) clearTimeout(savedTimer.current) + savedTimer.current = setTimeout(() => setStatus('idle'), 2000) } catch { - setKeySaveStatus('error') + setStatus('error') } } return ( - <> - -
- {PROVIDERS.map((p, i) => ( - - ))} -
+
+ + setLabel(e.target.value)} /> + +
+ + +
+ {!isActive && ( + + )} + {!isActive && ( + + )} +
+
+ ) +} - {/* Custom mode: API format selector */} - {isCustomMode && ( - - -

- Which API protocol does your endpoint speak? -

-
- )} +// ==================== Shared Profile Fields ==================== - {/* Standard mode: preset model dropdown */} - {!isCustomMode && ( - - setLoginMethod(e.target.value)}> + {backend === 'agent-sdk' ? ( + <> + + + + ) : ( + <> + + + + )} )} - {/* Free-text model ID — always shown in custom mode, or when "Custom..." selected in standard mode */} - {(isCustomMode || isCustomModelInStandard || (!isCustomMode && model === '')) && ( - - { setCustomModel(e.target.value); setModel(e.target.value) }} - placeholder={isCustomMode ? 'e.g. gpt-4o, claude-3-opus' : 'e.g. claude-sonnet-4-5-20250929'} - /> + {/* Provider (vercel-ai-sdk only) */} + {backend === 'vercel-ai-sdk' && ( + + )} - - setBaseUrl(e.target.value)} - placeholder={isCustomMode ? 'https://your-relay.example.com/v1' : 'Leave empty for official API'} - /> -

- {isCustomMode ? 'Your relay or proxy endpoint.' : 'Custom endpoint for proxy or relay.'} -

+ + setModel(e.target.value)} placeholder="e.g. claude-sonnet-4-6, gpt-5.4" /> - - - {/* API Keys */} -
- - - {showKeys && ( -
-

- {isCustomMode - ? 'Enter the API key for your relay. It will be sent under the matching provider header.' - : 'Enter API keys below. Leave empty to keep existing value.'} -

- {(isCustomMode - ? SDK_FORMATS.filter((f) => f.value === sdkProvider) - : PROVIDERS.filter((p) => p.value !== 'custom') - ).map((p) => ( - -
- setKeys((k) => ({ ...k, [p.value]: e.target.value }))} - placeholder={liveKeyStatus[p.value as keyof typeof liveKeyStatus] ? '(configured)' : 'Not configured'} - /> - {liveKeyStatus[p.value as keyof typeof liveKeyStatus] && ( - - active - - )} -
-
- ))} -
- - -
-
- )} -
+ + setBaseUrl(e.target.value)} placeholder="Leave empty for default" /> + ) } -// ==================== Agent SDK Auth Form ==================== +// ==================== Global API Keys ==================== -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') +function ApiKeysForm({ currentStatus, onSaved }: { + currentStatus: Record + onSaved: (status: Record) => void +}) { + const [keys, setKeys] = useState({ anthropic: '', openai: '', google: '' }) + const [status, setStatus] = 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') + const handleSave = async () => { + setStatus('saving') try { - const updatedKeys = { ...aiProvider.apiKeys, anthropic: apiKey } - await api.config.updateSection('aiProvider', { ...aiProvider, apiKeys: updatedKeys }) - onUpdate({ apiKeys: updatedKeys }) - setApiKey('') - setKeySaveStatus('saved') + const toSave: Record = {} + if (keys.anthropic) toSave.anthropic = keys.anthropic + if (keys.openai) toSave.openai = keys.openai + if (keys.google) toSave.google = keys.google + await api.config.updateApiKeys(toSave) + onSaved({ + ...currentStatus, + ...(keys.anthropic ? { anthropic: '(set)' } : {}), + ...(keys.openai ? { openai: '(set)' } : {}), + ...(keys.google ? { google: '(set)' } : {}), + }) + setKeys({ anthropic: '', openai: '', google: '' }) + setStatus('saved') if (savedTimer.current) clearTimeout(savedTimer.current) - savedTimer.current = setTimeout(() => setKeySaveStatus('idle'), 2000) - } catch { setKeySaveStatus('error') } + savedTimer.current = setTimeout(() => setStatus('idle'), 2000) + } catch { + setStatus('error') + } } + const fields = [ + { key: 'anthropic', label: 'Anthropic', placeholder: 'sk-ant-...' }, + { key: 'openai', label: 'OpenAI', placeholder: 'sk-...' }, + { key: 'google', label: 'Google', placeholder: 'AIza...' }, + ] as const + 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' && ( - + {fields.map((f) => ( +
setApiKey(e.target.value)} - placeholder={aiProvider.apiKeys?.anthropic ? '(configured)' : 'sk-ant-...'} + value={keys[f.key]} + onChange={(e) => setKeys((k) => ({ ...k, [f.key]: e.target.value }))} + placeholder={currentStatus[f.key] ? '(configured)' : f.placeholder} /> - {aiProvider.apiKeys?.anthropic && ( + {currentStatus[f.key] && ( active )}
-
- - -
- )} + ))} +
+ + +
) } diff --git a/ui/src/pages/SettingsPage.tsx b/ui/src/pages/SettingsPage.tsx index f3f43861..cea18ca1 100644 --- a/ui/src/pages/SettingsPage.tsx +++ b/ui/src/pages/SettingsPage.tsx @@ -48,6 +48,11 @@ export function SettingsPage() {
+ {/* Persona */} + + + + {/* Compaction */} @@ -91,3 +96,78 @@ function CompactionForm({ config }: { config: AppConfig }) { ) } +// ==================== Persona Editor ==================== + +function PersonaEditor() { + const [content, setContent] = useState('') + const [filePath, setFilePath] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + const [dirty, setDirty] = useState(false) + + useEffect(() => { + api.persona.get() + .then(({ content, path }) => { + setContent(content) + setFilePath(path) + }) + .catch(() => setError('Failed to load persona')) + .finally(() => setLoading(false)) + }, []) + + const handleSave = async () => { + setSaving(true) + setError(null) + setSaved(false) + try { + await api.persona.update(content) + setDirty(false) + setSaved(true) + setTimeout(() => setSaved(false), 2000) + } catch { + setError('Failed to save') + } finally { + setSaving(false) + } + } + + if (loading) return
Loading...
+ + return ( + <> +