diff --git a/package.json b/package.json index d15c569c..75aa643e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.13.0", + "version": "7.13.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/api/services/validation-service.ts b/src/api/services/validation-service.ts index 0973ca13..e9b830ce 100644 --- a/src/api/services/validation-service.ts +++ b/src/api/services/validation-service.ts @@ -5,7 +5,7 @@ * Extracted from api-command.ts for reuse and testability. */ -import { isReservedName } from '../../config/reserved-names'; +import { isReservedName, isWindowsReservedName } from '../../config/reserved-names'; /** * Validate API profile name @@ -24,6 +24,9 @@ export function validateApiName(name: string): string | null { if (isReservedName(name)) { return `'${name}' is a reserved name`; } + if (isWindowsReservedName(name)) { + return `'${name}' is a Windows reserved device name and cannot be used`; + } return null; } diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index 8ba64cd1..a65947c9 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -56,6 +56,25 @@ const DEFAULT_REGISTRY: AccountsRegistry = { providers: {}, }; +/** + * Extract unique account ID from token filename when email is unavailable + * For Kiro/GHCP OAuth, filenames are: --.json + * Extracts: - as unique identifier + * @example kiro-github-ABC123.json → github-ABC123 + * @example ghcp-amazon-XYZ789.json → amazon-XYZ789 + * @example kiro-nomail.json → default (no OAuth structure) + */ +export function extractAccountIdFromTokenFile(filename: string, email?: string): string { + if (email) return email; + + // Pattern: --.json → extract - + // Requires at least 2 hyphens to distinguish from simple filenames like "kiro-nomail.json" + const match = filename.match(/^[^-]+-([^-]+-[^.]+)\.json$/); + if (match) return match[1]; + + return 'default'; +} + /** * Generate nickname from email * Takes prefix before @ symbol, sanitizes whitespace @@ -260,8 +279,8 @@ export function registerAccount( throw new Error('Failed to initialize provider accounts'); } - // Determine account ID - const accountId = email || 'default'; + // Determine account ID - use email if available, otherwise extract from filename + const accountId = extractAccountIdFromTokenFile(tokenFile, email); const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0; // Generate nickname if not provided @@ -448,7 +467,7 @@ export function discoverExistingAccounts(): void { continue; } - // Extract email if available, fallback to filename + // Extract email if available, fallback to filename-based ID let email = data.email || undefined; // Fallback: extract email from filename (e.g., "kiro-google-user@example.com.json") @@ -459,7 +478,8 @@ export function discoverExistingAccounts(): void { } } - const accountId = email || 'default'; + // Use unified ID extraction: email or filename-based unique ID + const accountId = extractAccountIdFromTokenFile(file, email); // Initialize provider section if needed if (!registry.providers[provider]) { diff --git a/src/cliproxy/auth/gemini-token-refresh.ts b/src/cliproxy/auth/gemini-token-refresh.ts new file mode 100644 index 00000000..f6679c4c --- /dev/null +++ b/src/cliproxy/auth/gemini-token-refresh.ts @@ -0,0 +1,207 @@ +/** + * Gemini Token Refresh + * + * Handles proactive token validation and refresh for Gemini OAuth tokens. + * Prevents UND_ERR_SOCKET errors by ensuring tokens are valid before use. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +/** + * Gemini OAuth credentials - PUBLIC from official Gemini CLI source code + * These are not secrets - they're public OAuth client credentials that Google + * distributes with their official applications. See: + * https://github.com/google/generative-ai-python (Gemini CLI source) + * + * GitHub secret scanning may flag these, but they are intentionally hardcoded + * as they're required for OAuth token refresh and are publicly documented. + */ + +const GEMINI_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'; + +const GEMINI_CLIENT_SECRET = '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'; + +/** Google OAuth token endpoint */ +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; + +/** Refresh tokens 5 minutes before expiry */ +const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; + +/** Gemini oauth_creds.json structure */ +interface GeminiOAuthCreds { + access_token: string; + refresh_token?: string; + expiry_date?: number; // Unix timestamp in milliseconds + scope?: string; + token_type?: string; + id_token?: string; +} + +/** Token refresh response from Google */ +interface TokenRefreshResponse { + access_token?: string; + expires_in?: number; + token_type?: string; + error?: string; + error_description?: string; +} + +/** + * Get path to Gemini OAuth credentials file + */ +export function getGeminiOAuthPath(): string { + return path.join(os.homedir(), '.gemini', 'oauth_creds.json'); +} + +/** + * Read Gemini OAuth credentials + */ +function readGeminiCreds(): GeminiOAuthCreds | null { + const oauthPath = getGeminiOAuthPath(); + if (!fs.existsSync(oauthPath)) { + return null; + } + try { + const content = fs.readFileSync(oauthPath, 'utf8'); + return JSON.parse(content) as GeminiOAuthCreds; + } catch { + return null; + } +} + +/** + * Write Gemini OAuth credentials + * @returns error message if write failed, undefined on success + */ +function writeGeminiCreds(creds: GeminiOAuthCreds): string | undefined { + const oauthPath = getGeminiOAuthPath(); + const dir = path.dirname(oauthPath); + try { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(oauthPath, JSON.stringify(creds, null, 2), { mode: 0o600 }); + return undefined; + } catch (err) { + return err instanceof Error ? err.message : 'Failed to write credentials'; + } +} + +/** + * Check if Gemini token is expired or expiring soon + */ +export function isGeminiTokenExpiringSoon(): boolean { + const creds = readGeminiCreds(); + if (!creds || !creds.access_token) { + return true; // No token = needs auth + } + if (!creds.expiry_date) { + return false; // No expiry info = assume valid + } + const expiresIn = creds.expiry_date - Date.now(); + return expiresIn < REFRESH_LEAD_TIME_MS; +} + +/** + * Refresh Gemini access token using refresh_token + * @returns true if refresh succeeded, false otherwise + */ +export async function refreshGeminiToken(): Promise<{ + success: boolean; + error?: string; +}> { + const creds = readGeminiCreds(); + if (!creds || !creds.refresh_token) { + return { success: false, error: 'No refresh token available' }; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + signal: controller.signal, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: creds.refresh_token, + client_id: GEMINI_CLIENT_ID, + client_secret: GEMINI_CLIENT_SECRET, + }).toString(), + }); + + clearTimeout(timeoutId); + + const data = (await response.json()) as TokenRefreshResponse; + + if (!response.ok || data.error) { + return { + success: false, + error: data.error_description || data.error || `OAuth error: ${response.status}`, + }; + } + + if (!data.access_token) { + return { success: false, error: 'No access_token in response' }; + } + + // Update credentials file with new token + const updatedCreds: GeminiOAuthCreds = { + ...creds, + access_token: data.access_token, + expiry_date: Date.now() + (data.expires_in ?? 3600) * 1000, + }; + const writeError = writeGeminiCreds(updatedCreds); + if (writeError) { + return { success: false, error: `Token refreshed but failed to save: ${writeError}` }; + } + + return { success: true }; + } catch (err) { + clearTimeout(timeoutId); + if (err instanceof Error && err.name === 'AbortError') { + return { success: false, error: 'Token refresh timeout' }; + } + return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; + } +} + +/** + * Ensure Gemini token is valid, refreshing if needed + * @param verbose Log progress if true + * @returns true if token is valid (or was refreshed), false if refresh failed + */ +export async function ensureGeminiTokenValid(verbose = false): Promise<{ + valid: boolean; + refreshed: boolean; + error?: string; +}> { + const creds = readGeminiCreds(); + if (!creds || !creds.access_token) { + return { valid: false, refreshed: false, error: 'No Gemini credentials found' }; + } + + if (!isGeminiTokenExpiringSoon()) { + return { valid: true, refreshed: false }; + } + + // Token is expired or expiring soon - try to refresh + if (verbose) { + console.log('[i] Gemini token expired or expiring soon, refreshing...'); + } + + const result = await refreshGeminiToken(); + if (result.success) { + if (verbose) { + console.log('[OK] Gemini token refreshed successfully'); + } + return { valid: true, refreshed: true }; + } + + return { valid: false, refreshed: false, error: result.error }; +} diff --git a/src/cliproxy/auth/token-manager.ts b/src/cliproxy/auth/token-manager.ts index 4eb4fa72..436539cc 100644 --- a/src/cliproxy/auth/token-manager.ts +++ b/src/cliproxy/auth/token-manager.ts @@ -258,3 +258,27 @@ export function displayAuthStatus(): void { console.log('To authenticate: ccs --auth'); console.log('To logout: ccs --logout'); } + +/** + * Ensure OAuth token is valid for provider, refreshing if expired or expiring soon. + * This prevents UND_ERR_SOCKET errors caused by expired tokens during API calls. + * + * @param provider The CLIProxy provider + * @param verbose Log progress if true + * @returns Object with valid status and whether refresh occurred + */ +export async function ensureTokenValid( + provider: CLIProxyProvider, + verbose = false +): Promise<{ valid: boolean; refreshed: boolean; error?: string }> { + // Currently only Gemini uses oauth_creds.json with expiry_date + // Other providers (agy, codex) use CLIProxyAPI's internal auth management + if (provider === 'gemini') { + const { ensureGeminiTokenValid } = await import('./gemini-token-refresh'); + return ensureGeminiTokenValid(verbose); + } + + // For other providers, assume token is valid if authenticated + // CLIProxyAPI handles token refresh internally for antigravity/codex + return { valid: isAuthenticated(provider), refreshed: false }; +} diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 4b11748d..0de849d6 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -444,6 +444,23 @@ export async function execClaudeWithCLIProxy( } else { log(`${provider} already authenticated`); } + + // 3a. Proactive token refresh - prevent UND_ERR_SOCKET from expired tokens + // CLIProxyAPI may fail mid-request if token expires during use + const { ensureTokenValid } = await import('./auth/token-manager'); + const tokenResult = await ensureTokenValid(provider, verbose); + if (!tokenResult.valid) { + // Token expired and refresh failed - trigger re-auth + console.error(warn('OAuth token expired and refresh failed')); + if (tokenResult.error) { + console.error(` ${tokenResult.error}`); + } + console.error(` Run "ccs ${provider} --auth" to re-authenticate`); + process.exit(1); + } + if (tokenResult.refreshed) { + log('Token was refreshed proactively'); + } } // 4. First-run model configuration (interactive) diff --git a/src/cliproxy/proxy-target-resolver.ts b/src/cliproxy/proxy-target-resolver.ts index 7124c7a1..c9d5cd83 100644 --- a/src/cliproxy/proxy-target-resolver.ts +++ b/src/cliproxy/proxy-target-resolver.ts @@ -22,8 +22,10 @@ export interface ProxyTarget { port: number; /** Protocol (http/https) */ protocol: 'http' | 'https'; - /** Optional auth token - only send header if defined and non-empty */ + /** Optional auth token for API endpoints - only send header if defined and non-empty */ authToken?: string; + /** Optional management key for management API endpoints (/v0/management/*) */ + managementKey?: string; /** True if targeting remote server, false if local */ isRemote: boolean; } @@ -56,6 +58,7 @@ export function getProxyTarget(): ProxyTarget { port, protocol, authToken: config.remote.auth_token || undefined, // Empty string -> undefined + managementKey: config.remote.management_key || undefined, // Empty string -> undefined isRemote: true, }; } @@ -102,3 +105,29 @@ export function buildProxyHeaders( return headers; } + +/** + * Build request headers for management API endpoints (/v0/management/*). + * Uses management_key if configured, otherwise falls back to authToken. + * + * @param target Resolved proxy target + * @param additionalHeaders Extra headers to merge + */ +export function buildManagementHeaders( + target: ProxyTarget, + additionalHeaders: Record = {} +): Record { + const headers: Record = { + Accept: 'application/json', + ...additionalHeaders, + }; + + // Use management key for management API, fallback to authToken + const authKey = target.managementKey ?? target.authToken; + + if (authKey) { + headers['Authorization'] = `Bearer ${authKey}`; + } + + return headers; +} diff --git a/src/cliproxy/remote-auth-fetcher.ts b/src/cliproxy/remote-auth-fetcher.ts index 5acaba86..5de9218e 100644 --- a/src/cliproxy/remote-auth-fetcher.ts +++ b/src/cliproxy/remote-auth-fetcher.ts @@ -6,7 +6,7 @@ import { getProxyTarget, buildProxyUrl, - buildProxyHeaders, + buildManagementHeaders, ProxyTarget, } from './proxy-target-resolver'; @@ -51,6 +51,11 @@ const PROVIDER_MAP: Record = { codex: 'codex', qwen: 'qwen', iflow: 'iflow', + kiro: 'kiro', + codewhisperer: 'kiro', // CLIProxyAPI may use 'codewhisperer' for Kiro + ghcp: 'ghcp', + 'github-copilot': 'ghcp', + copilot: 'ghcp', }; /** Display names for providers */ @@ -60,6 +65,8 @@ const PROVIDER_DISPLAY_NAMES: Record = { codex: 'Codex', qwen: 'Qwen', iflow: 'iFlow', + kiro: 'Kiro (AWS)', + ghcp: 'GitHub Copilot (OAuth)', }; /** @@ -74,6 +81,7 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise controller.abort(), REMOTE_FETCH_TIMEOUT_MS); @@ -81,7 +89,7 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise { } /** - * Validate version format + * Validate version format (supports X.Y.Z or X.Y.Z-N suffix) */ export function isValidVersionFormat(version: string): boolean { - return /^\d+\.\d+\.\d+$/.test(version); + return /^\d+\.\d+\.\d+(-\d+)?$/.test(version); } /** diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index 58e4e05d..41a5dcfb 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -9,7 +9,7 @@ import * as os from 'os'; import * as path from 'path'; import { CLIProxyProfileName } from '../../auth/profile-detector'; import { CLIProxyProvider } from '../types'; -import { isReservedName } from '../../config/reserved-names'; +import { isReservedName, isWindowsReservedName } from '../../config/reserved-names'; import { isUnifiedMode } from '../../config/unified-config-loader'; import { deleteConfigForPort } from '../config-generator'; import { deleteSessionLockForPort } from '../session-tracker'; @@ -58,6 +58,9 @@ export function validateProfileName(name: string): string | null { if (isReservedName(name)) { return `'${name}' is a reserved name`; } + if (isWindowsReservedName(name)) { + return `'${name}' is a Windows reserved device name and cannot be used`; + } return null; } diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index 58c20a4b..6acaf8b7 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -6,7 +6,12 @@ */ import { getEffectiveApiKey, getEffectiveManagementSecret } from './auth-token-manager'; -import { getProxyTarget, buildProxyUrl, buildProxyHeaders } from './proxy-target-resolver'; +import { + getProxyTarget, + buildProxyUrl, + buildProxyHeaders, + buildManagementHeaders, +} from './proxy-target-resolver'; /** Per-account usage statistics */ export interface AccountUsageStats { @@ -109,9 +114,9 @@ export async function fetchCliproxyStats(port?: number): Promise { const installIdx = args.indexOf('--install'); if (installIdx !== -1) { - const version = args[installIdx + 1]; + let version = args[installIdx + 1]; if (!version || version.startsWith('-')) { console.error(fail('Missing version argument for --install')); console.error(' Usage: ccs cliproxy --install '); - console.error(' Example: ccs cliproxy --install 6.5.53'); + console.error(' Example: ccs cliproxy --install 6.6.80-0'); process.exit(1); } + // Strip leading 'v' prefix and whitespace (user may type " v6.6.80-0 ") + version = version.trim().replace(/^v/, ''); await handleInstallVersion(version, verbose); return; } diff --git a/src/config/reserved-names.ts b/src/config/reserved-names.ts index 25262265..7e53f4c3 100644 --- a/src/config/reserved-names.ts +++ b/src/config/reserved-names.ts @@ -19,6 +19,35 @@ export const RESERVED_PROFILE_NAMES = [ export type ReservedProfileName = (typeof RESERVED_PROFILE_NAMES)[number]; +/** + * Windows reserved device names - cannot be used as filenames on Windows. + * Case-insensitive on Windows filesystem. + */ +export const WINDOWS_RESERVED_NAMES = [ + 'CON', + 'PRN', + 'AUX', + 'NUL', + 'COM1', + 'COM2', + 'COM3', + 'COM4', + 'COM5', + 'COM6', + 'COM7', + 'COM8', + 'COM9', + 'LPT1', + 'LPT2', + 'LPT3', + 'LPT4', + 'LPT5', + 'LPT6', + 'LPT7', + 'LPT8', + 'LPT9', +] as const; + /** * Check if a name is reserved and cannot be used for user profiles. * @param name - The profile name to check @@ -28,6 +57,18 @@ export function isReservedName(name: string): boolean { return RESERVED_PROFILE_NAMES.includes(name.toLowerCase() as ReservedProfileName); } +/** + * Check if a name is a Windows reserved device name. + * These cause filesystem errors on Windows systems. + * @param name - The name to check + * @returns true if the name is a Windows reserved name + */ +export function isWindowsReservedName(name: string): boolean { + return WINDOWS_RESERVED_NAMES.includes( + name.toUpperCase() as (typeof WINDOWS_RESERVED_NAMES)[number] + ); +} + /** * Validate a profile name and throw if reserved. * @param name - The profile name to validate diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index f36cd130..3a23ba1e 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -194,6 +194,8 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { auth_token: partial.cliproxy_server?.remote?.auth_token ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.auth_token, + // management_key is optional - falls back to auth_token when not set + management_key: partial.cliproxy_server?.remote?.management_key, }, fallback: { enabled: diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 18d5e3d5..166349cb 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -225,8 +225,15 @@ export interface ProxyRemoteConfig { port?: number; /** Protocol for remote connection */ protocol: 'http' | 'https'; - /** Auth token for remote proxy (optional, sent as header) */ + /** Auth token for remote proxy API endpoints (optional, sent as header) */ auth_token: string; + /** + * Management key for remote proxy management API endpoints. + * CLIProxyAPI uses separate authentication for management endpoints + * (/v0/management/*) via 'secret-key' config. + * If not set, falls back to auth_token for backwards compatibility. + */ + management_key?: string; /** Connection timeout in milliseconds (default: 2000) */ timeout?: number; } diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 7e79ffaf..53338e3c 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -5,6 +5,7 @@ /* eslint-disable react-refresh/only-export-components */ import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { ConfirmDialog } from '@/components/shared/confirm-dialog'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Loader2, Code2 } from 'lucide-react'; @@ -93,11 +94,23 @@ export function ProviderEditor({ const accounts = authStatus.accounts || []; + // Fetch effective API key for presets (uses configured value, not hardcoded) + const { data: authTokens } = useQuery<{ apiKey: { value: string } }>({ + queryKey: ['auth-tokens-raw'], + queryFn: async () => { + const response = await fetch('/api/settings/auth/tokens/raw'); + if (!response.ok) return { apiKey: { value: 'ccs-internal-managed' } }; + return response.json(); + }, + staleTime: 60000, // Cache for 1 minute + }); + const effectiveApiKey = authTokens?.apiKey?.value ?? 'ccs-internal-managed'; + const handleApplyPreset = (updates: Record) => { const effectivePort = port ?? CLIPROXY_PORT; updateEnvValues({ ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`, - ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_AUTH_TOKEN: effectiveApiKey, ...updates, }); toast.success(`Applied "${updates.ANTHROPIC_MODEL?.split('/').pop() || 'preset'}" preset`); @@ -107,7 +120,7 @@ export function ProviderEditor({ const effectivePort = port ?? CLIPROXY_PORT; updateEnvValues({ ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`, - ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_AUTH_TOKEN: effectiveApiKey, ANTHROPIC_MODEL: values.default, ANTHROPIC_DEFAULT_OPUS_MODEL: values.opus, ANTHROPIC_DEFAULT_SONNET_MODEL: values.sonnet, @@ -189,6 +202,7 @@ export function ProviderEditor({ onRemoveAccount={onRemoveAccount} isRemovingAccount={isRemovingAccount} privacyMode={privacyMode} + isRemoteMode={isRemoteMode} /> void; isRemovingAccount?: boolean; privacyMode?: boolean; + /** True if connected to remote CLIProxy (quota not available) */ + isRemoteMode?: boolean; } export function ModelConfigTab({ @@ -60,6 +62,7 @@ export function ModelConfigTab({ onRemoveAccount, isRemovingAccount, privacyMode, + isRemoteMode, }: ModelConfigTabProps) { // Kiro-specific: no-incognito setting (defaults to true = normal browser) const isKiro = provider === 'kiro'; @@ -133,7 +136,7 @@ export function ModelConfigTab({ onRemoveAccount={onRemoveAccount} isRemovingAccount={isRemovingAccount} privacyMode={privacyMode} - showQuota={provider === 'agy'} + showQuota={provider === 'agy' && !isRemoteMode} isKiro={isKiro} kiroNoIncognito={kiroNoIncognito} onKiroNoIncognitoChange={saveKiroNoIncognito} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 60e3ec02..a2cf98a5 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -193,6 +193,8 @@ export interface ProxyRemoteConfig { port?: number; protocol: 'http' | 'https'; auth_token: string; + /** Management key for /v0/management/* endpoints (optional, falls back to auth_token) */ + management_key?: string; } /** Fallback configuration */ @@ -278,7 +280,9 @@ export const api = { cliproxy: { list: () => request<{ variants: Variant[] }>('/cliproxy'), getAuthStatus: () => - request<{ authStatus: AuthStatus[]; source?: 'remote' | 'local' }>('/cliproxy/auth'), + request<{ authStatus: AuthStatus[]; source?: 'remote' | 'local'; error?: string }>( + '/cliproxy/auth' + ), create: (data: CreateVariant) => request('/cliproxy', { method: 'POST', diff --git a/ui/src/lib/preset-utils.ts b/ui/src/lib/preset-utils.ts index 80ce9673..ebdb724d 100644 --- a/ui/src/lib/preset-utils.ts +++ b/ui/src/lib/preset-utils.ts @@ -8,6 +8,24 @@ import { MODEL_CATALOGS } from './model-catalogs'; /** CLIProxy port - should match the backend configuration */ export const CLIPROXY_PORT = 8317; +/** Default fallback API key if fetch fails */ +const DEFAULT_API_KEY = 'ccs-internal-managed'; + +/** + * Fetch effective API key from backend + * Falls back to default if fetch fails + */ +async function fetchEffectiveApiKey(): Promise { + try { + const response = await fetch('/api/settings/auth/tokens/raw'); + if (!response.ok) return DEFAULT_API_KEY; + const data = await response.json(); + return data?.apiKey?.value ?? DEFAULT_API_KEY; + } catch { + return DEFAULT_API_KEY; + } +} + /** * Apply default preset for a provider to its settings * Uses the first model's presetMapping or falls back to using defaultModel for all tiers @@ -32,11 +50,14 @@ export async function applyDefaultPreset( haiku: catalog.defaultModel, }; + // Fetch effective API key (respects user customization) + const effectiveApiKey = await fetchEffectiveApiKey(); + const effectivePort = port ?? CLIPROXY_PORT; const settings = { env: { ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`, - ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_AUTH_TOKEN: effectiveApiKey, ANTHROPIC_MODEL: mapping.default, ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus, ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet, diff --git a/ui/src/pages/settings/hooks/use-proxy-config.ts b/ui/src/pages/settings/hooks/use-proxy-config.ts index b9e9cb50..eae80880 100644 --- a/ui/src/pages/settings/hooks/use-proxy-config.ts +++ b/ui/src/pages/settings/hooks/use-proxy-config.ts @@ -13,6 +13,7 @@ export function useProxyConfig() { const [editedHost, setEditedHost] = useState(null); const [editedPort, setEditedPort] = useState(null); const [editedAuthToken, setEditedAuthToken] = useState(null); + const [editedManagementKey, setEditedManagementKey] = useState(null); const [editedLocalPort, setEditedLocalPort] = useState(null); const fetchConfig = useCallback(async () => { @@ -108,6 +109,8 @@ export function useProxyConfig() { setEditedPort, editedAuthToken, setEditedAuthToken, + editedManagementKey, + setEditedManagementKey, editedLocalPort, setEditedLocalPort, fetchConfig, diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index 14a8b666..a9807ec8 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -28,6 +28,8 @@ export default function ProxySection() { setEditedPort, editedAuthToken, setEditedAuthToken, + editedManagementKey, + setEditedManagementKey, editedLocalPort, setEditedLocalPort, fetchConfig, @@ -62,11 +64,13 @@ export default function ProxySection() { const hostInput = config.remote.host ?? ''; const portInput = config.remote.port !== undefined ? config.remote.port.toString() : ''; const authTokenInput = config.remote.auth_token ?? ''; + const managementKeyInput = config.remote.management_key ?? ''; const localPortInput = (config.local.port ?? 8317).toString(); const displayHost = editedHost ?? hostInput; const displayPort = editedPort ?? portInput; const displayAuthToken = editedAuthToken ?? authTokenInput; + const displayManagementKey = editedManagementKey ?? managementKeyInput; const displayLocalPort = editedLocalPort ?? localPortInput; // Save functions for blur events @@ -97,6 +101,14 @@ export default function ProxySection() { setEditedAuthToken(null); }; + const saveManagementKey = () => { + const value = editedManagementKey ?? displayManagementKey; + if (value !== config.remote.management_key) { + saveConfig({ remote: { ...remoteConfig, management_key: value || undefined } }); + } + setEditedManagementKey(null); + }; + const saveLocalPort = () => { const port = parseInt(editedLocalPort ?? displayLocalPort, 10); if (!isNaN(port) && port !== config.local.port) { @@ -203,12 +215,15 @@ export default function ProxySection() { displayHost={displayHost} displayPort={displayPort} displayAuthToken={displayAuthToken} + displayManagementKey={displayManagementKey} setEditedHost={setEditedHost} setEditedPort={setEditedPort} setEditedAuthToken={setEditedAuthToken} + setEditedManagementKey={setEditedManagementKey} onSaveHost={saveHost} onSavePort={savePort} onSaveAuthToken={saveAuthToken} + onSaveManagementKey={saveManagementKey} onSaveConfig={saveConfig} onTestConnection={handleTestConnection} /> diff --git a/ui/src/pages/settings/sections/proxy/remote-proxy-card.tsx b/ui/src/pages/settings/sections/proxy/remote-proxy-card.tsx index 29e26413..ad03b65d 100644 --- a/ui/src/pages/settings/sections/proxy/remote-proxy-card.tsx +++ b/ui/src/pages/settings/sections/proxy/remote-proxy-card.tsx @@ -23,12 +23,15 @@ interface RemoteProxyCardProps { displayHost: string; displayPort: string; displayAuthToken: string; + displayManagementKey: string; setEditedHost: (value: string | null) => void; setEditedPort: (value: string | null) => void; setEditedAuthToken: (value: string | null) => void; + setEditedManagementKey: (value: string | null) => void; onSaveHost: () => void; onSavePort: () => void; onSaveAuthToken: () => void; + onSaveManagementKey: () => void; onSaveConfig: (updates: Partial) => void; onTestConnection: () => void; } @@ -41,12 +44,15 @@ export function RemoteProxyCard({ displayHost, displayPort, displayAuthToken, + displayManagementKey, setEditedHost, setEditedPort, setEditedAuthToken, + setEditedManagementKey, onSaveHost, onSavePort, onSaveAuthToken, + onSaveManagementKey, onSaveConfig, onTestConnection, }: RemoteProxyCardProps) { @@ -115,18 +121,38 @@ export function RemoteProxyCard({ - {/* Auth Token */} + {/* Auth Token (API Key) */}
- + setEditedAuthToken(e.target.value)} onBlur={onSaveAuthToken} - placeholder="Bearer token for authentication" + placeholder="For /v1/* API endpoints" className="font-mono" disabled={saving} /> +

+ Used for API requests to /v1/chat/completions +

+
+ + {/* Management Key */} +
+ + setEditedManagementKey(e.target.value)} + onBlur={onSaveManagementKey} + placeholder="For /v0/management/* endpoints" + className="font-mono" + disabled={saving} + /> +

+ Used for dashboard management APIs. Falls back to API Key if not set. +

{/* Test Connection */}