From 29c80cad426dee28c967ee2403796d11fb1bf796 Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 11:42:45 +0800 Subject: [PATCH 01/18] test: add Codex provider e2e tests Verifies real API communication against the ChatGPT subscription endpoint: - Basic text response - Tool call with correct call_id/name/arguments (via output_item.done) - Full tool call round-trip (send result back, get final text) - Multi-turn structured input (model references prior context) Skips gracefully if ~/.codex/auth.json is not present. Runs via `pnpm test:e2e`, excluded from regular `pnpm test`. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../codex/__test__/codex.e2e.spec.ts | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 src/ai-providers/codex/__test__/codex.e2e.spec.ts 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) +}) From 7f5668e9ada11ed29ed116f1d8c10a2a83ace17d Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 13:30:14 +0800 Subject: [PATCH 02/18] feat: preset system for guided profile creation + OAuth model fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presets are constraint-based templates that define which fields are locked/hidden/required and what models are available to choose from. Users pick a preset, then fill only the editable fields. Built-in presets: - Claude (OAuth subscription / API key) - OpenAI/Codex (ChatGPT subscription / API key) - Google Gemini - MiniMax (third-party, Anthropic-compatible API) - Custom (full control) Also fixes OAuth model handling: when loginMethod is 'claudeai', model is optional — omitting it lets Claude Code pick based on the user's subscription plan. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai-providers/agent-sdk/query.ts | 6 +- src/ai-providers/presets.ts | 165 +++++++++++++ src/connectors/web/routes/config.ts | 4 + ui/src/api/config.ts | 8 +- ui/src/api/index.ts | 3 + ui/src/api/types.ts | 29 +++ ui/src/pages/AIProviderPage.tsx | 367 ++++++++++++++++++---------- 7 files changed, 454 insertions(+), 128 deletions(-) create mode 100644 src/ai-providers/presets.ts diff --git a/src/ai-providers/agent-sdk/query.ts b/src/ai-providers/agent-sdk/query.ts index d4ce05d1..751d5c60 100644 --- a/src/ai-providers/agent-sdk/query.ts +++ b/src/ai-providers/agent-sdk/query.ts @@ -148,7 +148,11 @@ export async function askAgentSdk( options: { cwd, env, - model: override?.model ?? 'claude-sonnet-4-6', + // OAuth mode: omit model to let Claude Code pick based on subscription plan. + // API key mode: use profile model or fall back to sonnet. + ...(isOAuthMode + ? (override?.model ? { model: override.model } : {}) + : { model: override?.model ?? 'claude-sonnet-4-6' }), maxTurns, allowedTools: finalAllowed, disallowedTools: finalDisallowed, diff --git a/src/ai-providers/presets.ts b/src/ai-providers/presets.ts new file mode 100644 index 00000000..191638c4 --- /dev/null +++ b/src/ai-providers/presets.ts @@ -0,0 +1,165 @@ +/** + * AI Provider Presets — constraint-based templates for profile creation. + * + * Each preset defines which fields are locked (not user-editable), + * hidden (not shown in UI), required (must be filled), and what + * models are available to choose from. + * + * Presets are NOT profiles — they are templates that guide profile + * creation. Users create concrete profiles from presets. + */ + +import type { AIBackend } from '../core/config.js' + +// ==================== Types ==================== + +export interface PresetModelOption { + /** Model ID as sent to the API (e.g. 'claude-sonnet-4-6'). */ + id: string + /** Human-readable label (e.g. 'Claude Sonnet 4.6'). */ + label: string +} + +export interface PresetField { + /** The preset value for this field. */ + value: T + /** If true, user cannot edit this field — it's baked into the preset. */ + locked: boolean + /** If true, field is not displayed in the UI (but its value is still used). */ + hidden?: boolean + /** If true, user must provide a value for this field (e.g. API key). */ + required?: boolean +} + +export interface Preset { + id: string + label: string + description: string + category: 'official' | 'third-party' | 'custom' + + // Field constraints (undefined = field not applicable to this preset) + backend: PresetField + loginMethod?: PresetField + provider?: PresetField + baseUrl?: PresetField + apiKey?: PresetField + + // Model selection + models: PresetModelOption[] + /** Default model ID to pre-select. */ + defaultModel?: string + /** If true, model can be left empty (OAuth mode — server picks based on plan). */ + modelOptional?: boolean +} + +// ==================== Built-in Presets ==================== + +export const BUILTIN_PRESETS: Preset[] = [ + // ── Official: Claude ── + { + id: 'claude-oauth', + label: 'Claude (Subscription)', + description: 'Use your Claude Pro/Max subscription', + category: 'official', + backend: { value: 'agent-sdk', locked: true, hidden: true }, + loginMethod: { value: 'claudeai', locked: true, hidden: true }, + apiKey: { value: '', locked: true, hidden: true }, + models: [ + { id: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, + { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }, + ], + modelOptional: true, + }, + { + id: 'claude-api', + label: 'Claude (API Key)', + description: 'Pay per token via Anthropic API', + category: 'official', + backend: { value: 'agent-sdk', locked: true, hidden: true }, + loginMethod: { value: 'api-key', locked: true, hidden: true }, + apiKey: { value: '', locked: false, required: true }, + 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' }, + ], + defaultModel: 'claude-sonnet-4-6', + }, + + // ── Official: OpenAI / Codex ── + { + id: 'codex-oauth', + label: 'OpenAI / Codex (Subscription)', + description: 'Use your ChatGPT subscription', + category: 'official', + backend: { value: 'codex', locked: true, hidden: true }, + loginMethod: { value: 'codex-oauth', locked: true, hidden: true }, + apiKey: { value: '', locked: true, hidden: true }, + models: [ + { id: 'gpt-5.4', label: 'GPT 5.4' }, + { id: 'gpt-5.4-mini', label: 'GPT 5.4 Mini' }, + ], + modelOptional: true, + defaultModel: 'gpt-5.4', + }, + { + id: 'codex-api', + label: 'OpenAI (API Key)', + description: 'Pay per token via OpenAI API', + category: 'official', + backend: { value: 'codex', locked: true, hidden: true }, + loginMethod: { value: 'api-key', locked: true, hidden: true }, + apiKey: { value: '', locked: false, required: true }, + models: [ + { id: 'gpt-5.4', label: 'GPT 5.4' }, + { id: 'gpt-5.4-mini', label: 'GPT 5.4 Mini' }, + ], + defaultModel: 'gpt-5.4', + }, + + // ── Official: Gemini ── + { + id: 'gemini', + label: 'Google Gemini', + description: 'Google AI via API key', + category: 'official', + backend: { value: 'vercel-ai-sdk', locked: true, hidden: true }, + provider: { value: 'google', locked: true, hidden: true }, + apiKey: { value: '', locked: false, required: true }, + models: [ + { id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, + { id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + ], + defaultModel: 'gemini-2.5-flash', + }, + + // ── Third-party: MiniMax ── + { + id: 'minimax', + label: 'MiniMax', + description: 'MiniMax models via Anthropic-compatible API', + category: 'third-party', + backend: { value: 'vercel-ai-sdk', locked: true, hidden: true }, + provider: { value: 'anthropic', locked: true, hidden: true }, + baseUrl: { value: 'https://api.minimaxi.com/anthropic', locked: true }, + apiKey: { value: '', locked: false, required: true }, + models: [ + { id: 'MiniMax-M2.7', label: 'MiniMax M2.7' }, + ], + defaultModel: 'MiniMax-M2.7', + }, + + // ── Custom ── + { + id: 'custom', + label: 'Custom', + description: 'Full control — any provider, model, and endpoint', + category: 'custom', + backend: { value: 'vercel-ai-sdk', locked: false }, + provider: { value: 'openai', locked: false }, + loginMethod: { value: 'api-key', locked: false }, + baseUrl: { value: '', locked: false }, + apiKey: { value: '', locked: false }, + models: [], + }, +] diff --git a/src/connectors/web/routes/config.ts b/src/connectors/web/routes/config.ts index b221b685..7e69b53d 100644 --- a/src/connectors/web/routes/config.ts +++ b/src/connectors/web/routes/config.ts @@ -5,6 +5,7 @@ import { 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 { onConnectorsChange?: () => Promise @@ -108,6 +109,9 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) { } }) + /** GET /presets — built-in preset templates for profile creation */ + app.get('/presets', (c) => c.json({ presets: BUILTIN_PRESETS })) + app.get('/api-keys/status', async (c) => { try { const config = await readAIProviderConfig() diff --git a/ui/src/api/config.ts b/ui/src/api/config.ts index 08ff00c7..76adfceb 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') diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index dbe1f73f..d6840c4b 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -34,6 +34,9 @@ export type { WebChannel, Profile, AIBackend, + Preset, + PresetField, + PresetModelOption, ChatMessage, ChatResponse, ToolCall, diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index ba3848a8..838cc884 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -12,6 +12,35 @@ export interface Profile { apiKey?: string } +// ==================== AI Provider Presets ==================== + +export interface PresetModelOption { + id: string + label: string +} + +export interface PresetField { + value: T + locked: boolean + hidden?: boolean + required?: boolean +} + +export interface Preset { + id: string + label: string + description: string + category: 'official' | 'third-party' | 'custom' + backend: PresetField + loginMethod?: PresetField + provider?: PresetField + baseUrl?: PresetField + apiKey?: PresetField + models: PresetModelOption[] + defaultModel?: string + modelOptional?: boolean +} + // ==================== Channels ==================== export interface WebChannel { diff --git a/ui/src/pages/AIProviderPage.tsx b/ui/src/pages/AIProviderPage.tsx index 9f1b8d51..28527d6c 100644 --- a/ui/src/pages/AIProviderPage.tsx +++ b/ui/src/pages/AIProviderPage.tsx @@ -1,5 +1,5 @@ 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 type { SaveStatus } from '../hooks/useAutoSave' @@ -8,25 +8,10 @@ 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: , - }, -} - -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' }, +const BACKEND_ICONS: Record = { + 'agent-sdk': , + 'codex': , + 'vercel-ai-sdk': , } // ==================== Main Page ==================== @@ -35,8 +20,9 @@ export function AIProviderPage() { const [profiles, setProfiles] = useState | null>(null) const [activeProfile, setActiveProfile] = useState('') const [apiKeys, setApiKeys] = useState<{ anthropic?: string; openai?: string; google?: string }>({}) + const [presets, setPresets] = useState([]) const [selectedSlug, setSelectedSlug] = useState(null) - const [creating, setCreating] = useState(null) + const [creatingPreset, setCreatingPreset] = useState(null) useEffect(() => { api.config.getProfiles().then(({ profiles: p, activeProfile: a }) => { @@ -44,6 +30,7 @@ export function AIProviderPage() { setActiveProfile(a) setSelectedSlug(a) }).catch(() => {}) + api.config.getPresets().then(({ presets: p }) => setPresets(p)).catch(() => {}) api.config.getApiKeysStatus().then((status) => { setApiKeys({ ...(status.anthropic ? { anthropic: '(set)' } : {}), @@ -57,7 +44,7 @@ export function AIProviderPage() { try { await api.config.setActiveProfile(slug) setActiveProfile(slug) - } catch { /* keep old state */ } + } catch {} } const handleDelete = async (slug: string) => { @@ -68,33 +55,34 @@ export function AIProviderPage() { delete updated[slug] setProfiles(updated) if (selectedSlug === slug) setSelectedSlug(activeProfile) - } catch { /* keep old state */ } - } - - const handleCreateStart = (backend: AIBackend) => { - setCreating(backend) - setSelectedSlug(null) + } catch {} } 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 */ } + await api.config.createProfile(slug, profile) + setProfiles((p) => p ? { ...p, [slug]: profile } : p) + setCreatingPreset(null) + setSelectedSlug(slug) } 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 + // Find the preset that matches the selected profile (for constraint-aware editing) + const selectedPreset = selectedProfile + ? presets.find(p => p.backend.value === selectedProfile.backend + && (!p.loginMethod || p.loginMethod.value === selectedProfile.loginMethod) + && (!p.provider || p.provider.value === selectedProfile.provider)) + : null + + 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') return (
@@ -106,67 +94,100 @@ export function AIProviderPage() {
{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) => ( - - ))} + {/* New Profile — Preset Cards */} +
+

New Profile

+
+ {officialPresets.map((preset) => ( + + ))} + {thirdPartyPresets.map((preset) => ( + + ))} + {customPreset && ( + + )} +
{/* Create Form */} - {creating && ( - - + setCreating(null)} + onCancel={() => setCreatingPreset(null)} /> )} {/* Edit Form */} - {selectedProfile && selectedSlug && !creating && ( - + {selectedProfile && selectedSlug && !creatingPreset && ( + handleProfileUpdate(selectedSlug, p)} onSetActive={() => handleSetActive(selectedSlug)} @@ -186,35 +207,41 @@ export function AIProviderPage() { ) } -// ==================== Profile Form (Create) ==================== +// ==================== Preset-driven Profile Form (Create) ==================== -function ProfileForm({ backend, onSave, onCancel }: { - backend: AIBackend +function PresetProfileForm({ preset, onSave, onCancel }: { + preset: Preset 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 [model, setModel] = useState(preset.defaultModel ?? '') + const [customModel, setCustomModel] = useState('') + const [loginMethod, setLoginMethod] = useState(preset.loginMethod?.value ?? '') + const [provider, setProvider] = useState(preset.provider?.value ?? '') + const [baseUrl, setBaseUrl] = useState(preset.baseUrl?.value ?? '') + const [apiKey, setApiKey] = useState('') const [saving, setSaving] = useState(false) const [error, setError] = useState('') + const effectiveModel = model === '__custom__' ? customModel : model + const handleSave = async () => { if (!label.trim()) { setError('Label is required'); return } + if (!preset.modelOptional && !effectiveModel) { setError('Model is required'); return } + if (preset.apiKey?.required && !apiKey) { setError('API key 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, + backend: preset.backend.value, label: label.trim(), - model, + model: effectiveModel, ...(loginMethod ? { loginMethod } : {}), - ...(backend === 'vercel-ai-sdk' ? { provider } : {}), + ...(provider ? { provider } : {}), ...(baseUrl ? { baseUrl } : {}), + ...(apiKey ? { apiKey } : {}), } try { await onSave(slug, profile) @@ -227,10 +254,17 @@ function ProfileForm({ backend, onSave, onCancel }: { return (
- - setLabel(e.target.value)} placeholder="e.g. Claude Main, GPT Fast" /> + + setLabel(e.target.value)} placeholder={`e.g. My ${preset.label}`} /> - + {error &&

{error}

}
@@ -242,44 +276,52 @@ function ProfileForm({ backend, onSave, onCancel }: { // ==================== Profile Editor (Edit existing) ==================== -function ProfileEditor({ slug, profile, isActive, onUpdate, onSetActive, onDelete }: { +function ProfileEditor({ slug, profile, preset, isActive, onUpdate, onSetActive, onDelete }: { slug: string profile: Profile + preset: Preset | null | undefined isActive: boolean onUpdate: (profile: Profile) => Promise onSetActive: () => void onDelete: () => void }) { + const isPresetModel = preset?.models.some(m => m.id === profile.model) const [label, setLabel] = useState(profile.label) - const [model, setModel] = useState(profile.model) + const [model, setModel] = useState(isPresetModel ? profile.model : (profile.model ? '__custom__' : '')) + const [customModel, setCustomModel] = useState(isPresetModel ? '' : profile.model) const [loginMethod, setLoginMethod] = useState(profile.loginMethod ?? '') - const [provider, setProvider] = useState(profile.provider ?? 'anthropic') + const [provider, setProvider] = useState(profile.provider ?? '') const [baseUrl, setBaseUrl] = useState(profile.baseUrl ?? '') + const [apiKey, setApiKey] = useState('') const [status, setStatus] = useState('idle') const savedTimer = useRef | null>(null) - // Reset form when selected profile changes useEffect(() => { + const isPreset = preset?.models.some(m => m.id === profile.model) setLabel(profile.label) - setModel(profile.model) + setModel(isPreset ? profile.model : (profile.model ? '__custom__' : '')) + setCustomModel(isPreset ? '' : profile.model) setLoginMethod(profile.loginMethod ?? '') - setProvider(profile.provider ?? 'anthropic') + setProvider(profile.provider ?? '') setBaseUrl(profile.baseUrl ?? '') + setApiKey('') setStatus('idle') - }, [slug, profile]) + }, [slug, profile, preset]) useEffect(() => () => { if (savedTimer.current) clearTimeout(savedTimer.current) }, []) + const effectiveModel = model === '__custom__' ? customModel : model + const handleSave = async () => { setStatus('saving') const updated: Profile = { backend: profile.backend, label: label.trim() || profile.label, - model, + model: effectiveModel, ...(loginMethod ? { loginMethod } : {}), - ...(profile.backend === 'vercel-ai-sdk' ? { provider } : {}), + ...(provider ? { provider } : {}), ...(baseUrl ? { baseUrl } : {}), - ...(profile.apiKey ? { apiKey: profile.apiKey } : {}), + ...(apiKey ? { apiKey } : profile.apiKey ? { apiKey: profile.apiKey } : {}), } try { await onUpdate(updated) @@ -291,75 +333,148 @@ function ProfileEditor({ slug, profile, isActive, onUpdate, onSetActive, onDelet } } + // Use preset if available, otherwise build a minimal "custom" view + const editPreset = preset ?? { + id: 'custom', label: 'Custom', description: '', category: 'custom' as const, + backend: { value: profile.backend, locked: true, hidden: true }, + loginMethod: profile.loginMethod ? { value: profile.loginMethod, locked: false } : undefined, + provider: profile.provider ? { value: profile.provider, locked: false } : undefined, + baseUrl: { value: profile.baseUrl ?? '', locked: false }, + apiKey: { value: '', locked: false }, + models: [], + } + return (
- + setLabel(e.target.value)} /> - +
- {!isActive && ( - - )} - {!isActive && ( - - )} + {!isActive && } + {!isActive && }
) } -// ==================== Shared Profile Fields ==================== +// ==================== Preset-aware Fields ==================== -function ProfileFields({ backend, model, setModel, loginMethod, setLoginMethod, provider, setProvider, baseUrl, setBaseUrl }: { - backend: AIBackend +function PresetFields({ preset, model, setModel, customModel, setCustomModel, loginMethod, setLoginMethod, provider, setProvider, baseUrl, setBaseUrl, apiKey, setApiKey, existingApiKey }: { + preset: Preset model: string; setModel: (v: string) => void + customModel: string; setCustomModel: (v: string) => void loginMethod: string; setLoginMethod: (v: string) => void provider: string; setProvider: (v: string) => void baseUrl: string; setBaseUrl: (v: string) => void + apiKey: string; setApiKey: (v: string) => void + existingApiKey?: boolean }) { + const f = preset + return ( <> - {/* Login Method (agent-sdk and codex only) */} - {(backend === 'agent-sdk' || backend === 'codex') && ( + {/* Login Method */} + {f.loginMethod && !f.loginMethod.hidden && ( - + {f.loginMethod.locked ? ( +

{f.loginMethod.value}

+ ) : ( + + )}
)} - {/* Provider (vercel-ai-sdk only) */} - {backend === 'vercel-ai-sdk' && ( + {/* Provider */} + {f.provider && !f.provider.hidden && ( - + {f.provider.locked ? ( +

{f.provider.value}

+ ) : ( + + )}
)} - - setModel(e.target.value)} placeholder="e.g. claude-sonnet-4-6, gpt-5.4" /> + {/* Model */} + + {f.models.length > 0 ? ( + <> + + {model === '__custom__' && ( + setCustomModel(e.target.value)} + placeholder="Enter model ID" + /> + )} + + ) : ( + { setModel(e.target.value); setCustomModel(e.target.value) }} + placeholder={f.modelOptional ? 'Leave empty for auto' : 'e.g. claude-sonnet-4-6, gpt-5.4'} + /> + )} - - setBaseUrl(e.target.value)} placeholder="Leave empty for default" /> - + {/* Base URL */} + {f.baseUrl && !f.baseUrl.hidden && ( + + {f.baseUrl.locked ? ( +

{f.baseUrl.value}

+ ) : ( + setBaseUrl(e.target.value)} placeholder="Leave empty for default" /> + )} +
+ )} + + {/* API Key */} + {f.apiKey && !f.apiKey.hidden && !f.apiKey.locked && ( + +
+ setApiKey(e.target.value)} + placeholder={existingApiKey ? '(configured — leave empty to keep)' : 'Enter API key'} + /> + {existingApiKey && !apiKey && ( + active + )} +
+
+ )} ) } From 081b4601f0d5c45863a5aca80c12dd988240cc07 Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 13:56:32 +0800 Subject: [PATCH 03/18] feat: modal-based profile UI + remove global API keys UX redesign: - Main page is a clean profile list with "Set Default" / "Edit" buttons - Edit opens a modal (preset-aware form with locked/hidden fields) - New Profile opens a modal (step 1: choose preset, step 2: fill form) - No more inline forms or mixed state between list and editor Remove global API keys: - apiKeys field migrated into individual profile.apiKey fields - resolveProfile() no longer falls back to global keys - Removed PUT /api-keys and GET /api-keys/status endpoints - Removed frontend ApiKeysForm and related API methods Co-Authored-By: Claude Opus 4.6 (1M context) --- src/connectors/web/routes/config.ts | 28 +- src/core/config.ts | 45 +- ui/src/api/config.ts | 16 - ui/src/pages/AIProviderPage.tsx | 630 ++++++++++++---------------- 4 files changed, 299 insertions(+), 420 deletions(-) diff --git a/src/connectors/web/routes/config.ts b/src/connectors/web/routes/config.ts index 7e69b53d..e100075e 100644 --- a/src/connectors/web/routes/config.ts +++ b/src/connectors/web/routes/config.ts @@ -1,7 +1,7 @@ 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' @@ -96,35 +96,11 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) { } }) - // ==================== 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) - } - }) + // ==================== Presets ==================== /** GET /presets — built-in preset templates for profile creation */ app.get('/presets', (c) => c.json({ presets: BUILTIN_PRESETS })) - app.get('/api-keys/status', async (c) => { - try { - const config = await readAIProviderConfig() - return c.json({ - anthropic: !!config.apiKeys.anthropic, - openai: !!config.apiKeys.openai, - google: !!config.apiKeys.google, - }) - } catch (err) { - return c.json({ error: String(err) }, 500) - } - }) - // ==================== Generic Section Writer ==================== app.put('/:section', async (c) => { diff --git a/src/core/config.ts b/src/core/config.ts index 9dbb477a..8cae4836 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -422,6 +422,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) { @@ -567,19 +594,13 @@ export interface ResolvedProfile { 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 +635,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/ui/src/api/config.ts b/ui/src/api/config.ts index 76adfceb..daf85eec 100644 --- a/ui/src/api/config.ts +++ b/ui/src/api/config.ts @@ -81,20 +81,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/pages/AIProviderPage.tsx b/ui/src/pages/AIProviderPage.tsx index 28527d6c..5417bf7c 100644 --- a/ui/src/pages/AIProviderPage.tsx +++ b/ui/src/pages/AIProviderPage.tsx @@ -1,17 +1,17 @@ import { useState, useEffect, useRef } from 'react' 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 type { SaveStatus } from '../hooks/useAutoSave' import { PageHeader } from '../components/PageHeader' import { PageLoading } from '../components/StateViews' -// ==================== Constants ==================== +// ==================== Icons ==================== const BACKEND_ICONS: Record = { - 'agent-sdk': , - 'codex': , - 'vercel-ai-sdk': , + 'agent-sdk': , + 'codex': , + 'vercel-ai-sdk': , } // ==================== Main Page ==================== @@ -19,25 +19,16 @@ const BACKEND_ICONS: 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 [presets, setPresets] = useState([]) - const [selectedSlug, setSelectedSlug] = useState(null) - const [creatingPreset, setCreatingPreset] = useState(null) + 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.getPresets().then(({ presets: p }) => setPresets(p)).catch(() => {}) - api.config.getApiKeysStatus().then((status) => { - setApiKeys({ - ...(status.anthropic ? { anthropic: '(set)' } : {}), - ...(status.openai ? { openai: '(set)' } : {}), - ...(status.google ? { google: '(set)' } : {}), - }) - }).catch(() => {}) }, []) const handleSetActive = async (slug: string) => { @@ -54,15 +45,14 @@ export function AIProviderPage() { const updated = { ...profiles } delete updated[slug] setProfiles(updated) - if (selectedSlug === slug) setSelectedSlug(activeProfile) + setEditingSlug(null) } catch {} } const handleCreateSave = async (slug: string, profile: Profile) => { await api.config.createProfile(slug, profile) setProfiles((p) => p ? { ...p, [slug]: profile } : p) - setCreatingPreset(null) - setSelectedSlug(slug) + setShowCreate(false) } const handleProfileUpdate = async (slug: string, profile: Profile) => { @@ -70,222 +60,126 @@ export function AIProviderPage() { setProfiles((p) => p ? { ...p, [slug]: profile } : p) } - if (!profiles) return
- - const selectedProfile = selectedSlug ? profiles[selectedSlug] : null - // Find the preset that matches the selected profile (for constraint-aware editing) - const selectedPreset = selectedProfile - ? presets.find(p => p.backend.value === selectedProfile.backend - && (!p.loginMethod || p.loginMethod.value === selectedProfile.loginMethod) - && (!p.provider || p.provider.value === selectedProfile.provider)) - : null - - 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') + if (!profiles) return
return (
- +
-
+
{/* Profile List */} - -
- {Object.entries(profiles).map(([slug, profile]) => { - const isActive = slug === activeProfile - const isSelected = slug === selectedSlug - return ( - - ) - })} -
- - {/* New Profile — Preset Cards */} -
-

New Profile

-
- {officialPresets.map((preset) => ( - - ))} - {thirdPartyPresets.map((preset) => ( - - ))} - {customPreset && ( + {Object.entries(profiles).map(([slug, profile]) => { + const isActive = slug === activeProfile + return ( +
+
{BACKEND_ICONS[profile.backend]}
+
+
+ {profile.label} + {isActive && Active} +
+

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

+
+
+ {!isActive && ( + + )} - )} +
-
- - - {/* Create Form */} - {creatingPreset && ( - - setCreatingPreset(null)} - /> - - )} + ) + })} - {/* Edit Form */} - {selectedProfile && selectedSlug && !creatingPreset && ( - - handleProfileUpdate(selectedSlug, p)} - onSetActive={() => handleSetActive(selectedSlug)} - onDelete={() => handleDelete(selectedSlug)} - /> - - )} - - {/* Global API Keys */} - - - + {/* New Profile Button */} +
+ + {/* Edit Modal */} + {editingSlug && profiles[editingSlug] && ( + handleProfileUpdate(editingSlug, p)} + onDelete={() => handleDelete(editingSlug)} + onClose={() => setEditingSlug(null)} + /> + )} + + {/* Create Modal */} + {showCreate && ( + setShowCreate(false)} + /> + )}
) } -// ==================== Preset-driven Profile Form (Create) ==================== - -function PresetProfileForm({ preset, onSave, onCancel }: { - preset: Preset - onSave: (slug: string, profile: Profile) => Promise - onCancel: () => void -}) { - const [label, setLabel] = useState('') - const [model, setModel] = useState(preset.defaultModel ?? '') - const [customModel, setCustomModel] = useState('') - const [loginMethod, setLoginMethod] = useState(preset.loginMethod?.value ?? '') - const [provider, setProvider] = useState(preset.provider?.value ?? '') - const [baseUrl, setBaseUrl] = useState(preset.baseUrl?.value ?? '') - const [apiKey, setApiKey] = useState('') - const [saving, setSaving] = useState(false) - const [error, setError] = useState('') - - const effectiveModel = model === '__custom__' ? customModel : model - - const handleSave = async () => { - if (!label.trim()) { setError('Label is required'); return } - if (!preset.modelOptional && !effectiveModel) { setError('Model is required'); return } - if (preset.apiKey?.required && !apiKey) { setError('API key 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: preset.backend.value, - label: label.trim(), - model: effectiveModel, - ...(loginMethod ? { loginMethod } : {}), - ...(provider ? { provider } : {}), - ...(baseUrl ? { baseUrl } : {}), - ...(apiKey ? { apiKey } : {}), - } - try { - await onSave(slug, profile) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save') - } finally { - setSaving(false) - } - } +// ==================== Modal Shell ==================== +function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) { return ( -
- - setLabel(e.target.value)} placeholder={`e.g. My ${preset.label}`} /> - - - {error &&

{error}

} -
- - +
+
e.stopPropagation()}> +
+

{title}

+ +
+
+ {children} +
) } -// ==================== Profile Editor (Edit existing) ==================== +// ==================== Edit Modal ==================== -function ProfileEditor({ slug, profile, preset, isActive, onUpdate, onSetActive, onDelete }: { +function ProfileEditModal({ slug, profile, presets, isActive, onSave, onDelete, onClose }: { slug: string profile: Profile - preset: Preset | null | undefined + presets: Preset[] isActive: boolean - onUpdate: (profile: Profile) => Promise - onSetActive: () => void + onSave: (profile: Profile) => Promise onDelete: () => void + onClose: () => void }) { - const isPresetModel = preset?.models.some(m => m.id === profile.model) + const preset = presets.find(p => + p.backend.value === profile.backend + && (!p.loginMethod || p.loginMethod.value === profile.loginMethod) + && (!p.provider || p.provider.value === profile.provider) + ) ?? presets.find(p => p.category === 'custom')! + + const isPresetModel = preset.models.some(m => m.id === profile.model) const [label, setLabel] = useState(profile.label) const [model, setModel] = useState(isPresetModel ? profile.model : (profile.model ? '__custom__' : '')) const [customModel, setCustomModel] = useState(isPresetModel ? '' : profile.model) @@ -296,76 +190,182 @@ function ProfileEditor({ slug, profile, preset, isActive, onUpdate, onSetActive, const [status, setStatus] = useState('idle') const savedTimer = useRef | null>(null) - useEffect(() => { - const isPreset = preset?.models.some(m => m.id === profile.model) - setLabel(profile.label) - setModel(isPreset ? profile.model : (profile.model ? '__custom__' : '')) - setCustomModel(isPreset ? '' : profile.model) - setLoginMethod(profile.loginMethod ?? '') - setProvider(profile.provider ?? '') - setBaseUrl(profile.baseUrl ?? '') - setApiKey('') - setStatus('idle') - }, [slug, profile, preset]) - useEffect(() => () => { if (savedTimer.current) clearTimeout(savedTimer.current) }, []) const effectiveModel = model === '__custom__' ? customModel : model const handleSave = async () => { setStatus('saving') - const updated: Profile = { - backend: profile.backend, - label: label.trim() || profile.label, - model: effectiveModel, - ...(loginMethod ? { loginMethod } : {}), - ...(provider ? { provider } : {}), - ...(baseUrl ? { baseUrl } : {}), - ...(apiKey ? { apiKey } : profile.apiKey ? { apiKey: profile.apiKey } : {}), - } try { - await onUpdate(updated) + await onSave({ + backend: profile.backend, + label: label.trim() || profile.label, + model: effectiveModel, + ...(loginMethod ? { loginMethod } : {}), + ...(provider ? { provider } : {}), + ...(baseUrl ? { baseUrl } : {}), + ...(apiKey ? { apiKey } : profile.apiKey ? { apiKey: profile.apiKey } : {}), + }) 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') } } - // Use preset if available, otherwise build a minimal "custom" view - const editPreset = preset ?? { - id: 'custom', label: 'Custom', description: '', category: 'custom' as const, - backend: { value: profile.backend, locked: true, hidden: true }, - loginMethod: profile.loginMethod ? { value: profile.loginMethod, locked: false } : undefined, - provider: profile.provider ? { value: profile.provider, locked: false } : undefined, - baseUrl: { value: profile.baseUrl ?? '', locked: false }, - apiKey: { value: '', locked: false }, - models: [], + return ( + +
+ + setLabel(e.target.value)} /> + + +
+ + +
+ {!isActive && } +
+
+ + ) +} + +// ==================== Create Modal ==================== + +function ProfileCreateModal({ presets, onSave, onClose }: { + presets: Preset[] + onSave: (slug: string, profile: Profile) => Promise + onClose: () => void +}) { + const [selectedPreset, setSelectedPreset] = useState(null) + const [label, setLabel] = useState('') + const [model, setModel] = useState('') + const [customModel, setCustomModel] = useState('') + const [loginMethod, setLoginMethod] = useState('') + const [provider, setProvider] = useState('') + const [baseUrl, setBaseUrl] = useState('') + const [apiKey, setApiKey] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + const selectPreset = (preset: Preset) => { + setSelectedPreset(preset) + setLabel('') + setModel(preset.defaultModel ?? '') + setCustomModel('') + setLoginMethod(preset.loginMethod?.value ?? '') + setProvider(preset.provider?.value ?? '') + setBaseUrl(preset.baseUrl?.value ?? '') + setApiKey('') + setError('') + } + + const effectiveModel = model === '__custom__' ? customModel : model + + const handleCreate = async () => { + if (!selectedPreset || !label.trim()) { setError('Profile name is required'); return } + if (!selectedPreset.modelOptional && !effectiveModel) { setError('Model is required'); return } + if (selectedPreset.apiKey?.required && !apiKey) { setError('API key is required'); return } + setSaving(true) + setError('') + const slug = label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + if (!slug) { setError('Invalid name for slug'); setSaving(false); return } + try { + await onSave(slug, { + backend: selectedPreset.backend.value, + label: label.trim(), + model: effectiveModel, + ...(loginMethod ? { loginMethod } : {}), + ...(provider ? { provider } : {}), + ...(baseUrl ? { baseUrl } : {}), + ...(apiKey ? { apiKey } : {}), + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create') + } finally { setSaving(false) } } + 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') + return ( -
- - setLabel(e.target.value)} /> - - -
- - -
- {!isActive && } - {!isActive && } -
-
+ + {!selectedPreset ? ( + /* Step 1: Choose Preset */ +
+ {officialPresets.length > 0 && ( +
+

Official

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

Third Party

+
+ {thirdPartyPresets.map((p) => ( + + ))} +
+
+ )} + {customPreset && ( + + )} +
+ ) : ( + /* Step 2: Fill Fields */ +
+ + setLabel(e.target.value)} placeholder={`e.g. My ${selectedPreset.label}`} autoFocus /> + + + {error &&

{error}

} +
+ + +
+
+ )} +
) } @@ -382,10 +382,8 @@ function PresetFields({ preset, model, setModel, customModel, setCustomModel, lo existingApiKey?: boolean }) { const f = preset - return ( <> - {/* Login Method */} {f.loginMethod && !f.loginMethod.hidden && ( {f.loginMethod.locked ? ( @@ -399,8 +397,6 @@ function PresetFields({ preset, model, setModel, customModel, setCustomModel, lo )} )} - - {/* Provider */} {f.provider && !f.provider.hidden && ( {f.provider.locked ? ( @@ -414,42 +410,26 @@ function PresetFields({ preset, model, setModel, customModel, setCustomModel, lo )} )} - - {/* Model */} {f.models.length > 0 ? ( <> - { setModel(e.target.value); if (e.target.value !== '__custom__') setCustomModel('') }}> {f.modelOptional && } {f.models.map((m) => )} {model === '__custom__' && ( - setCustomModel(e.target.value)} - placeholder="Enter model ID" - /> + setCustomModel(e.target.value)} placeholder="Enter model ID" /> )} ) : ( - { setModel(e.target.value); setCustomModel(e.target.value) }} - placeholder={f.modelOptional ? 'Leave empty for auto' : 'e.g. claude-sonnet-4-6, gpt-5.4'} - /> + placeholder={f.modelOptional ? 'Leave empty for auto' : 'e.g. claude-sonnet-4-6, gpt-5.4'} /> )} - - {/* Base URL */} {f.baseUrl && !f.baseUrl.hidden && ( - + {f.baseUrl.locked ? (

{f.baseUrl.value}

) : ( @@ -457,18 +437,11 @@ function PresetFields({ preset, model, setModel, customModel, setCustomModel, lo )}
)} - - {/* API Key */} {f.apiKey && !f.apiKey.hidden && !f.apiKey.locked && ( - +
- setApiKey(e.target.value)} - placeholder={existingApiKey ? '(configured — leave empty to keep)' : 'Enter API key'} - /> + setApiKey(e.target.value)} + placeholder={existingApiKey ? '(configured — leave empty to keep)' : 'Enter API key'} /> {existingApiKey && !apiKey && ( active )} @@ -478,70 +451,3 @@ function PresetFields({ preset, model, setModel, customModel, setCustomModel, lo ) } - -// ==================== Global API Keys ==================== - -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 handleSave = async () => { - setStatus('saving') - 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') - } - } - - const fields = [ - { key: 'anthropic', label: 'Anthropic', placeholder: 'sk-ant-...' }, - { key: 'openai', label: 'OpenAI', placeholder: 'sk-...' }, - { key: 'google', label: 'Google', placeholder: 'AIza...' }, - ] as const - - return ( - <> - {fields.map((f) => ( - -
- setKeys((k) => ({ ...k, [f.key]: e.target.value }))} - placeholder={currentStatus[f.key] ? '(configured)' : f.placeholder} - /> - {currentStatus[f.key] && ( - active - )} -
-
- ))} -
- - -
- - ) -} From abfb2292631bc456c3050f67d551ce3b205cd989 Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 14:29:18 +0800 Subject: [PATCH 04/18] refactor: schema-driven preset forms via JSON Schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presets now generate JSON Schema from Zod definitions. Frontend renders forms dynamically from schema — no hardcoded field logic. Backend (presets.ts): - Each preset defined as a Zod schema + metadata (label, description, hint) - z.toJSONSchema() serializes to JSON Schema, post-processed for: - oneOf + const + title for model dropdowns with labels - writeOnly for password fields (apiKey) - z.literal() → const (hidden, value baked in) Frontend (AIProviderPage.tsx): - SchemaForm component: generic JSON Schema → form renderer - const → hidden, oneOf → labeled dropdown, writeOnly → password, enum → dropdown, string → text input - required/default/description from schema - Removes PresetFields, PresetField, PresetModelOption types - Preset hint renders as guidance text above form Adding a new provider preset only requires editing presets.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai-providers/presets.ts | 195 ++++++++++----- ui/src/api/index.ts | 4 +- ui/src/api/types.ts | 40 +-- ui/src/pages/AIProviderPage.tsx | 414 ++++++++++++++------------------ 4 files changed, 332 insertions(+), 321 deletions(-) diff --git a/src/ai-providers/presets.ts b/src/ai-providers/presets.ts index 191638c4..fc0b7397 100644 --- a/src/ai-providers/presets.ts +++ b/src/ai-providers/presets.ts @@ -1,69 +1,99 @@ /** - * AI Provider Presets — constraint-based templates for profile creation. + * AI Provider Presets — schema-driven templates for profile creation. * - * Each preset defines which fields are locked (not user-editable), - * hidden (not shown in UI), required (must be filled), and what - * models are available to choose from. + * Each preset produces a JSON Schema that tells the frontend exactly + * how to render the creation/edit form: + * - const fields → hidden (value baked in) + * - oneOf fields → dropdown with labels + * - writeOnly fields → password input + * - required / default / description → form behavior * - * Presets are NOT profiles — they are templates that guide profile - * creation. Users create concrete profiles from presets. + * Frontend is a pure renderer — no field logic, no hardcoded options. */ +import { z } from 'zod' import type { AIBackend } from '../core/config.js' -// ==================== Types ==================== +// ==================== Serialized Preset (sent to frontend) ==================== -export interface PresetModelOption { - /** Model ID as sent to the API (e.g. 'claude-sonnet-4-6'). */ +export interface SerializedPreset { id: string - /** Human-readable label (e.g. 'Claude Sonnet 4.6'). */ label: string + description: string + category: 'official' | 'third-party' | 'custom' + hint?: string + schema: Record } -export interface PresetField { - /** The preset value for this field. */ - value: T - /** If true, user cannot edit this field — it's baked into the preset. */ - locked: boolean - /** If true, field is not displayed in the UI (but its value is still used). */ - hidden?: boolean - /** If true, user must provide a value for this field (e.g. API key). */ - required?: boolean +// ==================== Model option with label ==================== + +interface ModelOption { + id: string + label: string } -export interface Preset { +// ==================== Internal preset definition ==================== + +interface PresetDef { id: string label: string description: string category: 'official' | 'third-party' | 'custom' - - // Field constraints (undefined = field not applicable to this preset) - backend: PresetField - loginMethod?: PresetField - provider?: PresetField - baseUrl?: PresetField - apiKey?: PresetField - - // Model selection - models: PresetModelOption[] - /** Default model ID to pre-select. */ - defaultModel?: string - /** If true, model can be left empty (OAuth mode — server picks based on plan). */ + hint?: string + zodSchema: z.ZodType + /** Models with human-readable labels. Post-processed into oneOf. */ + models?: ModelOption[] + /** Property name for the model field (default: 'model'). */ + modelField?: string + /** If true, model can be left empty. */ modelOptional?: boolean + /** Properties that should be rendered as password fields. */ + writeOnlyFields?: string[] } -// ==================== Built-in Presets ==================== +// ==================== Schema post-processing ==================== + +/** Convert a Zod schema to JSON Schema, then apply preset-specific transforms. */ +function buildJsonSchema(def: PresetDef): Record { + const raw = z.toJSONSchema(def.zodSchema) as Record + const props = (raw.properties ?? {}) as Record> -export const BUILTIN_PRESETS: Preset[] = [ + // Inject oneOf for model field (replace plain enum with labeled options) + const mf = def.modelField ?? 'model' + if (def.models?.length && props[mf]) { + const oneOf = def.models.map(m => ({ const: m.id, title: m.label })) + if (def.modelOptional) { + oneOf.unshift({ const: '', title: 'Auto (based on subscription plan)' }) + } + const { enum: _e, ...rest } = props[mf] + props[mf] = { ...rest, oneOf } + } + + // Mark writeOnly fields (rendered as password inputs) + for (const field of def.writeOnlyFields ?? []) { + if (props[field]) props[field].writeOnly = true + } + + raw.properties = props + return raw +} + +// ==================== Preset definitions ==================== + +const PRESET_DEFS: PresetDef[] = [ // ── Official: Claude ── { id: 'claude-oauth', label: 'Claude (Subscription)', description: 'Use your Claude Pro/Max subscription', category: 'official', - backend: { value: 'agent-sdk', locked: true, hidden: true }, - loginMethod: { value: 'claudeai', locked: true, hidden: true }, - apiKey: { value: '', locked: true, hidden: true }, + hint: 'Requires Claude Code CLI login. Run `claude login` in your terminal first.', + zodSchema: z.object({ + label: z.string().min(1).describe('Profile name'), + backend: z.literal('agent-sdk' as const), + loginMethod: z.literal('claudeai' as const), + model: z.string().optional().default('').describe('Leave empty to auto-select based on your plan'), + }), models: [ { id: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }, @@ -75,15 +105,19 @@ export const BUILTIN_PRESETS: Preset[] = [ label: 'Claude (API Key)', description: 'Pay per token via Anthropic API', category: 'official', - backend: { value: 'agent-sdk', locked: true, hidden: true }, - loginMethod: { value: 'api-key', locked: true, hidden: true }, - apiKey: { value: '', locked: false, required: true }, + zodSchema: z.object({ + label: z.string().min(1).describe('Profile name'), + backend: z.literal('agent-sdk' as const), + loginMethod: z.literal('api-key' as const), + 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' }, ], - defaultModel: 'claude-sonnet-4-6', + writeOnlyFields: ['apiKey'], }, // ── Official: OpenAI / Codex ── @@ -92,29 +126,36 @@ export const BUILTIN_PRESETS: Preset[] = [ label: 'OpenAI / Codex (Subscription)', description: 'Use your ChatGPT subscription', category: 'official', - backend: { value: 'codex', locked: true, hidden: true }, - loginMethod: { value: 'codex-oauth', locked: true, hidden: true }, - apiKey: { value: '', locked: true, hidden: true }, + hint: 'Requires Codex CLI login. Run `codex login` in your terminal first.', + zodSchema: z.object({ + label: z.string().min(1).describe('Profile name'), + backend: z.literal('codex' as const), + loginMethod: z.literal('codex-oauth' as const), + model: z.string().optional().default('gpt-5.4').describe('Leave empty to auto-select'), + }), models: [ { id: 'gpt-5.4', label: 'GPT 5.4' }, { id: 'gpt-5.4-mini', label: 'GPT 5.4 Mini' }, ], modelOptional: true, - defaultModel: 'gpt-5.4', }, { id: 'codex-api', label: 'OpenAI (API Key)', description: 'Pay per token via OpenAI API', category: 'official', - backend: { value: 'codex', locked: true, hidden: true }, - loginMethod: { value: 'api-key', locked: true, hidden: true }, - apiKey: { value: '', locked: false, required: true }, + zodSchema: z.object({ + label: z.string().min(1).describe('Profile name'), + backend: z.literal('codex' as const), + loginMethod: z.literal('api-key' as const), + 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' }, ], - defaultModel: 'gpt-5.4', + writeOnlyFields: ['apiKey'], }, // ── Official: Gemini ── @@ -123,14 +164,18 @@ export const BUILTIN_PRESETS: Preset[] = [ label: 'Google Gemini', description: 'Google AI via API key', category: 'official', - backend: { value: 'vercel-ai-sdk', locked: true, hidden: true }, - provider: { value: 'google', locked: true, hidden: true }, - apiKey: { value: '', locked: false, required: true }, + zodSchema: z.object({ + label: z.string().min(1).describe('Profile name'), + backend: z.literal('vercel-ai-sdk' as const), + provider: z.literal('google' as const), + 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' }, ], - defaultModel: 'gemini-2.5-flash', + writeOnlyFields: ['apiKey'], }, // ── Third-party: MiniMax ── @@ -139,14 +184,19 @@ export const BUILTIN_PRESETS: Preset[] = [ label: 'MiniMax', description: 'MiniMax models via Anthropic-compatible API', category: 'third-party', - backend: { value: 'vercel-ai-sdk', locked: true, hidden: true }, - provider: { value: 'anthropic', locked: true, hidden: true }, - baseUrl: { value: 'https://api.minimaxi.com/anthropic', locked: true }, - apiKey: { value: '', locked: false, required: true }, + hint: 'Get your API key at minimaxi.com', + zodSchema: z.object({ + label: z.string().min(1).describe('Profile name'), + backend: z.literal('vercel-ai-sdk' as const), + provider: z.literal('anthropic' as const), + 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' }, ], - defaultModel: 'MiniMax-M2.7', + writeOnlyFields: ['apiKey'], }, // ── Custom ── @@ -155,11 +205,26 @@ export const BUILTIN_PRESETS: Preset[] = [ label: 'Custom', description: 'Full control — any provider, model, and endpoint', category: 'custom', - backend: { value: 'vercel-ai-sdk', locked: false }, - provider: { value: 'openai', locked: false }, - loginMethod: { value: 'api-key', locked: false }, - baseUrl: { value: '', locked: false }, - apiKey: { value: '', locked: false }, - models: [], + zodSchema: z.object({ + label: z.string().min(1).describe('Profile name'), + 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'], }, ] + +// ==================== Exported: serialized presets ==================== + +export const BUILTIN_PRESETS: SerializedPreset[] = PRESET_DEFS.map(def => ({ + id: def.id, + label: def.label, + description: def.description, + category: def.category, + hint: def.hint, + schema: buildJsonSchema(def), +})) diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index d6840c4b..fe71bbd1 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -35,8 +35,8 @@ export type { Profile, AIBackend, Preset, - PresetField, - PresetModelOption, + JsonSchema, + JsonSchemaProperty, ChatMessage, ChatResponse, ToolCall, diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 838cc884..2cf8340a 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -14,31 +14,33 @@ export interface Profile { // ==================== AI Provider Presets ==================== -export interface PresetModelOption { +export interface Preset { id: string label: string + description: string + category: 'official' | 'third-party' | 'custom' + hint?: string + schema: JsonSchema } -export interface PresetField { - value: T - locked: boolean - hidden?: boolean - required?: boolean +/** Subset of JSON Schema types we use for form rendering. */ +export interface JsonSchema { + type?: string + properties?: Record + required?: string[] + [key: string]: unknown } -export interface Preset { - id: string - label: string - description: string - category: 'official' | 'third-party' | 'custom' - backend: PresetField - loginMethod?: PresetField - provider?: PresetField - baseUrl?: PresetField - apiKey?: PresetField - models: PresetModelOption[] - defaultModel?: string - modelOptional?: boolean +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 ==================== diff --git a/ui/src/pages/AIProviderPage.tsx b/ui/src/pages/AIProviderPage.tsx index 5417bf7c..b88350c3 100644 --- a/ui/src/pages/AIProviderPage.tsx +++ b/ui/src/pages/AIProviderPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react' -import { api, type Profile, type AIBackend, type Preset } from '../api' +import { api, type Profile, type AIBackend, type Preset, type JsonSchemaProperty } from '../api' import { SaveIndicator } from '../components/SaveIndicator' import { Field, inputClass } from '../components/form' import type { SaveStatus } from '../hooks/useAutoSave' @@ -32,20 +32,15 @@ export function AIProviderPage() { }, []) const handleSetActive = async (slug: string) => { - try { - await api.config.setActiveProfile(slug) - setActiveProfile(slug) - } catch {} + 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) - setEditingSlug(null) + const updated = { ...profiles }; delete updated[slug] + setProfiles(updated); setEditingSlug(null) } catch {} } @@ -67,17 +62,10 @@ export function AIProviderPage() {
- - {/* Profile List */} {Object.entries(profiles).map(([slug, profile]) => { const isActive = slug === activeProfile return ( -
+
{BACKEND_ICONS[profile.backend]}
@@ -87,57 +75,24 @@ export function AIProviderPage() {

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

- {!isActive && ( - - )} - + {!isActive && } +
) })} - - {/* New Profile Button */} - - +
- {/* Edit Modal */} {editingSlug && profiles[editingSlug] && ( - handleProfileUpdate(editingSlug, p)} onDelete={() => handleDelete(editingSlug)} - onClose={() => setEditingSlug(null)} - /> - )} - - {/* Create Modal */} - {showCreate && ( - setShowCreate(false)} - /> + onClose={() => setEditingSlug(null)} /> )} + {showCreate && setShowCreate(false)} />}
) } @@ -154,9 +109,7 @@ function Modal({ title, onClose, children }: { title: string; onClose: () => voi
-
- {children} -
+
{children}
) @@ -165,47 +118,22 @@ function Modal({ title, onClose, children }: { title: string; onClose: () => voi // ==================== Edit Modal ==================== 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 + slug: string; profile: Profile; presets: Preset[]; isActive: boolean + onSave: (profile: Profile) => Promise; onDelete: () => void; onClose: () => void }) { - const preset = presets.find(p => - p.backend.value === profile.backend - && (!p.loginMethod || p.loginMethod.value === profile.loginMethod) - && (!p.provider || p.provider.value === profile.provider) - ) ?? presets.find(p => p.category === 'custom')! - - const isPresetModel = preset.models.some(m => m.id === profile.model) - const [label, setLabel] = useState(profile.label) - const [model, setModel] = useState(isPresetModel ? profile.model : (profile.model ? '__custom__' : '')) - const [customModel, setCustomModel] = useState(isPresetModel ? '' : profile.model) - const [loginMethod, setLoginMethod] = useState(profile.loginMethod ?? '') - const [provider, setProvider] = useState(profile.provider ?? '') - const [baseUrl, setBaseUrl] = useState(profile.baseUrl ?? '') - const [apiKey, setApiKey] = useState('') + const preset = findPresetForProfile(profile, presets) + const [formData, setFormData] = useState>(() => profileToFormData(profile)) const [status, setStatus] = useState('idle') const savedTimer = useRef | null>(null) + useEffect(() => { setFormData(profileToFormData(profile)); setStatus('idle') }, [slug, profile]) useEffect(() => () => { if (savedTimer.current) clearTimeout(savedTimer.current) }, []) - const effectiveModel = model === '__custom__' ? customModel : model - const handleSave = async () => { setStatus('saving') try { - await onSave({ - backend: profile.backend, - label: label.trim() || profile.label, - model: effectiveModel, - ...(loginMethod ? { loginMethod } : {}), - ...(provider ? { provider } : {}), - ...(baseUrl ? { baseUrl } : {}), - ...(apiKey ? { apiKey } : profile.apiKey ? { apiKey: profile.apiKey } : {}), - }) + const merged = mergeFormWithConsts(formData, preset?.schema) + await onSave(merged as unknown as Profile) setStatus('saved') if (savedTimer.current) clearTimeout(savedTimer.current) savedTimer.current = setTimeout(() => { setStatus('idle'); onClose() }, 1000) @@ -215,18 +143,8 @@ function ProfileEditModal({ slug, profile, presets, isActive, onSave, onDelete, return (
- - setLabel(e.target.value)} /> - - + {preset?.hint &&

{preset.hint}

} +
@@ -241,53 +159,37 @@ function ProfileEditModal({ slug, profile, presets, isActive, onSave, onDelete, // ==================== Create Modal ==================== function ProfileCreateModal({ presets, onSave, onClose }: { - presets: Preset[] - onSave: (slug: string, profile: Profile) => Promise - onClose: () => void + presets: Preset[]; onSave: (slug: string, profile: Profile) => Promise; onClose: () => void }) { const [selectedPreset, setSelectedPreset] = useState(null) - const [label, setLabel] = useState('') - const [model, setModel] = useState('') - const [customModel, setCustomModel] = useState('') - const [loginMethod, setLoginMethod] = useState('') - const [provider, setProvider] = useState('') - const [baseUrl, setBaseUrl] = useState('') - const [apiKey, setApiKey] = useState('') + const [formData, setFormData] = useState>({}) const [saving, setSaving] = useState(false) const [error, setError] = useState('') const selectPreset = (preset: Preset) => { setSelectedPreset(preset) - setLabel('') - setModel(preset.defaultModel ?? '') - setCustomModel('') - setLoginMethod(preset.loginMethod?.value ?? '') - setProvider(preset.provider?.value ?? '') - setBaseUrl(preset.baseUrl?.value ?? '') - setApiKey('') + setFormData(extractDefaults(preset.schema)) setError('') } - const effectiveModel = model === '__custom__' ? customModel : model - const handleCreate = async () => { - if (!selectedPreset || !label.trim()) { setError('Profile name is required'); return } - if (!selectedPreset.modelOptional && !effectiveModel) { setError('Model is required'); return } - if (selectedPreset.apiKey?.required && !apiKey) { setError('API key is required'); return } - setSaving(true) - setError('') - const slug = label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') - if (!slug) { setError('Invalid name for slug'); setSaving(false); return } + if (!selectedPreset) return + const label = formData.label?.trim() + if (!label) { setError('Profile name is required'); return } + // Check required fields + const required = (selectedPreset.schema.required as string[] | undefined) ?? [] + for (const field of required) { + if (field === 'label') continue + const prop = (selectedPreset.schema.properties as Record)?.[field] + if (prop?.const !== undefined) continue // const fields are auto-filled + if (!formData[field]?.trim()) { setError(`${prop?.title ?? field} is required`); return } + } + setSaving(true); setError('') + const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + if (!slug) { setError('Invalid name'); setSaving(false); return } try { - await onSave(slug, { - backend: selectedPreset.backend.value, - label: label.trim(), - model: effectiveModel, - ...(loginMethod ? { loginMethod } : {}), - ...(provider ? { provider } : {}), - ...(baseUrl ? { baseUrl } : {}), - ...(apiKey ? { apiKey } : {}), - }) + const merged = mergeFormWithConsts(formData, selectedPreset.schema) + await onSave(slug, merged as unknown as Profile) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create') } finally { setSaving(false) } @@ -300,16 +202,14 @@ function ProfileCreateModal({ presets, onSave, onClose }: { return ( {!selectedPreset ? ( - /* Step 1: Choose Preset */
{officialPresets.length > 0 && (

Official

{officialPresets.map((p) => ( - )}
) : ( - /* Step 2: Fill Fields */
- - setLabel(e.target.value)} placeholder={`e.g. My ${selectedPreset.label}`} autoFocus /> - - + {selectedPreset.hint &&

{selectedPreset.hint}

} + {error &&

{error}

}
@@ -369,85 +257,141 @@ function ProfileCreateModal({ presets, onSave, onClose }: { ) } -// ==================== Preset-aware Fields ==================== - -function PresetFields({ preset, model, setModel, customModel, setCustomModel, loginMethod, setLoginMethod, provider, setProvider, baseUrl, setBaseUrl, apiKey, setApiKey, existingApiKey }: { - preset: Preset - model: string; setModel: (v: string) => void - customModel: string; setCustomModel: (v: string) => void - loginMethod: string; setLoginMethod: (v: string) => void - provider: string; setProvider: (v: string) => void - baseUrl: string; setBaseUrl: (v: string) => void - apiKey: string; setApiKey: (v: string) => void - existingApiKey?: boolean +// ==================== Schema-driven Form Renderer ==================== + +function SchemaForm({ schema, formData, onChange, existingProfile }: { + schema?: Preset['schema'] + formData: Record + onChange: (data: Record) => void + existingProfile?: Profile }) { - const f = preset + if (!schema?.properties) return null + const props = schema.properties as Record + const required = new Set(schema.required as string[] ?? []) + + const setField = (key: string, value: string) => { + onChange({ ...formData, [key]: value }) + } + return ( <> - {f.loginMethod && !f.loginMethod.hidden && ( - - {f.loginMethod.locked ? ( -

{f.loginMethod.value}

- ) : ( - - )} -
- )} - {f.provider && !f.provider.hidden && ( - - {f.provider.locked ? ( -

{f.provider.value}

- ) : ( - - )} -
- )} - - {f.models.length > 0 ? ( - <> - - {model === '__custom__' && ( - setCustomModel(e.target.value)} placeholder="Enter model ID" /> - )} - - ) : ( - { setModel(e.target.value); setCustomModel(e.target.value) }} - placeholder={f.modelOptional ? 'Leave empty for auto' : 'e.g. claude-sonnet-4-6, gpt-5.4'} /> - )} - - {f.baseUrl && !f.baseUrl.hidden && ( - - {f.baseUrl.locked ? ( -

{f.baseUrl.value}

- ) : ( - setBaseUrl(e.target.value)} placeholder="Leave empty for default" /> - )} -
- )} - {f.apiKey && !f.apiKey.hidden && !f.apiKey.locked && ( - -
- setApiKey(e.target.value)} - placeholder={existingApiKey ? '(configured — leave empty to keep)' : 'Enter API key'} /> - {existingApiKey && !apiKey && ( - active - )} -
-
- )} + {Object.entries(props).map(([key, prop]) => { + // const → hidden, value baked in + if (prop.const !== undefined) return null + + const isRequired = required.has(key) + const isPassword = !!prop.writeOnly + const title = prop.title ?? key.charAt(0).toUpperCase() + key.slice(1) + const label = isRequired ? title : `${title} (optional)` + const value = formData[key] ?? '' + const hasExisting = existingProfile && key === 'apiKey' && !!(existingProfile as unknown as Record)[key] + + // oneOf → dropdown with labels + if (prop.oneOf) { + const showCustom = value === '__custom__' + return ( + + + {showCustom && { onChange({ ...formData, [key]: e.target.value, [`${key}__custom`]: e.target.value }) }} placeholder="Enter custom value" />} + + ) + } + + // enum → simple dropdown (no labels) + if (prop.enum) { + return ( + + + + ) + } + + // password field + if (isPassword) { + return ( + +
+ setField(key, e.target.value)} + placeholder={hasExisting ? '(configured — leave empty to keep)' : 'Enter value'} /> + {hasExisting && !value && active} +
+
+ ) + } + + // default: text input + return ( + + setField(key, e.target.value)} placeholder={prop.default !== undefined ? String(prop.default) : ''} /> + + ) + })} ) } + +// ==================== Helpers ==================== + +/** Extract const value from a schema property. */ +function getSchemaConst(schema: Preset['schema'], field: string): unknown { + const props = schema?.properties as Record | undefined + return props?.[field]?.const +} + +/** Extract default values from schema. */ +function extractDefaults(schema: Preset['schema']): Record { + const data: Record = {} + const props = schema?.properties as Record | undefined + if (!props) return data + for (const [key, prop] of Object.entries(props)) { + if (prop.const !== undefined) continue // const fields handled at merge time + if (prop.default !== undefined) data[key] = String(prop.default) + } + return data +} + +/** Merge user form data with const values from schema. */ +function mergeFormWithConsts(formData: Record, schema?: Preset['schema']): Record { + const result: Record = {} + const props = schema?.properties as Record | undefined + if (props) { + for (const [key, prop] of Object.entries(props)) { + if (prop.const !== undefined) { + result[key] = prop.const + } + } + } + for (const [key, value] of Object.entries(formData)) { + if (key.endsWith('__custom')) continue // internal custom field tracking + if (value !== '' && value !== undefined) result[key] = value + } + return result +} + +/** Convert an existing profile to form data (for editing). */ +function profileToFormData(profile: Profile): Record { + const data: Record = {} + for (const [key, value] of Object.entries(profile)) { + if (value !== undefined && value !== null) data[key] = String(value) + } + return data +} + +/** Find the best matching preset for an existing profile. */ +function findPresetForProfile(profile: Profile, presets: Preset[]): Preset | undefined { + return presets.find(p => { + const props = p.schema?.properties as Record | undefined + if (!props) return false + // Match by const fields (backend, loginMethod, provider, baseUrl) + for (const [key, prop] of Object.entries(props)) { + if (prop.const !== undefined && (profile as unknown as Record)[key] !== prop.const) return false + } + return true + }) ?? presets.find(p => p.category === 'custom') +} From 800e5d064e75c0633898b3482f9fb7e7358d2077 Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 15:25:31 +0800 Subject: [PATCH 05/18] refactor: replace label with name as profile identifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile objects no longer have a `label` field — the profiles Record key (name) serves as both unique identifier and display name. - Remove label from profile schemas, ResolvedProfile, frontend types - Presets gain defaultName — official presets auto-fill the name field, users only need to fill name for Custom presets - Frontend displays profile key directly in list and modal titles Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai-providers/presets.ts | 17 ++++++++++------- src/connectors/telegram/telegram-plugin.ts | 12 ++++++------ src/connectors/web/routes/config.ts | 4 ++-- src/core/ai-provider-manager.spec.ts | 10 +++++----- src/core/config.ts | 6 ++---- ui/src/api/types.ts | 2 +- ui/src/components/ChannelConfigModal.tsx | 2 +- ui/src/pages/AIProviderPage.tsx | 20 +++++++++++--------- 8 files changed, 38 insertions(+), 35 deletions(-) diff --git a/src/ai-providers/presets.ts b/src/ai-providers/presets.ts index fc0b7397..59a997c8 100644 --- a/src/ai-providers/presets.ts +++ b/src/ai-providers/presets.ts @@ -22,6 +22,7 @@ export interface SerializedPreset { description: string category: 'official' | 'third-party' | 'custom' hint?: string + defaultName: string schema: Record } @@ -40,6 +41,7 @@ interface PresetDef { description: string category: 'official' | 'third-party' | 'custom' hint?: string + defaultName: string zodSchema: z.ZodType /** Models with human-readable labels. Post-processed into oneOf. */ models?: ModelOption[] @@ -87,9 +89,9 @@ const PRESET_DEFS: PresetDef[] = [ 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({ - label: z.string().min(1).describe('Profile name'), backend: z.literal('agent-sdk' as const), loginMethod: z.literal('claudeai' as const), model: z.string().optional().default('').describe('Leave empty to auto-select based on your plan'), @@ -105,8 +107,8 @@ const PRESET_DEFS: PresetDef[] = [ label: 'Claude (API Key)', description: 'Pay per token via Anthropic API', category: 'official', + defaultName: 'Claude (API Key)', zodSchema: z.object({ - label: z.string().min(1).describe('Profile name'), backend: z.literal('agent-sdk' as const), loginMethod: z.literal('api-key' as const), model: z.string().default('claude-sonnet-4-6').describe('Model'), @@ -126,9 +128,9 @@ const PRESET_DEFS: PresetDef[] = [ 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({ - label: z.string().min(1).describe('Profile name'), backend: z.literal('codex' as const), loginMethod: z.literal('codex-oauth' as const), model: z.string().optional().default('gpt-5.4').describe('Leave empty to auto-select'), @@ -144,8 +146,8 @@ const PRESET_DEFS: PresetDef[] = [ label: 'OpenAI (API Key)', description: 'Pay per token via OpenAI API', category: 'official', + defaultName: 'OpenAI (API Key)', zodSchema: z.object({ - label: z.string().min(1).describe('Profile name'), backend: z.literal('codex' as const), loginMethod: z.literal('api-key' as const), model: z.string().default('gpt-5.4').describe('Model'), @@ -164,8 +166,8 @@ const PRESET_DEFS: PresetDef[] = [ label: 'Google Gemini', description: 'Google AI via API key', category: 'official', + defaultName: 'Google Gemini', zodSchema: z.object({ - label: z.string().min(1).describe('Profile name'), backend: z.literal('vercel-ai-sdk' as const), provider: z.literal('google' as const), model: z.string().default('gemini-2.5-flash').describe('Model'), @@ -184,9 +186,9 @@ const PRESET_DEFS: PresetDef[] = [ label: 'MiniMax', description: 'MiniMax models via Anthropic-compatible API', category: 'third-party', + defaultName: 'MiniMax', hint: 'Get your API key at minimaxi.com', zodSchema: z.object({ - label: z.string().min(1).describe('Profile name'), backend: z.literal('vercel-ai-sdk' as const), provider: z.literal('anthropic' as const), baseUrl: z.literal('https://api.minimaxi.com/anthropic').describe('MiniMax API endpoint'), @@ -205,8 +207,8 @@ const PRESET_DEFS: PresetDef[] = [ label: 'Custom', description: 'Full control — any provider, model, and endpoint', category: 'custom', + defaultName: '', zodSchema: z.object({ - label: z.string().min(1).describe('Profile name'), 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'), @@ -226,5 +228,6 @@ export const BUILTIN_PRESETS: SerializedPreset[] = PRESET_DEFS.map(def => ({ description: def.description, category: def.category, hint: def.hint, + defaultName: def.defaultName, schema: buildJsonSchema(def), })) 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 e100075e..5e6c2b57 100644 --- a/src/connectors/web/routes/config.ts +++ b/src/connectors/web/routes/config.ts @@ -40,8 +40,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]) { diff --git a/src/core/ai-provider-manager.spec.ts b/src/core/ai-provider-manager.spec.ts index ed665a4b..2c811dba 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,7 +187,7 @@ 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') diff --git a/src/core/config.ts b/src/core/config.ts index 8cae4836..2e8851d4 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -42,7 +42,6 @@ const apiKeysSchema = z.object({ }) const baseProfileFields = { - label: z.string().min(1), baseUrl: z.string().optional(), apiKey: z.string().optional(), } @@ -80,7 +79,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'), }) @@ -583,10 +582,9 @@ 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 apiKey?: string baseUrl?: string diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 2cf8340a..382d5822 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -4,7 +4,6 @@ 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 @@ -20,6 +19,7 @@ export interface Preset { description: string category: 'official' | 'third-party' | 'custom' hint?: string + defaultName: string schema: JsonSchema } 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/pages/AIProviderPage.tsx b/ui/src/pages/AIProviderPage.tsx index b88350c3..210482e7 100644 --- a/ui/src/pages/AIProviderPage.tsx +++ b/ui/src/pages/AIProviderPage.tsx @@ -69,7 +69,7 @@ export function AIProviderPage() {
{BACKEND_ICONS[profile.backend]}
- {profile.label} + {slug} {isActive && Active}

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

@@ -141,7 +141,7 @@ function ProfileEditModal({ slug, profile, presets, isActive, onSave, onDelete, } return ( - +
{preset?.hint &&

{preset.hint}

} @@ -162,34 +162,33 @@ function ProfileCreateModal({ presets, onSave, onClose }: { presets: Preset[]; onSave: (slug: string, profile: Profile) => Promise; onClose: () => void }) { const [selectedPreset, setSelectedPreset] = useState(null) + const [name, setName] = useState('') const [formData, setFormData] = useState>({}) const [saving, setSaving] = useState(false) const [error, setError] = useState('') const selectPreset = (preset: Preset) => { setSelectedPreset(preset) + setName(preset.defaultName) setFormData(extractDefaults(preset.schema)) setError('') } const handleCreate = async () => { if (!selectedPreset) return - const label = formData.label?.trim() - if (!label) { setError('Profile name is required'); return } - // Check required fields + const trimmedName = name.trim() + if (!trimmedName) { setError('Profile name is required'); return } + // Check required fields from schema const required = (selectedPreset.schema.required as string[] | undefined) ?? [] for (const field of required) { - if (field === 'label') continue const prop = (selectedPreset.schema.properties as Record)?.[field] if (prop?.const !== undefined) continue // const fields are auto-filled if (!formData[field]?.trim()) { setError(`${prop?.title ?? field} is required`); return } } setSaving(true); setError('') - const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') - if (!slug) { setError('Invalid name'); setSaving(false); return } try { const merged = mergeFormWithConsts(formData, selectedPreset.schema) - await onSave(slug, merged as unknown as Profile) + await onSave(trimmedName, merged as unknown as Profile) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create') } finally { setSaving(false) } @@ -245,6 +244,9 @@ function ProfileCreateModal({ presets, onSave, onClose }: { ) : (
{selectedPreset.hint &&

{selectedPreset.hint}

} + + setName(e.target.value)} placeholder="e.g. My Claude" autoFocus /> + {error &&

{error}

}
From d026486d95076d66ed1ef39194f35e461c84b0aa Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 19:00:20 +0800 Subject: [PATCH 06/18] fix: MiniMax preset uses agent-sdk backend, not vercel-ai-sdk MiniMax's Anthropic-compatible API works with Claude Agent SDK, not Vercel AI SDK (which appends /messages and gets 404). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai-providers/presets.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ai-providers/presets.ts b/src/ai-providers/presets.ts index 59a997c8..dad69c6f 100644 --- a/src/ai-providers/presets.ts +++ b/src/ai-providers/presets.ts @@ -12,7 +12,7 @@ */ import { z } from 'zod' -import type { AIBackend } from '../core/config.js' +// AIBackend type used implicitly via z.literal() values // ==================== Serialized Preset (sent to frontend) ==================== @@ -184,13 +184,13 @@ const PRESET_DEFS: PresetDef[] = [ { id: 'minimax', label: 'MiniMax', - description: 'MiniMax models via Anthropic-compatible API', + 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('vercel-ai-sdk' as const), - provider: z.literal('anthropic' as const), + backend: z.literal('agent-sdk' as const), + loginMethod: z.literal('api-key' as const), 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'), From b0efcd54446e5e1647e03a4c58de78577c27fc3f Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 19:34:58 +0800 Subject: [PATCH 07/18] feat: currency-aware UTA with FxService dual-table architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add currency tracking throughout the UTA abstraction layer to fix incorrect display of non-USD positions (e.g. HKD stocks shown as USD). - Add `currency` field to Position, `baseCurrency` to AccountInfo - New FxService with dual-table design: hardcoded default rates for offline resilience + live cache from market-data currency client - Lookup priority: live → stale cache → default table → 1:1 fallback - All four brokers (IBKR, Alpaca, CCXT, Mock) populate currency fields - CCXT normalizes stablecoins (USDT/USDC/BUSD) to USD at broker layer - AccountManager aggregates equity with FX conversion to USD - Snapshots and trading tools carry currency information through - 15 new tests for FxService covering full degradation chain Co-Authored-By: Claude Opus 4.6 (1M context) --- .../client/openbb-api/currency-client.ts | 4 +- src/domain/market-data/client/types.ts | 1 + src/domain/trading/account-manager.ts | 58 ++++-- .../trading/brokers/alpaca/AlpacaBroker.ts | 2 + src/domain/trading/brokers/ccxt/CcxtBroker.ts | 9 + src/domain/trading/brokers/ibkr/IbkrBroker.ts | 1 + .../trading/brokers/ibkr/request-bridge.ts | 1 + src/domain/trading/brokers/mock/MockBroker.ts | 15 +- src/domain/trading/brokers/types.ts | 4 + src/domain/trading/fx-service.spec.ts | 175 ++++++++++++++++++ src/domain/trading/fx-service.ts | 170 +++++++++++++++++ src/domain/trading/git/TradingGit.spec.ts | 8 +- src/domain/trading/guards/guards.spec.ts | 1 + src/domain/trading/snapshot/builder.ts | 2 + src/domain/trading/snapshot/snapshot.spec.ts | 1 + src/domain/trading/snapshot/types.ts | 2 + src/main.ts | 8 +- src/tool/trading.ts | 39 +++- 18 files changed, 471 insertions(+), 30 deletions(-) create mode 100644 src/domain/trading/fx-service.spec.ts create mode 100644 src/domain/trading/fx-service.ts 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) From 450b954aa6b50fbed200936eacc87f8f8472f751 Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 20:53:55 +0800 Subject: [PATCH 08/18] refactor: split preset catalog + useSchemaForm hook + profile preset link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preset-catalog.ts: pure data file with Zod-defined preset declarations. Update model versions by editing only this file. - presets.ts: slimmed to pure serialization (Zod → JSON Schema + post-processing) - Profile schema gains `preset` field — links profile to its source preset. Edit modal looks up preset by profile.preset instead of reverse-matching. - useSchemaForm hook: parses JSON Schema into field descriptors + form state. Components just render fields by type, no schema parsing logic. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai-providers/preset-catalog.ts | 189 ++++++++++++++++++++ src/ai-providers/presets.ts | 194 ++------------------ src/core/config.ts | 3 + ui/src/api/types.ts | 1 + ui/src/hooks/useSchemaForm.ts | 112 ++++++++++++ ui/src/pages/AIProviderPage.tsx | 278 ++++++++++++----------------- 6 files changed, 427 insertions(+), 350 deletions(-) create mode 100644 src/ai-providers/preset-catalog.ts create mode 100644 ui/src/hooks/useSchemaForm.ts diff --git a/src/ai-providers/preset-catalog.ts b/src/ai-providers/preset-catalog.ts new file mode 100644 index 00000000..8a69eef6 --- /dev/null +++ b/src/ai-providers/preset-catalog.ts @@ -0,0 +1,189 @@ +/** + * 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[] + modelOptional?: boolean + 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().optional().default('').describe('Leave empty to auto-select based on your plan'), + }), + models: [ + { id: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, + { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }, + ], + modelOptional: true, +} + +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().optional().default('gpt-5.4').describe('Leave empty to auto-select'), + }), + models: [ + { id: 'gpt-5.4', label: 'GPT 5.4' }, + { id: 'gpt-5.4-mini', label: 'GPT 5.4 Mini' }, + ], + modelOptional: true, +} + +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 index dad69c6f..7afd2ff7 100644 --- a/src/ai-providers/presets.ts +++ b/src/ai-providers/presets.ts @@ -1,18 +1,16 @@ /** - * AI Provider Presets — schema-driven templates for profile creation. + * AI Provider Presets — serialization layer. * - * Each preset produces a JSON Schema that tells the frontend exactly - * how to render the creation/edit form: - * - const fields → hidden (value baked in) - * - oneOf fields → dropdown with labels - * - writeOnly fields → password input - * - required / default / description → form behavior + * Reads preset definitions from preset-catalog.ts and converts + * their Zod schemas to JSON Schema for the frontend. * - * Frontend is a pure renderer — no field logic, no hardcoded options. + * Post-processing: + * - Model fields: enum → oneOf + const + title (labeled dropdowns) + * - API key fields: marked writeOnly (password inputs) */ import { z } from 'zod' -// AIBackend type used implicitly via z.literal() values +import { PRESET_CATALOG, type PresetDef } from './preset-catalog.js' // ==================== Serialized Preset (sent to frontend) ==================== @@ -26,42 +24,14 @@ export interface SerializedPreset { schema: Record } -// ==================== Model option with label ==================== - -interface ModelOption { - id: string - label: string -} - -// ==================== Internal preset definition ==================== - -interface PresetDef { - id: string - label: string - description: string - category: 'official' | 'third-party' | 'custom' - hint?: string - defaultName: string - zodSchema: z.ZodType - /** Models with human-readable labels. Post-processed into oneOf. */ - models?: ModelOption[] - /** Property name for the model field (default: 'model'). */ - modelField?: string - /** If true, model can be left empty. */ - modelOptional?: boolean - /** Properties that should be rendered as password fields. */ - writeOnlyFields?: string[] -} - // ==================== Schema post-processing ==================== -/** Convert a Zod schema to JSON Schema, then apply preset-specific transforms. */ function buildJsonSchema(def: PresetDef): Record { const raw = z.toJSONSchema(def.zodSchema) as Record const props = (raw.properties ?? {}) as Record> - // Inject oneOf for model field (replace plain enum with labeled options) - const mf = def.modelField ?? 'model' + // 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 })) if (def.modelOptional) { @@ -71,7 +41,7 @@ function buildJsonSchema(def: PresetDef): Record { props[mf] = { ...rest, oneOf } } - // Mark writeOnly fields (rendered as password inputs) + // Mark writeOnly fields for (const field of def.writeOnlyFields ?? []) { if (props[field]) props[field].writeOnly = true } @@ -80,149 +50,9 @@ function buildJsonSchema(def: PresetDef): Record { return raw } -// ==================== Preset definitions ==================== - -const PRESET_DEFS: PresetDef[] = [ - // ── Official: Claude ── - { - 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' as const), - loginMethod: z.literal('claudeai' as const), - model: z.string().optional().default('').describe('Leave empty to auto-select based on your plan'), - }), - models: [ - { id: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, - { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }, - ], - modelOptional: true, - }, - { - 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' as const), - loginMethod: z.literal('api-key' as const), - 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 ── - { - 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' as const), - loginMethod: z.literal('codex-oauth' as const), - model: z.string().optional().default('gpt-5.4').describe('Leave empty to auto-select'), - }), - models: [ - { id: 'gpt-5.4', label: 'GPT 5.4' }, - { id: 'gpt-5.4-mini', label: 'GPT 5.4 Mini' }, - ], - modelOptional: true, - }, - { - 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' as const), - loginMethod: z.literal('api-key' as const), - 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 ── - { - 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' as const), - provider: z.literal('google' as const), - 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 ── - { - 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' as const), - loginMethod: z.literal('api-key' as const), - 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 ── - { - 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'], - }, -] - -// ==================== Exported: serialized presets ==================== +// ==================== Exported ==================== -export const BUILTIN_PRESETS: SerializedPreset[] = PRESET_DEFS.map(def => ({ +export const BUILTIN_PRESETS: SerializedPreset[] = PRESET_CATALOG.map(def => ({ id: def.id, label: def.label, description: def.description, diff --git a/src/core/config.ts b/src/core/config.ts index 2e8851d4..4dcc8255 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -42,6 +42,8 @@ const apiKeysSchema = z.object({ }) const baseProfileFields = { + /** Preset ID this profile was created from (for constraint enforcement on edit). */ + preset: z.string().optional(), baseUrl: z.string().optional(), apiKey: z.string().optional(), } @@ -586,6 +588,7 @@ export async function readConnectorsConfig() { export interface ResolvedProfile { backend: AIBackend model: string + preset?: string apiKey?: string baseUrl?: string loginMethod?: string diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 382d5822..fd71e2ec 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -5,6 +5,7 @@ export type AIBackend = 'agent-sdk' | 'codex' | 'vercel-ai-sdk' export interface Profile { backend: AIBackend model: string + preset?: string // preset ID this profile was created from loginMethod?: string provider?: string // vercel-ai-sdk only baseUrl?: string diff --git a/ui/src/hooks/useSchemaForm.ts b/ui/src/hooks/useSchemaForm.ts new file mode 100644 index 00000000..64cb8964 --- /dev/null +++ b/ui/src/hooks/useSchemaForm.ts @@ -0,0 +1,112 @@ +/** + * 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 } from 'react' +import type { JsonSchema, JsonSchemaProperty } from '../api/types' + +// ==================== Types ==================== + +export interface SchemaField { + key: string + type: 'text' | 'password' | 'select' | 'select-custom' + 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-custom', 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 — initialized from provided values or schema defaults + const [formData, setFormData] = useState>(() => ({ + ...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 210482e7..e1ce52f6 100644 --- a/ui/src/pages/AIProviderPage.tsx +++ b/ui/src/pages/AIProviderPage.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useRef } from 'react' -import { api, type Profile, type AIBackend, type Preset, type JsonSchemaProperty } from '../api' +import { api, type Profile, type AIBackend, type Preset } from '../api' import { SaveIndicator } from '../components/SaveIndicator' 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' @@ -14,6 +15,11 @@ const BACKEND_ICONS: Record = { 'vercel-ai-sdk': , } +function getSchemaConst(schema: Preset['schema'], field: string): unknown { + const props = schema?.properties as Record | undefined + return props?.[field]?.const +} + // ==================== Main Page ==================== export function AIProviderPage() { @@ -25,8 +31,7 @@ export function AIProviderPage() { useEffect(() => { api.config.getProfiles().then(({ profiles: p, activeProfile: a }) => { - setProfiles(p) - setActiveProfile(a) + setProfiles(p); setActiveProfile(a) }).catch(() => {}) api.config.getPresets().then(({ presets: p }) => setPresets(p)).catch(() => {}) }, []) @@ -44,9 +49,9 @@ export function AIProviderPage() { } catch {} } - const handleCreateSave = async (slug: string, profile: Profile) => { - await api.config.createProfile(slug, profile) - setProfiles((p) => p ? { ...p, [slug]: profile } : p) + const handleCreateSave = async (name: string, profile: Profile) => { + await api.config.createProfile(name, profile) + setProfiles((p) => p ? { ...p, [name]: profile } : p) setShowCreate(false) } @@ -89,8 +94,7 @@ export function AIProviderPage() { handleProfileUpdate(editingSlug, p)} - onDelete={() => handleDelete(editingSlug)} - onClose={() => setEditingSlug(null)} /> + onDelete={() => handleDelete(editingSlug)} onClose={() => setEditingSlug(null)} /> )} {showCreate && setShowCreate(false)} />}
@@ -115,25 +119,104 @@ function Modal({ title, onClose, children }: { title: string; onClose: () => voi ) } +// ==================== Schema-driven Field Renderer ==================== + +function SchemaFormFields({ fields, formData, setField, existingProfile }: { + fields: SchemaField[] + formData: Record + setField: (key: string, value: string) => void + existingProfile?: Profile +}) { + return ( + <> + {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-custom') { + const isCustom = value === '__custom__' || (value && !field.options?.some(o => o.value === value)) + return ( + + + {isCustom && ( + { setField(field.key, e.target.value); setField(`${field.key}__custom`, e.target.value) }} + placeholder="Enter custom value" /> + )} + + ) + } + + 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 ?? ''} /> + + ) + })} + + ) +} + // ==================== Edit Modal ==================== 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 preset = findPresetForProfile(profile, presets) - const [formData, setFormData] = useState>(() => profileToFormData(profile)) + // 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) - useEffect(() => { setFormData(profileToFormData(profile)); setStatus('idle') }, [slug, profile]) useEffect(() => () => { if (savedTimer.current) clearTimeout(savedTimer.current) }, []) const handleSave = async () => { + const error = validate() + if (error) return setStatus('saving') try { - const merged = mergeFormWithConsts(formData, preset?.schema) - await onSave(merged as unknown as Profile) + 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'); onClose() }, 1000) @@ -143,8 +226,8 @@ function ProfileEditModal({ slug, profile, presets, isActive, onSave, onDelete, return (
- {preset?.hint &&

{preset.hint}

} - + {preset.hint &&

{preset.hint}

} +
@@ -159,18 +242,20 @@ function ProfileEditModal({ slug, profile, presets, isActive, onSave, onDelete, // ==================== Create Modal ==================== function ProfileCreateModal({ presets, onSave, onClose }: { - presets: Preset[]; onSave: (slug: string, profile: Profile) => Promise; onClose: () => void + presets: Preset[]; onSave: (name: string, profile: Profile) => Promise; onClose: () => void }) { const [selectedPreset, setSelectedPreset] = useState(null) const [name, setName] = useState('') - const [formData, setFormData] = useState>({}) const [saving, setSaving] = useState(false) const [error, setError] = useState('') + const { fields, formData, setField, getSubmitData, validate } = useSchemaForm( + selectedPreset?.schema, + ) + const selectPreset = (preset: Preset) => { setSelectedPreset(preset) setName(preset.defaultName) - setFormData(extractDefaults(preset.schema)) setError('') } @@ -178,17 +263,13 @@ function ProfileCreateModal({ presets, onSave, onClose }: { if (!selectedPreset) return const trimmedName = name.trim() if (!trimmedName) { setError('Profile name is required'); return } - // Check required fields from schema - const required = (selectedPreset.schema.required as string[] | undefined) ?? [] - for (const field of required) { - const prop = (selectedPreset.schema.properties as Record)?.[field] - if (prop?.const !== undefined) continue // const fields are auto-filled - if (!formData[field]?.trim()) { setError(`${prop?.title ?? field} is required`); return } - } + const validationError = validate() + if (validationError) { setError(validationError); return } setSaving(true); setError('') try { - const merged = mergeFormWithConsts(formData, selectedPreset.schema) - await onSave(trimmedName, merged as unknown as Profile) + const data = getSubmitData() + data.preset = selectedPreset.id + await onSave(trimmedName, data as unknown as Profile) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create') } finally { setSaving(false) } @@ -245,9 +326,9 @@ function ProfileCreateModal({ presets, onSave, onClose }: {
{selectedPreset.hint &&

{selectedPreset.hint}

} - setName(e.target.value)} placeholder="e.g. My Claude" autoFocus /> + setName(e.target.value)} placeholder={`e.g. My ${selectedPreset.label}`} autoFocus /> - + {error &&

{error}

}
@@ -258,142 +339,3 @@ function ProfileCreateModal({ presets, onSave, onClose }: { ) } - -// ==================== Schema-driven Form Renderer ==================== - -function SchemaForm({ schema, formData, onChange, existingProfile }: { - schema?: Preset['schema'] - formData: Record - onChange: (data: Record) => void - existingProfile?: Profile -}) { - if (!schema?.properties) return null - const props = schema.properties as Record - const required = new Set(schema.required as string[] ?? []) - - const setField = (key: string, value: string) => { - onChange({ ...formData, [key]: value }) - } - - return ( - <> - {Object.entries(props).map(([key, prop]) => { - // const → hidden, value baked in - if (prop.const !== undefined) return null - - const isRequired = required.has(key) - const isPassword = !!prop.writeOnly - const title = prop.title ?? key.charAt(0).toUpperCase() + key.slice(1) - const label = isRequired ? title : `${title} (optional)` - const value = formData[key] ?? '' - const hasExisting = existingProfile && key === 'apiKey' && !!(existingProfile as unknown as Record)[key] - - // oneOf → dropdown with labels - if (prop.oneOf) { - const showCustom = value === '__custom__' - return ( - - - {showCustom && { onChange({ ...formData, [key]: e.target.value, [`${key}__custom`]: e.target.value }) }} placeholder="Enter custom value" />} - - ) - } - - // enum → simple dropdown (no labels) - if (prop.enum) { - return ( - - - - ) - } - - // password field - if (isPassword) { - return ( - -
- setField(key, e.target.value)} - placeholder={hasExisting ? '(configured — leave empty to keep)' : 'Enter value'} /> - {hasExisting && !value && active} -
-
- ) - } - - // default: text input - return ( - - setField(key, e.target.value)} placeholder={prop.default !== undefined ? String(prop.default) : ''} /> - - ) - })} - - ) -} - -// ==================== Helpers ==================== - -/** Extract const value from a schema property. */ -function getSchemaConst(schema: Preset['schema'], field: string): unknown { - const props = schema?.properties as Record | undefined - return props?.[field]?.const -} - -/** Extract default values from schema. */ -function extractDefaults(schema: Preset['schema']): Record { - const data: Record = {} - const props = schema?.properties as Record | undefined - if (!props) return data - for (const [key, prop] of Object.entries(props)) { - if (prop.const !== undefined) continue // const fields handled at merge time - if (prop.default !== undefined) data[key] = String(prop.default) - } - return data -} - -/** Merge user form data with const values from schema. */ -function mergeFormWithConsts(formData: Record, schema?: Preset['schema']): Record { - const result: Record = {} - const props = schema?.properties as Record | undefined - if (props) { - for (const [key, prop] of Object.entries(props)) { - if (prop.const !== undefined) { - result[key] = prop.const - } - } - } - for (const [key, value] of Object.entries(formData)) { - if (key.endsWith('__custom')) continue // internal custom field tracking - if (value !== '' && value !== undefined) result[key] = value - } - return result -} - -/** Convert an existing profile to form data (for editing). */ -function profileToFormData(profile: Profile): Record { - const data: Record = {} - for (const [key, value] of Object.entries(profile)) { - if (value !== undefined && value !== null) data[key] = String(value) - } - return data -} - -/** Find the best matching preset for an existing profile. */ -function findPresetForProfile(profile: Profile, presets: Preset[]): Preset | undefined { - return presets.find(p => { - const props = p.schema?.properties as Record | undefined - if (!props) return false - // Match by const fields (backend, loginMethod, provider, baseUrl) - for (const [key, prop] of Object.entries(props)) { - if (prop.const !== undefined && (profile as unknown as Record)[key] !== prop.const) return false - } - return true - }) ?? presets.find(p => p.category === 'custom') -} From ae2ab6e0519ee93e33195e3fecb076b1759d8fef Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 21:03:56 +0800 Subject: [PATCH 09/18] fix: schema form initialization + remove Custom option from presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useSchemaForm: reset formData when schema changes (fixes "Model is required" when selecting a preset in create modal) - Remove select-custom field type — oneOf fields render as strict dropdowns without "Custom..." option. Official preset model lists are enforced, not escapable. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/hooks/useSchemaForm.ts | 17 +++++++++++++---- ui/src/pages/AIProviderPage.tsx | 19 ------------------- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/ui/src/hooks/useSchemaForm.ts b/ui/src/hooks/useSchemaForm.ts index 64cb8964..2a8056ca 100644 --- a/ui/src/hooks/useSchemaForm.ts +++ b/ui/src/hooks/useSchemaForm.ts @@ -5,14 +5,14 @@ * Components just iterate `fields` and render by `type`. */ -import { useState, useMemo, useCallback } from 'react' +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' | 'select-custom' + type: 'text' | 'password' | 'select' title: string description?: string required: boolean @@ -63,7 +63,7 @@ export function useSchemaForm( 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-custom', title, description: prop.description, required: isRequired, options }) + 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 }) @@ -80,12 +80,21 @@ export function useSchemaForm( return { constValues: consts, fieldDefs: fields, defaults: defs } }, [schema]) - // Form state — initialized from provided values or schema defaults + // 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 })) }, []) diff --git a/ui/src/pages/AIProviderPage.tsx b/ui/src/pages/AIProviderPage.tsx index e1ce52f6..fea43bca 100644 --- a/ui/src/pages/AIProviderPage.tsx +++ b/ui/src/pages/AIProviderPage.tsx @@ -134,25 +134,6 @@ function SchemaFormFields({ fields, formData, setField, existingProfile }: { 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-custom') { - const isCustom = value === '__custom__' || (value && !field.options?.some(o => o.value === value)) - return ( - - - {isCustom && ( - { setField(field.key, e.target.value); setField(`${field.key}__custom`, e.target.value) }} - placeholder="Enter custom value" /> - )} - - ) - } - if (field.type === 'select') { return ( From 3535b872d5cf4e43ae6d7b57f2bc9a2264d8bf57 Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 21:24:56 +0800 Subject: [PATCH 10/18] fix: require explicit model selection for OAuth presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove modelOptional and "Auto" option — OAuth presets now default to a specific model (claude-sonnet-4-6, gpt-5.4) instead of allowing empty. Simplifies validation and avoids the "Model is required" error when users select Auto. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai-providers/agent-sdk/query.ts | 6 +----- src/ai-providers/preset-catalog.ts | 7 ++----- src/ai-providers/presets.ts | 3 --- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/src/ai-providers/agent-sdk/query.ts b/src/ai-providers/agent-sdk/query.ts index 751d5c60..d4ce05d1 100644 --- a/src/ai-providers/agent-sdk/query.ts +++ b/src/ai-providers/agent-sdk/query.ts @@ -148,11 +148,7 @@ export async function askAgentSdk( options: { cwd, env, - // OAuth mode: omit model to let Claude Code pick based on subscription plan. - // API key mode: use profile model or fall back to sonnet. - ...(isOAuthMode - ? (override?.model ? { model: override.model } : {}) - : { model: override?.model ?? 'claude-sonnet-4-6' }), + model: override?.model ?? 'claude-sonnet-4-6', maxTurns, allowedTools: finalAllowed, disallowedTools: finalDisallowed, diff --git a/src/ai-providers/preset-catalog.ts b/src/ai-providers/preset-catalog.ts index 8a69eef6..b77441b1 100644 --- a/src/ai-providers/preset-catalog.ts +++ b/src/ai-providers/preset-catalog.ts @@ -29,7 +29,6 @@ export interface PresetDef { defaultName: string zodSchema: z.ZodType models?: ModelOption[] - modelOptional?: boolean writeOnlyFields?: string[] } @@ -45,13 +44,12 @@ export const CLAUDE_OAUTH: PresetDef = { zodSchema: z.object({ backend: z.literal('agent-sdk'), loginMethod: z.literal('claudeai'), - model: z.string().optional().default('').describe('Leave empty to auto-select based on your plan'), + 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' }, ], - modelOptional: true, } export const CLAUDE_API: PresetDef = { @@ -86,13 +84,12 @@ export const CODEX_OAUTH: PresetDef = { zodSchema: z.object({ backend: z.literal('codex'), loginMethod: z.literal('codex-oauth'), - model: z.string().optional().default('gpt-5.4').describe('Leave empty to auto-select'), + 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' }, ], - modelOptional: true, } export const CODEX_API: PresetDef = { diff --git a/src/ai-providers/presets.ts b/src/ai-providers/presets.ts index 7afd2ff7..ca106966 100644 --- a/src/ai-providers/presets.ts +++ b/src/ai-providers/presets.ts @@ -34,9 +34,6 @@ function buildJsonSchema(def: PresetDef): Record { const mf = 'model' if (def.models?.length && props[mf]) { const oneOf = def.models.map(m => ({ const: m.id, title: m.label })) - if (def.modelOptional) { - oneOf.unshift({ const: '', title: 'Auto (based on subscription plan)' }) - } const { enum: _e, ...rest } = props[mf] props[mf] = { ...rest, oneOf } } From 1b9f482985d5af46dd568a9efe92131431e3722b Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 21:55:38 +0800 Subject: [PATCH 11/18] =?UTF-8?q?feat:=20profile=20creation=20UX=20?= =?UTF-8?q?=E2=80=94=20name=20lockdown,=20duplicate=20check,=20connection?= =?UTF-8?q?=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Official preset names are read-only (locked to defaultName) - Already-configured presets show "Already configured" and are disabled - "Create & Test" button: saves profile then sends "Hi" to verify connectivity, displays response or error - New POST /config/profiles/:slug/test endpoint via AgentCenter.testProfile() Co-Authored-By: Claude Opus 4.6 (1M context) --- src/connectors/web/routes/config.ts | 17 +++++- src/connectors/web/web-plugin.ts | 1 + src/core/agent-center.ts | 6 ++ ui/src/api/config.ts | 5 ++ ui/src/pages/AIProviderPage.tsx | 91 ++++++++++++++++++++--------- 5 files changed, 92 insertions(+), 28 deletions(-) diff --git a/src/connectors/web/routes/config.ts b/src/connectors/web/routes/config.ts index 5e6c2b57..4ff62afe 100644 --- a/src/connectors/web/routes/config.ts +++ b/src/connectors/web/routes/config.ts @@ -8,10 +8,11 @@ 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() @@ -101,6 +102,20 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) { /** GET /presets — built-in preset templates for profile creation */ app.get('/presets', (c) => c.json({ presets: BUILTIN_PRESETS })) + // ==================== Profile Test ==================== + + /** POST /profiles/:slug/test — test a saved profile by sending "Hi" to its provider */ + app.post('/profiles/:slug/test', async (c) => { + if (!opts?.ctx) return c.json({ ok: false, error: 'Test not available' }, 500) + try { + const slug = c.req.param('slug') + const result = await opts.ctx.agentCenter.testProfile(slug) + return c.json({ ok: true, response: result.text }) + } catch (err) { + return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + // ==================== Generic Section Writer ==================== app.put('/:section', async (c) => { 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..7ffe7038 100644 --- a/src/core/agent-center.ts +++ b/src/core/agent-center.ts @@ -58,6 +58,12 @@ export class AgentCenter { return this.router.ask(prompt) } + /** Test a profile by sending a prompt to its provider. Used for connection testing. */ + async testProfile(profileSlug: string, prompt = 'Hi'): Promise { + const { provider } = await this.router.resolve(profileSlug) + return provider.ask(prompt) + } + /** 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/ui/src/api/config.ts b/ui/src/api/config.ts index daf85eec..51d1add1 100644 --- a/ui/src/api/config.ts +++ b/ui/src/api/config.ts @@ -69,6 +69,11 @@ export const configApi = { } }, + async testProfile(slug: string): Promise<{ ok: boolean; response?: string; error?: string }> { + const res = await fetch(`/api/config/profiles/${slug}/test`, { method: 'POST' }) + return res.json() + }, + async setActiveProfile(slug: string): Promise { const res = await fetch('/api/config/active-profile', { method: 'PUT', diff --git a/ui/src/pages/AIProviderPage.tsx b/ui/src/pages/AIProviderPage.tsx index fea43bca..1bcddc1c 100644 --- a/ui/src/pages/AIProviderPage.tsx +++ b/ui/src/pages/AIProviderPage.tsx @@ -96,7 +96,7 @@ export function AIProviderPage() { onSave={(p) => handleProfileUpdate(editingSlug, p)} onDelete={() => handleDelete(editingSlug)} onClose={() => setEditingSlug(null)} /> )} - {showCreate && setShowCreate(false)} />} + {showCreate && setShowCreate(false)} />}
) } @@ -222,37 +222,61 @@ function ProfileEditModal({ slug, profile, presets, isActive, onSave, onDelete, // ==================== Create Modal ==================== -function ProfileCreateModal({ presets, onSave, onClose }: { - presets: Preset[]; onSave: (name: string, profile: Profile) => Promise; onClose: () => void +function ProfileCreateModal({ presets, existingNames, onSave, onClose }: { + presets: Preset[]; existingNames: string[] + onSave: (name: string, profile: Profile) => Promise; onClose: () => void }) { 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('') + const existingSet = new Set(existingNames) + const { fields, formData, setField, getSubmitData, validate } = useSchemaForm( selectedPreset?.schema, ) 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 + 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 } + setSaving(true); setError('') try { const data = getSubmitData() data.preset = selectedPreset.id + // Save first await onSave(trimmedName, data as unknown as Profile) + + // Then test connectivity + setTesting(true) + const result = await api.config.testProfile(trimmedName) + setTestResult(result) + setTesting(false) + + if (result.ok) { + // Auto-close after brief success display + setTimeout(onClose, 1500) + } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create') + setTesting(false) } finally { setSaving(false) } } @@ -260,6 +284,26 @@ function ProfileCreateModal({ presets, onSave, onClose }: { 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 ( {!selectedPreset ? ( @@ -267,33 +311,13 @@ function ProfileCreateModal({ presets, onSave, onClose }: { {officialPresets.length > 0 && (

Official

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

Third Party

-
- {thirdPartyPresets.map((p) => ( - - ))} -
+
{thirdPartyPresets.map(renderPresetCard)}
)} {customPreset && ( @@ -307,12 +331,25 @@ function ProfileCreateModal({ presets, onSave, onClose }: {
{selectedPreset.hint &&

{selectedPreset.hint}

} - setName(e.target.value)} placeholder={`e.g. My ${selectedPreset.label}`} autoFocus /> + {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}`} +
+ )}
- +
From 176855aa18add36c53ed156ec460c66f2df39743 Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 21:56:47 +0800 Subject: [PATCH 12/18] fix: wait for connection test before closing create modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleCreateSave no longer closes modal — the modal manages its own lifecycle: save → test → show result → auto-close on success after 2s. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/pages/AIProviderPage.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ui/src/pages/AIProviderPage.tsx b/ui/src/pages/AIProviderPage.tsx index 1bcddc1c..5bdc855b 100644 --- a/ui/src/pages/AIProviderPage.tsx +++ b/ui/src/pages/AIProviderPage.tsx @@ -52,7 +52,7 @@ export function AIProviderPage() { const handleCreateSave = async (name: string, profile: Profile) => { await api.config.createProfile(name, profile) setProfiles((p) => p ? { ...p, [name]: profile } : p) - setShowCreate(false) + // Don't close modal here — let the modal handle test + close } const handleProfileUpdate = async (slug: string, profile: Profile) => { @@ -257,27 +257,27 @@ function ProfileCreateModal({ presets, existingNames, onSave, onClose }: { const validationError = validate() if (validationError) { setError(validationError); return } - setSaving(true); setError('') + setSaving(true); setError(''); setTestResult(null) try { const data = getSubmitData() data.preset = selectedPreset.id - // Save first + // Save profile await onSave(trimmedName, data as unknown as Profile) + setSaving(false) - // Then test connectivity + // Test connectivity setTesting(true) const result = await api.config.testProfile(trimmedName) setTestResult(result) setTesting(false) if (result.ok) { - // Auto-close after brief success display - setTimeout(onClose, 1500) + setTimeout(onClose, 2000) } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create') - setTesting(false) - } finally { setSaving(false) } + setSaving(false); setTesting(false) + } } const officialPresets = presets.filter(p => p.category === 'official') From 27679aa896fab81b896a2857a97eb421114fe94b Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 22:38:24 +0800 Subject: [PATCH 13/18] fix: remove slash from Codex preset name + encode profile slugs in URLs Profile names with "/" break URL routing (Hono treats it as path separator). Fixed by: - Renaming "OpenAI / Codex" to "OpenAI Codex" in preset defaults - Adding encodeURIComponent() to all profile API URL paths Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai-providers/preset-catalog.ts | 6 +++--- ui/src/api/config.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ai-providers/preset-catalog.ts b/src/ai-providers/preset-catalog.ts index b77441b1..d4e6cd7a 100644 --- a/src/ai-providers/preset-catalog.ts +++ b/src/ai-providers/preset-catalog.ts @@ -72,14 +72,14 @@ export const CLAUDE_API: PresetDef = { writeOnlyFields: ['apiKey'], } -// ==================== Official: OpenAI / Codex ==================== +// ==================== Official: OpenAI Codex ==================== export const CODEX_OAUTH: PresetDef = { id: 'codex-oauth', - label: 'OpenAI / Codex (Subscription)', + label: 'OpenAI Codex (Subscription)', description: 'Use your ChatGPT subscription', category: 'official', - defaultName: 'OpenAI / Codex (Subscription)', + defaultName: 'OpenAI Codex (Subscription)', hint: 'Requires Codex CLI login. Run `codex login` in your terminal first.', zodSchema: z.object({ backend: z.literal('codex'), diff --git a/ui/src/api/config.ts b/ui/src/api/config.ts index 51d1add1..0788fd26 100644 --- a/ui/src/api/config.ts +++ b/ui/src/api/config.ts @@ -49,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), @@ -62,7 +62,7 @@ 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') @@ -70,7 +70,7 @@ export const configApi = { }, async testProfile(slug: string): Promise<{ ok: boolean; response?: string; error?: string }> { - const res = await fetch(`/api/config/profiles/${slug}/test`, { method: 'POST' }) + const res = await fetch(`/api/config/profiles/${encodeURIComponent(slug)}/test`, { method: 'POST' }) return res.json() }, From aa4da513a0b95515696ae08dd862373e61ee0531 Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 22:41:32 +0800 Subject: [PATCH 14/18] =?UTF-8?q?fix:=20Codex=20ask()=20uses=20streaming?= =?UTF-8?q?=20=E2=80=94=20subscription=20endpoint=20rejects=20non-streamin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ChatGPT subscription endpoint returns 400 for non-streaming responses.create() calls. Changed ask() to use responses.stream() matching the same pattern as generate(). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai-providers/codex/codex-provider.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/ai-providers/codex/codex-provider.ts b/src/ai-providers/codex/codex-provider.ts index 3cacde5f..2e62d2e8 100644 --- a/src/ai-providers/codex/codex-provider.ts +++ b/src/ai-providers/codex/codex-provider.ts @@ -69,19 +69,18 @@ export class CodexProvider implements AIProvider { 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) { From b5449a58e2c56a6eccc393153472c1287eb9c455 Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 22:55:45 +0800 Subject: [PATCH 15/18] feat: ask() accepts profile parameter + test before save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AIProvider.ask() now takes optional ResolvedProfile — providers use it for auth/model/endpoint instead of falling back to defaults. Fixes Codex ask() always using OAuth regardless of profile loginMethod. GenerateRouter gains askWithProfile() for inline (unsaved) profiles. Test endpoint changed to POST /profiles/test — accepts profile data, tests connectivity WITHOUT saving first. Frontend flow: validate → test (with profile data) → show result → save on success Co-Authored-By: Claude Opus 4.6 (1M context) --- .../agent-sdk/agent-sdk-provider.ts | 10 ++--- src/ai-providers/codex/codex-provider.ts | 4 +- src/ai-providers/mock/index.ts | 2 +- src/ai-providers/types.ts | 4 +- .../vercel-ai-sdk/vercel-provider.ts | 4 +- src/connectors/web/routes/config.ts | 9 +++-- src/core/agent-center.ts | 11 ++++-- src/core/ai-provider-manager.spec.ts | 2 +- src/core/ai-provider-manager.ts | 17 ++++++++- ui/src/api/config.ts | 8 +++- ui/src/pages/AIProviderPage.tsx | 38 +++++++++++-------- 11 files changed, 70 insertions(+), 39 deletions(-) diff --git a/src/ai-providers/agent-sdk/agent-sdk-provider.ts b/src/ai-providers/agent-sdk/agent-sdk-provider.ts index 723e9369..b1610638 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,13 +44,13 @@ 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) diff --git a/src/ai-providers/codex/codex-provider.ts b/src/ai-providers/codex/codex-provider.ts index 2e62d2e8..9d876eb9 100644 --- a/src/ai-providers/codex/codex-provider.ts +++ b/src/ai-providers/codex/codex-provider.ts @@ -64,8 +64,8 @@ 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 { 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/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/web/routes/config.ts b/src/connectors/web/routes/config.ts index 4ff62afe..b904433b 100644 --- a/src/connectors/web/routes/config.ts +++ b/src/connectors/web/routes/config.ts @@ -104,12 +104,13 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) { // ==================== Profile Test ==================== - /** POST /profiles/:slug/test — test a saved profile by sending "Hi" to its provider */ - app.post('/profiles/:slug/test', 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 slug = c.req.param('slug') - const result = await opts.ctx.agentCenter.testProfile(slug) + 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({ ok: false, error: err instanceof Error ? err.message : String(err) }) diff --git a/src/core/agent-center.ts b/src/core/agent-center.ts index 7ffe7038..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,10 +59,14 @@ export class AgentCenter { return this.router.ask(prompt) } - /** Test a profile by sending a prompt to its provider. Used for connection testing. */ + /** Test a saved profile by sending a prompt to its provider. */ async testProfile(profileSlug: string, prompt = 'Hi'): Promise { - const { provider } = await this.router.resolve(profileSlug) - return provider.ask(prompt) + 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. */ diff --git a/src/core/ai-provider-manager.spec.ts b/src/core/ai-provider-manager.spec.ts index 2c811dba..46f8cc0c 100644 --- a/src/core/ai-provider-manager.spec.ts +++ b/src/core/ai-provider-manager.spec.ts @@ -190,6 +190,6 @@ describe('GenerateRouter', () => { 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/ui/src/api/config.ts b/ui/src/api/config.ts index 0788fd26..5b516454 100644 --- a/ui/src/api/config.ts +++ b/ui/src/api/config.ts @@ -69,8 +69,12 @@ export const configApi = { } }, - async testProfile(slug: string): Promise<{ ok: boolean; response?: string; error?: string }> { - const res = await fetch(`/api/config/profiles/${encodeURIComponent(slug)}/test`, { method: 'POST' }) + 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() }, diff --git a/ui/src/pages/AIProviderPage.tsx b/ui/src/pages/AIProviderPage.tsx index 5bdc855b..f919f3cf 100644 --- a/ui/src/pages/AIProviderPage.tsx +++ b/ui/src/pages/AIProviderPage.tsx @@ -257,26 +257,34 @@ function ProfileCreateModal({ presets, existingNames, onSave, onClose }: { const validationError = validate() if (validationError) { setError(validationError); return } - setSaving(true); setError(''); setTestResult(null) - try { - const data = getSubmitData() - data.preset = selectedPreset.id - // Save profile - await onSave(trimmedName, data as unknown as Profile) - setSaving(false) + setError(''); setTestResult(null) + const data = getSubmitData() + data.preset = selectedPreset.id + const profileData = data as unknown as Profile - // Test connectivity - setTesting(true) - const result = await api.config.testProfile(trimmedName) + // Step 1: Test connectivity (before saving) + setTesting(true) + try { + const result = await api.config.testProfile(profileData) setTestResult(result) setTesting(false) - if (result.ok) { - setTimeout(onClose, 2000) - } + if (!result.ok) return // Don't save if test failed } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create') - setSaving(false); setTesting(false) + setTestResult({ ok: false, error: err instanceof Error ? err.message : 'Test failed' }) + setTesting(false) + return + } + + // Step 2: Save (test passed) + setSaving(true) + try { + await onSave(trimmedName, profileData) + setSaving(false) + setTimeout(onClose, 1500) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save') + setSaving(false) } } From 663a1b9c6aacfb625a55d40c9f126313f65c00b3 Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 23:05:19 +0800 Subject: [PATCH 16/18] fix: test and save are separate user actions Test Connection button runs the test. On success, button changes to Save for user to explicitly confirm. No more auto-save after test. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/pages/AIProviderPage.tsx | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/ui/src/pages/AIProviderPage.tsx b/ui/src/pages/AIProviderPage.tsx index f919f3cf..4baee6f0 100644 --- a/ui/src/pages/AIProviderPage.tsx +++ b/ui/src/pages/AIProviderPage.tsx @@ -262,26 +262,29 @@ function ProfileCreateModal({ presets, existingNames, onSave, onClose }: { data.preset = selectedPreset.id const profileData = data as unknown as Profile - // Step 1: Test connectivity (before saving) + // Test connectivity (don't save yet — user must confirm) setTesting(true) try { const result = await api.config.testProfile(profileData) setTestResult(result) - setTesting(false) - - if (!result.ok) return // Don't save if test failed } catch (err) { setTestResult({ ok: false, error: err instanceof Error ? err.message : 'Test failed' }) + } finally { setTesting(false) - return } + } - // Step 2: Save (test passed) - setSaving(true) + const handleSave = async () => { + if (!selectedPreset) return + const trimmedName = name.trim() + if (!trimmedName) return + const data = getSubmitData() + data.preset = selectedPreset.id + setSaving(true); setError('') try { - await onSave(trimmedName, profileData) + await onSave(trimmedName, data as unknown as Profile) setSaving(false) - setTimeout(onClose, 1500) + onClose() } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save') setSaving(false) @@ -355,9 +358,15 @@ function ProfileCreateModal({ presets, existingNames, onSave, onClose }: {
)}
- + {testResult?.ok ? ( + + ) : ( + + )}
From bf7df694d919451a6886bf0870ca609c8b6511ab Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 23:30:58 +0800 Subject: [PATCH 17/18] fix: force API key mode with CLAUDE_CODE_SIMPLE=1 to disable OAuth fallback When loginMethod is 'api-key', the Claude Code CLI would silently use a local OAuth session from ~/.claude/ instead of the provided ANTHROPIC_API_KEY. Setting CLAUDE_CODE_SIMPLE=1 (equivalent to --bare) disables OAuth entirely, forcing the CLI to use the API key. Investigated via Claude Code source: forceLoginMethod only controls which OAuth provider to use, not API key vs OAuth. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai-providers/agent-sdk/query.ts | 3 +++ 1 file changed, 3 insertions(+) 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 From 9e4d144796cc702c67215d6358d6dadbd238182f Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 8 Apr 2026 23:32:56 +0800 Subject: [PATCH 18/18] fix: agent-sdk ask() throws on error instead of returning error as text When askAgentSdk returns ok:false (e.g. CLI exit code 1 from invalid API key), ask() was returning the error message as successful text. Now it throws, so the test endpoint correctly reports failure. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai-providers/agent-sdk/agent-sdk-provider.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ai-providers/agent-sdk/agent-sdk-provider.ts b/src/ai-providers/agent-sdk/agent-sdk-provider.ts index b1610638..f9171aee 100644 --- a/src/ai-providers/agent-sdk/agent-sdk-provider.ts +++ b/src/ai-providers/agent-sdk/agent-sdk-provider.ts @@ -54,6 +54,7 @@ export class AgentSdkProvider implements AIProvider { } 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: [] } }