mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 00:17:47 +00:00
Merge pull request #763 from kaitranntt/dev
feat: ship provider bridge and marketplace updates
This commit is contained in:
@@ -80,7 +80,8 @@ The dashboard provides visual management for all account types:
|
||||
|
||||
- **Claude Accounts**: Isolation-first by default (work, personal, client), with explicit shared context opt-in
|
||||
- **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot
|
||||
- **API Profiles**: Configure GLM, Kimi with your keys
|
||||
- **AI Providers**: Configure Gemini, Codex, Claude, Vertex, and OpenAI-compatible API keys under `CLIProxy -> AI Providers`
|
||||
- **API Profiles**: Configure GLM, Kimi, OpenRouter, and other Anthropic-compatible APIs as CCS-native profiles
|
||||
- **Factory Droid**: Track Droid install location and BYOK settings health
|
||||
- **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations)
|
||||
- **Health Monitor**: Real-time status across all profiles
|
||||
@@ -148,6 +149,8 @@ The dashboard provides visual management for all account types:
|
||||
|
||||
> **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`.
|
||||
|
||||
> **AI Providers dashboard:** Configure CLIProxy-managed API key families at `ccs config` -> `CLIProxy` -> `AI Providers`. Use `API Profiles` only for CCS-native Anthropic-compatible profiles.
|
||||
|
||||
**Powered by:**
|
||||
- [CLIProxyAPIPlus](https://github.com/router-for-me/CLIProxyAPIPlus) - Extended OAuth proxy with Kiro ([@fuko2935](https://github.com/fuko2935), [@Ravens2121](https://github.com/Ravens2121)) and Copilot ([@em4go](https://github.com/em4go)) support
|
||||
- [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) - Core OAuth proxy for Gemini, Codex, Antigravity
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CCS Codebase Summary
|
||||
|
||||
Last Updated: 2026-03-17
|
||||
Last Updated: 2026-03-18
|
||||
|
||||
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, and account-context validation hardening.
|
||||
|
||||
@@ -222,6 +222,13 @@ src/
|
||||
- API route rejects `context_group`/`continuity_mode` when mode is not `shared`
|
||||
- registry normalization drops malformed persisted `context_group` values
|
||||
|
||||
### Shared Plugin Layout
|
||||
|
||||
- Shared payload owner: `src/management/shared-manager.ts`.
|
||||
- Profile entry point: `src/management/instance-manager.ts`.
|
||||
- `plugins/marketplaces/`, `plugins/cache/`, and `installed_plugins.json` stay shared through the `~/.ccs/shared/` topology.
|
||||
- `known_marketplaces.json` is now instance-local under `~/.ccs/instances/<profile>/plugins/` so Claude Code validates `installLocation` against the active `CLAUDE_CONFIG_DIR` instead of a last-writer-wins shared file.
|
||||
|
||||
### Target Adapter Module
|
||||
|
||||
The targets module provides an extensible interface for dispatching profiles to different CLI implementations.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CCS Product Development Requirements (PDR)
|
||||
|
||||
Last Updated: 2026-02-04
|
||||
Last Updated: 2026-03-19
|
||||
|
||||
## Product Overview
|
||||
|
||||
@@ -32,10 +32,11 @@ CCS provides:
|
||||
|
||||
1. **Multi-Account Claude**: Isolated instances via `CLAUDE_CONFIG_DIR`
|
||||
2. **OAuth Providers**: Zero-config Gemini, Codex, Antigravity, Copilot, Kiro (ghcp) integration
|
||||
3. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API
|
||||
4. **Visual Dashboard**: React SPA for configuration management
|
||||
5. **Automatic WebSearch**: MCP fallback for third-party providers
|
||||
6. **Usage Analytics**: Token tracking, cost analysis, model breakdown
|
||||
3. **AI Providers**: Dedicated CLIProxy dashboard for Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key families
|
||||
4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API
|
||||
5. **Visual Dashboard**: React SPA for configuration management
|
||||
6. **Automatic WebSearch**: MCP fallback for third-party providers
|
||||
7. **Usage Analytics**: Token tracking, cost analysis, model breakdown
|
||||
|
||||
---
|
||||
|
||||
@@ -74,6 +75,11 @@ CCS provides:
|
||||
- Model mapping and configuration
|
||||
- OpenRouter integration with 300+ models
|
||||
|
||||
### FR-004A: CLIProxy AI Provider Management
|
||||
- Configure CLIProxy-managed Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key entries
|
||||
- Keep provider authoring separate from CCS API Profile creation
|
||||
- Support local config editing and remote CLIProxy management parity where available
|
||||
|
||||
### FR-005: Dashboard UI
|
||||
- Visual profile management
|
||||
- Real-time health monitoring
|
||||
@@ -332,5 +338,5 @@ CCS provides:
|
||||
|
||||
- [Codebase Summary](./codebase-summary.md) - Technical structure
|
||||
- [Code Standards](./code-standards.md) - Development conventions
|
||||
- [System Architecture](./system-architecture.md) - Architecture diagrams
|
||||
- [System Architecture](./system-architecture/index.md) - Architecture diagrams
|
||||
- [Project Roadmap](./project-roadmap.md) - Development phases and GitHub issues
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CCS Project Roadmap
|
||||
|
||||
Last Updated: 2026-03-17
|
||||
Last Updated: 2026-03-19
|
||||
|
||||
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
|
||||
|
||||
@@ -41,6 +41,8 @@ All major modularization work is complete. The codebase evolved from monolithic
|
||||
|
||||
### Recent Fixes
|
||||
|
||||
- **2026-03-19**: **#649** CCS splits CLIProxy provider-key authoring into a dedicated `CLIProxy -> AI Providers` dashboard route. `/cliproxy` now stays focused on OAuth accounts and variants, `/cliproxy/ai-providers` owns Gemini/Codex/Claude/Vertex/OpenAI-compatible key management, and `/providers` stays reserved for CCS-native API Profiles.
|
||||
- **2026-03-18**: **#755** Marketplace refresh no longer reuses one shared `known_marketplaces.json` across isolated instances. CCS now keeps marketplace payload directories shared while reconciling per-instance marketplace metadata so Claude Code validation succeeds for alternating or concurrent profiles, including Windows copy fallback.
|
||||
- **2026-03-17**: Deprecated user-facing GLMT discovery across CLI help, completions, presets, and docs. Existing `glmt` profiles now run through a compatibility path that normalizes legacy proxy settings to the direct GLM endpoint.
|
||||
- **#748**: API profile creation now keeps provider selection compact by collapsing advanced presets behind an explicit toggle, shrinking chooser cards so the form fields stay visually primary, and giving `llama.cpp` a dedicated provider logo.
|
||||
- **#744**: API profile creation now keeps featured providers in a horizontal rail with scroll fallback, moves Anthropic Direct API to the end, reuses the shared Claude logo, and separates the custom-endpoint entry point from advanced template discovery.
|
||||
@@ -239,6 +241,6 @@ The check mode supports a maintainability regression gate that blocks increases
|
||||
|
||||
- [Codebase Summary](./codebase-summary.md) - Current structure
|
||||
- [Code Standards](./code-standards.md) - Patterns and conventions
|
||||
- [System Architecture](./system-architecture.md) - Architecture diagrams
|
||||
- [System Architecture](./system-architecture/index.md) - Architecture diagrams
|
||||
- [Hardening Debt Burndown Tracker](./hardening-debt-burndown.md) - Legacy shim + sync-fs debt tracking
|
||||
- [CLAUDE.md](../CLAUDE.md) - AI development guidance
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CCS System Architecture
|
||||
|
||||
Last Updated: 2026-03-02
|
||||
Last Updated: 2026-03-18
|
||||
|
||||
High-level architecture overview for the CCS (Claude Code Switch) system.
|
||||
|
||||
@@ -233,12 +233,27 @@ For detailed provider flows (CLIProxyAPI, legacy GLMT compatibility, quota manag
|
||||
+---> commands/ # Claude Code commands
|
||||
+---> skills/ # Custom skills
|
||||
+---> agents/ # Agent configurations
|
||||
+---> plugins/
|
||||
|
|
||||
+---> cache/ # Shared plugin payload/cache data
|
||||
+---> marketplaces/ # Shared marketplace payload directories
|
||||
+---> installed_plugins.json
|
||||
|
||||
~/.ccs/instances/<profile>/
|
||||
|
|
||||
+---> plugins/
|
||||
|
|
||||
+---> known_marketplaces.json # Instance-local registry for active CLAUDE_CONFIG_DIR validation
|
||||
|
||||
~/.factory/ (Droid CLI)
|
||||
|
|
||||
+---> settings.json # Droid config (custom models)
|
||||
```
|
||||
|
||||
Plugin ownership note:
|
||||
- `commands/`, `skills/`, `agents/`, and `settings.json` remain shared through the existing symlink/copy flow.
|
||||
- Marketplace payload directories stay shared, but `known_marketplaces.json` is reconciled per instance so Claude Code can validate `installLocation` against that instance's `CLAUDE_CONFIG_DIR/plugins/marketplaces`.
|
||||
|
||||
### Config Loading Order
|
||||
|
||||
```
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.55.0",
|
||||
"version": "7.55.0-dev.3",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -72,7 +72,7 @@
|
||||
"test": "bun run build && bun run test:all",
|
||||
"test:ci": "bun run test:all",
|
||||
"test:all": "bun test tests/unit tests/integration tests/npm",
|
||||
"test:unit": "bun test tests/unit/",
|
||||
"test:unit": "bun test tests/unit",
|
||||
"test:npm": "bun test tests/npm/",
|
||||
"test:native": "bash tests/native/unix/edge-cases.sh",
|
||||
"test:e2e": "bun test tests/e2e/ --bail --timeout 60000",
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir, loadConfigSafe } from '../../utils/config-manager';
|
||||
import { buildProxyUrl, getProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
||||
import { getEffectiveApiKey } from '../../cliproxy/auth-token-manager';
|
||||
import { getModelMappingFromConfig } from '../../cliproxy/base-config-loader';
|
||||
import {
|
||||
CLIPROXY_PROVIDER_IDS,
|
||||
getProviderDescription,
|
||||
getProviderDisplayName,
|
||||
mapExternalProviderName,
|
||||
} from '../../cliproxy/provider-capabilities';
|
||||
import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer';
|
||||
import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import type { Settings } from '../../types/config';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import type {
|
||||
CliproxyBridgeMetadata,
|
||||
CliproxyBridgeProviderInfo,
|
||||
ModelMapping,
|
||||
ResolvedCliproxyBridgeProfile,
|
||||
} from './profile-types';
|
||||
|
||||
const DEFAULT_PROFILE_SUFFIX = '-api';
|
||||
|
||||
function normalizeBridgeUrl(value: string): string {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
const hostname = parsed.hostname === 'localhost' ? '127.0.0.1' : parsed.hostname;
|
||||
const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
|
||||
return `${parsed.protocol}//${hostname}:${parsed.port}${pathname}`;
|
||||
} catch {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProviderFromBaseUrl(baseUrl: unknown): CLIProxyProvider | null {
|
||||
if (typeof baseUrl !== 'string' || baseUrl.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(baseUrl);
|
||||
const extracted = extractProviderFromPathname(parsed.pathname);
|
||||
return extracted ? mapExternalProviderName(extracted) : null;
|
||||
} catch {
|
||||
const extracted = extractProviderFromPathname(baseUrl);
|
||||
return extracted ? mapExternalProviderName(extracted) : null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasConfiguredProfile(name: string): boolean {
|
||||
if (isUnifiedMode()) {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return name in config.profiles;
|
||||
}
|
||||
|
||||
const config = loadConfigSafe();
|
||||
return name in config.profiles;
|
||||
}
|
||||
|
||||
function hasSettingsFile(name: string): boolean {
|
||||
return fs.existsSync(path.join(getCcsDir(), `${name}.settings.json`));
|
||||
}
|
||||
|
||||
export function getDefaultCliproxyBridgeName(provider: CLIProxyProvider): string {
|
||||
return `${provider}${DEFAULT_PROFILE_SUFFIX}`;
|
||||
}
|
||||
|
||||
export function suggestCliproxyBridgeName(provider: CLIProxyProvider): string {
|
||||
const baseName = getDefaultCliproxyBridgeName(provider);
|
||||
if (!hasConfiguredProfile(baseName) && !hasSettingsFile(baseName)) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
for (let index = 2; index < 1000; index += 1) {
|
||||
const candidate = `${baseName}-${index}`;
|
||||
if (!hasConfiguredProfile(candidate) && !hasSettingsFile(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return `${baseName}-${Date.now()}`;
|
||||
}
|
||||
|
||||
function resolveBridgeModelMapping(provider: CLIProxyProvider): ModelMapping {
|
||||
const mapping = getModelMappingFromConfig(provider);
|
||||
return {
|
||||
default: mapping.defaultModel,
|
||||
opus: mapping.opusModel || mapping.defaultModel,
|
||||
sonnet: mapping.sonnetModel || mapping.defaultModel,
|
||||
haiku: mapping.haikuModel || mapping.defaultModel,
|
||||
};
|
||||
}
|
||||
|
||||
export function listCliproxyBridgeProviders(): CliproxyBridgeProviderInfo[] {
|
||||
return CLIPROXY_PROVIDER_IDS.map((provider) => ({
|
||||
provider,
|
||||
displayName: getProviderDisplayName(provider),
|
||||
description: getProviderDescription(provider),
|
||||
defaultProfileName: getDefaultCliproxyBridgeName(provider),
|
||||
routePath: `/api/provider/${provider}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveCliproxyBridgeProfile(
|
||||
provider: CLIProxyProvider,
|
||||
options: {
|
||||
name?: string;
|
||||
target?: TargetType;
|
||||
} = {}
|
||||
): ResolvedCliproxyBridgeProfile {
|
||||
const target = getProxyTarget();
|
||||
const profileName = options.name?.trim() || suggestCliproxyBridgeName(provider);
|
||||
const baseUrl = buildProxyUrl(target, `/api/provider/${provider}`);
|
||||
const apiKey = target.authToken ?? getEffectiveApiKey();
|
||||
|
||||
return {
|
||||
name: profileName,
|
||||
provider,
|
||||
providerDisplayName: getProviderDisplayName(provider),
|
||||
baseUrl,
|
||||
apiKey,
|
||||
models: resolveBridgeModelMapping(provider),
|
||||
target: options.target || 'claude',
|
||||
routePath: `/api/provider/${provider}`,
|
||||
source: target.isRemote ? 'remote' : 'local',
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCliproxyBridgeMetadata(
|
||||
settings: Pick<Settings, 'env'> | null | undefined
|
||||
): CliproxyBridgeMetadata | null {
|
||||
const provider = resolveProviderFromBaseUrl(settings?.env?.ANTHROPIC_BASE_URL);
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolved = resolveCliproxyBridgeProfile(provider);
|
||||
const actualBaseUrl = settings?.env?.ANTHROPIC_BASE_URL?.trim() || '';
|
||||
const actualAuthToken = settings?.env?.ANTHROPIC_AUTH_TOKEN?.trim() || '';
|
||||
|
||||
return {
|
||||
provider,
|
||||
providerDisplayName: resolved.providerDisplayName,
|
||||
routePath: resolved.routePath,
|
||||
currentBaseUrl: resolved.baseUrl,
|
||||
source: resolved.source,
|
||||
usesCurrentTarget: normalizeBridgeUrl(actualBaseUrl) === normalizeBridgeUrl(resolved.baseUrl),
|
||||
usesCurrentAuthToken: actualAuthToken.length > 0 && actualAuthToken === resolved.apiKey,
|
||||
};
|
||||
}
|
||||
@@ -14,8 +14,12 @@ export {
|
||||
type CliproxyVariantInfo,
|
||||
type ApiListResult,
|
||||
type CreateApiProfileResult,
|
||||
type CreateCliproxyBridgeProfileResult,
|
||||
type RemoveApiProfileResult,
|
||||
type UpdateApiProfileTargetResult,
|
||||
type CliproxyBridgeProviderInfo,
|
||||
type CliproxyBridgeMetadata,
|
||||
type ResolvedCliproxyBridgeProfile,
|
||||
type ProfileValidationIssue,
|
||||
type ProfileValidationSummary,
|
||||
type ApiProfileOrphanCandidate,
|
||||
@@ -38,6 +42,14 @@ export {
|
||||
|
||||
// Profile write operations
|
||||
export { createApiProfile, removeApiProfile, updateApiProfileTarget } from './profile-writer';
|
||||
export { createCliproxyBridgeProfile } from './profile-writer';
|
||||
export {
|
||||
getDefaultCliproxyBridgeName,
|
||||
listCliproxyBridgeProviders,
|
||||
resolveCliproxyBridgeMetadata,
|
||||
resolveCliproxyBridgeProfile,
|
||||
suggestCliproxyBridgeName,
|
||||
} from './cliproxy-profile-bridge';
|
||||
|
||||
// Lifecycle validation and operations
|
||||
export { validateApiProfileSettingsPayload } from './profile-lifecycle-validation';
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir, loadConfigSafe } from '../../utils/config-manager';
|
||||
import { loadConfigSafe } from '../../utils/config-manager';
|
||||
import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import type { Settings } from '../../types/config';
|
||||
import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types';
|
||||
import { resolveCliproxyBridgeMetadata } from './cliproxy-profile-bridge';
|
||||
|
||||
const VALID_TARGETS: ReadonlySet<TargetType> = new Set<TargetType>(['claude', 'droid']);
|
||||
|
||||
@@ -37,23 +39,43 @@ export function apiProfileExists(name: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings file from a config reference such as ~/.ccs/name.settings.json.
|
||||
*/
|
||||
function loadProfileSettings(settingsReference: string | undefined): Settings | null {
|
||||
if (!settingsReference || settingsReference === 'config.yaml') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const settingsPath = expandPath(settingsReference);
|
||||
if (!fs.existsSync(settingsPath)) return null;
|
||||
return JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as Settings;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isConfiguredFromSettings(settings: Settings | null): boolean {
|
||||
const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || settings?.env?.ANTHROPIC_API_KEY || '';
|
||||
return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
|
||||
}
|
||||
|
||||
function resolveProfileSettingsReference(name: string): string | undefined {
|
||||
if (isUnifiedMode()) {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return config.profiles[name]?.settings;
|
||||
}
|
||||
|
||||
const config = loadConfigSafe();
|
||||
return config.profiles[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if API profile has real API key (not placeholder)
|
||||
*/
|
||||
export function isApiProfileConfigured(apiName: string): boolean {
|
||||
try {
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${apiName}.settings.json`);
|
||||
|
||||
// Check settings.json file for API key
|
||||
if (!fs.existsSync(settingsPath)) return false;
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || settings?.env?.ANTHROPIC_API_KEY || '';
|
||||
return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return isConfiguredFromSettings(loadProfileSettings(resolveProfileSettingsReference(apiName)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,12 +95,15 @@ export function listApiProfiles(): ApiListResult {
|
||||
if (name === 'default' && profile.settings?.includes('.claude/settings.json')) {
|
||||
continue;
|
||||
}
|
||||
const settingsPath = profile.settings || 'config.yaml';
|
||||
const settings = loadProfileSettings(settingsPath);
|
||||
profiles.push({
|
||||
name,
|
||||
settingsPath: profile.settings || 'config.yaml',
|
||||
isConfigured: isApiProfileConfigured(name),
|
||||
settingsPath,
|
||||
isConfigured: isConfiguredFromSettings(settings),
|
||||
configSource: 'unified',
|
||||
target: sanitizeTarget(profile.target),
|
||||
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
|
||||
});
|
||||
}
|
||||
// CLIProxy variants
|
||||
@@ -103,12 +128,14 @@ export function listApiProfiles(): ApiListResult {
|
||||
if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) {
|
||||
continue;
|
||||
}
|
||||
const settings = loadProfileSettings(settingsPath as string);
|
||||
profiles.push({
|
||||
name,
|
||||
settingsPath: settingsPath as string,
|
||||
isConfigured: isApiProfileConfigured(name),
|
||||
isConfigured: isConfiguredFromSettings(settings),
|
||||
configSource: 'legacy',
|
||||
target: sanitizeTarget(legacyTargetMap?.[name]),
|
||||
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
|
||||
});
|
||||
}
|
||||
// CLIProxy variants
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
|
||||
/** Model mapping for API profiles */
|
||||
export interface ModelMapping {
|
||||
@@ -21,6 +22,7 @@ export interface ApiProfileInfo {
|
||||
isConfigured: boolean;
|
||||
configSource: 'unified' | 'legacy';
|
||||
target: TargetType;
|
||||
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
||||
}
|
||||
|
||||
/** CLIProxy variant info */
|
||||
@@ -44,6 +46,43 @@ export interface CreateApiProfileResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CliproxyBridgeProviderInfo {
|
||||
provider: CLIProxyProvider;
|
||||
displayName: string;
|
||||
description: string;
|
||||
defaultProfileName: string;
|
||||
routePath: string;
|
||||
}
|
||||
|
||||
export interface CliproxyBridgeMetadata {
|
||||
provider: CLIProxyProvider;
|
||||
providerDisplayName: string;
|
||||
routePath: string;
|
||||
currentBaseUrl: string;
|
||||
source: 'local' | 'remote';
|
||||
usesCurrentTarget: boolean;
|
||||
usesCurrentAuthToken: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedCliproxyBridgeProfile {
|
||||
name: string;
|
||||
provider: CLIProxyProvider;
|
||||
providerDisplayName: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
models: ModelMapping;
|
||||
target: TargetType;
|
||||
routePath: string;
|
||||
source: 'local' | 'remote';
|
||||
}
|
||||
|
||||
export interface CreateCliproxyBridgeProfileResult extends CreateApiProfileResult {
|
||||
name?: string;
|
||||
provider?: CLIProxyProvider;
|
||||
target?: TargetType;
|
||||
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
||||
}
|
||||
|
||||
/** Result from remove operation */
|
||||
export interface RemoveApiProfileResult {
|
||||
success: boolean;
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import { validateApiName } from './validation-service';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import { resolveDroidProvider } from '../../targets/droid-provider';
|
||||
import { isReservedName } from '../../config/reserved-names';
|
||||
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
||||
import {
|
||||
extractProviderFromPathname,
|
||||
@@ -23,9 +25,15 @@ import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import type {
|
||||
ModelMapping,
|
||||
CreateApiProfileResult,
|
||||
CreateCliproxyBridgeProfileResult,
|
||||
RemoveApiProfileResult,
|
||||
UpdateApiProfileTargetResult,
|
||||
} from './profile-types';
|
||||
import { apiProfileExists } from './profile-reader';
|
||||
import {
|
||||
resolveCliproxyBridgeMetadata,
|
||||
resolveCliproxyBridgeProfile,
|
||||
} from './cliproxy-profile-bridge';
|
||||
|
||||
/** Check if URL is an OpenRouter endpoint */
|
||||
function isOpenRouterUrl(baseUrl: string): boolean {
|
||||
@@ -233,6 +241,63 @@ export function createApiProfile(
|
||||
}
|
||||
}
|
||||
|
||||
export function createCliproxyBridgeProfile(
|
||||
provider: CLIProxyProvider,
|
||||
options: {
|
||||
name?: string;
|
||||
force?: boolean;
|
||||
target?: TargetType;
|
||||
} = {}
|
||||
): CreateCliproxyBridgeProfileResult {
|
||||
const providedName = options.name?.trim();
|
||||
if (providedName) {
|
||||
const nameError = validateApiName(providedName);
|
||||
if (nameError) {
|
||||
return { success: false, settingsFile: '', error: nameError };
|
||||
}
|
||||
if (isReservedName(providedName)) {
|
||||
return {
|
||||
success: false,
|
||||
settingsFile: '',
|
||||
error: `Profile name '${providedName}' is reserved`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = resolveCliproxyBridgeProfile(provider, options);
|
||||
const settingsPath = path.join(getCcsDir(), `${resolved.name}.settings.json`);
|
||||
if (!options.force && (apiProfileExists(resolved.name) || fs.existsSync(settingsPath))) {
|
||||
return {
|
||||
success: false,
|
||||
settingsFile: '',
|
||||
error: `Profile already exists: ${resolved.name}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = createApiProfile(
|
||||
resolved.name,
|
||||
resolved.baseUrl,
|
||||
resolved.apiKey,
|
||||
resolved.models,
|
||||
resolved.target,
|
||||
provider
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
name: resolved.name,
|
||||
provider,
|
||||
target: resolved.target,
|
||||
cliproxyBridge:
|
||||
resolveCliproxyBridgeMetadata({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: resolved.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: resolved.apiKey,
|
||||
},
|
||||
}) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update API profile target (claude/droid).
|
||||
* Persists to config.yaml in unified mode and config.json profile_targets in legacy mode.
|
||||
|
||||
@@ -163,7 +163,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
|
||||
if (!profileExistedBeforeCreate) {
|
||||
try {
|
||||
ctx.instanceMgr.deleteInstance(profileName);
|
||||
await ctx.instanceMgr.deleteInstance(profileName);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function handleRemove(ctx: CommandContext, args: string[]): Promise
|
||||
}
|
||||
|
||||
// Delete instance
|
||||
ctx.instanceMgr.deleteInstance(profileName);
|
||||
await ctx.instanceMgr.deleteInstance(profileName);
|
||||
|
||||
// Delete profile from appropriate config
|
||||
if (isUnifiedMode() && existsUnified) {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CLIProxyProvider, ProviderModelMapping } from './types';
|
||||
import type { CLIProxyProvider, ProviderModelMapping } from './types';
|
||||
|
||||
/** Base settings file structure */
|
||||
interface BaseSettings {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getDefaultAccount } from './account-manager';
|
||||
import { getProviderCatalog } from './model-catalog';
|
||||
import { fetchCodexQuota } from './quota-fetcher-codex';
|
||||
import { getCachedQuota, setCachedQuota } from './quota-response-cache';
|
||||
import type { CodexQuotaResult } from './quota-types';
|
||||
@@ -12,6 +13,9 @@ const FREE_SAFE_FAST_MODEL = 'gpt-5-codex-mini';
|
||||
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
|
||||
const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i;
|
||||
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
||||
const KNOWN_CODEX_MODELS = new Set(
|
||||
(getProviderCatalog('codex')?.models ?? []).map((model) => model.id.toLowerCase())
|
||||
);
|
||||
|
||||
const FREE_PLAN_FALLBACKS = new Map<string, string>([
|
||||
['gpt-5.3-codex', FREE_SAFE_DEFAULT_MODEL],
|
||||
@@ -19,7 +23,29 @@ const FREE_PLAN_FALLBACKS = new Map<string, string>([
|
||||
['gpt-5.4', FREE_SAFE_DEFAULT_MODEL],
|
||||
]);
|
||||
|
||||
function normalizeCodexModelId(model: string): string {
|
||||
export interface CodexRuntimeFallbackModelMap {
|
||||
defaultModel?: string;
|
||||
opusModel?: string;
|
||||
sonnetModel?: string;
|
||||
haikuModel?: string;
|
||||
}
|
||||
|
||||
export interface CodexUnsupportedModelError {
|
||||
message: string | null;
|
||||
code: 'model_not_supported';
|
||||
param: string | null;
|
||||
type: string | null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isKnownCodexModel(model: string): boolean {
|
||||
return KNOWN_CODEX_MODELS.has(model);
|
||||
}
|
||||
|
||||
export function normalizeCodexModelId(model: string): string {
|
||||
return model
|
||||
.trim()
|
||||
.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '')
|
||||
@@ -37,6 +63,74 @@ export function getFreePlanFallbackCodexModel(model: string): string | null {
|
||||
return FREE_PLAN_FALLBACKS.get(normalizeCodexModelId(model)) ?? null;
|
||||
}
|
||||
|
||||
export function parseCodexUnsupportedModelError(
|
||||
statusCode: number | undefined,
|
||||
responseBody: string
|
||||
): CodexUnsupportedModelError | null {
|
||||
if (statusCode !== 400 || !responseBody.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(responseBody);
|
||||
if (
|
||||
!isRecord(parsed) ||
|
||||
!isRecord(parsed.error) ||
|
||||
parsed.error.code !== 'model_not_supported'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
message: typeof parsed.error.message === 'string' ? parsed.error.message : null,
|
||||
code: 'model_not_supported',
|
||||
param: typeof parsed.error.param === 'string' ? parsed.error.param : null,
|
||||
type: typeof parsed.error.type === 'string' ? parsed.error.type : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRuntimeCodexFallbackModel(options: {
|
||||
requestedModel: string;
|
||||
modelMap: CodexRuntimeFallbackModelMap;
|
||||
excludeModels?: string[];
|
||||
}): string | null {
|
||||
const requestedModel = normalizeCodexModelId(options.requestedModel);
|
||||
if (!requestedModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const excludedModels = new Set(
|
||||
(options.excludeModels ?? []).map((model) => normalizeCodexModelId(model)).filter(Boolean)
|
||||
);
|
||||
const candidates = [
|
||||
options.modelMap.defaultModel,
|
||||
getFreePlanFallbackCodexModel(requestedModel),
|
||||
options.modelMap.opusModel,
|
||||
options.modelMap.sonnetModel,
|
||||
options.modelMap.haikuModel,
|
||||
getDefaultCodexModel(),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue;
|
||||
const normalizedCandidate = normalizeCodexModelId(candidate);
|
||||
if (
|
||||
!normalizedCandidate ||
|
||||
normalizedCandidate === requestedModel ||
|
||||
excludedModels.has(normalizedCandidate) ||
|
||||
!isKnownCodexModel(normalizedCandidate)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return normalizedCandidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function reconcileCodexModelForActivePlan(options: {
|
||||
settingsPath: string;
|
||||
currentModel: string | undefined;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import { URL } from 'url';
|
||||
import {
|
||||
normalizeCodexModelId,
|
||||
parseCodexUnsupportedModelError,
|
||||
resolveRuntimeCodexFallbackModel,
|
||||
} from './codex-plan-compatibility';
|
||||
import { getModelMaxLevel } from './model-catalog';
|
||||
|
||||
export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh';
|
||||
@@ -29,6 +34,14 @@ export interface CodexReasoningProxyConfig {
|
||||
disableEffort?: boolean;
|
||||
}
|
||||
|
||||
interface ForwardJsonContext {
|
||||
requestPath: string;
|
||||
requestedModel: string | null;
|
||||
attemptedUpstreamModel: string | null;
|
||||
effort: CodexReasoningEffort | null;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
||||
|
||||
function stripExtendedContextSuffix(model: string): string {
|
||||
@@ -170,6 +183,7 @@ export class CodexReasoningProxy {
|
||||
> &
|
||||
Pick<CodexReasoningProxyConfig, 'modelMap' | 'stripPathPrefix'>;
|
||||
private readonly modelEffort: Map<string, CodexReasoningEffort>;
|
||||
private readonly sessionFallbackByModel = new Map<string, string>();
|
||||
private readonly recent: Array<{
|
||||
at: string;
|
||||
model: string | null;
|
||||
@@ -193,6 +207,41 @@ export class CodexReasoningProxy {
|
||||
this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort);
|
||||
}
|
||||
|
||||
private getRememberedFallback(model: string | null): string | null {
|
||||
if (!model) return null;
|
||||
return this.sessionFallbackByModel.get(normalizeCodexModelId(model)) ?? null;
|
||||
}
|
||||
|
||||
private rememberFallback(requestedModel: string, fallbackModel: string): void {
|
||||
const normalizedRequestedModel = normalizeCodexModelId(requestedModel);
|
||||
const normalizedFallbackModel = normalizeCodexModelId(fallbackModel);
|
||||
if (!normalizedRequestedModel || !normalizedFallbackModel) return;
|
||||
this.sessionFallbackByModel.set(normalizedRequestedModel, normalizedFallbackModel);
|
||||
}
|
||||
|
||||
private buildForwardBody(
|
||||
body: unknown,
|
||||
upstreamModel: string | null,
|
||||
effort: CodexReasoningEffort | null
|
||||
): unknown {
|
||||
const withUpstreamModel =
|
||||
upstreamModel && isRecord(body) ? { ...body, model: upstreamModel } : body;
|
||||
if (this.config.disableEffort || !effort) {
|
||||
return withUpstreamModel;
|
||||
}
|
||||
return injectReasoningEffortIntoBody(withUpstreamModel, effort);
|
||||
}
|
||||
|
||||
private sendBufferedResponse(
|
||||
clientRes: http.ServerResponse,
|
||||
statusCode: number,
|
||||
headers: http.IncomingHttpHeaders,
|
||||
responseBody: string
|
||||
): void {
|
||||
clientRes.writeHead(statusCode, headers);
|
||||
clientRes.end(responseBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* Treat trailing "-high/-medium/-xhigh" as an effort alias only for known codex models.
|
||||
* Prevents stripping legitimate upstream model IDs that happen to end with those tokens.
|
||||
@@ -365,41 +414,48 @@ export class CodexReasoningProxy {
|
||||
? stripExtendedContextSuffix(originalModel)
|
||||
: null;
|
||||
|
||||
// When effort is disabled (thinking mode: off), strip model suffix but don't inject reasoning
|
||||
if (this.config.disableEffort) {
|
||||
const suffixParsed = this.parseEffortAlias(normalizedRequestModel);
|
||||
const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
|
||||
const forwarded =
|
||||
upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed;
|
||||
|
||||
this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`);
|
||||
await this.forwardJson(req, res, fullUpstreamUrl, forwarded);
|
||||
return;
|
||||
}
|
||||
|
||||
// Support "model aliases" like `gpt-5.2-codex-xhigh` by translating to:
|
||||
// - upstream model: `gpt-5.2-codex`
|
||||
// - reasoning.effort: `xhigh`
|
||||
//
|
||||
// This allows tier→effort mapping without inventing upstream model IDs.
|
||||
const suffixParsed = this.parseEffortAlias(normalizedRequestModel);
|
||||
const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
|
||||
const effort =
|
||||
const requestedUpstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
|
||||
const rememberedFallback = this.getRememberedFallback(requestedUpstreamModel);
|
||||
const upstreamModel = rememberedFallback ?? requestedUpstreamModel;
|
||||
const requestedEffort =
|
||||
suffixParsed?.effort ??
|
||||
getEffortForModel(normalizedRequestModel, this.modelEffort, this.config.defaultEffort);
|
||||
const effort =
|
||||
!this.config.disableEffort && upstreamModel
|
||||
? capEffortAtModelMax(upstreamModel, requestedEffort)
|
||||
: !this.config.disableEffort
|
||||
? requestedEffort
|
||||
: null;
|
||||
const rewritten = this.buildForwardBody(parsed, upstreamModel, effort);
|
||||
|
||||
const withUpstreamModel =
|
||||
upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed;
|
||||
const rewritten = injectReasoningEffortIntoBody(withUpstreamModel, effort);
|
||||
if (effort) {
|
||||
this.record(originalModel, upstreamModel, effort, requestPath);
|
||||
this.trace(
|
||||
`[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${
|
||||
upstreamModel ?? 'null'
|
||||
} effort=${effort} path=${requestPath}`
|
||||
);
|
||||
} else {
|
||||
this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`);
|
||||
}
|
||||
|
||||
this.record(originalModel, upstreamModel, effort, requestPath);
|
||||
this.trace(
|
||||
`[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${
|
||||
upstreamModel ?? 'null'
|
||||
} effort=${effort} path=${requestPath}`
|
||||
);
|
||||
if (rememberedFallback && rememberedFallback !== requestedUpstreamModel) {
|
||||
this.log(`Using remembered fallback ${requestedUpstreamModel} -> ${rememberedFallback}`);
|
||||
}
|
||||
|
||||
await this.forwardJson(req, res, fullUpstreamUrl, rewritten);
|
||||
await this.forwardJson(req, res, fullUpstreamUrl, rewritten, {
|
||||
requestPath,
|
||||
requestedModel: requestedUpstreamModel,
|
||||
attemptedUpstreamModel: upstreamModel,
|
||||
effort,
|
||||
retryCount: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
if (!res.headersSent) {
|
||||
@@ -487,8 +543,9 @@ export class CodexReasoningProxy {
|
||||
originalReq: http.IncomingMessage,
|
||||
clientRes: http.ServerResponse,
|
||||
upstreamUrl: URL,
|
||||
body: unknown
|
||||
): Promise<void> {
|
||||
body: unknown,
|
||||
context: ForwardJsonContext
|
||||
): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bodyString = JSON.stringify(body);
|
||||
const requestFn = this.getRequestFn(upstreamUrl);
|
||||
@@ -503,9 +560,73 @@ export class CodexReasoningProxy {
|
||||
headers: this.buildForwardHeaders(originalReq.headers, bodyString),
|
||||
},
|
||||
(upstreamRes) => {
|
||||
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
||||
upstreamRes.pipe(clientRes);
|
||||
upstreamRes.on('end', () => resolve());
|
||||
const statusCode = upstreamRes.statusCode || 200;
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
clientRes.writeHead(statusCode, upstreamRes.headers);
|
||||
upstreamRes.pipe(clientRes);
|
||||
upstreamRes.on('end', () => resolve(statusCode));
|
||||
upstreamRes.on('error', reject);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
upstreamRes.on('end', async () => {
|
||||
try {
|
||||
const responseBody = Buffer.concat(chunks).toString('utf8');
|
||||
const unsupportedError =
|
||||
context.retryCount === 0
|
||||
? parseCodexUnsupportedModelError(statusCode, responseBody)
|
||||
: null;
|
||||
const fallbackModel =
|
||||
unsupportedError && context.requestedModel
|
||||
? resolveRuntimeCodexFallbackModel({
|
||||
requestedModel: context.requestedModel,
|
||||
modelMap: this.config.modelMap,
|
||||
excludeModels: context.attemptedUpstreamModel
|
||||
? [context.attemptedUpstreamModel]
|
||||
: undefined,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (unsupportedError && fallbackModel && context.requestedModel) {
|
||||
const retryEffort =
|
||||
!this.config.disableEffort && context.effort
|
||||
? capEffortAtModelMax(fallbackModel, context.effort)
|
||||
: null;
|
||||
const retryBody = this.buildForwardBody(body, fallbackModel, retryEffort);
|
||||
|
||||
this.log(
|
||||
`Upstream rejected model "${context.attemptedUpstreamModel}". Retrying ${context.requestPath} with "${fallbackModel}".`
|
||||
);
|
||||
|
||||
const retryStatusCode = await this.forwardJson(
|
||||
originalReq,
|
||||
clientRes,
|
||||
upstreamUrl,
|
||||
retryBody,
|
||||
{
|
||||
...context,
|
||||
attemptedUpstreamModel: fallbackModel,
|
||||
effort: retryEffort,
|
||||
retryCount: context.retryCount + 1,
|
||||
}
|
||||
);
|
||||
|
||||
if (retryStatusCode >= 200 && retryStatusCode < 300) {
|
||||
this.rememberFallback(context.requestedModel, fallbackModel);
|
||||
}
|
||||
|
||||
resolve(retryStatusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendBufferedResponse(clientRes, statusCode, upstreamRes.headers, responseBody);
|
||||
resolve(statusCode);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
upstreamRes.on('error', reject);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* Used by API routes, service layer, and config loader to avoid contract drift.
|
||||
*/
|
||||
|
||||
import { CLIPROXY_SUPPORTED_PROVIDERS, CompositeTierConfig } from '../config/unified-config-types';
|
||||
import { CLIPROXY_SUPPORTED_PROVIDERS } from '../config/unified-config-types';
|
||||
import type { CompositeTierConfig } from '../config/unified-config-types';
|
||||
import type { CLIProxyProvider } from './types';
|
||||
import { getDeniedModelIdReasonForProvider } from './model-id-normalizer';
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CLIProxyProvider, ProviderModelMapping } from '../types';
|
||||
import type { CLIProxyProvider, ProviderModelMapping } from '../types';
|
||||
import { getModelMappingFromConfig, getEnvVarsFromConfig } from '../base-config-loader';
|
||||
import { getGlobalEnvConfig } from '../../config/unified-config-loader';
|
||||
import { getEffectiveApiKey } from '../auth-token-manager';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import { warn } from '../../utils/ui';
|
||||
import { CompositeTierConfig } from '../../config/unified-config-types';
|
||||
import type { CompositeTierConfig } from '../../config/unified-config-types';
|
||||
import {
|
||||
validatePort,
|
||||
validateRemotePort,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* - Claude (Anthropic): Opt-in via --1m flag
|
||||
*/
|
||||
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
import { supportsExtendedContext } from '../model-catalog';
|
||||
import { warn } from '../../utils/ui';
|
||||
import {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CLIProxyProvider, ProviderConfig } from '../types';
|
||||
import type { CLIProxyProvider, ProviderConfig } from '../types';
|
||||
import { getProviderDisplayName } from '../provider-capabilities';
|
||||
import { getModelMappingFromConfig } from '../base-config-loader';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
import { CLIPROXY_DEFAULT_PORT } from './port-manager';
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* Manages thinking budget suffixes for CLIProxyAPIPlus
|
||||
*/
|
||||
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { ThinkingConfig, DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types';
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
import { DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types';
|
||||
import type { ThinkingConfig } from '../../config/unified-config-types';
|
||||
import { getThinkingConfig } from '../../config/unified-config-loader';
|
||||
import { supportsThinking } from '../model-catalog';
|
||||
import { isThinkingOffValue, validateThinking } from '../thinking-validator';
|
||||
|
||||
@@ -143,6 +143,23 @@ export {
|
||||
TOGETHER_TEMPLATE,
|
||||
} from './openai-compat-manager';
|
||||
|
||||
// AI provider management
|
||||
export type {
|
||||
AiProviderFamilyId,
|
||||
AiProviderFamilyState,
|
||||
AiProviderEntryView,
|
||||
ListAiProvidersResult,
|
||||
UpsertAiProviderEntryInput,
|
||||
} from './ai-providers';
|
||||
export {
|
||||
AI_PROVIDER_FAMILY_DEFINITIONS,
|
||||
AI_PROVIDER_FAMILY_IDS,
|
||||
listAiProviders,
|
||||
createAiProviderEntry,
|
||||
updateAiProviderEntry,
|
||||
deleteAiProviderEntry,
|
||||
} from './ai-providers';
|
||||
|
||||
// Service manager (background CLIProxy for dashboard)
|
||||
export type { ServiceStartResult } from './service-manager';
|
||||
export { ensureCliproxyService, stopCliproxyService, getServiceStatus } from './service-manager';
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
ManagementHealthStatus,
|
||||
ManagementApiErrorCode,
|
||||
ClaudeKey,
|
||||
GetClaudeKeysResponse,
|
||||
ClaudeKeyPatch,
|
||||
RemoteModelInfo,
|
||||
GetModelDefinitionsResponse,
|
||||
@@ -189,11 +188,7 @@ export class ManagementApiClient {
|
||||
* Get all claude-api-key entries from remote CLIProxy.
|
||||
*/
|
||||
async getClaudeKeys(): Promise<ClaudeKey[]> {
|
||||
const response = await this.request<GetClaudeKeysResponse>(
|
||||
'GET',
|
||||
'/v0/management/claude-api-key'
|
||||
);
|
||||
return response.data?.['claude-api-key'] ?? [];
|
||||
return this.getSection<ClaudeKey>('claude-api-key');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,7 +196,7 @@ export class ManagementApiClient {
|
||||
* This is an atomic operation - all entries are replaced at once.
|
||||
*/
|
||||
async putClaudeKeys(keys: ClaudeKey[]): Promise<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 ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Models are mapped to their internal names used by the proxy backend.
|
||||
*/
|
||||
|
||||
import { CLIProxyProvider } from './types';
|
||||
import type { CLIProxyProvider } from './types';
|
||||
import {
|
||||
isAntigravityProvider,
|
||||
migrateDeniedAntigravityModelAliases,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* model version formats (e.g., 4.6 vs 4-6).
|
||||
*/
|
||||
|
||||
import { CLIProxyProvider } from './types';
|
||||
import type { CLIProxyProvider } from './types';
|
||||
|
||||
/** Env vars that carry model identifiers. */
|
||||
export const MODEL_ENV_VAR_KEYS = [
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
* - Warns when model doesn't support thinking
|
||||
*/
|
||||
|
||||
import { getModelThinkingSupport, ThinkingSupport } from './model-catalog';
|
||||
import { CLIProxyProvider } from './types';
|
||||
import { getModelThinkingSupport } from './model-catalog';
|
||||
import type { ThinkingSupport } from './model-catalog';
|
||||
import type { CLIProxyProvider } from './types';
|
||||
|
||||
/**
|
||||
* Thinking budget bounds (used for validation across API and CLI)
|
||||
|
||||
@@ -162,11 +162,33 @@ export interface CLIProxyConfig {
|
||||
'api-key': string;
|
||||
'base-url'?: string;
|
||||
}>;
|
||||
'claude-api-key'?: Array<{
|
||||
'api-key': string;
|
||||
'base-url'?: string;
|
||||
'proxy-url'?: string;
|
||||
prefix?: string;
|
||||
headers?: Record<string, string>;
|
||||
'excluded-models'?: string[];
|
||||
models?: Array<{
|
||||
name: string;
|
||||
alias: string;
|
||||
}>;
|
||||
}>;
|
||||
'vertex-api-key'?: Array<{
|
||||
'api-key': string;
|
||||
'base-url'?: string;
|
||||
}>;
|
||||
'openai-compatibility'?: Array<{
|
||||
name: string;
|
||||
'base-url': string;
|
||||
headers?: Record<string, string>;
|
||||
'api-key-entries': Array<{
|
||||
'api-key': string;
|
||||
'proxy-url'?: string;
|
||||
}>;
|
||||
models?: Array<{
|
||||
name: string;
|
||||
alias: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
apiProfileExists,
|
||||
createCliproxyBridgeProfile,
|
||||
createApiProfile,
|
||||
getDefaultCliproxyBridgeName,
|
||||
getPresetById,
|
||||
getPresetIds,
|
||||
getUrlWarning,
|
||||
@@ -8,11 +10,17 @@ import {
|
||||
isUsingUnifiedConfig,
|
||||
pickOpenRouterModel,
|
||||
sanitizeBaseUrl,
|
||||
suggestCliproxyBridgeName,
|
||||
validateApiName,
|
||||
validateUrl,
|
||||
type ModelMapping,
|
||||
type ProviderPreset,
|
||||
} from '../../api/services';
|
||||
import {
|
||||
CLIPROXY_PROVIDER_IDS,
|
||||
getProviderDisplayName,
|
||||
isCLIProxyProvider,
|
||||
} from '../../cliproxy/provider-capabilities';
|
||||
import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui';
|
||||
@@ -66,6 +74,34 @@ async function resolveProfileName(
|
||||
return name;
|
||||
}
|
||||
|
||||
async function resolveCliproxyProfileName(
|
||||
provider: string,
|
||||
providedName: string | undefined,
|
||||
yes: boolean | undefined
|
||||
): Promise<string | undefined> {
|
||||
if (providedName) {
|
||||
const error = validateApiName(providedName);
|
||||
if (error) {
|
||||
console.log(fail(error));
|
||||
process.exit(1);
|
||||
}
|
||||
return providedName;
|
||||
}
|
||||
|
||||
const suggestedName = isCLIProxyProvider(provider)
|
||||
? suggestCliproxyBridgeName(provider)
|
||||
: getDefaultCliproxyBridgeName('gemini');
|
||||
|
||||
if (yes) {
|
||||
return suggestedName;
|
||||
}
|
||||
|
||||
return InteractivePrompt.input('API name', {
|
||||
default: suggestedName,
|
||||
validate: validateApiName,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveBaseUrl(
|
||||
providedBaseUrl: string | undefined,
|
||||
preset: ProviderPreset | null
|
||||
@@ -248,6 +284,101 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
|
||||
console.log(header('Create API Profile'));
|
||||
console.log('');
|
||||
|
||||
if (parsedArgs.cliproxyProvider) {
|
||||
const cliproxyProvider = parsedArgs.cliproxyProvider.trim().toLowerCase();
|
||||
if (!isCLIProxyProvider(cliproxyProvider)) {
|
||||
console.log(fail(`Unknown CLIProxy provider: ${cliproxyProvider}`));
|
||||
console.log('');
|
||||
console.log(`Available providers: ${CLIPROXY_PROVIDER_IDS.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const incompatibleFlags = [
|
||||
parsedArgs.baseUrl && '--base-url',
|
||||
parsedArgs.apiKey && '--api-key',
|
||||
parsedArgs.model && '--model',
|
||||
parsedArgs.preset && '--preset',
|
||||
].filter(Boolean);
|
||||
|
||||
if (incompatibleFlags.length > 0) {
|
||||
console.log(
|
||||
fail(`--cliproxy-provider cannot be combined with ${incompatibleFlags.join(', ')}`)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const name = await resolveCliproxyProfileName(
|
||||
cliproxyProvider,
|
||||
parsedArgs.name,
|
||||
parsedArgs.yes
|
||||
);
|
||||
const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes);
|
||||
|
||||
if (name && apiProfileExists(name) && !parsedArgs.force) {
|
||||
console.log(fail(`API '${name}' already exists`));
|
||||
console.log(` Use ${color('--force', 'command')} to overwrite`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
info(
|
||||
`Using CLIProxy provider: ${getProviderDisplayName(cliproxyProvider)} (${cliproxyProvider})`
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
dim(' CCS will create a routed API profile. Provider credentials stay managed by CLIProxy.')
|
||||
);
|
||||
console.log('');
|
||||
console.log(info('Creating API profile...'));
|
||||
|
||||
const result = createCliproxyBridgeProfile(cliproxyProvider, {
|
||||
force: parsedArgs.force === true,
|
||||
name,
|
||||
target,
|
||||
});
|
||||
if (!result.success || !result.name || !result.cliproxyBridge) {
|
||||
console.log(fail(`Failed to create CLIProxy bridge profile: ${result.error}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
syncToLocalConfig();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.log(`[i] Auto-sync to CLIProxy config skipped: ${message}`);
|
||||
}
|
||||
|
||||
const details =
|
||||
`API: ${result.name}\n` +
|
||||
`Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` +
|
||||
`Settings: ${result.settingsFile}\n` +
|
||||
`Provider: ${result.cliproxyBridge.providerDisplayName}\n` +
|
||||
`Route: ${result.cliproxyBridge.routePath}\n` +
|
||||
`Proxy: ${result.cliproxyBridge.currentBaseUrl}\n` +
|
||||
`Target: ${target}`;
|
||||
|
||||
console.log('');
|
||||
console.log(infoBox(details, 'CLIProxy Bridge Created'));
|
||||
console.log('');
|
||||
console.log(header('Usage'));
|
||||
if (target === 'droid') {
|
||||
console.log(
|
||||
` ${color(`ccs ${result.name} "your prompt"`, 'command')} ${dim('# uses droid by default')}`
|
||||
);
|
||||
console.log(
|
||||
` ${color(`ccsd ${result.name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}`
|
||||
);
|
||||
} else {
|
||||
console.log(` ${color(`ccs ${result.name} "your prompt"`, 'command')}`);
|
||||
console.log(
|
||||
` ${color(`ccs ${result.name} --target droid "your prompt"`, 'command')} ${dim('# optional target override')}`
|
||||
);
|
||||
}
|
||||
console.log('');
|
||||
console.log(dim('Manage provider accounts, keys, and models in: ccs cliproxy'));
|
||||
return;
|
||||
}
|
||||
|
||||
showPresetDeprecationNotice(parsedArgs.preset);
|
||||
const preset = resolvePresetOrExit(parsedArgs.preset);
|
||||
const name = await resolveProfileName(parsedArgs.name, preset);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
PROVIDER_PRESETS,
|
||||
listCliproxyBridgeProviders,
|
||||
getPresetAliases,
|
||||
getPresetIds,
|
||||
type ProviderPreset,
|
||||
@@ -20,6 +21,7 @@ export async function showApiCommandHelp(): Promise<void> {
|
||||
const presetIds = getPresetIds()
|
||||
.map((id) => sanitizeHelpText(id))
|
||||
.filter(Boolean);
|
||||
const cliproxyProviderIds = listCliproxyBridgeProviders().map((provider) => provider.provider);
|
||||
const presetAliases = getPresetAliases();
|
||||
const presetIdWidth = Math.max(0, ...presetIds.map((id) => id.length)) + 2;
|
||||
|
||||
@@ -47,6 +49,9 @@ export async function showApiCommandHelp(): Promise<void> {
|
||||
console.log(
|
||||
` ${color('--preset <id>', 'command')} Use provider preset (${presetIds.join(', ')})`
|
||||
);
|
||||
console.log(
|
||||
` ${color('--cliproxy-provider <id>', 'command')} Use routed CLIProxy provider (${cliproxyProviderIds.join(', ')})`
|
||||
);
|
||||
console.log(` ${color('--base-url <url>', 'command')} API base URL (create)`);
|
||||
console.log(` ${color('--api-key <key>', 'command')} API key (create)`);
|
||||
console.log(` ${color('--model <model>', 'command')} Default model (create)`);
|
||||
@@ -84,6 +89,12 @@ export async function showApiCommandHelp(): Promise<void> {
|
||||
console.log(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`);
|
||||
console.log(` ${color('ccs api create --preset glm', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Create routed profile from existing CLIProxy provider config')}`);
|
||||
console.log(` ${color('ccs api create --cliproxy-provider gemini', 'command')}`);
|
||||
console.log(
|
||||
` ${color('ccs api create gemini-droid --cliproxy-provider gemini --target droid', 'command')}`
|
||||
);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Create with name')}`);
|
||||
console.log(` ${color('ccs api create myapi', 'command')}`);
|
||||
console.log(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`);
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface ApiCommandArgs {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
preset?: string;
|
||||
cliproxyProvider?: string;
|
||||
target?: TargetType;
|
||||
force?: boolean;
|
||||
yes?: boolean;
|
||||
@@ -21,6 +22,7 @@ export const API_VALUE_FLAGS = [
|
||||
'--api-key',
|
||||
'--model',
|
||||
'--preset',
|
||||
'--cliproxy-provider',
|
||||
'--target',
|
||||
] as const;
|
||||
export const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS];
|
||||
@@ -210,6 +212,18 @@ export function parseApiCommandArgs(
|
||||
false
|
||||
);
|
||||
|
||||
remaining = applyRepeatedOption(
|
||||
remaining,
|
||||
['--cliproxy-provider'],
|
||||
(value) => {
|
||||
result.cliproxyProvider = value.trim().toLowerCase();
|
||||
},
|
||||
() => {
|
||||
result.errors.push('Missing value for --cliproxy-provider');
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
remaining = applyRepeatedOption(
|
||||
remaining,
|
||||
['--target'],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as os from 'os';
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
||||
const WILDCARD_HOSTS = new Set(['0.0.0.0', '::', '[::]']);
|
||||
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
||||
const WILDCARD_HOSTS = new Set(['0.0.0.0', '::']);
|
||||
|
||||
interface NetworkInterfaceCandidate {
|
||||
address: string;
|
||||
|
||||
@@ -141,6 +141,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
['ccs ollama-cloud', 'Ollama Cloud (API key required)'],
|
||||
['', ''], // Spacer
|
||||
['ccs api create --preset anthropic', 'Anthropic direct API key (sk-ant-...)'],
|
||||
[
|
||||
'ccs api create --cliproxy-provider gemini',
|
||||
'Create routed API profile from CLIProxy Gemini',
|
||||
],
|
||||
['ccs api create', 'Create custom API profile'],
|
||||
['ccs api discover --register', 'Discover/register orphan settings files'],
|
||||
['ccs api copy <src> <dest>', 'Duplicate API profile'],
|
||||
|
||||
@@ -10,7 +10,6 @@ import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
import {
|
||||
UnifiedConfig,
|
||||
isUnifiedConfig,
|
||||
createEmptyUnifiedConfig,
|
||||
UNIFIED_CONFIG_VERSION,
|
||||
@@ -23,6 +22,9 @@ import {
|
||||
DEFAULT_THINKING_CONFIG,
|
||||
DEFAULT_DASHBOARD_AUTH_CONFIG,
|
||||
DEFAULT_IMAGE_ANALYSIS_CONFIG,
|
||||
} from './unified-config-types';
|
||||
import type {
|
||||
UnifiedConfig,
|
||||
CLIProxySafetyConfig,
|
||||
GlobalEnvConfig,
|
||||
ThinkingConfig,
|
||||
|
||||
@@ -26,11 +26,13 @@ class InstanceManager {
|
||||
private readonly instancesDir: string;
|
||||
private readonly sharedManager: SharedManager;
|
||||
private readonly contextSyncLock: ProfileContextSyncLock;
|
||||
private readonly pluginLayoutLock: ProfileContextSyncLock;
|
||||
|
||||
constructor() {
|
||||
this.instancesDir = path.join(getCcsDir(), 'instances');
|
||||
this.sharedManager = new SharedManager();
|
||||
this.contextSyncLock = new ProfileContextSyncLock(this.instancesDir);
|
||||
this.pluginLayoutLock = new ProfileContextSyncLock(this.instancesDir);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,9 +58,17 @@ class InstanceManager {
|
||||
// Apply context policy (isolated by default, optional shared group).
|
||||
await this.sharedManager.syncProjectContext(instancePath, contextPolicy);
|
||||
await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy);
|
||||
});
|
||||
|
||||
this.sharedManager.normalizeSharedPluginMetadataPaths(instancePath);
|
||||
await this.pluginLayoutLock.withNamedLock('__plugin-layout__', async () => {
|
||||
if (!options.bare) {
|
||||
this.sharedManager.linkSharedDirectories(instancePath);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sharedManager.detachSharedDirectories(instancePath);
|
||||
this.sharedManager.normalizeSharedPluginMetadataPaths();
|
||||
});
|
||||
});
|
||||
|
||||
// Sync MCP servers from global ~/.claude.json (unless bare)
|
||||
if (!options.bare) {
|
||||
@@ -82,7 +92,7 @@ class InstanceManager {
|
||||
private initializeInstance(
|
||||
profileName: string,
|
||||
instancePath: string,
|
||||
options: InstanceOptions = {}
|
||||
_options: InstanceOptions = {}
|
||||
): void {
|
||||
try {
|
||||
// Create base directory
|
||||
@@ -106,10 +116,7 @@ class InstanceManager {
|
||||
}
|
||||
});
|
||||
|
||||
// Bare profiles skip shared symlinks (commands, skills, agents, settings.json)
|
||||
if (!options.bare) {
|
||||
this.sharedManager.linkSharedDirectories(instancePath);
|
||||
}
|
||||
// Shared links are created during ensureInstance() under the plugin layout lock.
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to initialize instance for ${profileName}: ${(error as Error).message}`
|
||||
@@ -146,15 +153,22 @@ class InstanceManager {
|
||||
/**
|
||||
* Delete instance for profile
|
||||
*/
|
||||
deleteInstance(profileName: string): void {
|
||||
async deleteInstance(profileName: string): Promise<void> {
|
||||
const instancePath = this.getInstancePath(profileName);
|
||||
|
||||
if (!fs.existsSync(instancePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recursive delete
|
||||
fs.rmSync(instancePath, { recursive: true, force: true });
|
||||
await this.contextSyncLock.withLock(profileName, async () => {
|
||||
await this.pluginLayoutLock.withNamedLock('__plugin-layout__', async () => {
|
||||
if (!fs.existsSync(instancePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.rmSync(instancePath, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,6 +180,10 @@ class InstanceManager {
|
||||
}
|
||||
|
||||
return fs.readdirSync(this.instancesDir).filter((name) => {
|
||||
if (name.startsWith('.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const instancePath = path.join(this.instancesDir, name);
|
||||
return fs.statSync(instancePath).isDirectory();
|
||||
});
|
||||
|
||||
@@ -106,8 +106,8 @@ class ProfileContextSyncLock {
|
||||
return this.tryRemoveLockIfUnchanged(lockPath, snapshot.raw);
|
||||
}
|
||||
|
||||
async withLock<T>(profileName: string, callback: () => Promise<T>): Promise<T> {
|
||||
const lockPath = this.getLockPath(profileName);
|
||||
async withNamedLock<T>(lockName: string, callback: () => Promise<T>): Promise<T> {
|
||||
const lockPath = this.getLockPath(lockName);
|
||||
const retryDelayMs = 50;
|
||||
const staleLockMs = 30000;
|
||||
const timeoutMs = staleLockMs + 5000;
|
||||
@@ -159,7 +159,7 @@ class ProfileContextSyncLock {
|
||||
}
|
||||
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`Timed out waiting for profile context lock: ${profileName}`);
|
||||
throw new Error(`Timed out waiting for profile context lock: ${lockName}`);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
||||
@@ -172,6 +172,85 @@ class ProfileContextSyncLock {
|
||||
this.tryRemoveLockIfUnchanged(lockPath, ownerPayloadRaw);
|
||||
}
|
||||
}
|
||||
|
||||
async withLock<T>(profileName: string, callback: () => Promise<T>): Promise<T> {
|
||||
return this.withNamedLock(profileName, callback);
|
||||
}
|
||||
|
||||
withNamedLockSync<T>(lockName: string, callback: () => T): T {
|
||||
const lockPath = this.getLockPath(lockName);
|
||||
const retryDelayMs = 50;
|
||||
const staleLockMs = 30000;
|
||||
const timeoutMs = staleLockMs + 5000;
|
||||
const start = Date.now();
|
||||
const ownerPayload: ContextSyncLockPayload = {
|
||||
version: 1,
|
||||
pid: process.pid,
|
||||
nonce: createHash('sha1')
|
||||
.update(`${process.pid}:${Date.now()}:${Math.random()}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16),
|
||||
acquiredAtMs: Date.now(),
|
||||
};
|
||||
const ownerPayloadRaw = JSON.stringify(ownerPayload);
|
||||
|
||||
fs.mkdirSync(this.locksDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const fd = fs.openSync(lockPath, 'wx', 0o600);
|
||||
fs.writeFileSync(fd, ownerPayloadRaw, 'utf8');
|
||||
fs.closeSync(fd);
|
||||
break;
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const lockSnapshot = this.readContextSyncLockSnapshot(lockPath);
|
||||
if (lockSnapshot) {
|
||||
if (this.tryRemoveDeadOwnerLock(lockPath, lockSnapshot)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!lockSnapshot.owner) {
|
||||
try {
|
||||
const lockStats = fs.statSync(lockPath);
|
||||
if (Date.now() - lockStats.mtimeMs > staleLockMs) {
|
||||
if (this.tryRemoveLockIfUnchanged(lockPath, lockSnapshot.raw)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort stale lock cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`Timed out waiting for profile context lock: ${lockName}`);
|
||||
}
|
||||
|
||||
const until = Date.now() + retryDelayMs;
|
||||
while (Date.now() < until) {
|
||||
// Sync callers need a synchronous retry path here.
|
||||
// This lock only guards short local filesystem normalization work, so
|
||||
// contention should be brief and limited to profile/bootstrap edges.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
this.tryRemoveLockIfUnchanged(lockPath, ownerPayloadRaw);
|
||||
}
|
||||
}
|
||||
|
||||
withLockSync<T>(profileName: string, callback: () => T): T {
|
||||
return this.withNamedLockSync(profileName, callback);
|
||||
}
|
||||
}
|
||||
|
||||
export default ProfileContextSyncLock;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import ProfileContextSyncLock from './profile-context-sync-lock';
|
||||
import { ok, info, warn } from '../utils/ui';
|
||||
import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_GROUP } from '../auth/account-context';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
@@ -18,23 +19,64 @@ interface SharedItem {
|
||||
type: 'directory' | 'file';
|
||||
}
|
||||
|
||||
export function normalizePluginMetadataPathString(input: string): string {
|
||||
return input.replace(
|
||||
/([\\/])\.ccs\1instances\1[^\\/]+\1/g,
|
||||
(_match, separator: string) => `${separator}.claude${separator}`
|
||||
const DEFAULT_INSTALLED_PLUGIN_REGISTRY = JSON.stringify(
|
||||
{
|
||||
version: 2,
|
||||
plugins: {},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
function getPluginPathModule(
|
||||
targetConfigDir: string,
|
||||
input: string
|
||||
): typeof path.posix | typeof path.win32 {
|
||||
return targetConfigDir.includes('\\') || input.includes('\\') ? path.win32 : path.posix;
|
||||
}
|
||||
|
||||
function normalizeTargetConfigDir(targetConfigDir: string, input: string): string {
|
||||
const pathModule = getPluginPathModule(targetConfigDir, input);
|
||||
return pathModule.normalize(
|
||||
pathModule === path.win32
|
||||
? targetConfigDir.replace(/\//g, '\\')
|
||||
: targetConfigDir.replace(/\\/g, '/')
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePluginMetadataValue(value: unknown): { normalized: unknown; changed: boolean } {
|
||||
export function normalizePluginMetadataPathString(
|
||||
input: string,
|
||||
targetConfigDir = path.join(os.homedir(), '.claude')
|
||||
): string {
|
||||
const match = input.match(
|
||||
/^(.*?)([\\/])(?:\.claude|\.ccs\2shared|\.ccs\2instances\2[^\\/]+)\2plugins(?:(\2.*))?$/
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const pathModule = getPluginPathModule(targetConfigDir, input);
|
||||
const normalizedTargetConfigDir = normalizeTargetConfigDir(targetConfigDir, input);
|
||||
const suffix = match[3] ?? '';
|
||||
const suffixSegments = suffix.split(/[\\/]+/).filter(Boolean);
|
||||
|
||||
return pathModule.join(normalizedTargetConfigDir, 'plugins', ...suffixSegments);
|
||||
}
|
||||
|
||||
function normalizePluginMetadataValue(
|
||||
value: unknown,
|
||||
targetConfigDir: string
|
||||
): { normalized: unknown; changed: boolean } {
|
||||
if (typeof value === 'string') {
|
||||
const normalized = normalizePluginMetadataPathString(value);
|
||||
const normalized = normalizePluginMetadataPathString(value, targetConfigDir);
|
||||
return { normalized, changed: normalized !== value };
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
let changed = false;
|
||||
const normalized = value.map((item) => {
|
||||
const result = normalizePluginMetadataValue(item);
|
||||
const result = normalizePluginMetadataValue(item, targetConfigDir);
|
||||
changed = changed || result.changed;
|
||||
return result.normalized;
|
||||
});
|
||||
@@ -45,7 +87,7 @@ function normalizePluginMetadataValue(value: unknown): { normalized: unknown; ch
|
||||
let changed = false;
|
||||
const normalized = Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, item]) => {
|
||||
const result = normalizePluginMetadataValue(item);
|
||||
const result = normalizePluginMetadataValue(item, targetConfigDir);
|
||||
changed = changed || result.changed;
|
||||
return [key, result.normalized];
|
||||
})
|
||||
@@ -56,9 +98,12 @@ function normalizePluginMetadataValue(value: unknown): { normalized: unknown; ch
|
||||
return { normalized: value, changed: false };
|
||||
}
|
||||
|
||||
export function normalizePluginMetadataContent(original: string): string {
|
||||
export function normalizePluginMetadataContent(
|
||||
original: string,
|
||||
targetConfigDir = path.join(os.homedir(), '.claude')
|
||||
): string {
|
||||
const parsed = JSON.parse(original) as unknown;
|
||||
const result = normalizePluginMetadataValue(parsed);
|
||||
const result = normalizePluginMetadataValue(parsed, targetConfigDir);
|
||||
return result.changed ? JSON.stringify(result.normalized, null, 2) : original;
|
||||
}
|
||||
|
||||
@@ -70,7 +115,14 @@ class SharedManager {
|
||||
private readonly sharedDir: string;
|
||||
private readonly claudeDir: string;
|
||||
private readonly instancesDir: string;
|
||||
private readonly pluginLayoutLock: ProfileContextSyncLock;
|
||||
private readonly sharedItems: SharedItem[];
|
||||
private readonly sharedPluginEntries: readonly SharedItem[] = [
|
||||
{ name: 'cache', type: 'directory' },
|
||||
{ name: 'marketplaces', type: 'directory' },
|
||||
{ name: 'installed_plugins.json', type: 'file' },
|
||||
];
|
||||
private readonly instanceLocalPluginMetadataFiles = new Set(['known_marketplaces.json']);
|
||||
private readonly advancedContinuityItems: readonly string[] = [
|
||||
'session-env',
|
||||
'file-history',
|
||||
@@ -84,6 +136,7 @@ class SharedManager {
|
||||
this.sharedDir = path.join(ccsDir, 'shared');
|
||||
this.claudeDir = path.join(this.homeDir, '.claude');
|
||||
this.instancesDir = path.join(ccsDir, 'instances');
|
||||
this.pluginLayoutLock = new ProfileContextSyncLock(this.instancesDir);
|
||||
this.sharedItems = [
|
||||
{ name: 'commands', type: 'directory' },
|
||||
{ name: 'skills', type: 'directory' },
|
||||
@@ -148,6 +201,8 @@ class SharedManager {
|
||||
fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
this.ensureSharedPluginLayoutDefaults();
|
||||
|
||||
// Create symlinks ~/.ccs/shared/* → ~/.claude/*
|
||||
for (const item of this.sharedItems) {
|
||||
const claudePath = path.join(this.claudeDir, item.name);
|
||||
@@ -221,17 +276,15 @@ class SharedManager {
|
||||
this.ensureSharedDirectories();
|
||||
|
||||
for (const item of this.sharedItems) {
|
||||
if (item.name === 'plugins') {
|
||||
this.linkInstancePlugins(instancePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkPath = path.join(instancePath, item.name);
|
||||
const targetPath = path.join(this.sharedDir, item.name);
|
||||
|
||||
// Remove existing file/directory/link
|
||||
if (fs.existsSync(linkPath)) {
|
||||
if (item.type === 'directory') {
|
||||
fs.rmSync(linkPath, { recursive: true, force: true });
|
||||
} else {
|
||||
fs.unlinkSync(linkPath);
|
||||
}
|
||||
}
|
||||
this.removeExistingPath(linkPath, item.type);
|
||||
|
||||
// Create symlink
|
||||
try {
|
||||
@@ -257,6 +310,151 @@ class SharedManager {
|
||||
this.normalizeSharedPluginMetadataPaths(instancePath);
|
||||
}
|
||||
|
||||
detachSharedDirectories(instancePath: string): void {
|
||||
this.ensureSharedDirectories();
|
||||
|
||||
for (const item of this.sharedItems) {
|
||||
const managedPath = path.join(instancePath, item.name);
|
||||
if (!fs.existsSync(managedPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.name === 'plugins') {
|
||||
this.detachManagedPluginLayout(instancePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stats = fs.lstatSync(managedPath);
|
||||
if (!stats.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.symlinkPointsTo(managedPath, path.join(this.sharedDir, item.name))) {
|
||||
this.removeExistingPath(managedPath, item.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ensureSharedPluginLayoutDefaults(): void {
|
||||
const pluginsDir = path.join(this.claudeDir, 'plugins');
|
||||
fs.mkdirSync(pluginsDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
for (const entry of this.sharedPluginEntries) {
|
||||
const entryPath = path.join(pluginsDir, entry.name);
|
||||
if (fs.existsSync(entryPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'directory') {
|
||||
fs.mkdirSync(entryPath, { recursive: true, mode: 0o700 });
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.writeFileSync(entryPath, DEFAULT_INSTALLED_PLUGIN_REGISTRY, 'utf8');
|
||||
}
|
||||
|
||||
const marketplaceRegistryPath = path.join(pluginsDir, 'known_marketplaces.json');
|
||||
if (!fs.existsSync(marketplaceRegistryPath)) {
|
||||
fs.writeFileSync(marketplaceRegistryPath, JSON.stringify({}, null, 2), 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
private linkInstancePlugins(instancePath: string): void {
|
||||
const linkPath = path.join(instancePath, 'plugins');
|
||||
const targetPath = path.join(this.sharedDir, 'plugins');
|
||||
let linkStats: fs.Stats | null = null;
|
||||
|
||||
try {
|
||||
linkStats = fs.lstatSync(linkPath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (linkStats?.isSymbolicLink() || (linkStats && !linkStats.isDirectory())) {
|
||||
this.removeExistingPath(linkPath, linkStats.isDirectory() ? 'directory' : 'file');
|
||||
}
|
||||
|
||||
if (!linkStats || !linkStats.isDirectory()) {
|
||||
fs.mkdirSync(linkPath, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
for (const item of this.getSharedPluginLinkItems()) {
|
||||
const targetEntryPath = path.join(targetPath, item.name);
|
||||
const linkEntryPath = path.join(linkPath, item.name);
|
||||
|
||||
this.removeExistingPath(linkEntryPath, item.type);
|
||||
|
||||
try {
|
||||
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
|
||||
fs.symlinkSync(targetEntryPath, linkEntryPath, symlinkType);
|
||||
} catch (_err) {
|
||||
if (process.platform === 'win32') {
|
||||
if (item.type === 'directory') {
|
||||
this.copyDirectoryFallback(targetEntryPath, linkEntryPath);
|
||||
} else {
|
||||
fs.copyFileSync(targetEntryPath, linkEntryPath);
|
||||
}
|
||||
console.log(
|
||||
warn(`Symlink failed for plugins/${item.name}, copied instead (enable Developer Mode)`)
|
||||
);
|
||||
} else {
|
||||
throw _err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getSharedPluginLinkItems(): SharedItem[] {
|
||||
const sharedPluginsPath = path.join(this.sharedDir, 'plugins');
|
||||
const items = new Map<string, SharedItem>(
|
||||
this.sharedPluginEntries.map((entry) => [entry.name, { ...entry }])
|
||||
);
|
||||
|
||||
for (const entry of fs.readdirSync(sharedPluginsPath, { withFileTypes: true })) {
|
||||
if (items.has(entry.name) || this.instanceLocalPluginMetadataFiles.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryPath = path.join(sharedPluginsPath, entry.name);
|
||||
const stats = fs.statSync(entryPath);
|
||||
items.set(entry.name, {
|
||||
name: entry.name,
|
||||
type: stats.isDirectory() ? 'directory' : 'file',
|
||||
});
|
||||
}
|
||||
|
||||
return [...items.values()];
|
||||
}
|
||||
|
||||
private removeExistingPath(targetPath: string, typeHint: SharedItem['type']): void {
|
||||
try {
|
||||
const stats = fs.lstatSync(targetPath);
|
||||
if (stats.isDirectory() && !stats.isSymbolicLink()) {
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (stats.isSymbolicLink() || typeHint === 'file') {
|
||||
fs.unlinkSync(targetPath);
|
||||
return;
|
||||
}
|
||||
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeHint === 'directory') {
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
} else {
|
||||
fs.rmSync(targetPath, { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync project workspace context based on account policy.
|
||||
*
|
||||
@@ -583,13 +781,19 @@ class SharedManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize shared plugin metadata files to canonical ~/.claude/ paths.
|
||||
* Normalize plugin metadata and reconcile marketplace metadata for the active config dir.
|
||||
*/
|
||||
normalizeSharedPluginMetadataPaths(configDir?: string): void {
|
||||
this.normalizePluginRegistryPaths(configDir);
|
||||
this.normalizeMarketplaceRegistryPaths(configDir);
|
||||
}
|
||||
|
||||
normalizeSharedPluginMetadataPathsLocked(configDir?: string): void {
|
||||
this.pluginLayoutLock.withNamedLockSync('__plugin-layout__', () => {
|
||||
this.normalizeSharedPluginMetadataPaths(configDir);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize plugin registry paths to use canonical ~/.claude/ paths
|
||||
* instead of instance-specific ~/.ccs/instances/<name>/ paths.
|
||||
@@ -607,19 +811,31 @@ class SharedManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize marketplace registry paths to use canonical ~/.claude/ paths
|
||||
* instead of instance-specific ~/.ccs/instances/<name>/ paths.
|
||||
*
|
||||
* This ensures known_marketplaces.json is consistent regardless of
|
||||
* which CCS instance added the marketplace.
|
||||
* Reconcile marketplace registry content into the active config dir while
|
||||
* keeping the global ~/.claude copy up to date for non-instance flows.
|
||||
*/
|
||||
normalizeMarketplaceRegistryPaths(configDir?: string): void {
|
||||
this.normalizePluginMetadataFiles(
|
||||
'known_marketplaces.json',
|
||||
configDir,
|
||||
'Normalized marketplace registry paths',
|
||||
'marketplace registry'
|
||||
);
|
||||
const successMessage = 'Synchronized marketplace registry paths';
|
||||
const warningLabel = 'marketplace registry';
|
||||
|
||||
try {
|
||||
const sourcePaths = this.getMarketplaceRegistrySourcePaths(configDir);
|
||||
this.writePluginMetadataFile(
|
||||
path.join(this.claudeDir, 'plugins', 'known_marketplaces.json'),
|
||||
this.buildMarketplaceRegistryContent(sourcePaths, this.claudeDir),
|
||||
successMessage
|
||||
);
|
||||
|
||||
if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) {
|
||||
this.writePluginMetadataFile(
|
||||
path.join(configDir, 'plugins', 'known_marketplaces.json'),
|
||||
this.buildMarketplaceRegistryContent(sourcePaths, configDir),
|
||||
successMessage
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(warn(`Could not synchronize ${warningLabel}: ${(err as Error).message}`));
|
||||
}
|
||||
}
|
||||
|
||||
private normalizePluginMetadataFiles(
|
||||
@@ -676,6 +892,117 @@ class SharedManager {
|
||||
}
|
||||
}
|
||||
|
||||
private getMarketplaceRegistrySourcePaths(configDir?: string): string[] {
|
||||
const sourcePaths = new Set<string>([
|
||||
path.join(this.claudeDir, 'plugins', 'known_marketplaces.json'),
|
||||
]);
|
||||
|
||||
if (fs.existsSync(this.instancesDir)) {
|
||||
for (const entry of fs.readdirSync(this.instancesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sourcePaths.add(
|
||||
path.join(this.instancesDir, entry.name, 'plugins', 'known_marketplaces.json')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) {
|
||||
sourcePaths.add(path.join(configDir, 'plugins', 'known_marketplaces.json'));
|
||||
}
|
||||
|
||||
return [...sourcePaths];
|
||||
}
|
||||
|
||||
private buildMarketplaceRegistryContent(sourcePaths: string[], targetConfigDir: string): string {
|
||||
const merged: Record<string, unknown> = {};
|
||||
|
||||
for (const registryPath of sourcePaths) {
|
||||
if (!fs.existsSync(registryPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(
|
||||
warn(`Skipping malformed marketplace registry ${registryPath}: ${(err as Error).message}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const discoveredEntries = this.discoverMarketplaceEntries(targetConfigDir);
|
||||
|
||||
for (const [name, value] of Object.entries(discoveredEntries)) {
|
||||
const existing = merged[name];
|
||||
if (existing && typeof existing === 'object' && !Array.isArray(existing)) {
|
||||
merged[name] = {
|
||||
...(existing as Record<string, unknown>),
|
||||
installLocation: value.installLocation,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
merged[name] = value;
|
||||
}
|
||||
|
||||
for (const name of Object.keys(merged)) {
|
||||
if (!(name in discoveredEntries)) {
|
||||
delete merged[name];
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(merged, null, 2);
|
||||
}
|
||||
|
||||
private discoverMarketplaceEntries(
|
||||
targetConfigDir: string
|
||||
): Record<string, { installLocation: string }> {
|
||||
const marketplacesDir = path.join(targetConfigDir, 'plugins', 'marketplaces');
|
||||
if (!fs.existsSync(marketplacesDir)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const discovered: Record<string, { installLocation: string }> = {};
|
||||
|
||||
for (const entry of fs.readdirSync(marketplacesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
discovered[entry.name] = {
|
||||
installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name),
|
||||
};
|
||||
}
|
||||
|
||||
return discovered;
|
||||
}
|
||||
|
||||
private writePluginMetadataFile(
|
||||
registryPath: string,
|
||||
content: string,
|
||||
successMessage: string
|
||||
): void {
|
||||
fs.mkdirSync(path.dirname(registryPath), { recursive: true, mode: 0o700 });
|
||||
const current = fs.existsSync(registryPath) ? fs.readFileSync(registryPath, 'utf8') : null;
|
||||
|
||||
if (current === content) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.writeFileSync(registryPath, content, 'utf8');
|
||||
console.log(ok(successMessage));
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate from v3.1.1 (copied data in ~/.ccs/shared/) to v3.2.0 (symlinks to ~/.claude/)
|
||||
* Runs once on upgrade
|
||||
@@ -1157,6 +1484,118 @@ class SharedManager {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private symlinkPointsTo(linkPath: string, expectedTarget: string): boolean {
|
||||
try {
|
||||
const currentTarget = fs.readlinkSync(linkPath);
|
||||
const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget);
|
||||
return (
|
||||
this.resolveCanonicalPath(resolvedCurrentTarget) ===
|
||||
this.resolveCanonicalPath(expectedTarget)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private detachManagedPluginLayout(instancePath: string): void {
|
||||
const pluginsPath = path.join(instancePath, 'plugins');
|
||||
if (!fs.existsSync(pluginsPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = fs.lstatSync(pluginsPath);
|
||||
const sharedPluginsPath = path.join(this.sharedDir, 'plugins');
|
||||
|
||||
if (stats.isSymbolicLink()) {
|
||||
if (this.symlinkPointsTo(pluginsPath, sharedPluginsPath)) {
|
||||
this.removeExistingPath(pluginsPath, 'directory');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let removedManagedEntries = false;
|
||||
|
||||
for (const item of this.getSharedPluginLinkItems()) {
|
||||
const pluginEntryPath = path.join(pluginsPath, item.name);
|
||||
if (!fs.existsSync(pluginEntryPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryStats = fs.lstatSync(pluginEntryPath);
|
||||
if (!entryStats.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.symlinkPointsTo(pluginEntryPath, path.join(sharedPluginsPath, item.name))) {
|
||||
this.removeExistingPath(pluginEntryPath, item.type);
|
||||
removedManagedEntries = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!removedManagedEntries) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconcileLocalMarketplaceRegistry(instancePath);
|
||||
|
||||
if (fs.readdirSync(pluginsPath).length === 0) {
|
||||
fs.rmSync(pluginsPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private reconcileLocalMarketplaceRegistry(configDir: string): void {
|
||||
const registryPath = path.join(configDir, 'plugins', 'known_marketplaces.json');
|
||||
if (!fs.existsSync(registryPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const discoveredEntries = this.discoverMarketplaceEntries(configDir);
|
||||
if (Object.keys(discoveredEntries).length === 0) {
|
||||
this.removeExistingPath(registryPath, 'file');
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown> = {};
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as unknown;
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
parsed = raw as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
|
||||
const reconciled = Object.fromEntries(
|
||||
Object.entries(discoveredEntries).map(([name, value]) => {
|
||||
const existing = parsed[name];
|
||||
if (existing && typeof existing === 'object' && !Array.isArray(existing)) {
|
||||
return [
|
||||
name,
|
||||
{
|
||||
...(normalizePluginMetadataValue(existing, configDir).normalized as Record<
|
||||
string,
|
||||
unknown
|
||||
>),
|
||||
installLocation: value.installLocation,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [name, value];
|
||||
})
|
||||
);
|
||||
|
||||
this.writePluginMetadataFile(
|
||||
registryPath,
|
||||
JSON.stringify(reconciled, null, 2),
|
||||
'Synchronized marketplace registry paths'
|
||||
);
|
||||
}
|
||||
|
||||
private resolveCanonicalPath(targetPath: string): string {
|
||||
try {
|
||||
return fs.realpathSync.native(targetPath);
|
||||
|
||||
@@ -168,7 +168,7 @@ async function resolveExtensionEnv(
|
||||
profileType: result.type,
|
||||
target: 'claude',
|
||||
});
|
||||
new SharedManager().normalizeSharedPluginMetadataPaths(continuity.claudeConfigDir);
|
||||
new SharedManager().normalizeSharedPluginMetadataPathsLocked(continuity.claudeConfigDir);
|
||||
if (continuity.claudeConfigDir) {
|
||||
notes.push(`Default profile inherits continuity from account "${continuity.sourceAccount}".`);
|
||||
return {
|
||||
@@ -250,7 +250,7 @@ async function resolveExtensionEnv(
|
||||
);
|
||||
}
|
||||
|
||||
new SharedManager().normalizeSharedPluginMetadataPaths(env.CLAUDE_CONFIG_DIR);
|
||||
new SharedManager().normalizeSharedPluginMetadataPathsLocked(env.CLAUDE_CONFIG_DIR);
|
||||
|
||||
if (result.type === 'copilot') {
|
||||
warnings.push(
|
||||
|
||||
+11
-9
@@ -1,4 +1,4 @@
|
||||
import { SpawnOptions as NodeSpawnOptions } from 'child_process';
|
||||
import type { SpawnOptions as NodeSpawnOptions } from 'child_process';
|
||||
|
||||
/**
|
||||
* CLI Runtime Types
|
||||
@@ -43,11 +43,13 @@ export interface ClaudeCliInfo {
|
||||
/**
|
||||
* Exit codes
|
||||
*/
|
||||
export enum ExitCode {
|
||||
SUCCESS = 0,
|
||||
GENERIC_ERROR = 1,
|
||||
CLAUDE_NOT_FOUND = 127,
|
||||
CONFIG_ERROR = 2,
|
||||
DELEGATION_ERROR = 3,
|
||||
TIMEOUT = 124,
|
||||
}
|
||||
export const ExitCode = {
|
||||
SUCCESS: 0,
|
||||
GENERIC_ERROR: 1,
|
||||
CLAUDE_NOT_FOUND: 127,
|
||||
CONFIG_ERROR: 2,
|
||||
DELEGATION_ERROR: 3,
|
||||
TIMEOUT: 124,
|
||||
} as const;
|
||||
|
||||
export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode];
|
||||
|
||||
+8
-6
@@ -9,12 +9,14 @@ export type { ErrorCode } from '../utils/error-codes';
|
||||
/**
|
||||
* Log levels
|
||||
*/
|
||||
export enum LogLevel {
|
||||
DEBUG = 'debug',
|
||||
INFO = 'info',
|
||||
WARN = 'warn',
|
||||
ERROR = 'error',
|
||||
}
|
||||
export const LogLevel = {
|
||||
DEBUG: 'debug',
|
||||
INFO: 'info',
|
||||
WARN: 'warn',
|
||||
ERROR: 'error',
|
||||
} as const;
|
||||
|
||||
export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel];
|
||||
|
||||
/**
|
||||
* Color codes (TTY-aware)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { getCcsHome } from './config-manager';
|
||||
|
||||
/**
|
||||
* Resolve Claude config directory with test/dev overrides.
|
||||
@@ -13,11 +13,7 @@ export function getClaudeConfigDir(): string {
|
||||
return path.resolve(process.env.CLAUDE_CONFIG_DIR);
|
||||
}
|
||||
|
||||
if (process.env.CCS_HOME) {
|
||||
return path.join(path.resolve(process.env.CCS_HOME), '.claude');
|
||||
}
|
||||
|
||||
return path.join(os.homedir(), '.claude');
|
||||
return path.join(getCcsHome(), '.claude');
|
||||
}
|
||||
|
||||
/** Resolve Claude settings.json path. */
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { AsyncLocalStorage } from 'async_hooks';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import {
|
||||
Config,
|
||||
isConfig,
|
||||
Settings,
|
||||
isSettings,
|
||||
CLIProxyVariantsConfig,
|
||||
CLIProxyVariantConfig,
|
||||
} from '../types';
|
||||
import { isConfig, isSettings } from '../types';
|
||||
import type { Config, Settings, CLIProxyVariantsConfig, CLIProxyVariantConfig } from '../types';
|
||||
import { expandPath, error } from './helpers';
|
||||
import { info } from './ui';
|
||||
import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
@@ -19,6 +14,38 @@ import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-conf
|
||||
|
||||
// Module-level state for --config-dir CLI flag override
|
||||
let _globalConfigDir: string | undefined;
|
||||
const configScopeStorage = new AsyncLocalStorage<{ ccsHome?: string; ccsDir?: string }>();
|
||||
|
||||
function normalizeScopedPath(dir: string | undefined): string | undefined {
|
||||
return dir ? path.resolve(dir) : undefined;
|
||||
}
|
||||
|
||||
export async function runWithScopedConfig<T>(
|
||||
scope: { ccsHome?: string; ccsDir?: string },
|
||||
fn: () => Promise<T> | T
|
||||
): Promise<T> {
|
||||
return await configScopeStorage.run(
|
||||
{
|
||||
ccsHome: normalizeScopedPath(scope.ccsHome),
|
||||
ccsDir: normalizeScopedPath(scope.ccsDir),
|
||||
},
|
||||
fn
|
||||
);
|
||||
}
|
||||
|
||||
export async function runWithScopedCcsHome<T>(
|
||||
ccsHome: string,
|
||||
fn: () => Promise<T> | T
|
||||
): Promise<T> {
|
||||
return await runWithScopedConfig({ ccsHome }, fn);
|
||||
}
|
||||
|
||||
export async function runWithScopedConfigDir<T>(
|
||||
ccsDir: string,
|
||||
fn: () => Promise<T> | T
|
||||
): Promise<T> {
|
||||
return await runWithScopedConfig({ ccsDir }, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set global config directory from --config-dir CLI flag.
|
||||
@@ -34,6 +61,10 @@ export function setGlobalConfigDir(dir: string | undefined): void {
|
||||
* @returns Home directory path
|
||||
*/
|
||||
export function getCcsHome(): string {
|
||||
const scopedHome = configScopeStorage.getStore()?.ccsHome;
|
||||
if (scopedHome) {
|
||||
return scopedHome;
|
||||
}
|
||||
return process.env.CCS_HOME || os.homedir();
|
||||
}
|
||||
|
||||
@@ -42,6 +73,10 @@ export function getCcsHome(): string {
|
||||
* Single source of truth for precedence logic.
|
||||
*/
|
||||
function _resolveCcsDir(): { source: string; dir: string } {
|
||||
const scopedConfig = configScopeStorage.getStore();
|
||||
if (scopedConfig?.ccsDir) return { source: 'scoped:CCS_DIR', dir: scopedConfig.ccsDir };
|
||||
if (scopedConfig?.ccsHome)
|
||||
return { source: 'scoped:CCS_HOME', dir: path.join(scopedConfig.ccsHome, '.ccs') };
|
||||
if (_globalConfigDir) return { source: '--config-dir', dir: _globalConfigDir };
|
||||
if (process.env.CCS_DIR) return { source: 'CCS_DIR', dir: path.resolve(process.env.CCS_DIR) };
|
||||
if (process.env.CCS_HOME)
|
||||
|
||||
@@ -125,7 +125,7 @@ export function execClaude(
|
||||
|
||||
if (profileType !== 'account') {
|
||||
try {
|
||||
new SharedManager().normalizeSharedPluginMetadataPaths(env.CLAUDE_CONFIG_DIR);
|
||||
new SharedManager().normalizeSharedPluginMetadataPathsLocked(env.CLAUDE_CONFIG_DIR);
|
||||
} catch {
|
||||
// Best-effort normalization should never block Claude launch.
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import ProfileRegistry from '../../auth/profile-registry';
|
||||
import InstanceManager from '../../management/instance-manager';
|
||||
import { isUnifiedMode } from '../../config/unified-config-loader';
|
||||
import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import {
|
||||
getAllAccountsSummary,
|
||||
setDefaultAccount as setCliproxyDefault,
|
||||
@@ -30,12 +30,28 @@ import {
|
||||
parseCliproxyKey,
|
||||
type MergedAccountEntry,
|
||||
} from './account-route-helpers';
|
||||
import type { AccountConfig } from '../../config/unified-config-types';
|
||||
|
||||
const router = Router();
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
|
||||
function createProfileRegistry(): ProfileRegistry {
|
||||
return new ProfileRegistry();
|
||||
}
|
||||
|
||||
function createInstanceManager(): InstanceManager {
|
||||
return new InstanceManager();
|
||||
}
|
||||
|
||||
function getUnifiedAccountsRaw(): Record<string, AccountConfig> {
|
||||
if (!isUnifiedMode()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return loadOrCreateUnifiedConfig().accounts;
|
||||
}
|
||||
|
||||
function hasAuthAccount(name: string): boolean {
|
||||
const registry = createProfileRegistry();
|
||||
return registry.hasAccountUnified(name) || registry.hasProfile(name);
|
||||
}
|
||||
|
||||
@@ -44,8 +60,11 @@ function hasAuthAccount(name: string): boolean {
|
||||
*/
|
||||
router.get('/', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const registry = createProfileRegistry();
|
||||
|
||||
// Get profiles from both legacy and unified config (same logic as CLI)
|
||||
const legacyProfiles = registry.getAllProfiles();
|
||||
const rawUnifiedAccounts = getUnifiedAccountsRaw();
|
||||
const unifiedAccounts = registry.getAllAccountsUnified();
|
||||
|
||||
// Get CLIProxy OAuth accounts (gemini, codex, agy, etc.)
|
||||
@@ -76,11 +95,12 @@ router.get('/', (_req: Request, res: Response): void => {
|
||||
|
||||
// Override with unified config accounts (takes precedence)
|
||||
for (const [name, account] of Object.entries(unifiedAccounts)) {
|
||||
const rawAccount = rawUnifiedAccounts[name];
|
||||
const contextPolicy = resolveAccountContextPolicy(account);
|
||||
const hasExplicitContextMode =
|
||||
account.context_mode === 'isolated' || account.context_mode === 'shared';
|
||||
rawAccount?.context_mode === 'isolated' || rawAccount?.context_mode === 'shared';
|
||||
const hasExplicitContinuityMode =
|
||||
account.continuity_mode === 'standard' || account.continuity_mode === 'deeper';
|
||||
rawAccount?.continuity_mode === 'standard' || rawAccount?.continuity_mode === 'deeper';
|
||||
merged[name] = {
|
||||
type: 'account',
|
||||
created: account.created,
|
||||
@@ -138,6 +158,7 @@ router.get('/', (_req: Request, res: Response): void => {
|
||||
*/
|
||||
router.post('/default', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const registry = createProfileRegistry();
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name) {
|
||||
@@ -175,6 +196,8 @@ router.post('/default', (req: Request, res: Response): void => {
|
||||
*/
|
||||
router.put('/:name/context', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const registry = createProfileRegistry();
|
||||
const instanceMgr = createInstanceManager();
|
||||
const { name } = req.params;
|
||||
|
||||
if (!name) {
|
||||
@@ -310,6 +333,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
||||
*/
|
||||
router.delete('/reset-default', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const registry = createProfileRegistry();
|
||||
if (isUnifiedMode()) {
|
||||
registry.clearDefaultUnified();
|
||||
} else {
|
||||
@@ -324,8 +348,10 @@ router.delete('/reset-default', (_req: Request, res: Response): void => {
|
||||
/**
|
||||
* DELETE /api/accounts/:name - Delete an account
|
||||
*/
|
||||
router.delete('/:name', (req: Request, res: Response): void => {
|
||||
router.delete('/:name', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const registry = createProfileRegistry();
|
||||
const instanceMgr = createInstanceManager();
|
||||
const { name } = req.params;
|
||||
|
||||
if (!name) {
|
||||
@@ -371,7 +397,7 @@ router.delete('/:name', (req: Request, res: Response): void => {
|
||||
}
|
||||
|
||||
// Match CLI remove ordering: delete instance first, metadata second.
|
||||
instanceMgr.deleteInstance(name);
|
||||
await instanceMgr.deleteInstance(name);
|
||||
|
||||
if (existsUnified) {
|
||||
registry.removeAccountUnified(name);
|
||||
|
||||
@@ -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 cliproxyStatsRoutes from './cliproxy-stats-routes';
|
||||
import cliproxySyncRoutes from './cliproxy-sync-routes';
|
||||
import aiProviderRoutes from './ai-provider-routes';
|
||||
import copilotRoutes from './copilot-routes';
|
||||
import cursorRoutes from './cursor-routes';
|
||||
import droidRoutes from './droid-routes';
|
||||
@@ -59,6 +60,7 @@ apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes);
|
||||
apiRoutes.use('/cliproxy', cliproxyStatsRoutes);
|
||||
apiRoutes.use('/cliproxy/sync', cliproxySyncRoutes);
|
||||
apiRoutes.use('/cliproxy/catalog', catalogRoutes);
|
||||
apiRoutes.use('/cliproxy/ai-providers', aiProviderRoutes);
|
||||
apiRoutes.use('/cliproxy/openai-compat', providerRoutes);
|
||||
|
||||
// ==================== WebSearch ====================
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Router, Request, Response } from 'express';
|
||||
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
|
||||
import {
|
||||
createApiProfile,
|
||||
createCliproxyBridgeProfile,
|
||||
removeApiProfile,
|
||||
updateApiProfileTarget,
|
||||
discoverApiProfileOrphans,
|
||||
@@ -17,10 +18,12 @@ import {
|
||||
exportApiProfile,
|
||||
importApiProfileBundle,
|
||||
apiProfileExists,
|
||||
listCliproxyBridgeProviders,
|
||||
listApiProfiles,
|
||||
validateApiName,
|
||||
} from '../../api/services';
|
||||
import { normalizeDroidProvider } from '../../targets/droid-provider';
|
||||
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
|
||||
import { isAnthropicDirectProfile, updateSettingsFile, parseTarget } from './route-helpers';
|
||||
|
||||
const router = Router();
|
||||
@@ -66,6 +69,7 @@ router.get('/', (_req: Request, res: Response): void => {
|
||||
settingsPath: p.settingsPath,
|
||||
configured: p.isConfigured,
|
||||
target: p.target,
|
||||
cliproxyBridge: p.cliproxyBridge ?? null,
|
||||
}));
|
||||
res.json({ profiles });
|
||||
} catch (error) {
|
||||
@@ -73,6 +77,54 @@ router.get('/', (_req: Request, res: Response): void => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/cliproxy-bridge/providers', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
res.json({ providers: listCliproxyBridgeProviders() });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/cliproxy-bridge', (req: Request, res: Response): void => {
|
||||
const shape = validatePayloadShape(req.body, ['provider', 'name', 'target']);
|
||||
if (!shape.ok) {
|
||||
res.status(400).json({ error: shape.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = typeof shape.payload.provider === 'string' ? shape.payload.provider.trim() : '';
|
||||
if (!isCLIProxyProvider(provider)) {
|
||||
res.status(400).json({ error: 'Invalid provider. Expected a supported CLIProxy provider ID.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const target = parseTarget(shape.payload.target);
|
||||
if (shape.payload.target !== undefined && target === null) {
|
||||
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = createCliproxyBridgeProfile(provider, {
|
||||
name: typeof shape.payload.name === 'string' ? shape.payload.name : undefined,
|
||||
target: target || 'claude',
|
||||
});
|
||||
|
||||
if (!result.success || !result.name) {
|
||||
const errorMessage = result.error || 'Failed to create CLIProxy bridge profile';
|
||||
res.status(errorMessage.toLowerCase().includes('already exists') ? 409 : 400).json({
|
||||
error: errorMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
name: result.name,
|
||||
settingsPath: result.settingsFile,
|
||||
target: result.target || 'claude',
|
||||
cliproxyBridge: result.cliproxyBridge ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/profiles - Create new profile
|
||||
*/
|
||||
@@ -160,6 +212,7 @@ router.post('/', (req: Request, res: Response): void => {
|
||||
name,
|
||||
settingsPath: result.settingsFile,
|
||||
target: parsedTarget || 'claude',
|
||||
cliproxyBridge: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../../cliproxy';
|
||||
import { regenerateConfig } from '../../cliproxy/config-generator';
|
||||
import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
|
||||
import { resolveCliproxyBridgeMetadata } from '../../api/services';
|
||||
import {
|
||||
getDashboardAuthConfig,
|
||||
loadOrCreateUnifiedConfig,
|
||||
@@ -327,6 +328,7 @@ router.get('/:profile', (req: Request, res: Response): void => {
|
||||
settings: masked,
|
||||
mtime: stat.mtime.getTime(),
|
||||
path: settingsPath,
|
||||
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
|
||||
});
|
||||
} catch (error) {
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
@@ -354,6 +356,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => {
|
||||
settings,
|
||||
mtime: stat.mtime.getTime(),
|
||||
path: settingsPath,
|
||||
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
|
||||
});
|
||||
} catch (error) {
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { getEffectiveApiKey } from '../../../src/cliproxy/auth-token-manager';
|
||||
import {
|
||||
resolveCliproxyBridgeMetadata,
|
||||
resolveCliproxyBridgeProfile,
|
||||
suggestCliproxyBridgeName,
|
||||
} from '../../../src/api/services/cliproxy-profile-bridge';
|
||||
|
||||
describe('cliproxy-profile-bridge', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-bridge-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves routed profile payload for a local CLIProxy provider', () => {
|
||||
const bridge = resolveCliproxyBridgeProfile('gemini');
|
||||
|
||||
expect(bridge.name).toBe('gemini-api');
|
||||
expect(bridge.baseUrl).toBe('http://127.0.0.1:8317/api/provider/gemini');
|
||||
expect(bridge.routePath).toBe('/api/provider/gemini');
|
||||
expect(bridge.models.default.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('suggests a unique name when the default bridge settings file already exists', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ccsDir, 'gemini-api.settings.json'), '{}\n');
|
||||
|
||||
expect(suggestCliproxyBridgeName('gemini')).toBe('gemini-api-2');
|
||||
});
|
||||
|
||||
it('detects CLIProxy-backed profile metadata and normalizes localhost loopback URLs', () => {
|
||||
const metadata = resolveCliproxyBridgeMetadata({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/gemini',
|
||||
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(metadata?.provider).toBe('gemini');
|
||||
expect(metadata?.usesCurrentTarget).toBe(true);
|
||||
expect(metadata?.usesCurrentAuthToken).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,10 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import { clearQuotaCache } from '../../../src/cliproxy/quota-response-cache';
|
||||
|
||||
afterEach(() => {
|
||||
clearQuotaCache();
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
@@ -57,10 +59,6 @@ describe('codex plan compatibility reconcile', () => {
|
||||
accountId: 'free@example.com',
|
||||
}),
|
||||
}));
|
||||
mock.module('../../../src/cliproxy/quota-response-cache', () => ({
|
||||
getCachedQuota: () => null,
|
||||
setCachedQuota: () => {},
|
||||
}));
|
||||
mock.module('../../../src/utils/ui', () => ({
|
||||
info: (message: string) => message,
|
||||
warn: (message: string) => message,
|
||||
@@ -103,10 +101,6 @@ describe('codex plan compatibility reconcile', () => {
|
||||
throw new Error('should not fetch quota without a default account');
|
||||
},
|
||||
}));
|
||||
mock.module('../../../src/cliproxy/quota-response-cache', () => ({
|
||||
getCachedQuota: () => null,
|
||||
setCachedQuota: () => {},
|
||||
}));
|
||||
mock.module('../../../src/utils/ui', () => ({
|
||||
info: (message: string) => message,
|
||||
warn: (message: string) => message,
|
||||
@@ -153,10 +147,6 @@ describe('codex plan compatibility reconcile', () => {
|
||||
accountId: `${planType}@example.com`,
|
||||
}),
|
||||
}));
|
||||
mock.module('../../../src/cliproxy/quota-response-cache', () => ({
|
||||
getCachedQuota: () => null,
|
||||
setCachedQuota: () => {},
|
||||
}));
|
||||
mock.module('../../../src/utils/ui', () => ({
|
||||
info: (message: string) => message,
|
||||
warn: (message: string) => message,
|
||||
@@ -202,10 +192,6 @@ describe('codex plan compatibility reconcile', () => {
|
||||
error: 'network timeout',
|
||||
}),
|
||||
}));
|
||||
mock.module('../../../src/cliproxy/quota-response-cache', () => ({
|
||||
getCachedQuota: () => null,
|
||||
setCachedQuota: () => {},
|
||||
}));
|
||||
mock.module('../../../src/utils/ui', () => ({
|
||||
info: (message: string) => message,
|
||||
warn: (message: string) => message,
|
||||
@@ -250,10 +236,6 @@ describe('codex plan compatibility reconcile', () => {
|
||||
accountId: 'missing-plan@example.com',
|
||||
}),
|
||||
}));
|
||||
mock.module('../../../src/cliproxy/quota-response-cache', () => ({
|
||||
getCachedQuota: () => null,
|
||||
setCachedQuota: () => {},
|
||||
}));
|
||||
mock.module('../../../src/utils/ui', () => ({
|
||||
info: (message: string) => message,
|
||||
warn: (message: string) => message,
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getProviderCatalog, getModelMaxLevel } from '../../../src/cliproxy/mode
|
||||
import {
|
||||
getDefaultCodexModel,
|
||||
getFreePlanFallbackCodexModel,
|
||||
parseCodexUnsupportedModelError,
|
||||
resolveRuntimeCodexFallbackModel,
|
||||
} from '../../../src/cliproxy/codex-plan-compatibility';
|
||||
|
||||
describe('codex plan compatibility', () => {
|
||||
@@ -25,6 +27,50 @@ describe('codex plan compatibility', () => {
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.1-codex-mini')).toBeNull();
|
||||
});
|
||||
|
||||
it('detects upstream Codex model_not_supported responses', () => {
|
||||
expect(
|
||||
parseCodexUnsupportedModelError(
|
||||
400,
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: 'The requested model is not supported.',
|
||||
code: 'model_not_supported',
|
||||
param: 'model',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
message: 'The requested model is not supported.',
|
||||
code: 'model_not_supported',
|
||||
param: 'model',
|
||||
type: 'invalid_request_error',
|
||||
});
|
||||
expect(
|
||||
parseCodexUnsupportedModelError(500, '{"error":{"code":"model_not_supported"}}')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves runtime fallbacks without retrying the rejected model again', () => {
|
||||
expect(
|
||||
resolveRuntimeCodexFallbackModel({
|
||||
requestedModel: 'gpt-5.4',
|
||||
modelMap: { defaultModel: 'gpt-5-codex' },
|
||||
})
|
||||
).toBe('gpt-5-codex');
|
||||
|
||||
expect(
|
||||
resolveRuntimeCodexFallbackModel({
|
||||
requestedModel: 'gpt-5.4',
|
||||
modelMap: {
|
||||
defaultModel: 'gpt-5.4',
|
||||
haikuModel: 'gpt-5-codex-mini',
|
||||
},
|
||||
excludeModels: ['gpt-5-codex'],
|
||||
})
|
||||
).toBe('gpt-5-codex-mini');
|
||||
});
|
||||
|
||||
it('tracks Codex thinking caps for current safe defaults and paid models', () => {
|
||||
expect(getModelMaxLevel('codex', 'gpt-5-codex')).toBe('high');
|
||||
expect(getModelMaxLevel('codex', 'gpt-5-codex-mini')).toBe('high');
|
||||
|
||||
@@ -230,6 +230,90 @@ describe('CodexReasoningProxy extended-context compatibility', () => {
|
||||
expect(capturedBody?.model).toBe('enterprise-internal-high');
|
||||
});
|
||||
|
||||
it('retries unsupported live-session models once and remembers the fallback', async () => {
|
||||
const capturedModels: string[] = [];
|
||||
const capturedEfforts: Array<string | undefined> = [];
|
||||
|
||||
const upstream = http.createServer((req, res) => {
|
||||
let rawBody = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk) => {
|
||||
rawBody += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
const requestBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {};
|
||||
const reasoning = requestBody.reasoning as JsonRecord | undefined;
|
||||
const model = String(requestBody.model ?? '');
|
||||
const effort = typeof reasoning?.effort === 'string' ? reasoning.effort : undefined;
|
||||
|
||||
capturedModels.push(model);
|
||||
capturedEfforts.push(effort);
|
||||
|
||||
if (model === 'gpt-5.4') {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: 'The requested model is not supported.',
|
||||
code: 'model_not_supported',
|
||||
param: 'model',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
model,
|
||||
effort: effort ?? null,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
cleanupServers.push(upstream);
|
||||
|
||||
const upstreamPort = await listenOnRandomPort(upstream);
|
||||
const proxy = new CodexReasoningProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
modelMap: {
|
||||
defaultModel: 'gpt-5.4',
|
||||
haikuModel: 'gpt-5-codex-mini',
|
||||
},
|
||||
defaultEffort: 'medium',
|
||||
});
|
||||
|
||||
const proxyPort = await proxy.start();
|
||||
const firstResponse = await postJson(
|
||||
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||
{
|
||||
model: 'gpt-5.4-xhigh',
|
||||
messages: [],
|
||||
}
|
||||
);
|
||||
const secondResponse = await postJson(
|
||||
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||
{
|
||||
model: 'gpt-5.4-xhigh',
|
||||
messages: [],
|
||||
}
|
||||
);
|
||||
|
||||
proxy.stop();
|
||||
|
||||
expect(firstResponse.statusCode).toBe(200);
|
||||
expect(secondResponse.statusCode).toBe(200);
|
||||
expect(firstResponse.body.model).toBe('gpt-5-codex');
|
||||
expect(firstResponse.body.effort).toBe('high');
|
||||
expect(secondResponse.body.model).toBe('gpt-5-codex');
|
||||
expect(secondResponse.body.effort).toBe('high');
|
||||
expect(capturedModels).toEqual(['gpt-5.4', 'gpt-5-codex', 'gpt-5-codex']);
|
||||
expect(capturedEfforts).toEqual(['xhigh', 'high', 'high']);
|
||||
});
|
||||
|
||||
it('keeps reasoning enabled when CCS_THINKING=high overrides config off', async () => {
|
||||
let capturedBody: JsonRecord | null = null;
|
||||
|
||||
|
||||
@@ -90,6 +90,6 @@ describe('buildClaudeEnvironment - composite remote routing', () => {
|
||||
expect(env.ANTHROPIC_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/);
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/);
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toMatch(/^gemini-2.5-pro(\([^)]+\))?$/);
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini');
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toMatch(/^gpt-5.1-codex-mini(?:-medium)?$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,14 @@ describe('api-command arg parser', () => {
|
||||
expect(parsed.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('parses --cliproxy-provider for routed API profile creation', () => {
|
||||
const parsed = parseApiCommandArgs(['my-api', '--cliproxy-provider', 'Gemini']);
|
||||
|
||||
expect(parsed.name).toBe('my-api');
|
||||
expect(parsed.cliproxyProvider).toBe('gemini');
|
||||
expect(parsed.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('validates invalid --target values', () => {
|
||||
const parsed = parseApiCommandArgs(['my-api', '--target', 'invalid-target']);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
const startServerCalls: Array<Record<string, unknown>> = [];
|
||||
const resolveDashboardUrlsCalls: Array<[string | undefined, number]> = [];
|
||||
const configAuthCalls: string[][] = [];
|
||||
let logLines: string[] = [];
|
||||
let errorLines: string[] = [];
|
||||
@@ -14,7 +13,6 @@ let originalProcessExit: typeof process.exit;
|
||||
|
||||
beforeEach(() => {
|
||||
startServerCalls.length = 0;
|
||||
resolveDashboardUrlsCalls.length = 0;
|
||||
configAuthCalls.length = 0;
|
||||
logLines = [];
|
||||
errorLines = [];
|
||||
@@ -91,42 +89,6 @@ beforeEach(() => {
|
||||
mock.module('../../../src/utils/ui', () => uiModule);
|
||||
mock.module('../../../src/utils/ui.ts', () => uiModule);
|
||||
|
||||
mock.module('../../../src/commands/config-dashboard-host', () => ({
|
||||
normalizeDashboardHost: (host: string | undefined) => {
|
||||
if (!host) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (host.startsWith('[') && host.endsWith(']') && host.includes(':')) {
|
||||
return host.slice(1, -1);
|
||||
}
|
||||
|
||||
return host;
|
||||
},
|
||||
isLoopbackHost: (host: string) =>
|
||||
['localhost', '127.0.0.1', '::1', '[::1]'].includes(host.trim().toLowerCase()),
|
||||
isWildcardHost: (host: string) => ['0.0.0.0', '::', '[::]'].includes(host.trim().toLowerCase()),
|
||||
resolveDashboardUrls: (host: string | undefined, port: number) => {
|
||||
resolveDashboardUrlsCalls.push([host, port]);
|
||||
if (!host) {
|
||||
return { browserUrl: `http://localhost:${port}` };
|
||||
}
|
||||
|
||||
if (host === '0.0.0.0' || host === '::') {
|
||||
return {
|
||||
bindHost: host,
|
||||
browserUrl: `http://localhost:${port}`,
|
||||
networkUrls: [`http://192.168.1.25:${port}`, `http://100.64.0.12:${port}`],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
bindHost: host,
|
||||
browserUrl: `http://${host}:${port}`,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/commands/config-auth', () => ({
|
||||
handleConfigAuthCommand: async (args: string[]) => {
|
||||
configAuthCalls.push([...args]);
|
||||
@@ -158,7 +120,6 @@ describe('config command dashboard startup', () => {
|
||||
await expect(handleConfigCommand(['help'])).rejects.toThrow('process.exit(0)');
|
||||
|
||||
expect(startServerCalls).toHaveLength(0);
|
||||
expect(resolveDashboardUrlsCalls).toHaveLength(0);
|
||||
expect(logLines.join('\n')).toContain('Usage: ccs config [command] [options]');
|
||||
});
|
||||
|
||||
@@ -169,7 +130,6 @@ describe('config command dashboard startup', () => {
|
||||
|
||||
expect(configAuthCalls).toEqual([['setup']]);
|
||||
expect(startServerCalls).toHaveLength(0);
|
||||
expect(resolveDashboardUrlsCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects unknown config subcommands before dashboard startup', async () => {
|
||||
@@ -181,7 +141,6 @@ describe('config command dashboard startup', () => {
|
||||
await expect(handleConfigCommand(['bogus'])).rejects.toThrow('process.exit(1)');
|
||||
|
||||
expect(startServerCalls).toHaveLength(0);
|
||||
expect(resolveDashboardUrlsCalls).toHaveLength(0);
|
||||
expect(errorLines.join('\n')).toContain('Unexpected arguments: bogus');
|
||||
});
|
||||
|
||||
@@ -192,12 +151,11 @@ describe('config command dashboard startup', () => {
|
||||
|
||||
expect(startServerCalls).toHaveLength(1);
|
||||
expect(startServerCalls[0]).toEqual({ port: 3000, dev: false });
|
||||
expect(resolveDashboardUrlsCalls).toEqual([['::', 3000]]);
|
||||
|
||||
const rendered = logLines.join('\n');
|
||||
expect(rendered).toContain('Dashboard: http://localhost:3000');
|
||||
expect(rendered).toContain('Bind host: ::');
|
||||
expect(rendered).toContain('Network URLs:');
|
||||
expect(rendered).toContain('Dashboard may be reachable from other devices that can connect to this machine.');
|
||||
expect(rendered).toContain('Protect it before sharing: ccs config auth setup');
|
||||
expect(errorLines).toHaveLength(0);
|
||||
});
|
||||
@@ -210,13 +168,10 @@ describe('config command dashboard startup', () => {
|
||||
|
||||
expect(startServerCalls).toHaveLength(1);
|
||||
expect(startServerCalls[0]).toEqual({ port: 4100, dev: false, host: '0.0.0.0' });
|
||||
expect(resolveDashboardUrlsCalls).toEqual([['0.0.0.0', 4100]]);
|
||||
|
||||
const rendered = logLines.join('\n');
|
||||
expect(rendered).toContain('Dashboard: http://localhost:4100');
|
||||
expect(rendered).toContain('Bind host: 0.0.0.0');
|
||||
expect(rendered).toContain('Network URLs:');
|
||||
expect(rendered).toContain('http://192.168.1.25:4100');
|
||||
expect(rendered).toContain('http://100.64.0.12:4100');
|
||||
expect(rendered).toContain(
|
||||
'Dashboard may be reachable from other devices that can connect to this machine.'
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as lockfile from 'proper-lockfile';
|
||||
import { handlePersistCommand } from '../../../src/commands/persist-command';
|
||||
import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types';
|
||||
import { saveUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
import { runWithScopedCcsHome } from '../../../src/utils/config-manager';
|
||||
|
||||
interface RestoreFixture {
|
||||
claudeDir: string;
|
||||
@@ -18,11 +19,14 @@ interface RestoreFixture {
|
||||
|
||||
let tempRoot: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalProcessExit: typeof process.exit;
|
||||
let originalFsOpen: typeof fs.promises.open;
|
||||
let originalFsRename: typeof fs.promises.rename;
|
||||
|
||||
async function withScopedHome<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return await runWithScopedCcsHome(tempRoot, fn);
|
||||
}
|
||||
|
||||
async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.access(filePath, fs.constants.F_OK);
|
||||
@@ -69,11 +73,9 @@ function stubProcessExit(): void {
|
||||
beforeEach(async () => {
|
||||
tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ccs-persist-handler-test-'));
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalProcessExit = process.exit;
|
||||
originalFsOpen = fs.promises.open;
|
||||
originalFsRename = fs.promises.rename;
|
||||
process.env.CCS_HOME = tempRoot;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -87,12 +89,6 @@ afterEach(async () => {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
}
|
||||
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
|
||||
if (tempRoot) {
|
||||
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -100,50 +96,50 @@ afterEach(async () => {
|
||||
|
||||
describe('persist command real handler paths', () => {
|
||||
it('throws parseError for missing --permission-mode before profile detection', async () => {
|
||||
await expect(handlePersistCommand(['glm', '--permission-mode'])).rejects.toThrow(
|
||||
await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode']))).rejects.toThrow(
|
||||
'Missing value for --permission-mode'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws parseError for empty inline --permission-mode before profile detection', async () => {
|
||||
await expect(handlePersistCommand(['glm', '--permission-mode='])).rejects.toThrow(
|
||||
await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode=']))).rejects.toThrow(
|
||||
'Missing value for --permission-mode'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws parseError for invalid --permission-mode before profile detection', async () => {
|
||||
await expect(handlePersistCommand(['glm', '--permission-mode', 'invalid-mode'])).rejects.toThrow(
|
||||
/Invalid --permission-mode/
|
||||
);
|
||||
await expect(
|
||||
withScopedHome(() => handlePersistCommand(['glm', '--permission-mode', 'invalid-mode']))
|
||||
).rejects.toThrow(/Invalid --permission-mode/);
|
||||
});
|
||||
|
||||
it('throws parseError for unknown flags on real handler path', async () => {
|
||||
await expect(handlePersistCommand(['glm', '--unknown-flag'])).rejects.toThrow(
|
||||
await expect(withScopedHome(() => handlePersistCommand(['glm', '--unknown-flag']))).rejects.toThrow(
|
||||
/Unknown option\(s\)/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws parseError for list/restore conflict on real handler path', async () => {
|
||||
await expect(handlePersistCommand(['--list-backups', '--restore'])).rejects.toThrow(
|
||||
await expect(withScopedHome(() => handlePersistCommand(['--list-backups', '--restore']))).rejects.toThrow(
|
||||
'--list-backups cannot be used with --restore'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws parseError for permission flags with --restore on real handler path', async () => {
|
||||
await expect(handlePersistCommand(['--restore', '--auto-approve'])).rejects.toThrow(
|
||||
await expect(withScopedHome(() => handlePersistCommand(['--restore', '--auto-approve']))).rejects.toThrow(
|
||||
/Permission flags are not valid with backup operations/
|
||||
);
|
||||
});
|
||||
|
||||
it('shows help when --help is present even with other invalid args', async () => {
|
||||
await expect(handlePersistCommand(['--help', '--permission-mode'])).resolves.toBeUndefined();
|
||||
await expect(withScopedHome(() => handlePersistCommand(['--help', '--permission-mode']))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not create CLAUDE_CONFIG_DIR on parseError path', async () => {
|
||||
const isolatedClaudeDir = path.join(tempRoot, '.claude-parse-early');
|
||||
process.env.CLAUDE_CONFIG_DIR = isolatedClaudeDir;
|
||||
|
||||
await expect(handlePersistCommand(['glm', '--permission-mode='])).rejects.toThrow(
|
||||
await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode=']))).rejects.toThrow(
|
||||
'Missing value for --permission-mode'
|
||||
);
|
||||
expect(await pathExists(isolatedClaudeDir)).toBe(false);
|
||||
@@ -163,9 +159,9 @@ describe('persist command restore failure handling', () => {
|
||||
|
||||
stubProcessExit();
|
||||
try {
|
||||
await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow(
|
||||
'process.exit(1)'
|
||||
);
|
||||
await expect(
|
||||
withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes']))
|
||||
).rejects.toThrow('process.exit(1)');
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
@@ -186,9 +182,9 @@ describe('persist command restore failure handling', () => {
|
||||
}) as typeof fs.promises.open;
|
||||
|
||||
stubProcessExit();
|
||||
await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow(
|
||||
'process.exit(1)'
|
||||
);
|
||||
await expect(
|
||||
withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes']))
|
||||
).rejects.toThrow('process.exit(1)');
|
||||
});
|
||||
|
||||
it('exits when backup read fails with ELOOP (symlink rejection)', async () => {
|
||||
@@ -206,9 +202,9 @@ describe('persist command restore failure handling', () => {
|
||||
}) as typeof fs.promises.open;
|
||||
|
||||
stubProcessExit();
|
||||
await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow(
|
||||
'process.exit(1)'
|
||||
);
|
||||
await expect(
|
||||
withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes']))
|
||||
).rejects.toThrow('process.exit(1)');
|
||||
});
|
||||
|
||||
it('exits when backup path resolves to a non-regular file', async () => {
|
||||
@@ -229,9 +225,9 @@ describe('persist command restore failure handling', () => {
|
||||
}) as typeof fs.promises.open;
|
||||
|
||||
stubProcessExit();
|
||||
await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow(
|
||||
'process.exit(1)'
|
||||
);
|
||||
await expect(
|
||||
withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes']))
|
||||
).rejects.toThrow('process.exit(1)');
|
||||
});
|
||||
|
||||
it('rolls back settings when restore write fails mid-flight', async () => {
|
||||
@@ -248,9 +244,9 @@ describe('persist command restore failure handling', () => {
|
||||
}) as typeof fs.promises.rename;
|
||||
|
||||
stubProcessExit();
|
||||
await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow(
|
||||
'process.exit(1)'
|
||||
);
|
||||
await expect(
|
||||
withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes']))
|
||||
).rejects.toThrow('process.exit(1)');
|
||||
|
||||
const finalContent = await fs.promises.readFile(fixture.settingsPath, 'utf8');
|
||||
const finalSettings = JSON.parse(finalContent);
|
||||
@@ -273,9 +269,9 @@ describe('persist command restore failure handling', () => {
|
||||
|
||||
stubProcessExit();
|
||||
try {
|
||||
await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow(
|
||||
'process.exit(1)'
|
||||
);
|
||||
await expect(
|
||||
withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes']))
|
||||
).rejects.toThrow('process.exit(1)');
|
||||
expect(capturedLogs.some((line) => line.includes('Rollback also failed'))).toBe(true);
|
||||
} finally {
|
||||
console.log = originalConsoleLog;
|
||||
@@ -313,7 +309,9 @@ describe('persist command Claude extension parity', () => {
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
saveUnifiedConfig(config);
|
||||
await withScopedHome(async () => {
|
||||
saveUnifiedConfig(config);
|
||||
});
|
||||
}
|
||||
|
||||
it('persists account profiles via CLAUDE_CONFIG_DIR and clears stale managed env keys', async () => {
|
||||
@@ -337,7 +335,7 @@ describe('persist command Claude extension parity', () => {
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await handlePersistCommand(['work', '--yes']);
|
||||
await withScopedHome(() => handlePersistCommand(['work', '--yes']));
|
||||
|
||||
const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as {
|
||||
env: Record<string, string>;
|
||||
@@ -371,7 +369,7 @@ describe('persist command Claude extension parity', () => {
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await handlePersistCommand(['default', '--yes']);
|
||||
await withScopedHome(() => handlePersistCommand(['default', '--yes']));
|
||||
|
||||
const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as {
|
||||
env: Record<string, string>;
|
||||
|
||||
@@ -4,9 +4,9 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { resolveLifecyclePort } from '../../../src/commands/cliproxy/proxy-lifecycle-subcommand';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager';
|
||||
import { runWithScopedConfigDir } from '../../../src/utils/config-manager';
|
||||
|
||||
let tempDir: string;
|
||||
let originalCcsDir: string | undefined;
|
||||
|
||||
function writeUnifiedConfig(localPort: number): void {
|
||||
const configPath = path.join(tempDir, 'config.yaml');
|
||||
@@ -34,33 +34,31 @@ cliproxy_server:
|
||||
describe('resolveLifecyclePort', () => {
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-lifecycle-'));
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
process.env.CCS_DIR = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsDir !== undefined) {
|
||||
process.env.CCS_DIR = originalCcsDir;
|
||||
} else {
|
||||
delete process.env.CCS_DIR;
|
||||
}
|
||||
|
||||
if (tempDir && fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('uses configured cliproxy_server.local.port', () => {
|
||||
it('uses configured cliproxy_server.local.port', async () => {
|
||||
writeUnifiedConfig(9456);
|
||||
expect(resolveLifecyclePort()).toBe(9456);
|
||||
await runWithScopedConfigDir(tempDir, () => {
|
||||
expect(resolveLifecyclePort()).toBe(9456);
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to default port when configured local port is invalid', () => {
|
||||
it('falls back to default port when configured local port is invalid', async () => {
|
||||
writeUnifiedConfig(70000);
|
||||
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
await runWithScopedConfigDir(tempDir, () => {
|
||||
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to default port when config file is missing', () => {
|
||||
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
it('falls back to default port when config file is missing', async () => {
|
||||
await runWithScopedConfigDir(tempDir, () => {
|
||||
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,16 @@ describe('InstanceManager MCP sync', () => {
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsDir: string | undefined;
|
||||
|
||||
const claudeDir = () => path.join(tempRoot, '.claude');
|
||||
const marketplacePath = (configDir: string, name = 'claude-code-plugins') =>
|
||||
path.join(configDir, 'plugins', 'marketplaces', name);
|
||||
const readJson = (filePath: string) =>
|
||||
JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, unknown>;
|
||||
|
||||
function ensureMarketplacePayload(configDir: string, name = 'claude-code-plugins'): void {
|
||||
fs.mkdirSync(marketplacePath(configDir, name), { recursive: true });
|
||||
}
|
||||
|
||||
function writeMarketplaceRegistry(registryPath: string, installLocation: string): void {
|
||||
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
@@ -28,6 +38,33 @@ describe('InstanceManager MCP sync', () => {
|
||||
);
|
||||
}
|
||||
|
||||
function writeMarketplaceRegistryWithMetadata(
|
||||
registryPath: string,
|
||||
installLocation: string,
|
||||
metadata: Record<string, unknown>
|
||||
): void {
|
||||
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
'claude-code-plugins': {
|
||||
installLocation,
|
||||
...metadata,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
function expectMarketplaceLocation(registryPath: string, expectedLocation: string): void {
|
||||
const parsed = readJson(registryPath) as Record<string, { installLocation?: string }>;
|
||||
expect(parsed['claude-code-plugins']?.installLocation).toBe(expectedLocation);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-'));
|
||||
originalHome = process.env.HOME;
|
||||
@@ -95,9 +132,10 @@ describe('InstanceManager MCP sync', () => {
|
||||
const synced = manager.syncMcpServers(instancePath);
|
||||
expect(synced).toBe(true);
|
||||
|
||||
const instanceContent = JSON.parse(
|
||||
fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8')
|
||||
);
|
||||
const instanceContent = readJson(path.join(instancePath, '.claude.json')) as {
|
||||
otherKey: string;
|
||||
mcpServers: Record<string, { command: string }>;
|
||||
};
|
||||
expect(instanceContent.otherKey).toBe('keep-me');
|
||||
expect(instanceContent.mcpServers).toEqual({
|
||||
globalOnly: { command: 'global-cmd' },
|
||||
@@ -121,6 +159,17 @@ describe('InstanceManager MCP sync', () => {
|
||||
expect(String(warnSpy.mock.calls[0]?.[0] || '')).toContain('MCP sync skipped');
|
||||
});
|
||||
|
||||
it('does not list lock housekeeping as an instance', async () => {
|
||||
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||
spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(() => false);
|
||||
|
||||
const manager = new InstanceManager();
|
||||
await manager.ensureInstance('work', { mode: 'isolated' });
|
||||
|
||||
expect(manager.listInstances()).toEqual(['work']);
|
||||
});
|
||||
|
||||
it('skips shared symlinks and MCP sync for bare instance creation', async () => {
|
||||
const linkSharedSpy = spyOn(
|
||||
SharedManager.prototype,
|
||||
@@ -134,9 +183,10 @@ describe('InstanceManager MCP sync', () => {
|
||||
|
||||
const manager = new InstanceManager();
|
||||
const instancePath = manager.getInstancePath('sandbox');
|
||||
const sharedRegistryPath = path.join(tempRoot, '.claude', 'plugins', 'known_marketplaces.json');
|
||||
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
writeMarketplaceRegistry(
|
||||
sharedRegistryPath,
|
||||
globalRegistryPath,
|
||||
path.join(
|
||||
tempRoot,
|
||||
'.ccs',
|
||||
@@ -150,20 +200,100 @@ describe('InstanceManager MCP sync', () => {
|
||||
|
||||
await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true });
|
||||
|
||||
const normalized = JSON.parse(fs.readFileSync(sharedRegistryPath, 'utf8'));
|
||||
|
||||
expect(linkSharedSpy).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(instancePath)).toBe(true);
|
||||
expect(normalized['claude-code-plugins'].installLocation).toBe(
|
||||
path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins')
|
||||
);
|
||||
expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir()));
|
||||
expect(fs.existsSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'))).toBe(
|
||||
false
|
||||
);
|
||||
expect(syncMcpSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes shared plugin metadata for existing non-bare instances', async () => {
|
||||
it('detaches existing shared layout when an instance is reopened as bare', async () => {
|
||||
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||
() => false
|
||||
);
|
||||
|
||||
const manager = new InstanceManager();
|
||||
const instancePath = await manager.ensureInstance('work', { mode: 'isolated' });
|
||||
syncMcpSpy.mockClear();
|
||||
|
||||
await manager.ensureInstance('work', { mode: 'isolated' }, { bare: true });
|
||||
|
||||
expect(fs.existsSync(path.join(instancePath, 'settings.json'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(instancePath, 'commands'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(instancePath, 'skills'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(instancePath, 'agents'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(instancePath, 'plugins'))).toBe(false);
|
||||
expect(syncMcpSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the shared layout when a bare-reopened instance is switched back to non-bare', async () => {
|
||||
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||
() => false
|
||||
);
|
||||
|
||||
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
writeMarketplaceRegistry(globalRegistryPath, marketplacePath(claudeDir()));
|
||||
|
||||
const manager = new InstanceManager();
|
||||
const instancePath = await manager.ensureInstance('work', { mode: 'isolated' });
|
||||
await manager.ensureInstance('work', { mode: 'isolated' }, { bare: true });
|
||||
syncMcpSpy.mockClear();
|
||||
|
||||
await manager.ensureInstance('work', { mode: 'isolated' });
|
||||
|
||||
expect(fs.lstatSync(path.join(instancePath, 'settings.json')).isSymbolicLink()).toBe(true);
|
||||
expectMarketplaceLocation(
|
||||
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
|
||||
marketplacePath(instancePath)
|
||||
);
|
||||
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
|
||||
});
|
||||
|
||||
it('preserves genuine bare-local content when re-ensuring a bare instance', async () => {
|
||||
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||
() => false
|
||||
);
|
||||
|
||||
const manager = new InstanceManager();
|
||||
const instancePath = manager.getInstancePath('sandbox');
|
||||
fs.mkdirSync(path.join(instancePath, 'plugins', 'marketplaces', 'custom-market'), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.mkdirSync(path.join(instancePath, 'commands'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(instancePath, 'settings.json'),
|
||||
JSON.stringify({ local: true }, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(path.join(instancePath, 'commands', 'local.md'), '# local', 'utf8');
|
||||
writeMarketplaceRegistry(
|
||||
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
|
||||
marketplacePath(instancePath, 'custom-market')
|
||||
);
|
||||
|
||||
await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true });
|
||||
|
||||
expect(readJson(path.join(instancePath, 'settings.json'))).toEqual({ local: true });
|
||||
expect(fs.readFileSync(path.join(instancePath, 'commands', 'local.md'), 'utf8')).toBe(
|
||||
'# local'
|
||||
);
|
||||
expectMarketplaceLocation(
|
||||
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
|
||||
marketplacePath(instancePath, 'custom-market')
|
||||
);
|
||||
expect(syncMcpSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rewrites existing non-bare instance marketplace metadata to the instance-local plugin dir', async () => {
|
||||
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||
@@ -172,40 +302,32 @@ describe('InstanceManager MCP sync', () => {
|
||||
|
||||
const manager = new InstanceManager();
|
||||
const instancePath = manager.getInstancePath('work');
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
writeMarketplaceRegistry(
|
||||
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
|
||||
path.join(
|
||||
tempRoot,
|
||||
'.ccs',
|
||||
'instances',
|
||||
'work',
|
||||
'plugins',
|
||||
'marketplaces',
|
||||
'claude-code-plugins'
|
||||
)
|
||||
path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins')
|
||||
);
|
||||
|
||||
await manager.ensureInstance('work', { mode: 'isolated' });
|
||||
|
||||
const normalized = JSON.parse(
|
||||
fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8')
|
||||
);
|
||||
expect(normalized['claude-code-plugins'].installLocation).toBe(
|
||||
path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins')
|
||||
expectMarketplaceLocation(
|
||||
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
|
||||
marketplacePath(instancePath)
|
||||
);
|
||||
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
|
||||
});
|
||||
|
||||
it('normalizes shared plugin metadata during new non-bare instance creation', async () => {
|
||||
it('writes new non-bare instance marketplace metadata without clobbering the global copy', async () => {
|
||||
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||
() => false
|
||||
);
|
||||
|
||||
const registryPath = path.join(tempRoot, '.claude', 'plugins', 'known_marketplaces.json');
|
||||
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
writeMarketplaceRegistry(
|
||||
registryPath,
|
||||
globalRegistryPath,
|
||||
path.join(
|
||||
tempRoot,
|
||||
'.ccs',
|
||||
@@ -220,20 +342,110 @@ describe('InstanceManager MCP sync', () => {
|
||||
const manager = new InstanceManager();
|
||||
const instancePath = await manager.ensureInstance('work', { mode: 'isolated' });
|
||||
|
||||
const expected = path.join(
|
||||
tempRoot,
|
||||
'.claude',
|
||||
'plugins',
|
||||
'marketplaces',
|
||||
'claude-code-plugins'
|
||||
expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir()));
|
||||
expectMarketplaceLocation(
|
||||
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
|
||||
marketplacePath(instancePath)
|
||||
);
|
||||
const normalizedShared = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||
const normalizedInstance = JSON.parse(
|
||||
fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8')
|
||||
);
|
||||
|
||||
expect(normalizedShared['claude-code-plugins'].installLocation).toBe(expected);
|
||||
expect(normalizedInstance['claude-code-plugins'].installLocation).toBe(expected);
|
||||
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
|
||||
});
|
||||
|
||||
it('reconciles marketplace metadata across isolated instances without losing refresh fields', async () => {
|
||||
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||
|
||||
const manager = new InstanceManager();
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
const workPath = await manager.ensureInstance('work', { mode: 'isolated' });
|
||||
const workRegistryPath = path.join(workPath, 'plugins', 'known_marketplaces.json');
|
||||
writeMarketplaceRegistryWithMetadata(workRegistryPath, marketplacePath(workPath), {
|
||||
label: 'Official marketplace',
|
||||
refreshToken: 'refresh-token',
|
||||
metadata: {
|
||||
source: 'refresh-flow',
|
||||
lastSyncedAt: '2026-03-18T00:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
const personalPath = await manager.ensureInstance('personal', { mode: 'isolated' });
|
||||
const workRegistry = readJson(workRegistryPath) as Record<
|
||||
string,
|
||||
{
|
||||
installLocation?: string;
|
||||
label?: string;
|
||||
refreshToken?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
>;
|
||||
const personalRegistry = readJson(path.join(personalPath, 'plugins', 'known_marketplaces.json')) as Record<
|
||||
string,
|
||||
{
|
||||
installLocation?: string;
|
||||
label?: string;
|
||||
refreshToken?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
>;
|
||||
|
||||
expect(workRegistry['claude-code-plugins']).toMatchObject({
|
||||
installLocation: marketplacePath(workPath),
|
||||
label: 'Official marketplace',
|
||||
refreshToken: 'refresh-token',
|
||||
metadata: {
|
||||
source: 'refresh-flow',
|
||||
lastSyncedAt: '2026-03-18T00:00:00Z',
|
||||
},
|
||||
});
|
||||
expect(personalRegistry['claude-code-plugins']).toMatchObject({
|
||||
installLocation: marketplacePath(personalPath),
|
||||
label: 'Official marketplace',
|
||||
refreshToken: 'refresh-token',
|
||||
metadata: {
|
||||
source: 'refresh-flow',
|
||||
lastSyncedAt: '2026-03-18T00:00:00Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('upgrades a legacy shared plugins symlink to an instance-local layout', async () => {
|
||||
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||
|
||||
const manager = new InstanceManager();
|
||||
const legacyPath = manager.getInstancePath('legacy');
|
||||
const sharedPluginsPath = path.join(tempRoot, '.ccs', 'shared', 'plugins');
|
||||
fs.mkdirSync(sharedPluginsPath, { recursive: true });
|
||||
fs.mkdirSync(legacyPath, { recursive: true });
|
||||
fs.symlinkSync(sharedPluginsPath, path.join(legacyPath, 'plugins'), 'dir');
|
||||
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
writeMarketplaceRegistryWithMetadata(
|
||||
path.join(claudeDir(), 'plugins', 'known_marketplaces.json'),
|
||||
path.join(tempRoot, '.ccs', 'shared', 'plugins', 'marketplaces', 'claude-code-plugins'),
|
||||
{
|
||||
label: 'Legacy marketplace',
|
||||
refreshToken: 'legacy-refresh-token',
|
||||
}
|
||||
);
|
||||
|
||||
await manager.ensureInstance('legacy', { mode: 'isolated' });
|
||||
|
||||
expect(fs.lstatSync(path.join(legacyPath, 'plugins')).isSymbolicLink()).toBe(false);
|
||||
expectMarketplaceLocation(
|
||||
path.join(legacyPath, 'plugins', 'known_marketplaces.json'),
|
||||
marketplacePath(legacyPath)
|
||||
);
|
||||
|
||||
const legacyRegistry = readJson(
|
||||
path.join(legacyPath, 'plugins', 'known_marketplaces.json')
|
||||
) as Record<
|
||||
string,
|
||||
{ installLocation?: string; label?: string; refreshToken?: string }
|
||||
>;
|
||||
expect(legacyRegistry['claude-code-plugins']).toMatchObject({
|
||||
installLocation: marketplacePath(legacyPath),
|
||||
label: 'Legacy marketplace',
|
||||
refreshToken: 'legacy-refresh-token',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { createHash } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import ProfileContextSyncLock from '../../src/management/profile-context-sync-lock';
|
||||
|
||||
describe('ProfileContextSyncLock', () => {
|
||||
let tempRoot = '';
|
||||
let instancesDir = '';
|
||||
|
||||
const getLockPath = (lockName: string): string => {
|
||||
const safeName = lockName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
|
||||
const profileHash = createHash('sha1').update(lockName).digest('hex').slice(0, 8);
|
||||
return path.join(instancesDir, '.locks', `${safeName}-${profileHash}.lock`);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-context-lock-test-'));
|
||||
instancesDir = path.join(tempRoot, 'instances');
|
||||
fs.mkdirSync(instancesDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (tempRoot && fs.existsSync(tempRoot)) {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('acquires and releases synchronous named locks', () => {
|
||||
const lock = new ProfileContextSyncLock(instancesDir);
|
||||
const lockPath = getLockPath('__plugin-layout__');
|
||||
|
||||
let sawLockInsideCallback = false;
|
||||
const result = lock.withNamedLockSync('__plugin-layout__', () => {
|
||||
sawLockInsideCallback = fs.existsSync(lockPath);
|
||||
expect(fs.readFileSync(lockPath, 'utf8')).toContain(`"pid":${process.pid}`);
|
||||
return 'ok';
|
||||
});
|
||||
|
||||
expect(result).toBe('ok');
|
||||
expect(sawLockInsideCallback).toBe(true);
|
||||
expect(fs.existsSync(lockPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('releases synchronous named locks when the callback throws', () => {
|
||||
const lock = new ProfileContextSyncLock(instancesDir);
|
||||
const lockPath = getLockPath('__plugin-layout__');
|
||||
|
||||
expect(() =>
|
||||
lock.withNamedLockSync('__plugin-layout__', () => {
|
||||
throw new Error('boom');
|
||||
})
|
||||
).toThrow('boom');
|
||||
expect(fs.existsSync(lockPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('reclaims dead-owner locks before entering the callback', () => {
|
||||
const lock = new ProfileContextSyncLock(instancesDir);
|
||||
const lockPath = getLockPath('__plugin-layout__');
|
||||
|
||||
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
pid: 999999,
|
||||
nonce: 'dead-owner',
|
||||
acquiredAtMs: Date.now() - 1000,
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = lock.withNamedLockSync('__plugin-layout__', () => 'reclaimed');
|
||||
|
||||
expect(result).toBe('reclaimed');
|
||||
expect(fs.existsSync(lockPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('reclaims malformed stale locks before entering the callback', () => {
|
||||
const lock = new ProfileContextSyncLock(instancesDir);
|
||||
const lockPath = getLockPath('__plugin-layout__');
|
||||
const staleDate = new Date(Date.now() - 60_000);
|
||||
|
||||
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
||||
fs.writeFileSync(lockPath, 'not-json', 'utf8');
|
||||
fs.utimesSync(lockPath, staleDate, staleDate);
|
||||
|
||||
const result = lock.withNamedLockSync('__plugin-layout__', () => 'stale-reclaimed');
|
||||
|
||||
expect(result).toBe('stale-reclaimed');
|
||||
expect(fs.existsSync(lockPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
+226
-185
@@ -1,19 +1,12 @@
|
||||
/**
|
||||
* Unit tests for SharedManager - plugin registry path normalization
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import SharedManager, {
|
||||
normalizePluginMetadataContent,
|
||||
normalizePluginMetadataPathString,
|
||||
} from '../../src/management/shared-manager';
|
||||
|
||||
// Test the normalization regex pattern directly
|
||||
const normalizePluginPaths = (content: string): string => {
|
||||
return normalizePluginMetadataPathString(content);
|
||||
};
|
||||
|
||||
describe('SharedManager', () => {
|
||||
let tempRoot = '';
|
||||
let originalHome: string | undefined;
|
||||
@@ -21,6 +14,28 @@ describe('SharedManager', () => {
|
||||
let originalCcsDir: string | undefined;
|
||||
let originalPlatform: PropertyDescriptor | undefined;
|
||||
|
||||
const claudeDir = () => path.join(tempRoot, '.claude');
|
||||
const ccsDir = () => path.join(tempRoot, '.ccs');
|
||||
const instanceDir = (name: string) => path.join(ccsDir(), 'instances', name);
|
||||
const marketplacePath = (configDir: string, name = 'claude-code-plugins') =>
|
||||
path.join(configDir, 'plugins', 'marketplaces', name);
|
||||
const readJson = (filePath: string) =>
|
||||
JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, unknown>;
|
||||
|
||||
function ensureMarketplacePayload(configDir: string, name = 'claude-code-plugins'): void {
|
||||
fs.mkdirSync(marketplacePath(configDir, name), { recursive: true });
|
||||
}
|
||||
|
||||
function writeJson(filePath: string, value: unknown): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function readMarketplaceLocation(filePath: string, name = 'claude-code-plugins'): string {
|
||||
const parsed = readJson(filePath) as Record<string, { installLocation?: string }>;
|
||||
return parsed[name]?.installLocation ?? '';
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-manager-test-'));
|
||||
originalHome = process.env.HOME;
|
||||
@@ -55,218 +70,244 @@ describe('SharedManager', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('normalizePluginRegistryPaths', () => {
|
||||
describe('regex pattern', () => {
|
||||
it('should replace instance paths with canonical claude path', () => {
|
||||
const input = '/home/user/.ccs/instances/ck/plugins/cache/plugin/0.0.2';
|
||||
const expected = '/home/user/.claude/plugins/cache/plugin/0.0.2';
|
||||
expect(normalizePluginPaths(input)).toBe(expected);
|
||||
});
|
||||
describe('plugin metadata path normalization', () => {
|
||||
it('rewrites instance plugin paths to the requested target config dir', () => {
|
||||
const targetConfigDir = path.join('/home/user', '.claude');
|
||||
const input = '/home/user/.ccs/instances/work/plugins/cache/plugin/0.0.2';
|
||||
|
||||
it('should handle different instance names', () => {
|
||||
const inputs = [
|
||||
'/home/user/.ccs/instances/work/plugins/cache/plugin/1.0.0',
|
||||
'/home/user/.ccs/instances/personal/plugins/cache/plugin/1.0.0',
|
||||
'/home/user/.ccs/instances/test-account/plugins/cache/plugin/1.0.0',
|
||||
];
|
||||
for (const input of inputs) {
|
||||
expect(normalizePluginPaths(input)).toContain('/.claude/');
|
||||
expect(normalizePluginPaths(input)).not.toContain('/.ccs/instances/');
|
||||
}
|
||||
});
|
||||
expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe(
|
||||
'/home/user/.claude/plugins/cache/plugin/0.0.2'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple occurrences', () => {
|
||||
const input = JSON.stringify({
|
||||
it('rewrites shared plugin paths to an instance-local target config dir', () => {
|
||||
const targetConfigDir = instanceDir('personal');
|
||||
const input = path.join(tempRoot, '.ccs', 'shared', 'plugins', 'marketplaces', 'official');
|
||||
|
||||
expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe(
|
||||
marketplacePath(targetConfigDir, 'official')
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes all matching JSON string values without changing the structure', () => {
|
||||
const targetConfigDir = instanceDir('work');
|
||||
const input = JSON.stringify(
|
||||
{
|
||||
plugins: {
|
||||
'plugin-a': [{ installPath: '/home/user/.ccs/instances/ck/plugins/a' }],
|
||||
'plugin-b': [{ installPath: '/home/user/.ccs/instances/work/plugins/b' }],
|
||||
},
|
||||
});
|
||||
const result = normalizePluginPaths(input);
|
||||
expect(result).not.toContain('/.ccs/instances/');
|
||||
expect(result.match(/\.claude/g)?.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should not modify already-canonical paths', () => {
|
||||
const input = '/home/user/.claude/plugins/cache/plugin/0.0.2';
|
||||
expect(normalizePluginPaths(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should be idempotent', () => {
|
||||
const input = '/home/user/.ccs/instances/ck/plugins/cache/plugin/0.0.2';
|
||||
const first = normalizePluginPaths(input);
|
||||
const second = normalizePluginPaths(first);
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('should preserve JSON structure', () => {
|
||||
const original = {
|
||||
version: 2,
|
||||
plugins: {
|
||||
'claude-hud@claude-hud': [
|
||||
'plugin-a': [
|
||||
{
|
||||
scope: 'user',
|
||||
installPath:
|
||||
'/home/kai/.ccs/instances/ck/plugins/cache/claude-hud/claude-hud/0.0.2',
|
||||
version: '0.0.2',
|
||||
installPath: path.join(
|
||||
tempRoot,
|
||||
'.ccs',
|
||||
'instances',
|
||||
'old',
|
||||
'plugins',
|
||||
'cache',
|
||||
'plugin-a'
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const input = JSON.stringify(original, null, 2);
|
||||
const result = normalizePluginPaths(input);
|
||||
|
||||
// Should be valid JSON
|
||||
expect(() => JSON.parse(result)).not.toThrow();
|
||||
|
||||
// Should have normalized path
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.plugins['claude-hud@claude-hud'][0].installPath).toBe(
|
||||
'/home/kai/.claude/plugins/cache/claude-hud/claude-hud/0.0.2'
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize marketplace installLocation values', () => {
|
||||
const original = {
|
||||
'claude-code-plugins': {
|
||||
installLocation:
|
||||
'/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins',
|
||||
marketplaces: {
|
||||
official: {
|
||||
installLocation: path.join(
|
||||
tempRoot,
|
||||
'.claude',
|
||||
'plugins',
|
||||
'marketplaces',
|
||||
'official'
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
const input = JSON.stringify(original, null, 2);
|
||||
const result = normalizePluginPaths(input);
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
expect(() => JSON.parse(result)).not.toThrow();
|
||||
const normalized = JSON.parse(normalizePluginMetadataContent(input, targetConfigDir)) as {
|
||||
plugins: { 'plugin-a': [{ installPath: string }] };
|
||||
marketplaces: { official: { installLocation: string } };
|
||||
};
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed['claude-code-plugins'].installLocation).toBe(
|
||||
'/home/kai/.claude/plugins/marketplaces/claude-code-plugins'
|
||||
);
|
||||
});
|
||||
expect(normalized.plugins['plugin-a'][0].installPath).toBe(
|
||||
path.join(targetConfigDir, 'plugins', 'cache', 'plugin-a')
|
||||
);
|
||||
expect(normalized.marketplaces.official.installLocation).toBe(
|
||||
marketplacePath(targetConfigDir, 'official')
|
||||
);
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty object', () => {
|
||||
const input = JSON.stringify({});
|
||||
expect(normalizePluginPaths(input)).toBe(input);
|
||||
});
|
||||
it('preserves paths already rooted at the target config dir', () => {
|
||||
const targetConfigDir = instanceDir('work');
|
||||
const input = path.join(targetConfigDir, 'plugins', 'cache', 'plugin-a');
|
||||
|
||||
it('should handle plugins without installPath', () => {
|
||||
const input = JSON.stringify({ plugins: {} });
|
||||
expect(normalizePluginPaths(input)).toBe(input);
|
||||
});
|
||||
expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe(input);
|
||||
});
|
||||
|
||||
it('should handle Windows-style paths (backslash)', () => {
|
||||
const input = 'C:\\Users\\user\\.ccs\\instances\\ck\\plugins\\cache';
|
||||
expect(normalizePluginPaths(input)).toBe('C:\\Users\\user\\.claude\\plugins\\cache');
|
||||
});
|
||||
it('handles Windows path separators', () => {
|
||||
const targetConfigDir = 'C:\\Users\\user\\.claude';
|
||||
const input = 'C:\\Users\\user\\.ccs\\instances\\work\\plugins\\marketplaces\\official';
|
||||
|
||||
expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe(
|
||||
'C:\\Users\\user\\.claude\\plugins\\marketplaces\\official'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeMarketplaceRegistryPaths', () => {
|
||||
it('rewrites known_marketplaces.json on disk', () => {
|
||||
const pluginsDir = path.join(tempRoot, '.claude', 'plugins');
|
||||
fs.mkdirSync(pluginsDir, { recursive: true });
|
||||
describe('marketplace registry ownership', () => {
|
||||
it('writes global and instance registries with different authoritative install locations', () => {
|
||||
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
writeJson(globalRegistryPath, {
|
||||
'claude-code-plugins': {
|
||||
installLocation: path.join(
|
||||
tempRoot,
|
||||
'.ccs',
|
||||
'instances',
|
||||
'work',
|
||||
'plugins',
|
||||
'marketplaces',
|
||||
'claude-code-plugins'
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const registryPath = path.join(pluginsDir, 'known_marketplaces.json');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
'claude-code-plugins': {
|
||||
installLocation:
|
||||
'/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
const instancePath = instanceDir('personal');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
|
||||
const manager = new SharedManager();
|
||||
manager.normalizeMarketplaceRegistryPaths();
|
||||
manager.linkSharedDirectories(instancePath);
|
||||
|
||||
const normalized = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||
expect(normalized['claude-code-plugins'].installLocation).toBe(
|
||||
'/home/kai/.claude/plugins/marketplaces/claude-code-plugins'
|
||||
);
|
||||
const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json');
|
||||
expect(readMarketplaceLocation(globalRegistryPath)).toBe(marketplacePath(claudeDir()));
|
||||
expect(readMarketplaceLocation(instanceRegistryPath)).toBe(marketplacePath(instancePath));
|
||||
expect(fs.lstatSync(path.join(instancePath, 'plugins')).isSymbolicLink()).toBe(false);
|
||||
expect(fs.lstatSync(instanceRegistryPath).isSymbolicLink()).toBe(false);
|
||||
});
|
||||
|
||||
it('rewrites Windows-style known_marketplaces.json paths on disk', () => {
|
||||
const pluginsDir = path.join(tempRoot, '.claude', 'plugins');
|
||||
fs.mkdirSync(pluginsDir, { recursive: true });
|
||||
|
||||
const registryPath = path.join(pluginsDir, 'known_marketplaces.json');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
'claude-code-plugins': {
|
||||
installLocation:
|
||||
'C:\\Users\\kai\\.ccs\\instances\\work\\plugins\\marketplaces\\claude-code-plugins',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
it('self-heals missing installLocation from discovered marketplace payloads', () => {
|
||||
const manager = new SharedManager();
|
||||
manager.normalizeMarketplaceRegistryPaths();
|
||||
const instancePath = instanceDir('work');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
manager.linkSharedDirectories(instancePath);
|
||||
|
||||
const normalized = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||
expect(normalized['claude-code-plugins'].installLocation).toBe(
|
||||
'C:\\Users\\kai\\.claude\\plugins\\marketplaces\\claude-code-plugins'
|
||||
);
|
||||
fs.mkdirSync(marketplacePath(claudeDir()), { recursive: true });
|
||||
writeJson(path.join(instancePath, 'plugins', 'known_marketplaces.json'), {
|
||||
'claude-code-plugins': {
|
||||
label: 'Official marketplace',
|
||||
},
|
||||
});
|
||||
|
||||
manager.normalizeMarketplaceRegistryPaths(instancePath);
|
||||
|
||||
const repaired = readJson(
|
||||
path.join(instancePath, 'plugins', 'known_marketplaces.json')
|
||||
) as Record<string, { label?: string; installLocation?: string }>;
|
||||
expect(repaired['claude-code-plugins']).toEqual({
|
||||
label: 'Official marketplace',
|
||||
installLocation: marketplacePath(instancePath),
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes copied shared and instance metadata under Windows fallback', () => {
|
||||
it('prunes stale marketplace entries whose payload directories no longer exist', () => {
|
||||
const manager = new SharedManager();
|
||||
const instancePath = instanceDir('work');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
manager.linkSharedDirectories(instancePath);
|
||||
|
||||
fs.mkdirSync(marketplacePath(claudeDir(), 'claude-code-plugins'), { recursive: true });
|
||||
writeJson(path.join(instancePath, 'plugins', 'known_marketplaces.json'), {
|
||||
'claude-code-plugins': {
|
||||
installLocation: marketplacePath(instancePath, 'claude-code-plugins'),
|
||||
label: 'Official marketplace',
|
||||
},
|
||||
stale: {
|
||||
installLocation: marketplacePath(instancePath, 'stale'),
|
||||
label: 'Stale marketplace',
|
||||
},
|
||||
});
|
||||
|
||||
manager.normalizeMarketplaceRegistryPaths(instancePath);
|
||||
|
||||
const reconciled = readJson(
|
||||
path.join(instancePath, 'plugins', 'known_marketplaces.json')
|
||||
) as Record<string, { label?: string; installLocation?: string }>;
|
||||
expect(reconciled['claude-code-plugins']).toEqual({
|
||||
installLocation: marketplacePath(instancePath, 'claude-code-plugins'),
|
||||
label: 'Official marketplace',
|
||||
});
|
||||
expect(reconciled.stale).toBeUndefined();
|
||||
});
|
||||
|
||||
it('warns and skips malformed marketplace registries while keeping valid sources', () => {
|
||||
const manager = new SharedManager();
|
||||
const instancePath = instanceDir('work');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
manager.linkSharedDirectories(instancePath);
|
||||
|
||||
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
writeJson(globalRegistryPath, {
|
||||
'claude-code-plugins': {
|
||||
installLocation: path.join(
|
||||
tempRoot,
|
||||
'.ccs',
|
||||
'instances',
|
||||
'work',
|
||||
'plugins',
|
||||
'marketplaces',
|
||||
'claude-code-plugins'
|
||||
),
|
||||
label: 'Official marketplace',
|
||||
},
|
||||
});
|
||||
|
||||
const malformedRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json');
|
||||
fs.writeFileSync(malformedRegistryPath, '{invalid-json', 'utf8');
|
||||
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
manager.normalizeMarketplaceRegistryPaths(instancePath);
|
||||
|
||||
expect(readMarketplaceLocation(malformedRegistryPath)).toBe(marketplacePath(instancePath));
|
||||
expect(
|
||||
logSpy.mock.calls.some(
|
||||
([message]) =>
|
||||
String(message).includes('Skipping malformed marketplace registry') &&
|
||||
String(message).includes(malformedRegistryPath)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the instance-local registry valid under Windows copy fallback', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
spyOn(fs, 'symlinkSync').mockImplementation(() => {
|
||||
throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' });
|
||||
});
|
||||
|
||||
const pluginsDir = path.join(tempRoot, '.claude', 'plugins');
|
||||
fs.mkdirSync(pluginsDir, { recursive: true });
|
||||
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
writeJson(globalRegistryPath, {
|
||||
'claude-code-plugins': {
|
||||
installLocation: path.join(
|
||||
tempRoot,
|
||||
'.claude',
|
||||
'plugins',
|
||||
'marketplaces',
|
||||
'claude-code-plugins'
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const registryPath = path.join(pluginsDir, 'known_marketplaces.json');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
'claude-code-plugins': {
|
||||
installLocation:
|
||||
'/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
const instancePath = instanceDir('personal');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
|
||||
const manager = new SharedManager();
|
||||
const instancePath = path.join(tempRoot, '.ccs', 'instances', 'personal');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
manager.linkSharedDirectories(instancePath);
|
||||
|
||||
const expected = '/home/kai/.claude/plugins/marketplaces/claude-code-plugins';
|
||||
const claudeRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||
const sharedRegistry = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(tempRoot, '.ccs', 'shared', 'plugins', 'known_marketplaces.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
const instanceRegistry = JSON.parse(
|
||||
fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8')
|
||||
);
|
||||
|
||||
expect(claudeRegistry['claude-code-plugins'].installLocation).toBe(expected);
|
||||
expect(sharedRegistry['claude-code-plugins'].installLocation).toBe(expected);
|
||||
expect(instanceRegistry['claude-code-plugins'].installLocation).toBe(expected);
|
||||
const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json');
|
||||
expect(readMarketplaceLocation(globalRegistryPath)).toBe(marketplacePath(claudeDir()));
|
||||
expect(readMarketplaceLocation(instanceRegistryPath)).toBe(marketplacePath(instancePath));
|
||||
expect(fs.existsSync(path.join(instancePath, 'plugins', 'marketplaces'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,18 +14,30 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { Server } from 'http';
|
||||
import claudeExtensionRoutes from '../../../src/web-server/routes/claude-extension-routes';
|
||||
import SharedManager from '../../../src/management/shared-manager';
|
||||
import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types';
|
||||
import { saveUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
|
||||
describe('web-server claude-extension-routes', () => {
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
let setGlobalConfigDir: (dir: string | undefined) => void;
|
||||
let claudeExtensionRoutes: ReturnType<typeof express.Router>;
|
||||
let SharedManager: typeof import('../../../src/management/shared-manager').default;
|
||||
let createEmptyUnifiedConfig: typeof import('../../../src/config/unified-config-types').createEmptyUnifiedConfig;
|
||||
let saveUnifiedConfig: typeof import('../../../src/config/unified-config-loader').saveUnifiedConfig;
|
||||
|
||||
beforeAll(async () => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
({ setGlobalConfigDir } = await import('../../../src/utils/config-manager'));
|
||||
({ default: SharedManager } = await import('../../../src/management/shared-manager'));
|
||||
({ createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types'));
|
||||
({ saveUnifiedConfig } = await import('../../../src/config/unified-config-loader'));
|
||||
({ default: claudeExtensionRoutes } = await import(
|
||||
'../../../src/web-server/routes/claude-extension-routes'
|
||||
));
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/claude-extension', claudeExtensionRoutes);
|
||||
@@ -48,13 +60,23 @@ describe('web-server claude-extension-routes', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
if (server) {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
setGlobalConfigDir(undefined);
|
||||
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
else delete process.env.CLAUDE_CONFIG_DIR;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-claude-extension-routes-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
process.env.CLAUDE_CONFIG_DIR = path.join(tempHome, '.claude');
|
||||
setGlobalConfigDir(path.join(tempHome, '.ccs'));
|
||||
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
@@ -110,9 +132,9 @@ describe('web-server claude-extension-routes', () => {
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
setGlobalConfigDir(undefined);
|
||||
delete process.env.CCS_HOME;
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
|
||||
@@ -1,81 +1,57 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
||||
import express from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { Server } from 'http';
|
||||
|
||||
const installSpy = {
|
||||
calls: 0,
|
||||
};
|
||||
|
||||
mock.module('../../../src/config/unified-config-loader', () => ({
|
||||
loadOrCreateUnifiedConfig: () => ({
|
||||
cliproxy: { backend: 'plus' },
|
||||
}),
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/binary-manager', () => ({
|
||||
checkCliproxyUpdate: async () => ({
|
||||
hasUpdate: false,
|
||||
currentVersion: '6.6.80',
|
||||
latestVersion: '6.6.89',
|
||||
fromCache: false,
|
||||
checkedAt: Date.now(),
|
||||
backend: 'plus',
|
||||
backendLabel: 'CLIProxy Plus',
|
||||
isStable: true,
|
||||
maxStableVersion: '9.9.999-0',
|
||||
}),
|
||||
getInstalledCliproxyVersion: () => '6.6.80',
|
||||
installCliproxyVersion: async () => {},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/binary/version-checker', () => ({
|
||||
fetchAllVersions: async () => ({
|
||||
versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'],
|
||||
latestStable: '6.6.89',
|
||||
latest: '6.6.89',
|
||||
fromCache: false,
|
||||
checkedAt: Date.now(),
|
||||
}),
|
||||
isNewerVersion: (version: string, maxStable: string) => {
|
||||
const normalize = (value: string) => value.replace(/-\d+$/, '').split('.').map(Number);
|
||||
const versionParts = normalize(version);
|
||||
const maxStableParts = normalize(maxStable);
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const versionPart = versionParts[index] || 0;
|
||||
const maxStablePart = maxStableParts[index] || 0;
|
||||
|
||||
if (versionPart > maxStablePart) return true;
|
||||
if (versionPart < maxStablePart) return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
isVersionFaulty: (version: string) =>
|
||||
['6.6.81', '6.6.82', '6.6.83', '6.6.84', '6.6.85', '6.6.86', '6.6.87', '6.6.88'].includes(
|
||||
version
|
||||
),
|
||||
}));
|
||||
|
||||
mock.module('../../../src/web-server/services/cliproxy-dashboard-install-service', () => ({
|
||||
installDashboardCliproxyVersion: async () => {
|
||||
installSpy.calls += 1;
|
||||
return {
|
||||
success: true,
|
||||
restarted: true,
|
||||
port: 8317,
|
||||
message: 'installed',
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
let cliproxyStatsRoutes: typeof import('../../../src/web-server/routes/cliproxy-stats-routes').default;
|
||||
let createEmptyUnifiedConfig: typeof import('../../../src/config/unified-config-types').createEmptyUnifiedConfig;
|
||||
let saveUnifiedConfig: typeof import('../../../src/config/unified-config-loader').saveUnifiedConfig;
|
||||
let setGlobalConfigDir: typeof import('../../../src/utils/config-manager').setGlobalConfigDir;
|
||||
let writeInstalledVersion: typeof import('../../../src/cliproxy/binary/version-cache').writeInstalledVersion;
|
||||
let writeVersionListCache: typeof import('../../../src/cliproxy/binary/version-cache').writeVersionListCache;
|
||||
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
cliproxyStatsRoutes = (await import('../../../src/web-server/routes/cliproxy-stats-routes'))
|
||||
.default;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-install-route-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
({ setGlobalConfigDir } = await import('../../../src/utils/config-manager'));
|
||||
({ createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types'));
|
||||
({ saveUnifiedConfig } = await import('../../../src/config/unified-config-loader'));
|
||||
({ writeInstalledVersion, writeVersionListCache } = await import(
|
||||
'../../../src/cliproxy/binary/version-cache'
|
||||
));
|
||||
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
const plusBinDir = path.join(ccsDir, 'cliproxy', 'bin', 'plus');
|
||||
fs.mkdirSync(plusBinDir, { recursive: true });
|
||||
setGlobalConfigDir(ccsDir);
|
||||
|
||||
const config = createEmptyUnifiedConfig();
|
||||
config.cliproxy = { backend: 'plus' };
|
||||
saveUnifiedConfig(config);
|
||||
|
||||
writeInstalledVersion(plusBinDir, '6.6.80');
|
||||
writeVersionListCache(
|
||||
{
|
||||
versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'],
|
||||
latestStable: '6.6.89',
|
||||
latest: '6.6.89',
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
'plus'
|
||||
);
|
||||
|
||||
({ default: cliproxyStatsRoutes } = await import(
|
||||
'../../../src/web-server/routes/cliproxy-stats-routes'
|
||||
));
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -95,16 +71,26 @@ beforeAll(async () => {
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
installSpy.calls = 0;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
mock.restore();
|
||||
if (server) {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
||||
setGlobalConfigDir(undefined);
|
||||
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('cliproxy-stats-routes install contract', () => {
|
||||
@@ -120,7 +106,7 @@ describe('cliproxy-stats-routes install contract', () => {
|
||||
expect(body.faultyRange).toEqual({ min: '6.6.81-0', max: '6.6.88-0' });
|
||||
});
|
||||
|
||||
it('returns faulty confirmation metadata without calling the installer', async () => {
|
||||
it('returns faulty confirmation metadata without attempting the install', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/install`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -140,10 +126,9 @@ describe('cliproxy-stats-routes install contract', () => {
|
||||
expect(body.isFaulty).toBe(true);
|
||||
expect(body.isExperimental).toBe(false);
|
||||
expect(body.message).toContain('known bugs');
|
||||
expect(installSpy.calls).toBe(0);
|
||||
});
|
||||
|
||||
it('returns experimental confirmation metadata without calling the installer', async () => {
|
||||
it('returns experimental confirmation metadata without attempting the install', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/install`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -163,6 +148,5 @@ describe('cliproxy-stats-routes install contract', () => {
|
||||
expect(body.isFaulty).toBe(false);
|
||||
expect(body.isExperimental).toBe(true);
|
||||
expect(body.message).toContain('experimental');
|
||||
expect(installSpy.calls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { Server } from 'http';
|
||||
import cliproxyStatsRoutes from '../../../src/web-server/routes/cliproxy-stats-routes';
|
||||
|
||||
function writeSettings(filePath: string, env: Record<string, string>): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
@@ -16,8 +15,16 @@ describe('cliproxy-stats-routes model update canonicalization', () => {
|
||||
let baseUrl = '';
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let setGlobalConfigDir: (dir: string | undefined) => void;
|
||||
let cliproxyStatsRoutes: ReturnType<typeof express.Router>;
|
||||
|
||||
beforeAll(async () => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
({ setGlobalConfigDir } = await import('../../../src/utils/config-manager'));
|
||||
({ default: cliproxyStatsRoutes } = await import(
|
||||
'../../../src/web-server/routes/cliproxy-stats-routes'
|
||||
));
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/cliproxy', cliproxyStatsRoutes);
|
||||
@@ -41,20 +48,24 @@ describe('cliproxy-stats-routes model update canonicalization', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
setGlobalConfigDir(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-model-route-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-model-route-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
setGlobalConfigDir(path.join(tempHome, '.ccs'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setGlobalConfigDir(undefined);
|
||||
delete process.env.CCS_HOME;
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
|
||||
@@ -3,15 +3,12 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher';
|
||||
import { runWithScopedConfigDir } from '../../../src/utils/config-manager';
|
||||
|
||||
let ccsDir = '';
|
||||
let rawResponse: CliproxyUsageApiResponse | null = null;
|
||||
let fetchCalls = 0;
|
||||
|
||||
mock.module('../../../src/utils/config-manager', () => ({
|
||||
getCcsDir: () => ccsDir,
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/stats-fetcher', () => ({
|
||||
fetchCliproxyUsageRaw: async () => {
|
||||
fetchCalls++;
|
||||
@@ -69,12 +66,16 @@ afterAll(() => {
|
||||
|
||||
describe('cliproxy usage syncer', () => {
|
||||
it('writes and loads snapshot data', async () => {
|
||||
await syncer.syncCliproxyUsage();
|
||||
await runWithScopedConfigDir(ccsDir, async () => {
|
||||
await syncer.syncCliproxyUsage();
|
||||
});
|
||||
|
||||
const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json');
|
||||
expect(fs.existsSync(snapshotPath)).toBe(true);
|
||||
|
||||
const cached = await syncer.loadCachedCliproxyData();
|
||||
const cached = await runWithScopedConfigDir(ccsDir, async () => {
|
||||
return await syncer.loadCachedCliproxyData();
|
||||
});
|
||||
expect(cached.daily).toHaveLength(1);
|
||||
expect(cached.daily[0].source).toBe('cliproxy');
|
||||
expect(cached.daily[0].inputTokens).toBe(100);
|
||||
@@ -82,11 +83,13 @@ describe('cliproxy usage syncer', () => {
|
||||
expect(cached.monthly).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('startCliproxySync is idempotent and starts only one interval', () => {
|
||||
it('startCliproxySync is idempotent and starts only one interval', async () => {
|
||||
const intervalSpy = spyOn(globalThis, 'setInterval');
|
||||
|
||||
syncer.startCliproxySync();
|
||||
syncer.startCliproxySync();
|
||||
await runWithScopedConfigDir(ccsDir, async () => {
|
||||
syncer.startCliproxySync();
|
||||
syncer.startCliproxySync();
|
||||
});
|
||||
|
||||
expect(intervalSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchCalls).toBeGreaterThan(0);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import express from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { Server } from 'http';
|
||||
import profileRoutes from '../../../src/web-server/routes/profile-routes';
|
||||
|
||||
describe('profile-routes cliproxy bridge', () => {
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/profiles', profileRoutes);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = app.listen(0, '127.0.0.1');
|
||||
const onError = (error: Error) => reject(error);
|
||||
server.once('error', onError);
|
||||
server.once('listening', () => {
|
||||
server.off('error', onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-routes-cliproxy-bridge-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a routed CLIProxy-backed API profile and returns bridge metadata', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/profiles/cliproxy-bridge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: 'gemini' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const body = (await response.json()) as {
|
||||
name: string;
|
||||
settingsPath: string;
|
||||
cliproxyBridge: { provider: string; usesCurrentTarget: boolean };
|
||||
};
|
||||
expect(body.name).toBe('gemini-api');
|
||||
expect(body.settingsPath).toBe('~/.ccs/gemini-api.settings.json');
|
||||
expect(body.cliproxyBridge.provider).toBe('gemini');
|
||||
expect(body.cliproxyBridge.usesCurrentTarget).toBe(true);
|
||||
|
||||
const settingsPath = path.join(tempHome, '.ccs', 'gemini-api.settings.json');
|
||||
expect(fs.existsSync(settingsPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('auto-suggests the next routed profile name when the default bridge name is taken', async () => {
|
||||
const firstResponse = await fetch(`${baseUrl}/api/profiles/cliproxy-bridge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: 'gemini' }),
|
||||
});
|
||||
expect(firstResponse.status).toBe(201);
|
||||
|
||||
const secondResponse = await fetch(`${baseUrl}/api/profiles/cliproxy-bridge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: 'gemini' }),
|
||||
});
|
||||
|
||||
expect(secondResponse.status).toBe(201);
|
||||
const body = (await secondResponse.json()) as { name: string };
|
||||
expect(body.name).toBe('gemini-api-2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
style="flex:none;line-height:1"
|
||||
>
|
||||
<title>Codex</title>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M19.503 0H4.496A4.496 4.496 0 0 0 0 4.496v15.007A4.496 4.496 0 0 0 4.496 24h15.007A4.496 4.496 0 0 0 24 19.503V4.496A4.496 4.496 0 0 0 19.503 0z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#codex-color-fill)"
|
||||
d="M9.064 3.344a4.578 4.578 0 0 1 2.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 0 0 .043 0 4.55 4.55 0 0 1 3.046.275l.047.022.116.057a4.581 4.581 0 0 1 2.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 0 1-.134 1.223.123.123 0 0 0 .03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 0 1-2.201 1.388.123.123 0 0 0-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 0 0-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 0 1-1.945-.466 4.544 4.544 0 0 1-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 0 1-.37-.961 4.582 4.582 0 0 1-.014-2.298.124.124 0 0 0 .006-.056.085.085 0 0 0-.027-.048 4.467 4.467 0 0 1-1.034-1.651 3.896 3.896 0 0 1-.251-1.192 5.189 5.189 0 0 1 .141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 0 0 .065-.066 4.51 4.51 0 0 1 .829-1.615 4.535 4.535 0 0 1 1.837-1.388zm3.482 10.565a.637.637 0 0 0 0 1.272h3.636a.637.637 0 1 0 0-1.272h-3.636zM8.462 9.23a.637.637 0 0 0-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 1 0 1.095.649l1.454-2.455a.636.636 0 0 0 .005-.64L8.462 9.23z"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="codex-color-fill" x1="12" x2="12" y1="3" y2="21" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B1A7FF" />
|
||||
<stop offset=".5" stop-color="#7A9DFF" />
|
||||
<stop offset="1" stop-color="#3941FF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path d="M20 13.89a.77.77 0 0 0-1-.16L12 18.87v.22a.72.72 0 1 1 0 1.43v0a.74.74 0 0 0 .45-.15l7.41-5.47a.76.76 0 0 0 .14-1.01Z" fill="#669df6"/>
|
||||
<path d="M12 20.52a.72.72 0 0 1 0-1.43h0v-.22L5 13.73a.76.76 0 0 0-1 .16.74.74 0 0 0 .16 1l7.41 5.47a.73.73 0 0 0 .44.15v0Z" fill="#aecbfa"/>
|
||||
<path d="M12 18.34a1.47 1.47 0 1 0 1.47 1.47A1.47 1.47 0 0 0 12 18.34Zm0 2.18a.72.72 0 1 1 .72-.71.71.71 0 0 1-.72.71Z" fill="#4285f4"/>
|
||||
<path d="M6 6.11a.76.76 0 0 1-.75-.75V3.48a.76.76 0 1 1 1.51 0v1.88A.76.76 0 0 1 6 6.11Z" fill="#aecbfa"/>
|
||||
<circle cx="5.98" cy="12" r="0.76" fill="#aecbfa"/>
|
||||
<circle cx="5.98" cy="9.79" r="0.76" fill="#aecbfa"/>
|
||||
<circle cx="5.98" cy="7.57" r="0.76" fill="#aecbfa"/>
|
||||
<path d="M18 8.31a.76.76 0 0 1-.75-.76V5.67a.75.75 0 1 1 1.5 0v1.88a.75.75 0 0 1-.75.76Z" fill="#4285f4"/>
|
||||
<circle cx="18.02" cy="12.01" r="0.76" fill="#4285f4"/>
|
||||
<circle cx="18.02" cy="9.76" r="0.76" fill="#4285f4"/>
|
||||
<circle cx="18.02" cy="3.48" r="0.76" fill="#4285f4"/>
|
||||
<path d="M12 15a.76.76 0 0 1-.75-.75v-1.89a.76.76 0 0 1 1.51 0v1.89A.76.76 0 0 1 12 15Z" fill="#669df6"/>
|
||||
<circle cx="12" cy="16.45" r="0.76" fill="#669df6"/>
|
||||
<circle cx="12" cy="10.14" r="0.76" fill="#669df6"/>
|
||||
<circle cx="12" cy="7.92" r="0.76" fill="#669df6"/>
|
||||
<path d="M15 10.54a.76.76 0 0 1-.75-.75V7.91a.76.76 0 1 1 1.51 0v1.88a.76.76 0 0 1-.76.75Z" fill="#4285f4"/>
|
||||
<circle cx="15.01" cy="5.69" r="0.76" fill="#4285f4"/>
|
||||
<circle cx="15.01" cy="14.19" r="0.76" fill="#4285f4"/>
|
||||
<circle cx="15.01" cy="11.97" r="0.76" fill="#4285f4"/>
|
||||
<circle cx="8.99" cy="14.19" r="0.76" fill="#aecbfa"/>
|
||||
<circle cx="8.99" cy="7.92" r="0.76" fill="#aecbfa"/>
|
||||
<circle cx="8.99" cy="5.69" r="0.76" fill="#aecbfa"/>
|
||||
<path d="M9 12.73A.76.76 0 0 1 8.24 12V10.1a.75.75 0 1 1 1.5 0V12A.75.75 0 0 1 9 12.73Z" fill="#aecbfa"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -23,6 +23,9 @@ const ApiPage = lazy(() => import('@/pages/api').then((m) => ({ default: m.ApiPa
|
||||
const CliproxyPage = lazy(() =>
|
||||
import('@/pages/cliproxy').then((m) => ({ default: m.CliproxyPage }))
|
||||
);
|
||||
const CliproxyAiProvidersPage = lazy(() =>
|
||||
import('@/pages/cliproxy-ai-providers').then((m) => ({ default: m.CliproxyAiProvidersPage }))
|
||||
);
|
||||
const CliproxyControlPanelPage = lazy(() =>
|
||||
import('@/pages/cliproxy-control-panel').then((m) => ({ default: m.CliproxyControlPanelPage }))
|
||||
);
|
||||
@@ -98,6 +101,14 @@ export default function App() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/cliproxy/ai-providers"
|
||||
element={
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<CliproxyAiProvidersPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/cliproxy/control-panel"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||
import { getAiProviderFamilyVisual } from '@/lib/provider-config';
|
||||
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 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={getAiProviderFamilyVisual(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,291 @@
|
||||
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-xl border bg-background px-3 py-3 transition-colors',
|
||||
onSelect && 'cursor-pointer',
|
||||
isSelected
|
||||
? 'border-primary/20 bg-primary/5 shadow-sm'
|
||||
: 'border-border/60 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>
|
||||
<div className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{entry.baseUrl || family.routePath}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||
{renderCountLabel(entry.models.length, 'alias')}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||
{renderCountLabel(entry.headers.length, 'header')}
|
||||
</Badge>
|
||||
{entry.excludedModels.length > 0 && (
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||
{renderCountLabel(entry.excludedModels.length, 'rule')}
|
||||
</Badge>
|
||||
)}
|
||||
{(entry.proxyUrl || entry.prefix) && (
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||
Routed
|
||||
</Badge>
|
||||
)}
|
||||
</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,590 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||
import { getAiProviderFamilyVisual } from '@/lib/provider-config';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronDown, KeyRound, SlidersHorizontal } from 'lucide-react';
|
||||
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;
|
||||
}
|
||||
|
||||
type DialogGuide = {
|
||||
familyName: string;
|
||||
description: string;
|
||||
requiredNow: string[];
|
||||
optionalLater: string[];
|
||||
keyLabel: string;
|
||||
keyPlaceholder: string;
|
||||
keyHelper: string;
|
||||
connectorPlaceholder?: string;
|
||||
connectorHelper?: string;
|
||||
baseUrlPlaceholder: string;
|
||||
baseUrlHelper: string;
|
||||
aliasesPlaceholder: string;
|
||||
aliasesHelper: string;
|
||||
headersPlaceholder: string;
|
||||
};
|
||||
|
||||
function getDialogGuide(family: AiProviderFamilyId): DialogGuide {
|
||||
switch (family) {
|
||||
case 'gemini-api-key':
|
||||
return {
|
||||
familyName: 'Gemini',
|
||||
description:
|
||||
'Store the Gemini key here so CLIProxy can route Gemini requests without creating a separate CCS API Profile.',
|
||||
requiredNow: [
|
||||
'Paste the Gemini API key.',
|
||||
'Leave Base URL empty unless you use a custom Gemini host.',
|
||||
],
|
||||
optionalLater: [
|
||||
'Model mappings only when requested names and Gemini names differ.',
|
||||
'Headers only when your provider setup requires them.',
|
||||
],
|
||||
keyLabel: 'Gemini API Key',
|
||||
keyPlaceholder: 'AIza...',
|
||||
keyHelper: 'This is the only field most Gemini setups need.',
|
||||
baseUrlPlaceholder: 'https://generativelanguage.googleapis.com',
|
||||
baseUrlHelper: 'Optional. Leave blank to keep the default Gemini endpoint.',
|
||||
aliasesPlaceholder: 'claude-sonnet-4-5=gemini-2.5-pro',
|
||||
aliasesHelper:
|
||||
'Format: requested=upstream. Leave this blank unless the upstream Gemini model name differs.',
|
||||
headersPlaceholder: 'X-Goog-User-Project: your-project',
|
||||
};
|
||||
case 'codex-api-key':
|
||||
return {
|
||||
familyName: 'Codex',
|
||||
description:
|
||||
'Store the Codex or OpenAI key here so CLIProxy can route Codex requests without duplicating the setup in API Profiles.',
|
||||
requiredNow: [
|
||||
'Paste the Codex or OpenAI API key.',
|
||||
'Leave Base URL empty unless this route should target another OpenAI-style endpoint.',
|
||||
],
|
||||
optionalLater: [
|
||||
'Model mappings only when the upstream model ID differs.',
|
||||
'Headers only when org or project routing needs them.',
|
||||
],
|
||||
keyLabel: 'Codex API Key',
|
||||
keyPlaceholder: 'sk-...',
|
||||
keyHelper: 'This is the only field most Codex setups need.',
|
||||
baseUrlPlaceholder: 'https://api.openai.com/v1',
|
||||
baseUrlHelper: 'Optional. Leave blank to keep the default Codex endpoint.',
|
||||
aliasesPlaceholder: 'claude-sonnet-4-5=gpt-5',
|
||||
aliasesHelper:
|
||||
'Format: requested=upstream. Add a mapping only when the upstream model name differs.',
|
||||
headersPlaceholder: 'OpenAI-Organization: org_...',
|
||||
};
|
||||
case 'claude-api-key':
|
||||
return {
|
||||
familyName: 'Claude',
|
||||
description:
|
||||
'Store the Anthropic or compatible key here for CLIProxy-managed Claude routing. Save the key first, then add rewrites only if this route needs them.',
|
||||
requiredNow: [
|
||||
'Paste the Claude or Anthropic-compatible API key.',
|
||||
'Leave Base URL empty unless this route should target another compatible endpoint.',
|
||||
],
|
||||
optionalLater: [
|
||||
'Model mappings only when the requested and upstream Claude model IDs differ.',
|
||||
'Proxy, prefix, exclusions, and headers only for advanced routing cases.',
|
||||
],
|
||||
keyLabel: 'Claude API Key',
|
||||
keyPlaceholder: 'sk-ant-...',
|
||||
keyHelper: 'Most Claude routes can start with the key only.',
|
||||
baseUrlPlaceholder: 'https://api.anthropic.com',
|
||||
baseUrlHelper: 'Optional. Leave blank to keep the default Claude-compatible endpoint.',
|
||||
aliasesPlaceholder: 'claude-sonnet-4-5=claude-3-7-sonnet-latest',
|
||||
aliasesHelper:
|
||||
'Format: requested=upstream. Add a mapping only when the upstream model ID should differ.',
|
||||
headersPlaceholder: 'X-Project: internal-routing',
|
||||
};
|
||||
case 'vertex-api-key':
|
||||
return {
|
||||
familyName: 'Vertex',
|
||||
description:
|
||||
'Store the Vertex key here so CLIProxy can route Vertex traffic without creating a separate CCS API Profile.',
|
||||
requiredNow: [
|
||||
'Paste the Vertex API key.',
|
||||
'Leave Base URL empty unless a regional or gateway endpoint is required.',
|
||||
],
|
||||
optionalLater: [
|
||||
'Model mappings only when the upstream name differs.',
|
||||
'Headers only when the provider expects extra routing context.',
|
||||
],
|
||||
keyLabel: 'Vertex API Key',
|
||||
keyPlaceholder: 'AIza...',
|
||||
keyHelper: 'Most Vertex routes only need the key.',
|
||||
baseUrlPlaceholder: 'https://vertex.googleapis.com',
|
||||
baseUrlHelper: 'Optional. Leave blank to keep the default Vertex endpoint.',
|
||||
aliasesPlaceholder: 'claude-sonnet-4-5=gemini-2.5-pro',
|
||||
aliasesHelper:
|
||||
'Format: requested=upstream. Leave blank unless the upstream model name differs.',
|
||||
headersPlaceholder: 'X-Goog-User-Project: your-project',
|
||||
};
|
||||
case 'openai-compatibility':
|
||||
return {
|
||||
familyName: 'OpenAI-Compatible Connector',
|
||||
description:
|
||||
'Create a named connector for OpenRouter, Together, or any OpenAI-style endpoint. This page owns the connector setup directly inside CLIProxy.',
|
||||
requiredNow: [
|
||||
'Pick a connector name such as openrouter or together.',
|
||||
'Set the connector Base URL.',
|
||||
'Add at least one API key before saving.',
|
||||
],
|
||||
optionalLater: [
|
||||
'Model mappings only when requested and upstream model names differ.',
|
||||
'Headers only when the connector requires provider-specific auth or routing.',
|
||||
],
|
||||
keyLabel: 'API Keys',
|
||||
keyPlaceholder: 'sk-...',
|
||||
keyHelper: 'Add one key per line. Most connectors start with a single key.',
|
||||
connectorPlaceholder: 'openrouter',
|
||||
connectorHelper: 'This becomes the connector label in the saved entries list.',
|
||||
baseUrlPlaceholder: 'https://openrouter.ai/api/v1',
|
||||
baseUrlHelper: 'Required for connectors. This is the upstream OpenAI-style endpoint.',
|
||||
aliasesPlaceholder: 'claude-sonnet-4-5=gpt-4.1',
|
||||
aliasesHelper:
|
||||
'Format: requested=upstream. Leave blank unless the connector expects a different model ID.',
|
||||
headersPlaceholder: 'HTTP-Referer: https://your-app.example',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
return { name: line.trim(), alias: '' };
|
||||
}
|
||||
|
||||
return {
|
||||
name: line.slice(0, separatorIndex).trim(),
|
||||
alias: line.slice(separatorIndex + 1).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.alias.trim() ? `${item.name}=${item.alias}` : item.name))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function ChecklistCard({
|
||||
title,
|
||||
items,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
items: string[];
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-background/80 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<div key={`${title}:${item}`} className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border bg-muted/40 text-[11px] font-semibold text-muted-foreground">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-muted-foreground">{item}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderEntryDialog({
|
||||
family,
|
||||
entry,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
isSaving,
|
||||
}: ProviderEntryDialogProps) {
|
||||
const guide = useMemo(() => getDialogGuide(family), [family]);
|
||||
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 [advancedOpen, setAdvancedOpen] = useState(() =>
|
||||
Boolean(
|
||||
entry?.headers.length || entry?.excludedModels.length || entry?.proxyUrl || entry?.prefix
|
||||
)
|
||||
);
|
||||
|
||||
const secretHelper = useMemo(() => {
|
||||
if (!isEditing || !entry?.secretConfigured) return null;
|
||||
return supportsOpenAiCompat
|
||||
? 'Leave API keys blank to keep the stored connector secrets.'
|
||||
: 'Leave the API key blank to keep the stored secret.';
|
||||
}, [entry?.secretConfigured, isEditing, supportsOpenAiCompat]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const nextApiKey = apiKey.trim();
|
||||
const nextApiKeys = parseDelimitedLines(apiKeys);
|
||||
const preserveSecrets =
|
||||
isEditing && entry?.secretConfigured && !nextApiKey.length && nextApiKeys.length === 0;
|
||||
|
||||
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,
|
||||
...(supportsOpenAiCompat
|
||||
? nextApiKeys.length > 0
|
||||
? { apiKeys: nextApiKeys }
|
||||
: {}
|
||||
: nextApiKey.length > 0
|
||||
? { apiKey: nextApiKey }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await onSubmit(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="overflow-hidden p-0 sm:max-w-3xl">
|
||||
<div className="max-h-[85vh] overflow-y-auto">
|
||||
<div className="border-b bg-muted/20 px-6 py-5">
|
||||
<DialogHeader className="gap-4 text-left">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl border bg-background">
|
||||
<ProviderLogo provider={getAiProviderFamilyVisual(family)} size="md" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DialogTitle>
|
||||
{isEditing ? `Edit ${guide.familyName}` : `Set up ${guide.familyName}`}
|
||||
</DialogTitle>
|
||||
<Badge variant="outline" className="uppercase text-[11px]">
|
||||
{supportsOpenAiCompat ? 'connector' : 'api-key'}
|
||||
</Badge>
|
||||
</div>
|
||||
<DialogDescription className="mt-1 max-w-2xl leading-6">
|
||||
{guide.description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<ChecklistCard
|
||||
title="Required now"
|
||||
items={guide.requiredNow}
|
||||
icon={<KeyRound className="h-4 w-4 text-primary" />}
|
||||
/>
|
||||
<ChecklistCard
|
||||
title="Optional later"
|
||||
items={guide.optionalLater}
|
||||
icon={<SlidersHorizontal className="h-4 w-4 text-primary" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{secretHelper ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">
|
||||
{secretHelper}
|
||||
</div>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 px-6 py-6">
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">Required setup</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Save the smallest working configuration first.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{supportsOpenAiCompat ? (
|
||||
<div className="grid gap-4">
|
||||
<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={guide.connectorPlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{guide.connectorHelper}</p>
|
||||
</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={guide.baseUrlPlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{guide.baseUrlHelper}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>API Keys</Label>
|
||||
<TextArea
|
||||
value={apiKeys}
|
||||
onChange={setApiKeys}
|
||||
rows={4}
|
||||
placeholder={`${guide.keyPlaceholder}\n${guide.keyPlaceholder}`}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{guide.keyHelper}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="api-key">{guide.keyLabel}</Label>
|
||||
<Input
|
||||
id="api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder={guide.keyPlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{guide.keyHelper}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">Optional routing</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Only fill these when the route needs more than the default behavior.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!supportsOpenAiCompat ? (
|
||||
<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={guide.baseUrlPlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{guide.baseUrlHelper}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Model Mappings</Label>
|
||||
<TextArea
|
||||
value={modelAliases}
|
||||
onChange={setModelAliases}
|
||||
rows={4}
|
||||
placeholder={guide.aliasesPlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{guide.aliasesHelper}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<div className="rounded-xl border">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-4 px-4 py-4 text-left"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<SlidersHorizontal className="h-4 w-4 text-primary" />
|
||||
Advanced routing
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Headers
|
||||
{supportsClaudeAdvanced
|
||||
? ', proxy, prefix, and exclusions.'
|
||||
: ' and provider-specific overrides.'}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
advancedOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="border-t px-4 py-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Headers</Label>
|
||||
<TextArea
|
||||
value={headers}
|
||||
onChange={setHeaders}
|
||||
rows={3}
|
||||
placeholder={guide.headersPlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use headers only when the provider requires extra routing or auth context.
|
||||
</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-"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional. Prepends model names before routing.
|
||||
</p>
|
||||
</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"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional. Sends requests through an intermediate proxy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Excluded Models</Label>
|
||||
<TextArea
|
||||
value={excludedModels}
|
||||
onChange={setExcludedModels}
|
||||
rows={3}
|
||||
placeholder="claude-opus-4-1\nclaude-sonnet-4-5"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional. One model ID per line when this route should reject specific
|
||||
upstream models.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t bg-muted/10 px-6 py-4">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleSubmit()} disabled={isSaving}>
|
||||
{isSaving
|
||||
? 'Saving...'
|
||||
: supportsOpenAiCompat
|
||||
? isEditing
|
||||
? 'Save Connector'
|
||||
: 'Create Connector'
|
||||
: 'Save Entry'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { RefreshCw, AlertCircle, Key, X, Gauge, Globe, Settings } from 'lucide-react';
|
||||
import { RefreshCw, AlertCircle, Gauge } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api, withApiBase } from '@/lib/api-client';
|
||||
import type { CliproxyServerConfig } from '@/lib/api-client';
|
||||
@@ -28,7 +28,6 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
const [iframeRevision, setIframeRevision] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [showLoginHint, setShowLoginHint] = useState(true);
|
||||
|
||||
// Fetch cliproxy_server config for remote/local mode detection
|
||||
const { data: cliproxyConfig, error: configError } = useQuery<CliproxyServerConfig>({
|
||||
@@ -219,67 +218,32 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col relative">
|
||||
{/* Remote indicator and login hint banner */}
|
||||
{showLoginHint && !isLoading && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
|
||||
{isRemote && (
|
||||
<>
|
||||
<Globe className="h-3.5 w-3.5 text-green-600" />
|
||||
<span className="text-green-600 font-medium">Remote</span>
|
||||
<span className="text-blue-300 dark:text-blue-700">|</span>
|
||||
</>
|
||||
)}
|
||||
<Key className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span>
|
||||
Key:{' '}
|
||||
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
|
||||
{authToken && authToken.length > 4
|
||||
? `***${authToken.slice(-4)}`
|
||||
: authToken || 'ccs'}
|
||||
</code>
|
||||
</span>
|
||||
<a
|
||||
href="/settings?tab=auth"
|
||||
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||
title="Manage auth tokens"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||
onClick={() => setShowLoginHint(false)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 flex flex-col relative">
|
||||
{/* Loading overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isRemote
|
||||
? `Loading Control Panel from ${displayHost}...`
|
||||
: 'Loading Control Panel...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Loading overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isRemote
|
||||
? `Loading Control Panel from ${displayHost}...`
|
||||
: 'Loading Control Panel...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Iframe */}
|
||||
<iframe
|
||||
key={`${managementUrl}:${iframeRevision}`}
|
||||
ref={iframeRef}
|
||||
src={managementUrl}
|
||||
className="flex-1 w-full border-0"
|
||||
title="CLIProxy Management Panel"
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
{/* Iframe */}
|
||||
<iframe
|
||||
key={`${managementUrl}:${iframeRevision}`}
|
||||
ref={iframeRef}
|
||||
src={managementUrl}
|
||||
className="flex-1 w-full border-0"
|
||||
title="CLIProxy Management Panel"
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Sparkles, Zap, Star, X, Plus } from 'lucide-react';
|
||||
import { FlexibleModelSelector } from '../provider-model-selector';
|
||||
@@ -12,6 +13,24 @@ import { ExtendedContextToggle } from '../extended-context-toggle';
|
||||
import { stripExtendedContextSuffix } from '@/lib/extended-context-utils';
|
||||
import type { ModelConfigSectionProps } from './types';
|
||||
|
||||
type CatalogPresetModel = NonNullable<ModelConfigSectionProps['catalog']>['models'][number];
|
||||
|
||||
function getPresetUpdates(model: CatalogPresetModel): Record<string, string> {
|
||||
const mapping = model.presetMapping || {
|
||||
default: model.id,
|
||||
opus: model.id,
|
||||
sonnet: model.id,
|
||||
haiku: model.id,
|
||||
};
|
||||
|
||||
return {
|
||||
ANTHROPIC_MODEL: mapping.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku,
|
||||
};
|
||||
}
|
||||
|
||||
export function ModelConfigSection({
|
||||
catalog,
|
||||
savedPresets,
|
||||
@@ -29,8 +48,6 @@ export function ModelConfigSection({
|
||||
onDeletePreset,
|
||||
isDeletePending,
|
||||
}: ModelConfigSectionProps) {
|
||||
const showPresets = (catalog && catalog.models.length > 0) || savedPresets.length > 0;
|
||||
|
||||
// Find current model entry to check for extended context support
|
||||
// Strip [1m] suffix when looking up in catalog since catalog IDs don't have suffix
|
||||
const currentModelEntry = useMemo(() => {
|
||||
@@ -39,6 +56,37 @@ export function ModelConfigSection({
|
||||
return catalog.models.find((m) => m.id === baseModelId);
|
||||
}, [catalog, currentModel]);
|
||||
|
||||
const presetGroups = useMemo(() => {
|
||||
const presetModels = (catalog?.models ?? []).filter((model) => model.presetMapping);
|
||||
if (presetModels.length === 0) return [];
|
||||
|
||||
const hasPaidPresets = presetModels.some((model) => model.tier === 'paid');
|
||||
if (!hasPaidPresets) {
|
||||
return [{ key: 'default', models: presetModels.slice(0, 4) }];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'free',
|
||||
label: 'Free Tier',
|
||||
description: 'Available on free or paid plans',
|
||||
badgeClassName: 'text-[10px] bg-green-100 text-green-700 border-green-200',
|
||||
iconClassName: 'text-green-600',
|
||||
models: presetModels.filter((model) => model.tier !== 'paid'),
|
||||
},
|
||||
{
|
||||
key: 'paid',
|
||||
label: 'Paid Tier',
|
||||
description: 'Requires paid access',
|
||||
badgeClassName: 'text-[10px] bg-amber-100 text-amber-700 border-amber-200',
|
||||
iconClassName: 'text-amber-700',
|
||||
models: presetModels.filter((model) => model.tier === 'paid'),
|
||||
},
|
||||
].filter((group) => group.models.length > 0);
|
||||
}, [catalog]);
|
||||
|
||||
const showPresets = presetGroups.length > 0 || savedPresets.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Quick Presets */}
|
||||
@@ -49,77 +97,81 @@ export function ModelConfigSection({
|
||||
Presets
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-3">Apply pre-configured model mappings</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Recommended presets from catalog */}
|
||||
{catalog?.models.slice(0, 4).map((model) => (
|
||||
<Button
|
||||
key={model.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1"
|
||||
onClick={() => {
|
||||
const mapping = model.presetMapping || {
|
||||
default: model.id,
|
||||
opus: model.id,
|
||||
sonnet: model.id,
|
||||
haiku: model.id,
|
||||
};
|
||||
onApplyPreset({
|
||||
ANTHROPIC_MODEL: mapping.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Zap className="w-3 h-3" />
|
||||
{model.name}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{/* User saved presets */}
|
||||
{savedPresets.map((preset) => (
|
||||
<div key={preset.name} className="group relative">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1 pr-6"
|
||||
onClick={() => {
|
||||
onApplyPreset({
|
||||
ANTHROPIC_MODEL: preset.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: preset.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: preset.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: preset.haiku,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Star className="w-3 h-3 fill-current" />
|
||||
{preset.name}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-7 w-5 opacity-0 group-hover:opacity-100 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeletePreset(preset.name);
|
||||
}}
|
||||
disabled={isDeletePending}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
<div className="space-y-4">
|
||||
{presetGroups.map((group) => (
|
||||
<div key={group.key}>
|
||||
{'label' in group && group.label && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="outline" className={group.badgeClassName}>
|
||||
{group.label}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">{group.description}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.models.map((model) => (
|
||||
<Button
|
||||
key={model.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1"
|
||||
onClick={() => onApplyPreset(getPresetUpdates(model))}
|
||||
>
|
||||
<Zap
|
||||
className={`w-3 h-3 ${'iconClassName' in group ? group.iconClassName : ''}`}
|
||||
/>
|
||||
{model.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1 border-primary/50 text-primary hover:bg-primary/10 hover:border-primary"
|
||||
onClick={onOpenCustomPreset}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Custom
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* User saved presets */}
|
||||
{savedPresets.map((preset) => (
|
||||
<div key={preset.name} className="group relative">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1 pr-6"
|
||||
onClick={() => {
|
||||
onApplyPreset({
|
||||
ANTHROPIC_MODEL: preset.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: preset.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: preset.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: preset.haiku,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Star className="w-3 h-3 fill-current" />
|
||||
{preset.name}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-7 w-5 opacity-0 group-hover:opacity-100 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeletePreset(preset.name);
|
||||
}}
|
||||
disabled={isDeletePending}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1 border-primary/50 text-primary hover:bg-primary/10 hover:border-primary"
|
||||
onClick={onOpenCustomPreset}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Custom
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getProviderFallbackVisual,
|
||||
getProviderLogoAsset,
|
||||
providerNeedsDarkLogoBackground,
|
||||
providerUsesSelfContainedLogo,
|
||||
} from '@/lib/provider-config';
|
||||
|
||||
interface ProviderLogoProps {
|
||||
@@ -27,12 +28,14 @@ export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoP
|
||||
const fallback = getProviderFallbackVisual(provider);
|
||||
const sizeConfig = SIZE_CONFIG[size];
|
||||
const imageSrc = getProviderLogoAsset(provider);
|
||||
const usesSelfContainedLogo = providerUsesSelfContainedLogo(provider);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-md',
|
||||
imageSrc &&
|
||||
!usesSelfContainedLogo &&
|
||||
(providerNeedsDarkLogoBackground(provider) ? 'bg-gray-900 p-1' : 'bg-white p-1'),
|
||||
sizeConfig.container,
|
||||
className
|
||||
@@ -42,7 +45,10 @@ export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoP
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={`${provider} logo`}
|
||||
className={cn(sizeConfig.icon, 'object-contain')}
|
||||
className={cn(
|
||||
usesSelfContainedLogo ? sizeConfig.container : sizeConfig.icon,
|
||||
'object-contain'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<span className={cn('font-semibold', fallback.textClass, sizeConfig.text)}>
|
||||
|
||||
@@ -97,6 +97,7 @@ function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] {
|
||||
isCollapsible: true,
|
||||
children: [
|
||||
{ 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') },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Types for Profile Editor
|
||||
*/
|
||||
|
||||
import type { CliTarget } from '@/lib/api-client';
|
||||
import type { CliTarget, CliproxyBridgeMetadata } from '@/lib/api-client';
|
||||
|
||||
export interface Settings {
|
||||
env?: Record<string, string>;
|
||||
@@ -13,6 +13,7 @@ export interface SettingsResponse {
|
||||
settings: Settings;
|
||||
mtime: number;
|
||||
path: string;
|
||||
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
||||
}
|
||||
|
||||
export interface ProfileEditorProps {
|
||||
|
||||
@@ -8,18 +8,28 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
|
||||
import { Sparkles, ExternalLink, ArrowRight, Zap, CloudCog, KeyRound } from 'lucide-react';
|
||||
import {
|
||||
Sparkles,
|
||||
ExternalLink,
|
||||
ArrowRight,
|
||||
Zap,
|
||||
CloudCog,
|
||||
KeyRound,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface OpenRouterQuickStartProps {
|
||||
onOpenRouterClick: () => void;
|
||||
onAlibabaCodingPlanClick: () => void;
|
||||
onCliproxyClick: () => void;
|
||||
onCustomClick: () => void;
|
||||
}
|
||||
|
||||
export function OpenRouterQuickStart({
|
||||
onOpenRouterClick,
|
||||
onAlibabaCodingPlanClick,
|
||||
onCliproxyClick,
|
||||
onCustomClick,
|
||||
}: OpenRouterQuickStartProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -145,6 +155,53 @@ export function OpenRouterQuickStart({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-emerald-500/30 dark:border-emerald-500/40 bg-gradient-to-br from-emerald-500/5 to-background dark:from-emerald-500/10">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/10 dark:bg-emerald-500/20">
|
||||
<SlidersHorizontal className="w-6 h-6 text-emerald-700 dark:text-emerald-300" />
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200"
|
||||
>
|
||||
Configure in AI Providers
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-xl">Manage CLIProxy AI providers</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Configure Gemini, Codex, Claude, Vertex, and OpenAI-compatible connectors directly in
|
||||
the dedicated CLIProxy AI Providers page.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<SlidersHorizontal className="w-4 h-4 text-emerald-600" />
|
||||
<span>Dedicated /cliproxy/ai-providers workspace</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<KeyRound className="w-4 h-4 text-emerald-600" />
|
||||
<span>Manage provider secrets outside API Profiles</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={onCliproxyClick}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-600/90 text-white"
|
||||
size="lg"
|
||||
>
|
||||
Open AI Providers
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Keep runtime provider configuration in CLIProxy, then create API Profiles only when
|
||||
you need standalone Anthropic-compatible endpoints.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Separator className="flex-1" />
|
||||
|
||||
@@ -213,7 +213,7 @@ export function ProfileCreateDialog({
|
||||
applyPresetToForm(null);
|
||||
}
|
||||
}
|
||||
}, [open, initialMode, applyPresetToForm]);
|
||||
}, [open, initialMode, applyPresetToForm, reset]);
|
||||
|
||||
// Handle preset selection
|
||||
const handlePresetSelect = (presetId: string) => {
|
||||
@@ -288,6 +288,7 @@ export function ProfileCreateDialog({
|
||||
const hasBasicErrors = !!errors.name || !!errors.baseUrl || !!errors.apiKey;
|
||||
const hasModelErrors =
|
||||
!!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel;
|
||||
const isCreating = createMutation.isPending;
|
||||
|
||||
const isQuickTemplateSelected =
|
||||
selectedPreset !== CUSTOM_PRESET_ID && QUICK_TEMPLATE_PRESET_IDS.has(selectedPreset);
|
||||
@@ -303,7 +304,7 @@ export function ProfileCreateDialog({
|
||||
Create API Profile
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a provider or configure a custom API endpoint.
|
||||
Choose a provider preset or configure a custom API endpoint.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -311,7 +312,7 @@ export function ProfileCreateDialog({
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||
>
|
||||
<div className="border-b bg-muted/10 px-6 py-3">
|
||||
<div className="border-b bg-muted/10 px-6 py-3 space-y-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
@@ -662,10 +663,10 @@ export function ProfileCreateDialog({
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className={cn(createMutation.isPending && 'opacity-80')}
|
||||
disabled={isCreating}
|
||||
className={cn(isCreating && 'opacity-80')}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,11 @@
|
||||
*/
|
||||
|
||||
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_CONFLICT_ERROR_CODE = 'CONFLICT';
|
||||
@@ -96,11 +101,22 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
// Types
|
||||
export type CliTarget = 'claude' | 'droid';
|
||||
|
||||
export interface CliproxyBridgeMetadata {
|
||||
provider: CLIProxyProvider;
|
||||
providerDisplayName: string;
|
||||
routePath: string;
|
||||
currentBaseUrl: string;
|
||||
source: 'local' | 'remote';
|
||||
usesCurrentTarget: boolean;
|
||||
usesCurrentAuthToken: boolean;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
name: string;
|
||||
settingsPath: string;
|
||||
configured: boolean;
|
||||
target?: CliTarget;
|
||||
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
||||
}
|
||||
|
||||
export interface CreateProfile {
|
||||
@@ -831,6 +847,23 @@ export const api = {
|
||||
method: 'PUT',
|
||||
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
|
||||
getConfigYaml: async (): Promise<string> => {
|
||||
|
||||
@@ -111,45 +111,56 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
codex: {
|
||||
provider: 'codex',
|
||||
displayName: 'Codex',
|
||||
defaultModel: 'gpt-5.3-codex',
|
||||
defaultModel: 'gpt-5-codex',
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-5.3-codex',
|
||||
name: 'GPT-5.3 Codex',
|
||||
description: 'Supports up to xhigh effort',
|
||||
id: 'gpt-5-codex',
|
||||
name: 'GPT-5 Codex',
|
||||
description: 'Cross-plan safe Codex default',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.3-codex',
|
||||
opus: 'gpt-5.3-codex',
|
||||
sonnet: 'gpt-5.3-codex',
|
||||
haiku: 'gpt-5.1-codex-mini',
|
||||
default: 'gpt-5-codex',
|
||||
opus: 'gpt-5-codex',
|
||||
sonnet: 'gpt-5-codex',
|
||||
haiku: 'gpt-5-codex-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.2-codex',
|
||||
name: 'GPT-5.2 Codex',
|
||||
description: 'Previous stable Codex model',
|
||||
id: 'gpt-5-codex-mini',
|
||||
name: 'GPT-5 Codex Mini',
|
||||
description: 'Faster and cheaper Codex option',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.2-codex',
|
||||
opus: 'gpt-5.2-codex',
|
||||
sonnet: 'gpt-5.2-codex',
|
||||
haiku: 'gpt-5.1-codex-mini',
|
||||
default: 'gpt-5-codex-mini',
|
||||
opus: 'gpt-5-codex',
|
||||
sonnet: 'gpt-5-codex',
|
||||
haiku: 'gpt-5-codex-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5-mini',
|
||||
name: 'GPT-5 Mini',
|
||||
description: 'Fast, capped at high effort (no xhigh)',
|
||||
description: 'Legacy mini model ID kept for backwards compatibility',
|
||||
presetMapping: {
|
||||
default: 'gpt-5-mini',
|
||||
opus: 'gpt-5.3-codex',
|
||||
opus: 'gpt-5-codex',
|
||||
sonnet: 'gpt-5-mini',
|
||||
haiku: 'gpt-5-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1-codex-mini',
|
||||
name: 'GPT-5.1 Codex Mini',
|
||||
description: 'Legacy fast Codex mini model',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.1-codex-mini',
|
||||
opus: 'gpt-5.1-codex-max',
|
||||
sonnet: 'gpt-5.1-codex-max',
|
||||
haiku: 'gpt-5.1-codex-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1-codex-max',
|
||||
name: 'Codex Max (5.1)',
|
||||
description: 'Legacy most capable Codex model',
|
||||
name: 'GPT-5.1 Codex Max',
|
||||
description: 'Higher-effort Codex model with xhigh support',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.1-codex-max',
|
||||
opus: 'gpt-5.1-codex-max',
|
||||
@@ -158,20 +169,51 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.2',
|
||||
name: 'GPT 5.2',
|
||||
description: 'Latest GPT model',
|
||||
id: 'gpt-5.2-codex',
|
||||
name: 'GPT-5.2 Codex',
|
||||
description: 'Cross-plan Codex model with xhigh support',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.2',
|
||||
opus: 'gpt-5.2',
|
||||
sonnet: 'gpt-5.2',
|
||||
haiku: 'gpt-5.2',
|
||||
default: 'gpt-5.2-codex',
|
||||
opus: 'gpt-5.2-codex',
|
||||
sonnet: 'gpt-5.2-codex',
|
||||
haiku: 'gpt-5-codex-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1-codex-mini',
|
||||
name: 'Codex Mini',
|
||||
description: 'Fast and efficient Codex model',
|
||||
id: 'gpt-5.3-codex',
|
||||
name: 'GPT-5.3 Codex',
|
||||
tier: 'paid',
|
||||
description: 'Paid Codex plans only',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.3-codex',
|
||||
opus: 'gpt-5.3-codex',
|
||||
sonnet: 'gpt-5.3-codex',
|
||||
haiku: 'gpt-5-codex-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.3-codex-spark',
|
||||
name: 'GPT-5.3 Codex Spark',
|
||||
tier: 'paid',
|
||||
description: 'Paid Codex plans only, ultra-fast coding model',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.3-codex-spark',
|
||||
opus: 'gpt-5.3-codex',
|
||||
sonnet: 'gpt-5.3-codex',
|
||||
haiku: 'gpt-5-codex-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.4',
|
||||
name: 'GPT-5.4',
|
||||
tier: 'paid',
|
||||
description: 'Paid Codex plans only, latest GPT-5 family model',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.4',
|
||||
opus: 'gpt-5.4',
|
||||
sonnet: 'gpt-5.4',
|
||||
haiku: 'gpt-5-codex-mini',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PROVIDER_CAPABILITIES,
|
||||
getProvidersByOAuthFlow,
|
||||
} from '../../../src/cliproxy/provider-capabilities';
|
||||
import type { AiProviderFamilyId } from '../../../src/cliproxy/ai-providers';
|
||||
|
||||
// Monorepo contract: UI consumes provider capability constants directly from backend
|
||||
// to enforce one source of truth and prevent provider drift across surfaces.
|
||||
@@ -18,6 +19,7 @@ export const CLIPROXY_PROVIDERS = CLIPROXY_PROVIDER_IDS;
|
||||
|
||||
/** Union type for CLIProxy provider IDs */
|
||||
export type CLIProxyProvider = (typeof CLIPROXY_PROVIDERS)[number];
|
||||
export type ProviderVisualId = CLIProxyProvider | 'openai' | 'vertex';
|
||||
|
||||
/** Check if a string is a valid CLIProxy provider */
|
||||
export function isValidProvider(provider: string): provider is CLIProxyProvider {
|
||||
@@ -33,6 +35,15 @@ interface ProviderMetadata {
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SPECIAL_PROVIDER_VISUAL_IDS = ['openai', 'vertex'] as const;
|
||||
|
||||
function isProviderVisualId(provider: string): provider is ProviderVisualId {
|
||||
return (
|
||||
isValidProvider(provider) ||
|
||||
SPECIAL_PROVIDER_VISUAL_IDS.includes(provider as (typeof SPECIAL_PROVIDER_VISUAL_IDS)[number])
|
||||
);
|
||||
}
|
||||
|
||||
export const PROVIDER_METADATA: Record<CLIProxyProvider, ProviderMetadata> = Object.freeze(
|
||||
Object.fromEntries(
|
||||
CLIPROXY_PROVIDERS.map((provider) => [
|
||||
@@ -46,16 +57,18 @@ export const PROVIDER_METADATA: Record<CLIProxyProvider, ProviderMetadata> = Obj
|
||||
);
|
||||
|
||||
// Map provider names to asset filenames (only providers with actual logos)
|
||||
export const PROVIDER_ASSETS: Record<CLIProxyProvider, string> = {
|
||||
export const PROVIDER_ASSETS: Partial<Record<ProviderVisualId, string>> = {
|
||||
gemini: '/assets/providers/gemini-color.svg',
|
||||
agy: '/assets/providers/agy.png',
|
||||
codex: '/assets/providers/openai.svg',
|
||||
codex: '/assets/providers/codex-color.svg',
|
||||
qwen: '/assets/providers/qwen-color.svg',
|
||||
iflow: '/assets/providers/iflow.png',
|
||||
kiro: '/assets/providers/kiro.png',
|
||||
ghcp: '/assets/providers/copilot.svg',
|
||||
claude: '/assets/providers/claude.svg',
|
||||
kimi: '/assets/providers/kimi.svg',
|
||||
openai: '/assets/providers/openai.svg',
|
||||
vertex: '/assets/providers/vertex.svg',
|
||||
};
|
||||
|
||||
interface ProviderFallbackVisual {
|
||||
@@ -69,7 +82,7 @@ const DEFAULT_PROVIDER_FALLBACK_VISUAL: ProviderFallbackVisual = {
|
||||
};
|
||||
|
||||
/** Fallback visual style when a provider logo asset is unavailable. */
|
||||
export const PROVIDER_FALLBACK_VISUALS: Record<CLIProxyProvider, ProviderFallbackVisual> = {
|
||||
export const PROVIDER_FALLBACK_VISUALS: Record<ProviderVisualId, ProviderFallbackVisual> = {
|
||||
gemini: { textClass: 'text-blue-600', letter: 'G' },
|
||||
claude: { textClass: 'text-orange-600', letter: 'C' },
|
||||
codex: { textClass: 'text-emerald-600', letter: 'X' },
|
||||
@@ -79,14 +92,32 @@ export const PROVIDER_FALLBACK_VISUALS: Record<CLIProxyProvider, ProviderFallbac
|
||||
kiro: { textClass: 'text-teal-600', letter: 'K' },
|
||||
ghcp: { textClass: 'text-green-600', letter: 'C' },
|
||||
kimi: { textClass: 'text-orange-500', letter: 'K' },
|
||||
openai: { textClass: 'text-slate-900', letter: 'O' },
|
||||
vertex: { textClass: 'text-blue-600', letter: 'V' },
|
||||
};
|
||||
|
||||
/** Providers whose logo looks better on dark background. */
|
||||
export const PROVIDERS_WITH_DARK_LOGO_BG: ReadonlySet<CLIProxyProvider> = new Set(['kimi']);
|
||||
export const PROVIDERS_WITH_DARK_LOGO_BG: ReadonlySet<ProviderVisualId> = new Set(['kimi']);
|
||||
const PROVIDERS_WITH_SELF_CONTAINED_LOGO: ReadonlySet<ProviderVisualId> = new Set(['codex']);
|
||||
|
||||
export function getAiProviderFamilyVisual(familyId: AiProviderFamilyId): ProviderVisualId {
|
||||
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 'openai';
|
||||
}
|
||||
}
|
||||
|
||||
export function getProviderLogoAsset(provider: unknown): string | undefined {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
if (!isValidProvider(normalized)) {
|
||||
if (!isProviderVisualId(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return PROVIDER_ASSETS[normalized];
|
||||
@@ -94,7 +125,7 @@ export function getProviderLogoAsset(provider: unknown): string | undefined {
|
||||
|
||||
export function getProviderFallbackVisual(provider: unknown): ProviderFallbackVisual {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
if (isValidProvider(normalized)) {
|
||||
if (isProviderVisualId(normalized)) {
|
||||
return PROVIDER_FALLBACK_VISUALS[normalized];
|
||||
}
|
||||
return {
|
||||
@@ -105,7 +136,12 @@ export function getProviderFallbackVisual(provider: unknown): ProviderFallbackVi
|
||||
|
||||
export function providerNeedsDarkLogoBackground(provider: unknown): boolean {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
return isValidProvider(normalized) && PROVIDERS_WITH_DARK_LOGO_BG.has(normalized);
|
||||
return isProviderVisualId(normalized) && PROVIDERS_WITH_DARK_LOGO_BG.has(normalized);
|
||||
}
|
||||
|
||||
export function providerUsesSelfContainedLogo(provider: unknown): boolean {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
return isProviderVisualId(normalized) && PROVIDERS_WITH_SELF_CONTAINED_LOGO.has(normalized);
|
||||
}
|
||||
|
||||
// Provider brand colors
|
||||
@@ -113,6 +149,7 @@ export const PROVIDER_COLORS: Record<string, string> = {
|
||||
gemini: '#4285F4',
|
||||
agy: '#f3722c',
|
||||
codex: '#10a37f',
|
||||
openai: '#111827',
|
||||
vertex: '#4285F4',
|
||||
iflow: '#f94144',
|
||||
qwen: '#6236FF',
|
||||
@@ -126,6 +163,7 @@ const PROVIDER_NAMES: Record<string, string> = {
|
||||
...Object.fromEntries(
|
||||
CLIPROXY_PROVIDERS.map((provider) => [provider, PROVIDER_METADATA[provider].displayName])
|
||||
),
|
||||
openai: 'OpenAI',
|
||||
vertex: 'Vertex AI',
|
||||
};
|
||||
|
||||
|
||||
@@ -38,9 +38,11 @@ import { cn } from '@/lib/utils';
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export function ApiPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, isError, refetch } = useProfiles();
|
||||
const deleteMutation = useDeleteProfile();
|
||||
const discoverOrphansMutation = useDiscoverProfileOrphans();
|
||||
@@ -385,6 +387,9 @@ export function ApiPage() {
|
||||
</>
|
||||
) : (
|
||||
<OpenRouterQuickStart
|
||||
onCliproxyClick={() => {
|
||||
navigate('/cliproxy/ai-providers');
|
||||
}}
|
||||
onOpenRouterClick={() => {
|
||||
setCreateMode('openrouter');
|
||||
setCreateDialogOpen(true);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+104
-86
@@ -106,15 +106,28 @@ function VariantSidebarItem({
|
||||
isDeleting?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleActivate = () => {
|
||||
onSelect();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'group w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors cursor-pointer text-left pl-6',
|
||||
isSelected
|
||||
? 'bg-primary/10 border border-primary/20'
|
||||
: 'hover:bg-muted border border-transparent'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
onClick={handleActivate}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleActivate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<ProviderLogo provider={variant.provider} size="sm" />
|
||||
@@ -160,7 +173,7 @@ function VariantSidebarItem({
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -436,91 +449,96 @@ export function CliproxyPage() {
|
||||
)}
|
||||
|
||||
{selectedVariantData && parentAuthForVariant ? (
|
||||
// Variant selected - show ProviderEditor with variant profile name
|
||||
<ProviderEditor
|
||||
provider={selectedVariantData.name}
|
||||
displayName={t('cliproxyPage.variantDisplay', {
|
||||
name: selectedVariantData.name,
|
||||
provider: selectedVariantData.provider,
|
||||
})}
|
||||
authStatus={parentAuthForVariant}
|
||||
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
|
||||
logoProvider={selectedVariantData.provider}
|
||||
baseProvider={selectedVariantData.provider}
|
||||
defaultTarget={selectedVariantData.target}
|
||||
isRemoteMode={isRemoteMode}
|
||||
port={selectedVariantData.port}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
<>
|
||||
<ProviderEditor
|
||||
provider={selectedVariantData.name}
|
||||
displayName={t('cliproxyPage.variantDisplay', {
|
||||
name: selectedVariantData.name,
|
||||
provider: selectedVariantData.provider,
|
||||
displayName: parentAuthForVariant.displayName,
|
||||
isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0,
|
||||
})
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onRemoveAccount={(accountId) =>
|
||||
removeMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onPauseToggle={(accountId, paused) =>
|
||||
handlePauseToggle(selectedVariantData.provider, accountId, paused)
|
||||
}
|
||||
onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)}
|
||||
onBulkPause={(accountIds) => handleBulkPause(selectedVariantData.provider, accountIds)}
|
||||
onBulkResume={(accountIds) =>
|
||||
handleBulkResume(selectedVariantData.provider, accountIds)
|
||||
}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
isSoloingAccount={soloMutation.isPending}
|
||||
isBulkPausing={bulkPauseMutation.isPending}
|
||||
isBulkResuming={bulkResumeMutation.isPending}
|
||||
/>
|
||||
})}
|
||||
authStatus={parentAuthForVariant}
|
||||
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
|
||||
logoProvider={selectedVariantData.provider}
|
||||
baseProvider={selectedVariantData.provider}
|
||||
defaultTarget={selectedVariantData.target}
|
||||
isRemoteMode={isRemoteMode}
|
||||
port={selectedVariantData.port}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedVariantData.provider,
|
||||
displayName: parentAuthForVariant.displayName,
|
||||
isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0,
|
||||
})
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onRemoveAccount={(accountId) =>
|
||||
removeMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onPauseToggle={(accountId, paused) =>
|
||||
handlePauseToggle(selectedVariantData.provider, accountId, paused)
|
||||
}
|
||||
onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)}
|
||||
onBulkPause={(accountIds) =>
|
||||
handleBulkPause(selectedVariantData.provider, accountIds)
|
||||
}
|
||||
onBulkResume={(accountIds) =>
|
||||
handleBulkResume(selectedVariantData.provider, accountIds)
|
||||
}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
isSoloingAccount={soloMutation.isPending}
|
||||
isBulkPausing={bulkPauseMutation.isPending}
|
||||
isBulkResuming={bulkResumeMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
) : selectedStatus ? (
|
||||
<ProviderEditor
|
||||
provider={selectedStatus.provider}
|
||||
displayName={selectedStatus.displayName}
|
||||
authStatus={selectedStatus}
|
||||
catalog={MODEL_CATALOGS[selectedStatus.provider]}
|
||||
isRemoteMode={isRemoteMode}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedStatus.provider,
|
||||
displayName: selectedStatus.displayName,
|
||||
isFirstAccount: (selectedStatus.accounts?.length || 0) === 0,
|
||||
})
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onRemoveAccount={(accountId) =>
|
||||
removeMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onPauseToggle={(accountId, paused) =>
|
||||
handlePauseToggle(selectedStatus.provider, accountId, paused)
|
||||
}
|
||||
onSoloMode={(accountId) => handleSoloMode(selectedStatus.provider, accountId)}
|
||||
onBulkPause={(accountIds) => handleBulkPause(selectedStatus.provider, accountIds)}
|
||||
onBulkResume={(accountIds) => handleBulkResume(selectedStatus.provider, accountIds)}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
isSoloingAccount={soloMutation.isPending}
|
||||
isBulkPausing={bulkPauseMutation.isPending}
|
||||
isBulkResuming={bulkResumeMutation.isPending}
|
||||
/>
|
||||
<>
|
||||
<ProviderEditor
|
||||
provider={selectedStatus.provider}
|
||||
displayName={selectedStatus.displayName}
|
||||
authStatus={selectedStatus}
|
||||
catalog={MODEL_CATALOGS[selectedStatus.provider]}
|
||||
isRemoteMode={isRemoteMode}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedStatus.provider,
|
||||
displayName: selectedStatus.displayName,
|
||||
isFirstAccount: (selectedStatus.accounts?.length || 0) === 0,
|
||||
})
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onRemoveAccount={(accountId) =>
|
||||
removeMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onPauseToggle={(accountId, paused) =>
|
||||
handlePauseToggle(selectedStatus.provider, accountId, paused)
|
||||
}
|
||||
onSoloMode={(accountId) => handleSoloMode(selectedStatus.provider, accountId)}
|
||||
onBulkPause={(accountIds) => handleBulkPause(selectedStatus.provider, accountIds)}
|
||||
onBulkResume={(accountIds) => handleBulkResume(selectedStatus.provider, accountIds)}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
isSoloingAccount={soloMutation.isPending}
|
||||
isBulkPausing={bulkPauseMutation.isPending}
|
||||
isBulkResuming={bulkResumeMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, userEvent } from '@tests/setup/test-utils';
|
||||
|
||||
vi.mock('@/components/cliproxy/provider-model-selector', () => ({
|
||||
FlexibleModelSelector: () => <div data-testid="flexible-model-selector" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/cliproxy/extended-context-toggle', () => ({
|
||||
ExtendedContextToggle: () => <div data-testid="extended-context-toggle" />,
|
||||
}));
|
||||
|
||||
import { ModelConfigSection } from '@/components/cliproxy/provider-editor/model-config-section';
|
||||
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
||||
|
||||
describe('ModelConfigSection presets', () => {
|
||||
it('groups codex presets by free and paid tiers', async () => {
|
||||
const onApplyPreset = vi.fn();
|
||||
|
||||
render(
|
||||
<ModelConfigSection
|
||||
catalog={MODEL_CATALOGS.codex}
|
||||
savedPresets={[]}
|
||||
currentModel="gpt-5-codex"
|
||||
opusModel="gpt-5-codex"
|
||||
sonnetModel="gpt-5-codex"
|
||||
haikuModel="gpt-5-codex-mini"
|
||||
providerModels={[]}
|
||||
provider="codex"
|
||||
onApplyPreset={onApplyPreset}
|
||||
onUpdateEnvValue={vi.fn()}
|
||||
onOpenCustomPreset={vi.fn()}
|
||||
onDeletePreset={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Free Tier')).toBeInTheDocument();
|
||||
expect(screen.getByText('Paid Tier')).toBeInTheDocument();
|
||||
expect(screen.getByText('Available on free or paid plans')).toBeInTheDocument();
|
||||
expect(screen.getByText('Requires paid access')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'GPT-5.4' }));
|
||||
|
||||
expect(onApplyPreset).toHaveBeenCalledWith({
|
||||
ANTHROPIC_MODEL: 'gpt-5.4',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.4',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-codex-mini',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps non-tiered provider presets ungrouped', () => {
|
||||
render(
|
||||
<ModelConfigSection
|
||||
catalog={MODEL_CATALOGS.agy}
|
||||
savedPresets={[]}
|
||||
currentModel="claude-opus-4-6-thinking"
|
||||
opusModel="claude-opus-4-6-thinking"
|
||||
sonnetModel="claude-sonnet-4-6"
|
||||
haikuModel="claude-sonnet-4-6"
|
||||
providerModels={[]}
|
||||
provider="agy"
|
||||
onApplyPreset={vi.fn()}
|
||||
onUpdateEnvValue={vi.fn()}
|
||||
onOpenCustomPreset={vi.fn()}
|
||||
onDeletePreset={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Free Tier')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Paid Tier')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Claude Opus 4.6 Thinking' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,10 @@ vi.mock('@/hooks/use-profiles', () => ({
|
||||
mutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useCreateCliproxyBridgeProfile: () => ({
|
||||
mutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-openrouter-models', () => ({
|
||||
@@ -18,6 +22,12 @@ vi.mock('@/hooks/use-openrouter-models', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-cliproxy', () => ({
|
||||
useCliproxyAuth: () => ({
|
||||
data: { authStatus: [] },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ProfileCreateDialog', () => {
|
||||
beforeEach(() => {
|
||||
mutateAsync.mockReset();
|
||||
|
||||
@@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest';
|
||||
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
||||
|
||||
describe('codex model catalog defaults', () => {
|
||||
it('uses gpt-5.1-codex-mini as the haiku mapping for codex presets', () => {
|
||||
it('uses gpt-5-codex-mini as the haiku mapping for cross-plan codex presets', () => {
|
||||
const codexCatalog = MODEL_CATALOGS.codex;
|
||||
const codex53 = codexCatalog.models.find((model) => model.id === 'gpt-5.3-codex');
|
||||
const codex52 = codexCatalog.models.find((model) => model.id === 'gpt-5.2-codex');
|
||||
|
||||
expect(codex53?.presetMapping?.haiku).toBe('gpt-5.1-codex-mini');
|
||||
expect(codex52?.presetMapping?.haiku).toBe('gpt-5.1-codex-mini');
|
||||
expect(codex53?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
|
||||
expect(codex52?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user