diff --git a/src/ai-providers/agent-sdk/agent-sdk-provider.ts b/src/ai-providers/agent-sdk/agent-sdk-provider.ts index 723e9369..f9171aee 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, resolveProfile } from '../../core/config.js' +import { readAgentConfig, resolveProfile, type ResolvedProfile } from '../../core/config.js' import { createChannel } from '../../core/async-channel.js' import { askAgentSdk } from './query.js' import { buildAgentSdkMcpServer } from './tool-bridge.js' @@ -44,16 +44,17 @@ export class AgentSdkProvider implements AIProvider { return buildAgentSdkMcpServer(tools, disabledTools) } - async ask(prompt: string): Promise { + async ask(prompt: string, profile?: ResolvedProfile): Promise { const config = await this.resolveConfig() config.systemPrompt = await this.getSystemPrompt() - const profile = await resolveProfile() + const effectiveProfile = profile ?? await resolveProfile() const override: AgentSdkOverride = { - model: profile.model, apiKey: profile.apiKey, baseUrl: profile.baseUrl, - loginMethod: profile.loginMethod as 'api-key' | 'claudeai' | undefined, + model: effectiveProfile.model, apiKey: effectiveProfile.apiKey, baseUrl: effectiveProfile.baseUrl, + loginMethod: effectiveProfile.loginMethod as 'api-key' | 'claudeai' | undefined, } const mcpServer = await this.buildMcpServer() const result = await askAgentSdk(prompt, config, override, mcpServer) + if (!result.ok) throw new Error(result.text) return { text: result.text, media: [] } } diff --git a/src/ai-providers/agent-sdk/query.ts b/src/ai-providers/agent-sdk/query.ts index d4ce05d1..b96311c3 100644 --- a/src/ai-providers/agent-sdk/query.ts +++ b/src/ai-providers/agent-sdk/query.ts @@ -125,9 +125,12 @@ export async function askAgentSdk( if (isOAuthMode) { // Force OAuth by removing any inherited API key delete env.ANTHROPIC_API_KEY + delete env.CLAUDE_CODE_SIMPLE } else { const apiKey = override?.apiKey if (apiKey) env.ANTHROPIC_API_KEY = apiKey + // Force API key mode — disable OAuth even if local login exists + env.CLAUDE_CODE_SIMPLE = '1' } const baseUrl = override?.baseUrl if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl diff --git a/src/ai-providers/codex/__test__/codex.e2e.spec.ts b/src/ai-providers/codex/__test__/codex.e2e.spec.ts new file mode 100644 index 00000000..7caecb64 --- /dev/null +++ b/src/ai-providers/codex/__test__/codex.e2e.spec.ts @@ -0,0 +1,175 @@ +/** + * Codex provider E2E tests — verifies real API communication. + * + * Requires ~/.codex/auth.json (run `codex login` first). + * Skips gracefully if auth is not configured. + * + * Run: pnpm test:e2e + */ + +import { describe, it, expect, beforeAll, beforeEach } from 'vitest' +import OpenAI from 'openai' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { homedir } from 'node:os' + +// ==================== Setup ==================== + +const OAUTH_BASE_URL = 'https://chatgpt.com/backend-api/codex' +const MODEL = 'gpt-5.4-mini' // Use mini for faster/cheaper e2e + +let client: OpenAI | null = null + +async function tryLoadToken(): Promise { + const codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex') + try { + const raw = JSON.parse(await readFile(join(codexHome, 'auth.json'), 'utf-8')) + return raw?.tokens?.access_token ?? null + } catch { + return null + } +} + +beforeAll(async () => { + const token = await tryLoadToken() + if (!token) { + console.warn('codex e2e: ~/.codex/auth.json not found, skipping tests') + return + } + client = new OpenAI({ apiKey: token, baseURL: OAUTH_BASE_URL }) + console.log('codex e2e: client initialized') +}, 15_000) + +// ==================== Tests ==================== + +describe('Codex API — basic communication', () => { + beforeEach(({ skip }) => { if (!client) skip('no codex auth') }) + + it('receives a text response for a simple prompt', async () => { + const stream = client!.responses.stream({ + model: MODEL, + instructions: 'You are a helpful assistant. Be very brief.', + input: [{ role: 'user', content: 'What is 2+2? Answer with just the number.' }], + store: false, + }) + + let text = '' + for await (const event of stream) { + if (event.type === 'response.output_text.delta') text += event.delta + } + + expect(text).toBeTruthy() + expect(text).toContain('4') + }, 30_000) +}) + +describe('Codex API — tool call round-trip', () => { + beforeEach(({ skip }) => { if (!client) skip('no codex auth') }) + + const tools: OpenAI.Responses.Tool[] = [{ + type: 'function', + name: 'get_price', + description: 'Get the current price of a stock by symbol', + parameters: { + type: 'object', + properties: { symbol: { type: 'string', description: 'Stock ticker symbol' } }, + required: ['symbol'], + }, + strict: null, + }] + + it('receives a function call with call_id, name, and arguments', async () => { + const stream = client!.responses.stream({ + model: MODEL, + instructions: 'You are a stock assistant. Always use the get_price tool when asked about prices.', + input: [{ role: 'user', content: 'What is the price of AAPL?' }], + tools, + store: false, + }) + + let funcCall: { call_id: string; name: string; arguments: string } | null = null + for await (const event of stream) { + if (event.type === 'response.output_item.done') { + const item = (event as any).item + if (item?.type === 'function_call') { + funcCall = { call_id: item.call_id, name: item.name, arguments: item.arguments } + } + } + } + + expect(funcCall).not.toBeNull() + expect(funcCall!.call_id).toBeTruthy() + expect(funcCall!.name).toBe('get_price') + const args = JSON.parse(funcCall!.arguments) + expect(args.symbol).toMatch(/AAPL/i) + }, 30_000) + + it('completes a full tool call round-trip', async () => { + // Round 1: get function call + const stream1 = client!.responses.stream({ + model: MODEL, + instructions: 'You are a stock assistant. Always use the get_price tool.', + input: [{ role: 'user', content: 'Price of MSFT?' }], + tools, + store: false, + }) + + let funcCall: { call_id: string; name: string; arguments: string } | null = null + for await (const event of stream1) { + if (event.type === 'response.output_item.done') { + const item = (event as any).item + if (item?.type === 'function_call') { + funcCall = { call_id: item.call_id, name: item.name, arguments: item.arguments } + } + } + } + + expect(funcCall).not.toBeNull() + + // Round 2: send tool result back, get final text + const stream2 = client!.responses.stream({ + model: MODEL, + instructions: 'You are a stock assistant.', + input: [ + { role: 'user', content: 'Price of MSFT?' }, + { type: 'function_call', call_id: funcCall!.call_id, name: funcCall!.name, arguments: funcCall!.arguments } as any, + { type: 'function_call_output', call_id: funcCall!.call_id, output: '{"price": 420.50, "currency": "USD"}' } as any, + ], + tools, + store: false, + }) + + let responseText = '' + for await (const event of stream2) { + if (event.type === 'response.output_text.delta') responseText += event.delta + } + + expect(responseText).toBeTruthy() + expect(responseText).toMatch(/420/i) + }, 30_000) +}) + +describe('Codex API — structured multi-turn input', () => { + beforeEach(({ skip }) => { if (!client) skip('no codex auth') }) + + it('references earlier conversation context', async () => { + const stream = client!.responses.stream({ + model: MODEL, + instructions: 'You are a helpful assistant. Be very brief.', + input: [ + { role: 'user', content: 'My name is Alice.' }, + { role: 'assistant', content: 'Nice to meet you, Alice!' }, + { role: 'user', content: 'What is my name?' }, + ], + store: false, + }) + + let text = '' + for await (const event of stream) { + if (event.type === 'response.output_text.delta') text += event.delta + } + + expect(text).toBeTruthy() + expect(text.toLowerCase()).toContain('alice') + }, 30_000) +}) diff --git a/src/ai-providers/codex/codex-provider.ts b/src/ai-providers/codex/codex-provider.ts index 3cacde5f..9d876eb9 100644 --- a/src/ai-providers/codex/codex-provider.ts +++ b/src/ai-providers/codex/codex-provider.ts @@ -64,24 +64,23 @@ export class CodexProvider implements AIProvider { return { client: new OpenAI({ apiKey: token, baseURL }), model } } - async ask(prompt: string): Promise { - const { client, model } = await this.createClient() + async ask(prompt: string, profile?: ResolvedProfile): Promise { + const { client, model } = await this.createClient(profile) const instructions = await this.getSystemPrompt() try { - const response = await client.responses.create({ + // Use streaming — the ChatGPT subscription endpoint may not support non-streaming + const stream = client.responses.stream({ 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('') + let text = '' + for await (const event of stream) { + if (event.type === 'response.output_text.delta') text += event.delta + } return { text: text || '(no output)', media: [] } } catch (err) { diff --git a/src/ai-providers/mock/index.ts b/src/ai-providers/mock/index.ts index 38c59c33..fb33dab9 100644 --- a/src/ai-providers/mock/index.ts +++ b/src/ai-providers/mock/index.ts @@ -54,7 +54,7 @@ export class MockAIProvider implements AIProvider { this._askResult = opts?.askResult ?? 'mock-ask-result' } - async ask(prompt: string): Promise { + async ask(prompt: string, _profile?: unknown): Promise { this.askCalls.push(prompt) return { text: this._askResult, media: [] } } diff --git a/src/ai-providers/preset-catalog.ts b/src/ai-providers/preset-catalog.ts new file mode 100644 index 00000000..d4e6cd7a --- /dev/null +++ b/src/ai-providers/preset-catalog.ts @@ -0,0 +1,186 @@ +/** + * AI Provider Preset Catalog — Zod-defined preset declarations. + * + * This file is the single source of truth for all preset definitions. + * To add a new provider or update model versions, edit only this file. + * + * Each preset declares: + * - Metadata (id, label, description, category, hint, defaultName) + * - A Zod schema defining the profile fields and their constraints + * - A model catalog with human-readable labels + * - Fields that should render as password inputs (writeOnly) + */ + +import { z } from 'zod' + +// ==================== Types ==================== + +export interface ModelOption { + id: string + label: string +} + +export interface PresetDef { + id: string + label: string + description: string + category: 'official' | 'third-party' | 'custom' + hint?: string + defaultName: string + zodSchema: z.ZodType + models?: ModelOption[] + writeOnlyFields?: string[] +} + +// ==================== Official: Claude ==================== + +export const CLAUDE_OAUTH: PresetDef = { + id: 'claude-oauth', + label: 'Claude (Subscription)', + description: 'Use your Claude Pro/Max subscription', + category: 'official', + defaultName: 'Claude (Pro/Max)', + hint: 'Requires Claude Code CLI login. Run `claude login` in your terminal first.', + zodSchema: z.object({ + backend: z.literal('agent-sdk'), + loginMethod: z.literal('claudeai'), + model: z.string().default('claude-sonnet-4-6').describe('Model'), + }), + models: [ + { id: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, + { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }, + ], +} + +export const CLAUDE_API: PresetDef = { + id: 'claude-api', + label: 'Claude (API Key)', + description: 'Pay per token via Anthropic API', + category: 'official', + defaultName: 'Claude (API Key)', + zodSchema: z.object({ + backend: z.literal('agent-sdk'), + loginMethod: z.literal('api-key'), + model: z.string().default('claude-sonnet-4-6').describe('Model'), + apiKey: z.string().min(1).describe('Anthropic API key'), + }), + models: [ + { id: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, + { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }, + { id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5' }, + ], + writeOnlyFields: ['apiKey'], +} + +// ==================== Official: OpenAI Codex ==================== + +export const CODEX_OAUTH: PresetDef = { + id: 'codex-oauth', + label: 'OpenAI Codex (Subscription)', + description: 'Use your ChatGPT subscription', + category: 'official', + defaultName: 'OpenAI Codex (Subscription)', + hint: 'Requires Codex CLI login. Run `codex login` in your terminal first.', + zodSchema: z.object({ + backend: z.literal('codex'), + loginMethod: z.literal('codex-oauth'), + model: z.string().default('gpt-5.4').describe('Model'), + }), + models: [ + { id: 'gpt-5.4', label: 'GPT 5.4' }, + { id: 'gpt-5.4-mini', label: 'GPT 5.4 Mini' }, + ], +} + +export const CODEX_API: PresetDef = { + id: 'codex-api', + label: 'OpenAI (API Key)', + description: 'Pay per token via OpenAI API', + category: 'official', + defaultName: 'OpenAI (API Key)', + zodSchema: z.object({ + backend: z.literal('codex'), + loginMethod: z.literal('api-key'), + model: z.string().default('gpt-5.4').describe('Model'), + apiKey: z.string().min(1).describe('OpenAI API key'), + }), + models: [ + { id: 'gpt-5.4', label: 'GPT 5.4' }, + { id: 'gpt-5.4-mini', label: 'GPT 5.4 Mini' }, + ], + writeOnlyFields: ['apiKey'], +} + +// ==================== Official: Gemini ==================== + +export const GEMINI: PresetDef = { + id: 'gemini', + label: 'Google Gemini', + description: 'Google AI via API key', + category: 'official', + defaultName: 'Google Gemini', + zodSchema: z.object({ + backend: z.literal('vercel-ai-sdk'), + provider: z.literal('google'), + model: z.string().default('gemini-2.5-flash').describe('Model'), + apiKey: z.string().min(1).describe('Google AI API key'), + }), + models: [ + { id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, + { id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + ], + writeOnlyFields: ['apiKey'], +} + +// ==================== Third-party: MiniMax ==================== + +export const MINIMAX: PresetDef = { + id: 'minimax', + label: 'MiniMax', + description: 'MiniMax models via Claude Agent SDK (Anthropic-compatible)', + category: 'third-party', + defaultName: 'MiniMax', + hint: 'Get your API key at minimaxi.com', + zodSchema: z.object({ + backend: z.literal('agent-sdk'), + loginMethod: z.literal('api-key'), + baseUrl: z.literal('https://api.minimaxi.com/anthropic').describe('MiniMax API endpoint'), + model: z.string().default('MiniMax-M2.7').describe('Model'), + apiKey: z.string().min(1).describe('MiniMax API key'), + }), + models: [ + { id: 'MiniMax-M2.7', label: 'MiniMax M2.7' }, + ], + writeOnlyFields: ['apiKey'], +} + +// ==================== Custom ==================== + +export const CUSTOM: PresetDef = { + id: 'custom', + label: 'Custom', + description: 'Full control — any provider, model, and endpoint', + category: 'custom', + defaultName: '', + zodSchema: z.object({ + backend: z.enum(['agent-sdk', 'codex', 'vercel-ai-sdk']).default('vercel-ai-sdk').describe('Backend engine'), + provider: z.string().optional().default('openai').describe('SDK provider (for Vercel AI SDK)'), + loginMethod: z.string().optional().default('api-key').describe('Authentication method'), + model: z.string().describe('Model ID'), + baseUrl: z.string().optional().describe('Custom API endpoint (leave empty for official)'), + apiKey: z.string().optional().describe('API key'), + }), + writeOnlyFields: ['apiKey'], +} + +// ==================== All presets (ordered) ==================== + +export const PRESET_CATALOG: PresetDef[] = [ + CLAUDE_OAUTH, + CLAUDE_API, + CODEX_OAUTH, + CODEX_API, + GEMINI, + MINIMAX, + CUSTOM, +] diff --git a/src/ai-providers/presets.ts b/src/ai-providers/presets.ts new file mode 100644 index 00000000..ca106966 --- /dev/null +++ b/src/ai-providers/presets.ts @@ -0,0 +1,60 @@ +/** + * AI Provider Presets — serialization layer. + * + * Reads preset definitions from preset-catalog.ts and converts + * their Zod schemas to JSON Schema for the frontend. + * + * Post-processing: + * - Model fields: enum → oneOf + const + title (labeled dropdowns) + * - API key fields: marked writeOnly (password inputs) + */ + +import { z } from 'zod' +import { PRESET_CATALOG, type PresetDef } from './preset-catalog.js' + +// ==================== Serialized Preset (sent to frontend) ==================== + +export interface SerializedPreset { + id: string + label: string + description: string + category: 'official' | 'third-party' | 'custom' + hint?: string + defaultName: string + schema: Record +} + +// ==================== Schema post-processing ==================== + +function buildJsonSchema(def: PresetDef): Record { + const raw = z.toJSONSchema(def.zodSchema) as Record + const props = (raw.properties ?? {}) as Record> + + // Replace model enum with oneOf (labeled options) + const mf = 'model' + if (def.models?.length && props[mf]) { + const oneOf = def.models.map(m => ({ const: m.id, title: m.label })) + const { enum: _e, ...rest } = props[mf] + props[mf] = { ...rest, oneOf } + } + + // Mark writeOnly fields + for (const field of def.writeOnlyFields ?? []) { + if (props[field]) props[field].writeOnly = true + } + + raw.properties = props + return raw +} + +// ==================== Exported ==================== + +export const BUILTIN_PRESETS: SerializedPreset[] = PRESET_CATALOG.map(def => ({ + id: def.id, + label: def.label, + description: def.description, + category: def.category, + hint: def.hint, + defaultName: def.defaultName, + schema: buildJsonSchema(def), +})) diff --git a/src/ai-providers/types.ts b/src/ai-providers/types.ts index 873c92ee..fcd5c730 100644 --- a/src/ai-providers/types.ts +++ b/src/ai-providers/types.ts @@ -46,8 +46,8 @@ export interface GenerateOpts { export interface AIProvider { /** Session log provenance tag. */ readonly providerTag: 'vercel-ai' | 'claude-code' | 'agent-sdk' | 'codex' - /** Stateless one-shot prompt (used for compaction summarization, etc.). */ - ask(prompt: string): Promise + /** Stateless one-shot prompt. Profile controls auth/model/endpoint. */ + ask(prompt: string, profile?: ResolvedProfile): Promise /** Stream events from the backend. Yields tool_use/tool_result/text, then done. */ generate(entries: SessionEntry[], prompt: string, opts?: GenerateOpts): AsyncIterable /** diff --git a/src/ai-providers/vercel-ai-sdk/vercel-provider.ts b/src/ai-providers/vercel-ai-sdk/vercel-provider.ts index b09da3be..e23a79ca 100644 --- a/src/ai-providers/vercel-ai-sdk/vercel-provider.ts +++ b/src/ai-providers/vercel-ai-sdk/vercel-provider.ts @@ -43,8 +43,8 @@ export class VercelAIProvider implements AIProvider { return { model, tools, instructions } } - async ask(prompt: string): Promise { - const { model, tools, instructions } = await this.resolve() + async ask(prompt: string, profile?: ResolvedProfile): Promise { + const { model, tools, instructions } = await this.resolve(undefined, profile) const media: MediaAttachment[] = [] const result = await generateText({ diff --git a/src/connectors/telegram/telegram-plugin.ts b/src/connectors/telegram/telegram-plugin.ts index 1293c675..60ae65c2 100644 --- a/src/connectors/telegram/telegram-plugin.ts +++ b/src/connectors/telegram/telegram-plugin.ts @@ -18,8 +18,8 @@ import type { Operation } from '../../domain/trading/git/types.js' import { getOperationSymbol } from '../../domain/trading/git/types.js' /** Build a display label for a profile. */ -function profileLabel(slug: string, profile: { label: string; backend: string; model: string }): string { - return `${profile.label} (${profile.model})` +function profileLabel(name: string, profile: { model: string }): string { + return `${name} (${profile.model})` } export class TelegramPlugin implements Plugin { @@ -125,9 +125,9 @@ export class TelegramPlugin implements Plugin { // Edit the original settings message in-place const keyboard = new InlineKeyboard() - for (const [s, p] of Object.entries(config.profiles)) { + for (const s of Object.keys(config.profiles)) { const prefix = s === slug ? '> ' : '' - keyboard.text(`${prefix}${p.label}`, `profile:${s}`) + keyboard.text(`${prefix}${s}`, `profile:${s}`) } await ctx.editMessageText( `Current profile: ${label}\n\nChoose AI profile:`, @@ -343,9 +343,9 @@ export class TelegramPlugin implements Plugin { const activeLabel = activeProfile ? profileLabel(config.activeProfile, activeProfile) : config.activeProfile const keyboard = new InlineKeyboard() - for (const [slug, profile] of Object.entries(config.profiles)) { + for (const slug of Object.keys(config.profiles)) { const prefix = slug === config.activeProfile ? '> ' : '' - keyboard.text(`${prefix}${profile.label}`, `profile:${slug}`) + keyboard.text(`${prefix}${slug}`, `profile:${slug}`) } await this.bot!.api.sendMessage( diff --git a/src/connectors/web/routes/config.ts b/src/connectors/web/routes/config.ts index b221b685..b904433b 100644 --- a/src/connectors/web/routes/config.ts +++ b/src/connectors/web/routes/config.ts @@ -1,16 +1,18 @@ import { Hono } from 'hono' import { loadConfig, writeConfigSection, readAIProviderConfig, validSections, - writeProfile, deleteProfile, setActiveProfile, writeApiKeys, + writeProfile, deleteProfile, setActiveProfile, profileSchema, type ConfigSection, type Profile, } from '../../../core/config.js' import type { EngineContext } from '../../../core/types.js' +import { BUILTIN_PRESETS } from '../../../ai-providers/presets.js' interface ConfigRouteOpts { + ctx?: EngineContext onConnectorsChange?: () => Promise } -/** Config routes: GET /, PUT /:section, profile CRUD, api-keys */ +/** Config routes: GET /, PUT /:section, profile CRUD, presets, test */ export function createConfigRoutes(opts?: ConfigRouteOpts) { const app = new Hono() @@ -39,8 +41,8 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) { app.post('/profiles', async (c) => { try { 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) + if (!body.slug?.trim()) { + return c.json({ error: 'Profile name is required' }, 400) } const config = await readAIProviderConfig() if (config.profiles[body.slug]) { @@ -95,29 +97,23 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) { } }) - // ==================== API Keys ==================== + // ==================== Presets ==================== - /** 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) - } - }) + /** GET /presets — built-in preset templates for profile creation */ + app.get('/presets', (c) => c.json({ presets: BUILTIN_PRESETS })) + + // ==================== Profile Test ==================== - app.get('/api-keys/status', async (c) => { + /** POST /profiles/test — test profile config by sending "Hi" (without saving) */ + app.post('/profiles/test', async (c) => { + if (!opts?.ctx) return c.json({ ok: false, error: 'Test not available' }, 500) try { - const config = await readAIProviderConfig() - return c.json({ - anthropic: !!config.apiKeys.anthropic, - openai: !!config.apiKeys.openai, - google: !!config.apiKeys.google, - }) + const profileData = await c.req.json() + const validated = profileSchema.parse(profileData) + const result = await opts.ctx.agentCenter.testWithProfile(validated, 'Hi') + return c.json({ ok: true, response: result.text }) } catch (err) { - return c.json({ error: String(err) }, 500) + return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) diff --git a/src/connectors/web/web-plugin.ts b/src/connectors/web/web-plugin.ts index 0243422e..8d38b3fb 100644 --- a/src/connectors/web/web-plugin.ts +++ b/src/connectors/web/web-plugin.ts @@ -73,6 +73,7 @@ export class WebPlugin implements Plugin { app.route('/api/channels', createChannelsRoutes({ sessions, sseByChannel: this.sseByChannel })) app.route('/api/media', createMediaRoutes()) app.route('/api/config', createConfigRoutes({ + ctx, onConnectorsChange: async () => { await ctx.reconnectConnectors() }, })) app.route('/api/market-data', createMarketDataRoutes(ctx)) diff --git a/src/core/agent-center.ts b/src/core/agent-center.ts index bd2830f3..081b6d8b 100644 --- a/src/core/agent-center.ts +++ b/src/core/agent-center.ts @@ -13,6 +13,7 @@ */ import type { AskOptions, ProviderResult, ProviderEvent, GenerateOpts } from './ai-provider-manager.js' +import type { ResolvedProfile } from './config.js' import { GenerateRouter, StreamableResult } from './ai-provider-manager.js' import type { ISessionStore, ContentBlock } from './session.js' import type { CompactionConfig } from './compaction.js' @@ -58,6 +59,16 @@ export class AgentCenter { return this.router.ask(prompt) } + /** Test a saved profile by sending a prompt to its provider. */ + async testProfile(profileSlug: string, prompt = 'Hi'): Promise { + return this.router.askWithProfileSlug(prompt, profileSlug) + } + + /** Test an unsaved profile (inline data). Used for pre-save connection testing. */ + async testWithProfile(profile: ResolvedProfile, prompt = 'Hi'): Promise { + return this.router.askWithProfile(prompt, profile) + } + /** Prompt with session history — full orchestration pipeline. */ askWithSession(prompt: string, session: ISessionStore, opts?: AskOptions): StreamableResult { return new StreamableResult(this._generate(prompt, session, opts)) diff --git a/src/core/ai-provider-manager.spec.ts b/src/core/ai-provider-manager.spec.ts index ed665a4b..46f8cc0c 100644 --- a/src/core/ai-provider-manager.spec.ts +++ b/src/core/ai-provider-manager.spec.ts @@ -150,7 +150,7 @@ describe('GenerateRouter', () => { const agentSdk = makeProvider('agent-sdk') const router = new GenerateRouter(vercel, agentSdk) - mockResolveProfile.mockResolvedValue({ backend: 'agent-sdk', label: 'Claude', model: 'claude-sonnet-4-6' }) + mockResolveProfile.mockResolvedValue({ backend: 'agent-sdk', model: 'claude-sonnet-4-6' }) const { provider } = await router.resolve('claude-main') expect(provider).toBe(agentSdk) }) @@ -159,7 +159,7 @@ describe('GenerateRouter', () => { const vercel = makeProvider('vercel-ai') const router = new GenerateRouter(vercel, null) - mockResolveProfile.mockResolvedValue({ backend: 'vercel-ai-sdk', label: 'Vercel', model: 'claude-sonnet-4-6', provider: 'anthropic' }) + mockResolveProfile.mockResolvedValue({ backend: 'vercel-ai-sdk', model: 'claude-sonnet-4-6', provider: 'anthropic' }) const { provider } = await router.resolve() expect(provider).toBe(vercel) }) @@ -168,7 +168,7 @@ describe('GenerateRouter', () => { const vercel = makeProvider('vercel-ai') const router = new GenerateRouter(vercel, null) // no agent-sdk - mockResolveProfile.mockResolvedValue({ backend: 'agent-sdk', label: 'Claude', model: 'x' }) + mockResolveProfile.mockResolvedValue({ backend: 'agent-sdk', model: 'x' }) await expect(router.resolve('test')).rejects.toThrow('No provider registered for backend') }) @@ -177,7 +177,7 @@ describe('GenerateRouter', () => { const codex = makeProvider('codex') const router = new GenerateRouter(vercel, null, codex) - mockResolveProfile.mockResolvedValue({ backend: 'codex', label: 'GPT', model: 'gpt-5.4' }) + mockResolveProfile.mockResolvedValue({ backend: 'codex', model: 'gpt-5.4' }) const { provider, profile } = await router.resolve('gpt-main') expect(provider).toBe(codex) expect(profile.model).toBe('gpt-5.4') @@ -187,9 +187,9 @@ describe('GenerateRouter', () => { const vercel = makeProvider('vercel-ai') const router = new GenerateRouter(vercel, null) - mockResolveProfile.mockResolvedValue({ backend: 'vercel-ai-sdk', label: 'V', model: 'x', provider: 'anthropic' }) + mockResolveProfile.mockResolvedValue({ backend: 'vercel-ai-sdk', model: 'x', provider: 'anthropic' }) const result = await router.ask('test prompt') expect(result.text).toBe('from-vercel-ai') - expect(vercel.ask).toHaveBeenCalledWith('test prompt') + expect(vercel.ask).toHaveBeenCalledWith('test prompt', expect.objectContaining({ backend: 'vercel-ai-sdk' })) }) }) diff --git a/src/core/ai-provider-manager.ts b/src/core/ai-provider-manager.ts index b37f14fd..85908033 100644 --- a/src/core/ai-provider-manager.ts +++ b/src/core/ai-provider-manager.ts @@ -120,7 +120,20 @@ export class GenerateRouter { /** Stateless ask — delegates to the active profile's provider. */ async ask(prompt: string): Promise { - const { provider } = await this.resolve() - return provider.ask(prompt) + const { provider, profile } = await this.resolve() + return provider.ask(prompt, profile) + } + + /** Ask with a specific profile (by slug). Used for connection testing. */ + async askWithProfileSlug(prompt: string, profileSlug: string): Promise { + const { provider, profile } = await this.resolve(profileSlug) + return provider.ask(prompt, profile) + } + + /** Ask with an inline profile (not saved to config). Used for pre-save testing. */ + async askWithProfile(prompt: string, profile: ResolvedProfile): Promise { + const provider = this.providers[profile.backend] + if (!provider) throw new Error(`No provider registered for backend: ${profile.backend}`) + return provider.ask(prompt, profile) } } diff --git a/src/core/config.ts b/src/core/config.ts index 9dbb477a..4dcc8255 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -42,7 +42,8 @@ const apiKeysSchema = z.object({ }) const baseProfileFields = { - label: z.string().min(1), + /** Preset ID this profile was created from (for constraint enforcement on edit). */ + preset: z.string().optional(), baseUrl: z.string().optional(), apiKey: z.string().optional(), } @@ -80,7 +81,7 @@ export const aiProviderSchema = z.object({ z.string(), profileSchema, ).default({ - default: { backend: 'agent-sdk', label: 'Claude Sonnet', model: 'claude-sonnet-4-6', loginMethod: 'claudeai' }, + default: { backend: 'agent-sdk', model: 'claude-sonnet-4-6', loginMethod: 'claudeai' }, }), activeProfile: z.string().default('default'), }) @@ -422,6 +423,33 @@ export async function loadConfig(): Promise { await removeJsonFile('api-keys.json') } + // ---------- Migration: distribute global apiKeys into profiles ---------- + const aiConfigAfterMigration = raws[6] as Record | undefined + if (aiConfigAfterMigration && 'apiKeys' in aiConfigAfterMigration && 'profiles' in aiConfigAfterMigration) { + const keys = aiConfigAfterMigration.apiKeys as Record | undefined + const profiles = aiConfigAfterMigration.profiles as Record> + if (keys && Object.values(keys).some(Boolean)) { + let changed = false + for (const profile of Object.values(profiles)) { + if (profile.apiKey) continue // already has a key, don't overwrite + const vendor = profile.backend === 'codex' ? 'openai' + : profile.backend === 'agent-sdk' ? 'anthropic' + : (profile.provider as string) ?? 'anthropic' + const globalKey = keys[vendor] + if (globalKey) { + profile.apiKey = globalKey + changed = true + } + } + if (changed) { + delete aiConfigAfterMigration.apiKeys + raws[6] = aiConfigAfterMigration + await mkdir(CONFIG_DIR, { recursive: true }) + await writeFile(resolve(CONFIG_DIR, 'ai-provider-manager.json'), JSON.stringify(aiConfigAfterMigration, null, 2) + '\n') + } + } + } + // ---------- Migration: consolidate old telegram.json + engine port fields ---------- const connectorsRaw = raws[9] as Record | undefined if (connectorsRaw === undefined) { @@ -556,30 +584,24 @@ export async function readConnectorsConfig() { // ==================== Profile Helpers ==================== -/** Resolved profile with apiKey filled from global keys. */ +/** Resolved profile — all fields needed by providers. */ export interface ResolvedProfile { backend: AIBackend - label: string model: string + preset?: string apiKey?: string baseUrl?: string loginMethod?: string provider?: string } -/** Resolve a profile by slug, filling in global apiKey fallback. */ +/** Resolve a profile by slug. API key comes from the profile directly. */ 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], - } + return { ...profile } } /** Get the active profile slug. */ @@ -614,14 +636,6 @@ export async function deleteProfile(slug: string): Promise { 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/domain/market-data/client/openbb-api/currency-client.ts b/src/domain/market-data/client/openbb-api/currency-client.ts index 10c8bedf..8e3202a2 100644 --- a/src/domain/market-data/client/openbb-api/currency-client.ts +++ b/src/domain/market-data/client/openbb-api/currency-client.ts @@ -7,7 +7,7 @@ import type { OBBjectResponse } from '../../currency/types/index' import { buildCredentialsHeader } from '../../credential-map' -import type { CurrencyHistoricalData } from '@traderalice/opentypebb' +import type { CurrencyHistoricalData, CurrencySnapshotsData } from '@traderalice/opentypebb' export class OpenBBCurrencyClient { private baseUrl: string @@ -41,7 +41,7 @@ export class OpenBBCurrencyClient { // ==================== Snapshots ==================== async getSnapshots(params: Record) { - return this.request('/snapshots', params) + return this.request('/snapshots', params) } // ==================== Internal ==================== diff --git a/src/domain/market-data/client/types.ts b/src/domain/market-data/client/types.ts index b573d9c9..93a6506a 100644 --- a/src/domain/market-data/client/types.ts +++ b/src/domain/market-data/client/types.ts @@ -54,6 +54,7 @@ export interface CryptoClientLike { export interface CurrencyClientLike { search(params: Record): Promise[]> getHistorical(params: Record): Promise + getSnapshots(params: Record): Promise } export interface EtfClientLike { diff --git a/src/domain/trading/account-manager.ts b/src/domain/trading/account-manager.ts index b7b6b737..2db79fea 100644 --- a/src/domain/trading/account-manager.ts +++ b/src/domain/trading/account-manager.ts @@ -16,6 +16,7 @@ import { readAccountsConfig, type AccountConfig } from '../../core/config.js' import type { EventLog } from '../../core/event-log.js' import type { ToolCenter } from '../../core/tool-center.js' import type { ReconnectResult } from '../../core/types.js' +import type { FxService } from './fx-service.js' import './contract-ext.js' // ==================== Account summary ==================== @@ -34,9 +35,12 @@ export interface AggregatedEquity { totalCash: number totalUnrealizedPnL: number totalRealizedPnL: number + /** Present when one or more accounts used fallback FX rates. */ + fxWarnings?: string[] accounts: Array<{ id: string label: string + baseCurrency: string equity: number cash: number unrealizedPnL: number @@ -65,16 +69,22 @@ export class AccountManager { private eventLog?: EventLog private toolCenter?: ToolCenter private _snapshotHooks?: SnapshotHooks + private fxService?: FxService - constructor(deps?: { eventLog: EventLog; toolCenter: ToolCenter }) { + constructor(deps?: { eventLog: EventLog; toolCenter: ToolCenter; fxService?: FxService }) { this.eventLog = deps?.eventLog this.toolCenter = deps?.toolCenter + this.fxService = deps?.fxService } setSnapshotHooks(hooks: SnapshotHooks): void { this._snapshotHooks = hooks } + setFxService(fx: FxService): void { + this.fxService = fx + } + // ==================== Lifecycle ==================== /** Create a UTA from account config, register it, and start async broker connection. */ @@ -237,26 +247,46 @@ export class AccountManager { let totalCash = 0 let totalUnrealizedPnL = 0 let totalRealizedPnL = 0 + const fxWarnings: string[] = [] const accounts: AggregatedEquity['accounts'] = [] for (const { id, label, health, info } of results) { + const baseCurrency = info?.baseCurrency ?? 'USD' if (info) { - totalEquity += info.netLiquidation - totalCash += info.totalCashValue - totalUnrealizedPnL += info.unrealizedPnL - totalRealizedPnL += info.realizedPnL ?? 0 + if (this.fxService && baseCurrency !== 'USD') { + // Convert non-USD account values to USD + const [eqR, cashR, pnlR, rpnlR] = await Promise.all([ + this.fxService.convertToUsd(info.netLiquidation, baseCurrency), + this.fxService.convertToUsd(info.totalCashValue, baseCurrency), + this.fxService.convertToUsd(info.unrealizedPnL, baseCurrency), + this.fxService.convertToUsd(info.realizedPnL ?? 0, baseCurrency), + ]) + totalEquity += eqR.usd + totalCash += cashR.usd + totalUnrealizedPnL += pnlR.usd + totalRealizedPnL += rpnlR.usd + // Collect warnings (deduplicate — same currency produces same warning) + const w = eqR.fxWarning + if (w && !fxWarnings.includes(w)) fxWarnings.push(w) + accounts.push({ id, label, baseCurrency, equity: eqR.usd, cash: cashR.usd, unrealizedPnL: pnlR.usd, health }) + } else { + // Already USD or no FxService — pass through + totalEquity += info.netLiquidation + totalCash += info.totalCashValue + totalUnrealizedPnL += info.unrealizedPnL + totalRealizedPnL += info.realizedPnL ?? 0 + accounts.push({ id, label, baseCurrency, equity: info.netLiquidation, cash: info.totalCashValue, unrealizedPnL: info.unrealizedPnL, health }) + } + } else { + accounts.push({ id, label, baseCurrency, equity: 0, cash: 0, unrealizedPnL: 0, health }) } - accounts.push({ - id, - label, - equity: info?.netLiquidation ?? 0, - cash: info?.totalCashValue ?? 0, - unrealizedPnL: info?.unrealizedPnL ?? 0, - health, - }) } - return { totalEquity, totalCash, totalUnrealizedPnL, totalRealizedPnL, accounts } + return { + totalEquity, totalCash, totalUnrealizedPnL, totalRealizedPnL, + fxWarnings: fxWarnings.length > 0 ? fxWarnings : undefined, + accounts, + } } // ==================== Cross-account contract search ==================== diff --git a/src/domain/trading/brokers/alpaca/AlpacaBroker.ts b/src/domain/trading/brokers/alpaca/AlpacaBroker.ts index ec03d056..e3e5445c 100644 --- a/src/domain/trading/brokers/alpaca/AlpacaBroker.ts +++ b/src/domain/trading/brokers/alpaca/AlpacaBroker.ts @@ -319,6 +319,7 @@ export class AlpacaBroker implements IBroker { ).toNumber() return { + baseCurrency: 'USD', netLiquidation: parseFloat(account.equity), totalCashValue: parseFloat(account.cash), unrealizedPnL, @@ -336,6 +337,7 @@ export class AlpacaBroker implements IBroker { return raw.map(p => ({ contract: makeContract(p.symbol), + currency: 'USD', side: p.side === 'long' ? 'long' as const : 'short' as const, quantity: new Decimal(p.qty), avgCost: parseFloat(p.avg_entry_price), diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.ts index 93a59140..c5395b04 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -41,6 +41,13 @@ import { defaultCancelOrderById, } from './overrides.js' +const STABLECOIN_TO_USD = new Set(['USDT', 'USDC', 'BUSD', 'DAI', 'TUSD']) + +/** Normalize stablecoin quote currencies to 'USD' so they don't trigger FX conversion. */ +function normalizeQuoteCurrency(quote: string): string { + return STABLECOIN_TO_USD.has(quote.toUpperCase()) ? 'USD' : quote +} + /** Map IBKR orderType codes to CCXT order type strings. */ function ibkrOrderTypeToCcxt(orderType: string): string { switch (orderType) { @@ -475,6 +482,7 @@ export class CcxtBroker implements IBroker { const netLiquidation = free + totalPositionValue return { + baseCurrency: 'USD', netLiquidation, totalCashValue: free, unrealizedPnL, @@ -510,6 +518,7 @@ export class CcxtBroker implements IBroker { result.push({ contract: marketToContract(market, this.exchangeName), + currency: normalizeQuoteCurrency(market.quote ?? 'USDT'), side: p.side === 'long' ? 'long' : 'short', quantity, avgCost: entryPrice, diff --git a/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/src/domain/trading/brokers/ibkr/IbkrBroker.ts index a5d0e8bd..b2ed2aac 100644 --- a/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -283,6 +283,7 @@ export class IbkrBroker implements IBroker { : parseFloat(download.values.get('UnrealizedPnL') ?? '0') return { + baseCurrency: download.values.get('BaseCurrency') ?? 'USD', netLiquidation, totalCashValue, unrealizedPnL, diff --git a/src/domain/trading/brokers/ibkr/request-bridge.ts b/src/domain/trading/brokers/ibkr/request-bridge.ts index c988b9b3..8118dfae 100644 --- a/src/domain/trading/brokers/ibkr/request-bridge.ts +++ b/src/domain/trading/brokers/ibkr/request-bridge.ts @@ -458,6 +458,7 @@ export class RequestBridge extends DefaultEWrapper { this.accountCachePending_.positions.push({ contract, + currency: contract.currency || 'USD', side: position.greaterThan(0) ? 'long' : 'short', quantity: position.abs(), avgCost: averageCost, diff --git a/src/domain/trading/brokers/mock/MockBroker.ts b/src/domain/trading/brokers/mock/MockBroker.ts index 9026c222..60289cdc 100644 --- a/src/domain/trading/brokers/mock/MockBroker.ts +++ b/src/domain/trading/brokers/mock/MockBroker.ts @@ -59,6 +59,7 @@ export interface MockBrokerOptions { // ==================== Defaults ==================== export const DEFAULT_ACCOUNT_INFO: AccountInfo = { + baseCurrency: 'USD', netLiquidation: 105_000, totalCashValue: 100_000, unrealizedPnL: 5_000, @@ -87,6 +88,7 @@ export function makePosition(overrides: Partial = {}): Position { const contract = overrides.contract ?? makeContract() return { contract, + currency: contract.currency || 'USD', side: 'long', quantity: new Decimal(10), avgCost: 150, @@ -154,7 +156,7 @@ export class MockBroker implements IBroker { this._cash = new Decimal(options.cash ?? 100_000) if (options.accountInfo) { this._accountOverride = { - netLiquidation: 0, totalCashValue: 0, unrealizedPnL: 0, realizedPnL: 0, + baseCurrency: 'USD', netLiquidation: 0, totalCashValue: 0, unrealizedPnL: 0, realizedPnL: 0, ...options.accountInfo, } } @@ -339,6 +341,7 @@ export class MockBroker implements IBroker { const cash = this._cash.toNumber() return { + baseCurrency: 'USD', netLiquidation: cash + marketValue, totalCashValue: cash, unrealizedPnL, @@ -354,6 +357,7 @@ export class MockBroker implements IBroker { const price = this._quotes.get(pos.contract.symbol ?? '') ?? pos.avgCost.toNumber() result.push({ contract: pos.contract, + currency: pos.contract.currency || 'USD', side: pos.side, quantity: pos.quantity, avgCost: pos.avgCost.toNumber(), @@ -479,10 +483,13 @@ export class MockBroker implements IBroker { /** Override account info directly. Bypasses computed values from positions. */ setAccountInfo(info: Partial): void { - this._accountOverride = { - netLiquidation: 0, totalCashValue: 0, unrealizedPnL: 0, realizedPnL: 0, - ...this._accountOverride, ...info, + const base: AccountInfo = { + baseCurrency: 'USD', netLiquidation: 0, totalCashValue: 0, unrealizedPnL: 0, realizedPnL: 0, + ...this._accountOverride, } + Object.assign(base, info) + if (!base.baseCurrency) base.baseCurrency = 'USD' + this._accountOverride = base } // ==================== Internal ==================== diff --git a/src/domain/trading/brokers/types.ts b/src/domain/trading/brokers/types.ts index b3ac1021..402ec86a 100644 --- a/src/domain/trading/brokers/types.ts +++ b/src/domain/trading/brokers/types.ts @@ -67,6 +67,8 @@ export class BrokerError extends Error { */ export interface Position { contract: Contract + /** Currency denomination for all monetary fields (avgCost, marketPrice, marketValue, PnL). */ + currency: string side: 'long' | 'short' quantity: Decimal avgCost: number @@ -103,6 +105,8 @@ export interface OpenOrder { /** Field names aligned with IBKR AccountSummaryTags. */ export interface AccountInfo { + /** Base currency of this account — all monetary fields are denominated in this currency. */ + baseCurrency: string netLiquidation: number totalCashValue: number unrealizedPnL: number diff --git a/src/domain/trading/fx-service.spec.ts b/src/domain/trading/fx-service.spec.ts new file mode 100644 index 00000000..2c7fc40a --- /dev/null +++ b/src/domain/trading/fx-service.spec.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { FxService } from './fx-service.js' +import type { CurrencyClientLike } from '../market-data/client/types.js' + +function makeMockClient(snapshots: Array<{ base_currency: string; counter_currency: string; last_rate: number }> = []): CurrencyClientLike { + return { + search: vi.fn().mockResolvedValue([]), + getHistorical: vi.fn().mockResolvedValue([]), + getSnapshots: vi.fn().mockResolvedValue(snapshots), + } +} + +describe('FxService', () => { + let client: CurrencyClientLike + + beforeEach(() => { + client = makeMockClient([{ base_currency: 'HKD', counter_currency: 'USD', last_rate: 0.1282 }]) + }) + + // ==================== USD passthrough ==================== + + it('returns rate 1 for USD without calling client', async () => { + const fx = new FxService(client) + const rate = await fx.getRate('USD') + expect(rate.rate).toBe(1) + expect(rate.source).toBe('live') + expect(client.getSnapshots).not.toHaveBeenCalled() + }) + + // ==================== Live rate fetch ==================== + + it('fetches live rate from currency client', async () => { + const fx = new FxService(client) + const rate = await fx.getRate('HKD') + expect(rate.rate).toBe(0.1282) + expect(rate.source).toBe('live') + expect(rate.stale).toBeUndefined() + expect(client.getSnapshots).toHaveBeenCalledWith({ + base: 'HKD', + counter_currencies: 'USD', + provider: 'yfinance', + }) + }) + + // ==================== Cache hit ==================== + + it('returns cached rate on second call (no re-fetch)', async () => { + const fx = new FxService(client) + await fx.getRate('HKD') + await fx.getRate('HKD') + expect(client.getSnapshots).toHaveBeenCalledTimes(1) + }) + + // ==================== Cache expiry → refresh ==================== + + it('re-fetches after TTL expires', async () => { + const fx = new FxService(client, 100) // 100ms TTL + await fx.getRate('HKD') + await new Promise(r => setTimeout(r, 150)) + await fx.getRate('HKD') + expect(client.getSnapshots).toHaveBeenCalledTimes(2) + }) + + // ==================== Stale cache fallback ==================== + + it('returns stale cached rate when refresh fails', async () => { + const fx = new FxService(client, 100) + // First call succeeds → populates live cache + const fresh = await fx.getRate('HKD') + expect(fresh.source).toBe('live') + + // Expire cache, then make client fail + await new Promise(r => setTimeout(r, 150)) + client.getSnapshots = vi.fn().mockRejectedValue(new Error('network down')) + + const stale = await fx.getRate('HKD') + expect(stale.rate).toBe(0.1282) + expect(stale.source).toBe('cached') + expect(stale.stale).toBe(true) + }) + + // ==================== Default table fallback ==================== + + it('falls back to default table when client fails and no cache', async () => { + const failClient = makeMockClient() + failClient.getSnapshots = vi.fn().mockRejectedValue(new Error('network timeout')) + const fx = new FxService(failClient) + + const rate = await fx.getRate('HKD') + expect(rate.source).toBe('default') + expect(rate.rate).toBe(0.128) + expect(rate.updatedAt).toBe('2026-04-08') + }) + + it('falls back to default when snapshot has no matching counter currency', async () => { + const emptyClient = makeMockClient([]) + const fx = new FxService(emptyClient) + + const rate = await fx.getRate('EUR') + expect(rate.source).toBe('default') + expect(rate.rate).toBe(1.08) + }) + + // ==================== No client (offline mode) ==================== + + it('works without currencyClient — pure default table', async () => { + const fx = new FxService() // no client + const rate = await fx.getRate('GBP') + expect(rate.rate).toBe(1.27) + expect(rate.source).toBe('default') + }) + + // ==================== Unknown currency ==================== + + it('returns 1:1 for unknown currency with default source', async () => { + const fx = new FxService() + const rate = await fx.getRate('XYZ') + expect(rate.source).toBe('default') + expect(rate.rate).toBe(1) + }) + + // ==================== Default warn deduplication ==================== + + it('warns only once per currency for default rate usage', async () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const fx = new FxService() // no client + await fx.getRate('HKD') + await fx.getRate('HKD') + await fx.getRate('HKD') + const hkdWarns = spy.mock.calls.filter(c => String(c[0]).includes('HKD')) + expect(hkdWarns).toHaveLength(1) + spy.mockRestore() + }) + + // ==================== convertToUsd ==================== + + it('converts amount to USD using live rate — no warning', async () => { + const fx = new FxService(client) + const result = await fx.convertToUsd(80000, 'HKD') + expect(result.usd).toBeCloseTo(80000 * 0.1282, 2) + expect(result.fxWarning).toBeUndefined() + }) + + it('returns fxWarning only when using default rate', async () => { + const fx = new FxService() // no client → default table + const result = await fx.convertToUsd(80000, 'HKD') + expect(result.usd).toBeCloseTo(80000 * 0.128, 2) + expect(result.fxWarning).toMatch(/HKD.*default/) + }) + + it('no fxWarning for stale cached rate', async () => { + const fx = new FxService(client, 100) + await fx.getRate('HKD') // populate cache + await new Promise(r => setTimeout(r, 150)) + client.getSnapshots = vi.fn().mockRejectedValue(new Error('down')) + + const result = await fx.convertToUsd(80000, 'HKD') + expect(result.fxWarning).toBeUndefined() + }) + + it('returns zero without warning for zero amount', async () => { + const fx = new FxService(client) + const result = await fx.convertToUsd(0, 'HKD') + expect(result.usd).toBe(0) + expect(result.fxWarning).toBeUndefined() + }) + + // ==================== Case insensitivity ==================== + + it('normalizes currency codes to uppercase', async () => { + const fx = new FxService(client) + const rate = await fx.getRate('hkd') + expect(rate.rate).toBe(0.1282) + }) +}) diff --git a/src/domain/trading/fx-service.ts b/src/domain/trading/fx-service.ts new file mode 100644 index 00000000..d03a757a --- /dev/null +++ b/src/domain/trading/fx-service.ts @@ -0,0 +1,170 @@ +/** + * FX Rate Service — provides USD exchange rates with a dual-table architecture: + * + * 1. **Default table** (hardcoded) — developer-maintained, updated with releases. + * Guarantees UTA can run even with zero network connectivity. + * 2. **Live table** (runtime cache) — populated from the market-data currency client. + * Provides fresh rates when available. + * + * Lookup priority: live (fresh) → live (stale cache) → default table → 1:1 fallback. + */ + +import type { CurrencyClientLike } from '../market-data/client/types.js' + +// ==================== Types ==================== + +export interface FxRateEntry { + rate: number + updatedAt: string // ISO 8601 date string +} + +export type FxRateTable = Record + +export interface FxRate { + /** Conversion rate: 1 unit of `from` currency = `rate` units of USD. */ + rate: number + /** Where this rate came from. */ + source: 'live' | 'cached' | 'default' + /** When this rate was last updated (ISO 8601). */ + updatedAt: string + /** True when live data has expired but is still being used. */ + stale?: boolean +} + +export interface ConvertResult { + /** Amount converted to USD. */ + usd: number + /** Present only when a default (hardcoded) rate was used. Includes the updatedAt date. */ + fxWarning?: string +} + +// ==================== Default rate table ==================== + +/** + * Hardcoded rates (→ USD), manually maintained by developers. + * Each entry carries an updatedAt timestamp so consumers can judge data freshness. + * Update these values when releasing new versions. + */ +const DEFAULT_RATES: FxRateTable = { + // Major fiat + HKD: { rate: 0.128, updatedAt: '2026-04-08' }, + EUR: { rate: 1.08, updatedAt: '2026-04-08' }, + GBP: { rate: 1.27, updatedAt: '2026-04-08' }, + JPY: { rate: 0.0067, updatedAt: '2026-04-08' }, + CNY: { rate: 0.138, updatedAt: '2026-04-08' }, + CNH: { rate: 0.138, updatedAt: '2026-04-08' }, + CAD: { rate: 0.74, updatedAt: '2026-04-08' }, + AUD: { rate: 0.65, updatedAt: '2026-04-08' }, + NZD: { rate: 0.60, updatedAt: '2026-04-08' }, + SGD: { rate: 0.74, updatedAt: '2026-04-08' }, + CHF: { rate: 1.13, updatedAt: '2026-04-08' }, + KRW: { rate: 0.00074, updatedAt: '2026-04-08' }, + SEK: { rate: 0.095, updatedAt: '2026-04-08' }, + NOK: { rate: 0.092, updatedAt: '2026-04-08' }, + DKK: { rate: 0.145, updatedAt: '2026-04-08' }, + INR: { rate: 0.012, updatedAt: '2026-04-08' }, + TWD: { rate: 0.031, updatedAt: '2026-04-08' }, + MXN: { rate: 0.058, updatedAt: '2026-04-08' }, + ZAR: { rate: 0.054, updatedAt: '2026-04-08' }, + BRL: { rate: 0.19, updatedAt: '2026-04-08' }, +} + +// ==================== Live cache entry ==================== + +interface LiveCacheEntry { + rate: number + updatedAt: string + fetchedAt: number // Date.now() — for TTL comparison +} + +// ==================== FxService ==================== + +export class FxService { + private readonly liveRates = new Map() + private readonly ttlMs: number + private readonly client?: CurrencyClientLike + /** Track which default-rate currencies have already been warned about, to avoid log spam. */ + private readonly defaultWarned = new Set() + + /** + * @param currencyClient — optional. Without it, FxService works purely from the default table. + * @param ttlMs — cache TTL in milliseconds. Default 5 minutes. + */ + constructor(currencyClient?: CurrencyClientLike, ttlMs = 5 * 60_000) { + this.client = currencyClient + this.ttlMs = ttlMs + } + + /** + * Get the USD exchange rate for a given currency. + * + * Priority: live (fresh) → live (stale) → default table → 1:1 fallback. + */ + async getRate(from: string): Promise { + const key = from.toUpperCase() + if (key === 'USD') return { rate: 1, source: 'live', updatedAt: new Date().toISOString() } + + const now = Date.now() + const cached = this.liveRates.get(key) + + // 1. Fresh live cache + if (cached && now - cached.fetchedAt < this.ttlMs) { + return { rate: cached.rate, source: 'live', updatedAt: cached.updatedAt } + } + + // 2. Try to fetch fresh data (only if we have a client) + if (this.client) { + try { + const snapshots = await this.client.getSnapshots({ + base: key, + counter_currencies: 'USD', + provider: 'yfinance', + }) + const snap = snapshots.find(s => s.counter_currency?.toUpperCase() === 'USD') + if (snap && snap.last_rate > 0) { + const updatedAt = new Date().toISOString() + this.liveRates.set(key, { rate: snap.last_rate, updatedAt, fetchedAt: now }) + return { rate: snap.last_rate, source: 'live', updatedAt } + } + } catch { + // Silently fall through — stale cache or default table will handle it + } + } + + // 3. Stale live cache (expired but better than nothing) + if (cached) { + return { rate: cached.rate, source: 'cached', updatedAt: cached.updatedAt, stale: true } + } + + // 4. Default table + const def = DEFAULT_RATES[key] + if (def) { + if (!this.defaultWarned.has(key)) { + this.defaultWarned.add(key) + console.warn(`FxService: using default rate for ${key}/USD = ${def.rate} (from ${def.updatedAt})`) + } + return { rate: def.rate, source: 'default', updatedAt: def.updatedAt } + } + + // 5. Unknown currency — 1:1 fallback + if (!this.defaultWarned.has(key)) { + this.defaultWarned.add(key) + console.warn(`FxService: unknown currency "${key}", defaulting to 1:1 USD`) + } + return { rate: 1, source: 'default', updatedAt: '1970-01-01' } + } + + /** + * Convert an amount in the given currency to USD. + * Returns a warning only when the default (hardcoded) table is used. + */ + async convertToUsd(amount: number, currency: string): Promise { + if (amount === 0) return { usd: 0 } + const fx = await this.getRate(currency) + const usd = amount * fx.rate + if (fx.source === 'default') { + return { usd, fxWarning: `${currency}: using default rate ${fx.rate} (last updated ${fx.updatedAt})` } + } + return { usd } + } +} diff --git a/src/domain/trading/git/TradingGit.spec.ts b/src/domain/trading/git/TradingGit.spec.ts index 09722325..189d674e 100644 --- a/src/domain/trading/git/TradingGit.spec.ts +++ b/src/domain/trading/git/TradingGit.spec.ts @@ -582,6 +582,7 @@ describe('TradingGit', () => { positions: [ { contract: makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }), + currency: 'USD', side: 'long', quantity: new Decimal(10), avgCost: 150, @@ -612,6 +613,7 @@ describe('TradingGit', () => { positions: [ { contract: makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }), + currency: 'USD', side: 'long', quantity: new Decimal(10), avgCost: 150, @@ -640,12 +642,12 @@ describe('TradingGit', () => { positions: [ { contract: makeContract({ symbol: 'AAPL' }), - side: 'long', quantity: new Decimal(10), avgCost: 100, marketPrice: 100, + currency: 'USD', side: 'long', quantity: new Decimal(10), avgCost: 100, marketPrice: 100, marketValue: 1000, unrealizedPnL: 0, realizedPnL: 0, }, { contract: makeContract({ symbol: 'GOOG' }), - side: 'long', quantity: new Decimal(5), avgCost: 200, marketPrice: 200, + currency: 'USD', side: 'long', quantity: new Decimal(5), avgCost: 200, marketPrice: 200, marketValue: 1000, unrealizedPnL: 0, realizedPnL: 0, }, ], @@ -665,7 +667,7 @@ describe('TradingGit', () => { positions: [ { contract: makeContract({ symbol: 'AAPL' }), - side: 'long', quantity: new Decimal(10), avgCost: 100, marketPrice: 100, + currency: 'USD', side: 'long', quantity: new Decimal(10), avgCost: 100, marketPrice: 100, marketValue: 1000, unrealizedPnL: 0, realizedPnL: 0, }, ], diff --git a/src/domain/trading/guards/guards.spec.ts b/src/domain/trading/guards/guards.spec.ts index aa4d7f38..12d38e66 100644 --- a/src/domain/trading/guards/guards.spec.ts +++ b/src/domain/trading/guards/guards.spec.ts @@ -41,6 +41,7 @@ function makeContext(overrides: { operation: overrides.operation ?? makePlaceOrderOp(), positions: overrides.positions ?? [], account: { + baseCurrency: 'USD', netLiquidation: 100_000, totalCashValue: 100_000, unrealizedPnL: 0, diff --git a/src/domain/trading/snapshot/builder.ts b/src/domain/trading/snapshot/builder.ts index ab6c2c0d..16c92678 100644 --- a/src/domain/trading/snapshot/builder.ts +++ b/src/domain/trading/snapshot/builder.ts @@ -31,6 +31,7 @@ export async function buildSnapshot( timestamp: new Date().toISOString(), trigger, account: { + baseCurrency: accountInfo.baseCurrency, netLiquidation: String(accountInfo.netLiquidation), totalCashValue: String(accountInfo.totalCashValue), unrealizedPnL: String(accountInfo.unrealizedPnL), @@ -41,6 +42,7 @@ export async function buildSnapshot( }, positions: positions.map(p => ({ aliceId: p.contract.aliceId ?? uta.broker.getNativeKey(p.contract), + currency: p.currency, side: p.side, quantity: p.quantity.toString(), avgCost: String(p.avgCost), diff --git a/src/domain/trading/snapshot/snapshot.spec.ts b/src/domain/trading/snapshot/snapshot.spec.ts index 21f2521f..e0f37964 100644 --- a/src/domain/trading/snapshot/snapshot.spec.ts +++ b/src/domain/trading/snapshot/snapshot.spec.ts @@ -204,6 +204,7 @@ describe('Snapshot Store', () => { timestamp: new Date().toISOString(), trigger: 'manual', account: { + baseCurrency: 'USD', netLiquidation: '100000', totalCashValue: '90000', unrealizedPnL: '5000', diff --git a/src/domain/trading/snapshot/types.ts b/src/domain/trading/snapshot/types.ts index 1d0affcd..337334be 100644 --- a/src/domain/trading/snapshot/types.ts +++ b/src/domain/trading/snapshot/types.ts @@ -17,6 +17,7 @@ export interface UTASnapshot { trigger: SnapshotTrigger account: { + baseCurrency: string netLiquidation: string totalCashValue: string unrealizedPnL: string @@ -28,6 +29,7 @@ export interface UTASnapshot { positions: Array<{ aliceId: string + currency: string side: 'long' | 'short' quantity: string avgCost: string diff --git a/src/main.ts b/src/main.ts index 9f3421ce..e48596da 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,7 @@ import { WebPlugin } from './connectors/web/index.js' import { McpAskPlugin } from './connectors/mcp-ask/index.js' import { createThinkingTools } from './tool/thinking.js' import { AccountManager, createSnapshotService, createSnapshotScheduler } from './domain/trading/index.js' +import { FxService } from './domain/trading/fx-service.js' import { createTradingTools } from './tool/trading.js' import { Brain } from './domain/brain/index.js' import { createBrainTools } from './tool/brain.js' @@ -186,6 +187,11 @@ async function main() { // OpenBB API server is started later via optionalPlugins + // ==================== FX Service ==================== + + const fxService = new FxService(currencyClient) + accountManager.setFxService(fxService) + // ==================== Equity Symbol Index ==================== const symbolIndex = new SymbolIndex() @@ -197,7 +203,7 @@ async function main() { // One unified set of trading tools — routes via `source` parameter at runtime toolCenter.register( - createTradingTools(accountManager), + createTradingTools(accountManager, fxService), 'trading', ) diff --git a/src/tool/trading.ts b/src/tool/trading.ts index 9d20aa3b..cc574a5f 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -11,6 +11,7 @@ import { z } from 'zod' import { Contract, UNSET_DOUBLE, UNSET_DECIMAL } from '@traderalice/ibkr' import type { AccountManager } from '@/domain/trading/account-manager.js' import { BrokerError, type OpenOrder } from '@/domain/trading/brokers/types.js' +import type { FxService } from '@/domain/trading/fx-service.js' import '@/domain/trading/contract-ext.js' /** Classify a broker error into a structured response for AI consumption. */ @@ -59,7 +60,7 @@ const sourceDesc = (required: boolean, extra?: string) => { return base + req + (extra ? ` ${extra}` : '') } -export function createTradingTools(manager: AccountManager): Record { +export function createTradingTools(manager: AccountManager, fxService?: FxService): Record { return { listAccounts: tool({ description: 'List all registered trading accounts with their id, provider, label, and capabilities.', @@ -141,24 +142,50 @@ If this tool returns an error with transient=true, wait a few seconds and retry if (targets.length === 0) return { positions: [], message: 'No accounts available.' } try { const allPositions: Array> = [] + const fxWarnings: string[] = [] for (const uta of targets) { const positions = await uta.getPositions() const accountInfo = await uta.getAccount() - const totalMarketValue = positions.reduce((sum, p) => sum + p.marketValue, 0) + + // Convert position market values to USD for cross-currency percentage calculations + let totalMarketValueUsd = 0 + const posUsdValues: number[] = [] + for (const pos of positions) { + if (fxService && pos.currency !== 'USD') { + const r = await fxService.convertToUsd(pos.marketValue, pos.currency) + posUsdValues.push(r.usd) + if (r.fxWarning && !fxWarnings.includes(r.fxWarning)) fxWarnings.push(r.fxWarning) + } else { + posUsdValues.push(pos.marketValue) + } + totalMarketValueUsd += posUsdValues[posUsdValues.length - 1] + } + + // Account netLiq in USD for equity percentage + let netLiqUsd = accountInfo.netLiquidation + if (fxService && accountInfo.baseCurrency !== 'USD') { + const r = await fxService.convertToUsd(accountInfo.netLiquidation, accountInfo.baseCurrency) + netLiqUsd = r.usd + } + + let idx = 0 for (const pos of positions) { - if (symbol && symbol !== 'all' && pos.contract.symbol !== symbol) continue - const percentOfEquity = accountInfo.netLiquidation > 0 ? (pos.marketValue / accountInfo.netLiquidation) * 100 : 0 - const percentOfPortfolio = totalMarketValue > 0 ? (pos.marketValue / totalMarketValue) * 100 : 0 + if (symbol && symbol !== 'all' && pos.contract.symbol !== symbol) { idx++; continue } + const mvUsd = posUsdValues[idx] + const percentOfEquity = netLiqUsd > 0 ? (mvUsd / netLiqUsd) * 100 : 0 + const percentOfPortfolio = totalMarketValueUsd > 0 ? (mvUsd / totalMarketValueUsd) * 100 : 0 allPositions.push({ - source: uta.id, symbol: pos.contract.symbol, side: pos.side, + source: uta.id, symbol: pos.contract.symbol, currency: pos.currency, side: pos.side, quantity: pos.quantity.toNumber(), avgCost: pos.avgCost, marketPrice: pos.marketPrice, marketValue: pos.marketValue, unrealizedPnL: pos.unrealizedPnL, realizedPnL: pos.realizedPnL, percentageOfEquity: `${percentOfEquity.toFixed(1)}%`, percentageOfPortfolio: `${percentOfPortfolio.toFixed(1)}%`, }) + idx++ } } if (allPositions.length === 0) return { positions: [], message: 'No open positions.' } + if (fxWarnings.length > 0) return { positions: allPositions, fxWarnings } return allPositions } catch (err) { return handleBrokerError(err) diff --git a/ui/src/api/config.ts b/ui/src/api/config.ts index 08ff00c7..5b516454 100644 --- a/ui/src/api/config.ts +++ b/ui/src/api/config.ts @@ -1,5 +1,5 @@ import { headers } from './client' -import type { AppConfig, Profile } from './types' +import type { AppConfig, Profile, Preset } from './types' export const configApi = { async load(): Promise { @@ -23,6 +23,12 @@ export const configApi = { // ==================== Profile CRUD ==================== + async getPresets(): Promise<{ presets: Preset[] }> { + const res = await fetch('/api/config/presets') + if (!res.ok) throw new Error('Failed to load presets') + return res.json() + }, + async getProfiles(): Promise<{ profiles: Record; activeProfile: string }> { const res = await fetch('/api/config/profiles') if (!res.ok) throw new Error('Failed to load profiles') @@ -43,7 +49,7 @@ export const configApi = { }, async updateProfile(slug: string, profile: Profile): Promise<{ slug: string; profile: Profile }> { - const res = await fetch(`/api/config/profiles/${slug}`, { + const res = await fetch(`/api/config/profiles/${encodeURIComponent(slug)}`, { method: 'PUT', headers, body: JSON.stringify(profile), @@ -56,13 +62,22 @@ export const configApi = { }, async deleteProfile(slug: string): Promise { - const res = await fetch(`/api/config/profiles/${slug}`, { method: 'DELETE' }) + const res = await fetch(`/api/config/profiles/${encodeURIComponent(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 testProfile(profileData: Profile): Promise<{ ok: boolean; response?: string; error?: string }> { + const res = await fetch('/api/config/profiles/test', { + method: 'POST', + headers, + body: JSON.stringify(profileData), + }) + return res.json() + }, + async setActiveProfile(slug: string): Promise { const res = await fetch('/api/config/active-profile', { method: 'PUT', @@ -75,20 +90,4 @@ export const configApi = { } }, - // ==================== 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 dbe1f73f..fe71bbd1 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -34,6 +34,9 @@ export type { WebChannel, Profile, AIBackend, + Preset, + JsonSchema, + JsonSchemaProperty, ChatMessage, ChatResponse, ToolCall, diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index ba3848a8..fd71e2ec 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -4,14 +4,46 @@ export type AIBackend = 'agent-sdk' | 'codex' | 'vercel-ai-sdk' export interface Profile { backend: AIBackend - label: string model: string + preset?: string // preset ID this profile was created from loginMethod?: string provider?: string // vercel-ai-sdk only baseUrl?: string apiKey?: string } +// ==================== AI Provider Presets ==================== + +export interface Preset { + id: string + label: string + description: string + category: 'official' | 'third-party' | 'custom' + hint?: string + defaultName: string + schema: JsonSchema +} + +/** Subset of JSON Schema types we use for form rendering. */ +export interface JsonSchema { + type?: string + properties?: Record + required?: string[] + [key: string]: unknown +} + +export interface JsonSchemaProperty { + type?: string + const?: unknown + enum?: string[] + oneOf?: Array<{ const: string; title: string }> + default?: unknown + title?: string + description?: string + writeOnly?: boolean + [key: string]: unknown +} + // ==================== Channels ==================== export interface WebChannel { diff --git a/ui/src/components/ChannelConfigModal.tsx b/ui/src/components/ChannelConfigModal.tsx index 9f2d18a6..bb557c76 100644 --- a/ui/src/components/ChannelConfigModal.tsx +++ b/ui/src/components/ChannelConfigModal.tsx @@ -102,7 +102,7 @@ export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigM > {Object.entries(profiles).map(([slug, p]) => ( - + ))} diff --git a/ui/src/hooks/useSchemaForm.ts b/ui/src/hooks/useSchemaForm.ts new file mode 100644 index 00000000..2a8056ca --- /dev/null +++ b/ui/src/hooks/useSchemaForm.ts @@ -0,0 +1,121 @@ +/** + * useSchemaForm — parses a JSON Schema into form field descriptors and manages form state. + * + * Separates const fields (hidden, auto-merged on submit) from editable fields. + * Components just iterate `fields` and render by `type`. + */ + +import { useState, useMemo, useCallback, useEffect, useRef } from 'react' +import type { JsonSchema, JsonSchemaProperty } from '../api/types' + +// ==================== Types ==================== + +export interface SchemaField { + key: string + type: 'text' | 'password' | 'select' + title: string + description?: string + required: boolean + options?: Array<{ value: string; label: string }> + defaultValue?: string +} + +interface UseSchemaFormResult { + /** Editable fields (const fields excluded). */ + fields: SchemaField[] + /** Current form values for editable fields. */ + formData: Record + /** Update a single field value. */ + setField: (key: string, value: string) => void + /** Get submit-ready data: editable values + const values merged. */ + getSubmitData: () => Record + /** Validate required fields. Returns error message or null. */ + validate: () => string | null +} + +// ==================== Hook ==================== + +export function useSchemaForm( + schema: JsonSchema | undefined, + initialValues?: Record, +): UseSchemaFormResult { + // Parse schema into const values and editable field descriptors + const { constValues, fieldDefs, defaults } = useMemo(() => { + const consts: Record = {} + const fields: SchemaField[] = [] + const defs: Record = {} + + const props = (schema?.properties ?? {}) as Record + const required = new Set((schema?.required as string[]) ?? []) + + for (const [key, prop] of Object.entries(props)) { + // const → hidden, value auto-merged + if (prop.const !== undefined) { + consts[key] = prop.const + continue + } + + const title = prop.title ?? key.charAt(0).toUpperCase() + key.slice(1) + const isRequired = required.has(key) + + // Determine field type + if (prop.writeOnly) { + fields.push({ key, type: 'password', title, description: prop.description, required: isRequired }) + } else if (prop.oneOf) { + const options = prop.oneOf.map(o => ({ value: o.const, label: o.title })) + fields.push({ key, type: 'select', title, description: prop.description, required: isRequired, options }) + } else if (prop.enum) { + const options = prop.enum.map(v => ({ value: v, label: v })) + fields.push({ key, type: 'select', title, description: prop.description, required: isRequired, options }) + } else { + fields.push({ key, type: 'text', title, description: prop.description, required: isRequired, defaultValue: prop.default !== undefined ? String(prop.default) : undefined }) + } + + // Collect defaults + if (prop.default !== undefined) { + defs[key] = String(prop.default) + } + } + + return { constValues: consts, fieldDefs: fields, defaults: defs } + }, [schema]) + + // Form state — reset when schema changes (e.g. user picks a different preset) + const [formData, setFormData] = useState>(() => ({ + ...defaults, + ...(initialValues ?? {}), + })) + + // Re-initialize when defaults change (schema switch) + const prevDefaults = useRef(defaults) + useEffect(() => { + if (prevDefaults.current !== defaults) { + prevDefaults.current = defaults + setFormData({ ...defaults, ...(initialValues ?? {}) }) + } + }, [defaults, initialValues]) + + const setField = useCallback((key: string, value: string) => { + setFormData(prev => ({ ...prev, [key]: value })) + }, []) + + const getSubmitData = useCallback((): Record => { + const result: Record = { ...constValues } + for (const [key, value] of Object.entries(formData)) { + if (key.endsWith('__custom')) continue + if (value !== '' && value !== undefined) result[key] = value + } + return result + }, [constValues, formData]) + + const validate = useCallback((): string | null => { + for (const field of fieldDefs) { + if (field.required && !formData[field.key]?.trim()) { + return `${field.title} is required` + } + } + return null + }, [fieldDefs, formData]) + + return { fields: fieldDefs, formData, setField, getSubmitData, validate } +} diff --git a/ui/src/pages/AIProviderPage.tsx b/ui/src/pages/AIProviderPage.tsx index 9f1b8d51..4baee6f0 100644 --- a/ui/src/pages/AIProviderPage.tsx +++ b/ui/src/pages/AIProviderPage.tsx @@ -1,32 +1,23 @@ import { useState, useEffect, useRef } from 'react' -import { api, type Profile, type AIBackend } from '../api' +import { api, type Profile, type AIBackend, type Preset } from '../api' import { SaveIndicator } from '../components/SaveIndicator' -import { ConfigSection, Field, inputClass } from '../components/form' +import { Field, inputClass } from '../components/form' +import { useSchemaForm, type SchemaField } from '../hooks/useSchemaForm' import type { SaveStatus } from '../hooks/useAutoSave' import { PageHeader } from '../components/PageHeader' import { PageLoading } from '../components/StateViews' -// ==================== Constants ==================== - -const BACKEND_INFO: Record = { - 'agent-sdk': { - label: 'Claude', - icon: , - }, - 'codex': { - label: 'OpenAI / Codex', - icon: , - }, - 'vercel-ai-sdk': { - label: 'Vercel AI SDK', - icon: , - }, +// ==================== Icons ==================== + +const BACKEND_ICONS: Record = { + 'agent-sdk': , + 'codex': , + 'vercel-ai-sdk': , } -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' }, +function getSchemaConst(schema: Preset['schema'], field: string): unknown { + const props = schema?.properties as Record | undefined + return props?.[field]?.const } // ==================== Main Page ==================== @@ -34,399 +25,352 @@ const NEW_PROFILE_DEFAULTS: Record> = { export function AIProviderPage() { 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) + const [presets, setPresets] = useState([]) + const [editingSlug, setEditingSlug] = useState(null) + const [showCreate, setShowCreate] = useState(false) useEffect(() => { 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)' } : {}), - }) + setProfiles(p); setActiveProfile(a) }).catch(() => {}) + api.config.getPresets().then(({ presets: p }) => setPresets(p)).catch(() => {}) }, []) const handleSetActive = async (slug: string) => { - try { - await api.config.setActiveProfile(slug) - setActiveProfile(slug) - } catch { /* keep old state */ } + try { await api.config.setActiveProfile(slug); setActiveProfile(slug) } catch {} } 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 updated = { ...profiles }; delete updated[slug] + setProfiles(updated); setEditingSlug(null) + } catch {} } - 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 handleCreateSave = async (name: string, profile: Profile) => { + await api.config.createProfile(name, profile) + setProfiles((p) => p ? { ...p, [name]: profile } : p) + // Don't close modal here — let the modal handle test + close } 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 */ } + await api.config.updateProfile(slug, profile) + setProfiles((p) => p ? { ...p, [slug]: profile } : p) } - if (!profiles) return
- - const selectedProfile = selectedSlug ? profiles[selectedSlug] : null + if (!profiles) return
return (
- +
-
- - {/* 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)} - /> - - )} +
+ {Object.entries(profiles).map(([slug, profile]) => { + const isActive = slug === activeProfile + return ( +
+
{BACKEND_ICONS[profile.backend]}
+
+
+ {slug} + {isActive && Active} +
+

{profile.model || 'Auto (subscription plan)'}

+
+
+ {!isActive && } + +
+
+ ) + })} + +
+
- {/* Edit Form */} - {selectedProfile && selectedSlug && !creating && ( - - handleProfileUpdate(selectedSlug, p)} - onSetActive={() => handleSetActive(selectedSlug)} - onDelete={() => handleDelete(selectedSlug)} - /> - - )} + {editingSlug && profiles[editingSlug] && ( + handleProfileUpdate(editingSlug, p)} + onDelete={() => handleDelete(editingSlug)} onClose={() => setEditingSlug(null)} /> + )} + {showCreate && setShowCreate(false)} />} +
+ ) +} - {/* Global API Keys */} - - - +// ==================== Modal Shell ==================== +function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) { + return ( +
+
e.stopPropagation()}> +
+

{title}

+
+
{children}
) } -// ==================== Profile Form (Create) ==================== +// ==================== Schema-driven Field Renderer ==================== -function ProfileForm({ backend, onSave, onCancel }: { - backend: AIBackend - onSave: (slug: string, profile: Profile) => Promise - onCancel: () => void +function SchemaFormFields({ fields, formData, setField, existingProfile }: { + fields: SchemaField[] + formData: Record + setField: (key: string, value: string) => void + existingProfile?: Profile }) { - 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 { - await onSave(slug, profile) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save') - } finally { - setSaving(false) - } - } - return ( -
- - setLabel(e.target.value)} placeholder="e.g. Claude Main, GPT Fast" /> - - - {error &&

{error}

} -
- - -
-
+ <> + {fields.map((field) => { + const value = formData[field.key] ?? '' + const label = field.required ? field.title : `${field.title} (optional)` + const hasExisting = existingProfile && field.key === 'apiKey' && !!(existingProfile as unknown as Record)[field.key] + + if (field.type === 'select') { + return ( + + + + ) + } + + if (field.type === 'password') { + return ( + +
+ setField(field.key, e.target.value)} + placeholder={hasExisting ? '(configured — leave empty to keep)' : 'Enter value'} /> + {hasExisting && !value && active} +
+
+ ) + } + + return ( + + setField(field.key, e.target.value)} + placeholder={field.defaultValue ?? ''} /> + + ) + })} + ) } -// ==================== Profile Editor (Edit existing) ==================== +// ==================== Edit Modal ==================== -function ProfileEditor({ slug, profile, isActive, onUpdate, onSetActive, onDelete }: { - slug: string - profile: Profile - isActive: boolean - onUpdate: (profile: Profile) => Promise - onSetActive: () => void - onDelete: () => void +function ProfileEditModal({ slug, profile, presets, isActive, onSave, onDelete, onClose }: { + slug: string; profile: Profile; presets: Preset[]; isActive: boolean + onSave: (profile: Profile) => Promise; onDelete: () => void; onClose: () => 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 ?? '') + // Lookup preset by profile.preset field — no more reverse matching + const preset = presets.find(p => p.id === profile.preset) ?? presets.find(p => p.category === 'custom')! + + const profileData: Record = {} + for (const [k, v] of Object.entries(profile)) { + if (v !== undefined && v !== null) profileData[k] = String(v) + } + + const { fields, formData, setField, getSubmitData, validate } = useSchemaForm(preset.schema, profileData) const [status, setStatus] = useState('idle') const savedTimer = useRef | null>(null) - // 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]) - useEffect(() => () => { if (savedTimer.current) clearTimeout(savedTimer.current) }, []) const handleSave = async () => { + const error = validate() + if (error) return 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 } : {}), - } try { - await onUpdate(updated) + const data = getSubmitData() + // Preserve existing apiKey if user didn't enter a new one + if (!data.apiKey && profile.apiKey) data.apiKey = profile.apiKey + // Preserve preset association + data.preset = profile.preset + await onSave(data as unknown as Profile) setStatus('saved') if (savedTimer.current) clearTimeout(savedTimer.current) - savedTimer.current = setTimeout(() => setStatus('idle'), 2000) - } catch { - setStatus('error') - } + savedTimer.current = setTimeout(() => { setStatus('idle'); onClose() }, 1000) + } catch { setStatus('error') } } return ( -
- - setLabel(e.target.value)} /> - - -
- - -
- {!isActive && ( - - )} - {!isActive && ( - - )} + +
+ {preset.hint &&

{preset.hint}

} + +
+ + +
+ {!isActive && } +
-
+
) } -// ==================== Shared Profile Fields ==================== +// ==================== Create Modal ==================== -function ProfileFields({ backend, model, setModel, loginMethod, setLoginMethod, provider, setProvider, baseUrl, setBaseUrl }: { - backend: AIBackend - model: string; setModel: (v: string) => void - loginMethod: string; setLoginMethod: (v: string) => void - provider: string; setProvider: (v: string) => void - baseUrl: string; setBaseUrl: (v: string) => void +function ProfileCreateModal({ presets, existingNames, onSave, onClose }: { + presets: Preset[]; existingNames: string[] + onSave: (name: string, profile: Profile) => Promise; onClose: () => void }) { - return ( - <> - {/* Login Method (agent-sdk and codex only) */} - {(backend === 'agent-sdk' || backend === 'codex') && ( - - - - )} - - {/* Provider (vercel-ai-sdk only) */} - {backend === 'vercel-ai-sdk' && ( - - - - )} + const [selectedPreset, setSelectedPreset] = useState(null) + const [name, setName] = useState('') + const [saving, setSaving] = useState(false) + const [testing, setTesting] = useState(false) + const [testResult, setTestResult] = useState<{ ok: boolean; response?: string; error?: string } | null>(null) + const [error, setError] = useState('') - - setModel(e.target.value)} placeholder="e.g. claude-sonnet-4-6, gpt-5.4" /> - + const existingSet = new Set(existingNames) - - setBaseUrl(e.target.value)} placeholder="Leave empty for default" /> - - + const { fields, formData, setField, getSubmitData, validate } = useSchemaForm( + selectedPreset?.schema, ) -} -// ==================== Global API Keys ==================== + const selectPreset = (preset: Preset) => { + // If official preset already configured, don't open create form + if (preset.defaultName && existingSet.has(preset.defaultName)) return + setSelectedPreset(preset) + setName(preset.defaultName) + setTestResult(null) + setError('') + } + + const isOfficialPreset = selectedPreset ? !!selectedPreset.defaultName : false -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) + const handleCreate = async () => { + if (!selectedPreset) return + const trimmedName = name.trim() + if (!trimmedName) { setError('Profile name is required'); return } + const validationError = validate() + if (validationError) { setError(validationError); return } - useEffect(() => () => { if (savedTimer.current) clearTimeout(savedTimer.current) }, []) + setError(''); setTestResult(null) + const data = getSubmitData() + data.preset = selectedPreset.id + const profileData = data as unknown as Profile + + // Test connectivity (don't save yet — user must confirm) + setTesting(true) + try { + const result = await api.config.testProfile(profileData) + setTestResult(result) + } catch (err) { + setTestResult({ ok: false, error: err instanceof Error ? err.message : 'Test failed' }) + } finally { + setTesting(false) + } + } const handleSave = async () => { - setStatus('saving') + if (!selectedPreset) return + const trimmedName = name.trim() + if (!trimmedName) return + const data = getSubmitData() + data.preset = selectedPreset.id + setSaving(true); setError('') try { - 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(() => setStatus('idle'), 2000) - } catch { - setStatus('error') + await onSave(trimmedName, data as unknown as Profile) + setSaving(false) + onClose() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save') + setSaving(false) } } - const fields = [ - { key: 'anthropic', label: 'Anthropic', placeholder: 'sk-ant-...' }, - { key: 'openai', label: 'OpenAI', placeholder: 'sk-...' }, - { key: 'google', label: 'Google', placeholder: 'AIza...' }, - ] as const + const officialPresets = presets.filter(p => p.category === 'official') + const thirdPartyPresets = presets.filter(p => p.category === 'third-party') + const customPreset = presets.find(p => p.category === 'custom') + + const renderPresetCard = (p: Preset) => { + const alreadyExists = !!p.defaultName && existingSet.has(p.defaultName) + return ( + + ) + } return ( - <> - {fields.map((f) => ( - -
- setKeys((k) => ({ ...k, [f.key]: e.target.value }))} - placeholder={currentStatus[f.key] ? '(configured)' : f.placeholder} - /> - {currentStatus[f.key] && ( - active + + {!selectedPreset ? ( +
+ {officialPresets.length > 0 && ( +
+

Official

+
{officialPresets.map(renderPresetCard)}
+
+ )} + {thirdPartyPresets.length > 0 && ( +
+

Third Party

+
{thirdPartyPresets.map(renderPresetCard)}
+
+ )} + {customPreset && ( + + )} +
+ ) : ( +
+ {selectedPreset.hint &&

{selectedPreset.hint}

} + + {isOfficialPreset ? ( +

{name}

+ ) : ( + setName(e.target.value)} placeholder="Enter a name for this profile" autoFocus /> + )} +
+ + {error &&

{error}

} + {/* Test result */} + {testing &&

Testing connection...

} + {testResult && ( +
+ {testResult.ok ? `Connected: "${testResult.response?.slice(0, 100)}"` : `Failed: ${testResult.error}`} +
+ )} +
+ {testResult?.ok ? ( + + ) : ( + )} +
- - ))} -
- - -
- +
+ )} +
) }