From 50c55bb108dd03e7e9443a7abea9e2c48b7f3e64 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 19 Mar 2026 11:55:02 -0400 Subject: [PATCH] feat(cliproxy): add dedicated ai providers workspace --- README.md | 5 +- docs/project-overview-pdr.md | 17 +- docs/project-roadmap.md | 2 +- src/cliproxy/ai-providers/config-store.ts | 128 +++ .../ai-providers/config-yaml-sections.ts | 49 ++ src/cliproxy/ai-providers/index.ts | 19 + src/cliproxy/ai-providers/service.ts | 246 ++++++ src/cliproxy/ai-providers/types.ts | 153 ++++ src/cliproxy/index.ts | 17 + src/cliproxy/management-api-client.ts | 25 +- src/cliproxy/types.ts | 22 + src/web-server/routes/ai-provider-routes.ts | 128 +++ src/web-server/routes/index.ts | 2 + ui/src/App.tsx | 11 + .../cliproxy/ai-providers/family-rail.tsx | 101 +++ .../components/cliproxy/ai-providers/index.ts | 3 + .../ai-providers/provider-entry-card.tsx | 282 +++++++ .../ai-providers/provider-entry-dialog.tsx | 264 ++++++ .../cliproxy/api-profile-bridge-callout.tsx | 61 -- .../cliproxy/control-panel-embed.tsx | 37 +- ui/src/components/layout/app-sidebar.tsx | 1 + .../profiles/cliproxy-bridge-create-panel.tsx | 114 --- .../profiles/editor/info-section.tsx | 75 +- .../profiles/openrouter-quick-start.tsx | 32 +- .../profiles/profile-create-dialog.tsx | 798 +++++++----------- ui/src/hooks/use-cliproxy-ai-providers.ts | 76 ++ ui/src/hooks/use-profiles.ts | 17 - ui/src/lib/api-client.ts | 40 +- ui/src/pages/api.tsx | 77 +- ui/src/pages/cliproxy-ai-providers.tsx | 529 ++++++++++++ ui/src/pages/cliproxy.tsx | 37 +- 31 files changed, 2461 insertions(+), 907 deletions(-) create mode 100644 src/cliproxy/ai-providers/config-store.ts create mode 100644 src/cliproxy/ai-providers/config-yaml-sections.ts create mode 100644 src/cliproxy/ai-providers/index.ts create mode 100644 src/cliproxy/ai-providers/service.ts create mode 100644 src/cliproxy/ai-providers/types.ts create mode 100644 src/web-server/routes/ai-provider-routes.ts create mode 100644 ui/src/components/cliproxy/ai-providers/family-rail.tsx create mode 100644 ui/src/components/cliproxy/ai-providers/index.ts create mode 100644 ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx create mode 100644 ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx delete mode 100644 ui/src/components/cliproxy/api-profile-bridge-callout.tsx delete mode 100644 ui/src/components/profiles/cliproxy-bridge-create-panel.tsx create mode 100644 ui/src/hooks/use-cliproxy-ai-providers.ts create mode 100644 ui/src/pages/cliproxy-ai-providers.tsx diff --git a/README.md b/README.md index 400629ec..dd0d0dd4 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ The dashboard provides visual management for all account types: - **Claude Accounts**: Isolation-first by default (work, personal, client), with explicit shared context opt-in - **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot -- **API Profiles**: Configure GLM, Kimi with your keys, or create routed profiles from configured CLIProxy providers without copying proxy URLs or internal tokens +- **AI Providers**: Configure Gemini, Codex, Claude, Vertex, and OpenAI-compatible API keys under `CLIProxy -> AI Providers` +- **API Profiles**: Configure GLM, Kimi, OpenRouter, and other Anthropic-compatible APIs as CCS-native profiles - **Factory Droid**: Track Droid install location and BYOK settings health - **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations) - **Health Monitor**: Real-time status across all profiles @@ -148,7 +149,7 @@ The dashboard provides visual management for all account types: > **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`. -> **CLIProxy -> API Profiles bridge:** After you configure a provider in `ccs config` -> `CLIProxy` or the embedded control panel, open `API Profiles` and use the CLIProxy provider entry to create a routed profile. CCS fills the provider route and current proxy auth automatically. +> **AI Providers dashboard:** Configure CLIProxy-managed API key families at `ccs config` -> `CLIProxy` -> `AI Providers`. Use `API Profiles` only for CCS-native Anthropic-compatible profiles. **Powered by:** - [CLIProxyAPIPlus](https://github.com/router-for-me/CLIProxyAPIPlus) - Extended OAuth proxy with Kiro ([@fuko2935](https://github.com/fuko2935), [@Ravens2121](https://github.com/Ravens2121)) and Copilot ([@em4go](https://github.com/em4go)) support diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 0d85756b..2849246f 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -32,12 +32,11 @@ CCS provides: 1. **Multi-Account Claude**: Isolated instances via `CLAUDE_CONFIG_DIR` 2. **OAuth Providers**: Zero-config Gemini, Codex, Antigravity, Copilot, Kiro (ghcp) integration -3. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API -4. **Visual Dashboard**: React SPA for configuration management -5. **Automatic WebSearch**: MCP fallback for third-party providers -6. **Usage Analytics**: Token tracking, cost analysis, model breakdown - -Configured CLIProxy providers can also be bridged into routed API Profiles from the dashboard, so users do not have to hand-copy `/api/provider/{provider}` URLs or internal proxy auth tokens. +3. **AI Providers**: Dedicated CLIProxy dashboard for Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key families +4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API +5. **Visual Dashboard**: React SPA for configuration management +6. **Automatic WebSearch**: MCP fallback for third-party providers +7. **Usage Analytics**: Token tracking, cost analysis, model breakdown --- @@ -75,7 +74,11 @@ Configured CLIProxy providers can also be bridged into routed API Profiles from - Support Anthropic-compatible APIs - Model mapping and configuration - OpenRouter integration with 300+ models -- Create routed API Profiles from configured CLIProxy providers without manually copying proxy URLs or internal auth tokens + +### FR-004A: CLIProxy AI Provider Management +- Configure CLIProxy-managed Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key entries +- Keep provider authoring separate from CCS API Profile creation +- Support local config editing and remote CLIProxy management parity where available ### FR-005: Dashboard UI - Visual profile management diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 97f3aabe..4ca846c8 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -41,7 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes -- **2026-03-19**: **#649** CLIProxy and API Profiles now share a guided bridge flow. Users can configure a provider in CLIProxy or the embedded control panel, jump into `API Profiles`, and create a routed profile without manually typing `/api/provider/{provider}` URLs, proxy auth tokens, or default model wiring. Bridge profiles are labeled in the API list/editor and resolve against the current local or remote CLIProxy target. +- **2026-03-19**: **#649** CCS splits CLIProxy provider-key authoring into a dedicated `CLIProxy -> AI Providers` dashboard route. `/cliproxy` now stays focused on OAuth accounts and variants, `/cliproxy/ai-providers` owns Gemini/Codex/Claude/Vertex/OpenAI-compatible key management, and `/providers` stays reserved for CCS-native API Profiles. - **2026-03-18**: **#755** Marketplace refresh no longer reuses one shared `known_marketplaces.json` across isolated instances. CCS now keeps marketplace payload directories shared while reconciling per-instance marketplace metadata so Claude Code validation succeeds for alternating or concurrent profiles, including Windows copy fallback. - **2026-03-17**: Deprecated user-facing GLMT discovery across CLI help, completions, presets, and docs. Existing `glmt` profiles now run through a compatibility path that normalizes legacy proxy settings to the direct GLM endpoint. - **#748**: API profile creation now keeps provider selection compact by collapsing advanced presets behind an explicit toggle, shrinking chooser cards so the form fields stay visually primary, and giving `llama.cpp` a dedicated provider logo. diff --git a/src/cliproxy/ai-providers/config-store.ts b/src/cliproxy/ai-providers/config-store.ts new file mode 100644 index 00000000..2cc665f4 --- /dev/null +++ b/src/cliproxy/ai-providers/config-store.ts @@ -0,0 +1,128 @@ +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; +import { configExists, getCliproxyConfigPath, regenerateConfig } from '../config-generator'; +import { getProxyTarget } from '../proxy-target-resolver'; +import { createManagementClient } from '../management-api-client'; +import { rewriteTopLevelYamlSection } from './config-yaml-sections'; +import type { + AiProviderApiKeyEntry, + AiProviderFamilyId, + LocalAiProviderConfig, + OpenAICompatEntry, +} from './types'; + +type FamilyEntriesMap = { + 'gemini-api-key': AiProviderApiKeyEntry[]; + 'codex-api-key': AiProviderApiKeyEntry[]; + 'claude-api-key': AiProviderApiKeyEntry[]; + 'vertex-api-key': AiProviderApiKeyEntry[]; + 'openai-compatibility': OpenAICompatEntry[]; +}; + +export type FamilyEntries = FamilyEntriesMap[F]; + +function ensureLocalConfigPath(): string { + if (!configExists()) { + regenerateConfig(); + } + return getCliproxyConfigPath(); +} + +function readLocalConfig(): LocalAiProviderConfig { + const configPath = ensureLocalConfigPath(); + if (!fs.existsSync(configPath)) { + return {}; + } + + try { + const content = fs.readFileSync(configPath, 'utf8'); + return (yaml.load(content) as LocalAiProviderConfig) || {}; + } catch { + return {}; + } +} + +function writeLocalFamilySection( + family: F, + entries: FamilyEntries +): void { + const configPath = ensureLocalConfigPath(); + const content = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : ''; + const sectionYaml = + entries.length > 0 + ? yaml.dump( + { [family]: entries }, + { + indent: 2, + lineWidth: -1, + quotingType: "'", + forceQuotes: false, + } + ) + : null; + const nextContent = rewriteTopLevelYamlSection(content, family, sectionYaml); + const tempPath = `${configPath}.tmp`; + fs.writeFileSync(tempPath, nextContent, { mode: 0o600 }); + fs.renameSync(tempPath, configPath); +} + +export function getAiProvidersSourceSummary() { + const target = getProxyTarget(); + const managementAuth = target.isRemote + ? target.managementKey + ? 'configured' + : target.authToken + ? 'fallback' + : 'missing' + : 'configured'; + + return { + mode: target.isRemote ? 'remote' : 'local', + label: target.isRemote ? 'Remote CLIProxy' : 'Local CLIProxy', + target: `${target.protocol}://${target.host}:${target.port}`, + managementAuth, + } as const; +} + +export async function readFamilyEntries( + family: F +): Promise> { + const target = getProxyTarget(); + + if (!target.isRemote) { + const config = readLocalConfig(); + return (config[family] || []) as FamilyEntries; + } + + const client = createManagementClient({ + host: target.host, + port: target.port, + protocol: target.protocol, + management_key: target.managementKey, + auth_token: target.authToken, + }); + + return client.getSection[number]>(family) as Promise>; +} + +export async function writeFamilyEntries( + family: F, + entries: FamilyEntries +): Promise { + const target = getProxyTarget(); + + if (!target.isRemote) { + writeLocalFamilySection(family, entries); + return; + } + + const client = createManagementClient({ + host: target.host, + port: target.port, + protocol: target.protocol, + management_key: target.managementKey, + auth_token: target.authToken, + }); + + await client.putSection[number]>(family, entries as FamilyEntries[number][]); +} diff --git a/src/cliproxy/ai-providers/config-yaml-sections.ts b/src/cliproxy/ai-providers/config-yaml-sections.ts new file mode 100644 index 00000000..a5502b22 --- /dev/null +++ b/src/cliproxy/ai-providers/config-yaml-sections.ts @@ -0,0 +1,49 @@ +export function rewriteTopLevelYamlSection( + content: string, + sectionKey: string, + newSection: string | null +): string { + const lines = content.split('\n'); + const result: string[] = []; + let inSection = false; + let sectionFound = false; + + for (const line of lines) { + const trimmed = line.trimStart(); + if (trimmed.startsWith(`${sectionKey}:`)) { + inSection = true; + sectionFound = true; + if (newSection) { + result.push(newSection.trimEnd()); + } + continue; + } + + if (inSection) { + const isTopLevelKey = + line.length > 0 && + !line.startsWith(' ') && + !line.startsWith('\t') && + !line.startsWith('#') && + /^[a-zA-Z_][a-zA-Z0-9_-]*\s*:/.test(line); + + if (isTopLevelKey) { + inSection = false; + result.push(line); + } + continue; + } + + result.push(line); + } + + if (!sectionFound && newSection) { + result.push(''); + result.push(newSection.trimEnd()); + } + + return `${result + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trimEnd()}\n`; +} diff --git a/src/cliproxy/ai-providers/index.ts b/src/cliproxy/ai-providers/index.ts new file mode 100644 index 00000000..e052a979 --- /dev/null +++ b/src/cliproxy/ai-providers/index.ts @@ -0,0 +1,19 @@ +export type { + AiProviderApiKeyEntry, + AiProviderEntryView, + AiProviderFamilyDefinition, + AiProviderFamilyId, + AiProviderFamilyState, + AiProviderModelAlias, + AiProvidersSourceSummary, + ListAiProvidersResult, + OpenAICompatEntry, + UpsertAiProviderEntryInput, +} from './types'; +export { AI_PROVIDER_FAMILY_DEFINITIONS, AI_PROVIDER_FAMILY_IDS } from './types'; +export { + listAiProviders, + createAiProviderEntry, + updateAiProviderEntry, + deleteAiProviderEntry, +} from './service'; diff --git a/src/cliproxy/ai-providers/service.ts b/src/cliproxy/ai-providers/service.ts new file mode 100644 index 00000000..a03f959a --- /dev/null +++ b/src/cliproxy/ai-providers/service.ts @@ -0,0 +1,246 @@ +import { + AI_PROVIDER_FAMILY_DEFINITIONS, + AI_PROVIDER_FAMILY_IDS, + type AiProviderApiKeyEntry, + type AiProviderEntryView, + type AiProviderFamilyId, + type AiProviderFamilyState, + type AiProviderModelAlias, + type ListAiProvidersResult, + type OpenAICompatEntry, + type UpsertAiProviderEntryInput, +} from './types'; +import { getAiProvidersSourceSummary, readFamilyEntries, writeFamilyEntries } from './config-store'; + +function maskSecret(value: string | undefined): string | undefined { + if (!value) return undefined; + return value.length > 8 ? `...${value.slice(-4)}` : '***'; +} + +function normalizeHeaders( + headers: Array<{ key: string; value: string }> | undefined +): Record | undefined { + if (!headers) return undefined; + const normalized = headers.reduce>((acc, header) => { + const key = header.key.trim(); + if (!key) return acc; + acc[key] = header.value; + return acc; + }, {}); + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +function toHeaderPairs( + headers: Record | undefined +): Array<{ key: string; value: string }> { + return Object.entries(headers || {}).map(([key, value]) => ({ key, value })); +} + +function normalizeModelAliases(models: AiProviderModelAlias[] | undefined): AiProviderModelAlias[] { + return (models || []) + .map((model) => ({ + name: model.name.trim(), + alias: model.alias.trim(), + })) + .filter((model) => model.name.length > 0 || model.alias.length > 0); +} + +function buildApiKeyEntryView( + family: AiProviderFamilyId, + entry: AiProviderApiKeyEntry, + index: number +): AiProviderEntryView { + return { + id: `${family}:${index}`, + index, + label: entry.prefix?.trim() || entry['base-url']?.trim() || `Entry ${index + 1}`, + baseUrl: entry['base-url']?.trim() || undefined, + proxyUrl: entry['proxy-url']?.trim() || undefined, + prefix: entry.prefix?.trim() || undefined, + headers: toHeaderPairs(entry.headers), + excludedModels: [...(entry['excluded-models'] || [])], + models: normalizeModelAliases(entry.models), + apiKeyMasked: maskSecret(entry['api-key']), + secretConfigured: Boolean(entry['api-key']), + }; +} + +function buildOpenAiCompatEntryView(entry: OpenAICompatEntry, index: number): AiProviderEntryView { + return { + id: `openai-compatibility:${index}`, + index, + name: entry.name, + label: entry.name, + baseUrl: entry['base-url']?.trim() || undefined, + headers: toHeaderPairs(entry.headers), + excludedModels: [], + models: normalizeModelAliases(entry.models), + apiKeysMasked: (entry['api-key-entries'] || []).map( + (apiKeyEntry) => maskSecret(apiKeyEntry['api-key']) || '***' + ), + secretConfigured: (entry['api-key-entries'] || []).length > 0, + }; +} + +function resolveFamilyStatus(entries: AiProviderEntryView[]): AiProviderFamilyState['status'] { + if (entries.length === 0) return 'empty'; + return entries.every((entry) => entry.secretConfigured) ? 'ready' : 'partial'; +} + +export async function listAiProviders(): Promise { + const families = await Promise.all( + AI_PROVIDER_FAMILY_IDS.map(async (familyId) => { + const definition = AI_PROVIDER_FAMILY_DEFINITIONS[familyId]; + if (familyId === 'openai-compatibility') { + const entries = await readFamilyEntries(familyId); + const entryViews = entries.map((entry, index) => buildOpenAiCompatEntryView(entry, index)); + return { + ...definition, + status: resolveFamilyStatus(entryViews), + entries: entryViews, + }; + } + + const entries = await readFamilyEntries(familyId); + const entryViews = entries.map((entry, index) => + buildApiKeyEntryView(familyId, entry, index) + ); + return { + ...definition, + status: resolveFamilyStatus(entryViews), + entries: entryViews, + }; + }) + ); + + return { + source: getAiProvidersSourceSummary(), + families, + }; +} + +function toApiKeyEntry( + input: UpsertAiProviderEntryInput, + existing?: AiProviderApiKeyEntry +): AiProviderApiKeyEntry { + const nextSecret = + input.apiKey !== undefined + ? input.apiKey.trim() + : input.preserveSecrets + ? existing?.['api-key'] || '' + : existing?.['api-key'] || ''; + + return { + 'api-key': nextSecret, + 'base-url': input.baseUrl?.trim() || undefined, + 'proxy-url': input.proxyUrl?.trim() || undefined, + prefix: input.prefix?.trim() || undefined, + headers: normalizeHeaders(input.headers), + 'excluded-models': (input.excludedModels || []) + .map((value) => value.trim()) + .filter((value) => value.length > 0), + models: normalizeModelAliases(input.models), + }; +} + +function toOpenAiCompatEntry( + input: UpsertAiProviderEntryInput, + existing?: OpenAICompatEntry +): OpenAICompatEntry { + const nextApiKeys = + input.apiKeys !== undefined + ? input.apiKeys.map((value) => value.trim()).filter((value) => value.length > 0) + : input.preserveSecrets + ? (existing?.['api-key-entries'] || []).map((entry) => entry['api-key']) + : (existing?.['api-key-entries'] || []).map((entry) => entry['api-key']); + + return { + name: input.name?.trim() || existing?.name || 'connector', + 'base-url': input.baseUrl?.trim() || existing?.['base-url'] || '', + headers: normalizeHeaders(input.headers), + 'api-key-entries': nextApiKeys.map((apiKey) => ({ 'api-key': apiKey })), + models: normalizeModelAliases(input.models), + }; +} + +function assertIndex(entries: unknown[], index: number): void { + if (!Number.isInteger(index) || index < 0 || index >= entries.length) { + throw new Error('Entry not found'); + } +} + +function validateFamilyInput(family: AiProviderFamilyId, input: UpsertAiProviderEntryInput): void { + if (family === 'openai-compatibility') { + if (!(input.name?.trim() || '').length) { + throw new Error('name is required'); + } + if (!(input.baseUrl?.trim() || '').length) { + throw new Error('baseUrl is required'); + } + if (!input.preserveSecrets && !(input.apiKeys || []).some((value) => value.trim().length > 0)) { + throw new Error('At least one api key is required'); + } + return; + } + + if (!input.preserveSecrets && !(input.apiKey?.trim() || '').length) { + throw new Error('apiKey is required'); + } +} + +export async function createAiProviderEntry( + family: AiProviderFamilyId, + input: UpsertAiProviderEntryInput +): Promise { + validateFamilyInput(family, input); + + if (family === 'openai-compatibility') { + const entries = await readFamilyEntries(family); + entries.push(toOpenAiCompatEntry(input)); + await writeFamilyEntries(family, entries); + return; + } + + const entries = await readFamilyEntries(family); + entries.push(toApiKeyEntry(input)); + await writeFamilyEntries(family, entries); +} + +export async function updateAiProviderEntry( + family: AiProviderFamilyId, + index: number, + input: UpsertAiProviderEntryInput +): Promise { + if (family === 'openai-compatibility') { + const entries = await readFamilyEntries(family); + assertIndex(entries, index); + validateFamilyInput(family, input); + entries[index] = toOpenAiCompatEntry(input, entries[index]); + await writeFamilyEntries(family, entries); + return; + } + + const entries = await readFamilyEntries(family); + assertIndex(entries, index); + validateFamilyInput(family, input); + entries[index] = toApiKeyEntry(input, entries[index]); + await writeFamilyEntries(family, entries); +} + +export async function deleteAiProviderEntry( + family: AiProviderFamilyId, + index: number +): Promise { + if (family === 'openai-compatibility') { + const entries = await readFamilyEntries(family); + assertIndex(entries, index); + entries.splice(index, 1); + await writeFamilyEntries(family, entries); + return; + } + + const entries = await readFamilyEntries(family); + assertIndex(entries, index); + entries.splice(index, 1); + await writeFamilyEntries(family, entries); +} diff --git a/src/cliproxy/ai-providers/types.ts b/src/cliproxy/ai-providers/types.ts new file mode 100644 index 00000000..3fe9cff6 --- /dev/null +++ b/src/cliproxy/ai-providers/types.ts @@ -0,0 +1,153 @@ +export const AI_PROVIDER_FAMILY_IDS = [ + 'gemini-api-key', + 'codex-api-key', + 'claude-api-key', + 'vertex-api-key', + 'openai-compatibility', +] as const; + +export type AiProviderFamilyId = (typeof AI_PROVIDER_FAMILY_IDS)[number]; + +export interface AiProviderModelAlias { + name: string; + alias: string; +} + +export interface AiProviderApiKeyEntry { + 'api-key': string; + 'base-url'?: string; + 'proxy-url'?: string; + prefix?: string; + headers?: Record; + 'excluded-models'?: string[]; + models?: AiProviderModelAlias[]; +} + +export interface OpenAICompatApiKeyEntry { + 'api-key': string; + 'proxy-url'?: string; +} + +export interface OpenAICompatEntry { + name: string; + 'base-url': string; + headers?: Record; + 'api-key-entries': OpenAICompatApiKeyEntry[]; + models?: AiProviderModelAlias[]; +} + +export interface AiProviderFamilyDefinition { + id: AiProviderFamilyId; + displayName: string; + description: string; + authMode: 'api-key' | 'hybrid' | 'connector'; + supportsNamedEntries: boolean; + routePath: string; +} + +export interface AiProviderEntryView { + id: string; + index: number; + name?: string; + label: string; + baseUrl?: string; + proxyUrl?: string; + prefix?: string; + headers: Array<{ key: string; value: string }>; + excludedModels: string[]; + models: AiProviderModelAlias[]; + apiKeyMasked?: string; + apiKeysMasked?: string[]; + secretConfigured: boolean; +} + +export interface AiProviderFamilyState { + id: AiProviderFamilyId; + displayName: string; + description: string; + authMode: 'api-key' | 'hybrid' | 'connector'; + routePath: string; + status: 'empty' | 'partial' | 'ready'; + supportsNamedEntries: boolean; + entries: AiProviderEntryView[]; +} + +export interface AiProvidersSourceSummary { + mode: 'local' | 'remote'; + label: string; + target: string; + managementAuth: 'configured' | 'fallback' | 'missing'; +} + +export interface ListAiProvidersResult { + source: AiProvidersSourceSummary; + families: AiProviderFamilyState[]; +} + +export interface UpsertAiProviderEntryInput { + name?: string; + baseUrl?: string; + proxyUrl?: string; + prefix?: string; + headers?: Array<{ key: string; value: string }>; + excludedModels?: string[]; + models?: AiProviderModelAlias[]; + apiKey?: string; + apiKeys?: string[]; + preserveSecrets?: boolean; +} + +export interface LocalAiProviderConfig { + 'gemini-api-key'?: AiProviderApiKeyEntry[]; + 'codex-api-key'?: AiProviderApiKeyEntry[]; + 'claude-api-key'?: AiProviderApiKeyEntry[]; + 'vertex-api-key'?: AiProviderApiKeyEntry[]; + 'openai-compatibility'?: OpenAICompatEntry[]; + [key: string]: unknown; +} + +export const AI_PROVIDER_FAMILY_DEFINITIONS: Record< + AiProviderFamilyId, + AiProviderFamilyDefinition +> = { + 'gemini-api-key': { + id: 'gemini-api-key', + displayName: 'Gemini', + description: 'Google Gemini API keys and route defaults', + authMode: 'hybrid', + supportsNamedEntries: false, + routePath: '/api/provider/gemini', + }, + 'codex-api-key': { + id: 'codex-api-key', + displayName: 'Codex', + description: 'OpenAI Codex API keys and endpoint overrides', + authMode: 'hybrid', + supportsNamedEntries: false, + routePath: '/api/provider/codex', + }, + 'claude-api-key': { + id: 'claude-api-key', + displayName: 'Claude', + description: 'Anthropic-compatible routing entries with aliases and filters', + authMode: 'api-key', + supportsNamedEntries: false, + routePath: '/api/provider/claude', + }, + 'vertex-api-key': { + id: 'vertex-api-key', + displayName: 'Vertex', + description: 'Vertex AI API keys and regional endpoint overrides', + authMode: 'api-key', + supportsNamedEntries: false, + routePath: '/api/provider/vertex', + }, + 'openai-compatibility': { + id: 'openai-compatibility', + displayName: 'OpenAI-Compatible', + description: 'Named connectors for OpenRouter, Together, and custom OpenAI-style APIs', + authMode: 'connector', + supportsNamedEntries: true, + routePath: '/api/provider/openai-compat', + }, +}; diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index 375a2b34..aef15b13 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -143,6 +143,23 @@ export { TOGETHER_TEMPLATE, } from './openai-compat-manager'; +// AI provider management +export type { + AiProviderFamilyId, + AiProviderFamilyState, + AiProviderEntryView, + ListAiProvidersResult, + UpsertAiProviderEntryInput, +} from './ai-providers'; +export { + AI_PROVIDER_FAMILY_DEFINITIONS, + AI_PROVIDER_FAMILY_IDS, + listAiProviders, + createAiProviderEntry, + updateAiProviderEntry, + deleteAiProviderEntry, +} from './ai-providers'; + // Service manager (background CLIProxy for dashboard) export type { ServiceStartResult } from './service-manager'; export { ensureCliproxyService, stopCliproxyService, getServiceStatus } from './service-manager'; diff --git a/src/cliproxy/management-api-client.ts b/src/cliproxy/management-api-client.ts index f33edd21..7afff06f 100644 --- a/src/cliproxy/management-api-client.ts +++ b/src/cliproxy/management-api-client.ts @@ -11,7 +11,6 @@ import type { ManagementHealthStatus, ManagementApiErrorCode, ClaudeKey, - GetClaudeKeysResponse, ClaudeKeyPatch, RemoteModelInfo, GetModelDefinitionsResponse, @@ -189,11 +188,7 @@ export class ManagementApiClient { * Get all claude-api-key entries from remote CLIProxy. */ async getClaudeKeys(): Promise { - const response = await this.request( - 'GET', - '/v0/management/claude-api-key' - ); - return response.data?.['claude-api-key'] ?? []; + return this.getSection('claude-api-key'); } /** @@ -201,7 +196,7 @@ export class ManagementApiClient { * This is an atomic operation - all entries are replaced at once. */ async putClaudeKeys(keys: ClaudeKey[]): Promise { - await this.request('PUT', '/v0/management/claude-api-key', keys); + await this.putSection('claude-api-key', keys); } /** @@ -232,6 +227,22 @@ export class ManagementApiClient { return response.data?.models ?? []; } + /** + * Get a management section from CLIProxyAPI. + * Example sections: claude-api-key, gemini-api-key, codex-api-key. + */ + async getSection(section: string): Promise { + const response = await this.request>('GET', `/v0/management/${section}`); + return response.data?.[section] ?? []; + } + + /** + * Replace an entire management section on CLIProxyAPI. + */ + async putSection(section: string, entries: T[]): Promise { + await this.request('PUT', `/v0/management/${section}`, entries); + } + /** * Make an HTTP request to the Management API. */ diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index f3b246de..2cb7b292 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -162,11 +162,33 @@ export interface CLIProxyConfig { 'api-key': string; 'base-url'?: string; }>; + 'claude-api-key'?: Array<{ + 'api-key': string; + 'base-url'?: string; + 'proxy-url'?: string; + prefix?: string; + headers?: Record; + 'excluded-models'?: string[]; + models?: Array<{ + name: string; + alias: string; + }>; + }>; + 'vertex-api-key'?: Array<{ + 'api-key': string; + 'base-url'?: string; + }>; 'openai-compatibility'?: Array<{ name: string; 'base-url': string; + headers?: Record; 'api-key-entries': Array<{ 'api-key': string; + 'proxy-url'?: string; + }>; + models?: Array<{ + name: string; + alias: string; }>; }>; } diff --git a/src/web-server/routes/ai-provider-routes.ts b/src/web-server/routes/ai-provider-routes.ts new file mode 100644 index 00000000..cb2a3492 --- /dev/null +++ b/src/web-server/routes/ai-provider-routes.ts @@ -0,0 +1,128 @@ +import { Router, type Request, type Response } from 'express'; +import { + AI_PROVIDER_FAMILY_IDS, + createAiProviderEntry, + deleteAiProviderEntry, + listAiProviders, + updateAiProviderEntry, + type AiProviderFamilyId, + type UpsertAiProviderEntryInput, +} from '../../cliproxy/ai-providers'; + +const router = Router(); + +function isAiProviderFamilyId(value: string): value is AiProviderFamilyId { + return AI_PROVIDER_FAMILY_IDS.includes(value as AiProviderFamilyId); +} + +function parseFamily(req: Request, res: Response): AiProviderFamilyId | null { + const family = req.params.family?.trim(); + if (!family || !isAiProviderFamilyId(family)) { + res.status(400).json({ error: 'Invalid AI provider family' }); + return null; + } + return family; +} + +function parseIndex(req: Request, res: Response): number | null { + const index = Number.parseInt(req.params.index || '', 10); + if (!Number.isInteger(index) || index < 0) { + res.status(400).json({ error: 'Invalid entry index' }); + return null; + } + return index; +} + +function parseInput(body: unknown): UpsertAiProviderEntryInput { + const payload = + typeof body === 'object' && body !== null ? (body as Record) : {}; + return { + name: typeof payload.name === 'string' ? payload.name : undefined, + baseUrl: typeof payload.baseUrl === 'string' ? payload.baseUrl : undefined, + proxyUrl: typeof payload.proxyUrl === 'string' ? payload.proxyUrl : undefined, + prefix: typeof payload.prefix === 'string' ? payload.prefix : undefined, + headers: Array.isArray(payload.headers) + ? payload.headers + .filter( + (item): item is { key?: unknown; value?: unknown } => + typeof item === 'object' && item !== null + ) + .map((item) => ({ + key: typeof item.key === 'string' ? item.key : '', + value: typeof item.value === 'string' ? item.value : '', + })) + : undefined, + excludedModels: Array.isArray(payload.excludedModels) + ? payload.excludedModels.filter((item): item is string => typeof item === 'string') + : undefined, + models: Array.isArray(payload.models) + ? payload.models + .filter( + (item): item is { name?: unknown; alias?: unknown } => + typeof item === 'object' && item !== null + ) + .map((item) => ({ + name: typeof item.name === 'string' ? item.name : '', + alias: typeof item.alias === 'string' ? item.alias : '', + })) + : undefined, + apiKey: typeof payload.apiKey === 'string' ? payload.apiKey : undefined, + apiKeys: Array.isArray(payload.apiKeys) + ? payload.apiKeys.filter((item): item is string => typeof item === 'string') + : undefined, + preserveSecrets: payload.preserveSecrets === true, + }; +} + +router.get('/', async (_req: Request, res: Response) => { + try { + res.json(await listAiProviders()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.post('/:family', async (req: Request, res: Response) => { + const family = parseFamily(req, res); + if (!family) return; + + try { + await createAiProviderEntry(family, parseInput(req.body)); + res.status(201).json({ success: true }); + } catch (error) { + const message = (error as Error).message; + res.status(message === 'Entry not found' ? 404 : 400).json({ error: message }); + } +}); + +router.put('/:family/:index', async (req: Request, res: Response) => { + const family = parseFamily(req, res); + if (!family) return; + const index = parseIndex(req, res); + if (index === null) return; + + try { + await updateAiProviderEntry(family, index, parseInput(req.body)); + res.json({ success: true }); + } catch (error) { + const message = (error as Error).message; + res.status(message === 'Entry not found' ? 404 : 400).json({ error: message }); + } +}); + +router.delete('/:family/:index', async (req: Request, res: Response) => { + const family = parseFamily(req, res); + if (!family) return; + const index = parseIndex(req, res); + if (index === null) return; + + try { + await deleteAiProviderEntry(family, index); + res.json({ success: true }); + } catch (error) { + const message = (error as Error).message; + res.status(message === 'Entry not found' ? 404 : 400).json({ error: message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 9fafe3f7..49066a51 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -19,6 +19,7 @@ import websearchRoutes from './websearch-routes'; import cliproxyAuthRoutes from './cliproxy-auth-routes'; import cliproxyStatsRoutes from './cliproxy-stats-routes'; import cliproxySyncRoutes from './cliproxy-sync-routes'; +import aiProviderRoutes from './ai-provider-routes'; import copilotRoutes from './copilot-routes'; import cursorRoutes from './cursor-routes'; import droidRoutes from './droid-routes'; @@ -59,6 +60,7 @@ apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes); apiRoutes.use('/cliproxy', cliproxyStatsRoutes); apiRoutes.use('/cliproxy/sync', cliproxySyncRoutes); apiRoutes.use('/cliproxy/catalog', catalogRoutes); +apiRoutes.use('/cliproxy/ai-providers', aiProviderRoutes); apiRoutes.use('/cliproxy/openai-compat', providerRoutes); // ==================== WebSearch ==================== diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 31b3f343..b065cbc1 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -23,6 +23,9 @@ const ApiPage = lazy(() => import('@/pages/api').then((m) => ({ default: m.ApiPa const CliproxyPage = lazy(() => import('@/pages/cliproxy').then((m) => ({ default: m.CliproxyPage })) ); +const CliproxyAiProvidersPage = lazy(() => + import('@/pages/cliproxy-ai-providers').then((m) => ({ default: m.CliproxyAiProvidersPage })) +); const CliproxyControlPanelPage = lazy(() => import('@/pages/cliproxy-control-panel').then((m) => ({ default: m.CliproxyControlPanelPage })) ); @@ -98,6 +101,14 @@ export default function App() { } /> + }> + + + } + /> void; +} + +function getFamilyProvider(familyId: AiProviderFamilyId): string { + switch (familyId) { + case 'gemini-api-key': + return 'gemini'; + case 'codex-api-key': + return 'codex'; + case 'claude-api-key': + return 'claude'; + case 'vertex-api-key': + return 'vertex'; + case 'openai-compatibility': + return 'openrouter'; + } +} + +function getStatusState(status: AiProviderFamilyState['status']) { + switch (status) { + case 'ready': + return { + icon: Check, + text: 'Ready', + className: 'text-green-600', + }; + case 'partial': + return { + icon: AlertCircle, + text: 'Needs attention', + className: 'text-amber-600', + }; + default: + return { + icon: Circle, + text: 'Not configured', + className: 'text-muted-foreground', + }; + } +} + +export function FamilyRail({ families, selectedFamily, onSelect }: FamilyRailProps) { + return ( +
+ {families.map((family) => { + const isSelected = family.id === selectedFamily; + const statusState = getStatusState(family.status); + const StatusIcon = statusState.icon; + + return ( + + ); + })} +
+ ); +} diff --git a/ui/src/components/cliproxy/ai-providers/index.ts b/ui/src/components/cliproxy/ai-providers/index.ts new file mode 100644 index 00000000..c3747f41 --- /dev/null +++ b/ui/src/components/cliproxy/ai-providers/index.ts @@ -0,0 +1,3 @@ +export { FamilyRail } from './family-rail'; +export { ProviderEntryCard } from './provider-entry-card'; +export { ProviderEntryDialog } from './provider-entry-dialog'; diff --git a/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx b/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx new file mode 100644 index 00000000..abac67eb --- /dev/null +++ b/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx @@ -0,0 +1,282 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { + AiProviderEntryView, + AiProviderFamilyState, +} from '../../../../../src/cliproxy/ai-providers'; +import { Check, ChevronRight, Circle, KeyRound, Pencil, Trash2 } from 'lucide-react'; + +interface ProviderEntryCardProps { + family: AiProviderFamilyState; + entry: AiProviderEntryView; + onEdit: () => void; + onDelete: () => void; + onSelect?: () => void; + isSelected?: boolean; + variant?: 'row' | 'detail'; +} + +function renderCountLabel(count: number, singular: string, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; +} + +function renderSecretBadge(entry: AiProviderEntryView) { + return ( + + {entry.secretConfigured ? 'Configured' : 'Missing secret'} + + ); +} + +export function ProviderEntryCard({ + family, + entry, + onEdit, + onDelete, + onSelect, + isSelected = false, + variant = 'detail', +}: ProviderEntryCardProps) { + const hasAdvancedRouting = entry.prefix || entry.proxyUrl || entry.excludedModels.length > 0; + + if (variant === 'row') { + return ( +
{ + if (!onSelect) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect(); + } + }} + > +
+
+ +
+ +
+
+ {entry.label} + {renderSecretBadge(entry)} +
+

+ {entry.baseUrl || family.routePath} +

+
+ {renderCountLabel(entry.models.length, 'alias')} + + {renderCountLabel(entry.headers.length, 'header')} + {entry.excludedModels.length > 0 && ( + <> + + {renderCountLabel(entry.excludedModels.length, 'exclusion')} + + )} +
+
+ +
+ + + {onSelect ? ( +
+ +
+ ) : null} +
+
+
+ ); + } + + return ( +
+
+
+
+

{entry.label}

+ {renderSecretBadge(entry)} +
+

+ Routed through {family.routePath} +

+
+
+ + +
+
+ +
+
+
Secret
+
+ + {entry.apiKeyMasked || entry.apiKeysMasked?.join(', ') || 'Not stored'} +
+
+
+
Base URL
+
+ {entry.baseUrl || 'Default runtime endpoint'} +
+
+
+
Aliases
+
{renderCountLabel(entry.models.length, 'mapping')}
+
+
+
Headers
+
{renderCountLabel(entry.headers.length, 'header')}
+
+
+ + {hasAdvancedRouting && ( +
+ {entry.prefix && Prefix {entry.prefix}} + {entry.proxyUrl && Proxy URL set} + {entry.excludedModels.length > 0 && ( + + {renderCountLabel(entry.excludedModels.length, 'excluded model')} + + )} +
+ )} + + {(entry.models.length > 0 || entry.headers.length > 0 || entry.excludedModels.length > 0) && ( +
+
+
+ Route metadata +
+
+
+ {entry.secretConfigured ? ( + + ) : ( + + )} + + {entry.secretConfigured ? 'Secret stored in CLIProxy' : 'Secret missing'} + +
+
+ {entry.proxyUrl || entry.baseUrl || 'Default runtime endpoint'} +
+
+
+ + {entry.models.length > 0 ? ( +
+
+ Model aliases +
+
+ {entry.models.map((model) => ( +
+ {model.name} + + {model.alias} +
+ ))} +
+
+ ) : null} + + {entry.headers.length > 0 ? ( +
+
+ Headers +
+
+ {entry.headers.map((header) => ( +
+ {header.key} + : + {header.value} +
+ ))} +
+
+ ) : null} + + {entry.excludedModels.length > 0 ? ( +
+
+ Excluded models +
+
+ {entry.excludedModels.map((model) => ( + + {model} + + ))} +
+
+ ) : null} +
+ )} +
+ ); +} diff --git a/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx new file mode 100644 index 00000000..d23d710a --- /dev/null +++ b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx @@ -0,0 +1,264 @@ +import { useMemo, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type { + AiProviderEntryView, + AiProviderFamilyId, + UpsertAiProviderEntryInput, +} from '../../../../../src/cliproxy/ai-providers'; + +interface ProviderEntryDialogProps { + family: AiProviderFamilyId; + entry?: AiProviderEntryView | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: UpsertAiProviderEntryInput) => Promise | void; + isSaving: boolean; +} + +function parseDelimitedLines(value: string): string[] { + return value + .split('\n') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +function parseKeyValueLines(value: string): Array<{ key: string; value: string }> { + return value + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const separator = line.includes(':') ? ':' : '='; + const [key, ...rest] = line.split(separator); + return { key: key.trim(), value: rest.join(separator).trim() }; + }) + .filter((item) => item.key.length > 0); +} + +function parseModelAliasLines(value: string) { + return value + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const [name, ...rest] = line.split('='); + return { name: name.trim(), alias: rest.join('=').trim() }; + }) + .filter((item) => item.name.length > 0 || item.alias.length > 0); +} + +function TextArea({ + value, + onChange, + placeholder, + rows = 4, +}: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + rows?: number; +}) { + return ( +