Merge pull request #263 from kaitranntt/dev

feat: cliproxy improvements and validation enhancements
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-01-04 21:08:21 -08:00
commit 3c01d04749
22 files changed
+484 -30

No files matched your search

+1 -1
View File
@@ -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",
+4 -1
View File
@@ -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;
}
+24 -4
View File
@@ -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: <provider>-<oauth>-<profile_id>.json
* Extracts: <oauth>-<profile_id> 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: <provider>-<oauth>-<profile_id>.json → extract <oauth>-<profile_id>
// 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]) {
+207
View File
@@ -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 };
}
+24
View File
@@ -258,3 +258,27 @@ export function displayAuthStatus(): void {
console.log('To authenticate: ccs <provider> --auth');
console.log('To logout: ccs <provider> --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 };
}
+17
View File
@@ -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)
+30 -1
View File
@@ -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<string, string> = {}
): Record<string, string> {
const headers: Record<string, string> = {
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;
}
+10 -2
View File
@@ -6,7 +6,7 @@
import {
getProxyTarget,
buildProxyUrl,
buildProxyHeaders,
buildManagementHeaders,
ProxyTarget,
} from './proxy-target-resolver';
@@ -51,6 +51,11 @@ const PROVIDER_MAP: Record<string, string> = {
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<string, string> = {
codex: 'Codex',
qwen: 'Qwen',
iflow: 'iFlow',
kiro: 'Kiro (AWS)',
ghcp: 'GitHub Copilot (OAuth)',
};
/**
@@ -74,6 +81,7 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise<Remot
}
const url = buildProxyUrl(proxyTarget, '/v0/management/auth-files');
const headers = buildManagementHeaders(proxyTarget);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REMOTE_FETCH_TIMEOUT_MS);
@@ -81,7 +89,7 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise<Remot
try {
const response = await fetch(url, {
signal: controller.signal,
headers: buildProxyHeaders(proxyTarget),
headers,
});
clearTimeout(timeoutId);
+2 -2
View File
@@ -84,10 +84,10 @@ export async function checkLatestVersion(): Promise<LatestVersionResult> {
}
/**
* 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);
}
/**
+4 -1
View File
@@ -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;
}
+12 -7
View File
@@ -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<CliproxyStats |
}
const url = buildProxyUrl(target, '/v0/management/usage');
// For management endpoints, use CCS control panel secret for local, remote auth for remote
// For management endpoints, use management key for remote, local management secret for local
const headers = target.isRemote
? buildProxyHeaders(target)
? buildManagementHeaders(target)
: { Accept: 'application/json', Authorization: `Bearer ${getEffectiveManagementSecret()}` };
const response = await fetch(url, {
@@ -326,9 +331,9 @@ export async function fetchCliproxyErrorLogs(port?: number): Promise<CliproxyErr
}
const url = buildProxyUrl(target, '/v0/management/request-error-logs');
// For management endpoints, use CCS control panel secret for local, remote auth for remote
// For management endpoints, use management key for remote, local management secret for local
const headers = target.isRemote
? buildProxyHeaders(target)
? buildManagementHeaders(target)
: { Accept: 'application/json', Authorization: `Bearer ${getEffectiveManagementSecret()}` };
const response = await fetch(url, {
@@ -374,9 +379,9 @@ export async function fetchCliproxyErrorLogContent(
`/v0/management/request-error-logs/${encodeURIComponent(name)}`
);
// For management endpoints, use CCS control panel secret for local, remote auth for remote
// For management endpoints, use management key for remote, local management secret for local
const headers = target.isRemote
? buildProxyHeaders(target)
? buildManagementHeaders(target)
: { Authorization: `Bearer ${getEffectiveManagementSecret()}` };
const response = await fetch(url, {
+4 -2
View File
@@ -619,13 +619,15 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
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 <version>');
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;
}
+41
View File
@@ -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
+2
View File
@@ -194,6 +194,8 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): 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:
+8 -1
View File
@@ -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;
}
@@ -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<string, string>) => {
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}
/>
</TabsContent>
<TabsContent
@@ -38,6 +38,8 @@ interface ModelConfigTabProps {
onRemoveAccount: (accountId: string) => 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}
+5 -1
View File
@@ -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',
+22 -1
View File
@@ -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<string> {
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,
@@ -13,6 +13,7 @@ export function useProxyConfig() {
const [editedHost, setEditedHost] = useState<string | null>(null);
const [editedPort, setEditedPort] = useState<string | null>(null);
const [editedAuthToken, setEditedAuthToken] = useState<string | null>(null);
const [editedManagementKey, setEditedManagementKey] = useState<string | null>(null);
const [editedLocalPort, setEditedLocalPort] = useState<string | null>(null);
const fetchConfig = useCallback(async () => {
@@ -108,6 +109,8 @@ export function useProxyConfig() {
setEditedPort,
editedAuthToken,
setEditedAuthToken,
editedManagementKey,
setEditedManagementKey,
editedLocalPort,
setEditedLocalPort,
fetchConfig,
@@ -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}
/>
@@ -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<CliproxyServerConfig>) => 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({
</div>
</div>
{/* Auth Token */}
{/* Auth Token (API Key) */}
<div className="space-y-1">
<label className="text-sm text-muted-foreground">Auth Token (optional)</label>
<label className="text-sm text-muted-foreground">API Key (optional)</label>
<Input
type="password"
value={displayAuthToken}
onChange={(e) => setEditedAuthToken(e.target.value)}
onBlur={onSaveAuthToken}
placeholder="Bearer token for authentication"
placeholder="For /v1/* API endpoints"
className="font-mono"
disabled={saving}
/>
<p className="text-xs text-muted-foreground">
Used for API requests to /v1/chat/completions
</p>
</div>
{/* Management Key */}
<div className="space-y-1">
<label className="text-sm text-muted-foreground">Management Key (optional)</label>
<Input
type="password"
value={displayManagementKey}
onChange={(e) => setEditedManagementKey(e.target.value)}
onBlur={onSaveManagementKey}
placeholder="For /v0/management/* endpoints"
className="font-mono"
disabled={saving}
/>
<p className="text-xs text-muted-foreground">
Used for dashboard management APIs. Falls back to API Key if not set.
</p>
</div>
{/* Test Connection */}