mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +00:00
feat(cliproxy): add dedicated ai providers workspace
This commit is contained in:
1 parent
7d87cfa448
commit
50c55bb108
31 files changed
+2461
-907
No files matched your search
@@ -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
|
- **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
|
- **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
|
- **Factory Droid**: Track Droid install location and BYOK settings health
|
||||||
- **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations)
|
- **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations)
|
||||||
- **Health Monitor**: Real-time status across all profiles
|
- **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/`.
|
> **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:**
|
**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
|
- [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
|
||||||
|
|||||||
@@ -32,12 +32,11 @@ CCS provides:
|
|||||||
|
|
||||||
1. **Multi-Account Claude**: Isolated instances via `CLAUDE_CONFIG_DIR`
|
1. **Multi-Account Claude**: Isolated instances via `CLAUDE_CONFIG_DIR`
|
||||||
2. **OAuth Providers**: Zero-config Gemini, Codex, Antigravity, Copilot, Kiro (ghcp) integration
|
2. **OAuth Providers**: Zero-config Gemini, Codex, Antigravity, Copilot, Kiro (ghcp) integration
|
||||||
3. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API
|
3. **AI Providers**: Dedicated CLIProxy dashboard for Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key families
|
||||||
4. **Visual Dashboard**: React SPA for configuration management
|
4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API
|
||||||
5. **Automatic WebSearch**: MCP fallback for third-party providers
|
5. **Visual Dashboard**: React SPA for configuration management
|
||||||
6. **Usage Analytics**: Token tracking, cost analysis, model breakdown
|
6. **Automatic WebSearch**: MCP fallback for third-party providers
|
||||||
|
7. **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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +74,11 @@ Configured CLIProxy providers can also be bridged into routed API Profiles from
|
|||||||
- Support Anthropic-compatible APIs
|
- Support Anthropic-compatible APIs
|
||||||
- Model mapping and configuration
|
- Model mapping and configuration
|
||||||
- OpenRouter integration with 300+ models
|
- 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
|
### FR-005: Dashboard UI
|
||||||
- Visual profile management
|
- Visual profile management
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
|
|||||||
|
|
||||||
### Recent Fixes
|
### 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-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.
|
- **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.
|
- **#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.
|
||||||
|
|||||||
@@ -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<F extends AiProviderFamilyId> = 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<F extends AiProviderFamilyId>(
|
||||||
|
family: F,
|
||||||
|
entries: FamilyEntries<F>
|
||||||
|
): 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<F extends AiProviderFamilyId>(
|
||||||
|
family: F
|
||||||
|
): Promise<FamilyEntries<F>> {
|
||||||
|
const target = getProxyTarget();
|
||||||
|
|
||||||
|
if (!target.isRemote) {
|
||||||
|
const config = readLocalConfig();
|
||||||
|
return (config[family] || []) as FamilyEntries<F>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createManagementClient({
|
||||||
|
host: target.host,
|
||||||
|
port: target.port,
|
||||||
|
protocol: target.protocol,
|
||||||
|
management_key: target.managementKey,
|
||||||
|
auth_token: target.authToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
return client.getSection<FamilyEntries<F>[number]>(family) as Promise<FamilyEntries<F>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeFamilyEntries<F extends AiProviderFamilyId>(
|
||||||
|
family: F,
|
||||||
|
entries: FamilyEntries<F>
|
||||||
|
): Promise<void> {
|
||||||
|
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<FamilyEntries<F>[number]>(family, entries as FamilyEntries<F>[number][]);
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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<string, string> | undefined {
|
||||||
|
if (!headers) return undefined;
|
||||||
|
const normalized = headers.reduce<Record<string, string>>((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<string, string> | 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<ListAiProvidersResult> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<string, string>;
|
||||||
|
'excluded-models'?: string[];
|
||||||
|
models?: AiProviderModelAlias[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenAICompatApiKeyEntry {
|
||||||
|
'api-key': string;
|
||||||
|
'proxy-url'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenAICompatEntry {
|
||||||
|
name: string;
|
||||||
|
'base-url': string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
'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',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -143,6 +143,23 @@ export {
|
|||||||
TOGETHER_TEMPLATE,
|
TOGETHER_TEMPLATE,
|
||||||
} from './openai-compat-manager';
|
} 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)
|
// Service manager (background CLIProxy for dashboard)
|
||||||
export type { ServiceStartResult } from './service-manager';
|
export type { ServiceStartResult } from './service-manager';
|
||||||
export { ensureCliproxyService, stopCliproxyService, getServiceStatus } from './service-manager';
|
export { ensureCliproxyService, stopCliproxyService, getServiceStatus } from './service-manager';
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import type {
|
|||||||
ManagementHealthStatus,
|
ManagementHealthStatus,
|
||||||
ManagementApiErrorCode,
|
ManagementApiErrorCode,
|
||||||
ClaudeKey,
|
ClaudeKey,
|
||||||
GetClaudeKeysResponse,
|
|
||||||
ClaudeKeyPatch,
|
ClaudeKeyPatch,
|
||||||
RemoteModelInfo,
|
RemoteModelInfo,
|
||||||
GetModelDefinitionsResponse,
|
GetModelDefinitionsResponse,
|
||||||
@@ -189,11 +188,7 @@ export class ManagementApiClient {
|
|||||||
* Get all claude-api-key entries from remote CLIProxy.
|
* Get all claude-api-key entries from remote CLIProxy.
|
||||||
*/
|
*/
|
||||||
async getClaudeKeys(): Promise<ClaudeKey[]> {
|
async getClaudeKeys(): Promise<ClaudeKey[]> {
|
||||||
const response = await this.request<GetClaudeKeysResponse>(
|
return this.getSection<ClaudeKey>('claude-api-key');
|
||||||
'GET',
|
|
||||||
'/v0/management/claude-api-key'
|
|
||||||
);
|
|
||||||
return response.data?.['claude-api-key'] ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -201,7 +196,7 @@ export class ManagementApiClient {
|
|||||||
* This is an atomic operation - all entries are replaced at once.
|
* This is an atomic operation - all entries are replaced at once.
|
||||||
*/
|
*/
|
||||||
async putClaudeKeys(keys: ClaudeKey[]): Promise<void> {
|
async putClaudeKeys(keys: ClaudeKey[]): Promise<void> {
|
||||||
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 ?? [];
|
return response.data?.models ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a management section from CLIProxyAPI.
|
||||||
|
* Example sections: claude-api-key, gemini-api-key, codex-api-key.
|
||||||
|
*/
|
||||||
|
async getSection<T>(section: string): Promise<T[]> {
|
||||||
|
const response = await this.request<Record<string, T[]>>('GET', `/v0/management/${section}`);
|
||||||
|
return response.data?.[section] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace an entire management section on CLIProxyAPI.
|
||||||
|
*/
|
||||||
|
async putSection<T>(section: string, entries: T[]): Promise<void> {
|
||||||
|
await this.request('PUT', `/v0/management/${section}`, entries);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make an HTTP request to the Management API.
|
* Make an HTTP request to the Management API.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -162,11 +162,33 @@ export interface CLIProxyConfig {
|
|||||||
'api-key': string;
|
'api-key': string;
|
||||||
'base-url'?: string;
|
'base-url'?: string;
|
||||||
}>;
|
}>;
|
||||||
|
'claude-api-key'?: Array<{
|
||||||
|
'api-key': string;
|
||||||
|
'base-url'?: string;
|
||||||
|
'proxy-url'?: string;
|
||||||
|
prefix?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
'excluded-models'?: string[];
|
||||||
|
models?: Array<{
|
||||||
|
name: string;
|
||||||
|
alias: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
'vertex-api-key'?: Array<{
|
||||||
|
'api-key': string;
|
||||||
|
'base-url'?: string;
|
||||||
|
}>;
|
||||||
'openai-compatibility'?: Array<{
|
'openai-compatibility'?: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
'base-url': string;
|
'base-url': string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
'api-key-entries': Array<{
|
'api-key-entries': Array<{
|
||||||
'api-key': string;
|
'api-key': string;
|
||||||
|
'proxy-url'?: string;
|
||||||
|
}>;
|
||||||
|
models?: Array<{
|
||||||
|
name: string;
|
||||||
|
alias: string;
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown>) : {};
|
||||||
|
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;
|
||||||
@@ -19,6 +19,7 @@ import websearchRoutes from './websearch-routes';
|
|||||||
import cliproxyAuthRoutes from './cliproxy-auth-routes';
|
import cliproxyAuthRoutes from './cliproxy-auth-routes';
|
||||||
import cliproxyStatsRoutes from './cliproxy-stats-routes';
|
import cliproxyStatsRoutes from './cliproxy-stats-routes';
|
||||||
import cliproxySyncRoutes from './cliproxy-sync-routes';
|
import cliproxySyncRoutes from './cliproxy-sync-routes';
|
||||||
|
import aiProviderRoutes from './ai-provider-routes';
|
||||||
import copilotRoutes from './copilot-routes';
|
import copilotRoutes from './copilot-routes';
|
||||||
import cursorRoutes from './cursor-routes';
|
import cursorRoutes from './cursor-routes';
|
||||||
import droidRoutes from './droid-routes';
|
import droidRoutes from './droid-routes';
|
||||||
@@ -59,6 +60,7 @@ apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes);
|
|||||||
apiRoutes.use('/cliproxy', cliproxyStatsRoutes);
|
apiRoutes.use('/cliproxy', cliproxyStatsRoutes);
|
||||||
apiRoutes.use('/cliproxy/sync', cliproxySyncRoutes);
|
apiRoutes.use('/cliproxy/sync', cliproxySyncRoutes);
|
||||||
apiRoutes.use('/cliproxy/catalog', catalogRoutes);
|
apiRoutes.use('/cliproxy/catalog', catalogRoutes);
|
||||||
|
apiRoutes.use('/cliproxy/ai-providers', aiProviderRoutes);
|
||||||
apiRoutes.use('/cliproxy/openai-compat', providerRoutes);
|
apiRoutes.use('/cliproxy/openai-compat', providerRoutes);
|
||||||
|
|
||||||
// ==================== WebSearch ====================
|
// ==================== WebSearch ====================
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ const ApiPage = lazy(() => import('@/pages/api').then((m) => ({ default: m.ApiPa
|
|||||||
const CliproxyPage = lazy(() =>
|
const CliproxyPage = lazy(() =>
|
||||||
import('@/pages/cliproxy').then((m) => ({ default: m.CliproxyPage }))
|
import('@/pages/cliproxy').then((m) => ({ default: m.CliproxyPage }))
|
||||||
);
|
);
|
||||||
|
const CliproxyAiProvidersPage = lazy(() =>
|
||||||
|
import('@/pages/cliproxy-ai-providers').then((m) => ({ default: m.CliproxyAiProvidersPage }))
|
||||||
|
);
|
||||||
const CliproxyControlPanelPage = lazy(() =>
|
const CliproxyControlPanelPage = lazy(() =>
|
||||||
import('@/pages/cliproxy-control-panel').then((m) => ({ default: m.CliproxyControlPanelPage }))
|
import('@/pages/cliproxy-control-panel').then((m) => ({ default: m.CliproxyControlPanelPage }))
|
||||||
);
|
);
|
||||||
@@ -98,6 +101,14 @@ export default function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/cliproxy/ai-providers"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<CliproxyAiProvidersPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/cliproxy/control-panel"
|
path="/cliproxy/control-panel"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type {
|
||||||
|
AiProviderFamilyId,
|
||||||
|
AiProviderFamilyState,
|
||||||
|
} from '../../../../../src/cliproxy/ai-providers';
|
||||||
|
import { AlertCircle, Check, Circle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface FamilyRailProps {
|
||||||
|
families: AiProviderFamilyState[];
|
||||||
|
selectedFamily: AiProviderFamilyId;
|
||||||
|
onSelect: (family: AiProviderFamilyId) => 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 (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{families.map((family) => {
|
||||||
|
const isSelected = family.id === selectedFamily;
|
||||||
|
const statusState = getStatusState(family.status);
|
||||||
|
const StatusIcon = statusState.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={family.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(family.id)}
|
||||||
|
className={cn(
|
||||||
|
'w-full cursor-pointer rounded-lg border px-3 py-2.5 text-left transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'border-primary/20 bg-primary/10'
|
||||||
|
: 'border-transparent hover:bg-muted/70'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ProviderLogo provider={getFamilyProvider(family.id)} size="md" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">{family.displayName}</span>
|
||||||
|
{family.entries.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="h-4 px-1 text-[10px]">
|
||||||
|
{family.entries.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn('mt-0.5 flex items-center gap-1.5 text-xs', statusState.className)}
|
||||||
|
>
|
||||||
|
<StatusIcon className="h-3 w-3" />
|
||||||
|
<span>{statusState.text}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="h-5 px-1.5 text-[9px] uppercase tracking-wide">
|
||||||
|
{family.authMode}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { FamilyRail } from './family-rail';
|
||||||
|
export { ProviderEntryCard } from './provider-entry-card';
|
||||||
|
export { ProviderEntryDialog } from './provider-entry-dialog';
|
||||||
@@ -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 (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(
|
||||||
|
'border-transparent text-[10px]',
|
||||||
|
entry.secretConfigured
|
||||||
|
? 'bg-emerald-50 text-emerald-700 hover:bg-emerald-50'
|
||||||
|
: 'bg-muted text-muted-foreground hover:bg-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.secretConfigured ? 'Configured' : 'Missing secret'}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
role={onSelect ? 'button' : undefined}
|
||||||
|
tabIndex={onSelect ? 0 : undefined}
|
||||||
|
className={cn(
|
||||||
|
'group rounded-lg border bg-background px-3 py-3 transition-colors',
|
||||||
|
onSelect && 'cursor-pointer',
|
||||||
|
isSelected ? 'border-primary/20 bg-primary/5 shadow-sm' : 'hover:bg-muted/40'
|
||||||
|
)}
|
||||||
|
onClick={onSelect}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (!onSelect) return;
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
onSelect();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mt-0.5 flex h-8 w-8 items-center justify-center rounded-md border',
|
||||||
|
entry.secretConfigured
|
||||||
|
? 'border-emerald-200 bg-emerald-50 text-emerald-600'
|
||||||
|
: 'border-border bg-muted/60 text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">{entry.label}</span>
|
||||||
|
{renderSecretBadge(entry)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||||
|
{entry.baseUrl || family.routePath}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||||
|
<span>{renderCountLabel(entry.models.length, 'alias')}</span>
|
||||||
|
<span className="text-border">•</span>
|
||||||
|
<span>{renderCountLabel(entry.headers.length, 'header')}</span>
|
||||||
|
{entry.excludedModels.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-border">•</span>
|
||||||
|
<span>{renderCountLabel(entry.excludedModels.length, 'exclusion')}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 opacity-0 group-hover:opacity-100 hover:text-destructive"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
{onSelect ? (
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center text-muted-foreground">
|
||||||
|
<ChevronRight
|
||||||
|
className={cn('h-4 w-4 transition-transform', isSelected && 'translate-x-0.5')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b px-5 py-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="text-base font-semibold">{entry.label}</h3>
|
||||||
|
{renderSecretBadge(entry)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Routed through <span className="font-mono">{family.routePath}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" size="sm" variant="ghost" onClick={onEdit}>
|
||||||
|
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="ghost" onClick={onDelete}>
|
||||||
|
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 p-5 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-md border bg-muted/20 p-3">
|
||||||
|
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">Secret</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-sm">
|
||||||
|
<KeyRound className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{entry.apiKeyMasked || entry.apiKeysMasked?.join(', ') || 'Not stored'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-muted/20 p-3">
|
||||||
|
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">Base URL</div>
|
||||||
|
<div className="mt-1 break-all text-sm">
|
||||||
|
{entry.baseUrl || 'Default runtime endpoint'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-muted/20 p-3">
|
||||||
|
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">Aliases</div>
|
||||||
|
<div className="mt-1 text-sm">{renderCountLabel(entry.models.length, 'mapping')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-muted/20 p-3">
|
||||||
|
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">Headers</div>
|
||||||
|
<div className="mt-1 text-sm">{renderCountLabel(entry.headers.length, 'header')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasAdvancedRouting && (
|
||||||
|
<div className="flex flex-wrap gap-2 px-5 pb-4 text-xs text-muted-foreground">
|
||||||
|
{entry.prefix && <Badge variant="secondary">Prefix {entry.prefix}</Badge>}
|
||||||
|
{entry.proxyUrl && <Badge variant="secondary">Proxy URL set</Badge>}
|
||||||
|
{entry.excludedModels.length > 0 && (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{renderCountLabel(entry.excludedModels.length, 'excluded model')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(entry.models.length > 0 || entry.headers.length > 0 || entry.excludedModels.length > 0) && (
|
||||||
|
<div className="grid gap-4 border-t px-5 py-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Route metadata
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{entry.secretConfigured ? (
|
||||||
|
<Check className="h-4 w-4 text-emerald-600" />
|
||||||
|
) : (
|
||||||
|
<Circle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{entry.secretConfigured ? 'Secret stored in CLIProxy' : 'Secret missing'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{entry.proxyUrl || entry.baseUrl || 'Default runtime endpoint'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entry.models.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Model aliases
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
{entry.models.map((model) => (
|
||||||
|
<div
|
||||||
|
key={`${model.name}:${model.alias}`}
|
||||||
|
className="rounded-md border bg-muted/20 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{model.name}</span>
|
||||||
|
<span className="mx-2 text-muted-foreground">→</span>
|
||||||
|
<span className="text-muted-foreground">{model.alias}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{entry.headers.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Headers
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
{entry.headers.map((header) => (
|
||||||
|
<div
|
||||||
|
key={`${header.key}:${header.value}`}
|
||||||
|
className="rounded-md border bg-muted/20 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{header.key}</span>
|
||||||
|
<span className="mx-2 text-muted-foreground">:</span>
|
||||||
|
<span className="break-all text-muted-foreground">{header.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{entry.excludedModels.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Excluded models
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{entry.excludedModels.map((model) => (
|
||||||
|
<Badge key={model} variant="secondary">
|
||||||
|
{model}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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> | 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 (
|
||||||
|
<textarea
|
||||||
|
rows={rows}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="flex min-h-24 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHeaders(entry?: AiProviderEntryView | null): string {
|
||||||
|
return (entry?.headers || []).map((item) => `${item.key}: ${item.value}`).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExcludedModels(entry?: AiProviderEntryView | null): string {
|
||||||
|
return (entry?.excludedModels || []).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatModelAliases(entry?: AiProviderEntryView | null): string {
|
||||||
|
return (entry?.models || []).map((item) => `${item.name}=${item.alias}`).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderEntryDialog({
|
||||||
|
family,
|
||||||
|
entry,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
isSaving,
|
||||||
|
}: ProviderEntryDialogProps) {
|
||||||
|
const isEditing = Boolean(entry);
|
||||||
|
const supportsOpenAiCompat = family === 'openai-compatibility';
|
||||||
|
const supportsClaudeAdvanced = family === 'claude-api-key';
|
||||||
|
const [name, setName] = useState(() => entry?.name || '');
|
||||||
|
const [baseUrl, setBaseUrl] = useState(() => entry?.baseUrl || '');
|
||||||
|
const [proxyUrl, setProxyUrl] = useState(() => entry?.proxyUrl || '');
|
||||||
|
const [prefix, setPrefix] = useState(() => entry?.prefix || '');
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [apiKeys, setApiKeys] = useState('');
|
||||||
|
const [headers, setHeaders] = useState(() => formatHeaders(entry));
|
||||||
|
const [excludedModels, setExcludedModels] = useState(() => formatExcludedModels(entry));
|
||||||
|
const [modelAliases, setModelAliases] = useState(() => formatModelAliases(entry));
|
||||||
|
|
||||||
|
const secretHelper = useMemo(() => {
|
||||||
|
if (!isEditing || !entry?.secretConfigured) return null;
|
||||||
|
return supportsOpenAiCompat
|
||||||
|
? 'Leave API keys blank to keep the stored connector secrets.'
|
||||||
|
: 'Leave API key blank to keep the stored secret.';
|
||||||
|
}, [entry?.secretConfigured, isEditing, supportsOpenAiCompat]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const payload: UpsertAiProviderEntryInput = {
|
||||||
|
name: supportsOpenAiCompat ? name : undefined,
|
||||||
|
baseUrl,
|
||||||
|
proxyUrl: supportsClaudeAdvanced ? proxyUrl : undefined,
|
||||||
|
prefix: supportsClaudeAdvanced ? prefix : undefined,
|
||||||
|
headers: parseKeyValueLines(headers),
|
||||||
|
excludedModels: supportsClaudeAdvanced ? parseDelimitedLines(excludedModels) : undefined,
|
||||||
|
models: parseModelAliasLines(modelAliases),
|
||||||
|
preserveSecrets: isEditing && entry?.secretConfigured && !apiKey.trim() && !apiKeys.trim(),
|
||||||
|
apiKey: supportsOpenAiCompat ? undefined : apiKey,
|
||||||
|
apiKeys: supportsOpenAiCompat ? parseDelimitedLines(apiKeys) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await onSubmit(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEditing ? 'Edit provider entry' : 'Add provider entry'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure this AI provider family without creating a separate CCS API Profile.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{supportsOpenAiCompat && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="connector-name">Connector Name</Label>
|
||||||
|
<Input
|
||||||
|
id="connector-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
placeholder="openrouter"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="base-url">Base URL</Label>
|
||||||
|
<Input
|
||||||
|
id="base-url"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(event) => setBaseUrl(event.target.value)}
|
||||||
|
placeholder="https://example.com/v1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{supportsOpenAiCompat ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>API Keys</Label>
|
||||||
|
<TextArea
|
||||||
|
value={apiKeys}
|
||||||
|
onChange={setApiKeys}
|
||||||
|
rows={4}
|
||||||
|
placeholder="sk-...\nsk-..."
|
||||||
|
/>
|
||||||
|
{secretHelper && <p className="text-xs text-muted-foreground">{secretHelper}</p>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="api-key">API Key</Label>
|
||||||
|
<Input
|
||||||
|
id="api-key"
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(event) => setApiKey(event.target.value)}
|
||||||
|
placeholder="sk-..."
|
||||||
|
/>
|
||||||
|
{secretHelper && <p className="text-xs text-muted-foreground">{secretHelper}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{supportsClaudeAdvanced && (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="prefix">Prefix</Label>
|
||||||
|
<Input
|
||||||
|
id="prefix"
|
||||||
|
value={prefix}
|
||||||
|
onChange={(event) => setPrefix(event.target.value)}
|
||||||
|
placeholder="glm-"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="proxy-url">Proxy URL</Label>
|
||||||
|
<Input
|
||||||
|
id="proxy-url"
|
||||||
|
value={proxyUrl}
|
||||||
|
onChange={(event) => setProxyUrl(event.target.value)}
|
||||||
|
placeholder="http://127.0.0.1:8080"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Headers</Label>
|
||||||
|
<TextArea
|
||||||
|
value={headers}
|
||||||
|
onChange={setHeaders}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Authorization: Bearer ... X-Project: demo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{supportsClaudeAdvanced && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Excluded Models</Label>
|
||||||
|
<TextArea
|
||||||
|
value={excludedModels}
|
||||||
|
onChange={setExcludedModels}
|
||||||
|
rows={3}
|
||||||
|
placeholder="claude-opus-4-1 claude-sonnet-4-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Model Aliases</Label>
|
||||||
|
<TextArea
|
||||||
|
value={modelAliases}
|
||||||
|
onChange={setModelAliases}
|
||||||
|
rows={4}
|
||||||
|
placeholder="claude-sonnet=gemini-2.5-pro claude-haiku=gpt-4.1-mini"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => void handleSubmit()} disabled={isSaving}>
|
||||||
|
{isSaving ? 'Saving...' : isEditing ? 'Save Changes' : 'Create Entry'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { CLIProxyProvider } from '@/lib/provider-config';
|
|
||||||
import { ArrowRight, Link2, ShieldCheck } from 'lucide-react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
interface ApiProfileBridgeCalloutProps {
|
|
||||||
provider?: CLIProxyProvider;
|
|
||||||
className?: string;
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ApiProfileBridgeCallout({
|
|
||||||
provider,
|
|
||||||
className,
|
|
||||||
compact = false,
|
|
||||||
}: ApiProfileBridgeCalloutProps) {
|
|
||||||
const providerQuery = provider ? `&cliproxyProvider=${provider}` : '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn('rounded-xl border bg-card/95 shadow-sm', compact ? 'p-3' : 'p-4', className)}
|
|
||||||
>
|
|
||||||
<div className={cn('flex gap-3', compact ? 'items-start' : 'items-center')}>
|
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
{provider ? (
|
|
||||||
<ProviderLogo provider={provider} size="md" />
|
|
||||||
) : (
|
|
||||||
<Link2 className="h-5 w-5 text-primary" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<h3 className="text-sm font-semibold">Use this provider in API Profiles</h3>
|
|
||||||
<Badge variant="secondary" className="gap-1">
|
|
||||||
<ShieldCheck className="h-3 w-3" />
|
|
||||||
No manual token copy
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Configure keys, OAuth accounts, or models here, then create a routed API Profile in CCS.
|
|
||||||
The profile will point at the provider route automatically.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button size="sm" asChild>
|
|
||||||
<Link to={`/providers?cliproxyBridge=1${providerQuery}`}>
|
|
||||||
Create API Profile
|
|
||||||
<ArrowRight className="ml-1 h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" asChild>
|
|
||||||
<Link to="/providers">Open API Profiles</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { api, withApiBase } from '@/lib/api-client';
|
import { api, withApiBase } from '@/lib/api-client';
|
||||||
import type { CliproxyServerConfig } from '@/lib/api-client';
|
import type { CliproxyServerConfig } from '@/lib/api-client';
|
||||||
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
||||||
import { ApiProfileBridgeCallout } from './api-profile-bridge-callout';
|
|
||||||
|
|
||||||
interface AuthTokensResponse {
|
interface AuthTokensResponse {
|
||||||
apiKey: { value: string; isCustom: boolean };
|
apiKey: { value: string; isCustom: boolean };
|
||||||
@@ -202,7 +201,23 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-b bg-muted/20 p-4">
|
<div className="border-b bg-muted/20 p-4">
|
||||||
<ApiProfileBridgeCallout compact className="max-w-3xl mx-auto" />
|
<div className="mx-auto max-w-3xl rounded-xl border bg-card/95 p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">Need the CCS-native workflow?</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Configure provider entries in the dedicated AI Providers page. Use the embedded
|
||||||
|
control panel only when you need the raw upstream dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/cliproxy/ai-providers"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
|
||||||
|
>
|
||||||
|
Open AI Providers
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-center justify-center bg-muted/20">
|
<div className="flex-1 flex items-center justify-center bg-muted/20">
|
||||||
<div className="text-center max-w-md px-8">
|
<div className="text-center max-w-md px-8">
|
||||||
@@ -225,7 +240,23 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<div className="border-b bg-muted/20 p-4">
|
<div className="border-b bg-muted/20 p-4">
|
||||||
<ApiProfileBridgeCallout compact className="max-w-3xl mx-auto" />
|
<div className="mx-auto max-w-3xl rounded-xl border bg-card/95 p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">Need the CCS-native workflow?</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Configure provider entries in the dedicated AI Providers page. Use the embedded
|
||||||
|
control panel only when you need the raw upstream dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/cliproxy/ai-providers"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
|
||||||
|
>
|
||||||
|
Open AI Providers
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex flex-col relative">
|
<div className="flex-1 flex flex-col relative">
|
||||||
{/* Remote indicator and login hint banner */}
|
{/* Remote indicator and login hint banner */}
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] {
|
|||||||
isCollapsible: true,
|
isCollapsible: true,
|
||||||
children: [
|
children: [
|
||||||
{ path: '/cliproxy', label: t('nav.cliproxyOverview') },
|
{ path: '/cliproxy', label: t('nav.cliproxyOverview') },
|
||||||
|
{ path: '/cliproxy/ai-providers', icon: Key, label: 'AI Providers' },
|
||||||
{ path: '/cliproxy/control-panel', icon: Gauge, label: t('nav.controlPanel') },
|
{ path: '/cliproxy/control-panel', icon: Gauge, label: t('nav.controlPanel') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
|
||||||
import type { AuthStatus } from '@/lib/api-client';
|
|
||||||
import {
|
|
||||||
CLIPROXY_PROVIDERS,
|
|
||||||
getProviderDescription,
|
|
||||||
getProviderDisplayName,
|
|
||||||
type CLIProxyProvider,
|
|
||||||
} from '@/lib/provider-config';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { CheckCircle2, Link2, Settings2 } from 'lucide-react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
interface CliproxyBridgeCreatePanelProps {
|
|
||||||
selectedProvider: CLIProxyProvider;
|
|
||||||
authStatuses?: AuthStatus[];
|
|
||||||
onProviderSelect: (provider: CLIProxyProvider) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CliproxyBridgeCreatePanel({
|
|
||||||
selectedProvider,
|
|
||||||
authStatuses,
|
|
||||||
onProviderSelect,
|
|
||||||
}: CliproxyBridgeCreatePanelProps) {
|
|
||||||
const statusMap = new Map((authStatuses || []).map((status) => [status.provider, status]));
|
|
||||||
const selectedStatus = statusMap.get(selectedProvider);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">CLIProxy Provider</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Pick the provider already configured in CLIProxy. CCS will create a routed API Profile
|
|
||||||
without asking you to copy the proxy URL or internal auth token.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
{CLIPROXY_PROVIDERS.map((provider) => {
|
|
||||||
const status = statusMap.get(provider);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={provider}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onProviderSelect(provider)}
|
|
||||||
className={cn(
|
|
||||||
'rounded-xl border p-3 text-left transition-colors',
|
|
||||||
selectedProvider === provider
|
|
||||||
? 'border-primary bg-primary/5 ring-1 ring-primary/15'
|
|
||||||
: 'border-border hover:border-primary/35 hover:bg-accent/15'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="mt-0.5">
|
|
||||||
<ProviderLogo provider={provider} size="md" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="font-medium">{getProviderDisplayName(provider)}</span>
|
|
||||||
{status?.authenticated ? (
|
|
||||||
<Badge variant="secondary" className="gap-1">
|
|
||||||
<CheckCircle2 className="h-3 w-3" />
|
|
||||||
Connected
|
|
||||||
</Badge>
|
|
||||||
) : status?.accounts?.length ? (
|
|
||||||
<Badge variant="secondary">{status.accounts.length} account(s)</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline">Configured in CLIProxy</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{getProviderDescription(provider)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border bg-muted/20 p-4 text-sm">
|
|
||||||
<div className="flex items-center gap-2 font-medium">
|
|
||||||
<Link2 className="h-4 w-4 text-primary" />
|
|
||||||
Routed profile summary
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 space-y-2 text-xs text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
Provider route:{' '}
|
|
||||||
<code className="rounded bg-muted px-1.5 py-0.5">/api/provider/{selectedProvider}</code>
|
|
||||||
</p>
|
|
||||||
<p>CCS resolves the active CLIProxy target automatically for local and remote setups.</p>
|
|
||||||
<p>
|
|
||||||
{selectedStatus?.authenticated
|
|
||||||
? 'CCS OAuth account detected for this provider.'
|
|
||||||
: 'No CCS OAuth account is required here if you already configured provider API keys in CLIProxy Control Panel.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
<Button size="sm" variant="outline" asChild>
|
|
||||||
<Link to={`/cliproxy?provider=${selectedProvider}`}>
|
|
||||||
<Settings2 className="mr-1 h-4 w-4" />
|
|
||||||
Open CLIProxy
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" asChild>
|
|
||||||
<Link to="/cliproxy/control-panel">Open Control Panel</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { CopyButton } from '@/components/ui/copy-button';
|
import { CopyButton } from '@/components/ui/copy-button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Info } from 'lucide-react';
|
||||||
import { CheckCircle2, Info, Link2, TriangleAlert } from 'lucide-react';
|
|
||||||
import type { SettingsResponse } from './types';
|
import type { SettingsResponse } from './types';
|
||||||
import type { CliTarget } from '@/lib/api-client';
|
import type { CliTarget } from '@/lib/api-client';
|
||||||
|
|
||||||
@@ -63,78 +62,6 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="font-mono">{target}</span>
|
<span className="font-mono">{target}</span>
|
||||||
</div>
|
</div>
|
||||||
{data.cliproxyBridge && (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-start">
|
|
||||||
<span className="font-medium text-muted-foreground">Source</span>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge variant="secondary" className="gap-1">
|
|
||||||
<Link2 className="h-3 w-3" />
|
|
||||||
CLIProxy {data.cliproxyBridge.providerDisplayName}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="uppercase">
|
|
||||||
{data.cliproxyBridge.source}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
This profile is routed through{' '}
|
|
||||||
<code className="bg-muted px-1 rounded text-[10px]">
|
|
||||||
{data.cliproxyBridge.routePath}
|
|
||||||
</code>
|
|
||||||
. Keys and account state stay managed in CLIProxy.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-start">
|
|
||||||
<span className="font-medium text-muted-foreground">Bridge Status</span>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={
|
|
||||||
data.cliproxyBridge.usesCurrentTarget
|
|
||||||
? 'border-green-200 text-green-700 bg-green-50'
|
|
||||||
: 'border-amber-200 text-amber-700 bg-amber-50'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{data.cliproxyBridge.usesCurrentTarget ? (
|
|
||||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<TriangleAlert className="mr-1 h-3 w-3" />
|
|
||||||
)}
|
|
||||||
{data.cliproxyBridge.usesCurrentTarget
|
|
||||||
? 'Proxy target is current'
|
|
||||||
: 'Proxy target changed'}
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={
|
|
||||||
data.cliproxyBridge.usesCurrentAuthToken
|
|
||||||
? 'border-green-200 text-green-700 bg-green-50'
|
|
||||||
: 'border-amber-200 text-amber-700 bg-amber-50'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{data.cliproxyBridge.usesCurrentAuthToken ? (
|
|
||||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<TriangleAlert className="mr-1 h-3 w-3" />
|
|
||||||
)}
|
|
||||||
{data.cliproxyBridge.usesCurrentAuthToken
|
|
||||||
? 'Auth token matches current proxy'
|
|
||||||
: 'Auth token may need refresh'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Expected proxy URL:{' '}
|
|
||||||
<code className="bg-muted px-1 rounded text-[10px] break-all">
|
|
||||||
{data.cliproxyBridge.currentBaseUrl}
|
|
||||||
</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
|
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
|
||||||
import { Sparkles, ExternalLink, ArrowRight, Zap, CloudCog, KeyRound, Link2 } from 'lucide-react';
|
import {
|
||||||
|
Sparkles,
|
||||||
|
ExternalLink,
|
||||||
|
ArrowRight,
|
||||||
|
Zap,
|
||||||
|
CloudCog,
|
||||||
|
KeyRound,
|
||||||
|
SlidersHorizontal,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface OpenRouterQuickStartProps {
|
interface OpenRouterQuickStartProps {
|
||||||
@@ -151,30 +159,30 @@ export function OpenRouterQuickStart({
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="p-2 rounded-lg bg-emerald-500/10 dark:bg-emerald-500/20">
|
<div className="p-2 rounded-lg bg-emerald-500/10 dark:bg-emerald-500/20">
|
||||||
<Link2 className="w-6 h-6 text-emerald-700 dark:text-emerald-300" />
|
<SlidersHorizontal className="w-6 h-6 text-emerald-700 dark:text-emerald-300" />
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200"
|
className="bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200"
|
||||||
>
|
>
|
||||||
Routed via CLIProxy
|
Configure in AI Providers
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-xl">Use an existing CLIProxy provider</CardTitle>
|
<CardTitle className="text-xl">Manage CLIProxy AI providers</CardTitle>
|
||||||
<CardDescription className="text-base">
|
<CardDescription className="text-base">
|
||||||
If you already configured Gemini, Codex, Claude, or other providers in CLIProxy,
|
Configure Gemini, Codex, Claude, Vertex, and OpenAI-compatible connectors directly in
|
||||||
create an API Profile without copying internal URLs or auth tokens.
|
the dedicated CLIProxy AI Providers page.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
<Link2 className="w-4 h-4 text-emerald-600" />
|
<SlidersHorizontal className="w-4 h-4 text-emerald-600" />
|
||||||
<span>Route /api/provider/<provider></span>
|
<span>Dedicated /cliproxy/ai-providers workspace</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
<KeyRound className="w-4 h-4 text-emerald-600" />
|
<KeyRound className="w-4 h-4 text-emerald-600" />
|
||||||
<span>Proxy token resolved automatically</span>
|
<span>Manage provider secrets outside API Profiles</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -183,13 +191,13 @@ export function OpenRouterQuickStart({
|
|||||||
className="w-full bg-emerald-600 hover:bg-emerald-600/90 text-white"
|
className="w-full bg-emerald-600 hover:bg-emerald-600/90 text-white"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
Create CLIProxy Profile
|
Open AI Providers
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-xs text-center text-muted-foreground">
|
<p className="text-xs text-center text-muted-foreground">
|
||||||
Configure providers under CLIProxy or the embedded Control Panel, then bridge them
|
Keep runtime provider configuration in CLIProxy, then create API Profiles only when
|
||||||
here.
|
you need standalone Anthropic-compatible endpoints.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||||
import { useCreateCliproxyBridgeProfile, useCreateProfile } from '@/hooks/use-profiles';
|
import { useCreateProfile } from '@/hooks/use-profiles';
|
||||||
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
|
||||||
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
|
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -42,7 +41,6 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Link2,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -63,9 +61,7 @@ import {
|
|||||||
} from '@/lib/openrouter-utils';
|
} from '@/lib/openrouter-utils';
|
||||||
import type { CategorizedModel } from '@/lib/openrouter-types';
|
import type { CategorizedModel } from '@/lib/openrouter-types';
|
||||||
import type { CliTarget } from '@/lib/api-client';
|
import type { CliTarget } from '@/lib/api-client';
|
||||||
import { isValidProvider, type CLIProxyProvider } from '@/lib/provider-config';
|
|
||||||
import i18n from '@/lib/i18n';
|
import i18n from '@/lib/i18n';
|
||||||
import { CliproxyBridgeCreatePanel } from './cliproxy-bridge-create-panel';
|
|
||||||
|
|
||||||
const optionalUrlSchema = z
|
const optionalUrlSchema = z
|
||||||
.string()
|
.string()
|
||||||
@@ -94,8 +90,7 @@ interface ProfileCreateDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onSuccess: (name: string) => void;
|
onSuccess: (name: string) => void;
|
||||||
initialMode?: 'normal' | 'openrouter' | 'alibaba-coding-plan' | 'cliproxy';
|
initialMode?: 'normal' | 'openrouter' | 'alibaba-coding-plan';
|
||||||
initialCliproxyProvider?: CLIProxyProvider | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Common URL mistakes to warn about
|
// Common URL mistakes to warn about
|
||||||
@@ -127,25 +122,15 @@ export function ProfileCreateDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
initialMode = 'openrouter',
|
initialMode = 'openrouter',
|
||||||
initialCliproxyProvider = null,
|
|
||||||
}: ProfileCreateDialogProps) {
|
}: ProfileCreateDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const createMutation = useCreateProfile();
|
const createMutation = useCreateProfile();
|
||||||
const createCliproxyBridgeMutation = useCreateCliproxyBridgeProfile();
|
|
||||||
const { data: cliproxyAuthData } = useCliproxyAuth();
|
|
||||||
const [activeTab, setActiveTab] = useState('basic');
|
const [activeTab, setActiveTab] = useState('basic');
|
||||||
const [createKind, setCreateKind] = useState<'preset' | 'cliproxy'>(
|
|
||||||
initialMode === 'cliproxy' ? 'cliproxy' : 'preset'
|
|
||||||
);
|
|
||||||
const [urlWarning, setUrlWarning] = useState<string | null>(null);
|
const [urlWarning, setUrlWarning] = useState<string | null>(null);
|
||||||
const [showApiKey, setShowApiKey] = useState(false);
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
const [selectedPreset, setSelectedPreset] = useState<PresetSelection>(DEFAULT_PRESET_ID);
|
const [selectedPreset, setSelectedPreset] = useState<PresetSelection>(DEFAULT_PRESET_ID);
|
||||||
const [showMorePresets, setShowMorePresets] = useState(false);
|
const [showMorePresets, setShowMorePresets] = useState(false);
|
||||||
const [modelSearch, setModelSearch] = useState('');
|
const [modelSearch, setModelSearch] = useState('');
|
||||||
const [selectedCliproxyProvider, setSelectedCliproxyProvider] = useState<CLIProxyProvider>(
|
|
||||||
initialCliproxyProvider ?? 'gemini'
|
|
||||||
);
|
|
||||||
const [autoGeneratedCliproxyName, setAutoGeneratedCliproxyName] = useState('gemini-api');
|
|
||||||
|
|
||||||
// OpenRouter models for model picker
|
// OpenRouter models for model picker
|
||||||
const { models: openRouterModels } = useOpenRouterCatalog();
|
const { models: openRouterModels } = useOpenRouterCatalog();
|
||||||
@@ -155,7 +140,6 @@ export function ProfileCreateDialog({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
control,
|
control,
|
||||||
getValues,
|
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
@@ -165,21 +149,6 @@ export function ProfileCreateDialog({
|
|||||||
|
|
||||||
const baseUrlValue = useWatch({ control, name: 'baseUrl' });
|
const baseUrlValue = useWatch({ control, name: 'baseUrl' });
|
||||||
const targetValue = useWatch({ control, name: 'target' });
|
const targetValue = useWatch({ control, name: 'target' });
|
||||||
const preferredCliproxyProvider = useMemo<CLIProxyProvider>(() => {
|
|
||||||
if (initialCliproxyProvider && isValidProvider(initialCliproxyProvider)) {
|
|
||||||
return initialCliproxyProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectedProvider = cliproxyAuthData?.authStatus.find(
|
|
||||||
(status) =>
|
|
||||||
isValidProvider(status.provider) &&
|
|
||||||
(status.authenticated || (status.accounts?.length ?? 0) > 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
return connectedProvider && isValidProvider(connectedProvider.provider)
|
|
||||||
? connectedProvider.provider
|
|
||||||
: 'gemini';
|
|
||||||
}, [cliproxyAuthData?.authStatus, initialCliproxyProvider]);
|
|
||||||
const applyPresetToForm = useCallback(
|
const applyPresetToForm = useCallback(
|
||||||
(preset: ProviderPreset | null) => {
|
(preset: ProviderPreset | null) => {
|
||||||
if (!preset) {
|
if (!preset) {
|
||||||
@@ -221,24 +190,11 @@ export function ProfileCreateDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setActiveTab('basic');
|
setActiveTab('basic');
|
||||||
setCreateKind(initialMode === 'cliproxy' ? 'cliproxy' : 'preset');
|
|
||||||
setUrlWarning(null);
|
setUrlWarning(null);
|
||||||
setShowApiKey(false);
|
setShowApiKey(false);
|
||||||
setShowMorePresets(false);
|
setShowMorePresets(false);
|
||||||
setModelSearch('');
|
setModelSearch('');
|
||||||
|
|
||||||
if (initialMode === 'cliproxy') {
|
|
||||||
const provider = preferredCliproxyProvider;
|
|
||||||
const generatedName = `${provider}-api`;
|
|
||||||
setSelectedCliproxyProvider(provider);
|
|
||||||
setAutoGeneratedCliproxyName(generatedName);
|
|
||||||
reset({
|
|
||||||
...EMPTY_FORM_VALUES,
|
|
||||||
name: generatedName,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set initial preset based on initialMode
|
// Set initial preset based on initialMode
|
||||||
if (initialMode === 'normal') {
|
if (initialMode === 'normal') {
|
||||||
setSelectedPreset(CUSTOM_PRESET_ID);
|
setSelectedPreset(CUSTOM_PRESET_ID);
|
||||||
@@ -257,20 +213,7 @@ export function ProfileCreateDialog({
|
|||||||
applyPresetToForm(null);
|
applyPresetToForm(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [open, initialMode, applyPresetToForm, preferredCliproxyProvider, reset]);
|
}, [open, initialMode, applyPresetToForm, reset]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || createKind !== 'cliproxy') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextName = `${selectedCliproxyProvider}-api`;
|
|
||||||
const currentName = getValues('name').trim();
|
|
||||||
if (!currentName || currentName === autoGeneratedCliproxyName) {
|
|
||||||
setValue('name', nextName);
|
|
||||||
}
|
|
||||||
setAutoGeneratedCliproxyName(nextName);
|
|
||||||
}, [autoGeneratedCliproxyName, createKind, getValues, open, selectedCliproxyProvider, setValue]);
|
|
||||||
|
|
||||||
// Handle preset selection
|
// Handle preset selection
|
||||||
const handlePresetSelect = (presetId: string) => {
|
const handlePresetSelect = (presetId: string) => {
|
||||||
@@ -322,22 +265,6 @@ export function ProfileCreateDialog({
|
|||||||
}, [baseUrlValue, selectedPreset]);
|
}, [baseUrlValue, selectedPreset]);
|
||||||
|
|
||||||
const onSubmit = async (data: FormData) => {
|
const onSubmit = async (data: FormData) => {
|
||||||
if (createKind === 'cliproxy') {
|
|
||||||
try {
|
|
||||||
const result = await createCliproxyBridgeMutation.mutateAsync({
|
|
||||||
provider: selectedCliproxyProvider,
|
|
||||||
name: data.name,
|
|
||||||
target: data.target,
|
|
||||||
});
|
|
||||||
toast.success(`Profile "${result.name}" created`);
|
|
||||||
onSuccess(result.name);
|
|
||||||
onOpenChange(false);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error((error as Error).message || 'Failed to create routed profile');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate API key - required unless preset has requiresApiKey: false
|
// Validate API key - required unless preset has requiresApiKey: false
|
||||||
if (currentPreset?.requiresApiKey !== false && !data.apiKey) {
|
if (currentPreset?.requiresApiKey !== false && !data.apiKey) {
|
||||||
toast.error(i18n.t('commonToast.apiKeyRequired'));
|
toast.error(i18n.t('commonToast.apiKeyRequired'));
|
||||||
@@ -361,41 +288,12 @@ export function ProfileCreateDialog({
|
|||||||
const hasBasicErrors = !!errors.name || !!errors.baseUrl || !!errors.apiKey;
|
const hasBasicErrors = !!errors.name || !!errors.baseUrl || !!errors.apiKey;
|
||||||
const hasModelErrors =
|
const hasModelErrors =
|
||||||
!!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel;
|
!!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel;
|
||||||
const isCreating =
|
const isCreating = createMutation.isPending;
|
||||||
createKind === 'cliproxy' ? createCliproxyBridgeMutation.isPending : createMutation.isPending;
|
|
||||||
|
|
||||||
const isQuickTemplateSelected =
|
const isQuickTemplateSelected =
|
||||||
selectedPreset !== CUSTOM_PRESET_ID && QUICK_TEMPLATE_PRESET_IDS.has(selectedPreset);
|
selectedPreset !== CUSTOM_PRESET_ID && QUICK_TEMPLATE_PRESET_IDS.has(selectedPreset);
|
||||||
const isOpenRouter = currentPreset?.id === DEFAULT_PRESET_ID;
|
const isOpenRouter = currentPreset?.id === DEFAULT_PRESET_ID;
|
||||||
const showQuickTemplates = showMorePresets || isQuickTemplateSelected;
|
const showQuickTemplates = showMorePresets || isQuickTemplateSelected;
|
||||||
const handleCreateKindChange = (nextKind: 'preset' | 'cliproxy') => {
|
|
||||||
if (nextKind === createKind) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCreateKind(nextKind);
|
|
||||||
setActiveTab('basic');
|
|
||||||
setUrlWarning(null);
|
|
||||||
|
|
||||||
if (nextKind === 'cliproxy') {
|
|
||||||
const provider = selectedCliproxyProvider || preferredCliproxyProvider;
|
|
||||||
const generatedName = `${provider}-api`;
|
|
||||||
setSelectedCliproxyProvider(provider);
|
|
||||||
setAutoGeneratedCliproxyName(generatedName);
|
|
||||||
reset({
|
|
||||||
...EMPTY_FORM_VALUES,
|
|
||||||
name: generatedName,
|
|
||||||
target: targetValue || 'claude',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPreset) {
|
|
||||||
applyPresetToForm(currentPreset);
|
|
||||||
} else {
|
|
||||||
applyPresetToForm(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -406,9 +304,7 @@ export function ProfileCreateDialog({
|
|||||||
Create API Profile
|
Create API Profile
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{createKind === 'cliproxy'
|
Choose a provider preset or configure a custom API endpoint.
|
||||||
? 'Bridge a provider already managed by CLIProxy into a usable CCS API Profile.'
|
|
||||||
: 'Choose a provider or configure a custom API endpoint.'}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -417,110 +313,107 @@ export function ProfileCreateDialog({
|
|||||||
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="border-b bg-muted/10 px-6 py-3 space-y-3">
|
<div className="border-b bg-muted/10 px-6 py-3 space-y-3">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="space-y-3">
|
||||||
<Button
|
<div className="flex items-start justify-between gap-4">
|
||||||
type="button"
|
<div className="space-y-1">
|
||||||
variant={createKind === 'cliproxy' ? 'default' : 'outline'}
|
<Label className="text-xs text-muted-foreground">
|
||||||
onClick={() => handleCreateKindChange('cliproxy')}
|
{t('profileEditor.provider')}
|
||||||
>
|
|
||||||
Use CLIProxy Provider
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={createKind === 'preset' ? 'default' : 'outline'}
|
|
||||||
onClick={() => handleCreateKindChange('preset')}
|
|
||||||
>
|
|
||||||
Preset or Manual Setup
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{createKind === 'preset' && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs text-muted-foreground">
|
|
||||||
{t('profileEditor.provider')}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('profileEditor.providerChooserHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="pt-0.5 text-[11px] text-muted-foreground">
|
|
||||||
{t('profileEditor.scrollHint')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs font-medium uppercase tracking-[0.12em] text-foreground/70">
|
|
||||||
{t('profileEditor.featuredProviders')}
|
|
||||||
</Label>
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('profileEditor.providerChooserHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="pt-0.5 text-[11px] text-muted-foreground">
|
||||||
|
{t('profileEditor.scrollHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium uppercase tracking-[0.12em] text-foreground/70">
|
||||||
|
{t('profileEditor.featuredProviders')}
|
||||||
|
</Label>
|
||||||
|
<div className="-mx-1 overflow-x-auto pb-1">
|
||||||
|
<div className="flex min-w-max items-stretch gap-2 px-1">
|
||||||
|
{RECOMMENDED_PRESETS.map((preset) => (
|
||||||
|
<CompactPresetCard
|
||||||
|
key={preset.id}
|
||||||
|
preset={preset}
|
||||||
|
isSelected={selectedPreset === preset.id}
|
||||||
|
onClick={() => handlePresetSelect(preset.id)}
|
||||||
|
density="featured"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="mx-1 hidden w-px rounded-full bg-border/70 sm:block" />
|
||||||
|
<CustomPresetCard
|
||||||
|
isSelected={selectedPreset === CUSTOM_PRESET_ID}
|
||||||
|
onClick={() => handlePresetSelect(CUSTOM_PRESET_ID)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowMorePresets((current) => !current)}
|
||||||
|
aria-expanded={showQuickTemplates}
|
||||||
|
className="h-8 px-2 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'mr-1 h-3.5 w-3.5 transition-transform',
|
||||||
|
showQuickTemplates ? 'rotate-0' : '-rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{t('profileEditor.morePresets')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showQuickTemplates && (
|
||||||
<div className="-mx-1 overflow-x-auto pb-1">
|
<div className="-mx-1 overflow-x-auto pb-1">
|
||||||
<div className="flex min-w-max items-stretch gap-2 px-1">
|
<div className="flex min-w-max items-stretch gap-2 px-1">
|
||||||
{RECOMMENDED_PRESETS.map((preset) => (
|
{QUICK_TEMPLATE_PRESETS.map((preset) => (
|
||||||
<CompactPresetCard
|
<CompactPresetCard
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
preset={preset}
|
preset={preset}
|
||||||
isSelected={selectedPreset === preset.id}
|
isSelected={selectedPreset === preset.id}
|
||||||
onClick={() => handlePresetSelect(preset.id)}
|
onClick={() => handlePresetSelect(preset.id)}
|
||||||
density="featured"
|
density="compact"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="mx-1 hidden w-px rounded-full bg-border/70 sm:block" />
|
|
||||||
<CustomPresetCard
|
|
||||||
isSelected={selectedPreset === CUSTOM_PRESET_ID}
|
|
||||||
onClick={() => handlePresetSelect(CUSTOM_PRESET_ID)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowMorePresets((current) => !current)}
|
|
||||||
aria-expanded={showQuickTemplates}
|
|
||||||
className="h-8 px-2 text-xs font-medium text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ChevronDown
|
|
||||||
className={cn(
|
|
||||||
'mr-1 h-3.5 w-3.5 transition-transform',
|
|
||||||
showQuickTemplates ? 'rotate-0' : '-rotate-90'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{t('profileEditor.morePresets')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{showQuickTemplates && (
|
|
||||||
<div className="-mx-1 overflow-x-auto pb-1">
|
|
||||||
<div className="flex min-w-max items-stretch gap-2 px-1">
|
|
||||||
{QUICK_TEMPLATE_PRESETS.map((preset) => (
|
|
||||||
<CompactPresetCard
|
|
||||||
key={preset.id}
|
|
||||||
preset={preset}
|
|
||||||
isSelected={selectedPreset === preset.id}
|
|
||||||
onClick={() => handlePresetSelect(preset.id)}
|
|
||||||
density="compact"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{createKind === 'cliproxy' ? (
|
<Tabs
|
||||||
<>
|
value={activeTab}
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto p-6 space-y-5">
|
onValueChange={setActiveTab}
|
||||||
<CliproxyBridgeCreatePanel
|
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||||
selectedProvider={selectedCliproxyProvider}
|
>
|
||||||
authStatuses={cliproxyAuthData?.authStatus}
|
<div className="px-6 pt-4">
|
||||||
onProviderSelect={setSelectedCliproxyProvider}
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
/>
|
<TabsTrigger value="basic" className="relative">
|
||||||
|
Basic Information
|
||||||
|
{hasBasicErrors && (
|
||||||
|
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="models" className="relative">
|
||||||
|
Model Configuration
|
||||||
|
{hasModelErrors && (
|
||||||
|
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<TabsContent value="basic" className="p-6 space-y-4 mt-0">
|
||||||
|
{/* Profile Name */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="name">
|
<Label htmlFor="name">
|
||||||
Profile Name <span className="text-destructive">*</span>
|
Profile Name <span className="text-destructive">*</span>
|
||||||
@@ -541,6 +434,81 @@ export function ProfileCreateDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Base URL - always editable, pre-filled from preset */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="baseUrl">API Base URL</Label>
|
||||||
|
<Input
|
||||||
|
id="baseUrl"
|
||||||
|
{...register('baseUrl')}
|
||||||
|
placeholder="https://api.example.com/v1"
|
||||||
|
/>
|
||||||
|
{errors.baseUrl ? (
|
||||||
|
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
|
||||||
|
) : urlWarning ? (
|
||||||
|
<div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
<span>{urlWarning}</span>
|
||||||
|
</div>
|
||||||
|
) : currentPreset ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{currentPreset.baseUrl
|
||||||
|
? `Pre-filled from ${currentPreset.name}. You can customize if needed.`
|
||||||
|
: `Optional for ${currentPreset.name}. Leave blank to use native Anthropic auth.`}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The endpoint that accepts OpenAI-compatible and Anthropic requests
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Key - optional for presets that don't require it */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="apiKey">
|
||||||
|
API Key{' '}
|
||||||
|
{currentPreset?.requiresApiKey !== false && (
|
||||||
|
<span className="text-destructive">*</span>
|
||||||
|
)}
|
||||||
|
{currentPreset?.requiresApiKey === false && (
|
||||||
|
<span className="text-muted-foreground text-xs font-normal">(optional)</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="apiKey"
|
||||||
|
type={showApiKey ? 'text' : 'password'}
|
||||||
|
{...register('apiKey')}
|
||||||
|
placeholder={
|
||||||
|
currentPreset?.requiresApiKey === false
|
||||||
|
? 'Optional - only if auth is enabled'
|
||||||
|
: (currentPreset?.apiKeyPlaceholder ?? 'sk-...')
|
||||||
|
}
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setShowApiKey(!showApiKey)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{errors.apiKey ? (
|
||||||
|
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
|
||||||
|
) : currentPreset?.requiresApiKey === false ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Only needed if your local endpoint has authentication enabled
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
currentPreset?.apiKeyHint && (
|
||||||
|
<p className="text-xs text-muted-foreground">{currentPreset.apiKeyHint}</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="target">Default Target CLI</Label>
|
<Label htmlFor="target">Default Target CLI</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -556,336 +524,162 @@ export function ProfileCreateDialog({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
The routed profile still runs like a normal API Profile via{' '}
|
Run with{' '}
|
||||||
<code className="bg-muted px-1 rounded text-[10px]">
|
<code className="bg-muted px-1 rounded text-[10px]">
|
||||||
{targetValue === 'droid' ? 'ccsd' : 'ccs'}
|
{targetValue === 'droid' ? 'ccsd' : 'ccs'}
|
||||||
</code>
|
</code>{' '}
|
||||||
. You can override per command with{' '}
|
by default. You can still override each run with{' '}
|
||||||
<code className="bg-muted px-1 rounded text-[10px]">--target</code>.
|
<code className="bg-muted px-1 rounded text-[10px]">--target</code>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsContent>
|
||||||
|
|
||||||
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
|
<TabsContent value="models" className="p-6 mt-0 space-y-4">
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
|
||||||
Cancel
|
<Info className="w-5 h-5 shrink-0 mt-0.5" />
|
||||||
</Button>
|
<div>
|
||||||
<Button
|
<p className="font-medium mb-1">Model Mapping</p>
|
||||||
type="submit"
|
<p className="text-xs opacity-90">
|
||||||
disabled={isCreating}
|
Map Claude Code tiers (Opus/Sonnet/Haiku) to models supported by your
|
||||||
className={cn(isCreating && 'opacity-80')}
|
provider.
|
||||||
>
|
|
||||||
{isCreating ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Creating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Link2 className="w-4 h-4 mr-2" />
|
|
||||||
Create Routed Profile
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Tabs
|
|
||||||
value={activeTab}
|
|
||||||
onValueChange={setActiveTab}
|
|
||||||
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="px-6 pt-4">
|
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="basic" className="relative">
|
|
||||||
Basic Information
|
|
||||||
{hasBasicErrors && (
|
|
||||||
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="models" className="relative">
|
|
||||||
Model Configuration
|
|
||||||
{hasModelErrors && (
|
|
||||||
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
||||||
<TabsContent value="basic" className="p-6 space-y-4 mt-0">
|
|
||||||
{/* Profile Name */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="name">
|
|
||||||
Profile Name <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
{...register('name')}
|
|
||||||
placeholder="my-api"
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
{errors.name ? (
|
|
||||||
<p className="text-xs text-destructive">{errors.name.message}</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Used in CLI:{' '}
|
|
||||||
<code className="bg-muted px-1 rounded text-[10px]">
|
|
||||||
ccs my-api "prompt"
|
|
||||||
</code>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Base URL - always editable, pre-filled from preset */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="baseUrl">API Base URL</Label>
|
|
||||||
<Input
|
|
||||||
id="baseUrl"
|
|
||||||
{...register('baseUrl')}
|
|
||||||
placeholder="https://api.example.com/v1"
|
|
||||||
/>
|
|
||||||
{errors.baseUrl ? (
|
|
||||||
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
|
|
||||||
) : urlWarning ? (
|
|
||||||
<div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
|
|
||||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
|
||||||
<span>{urlWarning}</span>
|
|
||||||
</div>
|
|
||||||
) : currentPreset ? (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{currentPreset.baseUrl
|
|
||||||
? `Pre-filled from ${currentPreset.name}. You can customize if needed.`
|
|
||||||
: `Optional for ${currentPreset.name}. Leave blank to use native Anthropic auth.`}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
The endpoint that accepts OpenAI-compatible and Anthropic requests
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API Key - optional for presets that don't require it */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="apiKey">
|
|
||||||
API Key{' '}
|
|
||||||
{currentPreset?.requiresApiKey !== false && (
|
|
||||||
<span className="text-destructive">*</span>
|
|
||||||
)}
|
|
||||||
{currentPreset?.requiresApiKey === false && (
|
|
||||||
<span className="text-muted-foreground text-xs font-normal">
|
|
||||||
(optional)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
id="apiKey"
|
|
||||||
type={showApiKey ? 'text' : 'password'}
|
|
||||||
{...register('apiKey')}
|
|
||||||
placeholder={
|
|
||||||
currentPreset?.requiresApiKey === false
|
|
||||||
? 'Optional - only if auth is enabled'
|
|
||||||
: (currentPreset?.apiKeyPlaceholder ?? 'sk-...')
|
|
||||||
}
|
|
||||||
className="pr-10"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => setShowApiKey(!showApiKey)}
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{errors.apiKey ? (
|
|
||||||
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
|
|
||||||
) : currentPreset?.requiresApiKey === false ? (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Only needed if your local endpoint has authentication enabled
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
currentPreset?.apiKeyHint && (
|
|
||||||
<p className="text-xs text-muted-foreground">{currentPreset.apiKeyHint}</p>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="target">Default Target CLI</Label>
|
|
||||||
<Select
|
|
||||||
value={targetValue}
|
|
||||||
onValueChange={(value) => setValue('target', value as CliTarget)}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="target">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="claude">Claude Code (default)</SelectItem>
|
|
||||||
<SelectItem value="droid">Factory Droid</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Run with{' '}
|
|
||||||
<code className="bg-muted px-1 rounded text-[10px]">
|
|
||||||
{targetValue === 'droid' ? 'ccsd' : 'ccs'}
|
|
||||||
</code>{' '}
|
|
||||||
by default. You can still override each run with{' '}
|
|
||||||
<code className="bg-muted px-1 rounded text-[10px]">--target</code>.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="models" className="p-6 mt-0 space-y-4">
|
{/* OpenRouter Model Picker */}
|
||||||
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
|
{isOpenRouter && (
|
||||||
<Info className="w-5 h-5 shrink-0 mt-0.5" />
|
<div className="space-y-2">
|
||||||
<div>
|
<Label>Search Models</Label>
|
||||||
<p className="font-medium mb-1">Model Mapping</p>
|
<Input
|
||||||
<p className="text-xs opacity-90">
|
value={modelSearch}
|
||||||
Map Claude Code tiers (Opus/Sonnet/Haiku) to models supported by your
|
onChange={(e) => setModelSearch(e.target.value)}
|
||||||
provider.
|
placeholder="Type to search (e.g., opus, sonnet, gpt-4o)..."
|
||||||
</p>
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && filteredModels.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleModelSelect(filteredModels[0]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="border rounded-md max-h-48 overflow-y-auto">
|
||||||
|
{filteredModels.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground p-3 text-center">
|
||||||
|
{modelSearch
|
||||||
|
? `No models found for "${modelSearch}"`
|
||||||
|
: 'Loading models...'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="p-1">
|
||||||
|
{!modelSearch && (
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground">
|
||||||
|
<Sparkles className="w-3 h-3 text-accent" />
|
||||||
|
<span>Newest Models</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filteredModels.map((model) => (
|
||||||
|
<ModelSearchItem
|
||||||
|
key={model.id}
|
||||||
|
model={model}
|
||||||
|
onClick={() => handleModelSelect(model)}
|
||||||
|
showAge={!modelSearch}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* OpenRouter Model Picker */}
|
{/* Model Inputs */}
|
||||||
{isOpenRouter && (
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<Label>Search Models</Label>
|
<Label htmlFor="model">
|
||||||
<Input
|
Default Model
|
||||||
value={modelSearch}
|
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||||
onChange={(e) => setModelSearch(e.target.value)}
|
ANTHROPIC_MODEL
|
||||||
placeholder="Type to search (e.g., opus, sonnet, gpt-4o)..."
|
</Badge>
|
||||||
onKeyDown={(e) => {
|
</Label>
|
||||||
if (e.key === 'Enter' && filteredModels.length > 0) {
|
<Input
|
||||||
e.preventDefault();
|
id="model"
|
||||||
handleModelSelect(filteredModels[0]);
|
{...register('model')}
|
||||||
}
|
placeholder={currentPreset?.defaultModel ?? 'claude-sonnet-4'}
|
||||||
}}
|
className="font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
<div className="border rounded-md max-h-48 overflow-y-auto">
|
</div>
|
||||||
{filteredModels.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground p-3 text-center">
|
|
||||||
{modelSearch
|
|
||||||
? `No models found for "${modelSearch}"`
|
|
||||||
: 'Loading models...'}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="p-1">
|
|
||||||
{!modelSearch && (
|
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground">
|
|
||||||
<Sparkles className="w-3 h-3 text-accent" />
|
|
||||||
<span>Newest Models</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{filteredModels.map((model) => (
|
|
||||||
<ModelSearchItem
|
|
||||||
key={model.id}
|
|
||||||
model={model}
|
|
||||||
onClick={() => handleModelSelect(model)}
|
|
||||||
showAge={!modelSearch}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Model Inputs */}
|
<div className="grid gap-3 pt-2 border-t">
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="model">
|
<Label htmlFor="sonnetModel" className="text-sm">
|
||||||
Default Model
|
Sonnet Mapping
|
||||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||||
ANTHROPIC_MODEL
|
DEFAULT_SONNET
|
||||||
</Badge>
|
</Badge>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="model"
|
id="sonnetModel"
|
||||||
{...register('model')}
|
{...register('sonnetModel')}
|
||||||
placeholder={currentPreset?.defaultModel ?? 'claude-sonnet-4'}
|
placeholder="e.g. gpt-4o, claude-sonnet-4"
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 pt-2 border-t">
|
<div className="space-y-1.5">
|
||||||
<div className="space-y-1.5">
|
<Label htmlFor="opusModel" className="text-sm">
|
||||||
<Label htmlFor="sonnetModel" className="text-sm">
|
Opus Mapping
|
||||||
Sonnet Mapping
|
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
DEFAULT_OPUS
|
||||||
DEFAULT_SONNET
|
</Badge>
|
||||||
</Badge>
|
</Label>
|
||||||
</Label>
|
<Input
|
||||||
<Input
|
id="opusModel"
|
||||||
id="sonnetModel"
|
{...register('opusModel')}
|
||||||
{...register('sonnetModel')}
|
placeholder="e.g. o1, claude-opus-4.5"
|
||||||
placeholder="e.g. gpt-4o, claude-sonnet-4"
|
className="font-mono text-sm h-9"
|
||||||
className="font-mono text-sm h-9"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="opusModel" className="text-sm">
|
<Label htmlFor="haikuModel" className="text-sm">
|
||||||
Opus Mapping
|
Haiku Mapping
|
||||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||||
DEFAULT_OPUS
|
DEFAULT_HAIKU
|
||||||
</Badge>
|
</Badge>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="opusModel"
|
id="haikuModel"
|
||||||
{...register('opusModel')}
|
{...register('haikuModel')}
|
||||||
placeholder="e.g. o1, claude-opus-4.5"
|
placeholder="e.g. gpt-4o-mini, claude-3.5-haiku"
|
||||||
className="font-mono text-sm h-9"
|
className="font-mono text-sm h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="haikuModel" className="text-sm">
|
|
||||||
Haiku Mapping
|
|
||||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
|
||||||
DEFAULT_HAIKU
|
|
||||||
</Badge>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="haikuModel"
|
|
||||||
{...register('haikuModel')}
|
|
||||||
placeholder="e.g. gpt-4o-mini, claude-3.5-haiku"
|
|
||||||
className="font-mono text-sm h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</div>
|
||||||
</div>
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
|
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isCreating}
|
disabled={isCreating}
|
||||||
className={cn(isCreating && 'opacity-80')}
|
className={cn(isCreating && 'opacity-80')}
|
||||||
>
|
>
|
||||||
{isCreating ? (
|
{isCreating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Creating...
|
Creating...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Create Profile
|
Create Profile
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type {
|
||||||
|
AiProviderFamilyId,
|
||||||
|
UpsertAiProviderEntryInput,
|
||||||
|
} from '../../../src/cliproxy/ai-providers';
|
||||||
|
|
||||||
|
const QUERY_KEY = ['cliproxy-ai-providers'] as const;
|
||||||
|
|
||||||
|
export function useCliproxyAiProviders() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEY,
|
||||||
|
queryFn: () => api.cliproxy.aiProviders.list(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateCliproxyAiProviderEntry() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
family,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
family: AiProviderFamilyId;
|
||||||
|
data: UpsertAiProviderEntryInput;
|
||||||
|
}) => api.cliproxy.aiProviders.create(family, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||||
|
toast.success('Provider entry created');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCliproxyAiProviderEntry() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
family,
|
||||||
|
index,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
family: AiProviderFamilyId;
|
||||||
|
index: number;
|
||||||
|
data: UpsertAiProviderEntryInput;
|
||||||
|
}) => api.cliproxy.aiProviders.update(family, index, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||||
|
toast.success('Provider entry updated');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteCliproxyAiProviderEntry() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ family, index }: { family: AiProviderFamilyId; index: number }) =>
|
||||||
|
api.cliproxy.aiProviders.delete(family, index),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||||
|
toast.success('Provider entry removed');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type CreateProfile,
|
type CreateProfile,
|
||||||
type CreateCliproxyBridgeProfileRequest,
|
|
||||||
type UpdateProfile,
|
type UpdateProfile,
|
||||||
type RegisterProfileOrphansRequest,
|
type RegisterProfileOrphansRequest,
|
||||||
type CopyProfileRequest,
|
type CopyProfileRequest,
|
||||||
@@ -37,22 +36,6 @@ export function useCreateProfile() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateCliproxyBridgeProfile() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateCliproxyBridgeProfileRequest) =>
|
|
||||||
api.profiles.createCliproxyBridge(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
|
||||||
toast.success('CLIProxy-routed profile created successfully');
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
toast.error(error.message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateProfile() {
|
export function useUpdateProfile() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
+22
-18
@@ -4,6 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CLIProxyProvider } from './provider-config';
|
import type { CLIProxyProvider } from './provider-config';
|
||||||
|
import type {
|
||||||
|
AiProviderFamilyId,
|
||||||
|
ListAiProvidersResult,
|
||||||
|
UpsertAiProviderEntryInput,
|
||||||
|
} from '../../../src/cliproxy/ai-providers';
|
||||||
|
|
||||||
export const API_BASE_URL = '/api';
|
export const API_BASE_URL = '/api';
|
||||||
export const API_CONFLICT_ERROR_CODE = 'CONFLICT';
|
export const API_CONFLICT_ERROR_CODE = 'CONFLICT';
|
||||||
@@ -135,19 +140,6 @@ export interface UpdateProfile {
|
|||||||
target?: CliTarget;
|
target?: CliTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateCliproxyBridgeProfileRequest {
|
|
||||||
provider: CLIProxyProvider;
|
|
||||||
name?: string;
|
|
||||||
target?: CliTarget;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateCliproxyBridgeProfileResponse {
|
|
||||||
name: string;
|
|
||||||
settingsPath: string;
|
|
||||||
target: CliTarget;
|
|
||||||
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProfileValidationIssue {
|
export interface ProfileValidationIssue {
|
||||||
level: 'error' | 'warning';
|
level: 'error' | 'warning';
|
||||||
code: string;
|
code: string;
|
||||||
@@ -786,11 +778,6 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
createCliproxyBridge: (data: CreateCliproxyBridgeProfileRequest) =>
|
|
||||||
request<CreateCliproxyBridgeProfileResponse>('/profiles/cliproxy-bridge', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
update: (name: string, data: UpdateProfile) =>
|
update: (name: string, data: UpdateProfile) =>
|
||||||
request(`/profiles/${name}`, {
|
request(`/profiles/${name}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -860,6 +847,23 @@ export const api = {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ model }),
|
body: JSON.stringify({ model }),
|
||||||
}),
|
}),
|
||||||
|
aiProviders: {
|
||||||
|
list: () => request<ListAiProvidersResult>('/cliproxy/ai-providers'),
|
||||||
|
create: (family: AiProviderFamilyId, data: UpsertAiProviderEntryInput) =>
|
||||||
|
request(`/cliproxy/ai-providers/${encodeURIComponent(family)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
update: (family: AiProviderFamilyId, index: number, data: UpsertAiProviderEntryInput) =>
|
||||||
|
request(`/cliproxy/ai-providers/${encodeURIComponent(family)}/${index}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
delete: (family: AiProviderFamilyId, index: number) =>
|
||||||
|
request(`/cliproxy/ai-providers/${encodeURIComponent(family)}/${index}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
// Config YAML for Config tab
|
// Config YAML for Config tab
|
||||||
getConfigYaml: async (): Promise<string> => {
|
getConfigYaml: async (): Promise<string> => {
|
||||||
|
|||||||
+8
-69
@@ -38,13 +38,10 @@ import { cn } from '@/lib/utils';
|
|||||||
import { CopyButton } from '@/components/ui/copy-button';
|
import { CopyButton } from '@/components/ui/copy-button';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { CLIProxyProvider } from '@/lib/provider-config';
|
|
||||||
import { isValidProvider } from '@/lib/provider-config';
|
|
||||||
|
|
||||||
export function ApiPage() {
|
export function ApiPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data, isLoading, isError, refetch } = useProfiles();
|
const { data, isLoading, isError, refetch } = useProfiles();
|
||||||
const deleteMutation = useDeleteProfile();
|
const deleteMutation = useDeleteProfile();
|
||||||
@@ -56,11 +53,9 @@ export function ApiPage() {
|
|||||||
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
|
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
const [createMode, setCreateMode] = useState<
|
const [createMode, setCreateMode] = useState<'normal' | 'openrouter' | 'alibaba-coding-plan'>(
|
||||||
'normal' | 'openrouter' | 'alibaba-coding-plan' | 'cliproxy'
|
'normal'
|
||||||
>('normal');
|
);
|
||||||
const [createDialogCliproxyProvider, setCreateDialogCliproxyProvider] =
|
|
||||||
useState<CLIProxyProvider | null>(null);
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
const [editorHasChanges, setEditorHasChanges] = useState(false);
|
const [editorHasChanges, setEditorHasChanges] = useState(false);
|
||||||
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
|
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
|
||||||
@@ -75,34 +70,6 @@ export function ApiPage() {
|
|||||||
const selectedProfileData = selectedProfile
|
const selectedProfileData = selectedProfile
|
||||||
? profiles.find((p) => p.name === selectedProfile)
|
? profiles.find((p) => p.name === selectedProfile)
|
||||||
: null;
|
: null;
|
||||||
const cliproxyBridgeIntent = useMemo(() => {
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
const rawProvider = params.get('cliproxyProvider');
|
|
||||||
const wantsBridge = params.get('cliproxyBridge') === '1' || rawProvider !== null;
|
|
||||||
if (!wantsBridge) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = rawProvider && isValidProvider(rawProvider) ? rawProvider : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider,
|
|
||||||
};
|
|
||||||
}, [location.search]);
|
|
||||||
|
|
||||||
const clearCliproxyBridgeIntent = () => {
|
|
||||||
if (!cliproxyBridgeIntent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate({ pathname: location.pathname, search: '' }, { replace: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolvedCreateMode = cliproxyBridgeIntent ? 'cliproxy' : createMode;
|
|
||||||
const resolvedCreateDialogProvider = cliproxyBridgeIntent
|
|
||||||
? cliproxyBridgeIntent.provider
|
|
||||||
: createDialogCliproxyProvider;
|
|
||||||
const isResolvedCreateDialogOpen = isCreateDialogOpen || !!cliproxyBridgeIntent;
|
|
||||||
|
|
||||||
const switchToProfile = (name: string) => {
|
const switchToProfile = (name: string) => {
|
||||||
if (editorHasChanges && selectedProfile !== name) {
|
if (editorHasChanges && selectedProfile !== name) {
|
||||||
@@ -126,7 +93,6 @@ export function ApiPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateSuccess = (name: string) => {
|
const handleCreateSuccess = (name: string) => {
|
||||||
clearCliproxyBridgeIntent();
|
|
||||||
setCreateDialogOpen(false);
|
setCreateDialogOpen(false);
|
||||||
switchToProfile(name);
|
switchToProfile(name);
|
||||||
};
|
};
|
||||||
@@ -134,13 +100,6 @@ export function ApiPage() {
|
|||||||
switchToProfile(name);
|
switchToProfile(name);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateDialogOpenChange = (open: boolean) => {
|
|
||||||
if (!open) {
|
|
||||||
clearCliproxyBridgeIntent();
|
|
||||||
}
|
|
||||||
setCreateDialogOpen(open);
|
|
||||||
};
|
|
||||||
|
|
||||||
const triggerDownload = (filename: string, bundle: ApiProfileExportBundle) => {
|
const triggerDownload = (filename: string, bundle: ApiProfileExportBundle) => {
|
||||||
const content = JSON.stringify(bundle, null, 2) + '\n';
|
const content = JSON.stringify(bundle, null, 2) + '\n';
|
||||||
const blob = new Blob([content], { type: 'application/json' });
|
const blob = new Blob([content], { type: 'application/json' });
|
||||||
@@ -335,18 +294,6 @@ export function ApiPage() {
|
|||||||
{t('apiProfiles.noProfilesDesc')}
|
{t('apiProfiles.noProfilesDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setCreateMode('cliproxy');
|
|
||||||
setCreateDialogCliproxyProvider(null);
|
|
||||||
setCreateDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Server className="w-4 h-4 mr-1" />
|
|
||||||
Use CLIProxy
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -441,9 +388,7 @@ export function ApiPage() {
|
|||||||
) : (
|
) : (
|
||||||
<OpenRouterQuickStart
|
<OpenRouterQuickStart
|
||||||
onCliproxyClick={() => {
|
onCliproxyClick={() => {
|
||||||
setCreateMode('cliproxy');
|
navigate('/cliproxy/ai-providers');
|
||||||
setCreateDialogCliproxyProvider(null);
|
|
||||||
setCreateDialogOpen(true);
|
|
||||||
}}
|
}}
|
||||||
onOpenRouterClick={() => {
|
onOpenRouterClick={() => {
|
||||||
setCreateMode('openrouter');
|
setCreateMode('openrouter');
|
||||||
@@ -471,11 +416,10 @@ export function ApiPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ProfileCreateDialog
|
<ProfileCreateDialog
|
||||||
open={isResolvedCreateDialogOpen}
|
open={isCreateDialogOpen}
|
||||||
onOpenChange={handleCreateDialogOpenChange}
|
onOpenChange={setCreateDialogOpen}
|
||||||
onSuccess={handleCreateSuccess}
|
onSuccess={handleCreateSuccess}
|
||||||
initialMode={resolvedCreateMode}
|
initialMode={createMode}
|
||||||
initialCliproxyProvider={resolvedCreateDialogProvider}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -541,11 +485,6 @@ function ProfileListItem({
|
|||||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5 uppercase">
|
<Badge variant="outline" className="text-[10px] h-4 px-1.5 uppercase">
|
||||||
{profile.target || 'claude'}
|
{profile.target || 'claude'}
|
||||||
</Badge>
|
</Badge>
|
||||||
{profile.cliproxyBridge && (
|
|
||||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5">
|
|
||||||
CLIProxy {profile.cliproxyBridge.provider}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<div className="text-xs text-muted-foreground truncate flex-1">
|
<div className="text-xs text-muted-foreground truncate flex-1">
|
||||||
|
|||||||
@@ -0,0 +1,529 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import type { AiProviderEntryView, AiProviderFamilyId } from '../../../src/cliproxy/ai-providers';
|
||||||
|
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||||
|
import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
|
||||||
|
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
FamilyRail,
|
||||||
|
ProviderEntryCard,
|
||||||
|
ProviderEntryDialog,
|
||||||
|
} from '@/components/cliproxy/ai-providers';
|
||||||
|
import {
|
||||||
|
useCliproxyAiProviders,
|
||||||
|
useCreateCliproxyAiProviderEntry,
|
||||||
|
useDeleteCliproxyAiProviderEntry,
|
||||||
|
useUpdateCliproxyAiProviderEntry,
|
||||||
|
} from '@/hooks/use-cliproxy-ai-providers';
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ExternalLink,
|
||||||
|
KeyRound,
|
||||||
|
ListFilter,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldCheck,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
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 SummaryCard({ label, value, hint }: { label: string; value: string; hint?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-background p-3">
|
||||||
|
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||||
|
<div className="mt-1 break-all text-sm font-medium leading-5">{value}</div>
|
||||||
|
{hint ? <div className="mt-1 text-xs text-muted-foreground">{hint}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFamilyStatusBadge(status: 'empty' | 'partial' | 'ready') {
|
||||||
|
switch (status) {
|
||||||
|
case 'ready':
|
||||||
|
return {
|
||||||
|
label: 'Ready',
|
||||||
|
className: 'bg-emerald-50 text-emerald-700 hover:bg-emerald-50',
|
||||||
|
};
|
||||||
|
case 'partial':
|
||||||
|
return {
|
||||||
|
label: 'Needs attention',
|
||||||
|
className: 'bg-amber-50 text-amber-700 hover:bg-amber-50',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
label: 'Empty',
|
||||||
|
className: 'bg-muted text-muted-foreground hover:bg-muted',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyEntryWorkspace({
|
||||||
|
familyDisplayName,
|
||||||
|
routePath,
|
||||||
|
authMode,
|
||||||
|
onAddEntry,
|
||||||
|
onOpenControlPanel,
|
||||||
|
onOpenProfiles,
|
||||||
|
}: {
|
||||||
|
familyDisplayName: string;
|
||||||
|
routePath: string;
|
||||||
|
authMode: string;
|
||||||
|
onAddEntry: () => void;
|
||||||
|
onOpenControlPanel: () => void;
|
||||||
|
onOpenProfiles: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||||
|
<KeyRound className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">No entries configured yet</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Store {familyDisplayName} keys here so CLIProxy can serve this route directly
|
||||||
|
without asking users to create a separate CCS API Profile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<SummaryCard label="Route" value={routePath} hint="served by CLIProxy" />
|
||||||
|
<SummaryCard label="Auth Mode" value={authMode.toUpperCase()} />
|
||||||
|
<SummaryCard label="Ownership" value="AI Providers" hint="OAuth stays in Overview" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" onClick={onAddEntry}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
Add {familyDisplayName} Entry
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={onOpenControlPanel}>
|
||||||
|
Control Panel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={onOpenProfiles}>
|
||||||
|
API Profiles
|
||||||
|
<ExternalLink className="ml-1 h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card p-5">
|
||||||
|
<div className="text-sm font-medium">Where to configure what</div>
|
||||||
|
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="rounded-lg border bg-muted/20 p-3">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Overview
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
OAuth accounts, account health, variants, and browser sign-in flows.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-muted/20 p-3">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
AI Providers
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
CLIProxy-managed API keys, connector routes, aliases, and provider-level overrides.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-muted/20 p-3">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
API Profiles
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
CCS-native Anthropic-compatible profiles such as GLM, Kimi, OpenRouter, and custom
|
||||||
|
gateways.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CliproxyAiProvidersPage() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, isLoading, isFetching, refetch } = useCliproxyAiProviders();
|
||||||
|
const createMutation = useCreateCliproxyAiProviderEntry();
|
||||||
|
const updateMutation = useUpdateCliproxyAiProviderEntry();
|
||||||
|
const deleteMutation = useDeleteCliproxyAiProviderEntry();
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingEntry, setEditingEntry] = useState<AiProviderEntryView | null>(null);
|
||||||
|
const [deleteEntry, setDeleteEntry] = useState<AiProviderEntryView | null>(null);
|
||||||
|
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const families = useMemo(() => data?.families ?? [], [data?.families]);
|
||||||
|
const requestedFamily = useMemo(
|
||||||
|
() => (new URLSearchParams(location.search).get('family') as AiProviderFamilyId | null) || null,
|
||||||
|
[location.search]
|
||||||
|
);
|
||||||
|
const selectedFamily = useMemo<AiProviderFamilyId>(() => {
|
||||||
|
if (requestedFamily && families.some((family) => family.id === requestedFamily)) {
|
||||||
|
return requestedFamily;
|
||||||
|
}
|
||||||
|
|
||||||
|
return families[0]?.id ?? 'gemini-api-key';
|
||||||
|
}, [families, requestedFamily]);
|
||||||
|
|
||||||
|
const selectedFamilyState = useMemo(
|
||||||
|
() => families.find((family) => family.id === selectedFamily) || null,
|
||||||
|
[families, selectedFamily]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFamilySelect = (family: AiProviderFamilyId) => {
|
||||||
|
navigate({ pathname: location.pathname, search: `?family=${family}` }, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
setEditingEntry(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const effectiveSelectedEntryId = useMemo(() => {
|
||||||
|
const entries = selectedFamilyState?.entries ?? [];
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedEntryId && entries.some((entry) => entry.id === selectedEntryId)) {
|
||||||
|
return selectedEntryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries[0]?.id ?? null;
|
||||||
|
}, [selectedEntryId, selectedFamilyState?.entries]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-100px)] min-h-0">
|
||||||
|
<Skeleton className="h-full w-80 rounded-none" />
|
||||||
|
<Skeleton className="h-full flex-1 rounded-none" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedFamilyState || !data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredEntries = selectedFamilyState.entries.filter((entry) => entry.secretConfigured);
|
||||||
|
const readyFamilies = families.filter((family) => family.status === 'ready').length;
|
||||||
|
const selectedEntry =
|
||||||
|
selectedFamilyState.entries.find((entry) => entry.id === effectiveSelectedEntryId) ?? null;
|
||||||
|
const statusBadge = getFamilyStatusBadge(selectedFamilyState.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-100px)] min-h-0">
|
||||||
|
<div className="flex w-80 flex-col border-r bg-muted/30">
|
||||||
|
<div className="border-b bg-background p-4">
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="h-5 w-5 text-primary" />
|
||||||
|
<h1 className="font-semibold">CLIProxy Plus</h1>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-4 w-4', isFetching && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mb-3 text-xs text-muted-foreground">AI provider key management</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="w-full gap-2"
|
||||||
|
type="button"
|
||||||
|
onClick={openCreateDialog}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Entry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="px-3 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Provider Families
|
||||||
|
</div>
|
||||||
|
<FamilyRail
|
||||||
|
families={families}
|
||||||
|
selectedFamily={selectedFamily}
|
||||||
|
onSelect={handleFamilySelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<div className="border-t p-3">
|
||||||
|
<ProxyStatusWidget />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t bg-background p-3 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>{families.length} families</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Check className="h-3 w-3 text-emerald-600" />
|
||||||
|
{readyFamilies} ready
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col bg-background">
|
||||||
|
<div className="shrink-0 border-b bg-background px-6 py-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ProviderLogo provider={getFamilyProvider(selectedFamilyState.id)} size="lg" />
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 className="text-lg font-semibold">{selectedFamilyState.displayName}</h2>
|
||||||
|
<Badge variant="secondary" className={statusBadge.className}>
|
||||||
|
{statusBadge.label}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="uppercase">
|
||||||
|
{selectedFamilyState.authMode}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="font-mono text-[11px]">
|
||||||
|
{selectedFamilyState.routePath}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{selectedFamilyState.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-4 w-4', isFetching && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate('/cliproxy/control-panel')}
|
||||||
|
>
|
||||||
|
Control Panel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => navigate('/providers')}>
|
||||||
|
API Profiles
|
||||||
|
<ExternalLink className="ml-1 h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={openCreateDialog}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
Add Entry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid min-h-0 flex-1 grid-cols-[36%_64%] divide-x overflow-hidden">
|
||||||
|
<div className="flex flex-col overflow-hidden bg-muted/5">
|
||||||
|
<div className="shrink-0 px-4 pt-4">
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<SummaryCard
|
||||||
|
label="Entries"
|
||||||
|
value={`${selectedFamilyState.entries.length}`}
|
||||||
|
hint="configured rows"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Secrets"
|
||||||
|
value={`${configuredEntries.length}/${selectedFamilyState.entries.length || 0}`}
|
||||||
|
hint="rows with stored secrets"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 rounded-lg border bg-background p-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ShieldCheck className="mt-0.5 h-4 w-4 text-emerald-600" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium">Runtime separation</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
AI Providers owns CLIProxy routes and secrets. OAuth stays in Overview.
|
||||||
|
Anthropic-compatible CCS profiles stay in API Profiles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-4 pt-3">
|
||||||
|
<div className="flex items-center justify-between pb-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
<ListFilter className="h-3 w-3" />
|
||||||
|
Entry Inventory
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" variant="ghost" onClick={openCreateDialog}>
|
||||||
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedFamilyState.entries.length === 0 ? (
|
||||||
|
<div className="rounded-lg border bg-background p-4 text-sm text-muted-foreground">
|
||||||
|
No entries in this family yet. Add the first one to start routing requests
|
||||||
|
through CLIProxy.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedFamilyState.entries.map((entry) => (
|
||||||
|
<ProviderEntryCard
|
||||||
|
key={entry.id}
|
||||||
|
family={selectedFamilyState}
|
||||||
|
entry={entry}
|
||||||
|
variant="row"
|
||||||
|
isSelected={entry.id === effectiveSelectedEntryId}
|
||||||
|
onSelect={() => setSelectedEntryId(entry.id)}
|
||||||
|
onEdit={() => {
|
||||||
|
setEditingEntry(entry);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
onDelete={() => setDeleteEntry(entry)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
{selectedEntry ? (
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
<div className="grid gap-3 xl:grid-cols-3">
|
||||||
|
<SummaryCard
|
||||||
|
label="Sync Source"
|
||||||
|
value={data.source.label}
|
||||||
|
hint={data.source.target}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Base URL"
|
||||||
|
value={selectedEntry.baseUrl || 'Default runtime endpoint'}
|
||||||
|
hint="effective upstream"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Advanced Routing"
|
||||||
|
value={
|
||||||
|
selectedEntry.prefix ||
|
||||||
|
selectedEntry.proxyUrl ||
|
||||||
|
selectedEntry.excludedModels.length > 0
|
||||||
|
? 'Configured'
|
||||||
|
: 'Default'
|
||||||
|
}
|
||||||
|
hint="prefix, proxy URL, or exclusions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProviderEntryCard
|
||||||
|
family={selectedFamilyState}
|
||||||
|
entry={selectedEntry}
|
||||||
|
onEdit={() => {
|
||||||
|
setEditingEntry(selectedEntry);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
onDelete={() => setDeleteEntry(selectedEntry)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<div className="text-sm font-medium">Route responsibility</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Requests hitting{' '}
|
||||||
|
<span className="font-mono">{selectedFamilyState.routePath}</span> use this
|
||||||
|
CLIProxy-managed entry. If you need a CCS-native Anthropic-compatible profile,
|
||||||
|
create that under <span className="font-medium">API Profiles</span> instead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyEntryWorkspace
|
||||||
|
familyDisplayName={selectedFamilyState.displayName}
|
||||||
|
routePath={selectedFamilyState.routePath}
|
||||||
|
authMode={selectedFamilyState.authMode}
|
||||||
|
onAddEntry={openCreateDialog}
|
||||||
|
onOpenControlPanel={() => navigate('/cliproxy/control-panel')}
|
||||||
|
onOpenProfiles={() => navigate('/providers')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProviderEntryDialog
|
||||||
|
key={`${selectedFamily}:${editingEntry?.id ?? 'new'}:${dialogOpen ? 'open' : 'closed'}`}
|
||||||
|
family={selectedFamily}
|
||||||
|
entry={editingEntry}
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
onSubmit={async (payload) => {
|
||||||
|
if (editingEntry) {
|
||||||
|
await updateMutation.mutateAsync({
|
||||||
|
family: selectedFamily,
|
||||||
|
index: editingEntry.index,
|
||||||
|
data: payload,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync({ family: selectedFamily, data: payload });
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingEntry(null);
|
||||||
|
void refetch();
|
||||||
|
}}
|
||||||
|
isSaving={createMutation.isPending || updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteEntry !== null}
|
||||||
|
title="Remove provider entry?"
|
||||||
|
description={
|
||||||
|
deleteEntry
|
||||||
|
? `This removes ${deleteEntry.label} from ${selectedFamilyState.displayName}.`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmText="Remove"
|
||||||
|
variant="destructive"
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!deleteEntry) return;
|
||||||
|
await deleteMutation.mutateAsync({
|
||||||
|
family: selectedFamily,
|
||||||
|
index: deleteEntry.index,
|
||||||
|
});
|
||||||
|
setDeleteEntry(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setDeleteEntry(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+17
-20
@@ -15,7 +15,6 @@ import { QuickSetupWizard } from '@/components/quick-setup-wizard';
|
|||||||
import { AddAccountDialog } from '@/components/account/add-account-dialog';
|
import { AddAccountDialog } from '@/components/account/add-account-dialog';
|
||||||
import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card';
|
import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card';
|
||||||
import { ProviderEditor } from '@/components/cliproxy/provider-editor';
|
import { ProviderEditor } from '@/components/cliproxy/provider-editor';
|
||||||
import { ApiProfileBridgeCallout } from '@/components/cliproxy/api-profile-bridge-callout';
|
|
||||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||||
import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
|
import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
|
||||||
import {
|
import {
|
||||||
@@ -107,15 +106,28 @@ function VariantSidebarItem({
|
|||||||
isDeleting?: boolean;
|
isDeleting?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleActivate = () => {
|
||||||
|
onSelect();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors cursor-pointer text-left pl-6',
|
'group w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors cursor-pointer text-left pl-6',
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-primary/10 border border-primary/20'
|
? 'bg-primary/10 border border-primary/20'
|
||||||
: 'hover:bg-muted border border-transparent'
|
: 'hover:bg-muted border border-transparent'
|
||||||
)}
|
)}
|
||||||
onClick={onSelect}
|
onClick={handleActivate}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleActivate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<ProviderLogo provider={variant.provider} size="sm" />
|
<ProviderLogo provider={variant.provider} size="sm" />
|
||||||
@@ -161,7 +173,7 @@ function VariantSidebarItem({
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,11 +450,6 @@ export function CliproxyPage() {
|
|||||||
|
|
||||||
{selectedVariantData && parentAuthForVariant ? (
|
{selectedVariantData && parentAuthForVariant ? (
|
||||||
<>
|
<>
|
||||||
<ApiProfileBridgeCallout
|
|
||||||
provider={selectedVariantData.provider}
|
|
||||||
compact
|
|
||||||
className="mx-4 mt-4"
|
|
||||||
/>
|
|
||||||
<ProviderEditor
|
<ProviderEditor
|
||||||
provider={selectedVariantData.name}
|
provider={selectedVariantData.name}
|
||||||
displayName={t('cliproxyPage.variantDisplay', {
|
displayName={t('cliproxyPage.variantDisplay', {
|
||||||
@@ -494,13 +501,6 @@ export function CliproxyPage() {
|
|||||||
</>
|
</>
|
||||||
) : selectedStatus ? (
|
) : selectedStatus ? (
|
||||||
<>
|
<>
|
||||||
<ApiProfileBridgeCallout
|
|
||||||
provider={
|
|
||||||
isValidProvider(selectedStatus.provider) ? selectedStatus.provider : undefined
|
|
||||||
}
|
|
||||||
compact
|
|
||||||
className="mx-4 mt-4"
|
|
||||||
/>
|
|
||||||
<ProviderEditor
|
<ProviderEditor
|
||||||
provider={selectedStatus.provider}
|
provider={selectedStatus.provider}
|
||||||
displayName={selectedStatus.displayName}
|
displayName={selectedStatus.displayName}
|
||||||
@@ -540,10 +540,7 @@ export function CliproxyPage() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 flex-col min-h-0">
|
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
|
||||||
<ApiProfileBridgeCallout compact className="mx-4 mt-4" />
|
|
||||||
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user