diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 35db2a30..e61a5661 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -3,6 +3,11 @@ name: Dev Release on: push: branches: [dev] + workflow_dispatch: + +concurrency: + group: dev-release-${{ github.ref }} + cancel-in-progress: false jobs: release: @@ -21,7 +26,8 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.PAT_TOKEN }} + # Always use the built-in workflow token; PAT rotation/breakage must not block dev releases. + token: ${{ github.token }} - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -42,15 +48,18 @@ jobs: - name: Build run: bun run build:all - - name: Validate (typecheck + lint + format + maintainability [strict] + tests) + - name: Validate (typecheck + lint + format + maintainability [warn] + tests) + env: + # Dev channel should surface maintainability regressions without blocking every merge. + CCS_MAINTAINABILITY_MODE: warn run: bun run validate - name: Release id: release env: HUSKY: 0 - # Use built-in GITHUB_TOKEN for bot identity on comments/releases - # PAT_TOKEN is only used for checkout (to trigger downstream workflows) + # Use built-in GITHUB_TOKEN for release + issue operations. + # Checkout credentials are resolved above with PAT fallback. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d91bfe3..ae8b97d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,8 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.PAT_TOKEN }} + # Always use the built-in workflow token; PAT rotation/breakage must not block releases. + token: ${{ github.token }} - name: Setup Node.js uses: actions/setup-node@v4 @@ -48,8 +49,8 @@ jobs: id: release env: HUSKY: 0 - # Use built-in GITHUB_TOKEN for bot identity on comments/releases - # PAT_TOKEN is only used for checkout (to trigger downstream workflows) + # Use built-in GITHUB_TOKEN for release + issue operations. + # Checkout credentials are resolved above with PAT fallback. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/sync-dev-after-release.yml b/.github/workflows/sync-dev-after-release.yml index 48495fe6..b89d1cdf 100644 --- a/.github/workflows/sync-dev-after-release.yml +++ b/.github/workflows/sync-dev-after-release.yml @@ -20,7 +20,8 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.PAT_TOKEN }} + # Always use built-in workflow token so PAT rotation never breaks sync jobs. + token: ${{ github.token }} - name: Configure Git run: | diff --git a/README.md b/README.md index d626d812..8eecda7c 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ The dashboard provides visual management for all account types: - **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 +- **Language Switcher**: Toggle dashboard locale between English, Simplified Chinese, and Vietnamese **Analytics Dashboard** @@ -108,10 +109,14 @@ The dashboard provides visual management for all account types: | **Azure Foundry** | API Key | `ccs foundry` | Claude via Microsoft Azure | | **Minimax** | API Key | `ccs mm` | M2 series, 1M context | | **DeepSeek** | API Key | `ccs deepseek` | V3.2 and R1 reasoning | -| **Qwen** | API Key | `ccs qwen` | Alibaba Cloud, qwen3-coder | +| **Qwen (OAuth)** | OAuth | `ccs qwen` | Qwen Code via CLIProxy | +| **Qwen API** | API Key | `ccs api create --preset qwen` | DashScope Anthropic-compatible API | +| **Alibaba Coding Plan** | API Key | `ccs api create --preset alibaba-coding-plan` | Model Studio Coding Plan endpoint | **OpenRouter Integration** (v7.0.0): CCS v7.0.0 adds OpenRouter with interactive model picker, dynamic discovery, and tier mapping (opus/sonnet/haiku). Create via `ccs api create --preset openrouter` or dashboard. +**Alibaba Coding Plan Integration**: Configure via `ccs api create --preset alibaba-coding-plan` (or preset alias `alibaba`) with Coding Plan keys (`sk-sp-...`) and endpoint `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic`. + **Ollama Integration**: Run local open-source models (qwen3-coder, gpt-oss:20b) with full privacy. Use `ccs api create --preset ollama` - requires [Ollama v0.14.0+](https://ollama.com) installed. For cloud models, use `ccs api create --preset ollama-cloud`. **Azure Foundry**: Use `ccs api create --preset foundry` to set up Claude via Microsoft Azure AI Foundry. Requires Azure resource and API key from [ai.azure.com](https://ai.azure.com). @@ -142,9 +147,11 @@ ccs cursor # Cursor IDE integration (token import + local daemon) ccs kiro # Kiro/AWS CodeWhisperer (OAuth) ccs ghcp # GitHub Copilot (OAuth device flow) ccs agy # Antigravity (OAuth) +ccs qwen # Qwen Code (OAuth via CLIProxy) ccs ollama # Local Ollama (no API key needed) ccs glm # GLM (API key) ccs km # Kimi API profile (API key) +ccs api create --preset alibaba-coding-plan # Alibaba Coding Plan profile ``` ### Droid Alias (`argv[0]` pattern) @@ -330,6 +337,20 @@ accounts: Shared context with `standard` depth links project workspace data. `deeper` depth links additional continuity artifacts. Credentials remain isolated per account. +#### Cross-Profile Continuity Inheritance (Claude Target) + +You can map non-account profiles (API, CLIProxy, Copilot, or `default`) to reuse continuity artifacts from an account profile: + +```yaml +continuity: + inherit_from_account: + glm: pro + gemini: pro + copilot: pro +``` + +With this config, `ccs glm`, `ccs gemini`, and `ccs copilot` run with `pro`'s `CLAUDE_CONFIG_DIR` continuity context while keeping each profile's own provider credentials/settings. + Alternative path for lower manual switching: - Use CLIProxy Claude pool (`ccs cliproxy auth claude`) and manage pool behavior in `ccs config` -> `CLIProxy Plus`. @@ -515,6 +536,7 @@ See [Remote Proxy documentation](https://docs.ccs.kaitran.ca/features/remote-pro | API Profiles | [docs.ccs.kaitran.ca/providers/api-profiles](https://docs.ccs.kaitran.ca/providers/api-profiles) | | Remote Proxy | [docs.ccs.kaitran.ca/features/remote-proxy](https://docs.ccs.kaitran.ca/features/remote-proxy) | | Cursor IDE (local guide) | [./docs/cursor-integration.md](./docs/cursor-integration.md) | +| Dashboard i18n (local guide) | [./docs/i18n-dashboard.md](./docs/i18n-dashboard.md) | | CLI Reference | [docs.ccs.kaitran.ca/reference/cli-commands](https://docs.ccs.kaitran.ca/reference/cli-commands) | | Architecture | [docs.ccs.kaitran.ca/reference/architecture](https://docs.ccs.kaitran.ca/reference/architecture) | | Troubleshooting | [docs.ccs.kaitran.ca/reference/troubleshooting](https://docs.ccs.kaitran.ca/reference/troubleshooting) | diff --git a/docs/i18n-dashboard.md b/docs/i18n-dashboard.md new file mode 100644 index 00000000..58a0b293 --- /dev/null +++ b/docs/i18n-dashboard.md @@ -0,0 +1,115 @@ +# Dashboard i18n Guide + +Last Updated: 2026-03-02 + +This document describes the internationalization (i18n) architecture used by the CCS Dashboard (`ui/`), how locale selection works, and how to add new languages safely. + +--- + +## Scope + +Dashboard i18n currently covers UI text rendered by React components. + +- Supported locales today: + - `en` (English) + - `zh-CN` (Simplified Chinese) + - `vi` (Vietnamese) +- Locale state is persisted in browser localStorage using `ccs-ui-locale`. +- Fallback language is `en`. + +Out of scope: + +- CLI terminal output localization +- Backend/API payload localization + +--- + +## Architecture + +### Initialization + +- File: `ui/src/App.tsx` +- `import '@/lib/i18n'` initializes i18next once before routes render. + +### Translation resources + +- File: `ui/src/lib/i18n.ts` +- Contains `resources` object with locale blocks: + - `en.translation` + - `zh-CN.translation` + - `vi.translation` +- Uses `initReactI18next` for React integration. + +### Locale utilities + +- File: `ui/src/lib/locales.ts` +- Core helpers: + - `normalizeLocale(locale)` maps browser/storage values to supported app locales. + - `getInitialLocale` chooses stored locale, then browser locale, then `en`. + - `persistLocale` writes normalized locale to localStorage. + +### Language switcher + +- File: `ui/src/components/layout/language-switcher.tsx` +- Uses `react-i18next` + shadcn `Select`. +- Calls `persistLocale` and `i18n.changeLanguage` on selection. + +--- + +## Key Conventions + +### Translation key naming + +Use dot-notation namespaces by feature area: + +- `nav.home` +- `cursorPage.title` +- `settingsTabs.web` + +Keep key names stable and semantic to minimize churn across locales. + +### Pluralization + +For count-based strings, use i18next suffix rules: + +- `_one` +- `_other` + +Examples exist in sync/account counters. + +### Interpolation and rich text + +- Use interpolation for runtime values: `t('key', { value })`. +- Prefer plain text keys whenever possible. +- If formatting is required (for example bold segments), use `` instead of `dangerouslySetInnerHTML`. + +--- + +## Adding a New Locale + +When adding a locale such as Thai (`th`): + +1. Add locale id to the supported locale list in `ui/src/lib/locales.ts`. +2. Add locale display label under `translation.locale` in `ui/src/lib/i18n.ts`. +3. Add a full `.translation` block with all keys. +4. Verify fallback behavior remains `en` for missing keys. +5. Run UI validation and i18n tests: + - `cd ui && bun run validate` + - `cd ui && bun run test:run tests/unit/ui/i18n/language-switcher.test.tsx` +6. Add or update key-parity tests to catch locale drift. + +Current issue driving the Vietnamese rollout: + +- https://github.com/kaitranntt/ccs/issues/659 + +--- + +## Contributor Checklist + +Before opening a PR that touches i18n: + +- [ ] New UI strings are translated in all supported locales. +- [ ] No raw key literals appear in UI at runtime. +- [ ] No unsafe HTML injection path introduced for translated content. +- [ ] `ui` validate/test commands pass. +- [ ] This document is updated if architecture or conventions changed. diff --git a/docs/metrics/maintainability-baseline.json b/docs/metrics/maintainability-baseline.json index 773fb0b9..2d0dcd87 100644 --- a/docs/metrics/maintainability-baseline.json +++ b/docs/metrics/maintainability-baseline.json @@ -1,9 +1,9 @@ { "sourceDirectory": "src", "largeFileThresholdLoc": 350, - "typeScriptFileCount": 385, - "locInSrc": 81556, + "typeScriptFileCount": 389, + "locInSrc": 83534, "processExitReferenceCount": 185, - "synchronousFsApiReferenceCount": 892, - "largeFileCountOver350Loc": 58 + "synchronousFsApiReferenceCount": 894, + "largeFileCountOver350Loc": 59 } diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 2ef26085..8a721c93 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -86,6 +86,7 @@ All major modularization work is complete. The codebase evolved from monolithic |-------|-------|------| | #137 | CCS Cannot Connect to IDE, but Native Claude Works | support | | #89 | Add Claude Code CLI flag passthrough for delegation | enhancement | +| #659 | Comprehensive Vietnamese i18n for dashboard | enhancement | ### Low Priority / Questions diff --git a/docs/session-sharing-technical-analysis.md b/docs/session-sharing-technical-analysis.md index 9e4b9a68..a42b7dff 100644 --- a/docs/session-sharing-technical-analysis.md +++ b/docs/session-sharing-technical-analysis.md @@ -50,6 +50,25 @@ Deeper continuity links these directories per context group: `.anthropic` and account credentials remain isolated. +## Cross-Profile Inheritance (API / CLIProxy / Copilot) + +You can explicitly map non-account profiles (including `default`) to reuse continuity artifacts from an account profile: + +```yaml +continuity: + inherit_from_account: + glm: pro + gemini: pro + copilot: pro +``` + +Behavior: + +- Applies only when running Claude target (`ccs ` or `--target claude`) +- Does not change provider credentials or API routing +- Reuses `CLAUDE_CONFIG_DIR` from mapped account profile after normal account context policy resolution +- Invalid/missing mapped accounts are skipped safely + ## User Workflows ### New account with shared context diff --git a/docs/system-architecture/index.md b/docs/system-architecture/index.md index 38f64bf0..02b3d450 100644 --- a/docs/system-architecture/index.md +++ b/docs/system-architecture/index.md @@ -1,6 +1,6 @@ # CCS System Architecture -Last Updated: 2026-02-16 +Last Updated: 2026-03-02 High-level architecture overview for the CCS (Claude Code Switch) system. @@ -15,6 +15,8 @@ The system consists of two main components: 1. **CLI Application** (`src/`) - Node.js TypeScript CLI 2. **Dashboard UI** (`ui/`) - React web application served by Express +Dashboard localization (i18n) architecture and contributor workflow are documented in [Dashboard i18n Guide](../i18n-dashboard.md). + CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy with automatic injection for all profile types. ``` diff --git a/package.json b/package.json index d4699053..13dcd428 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.51.0", + "version": "7.51.0-dev.14", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index 6e21c2e4..1b9ae1b3 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -14,6 +14,12 @@ import { import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; import type { TargetType } from '../../targets/target-adapter'; import { resolveDroidProvider } from '../../targets/droid-provider'; +import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; +import { + extractProviderFromPathname, + getDeniedModelIdReasonForProvider, +} from '../../cliproxy/model-id-normalizer'; +import type { CLIProxyProvider } from '../../cliproxy/types'; import type { ModelMapping, CreateApiProfileResult, @@ -26,6 +32,34 @@ function isOpenRouterUrl(baseUrl: string): boolean { return baseUrl.toLowerCase().includes('openrouter.ai'); } +function resolveProviderFromBaseUrl(baseUrl: string): CLIProxyProvider | null { + if (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 getDeniedModelReason(baseUrl: string, models: ModelMapping): string | null { + const provider = resolveProviderFromBaseUrl(baseUrl); + if (!provider) return null; + + for (const modelId of [models.default, models.opus, models.sonnet, models.haiku]) { + if (modelId.trim().length === 0) continue; + const deniedReason = getDeniedModelIdReasonForProvider(modelId, provider); + if (deniedReason) return deniedReason; + } + + return null; +} + /** Create settings.json file for API profile (legacy format) */ function createSettingsFile( name: string, @@ -159,6 +193,11 @@ export function createApiProfile( provider?: string ): CreateApiProfileResult { try { + const deniedReason = getDeniedModelReason(baseUrl, models); + if (deniedReason) { + return { success: false, settingsFile: '', error: deniedReason }; + } + const settingsFile = `~/.ccs/${name}.settings.json`; if (isUnifiedMode()) { diff --git a/src/auth/profile-continuity-inheritance.ts b/src/auth/profile-continuity-inheritance.ts new file mode 100644 index 00000000..7503c053 --- /dev/null +++ b/src/auth/profile-continuity-inheritance.ts @@ -0,0 +1,164 @@ +import * as fs from 'fs'; +import { + getConfigJsonPath, + getContinuityInheritanceMap, + isUnifiedMode, +} from '../config/unified-config-loader'; +import { warn } from '../utils/ui'; +import InstanceManager from '../management/instance-manager'; +import ProfileRegistry from './profile-registry'; +import { isAccountContextMetadata, resolveAccountContextPolicy } from './account-context'; +import type { ProfileType } from '../types/profile'; +import { getProfileLookupCandidates, resolveAliasToCanonical } from '../utils/profile-compat'; + +export interface ProfileContinuityInheritanceInput { + profileName: string; + profileType: ProfileType; + target: string; +} + +export interface ProfileContinuityInheritanceResult { + sourceAccount?: string; + claudeConfigDir?: string; +} + +function loadLegacyContinuityInheritanceMap(): Record { + const configJsonPath = getConfigJsonPath(); + if (!fs.existsSync(configJsonPath)) { + return {}; + } + + try { + const raw = fs.readFileSync(configJsonPath, 'utf8'); + const parsed = JSON.parse(raw) as { continuity_inherit_from_account?: unknown }; + if ( + typeof parsed.continuity_inherit_from_account !== 'object' || + parsed.continuity_inherit_from_account === null || + Array.isArray(parsed.continuity_inherit_from_account) + ) { + return {}; + } + + const normalized: Record = {}; + for (const [profileName, accountName] of Object.entries( + parsed.continuity_inherit_from_account as Record + )) { + if (typeof accountName !== 'string') continue; + const normalizedProfile = profileName.trim(); + const normalizedAccount = accountName.trim(); + if (!normalizedProfile || !normalizedAccount) continue; + normalized[normalizedProfile] = normalizedAccount; + } + + return normalized; + } catch (error) { + console.error( + warn( + `Failed to parse legacy continuity mapping at "${configJsonPath}": ${(error as Error).message}` + ) + ); + return {}; + } +} + +function resolveMappedAccount( + profileName: string, + profileType: ProfileType, + inheritFromAccount: Record +): string | undefined { + const candidates = new Set(getProfileLookupCandidates(profileName)); + if (profileType === 'settings') { + candidates.add(resolveAliasToCanonical(profileName)); + } + + for (const candidate of candidates) { + const mapped = inheritFromAccount[candidate]; + if (typeof mapped !== 'string') { + continue; + } + + const normalized = mapped.trim(); + if (normalized.length > 0) { + return normalized; + } + } + + const normalizedCandidates = new Set( + [...candidates].map((candidate) => candidate.trim().toLowerCase()).filter(Boolean) + ); + for (const [mappedProfileName, mappedAccountName] of Object.entries(inheritFromAccount)) { + if (!normalizedCandidates.has(mappedProfileName.trim().toLowerCase())) { + continue; + } + + const normalized = mappedAccountName.trim(); + if (normalized.length > 0) { + return normalized; + } + } + + return undefined; +} + +/** + * Resolve optional cross-profile continuity inheritance. + * + * Rules: + * - Claude target only. + * - Never applies to account profiles. + * - Mapping source must be an account profile. + */ +export async function resolveProfileContinuityInheritance( + input: ProfileContinuityInheritanceInput +): Promise { + if (input.target !== 'claude' || input.profileType === 'account') { + return {}; + } + + const inheritFromAccount = getContinuityInheritanceMap(); + const sourceAccount = + resolveMappedAccount(input.profileName, input.profileType, inheritFromAccount) ?? + (!isUnifiedMode() + ? resolveMappedAccount( + input.profileName, + input.profileType, + loadLegacyContinuityInheritanceMap() + ) + : undefined); + if (!sourceAccount) { + return {}; + } + + try { + const registry = new ProfileRegistry(); + const profiles = registry.getAllProfilesMerged(); + const mappedProfile = profiles[sourceAccount]; + if (!mappedProfile || mappedProfile.type !== 'account') { + console.error( + warn( + `Continuity inheritance skipped for "${input.profileName}": source "${sourceAccount}" not found or not an account profile` + ) + ); + return {}; + } + + const contextPolicy = resolveAccountContextPolicy( + isAccountContextMetadata(mappedProfile) ? mappedProfile : undefined + ); + const instanceMgr = new InstanceManager(); + const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy); + + return { + sourceAccount, + claudeConfigDir: instancePath, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error( + warn( + `Continuity inheritance skipped for "${input.profileName}": failed to initialize source "${sourceAccount}" (${message})` + ) + ); + return {}; + } +} diff --git a/src/ccs.ts b/src/ccs.ts index 0d735973..49e5c898 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -44,7 +44,12 @@ import { handleShellCompletionCommand } from './commands/shell-completion-comman import { handleUpdateCommand } from './commands/update-command'; // Import extracted utility functions -import { execClaude, escapeShellArg, stripClaudeCodeEnv } from './utils/shell-executor'; +import { + execClaude, + escapeShellArg, + stripClaudeCodeEnv, + getClaudeLaunchEnvOverrides, +} from './utils/shell-executor'; import { wireChildProcessSignals } from './utils/signal-forwarder'; // Import target adapter system @@ -102,7 +107,8 @@ function detectProfile(args: string[]): DetectedProfile { async function execClaudeWithProxy( claudeCli: string, profileName: string, - args: string[] + args: string[], + claudeConfigDir?: string ): Promise { // 1. Read settings to get API key const settingsPath = getSettingsPath(profileName); @@ -203,6 +209,7 @@ async function execClaudeWithProxy( ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`, ANTHROPIC_AUTH_TOKEN: apiKey, ANTHROPIC_MODEL: configuredModel, + ...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}), }; const isWindows = process.platform === 'win32'; @@ -210,8 +217,10 @@ async function execClaudeWithProxy( const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli); const webSearchEnv = getWebSearchHookEnv(); const imageAnalysisEnv = getImageAnalysisHookEnv(profileName); + const claudeLaunchEnv = getClaudeLaunchEnvOverrides(); const env = stripClaudeCodeEnv({ ...process.env, + ...claudeLaunchEnv, ...envVars, ...webSearchEnv, ...imageAnalysisEnv, @@ -604,6 +613,8 @@ async function main(): Promise { const ProfileRegistry = ProfileRegistryModule.default; const AccountContextModule = await import('./auth/account-context'); const { resolveAccountContextPolicy, isAccountContextMetadata } = AccountContextModule; + const ProfileContinuityModule = await import('./auth/profile-continuity-inheritance'); + const { resolveProfileContinuityInheritance } = ProfileContinuityModule; const detector = new ProfileDetector(); @@ -941,7 +952,23 @@ async function main(): Promise { console.error(fail('Copilot configuration not found')); process.exit(1); } - const exitCode = await executeCopilotProfile(copilotConfig, remainingArgs); + const continuityInheritance = await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }); + if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` + ) + ); + } + const exitCode = await executeCopilotProfile( + copilotConfig, + remainingArgs, + continuityInheritance.claudeConfigDir + ); process.exit(exitCode); } else if (profileInfo.type === 'settings') { // Settings-based profiles (glm, glmt) are third-party providers @@ -956,6 +983,23 @@ async function main(): Promise { // Display WebSearch status (single line, equilibrium UX) displayWebSearchStatus(); + const continuityInheritance = + resolvedTarget === 'claude' + ? await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }) + : {}; + if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` + ) + ); + } + const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; + // Pre-flight validation for GLM/GLMT/MiniMax profiles if (profileInfo.name === 'glm' || profileInfo.name === 'glmt') { const preflightSettingsPath = getSettingsPath(profileInfo.name); @@ -1019,7 +1063,12 @@ async function main(): Promise { process.exit(1); } // GLMT FLOW: Settings-based with embedded proxy for thinking support - await execClaudeWithProxy(claudeCli, profileInfo.name, remainingArgs); + await execClaudeWithProxy( + claudeCli, + profileInfo.name, + remainingArgs, + inheritedClaudeConfigDir + ); } else { // EXISTING FLOW: Settings-based profile (glm) // Use --settings flag (backward compatible) @@ -1048,6 +1097,7 @@ async function main(): Promise { const envVars: NodeJS.ProcessEnv = { ...globalEnv, ...settingsEnv, // Explicitly inject all settings env vars + ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), ...webSearchEnv, ...imageAnalysisEnv, CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider @@ -1121,6 +1171,24 @@ async function main(): Promise { CCS_IMAGE_ANALYSIS_SKIP: '1', }; + if (resolvedTarget === 'claude') { + const defaultContinuityInheritance = await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }); + if (defaultContinuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${defaultContinuityInheritance.sourceAccount}"` + ) + ); + } + if (defaultContinuityInheritance.claudeConfigDir) { + envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir; + } + } + // Dispatch through target adapter for non-claude targets if (resolvedTarget !== 'claude') { const adapter = targetAdapter; diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index ba157784..d3458a18 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -217,13 +217,67 @@ function displayUrlFromStderr( } } +const ANSI_ESCAPE_REGEX = /\x1b\[[0-9;]*m/g; + +export function extractLikelyAuthFailureFromStderr( + provider: CLIProxyProvider, + stderrData: string +): string | null { + // Keep this scoped to ghcp to avoid over-classifying other providers. + if (provider !== 'ghcp') { + return null; + } + + if (!stderrData.trim()) { + return null; + } + + const normalizedLines = stderrData + .split('\n') + .map((line) => line.replace(ANSI_ESCAPE_REGEX, '').trim()) + .filter(Boolean) + .map((line) => { + const messageIndex = line.indexOf('msg="'); + if (messageIndex >= 0) { + const message = line + .slice(messageIndex + 5) + .replace(/"$/, '') + .trim(); + if (message) { + return message; + } + } + return line; + }); + + const prioritizedPatterns = [ + /github copilot authentication failed:\s*(.+)/i, + /authentication failed:\s*(.+)/i, + /failed to verify copilot access[^:]*:\s*(.+)/i, + /failed to save auth:\s*(.+)/i, + ]; + + for (let i = normalizedLines.length - 1; i >= 0; i--) { + const line = normalizedLines[i]; + for (const pattern of prioritizedPatterns) { + const match = line.match(pattern); + if (match?.[1]?.trim()) { + return match[1].trim().slice(0, 240); + } + } + } + + return null; +} + /** Handle token not found after successful process exit */ async function handleTokenNotFound( provider: CLIProxyProvider, callbackPort: number | null, tokenDir: string, nickname: string | undefined, - verbose: boolean + verbose: boolean, + failureReason?: string ): Promise { // Kiro-specific: Try auto-import from Kiro IDE if (provider === 'kiro') { @@ -248,6 +302,22 @@ async function handleTokenNotFound( // Default behavior for other providers console.log(''); + + if (failureReason) { + // Sanitize internal URLs/paths from failure reason to avoid leaking infrastructure details + const sanitizedReason = failureReason + .replace(/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)[^\s]*/gi, '[internal-url]') + .replace(/\/(?:root|home|opt|tmp|var)\/[^\s]*/g, '[path]'); + console.log(fail('Authentication completed but token was not persisted')); + console.log(` ${sanitizedReason}`); + console.log(''); + console.log('This usually means provider-side authorization was accepted,'); + console.log('but CLIProxy failed a post-auth verification or token save step.'); + console.log(''); + console.log(`Try: ccs ${provider} --auth --verbose`); + return null; + } + console.log(fail('Token not found after authentication')); console.log(''); console.log('The browser showed success but callback was not received.'); @@ -470,11 +540,13 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise !getDeniedModelIdReasonForProvider(remoteModel.id, provider) + ) + : remoteModels; + const staticCatalog = MODEL_CATALOG[provider]; - if (!staticCatalog && remoteModels.length === 0) return undefined; + if (!staticCatalog && filteredRemoteModels.length === 0) return undefined; const displayName = staticCatalog?.displayName || provider; - const defaultModel = staticCatalog?.defaultModel || (remoteModels[0]?.id ?? ''); + const defaultModel = staticCatalog?.defaultModel || (filteredRemoteModels[0]?.id ?? ''); // Build map of static models by lowercase ID for fast lookup const staticMap = new Map(); @@ -161,7 +169,7 @@ export function mergeCatalog( const mergedIds = new Set(); const mergedModels: ModelEntry[] = []; - for (const remote of remoteModels) { + for (const remote of filteredRemoteModels) { const remoteEntry = mapRemoteToModelEntry(remote); const staticEntry = staticMap.get(remote.id.toLowerCase()); mergedIds.add(remote.id.toLowerCase()); @@ -185,6 +193,9 @@ export function mergeCatalog( // Add static-only models not in remote if (staticCatalog) { for (const model of staticCatalog.models) { + if (getDeniedModelIdReasonForProvider(model.id, provider)) { + continue; + } if (!mergedIds.has(model.id.toLowerCase())) { mergedModels.push(model); } diff --git a/src/cliproxy/composite-validator.ts b/src/cliproxy/composite-validator.ts index 981b58e0..52b24f3b 100644 --- a/src/cliproxy/composite-validator.ts +++ b/src/cliproxy/composite-validator.ts @@ -5,6 +5,7 @@ import { CLIPROXY_SUPPORTED_PROVIDERS, CompositeTierConfig } from '../config/unified-config-types'; import type { CLIProxyProvider } from './types'; +import { getDeniedModelIdReasonForProvider } from './model-id-normalizer'; export const VALID_COMPOSITE_TIERS = ['opus', 'sonnet', 'haiku'] as const; export type CompositeTierName = (typeof VALID_COMPOSITE_TIERS)[number]; @@ -90,6 +91,11 @@ export function validateCompositeTiers( return `Invalid provider '${provider}' for tier '${tier}': must be one of ${CLIPROXY_SUPPORTED_PROVIDERS.join(', ')}`; } + const deniedTierModelReason = getDeniedModelIdReasonForProvider(model, provider); + if (deniedTierModelReason) { + return `Invalid model for tier '${tier}': ${deniedTierModelReason}`; + } + if (tierValue.fallback !== undefined) { const fallback = tierValue.fallback; if (!isRecord(fallback)) { @@ -108,6 +114,14 @@ export function validateCompositeTiers( return `Invalid fallback provider '${fallback.provider}' for tier '${tier}': must be one of ${CLIPROXY_SUPPORTED_PROVIDERS.join(', ')}`; } + const deniedFallbackReason = getDeniedModelIdReasonForProvider( + fallback.model, + fallback.provider + ); + if (deniedFallbackReason) { + return `Invalid fallback model for tier '${tier}': ${deniedFallbackReason}`; + } + if (fallback.provider === provider && fallback.model === model) { return `Circular fallback in tier '${tier}': fallback cannot point to same provider and model`; } diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index f90a0691..3a985f6b 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -21,8 +21,11 @@ import { } from './port-manager'; import { getProviderSettingsPath } from './path-resolver'; import { + canonicalizeModelIdForProvider, MODEL_ENV_VAR_KEYS, + migrateDeniedAntigravityModelAliases, normalizeModelEnvVarsForProvider, + normalizeIFlowLegacyModelAliases, normalizeModelIdForProvider, } from '../model-id-normalizer'; @@ -37,8 +40,6 @@ const DEPRECATED_MODEL_PREFIX = 'gemini-claude-'; /** Replacement prefix matching actual upstream model names */ const UPSTREAM_MODEL_PREFIX = 'claude-'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; -const IFLOW_PLACEHOLDER_MODEL = 'iflow-default'; -const IFLOW_DEFAULT_MODEL = 'qwen3-coder-plus'; const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; const REQUIRED_PROVIDER_ENV_KEYS = [ 'ANTHROPIC_BASE_URL', @@ -60,7 +61,11 @@ function stripCodexEffortSuffix(modelId: string): string { * * Returns true if migration was performed and file was updated. */ -function migrateDeprecatedModelNames(settingsPath: string, settings: ProviderSettings): boolean { +function migrateDeprecatedModelNames( + settingsPath: string, + provider: CLIProxyProvider, + settings: ProviderSettings +): boolean { if (!settings.env || typeof settings.env !== 'object') return false; let migrated = false; @@ -68,13 +73,40 @@ function migrateDeprecatedModelNames(settingsPath: string, settings: ProviderSet const value = settings.env[key]; if (typeof value !== 'string') continue; - // Check if the base model name (before any suffixes) uses the deprecated prefix - if (value.toLowerCase().startsWith(DEPRECATED_MODEL_PREFIX)) { - settings.env[key] = UPSTREAM_MODEL_PREFIX + value.slice(DEPRECATED_MODEL_PREFIX.length); + let canonical = value; + // Check if the base model name (before any suffixes) uses the deprecated prefix. + if (canonical.toLowerCase().startsWith(DEPRECATED_MODEL_PREFIX)) { + canonical = UPSTREAM_MODEL_PREFIX + canonical.slice(DEPRECATED_MODEL_PREFIX.length); + } + canonical = normalizeModelIdForProvider(canonical, provider); + if (provider === 'agy') { + canonical = migrateDeniedAntigravityModelAliases(canonical); + } + + if (canonical !== value) { + settings.env[key] = canonical; migrated = true; } } + if (provider === 'agy' && Array.isArray(settings.presets)) { + for (const preset of settings.presets) { + if (!preset || typeof preset !== 'object') continue; + const presetRecord = preset as Record; + + for (const key of PRESET_MODEL_KEYS) { + const value = presetRecord[key]; + if (typeof value !== 'string') continue; + let canonical = normalizeModelIdForProvider(value, provider); + canonical = migrateDeniedAntigravityModelAliases(canonical); + if (canonical !== value) { + presetRecord[key] = canonical; + migrated = true; + } + } + } + } + if (migrated) { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 }); @@ -139,8 +171,8 @@ function migrateCodexEffortSuffixes( } /** - * Migrate legacy iFlow placeholder model IDs to a real default model. - * Example: iflow-default -> qwen3-coder-plus + * Migrate legacy iFlow model IDs to current upstream model IDs. + * Example: iflow-default -> qwen3-coder-plus, kimi-k2.5 -> kimi-k2 */ function migrateIFlowPlaceholderModel( settingsPath: string, @@ -151,14 +183,13 @@ function migrateIFlowPlaceholderModel( if (!settings.env || typeof settings.env !== 'object') return false; let migrated = false; - const normalize = (value: string): string => value.trim().toLowerCase(); - const replaceIfPlaceholder = (value: string): string => - normalize(value) === IFLOW_PLACEHOLDER_MODEL ? IFLOW_DEFAULT_MODEL : value; + const replaceLegacyIFlowModel = (value: string): string => + normalizeIFlowLegacyModelAliases(value); for (const key of MODEL_ENV_VAR_KEYS) { const value = settings.env[key]; if (typeof value !== 'string') continue; - const replaced = replaceIfPlaceholder(value); + const replaced = replaceLegacyIFlowModel(value); if (replaced !== value) { settings.env[key] = replaced; migrated = true; @@ -173,7 +204,7 @@ function migrateIFlowPlaceholderModel( for (const key of PRESET_MODEL_KEYS) { const value = presetRecord[key]; if (typeof value !== 'string') continue; - const replaced = replaceIfPlaceholder(value); + const replaced = replaceLegacyIFlowModel(value); if (replaced !== value) { presetRecord[key] = replaced; migrated = true; @@ -409,7 +440,7 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // Migrate deprecated gemini-claude-* model names if present - migrateDeprecatedModelNames(expandedPath, settings); + migrateDeprecatedModelNames(expandedPath, provider, settings); // Migrate codex effort suffixes to canonical IDs if present migrateCodexEffortSuffixes(expandedPath, provider, settings); // Migrate legacy iFlow placeholders to supported model IDs @@ -444,7 +475,7 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // Migrate deprecated gemini-claude-* model names if present - migrateDeprecatedModelNames(settingsPath, settings); + migrateDeprecatedModelNames(settingsPath, provider, settings); // Migrate codex effort suffixes to canonical IDs if present migrateCodexEffortSuffixes(settingsPath, provider, settings); // Migrate legacy iFlow placeholders to supported model IDs @@ -532,6 +563,40 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { } } + // Canonicalize provider-specific model aliases (e.g., AGY Sonnet 4.6 thinking legacy IDs). + for (const key of MODEL_ENV_VAR_KEYS) { + const current = mergedEnv[key]; + if (typeof current !== 'string' || current.trim().length === 0) continue; + let canonical = canonicalizeModelIdForProvider(current, provider); + if (provider === 'agy') { + canonical = migrateDeniedAntigravityModelAliases(canonical); + } + if (canonical !== current) { + mergedEnv[key] = canonical; + mutated = true; + } + } + + const presetsCandidate = parsed.presets; + if (Array.isArray(presetsCandidate)) { + for (const preset of presetsCandidate) { + if (!preset || typeof preset !== 'object') continue; + const presetRecord = preset as Record; + for (const key of PRESET_MODEL_KEYS) { + const value = presetRecord[key]; + if (typeof value !== 'string') continue; + let canonical = canonicalizeModelIdForProvider(value, provider); + if (provider === 'agy') { + canonical = migrateDeniedAntigravityModelAliases(canonical); + } + if (canonical !== value) { + presetRecord[key] = canonical; + mutated = true; + } + } + } + } + if (!mutated) { return; } @@ -584,7 +649,7 @@ export function getRemoteEnvVars( const content = fs.readFileSync(expandedPath, 'utf-8'); const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { - migrateDeprecatedModelNames(expandedPath, settings); + migrateDeprecatedModelNames(expandedPath, provider, settings); migrateCodexEffortSuffixes(expandedPath, provider, settings); migrateIFlowPlaceholderModel(expandedPath, provider, settings); userEnvVars = settings.env as Record; @@ -604,7 +669,7 @@ export function getRemoteEnvVars( const content = fs.readFileSync(settingsPath, 'utf-8'); const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { - migrateDeprecatedModelNames(settingsPath, settings); + migrateDeprecatedModelNames(settingsPath, provider, settings); migrateCodexEffortSuffixes(settingsPath, provider, settings); migrateIFlowPlaceholderModel(settingsPath, provider, settings); userEnvVars = settings.env as Record; diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 0b88f6f0..3a7729e0 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -11,6 +11,7 @@ import { getModelMappingFromConfig } from '../base-config-loader'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { getEffectiveApiKey, getEffectiveManagementSecret } from '../auth-token-manager'; import { getCachedCatalog } from '../catalog-cache'; +import { getDeniedModelIdReasonForProvider } from '../model-id-normalizer'; import { getAuthDir, getProviderAuthDir, getConfigPathForPort } from './path-resolver'; import { CLIPROXY_DEFAULT_PORT } from './port-manager'; @@ -32,8 +33,10 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; * v8: Added Gemini 3.1 preview aliases for provider routing compatibility * v9: Added resilient alias compatibility expansion and cache-assisted alias enrichment * v10: Migrated deprecated gemini-claude-* aliases to upstream claude-* aliases + * v11: Migrated deprecated claude-sonnet-4-6-thinking aliases to claude-sonnet-4-6 + * v12: Removed denylisted Antigravity Claude 4.5 aliases */ -export const CLIPROXY_CONFIG_VERSION = 10; +export const CLIPROXY_CONFIG_VERSION = 12; interface OAuthModelAliasEntry { name: string; @@ -44,6 +47,8 @@ interface OAuthModelAliasEntry { const GEMINI_MINOR_COMPAT_RANGE = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const; const DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX = 'gemini-claude-'; const UPSTREAM_CLAUDE_ALIAS_PREFIX = 'claude-'; +const DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX = /^claude-sonnet-4(?:[.-])6-thinking$/i; +const CANONICAL_ANTIGRAVITY_SONNET_46_ALIAS = 'claude-sonnet-4-6'; /** * Default Antigravity oauth-model-alias entries. @@ -58,10 +63,8 @@ const DEFAULT_ANTIGRAVITY_ALIASES: OAuthModelAliasEntry[] = [ { name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview-customtools' }, { name: 'gemini-3-flash', alias: 'gemini-3-flash-preview' }, { name: 'claude-sonnet-4-6', alias: 'claude-sonnet-4-6', fork: true }, - { name: 'claude-sonnet-4-6-thinking', alias: 'claude-sonnet-4-6-thinking', fork: true }, - { name: 'claude-sonnet-4-5', alias: 'claude-sonnet-4-5', fork: true }, - { name: 'claude-sonnet-4-5-thinking', alias: 'claude-sonnet-4-5-thinking', fork: true }, - { name: 'claude-opus-4-5-thinking', alias: 'claude-opus-4-5-thinking', fork: true }, + // Backward compatibility: legacy sonnet thinking alias now routes to canonical model ID. + { name: 'claude-sonnet-4-6-thinking', alias: 'claude-sonnet-4-6', fork: true }, { name: 'claude-opus-4-6-thinking', alias: 'claude-opus-4-6-thinking', fork: true }, ]; @@ -107,10 +110,15 @@ function sanitizeYamlScalar(rawValue: string): string { function normalizeAntigravityAlias(rawAlias: string): string { const normalized = sanitizeYamlScalar(rawAlias); if (normalized.toLowerCase().startsWith(DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX)) { - return ( - UPSTREAM_CLAUDE_ALIAS_PREFIX + normalized.slice(DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX.length) - ); + const migratedPrefix = + UPSTREAM_CLAUDE_ALIAS_PREFIX + normalized.slice(DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX.length); + return normalizeAntigravityAlias(migratedPrefix); } + + if (DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX.test(normalized)) { + return CANONICAL_ANTIGRAVITY_SONNET_46_ALIAS; + } + return normalized; } @@ -125,6 +133,8 @@ function addAliasEntry( fork: entry.fork || undefined, }; if (!normalized.name || !normalized.alias) return; + if (getDeniedModelIdReasonForProvider(normalized.name, 'agy')) return; + if (getDeniedModelIdReasonForProvider(normalized.alias, 'agy')) return; const key = `${normalized.name}\u0000${normalized.alias}`; const existingIndex = indexByKey.get(key); diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index dbe49ad6..043ed4c3 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -59,6 +59,8 @@ export interface ProxyChainConfig { }; /** Composite variant: which tier is the default */ compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku'; + /** Optional inherited continuity directory from mapped account profile */ + claudeConfigDir?: string; } const CODEX_EFFORT_SUFFIX_REGEX = /^(.*)-(xhigh|high|medium)$/i; @@ -113,6 +115,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record account "${continuityInheritance.sourceAccount}"` + ); + } + } // Build initial env vars to get ANTHROPIC_BASE_URL const initialEnvVars = buildClaudeEnvironment({ @@ -818,6 +834,7 @@ export async function execClaudeWithCLIProxy( isComposite: cfg.isComposite, compositeTiers: cfg.compositeTiers, compositeDefaultTier: cfg.compositeDefaultTier, + claudeConfigDir: inheritedClaudeConfigDir, }); if (initialEnvVars.ANTHROPIC_BASE_URL) { @@ -913,6 +930,7 @@ export async function execClaudeWithCLIProxy( isComposite: cfg.isComposite, compositeTiers: cfg.compositeTiers, compositeDefaultTier: cfg.compositeDefaultTier, + claudeConfigDir: inheritedClaudeConfigDir, }); if (cfg.isComposite && cfg.compositeTiers && cfg.compositeDefaultTier) { diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index eb6a87f5..5300ef8d 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -6,7 +6,11 @@ */ import { CLIProxyProvider } from './types'; -import { normalizeModelIdForProvider } from './model-id-normalizer'; +import { + isAntigravityProvider, + migrateDeniedAntigravityModelAliases, + normalizeModelIdForProvider, +} from './model-id-normalizer'; /** * Thinking support configuration for a model. @@ -92,53 +96,17 @@ export const MODEL_CATALOG: Partial> = extendedContext: false, }, { - id: 'claude-opus-4-5-thinking', - name: 'Claude Opus 4.5 Thinking', - description: 'Previous flagship, extended thinking', + id: 'claude-sonnet-4-6', + name: 'Claude Sonnet 4.6', + description: 'Latest Sonnet with thinking budget support', thinking: { type: 'budget', min: 1024, - max: 100000, - zeroAllowed: false, - dynamicAllowed: true, - }, - }, - { - id: 'claude-sonnet-4-6-thinking', - name: 'Claude Sonnet 4.6 Thinking', - description: 'Latest Sonnet with extended thinking', - thinking: { - type: 'budget', - min: 1024, - max: 128000, + max: 64000, zeroAllowed: true, dynamicAllowed: true, }, }, - { - id: 'claude-sonnet-4-6', - name: 'Claude Sonnet 4.6', - description: 'Latest Sonnet baseline', - thinking: { type: 'none' }, - }, - { - id: 'claude-sonnet-4-5-thinking', - name: 'Claude Sonnet 4.5 Thinking', - description: 'Balanced with extended thinking', - thinking: { - type: 'budget', - min: 1024, - max: 100000, - zeroAllowed: false, - dynamicAllowed: true, - }, - }, - { - id: 'claude-sonnet-4-5', - name: 'Claude Sonnet 4.5', - description: 'Fast and capable', - thinking: { type: 'none' }, - }, { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', @@ -345,6 +313,14 @@ export function findModel(provider: CLIProxyProvider, modelId: string): ModelEnt .trim() .toLowerCase(); const lookupCandidates = new Set([normalizedId, providerNormalizedId]); + if (isAntigravityProvider(provider)) { + const migratedRaw = migrateDeniedAntigravityModelAliases(normalizedId).trim().toLowerCase(); + const migratedProvider = migrateDeniedAntigravityModelAliases(providerNormalizedId) + .trim() + .toLowerCase(); + lookupCandidates.add(migratedRaw); + lookupCandidates.add(migratedProvider); + } return catalog.models.find((m) => lookupCandidates.has(m.id.toLowerCase())); } diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index cb1ddb34..0400a54a 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -13,6 +13,7 @@ import { getProviderSettingsPath, getClaudeEnvVars } from './config-generator'; import { CLIProxyProvider } from './types'; import { initUI, color, bold, dim, ok, info, header } from '../utils/ui'; import { getCcsDir } from '../utils/config-manager'; +import { normalizeModelIdForProvider } from './model-id-normalizer'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; @@ -21,9 +22,9 @@ function stripCodexEffortSuffix(model: string, provider: CLIProxyProvider): stri return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); } -function normalizeCodexTierModel(provider: CLIProxyProvider, model: string): string { - if (provider !== 'codex') return model; - return stripCodexEffortSuffix(model, provider); +function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string): string { + const withoutCodexSuffix = stripCodexEffortSuffix(model, provider); + return normalizeModelIdForProvider(withoutCodexSuffix, provider); } /** @@ -50,7 +51,8 @@ export function getCurrentModel( try { const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - return settings.env?.ANTHROPIC_MODEL; + const model = settings.env?.ANTHROPIC_MODEL; + return typeof model === 'string' ? canonicalizeModelForProvider(provider, model) : model; } catch { return undefined; } @@ -134,7 +136,7 @@ export async function configureProviderModel( // Find default index - use current model if configured, otherwise catalog default const currentModel = getCurrentModel(provider, customSettingsPath); const targetModel = currentModel - ? stripCodexEffortSuffix(currentModel, provider) + ? canonicalizeModelForProvider(provider, currentModel) : catalog.defaultModel; const defaultIdx = catalog.models.findIndex((m) => m.id === targetModel); const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0; @@ -157,10 +159,10 @@ export async function configureProviderModel( // Get base env vars for defaults const baseEnv = getClaudeEnvVars(provider); - const selectedDefaultModel = normalizeCodexTierModel(provider, selectedModel); - const selectedOpusModel = normalizeCodexTierModel(provider, selectedModel); - const selectedSonnetModel = normalizeCodexTierModel(provider, selectedModel); - const selectedHaikuModel = normalizeCodexTierModel( + const selectedDefaultModel = canonicalizeModelForProvider(provider, selectedModel); + const selectedOpusModel = canonicalizeModelForProvider(provider, selectedModel); + const selectedSonnetModel = canonicalizeModelForProvider(provider, selectedModel); + const selectedHaikuModel = canonicalizeModelForProvider( provider, baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || selectedModel ); @@ -251,7 +253,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise> = Object.freeze({ + 'iflow-default': 'qwen3-coder-plus', + 'kimi-k2.5': 'kimi-k2', + 'deepseek-v3.2-chat': 'deepseek-v3.2', + 'glm-4.7': 'glm-4.6', + 'minimax-m2.5': 'qwen3-coder-plus', +}); + +function trimModelId(model: string): string { + return model.trim(); +} + +function splitBaseModelAndSuffix(model: string): { baseModel: string; suffix: string } { + const openParenIndex = model.indexOf('('); + const openBracketIndex = model.indexOf('['); + const suffixIndexCandidates = [openParenIndex, openBracketIndex].filter((index) => index >= 0); + + if (suffixIndexCandidates.length === 0) { + return { baseModel: model, suffix: '' }; + } + + const suffixStart = Math.min(...suffixIndexCandidates); + return { + baseModel: model.slice(0, suffixStart), + suffix: model.slice(suffixStart), + }; +} /** * Extract provider segment from `/api/provider/{provider}` request paths. @@ -41,6 +79,37 @@ export function isAntigravityProvider(provider: ProviderLike): boolean { return normalized === 'agy' || normalized === 'antigravity'; } +/** Whether provider maps to Codex model canonicalization rules. */ +export function isCodexProvider(provider: ProviderLike): boolean { + if (typeof provider !== 'string') return false; + return provider.trim().toLowerCase() === 'codex'; +} + +/** Whether provider maps to iFlow model alias canonicalization rules. */ +export function isIFlowProvider(provider: ProviderLike): boolean { + if (typeof provider !== 'string') return false; + return provider.trim().toLowerCase() === 'iflow'; +} + +/** Normalize Codex effort-suffixed IDs to canonical IDs. */ +export function stripCodexEffortSuffix(model: string): string { + return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); +} + +/** + * Normalize known legacy iFlow model aliases to current upstream model IDs. + * Preserves suffixes such as (budget) and [1m]. + */ +export function normalizeIFlowLegacyModelAliases(model: string): string { + const trimmed = trimModelId(model); + const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed); + const replacement = IFLOW_LEGACY_MODEL_ALIASES[baseModel.trim().toLowerCase()]; + if (!replacement) { + return trimmed; + } + return `${replacement}${suffix}`; +} + /** Normalize Claude dotted major.minor IDs to hyphenated format. */ export function normalizeClaudeDottedMajorMinor(model: string): string { return model.replace( @@ -62,6 +131,48 @@ export function normalizeClaudeDottedThinkingMajorMinor(model: string): string { ); } +/** + * Antigravity no longer exposes `claude-sonnet-4-6-thinking`. + * Canonicalize legacy aliases to `claude-sonnet-4-6` while preserving suffixes. + */ +export function normalizeDeprecatedAntigravityModelAliases(model: string): string { + return model.replace( + DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX, + CANONICAL_ANTIGRAVITY_SONNET_46_MODEL + ); +} + +/** + * Migrate denylisted Antigravity Claude 4.5 aliases to supported Claude 4.6 IDs. + * This is intended for repairing stale local settings at runtime. + */ +export function migrateDeniedAntigravityModelAliases(model: string): string { + const normalized = normalizeClaudeDottedMajorMinor(trimModelId(model)); + return normalized + .replace(DENIED_ANTIGRAVITY_OPUS_45_REGEX, CANONICAL_ANTIGRAVITY_OPUS_46_MODEL) + .replace(DENIED_ANTIGRAVITY_SONNET_45_REGEX, CANONICAL_ANTIGRAVITY_SONNET_46_MODEL); +} + +/** + * Deprecated Antigravity models that are denylisted and should not be used. + */ +export function isDeniedAntigravityModelId(model: string): boolean { + return DENIED_ANTIGRAVITY_MODEL_REGEX.test(trimModelId(model)); +} + +/** + * Returns deny reason when model ID is blocked for the given provider. + */ +export function getDeniedModelIdReasonForProvider( + model: string, + provider: ProviderLike +): string | null { + if (!isAntigravityProvider(provider)) return null; + const normalized = normalizeClaudeDottedMajorMinor(trimModelId(model)); + if (!isDeniedAntigravityModelId(normalized)) return null; + return 'Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.'; +} + /** * Normalize model ID for a specific provider. * Antigravity requires hyphenated Claude major.minor model IDs. @@ -75,8 +186,26 @@ export function normalizeClaudeDottedThinkingMajorMinor(model: string): string { * // => 'claude-opus-4.6-thinking' */ export function normalizeModelIdForProvider(model: string, provider: ProviderLike): string { - if (!isAntigravityProvider(provider)) return model; - return normalizeClaudeDottedMajorMinor(model); + const trimmedModel = trimModelId(model); + if (isIFlowProvider(provider)) { + return normalizeIFlowLegacyModelAliases(trimmedModel); + } + if (!isAntigravityProvider(provider)) return trimmedModel; + const normalizedDottedVersion = normalizeClaudeDottedMajorMinor(trimmedModel); + return normalizeDeprecatedAntigravityModelAliases(normalizedDottedVersion); +} + +/** + * Canonicalize model ID for provider-specific compatibility. + * - Codex: strip effort suffixes. + * - Antigravity: normalize dotted/historical aliases. + */ +export function canonicalizeModelIdForProvider(model: string, provider: ProviderLike): string { + const trimmedModel = trimModelId(model); + const withoutCodexSuffix = isCodexProvider(provider) + ? stripCodexEffortSuffix(trimmedModel) + : trimmedModel; + return normalizeModelIdForProvider(withoutCodexSuffix, provider); } /** @@ -87,17 +216,23 @@ export function normalizeModelIdForProvider(model: string, provider: ProviderLik * * @example * normalizeModelIdForRouting('claude-sonnet-4.6-thinking', null) - * // => 'claude-sonnet-4-6-thinking' + * // => 'claude-sonnet-4-6' * * @example * normalizeModelIdForRouting('claude-sonnet-4.6', null) * // => 'claude-sonnet-4.6' */ export function normalizeModelIdForRouting(model: string, provider: ProviderLike): string { + const trimmedModel = trimModelId(model); if (isAntigravityProvider(provider)) { - return normalizeClaudeDottedMajorMinor(model); + return normalizeModelIdForProvider(trimmedModel, provider); } - return normalizeClaudeDottedThinkingMajorMinor(model); + // Explicit provider routes still apply provider-specific canonicalization. + if (typeof provider === 'string' && provider.trim().length > 0) { + return normalizeModelIdForProvider(trimmedModel, provider); + } + const normalizedThinking = normalizeClaudeDottedThinkingMajorMinor(trimmedModel); + return normalizeDeprecatedAntigravityModelAliases(normalizedThinking); } /** @@ -109,7 +244,7 @@ export function normalizeModelIdForRouting(model: string, provider: ProviderLike * { ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking' }, * 'agy' * ) - * // => { ANTHROPIC_MODEL: 'claude-sonnet-4-6-thinking' } + * // => { ANTHROPIC_MODEL: 'claude-sonnet-4-6' } */ export function normalizeModelEnvVarsForProvider( envVars: NodeJS.ProcessEnv, diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index fbb917a6..f8970270 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -20,6 +20,10 @@ import { hasActiveSessions, deleteSessionLockForPort } from '../session-tracker' import { warn } from '../../utils/ui'; import { getCcsDir } from '../../utils/config-manager'; import { validateCompositeTiers } from '../composite-validator'; +import { + canonicalizeModelIdForProvider, + getDeniedModelIdReasonForProvider, +} from '../model-id-normalizer'; import { createSettingsFile, createSettingsFileUnified, @@ -115,6 +119,12 @@ export function createVariant( target: TargetType = 'claude' ): VariantOperationResult { try { + const canonicalModel = canonicalizeModelIdForProvider(model, provider); + const deniedModelReason = getDeniedModelIdReasonForProvider(canonicalModel, provider); + if (deniedModelReason) { + return { success: false, error: deniedModelReason }; + } + // Validate provider/backend compatibility (block kiro/ghcp on original backend) const backendError = validateProviderBackend(provider); if (backendError) { @@ -127,7 +137,7 @@ export function createVariant( let settingsPath: string; if (isUnifiedMode()) { - settingsPath = createSettingsFileUnified(name, provider, model, port); + settingsPath = createSettingsFileUnified(name, provider, canonicalModel, port); saveVariantUnified( name, provider as CLIProxyProvider, @@ -137,7 +147,7 @@ export function createVariant( target ); } else { - settingsPath = createSettingsFile(name, provider, model, port); + settingsPath = createSettingsFile(name, provider, canonicalModel, port); saveVariantLegacy( name, provider, @@ -151,7 +161,7 @@ export function createVariant( return { success: true, settingsPath, - variant: { provider, model, account, port, target }, + variant: { provider, model: canonicalModel, account, port, target }, }; } catch (error) { return { @@ -247,6 +257,11 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari const existingTarget = existing.target || 'claude'; const targetChanged = updates.target !== undefined && updates.target !== existingTarget; const hasModelUpdate = updates.model !== undefined && updates.model.trim().length > 0; + const providerForModelUpdate = (updates.provider ?? existing.provider) as CLIProxyProfileName; + const canonicalModelUpdate = + updates.model !== undefined + ? canonicalizeModelIdForProvider(updates.model.trim(), providerForModelUpdate) + : undefined; if (providerChanged && !hasModelUpdate) { return { @@ -255,6 +270,16 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari }; } + if (hasModelUpdate) { + const deniedModelReason = getDeniedModelIdReasonForProvider( + canonicalModelUpdate || '', + providerForModelUpdate + ); + if (deniedModelReason) { + return { success: false, error: deniedModelReason }; + } + } + // Update settings file if (existing.settings) { const settingsPath = existing.settings.replace(/^~/, os.homedir()); @@ -262,11 +287,15 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari updateSettingsProviderAndModel( settingsPath, updates.provider as CLIProxyProfileName, - updates.model?.trim() || '', + canonicalModelUpdate || '', existing.port ); } else if (updates.model !== undefined) { - updateSettingsModel(settingsPath, updates.model, existing.provider as CLIProxyProfileName); + updateSettingsModel( + settingsPath, + canonicalModelUpdate || '', + existing.provider as CLIProxyProfileName + ); } } @@ -309,7 +338,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari success: true, variant: { provider: updates.provider ?? existing.provider, - model: updates.model?.trim() || existing.model, + model: canonicalModelUpdate || existing.model, account: updates.account !== undefined ? updates.account : existing.account, port: existing.port, settings: existing.settings, diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index 36862948..4dbca1fc 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -18,6 +18,7 @@ import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector' import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector'; import { getEffectiveApiKey } from '../auth-token-manager'; import { warn } from '../../utils/ui'; +import { normalizeModelIdForProvider } from '../model-id-normalizer'; /** Environment settings structure */ interface SettingsEnv { @@ -41,8 +42,10 @@ function canonicalizeModelForProvider( provider: CLIProxyProfileName | undefined, model: string ): string { - if (provider !== 'codex') return model; - return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); + const withoutCodexSuffix = + provider === 'codex' ? model.replace(CODEX_EFFORT_SUFFIX_REGEX, '') : model; + if (!provider) return withoutCodexSuffix; + return normalizeModelIdForProvider(withoutCodexSuffix, provider); } /** diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index 6acaf8b7..72aacd0c 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -56,7 +56,7 @@ export interface CliproxyStats { } /** Request detail from CLIProxyAPI */ -interface RequestDetail { +export interface CliproxyRequestDetail { timestamp: string; source: string; auth_index: number; @@ -70,8 +70,11 @@ interface RequestDetail { failed: boolean; } +/** @deprecated Use CliproxyRequestDetail instead */ +type RequestDetail = CliproxyRequestDetail; + /** Usage API response from CLIProxyAPI /v0/management/usage endpoint */ -interface UsageApiResponse { +export interface CliproxyUsageApiResponse { failed_requests?: number; usage?: { total_requests?: number; @@ -130,7 +133,7 @@ export async function fetchCliproxyStats(port?: number): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const target = getProxyTarget(); + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl(target, '/v0/management/usage'); + + const headers = target.isRemote + ? buildManagementHeaders(target) + : { Accept: 'application/json', Authorization: `Bearer ${getEffectiveManagementSecret()}` }; + + const response = await fetch(url, { + signal: controller.signal, + headers, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + return (await response.json()) as CliproxyUsageApiResponse; + } catch { + return null; + } +} + /** OpenAI-compatible model object from /v1/models endpoint */ export interface CliproxyModel { id: string; diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index 3dc0b379..2c9e31c1 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -18,7 +18,11 @@ import * as os from 'os'; import { URL } from 'url'; import { ToolNameMapper, type Tool, type ContentBlock } from './tool-name-mapper'; import { sanitizeToolSchemas } from './schema-sanitizer'; -import { extractProviderFromPathname, normalizeModelIdForRouting } from './model-id-normalizer'; +import { + extractProviderFromPathname, + getDeniedModelIdReasonForProvider, + normalizeModelIdForRouting, +} from './model-id-normalizer'; import { getCcsDir } from '../utils/config-manager'; export interface ToolSanitizationProxyConfig { @@ -211,6 +215,15 @@ export class ToolSanitizationProxy { let modifiedBody = parsed; if (isRecord(modifiedBody) && typeof modifiedBody.model === 'string') { const providerFromPath = extractProviderFromPathname(fullUpstreamUrl.pathname); + const deniedReason = getDeniedModelIdReasonForProvider( + modifiedBody.model, + providerFromPath + ); + if (deniedReason) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: deniedReason })); + return; + } const normalizedModel = normalizeModelIdForRouting(modifiedBody.model, providerFromPath); if (normalizedModel !== modifiedBody.model) { this.writeLog( diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 925ee534..f3b246de 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -197,6 +197,8 @@ export interface ExecutorConfig { compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku'; /** Original profile/variant name (e.g., "my-mix" for composite variants) */ profileName?: string; + /** Optional inherited continuity directory from mapped account profile */ + claudeConfigDir?: string; } /** diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index cdbb2f43..f44f6f49 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -659,6 +659,8 @@ async function showHelp(): Promise { console.log(''); console.log(` ${dim('# Quick setup with preset')}`); console.log(` ${color('ccs api create --preset openrouter', 'command')}`); + console.log(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`); + 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 with name')}`); diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index 28bb0dfa..611b553c 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -160,10 +160,10 @@ export async function handleConfigCommand(args: string[]): Promise { try { // Start server - const { server, wss } = await startServer({ port, dev: options.dev }); + const { server, wss, cleanup } = await startServer({ port, dev: options.dev }); // Setup graceful shutdown - setupGracefulShutdown(server, wss); + setupGracefulShutdown(server, wss, cleanup); const url = `http://localhost:${port}`; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index b7b8ed0e..8c849d36 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -133,6 +133,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs glm', 'GLM 5 (API key required)'], ['ccs glmt', 'GLM with thinking mode'], ['ccs km', 'Kimi for Coding (API key)'], + [ + 'ccs api create --preset alibaba-coding-plan', + 'Alibaba Coding Plan (Anthropic-compatible API key)', + ], ['ccs ollama', 'Local Ollama (http://localhost:11434)'], ['ccs ollama-cloud', 'Ollama Cloud (API key required)'], ['', ''], // Spacer @@ -155,6 +159,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); 'Create account profile (supports shared groups + --deeper-continuity)', ], ['ccs config', 'Dashboard: Accounts table can edit context mode/group/continuity depth'], + [ + '~/.ccs/config.yaml', + 'Optional: continuity.inherit_from_account maps API/CLIProxy/copilot/default profiles to an account context', + ], ['ccs auth list', 'List all account profiles'], ['ccs auth default ', 'Set default profile'], ['ccs auth reset-default', 'Restore original CCS default'], @@ -180,7 +188,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs gemini', 'Google Gemini (gemini-2.5-pro or 3-pro)'], ['ccs codex', 'OpenAI Codex (supports -medium/-high/-xhigh model suffixes)'], ['ccs agy', 'Antigravity (Claude/Gemini models)'], - ['ccs qwen', 'Qwen Code (qwen3-coder)'], + ['ccs qwen', 'Qwen Code OAuth (CLIProxy)'], ['ccs kimi', 'Kimi (Moonshot AI K2/K2.5 models)'], ['ccs kiro', 'Kiro (AWS CodeWhisperer Claude models)'], ['ccs ghcp', 'GitHub Copilot (OAuth via CLIProxy Plus)'], diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 1fbec374..cc8e0381 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -29,6 +29,7 @@ import { DashboardAuthConfig, ImageAnalysisConfig, CursorConfig, + ContinuityConfig, } from './unified-config-types'; import { validateCompositeTiers } from '../cliproxy/composite-validator'; import { isUnifiedConfigEnabled } from './feature-flags'; @@ -230,12 +231,60 @@ function validateCompositeVariants(config: UnifiedConfig): void { } } +/** + * Normalize continuity inheritance mapping payload. + * Keeps only non-empty string keys and values. + */ +function normalizeContinuityInheritanceMap(value: unknown): Record | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return undefined; + } + + const normalized: Record = {}; + for (const [profileName, accountName] of Object.entries(value as Record)) { + const normalizedProfile = profileName.trim(); + const normalizedAccount = typeof accountName === 'string' ? accountName.trim() : ''; + + if (!normalizedProfile || !normalizedAccount) { + continue; + } + + normalized[normalizedProfile] = normalizedAccount; + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +/** + * Normalize continuity section. + * Supports legacy root key: continuity_inherit_from_account. + */ +function normalizeContinuityConfig(partial: Partial): ContinuityConfig | undefined { + const legacyMap = normalizeContinuityInheritanceMap( + (partial as Partial & { continuity_inherit_from_account?: unknown }) + .continuity_inherit_from_account + ); + const continuityMap = normalizeContinuityInheritanceMap(partial.continuity?.inherit_from_account); + + if (!legacyMap && !continuityMap) { + return undefined; + } + + return { + inherit_from_account: { + ...(legacyMap ?? {}), + ...(continuityMap ?? {}), + }, + }; +} + /** * Merge partial config with defaults. * Preserves existing data while filling in missing sections. */ function mergeWithDefaults(partial: Partial): UnifiedConfig { const defaults = createEmptyUnifiedConfig(); + const continuity = normalizeContinuityConfig(partial); return { version: partial.version ?? defaults.version, setup_completed: partial.setup_completed, @@ -328,6 +377,7 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { enabled: partial.global_env?.enabled ?? true, env: partial.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV }, }, + continuity, // CLIProxy server config - remote/local CLIProxyAPI settings cliproxy_server: { remote: { @@ -650,6 +700,22 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Continuity inheritance section + if (config.continuity?.inherit_from_account) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Continuity Inheritance: Reuse account continuity artifacts across profiles'); + lines.push('# Map execution profile names to source account profiles (CLAUDE_CONFIG_DIR).'); + lines.push('# Applies to Claude target only; credentials remain profile-specific.'); + lines.push('# Example: continuity.inherit_from_account.glm: pro'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump({ continuity: config.continuity }, { indent: 2, lineWidth: -1, quotingType: '"' }) + .trim() + ); + lines.push(''); + } + // Thinking section (extended thinking/reasoning configuration) if (config.thinking) { lines.push('# ----------------------------------------------------------------------------'); @@ -958,6 +1024,15 @@ export function getGlobalEnvConfig(): GlobalEnvConfig { }; } +/** + * Get continuity inheritance mapping. + * Returns empty mapping when not configured. + */ +export function getContinuityInheritanceMap(): Record { + const config = loadOrCreateUnifiedConfig(); + return config.continuity?.inherit_from_account ?? {}; +} + /** * Get cliproxy safety configuration. * Returns defaults if not configured. diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 287cba72..5ddc755a 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -418,6 +418,15 @@ export interface GlobalEnvConfig { env: Record; } +/** + * Cross-profile continuity inheritance configuration. + * Maps execution profile names to source account profiles for CLAUDE_CONFIG_DIR reuse. + */ +export interface ContinuityConfig { + /** Profile name -> source account profile name */ + inherit_from_account?: Record; +} + /** * Default global env vars for third-party profiles. * These disable Claude Code telemetry/reporting since we're using proxy. @@ -717,6 +726,8 @@ export interface UnifiedConfig { websearch?: WebSearchConfig; /** Global environment variables for all non-Claude subscription profiles */ global_env?: GlobalEnvConfig; + /** Cross-profile continuity inheritance mapping */ + continuity?: ContinuityConfig; /** Copilot API configuration (GitHub Copilot proxy) */ copilot?: CopilotConfig; /** Cursor IDE configuration (Cursor proxy daemon) */ diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index 2fd90074..a44f7c75 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -39,7 +39,10 @@ export async function getCopilotStatus(config: CopilotConfig): Promise { +export function generateCopilotEnv( + config: CopilotConfig, + claudeConfigDir?: string +): Record { // Use mapped models if configured, otherwise fall back to default model const opusModel = config.opus_model || config.model; const sonnetModel = config.sonnet_model || config.model; @@ -59,6 +62,7 @@ export function generateCopilotEnv(config: CopilotConfig): Record { // Ensure copilot-api is installed (auto-install if missing, auto-update if outdated) try { @@ -130,7 +135,7 @@ export async function executeCopilotProfile( } // Generate environment for Claude - const copilotEnv = generateCopilotEnv(config); + const copilotEnv = generateCopilotEnv(config, claudeConfigDir); // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles const globalEnvConfig = getGlobalEnvConfig(); diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index a4695560..85265534 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -17,7 +17,7 @@ import { StreamBuffer, formatToolVerbose } from './executor/stream-parser'; import { buildExecutionResult } from './executor/result-aggregator'; import { getCcsDir, getModelDisplayName } from '../utils/config-manager'; import { getProfileLookupCandidates } from '../utils/profile-compat'; -import { stripClaudeCodeEnv } from '../utils/shell-executor'; +import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor'; // Re-export types for consumers export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types'; @@ -210,7 +210,10 @@ export class HeadlessExecutor { // Strip Claude Code nested session guard env var to allow CCS delegation // (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions) - const cleanEnv = stripClaudeCodeEnv(process.env); + const cleanEnv = stripClaudeCodeEnv({ + ...process.env, + ...getClaudeLaunchEnvOverrides(), + }); const proc = spawn(claudeCli, args, { cwd, diff --git a/src/errors/error-types.ts b/src/errors/error-types.ts index 92cf2cd7..08424dfe 100644 --- a/src/errors/error-types.ts +++ b/src/errors/error-types.ts @@ -155,6 +155,20 @@ export class UserAbortError extends CCSError { } } +/** + * Input validation errors (model denylist, invalid format, length limits) + * Distinguishes user-input validation failures from system errors + */ +export class ValidationError extends CCSError { + constructor( + message: string, + public readonly field?: string + ) { + super(message, ExitCode.GENERAL_ERROR, false); + this.name = 'ValidationError'; + } +} + /** * Type guard to check if an error is a CCSError */ diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index b6c017e9..551e3a4d 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -9,6 +9,7 @@ export type PresetCategory = 'recommended' | 'alternative'; export const PROVIDER_PRESET_IDS = [ 'openrouter', + 'alibaba-coding-plan', 'ollama', 'glm', 'glmt', @@ -51,6 +52,8 @@ export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api'; */ export const PROVIDER_PRESET_ALIASES: Readonly> = Object.freeze({ kimi: 'km', + alibaba: 'alibaba-coding-plan', + acp: 'alibaba-coding-plan', }); const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ @@ -69,6 +72,21 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ featured: true, icon: '/icons/openrouter.svg', }, + { + id: 'alibaba-coding-plan', + name: 'Alibaba Coding Plan', + description: 'Alibaba Cloud Coding Plan via Anthropic-compatible endpoint', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/apps/anthropic', + defaultProfileName: 'albb', + defaultModel: 'qwen3-coder-plus', + apiKeyPlaceholder: 'sk-sp-...', + apiKeyHint: 'Get your Coding Plan key from Alibaba Cloud Model Studio', + category: 'recommended', + requiresApiKey: true, + badge: 'Coding Plan', + featured: true, + icon: '/assets/providers/alibabacloud-color.svg', + }, { id: 'ollama', name: 'Ollama (Local)', @@ -183,7 +201,7 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ name: 'Qwen', description: 'Alibaba Cloud - Qwen3 models (256K-1M context, thinking support)', baseUrl: 'https://dashscope-intl.aliyuncs.com/apps/anthropic', - defaultProfileName: 'qwen', + defaultProfileName: 'qwen-api', defaultModel: 'qwen3-coder-plus', apiKeyPlaceholder: 'sk-...', apiKeyHint: 'Get your API key from Alibaba Cloud Model Studio', diff --git a/src/types/config.ts b/src/types/config.ts index bd3787ae..754753dc 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -51,6 +51,8 @@ export interface Config { profile_targets?: Record; /** User-defined CLIProxy profile variants (optional) */ cliproxy?: CLIProxyVariantsConfig; + /** Legacy continuity inheritance mapping (profile -> source account) */ + continuity_inherit_from_account?: Record; } /** diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index d8ea89d3..0d9cbd45 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -8,6 +8,7 @@ import { spawn, spawnSync, ChildProcess } from 'child_process'; import { ErrorManager } from './error-manager'; import { getWebSearchHookEnv } from './websearch-manager'; import { wireChildProcessSignals } from './signal-forwarder'; +import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; /** * Strip ANTHROPIC_* env vars from an environment object. @@ -40,6 +41,22 @@ export function stripClaudeCodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return result; } +/** + * Resolve CCS-managed environment overrides for Claude launch. + * - preferences.auto_update: false -> DISABLE_AUTOUPDATER=1 + */ +export function getClaudeLaunchEnvOverrides(): NodeJS.ProcessEnv { + try { + const config = loadOrCreateUnifiedConfig(); + if (config.preferences?.auto_update === false) { + return { DISABLE_AUTOUPDATER: '1' }; + } + } catch { + // Config read errors should never block Claude launch. + } + return {}; +} + /** * Escape arguments for shell execution (cross-platform) * @@ -84,6 +101,7 @@ export function execClaude( // Get WebSearch hook config env vars const webSearchEnv = getWebSearchHookEnv(); + const claudeLaunchEnv = getClaudeLaunchEnvOverrides(); // For account/default profiles, strip ANTHROPIC_* from parent env to prevent // stale proxy config (e.g., from prior CLIProxy sessions) from interfering @@ -97,8 +115,8 @@ export function execClaude( // Prepare environment (merge with base env if envVars provided) const mergedEnv = envVars - ? { ...baseEnv, ...envVars, ...webSearchEnv } - : { ...baseEnv, ...webSearchEnv }; + ? { ...baseEnv, ...claudeLaunchEnv, ...envVars, ...webSearchEnv } + : { ...baseEnv, ...claudeLaunchEnv, ...webSearchEnv }; // Strip Claude Code nested session guard env var to allow CCS delegation // (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions) diff --git a/src/web-server/index.ts b/src/web-server/index.ts index fd64547c..61dd2941 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -13,6 +13,7 @@ import { WebSocketServer } from 'ws'; import { setupWebSocket } from './websocket'; import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware'; import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync'; +import { shutdownUsageAggregator } from './usage/aggregator'; export interface ServerOptions { port: number; @@ -107,6 +108,7 @@ export async function startServer(options: ServerOptions): Promise { wsCleanup(); stopAutoSyncWatcher().catch(() => {}); + shutdownUsageAggregator(); }; // Start listening diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 4513d36c..935ecccf 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -151,6 +151,19 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 3.75, cacheReadPerMillion: 0.3, }, + // Claude 4.6 Sonnet ($3/$15) + 'claude-sonnet-4-6': { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, + }, + 'claude-sonnet-4-6-thinking': { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, + }, // Claude 4 Opus ($15/$75) 'claude-4-opus-20250514': { inputPerMillion: 15.0, @@ -202,6 +215,19 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, }, + // Claude 4.6 Opus ($5/$25) + 'claude-opus-4-6': { + inputPerMillion: 5.0, + outputPerMillion: 25.0, + cacheCreationPerMillion: 6.25, + cacheReadPerMillion: 0.5, + }, + 'claude-opus-4-6-thinking': { + inputPerMillion: 5.0, + outputPerMillion: 25.0, + cacheCreationPerMillion: 6.25, + cacheReadPerMillion: 0.5, + }, // --------------------------------------------------------------------------- // OpenAI Models - Source: better-ccusage @@ -719,6 +745,18 @@ function normalizeModelName(model: string): string { return normalized; } +/** + * Strip trailing date suffix from model name (e.g., "-20260101") + * Claude session IDs can place dates either at the end or before "-thinking". + */ +function stripDateSuffix(model: string): string { + if (!model.startsWith('claude-')) { + return model; + } + + return model.replace(/-\d{8}(?=-thinking(?:$|:))/g, '').replace(/-\d{8}(?=$|:)/g, ''); +} + const NORMALIZED_PRICING_REGISTRY: Record = Object.entries( PRICING_REGISTRY ).reduce>((acc, [key, pricing]) => { @@ -730,7 +768,22 @@ function getLookupCandidates(model: string): string[] { const normalized = normalizeModelName(model); const baseModel = normalized.split(':')[0]; - return baseModel === normalized ? [normalized] : [normalized, baseModel]; + const candidates: string[] = [normalized]; + if (baseModel !== normalized) { + candidates.push(baseModel); + } + + // Add date-stripped variants (e.g., "claude-opus-4-6-20260101" -> "claude-opus-4-6") + const stripped = stripDateSuffix(normalized); + if (stripped !== normalized && !candidates.includes(stripped)) { + candidates.push(stripped); + } + const baseStripped = stripDateSuffix(baseModel); + if (baseStripped !== baseModel && !candidates.includes(baseStripped)) { + candidates.push(baseStripped); + } + + return candidates; } function getDirectOrAliasPricing(model: string): ModelPricing | undefined { diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 940f9e7d..af838c5b 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -50,29 +50,14 @@ import { validateAntigravityRiskAcknowledgement, isAntigravityResponsibilityBypassEnabled, } from '../../cliproxy/antigravity-responsibility'; +import { createRouteErrorHelpers } from './route-helpers'; const router = Router(); // Valid providers list - derived from canonical CLIPROXY_PROFILES const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; -function logRouteError(context: string, error: unknown): void { - if (error instanceof Error) { - console.error(`[cliproxy-auth-routes] ${context}: ${error.message}`); - return; - } - console.error(`[cliproxy-auth-routes] ${context}: unknown error`); -} - -function respondInternalError( - res: Response, - error: unknown, - fallbackMessage: string, - statusCode = 500 -): void { - logRouteError(fallbackMessage, error); - res.status(statusCode).json({ error: fallbackMessage }); -} +const { respondInternalError } = createRouteErrorHelpers('cliproxy-auth-routes'); function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } { if (raw === undefined || raw === null) { @@ -112,6 +97,13 @@ export function getStartUrlUnsupportedReason( return null; } +export function getStartAuthFailureMessage(provider: CLIProxyProvider): string { + if (provider === 'ghcp') { + return 'Authentication failed, was cancelled, or GitHub Copilot verification did not complete. Ensure the account has an active Copilot subscription and retry.'; + } + return 'Authentication failed or was cancelled'; +} + /** * GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles * Also fetches CLIProxyAPI stats to update lastUsedAt for active providers @@ -503,7 +495,9 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise 1000) { + for (const [k, v] of quotaRateLimits) { + if (now - v.windowStart >= QUOTA_RATE_LIMIT_WINDOW_MS * 2) { + quotaRateLimits.delete(k); + } + } + } + const current = quotaRateLimits.get(key); if (!current || now - current.windowStart >= QUOTA_RATE_LIMIT_WINDOW_MS) { @@ -85,62 +100,21 @@ function isQuotaRouteRateLimited(req: Request, provider: string): boolean { } /** - * Cache only stable failures; avoid pinning transient network failures (timeouts, 429s). + * Cache only stable failures; skip transient network errors (timeouts, 429s, 5xx). + * Generic across all quota result types. */ -function shouldCacheCodexQuotaResult(result: CodexQuotaResult): boolean { +function shouldCacheQuotaResult(result: { + success: boolean; + needsReauth?: boolean; + isForbidden?: boolean; + error?: string; +}): boolean { if (result.success) return true; if (result.needsReauth || result.isForbidden) return true; - const msg = (result.error || '').toLowerCase(); if (!msg) return false; - if (msg.includes('timeout')) return false; - if (msg.includes('rate limited')) return false; - if (msg.includes('api error: 5')) return false; - if (msg.includes('fetch failed')) return false; - - return false; -} - -function shouldCacheGeminiQuotaResult(result: GeminiCliQuotaResult): boolean { - if (result.success) return true; - if (result.needsReauth) return true; - - const msg = (result.error || '').toLowerCase(); - if (!msg) return false; - if (msg.includes('timeout')) return false; - if (msg.includes('rate limited')) return false; - if (msg.includes('api error: 5')) return false; - if (msg.includes('fetch failed')) return false; - - return false; -} - -function shouldCacheClaudeQuotaResult(result: ClaudeQuotaResult): boolean { - if (result.success) return true; - if (result.needsReauth) return true; - - const msg = (result.error || '').toLowerCase(); - if (!msg) return false; - if (msg.includes('timeout')) return false; - if (msg.includes('rate limited')) return false; - if (msg.includes('api error: 5')) return false; - if (msg.includes('fetch failed')) return false; - - return false; -} - -function shouldCacheGhcpQuotaResult(result: GhcpQuotaResult): boolean { - if (result.success) return true; - if (result.needsReauth) return true; - - const msg = (result.error || '').toLowerCase(); - if (!msg) return false; - if (msg.includes('timeout')) return false; - if (msg.includes('rate limited')) return false; - if (msg.includes('api error: 5')) return false; - if (msg.includes('fetch failed')) return false; - - return false; + const transientPatterns = ['timeout', 'rate limited', 'api error: 5', 'fetch failed']; + return !transientPatterns.some((p) => msg.includes(p)); } /** Get configured backend from config */ @@ -154,20 +128,21 @@ function getConfiguredBackend() { } /** - * Extract status code and model from error log file (lightweight parsing) - * Reads first 4KB for model, last 2KB for status code + * Extract status code and model from error log file (lightweight parsing). + * Reads first 4KB for model, last 2KB for status code. Async to avoid blocking event loop. */ async function extractErrorLogMetadata( filePath: string ): Promise<{ statusCode?: number; model?: string }> { + let fh: fs.promises.FileHandle | null = null; try { - const fd = fs.openSync(filePath, 'r'); - const stat = fs.fstatSync(fd); + fh = await fs.promises.open(filePath, 'r'); + const stat = await fh.stat(); const fileSize = stat.size; // Read first 4KB for model (in request body) const startBuffer = Buffer.alloc(Math.min(4096, fileSize)); - fs.readSync(fd, startBuffer, 0, startBuffer.length, 0); + await fh.read(startBuffer, 0, startBuffer.length, 0); const startContent = startBuffer.toString('utf-8'); // Extract model from request body JSON: "model":"gemini-3-flash-preview" @@ -178,7 +153,7 @@ async function extractErrorLogMetadata( let statusCode: number | undefined; if (fileSize > 2048) { const endBuffer = Buffer.alloc(2048); - fs.readSync(fd, endBuffer, 0, 2048, fileSize - 2048); + await fh.read(endBuffer, 0, 2048, fileSize - 2048); const endContent = endBuffer.toString('utf-8'); const statusMatch = endContent.match(/Status:\s*(\d{3})/); statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined; @@ -188,10 +163,11 @@ async function extractErrorLogMetadata( statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined; } - fs.closeSync(fd); return { statusCode, model }; } catch { return {}; + } finally { + await fh?.close(); } } @@ -222,7 +198,8 @@ const handleStatsRequest = async (_req: Request, res: Response): Promise = res.json(stats); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }; @@ -246,7 +223,8 @@ router.get('/status', async (_req: Request, res: Response): Promise => { const running = await isCliproxyRunning(); res.json({ running }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -282,7 +260,8 @@ router.get('/proxy-status', async (_req: Request, res: Response): Promise res.json(sessionStatus); } } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -296,7 +275,8 @@ router.post('/proxy-start', async (_req: Request, res: Response): Promise const result = await ensureCliproxyService(); res.json(result); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -309,7 +289,8 @@ router.post('/proxy-stop', async (_req: Request, res: Response): Promise = const result = await stopProxy(); res.json(result); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -323,7 +304,8 @@ router.get('/update-check', async (_req: Request, res: Response): Promise const result = await checkCliproxyUpdate(backend); res.json(result); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -355,7 +337,8 @@ router.get('/models', async (_req: Request, res: Response): Promise => { res.json(modelsResponse); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -402,7 +385,8 @@ router.get('/error-logs', async (_req: Request, res: Response): Promise => res.json({ files: filesWithMetadata }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -441,7 +425,8 @@ router.get('/error-logs/:name', async (req: Request, res: Response): Promise = const content = fs.readFileSync(configPath, 'utf8'); res.type('text/yaml').send(content); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -495,7 +481,8 @@ router.put('/config.yaml', async (req: Request, res: Response): Promise => res.json({ success: true, path: configPath }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -529,7 +516,8 @@ router.get('/auth-files', async (_req: Request, res: Response): Promise => res.json({ files, directory: authDir }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -565,7 +553,8 @@ router.get('/auth-files/download', async (req: Request, res: Response): Promise< res.setHeader('Content-Disposition', `attachment; filename="${name}"`); res.type('application/octet-stream').send(content); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -579,6 +568,13 @@ router.get('/auth-files/download', async (req: Request, res: Response): Promise< router.put('/models/:provider', async (req: Request, res: Response): Promise => { try { const { provider } = req.params; + + // Validate provider name to prevent path traversal via crafted provider param + if (!provider || provider.length > 64 || !/^[a-zA-Z0-9_-]+$/.test(provider)) { + res.status(400).json({ error: 'Invalid provider name' }); + return; + } + const { model } = req.body; if (!model || typeof model !== 'string') { @@ -586,6 +582,17 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise 256) { + res.status(400).json({ error: 'Model ID exceeds maximum length (256 characters)' }); + return; + } + + const deniedReason = getDeniedModelIdReasonForProvider(model, provider); + if (deniedReason) { + res.status(400).json({ error: deniedReason }); + return; + } + // Get the settings file for this provider const ccsDir = getCliproxyWritablePath(); const settingsPath = path.join(ccsDir, `${provider}.settings.json`); @@ -596,18 +603,46 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise; + [key: string]: unknown; + }; + const canonicalModel = canonicalizeModelIdForProvider(model, provider); + const env = + settings.env && typeof settings.env === 'object' && !Array.isArray(settings.env) + ? settings.env + : {}; + + const previousDefault = + typeof env.ANTHROPIC_MODEL === 'string' ? env.ANTHROPIC_MODEL : canonicalModel; + const previousCanonicalDefault = canonicalizeModelIdForProvider(previousDefault, provider); + + for (const key of MODEL_ENV_VAR_KEYS) { + if (key === 'ANTHROPIC_MODEL') { + env[key] = canonicalModel; + continue; + } + + const current = env[key]; + if (typeof current !== 'string') { + env[key] = canonicalModel; + continue; + } + + const canonicalCurrent = canonicalizeModelIdForProvider(current, provider); + env[key] = canonicalCurrent === previousCanonicalDefault ? canonicalModel : canonicalCurrent; + } + settings.env = env; // Write atomically const tempPath = settingsPath + '.tmp'; fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); fs.renameSync(tempPath, settingsPath); - res.json({ success: true, provider, model }); + res.json({ success: true, provider, model: canonicalModel }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -652,13 +687,14 @@ router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promi const result = await fetchCodexQuota(accountId); // Cache successful and stable failure states; skip transient network failures. - if (shouldCacheCodexQuotaResult(result)) { + if (shouldCacheQuotaResult(result)) { setCachedQuota('codex', accountId, result); } res.json(result); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -699,13 +735,14 @@ router.get('/quota/claude/:accountId', async (req: Request, res: Response): Prom const result = await fetchClaudeQuota(accountId); // Cache successful and stable failure states; skip transient network failures. - if (shouldCacheClaudeQuotaResult(result)) { + if (shouldCacheQuotaResult(result)) { setCachedQuota('claude', accountId, result); } res.json(result); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -746,13 +783,14 @@ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Prom const result = await fetchGeminiCliQuota(accountId); // Cache successful and stable failure states; skip transient network failures. - if (shouldCacheGeminiQuotaResult(result)) { + if (shouldCacheQuotaResult(result)) { setCachedQuota('gemini', accountId, result); } res.json(result); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -793,13 +831,14 @@ router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promis const result = await fetchGhcpQuota(accountId); // Cache successful and stable failure states; skip transient network failures. - if (shouldCacheGhcpQuotaResult(result)) { + if (shouldCacheQuotaResult(result)) { setCachedQuota('ghcp', accountId, result); } res.json(result); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -857,7 +896,8 @@ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): P res.json(result); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -880,7 +920,8 @@ router.get('/versions', async (_req: Request, res: Response): Promise => { faultyRange: CLIPROXY_FAULTY_RANGE, }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -944,7 +985,8 @@ router.post('/install', async (req: Request, res: Response): Promise => { message: `Successfully installed CLIProxy Plus v${version}`, }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -969,7 +1011,8 @@ router.post('/restart', async (_req: Request, res: Response): Promise => { res.json({ success: false, error: startResult.error || 'Failed to start proxy' }); } } catch (error) { - res.status(500).json({ error: (error as Error).message }); + console.error(`[cliproxy-stats] ${(error as Error).message}`); + res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/src/web-server/routes/misc-routes.ts b/src/web-server/routes/misc-routes.ts index fb3037c9..a3c7fbdc 100644 --- a/src/web-server/routes/misc-routes.ts +++ b/src/web-server/routes/misc-routes.ts @@ -10,6 +10,7 @@ import { expandPath } from '../../utils/helpers'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig, + mutateUnifiedConfig, getGlobalEnvConfig, getThinkingConfig, getConfigYamlPath, @@ -218,28 +219,27 @@ router.get('/global-env', (_req: Request, res: Response): void => { * Updates the global_env section in config.yaml */ router.put('/global-env', (req: Request, res: Response): void => { - try { - const { enabled, env } = req.body; - const config = loadOrCreateUnifiedConfig(); + const { enabled, env } = req.body; - // Validate env is an object with string values - if (env !== undefined && typeof env === 'object' && env !== null) { - for (const [key, value] of Object.entries(env)) { - if (typeof value !== 'string') { - res.status(400).json({ error: `Invalid value for ${key}: must be a string` }); - return; - } + // Validate env values before acquiring write lock + if (env !== undefined && typeof env === 'object' && env !== null) { + for (const [key, value] of Object.entries(env)) { + if (typeof value !== 'string') { + res.status(400).json({ error: `Invalid value for ${key}: must be a string` }); + return; } } + } - // Update global_env section - config.global_env = { - enabled: enabled ?? config.global_env?.enabled ?? true, - env: env ?? config.global_env?.env ?? {}, - }; - - saveUnifiedConfig(config); - res.json({ success: true, config: config.global_env }); + try { + // Atomic read-modify-write — avoids race between load and save + const updated = mutateUnifiedConfig((config) => { + config.global_env = { + enabled: enabled ?? config.global_env?.enabled ?? true, + env: env ?? config.global_env?.env ?? {}, + }; + }); + res.json({ success: true, config: updated.global_env }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index eb176802..f8c87cab 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -13,27 +13,13 @@ import { updateApiProfileTarget, } from '../../api/services/profile-writer'; import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader'; -import type { TargetType } from '../../targets/target-adapter'; import { normalizeDroidProvider } from '../../targets/droid-provider'; -import { updateSettingsFile } from './route-helpers'; +import { updateSettingsFile, parseTarget } from './route-helpers'; const router = Router(); -export function parseTarget(rawTarget: unknown): TargetType | null { - if (rawTarget === undefined || rawTarget === null || rawTarget === '') { - return null; - } - - if (typeof rawTarget !== 'string') { - return null; - } - - const normalized = rawTarget.trim().toLowerCase(); - if (normalized === 'claude' || normalized === 'droid') { - return normalized; - } - - return null; +function isDenylistError(message: string | undefined): boolean { + return typeof message === 'string' && message.toLowerCase().includes('denylist'); } // ==================== Profile CRUD ==================== @@ -113,7 +99,8 @@ router.post('/', (req: Request, res: Response): void => { ); if (!result.success) { - res.status(500).json({ error: result.error || 'Failed to create profile' }); + const errorMessage = result.error || 'Failed to create profile'; + res.status(isDenylistError(errorMessage) ? 400 : 500).json({ error: errorMessage }); return; } @@ -203,7 +190,8 @@ router.put('/:name', (req: Request, res: Response): void => { ...(hasTargetUpdate && parsedTarget && { target: parsedTarget }), }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + const errorMessage = (error as Error).message; + res.status(isDenylistError(errorMessage) ? 400 : 500).json({ error: errorMessage }); } }); diff --git a/src/web-server/routes/proxy-routes.ts b/src/web-server/routes/proxy-routes.ts index a8c92f84..2d9ba46d 100644 --- a/src/web-server/routes/proxy-routes.ts +++ b/src/web-server/routes/proxy-routes.ts @@ -8,7 +8,7 @@ */ import { Router, Request, Response } from 'express'; -import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader'; +import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; import { testConnection } from '../../cliproxy/remote-proxy-client'; import { isProxyRunning } from '../../cliproxy/services/proxy-lifecycle-service'; import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector'; @@ -36,32 +36,32 @@ router.get('/', async (_req: Request, res: Response) => { /** * PUT /api/cliproxy-server - Update proxy configuration */ -router.put('/', async (req: Request, res: Response) => { +router.put('/', (req: Request, res: Response) => { try { - const config = await loadOrCreateUnifiedConfig(); const updates = req.body as Partial; - // Deep merge with defaults and current config - config.cliproxy_server = { - remote: { - ...DEFAULT_CLIPROXY_SERVER_CONFIG.remote, - ...config.cliproxy_server?.remote, - ...updates.remote, - }, - fallback: { - ...DEFAULT_CLIPROXY_SERVER_CONFIG.fallback, - ...config.cliproxy_server?.fallback, - ...updates.fallback, - }, - local: { - ...DEFAULT_CLIPROXY_SERVER_CONFIG.local, - ...config.cliproxy_server?.local, - ...updates.local, - }, - }; + // Atomic read-modify-write — avoids race between load and save + const updated = mutateUnifiedConfig((config) => { + config.cliproxy_server = { + remote: { + ...DEFAULT_CLIPROXY_SERVER_CONFIG.remote, + ...config.cliproxy_server?.remote, + ...updates.remote, + }, + fallback: { + ...DEFAULT_CLIPROXY_SERVER_CONFIG.fallback, + ...config.cliproxy_server?.fallback, + ...updates.fallback, + }, + local: { + ...DEFAULT_CLIPROXY_SERVER_CONFIG.local, + ...config.cliproxy_server?.local, + ...updates.local, + }, + }; + }); - await saveUnifiedConfig(config); - res.json(config.cliproxy_server); + res.json(updated.cliproxy_server); } catch (error) { console.error('[cliproxy-server-routes] Failed to save proxy config:', error); res.status(500).json({ error: 'Failed to save proxy config' }); @@ -91,7 +91,7 @@ router.get('/backend', async (_req: Request, res: Response) => { * @throws {400} Invalid backend value * @throws {409} Proxy is running (unless force=true) */ -router.put('/backend', async (req: Request, res: Response) => { +router.put('/backend', (req: Request, res: Response) => { try { const { backend, force } = req.body; if (backend !== 'original' && backend !== 'plus') { @@ -99,9 +99,9 @@ router.put('/backend', async (req: Request, res: Response) => { return; } - // Check if proxy is running - warn about restart requirement - const config = await loadOrCreateUnifiedConfig(); - const currentBackend = config.cliproxy?.backend ?? DEFAULT_BACKEND; + // Pre-flight read: check running state before acquiring write lock + const currentConfig = loadOrCreateUnifiedConfig(); + const currentBackend = currentConfig.cliproxy?.backend ?? DEFAULT_BACKEND; if (currentBackend !== backend && isProxyRunning() && !force) { res.status(409).json({ error: 'Proxy is running. Stop proxy first or use force=true to change backend.', @@ -110,18 +110,21 @@ router.put('/backend', async (req: Request, res: Response) => { }); return; } - if (!config.cliproxy) { - config.cliproxy = { - backend, - oauth_accounts: {}, - providers: [...CLIPROXY_PROVIDER_IDS], - variants: {}, - }; - } else { - config.cliproxy.backend = backend; - } - await saveUnifiedConfig(config); + // Atomic write — avoids race between load and save + mutateUnifiedConfig((config) => { + if (!config.cliproxy) { + config.cliproxy = { + backend, + oauth_accounts: {}, + providers: [...CLIPROXY_PROVIDER_IDS], + variants: {}, + }; + } else { + config.cliproxy.backend = backend; + } + }); + res.json({ backend }); } catch (error) { console.error('[cliproxy-server-routes] Failed to save backend config:', error); diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 24b54245..2b38e486 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -4,11 +4,21 @@ import * as fs from 'fs'; import * as path from 'path'; +import type { Response } from 'express'; import { getCcsDir, getConfigPath, loadConfigSafe, loadSettings } from '../../utils/config-manager'; import { expandPath } from '../../utils/helpers'; import { getClaudeSettingsPath } from '../../utils/claude-config-path'; import { resolveDroidProvider } from '../../targets/droid-provider'; +import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; +import { + canonicalizeModelIdForProvider, + extractProviderFromPathname, + getDeniedModelIdReasonForProvider, +} from '../../cliproxy/model-id-normalizer'; +import type { CLIProxyProvider } from '../../cliproxy/types'; import type { Config, Settings } from '../../types/config'; +import type { TargetType } from '../../targets/target-adapter'; +import { ValidationError } from '../../errors/error-types'; /** Model mapping for API profiles */ export interface ModelMapping { @@ -18,6 +28,60 @@ export interface ModelMapping { haikuModel?: string; } +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 resolveProviderForModelCanonicalization( + baseUrl: unknown, + providerHint: unknown +): CLIProxyProvider | null { + const fromBaseUrl = resolveProviderFromBaseUrl(baseUrl); + if (fromBaseUrl) { + return fromBaseUrl; + } + if (typeof providerHint === 'string' && providerHint.trim().length > 0) { + const fromProviderHint = mapExternalProviderName(providerHint); + if (fromProviderHint) { + return fromProviderHint; + } + } + return null; +} + +function getDeniedModelReasonForProvider( + provider: CLIProxyProvider | null, + values: Array +): string | null { + if (!provider) return null; + for (const value of values) { + if (typeof value !== 'string' || value.trim().length === 0) continue; + const deniedReason = getDeniedModelIdReasonForProvider(value, provider); + if (deniedReason) return deniedReason; + } + return null; +} + +function canonicalizeModelForProvider( + provider: CLIProxyProvider | null, + value: string | undefined +): string | undefined { + if (typeof value !== 'string') return value; + if (!provider || value.trim().length === 0) return value; + return canonicalizeModelIdForProvider(value, provider); +} + /** * Read config safely with fallback. * Uses loadConfigSafe which supports both unified (config.yaml) and legacy (config.json). @@ -70,24 +134,51 @@ export function createSettingsFile( ): string { const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); const { model, opusModel, sonnetModel, haikuModel } = models; + const providerForModelCanonicalization = resolveProviderForModelCanonicalization( + baseUrl, + provider + ); + const canonicalModel = canonicalizeModelForProvider(providerForModelCanonicalization, model); + const canonicalOpusModel = canonicalizeModelForProvider( + providerForModelCanonicalization, + opusModel + ); + const canonicalSonnetModel = canonicalizeModelForProvider( + providerForModelCanonicalization, + sonnetModel + ); + const canonicalHaikuModel = canonicalizeModelForProvider( + providerForModelCanonicalization, + haikuModel + ); + const deniedReason = getDeniedModelReasonForProvider(providerForModelCanonicalization, [ + canonicalModel, + canonicalOpusModel, + canonicalSonnetModel, + canonicalHaikuModel, + ]); + if (deniedReason) { + throw new ValidationError(deniedReason, 'model'); + } const droidProvider = resolveDroidProvider({ provider, baseUrl, - model, + model: canonicalModel, }); const settings: Settings = { env: { ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_AUTH_TOKEN: apiKey, - ...(model && { ANTHROPIC_MODEL: model }), - ...(opusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel }), - ...(sonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel }), - ...(haikuModel && { ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel }), + ...(canonicalModel && { ANTHROPIC_MODEL: canonicalModel }), + ...(canonicalOpusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: canonicalOpusModel }), + ...(canonicalSonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: canonicalSonnetModel }), + ...(canonicalHaikuModel && { ANTHROPIC_DEFAULT_HAIKU_MODEL: canonicalHaikuModel }), CCS_DROID_PROVIDER: droidProvider, }, }; + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); return `~/.ccs/${name}.settings.json`; } @@ -114,6 +205,43 @@ export function updateSettingsFile( } const settings = loadSettings(settingsPath); + const providerForValidation = + resolveProviderForModelCanonicalization(updates.baseUrl, updates.provider) ?? + resolveProviderForModelCanonicalization( + settings.env?.ANTHROPIC_BASE_URL, + updates.provider ?? settings.env?.CCS_DROID_PROVIDER + ); + const canonicalModel = + updates.model !== undefined + ? canonicalizeModelForProvider(providerForValidation, updates.model) + : undefined; + const canonicalOpusModel = + updates.opusModel !== undefined + ? canonicalizeModelForProvider(providerForValidation, updates.opusModel) + : undefined; + const canonicalSonnetModel = + updates.sonnetModel !== undefined + ? canonicalizeModelForProvider(providerForValidation, updates.sonnetModel) + : undefined; + const canonicalHaikuModel = + updates.haikuModel !== undefined + ? canonicalizeModelForProvider(providerForValidation, updates.haikuModel) + : undefined; + const deniedReason = getDeniedModelReasonForProvider(providerForValidation, [ + canonicalModel !== undefined ? canonicalModel : settings.env?.ANTHROPIC_MODEL, + canonicalOpusModel !== undefined + ? canonicalOpusModel + : settings.env?.ANTHROPIC_DEFAULT_OPUS_MODEL, + canonicalSonnetModel !== undefined + ? canonicalSonnetModel + : settings.env?.ANTHROPIC_DEFAULT_SONNET_MODEL, + canonicalHaikuModel !== undefined + ? canonicalHaikuModel + : settings.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL, + ]); + if (deniedReason) { + throw new ValidationError(deniedReason, 'model'); + } if (updates.baseUrl) { settings.env = settings.env || {}; @@ -127,8 +255,8 @@ export function updateSettingsFile( if (updates.model !== undefined) { settings.env = settings.env || {}; - if (updates.model) { - settings.env.ANTHROPIC_MODEL = updates.model; + if (canonicalModel) { + settings.env.ANTHROPIC_MODEL = canonicalModel; } else { delete settings.env.ANTHROPIC_MODEL; } @@ -137,8 +265,8 @@ export function updateSettingsFile( // Handle model mapping fields if (updates.opusModel !== undefined) { settings.env = settings.env || {}; - if (updates.opusModel) { - settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = updates.opusModel; + if (canonicalOpusModel) { + settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = canonicalOpusModel; } else { delete settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL; } @@ -146,8 +274,8 @@ export function updateSettingsFile( if (updates.sonnetModel !== undefined) { settings.env = settings.env || {}; - if (updates.sonnetModel) { - settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = updates.sonnetModel; + if (canonicalSonnetModel) { + settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = canonicalSonnetModel; } else { delete settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL; } @@ -155,8 +283,8 @@ export function updateSettingsFile( if (updates.haikuModel !== undefined) { settings.env = settings.env || {}; - if (updates.haikuModel) { - settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = updates.haikuModel; + if (canonicalHaikuModel) { + settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = canonicalHaikuModel; } else { delete settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL; } @@ -172,7 +300,7 @@ export function updateSettingsFile( const resolvedProvider = resolveDroidProvider({ provider: updates.provider ?? settings.env.CCS_DROID_PROVIDER, baseUrl: updates.baseUrl ?? settings.env.ANTHROPIC_BASE_URL, - model: updates.model ?? settings.env.ANTHROPIC_MODEL, + model: canonicalModel ?? settings.env.ANTHROPIC_MODEL, }); settings.env.CCS_DROID_PROVIDER = resolvedProvider; } @@ -263,3 +391,58 @@ export function validateFilePath(filePath: string): { return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; } + +/** + * Parse and validate a target param (claude/droid). Returns null if invalid/absent. + * Shared by profile-routes and variant-routes. + */ +export function parseTarget(rawTarget: unknown): TargetType | null { + if (rawTarget === undefined || rawTarget === null || rawTarget === '') { + return null; + } + + if (typeof rawTarget !== 'string') { + return null; + } + + const normalized = rawTarget.trim().toLowerCase(); + if (normalized === 'claude' || normalized === 'droid') { + return normalized; + } + + return null; +} + +/** + * Create route-specific error helpers with a log prefix. + * Eliminates duplicate logRouteError/respondInternalError in each route file. + */ +export function createRouteErrorHelpers(prefix: string): { + logRouteError: (context: string, error: unknown) => void; + respondInternalError: ( + res: Response, + error: unknown, + fallbackMessage: string, + statusCode?: number + ) => void; +} { + function logRouteError(context: string, error: unknown): void { + if (error instanceof Error) { + console.error(`[${prefix}] ${context}: ${error.message}`); + return; + } + console.error(`[${prefix}] ${context}: unknown error`); + } + + function respondInternalError( + res: Response, + error: unknown, + fallbackMessage: string, + statusCode = 500 + ): void { + logRouteError(fallbackMessage, error); + res.status(statusCode).json({ error: fallbackMessage }); + } + + return { logRouteError, respondInternalError }; +} diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 54a3e265..69dc878e 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -25,9 +25,16 @@ import { mutateUnifiedConfig, } from '../../config/unified-config-loader'; import type { Settings } from '../../types/config'; +import type { CLIProxyProvider } from '../../cliproxy/types'; +import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; +import { + canonicalizeModelIdForProvider, + extractProviderFromPathname, + getDeniedModelIdReasonForProvider, +} from '../../cliproxy/model-id-normalizer'; +import { createRouteErrorHelpers } from './route-helpers'; const router = Router(); -const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; const MODEL_ENV_KEYS = [ 'ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', @@ -36,23 +43,7 @@ const MODEL_ENV_KEYS = [ ] as const; const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; -function logRouteError(context: string, error: unknown): void { - if (error instanceof Error) { - console.error(`[settings-routes] ${context}: ${error.message}`); - return; - } - console.error(`[settings-routes] ${context}: unknown error`); -} - -function respondInternalError( - res: Response, - error: unknown, - fallbackMessage: string, - statusCode = 500 -): void { - logRouteError(fallbackMessage, error); - res.status(statusCode).json({ error: fallbackMessage }); -} +const { logRouteError, respondInternalError } = createRouteErrorHelpers('settings-routes'); function isLoopbackAddress(value: string | undefined): boolean { if (!value) return false; @@ -72,10 +63,10 @@ function requireSensitiveLocalAccess(req: Request, res: Response): boolean { return true; } - const forwarded = req.headers['x-forwarded-for']; - const firstForwarded = - typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : undefined; - const candidateAddress = firstForwarded || req.socket.remoteAddress || req.ip; + // Use only socket-level address for security decisions. + // X-Forwarded-For is trivially spoofable and must NOT be trusted + // without an explicit trust proxy configuration. + const candidateAddress = req.socket.remoteAddress; if (isLoopbackAddress(candidateAddress)) { return true; @@ -122,18 +113,105 @@ function resolveSettingsPath(profileOrVariant: string): string { return path.join(ccsDir, `${profileOrVariant}.settings.json`); } -function isCodexProfile(profileOrVariant: string): boolean { - if (profileOrVariant.toLowerCase() === 'codex') return true; +function resolveProviderForProfile(profileOrVariant: string): CLIProxyProvider | null { + const directProvider = mapExternalProviderName(profileOrVariant); + if (directProvider) { + return directProvider; + } + const variants = listVariants(); - return variants[profileOrVariant]?.provider === 'codex'; + const variantProvider = variants[profileOrVariant]?.provider; + if (typeof variantProvider === 'string') { + return mapExternalProviderName(variantProvider); + } + + return null; } -function stripCodexEffortSuffix(modelId: string): string { - return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); +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 canonicalizeCodexSettings(profileOrVariant: string, settings: Settings): Settings { - if (!isCodexProfile(profileOrVariant)) return settings; +function resolveProviderForSettings( + profileOrVariant: string, + settings?: Pick +): CLIProxyProvider | null { + const providerFromProfile = resolveProviderForProfile(profileOrVariant); + if (providerFromProfile) { + return providerFromProfile; + } + + const baseUrl = + settings?.env && typeof settings.env === 'object' ? settings.env.ANTHROPIC_BASE_URL : undefined; + return resolveProviderFromBaseUrl(baseUrl); +} + +function canonicalizeProfileModelId( + profileOrVariant: string, + modelId: string, + settings?: Settings +): string { + const provider = resolveProviderForSettings(profileOrVariant, settings); + if (!provider) return modelId; + return canonicalizeModelIdForProvider(modelId, provider); +} + +function findDeniedProfileModel( + profileOrVariant: string, + modelId: string, + settings?: Settings +): string | null { + const provider = resolveProviderForSettings(profileOrVariant, settings); + if (!provider) return null; + return getDeniedModelIdReasonForProvider(modelId, provider); +} + +function validateProfileSettingsModelDenylist( + profileOrVariant: string, + settings: Settings +): string | null { + if (settings.env && typeof settings.env === 'object') { + for (const key of MODEL_ENV_KEYS) { + const value = settings.env[key]; + if (typeof value !== 'string') continue; + const deniedReason = findDeniedProfileModel(profileOrVariant, value, settings); + if (deniedReason) { + return `${key}: ${deniedReason}`; + } + } + } + + if (!Array.isArray(settings.presets)) return null; + + for (const preset of settings.presets) { + for (const key of PRESET_MODEL_KEYS) { + const value = preset[key]; + if (typeof value !== 'string') continue; + const deniedReason = findDeniedProfileModel(profileOrVariant, value, settings); + if (deniedReason) { + const presetName = typeof preset.name === 'string' ? preset.name : 'unnamed'; + return `Preset '${presetName}' (${key}): ${deniedReason}`; + } + } + } + + return null; +} + +function canonicalizeProfileSettings(profileOrVariant: string, settings: Settings): Settings { + const provider = resolveProviderForSettings(profileOrVariant, settings); + if (!provider) return settings; let changed = false; const next: Settings = { ...settings }; @@ -143,7 +221,7 @@ function canonicalizeCodexSettings(profileOrVariant: string, settings: Settings) for (const key of MODEL_ENV_KEYS) { const value = env[key]; if (typeof value !== 'string') continue; - const canonical = stripCodexEffortSuffix(value); + const canonical = canonicalizeModelIdForProvider(value, provider); if (canonical !== value) { env[key] = canonical; changed = true; @@ -153,13 +231,19 @@ function canonicalizeCodexSettings(profileOrVariant: string, settings: Settings) } if (Array.isArray(settings.presets)) { - const normalizedPresets = settings.presets.map((preset) => { + const normalizedPresets = settings.presets.flatMap((preset) => { const normalizedPreset = { ...preset }; let presetChanged = false; for (const key of PRESET_MODEL_KEYS) { const value = normalizedPreset[key]; - const canonical = stripCodexEffortSuffix(value); + if (typeof value !== 'string') continue; + const deniedReason = getDeniedModelIdReasonForProvider(value, provider); + if (deniedReason) { + changed = true; + return []; + } + const canonical = canonicalizeModelIdForProvider(value, provider); if (canonical !== value) { normalizedPreset[key] = canonical; presetChanged = true; @@ -175,6 +259,35 @@ function canonicalizeCodexSettings(profileOrVariant: string, settings: Settings) return changed ? next : settings; } +function writeSettingsAtomically(settingsPath: string, settings: Settings): void { + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); +} + +function loadCanonicalProfileSettings( + profileOrVariant: string, + settingsPath: string, + persist = false, + strictPersist = false +): Settings { + const loaded = loadSettings(settingsPath); + const canonicalized = canonicalizeProfileSettings(profileOrVariant, loaded); + + if (persist && canonicalized !== loaded) { + try { + writeSettingsAtomically(settingsPath, canonicalized); + } catch (error) { + if (strictPersist) { + throw error; + } + logRouteError(`Failed to persist canonicalized settings for ${profileOrVariant}`, error); + } + } + + return canonicalized; +} + /** * Helper: Mask API keys in settings */ @@ -205,8 +318,8 @@ router.get('/:profile', (req: Request, res: Response): void => { return; } + const settings = loadCanonicalProfileSettings(profile, settingsPath, true); const stat = fs.statSync(settingsPath); - const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath)); const masked = maskApiKeys(settings); res.json({ @@ -233,8 +346,8 @@ router.get('/:profile/raw', (req: Request, res: Response): void => { return; } + const settings = loadCanonicalProfileSettings(profile, settingsPath, true); const stat = fs.statSync(settingsPath); - const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath)); res.json({ profile, @@ -270,7 +383,13 @@ router.put('/:profile', (req: Request, res: Response): void => { return; } - const normalizedSettings = canonicalizeCodexSettings(profile, settings as Settings); + const deniedModelReason = validateProfileSettingsModelDenylist(profile, settings as Settings); + if (deniedModelReason) { + res.status(400).json({ error: deniedModelReason }); + return; + } + + const normalizedSettings = canonicalizeProfileSettings(profile, settings as Settings); // Deduplicate CCS hooks to prevent accumulation (fixes #450) // This handles cases where duplicate hooks were added by previous versions @@ -355,7 +474,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => { return; } - const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath)); + const settings = loadCanonicalProfileSettings(profile, settingsPath, true); res.json({ presets: settings.presets || [] }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); @@ -383,7 +502,7 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n'); } - const settings = loadSettings(settingsPath); + const settings = loadCanonicalProfileSettings(profile, settingsPath, false); settings.presets = settings.presets || []; // Check for duplicate name @@ -393,7 +512,20 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { } const normalizePresetModel = (modelId: string): string => - isCodexProfile(profile) ? stripCodexEffortSuffix(modelId) : modelId; + canonicalizeProfileModelId(profile, modelId, settings); + + for (const modelId of [ + defaultModel, + opus || defaultModel, + sonnet || defaultModel, + haiku || defaultModel, + ]) { + const deniedReason = findDeniedProfileModel(profile, modelId, settings); + if (deniedReason) { + res.status(400).json({ error: deniedReason }); + return; + } + } const normalizedDefaultModel = normalizePresetModel(defaultModel); const normalizedOpusModel = normalizePresetModel(opus || defaultModel); @@ -409,13 +541,12 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { }; settings.presets.push(preset); + const canonicalizedSettings = canonicalizeProfileSettings(profile, settings); + writeSettingsAtomically(settingsPath, canonicalizedSettings); - // Atomic write: temp file + rename - const tempPath = settingsPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); - fs.renameSync(tempPath, settingsPath); - - res.status(201).json({ preset }); + const persistedPreset = + canonicalizedSettings.presets?.find((entry) => entry.name === name) || preset; + res.status(201).json({ preset: persistedPreset }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); } @@ -434,18 +565,15 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void => return; } - const settings = loadSettings(settingsPath); + const settings = loadCanonicalProfileSettings(profile, settingsPath, false); if (!settings.presets || !settings.presets.some((p) => p.name === name)) { res.status(404).json({ error: 'Preset not found' }); return; } settings.presets = settings.presets.filter((p) => p.name !== name); - - // Atomic write: temp file + rename - const tempPath = settingsPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); - fs.renameSync(tempPath, settingsPath); + const canonicalizedSettings = canonicalizeProfileSettings(profile, settings); + writeSettingsAtomically(settingsPath, canonicalizedSettings); res.json({ success: true }); } catch (error) { diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts index 65a4c07b..7e9013ef 100644 --- a/src/web-server/routes/variant-routes.ts +++ b/src/web-server/routes/variant-routes.ts @@ -7,7 +7,7 @@ import { Router, Request, Response } from 'express'; import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; import type { CLIProxyProvider } from '../../cliproxy/types'; -import type { TargetType } from '../../targets/target-adapter'; +import { parseTarget } from './route-helpers'; import { createVariant, removeVariant, @@ -24,23 +24,6 @@ import { const router = Router(); -export function parseTarget(rawTarget: unknown): TargetType | null { - if (rawTarget === undefined || rawTarget === null || rawTarget === '') { - return null; - } - - if (typeof rawTarget !== 'string') { - return null; - } - - const normalized = rawTarget.trim().toLowerCase(); - if (normalized === 'claude' || normalized === 'droid') { - return normalized; - } - - return null; -} - /** * GET /api/cliproxy - List cliproxy variants * Uses variant-service for consistent behavior with CLI diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts index 5d30676e..8e9fa3c0 100644 --- a/src/web-server/usage/aggregator.ts +++ b/src/web-server/usage/aggregator.ts @@ -7,13 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { - loadDailyUsageData, - loadMonthlyUsageData, - loadSessionData, - loadAllUsageData, - loadHourlyUsageData, -} from './data-aggregator'; +import { loadAllUsageData } from './data-aggregator'; import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './types'; import { readDiskCache, @@ -25,6 +19,12 @@ import { } from './disk-cache'; import { ok, info, fail } from '../../utils/ui'; import { getCcsDir } from '../../utils/config-manager'; +import { + loadCachedCliproxyData, + startCliproxySync, + stopCliproxySync, + syncCliproxyUsage, +} from './cliproxy-usage-syncer'; // ============================================================================ // Multi-Instance Support - Aggregate usage from CCS profiles @@ -263,6 +263,14 @@ let diskCacheInitialized = false; // Track if background refresh is in progress let isRefreshing = false; +// Coalesced full refresh promise shared across all usage loaders +let pendingFullRefresh: Promise<{ + daily: DailyUsage[]; + hourly: HourlyUsage[]; + monthly: MonthlyUsage[]; + session: SessionUsage[]; +}> | null = null; + /** * Persist cache to disk when we have enough data to be useful. */ @@ -288,6 +296,10 @@ async function refreshFromSource(): Promise<{ monthly: MonthlyUsage[]; session: SessionUsage[]; }> { + // Try to sync CLIProxy snapshot before reading it. + // Non-fatal: syncer handles unavailability and stale fallback. + await syncCliproxyUsage(); + // Load default data (from ~/.claude/projects/ or CLAUDE_CONFIG_DIR) const defaultData = await loadAllUsageData(); @@ -327,6 +339,19 @@ async function refreshFromSource(): Promise<{ console.log(info(`Aggregated usage data from ${instanceDataResults.length} CCS instance(s)`)); } + // Load CLIProxy usage data (from local snapshot cache) + try { + const cliproxyData = await loadCachedCliproxyData(); + if (cliproxyData.daily.length > 0) { + allDailySources.push(cliproxyData.daily); + allHourlySources.push(cliproxyData.hourly); + allMonthlySources.push(cliproxyData.monthly); + console.log(info('Included CLIProxy usage data')); + } + } catch (err) { + console.error(fail(`Failed to load CLIProxy usage data: ${err}`)); + } + // Merge all data sources const daily = mergeDailyData(allDailySources); const hourly = mergeHourlyData(allHourlySources); @@ -347,10 +372,44 @@ async function refreshFromSource(): Promise<{ return { daily, hourly, monthly, session }; } +async function refreshFromSourceCoalesced(force = false): Promise<{ + daily: DailyUsage[]; + hourly: HourlyUsage[]; + monthly: MonthlyUsage[]; + session: SessionUsage[]; +}> { + // Wait for any in-flight refresh to finish before starting a forced one + // to prevent concurrent refreshes competing for the same resources + if (force && pendingFullRefresh) { + await pendingFullRefresh.catch(() => {}); + } + + if (force) { + pendingFullRefresh = refreshFromSource().finally(() => { + pendingFullRefresh = null; + }); + return pendingFullRefresh; + } + + if (pendingFullRefresh) { + return pendingFullRefresh; + } + + pendingFullRefresh = refreshFromSource().finally(() => { + pendingFullRefresh = null; + }); + + return pendingFullRefresh; +} + /** * Initialize in-memory cache from disk cache (lazy - called on first API request). */ function ensureDiskCacheLoaded(): void { + // Start sync when usage APIs are actually accessed. + // startCliproxySync() is idempotent. + startCliproxySync(); + if (diskCacheInitialized) return; diskCacheInitialized = true; @@ -427,28 +486,28 @@ async function getCachedData(key: string, ttl: number, loader: () => Promise< /** Cached loader for daily usage data */ export async function getCachedDailyData(): Promise { return getCachedData('daily', CACHE_TTL.daily, async () => { - return await loadDailyUsageData(); + return (await refreshFromSourceCoalesced()).daily; }); } /** Cached loader for monthly usage data */ export async function getCachedMonthlyData(): Promise { return getCachedData('monthly', CACHE_TTL.monthly, async () => { - return await loadMonthlyUsageData(); + return (await refreshFromSourceCoalesced()).monthly; }); } /** Cached loader for session data */ export async function getCachedSessionData(): Promise { return getCachedData('session', CACHE_TTL.session, async () => { - return await loadSessionData(); + return (await refreshFromSourceCoalesced()).session; }); } /** Cached loader for hourly usage data */ export async function getCachedHourlyData(): Promise { return getCachedData('hourly', CACHE_TTL.daily, async () => { - return await loadHourlyUsageData(); + return (await refreshFromSourceCoalesced()).hourly; }); } @@ -457,9 +516,12 @@ export async function getCachedHourlyData(): Promise { */ export function clearUsageCache(): void { cache.clear(); + pendingRequests.clear(); + pendingFullRefresh = null; clearDiskCache(); // Reset so next API call will try to reload from disk/source diskCacheInitialized = false; + lastFetchTimestamp = null; } /** @@ -479,6 +541,9 @@ export async function prewarmUsageCache(): Promise<{ console.log(info('Pre-warming usage cache...')); try { + // Start CLIProxy usage syncer early (runs in background every 5 min) + startCliproxySync(); + const diskCache = readDiskCache(); // Fresh disk cache - use it directly @@ -516,7 +581,7 @@ export async function prewarmUsageCache(): Promise<{ // Background refresh if (!isRefreshing) { isRefreshing = true; - refreshFromSource() + refreshFromSourceCoalesced() .then(() => console.log(ok('Background refresh complete'))) .catch((err) => console.error(fail(`Background refresh failed: ${err}`))) .finally(() => { @@ -529,7 +594,7 @@ export async function prewarmUsageCache(): Promise<{ // No usable disk cache - refresh from source (blocking for first startup only) console.log(info('No disk cache, loading from source...')); - await refreshFromSource(); + await refreshFromSourceCoalesced(); const elapsed = Date.now() - start; console.log(ok(`Usage cache ready (${elapsed}ms)`)); @@ -539,3 +604,20 @@ export async function prewarmUsageCache(): Promise<{ throw err; } } + +/** + * Shutdown usage aggregator cleanly (stops background syncer) + */ +export function shutdownUsageAggregator(): void { + stopCliproxySync(); +} + +/** + * Force refresh usage cache from all sources. + * Used by manual refresh endpoint. + */ +export async function refreshUsageCache(): Promise { + // Ensure periodic sync is running for subsequent updates. + startCliproxySync(); + await refreshFromSourceCoalesced(true); +} diff --git a/src/web-server/usage/cliproxy-usage-syncer.ts b/src/web-server/usage/cliproxy-usage-syncer.ts new file mode 100644 index 00000000..64e564ad --- /dev/null +++ b/src/web-server/usage/cliproxy-usage-syncer.ts @@ -0,0 +1,177 @@ +/** + * CLIProxy Usage Syncer + * + * Periodically fetches CLIProxy usage data, transforms it, and persists + * snapshots locally so analytics data survives CLIProxy restarts. + * + * Snapshot location: ~/.ccs/cache/cliproxy-usage/latest.json + * Sync interval: 5 minutes + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { fetchCliproxyUsageRaw } from '../../cliproxy/stats-fetcher'; +import { + transformCliproxyToDailyUsage, + transformCliproxyToHourlyUsage, + transformCliproxyToMonthlyUsage, +} from './cliproxy-usage-transformer'; +import type { DailyUsage, HourlyUsage, MonthlyUsage } from './types'; +import { getCcsDir } from '../../utils/config-manager'; +import { ok, info, warn } from '../../utils/ui'; + +// --------------------------------------------------------------------------- +// Snapshot format +// --------------------------------------------------------------------------- + +interface CliproxyUsageSnapshot { + version: number; + timestamp: number; + daily: DailyUsage[]; + hourly: HourlyUsage[]; + monthly: MonthlyUsage[]; +} + +const SNAPSHOT_VERSION = 1; + +/** Sync interval in ms, configurable via CCS_CLIPROXY_SYNC_INTERVAL env var (default: 5 min) */ +const SYNC_INTERVAL_MS = Math.max( + 30_000, + parseInt(process.env.CCS_CLIPROXY_SYNC_INTERVAL ?? '300000', 10) || 300_000 +); + +// Module-level interval ID +let syncIntervalId: ReturnType | null = null; + +// --------------------------------------------------------------------------- +// Cache directory helpers +// --------------------------------------------------------------------------- + +function getCliproxyCacheDir(): string { + return path.join(getCcsDir(), 'cache', 'cliproxy-usage'); +} + +function getLatestSnapshotPath(): string { + return path.join(getCliproxyCacheDir(), 'latest.json'); +} + +function ensureCliproxyCacheDir(): void { + const dir = getCliproxyCacheDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +// --------------------------------------------------------------------------- +// Load cached data +// --------------------------------------------------------------------------- + +/** + * Read the latest CLIProxy usage snapshot from disk. + * Returns empty arrays on failure (file not found, parse error, version mismatch). + */ +export async function loadCachedCliproxyData(): Promise<{ + daily: DailyUsage[]; + hourly: HourlyUsage[]; + monthly: MonthlyUsage[]; +}> { + const empty = { daily: [], hourly: [], monthly: [] }; + + try { + const snapshotPath = getLatestSnapshotPath(); + if (!fs.existsSync(snapshotPath)) { + return empty; + } + + const raw = fs.readFileSync(snapshotPath, 'utf-8'); + const snapshot: CliproxyUsageSnapshot = JSON.parse(raw); + + if (snapshot.version !== SNAPSHOT_VERSION) { + console.log(info('CLIProxy snapshot version mismatch, will refresh on next sync')); + return empty; + } + + return { daily: snapshot.daily, hourly: snapshot.hourly, monthly: snapshot.monthly }; + } catch (err) { + console.log(warn('Failed to read CLIProxy snapshot:') + ` ${(err as Error).message}`); + return empty; + } +} + +// --------------------------------------------------------------------------- +// Sync +// --------------------------------------------------------------------------- + +/** + * Fetch latest CLIProxy usage data and persist a snapshot to disk. + * Non-fatal: logs warning and returns early if CLIProxy is unavailable. + */ +export async function syncCliproxyUsage(): Promise { + const raw = await fetchCliproxyUsageRaw(); + + if (raw === null) { + console.log(warn('CLIProxy usage sync skipped: proxy unavailable')); + return; + } + + try { + ensureCliproxyCacheDir(); + + const daily = transformCliproxyToDailyUsage(raw); + const hourly = transformCliproxyToHourlyUsage(raw); + const monthly = transformCliproxyToMonthlyUsage(raw); + + const snapshot: CliproxyUsageSnapshot = { + version: SNAPSHOT_VERSION, + timestamp: Date.now(), + daily, + hourly, + monthly, + }; + + // Atomic write: temp file + rename + const snapshotPath = getLatestSnapshotPath(); + const tempFile = snapshotPath + '.tmp'; + fs.writeFileSync(tempFile, JSON.stringify(snapshot), 'utf-8'); + fs.renameSync(tempFile, snapshotPath); + + console.log(ok('CLIProxy usage snapshot updated')); + } catch (err) { + // Non-fatal - stale snapshot will continue to be served + console.log(warn('Failed to write CLIProxy snapshot:') + ` ${(err as Error).message}`); + } +} + +// --------------------------------------------------------------------------- +// Interval management +// --------------------------------------------------------------------------- + +/** + * Start periodic CLIProxy usage sync (every 5 minutes). + * Performs an immediate sync on startup. + */ +export function startCliproxySync(): void { + if (syncIntervalId !== null) { + return; + } + + const intervalMin = Math.round(SYNC_INTERVAL_MS / 60_000); + console.log(info(`Starting CLIProxy usage sync (interval: ${intervalMin} min)`)); + + // Fire-and-forget initial sync + void syncCliproxyUsage(); + + syncIntervalId = setInterval(() => { + void syncCliproxyUsage(); + }, SYNC_INTERVAL_MS); +} + +/** + * Stop periodic CLIProxy usage sync. + */ +export function stopCliproxySync(): void { + if (syncIntervalId !== null) { + clearInterval(syncIntervalId); + syncIntervalId = null; + } +} diff --git a/src/web-server/usage/cliproxy-usage-transformer.ts b/src/web-server/usage/cliproxy-usage-transformer.ts new file mode 100644 index 00000000..aff50311 --- /dev/null +++ b/src/web-server/usage/cliproxy-usage-transformer.ts @@ -0,0 +1,189 @@ +/** + * CLIProxy Usage Transformer + * + * Transforms CLIProxy's usage API response into DailyUsage/HourlyUsage/MonthlyUsage + * types compatible with the CCS analytics dashboard. + */ + +import type { CliproxyUsageApiResponse, CliproxyRequestDetail } from '../../cliproxy/stats-fetcher'; +import { calculateCost } from '../model-pricing'; +import type { ModelBreakdown, DailyUsage, HourlyUsage, MonthlyUsage } from './types'; + +// ============================================================================ +// INTERNAL HELPERS +// ============================================================================ + +/** Flat entry pairing a model name with its request detail */ +interface FlatDetail { + model: string; + detail: CliproxyRequestDetail; +} + +/** Accumulator for token counts per model per time bucket */ +interface ModelAccumulator { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; +} + +/** Build ModelBreakdown from accumulated token counts */ +function buildModelBreakdown(modelName: string, acc: ModelAccumulator): ModelBreakdown { + const { inputTokens, outputTokens, cacheReadTokens } = acc; + const cost = calculateCost( + { inputTokens, outputTokens, cacheCreationTokens: 0, cacheReadTokens }, + modelName + ); + return { modelName, inputTokens, outputTokens, cacheCreationTokens: 0, cacheReadTokens, cost }; +} + +// ============================================================================ +// FLATTEN +// ============================================================================ + +/** + * Flatten the nested response.usage.apis[provider].models[model].details[] + * structure into a flat array. Failed requests are skipped. + */ +export function flattenCliproxyDetails(response: CliproxyUsageApiResponse): FlatDetail[] { + const apis = response?.usage?.apis; + if (!apis) return []; + + const results: FlatDetail[] = []; + for (const providerData of Object.values(apis)) { + const models = providerData?.models; + if (!models) continue; + for (const [model, modelData] of Object.entries(models)) { + const details = modelData?.details; + if (!details) continue; + for (const detail of details) { + if (detail.failed) continue; + results.push({ model, detail }); + } + } + } + return results; +} + +// ============================================================================ +// GENERIC AGGREGATOR +// ============================================================================ + +/** Group flat details by a time key extractor, return sorted DailyUsage-like records */ +function aggregateByKey( + flat: FlatDetail[], + keyFn: (timestamp: string) => string, + buildRecord: (key: string, breakdowns: ModelBreakdown[]) => T, + sortFn: (a: T, b: T) => number +): T[] { + // bucket: timeKey -> modelName -> accumulator + const buckets = new Map>(); + + for (const { model, detail } of flat) { + const key = keyFn(detail.timestamp); + if (!buckets.has(key)) buckets.set(key, new Map()); + const modelMap = buckets.get(key) as Map; + if (!modelMap.has(model)) { + modelMap.set(model, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }); + } + const acc = modelMap.get(model) as ModelAccumulator; + acc.inputTokens += detail.tokens?.input_tokens ?? 0; + acc.outputTokens += detail.tokens?.output_tokens ?? 0; + acc.cacheReadTokens += detail.tokens?.cached_tokens ?? 0; + } + + const records: T[] = []; + Array.from(buckets.entries()).forEach(([key, modelMap]) => { + const breakdowns = Array.from(modelMap.entries()).map(([name, acc]) => + buildModelBreakdown(name, acc) + ); + records.push(buildRecord(key, breakdowns)); + }); + + return records.sort(sortFn); +} + +/** Sum token field across all breakdowns */ +function sumField(breakdowns: ModelBreakdown[], field: keyof ModelBreakdown): number { + return breakdowns.reduce((acc, b) => acc + (b[field] as number), 0); +} + +// ============================================================================ +// TRANSFORMS +// ============================================================================ + +/** Transform CLIProxy usage response into DailyUsage array (sorted descending by date) */ +export function transformCliproxyToDailyUsage(response: CliproxyUsageApiResponse): DailyUsage[] { + const flat = flattenCliproxyDetails(response); + return aggregateByKey( + flat, + (ts) => ts.slice(0, 10), + (date, breakdowns) => { + const totalCost = sumField(breakdowns, 'cost'); + return { + date, + source: 'cliproxy', + inputTokens: sumField(breakdowns, 'inputTokens'), + outputTokens: sumField(breakdowns, 'outputTokens'), + cacheCreationTokens: 0, + cacheReadTokens: sumField(breakdowns, 'cacheReadTokens'), + cost: totalCost, + totalCost, + modelsUsed: breakdowns.map((b) => b.modelName), + modelBreakdowns: breakdowns, + }; + }, + (a, b) => b.date.localeCompare(a.date) + ); +} + +/** Transform CLIProxy usage response into HourlyUsage array (sorted descending by hour) */ +export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiResponse): HourlyUsage[] { + const flat = flattenCliproxyDetails(response); + return aggregateByKey( + flat, + (ts) => { + const date = ts.slice(0, 10); + const hour = ts.slice(11, 13) || '00'; + return `${date} ${hour}:00`; + }, + (hour, breakdowns) => { + const totalCost = sumField(breakdowns, 'cost'); + return { + hour, + source: 'cliproxy', + inputTokens: sumField(breakdowns, 'inputTokens'), + outputTokens: sumField(breakdowns, 'outputTokens'), + cacheCreationTokens: 0, + cacheReadTokens: sumField(breakdowns, 'cacheReadTokens'), + cost: totalCost, + totalCost, + modelsUsed: breakdowns.map((b) => b.modelName), + modelBreakdowns: breakdowns, + }; + }, + (a, b) => b.hour.localeCompare(a.hour) + ); +} + +/** Transform CLIProxy usage response into MonthlyUsage array (sorted descending by month) */ +export function transformCliproxyToMonthlyUsage( + response: CliproxyUsageApiResponse +): MonthlyUsage[] { + const flat = flattenCliproxyDetails(response); + return aggregateByKey( + flat, + (ts) => ts.slice(0, 7), + (month, breakdowns) => ({ + month, + source: 'cliproxy', + inputTokens: sumField(breakdowns, 'inputTokens'), + outputTokens: sumField(breakdowns, 'outputTokens'), + cacheCreationTokens: 0, + cacheReadTokens: sumField(breakdowns, 'cacheReadTokens'), + totalCost: sumField(breakdowns, 'cost'), + modelsUsed: breakdowns.map((b) => b.modelName), + modelBreakdowns: breakdowns, + }), + (a, b) => b.month.localeCompare(a.month) + ); +} diff --git a/src/web-server/usage/disk-cache.ts b/src/web-server/usage/disk-cache.ts index 523169c9..a2bc1e86 100644 --- a/src/web-server/usage/disk-cache.ts +++ b/src/web-server/usage/disk-cache.ts @@ -35,8 +35,11 @@ export interface UsageDiskCache { } // Current cache version - increment to invalidate old caches +// v1: Initial cache format (daily, monthly, session) +// v2: Added multi-instance aggregation // v3: Added hourly data to cache -const CACHE_VERSION = 3; +// v4: Pricing fix for Claude 4.6 models (invalidate stale costs) +const CACHE_VERSION = 4; /** * Ensure ~/.ccs/cache directory exists diff --git a/src/web-server/usage/handlers.ts b/src/web-server/usage/handlers.ts index fb42867c..e9e34668 100644 --- a/src/web-server/usage/handlers.ts +++ b/src/web-server/usage/handlers.ts @@ -13,8 +13,8 @@ import { getCachedMonthlyData, getCachedSessionData, getCachedHourlyData, - clearUsageCache, getLastFetchTimestamp, + refreshUsageCache, } from './aggregator'; // ============================================================================ @@ -602,9 +602,13 @@ export async function handleMonthly( } } -export function handleRefresh(_req: Request, res: Response): void { - clearUsageCache(); - res.json({ success: true, message: 'Usage cache cleared' }); +export async function handleRefresh(_req: Request, res: Response): Promise { + try { + await refreshUsageCache(); + res.json({ success: true, message: 'Usage cache refreshed' }); + } catch (error) { + errorResponse(res, error, 'Failed to refresh usage cache'); + } } export function handleStatus(_req: Request, res: Response): void { diff --git a/tests/unit/api/profile-writer-denylist.test.ts b/tests/unit/api/profile-writer-denylist.test.ts new file mode 100644 index 00000000..a1975e06 --- /dev/null +++ b/tests/unit/api/profile-writer-denylist.test.ts @@ -0,0 +1,48 @@ +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 { createApiProfile } from '../../../src/api/services/profile-writer'; + +describe('profile-writer AGY denylist', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-writer-')); + 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('rejects denylisted AGY 4.5 models on profile create', () => { + const result = createApiProfile( + 'agy-denied', + 'http://127.0.0.1:8317/api/provider/agy', + 'test-token', + { + default: 'claude-opus-4.5', + opus: 'claude-opus-4.5', + sonnet: 'claude-sonnet-4.5', + haiku: 'claude-haiku-4.5', + }, + 'claude' + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('denylist'); + const settingsPath = path.join(tempHome, '.ccs', 'agy-denied.settings.json'); + expect(fs.existsSync(settingsPath)).toBe(false); + }); +}); diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts index b51576c2..1235088f 100644 --- a/tests/unit/api/provider-presets.test.ts +++ b/tests/unit/api/provider-presets.test.ts @@ -2,6 +2,22 @@ import { describe, expect, it } from 'bun:test'; import { getPresetById, isValidPresetId } from '../../../src/api/services/provider-presets'; describe('provider-presets', () => { + it('resolves Alibaba Coding Plan preset id', () => { + const preset = getPresetById('alibaba-coding-plan'); + expect(preset?.id).toBe('alibaba-coding-plan'); + expect(preset?.baseUrl).toBe('https://coding-intl.dashscope.aliyuncs.com/apps/anthropic'); + expect(preset?.defaultProfileName).toBe('albb'); + }); + + it('resolves alibaba alias to Alibaba Coding Plan preset', () => { + const preset = getPresetById('alibaba'); + expect(preset?.id).toBe('alibaba-coding-plan'); + }); + + it('treats alibaba alias as a valid preset id', () => { + expect(isValidPresetId('alibaba')).toBe(true); + }); + it('resolves canonical km preset id', () => { const preset = getPresetById('km'); expect(preset?.id).toBe('km'); @@ -25,4 +41,9 @@ describe('provider-presets', () => { it('treats legacy kimi alias as a valid preset id', () => { expect(isValidPresetId('kimi')).toBe(true); }); + + it('uses non-reserved default profile name for qwen API preset', () => { + const preset = getPresetById('qwen'); + expect(preset?.defaultProfileName).toBe('qwen-api'); + }); }); diff --git a/tests/unit/auth/profile-continuity-inheritance.test.ts b/tests/unit/auth/profile-continuity-inheritance.test.ts new file mode 100644 index 00000000..acf322ec --- /dev/null +++ b/tests/unit/auth/profile-continuity-inheritance.test.ts @@ -0,0 +1,344 @@ +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as configLoader from '../../../src/config/unified-config-loader'; +import ProfileRegistry from '../../../src/auth/profile-registry'; +import InstanceManager from '../../../src/management/instance-manager'; +import { resolveProfileContinuityInheritance } from '../../../src/auth/profile-continuity-inheritance'; + +describe('resolveProfileContinuityInheritance', () => { + afterEach(() => { + mock.restore(); + }); + + it('returns no inheritance for non-claude targets', async () => { + const result = await resolveProfileContinuityInheritance({ + profileName: 'glm', + profileType: 'settings', + target: 'droid', + }); + + expect(result).toEqual({}); + }); + + it('resolves mapped account and ensures inherited instance path', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + continuity: { + inherit_from_account: { + glm: 'pro', + }, + }, + } as ReturnType); + + const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue( + '/tmp/.ccs/instances/pro' + ); + const getProfilesSpy = spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue( + { + pro: { + type: 'account', + created: '2026-03-01T00:00:00.000Z', + last_used: null, + context_mode: 'shared', + context_group: 'Team Alpha', + continuity_mode: 'deeper', + }, + } + ); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'glm', + profileType: 'settings', + target: 'claude', + }); + + expect(result).toEqual({ + sourceAccount: 'pro', + claudeConfigDir: '/tmp/.ccs/instances/pro', + }); + expect(getProfilesSpy).toHaveBeenCalledTimes(1); + expect(ensureInstanceSpy).toHaveBeenCalledWith('pro', { + mode: 'shared', + group: 'team-alpha', + continuityMode: 'deeper', + }); + }); + + it('supports legacy continuity_inherit_from_account fallback', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + } as ReturnType); + spyOn(configLoader, 'isUnifiedMode').mockReturnValue(false); + spyOn(configLoader, 'getConfigJsonPath').mockReturnValue('/tmp/ccs-test-config.json'); + spyOn(fs, 'existsSync').mockImplementation((filePath) => { + return filePath === '/tmp/ccs-test-config.json'; + }); + spyOn(fs, 'readFileSync').mockImplementation((filePath) => { + if (filePath === '/tmp/ccs-test-config.json') { + return JSON.stringify({ + continuity_inherit_from_account: { + copilot: 'work', + }, + }); + } + return ''; + }); + + spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({ + work: { + type: 'account', + created: '2026-03-01T00:00:00.000Z', + last_used: null, + }, + }); + spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue('/tmp/.ccs/instances/work'); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'copilot', + profileType: 'copilot', + target: 'claude', + }); + + expect(result).toEqual({ + sourceAccount: 'work', + claudeConfigDir: '/tmp/.ccs/instances/work', + }); + }); + + it('returns empty result when mapped source account does not exist', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + continuity: { + inherit_from_account: { + glm: 'missing', + }, + }, + } as ReturnType); + + spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({}); + const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance'); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'glm', + profileType: 'settings', + target: 'claude', + }); + + expect(result).toEqual({}); + expect(ensureInstanceSpy).not.toHaveBeenCalled(); + }); + + it('does not read legacy config fallback when unified mode is active', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + } as ReturnType); + spyOn(configLoader, 'isUnifiedMode').mockReturnValue(true); + spyOn(configLoader, 'getConfigJsonPath').mockReturnValue('/tmp/ccs-test-config.json'); + const existsSpy = spyOn(fs, 'existsSync').mockImplementation((filePath) => { + return filePath === '/tmp/ccs-test-config.json'; + }); + const readSpy = spyOn(fs, 'readFileSync').mockImplementation((filePath) => { + if (filePath === '/tmp/ccs-test-config.json') { + return JSON.stringify({ + continuity_inherit_from_account: { + glm: 'pro', + }, + }); + } + return ''; + }); + + spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({}); + const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance'); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'glm', + profileType: 'settings', + target: 'claude', + }); + + expect(result).toEqual({}); + expect(ensureInstanceSpy).not.toHaveBeenCalled(); + expect(existsSpy).not.toHaveBeenCalled(); + expect(readSpy).not.toHaveBeenCalled(); + }); + + it('supports default profile inheritance when explicitly mapped', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + continuity: { + inherit_from_account: { + default: 'pro', + }, + }, + } as ReturnType); + spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({ + pro: { + type: 'account', + created: '2026-03-01T00:00:00.000Z', + last_used: null, + }, + }); + spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue('/tmp/.ccs/instances/pro'); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'default', + profileType: 'default', + target: 'claude', + }); + + expect(result).toEqual({ + sourceAccount: 'pro', + claudeConfigDir: '/tmp/.ccs/instances/pro', + }); + }); + + it('returns empty result when mapped source exists but is not an account profile', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + continuity: { + inherit_from_account: { + glm: 'settings-profile', + }, + }, + } as ReturnType); + + spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({ + 'settings-profile': { + type: 'settings', + created: '2026-03-01T00:00:00.000Z', + last_used: null, + }, + }); + const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance'); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'glm', + profileType: 'settings', + target: 'claude', + }); + + expect(result).toEqual({}); + expect(ensureInstanceSpy).not.toHaveBeenCalled(); + }); + + it('supports profile alias lookup when continuity mapping uses legacy key', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + continuity: { + inherit_from_account: { + kimi: 'pro', + }, + }, + } as ReturnType); + + spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({ + pro: { + type: 'account', + created: '2026-03-01T00:00:00.000Z', + last_used: null, + }, + }); + const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue( + '/tmp/.ccs/instances/pro' + ); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'km', + profileType: 'settings', + target: 'claude', + }); + + expect(result).toEqual({ + sourceAccount: 'pro', + claudeConfigDir: '/tmp/.ccs/instances/pro', + }); + expect(ensureInstanceSpy).toHaveBeenCalledWith('pro', { + mode: 'isolated', + }); + }); + + it('does not apply km settings alias mapping to kimi cliproxy profile', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + continuity: { + inherit_from_account: { + km: 'pro', + }, + }, + } as ReturnType); + + const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance'); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'kimi', + profileType: 'cliproxy', + target: 'claude', + }); + + expect(result).toEqual({}); + expect(ensureInstanceSpy).not.toHaveBeenCalled(); + }); + + it('does not apply km settings alias mapping to kimi cliproxy profile in legacy mode', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + } as ReturnType); + spyOn(configLoader, 'isUnifiedMode').mockReturnValue(false); + spyOn(configLoader, 'getConfigJsonPath').mockReturnValue('/tmp/ccs-test-config.json'); + spyOn(fs, 'existsSync').mockImplementation((filePath) => { + return filePath === '/tmp/ccs-test-config.json'; + }); + spyOn(fs, 'readFileSync').mockImplementation((filePath) => { + if (filePath === '/tmp/ccs-test-config.json') { + return JSON.stringify({ + continuity_inherit_from_account: { + km: 'pro', + }, + }); + } + return ''; + }); + + const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance'); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'kimi', + profileType: 'cliproxy', + target: 'claude', + }); + + expect(result).toEqual({}); + expect(ensureInstanceSpy).not.toHaveBeenCalled(); + }); + + it('fails open when source account instance initialization throws', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + continuity: { + inherit_from_account: { + glm: 'pro', + }, + }, + } as ReturnType); + + spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({ + pro: { + type: 'account', + created: '2026-03-01T00:00:00.000Z', + last_used: null, + }, + }); + spyOn(InstanceManager.prototype, 'ensureInstance').mockRejectedValue( + new Error('instance init failed') + ); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'glm', + profileType: 'settings', + target: 'claude', + }); + + expect(result).toEqual({}); + }); +}); diff --git a/tests/unit/cliproxy/composite-validator.test.ts b/tests/unit/cliproxy/composite-validator.test.ts index 3701b089..d762294d 100644 --- a/tests/unit/cliproxy/composite-validator.test.ts +++ b/tests/unit/cliproxy/composite-validator.test.ts @@ -10,7 +10,7 @@ import { const validTier = { provider: 'agy', - model: 'claude-sonnet-4-5-thinking', + model: 'claude-sonnet-4-6', }; describe('validateCompositeDefaultTier', () => { @@ -77,4 +77,17 @@ describe('validateCompositeTiers', () => { expect(error).toContain("Circular fallback in tier 'opus'"); }); + + it('rejects denylisted agy 4.5 models', () => { + const error = validateCompositeTiers( + { + opus: { provider: 'agy', model: 'claude-opus-4.5-thinking' }, + sonnet: { provider: 'gemini', model: 'gemini-2.5-pro' }, + haiku: { provider: 'gemini', model: 'gemini-2.5-flash' }, + }, + { defaultTier: 'opus', requireAllTiers: true } + ); + + expect(error).toContain('denylist'); + }); }); diff --git a/tests/unit/cliproxy/composite-variant-service.test.ts b/tests/unit/cliproxy/composite-variant-service.test.ts index 22ad8ae8..ccc47fda 100644 --- a/tests/unit/cliproxy/composite-variant-service.test.ts +++ b/tests/unit/cliproxy/composite-variant-service.test.ts @@ -73,7 +73,7 @@ cliproxy: default_tier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, settings: 'cliproxy/composite-test.settings.json', @@ -99,7 +99,7 @@ cliproxy: expect(result.success).toBe(true); expect(result.variant?.tiers?.opus.model).toBe('claude-opus-4-6-thinking'); - expect(result.variant?.tiers?.sonnet.model).toBe('claude-sonnet-4-5-thinking'); // Unchanged + expect(result.variant?.tiers?.sonnet.model).toBe('claude-sonnet-4-6'); // Unchanged expect(result.variant?.tiers?.haiku.model).toBe('claude-haiku-4-5-20251001'); // Unchanged }); @@ -110,7 +110,7 @@ cliproxy: default_tier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, settings: 'cliproxy/composite-test.settings.json', @@ -144,7 +144,7 @@ cliproxy: default_tier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, settings: 'cliproxy/composite-test.settings.json', @@ -188,7 +188,7 @@ cliproxy: thinking: 'xhigh', account: 'team-a', }, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, settings: 'cliproxy/composite-test.settings.json', @@ -228,7 +228,7 @@ cliproxy: default_tier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, settings: customSettingsPath, @@ -244,9 +244,9 @@ cliproxy: env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', - ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_MODEL: 'claude-sonnet-4-6', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-preview', - ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', CUSTOM_ENV: 'preserve-this', }, @@ -261,7 +261,7 @@ cliproxy: const result = updateCompositeVariant('test', { tiers: { - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking(high)' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6(high)' }, }, }); @@ -273,7 +273,7 @@ cliproxy: hooks: { PreToolUse: unknown[] }; customPreset: boolean; }; - expect(updatedSettings.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking(high)'); + expect(updatedSettings.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6(high)'); expect(updatedSettings.env.CUSTOM_ENV).toBe('preserve-this'); expect(updatedSettings.hooks.PreToolUse.length).toBe(1); expect(updatedSettings.customPreset).toBe(true); @@ -377,7 +377,7 @@ cliproxy: defaultTier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-2.5-pro' }, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, } as unknown as { opus: { provider: 'gemini' | 'codex' | 'agy'; model: string }; sonnet: { provider: 'gemini' | 'codex' | 'agy'; model: string }; @@ -395,7 +395,7 @@ cliproxy: defaultTier: 'sonnet', tiers: { opus: null, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, } as unknown as { opus: { provider: 'gemini' | 'codex' | 'agy'; model: string }; @@ -414,7 +414,7 @@ cliproxy: defaultTier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-2.5-pro' }, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, }); @@ -479,7 +479,7 @@ cliproxy: default_tier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, settings: 'cliproxy/composite-test.settings.json', @@ -547,7 +547,7 @@ cliproxy: model: gemini-3-pro-preview sonnet: provider: agy - model: claude-sonnet-4-5-thinking + model: claude-sonnet-4-6 haiku: provider: agy model: claude-haiku-4-5-20251001 @@ -588,7 +588,7 @@ cliproxy: thinking: xhigh sonnet: provider: agy - model: claude-sonnet-4-5-thinking + model: claude-sonnet-4-6 thinking: medium haiku: provider: agy @@ -632,7 +632,7 @@ cliproxy: model: claude-opus-4-6-thinking sonnet: provider: agy - model: claude-sonnet-4-5-thinking + model: claude-sonnet-4-6 haiku: provider: agy model: claude-haiku-4-5-20251001 @@ -671,7 +671,7 @@ cliproxy: model: claude-opus-4-6-thinking sonnet: provider: agy - model: claude-sonnet-4-5-thinking + model: claude-sonnet-4-6 settings: cliproxy/bad.settings.json port: 8318 good: diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js index d32269f1..751bd766 100644 --- a/tests/unit/cliproxy/config-generator.test.js +++ b/tests/unit/cliproxy/config-generator.test.js @@ -584,9 +584,16 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" const config = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); // Claude aliases should have fork: true - assert(config.includes('claude-sonnet-4-5'), 'Should include Claude sonnet model'); assert(config.includes('claude-sonnet-4-6'), 'Should include Claude Sonnet 4.6 model'); assert(config.includes('fork: true'), 'Should include fork: true for Claude aliases'); + assert( + !config.includes('claude-sonnet-4-5'), + 'Should not include denylisted Claude Sonnet 4.5 aliases' + ); + assert( + !config.includes('claude-opus-4-5'), + 'Should not include denylisted Claude Opus 4.5 aliases' + ); assert( !config.includes('alias: gemini-claude-'), 'Should not emit deprecated gemini-claude aliases' @@ -792,13 +799,43 @@ oauth-model-alias: const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); assert( - newConfig.includes('alias: claude-sonnet-4-6-thinking'), - 'Should include normalized upstream Claude alias' + newConfig.includes('alias: claude-sonnet-4-6'), + 'Should migrate deprecated sonnet thinking alias to canonical model ID' ); assert( !newConfig.includes('alias: gemini-claude-sonnet-4-6-thinking'), 'Should remove deprecated gemini-claude alias after regeneration' ); }); + + it('normalizes dotted deprecated sonnet thinking aliases during regeneration', () => { + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v11 +port: 8317 +api-keys: + - "ccs-internal-managed" +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +oauth-model-alias: + antigravity: + - name: claude-sonnet-4-6-thinking + alias: claude-sonnet-4.6-thinking + fork: true +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + regenerateConfig(); + + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert( + newConfig.includes('alias: claude-sonnet-4-6'), + 'Should migrate dotted deprecated sonnet thinking alias to canonical model ID' + ); + assert( + !newConfig.includes('alias: claude-sonnet-4.6-thinking'), + 'Should remove dotted deprecated sonnet thinking alias after regeneration' + ); + }); }); }); diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index 9c6f9002..8b589ca5 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -105,10 +105,61 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); const env = getEffectiveEnvVars('agy', 8317, settingsPath); - expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6-thinking'); + expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); - expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6-thinking'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); + }); + + it('migrates denylisted AGY 4.5 model IDs to supported 4.6 fallbacks', () => { + writeSettings( + settingsPath, + { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-opus-4.5', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-5', + }, + { + presets: [ + { + name: 'legacy-45', + default: 'claude-opus-4.5', + opus: 'claude-opus-4-5-thinking', + sonnet: 'claude-sonnet-4.5-thinking', + haiku: 'claude-sonnet-4-5', + }, + ], + } + ); + + const env = getEffectiveEnvVars('agy', 8317, settingsPath); + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + presets?: Array>; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6'); + expect(persisted.presets?.[0]?.default).toBe('claude-opus-4-6-thinking'); + expect(persisted.presets?.[0]?.opus).toBe('claude-opus-4-6-thinking'); + expect(persisted.presets?.[0]?.sonnet).toBe('claude-sonnet-4-6'); + expect(persisted.presets?.[0]?.haiku).toBe('claude-sonnet-4-6'); }); it('migrates codex preset model mappings to canonical IDs', () => { @@ -191,6 +242,51 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(persisted.presets[0]?.haiku).toBe('qwen3-coder-plus'); }); + it('migrates stale iflow model aliases (including kimi-k2.5) to supported IDs', () => { + const iflowSettingsPath = path.join(tempHome, 'iflow.settings.json'); + writeSettings( + iflowSettingsPath, + { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/iflow', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'kimi-k2.5', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'iflow-default', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'deepseek-v3.2-chat', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.7', + }, + { + presets: [ + { + name: 'legacy-iflow', + default: 'kimi-k2.5', + opus: 'iflow-default', + sonnet: 'deepseek-v3.2-chat', + haiku: 'minimax-m2.5', + }, + ], + } + ); + + const env = getEffectiveEnvVars('iflow', 8317, iflowSettingsPath); + expect(env.ANTHROPIC_MODEL).toBe('kimi-k2'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('glm-4.6'); + + const persisted = JSON.parse(fs.readFileSync(iflowSettingsPath, 'utf-8')) as { + env: Record; + presets: Array>; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('kimi-k2'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('glm-4.6'); + expect(persisted.presets[0]?.default).toBe('kimi-k2'); + expect(persisted.presets[0]?.opus).toBe('qwen3-coder-plus'); + expect(persisted.presets[0]?.sonnet).toBe('deepseek-v3.2'); + expect(persisted.presets[0]?.haiku).toBe('qwen3-coder-plus'); + }); + it('repairs existing provider settings files that are missing env keys', () => { process.env.CCS_HOME = tempHome; const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); @@ -222,6 +318,100 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBeDefined(); }); + it('migrates deprecated agy sonnet 4.6 thinking IDs during ensureProviderSettings', () => { + process.env.CCS_HOME = tempHome; + const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true }); + fs.writeFileSync( + agySettingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4-6-thinking(8192)', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-5', + }, + presets: [ + { + name: 'legacy', + default: 'claude-sonnet-4-6-thinking', + opus: 'claude-opus-4.6-thinking', + sonnet: 'claude-sonnet-4.6-thinking', + haiku: 'claude-sonnet-4-5', + }, + ], + }, + null, + 2 + ) + ); + + ensureProviderSettings('agy'); + + const repaired = JSON.parse(fs.readFileSync(agySettingsPath, 'utf-8')) as { + env?: Record; + presets?: Array>; + }; + expect(repaired.env?.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6(8192)'); + expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); + expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6'); + expect(repaired.presets?.[0]?.default).toBe('claude-sonnet-4-6'); + expect(repaired.presets?.[0]?.opus).toBe('claude-opus-4-6-thinking'); + expect(repaired.presets?.[0]?.sonnet).toBe('claude-sonnet-4-6'); + expect(repaired.presets?.[0]?.haiku).toBe('claude-sonnet-4-6'); + }); + + it('migrates codex effort-suffixed preset IDs during ensureProviderSettings', () => { + process.env.CCS_HOME = tempHome; + const codexSettingsPath = path.join(tempHome, '.ccs', 'codex.settings.json'); + fs.mkdirSync(path.dirname(codexSettingsPath), { recursive: true }); + fs.writeFileSync( + codexSettingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: ' gpt-5.3-codex-xhigh ', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium', + }, + presets: [ + { + name: 'legacy', + default: 'gpt-5.3-codex-xhigh', + opus: 'gpt-5.3-codex-xhigh', + sonnet: 'gpt-5.3-codex-high', + haiku: 'gpt-5-mini-medium', + }, + ], + }, + null, + 2 + ) + ); + + ensureProviderSettings('codex'); + + const repaired = JSON.parse(fs.readFileSync(codexSettingsPath, 'utf-8')) as { + env?: Record; + presets?: Array>; + }; + expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); + expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); + expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); + expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex'); + expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex'); + expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex'); + expect(repaired.presets?.[0]?.haiku).toBe('gpt-5-mini'); + }); + it('recovers malformed provider settings files by writing defaults and backup copy', () => { process.env.CCS_HOME = tempHome; const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); diff --git a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts index 0162ed26..61d13406 100644 --- a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts +++ b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts @@ -92,4 +92,24 @@ describe('buildClaudeEnvironment codex fallback normalization', () => { expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-medium'); expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9444/api/provider/codex'); }); + + it('propagates inherited CLAUDE_CONFIG_DIR when provided', () => { + const settingsPath = createCodexSettingsFile({ + defaultModel: 'gpt-5.3-codex', + opusModel: 'gpt-5.3-codex', + sonnetModel: 'gpt-5.3-codex', + haikuModel: 'gpt-5-mini', + }); + + const env = buildClaudeEnvironment({ + provider: 'codex', + useRemoteProxy: false, + localPort: 8317, + customSettingsPath: settingsPath, + claudeConfigDir: '/tmp/.ccs/instances/pro', + verbose: false, + }); + + expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/.ccs/instances/pro'); + }); }); diff --git a/tests/unit/cliproxy/model-catalog-compat.test.ts b/tests/unit/cliproxy/model-catalog-compat.test.ts index 90fad46f..af51012f 100644 --- a/tests/unit/cliproxy/model-catalog-compat.test.ts +++ b/tests/unit/cliproxy/model-catalog-compat.test.ts @@ -3,15 +3,35 @@ import { findModel, supportsThinking } from '../../../src/cliproxy/model-catalog describe('model-catalog compatibility lookups', () => { it('finds agy Claude models using dotted major.minor IDs', () => { - const dottedThinking = findModel('agy', 'claude-sonnet-4.5-thinking'); - const dottedNonThinking = findModel('agy', 'claude-sonnet-4.5'); + const dottedThinking = findModel('agy', 'claude-opus-4.6-thinking'); + const dottedNonThinking = findModel('agy', 'claude-sonnet-4.6'); - expect(dottedThinking?.id).toBe('claude-sonnet-4-5-thinking'); - expect(dottedNonThinking?.id).toBe('claude-sonnet-4-5'); + expect(dottedThinking?.id).toBe('claude-opus-4-6-thinking'); + expect(dottedNonThinking?.id).toBe('claude-sonnet-4-6'); + }); + + it('maps legacy agy 4.5 IDs to canonical 4.6 catalog models', () => { + const legacyOpusThinking = findModel('agy', 'claude-opus-4.5-thinking'); + const legacySonnetThinking = findModel('agy', 'claude-sonnet-4.5-thinking'); + const legacySonnet = findModel('agy', 'claude-sonnet-4.5'); + + expect(legacyOpusThinking?.id).toBe('claude-opus-4-6-thinking'); + expect(legacySonnetThinking?.id).toBe('claude-sonnet-4-6'); + expect(legacySonnet?.id).toBe('claude-sonnet-4-6'); }); it('supports thinking checks for dotted agy model IDs', () => { expect(supportsThinking('agy', 'claude-opus-4.6-thinking')).toBe(true); - expect(supportsThinking('agy', 'claude-sonnet-4.5')).toBe(false); + expect(supportsThinking('agy', 'claude-sonnet-4.6')).toBe(true); + expect(supportsThinking('agy', 'claude-opus-4.5-thinking')).toBe(true); + expect(supportsThinking('agy', 'claude-sonnet-4.5')).toBe(true); + }); + + it('maps legacy sonnet 4.6 thinking aliases to canonical agy model', () => { + const dottedLegacy = findModel('agy', 'claude-sonnet-4.6-thinking'); + const hyphenLegacy = findModel('agy', 'claude-sonnet-4-6-thinking'); + + expect(dottedLegacy?.id).toBe('claude-sonnet-4-6'); + expect(hyphenLegacy?.id).toBe('claude-sonnet-4-6'); }); }); diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 3cd6d7e8..87acb22a 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -69,29 +69,11 @@ describe('Model Catalog', () => { assert.strictEqual(MODEL_CATALOG.agy.defaultModel, 'claude-opus-4-6-thinking'); }); - it('includes Claude Opus 4.5 Thinking', () => { + it('includes Claude Opus 4.6 Thinking', () => { const { MODEL_CATALOG } = modelCatalog; - const opus = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-opus-4-5-thinking'); - assert(opus, 'Should include Claude Opus 4.5 Thinking'); - assert.strictEqual(opus.name, 'Claude Opus 4.5 Thinking'); - }); - - it('includes Claude Sonnet 4.5 Thinking', () => { - const { MODEL_CATALOG } = modelCatalog; - const sonnetThinking = MODEL_CATALOG.agy.models.find( - (m) => m.id === 'claude-sonnet-4-5-thinking' - ); - assert(sonnetThinking, 'Should include Claude Sonnet 4.5 Thinking'); - assert.strictEqual(sonnetThinking.name, 'Claude Sonnet 4.5 Thinking'); - }); - - it('includes Claude Sonnet 4.6 Thinking', () => { - const { MODEL_CATALOG } = modelCatalog; - const sonnetThinking = MODEL_CATALOG.agy.models.find( - (m) => m.id === 'claude-sonnet-4-6-thinking' - ); - assert(sonnetThinking, 'Should include Claude Sonnet 4.6 Thinking'); - assert.strictEqual(sonnetThinking.name, 'Claude Sonnet 4.6 Thinking'); + const opus = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-opus-4-6-thinking'); + assert(opus, 'Should include Claude Opus 4.6 Thinking'); + assert.strictEqual(opus.name, 'Claude Opus 4.6 Thinking'); }); it('includes Claude Sonnet 4.6', () => { @@ -101,11 +83,12 @@ describe('Model Catalog', () => { assert.strictEqual(sonnet.name, 'Claude Sonnet 4.6'); }); - it('includes Claude Sonnet 4.5', () => { + it('does not include deprecated Claude 4.5 AGY models', () => { const { MODEL_CATALOG } = modelCatalog; - const sonnet = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-sonnet-4-5'); - assert(sonnet, 'Should include Claude Sonnet 4.5'); - assert.strictEqual(sonnet.name, 'Claude Sonnet 4.5'); + const ids = MODEL_CATALOG.agy.models.map((m) => m.id); + assert.strictEqual(ids.includes('claude-opus-4-5-thinking'), false); + assert.strictEqual(ids.includes('claude-sonnet-4-5-thinking'), false); + assert.strictEqual(ids.includes('claude-sonnet-4-5'), false); }); it('includes Gemini 3 Pro (free via Antigravity)', () => { @@ -117,9 +100,9 @@ describe('Model Catalog', () => { assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier'); }); - it('has 7 models total', () => { + it('has 3 models total', () => { const { MODEL_CATALOG } = modelCatalog; - assert.strictEqual(MODEL_CATALOG.agy.models.length, 7); + assert.strictEqual(MODEL_CATALOG.agy.models.length, 3); }); }); @@ -212,11 +195,11 @@ describe('Model Catalog', () => { }); describe('findModel', () => { - it('finds Claude Opus 4.5 Thinking in agy', () => { + it('finds Claude Opus 4.6 Thinking in agy', () => { const { findModel } = modelCatalog; - const model = findModel('agy', 'claude-opus-4-5-thinking'); + const model = findModel('agy', 'claude-opus-4-6-thinking'); assert(model, 'Should find model'); - assert.strictEqual(model.name, 'Claude Opus 4.5 Thinking'); + assert.strictEqual(model.name, 'Claude Opus 4.6 Thinking'); }); it('finds Gemini 2.5 Pro in gemini', () => { @@ -226,6 +209,16 @@ describe('Model Catalog', () => { assert.strictEqual(model.name, 'Gemini 2.5 Pro'); }); + it('maps legacy AGY 4.5 IDs to supported 4.6 models', () => { + const { findModel } = modelCatalog; + const legacyOpus = findModel('agy', 'claude-opus-4-5-thinking'); + const legacySonnetThinking = findModel('agy', 'claude-sonnet-4-5-thinking'); + const legacySonnet = findModel('agy', 'claude-sonnet-4-5'); + assert.strictEqual(legacyOpus?.id, 'claude-opus-4-6-thinking'); + assert.strictEqual(legacySonnetThinking?.id, 'claude-sonnet-4-6'); + assert.strictEqual(legacySonnet?.id, 'claude-sonnet-4-6'); + }); + it('returns undefined for unknown model', () => { const { findModel } = modelCatalog; const model = findModel('agy', 'unknown-model'); @@ -278,19 +271,17 @@ describe('Model Catalog', () => { }); describe('Thinking models ordering', () => { - it('Claude Opus 4.5 Thinking is not deprecated', () => { + it('Claude Opus 4.6 Thinking is not deprecated', () => { const { MODEL_CATALOG } = modelCatalog; - const opus = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-opus-4-5-thinking'); - assert(opus, 'Should include Claude Opus 4.5 Thinking'); + const opus = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-opus-4-6-thinking'); + assert(opus, 'Should include Claude Opus 4.6 Thinking'); assert.strictEqual(opus.deprecated, undefined, 'Should not be marked as deprecated'); }); - it('Claude Sonnet 4.5 Thinking is not deprecated', () => { + it('Claude Sonnet 4.6 is not deprecated', () => { const { MODEL_CATALOG } = modelCatalog; - const sonnetThinking = MODEL_CATALOG.agy.models.find( - (m) => m.id === 'claude-sonnet-4-5-thinking' - ); - assert(sonnetThinking, 'Should include Claude Sonnet 4.5 Thinking'); + const sonnetThinking = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-sonnet-4-6'); + assert(sonnetThinking, 'Should include Claude Sonnet 4.6'); assert.strictEqual( sonnetThinking.deprecated, undefined, @@ -303,31 +294,27 @@ describe('Model Catalog', () => { const models = MODEL_CATALOG.agy.models; // Find indices of thinking models - const opusIdx = models.findIndex((m) => m.id === 'claude-opus-4-5-thinking'); - const sonnetThinkingIdx = models.findIndex((m) => m.id === 'claude-sonnet-4-5-thinking'); + const opusIdx = models.findIndex((m) => m.id === 'claude-opus-4-6-thinking'); + const sonnetThinkingIdx = models.findIndex((m) => m.id === 'claude-sonnet-4-6'); - // Find indices of non-thinking models - const sonnetIdx = models.findIndex((m) => m.id === 'claude-sonnet-4-5'); + // Find indices of the remaining non-Claude model const geminiIdx = models.findIndex((m) => m.id === 'gemini-3-pro-preview'); - // Thinking models should come before non-thinking models - assert(opusIdx < sonnetIdx, 'Opus Thinking should be above non-thinking Sonnet'); - assert(opusIdx < geminiIdx, 'Opus Thinking should be above non-thinking Gemini'); - assert(sonnetThinkingIdx < sonnetIdx, 'Sonnet Thinking should be above non-thinking Sonnet'); - assert(sonnetThinkingIdx < geminiIdx, 'Sonnet Thinking should be above non-thinking Gemini'); + // Primary Claude choices should appear ahead of Gemini fallback. + assert(opusIdx < geminiIdx, 'Opus should be above Gemini'); + assert(sonnetThinkingIdx < geminiIdx, 'Sonnet should be above Gemini'); }); }); describe('isModelDeprecated', () => { - it('returns false for thinking models (no longer deprecated)', () => { + it('returns false for active AGY Claude models', () => { const { isModelDeprecated } = modelCatalog; - assert.strictEqual(isModelDeprecated('agy', 'claude-opus-4-5-thinking'), false); - assert.strictEqual(isModelDeprecated('agy', 'claude-sonnet-4-5-thinking'), false); + assert.strictEqual(isModelDeprecated('agy', 'claude-opus-4-6-thinking'), false); + assert.strictEqual(isModelDeprecated('agy', 'claude-sonnet-4-6'), false); }); it('returns false for non-deprecated models', () => { const { isModelDeprecated } = modelCatalog; - assert.strictEqual(isModelDeprecated('agy', 'claude-sonnet-4-5'), false); assert.strictEqual(isModelDeprecated('agy', 'gemini-3-pro-preview'), false); }); @@ -338,15 +325,15 @@ describe('Model Catalog', () => { }); describe('getModelDeprecationReason', () => { - it('returns undefined for thinking models (no longer deprecated)', () => { + it('returns undefined for active AGY Claude models', () => { const { getModelDeprecationReason } = modelCatalog; - assert.strictEqual(getModelDeprecationReason('agy', 'claude-opus-4-5-thinking'), undefined); - assert.strictEqual(getModelDeprecationReason('agy', 'claude-sonnet-4-5-thinking'), undefined); + assert.strictEqual(getModelDeprecationReason('agy', 'claude-opus-4-6-thinking'), undefined); + assert.strictEqual(getModelDeprecationReason('agy', 'claude-sonnet-4-6'), undefined); }); it('returns undefined for non-deprecated models', () => { const { getModelDeprecationReason } = modelCatalog; - assert.strictEqual(getModelDeprecationReason('agy', 'claude-sonnet-4-5'), undefined); + assert.strictEqual(getModelDeprecationReason('agy', 'gemini-3-pro-preview'), undefined); }); }); }); diff --git a/tests/unit/cliproxy/model-id-normalizer.test.ts b/tests/unit/cliproxy/model-id-normalizer.test.ts index d13bca6b..6d4cc361 100644 --- a/tests/unit/cliproxy/model-id-normalizer.test.ts +++ b/tests/unit/cliproxy/model-id-normalizer.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect } from 'bun:test'; import { + canonicalizeModelIdForProvider, extractProviderFromPathname, + getDeniedModelIdReasonForProvider, + isDeniedAntigravityModelId, isAntigravityProvider, + migrateDeniedAntigravityModelAliases, normalizeClaudeDottedMajorMinor, normalizeClaudeDottedThinkingMajorMinor, normalizeModelIdForProvider, @@ -44,42 +48,128 @@ describe('model-id-normalizer', () => { it('applies provider-aware routing normalization', () => { expect(normalizeModelIdForRouting('claude-sonnet-4.6-thinking', null)).toBe( - 'claude-sonnet-4-6-thinking' + 'claude-sonnet-4-6' ); expect(normalizeModelIdForRouting('claude-sonnet-4.6', null)).toBe('claude-sonnet-4.6'); expect(normalizeModelIdForRouting('claude-sonnet-4.6', 'agy')).toBe('claude-sonnet-4-6'); + expect(normalizeModelIdForRouting('kimi-k2.5', 'iflow')).toBe('kimi-k2'); + expect(normalizeModelIdForRouting('kimi-k2.5(8192)', 'iflow')).toBe('kimi-k2(8192)'); + expect(normalizeModelIdForRouting('claude-sonnet-4-6-thinking', 'claude')).toBe( + 'claude-sonnet-4-6-thinking' + ); + expect(normalizeModelIdForRouting('claude-sonnet-4.6-thinking', 'claude')).toBe( + 'claude-sonnet-4.6-thinking' + ); }); it('applies provider-only normalization for antigravity', () => { + expect(normalizeModelIdForProvider('claude-sonnet-4.6-thinking', 'agy')).toBe( + 'claude-sonnet-4-6' + ); expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'agy')).toBe( 'claude-opus-4-6-thinking' ); + expect(normalizeModelIdForProvider('claude-opus-4.5-thinking', 'agy')).toBe( + 'claude-opus-4-5-thinking' + ); + expect(normalizeModelIdForProvider('claude-sonnet-4.5-thinking', 'agy')).toBe( + 'claude-sonnet-4-5-thinking' + ); + expect(normalizeModelIdForProvider('claude-sonnet-4.5', 'agy')).toBe('claude-sonnet-4-5'); expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'gemini')).toBe( 'claude-opus-4.6-thinking' ); }); + + it('applies provider canonicalization for codex and antigravity', () => { + expect(canonicalizeModelIdForProvider('gpt-5.3-codex-xhigh', 'codex')).toBe('gpt-5.3-codex'); + expect(canonicalizeModelIdForProvider('claude-sonnet-4.6-thinking', 'agy')).toBe( + 'claude-sonnet-4-6' + ); + expect(canonicalizeModelIdForProvider('claude-sonnet-4-6-thinking', 'claude')).toBe( + 'claude-sonnet-4-6-thinking' + ); + }); + + it('trims and canonicalizes provider model IDs with surrounding whitespace', () => { + expect(canonicalizeModelIdForProvider(' gpt-5.3-codex-high ', 'codex')).toBe( + 'gpt-5.3-codex' + ); + expect(canonicalizeModelIdForProvider(' claude-sonnet-4.6-thinking ', 'agy')).toBe( + 'claude-sonnet-4-6' + ); + expect(canonicalizeModelIdForProvider(' claude-sonnet-4-6-thinking ', 'claude')).toBe( + 'claude-sonnet-4-6-thinking' + ); + expect(normalizeModelIdForRouting(' claude-sonnet-4.6-thinking ', null)).toBe( + 'claude-sonnet-4-6' + ); + }); + + it('normalizes known legacy iflow model aliases to supported upstream IDs', () => { + expect(normalizeModelIdForProvider('iflow-default', 'iflow')).toBe('qwen3-coder-plus'); + expect(normalizeModelIdForProvider('kimi-k2.5', 'iflow')).toBe('kimi-k2'); + expect(normalizeModelIdForProvider('kimi-k2.5(8192)', 'iflow')).toBe('kimi-k2(8192)'); + expect(canonicalizeModelIdForProvider('deepseek-v3.2-chat', 'iflow')).toBe('deepseek-v3.2'); + expect(canonicalizeModelIdForProvider('glm-4.7', 'iflow')).toBe('glm-4.6'); + expect(canonicalizeModelIdForProvider('minimax-m2.5', 'iflow')).toBe('qwen3-coder-plus'); + expect(canonicalizeModelIdForProvider('kimi-k2.5', 'gemini')).toBe('kimi-k2.5'); + }); }); describe('env normalization', () => { it('normalizes model env vars for antigravity only', () => { const input: NodeJS.ProcessEnv = { - ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking', - ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.6-thinking', - ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6', + ANTHROPIC_MODEL: 'claude-sonnet-4.5-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.5-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.5', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5', UNRELATED: 'keep-me', }; const normalized = normalizeModelEnvVarsForProvider(input, 'agy'); - expect(normalized.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6-thinking'); - expect(normalized.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); - expect(normalized.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); + expect(normalized.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking'); + expect(normalized.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-thinking'); + expect(normalized.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5'); expect(normalized.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); expect(normalized.UNRELATED).toBe('keep-me'); const unchanged = normalizeModelEnvVarsForProvider(input, 'gemini'); - expect(unchanged.ANTHROPIC_MODEL).toBe('claude-sonnet-4.6-thinking'); + expect(unchanged.ANTHROPIC_MODEL).toBe('claude-sonnet-4.5-thinking'); expect(unchanged.UNRELATED).toBe('keep-me'); }); + + it('normalizes iflow legacy model aliases in env vars', () => { + const input: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'kimi-k2.5', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'iflow-default', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'deepseek-v3.2-chat', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.7', + }; + + const normalized = normalizeModelEnvVarsForProvider(input, 'iflow'); + expect(normalized.ANTHROPIC_MODEL).toBe('kimi-k2'); + expect(normalized.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus'); + expect(normalized.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2'); + expect(normalized.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('glm-4.6'); + }); + + it('flags denylisted antigravity models', () => { + expect(isDeniedAntigravityModelId('claude-opus-4-5-thinking')).toBe(true); + expect(isDeniedAntigravityModelId('claude-sonnet-4.5')).toBe(true); + expect(getDeniedModelIdReasonForProvider('claude-sonnet-4.5', 'agy')).toContain('denylist'); + expect(getDeniedModelIdReasonForProvider('claude-sonnet-4.5', 'gemini')).toBeNull(); + expect(getDeniedModelIdReasonForProvider('claude-haiku-4.5', 'agy')).toBeNull(); + }); + + it('migrates denylisted antigravity IDs to supported 4.6 fallbacks', () => { + expect(migrateDeniedAntigravityModelAliases('claude-opus-4.5')).toBe( + 'claude-opus-4-6-thinking' + ); + expect(migrateDeniedAntigravityModelAliases('claude-sonnet-4-5-thinking(8192)')).toBe( + 'claude-sonnet-4-6(8192)' + ); + expect(migrateDeniedAntigravityModelAliases('claude-haiku-4-5')).toBe('claude-haiku-4-5'); + }); }); }); diff --git a/tests/unit/cliproxy/oauth-process-error-parser.test.ts b/tests/unit/cliproxy/oauth-process-error-parser.test.ts new file mode 100644 index 00000000..84fe5331 --- /dev/null +++ b/tests/unit/cliproxy/oauth-process-error-parser.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'bun:test'; +import { extractLikelyAuthFailureFromStderr } from '../../../src/cliproxy/auth/oauth-process'; + +describe('oauth-process stderr parsing', () => { + it('ignores non-ghcp providers', () => { + const stderr = + 'time="2026-03-03T10:00:00Z" level=error msg="GitHub Copilot authentication failed: example"'; + + expect(extractLikelyAuthFailureFromStderr('qwen', stderr)).toBeNull(); + }); + + it('extracts copilot verification failures from logrus lines', () => { + const stderr = + 'time="2026-03-03T10:00:00Z" level=error msg="GitHub Copilot authentication failed: github-copilot: failed to verify Copilot access - you may not have an active Copilot subscription: 403 Forbidden"'; + + expect(extractLikelyAuthFailureFromStderr('ghcp', stderr)).toBe( + 'github-copilot: failed to verify Copilot access - you may not have an active Copilot subscription: 403 Forbidden' + ); + }); + + it('extracts generic authentication failure lines', () => { + const stderr = 'level=error msg="Authentication failed: state mismatch"'; + + expect(extractLikelyAuthFailureFromStderr('ghcp', stderr)).toBe('state mismatch'); + }); + + it('caps extracted message length to prevent noisy broadcasts', () => { + const longSuffix = 'x'.repeat(400); + const stderr = `level=error msg="Authentication failed: ${longSuffix}"`; + + const parsed = extractLikelyAuthFailureFromStderr('ghcp', stderr); + expect(parsed).not.toBeNull(); + expect((parsed as string).length).toBe(240); + }); +}); diff --git a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts index dd7dd2ac..7f591232 100644 --- a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts +++ b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts @@ -201,9 +201,7 @@ describe('ToolSanitizationProxy Integration', () => { }); expect(lastRequest).not.toBeNull(); - expect((lastRequest!.body as Record).model).toBe( - 'claude-sonnet-4-6-thinking' - ); + expect((lastRequest!.body as Record).model).toBe('claude-sonnet-4-6'); } finally { proxy.stop(); } @@ -255,6 +253,79 @@ describe('ToolSanitizationProxy Integration', () => { } }); + it('rejects denylisted AGY 4.5 model IDs on antigravity provider route', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/provider/agy/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'claude-sonnet-4.5', + messages: [{ role: 'user', content: 'test' }], + }), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toContain('denylist'); + expect(lastRequest).toBeNull(); + } finally { + proxy.stop(); + } + }); + + it('does not rewrite AGY legacy aliases on non-AGY provider routes', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/api/provider/claude/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'claude-sonnet-4-6-thinking', + messages: [{ role: 'user', content: 'test' }], + }), + }); + + expect(lastRequest).not.toBeNull(); + expect((lastRequest!.body as Record).model).toBe( + 'claude-sonnet-4-6-thinking' + ); + } finally { + proxy.stop(); + } + }); + + it('normalizes legacy iFlow aliases on explicit iflow provider routes', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/api/provider/iflow/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'kimi-k2.5', + messages: [{ role: 'user', content: 'test' }], + }), + }); + + expect(lastRequest).not.toBeNull(); + expect((lastRequest!.body as Record).model).toBe('kimi-k2'); + } finally { + proxy.stop(); + } + }); + it('preserves other tool properties during sanitization', async () => { const proxy = new ToolSanitizationProxy({ upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, diff --git a/tests/unit/cliproxy/variant-update-service.test.ts b/tests/unit/cliproxy/variant-update-service.test.ts index ae3874a4..af491a6a 100644 --- a/tests/unit/cliproxy/variant-update-service.test.ts +++ b/tests/unit/cliproxy/variant-update-service.test.ts @@ -83,6 +83,15 @@ cliproxy: expect(result.error).toContain('Changing provider requires model update'); }); + it('rejects denylisted AGY 4.5 model updates', () => { + const result = updateVariant('demo', { + provider: 'agy', + model: 'claude-sonnet-4.5', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('denylist'); + }); + it('updates provider and regenerates provider-specific core env in same settings file', () => { const result = updateVariant('demo', { provider: 'codex', diff --git a/tests/unit/copilot/copilot-executor-env.test.ts b/tests/unit/copilot/copilot-executor-env.test.ts new file mode 100644 index 00000000..86b3db96 --- /dev/null +++ b/tests/unit/copilot/copilot-executor-env.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'bun:test'; +import { generateCopilotEnv } from '../../../src/copilot/copilot-executor'; +import type { CopilotConfig } from '../../../src/config/unified-config-types'; + +const baseConfig: CopilotConfig = { + enabled: true, + auto_start: false, + port: 4141, + account_type: 'individual', + rate_limit: null, + wait_on_limit: true, + model: 'gpt-4.1', +}; + +describe('generateCopilotEnv', () => { + it('includes inherited CLAUDE_CONFIG_DIR when provided', () => { + const env = generateCopilotEnv(baseConfig, '/tmp/.ccs/instances/pro'); + expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/.ccs/instances/pro'); + }); + + it('omits CLAUDE_CONFIG_DIR when inheritance is not configured', () => { + const env = generateCopilotEnv(baseConfig); + expect(env.CLAUDE_CONFIG_DIR).toBeUndefined(); + }); +}); + diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 30efdc59..3b483563 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -84,6 +84,54 @@ describe('model-pricing', () => { expect(opus.inputPerMillion).toBeGreaterThan(sonnet.inputPerMillion); expect(sonnet.inputPerMillion).toBeGreaterThan(haiku.inputPerMillion); }); + + it('should return correct pricing for Claude Opus 4.6 (not 3x Opus 4 rate)', () => { + const opus46 = getModelPricing('claude-opus-4-6'); + expect(opus46.inputPerMillion).toBe(5.0); + expect(opus46.outputPerMillion).toBe(25.0); + }); + + it('should return correct pricing for Claude Opus 4.6 thinking variant', () => { + const opus46t = getModelPricing('claude-opus-4-6-thinking'); + expect(opus46t.inputPerMillion).toBe(5.0); + expect(opus46t.outputPerMillion).toBe(25.0); + }); + + it('should return correct pricing for Claude Sonnet 4.6', () => { + const sonnet46 = getModelPricing('claude-sonnet-4-6'); + expect(sonnet46.inputPerMillion).toBe(3.0); + expect(sonnet46.outputPerMillion).toBe(15.0); + }); + + it('should match date-stamped Claude Opus 4.6 to correct pricing', () => { + const opus46dated = getModelPricing('claude-opus-4-6-20260101'); + expect(opus46dated.inputPerMillion).toBe(5.0); + expect(opus46dated.outputPerMillion).toBe(25.0); + }); + + it('should match date-stamped Claude Sonnet 4.6 to correct pricing', () => { + const sonnet46dated = getModelPricing('claude-sonnet-4-6-20260115'); + expect(sonnet46dated.inputPerMillion).toBe(3.0); + expect(sonnet46dated.outputPerMillion).toBe(15.0); + }); + + it('should match provider-prefixed date-stamped model to correct pricing', () => { + const opus46 = getModelPricing('anthropic/claude-opus-4-6-20260101'); + expect(opus46.inputPerMillion).toBe(5.0); + expect(opus46.outputPerMillion).toBe(25.0); + }); + + it('should match date-stamped thinking Claude Opus 4.6 to correct pricing', () => { + const opus46 = getModelPricing('claude-opus-4-6-20260101-thinking'); + expect(opus46.inputPerMillion).toBe(5.0); + expect(opus46.outputPerMillion).toBe(25.0); + }); + + it('should match provider-prefixed date-stamped thinking model to correct pricing', () => { + const opus46 = getModelPricing('anthropic/claude-opus-4-6-20260101-thinking'); + expect(opus46.inputPerMillion).toBe(5.0); + expect(opus46.outputPerMillion).toBe(25.0); + }); }); describe('calculateCost', () => { @@ -143,6 +191,17 @@ describe('model-pricing', () => { const cost = calculateCost(usage, 'gemini-2.0-flash-exp'); expect(cost).toBe(0); // Experimental models are free }); + + it('should calculate Claude Opus 4.6 cost including cache token rates', () => { + const usage: TokenUsage = { + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheCreationTokens: 1_000_000, + cacheReadTokens: 1_000_000, + }; + const cost = calculateCost(usage, 'claude-opus-4-6'); + expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5 + }); }); describe('getKnownModels', () => { @@ -173,6 +232,10 @@ describe('model-pricing', () => { expect(hasCustomPricing('qwen3-coder')).toBe(true); }); + it('should not treat date-stamped non-Claude IDs as deterministic aliases', () => { + expect(hasCustomPricing('qwen3-32b-20260101')).toBe(false); + }); + it('should return false for unknown models', () => { expect(hasCustomPricing('unknown-model-xyz')).toBe(false); }); diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index b4094ea4..6572a0df 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -2,6 +2,9 @@ * Unit tests for unified config module */ import { describe, it, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; // Import only modules without transitive dependencies on config-manager import { @@ -15,6 +18,7 @@ import { UNIFIED_CONFIG_VERSION, } from '../../src/config/unified-config-types'; import { isUnifiedConfigEnabled } from '../../src/config/feature-flags'; +import { loadOrCreateUnifiedConfig } from '../../src/config/unified-config-loader'; // Inline helper to test secret key detection (utility kept for potential reuse) function isSecretKey(key: string): boolean { @@ -188,3 +192,77 @@ describe('feature-flags', () => { }); }); }); + +describe('continuity-inheritance-config', () => { + it('normalizes legacy continuity_inherit_from_account into continuity.inherit_from_account', () => { + const originalCcsHome = process.env.CCS_HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-continuity-home-')); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'continuity_inherit_from_account:', + ' glm: pro', + ' empty: ""', + '', + ].join('\n') + ); + + process.env.CCS_HOME = tempHome; + try { + const config = loadOrCreateUnifiedConfig(); + expect(config.continuity?.inherit_from_account).toEqual({ + glm: 'pro', + }); + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('merges continuity.inherit_from_account with legacy continuity_inherit_from_account', () => { + const originalCcsHome = process.env.CCS_HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-continuity-merge-home-')); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'continuity:', + ' inherit_from_account:', + ' glm: team-a', + ' copilot: work', + 'continuity_inherit_from_account:', + ' glm: legacy', + ' km: account-kimi', + '', + ].join('\n') + ); + + process.env.CCS_HOME = tempHome; + try { + const config = loadOrCreateUnifiedConfig(); + expect(config.continuity?.inherit_from_account).toEqual({ + glm: 'team-a', + copilot: 'work', + km: 'account-kimi', + }); + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 3950ae52..64a2323a 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -1,6 +1,9 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'; import { EventEmitter } from 'events'; import * as childProcess from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; type SpawnCall = { command: string; @@ -13,6 +16,8 @@ const originalPlatform = process.platform; let baselineSigintListeners: Array<(...args: unknown[]) => void> = []; let baselineSigtermListeners: Array<(...args: unknown[]) => void> = []; let baselineSighupListeners: Array<(...args: unknown[]) => void> = []; +let originalCcsHome: string | undefined; +let originalDisableAutoUpdater: string | undefined; const realSpawn = childProcess.spawn.bind(childProcess); const realSpawnSync = childProcess.spawnSync.bind(childProcess); const realExecSync = childProcess.execSync.bind(childProcess); @@ -95,6 +100,18 @@ function registerChildProcessMock(): void { })); } +function writeConfigWithAutoUpdatePreference(enabled: boolean): void { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auto-update-pref-')); + process.env.CCS_HOME = tempHome; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const yaml = `version: 8 +preferences: + auto_update: ${enabled ? 'true' : 'false'} +`; + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf8'); +} + let execClaude: typeof import('../../../src/utils/shell-executor').execClaude; let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv; let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor; @@ -118,6 +135,9 @@ describe('CLAUDECODE environment stripping', () => { beforeEach(() => { spawnCalls.length = 0; process.env.CCS_QUIET = '1'; + originalCcsHome = process.env.CCS_HOME; + originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER; + delete process.env.DISABLE_AUTOUPDATER; baselineSigintListeners = process.listeners('SIGINT'); baselineSigtermListeners = process.listeners('SIGTERM'); baselineSighupListeners = process.listeners('SIGHUP'); @@ -128,6 +148,13 @@ describe('CLAUDECODE environment stripping', () => { delete process.env.CLAUDECODE; delete process.env.claudecode; delete process.env.CCS_QUIET; + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + if (originalDisableAutoUpdater !== undefined) { + process.env.DISABLE_AUTOUPDATER = originalDisableAutoUpdater; + } else { + delete process.env.DISABLE_AUTOUPDATER; + } for (const listener of process.listeners('SIGINT')) { if (!baselineSigintListeners.includes(listener)) { @@ -197,7 +224,26 @@ describe('CLAUDECODE environment stripping', () => { expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE'); }); + it('execClaude sets DISABLE_AUTOUPDATER=1 when preferences.auto_update is false', () => { + writeConfigWithAutoUpdatePreference(false); + execClaude('claude', ['--version'], { CCS_PROFILE_TYPE: 'default' }); + + expect(spawnCalls.length).toBeGreaterThan(0); + const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; + expect(env.DISABLE_AUTOUPDATER).toBe('1'); + }); + + it('execClaude does not force DISABLE_AUTOUPDATER when preferences.auto_update is true', () => { + writeConfigWithAutoUpdatePreference(true); + execClaude('claude', ['--version'], { CCS_PROFILE_TYPE: 'default' }); + + expect(spawnCalls.length).toBeGreaterThan(0); + const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; + expect(env.DISABLE_AUTOUPDATER).toBeUndefined(); + }); + it('headless executor spawn path strips CLAUDECODE before spawn', async () => { + writeConfigWithAutoUpdatePreference(false); process.env.CLAUDECODE = 'nested'; process.env.claudecode = 'nested-lower'; @@ -237,5 +283,6 @@ describe('CLAUDECODE environment stripping', () => { expect(spawnCalls.length).toBeGreaterThan(0); const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE'); + expect(env.DISABLE_AUTOUPDATER).toBe('1'); }); }); diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts index 9982c133..9314f32c 100644 --- a/tests/unit/web-server/cliproxy-auth-routes.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'bun:test'; -import { getStartUrlUnsupportedReason } from '../../../src/web-server/routes/cliproxy-auth-routes'; +import { + getStartAuthFailureMessage, + getStartUrlUnsupportedReason, +} from '../../../src/web-server/routes/cliproxy-auth-routes'; describe('cliproxy-auth-routes start-url guard', () => { it('rejects device code providers', () => { @@ -27,3 +30,16 @@ describe('cliproxy-auth-routes start-url guard', () => { expect(getStartUrlUnsupportedReason('claude')).toBeNull(); }); }); + +describe('cliproxy-auth-routes start failure messaging', () => { + it('returns ghcp-specific guidance for Copilot verification failures', () => { + expect(getStartAuthFailureMessage('ghcp')).toContain( + 'GitHub Copilot verification did not complete' + ); + }); + + it('keeps generic failure text for other providers', () => { + expect(getStartAuthFailureMessage('gemini')).toBe('Authentication failed or was cancelled'); + expect(getStartAuthFailureMessage('kiro')).toBe('Authentication failed or was cancelled'); + }); +}); diff --git a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts new file mode 100644 index 00000000..09029bd8 --- /dev/null +++ b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts @@ -0,0 +1,180 @@ +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 cliproxyStatsRoutes from '../../../src/web-server/routes/cliproxy-stats-routes'; + +function writeSettings(filePath: string, env: Record): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ env }, null, 2) + '\n'); +} + +describe('cliproxy-stats-routes model update canonicalization', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/cliproxy', cliproxyStatsRoutes); + + await new Promise((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((resolve) => server.close(() => resolve())); + }); + + 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; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('updates linked tiers while preserving intentionally distinct tier models', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'agy.settings.json'); + writeSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5', + }); + + const response = await fetch(`${baseUrl}/api/cliproxy/models/agy`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'claude-sonnet-4-6-thinking(8192)' }), + }); + expect(response.status).toBe(200); + + const body = (await response.json()) as { model: string }; + expect(body.model).toBe('claude-sonnet-4-6(8192)'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6(8192)'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6(8192)'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); + }); + + it('rejects denylisted AGY 4.5 model updates', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'agy.settings.json'); + writeSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5', + }); + + const response = await fetch(`${baseUrl}/api/cliproxy/models/agy`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'claude-opus-4.5' }), + }); + expect(response.status).toBe(400); + + const body = (await response.json()) as { error: string }; + expect(body.error).toContain('denylist'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); + }); + + it('canonicalizes Codex effort suffix and syncs linked core model env vars', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'codex.settings.json'); + writeSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gpt-5.3-codex', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini', + }); + + const response = await fetch(`${baseUrl}/api/cliproxy/models/codex`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-5.3-codex-xhigh' }), + }); + expect(response.status).toBe(200); + + const body = (await response.json()) as { model: string }; + expect(body.model).toBe('gpt-5.3-codex'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); + }); + + it('canonicalizes legacy iflow model IDs to supported upstream IDs', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'iflow.settings.json'); + writeSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/iflow', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'qwen3-coder-plus', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'qwen3-coder-plus', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'qwen3-coder-plus', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'qwen3-coder-plus', + }); + + const response = await fetch(`${baseUrl}/api/cliproxy/models/iflow`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'kimi-k2.5' }), + }); + expect(response.status).toBe(200); + + const body = (await response.json()) as { model: string }; + expect(body.model).toBe('kimi-k2'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('kimi-k2'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('kimi-k2'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('kimi-k2'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('kimi-k2'); + }); +}); diff --git a/tests/unit/web-server/cliproxy-usage-syncer.test.ts b/tests/unit/web-server/cliproxy-usage-syncer.test.ts new file mode 100644 index 00000000..1d3f165e --- /dev/null +++ b/tests/unit/web-server/cliproxy-usage-syncer.test.ts @@ -0,0 +1,97 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher'; + +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++; + return rawResponse; + }, +})); + +let syncer: typeof import('../../../src/web-server/usage/cliproxy-usage-syncer'); + +beforeAll(async () => { + syncer = await import('../../../src/web-server/usage/cliproxy-usage-syncer'); +}); + +beforeEach(() => { + ccsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-syncer-')); + fetchCalls = 0; + rawResponse = { + usage: { + apis: { + gemini: { + models: { + 'gemini-2.5-pro': { + details: [ + { + timestamp: '2026-03-02T12:00:00.000Z', + source: 'account-a', + auth_index: 0, + tokens: { + input_tokens: 100, + output_tokens: 20, + reasoning_tokens: 0, + cached_tokens: 10, + total_tokens: 130, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + }; + syncer.stopCliproxySync(); +}); + +afterEach(() => { + syncer.stopCliproxySync(); + fs.rmSync(ccsDir, { recursive: true, force: true }); +}); + +afterAll(() => { + mock.restore(); +}); + +describe('cliproxy usage syncer', () => { + it('writes and loads snapshot data', 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(); + expect(cached.daily).toHaveLength(1); + expect(cached.daily[0].source).toBe('cliproxy'); + expect(cached.daily[0].inputTokens).toBe(100); + expect(cached.hourly).toHaveLength(1); + expect(cached.monthly).toHaveLength(1); + }); + + it('startCliproxySync is idempotent and starts only one interval', () => { + const intervalSpy = spyOn(globalThis, 'setInterval'); + + syncer.startCliproxySync(); + syncer.startCliproxySync(); + + expect(intervalSpy).toHaveBeenCalledTimes(1); + expect(fetchCalls).toBeGreaterThan(0); + + syncer.stopCliproxySync(); + intervalSpy.mockRestore(); + }); +}); diff --git a/tests/unit/web-server/cliproxy-usage-transformer.test.ts b/tests/unit/web-server/cliproxy-usage-transformer.test.ts new file mode 100644 index 00000000..ac2e6eec --- /dev/null +++ b/tests/unit/web-server/cliproxy-usage-transformer.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'bun:test'; +import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher'; +import { + flattenCliproxyDetails, + transformCliproxyToDailyUsage, + transformCliproxyToHourlyUsage, + transformCliproxyToMonthlyUsage, +} from '../../../src/web-server/usage/cliproxy-usage-transformer'; + +const sampleResponse: CliproxyUsageApiResponse = { + usage: { + apis: { + gemini: { + models: { + 'gemini-2.5-pro': { + details: [ + { + timestamp: '2026-03-01T10:15:00.000Z', + source: 'account-a', + auth_index: 0, + tokens: { + input_tokens: 100, + output_tokens: 50, + reasoning_tokens: 0, + cached_tokens: 20, + total_tokens: 170, + }, + failed: false, + }, + { + timestamp: '2026-03-01T11:30:00.000Z', + source: 'account-a', + auth_index: 0, + tokens: { + input_tokens: 40, + output_tokens: 10, + reasoning_tokens: 0, + cached_tokens: 5, + total_tokens: 55, + }, + failed: true, + }, + { + timestamp: '2026-03-01T10:45:00.000Z', + source: 'account-a', + auth_index: 0, + tokens: { + input_tokens: 30, + output_tokens: 20, + reasoning_tokens: 0, + cached_tokens: 10, + total_tokens: 60, + }, + failed: false, + }, + ], + }, + }, + }, + codex: { + models: { + 'gpt-4.1': { + details: [ + { + timestamp: '2026-03-02T01:00:00.000Z', + source: 'account-b', + auth_index: 1, + tokens: { + input_tokens: 70, + output_tokens: 30, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 100, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, +}; + +describe('cliproxy usage transformer', () => { + it('flattens nested API details and skips failed requests', () => { + const flat = flattenCliproxyDetails(sampleResponse); + expect(flat).toHaveLength(3); + expect(flat.every((entry) => entry.detail.failed === false)).toBe(true); + }); + + it('transforms daily usage with aggregated model totals', () => { + const daily = transformCliproxyToDailyUsage(sampleResponse); + + expect(daily).toHaveLength(2); + expect(daily[0].date).toBe('2026-03-02'); + expect(daily[0].source).toBe('cliproxy'); + expect(daily[1].date).toBe('2026-03-01'); + + const marchFirst = daily.find((d) => d.date === '2026-03-01'); + expect(marchFirst?.inputTokens).toBe(130); + expect(marchFirst?.outputTokens).toBe(70); + expect(marchFirst?.cacheReadTokens).toBe(30); + expect(marchFirst?.modelsUsed).toContain('gemini-2.5-pro'); + }); + + it('transforms hourly usage with hour buckets', () => { + const hourly = transformCliproxyToHourlyUsage(sampleResponse); + + expect(hourly).toHaveLength(2); + expect(hourly[0].hour).toBe('2026-03-02 01:00'); + + const tenAm = hourly.find((h) => h.hour === '2026-03-01 10:00'); + expect(tenAm?.inputTokens).toBe(130); + expect(tenAm?.outputTokens).toBe(70); + }); + + it('transforms monthly usage with cliproxy source', () => { + const monthly = transformCliproxyToMonthlyUsage(sampleResponse); + + expect(monthly).toHaveLength(1); + expect(monthly[0].month).toBe('2026-03'); + expect(monthly[0].source).toBe('cliproxy'); + expect(monthly[0].inputTokens).toBe(200); + expect(monthly[0].outputTokens).toBe(100); + expect(monthly[0].cacheReadTokens).toBe(30); + }); +}); diff --git a/tests/unit/web-server/route-helpers-model-denylist.test.ts b/tests/unit/web-server/route-helpers-model-denylist.test.ts new file mode 100644 index 00000000..f9df3665 --- /dev/null +++ b/tests/unit/web-server/route-helpers-model-denylist.test.ts @@ -0,0 +1,214 @@ +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 { + createSettingsFile, + updateSettingsFile, +} from '../../../src/web-server/routes/route-helpers'; + +describe('route-helpers AGY denylist', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-route-helpers-')); + 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('rejects denylisted AGY models on settings create', () => { + expect(() => + createSettingsFile('agy-denied', 'http://127.0.0.1:8317/api/provider/agy', 'test-token', { + model: 'claude-sonnet-4.5', + opusModel: 'claude-opus-4.5', + sonnetModel: 'claude-sonnet-4.5', + haikuModel: 'claude-haiku-4.5', + }) + ).toThrow(/denylist/i); + }); + + it('rejects denylisted AGY models on settings update', () => { + const settingsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(settingsDir, { recursive: true }); + const settingsPath = path.join(settingsDir, 'agy-profile.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'test-token', + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5', + }, + }, + null, + 2 + ) + '\n' + ); + + expect(() => updateSettingsFile('agy-profile', { model: 'claude-opus-4.5' })).toThrow( + /denylist/i + ); + }); + + it('keeps AGY denylist enforcement when CCS_DROID_PROVIDER is present', () => { + const settingsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(settingsDir, { recursive: true }); + const settingsPath = path.join(settingsDir, 'agy-profile.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'test-token', + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5', + CCS_DROID_PROVIDER: 'anthropic', + }, + }, + null, + 2 + ) + '\n' + ); + + expect(() => updateSettingsFile('agy-profile', { model: 'claude-sonnet-4.5' })).toThrow( + /denylist/i + ); + }); + + it('canonicalizes legacy iflow model IDs on settings create', () => { + createSettingsFile('iflow-profile', 'http://127.0.0.1:8317/api/provider/iflow', 'test-token', { + model: 'kimi-k2.5', + opusModel: 'iflow-default', + sonnetModel: 'deepseek-v3.2-chat', + haikuModel: 'glm-4.7', + }); + + const settingsPath = path.join(tempHome, '.ccs', 'iflow-profile.settings.json'); + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + + expect(persisted.env.ANTHROPIC_MODEL).toBe('kimi-k2'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('glm-4.6'); + }); + + it('canonicalizes legacy iflow model IDs on settings create with root URL + provider hint', () => { + createSettingsFile( + 'iflow-profile-root', + 'http://127.0.0.1:8317', + 'test-token', + { + model: 'kimi-k2.5', + opusModel: 'iflow-default', + sonnetModel: 'deepseek-v3.2-chat', + haikuModel: 'glm-4.7', + }, + 'iflow' + ); + + const settingsPath = path.join(tempHome, '.ccs', 'iflow-profile-root.settings.json'); + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + + expect(persisted.env.ANTHROPIC_MODEL).toBe('kimi-k2'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('glm-4.6'); + }); + + it('canonicalizes legacy iflow model IDs on settings update', () => { + const settingsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(settingsDir, { recursive: true }); + const settingsPath = path.join(settingsDir, 'iflow-profile.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/iflow', + ANTHROPIC_AUTH_TOKEN: 'test-token', + ANTHROPIC_MODEL: 'qwen3-coder-plus', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'qwen3-coder-plus', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'qwen3-coder-plus', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'qwen3-coder-plus', + }, + }, + null, + 2 + ) + '\n' + ); + + updateSettingsFile('iflow-profile', { + model: 'kimi-k2.5', + sonnetModel: 'deepseek-v3.2-chat', + haikuModel: 'minimax-m2.5', + }); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('kimi-k2'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('qwen3-coder-plus'); + }); + + it('canonicalizes legacy iflow model IDs on settings update with root URL + provider hint', () => { + const settingsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(settingsDir, { recursive: true }); + const settingsPath = path.join(settingsDir, 'iflow-root.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', + ANTHROPIC_AUTH_TOKEN: 'test-token', + ANTHROPIC_MODEL: 'qwen3-coder-plus', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'qwen3-coder-plus', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'qwen3-coder-plus', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'qwen3-coder-plus', + }, + }, + null, + 2 + ) + '\n' + ); + + updateSettingsFile('iflow-root', { + model: 'kimi-k2.5', + sonnetModel: 'deepseek-v3.2-chat', + haikuModel: 'minimax-m2.5', + provider: 'iflow', + }); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('kimi-k2'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('qwen3-coder-plus'); + }); +}); diff --git a/tests/unit/web-server/settings-routes-model-canonicalization.test.ts b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts new file mode 100644 index 00000000..bf70d518 --- /dev/null +++ b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts @@ -0,0 +1,482 @@ +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 settingsRoutes from '../../../src/web-server/routes/settings-routes'; + +function writeSettings(filePath: string, value: Record): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n'); +} + +describe('settings-routes model canonicalization', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/settings', settingsRoutes); + + await new Promise((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((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-settings-routes-')); + 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('canonicalizes AGY env and preset model IDs on PUT /:profile', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + writeSettings(settingsPath, { env: {} }); + + const response = await fetch(`${baseUrl}/api/settings/agy`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4-6-thinking(8192)', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5', + }, + presets: [ + { + name: 'legacy', + default: 'claude-sonnet-4-6-thinking', + opus: 'claude-opus-4.6-thinking', + sonnet: 'claude-sonnet-4.6-thinking', + haiku: 'claude-haiku-4.5', + }, + ], + }, + }), + }); + expect(response.status).toBe(200); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + presets: Array>; + }; + + expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6(8192)'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); + + expect(persisted.presets[0]?.default).toBe('claude-sonnet-4-6'); + expect(persisted.presets[0]?.opus).toBe('claude-opus-4-6-thinking'); + expect(persisted.presets[0]?.sonnet).toBe('claude-sonnet-4-6'); + expect(persisted.presets[0]?.haiku).toBe('claude-haiku-4-5'); + }); + + it('canonicalizes legacy iflow model IDs on PUT /:profile', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'iflow.settings.json'); + writeSettings(settingsPath, { env: {} }); + + const response = await fetch(`${baseUrl}/api/settings/iflow`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/iflow', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'kimi-k2.5', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'iflow-default', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'deepseek-v3.2-chat', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.7', + }, + presets: [ + { + name: 'legacy-iflow', + default: 'kimi-k2.5', + opus: 'iflow-default', + sonnet: 'deepseek-v3.2-chat', + haiku: 'minimax-m2.5', + }, + ], + }, + }), + }); + expect(response.status).toBe(200); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + presets: Array>; + }; + + expect(persisted.env.ANTHROPIC_MODEL).toBe('kimi-k2'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('glm-4.6'); + expect(persisted.presets[0]?.default).toBe('kimi-k2'); + expect(persisted.presets[0]?.opus).toBe('qwen3-coder-plus'); + expect(persisted.presets[0]?.sonnet).toBe('deepseek-v3.2'); + expect(persisted.presets[0]?.haiku).toBe('qwen3-coder-plus'); + }); + + it('canonicalizes AGY preset values on POST /:profile/presets', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + writeSettings(settingsPath, { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + }, + presets: [ + { + name: 'legacy-existing', + default: 'claude-sonnet-4-6-thinking', + opus: 'claude-opus-4.6-thinking', + sonnet: 'claude-sonnet-4.6-thinking', + haiku: 'claude-haiku-4.5', + }, + ], + }); + + const response = await fetch(`${baseUrl}/api/settings/agy/presets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'new-legacy', + default: 'claude-sonnet-4-6-thinking', + opus: 'claude-opus-4.6-thinking', + sonnet: 'claude-sonnet-4.6-thinking', + haiku: 'claude-haiku-4.5', + }), + }); + expect(response.status).toBe(201); + + const body = (await response.json()) as { preset: Record }; + expect(body.preset.default).toBe('claude-sonnet-4-6'); + expect(body.preset.opus).toBe('claude-opus-4-6-thinking'); + expect(body.preset.sonnet).toBe('claude-sonnet-4-6'); + expect(body.preset.haiku).toBe('claude-haiku-4-5'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + presets: Array>; + }; + expect(persisted.presets[0]?.default).toBe('claude-sonnet-4-6'); + expect(persisted.presets[0]?.opus).toBe('claude-opus-4-6-thinking'); + expect(persisted.presets[0]?.sonnet).toBe('claude-sonnet-4-6'); + expect(persisted.presets[0]?.haiku).toBe('claude-haiku-4-5'); + expect(persisted.presets[1]?.default).toBe('claude-sonnet-4-6'); + expect(persisted.presets[1]?.opus).toBe('claude-opus-4-6-thinking'); + expect(persisted.presets[1]?.sonnet).toBe('claude-sonnet-4-6'); + expect(persisted.presets[1]?.haiku).toBe('claude-haiku-4-5'); + }); + + it('canonicalizes and persists AGY preset aliases on GET /:profile/presets', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + writeSettings(settingsPath, { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking', + }, + presets: [ + { + name: 'legacy', + default: 'claude-sonnet-4-6-thinking', + opus: 'claude-opus-4.6-thinking', + sonnet: 'claude-sonnet-4.6-thinking', + haiku: 'claude-haiku-4.5', + }, + ], + }); + + const response = await fetch(`${baseUrl}/api/settings/agy/presets`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { presets: Array> }; + expect(body.presets[0]?.default).toBe('claude-sonnet-4-6'); + expect(body.presets[0]?.opus).toBe('claude-opus-4-6-thinking'); + expect(body.presets[0]?.sonnet).toBe('claude-sonnet-4-6'); + expect(body.presets[0]?.haiku).toBe('claude-haiku-4-5'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + presets: Array>; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); + expect(persisted.presets[0]?.default).toBe('claude-sonnet-4-6'); + expect(persisted.presets[0]?.opus).toBe('claude-opus-4-6-thinking'); + expect(persisted.presets[0]?.sonnet).toBe('claude-sonnet-4-6'); + expect(persisted.presets[0]?.haiku).toBe('claude-haiku-4-5'); + }); + + it('canonicalizes existing settings on DELETE /:profile/presets/:name writes', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + writeSettings(settingsPath, { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking', + }, + presets: [ + { + name: 'remove-me', + default: 'claude-sonnet-4-6-thinking', + opus: 'claude-opus-4.6-thinking', + sonnet: 'claude-sonnet-4.6-thinking', + haiku: 'claude-haiku-4.5', + }, + ], + }); + + const response = await fetch(`${baseUrl}/api/settings/agy/presets/remove-me`, { + method: 'DELETE', + }); + expect(response.status).toBe(200); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + presets: Array>; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); + expect(persisted.presets.length).toBe(0); + }); + + it('does not mutate settings on duplicate preset POST failure', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + writeSettings(settingsPath, { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking', + }, + presets: [ + { + name: 'existing', + default: 'claude-sonnet-4-6-thinking', + opus: 'claude-opus-4.6-thinking', + sonnet: 'claude-sonnet-4.6-thinking', + haiku: 'claude-haiku-4.5', + }, + ], + }); + const before = fs.readFileSync(settingsPath, 'utf8'); + + const response = await fetch(`${baseUrl}/api/settings/agy/presets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'existing', + default: 'claude-sonnet-4-6-thinking', + }), + }); + expect(response.status).toBe(409); + + const after = fs.readFileSync(settingsPath, 'utf8'); + expect(after).toBe(before); + }); + + it('does not mutate settings on missing preset DELETE failure', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + writeSettings(settingsPath, { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking', + }, + presets: [ + { + name: 'keep-me', + default: 'claude-sonnet-4-6-thinking', + opus: 'claude-opus-4.6-thinking', + sonnet: 'claude-sonnet-4.6-thinking', + haiku: 'claude-haiku-4.5', + }, + ], + }); + const before = fs.readFileSync(settingsPath, 'utf8'); + + const response = await fetch(`${baseUrl}/api/settings/agy/presets/does-not-exist`, { + method: 'DELETE', + }); + expect(response.status).toBe(404); + + const after = fs.readFileSync(settingsPath, 'utf8'); + expect(after).toBe(before); + }); + + it('canonicalizes AGY aliases via provider alias profile names', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'antigravity.settings.json'); + writeSettings(settingsPath, { env: {} }); + + const response = await fetch(`${baseUrl}/api/settings/antigravity`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5', + }, + presets: [], + }, + }), + }); + expect(response.status).toBe(200); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); + }); + + it('rejects denylisted AGY models on PUT /:profile', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + writeSettings(settingsPath, { env: {} }); + + const response = await fetch(`${baseUrl}/api/settings/agy`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4.5', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5', + }, + presets: [], + }, + }), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error?: string }; + expect(body.error).toContain('denylist'); + }); + + it('rejects denylisted AGY models on POST /:profile/presets', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + writeSettings(settingsPath, { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + }, + presets: [], + }); + + const response = await fetch(`${baseUrl}/api/settings/agy/presets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'legacy', + default: 'claude-opus-4.5-thinking', + }), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error?: string }; + expect(body.error).toContain('denylist'); + }); + + it('drops denylisted AGY presets on read canonicalization', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + writeSettings(settingsPath, { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + }, + presets: [ + { + name: 'denylisted', + default: 'claude-sonnet-4.5', + opus: 'claude-opus-4-6-thinking', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4.5', + }, + ], + }); + + const response = await fetch(`${baseUrl}/api/settings/agy/presets`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { presets: Array> }; + expect(body.presets.length).toBe(0); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + presets: Array>; + }; + expect(persisted.presets.length).toBe(0); + }); + + it('does not crash on malformed non-string preset model values', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + writeSettings(settingsPath, { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + }, + presets: [ + { + name: 'malformed', + default: 123, + opus: null, + sonnet: 'claude-sonnet-4.6-thinking', + haiku: 'claude-haiku-4.5', + }, + ], + }); + + const response = await fetch(`${baseUrl}/api/settings/agy/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + settings: { presets: Array> }; + }; + expect(body.settings.presets[0]?.default).toBe(123); + expect(body.settings.presets[0]?.sonnet).toBe('claude-sonnet-4-6'); + }); +}); diff --git a/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts b/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts new file mode 100644 index 00000000..3b5cd6cf --- /dev/null +++ b/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +let tempRoot = ''; +let ccsDir = ''; +let claudeDir = ''; +let aggregator: typeof import('../../../src/web-server/usage/aggregator'); +let originalCcsDir: string | undefined; +let originalClaudeConfigDir: string | undefined; + +function writeClaudeJsonlFixture(): void { + const projectDir = path.join(claudeDir, 'projects', 'project-one'); + fs.mkdirSync(projectDir, { recursive: true }); + + const line = JSON.stringify({ + type: 'assistant', + sessionId: 'session-a', + timestamp: '2026-03-02T10:00:00.000Z', + version: '1.0.0', + cwd: '/tmp/project', + message: { + model: 'claude-sonnet-4-5', + usage: { + input_tokens: 100, + output_tokens: 40, + }, + }, + }); + + fs.writeFileSync(path.join(projectDir, 'usage.jsonl'), `${line}\n`, 'utf-8'); +} + +function writeCliproxySnapshotFixture(): void { + const snapshotDir = path.join(ccsDir, 'cache', 'cliproxy-usage'); + fs.mkdirSync(snapshotDir, { recursive: true }); + + const snapshot = { + version: 1, + timestamp: Date.now(), + daily: [ + { + date: '2026-03-02', + source: 'cliproxy', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + }, + ], + }, + ], + hourly: [ + { + hour: '2026-03-02 10:00', + source: 'cliproxy', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + }, + ], + }, + ], + monthly: [ + { + month: '2026-03', + source: 'cliproxy', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + }, + ], + }, + ], + }; + + fs.writeFileSync(path.join(snapshotDir, 'latest.json'), JSON.stringify(snapshot), 'utf-8'); +} + +function writeUnifiedConfigFixture(): void { + const yaml = `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: {} +cliproxy_server: + local: + port: 65534 +`; + + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf-8'); +} + +beforeEach(async () => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-usage-agg-')); + ccsDir = path.join(tempRoot, '.ccs'); + claudeDir = path.join(tempRoot, '.claude'); + + originalCcsDir = process.env.CCS_DIR; + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CCS_DIR = ccsDir; + process.env.CLAUDE_CONFIG_DIR = claudeDir; + + writeUnifiedConfigFixture(); + writeClaudeJsonlFixture(); + writeCliproxySnapshotFixture(); + + aggregator = await import('../../../src/web-server/usage/aggregator'); + aggregator.clearUsageCache(); +}); + +afterEach(() => { + aggregator.shutdownUsageAggregator(); + aggregator.clearUsageCache(); + + if (originalCcsDir !== undefined) { + process.env.CCS_DIR = originalCcsDir; + } else { + delete process.env.CCS_DIR; + } + + if (originalClaudeConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } else { + delete process.env.CLAUDE_CONFIG_DIR; + } + + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe('usage aggregator cliproxy integration', () => { + it('merges cliproxy snapshot data into getCachedDailyData', async () => { + const daily = await aggregator.getCachedDailyData(); + + expect(daily).toHaveLength(1); + expect(daily[0].date).toBe('2026-03-02'); + expect(daily[0].inputTokens).toBe(150); + expect(daily[0].outputTokens).toBe(50); + expect(daily[0].cacheReadTokens).toBe(5); + expect(daily[0].modelsUsed).toContain('claude-sonnet-4-5'); + expect(daily[0].modelsUsed).toContain('gemini-2.5-pro'); + }); + + it('clearUsageCache resets last fetch timestamp', async () => { + await aggregator.getCachedDailyData(); + expect(aggregator.getLastFetchTimestamp()).not.toBeNull(); + + aggregator.clearUsageCache(); + expect(aggregator.getLastFetchTimestamp()).toBeNull(); + }); +}); diff --git a/ui/README.md b/ui/README.md index d2e77611..cc1d3800 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1,73 +1,63 @@ -# React + TypeScript + Vite +# CCS Dashboard UI -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +React + TypeScript + Vite frontend for the CCS local dashboard. -Currently, two official plugins are available: +This UI is served by the CCS web server and is accessed via: -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```bash +ccs config ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +--- -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' +## Development -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +From project root: + +```bash +bun run dev ``` + +This starts the CCS server and serves the dashboard at `http://localhost:3000`. + +From `ui/` only (frontend dev server): + +```bash +cd ui +bun run dev +``` + +--- + +## Quality Commands + +```bash +cd ui +bun run typecheck +bun run lint +bun run validate +bun run test:run +``` + +--- + +## i18n + +Dashboard localization uses `react-i18next`. + +- Main setup: `ui/src/lib/i18n.ts` +- Locale helpers: `ui/src/lib/locales.ts` +- Language switcher: `ui/src/components/layout/language-switcher.tsx` + +For full architecture, conventions, and locale onboarding, see: + +- [`../docs/i18n-dashboard.md`](../docs/i18n-dashboard.md) + +--- + +## Notes + +- UI locale persistence uses browser localStorage key `ccs-ui-locale`. +- Current supported locales are managed in `ui/src/lib/locales.ts`. +- Current locales: `en`, `zh-CN`, `vi`. +- Fallback locale is English (`en`). diff --git a/ui/bun.lock b/ui/bun.lock index c2e29de1..6a181f1a 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -28,12 +28,14 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "i18next": "^25.8.13", "lucide-react": "^0.556.0", "prism-react-renderer": "^2.4.1", "react": "^19.2.0", "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", + "react-i18next": "^16.5.4", "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.10.1", "react-simple-code-editor": "^0.14.1", @@ -761,10 +763,14 @@ "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "i18next": ["i18next@25.8.13", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA=="], + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -931,6 +937,8 @@ "react-hook-form": ["react-hook-form@7.68.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q=="], + "react-i18next": ["react-i18next@16.5.4", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g=="], + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], @@ -1069,12 +1077,16 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], "vite": ["vite@7.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ=="], "vitest": ["vitest@4.0.16", "", { "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", "@vitest/pretty-format": "4.0.16", "@vitest/runner": "4.0.16", "@vitest/snapshot": "4.0.16", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.16", "@vitest/browser-preview": "4.0.16", "@vitest/browser-webdriverio": "4.0.16", "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q=="], + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "webidl-conversions": ["webidl-conversions@8.0.0", "", {}, "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA=="], diff --git a/ui/package.json b/ui/package.json index 23c50f38..0dc9b46e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -42,12 +42,14 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "i18next": "^25.8.13", "lucide-react": "^0.556.0", "prism-react-renderer": "^2.4.1", "react": "^19.2.0", "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", + "react-i18next": "^16.5.4", "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.10.1", "react-simple-code-editor": "^0.14.1", diff --git a/ui/public/assets/providers/alibabacloud-color.svg b/ui/public/assets/providers/alibabacloud-color.svg new file mode 100644 index 00000000..56c20781 --- /dev/null +++ b/ui/public/assets/providers/alibabacloud-color.svg @@ -0,0 +1 @@ +AlibabaCloud \ No newline at end of file diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 62ecb304..7b70e49b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; import { Toaster } from 'sonner'; import { queryClient } from '@/lib/query-client'; +import '@/lib/i18n'; import { ThemeProvider } from '@/components/layout/theme-provider'; import { PrivacyProvider } from '@/contexts/privacy-context'; import { AuthProvider } from '@/contexts/auth-context'; diff --git a/ui/src/components/account/account-safety-warning-card.tsx b/ui/src/components/account/account-safety-warning-card.tsx index ad646ad1..97fe4fa7 100644 --- a/ui/src/components/account/account-safety-warning-card.tsx +++ b/ui/src/components/account/account-safety-warning-card.tsx @@ -3,6 +3,7 @@ import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants'; interface AccountSafetyWarningCardProps { @@ -24,21 +25,14 @@ export function AccountSafetyWarningCard({ disabled = false, showProxySettingsLink = false, }: AccountSafetyWarningCardProps) { - const title = 'OAuth Account Safety Warning'; - const subtitle = 'Issue #509 · Gemini + AGY OAuth risk'; - const firstLine = ( - <> - Issue #509 documents suspension/ban reports tied to ccs agy{' '} - and shared-account usage between ccs gemini and{' '} - ccs agy. - - ); - const secondLine = ( - <>Continue only if you accept full responsibility for OAuth and account-access risk. - ); + const { t } = useTranslation(); + const title = t('accountSafetyWarning.title'); + const subtitle = t('accountSafetyWarning.subtitle'); + const firstLine = t('accountSafetyWarning.firstLine'); + const secondLine = t('accountSafetyWarning.secondLine'); const issueUrl = 'https://github.com/kaitranntt/ccs/issues/509'; - const issueLabel = 'Read issue #509'; - const proxySettingsLabel = 'Gemini + AGY controls: Settings > Proxy'; + const issueLabel = t('accountSafetyWarning.issueLabel'); + const proxySettingsLabel = t('accountSafetyWarning.proxySettingsLabel'); return (
[] = [ { accessorKey: 'name', - header: 'Name', + header: t('accountsTable.name'), size: 200, cell: ({ row }) => (
{row.original.name} {row.original.name === defaultAccount && ( - default + {t('accountsTable.defaultBadge')} )}
@@ -65,7 +67,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { }, { accessorKey: 'type', - header: 'Type', + header: t('accountsTable.type'), size: 100, cell: ({ row }) => ( {row.original.type || 'oauth'} @@ -73,7 +75,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { }, { accessorKey: 'created', - header: 'Created', + header: t('accountsTable.created'), size: 150, cell: ({ row }) => { const date = new Date(row.original.created); @@ -82,7 +84,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { }, { accessorKey: 'last_used', - header: 'Last Used', + header: t('accountsTable.lastUsed'), size: 150, cell: ({ row }) => { if (!row.original.last_used) return -; @@ -92,7 +94,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { }, { id: 'context', - header: 'History Sync', + header: t('accountsTable.historySync'), size: 170, cell: ({ row }) => { if (row.original.type === 'cliproxy') { @@ -103,32 +105,42 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { if (mode === 'shared') { const group = row.original.context_group || 'default'; if (row.original.continuity_mode === 'deeper') { - return shared ({group}, deeper); + return ( + + {t('accountsTable.sharedGroupDeeper', { group })} + + ); } if (row.original.continuity_inferred) { return ( - shared ({group}, standard legacy) + {t('accountsTable.sharedGroupLegacy', { group })} ); } - return shared ({group}, standard); + return ( + + {t('accountsTable.sharedGroupStandard', { group })} + + ); } if (row.original.context_inferred) { return ( - isolated (legacy default) + + {t('accountsTable.isolatedLegacy')} + ); } - return isolated; + return {t('accountsTable.isolated')}; }, }, { id: 'actions', - header: 'Actions', + header: t('accountsTable.actions'), size: 220, cell: ({ row }) => { const isDefault = row.original.name === defaultAccount; @@ -149,10 +161,10 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { className="h-8 px-2" disabled={isPending} onClick={() => setContextTarget(row.original)} - title="Edit sync mode, group, and continuity depth" + title={t('accountsTable.syncTitle')} > - Sync + {t('accountsTable.sync')} )} {!isCliproxy && hasLegacyInference && ( @@ -177,10 +189,10 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { : undefined, }) } - title="Confirm this legacy account's current mode as explicit" + title={t('accountsTable.confirmLegacyTitle')} > - Confirm + {t('accountsTable.confirm')} )} @@ -218,10 +234,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { if (data.length === 0) { return ( -
- No CCS auth accounts found. Use{' '} - ccs auth create to add accounts. -
+
{t('accountsTable.noAccounts')}
); } @@ -279,7 +292,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { disabled={resetDefaultMutation.isPending} > - Reset to CCS Default + {t('accountsTable.resetToDefault')} )} @@ -293,14 +306,13 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { !open && setDeleteTarget(null)}> - Delete Account + {t('accountsTable.deleteDialogTitle')} - Are you sure you want to delete the account "{deleteTarget}"? This will - remove the profile and all its session data. This action cannot be undone. + {t('accountsTable.deleteDialogDesc', { name: deleteTarget ?? '' })} - Cancel + {t('accountsTable.cancel')} { @@ -310,7 +322,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { } }} > - Delete + {t('accountsTable.delete')} diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 094ce6ff..bdb2e90a 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -45,6 +45,7 @@ import { KIRO_AUTH_METHOD_OPTIONS, } from '@/lib/provider-config'; import type { KiroAuthMethod } from '@/lib/provider-config'; +import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; interface AddAccountDialogProps { @@ -76,6 +77,7 @@ export function AddAccountDialog({ const [agyAckBypassEnabled, setAgyAckBypassEnabled] = useState(false); const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(false); const [kiroAuthMethod, setKiroAuthMethod] = useState(DEFAULT_KIRO_AUTH_METHOD); + const { t } = useTranslation(); const wasAuthenticatingRef = useRef(false); const authFlow = useCliproxyAuthFlow(); const kiroImportMutation = useKiroImport(); @@ -318,13 +320,13 @@ export function AddAccountDialog({ }} > - Add {displayName} Account + {t('addAccountDialog.title', { displayName })} {isKiro - ? 'Choose a Kiro auth method, then authenticate via browser or import from Kiro IDE.' + ? t('addAccountDialog.descKiro') : isDeviceCode - ? 'Click Authenticate. A verification code will appear for you to enter on the provider website.' - : 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.'} + ? t('addAccountDialog.descDeviceCode') + : t('addAccountDialog.descOauth')} @@ -344,10 +346,9 @@ export function AddAccountDialog({
- Power user mode enabled + {t('addAccountDialog.powerUserEnabled')}
- AGY responsibility checklist is skipped from Settings {'>'} Proxy. You accept full - responsibility for OAuth/account risk. + {t('addAccountDialog.powerUserSkipped')}
)} @@ -367,7 +368,7 @@ export function AddAccountDialog({ {/* Kiro auth method */} {isKiro && !showAuthUI && (
- + setCallbackUrl(e.target.value)} - placeholder="Paste the redirect URL here..." + placeholder={t('addAccountDialog.callbackPlaceholder')} className="font-mono text-xs" />
@@ -511,7 +511,7 @@ export function AddAccountDialog({ {!authFlow.authUrl && !authFlow.isDeviceCodeFlow && (

- Preparing sign-in URL... + {t('addAccountDialog.preparingUrl')}

)} @@ -524,26 +524,26 @@ export function AddAccountDialog({ {kiroImportMutation.isPending && (

- Importing token from Kiro IDE... + {t('addAccountDialog.importingToken')}

)} {/* Action buttons */}
{isKiro && !showAuthUI && ( @@ -560,7 +560,7 @@ export function AddAccountDialog({ } > - Authenticate + {t('addAccountDialog.authenticate')} )}
diff --git a/ui/src/components/account/antigravity-responsibility-checklist.tsx b/ui/src/components/account/antigravity-responsibility-checklist.tsx index 716fc130..8efd6eda 100644 --- a/ui/src/components/account/antigravity-responsibility-checklist.tsx +++ b/ui/src/components/account/antigravity-responsibility-checklist.tsx @@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Progress } from '@/components/ui/progress'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; import { ANTIGRAVITY_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants'; import type { AntigravityRiskChecklistValue } from '@/components/account/antigravity-responsibility-constants'; @@ -21,6 +22,7 @@ export function AntigravityResponsibilityChecklist({ onChange, disabled = false, }: AntigravityResponsibilityChecklistProps) { + const { t } = useTranslation(); const completedSteps = [ value.reviewedIssue509, value.understandsBanRisk, @@ -50,21 +52,19 @@ export function AntigravityResponsibilityChecklist({
-

Antigravity OAuth Responsibility

-

- Complete all 4 steps before you can authenticate. -

+

{t('antigravityChecklist.title')}

+

{t('antigravityChecklist.subtitle')}

- Mandatory + {t('antigravityChecklist.mandatory')}
- Completion - {completedSteps}/4 steps + {t('antigravityChecklist.completion')} + {t('antigravityChecklist.stepsCount', { current: completedSteps })}
diff --git a/ui/src/components/account/create-auth-profile-dialog.tsx b/ui/src/components/account/create-auth-profile-dialog.tsx index 1c6cd3ec..66517d20 100644 --- a/ui/src/components/account/create-auth-profile-dialog.tsx +++ b/ui/src/components/account/create-auth-profile-dialog.tsx @@ -16,6 +16,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Copy, Check, Terminal } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; interface CreateAuthProfileDialogProps { open: boolean; @@ -25,6 +26,7 @@ interface CreateAuthProfileDialogProps { const MAX_CONTEXT_GROUP_LENGTH = 64; export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) { + const { t } = useTranslation(); const [profileName, setProfileName] = useState(''); const [shareContext, setShareContext] = useState(false); const [contextGroup, setContextGroup] = useState(''); @@ -52,7 +54,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial ] .filter(Boolean) .join(' ') - : 'ccs auth create '; + : t('createAuthProfileDialog.commandFallback'); const handleCopy = async () => { if (!isValidName || (shareContext && !isValidContextGroup)) return; @@ -74,27 +76,23 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial !isOpen && handleClose()}> - Create New Account - - Auth profiles require Claude CLI login. Run the command below in your terminal. You can - edit sync mode, group, and continuity depth later from the Accounts table. - + {t('createAuthProfileDialog.title')} + {t('createAuthProfileDialog.description')}
- + setProfileName(e.target.value)} - placeholder="e.g., work, personal, client" + placeholder={t('createAuthProfileDialog.profileNamePlaceholder')} autoComplete="off" /> {profileName && !isValidName && (

- Name must start with a letter and contain only letters, numbers, dashes, or - underscores. + {t('createAuthProfileDialog.invalidProfileName')}

)}
@@ -107,22 +105,24 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial onCheckedChange={(checked) => setShareContext(checked === true)} />
{shareContext && (
- + setContextGroup(e.target.value)} - placeholder="default, sprint-a, client-x" + placeholder={t('createAuthProfileDialog.historySyncGroupPlaceholder')} autoComplete="off" />

- Leave empty to use the default shared group. Spaces are normalized to dashes. + {t('createAuthProfileDialog.historySyncGroupHint')}

setDeeperContinuity(checked === true)} />

- Adds sync for session-env, file-history,{' '} - shell-snapshots, and todos. Credentials stay isolated. + {t('createAuthProfileDialog.deeperContinuityHint')}

{contextGroup.trim().length > 0 && !isValidContextGroup && (

- Group must start with a letter and use only letters, numbers, dashes, or - underscores (max {MAX_CONTEXT_GROUP_LENGTH} chars). + {t('createAuthProfileDialog.invalidContextGroup', { + max: MAX_CONTEXT_GROUP_LENGTH, + })}

)}
@@ -149,7 +149,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
- +
{command} @@ -170,20 +170,17 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
-

After running the command:

+

{t('createAuthProfileDialog.afterRunPrefix')}

    -
  1. Complete the Claude login in your browser
  2. -
  3. Return here and refresh to see the new account
  4. +
  5. {t('createAuthProfileDialog.afterRunStep1')}
  6. +
  7. {t('createAuthProfileDialog.afterRunStep2')}
-

- Prefer pooled Claude OAuth routing instead? Use CLIProxy Claude pool from the Accounts - page action button. -

+

{t('createAuthProfileDialog.poolingHint')}

diff --git a/ui/src/components/account/edit-account-context-dialog.tsx b/ui/src/components/account/edit-account-context-dialog.tsx index 4edf98ad..650c76e0 100644 --- a/ui/src/components/account/edit-account-context-dialog.tsx +++ b/ui/src/components/account/edit-account-context-dialog.tsx @@ -17,6 +17,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { useTranslation } from 'react-i18next'; import type { Account } from '@/lib/api-client'; import { useUpdateAccountContext } from '@/hooks/use-accounts'; @@ -32,6 +33,7 @@ interface EditAccountContextDialogProps { } export function EditAccountContextDialog({ account, onClose }: EditAccountContextDialogProps) { + const { t } = useTranslation(); const updateContextMutation = useUpdateAccountContext(); const [mode, setMode] = useState( account.context_mode === 'shared' ? 'shared' : 'isolated' @@ -78,89 +80,86 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex - Edit History Sync + {t('editAccountContext.title')} - Configure how "{account.name}" shares history and continuity with other - ccs auth - accounts. + {t('editAccountContext.description', { name: account.name })}
- +

{mode === 'shared' - ? 'Shared mode reuses workspace context for accounts in the same history sync group.' - : 'Isolated mode keeps this account fully separate from other ccs auth accounts.'} + ? t('editAccountContext.sharedModeHint') + : t('editAccountContext.isolatedModeHint')}

{mode === 'shared' && (
- + setGroup(event.target.value)} - placeholder="default" + placeholder={t('editAccountContext.groupPlaceholder')} autoComplete="off" />

- Normalized to lowercase (spaces become dashes). Allowed: letters, numbers, `_`, `-` - (max {MAX_CONTEXT_GROUP_LENGTH} chars). + {t('editAccountContext.groupHint', { max: MAX_CONTEXT_GROUP_LENGTH })}

{!isSharedGroupValid && ( -

- Enter a valid group name that starts with a letter. -

+

{t('editAccountContext.invalidGroup')}

)}
)} {mode === 'shared' && (
- +

{continuityMode === 'deeper' - ? 'Advanced mode also syncs session-env, file-history, shell-snapshots, and todos.' - : 'Standard mode syncs project workspace context only.'} + ? t('editAccountContext.deeperHint') + : t('editAccountContext.standardHint')}

)}

- Credentials and `.anthropic` remain isolated per account in all modes. + {t('editAccountContext.credentialsIsolated')}

diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index f3410a91..dfecbf8a 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -17,6 +17,7 @@ import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; +import { useTranslation } from 'react-i18next'; import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content'; import type { AccountData, DragOffset } from './types'; @@ -94,6 +95,7 @@ export function AccountCard({ onPauseToggle, isPausingAccount, }: AccountCardProps) { + const { t } = useTranslation(); const borderSide = BORDER_SIDE_MAP[zone]; const borderColor = getBorderColorStyle(zone, account.color); const connectorPosition = CONNECTOR_POSITION_MAP[zone]; @@ -245,7 +247,7 @@ export function AccountCard({ - {account.paused ? 'Resume account' : 'Pause account'} + {account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')} @@ -266,7 +268,7 @@ export function AccountCard({ {quotaLoading ? (
- Quota... + {t('accountCard.quotaLoading')}
) : minQuotaValue !== null ? ( @@ -275,7 +277,7 @@ export function AccountCard({
- Quota + {t('accountCard.quota')} ) : quota?.success ? ( -
Quota limits unavailable
+
+ {t('accountCard.quotaUnavailable')} +
) : quota?.needsReauth ? (
- Reauth needed + {t('accountCard.reauthNeeded')}

{quota.error?.includes('No refresh token') - ? 'Remove and re-add account' - : quota.error || 'Auto-refresh failed'} + ? t('accountCard.removeAndReadd') + : quota.error || t('accountCard.autoRefreshFailed')}

diff --git a/ui/src/components/account/flow-viz/connection-timeline.tsx b/ui/src/components/account/flow-viz/connection-timeline.tsx index f7743435..15a32b91 100644 --- a/ui/src/components/account/flow-viz/connection-timeline.tsx +++ b/ui/src/components/account/flow-viz/connection-timeline.tsx @@ -2,6 +2,7 @@ * Connection Timeline Component - right sidebar panel */ +import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { STATUS_COLORS } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; @@ -16,10 +17,13 @@ interface ConnectionTimelineProps { } export function ConnectionTimeline({ events, privacyMode }: ConnectionTimelineProps) { + const { t } = useTranslation(); if (events.length === 0) { return (
-
No recent connections
+
+ {t('flowViz.noRecentConnections')} +
); } @@ -30,7 +34,7 @@ export function ConnectionTimeline({ events, privacyMode }: ConnectionTimelinePr
- Connection Timeline + {t('flowViz.connectionTimeline')}
diff --git a/ui/src/components/account/flow-viz/flow-viz-header.tsx b/ui/src/components/account/flow-viz/flow-viz-header.tsx index 01065856..3d66d3bd 100644 --- a/ui/src/components/account/flow-viz/flow-viz-header.tsx +++ b/ui/src/components/account/flow-viz/flow-viz-header.tsx @@ -2,6 +2,7 @@ * Flow Visualization Header Component */ +import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { ChevronRight, Eye, EyeOff, RotateCcw } from 'lucide-react'; @@ -20,6 +21,7 @@ export function FlowVizHeader({ hasCustomPositions, onResetPositions, }: FlowVizHeaderProps) { + const { t } = useTranslation(); return (
{onBack ? ( @@ -28,7 +30,7 @@ export function FlowVizHeader({ className="group flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-all duration-200 px-3 py-1.5 rounded-md hover:bg-muted/50 border border-transparent hover:border-border/50" > - Back to providers + {t('flowViz.backToProviders')} ) : (
@@ -44,7 +46,7 @@ export function FlowVizHeader({ )} > {showDetails ? : } - {showDetails ? 'Hide Details' : 'Show Details'} + {showDetails ? t('flowViz.hideDetails') : t('flowViz.showDetails')} {hasCustomPositions && ( )}
diff --git a/ui/src/components/account/flow-viz/provider-card.tsx b/ui/src/components/account/flow-viz/provider-card.tsx index 95328d94..3973ca31 100644 --- a/ui/src/components/account/flow-viz/provider-card.tsx +++ b/ui/src/components/account/flow-viz/provider-card.tsx @@ -2,6 +2,7 @@ * Provider Card Component for Flow Visualization */ +import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { ProviderIcon } from '@/components/shared/provider-icon'; import { GripVertical } from 'lucide-react'; @@ -39,6 +40,7 @@ export function ProviderCard({ onPointerMove, onPointerUp, }: ProviderCardProps) { + const { t } = useTranslation(); const { accounts } = providerData; return ( @@ -115,17 +117,19 @@ export function ProviderCard({

{providerData.displayName}

-

Provider

+

+ {t('flowViz.provider')} +

- Total Requests + {t('flowViz.totalRequests')} {totalRequests.toLocaleString()}
- Accounts + {t('flowViz.accounts')} {accounts.length}
diff --git a/ui/src/components/account/history-sync-learning-map.tsx b/ui/src/components/account/history-sync-learning-map.tsx index 7c7b2f94..4c941b47 100644 --- a/ui/src/components/account/history-sync-learning-map.tsx +++ b/ui/src/components/account/history-sync-learning-map.tsx @@ -13,6 +13,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; interface HistorySyncLearningMapProps { @@ -76,6 +77,7 @@ export function HistorySyncLearningMap({ legacyTargetCount, cliproxyCount, }: HistorySyncLearningMapProps) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const groupsToShow = sharedGroups.length > 0 ? sharedGroups : ['default']; @@ -84,33 +86,47 @@ export function HistorySyncLearningMap({
- How History Sync Works + {t('historySyncLearningMap.title')} - Isolated -> Shared -> Deeper. Use Sync per row for all changes. + {t('historySyncLearningMap.description')}
- Learning Map + {t('historySyncLearningMap.learningMap')}
{cliproxyCount > 0 && (
- {cliproxyCount} CLIProxy Claude pool account{cliproxyCount > 1 ? 's are' : ' is'} - managed in Action Center / CLIProxy page. + {t('historySyncLearningMap.cliproxyManaged', { count: cliproxyCount })}
)}
- +
- +
- +
@@ -119,7 +135,7 @@ export function HistorySyncLearningMap({ variant="ghost" className="h-8 w-full justify-between rounded-md px-2 text-xs text-muted-foreground hover:bg-muted/40 hover:text-foreground" > - Show details: groups, switching, and legacy policy + {t('historySyncLearningMap.showDetails')} @@ -128,21 +144,20 @@ export function HistorySyncLearningMap({
-

Mode Switch

+

{t('historySyncLearningMap.modeSwitch')}

- Sync dialog lets users move between isolated/shared and choose deeper continuity. + {t('historySyncLearningMap.modeSwitchDesc')}

-

History Sync Group

+

{t('historySyncLearningMap.historySyncGroup')}

- Same group means shared project context lane. Default fallback is{' '} - default. + {t('historySyncLearningMap.historySyncGroupDesc')}

{groupsToShow.map((group) => ( diff --git a/ui/src/components/analytics/usage-summary-cards.tsx b/ui/src/components/analytics/usage-summary-cards.tsx index 1bd7a3fc..0ca03eee 100644 --- a/ui/src/components/analytics/usage-summary-cards.tsx +++ b/ui/src/components/analytics/usage-summary-cards.tsx @@ -12,6 +12,7 @@ import { DollarSign, Database, FileText, ArrowDownRight, ArrowUpRight } from 'lu import { cn } from '@/lib/utils'; import type { UsageSummary } from '@/hooks/use-usage'; import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; +import { useTranslation } from 'react-i18next'; interface UsageSummaryCardsProps { data?: UsageSummary; @@ -20,6 +21,7 @@ interface UsageSummaryCardsProps { export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) { const { privacyMode } = usePrivacy(); + const { t } = useTranslation(); if (isLoading) { return ( @@ -48,49 +50,61 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) { const cards = [ { - title: 'Total Tokens', + title: t('analyticsSummary.totalTokens'), value: data?.totalTokens ?? 0, icon: FileText, format: (v: number) => formatNumber(v), color: 'text-blue-600', bgColor: 'bg-blue-100 dark:bg-blue-900/20', - subtitle: `${formatNumber(data?.totalInputTokens ?? 0)} in / ${formatNumber(data?.totalOutputTokens ?? 0)} out`, + subtitle: t('analyticsSummary.totalTokensSubtitle', { + input: formatNumber(data?.totalInputTokens ?? 0), + output: formatNumber(data?.totalOutputTokens ?? 0), + }), }, { - title: 'Total Cost', + title: t('analyticsSummary.totalCost'), value: data?.totalCost ?? 0, icon: DollarSign, format: (v: number) => `$${v.toFixed(2)}`, color: 'text-green-600', bgColor: 'bg-green-100 dark:bg-green-900/20', - subtitle: `$${data?.averageCostPerDay?.toFixed(2) ?? '0.00'}/day avg`, + subtitle: t('analyticsSummary.totalCostSubtitle', { + value: data?.averageCostPerDay?.toFixed(2) ?? '0.00', + }), }, { - title: 'Cache Tokens', + title: t('analyticsSummary.cacheTokens'), value: data?.totalCacheTokens ?? 0, icon: Database, format: (v: number) => formatNumber(v), color: 'text-cyan-600', bgColor: 'bg-cyan-100 dark:bg-cyan-900/20', - subtitle: `$${cacheCost.toFixed(2)} (${cacheCostPercent}% of cost)`, + subtitle: t('analyticsSummary.cacheTokensSubtitle', { + cost: cacheCost.toFixed(2), + percent: cacheCostPercent, + }), }, { - title: 'Input Cost', + title: t('analyticsSummary.inputCost'), value: data?.tokenBreakdown?.input?.cost ?? 0, icon: ArrowDownRight, format: (v: number) => `$${v.toFixed(2)}`, color: 'text-purple-600', bgColor: 'bg-purple-100 dark:bg-purple-900/20', - subtitle: `${formatNumber(data?.tokenBreakdown?.input?.tokens ?? 0)} tokens`, + subtitle: t('analyticsSummary.tokensSubtitle', { + value: formatNumber(data?.tokenBreakdown?.input?.tokens ?? 0), + }), }, { - title: 'Output Cost', + title: t('analyticsSummary.outputCost'), value: data?.tokenBreakdown?.output?.cost ?? 0, icon: ArrowUpRight, format: (v: number) => `$${v.toFixed(2)}`, color: 'text-orange-600', bgColor: 'bg-orange-100 dark:bg-orange-900/20', - subtitle: `${formatNumber(data?.tokenBreakdown?.output?.tokens ?? 0)} tokens`, + subtitle: t('analyticsSummary.tokensSubtitle', { + value: formatNumber(data?.tokenBreakdown?.output?.tokens ?? 0), + }), }, ]; diff --git a/ui/src/components/cliproxy/categorized-model-selector.tsx b/ui/src/components/cliproxy/categorized-model-selector.tsx index 4418ed5b..c52f8794 100644 --- a/ui/src/components/cliproxy/categorized-model-selector.tsx +++ b/ui/src/components/cliproxy/categorized-model-selector.tsx @@ -18,21 +18,22 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Cpu } from 'lucide-react'; import type { CliproxyModelsResponse } from '@/lib/api-client'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; /** Provider display configuration */ -const CATEGORY_CONFIG: Record = { - google: { name: 'Google (Gemini)', color: 'text-blue-600' }, - openai: { name: 'OpenAI (GPT)', color: 'text-green-600' }, - anthropic: { name: 'Anthropic (Claude)', color: 'text-orange-600' }, - antigravity: { name: 'Antigravity', color: 'text-purple-600' }, - other: { name: 'Other', color: 'text-gray-600' }, +const CATEGORY_CONFIG: Record = { + google: { key: 'google', color: 'text-blue-600' }, + openai: { key: 'openai', color: 'text-green-600' }, + anthropic: { key: 'anthropic', color: 'text-orange-600' }, + antigravity: { key: 'antigravity', color: 'text-purple-600' }, + other: { key: 'other', color: 'text-gray-600' }, }; /** Get display name for category */ function getCategoryDisplay(category: string) { return ( CATEGORY_CONFIG[category.toLowerCase()] || { - name: category.charAt(0).toUpperCase() + category.slice(1), + key: 'other', color: 'text-gray-600', } ); @@ -61,9 +62,12 @@ export function CategorizedModelSelector({ value, onChange, disabled, - placeholder = 'Select a model', + placeholder, className, }: CategorizedModelSelectorProps) { + const { t } = useTranslation(); + const resolvedPlaceholder = placeholder ?? t('categorizedModelSelector.selectModel'); + // Sort categories by model count (descending) const sortedCategories = useMemo(() => { if (!modelsData?.byCategory) return []; @@ -84,7 +88,7 @@ export function CategorizedModelSelector({ return (
- No models available + {t('categorizedModelSelector.noModelsAvailable')}
); } @@ -92,7 +96,7 @@ export function CategorizedModelSelector({ return ( + + {singleForm.formState.errors.name && ( {singleForm.formState.errors.name.message} @@ -161,13 +189,13 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
- + - + {providerAccounts.map((acc) => ( ))} @@ -201,39 +229,45 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { )}
- - + +
- +
) : (
- + {compositeForm.formState.errors.name && ( @@ -243,17 +277,17 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
- + - Opus - Sonnet - Haiku + {t('cliproxyDialog.opus')} + {t('cliproxyDialog.sonnet')} + {t('cliproxyDialog.haiku')} {(['opus', 'sonnet', 'haiku'] as const).map((tier) => (
- + {compositeForm.formState.errors.tiers?.[tier]?.model && ( @@ -280,11 +314,13 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { )}
- +
@@ -293,36 +329,38 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
- +
- +
diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx index 82abaf5e..07d3b430 100644 --- a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx @@ -12,9 +12,12 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; import { useUpdateVariant } from '@/hooks/use-cliproxy'; import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; import type { UpdateVariant, Variant } from '@/lib/api-client'; +import { isDeniedAgyModelId } from '@/lib/utils'; const singleProviderSchema = z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), @@ -60,12 +63,18 @@ const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({ })); const COMPOSITE_TIERS = ['opus', 'sonnet', 'haiku'] as const; +const AGY_DENYLIST_MESSAGE = + 'Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.'; function normalizeOptionalValue(value?: string): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } +function isDeniedAgyModelForProvider(provider: string, modelId: string | undefined): boolean { + return provider === 'agy' && typeof modelId === 'string' && isDeniedAgyModelId(modelId); +} + function isSingleVariantOnlyTargetChange(variant: Variant, data: SingleProviderFormData): boolean { const currentTarget = variant.target || 'claude'; const currentModel = normalizeOptionalValue(variant.model); @@ -117,6 +126,7 @@ function isCompositeVariantOnlyTargetChange(variant: Variant, data: CompositeFor } export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEditDialogProps) { + const { t } = useTranslation(); const updateMutation = useUpdateVariant(); const isComposite = variant?.type === 'composite'; @@ -188,6 +198,14 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit } } + const nextProvider = payload.provider || data.provider || variant.provider; + const nextModelForValidation = payload.model ?? normalizeOptionalValue(data.model); + if (isDeniedAgyModelForProvider(nextProvider, nextModelForValidation)) { + singleForm.setError('model', { message: AGY_DENYLIST_MESSAGE }); + toast.error(AGY_DENYLIST_MESSAGE); + return; + } + if (Object.keys(payload).length === 0) { onOpenChange(false); return; @@ -241,6 +259,15 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit } } + const tiersToValidate = payload.tiers ?? data.tiers; + for (const tier of COMPOSITE_TIERS) { + const tierConfig = tiersToValidate[tier]; + if (!isDeniedAgyModelForProvider(tierConfig.provider, tierConfig.model)) continue; + compositeForm.setError(`tiers.${tier}.model`, { message: AGY_DENYLIST_MESSAGE }); + toast.error(AGY_DENYLIST_MESSAGE); + return; + } + if (Object.keys(payload).length === 0) { onOpenChange(false); return; @@ -264,24 +291,28 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit - Edit {isComposite ? 'Composite' : 'Single'} Variant: {variant.name} + {isComposite + ? t('cliproxyDialog.editCompositeTitle', { name: variant.name }) + : t('cliproxyDialog.editSingleTitle', { name: variant.name })} {isComposite ? (
- + - Opus - Sonnet - Haiku + {t('cliproxyDialog.opus')} + {t('cliproxyDialog.sonnet')} + {t('cliproxyDialog.haiku')} {(['opus', 'sonnet', 'haiku'] as const).map((tier) => (
- + {compositeForm.formState.errors.tiers?.[tier]?.model && ( @@ -308,11 +339,13 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit )}
- +
@@ -321,43 +354,45 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
- +
- +
) : (
- + -
- -
- +
- + + +
+ +
+
diff --git a/ui/src/components/cliproxy/overview/credential-health-list.tsx b/ui/src/components/cliproxy/overview/credential-health-list.tsx index 5d08146a..4e484036 100644 --- a/ui/src/components/cliproxy/overview/credential-health-list.tsx +++ b/ui/src/components/cliproxy/overview/credential-health-list.tsx @@ -11,6 +11,7 @@ import { useCliproxyAuth } from '@/hooks/use-cliproxy'; import { useCliproxyStats } from '@/hooks/use-cliproxy-stats'; import { cn } from '@/lib/utils'; import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; +import { useTranslation } from 'react-i18next'; type CredentialStatus = 'ready' | 'warning' | 'error' | 'disabled'; @@ -35,6 +36,8 @@ function CredentialRow({ onRefresh, privacyMode, }: CredentialRowProps) { + const { t } = useTranslation(); + const statusConfig = { ready: { icon: CheckCircle2, @@ -62,21 +65,21 @@ function CredentialRow({ const Icon = config.icon; const formatLastUsed = (date?: string) => { - if (!date) return 'Never used'; + if (!date) return t('credentialHealth.neverUsed'); try { const lastUsed = new Date(date); const now = new Date(); const diff = now.getTime() - lastUsed.getTime(); - if (diff < 0) return 'Just now'; + if (diff < 0) return t('credentialHealth.justNow'); const minutes = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); - if (days > 0) return `${days}d ago`; - if (hours > 0) return `${hours}h ago`; - if (minutes > 0) return `${minutes}m ago`; - return 'Just now'; + if (days > 0) return t('credentialHealth.daysAgo', { count: days }); + if (hours > 0) return t('credentialHealth.hoursAgo', { count: hours }); + if (minutes > 0) return t('credentialHealth.minutesAgo', { count: minutes }); + return t('credentialHealth.justNow'); } catch { - return 'Unknown'; + return t('credentialHealth.unknown'); } }; @@ -120,10 +123,12 @@ function CredentialRow({ } function CredentialHealthSkeleton() { + const { t } = useTranslation(); + return ( - Credential Health + {t('credentialHealth.title')}
@@ -137,6 +142,7 @@ function CredentialHealthSkeleton() { } export function CredentialHealthList() { + const { t } = useTranslation(); const { data: authData, isLoading } = useCliproxyAuth(); const { data: stats } = useCliproxyStats(true); const { privacyMode } = usePrivacy(); @@ -155,7 +161,8 @@ export function CredentialHealthList() { name: account.id, provider: status.provider, status: (account as { status?: CredentialStatus }).status ?? 'ready', - statusMessage: (account as { statusMessage?: string }).statusMessage ?? 'Ready', + statusMessage: + (account as { statusMessage?: string }).statusMessage ?? t('credentialHealth.ready'), email: account.email, lastUsedAt: runtimeLastUsed || account.lastUsedAt, }; @@ -166,11 +173,11 @@ export function CredentialHealthList() { return ( - Credential Health + {t('credentialHealth.title')}

- No credentials configured. Use the login buttons above to authenticate. + {t('credentialHealth.noCredentialsConfigured')}

@@ -180,7 +187,7 @@ export function CredentialHealthList() { return ( - Credential Health + {t('credentialHealth.title')} {credentials.map((cred, i) => ( diff --git a/ui/src/components/cliproxy/overview/model-preferences-grid.tsx b/ui/src/components/cliproxy/overview/model-preferences-grid.tsx index 8bdd8f81..ab5040ef 100644 --- a/ui/src/components/cliproxy/overview/model-preferences-grid.tsx +++ b/ui/src/components/cliproxy/overview/model-preferences-grid.tsx @@ -10,31 +10,34 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Cpu, AlertCircle } from 'lucide-react'; import { useCliproxyModels, useCliproxyUpdateCheck } from '@/hooks/use-cliproxy'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; /** Category display configuration */ -const CATEGORY_CONFIG: Record = { - google: { name: 'Google (Gemini)', color: 'text-blue-600', bgColor: 'bg-blue-50' }, - openai: { name: 'OpenAI (GPT)', color: 'text-green-600', bgColor: 'bg-green-50' }, - anthropic: { name: 'Anthropic (Claude)', color: 'text-orange-600', bgColor: 'bg-orange-50' }, - antigravity: { name: 'Antigravity', color: 'text-purple-600', bgColor: 'bg-purple-50' }, - other: { name: 'Other', color: 'text-gray-600', bgColor: 'bg-gray-50' }, +const CATEGORY_CONFIG: Record = { + google: { key: 'google', color: 'text-blue-600', bgColor: 'bg-blue-50' }, + openai: { key: 'openai', color: 'text-green-600', bgColor: 'bg-green-50' }, + anthropic: { key: 'anthropic', color: 'text-orange-600', bgColor: 'bg-orange-50' }, + antigravity: { key: 'antigravity', color: 'text-purple-600', bgColor: 'bg-purple-50' }, + other: { key: 'other', color: 'text-gray-600', bgColor: 'bg-gray-50' }, }; function getCategoryDisplay(category: string) { + const normalized = category.toLowerCase(); + const configured = CATEGORY_CONFIG[normalized]; return ( - CATEGORY_CONFIG[category.toLowerCase()] || { - name: category.charAt(0).toUpperCase() + category.slice(1), + configured || { + key: 'other', color: 'text-gray-600', bgColor: 'bg-gray-50', } ); } -function ModelPreferencesSkeleton() { +function ModelPreferencesSkeleton({ title }: { title: string }) { return ( - Available Models + {title}
@@ -47,21 +50,27 @@ function ModelPreferencesSkeleton() { ); } -function EmptyModelsState() { +function EmptyModelsState({ + title, + noModels, + hint, +}: { + title: string; + noModels: string; + hint: string; +}) { return ( - Available Models + {title}
-

No models available

-

- Start a CLIProxy session to fetch available models -

+

{noModels}

+

{hint}

@@ -69,6 +78,7 @@ function EmptyModelsState() { } export function ModelPreferencesGrid() { + const { t } = useTranslation(); const { data: modelsData, isLoading, isError } = useCliproxyModels(); const { data: updateCheck } = useCliproxyUpdateCheck(); const backendLabel = updateCheck?.backendLabel ?? 'CLIProxy'; @@ -86,11 +96,17 @@ export function ModelPreferencesGrid() { }, [modelsData]); if (isLoading) { - return ; + return ; } if (isError || !modelsData || modelsData.totalCount === 0) { - return ; + return ( + + ); } return ( @@ -98,13 +114,13 @@ export function ModelPreferencesGrid() { - Available Models + {t('cliproxyOverviewComponents.availableModels')} - {modelsData.totalCount} total + {t('cliproxyOverviewComponents.totalCount', { count: modelsData.totalCount })} - Models available through {backendLabel}, grouped by provider + {t('cliproxyOverviewComponents.availableThroughGroupedByProvider', { backendLabel })} @@ -115,7 +131,9 @@ export function ModelPreferencesGrid() { className={cn('p-4 rounded-lg border', display.bgColor, 'border-transparent')} >
-

{display.name}

+

+ {t(`cliproxyModelCategory.${display.key}`)} +

{models.length} @@ -132,7 +150,7 @@ export function ModelPreferencesGrid() { ))} {models.length > 5 && (
- +{models.length - 5} more + {t('cliproxyOverviewComponents.moreCount', { count: models.length - 5 })}
)}
diff --git a/ui/src/components/cliproxy/overview/quick-stats-row.tsx b/ui/src/components/cliproxy/overview/quick-stats-row.tsx index 7e533d96..4e8931db 100644 --- a/ui/src/components/cliproxy/overview/quick-stats-row.tsx +++ b/ui/src/components/cliproxy/overview/quick-stats-row.tsx @@ -4,6 +4,7 @@ */ import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import { Card, CardContent } from '@/components/ui/card'; import { Activity, CheckCircle2, Coins, Cpu } from 'lucide-react'; import { useCliproxyStats } from '@/hooks/use-cliproxy'; @@ -45,6 +46,7 @@ function StatsSkeleton() { } export function QuickStatsRow() { + const { t } = useTranslation(); const { data: stats, isLoading } = useCliproxyStats(); if (isLoading) { @@ -71,16 +73,24 @@ export function QuickStatsRow() {
} - label="Total Requests" + label={t('quickStats.totalRequests')} value={totalRequests} /> } - label="Success Rate" + label={t('quickStats.successRate')} value={`${successRate}%`} /> - } label="Total Tokens" value={tokenDisplay} /> - } label="Active Models" value={modelCount} /> + } + label={t('quickStats.totalTokens')} + value={tokenDisplay} + /> + } + label={t('quickStats.activeModels')} + value={modelCount} + />
); } diff --git a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx index 07dbfd46..6a494631 100644 --- a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx +++ b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx @@ -18,6 +18,7 @@ import { import { Sparkles, Loader2, Star, Zap } from 'lucide-react'; import { FlexibleModelSelector } from '../provider-model-selector'; import type { CustomPresetDialogProps, ModelMappingValues } from './types'; +import { useTranslation } from 'react-i18next'; export function CustomPresetDialog({ open, @@ -29,6 +30,7 @@ export function CustomPresetDialog({ catalog, allModels, }: CustomPresetDialogProps) { + const { t } = useTranslation(); const [values, setValues] = useState(currentValues); const [presetName, setPresetName] = useState(''); @@ -48,54 +50,54 @@ export function CustomPresetDialog({ - Custom Preset + {t('customPresetDialog.title')}
- + setPresetName(e.target.value)} - placeholder="e.g., My Custom Config" + placeholder={t('customPresetDialog.presetNamePlaceholder')} className="text-sm" />
{catalog?.provider === 'codex' && (

- Codex tip: suffixes -medium, -high, and -xhigh{' '} - pin effort. Unsuffixed models use Thinking settings. + {t('customPresetDialog.codexTipPrefix')} -medium, -high, and{' '} + -xhigh {t('customPresetDialog.codexTipSuffix')}

)} setValues({ ...values, default: model })} catalog={catalog} allModels={allModels} /> setValues({ ...values, opus: model })} catalog={catalog} allModels={allModels} /> setValues({ ...values, sonnet: model })} catalog={catalog} allModels={allModels} /> setValues({ ...values, haiku: model })} catalog={catalog} @@ -104,7 +106,7 @@ export function CustomPresetDialog({
{onSave && ( )} diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index c02ff4f6..502b8583 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -17,6 +17,8 @@ import { useDeletePreset, } from '@/hooks/use-cliproxy'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; +import { isDeniedAgyModelId } from '@/lib/utils'; +import i18n from '@/lib/i18n'; import { usePrivacy } from '@/contexts/privacy-context'; import { useProviderEditor } from './use-provider-editor'; import { CustomPresetDialog } from './custom-preset-dialog'; @@ -56,10 +58,23 @@ export function ProviderEditor({ const { data: presetsData } = usePresets(provider); const createPresetMutation = useCreatePreset(); const deletePresetMutation = useDeletePreset(); - const savedPresets = presetsData?.presets || []; // Use baseProvider for model filtering (for variants, this is the parent provider) const modelFilterProvider = baseProvider || provider; + const isAgyProvider = modelFilterProvider.toLowerCase() === 'agy'; + + const savedPresets = useMemo(() => { + const presets = presetsData?.presets || []; + if (!isAgyProvider) return presets; + + return presets.filter( + (preset) => + !isDeniedAgyModelId(preset.default) && + !isDeniedAgyModelId(preset.opus) && + !isDeniedAgyModelId(preset.sonnet) && + !isDeniedAgyModelId(preset.haiku) + ); + }, [isAgyProvider, presetsData?.presets]); const providerModels = useMemo(() => { if (!modelsData?.models) return []; @@ -76,10 +91,12 @@ export function ProviderEditor({ const owners = ownerMap[modelFilterProvider.toLowerCase()] || [ modelFilterProvider.toLowerCase(), ]; - return modelsData.models.filter((m) => - owners.some((o) => m.owned_by.toLowerCase().includes(o)) - ); - }, [modelsData, modelFilterProvider]); + return modelsData.models.filter((m) => { + if (!owners.some((o) => m.owned_by.toLowerCase().includes(o))) return false; + if (!isAgyProvider) return true; + return !isDeniedAgyModelId(m.id); + }); + }, [isAgyProvider, modelsData, modelFilterProvider]); const providerRoute = (baseProvider || provider).toLowerCase(); @@ -130,6 +147,19 @@ export function ProviderEditor({ const effectiveApiKey = authTokens?.apiKey?.value ?? 'ccs-internal-managed'; const handleApplyPreset = (updates: Record) => { + if ( + isAgyProvider && + [ + updates.ANTHROPIC_MODEL, + updates.ANTHROPIC_DEFAULT_OPUS_MODEL, + updates.ANTHROPIC_DEFAULT_SONNET_MODEL, + updates.ANTHROPIC_DEFAULT_HAIKU_MODEL, + ].some((modelId) => typeof modelId === 'string' && isDeniedAgyModelId(modelId)) + ) { + toast.error('Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.'); + return; + } + const effectivePort = port ?? CLIPROXY_DEFAULT_PORT; updateEnvValues({ ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${providerRoute}`, @@ -140,6 +170,16 @@ export function ProviderEditor({ }; const handleCustomPresetApply = (values: ModelMappingValues, presetName?: string) => { + if ( + isAgyProvider && + [values.default, values.opus, values.sonnet, values.haiku].some((modelId) => + isDeniedAgyModelId(modelId) + ) + ) { + toast.error('Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.'); + return; + } + const effectivePort = port ?? CLIPROXY_DEFAULT_PORT; updateEnvValues({ ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${providerRoute}`, @@ -155,7 +195,16 @@ export function ProviderEditor({ const handleCustomPresetSave = (values: ModelMappingValues, presetName?: string) => { if (!presetName) { - toast.error('Please enter a preset name to save'); + toast.error(i18n.t('commonToast.enterPresetName')); + return; + } + if ( + isAgyProvider && + [values.default, values.opus, values.sonnet, values.haiku].some((modelId) => + isDeniedAgyModelId(modelId) + ) + ) { + toast.error('Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.'); return; } createPresetMutation.mutate({ profile: provider, data: { name: presetName, ...values } }); diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 2bc9d138..5b8d5ec6 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -19,6 +19,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { AlertTriangle, AlertCircle, Check } from 'lucide-react'; import { cn } from '@/lib/utils'; import { getCodexEffortDisplay } from '@/lib/codex-effort'; +import { useTranslation } from 'react-i18next'; /** Model entry from catalog */ export interface ModelEntry { @@ -72,9 +73,12 @@ export function ProviderModelSelector({ value, onChange, disabled, - placeholder = 'Select a model', + placeholder, className, }: ProviderModelSelectorProps) { + const { t } = useTranslation(); + const resolvedPlaceholder = placeholder ?? t('providerModelSelector.selectModel'); + // Group models by tier const groupedModels = useMemo(() => { if (!catalog?.models) return { free: [], paid: [] }; @@ -95,7 +99,7 @@ export function ProviderModelSelector({ if (!catalog || catalog.models.length === 0) { return (
- No models available for this provider + {t('providerModelSelector.noModelsForProvider')}
); } @@ -110,12 +114,12 @@ export function ProviderModelSelector({ {model.name} {model.broken && ( - BROKEN + {t('providerModelSelector.broken')} )} {model.deprecated && ( - DEPRECATED + {t('providerModelSelector.deprecated')} )} {value === model.id && } @@ -127,13 +131,13 @@ export function ProviderModelSelector({
- + {value && {value}} @@ -230,7 +239,7 @@ export function ModelMappingSelector({ {model.name} {model.tier === 'paid' && ( - PAID + {t('providerModelSelector.paid')} )}
@@ -262,6 +271,7 @@ export function FlexibleModelSelector({ allModels, disabled, }: FlexibleModelSelectorProps) { + const { t } = useTranslation(); // Combine catalog models (recommended) with all available models const catalogModelIds = new Set(catalog?.models.map((m) => m.id) || []); const isCodexProvider = catalog?.provider === 'codex'; @@ -275,7 +285,7 @@ export function FlexibleModelSelector({
diff --git a/ui/src/components/cliproxy/sync/sync-dialog.tsx b/ui/src/components/cliproxy/sync/sync-dialog.tsx index 46a63270..4e106a36 100644 --- a/ui/src/components/cliproxy/sync/sync-dialog.tsx +++ b/ui/src/components/cliproxy/sync/sync-dialog.tsx @@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Loader2, Upload, CheckCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { useSyncPreview, useExecuteSync } from '@/hooks/use-cliproxy-sync'; @@ -23,6 +24,7 @@ interface SyncDialogProps { } export function SyncDialog({ open, onOpenChange }: SyncDialogProps) { + const { t } = useTranslation(); const { data: preview, isLoading: previewLoading } = useSyncPreview(); const { mutate: executeSync, isPending: isSyncing, isSuccess, reset } = useExecuteSync(); @@ -45,11 +47,9 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) { - Sync Profiles to Local CLIProxy + {t('syncDialog.title')} - - Sync your CCS API profiles to the local CLIProxy config.yaml. - + {t('syncDialog.description')}
@@ -59,8 +59,8 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
) : preview?.count === 0 ? (
-

No profiles configured to sync.

-

Create API profiles first using the Profiles tab.

+

{t('syncDialog.noProfiles')}

+

{t('syncDialog.createProfilesFirst')}

) : ( @@ -74,7 +74,7 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
{profile.name}
{profile.modelName && (
- Model: {profile.modelName} + {t('syncDialog.modelLabel')} {profile.modelName}
)} {profile.baseUrl && ( @@ -84,7 +84,7 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) { )}
- Ready + {t('syncDialog.ready')}
))} @@ -94,11 +94,11 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
- {preview?.count ?? 0} profile{(preview?.count ?? 0) !== 1 ? 's' : ''} to sync + {t('syncDialog.profilesToSync', { count: preview?.count ?? 0 })}
diff --git a/ui/src/components/cliproxy/sync/sync-status-card.tsx b/ui/src/components/cliproxy/sync/sync-status-card.tsx index 4c220292..e4054a1b 100644 --- a/ui/src/components/cliproxy/sync/sync-status-card.tsx +++ b/ui/src/components/cliproxy/sync/sync-status-card.tsx @@ -9,10 +9,12 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Loader2, RefreshCw, FileDown, Check, AlertCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; import { SyncDialog } from './sync-dialog'; import { useSyncStatus, useExecuteSync } from '@/hooks/use-cliproxy-sync'; export function SyncStatusCard() { + const { t } = useTranslation(); const [dialogOpen, setDialogOpen] = useState(false); const { data: status, isLoading, refetch } = useSyncStatus(); const { mutate: executeSync, isPending: isSyncing } = useExecuteSync(); @@ -31,7 +33,7 @@ export function SyncStatusCard() { - Profile Sync + {t('syncStatusCard.profileSync')} @@ -50,7 +52,7 @@ export function SyncStatusCard() {
- Profile Sync + {t('syncStatusCard.profileSync')} {isConfigured ? : } - {isConfigured ? 'Ready' : 'No Config'} + {isConfigured ? t('syncStatusCard.ready') : t('syncStatusCard.noConfig')}
{isConfigured && (
- Syncs API profiles to local CLIProxy config + {t('syncStatusCard.syncsProfilesDesc')}
)} {!isConfigured && ( -

- Run ccs doctor --fix to generate - config. -

+

)} {status?.error &&

{status.error}

} @@ -89,7 +93,7 @@ export function SyncStatusCard() { className="flex-1" onClick={() => setDialogOpen(true)} > - Details + {t('syncStatusCard.details')}
diff --git a/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx index 3b0cf8bf..6eec468b 100644 --- a/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx +++ b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx @@ -4,6 +4,7 @@ import { Copy, FileCode2, Loader2, RefreshCw, Save } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { CodeEditor } from '@/components/shared/code-editor'; +import i18n from '@/lib/i18n'; import { cn } from '@/lib/utils'; interface RawJsonSettingsEditorPanelProps { @@ -39,7 +40,7 @@ export function RawJsonSettingsEditorPanel({ if (!value) return; await navigator.clipboard.writeText(value); setCopied(true); - toast.success('Settings copied to clipboard'); + toast.success(i18n.t('commonToast.settingsCopied')); window.setTimeout(() => setCopied(false), 1500); }; diff --git a/ui/src/components/copilot/config-form/model-selector.tsx b/ui/src/components/copilot/config-form/model-selector.tsx index 27638b42..1f3a91f0 100644 --- a/ui/src/components/copilot/config-form/model-selector.tsx +++ b/ui/src/components/copilot/config-form/model-selector.tsx @@ -16,6 +16,7 @@ import { import { Check } from 'lucide-react'; import type { FlexibleModelSelectorProps } from './types'; import { getPlanBadgeStyle, getMultiplierDisplay } from './utils'; +import { useTranslation } from 'react-i18next'; export function FlexibleModelSelector({ label, @@ -25,6 +26,7 @@ export function FlexibleModelSelector({ models, disabled, }: FlexibleModelSelectorProps) { + const { t } = useTranslation(); // Find current model for display const currentModel = models.find((m) => m.id === value); @@ -36,7 +38,7 @@ export function FlexibleModelSelector({
@@ -108,18 +117,18 @@ export function SettingsTab({ {/* Rate Limiting */}
-

Rate Limiting

+

{t('copilotSettings.rateLimiting')}

onUpdateRateLimit(e.target.value)} - placeholder="No limit" + placeholder={t('copilotSettings.noLimit')} min={0} className="max-w-[150px] h-8" /> @@ -128,10 +137,10 @@ export function SettingsTab({

- Wait instead of error when limit hit + {t('copilotSettings.waitOnRateLimitDesc')}

-

Daemon Settings

+

{t('copilotSettings.daemonSettings')}

- Start copilot-api when using profile + {t('copilotSettings.autoStartDaemonDesc')}

diff --git a/ui/src/components/health/health-check-item.tsx b/ui/src/components/health/health-check-item.tsx index 88268d64..6370bdc5 100644 --- a/ui/src/components/health/health-check-item.tsx +++ b/ui/src/components/health/health-check-item.tsx @@ -5,6 +5,7 @@ import { useFixHealth, type HealthCheck } from '@/hooks/use-health'; import { cn } from '@/lib/utils'; import { useState } from 'react'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; const statusConfig = { ok: { dot: 'bg-green-500', label: 'OK', labelColor: 'text-green-500' }, @@ -14,6 +15,7 @@ const statusConfig = { }; export function HealthCheckItem({ check }: { check: HealthCheck }) { + const { t } = useTranslation(); const fixMutation = useFixHealth(); const config = statusConfig[check.status]; const [isOpen, setIsOpen] = useState(false); @@ -21,7 +23,7 @@ export function HealthCheckItem({ check }: { check: HealthCheck }) { const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); - toast.success('Copied to clipboard'); + toast.success(t('health.copied')); }; // Compact single-line display for items without expandable content @@ -62,7 +64,7 @@ export function HealthCheckItem({ check }: { check: HealthCheck }) { className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity" > - Fix + {t('health.fix')} )}
@@ -149,7 +151,7 @@ export function HealthCheckItem({ check }: { check: HealthCheck }) { className="h-7 px-3 text-xs" > - Apply Fix + {t('health.applyFix')} )}
diff --git a/ui/src/components/health/health-gauge.tsx b/ui/src/components/health/health-gauge.tsx index 2936b726..04c7a003 100644 --- a/ui/src/components/health/health-gauge.tsx +++ b/ui/src/components/health/health-gauge.tsx @@ -1,4 +1,5 @@ import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; interface HealthGaugeProps { passed: number; @@ -20,6 +21,7 @@ const statusColors = { }; export function HealthGauge({ passed, total, status, size = 'md' }: HealthGaugeProps) { + const { t } = useTranslation(); const config = sizeConfig[size]; const colors = statusColors[status]; const percentage = total > 0 ? Math.round((passed / total) * 100) : 0; @@ -83,7 +85,7 @@ export function HealthGauge({ passed, total, status, size = 'md' }: HealthGaugeP config.labelSize )} > - health + {t('health.healthLabel')}
diff --git a/ui/src/components/health/health-stats-bar.tsx b/ui/src/components/health/health-stats-bar.tsx index 042a3f89..bf5c84a1 100644 --- a/ui/src/components/health/health-stats-bar.tsx +++ b/ui/src/components/health/health-stats-bar.tsx @@ -1,4 +1,5 @@ import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; interface HealthStatsBarProps { total: number; @@ -28,6 +29,7 @@ function StatItem({ label, value, color, bgColor }: StatItemProps) { } export function HealthStatsBar({ total, passed, warnings, errors, info }: HealthStatsBarProps) { + const { t } = useTranslation(); // Calculate percentages for the progress bar const passedPct = (passed / total) * 100; const warningPct = (warnings / total) * 100; @@ -68,7 +70,7 @@ export function HealthStatsBar({ total, passed, warnings, errors, info }: Health
- Checks + {t('health.checks')} {total}
diff --git a/ui/src/components/layout/app-sidebar.tsx b/ui/src/components/layout/app-sidebar.tsx index 2f81dbf7..91ee0c12 100644 --- a/ui/src/components/layout/app-sidebar.tsx +++ b/ui/src/components/layout/app-sidebar.tsx @@ -35,10 +35,13 @@ import { useSidebar } from '@/hooks/use-sidebar'; import { useCliproxyUpdateCheck } from '@/hooks/use-cliproxy'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useTranslation } from 'react-i18next'; +import { cn } from '@/lib/utils'; interface SidebarBadge { - text: string; - icon: string; + icon?: string; + icons?: string[]; + text?: string; } interface SidebarChildItem { @@ -62,66 +65,75 @@ interface SidebarGroupDef { items: SidebarItem[]; } -// Define navigation groups -const navGroups: SidebarGroupDef[] = [ - { - title: 'General', - items: [ - { path: '/', icon: Home, label: 'Home' }, - { path: '/analytics', icon: BarChart3, label: 'Analytics' }, - ], - }, - { - title: 'Identity & Access', - items: [ - { - path: '/providers', - icon: Key, - label: 'API Profiles', - badge: { text: 'OpenRouter', icon: '/icons/openrouter.svg' }, - }, - { - path: '/cliproxy', - icon: Zap, - label: 'CLIProxy Plus', - isCollapsible: true, - children: [ - { path: '/cliproxy', label: 'Overview' }, - { path: '/cliproxy/control-panel', icon: Gauge, label: 'Control Panel' }, - ], - }, - { path: '/copilot', icon: Github, label: 'GitHub Copilot' }, - { path: '/cursor', iconSrc: '/assets/sidebar/cursor.svg', label: 'Cursor IDE' }, - { - path: '/accounts', - icon: Users, - label: 'Accounts', - isCollapsible: true, - children: [ - { path: '/accounts', label: 'All Accounts' }, - { path: '/shared', icon: FolderOpen, label: 'Shared Data' }, - ], - }, - ], - }, - { - title: 'Compatible CLIs', - items: [{ path: '/droid', icon: TerminalSquare, label: 'Factory Droid' }], - }, - { - title: 'System', - items: [ - { path: '/health', icon: Activity, label: 'Health' }, - { path: '/settings', icon: Settings, label: 'Settings' }, - ], - }, -]; +function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] { + return [ + { + title: t('nav.general'), + items: [ + { path: '/', icon: Home, label: t('nav.home') }, + { path: '/analytics', icon: BarChart3, label: t('nav.analytics') }, + ], + }, + { + title: t('nav.identityAccess'), + items: [ + { + path: '/providers', + icon: Key, + label: t('nav.apiProfiles'), + badge: { + icons: [ + '/icons/openrouter.svg', + '/assets/providers/alibabacloud-color.svg', + '/icons/ollama.svg', + ], + }, + }, + { + path: '/cliproxy', + icon: Zap, + label: t('nav.cliproxyPlus'), + isCollapsible: true, + children: [ + { path: '/cliproxy', label: t('nav.cliproxyOverview') }, + { path: '/cliproxy/control-panel', icon: Gauge, label: t('nav.controlPanel') }, + ], + }, + { path: '/copilot', icon: Github, label: t('nav.githubCopilot') }, + { path: '/cursor', iconSrc: '/assets/sidebar/cursor.svg', label: t('nav.cursorIde') }, + { + path: '/accounts', + icon: Users, + label: t('nav.accounts'), + isCollapsible: true, + children: [ + { path: '/accounts', label: t('nav.allAccounts') }, + { path: '/shared', icon: FolderOpen, label: t('nav.sharedData') }, + ], + }, + ], + }, + { + title: t('nav.compatibleClis'), + items: [{ path: '/droid', icon: TerminalSquare, label: t('nav.factoryDroid') }], + }, + { + title: t('nav.system'), + items: [ + { path: '/health', icon: Activity, label: t('nav.health') }, + { path: '/settings', icon: Settings, label: t('nav.settings') }, + ], + }, + ]; +} export function AppSidebar() { + const { t } = useTranslation(); const location = useLocation(); const navigate = useNavigate(); const { state } = useSidebar(); const { data: updateCheck } = useCliproxyUpdateCheck(); + const navGroups = buildNavGroups(t); // Dynamic label for CLIProxy based on backend const cliproxyLabel = updateCheck?.backendLabel ?? 'CLIProxy'; @@ -225,18 +237,42 @@ export function AppSidebar() { - - {item.badge.text} + {(item.badge.icons && item.badge.icons.length > 0 + ? item.badge.icons + : item.badge.icon + ? [item.badge.icon] + : [] + ).map((iconPath) => ( + + + + ))} + {item.badge.text && ( + {item.badge.text} + )} -

349+ models via OpenRouter

+

{t('nav.openrouterTooltip')}

)} diff --git a/ui/src/components/layout/language-switcher.tsx b/ui/src/components/layout/language-switcher.tsx new file mode 100644 index 00000000..8ab4fbbe --- /dev/null +++ b/ui/src/components/layout/language-switcher.tsx @@ -0,0 +1,38 @@ +import { Globe } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { normalizeLocale, persistLocale, SUPPORTED_LOCALES } from '@/lib/locales'; + +export function LanguageSwitcher() { + const { t, i18n } = useTranslation(); + const currentLocale = normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? 'en'); + + const handleLocaleChange = (nextLocale: string) => { + const normalized = persistLocale(nextLocale); + void i18n.changeLanguage(normalized); + }; + + return ( +
+ + +
+ ); +} diff --git a/ui/src/components/layout/layout.tsx b/ui/src/components/layout/layout.tsx index 03adb74b..0e69960d 100644 --- a/ui/src/components/layout/layout.tsx +++ b/ui/src/components/layout/layout.tsx @@ -14,6 +14,7 @@ import { SponsorButton } from '@/components/shared/sponsor-button'; import { ProjectSelectionDialog } from '@/components/shared/project-selection-dialog'; import { DeviceCodeDialog } from '@/components/shared/device-code-dialog'; import { UserMenu } from '@/components/auth/user-menu'; +import { LanguageSwitcher } from './language-switcher'; import { useProjectSelection } from '@/hooks/use-project-selection'; import { useDeviceCode } from '@/hooks/use-device-code'; import { storeLastRoute } from '@/lib/last-route'; @@ -47,6 +48,7 @@ export function Layout() {
+ diff --git a/ui/src/components/monitoring/auth-monitor/index.tsx b/ui/src/components/monitoring/auth-monitor/index.tsx index 93202ba3..1e40e6d0 100644 --- a/ui/src/components/monitoring/auth-monitor/index.tsx +++ b/ui/src/components/monitoring/auth-monitor/index.tsx @@ -5,6 +5,7 @@ */ import { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { STATUS_COLORS } from '@/lib/utils'; import { Skeleton } from '@/components/ui/skeleton'; import { AccountFlowViz } from '@/components/account-flow-viz'; @@ -20,6 +21,7 @@ import { SummaryCard } from './components/summary-card'; const STORAGE_KEY = 'auth-monitor-selected-provider'; export function AuthMonitor() { + const { t } = useTranslation(); const { accounts, totalSuccess, @@ -117,7 +119,7 @@ export function AuthMonitor() { Updated {timeSinceUpdate || 'now'}
| - {accounts.length} accounts + {t('authMonitor.accountsCount', { count: accounts.length })} {totalRequests.toLocaleString()} req
@@ -126,25 +128,25 @@ export function AuthMonitor() {
} - label="Accounts" + label={t('authMonitor.accounts')} value={accounts.length} color="var(--accent)" /> } - label="Success" + label={t('authMonitor.success')} value={totalSuccess.toLocaleString()} color={STATUS_COLORS.success} /> } - label="Failed" + label={t('authMonitor.failed')} value={totalFailure.toLocaleString()} color={totalFailure > 0 ? STATUS_COLORS.failed : undefined} /> } - label="Success Rate" + label={t('authMonitor.successRate')} value={`${overallSuccessRate}%`} color={ overallSuccessRate === 100 diff --git a/ui/src/components/monitoring/error-logs/index.tsx b/ui/src/components/monitoring/error-logs/index.tsx index 8f8cea22..2b5c6501 100644 --- a/ui/src/components/monitoring/error-logs/index.tsx +++ b/ui/src/components/monitoring/error-logs/index.tsx @@ -14,10 +14,12 @@ import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; import { Skeleton } from '@/components/ui/skeleton'; import { ScrollArea } from '@/components/ui/scroll-area'; import { AlertTriangle, FileWarning, GripVertical, GripHorizontal } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { ErrorLogItem } from './error-log-item'; import { LogContentPanel } from './log-content-panel'; export function ErrorLogsMonitor() { + const { t } = useTranslation(); const { data: status, isLoading: isStatusLoading } = useCliproxyStatus(); const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false); @@ -142,14 +144,14 @@ export function ErrorLogsMonitor() {
- Error Logs + {t('errorLogs.title')} - {errorCount} failed request{errorCount !== 1 ? 's' : ''} + {t('errorLogs.failedRequests', { count: errorCount })}
- CLIProxy Diagnostics + {t('errorLogs.cliproxyDiagnostics')}
@@ -175,7 +177,7 @@ export function ErrorLogsMonitor() {
{logs.length > 50 && (
- Showing 50 of {logs.length} logs + {t('errorLogs.showingCount', { total: logs.length })}
)} diff --git a/ui/src/components/monitoring/error-logs/log-content-panel.tsx b/ui/src/components/monitoring/error-logs/log-content-panel.tsx index 45804590..46fc5a72 100644 --- a/ui/src/components/monitoring/error-logs/log-content-panel.tsx +++ b/ui/src/components/monitoring/error-logs/log-content-panel.tsx @@ -8,12 +8,14 @@ import { useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats'; import { Skeleton } from '@/components/ui/skeleton'; import { CopyButton } from '@/components/ui/copy-button'; import { Terminal, Info, Code, ArrowUpRight, ArrowDownLeft, FileText } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { parseErrorLog } from '@/lib/error-log-parser'; import type { TabType, LogContentPanelProps } from './types'; import { TabButton, StatusBadge } from './ui-primitives'; import { OverviewTab, HeadersTab, BodyTab, RawTab } from './tab-components'; export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) { + const { t } = useTranslation(); const [activeTab, setActiveTab] = useState('overview'); const { data: content, isLoading, error } = useCliproxyErrorLogContent(name); @@ -29,7 +31,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
-

Select a log to view details

+

{t('errorLogs.selectLog')}

); @@ -51,7 +53,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) { if (error || !content || !parsed) { return (
-

Failed to load log content

+

{t('errorLogs.failedLoadContent')}

); } @@ -69,7 +71,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) { {name && ( @@ -80,7 +82,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) { {content && ( setActiveTab('overview')} icon={Info} > - Overview + {t('errorLogs.tabOverview')} setActiveTab('headers')} icon={Code} > - Headers + {t('errorLogs.tabHeaders')} setActiveTab('request')} icon={ArrowUpRight} > - Request + {t('errorLogs.tabRequest')} setActiveTab('response')} icon={ArrowDownLeft} > - Response + {t('errorLogs.tabResponse')} setActiveTab('raw')} icon={FileText}> - Raw + {t('errorLogs.tabRaw')}
diff --git a/ui/src/components/monitoring/error-logs/tab-components.tsx b/ui/src/components/monitoring/error-logs/tab-components.tsx index bba80c37..da2374b8 100644 --- a/ui/src/components/monitoring/error-logs/tab-components.tsx +++ b/ui/src/components/monitoring/error-logs/tab-components.tsx @@ -5,6 +5,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Info, Clock, Cpu, AlertTriangle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { getErrorTypeLabel, @@ -16,9 +17,11 @@ import { StatusBadge } from './ui-primitives'; /** Overview tab content */ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) { + const { t } = useTranslation(); const quotaResetDisplay = formatQuotaResetDelay(parsed.quotaResetDelay) || formatQuotaResetTimestamp(parsed.quotaResetTimestamp); + const na = t('errorLogs.na'); return ( @@ -37,7 +40,7 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
- Model: + {t('errorLogs.modelLabel')} {parsed.model} @@ -50,7 +53,7 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
- Quota resets in + {t('errorLogs.quotaResetsIn')} {quotaResetDisplay} @@ -61,37 +64,37 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) { {/* Key metrics grid */}
-
Method
-
{parsed.method || 'N/A'}
+
{t('errorLogs.method')}
+
{parsed.method || na}
-
Provider
-
{parsed.provider || 'N/A'}
+
{t('errorLogs.provider')}
+
{parsed.provider || na}
-
Version
-
{parsed.version || 'N/A'}
+
{t('errorLogs.version')}
+
{parsed.version || na}
-
Endpoint
+
{t('errorLogs.endpoint')}
- {parsed.endpoint || 'N/A'} + {parsed.endpoint || na}
{/* URL */}
-
URL
+
{t('errorLogs.url')}
- {parsed.url || 'N/A'} + {parsed.url || na}
{/* Timestamp */}
-
Timestamp
-
{parsed.timestamp || 'N/A'}
+
{t('errorLogs.timestamp')}
+
{parsed.timestamp || na}
{/* Actionable suggestion based on error type */} @@ -123,27 +126,14 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) { : 'text-blue-600 dark:text-blue-400' )} > - {parsed.errorType === 'rate_limit' && ( - <> - Rate Limited. Switch to a different account or wait for quota - reset. - {parsed.model && ( - <> - {' '} - Model {parsed.model} has - exhausted quota. - - )} - - )} - {parsed.errorType === 'auth' && - 'Authentication failed. Re-authenticate via CLIProxy Settings or check API key.'} - {parsed.errorType === 'not_found' && - 'Endpoint not found. This endpoint may not exist on this provider.'} - {parsed.errorType === 'server' && - 'Server error from upstream. Retry later or check provider status page.'} - {parsed.errorType === 'timeout' && - 'Request timed out. Check network connection or increase timeout settings.'} + {parsed.errorType === 'rate_limit' && + (parsed.model + ? t('errorLogs.rateLimitedMsg', { model: parsed.model }) + : t('errorLogs.rateLimitedMsgNoModel'))} + {parsed.errorType === 'auth' && t('errorLogs.authFailedMsg')} + {parsed.errorType === 'not_found' && t('errorLogs.notFoundMsg')} + {parsed.errorType === 'server' && t('errorLogs.serverErrorMsg')} + {parsed.errorType === 'timeout' && t('errorLogs.timeoutMsg')}
)} @@ -178,8 +168,10 @@ export function HeadersTab({ headers }: { headers: Record }) { /** JSON/Body tab content */ export function BodyTab({ content, label }: { content: string; label: string }) { + const { t } = useTranslation(); if (!content || content.trim() === '') { - return
No {label.toLowerCase()} body
; + const key = label === 'Request' ? 'errorLogs.noRequestBody' : 'errorLogs.noResponseBody'; + return
{t(key)}
; } // Try to format as JSON diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index 043a3c5f..6e3c2adc 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -51,6 +51,7 @@ import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useQuery, useIsMutating } from '@tanstack/react-query'; import { api, type CliproxyServerConfig } from '@/lib/api-client'; +import { Trans, useTranslation } from 'react-i18next'; import { useProxyStatus, useStartProxy, @@ -89,15 +90,17 @@ function formatUptime(startedAt?: string): string { return `${minutes}m`; } -function formatTimeAgo(timestamp?: number): string { +function formatTimeAgo( + timestamp: number | undefined, + t: (key: string, opts?: { count?: number }) => string +): string { if (!timestamp) return ''; const diff = Date.now() - timestamp; const minutes = Math.floor(diff / (1000 * 60)); const hours = Math.floor(diff / (1000 * 60 * 60)); - - if (minutes < 1) return 'just now'; - if (minutes < 60) return `${minutes}m ago`; - return `${hours}h ago`; + if (minutes < 1) return t('proxyStatusWidget.justNow'); + if (minutes < 60) return t('proxyStatusWidget.minutesAgo', { count: minutes }); + return t('proxyStatusWidget.hoursAgo', { count: hours }); } /** Icon button with tooltip wrapper */ @@ -148,6 +151,7 @@ function IconButton({ } export function ProxyStatusWidget() { + const { t } = useTranslation(); const { data: status, isLoading } = useProxyStatus(); const { data: updateCheck } = useCliproxyUpdateCheck(); const { data: versionsData, isLoading: versionsLoading } = useCliproxyVersions(); @@ -196,7 +200,9 @@ export function ProxyStatusWidget() { // Sync status const isSyncConfigured = syncStatus?.configured ?? false; - const syncStatusText = isSyncConfigured ? 'Sync Ready' : 'No Config'; + const syncStatusText = isSyncConfigured + ? t('proxyStatusWidget.syncReady') + : t('proxyStatusWidget.noConfig'); // Target version for update/downgrade badge const targetVersion = isUnstable @@ -257,12 +263,12 @@ export function ProxyStatusWidget() {
- Remote Proxy + {t('proxyStatusWidget.remoteProxy')} - Active + {t('proxyStatusWidget.active')}
@@ -273,7 +279,7 @@ export function ProxyStatusWidget() { {remoteDisplayHost}

- Traffic auto-routed to remote server + {t('proxyStatusWidget.trafficAutoRouted')}

@@ -310,21 +316,21 @@ export function ProxyStatusWidget() { <> executeSync()} disabled={!isSyncConfigured || isActioning} isPending={isSyncing} /> restartProxy.mutate()} disabled={isActioning} isPending={restartProxy.isPending} /> stopProxy.mutate()} disabled={isActioning} isPending={stopProxy.isPending} @@ -335,7 +341,11 @@ export function ProxyStatusWidget() { {/* Settings button always visible */} setIsExpanded(!isExpanded)} className={isExpanded ? 'bg-muted' : undefined} /> @@ -363,7 +373,11 @@ export function ProxyStatusWidget() { : 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50' )} onClick={() => handleInstallVersion(targetVersion)} - title={`Click to ${isUnstable ? 'downgrade' : 'update'}`} + title={ + isUnstable + ? t('proxyStatusWidget.clickToDowngrade') + : t('proxyStatusWidget.clickToUpdate') + } > {isUnstable ? ( @@ -379,11 +393,13 @@ export function ProxyStatusWidget() { {/* Stats row when running */} {isRunning && status && (
- Port {status.port} + + {t('proxyStatusWidget.port', { port: status.port })} + {status.sessionCount !== undefined && status.sessionCount > 0 && ( - {status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''} + {t('proxyStatusWidget.sessionCount', { count: status.sessionCount })} )} {status.startedAt && ( @@ -416,7 +432,9 @@ export function ProxyStatusWidget() { {/* Section header */} -

Version Management

+

+ {t('proxyStatusWidget.versionManagement')} +

{/* Version picker row */}
@@ -427,7 +445,7 @@ export function ProxyStatusWidget() { disabled={versionsLoading} > - + {versionsData?.versions.slice(0, 20).map((v) => { @@ -439,7 +457,9 @@ export function ProxyStatusWidget() { v{v} {v === versionsData.latestStable && ( - (stable) + + {t('proxyStatusWidget.stable')} + )} {vIsUnstable && ( ⚠ @@ -464,7 +484,7 @@ export function ProxyStatusWidget() { ) : ( )} - Install + {t('proxyStatusWidget.install')}
@@ -474,14 +494,20 @@ export function ProxyStatusWidget() { isNewerVersionClient(selectedVersion, versionsData.maxStableVersion) && (
- Versions above {versionsData.maxStableVersion} have known issues + + {t('proxyStatusWidget.versionsAboveUnstable', { + version: versionsData.maxStableVersion, + })} +
)} {/* Sync time */} {updateCheck?.checkedAt && (
- Last checked {formatTimeAgo(updateCheck.checkedAt)} + {t('proxyStatusWidget.lastChecked', { + time: formatTimeAgo(updateCheck.checkedAt, t), + })}
)}
@@ -490,7 +516,9 @@ export function ProxyStatusWidget() { {/* Not running state */} {!isRunning && (
- Not running + + {t('proxyStatusWidget.notRunning')} +
)} @@ -514,27 +542,34 @@ export function ProxyStatusWidget() { - Install Unstable Version? + {t('proxyStatusWidget.installUnstableTitle')}

- You are about to install v{pendingInstallVersion}, which is above - the maximum stable version{' '} - v{versionsData?.maxStableVersion || '6.6.80'}. + }} + />

- This version has known stability issues and may cause unexpected behavior. + {t('proxyStatusWidget.installUnstableWarning')}

-

Are you sure you want to proceed?

+

{t('proxyStatusWidget.installUnstableConfirm')}

- Cancel + + {t('proxyStatusWidget.cancel')} + - Install Anyway + {t('proxyStatusWidget.installAnyway')} diff --git a/ui/src/components/profiles/alibaba-coding-plan-promo-card.tsx b/ui/src/components/profiles/alibaba-coding-plan-promo-card.tsx new file mode 100644 index 00000000..cd13282f --- /dev/null +++ b/ui/src/components/profiles/alibaba-coding-plan-promo-card.tsx @@ -0,0 +1,43 @@ +/** + * Alibaba Coding Plan Promo Card + * Permanent promotional card for Alibaba Coding Plan in providers sidebar. + */ + +import { Button } from '@/components/ui/button'; +import { CloudCog } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +interface AlibabaCodingPlanPromoCardProps { + onCreateClick: () => void; +} + +export function AlibabaCodingPlanPromoCard({ onCreateClick }: AlibabaCodingPlanPromoCardProps) { + const { t } = useTranslation(); + + return ( +
+
+
+ +
+
+

+ {t('alibabaCodingPlanPromo.title')} +

+

+ {t('alibabaCodingPlanPromo.subtitle')} +

+
+ +
+
+ ); +} diff --git a/ui/src/components/profiles/editor/env-editor-section.tsx b/ui/src/components/profiles/editor/env-editor-section.tsx index f9ff8c30..1a42f09f 100644 --- a/ui/src/components/profiles/editor/env-editor-section.tsx +++ b/ui/src/components/profiles/editor/env-editor-section.tsx @@ -12,6 +12,7 @@ import { MaskedInput } from '@/components/ui/masked-input'; import { Plus } from 'lucide-react'; import { isSensitiveKey } from './utils'; import type { Settings } from './types'; +import { useTranslation } from 'react-i18next'; interface EnvEditorSectionProps { currentSettings: Settings | undefined; @@ -32,6 +33,7 @@ export function EnvEditorSection({ onEnvValueChange, onAddEnvVar, }: EnvEditorSectionProps) { + const { t } = useTranslation(); return ( <> {/* Scrollable Environment Variables List */} @@ -45,7 +47,7 @@ export function EnvEditorSection({ {key} {isSensitiveKey(key) && ( - sensitive + {t('envEditor.sensitive')} )} @@ -67,10 +69,8 @@ export function EnvEditorSection({ ) : (
-

No environment variables configured.

-

- Add variables using the input below or edit the JSON directly. -

+

{t('envEditor.none')}

+

{t('envEditor.noneHint')}

)}
@@ -79,18 +79,18 @@ export function EnvEditorSection({ {/* Fixed Add Input at Bottom */}
onNewEnvKeyChange(e.target.value.toUpperCase())} className="font-mono text-sm h-8 w-2/5" onKeyDown={(e) => e.key === 'Enter' && newEnvKey.trim() && onAddEnvVar()} /> onNewEnvValueChange(e.target.value)} className="font-mono text-sm h-8 flex-1" diff --git a/ui/src/components/profiles/editor/friendly-ui-section.tsx b/ui/src/components/profiles/editor/friendly-ui-section.tsx index 7a5bdeed..10cec625 100644 --- a/ui/src/components/profiles/editor/friendly-ui-section.tsx +++ b/ui/src/components/profiles/editor/friendly-ui-section.tsx @@ -19,6 +19,7 @@ import { ChevronRight, Settings2, Plus } from 'lucide-react'; import { isOpenRouterProfile, extractTierMapping, applyTierMapping } from './utils'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; +import i18n from '@/lib/i18n'; import type { Settings, SettingsResponse } from './types'; import type { CliTarget } from '@/lib/api-client'; @@ -81,7 +82,7 @@ export function FriendlyUISection({ onEnvValueChange('ANTHROPIC_DEFAULT_HAIKU_MODEL', modelId); } // Show feedback toast - toast.success('Applied model to all tiers', { duration: 2000 }); + toast.success(i18n.t('commonToast.appliedModelAllTiers'), { duration: 2000 }); }; // Handle tier mapping change diff --git a/ui/src/components/profiles/editor/index.tsx b/ui/src/components/profiles/editor/index.tsx index 25bb4d37..d13c4353 100644 --- a/ui/src/components/profiles/editor/index.tsx +++ b/ui/src/components/profiles/editor/index.tsx @@ -16,6 +16,7 @@ import { FriendlyUISection } from './friendly-ui-section'; import { RawEditorSection } from './raw-editor-section'; import type { ProfileEditorProps, Settings, SettingsResponse } from './types'; import { api, type CliTarget } from '@/lib/api-client'; +import i18n from '@/lib/i18n'; export function ProfileEditor({ profileName, @@ -143,7 +144,7 @@ export function ProfileEditor({ queryClient.invalidateQueries({ queryKey: ['profiles'] }); setLocalEdits({}); setRawJsonEdits(null); - toast.success('Settings saved'); + toast.success(i18n.t('commonToast.settingsSaved')); }, onError: (error: Error) => { if (error.message === 'CONFLICT') setConflictDialog(true); @@ -158,12 +159,12 @@ export function ProfileEditor({ }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['profiles'] }); - toast.success('Default target updated'); + toast.success(i18n.t('commonToast.defaultTargetUpdated')); }, onError: (error: Error, target: CliTarget) => { const targetLabel = target === 'droid' ? 'Factory Droid' : 'Claude Code'; const suffix = error.message.trim() ? `: ${error.message}` : ''; - toast.error(`Failed to update default target to ${targetLabel}${suffix}`); + toast.error(i18n.t('commonToast.failedUpdateDefaultTarget', { target: targetLabel, suffix })); }, }); diff --git a/ui/src/components/profiles/editor/info-section.tsx b/ui/src/components/profiles/editor/info-section.tsx index acfc6b1c..38ee6916 100644 --- a/ui/src/components/profiles/editor/info-section.tsx +++ b/ui/src/components/profiles/editor/info-section.tsx @@ -3,6 +3,7 @@ * Displays profile information and usage commands */ +import { useTranslation } from 'react-i18next'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Label } from '@/components/ui/label'; import { CopyButton } from '@/components/ui/copy-button'; @@ -17,6 +18,7 @@ interface InfoSectionProps { } export function InfoSection({ profileName, target, data }: InfoSectionProps) { + const { t } = useTranslation(); const isDroidTarget = target === 'droid'; return ( @@ -26,17 +28,21 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {

- Profile Information + {t('profileEditor.profileInfo')}

{data && ( <>
- Profile Name + + {t('profileEditor.profileName')} + {data.profile}
- File Path + + {t('profileEditor.filePath')} +
{data.path} @@ -45,11 +51,15 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
- Last Modified + + {t('profileEditor.lastModified')} + {new Date(data.mtime).toLocaleString()}
- Default Target + + {t('profileEditor.defaultTarget')} + {target}
@@ -59,10 +69,12 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) { {/* Usage */}
-

Quick Usage

+

{t('profileEditor.quickUsage')}

- +
ccs {profileName} "prompt" @@ -72,7 +84,9 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
@@ -93,7 +107,9 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
@@ -107,7 +123,9 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
- +
ccs default {profileName} diff --git a/ui/src/components/profiles/index.ts b/ui/src/components/profiles/index.ts index 432cbca3..d3724660 100644 --- a/ui/src/components/profiles/index.ts +++ b/ui/src/components/profiles/index.ts @@ -19,5 +19,6 @@ export { OpenRouterBanner } from './openrouter-banner'; export { OpenRouterModelPicker } from './openrouter-model-picker'; export { OpenRouterPromoCard } from './openrouter-promo-card'; export { OpenRouterQuickStart } from './openrouter-quick-start'; +export { AlibabaCodingPlanPromoCard } from './alibaba-coding-plan-promo-card'; export { ModelTierMapping } from './model-tier-mapping'; export type { TierMapping } from './model-tier-mapping'; diff --git a/ui/src/components/profiles/model-tier-mapping.tsx b/ui/src/components/profiles/model-tier-mapping.tsx index 712e43c0..2cd87fbc 100644 --- a/ui/src/components/profiles/model-tier-mapping.tsx +++ b/ui/src/components/profiles/model-tier-mapping.tsx @@ -12,6 +12,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models'; import { suggestTierMappings } from '@/lib/openrouter-utils'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; export interface TierMapping { opus?: string; @@ -32,6 +33,7 @@ export function ModelTierMapping({ onChange, className, }: ModelTierMappingProps) { + const { t } = useTranslation(); const { models } = useOpenRouterCatalog(); const suggestions = useMemo(() => { @@ -53,18 +55,18 @@ export function ModelTierMapping({ - Model Tier Mapping - (Advanced) + {t('modelTierMapping.title')} + + ({t('modelTierMapping.advanced')}) + -

- Configure different models for Claude Code's opus/sonnet/haiku tiers. -

+

{t('modelTierMapping.description')}

{hasSuggestions && ( )} @@ -77,7 +79,7 @@ export function ModelTierMapping({ id="tier-opus" value={value.opus ?? ''} onChange={(e) => updateTier('opus', e.target.value)} - placeholder="e.g., anthropic/claude-opus-4" + placeholder={t('modelTierMapping.opusPlaceholder')} />
@@ -88,7 +90,7 @@ export function ModelTierMapping({ id="tier-sonnet" value={value.sonnet ?? ''} onChange={(e) => updateTier('sonnet', e.target.value)} - placeholder="e.g., anthropic/claude-sonnet-4" + placeholder={t('modelTierMapping.sonnetPlaceholder')} />
@@ -99,15 +101,12 @@ export function ModelTierMapping({ id="tier-haiku" value={value.haiku ?? ''} onChange={(e) => updateTier('haiku', e.target.value)} - placeholder="e.g., anthropic/claude-3.5-haiku" + placeholder={t('modelTierMapping.haikuPlaceholder')} />
-

- These set ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL, - ANTHROPIC_DEFAULT_HAIKU_MODEL. -

+

{t('modelTierMapping.footer')}

); diff --git a/ui/src/components/profiles/openrouter-quick-start.tsx b/ui/src/components/profiles/openrouter-quick-start.tsx index 3d9260dc..76c75c88 100644 --- a/ui/src/components/profiles/openrouter-quick-start.tsx +++ b/ui/src/components/profiles/openrouter-quick-start.tsx @@ -8,17 +8,21 @@ 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 } from 'lucide-react'; +import { Sparkles, ExternalLink, ArrowRight, Zap, CloudCog, KeyRound } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; interface OpenRouterQuickStartProps { onOpenRouterClick: () => void; + onAlibabaCodingPlanClick: () => void; onCustomClick: () => void; } export function OpenRouterQuickStart({ onOpenRouterClick, + onAlibabaCodingPlanClick, onCustomClick, }: OpenRouterQuickStartProps) { + const { t } = useTranslation(); const { modelCount, isLoading } = useOpenRouterReady(); return ( @@ -35,13 +39,14 @@ export function OpenRouterQuickStart({ variant="secondary" className="bg-accent/10 text-accent dark:bg-accent/20 dark:text-accent-foreground" > - Recommended + {t('openrouterQuickStart.recommended')}
- Start with OpenRouter + {t('openrouterQuickStart.title')} - Access {isLoading ? '300+' : `${modelCount}+`} models from OpenAI, Anthropic, Google, - Meta and more - all through one API. + {t('openrouterQuickStart.description', { + modelCountLabel: isLoading ? '300+' : `${modelCount}+`, + })} @@ -49,11 +54,11 @@ export function OpenRouterQuickStart({
- One API, all providers + {t('openrouterQuickStart.featureOneApi')}
- Model tier mapping + {t('openrouterQuickStart.featureTierMapping')}
@@ -62,12 +67,12 @@ export function OpenRouterQuickStart({ className="w-full bg-accent hover:bg-accent/90 text-white" size="lg" > - Create OpenRouter Profile + {t('openrouterQuickStart.createOpenRouterProfile')}

- Get your API key at{' '} + {t('openrouterQuickStart.getApiKeyAt')}{' '} + {/* Alibaba Coding Plan Card */} + + +

+ {t('alibabaCodingPlanQuickStart.title')} + + {t('alibabaCodingPlanQuickStart.description')} + + + +
+
+ + {t('alibabaCodingPlanQuickStart.featureEndpoint')} +
+
+ + {t('alibabaCodingPlanQuickStart.featureKeyFormat')} +
+
+ + + +

+ {t('alibabaCodingPlanQuickStart.readGuideAt')}{' '} + + Alibaba Cloud Model Studio + + +

+
+ + {/* Divider */}
- or + {t('openrouterQuickStart.or')}
{/* Custom Option */}
diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index 510e165a..0131b28a 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -11,7 +11,6 @@ import * as z from 'zod'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { ScrollArea } from '@/components/ui/scroll-area'; import { Select, SelectContent, @@ -32,6 +31,7 @@ import { Badge } from '@/components/ui/badge'; import { useCreateProfile } from '@/hooks/use-profiles'; import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models'; import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff, Settings2, Sparkles } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { @@ -49,6 +49,7 @@ import { } from '@/lib/openrouter-utils'; import type { CategorizedModel } from '@/lib/openrouter-types'; import type { CliTarget } from '@/lib/api-client'; +import i18n from '@/lib/i18n'; const schema = z.object({ name: z @@ -71,7 +72,7 @@ interface ProfileCreateDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onSuccess: (name: string) => void; - initialMode?: 'normal' | 'openrouter'; + initialMode?: 'normal' | 'openrouter' | 'alibaba-coding-plan'; } // Common URL mistakes to warn about @@ -104,6 +105,7 @@ export function ProfileCreateDialog({ onSuccess, initialMode = 'openrouter', }: ProfileCreateDialogProps) { + const { t } = useTranslation(); const createMutation = useCreateProfile(); const [activeTab, setActiveTab] = useState('basic'); const [urlWarning, setUrlWarning] = useState(null); @@ -178,7 +180,8 @@ export function ProfileCreateDialog({ setSelectedPreset(CUSTOM_PRESET_ID); applyPresetToForm(null); } else { - const defaultPreset = getPresetById(DEFAULT_PRESET_ID); + const presetId = initialMode === 'openrouter' ? DEFAULT_PRESET_ID : initialMode; + const defaultPreset = getPresetById(presetId); if (defaultPreset) { setSelectedPreset(defaultPreset.id); applyPresetToForm(defaultPreset); @@ -242,7 +245,7 @@ export function ProfileCreateDialog({ const onSubmit = async (data: FormData) => { // Validate API key - required unless preset has requiresApiKey: false if (currentPreset?.requiresApiKey !== false && !data.apiKey) { - toast.error('API key is required'); + toast.error(i18n.t('commonToast.apiKeyRequired')); return; } // Use user-provided baseUrl (allows customization of preset URLs) @@ -271,7 +274,7 @@ export function ProfileCreateDialog({ return ( - + @@ -290,7 +293,9 @@ export function ProfileCreateDialog({
{/* Main Options: OpenRouter + Custom */}
- +
{RECOMMENDED_PRESETS.map((preset) => ( - Custom + {t('profileEditor.custom')}
@@ -359,7 +364,7 @@ export function ProfileCreateDialog({
- +
{/* Profile Name */}
@@ -603,7 +608,7 @@ export function ProfileCreateDialog({
-
+
diff --git a/ui/src/components/setup/wizard/index.tsx b/ui/src/components/setup/wizard/index.tsx index 0b894eff..16576155 100644 --- a/ui/src/components/setup/wizard/index.tsx +++ b/ui/src/components/setup/wizard/index.tsx @@ -24,6 +24,7 @@ import { import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; import type { CLIProxyProvider } from '@/lib/provider-config'; import { applyDefaultPreset } from '@/lib/preset-utils'; +import i18n from '@/lib/i18n'; import { usePrivacy } from '@/contexts/privacy-context'; import { toast } from 'sonner'; @@ -100,7 +101,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { if (result.success && result.presetName) { toast.success(`Applied "${result.presetName}" preset`); } else if (!result.success) { - toast.warning('Account added, but failed to apply default preset'); + toast.warning(i18n.t('commonToast.accountAddedPresetFailed')); } } diff --git a/ui/src/components/setup/wizard/steps/variant-step.tsx b/ui/src/components/setup/wizard/steps/variant-step.tsx index 97e99166..1e185081 100644 --- a/ui/src/components/setup/wizard/steps/variant-step.tsx +++ b/ui/src/components/setup/wizard/steps/variant-step.tsx @@ -14,10 +14,11 @@ import { SelectValue, } from '@/components/ui/select'; import { ArrowLeft, User } from 'lucide-react'; -import { cn } from '@/lib/utils'; +import { cn, isDeniedAgyModelId } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { MODEL_CATALOGS } from '@/lib/model-catalogs'; import type { VariantStepProps } from '../types'; +import { useTranslation } from 'react-i18next'; const CUSTOM_MODEL_VALUE = '__custom__'; @@ -34,10 +35,15 @@ export function VariantStep({ onSkip, onCreate, }: VariantStepProps) { + const { t } = useTranslation(); // Track if user selected custom model option const catalogModels = MODEL_CATALOGS[selectedProvider]?.models || []; const isCustomModel = modelName && !catalogModels.some((m) => m.id === modelName); const [showCustomInput, setShowCustomInput] = useState(isCustomModel); + const deniedCustomModel = + selectedProvider === 'agy' && modelName.trim().length > 0 + ? isDeniedAgyModelId(modelName) + : false; const handleModelSelect = (value: string) => { if (value === CUSTOM_MODEL_VALUE) { @@ -55,7 +61,7 @@ export function VariantStep({
- Using:{' '} + {t('setupVariant.using')}{' '} {selectedAccount.email || selectedAccount.id} @@ -64,33 +70,43 @@ export function VariantStep({ )}
- + onVariantNameChange(e.target.value)} - placeholder="e.g., my-gemini, g3, flash" + placeholder={t('setupVariant.variantNamePlaceholder')} />
- Use this name to invoke: ccs {variantName || ''} "prompt" + {t('setupVariant.invokeHintPrefix')} ccs {variantName || ''} "prompt"
- + {showCustomInput ? (
onModelChange(e.target.value)} - placeholder="e.g., gemini-2.5-pro, claude-opus-4-5" + placeholder={t('setupVariant.modelPlaceholder')} /> + {deniedCustomModel && ( +

+ Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated. +

+ )}
) : ( @@ -99,7 +115,7 @@ export function VariantStep({ onValueChange={handleModelSelect} > - + {catalogModels.map((m) => ( @@ -113,35 +129,37 @@ export function VariantStep({ ))} - Custom model name... + {t('setupVariant.customModelName')} )}
{showCustomInput - ? 'Enter any model name supported by CLIProxy' - : `Default: ${MODEL_CATALOGS[selectedProvider]?.defaultModel || 'provider default'}`} + ? t('setupVariant.enterAnyModel') + : t('setupVariant.defaultModel', { + model: + MODEL_CATALOGS[selectedProvider]?.defaultModel || + t('setupVariant.providerDefault'), + })}
-
-

- Skip if you just wanted to add an account without creating a variant -

+

{t('setupVariant.skipHint')}

); } diff --git a/ui/src/components/shared/device-code-dialog.tsx b/ui/src/components/shared/device-code-dialog.tsx index 809f4a43..9183be6a 100644 --- a/ui/src/components/shared/device-code-dialog.tsx +++ b/ui/src/components/shared/device-code-dialog.tsx @@ -21,6 +21,7 @@ import { getDeviceCodeProviderDisplayName, getDeviceCodeProviderInstruction, } from '@/lib/provider-config'; +import i18n from '@/lib/i18n'; interface DeviceCodeDialogProps { open: boolean; @@ -72,10 +73,10 @@ export function DeviceCodeDialog({ try { await navigator.clipboard.writeText(userCode); setHasCopied(true); - toast.success('Code copied to clipboard'); + toast.success(i18n.t('commonToast.codeCopied')); setTimeout(() => setHasCopied(false), 2000); } catch { - toast.error('Failed to copy code'); + toast.error(i18n.t('commonToast.copyCodeFailed')); } }, [userCode]); diff --git a/ui/src/components/shared/settings-dialog.tsx b/ui/src/components/shared/settings-dialog.tsx index f0f6c760..faeffb54 100644 --- a/ui/src/components/shared/settings-dialog.tsx +++ b/ui/src/components/shared/settings-dialog.tsx @@ -20,6 +20,7 @@ import { MaskedInput } from '@/components/ui/masked-input'; import { ConfirmDialog } from './confirm-dialog'; import { Save, X, Loader2, Code2 } from 'lucide-react'; import { toast } from 'sonner'; +import i18n from '@/lib/i18n'; // Lazy load CodeEditor to reduce initial bundle size const CodeEditor = lazy(() => import('./code-editor').then((m) => ({ default: m.CodeEditor }))); @@ -154,7 +155,7 @@ function SettingsDialogContent({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['settings', profileName] }); queryClient.invalidateQueries({ queryKey: ['profiles'] }); - toast.success('Settings saved'); + toast.success(i18n.t('commonToast.settingsSaved')); onClose(); }, onError: (error: Error) => { diff --git a/ui/src/components/updates/notice-progress-badge.tsx b/ui/src/components/updates/notice-progress-badge.tsx index 5b6c77ec..fa53c37c 100644 --- a/ui/src/components/updates/notice-progress-badge.tsx +++ b/ui/src/components/updates/notice-progress-badge.tsx @@ -1,33 +1,7 @@ import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { type NoticeProgressState } from '@/lib/updates-notice-state'; - -const NOTICE_PROGRESS_META: Record< - NoticeProgressState, - { label: string; className: string; showDot?: boolean } -> = { - new: { - label: 'Needs Action', - className: - 'border-amber-300/70 bg-amber-100/70 text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-300', - showDot: true, - }, - seen: { - label: 'In Review', - className: - 'border-blue-300/70 bg-blue-100/70 text-blue-800 dark:border-blue-500/40 dark:bg-blue-500/15 dark:text-blue-300', - }, - done: { - label: 'Done', - className: - 'border-emerald-300/70 bg-emerald-100/70 text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/15 dark:text-emerald-300', - }, - dismissed: { - label: 'Dismissed', - className: - 'border-muted-foreground/20 bg-muted text-muted-foreground dark:border-muted-foreground/30', - }, -}; +import { useTranslation } from 'react-i18next'; export function NoticeProgressBadge({ state, @@ -36,7 +10,30 @@ export function NoticeProgressBadge({ state: NoticeProgressState; className?: string; }) { - const meta = NOTICE_PROGRESS_META[state]; + const { t } = useTranslation(); + const meta = { + new: { + label: t('updates.progressNeedsAction'), + className: + 'border-amber-300/70 bg-amber-100/70 text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-300', + showDot: true, + }, + seen: { + label: t('updates.progressInReview'), + className: + 'border-blue-300/70 bg-blue-100/70 text-blue-800 dark:border-blue-500/40 dark:bg-blue-500/15 dark:text-blue-300', + }, + done: { + label: t('updates.progressDone'), + className: + 'border-emerald-300/70 bg-emerald-100/70 text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/15 dark:text-emerald-300', + }, + dismissed: { + label: t('updates.progressDismissed'), + className: + 'border-muted-foreground/20 bg-muted text-muted-foreground dark:border-muted-foreground/30', + }, + }[state] as { label: string; className: string; showDot?: boolean }; return ( = { target: @@ -27,6 +22,13 @@ const SCOPE_STYLES: Record = { }; export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) { + const { t } = useTranslation(); + const pillarLabels: { key: keyof CliSupportEntry['pillars']; label: string }[] = [ + { key: 'baseUrl', label: 'Base URL' }, + { key: 'auth', label: 'Auth' }, + { key: 'model', label: 'Model' }, + ]; + return ( @@ -45,7 +47,7 @@ export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
- {PILLAR_LABELS.map((pillar) => ( + {pillarLabels.map((pillar) => (

{pillar.label} @@ -57,7 +59,7 @@ export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {

- Dashboard + {t('updates.dashboard')}

{entry.routes.map((route) => ( @@ -75,7 +77,7 @@ export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {

- CLI Usage + {t('updates.cliUsage')}

{entry.commands.slice(0, 2).map((command) => ( diff --git a/ui/src/components/updates/support-status-badge.tsx b/ui/src/components/updates/support-status-badge.tsx index 2ab10cd8..a150912c 100644 --- a/ui/src/components/updates/support-status-badge.tsx +++ b/ui/src/components/updates/support-status-badge.tsx @@ -1,12 +1,7 @@ import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import type { SupportStatus } from '@/lib/support-updates-catalog'; - -const STATUS_LABELS: Record = { - new: 'New', - stable: 'Stable', - planned: 'Planned', -}; +import { useTranslation } from 'react-i18next'; const STATUS_STYLES: Record = { new: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-300', @@ -23,9 +18,16 @@ export function SupportStatusBadge({ status: SupportStatus; className?: string; }) { + const { t } = useTranslation(); + const labels: Record = { + new: t('updates.statusNew'), + stable: t('updates.statusStable'), + planned: t('updates.statusPlanned'), + }; + return ( - {STATUS_LABELS[status]} + {labels[status]} ); } diff --git a/ui/src/components/updates/updates-details-panel.tsx b/ui/src/components/updates/updates-details-panel.tsx index 8a7c107f..566b9b36 100644 --- a/ui/src/components/updates/updates-details-panel.tsx +++ b/ui/src/components/updates/updates-details-panel.tsx @@ -15,6 +15,7 @@ import { type SupportNotice, } from '@/lib/support-updates-catalog'; import { type NoticeProgressState } from '@/lib/updates-notice-state'; +import { useTranslation } from 'react-i18next'; type UpdatableNoticeProgress = 'new' | 'seen' | 'done' | 'dismissed'; @@ -29,10 +30,11 @@ export function UpdatesDetailsPanel({ relatedEntries: CliSupportEntry[]; onUpdateProgress: (nextState: UpdatableNoticeProgress) => void; }) { + const { t } = useTranslation(); if (!notice) { return (
-

No updates available.

+

{t('updates.noUpdates')}

); } @@ -53,21 +55,21 @@ export function UpdatesDetailsPanel({
- Published {formatCatalogDate(notice.publishedAt)} + {t('updates.published', { date: formatCatalogDate(notice.publishedAt) })}
@@ -78,7 +80,7 @@ export function UpdatesDetailsPanel({
- Do Next + {t('updates.doNext')}
{notice.primaryAction}
@@ -96,8 +98,8 @@ export function UpdatesDetailsPanel({
- Impacted Integrations - Related areas based on update scope and routing. + {t('updates.impactedIntegrations')} + {t('updates.impactedDesc')} @@ -134,10 +136,8 @@ export function UpdatesDetailsPanel({ - Why It Matters - - Short context only, no wall-of-text release notes. - + {t('updates.whyMatters')} + {t('updates.whyMattersDesc')} diff --git a/ui/src/components/updates/updates-notice-action-row.tsx b/ui/src/components/updates/updates-notice-action-row.tsx index 0ac130f0..e2f27146 100644 --- a/ui/src/components/updates/updates-notice-action-row.tsx +++ b/ui/src/components/updates/updates-notice-action-row.tsx @@ -3,8 +3,10 @@ import { ArrowUpRight, Terminal } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { CopyButton } from '@/components/ui/copy-button'; import { type SupportNoticeAction } from '@/lib/support-updates-catalog'; +import { useTranslation } from 'react-i18next'; export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction }) { + const { t } = useTranslation(); return (
@@ -16,7 +18,7 @@ export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction {action.type === 'route' && action.path && ( diff --git a/ui/src/lib/error-log-parser.ts b/ui/src/lib/error-log-parser.ts index ff2116b9..66b4207d 100644 --- a/ui/src/lib/error-log-parser.ts +++ b/ui/src/lib/error-log-parser.ts @@ -1,3 +1,5 @@ +import { getFormattingLocale } from '@/lib/locales'; + /** * Error Log Parser Utility * @@ -394,7 +396,10 @@ function getStatusText(code: number): string { /** * Format Unix timestamp (seconds) to relative time string */ -export function formatRelativeTime(modifiedSeconds: number): string { +export function formatRelativeTime(modifiedSeconds: number, locale?: string): string { + const formatLocale = getFormattingLocale(locale); + const isZh = formatLocale === 'zh-CN'; + const isVi = formatLocale === 'vi'; const now = Date.now(); const modified = modifiedSeconds * 1000; // Convert to milliseconds const diff = now - modified; @@ -404,14 +409,30 @@ export function formatRelativeTime(modifiedSeconds: number): string { const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); - if (seconds < 60) return 'just now'; - if (minutes < 60) return `${minutes}m ago`; - if (hours < 24) return `${hours}h ago`; - if (days < 7) return `${days}d ago`; + if (seconds < 60) { + if (isZh) return '刚刚'; + if (isVi) return 'vừa xong'; + return 'just now'; + } + if (minutes < 60) { + if (isZh) return `${minutes} 分钟前`; + if (isVi) return `${minutes} phút trước`; + return `${minutes}m ago`; + } + if (hours < 24) { + if (isZh) return `${hours} 小时前`; + if (isVi) return `${hours} giờ trước`; + return `${hours}h ago`; + } + if (days < 7) { + if (isZh) return `${days} 天前`; + if (isVi) return `${days} ngày trước`; + return `${days}d ago`; + } // Format as date for older logs const date = new Date(modified); - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + return date.toLocaleDateString(formatLocale, { month: 'short', day: 'numeric' }); } /** @@ -436,7 +457,31 @@ export function getStatusColor(code: number): string { /** * Get error type label */ -export function getErrorTypeLabel(type: ParsedErrorLog['errorType']): string { +export function getErrorTypeLabel(type: ParsedErrorLog['errorType'], locale?: string): string { + const formatLocale = getFormattingLocale(locale); + if (formatLocale === 'zh-CN') { + const labels: Record = { + rate_limit: '限流', + auth: '认证错误', + not_found: '未找到', + server: '服务错误', + timeout: '超时', + unknown: '错误', + }; + return labels[type] || '错误'; + } + if (formatLocale === 'vi') { + const labels: Record = { + rate_limit: 'Quá giới hạn', + auth: 'Lỗi xác thực', + not_found: 'Không tìm thấy', + server: 'Lỗi máy chủ', + timeout: 'Hết thời gian chờ', + unknown: 'Lỗi', + }; + return labels[type] || 'Lỗi'; + } + const labels: Record = { rate_limit: 'Rate Limited', auth: 'Auth Error', @@ -467,22 +512,40 @@ export function formatQuotaResetDelay(seconds: number | null): string | null { /** * Format quota reset timestamp as relative time */ -export function formatQuotaResetTimestamp(timestamp: string | null): string | null { +export function formatQuotaResetTimestamp( + timestamp: string | null, + locale?: string +): string | null { if (!timestamp) return null; + const formatLocale = getFormattingLocale(locale); + const isZh = formatLocale === 'zh-CN'; + const isVi = formatLocale === 'vi'; try { const resetDate = new Date(timestamp); const now = new Date(); const diff = resetDate.getTime() - now.getTime(); - if (diff <= 0) return 'now'; + if (diff <= 0) { + if (isZh) return '现在'; + if (isVi) return 'bây giờ'; + return 'now'; + } const seconds = Math.floor(diff / 1000); - if (seconds < 60) return `${seconds}s`; + if (seconds < 60) { + if (isZh) return `${seconds}秒`; + if (isVi) return `${seconds} giây`; + return `${seconds}s`; + } if (seconds < 3600) { const mins = Math.floor(seconds / 60); + if (isZh) return `${mins}分钟`; + if (isVi) return `${mins} phút`; return `${mins}m`; } const hours = Math.floor(seconds / 3600); const mins = Math.floor((seconds % 3600) / 60); + if (isZh) return mins > 0 ? `${hours}小时 ${mins}分钟` : `${hours}小时`; + if (isVi) return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`; return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; } catch { return null; diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts new file mode 100644 index 00000000..3d72b140 --- /dev/null +++ b/ui/src/lib/i18n.ts @@ -0,0 +1,3436 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import { getInitialLocale } from '@/lib/locales'; + +const resources = { + en: { + translation: { + locale: { + en: 'English', + 'zh-CN': 'Simplified Chinese', + vi: 'Vietnamese', + }, + layout: { + languageSwitcher: 'Language', + }, + nav: { + general: 'General', + home: 'Home', + analytics: 'Analytics', + identityAccess: 'Identity & Access', + apiProfiles: 'API Profiles', + cliproxyPlus: 'CLIProxy Plus', + cliproxyOverview: 'Overview', + controlPanel: 'Control Panel', + githubCopilot: 'GitHub Copilot', + cursorIde: 'Cursor IDE', + accounts: 'Accounts', + allAccounts: 'All Accounts', + sharedData: 'Shared Data', + compatibleClis: 'Compatible CLIs', + factoryDroid: 'Factory Droid', + system: 'System', + health: 'Health', + settings: 'Settings', + openrouterTooltip: 'Featured: OpenRouter + Alibaba Coding Plan + Ollama', + }, + home: { + profiles: 'Profiles', + cliproxy: 'CLIProxy', + accounts: 'Accounts', + health: 'Health', + configurationRequired: 'Configuration Required', + }, + auth: { + dashboardTitle: 'CCS Dashboard', + loginDescription: 'Enter your credentials to access the dashboard', + username: 'Username', + password: 'Password', + usernamePlaceholder: 'Enter username', + passwordPlaceholder: 'Enter password', + signIn: 'Sign In', + signingIn: 'Signing in...', + loginFailed: 'Login failed', + }, + commonToast: { + apiKeyRequired: 'API key is required', + accountAddedPresetFailed: 'Account added, but failed to apply default preset', + settingsCopied: 'Settings copied to clipboard', + enterPresetName: 'Please enter a preset name to save', + codeCopied: 'Code copied to clipboard', + copyCodeFailed: 'Failed to copy code', + appliedModelAllTiers: 'Applied model to all tiers', + settingsSaved: 'Settings saved', + defaultTargetUpdated: 'Default target updated', + failedUpdateDefaultTarget: 'Failed to update default target to {{target}}{{suffix}}', + }, + profileDialog: { + editTitle: 'Edit Profile', + createTitle: 'Create API Profile', + name: 'Name', + namePlaceholder: 'my-api', + baseUrl: 'Base URL', + baseUrlPlaceholder: 'https://api.example.com', + apiKey: 'API Key', + defaultModel: 'Default Model (ANTHROPIC_MODEL)', + defaultModelHint: 'Leave blank to use: {{model}}', + modelMappingTitle: 'Model Mapping (Opus/Sonnet/Haiku)', + modelMappingDesc: + 'Configure different model IDs for each tier. Useful for API proxies that route different model types to different backends.', + opusModel: 'Opus Model (ANTHROPIC_DEFAULT_OPUS_MODEL)', + sonnetModel: 'Sonnet Model (ANTHROPIC_DEFAULT_SONNET_MODEL)', + haikuModel: 'Haiku Model (ANTHROPIC_DEFAULT_HAIKU_MODEL)', + cancel: 'Cancel', + saving: 'Saving...', + update: 'Update', + create: 'Create', + }, + modelTierMapping: { + title: 'Model Tier Mapping', + advanced: 'Advanced', + description: "Configure different models for Claude Code's opus/sonnet/haiku tiers.", + autoSuggest: 'Auto-suggest based on {{provider}}', + opusPlaceholder: 'e.g., anthropic/claude-opus-4', + sonnetPlaceholder: 'e.g., anthropic/claude-sonnet-4', + haikuPlaceholder: 'e.g., anthropic/claude-3.5-haiku', + footer: + 'These set ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_DEFAULT_HAIKU_MODEL.', + }, + envEditor: { + sensitive: 'sensitive', + none: 'No environment variables configured.', + noneHint: 'Add variables using the input below or edit the JSON directly.', + addVariable: 'Add Environment Variable', + keyPlaceholder: 'VARIABLE_NAME', + valuePlaceholder: 'value', + }, + setupVariant: { + using: 'Using:', + variantNameRequired: 'Variant Name *', + variantNamePlaceholder: 'e.g., my-gemini, g3, flash', + invokeHintPrefix: 'Use this name to invoke:', + model: 'Model', + modelPlaceholder: 'e.g., gemini-2.5-pro, claude-opus-4-6-thinking', + choosePresetInstead: 'Choose from presets instead', + selectModel: 'Select a model', + customModelName: 'Custom model name...', + enterAnyModel: 'Enter any model name supported by CLIProxy', + defaultModel: 'Default: {{model}}', + providerDefault: 'provider default', + back: 'Back', + skip: 'Skip', + creating: 'Creating...', + createVariant: 'Create Variant', + skipHint: 'Skip if you just wanted to add an account without creating a variant', + }, + customPresetDialog: { + title: 'Custom Preset', + presetNameOptional: 'Preset Name (optional)', + presetNamePlaceholder: 'e.g., My Custom Config', + codexTipPrefix: 'Codex tip: suffixes', + codexTipSuffix: 'pin effort. Unsuffixed models use Thinking settings.', + defaultModel: 'Default Model', + defaultModelDesc: 'Used when no specific tier is requested', + opusModel: 'Opus (Most capable)', + opusModelDesc: 'For complex reasoning tasks', + sonnetModel: 'Sonnet (Balanced)', + sonnetModelDesc: 'Balance of speed and capability', + haikuModel: 'Haiku (Fast)', + haikuModelDesc: 'Quick responses for simple tasks', + cancel: 'Cancel', + savePreset: 'Save Preset', + applyPreset: 'Apply Preset', + }, + componentModelSelector: { + selectModel: 'Select model', + availableModelsCount: 'Available Models ({{count}})', + preview: 'Preview', + }, + copilotSettings: { + enableCopilot: 'Enable Copilot', + enableCopilotDesc: 'Allow using GitHub Copilot subscription', + basicSettings: 'Basic Settings', + accountType: 'Account Type', + accountTypeIndividual: 'Individual', + accountTypeBusiness: 'Business', + accountTypeEnterprise: 'Enterprise', + rateLimiting: 'Rate Limiting', + rateLimitSeconds: 'Rate Limit (seconds)', + noLimit: 'No limit', + waitOnRateLimit: 'Wait on Rate Limit', + waitOnRateLimitDesc: 'Wait instead of error when limit hit', + daemonSettings: 'Daemon Settings', + autoStartDaemon: 'Auto-start Daemon', + autoStartDaemonDesc: 'Start copilot-api when using profile', + }, + providerModelSelector: { + selectModel: 'Select model', + noModelsForProvider: 'No models available for this provider', + broken: 'BROKEN', + deprecated: 'DEPRECATED', + paid: 'PAID', + freeTier: 'Free Tier', + paidTier: 'Paid Tier', + modelKnownIssues: 'This model has known issues', + viewIssueDetails: 'View issue details', + modelDeprecated: 'This model is deprecated', + recommended: 'Recommended', + allModelsCount: 'All Models ({{count}})', + noModelsAvailable: 'No models available', + }, + createAuthProfileDialog: { + title: 'Create New Account', + description: + 'Auth profiles require Claude CLI login. Run the command below in your terminal. You can edit sync mode, group, and continuity depth later from the Accounts table.', + profileName: 'Profile Name', + profileNamePlaceholder: 'e.g., work, personal, client', + invalidProfileName: + 'Name must start with a letter and contain only letters, numbers, dashes, or underscores.', + enableSharedHistory: 'Enable shared history sync with other ccs auth accounts', + historySyncGroupOptional: 'History Sync Group (optional)', + historySyncGroupPlaceholder: 'default, sprint-a, client-x', + historySyncGroupHint: + 'Leave empty to use the default shared group. Spaces are normalized to dashes.', + deeperContinuity: 'Advanced: deeper continuity mode', + deeperContinuityHint: + 'Adds sync for session-env, file-history, shell-snapshots, and todos. Credentials stay isolated.', + invalidContextGroup: + 'Group must start with a letter and use only letters, numbers, dashes, or underscores (max {{max}} chars).', + command: 'Command', + commandFallback: 'ccs auth create ', + afterRunPrefix: 'After running the command:', + afterRunStep1: 'Complete the Claude login in your browser', + afterRunStep2: 'Return here and refresh to see the new account', + poolingHint: + 'Prefer pooled Claude OAuth routing instead? Use CLIProxy Claude pool from the Accounts page action button.', + close: 'Close', + copied: 'Copied!', + copyCommand: 'Copy Command', + }, + cliproxyModelCategory: { + google: 'Google (Gemini)', + openai: 'OpenAI (GPT)', + anthropic: 'Anthropic (Claude)', + antigravity: 'Antigravity', + other: 'Other', + }, + cliproxyOverviewComponents: { + availableModels: 'Available Models', + noModelsAvailable: 'No models available', + startSessionHint: 'Start a CLIProxy session to fetch available models', + totalCount: '{{count}} total', + availableThroughGroupedByProvider: + 'Models available through {{backendLabel}}, grouped by provider', + moreCount: '+{{count}} more', + }, + categorizedModelSelector: { + selectModel: 'Select a model', + noModelsAvailable: 'No models available', + modelPlaceholder: 'Model...', + }, + openrouterQuickStart: { + recommended: 'Recommended', + title: 'Start with OpenRouter', + description: + 'Access {{modelCountLabel}} models from OpenAI, Anthropic, Google, Meta and more - all through one API.', + featureOneApi: 'One API, all providers', + featureTierMapping: 'Model tier mapping', + createOpenRouterProfile: 'Create OpenRouter Profile', + getApiKeyAt: 'Get your API key at', + or: 'or', + createCustomProfile: 'Create Custom API Profile', + }, + alibabaCodingPlanQuickStart: { + recommended: 'Recommended', + title: 'Try Alibaba Coding Plan', + description: + 'Use Alibaba Cloud Coding Plan through an Anthropic-compatible endpoint for coding workloads.', + featureEndpoint: 'Anthropic-compatible endpoint', + featureKeyFormat: 'Dedicated sk-sp API keys', + createAlibabaProfile: 'Create Alibaba Coding Plan Profile', + readGuideAt: 'Read setup guide at', + }, + alibabaCodingPlanPromo: { + title: 'Alibaba Coding Plan', + subtitle: 'Model Studio Coding Plan via Anthropic endpoint', + add: 'Add', + }, + credentialHealth: { + title: 'Credential Health', + ready: 'Ready', + noCredentialsConfigured: + 'No credentials configured. Use the login buttons above to authenticate.', + neverUsed: 'Never used', + justNow: 'Just now', + unknown: 'Unknown', + daysAgo: '{{count}}d ago', + hoursAgo: '{{count}}h ago', + minutesAgo: '{{count}}m ago', + }, + proxyStatusWidget: { + justNow: 'just now', + minutesAgo: '{{count}}m ago', + hoursAgo: '{{count}}h ago', + syncReady: 'Sync Ready', + noConfig: 'No Config', + remoteProxy: 'Remote Proxy', + active: 'Active', + trafficAutoRouted: 'Traffic auto-routed to remote server', + tooltipSync: 'Sync profiles to CLIProxy', + tooltipRestart: 'Restart', + tooltipStop: 'Stop', + tooltipClose: 'Close', + tooltipVersionSettings: 'Version settings', + versionManagement: 'Version Management', + selectVersionPlaceholder: 'Select version to install...', + stable: '(stable)', + install: 'Install', + versionsAboveUnstable: 'Versions above {{version}} have known issues', + lastChecked: 'Last checked {{time}}', + notRunning: 'Not running', + start: 'Start', + port: 'Port {{port}}', + sessionCount: '{{count}} session', + sessionCount_other: '{{count}} sessions', + installUnstableTitle: 'Install Unstable Version?', + installUnstableDesc: + 'You are about to install v{{version}}, which is above the maximum stable version v{{maxStable}}.', + installUnstableWarning: + 'This version has known stability issues and may cause unexpected behavior.', + installUnstableConfirm: 'Are you sure you want to proceed?', + cancel: 'Cancel', + installAnyway: 'Install Anyway', + clickToDowngrade: 'Click to downgrade', + clickToUpdate: 'Click to update', + }, + syncStatusCard: { + profileSync: 'Profile Sync', + ready: 'Ready', + noConfig: 'No Config', + syncsProfilesDesc: 'Syncs API profiles to local CLIProxy config', + runDoctorHint: 'Run ccs doctor --fix to generate config.', + details: 'Details', + syncNow: 'Sync Now', + }, + syncDialog: { + title: 'Sync Profiles to Local CLIProxy', + description: 'Sync your CCS API profiles to the local CLIProxy config.yaml.', + noProfiles: 'No profiles configured to sync.', + createProfilesFirst: 'Create API profiles first using the Profiles tab.', + modelLabel: 'Model:', + ready: 'Ready', + profilesToSync_one: '{{count}} profile to sync', + profilesToSync_other: '{{count}} profiles to sync', + cancel: 'Cancel', + syncing: 'Syncing...', + synced: 'Synced!', + syncNow: 'Sync Now', + }, + errorLogs: { + title: 'Error Logs', + failedRequests_one: '{{count}} failed request', + failedRequests_other: '{{count}} failed requests', + cliproxyDiagnostics: 'CLIProxy Diagnostics', + showingCount: 'Showing 50 of {{total}} logs', + selectLog: 'Select a log to view details', + failedLoadContent: 'Failed to load log content', + copyAbsolutePath: 'Copy absolute path', + copyRawContent: 'Copy raw log content', + tabOverview: 'Overview', + tabHeaders: 'Headers', + tabRequest: 'Request', + tabResponse: 'Response', + tabRaw: 'Raw', + modelLabel: 'Model:', + quotaResetsIn: 'Quota resets in ', + method: 'Method', + provider: 'Provider', + version: 'Version', + endpoint: 'Endpoint', + url: 'URL', + timestamp: 'Timestamp', + na: 'N/A', + noHeaders: 'No headers available', + noRequestBody: 'No request body', + noResponseBody: 'No response body', + rateLimitedMsg: + 'Rate Limited. Switch to a different account or wait for quota reset. Model {{model}} has exhausted quota.', + rateLimitedMsgNoModel: + 'Rate Limited. Switch to a different account or wait for quota reset.', + authFailedMsg: + 'Authentication failed. Re-authenticate via CLIProxy Settings or check API key.', + notFoundMsg: 'Endpoint not found. This endpoint may not exist on this provider.', + serverErrorMsg: 'Server error from upstream. Retry later or check provider status page.', + timeoutMsg: 'Request timed out. Check network connection or increase timeout settings.', + unknown: 'unknown', + }, + editAccountContext: { + title: 'Edit History Sync', + description: + 'Configure how "{{name}}" shares history and continuity with other ccs auth accounts.', + syncMode: 'Sync Mode', + selectContextMode: 'Select context mode', + isolatedOption: 'isolated (no sync)', + sharedOption: 'shared (sync enabled)', + sharedModeHint: + 'Shared mode reuses workspace context for accounts in the same history sync group.', + isolatedModeHint: + 'Isolated mode keeps this account fully separate from other ccs auth accounts.', + historySyncGroup: 'History Sync Group', + groupPlaceholder: 'default', + groupHint: + 'Normalized to lowercase (spaces become dashes). Allowed: letters, numbers, _ , - (max {{max}} chars).', + invalidGroup: 'Enter a valid group name that starts with a letter.', + continuityDepth: 'Continuity Depth', + selectContinuityDepth: 'Select continuity depth', + standardOption: 'standard (projects only)', + deeperOption: 'deeper continuity (advanced)', + deeperHint: + 'Advanced mode also syncs session-env, file-history, shell-snapshots, and todos.', + standardHint: 'Standard mode syncs project workspace context only.', + credentialsIsolated: 'Credentials and .anthropic remain isolated per account in all modes.', + cancel: 'Cancel', + save: 'Save', + saving: 'Saving...', + }, + historySyncLearningMap: { + title: 'How History Sync Works', + description: 'Isolated -> Shared -> Deeper. Use Sync per row for all changes.', + learningMap: 'Learning Map', + isolated: 'Isolated', + shared: 'Shared', + deeper: 'Deeper', + cliproxyManaged_one: + '{{count}} CLIProxy Claude pool account is managed in Action Center / CLIProxy page.', + cliproxyManaged_other: + '{{count}} CLIProxy Claude pool accounts are managed in Action Center / CLIProxy page.', + showDetails: 'Show details: groups, switching, and legacy policy', + modeSwitch: 'Mode Switch', + modeSwitchDesc: + 'Sync dialog lets users move between isolated/shared and choose deeper continuity.', + historySyncGroup: 'History Sync Group', + historySyncGroupDesc: + 'Same group means shared project context lane. Default fallback is default.', + }, + accountsTable: { + name: 'Name', + type: 'Type', + defaultBadge: 'default', + created: 'Created', + lastUsed: 'Last Used', + historySync: 'History Sync', + actions: 'Actions', + syncTitle: 'Edit sync mode, group, and continuity depth', + confirmLegacyTitle: "Confirm this legacy account's current mode as explicit", + sync: 'Sync', + confirm: 'Confirm', + active: 'Active', + setDefault: 'Set Default', + deleteAccount: 'Delete account', + cannotDeleteDefault: 'Cannot delete default account', + noAccounts: 'No CCS auth accounts found. Use ccs auth create to add accounts.', + resetToDefault: 'Reset to CCS Default', + deleteDialogTitle: 'Delete Account', + deleteDialogDesc: + 'Are you sure you want to delete the account "{{name}}"? This will remove the profile and all its session data. This action cannot be undone.', + cancel: 'Cancel', + delete: 'Delete', + sharedGroupStandard: 'shared ({{group}}, standard)', + sharedGroupDeeper: 'shared ({{group}}, deeper)', + sharedGroupLegacy: 'shared ({{group}}, standard legacy)', + isolatedLegacy: 'isolated (legacy default)', + isolated: 'isolated', + }, + addAccountDialog: { + title: 'Add {{displayName}} Account', + descKiro: + 'Choose a Kiro auth method, then authenticate via browser or import from Kiro IDE.', + descDeviceCode: + 'Click Authenticate. A verification code will appear for you to enter on the provider website.', + descOauth: 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.', + powerUserEnabled: 'Power user mode enabled', + powerUserSkipped: + 'AGY responsibility checklist is skipped from Settings > Proxy. You accept full responsibility for OAuth/account risk.', + authMethod: 'Auth Method', + selectKiroAuthMethod: 'Select Kiro auth method', + nicknameRequired: 'Nickname (required)', + nicknameOptional: 'Nickname (optional)', + nicknamePlaceholder: 'e.g., work, personal', + nicknameRequiredHint: + 'Required for this provider. Use a unique friendly name (e.g., work, personal).', + nicknameOptionalHint: + 'A friendly name to identify this account. Auto-generated from email if left empty.', + waitingForAuth: 'Waiting for authentication...', + deviceCodeHint: + 'A verification code dialog will appear shortly. Enter the code on the provider website.', + browserHint: + 'Complete the authentication in your browser. This dialog closes automatically.', + openUrlLabel: 'Open this URL in any browser to sign in:', + copied: 'Copied', + copy: 'Copy', + open: 'Open', + popupBlocked: 'Popup blocked. Copy the URL above and open it manually in your browser.', + redirectPasteLabel: "Redirect didn't work? Paste the callback URL:", + callbackPlaceholder: 'Paste the redirect URL here...', + submitCallback: 'Submit Callback', + submitting: 'Submitting...', + preparingUrl: 'Preparing sign-in URL...', + cancel: 'Cancel', + importingToken: 'Importing token from Kiro IDE...', + authenticate: 'Authenticate', + importFromIde: 'Import from IDE', + importing: 'Importing...', + }, + flowViz: { + backToProviders: 'Back to providers', + showDetails: 'Show Details', + hideDetails: 'Hide Details', + resetLayout: 'Reset layout', + provider: 'Provider', + totalRequests: 'Total Requests', + accounts: 'Accounts', + connectionTimeline: 'Connection Timeline', + noRecentConnections: 'No recent connections', + }, + quickStats: { + totalRequests: 'Total Requests', + successRate: 'Success Rate', + totalTokens: 'Total Tokens', + activeModels: 'Active Models', + }, + authMonitor: { + accounts: 'Accounts', + accountsCount: '{{count}} accounts', + success: 'Success', + failed: 'Failed', + successRate: 'Success Rate', + }, + accountCard: { + resumeAccount: 'Resume account', + pauseAccount: 'Pause account', + quotaLoading: 'Quota...', + quota: 'Quota', + quotaUnavailable: 'Quota limits unavailable', + reauthNeeded: 'Reauth needed', + removeAndReadd: 'Remove and re-add account', + autoRefreshFailed: 'Auto-refresh failed', + }, + antigravityChecklist: { + title: 'Antigravity OAuth Responsibility', + subtitle: 'Complete all 4 steps before you can authenticate.', + mandatory: 'Mandatory', + completion: 'Completion', + stepsCount: '{{current}}/4 steps', + }, + accountSafetyWarning: { + title: 'OAuth Account Safety Warning', + subtitle: 'Issue #509 · Gemini + AGY OAuth risk', + firstLine: + 'Issue #509 documents suspension/ban reports tied to ccs agy and shared-account usage between ccs gemini and ccs agy.', + secondLine: + 'Continue only if you accept full responsibility for OAuth and account-access risk.', + issueLabel: 'Read issue #509', + proxySettingsLabel: 'Gemini + AGY controls: Settings > Proxy', + }, + cliproxyDialog: { + name: 'Name', + provider: 'Provider', + account: 'Account', + accountOptional: 'Account (optional)', + model: 'Model', + modelOptional: 'Model (optional)', + defaultTarget: 'Default Target', + defaultTier: 'Default Tier', + tierConfig: 'Tier Configuration', + selectProvider: 'Select provider...', + useDefaultAccount: 'Use default account', + defaultSuffix: '(default)', + opus: 'Opus', + sonnet: 'Sonnet', + haiku: 'Haiku', + claudeCode: 'Claude Code', + factoryDroid: 'Factory Droid', + cancel: 'Cancel', + create: 'Create', + creating: 'Creating...', + saveChanges: 'Save Changes', + saving: 'Saving...', + placeholderName: 'my-gemini', + placeholderModel: 'gemini-2.5-pro', + placeholderComposite: 'my-composite', + placeholderModelId: 'model-id', + placeholderAccountId: 'account-id', + editCompositeTitle: 'Edit Composite Variant: {{name}}', + editSingleTitle: 'Edit Single Variant: {{name}}', + }, + profileEditor: { + profileInfo: 'Profile Information', + profileName: 'Profile Name', + filePath: 'File Path', + lastModified: 'Last Modified', + defaultTarget: 'Default Target', + quickUsage: 'Quick Usage', + runWithProfile: 'Run with profile', + runOnDroid: 'Run on Droid', + droidAliasExplicit: 'Droid alias (explicit)', + overrideToClaude: 'Override to Claude', + overrideToClaudeExplicit: 'Override to Claude (explicit)', + setAsDefault: 'Set as default', + provider: 'Provider', + custom: 'Custom', + }, + settings: { + sectionLoadFailed: 'Failed to load section', + unknownError: 'Unknown error occurred', + reloadPage: 'Reload page', + refresh: 'Refresh', + copy: 'Copy', + copied: 'Copied', + saved: 'Saved', + loading: 'Loading...', + configFileNotFound: 'Config file not found', + }, + settingsTabs: { + web: 'Web', + env: 'Env', + think: 'Think', + proxy: 'Proxy', + auth: 'Auth', + backup: 'Backup', + }, + settingsAuth: { + failedFetchTokens: 'Failed to fetch auth tokens', + failedSaveTokens: 'Failed to save tokens', + tokensUpdated: 'Tokens updated. Restart CLIProxy to apply.', + failedRegenerate: 'Failed to regenerate secret', + secretRegenerated: 'New management secret generated. Restart CLIProxy to apply.', + failedReset: 'Failed to reset tokens', + tokensReset: 'Tokens reset to defaults. Restart CLIProxy to apply.', + description: 'Configure CLIProxy authentication tokens. Changes require CLIProxy restart.', + apiKey: 'API Key', + custom: 'Custom', + apiKeyDesc: 'Used by Claude Code to authenticate with CLIProxy', + apiKeyPlaceholder: 'API key', + managementSecret: 'Management Secret', + managementSecretDesc: 'Used by CCS dashboard to access CLIProxy management APIs', + managementSecretPlaceholder: 'Management secret', + generateSecret: 'Generate new secure secret', + resetDefaults: 'Reset to Defaults', + resetDesc: 'Resets both API key and management secret to their default values.', + saving: 'Saving...', + save: 'Save', + }, + settingsGlobalEnv: { + description: + 'Environment variables injected into all non-Claude subscription profiles (gemini, codex, agy, ghcp, etc.)', + enabled: 'Global Env enabled', + disabled: 'Global Env disabled', + enabledDesc: 'Env vars will be injected into third-party profiles', + disabledDesc: 'Env vars will not be injected', + envVars: 'Environment Variables', + noneConfigured: 'No environment variables configured', + addNew: 'Add New Variable', + keyName: 'KEY_NAME', + value: 'value', + add: 'Add', + quickAdd: 'Quick Add Common Variables', + allConfigured: 'All common variables are configured', + }, + settingsBackups: { + failedFetch: 'Failed to fetch backups', + failedRestore: 'Failed to restore backup', + restoreSuccess: 'Backup restored successfully', + title: 'Settings Backups', + description: + 'Restore previous versions of your settings.json file. Backups are created automatically when settings are modified.', + none: 'No backups available', + noneHint: 'Backups will appear here when you modify settings', + latest: 'Latest', + restoring: 'Restoring...', + restore: 'Restore', + restoreConfirmTitle: 'Restore Backup?', + restoreConfirmPrefix: 'This will replace your current settings with backup from', + restoreConfirmSuffix: 'This action cannot be undone.', + cancel: 'Cancel', + }, + settingsWebsearch: { + description: 'CLI-based web search for third-party profiles (gemini, codex, agy, etc.)', + enabled: 'WebSearch enabled', + disabled: 'WebSearch disabled', + checking: 'Checking status...', + providers: 'Providers', + noneConfigured: 'No providers configured', + noneConfiguredHint: 'Install CLI tools to enable web search providers', + checkProviders: 'Check for providers', + installed: 'installed', + notInstalled: 'not installed', + model: 'Model:', + howInstall: 'How to install {{name}} CLI', + installGlobally: 'Install globally', + requiresKey: '(requires xAI API key)', + freeTier: '(FREE tier available)', + viewDocs: 'View documentation', + }, + settingsProxy: { + failedLoadAgyMode: 'Failed to load AGY power user mode', + failedUpdateAgyMode: 'Failed to update AGY power user mode', + failedVerifyAgyMode: 'Failed to verify AGY power user mode persistence', + notPersistedAgyMode: + 'AGY power user mode was not persisted. Config may have been modified by another process.', + agyModeEnabled: 'AGY power user mode enabled.', + agyModeDisabled: 'AGY power user mode disabled.', + typePhraseToContinue: 'Type "{{value}}" to continue.', + description: 'Configure local or remote {{backend}} connection for proxy-based profiles', + backendPlus: 'CLIProxy Plus', + backend: 'CLIProxy', + instanceStatus: 'Instance Status', + connectionMode: 'Connection Mode', + local: 'Local', + remote: 'Remote', + localDesc: 'Run {{backend}} binary on this machine', + remoteDesc: 'Connect to a remote {{backend}} server', + backendBinary: 'Backend Binary', + stopProxyToSwitch: 'Stop the running proxy in Instance Status to switch backend.', + default: 'Default', + plusDesc: 'Full provider support including Kiro and GitHub Copilot', + originalDesc: 'Original binary (Gemini, Codex, Antigravity only)', + variantsIncompatible: + 'Existing Kiro/Copilot variants will not work with CLIProxyAPI. Switch to CLIProxyAPIPlus or remove those variants.', + safety: 'Safety', + agyModeTitle: 'Antigravity Power User Mode', + agyModeDesc: 'Skip AGY responsibility checklist in Add Account and `ccs agy` flows.', + agyWarning: + 'Use only if you fully understand the OAuth suspension/ban risk pattern (#509). CCS cannot assume responsibility for account loss.', + finalConfirm: 'Final confirmation required', + finalConfirmDesc: + 'Enabling this will skip AGY safety checkpoints in both dashboard and CLI. Review issue #509 and type the exact phrase to proceed.', + step1: 'Step 1', + readIssue: 'Read issue #509', + step2: 'Step 2', + typePrefix: 'Type', + typeSuffix: 'to enable.', + typePhraseAria: 'Type I ACCEPT RISK to enable Antigravity power user mode', + exactPhrase: 'Exact phrase required.', + enableAgyMode: 'Enable Power User Mode', + toggleAgyMode: 'Toggle AGY power user mode', + fallbackSettings: 'Fallback Settings', + enableFallback: 'Enable fallback to local', + enableFallbackDesc: 'Use local proxy if remote is unreachable', + autoStartLocal: 'Auto-start local proxy', + autoStartLocalDesc: 'Automatically start local proxy on fallback', + advanced: 'Advanced', + debugMode: 'Debug Mode', + debugModeDesc: 'Enable developer diagnostics in browser console', + debugModeEnabled: 'Debug mode enabled. Check browser console for detailed logs.', + localProxy: 'Local Proxy', + port: 'Port', + autoStart: 'Auto-start', + localAutoStartDesc: 'Start local proxy automatically when needed', + remoteConfigTitle: 'Remote Server Configuration', + host: 'Host', + hostPlaceholder: '192.168.1.100 or proxy.example.com', + defaultPort: 'default: {{value}}', + portPlaceholder: 'Leave empty for {{value}}', + protocol: 'Protocol', + http: 'HTTP', + https: 'HTTPS', + apiKeyOptional: 'API Key (optional)', + apiKeyPlaceholder: 'For /v1/* API endpoints', + apiKeyDesc: 'Used for API requests to /v1/chat/completions', + managementKeyOptional: 'Management Key (optional)', + managementKeyPlaceholder: 'For /v0/management/* endpoints', + managementKeyDesc: 'Used for dashboard management APIs. Falls back to API Key if not set.', + testing: 'Testing...', + testConnection: 'Test Connection', + connectedLatency: 'Connected ({{value}}ms)', + connectionFailed: 'Connection failed', + invalidPortOrEmpty: 'Port must be an integer between 1 and 65535, or empty for default', + invalidLocalPort: 'Local port must be an integer between 1 and 65535', + backendPlusApi: 'CLIProxyAPIPlus', + backendApi: 'CLIProxyAPI', + }, + settingsThinking: { + description: 'Configure extended thinking/reasoning for supported models.', + supportedProviders: 'Supported Providers', + modeTitle: 'Thinking Mode', + modeAutoDesc: 'Automatically set thinking based on model tier', + modeOffDesc: 'Disable extended thinking', + modeManualDesc: 'Set a persistent override level or use CLI flags per run', + tierDefaults: 'Tier Defaults', + tierDefaultsDesc: 'Default thinking level for each model tier when in auto mode.', + persistentOverride: 'Persistent Override', + persistentOverrideDesc: 'Applied to all sessions. CLI flags still take priority.', + enterCustomBudget: 'Enter custom budget', + apply: 'Apply', + providerOverrides: 'Provider Overrides ({{count}})', + providerOverridesDesc: + 'Override tier defaults for specific providers. Add custom provider keys as needed.', + addProviderPlaceholder: 'Add provider key (e.g. qwen)', + showWarnings: 'Show Warnings', + showWarningsDesc: 'Display warnings when thinking values are clamped or adjusted', + cliEnvOverride: 'CLI & Env Override', + supportLine1Prefix: 'Thinking budget:', + supportLine1Suffix: '(token-based)', + supportLine2Prefix: 'Reasoning effort:', + supportLine2SuffixPrefix: '(suffix or ', + supportLine2SuffixPostfix: ': medium/high/xhigh)', + supportLine3Prefix: 'Codex suffixes pin effort (for example ', + supportLine3Suffix: '); unsuffixed models use Thinking mode.', + }, + analytics: { + title: 'Analytics', + subtitle: 'Track usage and insights', + month: 'Month', + allTime: 'All Time', + updated: 'Updated {{value}}', + }, + analyticsSummary: { + totalTokens: 'Total Tokens', + totalTokensSubtitle: '{{input}} in / {{output}} out', + totalCost: 'Total Cost', + totalCostSubtitle: '${{value}}/day avg', + cacheTokens: 'Cache Tokens', + cacheTokensSubtitle: '${{cost}} ({{percent}}% of cost)', + inputCost: 'Input Cost', + outputCost: 'Output Cost', + tokensSubtitle: '{{value}} tokens', + }, + apiProfiles: { + title: 'API Profiles', + new: 'New', + searchPlaceholder: 'Search profiles...', + loadingProfiles: 'Loading profiles...', + failedLoadTitle: 'Failed to load profiles', + failedLoadDesc: 'Unable to fetch API profiles. Please try again.', + retry: 'Retry', + noProfilesYet: 'No API profiles yet', + noProfilesDesc: 'Create your first profile to connect to custom API endpoints', + createProfile: 'Create Profile', + noProfileMatch: 'No profiles match "{{query}}"', + profileCount: '{{count}} profile', + profileCount_other: '{{count}} profiles', + configuredCount: '{{count}} configured', + deleteProfileTitle: 'Delete Profile', + deleteProfileDesc: + 'Are you sure you want to delete "{{name}}"? This will remove the settings file and cannot be undone.', + delete: 'Delete', + unsavedChangesTitle: 'Unsaved Changes', + unsavedChangesDesc: + 'You have unsaved changes in "{{current}}". Discard and switch to "{{next}}"?', + discardSwitch: 'Discard and Switch', + }, + accountsPage: { + title: 'Accounts', + managePrefix: 'Manage', + manageSuffix: 'accounts and pool onboarding from one panel.', + mobileManageSuffix: 'continuity per account.', + primaryActions: 'Primary Actions', + createAccount: 'Create Account', + authClaudeInPool: 'Authenticate Claude in Pool', + openClaudePoolSettings: 'Open Claude Pool Settings', + openCliProxyClaudePool: 'Open CLIProxy Claude Pool', + migrationFollowup: 'Migration Follow-up', + legacyContextPending: '{{count}} account still needs first-time mode confirmation.', + legacyContextPending_other: '{{count}} accounts still need first-time mode confirmation.', + legacyContinuityPending: + '{{count}} shared account remains on standard legacy continuity depth.', + legacyContinuityPending_other: + '{{count}} shared accounts remain on standard legacy continuity depth.', + confirmingLegacy: 'Confirming Legacy Policies...', + confirmLegacy: 'Confirm Legacy Policies ({{count}})', + noLegacyFollowup: 'No legacy follow-up pending.', + continuityGuide: 'Continuity Guide', + expandWhenNeeded: 'Expand only when needed.', + sharedStandard: 'Shared Standard', + sharedStandardDesc: 'Project workspace sync only. Best default for most teams.', + sharedDeeper: 'Shared Deeper', + sharedDeeperPrefix: 'Adds', + isolated: 'Isolated', + isolatedDesc: 'No link. Best for strict separation.', + quickCommands: 'Quick Commands', + quickCommandsDesc: 'Copy and run in terminal.', + workspaceBadge: 'ccs auth Workspace', + historySyncBadge: 'History Sync Controls', + authAccounts: 'Auth Accounts', + tableScopePrefix: 'This table is intentionally scoped to', + tableScopeMiddle: 'accounts. Use', + tableScopeSuffix: 'for mode/group/depth changes.', + accountMatrix: 'Account Matrix', + sharedTotalDesc: + 'Shared total: {{count}}. Actions include Sync settings and legacy confirmation.', + loadingAccounts: 'Loading accounts...', + }, + health: { + justNow: 'just now', + secondsAgo: '{{count}}s ago', + minutesAgo: '{{count}}m ago', + hoursAgo: '{{count}}h ago', + systemHealth: 'System Health', + build: 'build {{version}}', + lastScan: 'Last scan:', + autoRefresh: 'Auto-refresh:', + refresh: 'Refresh', + checks: 'Checks', + version: 'Version', + platform: 'Platform', + liveMonitoring: 'Live monitoring active', + copied: 'Copied to clipboard', + refreshing: 'Refreshing health checks...', + fix: 'Fix', + applyFix: 'Apply Fix', + healthLabel: 'health', + }, + updates: { + actionRequired: 'Action Required', + done: 'Done', + all: 'All', + inboxTitle: 'Updates Inbox', + inboxSubtitle: 'Focus on actions, then mark updates done or dismissed.', + needsAction: 'Needs Action', + doneCount: 'Done', + searchPlaceholder: 'Search actions or commands', + noNotices: 'No notices match this view.', + noUpdates: 'No updates available.', + published: 'Published {{date}}', + markDone: 'Mark done', + dismiss: 'Dismiss', + reopen: 'Reopen', + doNext: 'Do Next', + impactedIntegrations: 'Impacted Integrations', + impactedDesc: 'Related areas based on update scope and routing.', + whyMatters: 'Why It Matters', + whyMattersDesc: 'Short context only, no wall-of-text release notes.', + open: 'Open', + progressNeedsAction: 'Needs Action', + progressInReview: 'In Review', + progressDone: 'Done', + progressDismissed: 'Dismissed', + statusNew: 'New', + statusStable: 'Stable', + statusPlanned: 'Planned', + dashboard: 'Dashboard', + cliUsage: 'CLI Usage', + }, + cliproxyPage: { + connected: 'Connected', + notConnected: 'Not connected', + variant: 'variant', + parentNotConnected: 'Parent not connected', + viaProvider: 'via {{provider}}', + emptyTitle: 'CCS Profile Manager', + emptyDesc: + 'Manage OAuth authentication, account preferences, and model selection for CLIProxy providers. Configure how CCS routes requests to different AI backends.', + emptyControlPanelPrefix: 'For live usage stats and real-time monitoring, visit the', + controlPanel: 'Control Panel', + quickSetup: 'Quick Setup', + accountManagement: 'CCS-level account management', + providers: 'Providers', + variants: 'Variants', + providerCount: '{{count}} provider', + providerCount_other: '{{count}} providers', + connectedCount: '{{count}} connected', + variantDisplay: '{{name}} ({{provider}} variant)', + }, + copilotPage: { + title: 'Copilot', + subtitle: 'GitHub Copilot proxy', + setup: 'Setup', + auth: 'Auth', + daemon: 'Daemon', + unofficialTitle: 'Unofficial API - Use at Your Own Risk', + unofficialItem1: 'Reverse-engineered API - may break anytime', + unofficialItem2: 'Excessive use may trigger account restrictions', + unofficialItem3: 'No warranty, no responsibility from CCS', + yes: 'Yes', + no: 'No', + installed: 'Installed', + missing: 'Missing', + integration: 'Integration', + enabled: 'Enabled', + disabled: 'Disabled', + github: 'GitHub', + connected: 'Connected', + notConnected: 'Not Connected', + authenticate: 'Authenticate', + authenticating: 'Authenticating...', + status: 'Status', + running: 'Running', + stopped: 'Stopped', + port: 'Port', + stopping: 'Stopping...', + stop: 'Stop', + starting: 'Starting...', + start: 'Start', + installing: 'Installing...', + installCopilotApi: 'Install copilot-api', + proxy: 'Proxy', + active: 'Active', + inactive: 'Inactive', + }, + cursorPage: { + title: 'Cursor', + beta: 'Beta', + subtitle: 'Dedicated Cursor integration controls', + unofficialTitle: 'Unofficial API - Use at Your Own Risk', + unofficialItem1: 'Reverse-engineered integration may break anytime', + unofficialItem2: 'Abuse or excessive usage may risk account restrictions', + unofficialItem3: 'No warranty, no responsibility from CCS', + integration: 'Integration', + authentication: 'Authentication', + daemon: 'Daemon', + enabled: 'Enabled', + disabled: 'Disabled', + expired: 'Expired', + connected: 'Connected', + notConnected: 'Not connected', + running: 'Running', + stopped: 'Stopped', + actions: 'Actions', + disableIntegration: 'Disable Integration', + enableIntegration: 'Enable Integration', + autoDetectAuth: 'Auto-detect Auth', + manualAuthImport: 'Manual Auth Import', + stopDaemon: 'Stop Daemon', + startDaemon: 'Start Daemon', + port: 'Port', + configuration: 'Cursor Configuration', + lastModified: 'Last modified:', + neverSaved: 'Never saved', + saving: 'Saving...', + save: 'Save', + modelConfig: 'Model Config', + settings: 'Settings', + info: 'Info', + presets: 'Presets', + presetsDesc: 'Apply pre-configured model mappings', + modelMapping: 'Model Mapping', + modelMappingDesc: 'Configure which models to use for each tier', + defaultModel: 'Default Model', + defaultModelDesc: 'Used when no specific tier is requested.', + opusModel: 'Opus (Most capable)', + opusModelDesc: 'Complex reasoning and highest quality responses.', + sonnetModel: 'Sonnet (Balanced)', + sonnetModelDesc: 'General coding and day-to-day chat workloads.', + haikuModel: 'Haiku (Fast)', + haikuModelDesc: 'Low-latency and lightweight request paths.', + runtimeSettings: 'Runtime Settings', + autoStartDaemon: 'Auto-start Daemon', + autoStartDesc: 'Start Cursor daemon automatically when integration is used.', + ghostMode: 'Ghost Mode', + ghostModeDesc: 'Request x-ghost-mode to reduce telemetry.', + provider: 'Provider', + filePath: 'File Path', + availableModels: 'Available Models', + loadingModels: 'Loading models...', + noModels: 'No model metadata available yet. Start daemon and refresh.', + default: 'Default', + rawConfiguration: 'Raw Configuration (JSON)', + manualImportTitle: 'Manual Cursor Auth Import', + manualImportDesc: + 'Provide Cursor access token and machine ID if auto-detect is unavailable.', + accessToken: 'Access Token', + accessTokenPlaceholder: 'Paste Cursor access token', + machineId: 'Machine ID', + machineIdPlaceholder: '32-char hex (UUID without hyphens)', + cancel: 'Cancel', + import: 'Import', + selectModel: 'Select model', + useDefaultModel: 'Use Default Model', + availableModelCount: 'Available Models ({{count}})', + invalidPort: 'Port must be an integer between 1 and 65535', + defaultModelRequired: 'Default model is required', + savedConfig: 'Cursor config and model mapping saved', + failedSaveConfig: 'Failed to save config', + modelsLoadingWait: 'Models are still loading. Please wait before applying a preset.', + noModelsAvailable: 'No models available yet. Start the daemon and refresh first.', + appliedCodexPreset: 'Applied GPT-5.3 Codex preset', + appliedClaudePreset: 'Applied Claude 4.6 preset', + appliedGeminiPreset: 'Applied Gemini 3 preset', + integrationEnabled: 'Cursor integration enabled', + integrationDisabled: 'Cursor integration disabled', + failedUpdateIntegration: 'Failed to update integration state', + credentialsImported: 'Cursor credentials imported', + autoDetectFailed: 'Auto-detect failed', + manualRequired: 'Token and machine ID are required', + manualImportFailed: 'Manual import failed', + failedStartDaemon: 'Failed to start daemon', + daemonStarted: 'Daemon started', + daemonStartedWithPid: 'Daemon started (PID: {{pid}})', + failedStopDaemon: 'Failed to stop daemon', + daemonStopped: 'Daemon stopped', + rawLoading: 'Raw settings are still loading. Please wait and try again.', + invalidJson: 'Invalid JSON', + rawSaved: 'Raw settings saved', + rawChanged: 'Raw settings changed externally. Refresh and retry.', + failedSaveRaw: 'Failed to save raw settings', + savedAll: 'Cursor configuration saved', + }, + droidPage: { + loadingDiagnostics: 'Loading Droid diagnostics...', + failedDiagnostics: 'Failed to load Droid diagnostics.', + overview: 'Overview', + byok: 'BYOK', + docs: 'Docs', + runtimeInstall: 'Runtime and Installation', + status: 'Status', + detected: 'Detected', + notFound: 'Not Found', + detectionSource: 'Detection source', + binaryPath: 'Binary path', + installDirectory: 'Install directory', + version: 'Version', + overridePath: 'Override (CCS_DROID_PATH)', + configFiles: 'Config Files', + path: 'Path', + resolved: 'Resolved', + size: 'Size', + lastModified: 'Last modified', + parseWarning: 'Parse warning: {{value}}', + readWarning: 'Read warning: {{value}}', + warnings: 'Warnings', + byokSummary: 'BYOK Summary', + activeModelSelector: 'Active model selector', + customModels: 'Custom models', + ccsManaged: 'CCS-managed', + userManaged: 'User-managed', + malformedEntries: 'Malformed entries', + providers: 'Providers', + none: 'none', + customModelsTitle: 'Custom Models', + modelName: 'Name / Model', + provider: 'Provider', + baseUrl: 'Base URL', + noCustomModels: 'No custom models', + docsAlignedNotes: 'Docs-Aligned Notes', + factoryDocs: 'Factory Docs', + providerFactCheckDocs: 'Provider Fact-Check Docs', + providerValues: 'Provider values: {{value}}', + settingsHierarchy: 'Settings hierarchy: {{value}}', + settingsTitle: 'Droid BYOK Settings', + fixJsonBeforeQuickSettings: 'Fix JSON syntax before using quick settings controls.', + fixJsonBeforeReasoning: 'Fix JSON syntax before updating BYOK reasoning settings.', + unableUpdateReasoning: 'Unable to update selected BYOK model reasoning setting.', + fixJsonBeforeBudget: 'Fix JSON syntax before updating thinking budget.', + anthropicOnlyBudget: 'Thinking budget is only available for Anthropic BYOK models.', + invalidJson: 'Invalid JSON: {{value}}', + saved: 'Droid settings saved', + changedExternally: 'Droid settings changed externally. Refresh and retry.', + failedSave: 'Failed to save Droid settings', + }, + sharedPage: { + commands: 'Commands', + skills: 'Skills', + agents: 'Agents', + title: 'Shared Data', + subtitle: 'Commands, skills, and agents shared across Claude instances', + totalShared: 'Total Shared', + visible: 'Visible', + markdownDetail: 'Markdown detail view', + filterPrefix: 'Filter:', + configurationRequired: 'Configuration Required', + countsUnavailable: 'Counts unavailable', + retryCounts: 'Retry counts', + filterPlaceholder: 'Filter {{tab}} by name, description, or path', + showing: 'Showing {{visible}} of {{total}} {{tab}}', + showingQuery: ' for "{{query}}"', + refreshing: ' (refreshing...)', + loadingShared: 'Loading shared {{tab}}...', + failedLoadShared: 'Failed to load shared {{tab}}', + retry: 'Retry', + noSharedFound: + 'No shared {{tab}} found. Run `ccs sync` or add items in your shared directory.', + noMatch: 'No {{tab}} match "{{query}}".', + selectOne: 'Select a {{tab}} to view full content.', + pathLabel: 'Path', + resolvedSource: 'Resolved Source', + loadingMarkdown: 'Loading markdown content...', + failedLoadContent: 'Failed to load content', + retryContent: 'Retry content', + noMarkdown: 'No markdown content available.', + }, + }, + }, + 'zh-CN': { + translation: { + locale: { + en: 'English', + 'zh-CN': '简体中文', + vi: '越南语', + }, + layout: { + languageSwitcher: '语言', + }, + nav: { + general: '通用', + home: '首页', + analytics: '分析', + identityAccess: '身份与访问', + apiProfiles: 'API 配置', + cliproxyPlus: 'CLIProxy Plus', + cliproxyOverview: '概览', + controlPanel: '控制面板', + githubCopilot: 'GitHub Copilot', + cursorIde: 'Cursor IDE', + accounts: '账号', + allAccounts: '全部账号', + sharedData: '共享数据', + compatibleClis: '兼容 CLI', + factoryDroid: 'Factory Droid', + system: '系统', + health: '健康', + settings: '设置', + openrouterTooltip: '精选:OpenRouter + Alibaba Coding Plan + Ollama', + }, + home: { + profiles: '配置', + cliproxy: 'CLIProxy', + accounts: '账号', + health: '健康', + configurationRequired: '需要配置', + }, + auth: { + dashboardTitle: 'CCS 控制台', + loginDescription: '请输入凭据以访问控制台', + username: '用户名', + password: '密码', + usernamePlaceholder: '请输入用户名', + passwordPlaceholder: '请输入密码', + signIn: '登录', + signingIn: '登录中...', + loginFailed: '登录失败', + }, + commonToast: { + apiKeyRequired: 'API key 为必填项', + accountAddedPresetFailed: '账号已添加,但应用默认预设失败', + settingsCopied: '设置已复制到剪贴板', + enterPresetName: '请先输入预设名称再保存', + codeCopied: '验证码已复制到剪贴板', + copyCodeFailed: '复制验证码失败', + appliedModelAllTiers: '已将模型应用到全部层级', + settingsSaved: '设置已保存', + defaultTargetUpdated: '默认目标已更新', + failedUpdateDefaultTarget: '更新默认目标到 {{target}} 失败{{suffix}}', + }, + profileDialog: { + editTitle: '编辑配置', + createTitle: '创建 API 配置', + name: '名称', + namePlaceholder: 'my-api', + baseUrl: 'Base URL', + baseUrlPlaceholder: 'https://api.example.com', + apiKey: 'API Key', + defaultModel: '默认模型(ANTHROPIC_MODEL)', + defaultModelHint: '留空则使用:{{model}}', + modelMappingTitle: '模型映射(Opus/Sonnet/Haiku)', + modelMappingDesc: '为不同档位配置不同模型 ID,适合按模型类型路由到不同后端的 API 代理。', + opusModel: 'Opus 模型(ANTHROPIC_DEFAULT_OPUS_MODEL)', + sonnetModel: 'Sonnet 模型(ANTHROPIC_DEFAULT_SONNET_MODEL)', + haikuModel: 'Haiku 模型(ANTHROPIC_DEFAULT_HAIKU_MODEL)', + cancel: '取消', + saving: '保存中...', + update: '更新', + create: '创建', + }, + modelTierMapping: { + title: '模型档位映射', + advanced: '高级', + description: '为 Claude Code 的 opus/sonnet/haiku 档位配置不同模型。', + autoSuggest: '基于 {{provider}} 自动建议', + opusPlaceholder: '例如:anthropic/claude-opus-4', + sonnetPlaceholder: '例如:anthropic/claude-sonnet-4', + haikuPlaceholder: '例如:anthropic/claude-3.5-haiku', + footer: + '这些字段对应 ANTHROPIC_DEFAULT_OPUS_MODEL、ANTHROPIC_DEFAULT_SONNET_MODEL、ANTHROPIC_DEFAULT_HAIKU_MODEL。', + }, + envEditor: { + sensitive: '敏感', + none: '尚未配置环境变量。', + noneHint: '可在下方输入添加,或直接编辑 JSON。', + addVariable: '添加环境变量', + keyPlaceholder: 'VARIABLE_NAME', + valuePlaceholder: 'value', + }, + setupVariant: { + using: '当前使用:', + variantNameRequired: '变体名称 *', + variantNamePlaceholder: '例如:my-gemini、g3、flash', + invokeHintPrefix: '可通过该名称调用:', + model: '模型', + modelPlaceholder: '例如:gemini-2.5-pro、claude-opus-4-6-thinking', + choosePresetInstead: '改为从预设中选择', + selectModel: '选择模型', + customModelName: '自定义模型名称...', + enterAnyModel: '可输入 CLIProxy 支持的任意模型名', + defaultModel: '默认:{{model}}', + providerDefault: '提供商默认值', + back: '返回', + skip: '跳过', + creating: '创建中...', + createVariant: '创建变体', + skipHint: '如果你只想添加账号而不创建变体,可以直接跳过', + }, + customPresetDialog: { + title: '自定义预设', + presetNameOptional: '预设名称(可选)', + presetNamePlaceholder: '例如:我的自定义配置', + codexTipPrefix: 'Codex 提示:后缀', + codexTipSuffix: '会固定 effort。无后缀模型使用 Thinking 设置。', + defaultModel: '默认模型', + defaultModelDesc: '未指定档位时使用', + opusModel: 'Opus(最强)', + opusModelDesc: '用于复杂推理任务', + sonnetModel: 'Sonnet(均衡)', + sonnetModelDesc: '兼顾速度与能力', + haikuModel: 'Haiku(快速)', + haikuModelDesc: '适合简单任务的快速响应', + cancel: '取消', + savePreset: '保存预设', + applyPreset: '应用预设', + }, + componentModelSelector: { + selectModel: '选择模型', + availableModelsCount: '可用模型({{count}})', + preview: '预览', + }, + copilotSettings: { + enableCopilot: '启用 Copilot', + enableCopilotDesc: '允许使用 GitHub Copilot 订阅', + basicSettings: '基础设置', + accountType: '账户类型', + accountTypeIndividual: '个人', + accountTypeBusiness: '企业', + accountTypeEnterprise: '组织', + rateLimiting: '速率限制', + rateLimitSeconds: '速率限制(秒)', + noLimit: '不限制', + waitOnRateLimit: '触发限速时等待', + waitOnRateLimitDesc: '达到限制时等待而不是报错', + daemonSettings: '守护进程设置', + autoStartDaemon: '自动启动守护进程', + autoStartDaemonDesc: '使用配置时自动启动 copilot-api', + }, + providerModelSelector: { + selectModel: '选择模型', + noModelsForProvider: '该提供商暂无可用模型', + broken: '故障', + deprecated: '已弃用', + paid: '付费', + freeTier: '免费档', + paidTier: '付费档', + modelKnownIssues: '该模型存在已知问题', + viewIssueDetails: '查看问题详情', + modelDeprecated: '该模型已弃用', + recommended: '推荐', + allModelsCount: '全部模型({{count}})', + noModelsAvailable: '暂无可用模型', + }, + createAuthProfileDialog: { + title: '创建新账号', + description: + 'Auth 配置需要先完成 Claude CLI 登录。请在终端执行下方命令。稍后你仍可在 Accounts 表中编辑同步模式、分组与连续性深度。', + profileName: '配置名称', + profileNamePlaceholder: '例如:work、personal、client', + invalidProfileName: '名称必须以字母开头,且只能包含字母、数字、短横线或下划线。', + enableSharedHistory: '与其他 ccs auth 账号启用共享历史同步', + historySyncGroupOptional: '历史同步分组(可选)', + historySyncGroupPlaceholder: 'default、sprint-a、client-x', + historySyncGroupHint: '留空将使用默认共享分组。空格会自动规范为短横线。', + deeperContinuity: '高级:更深连续性模式', + deeperContinuityHint: + '会同步 session-env、file-history、shell-snapshots 与 todos。凭据仍保持隔离。', + invalidContextGroup: + '分组必须以字母开头,且只能包含字母、数字、短横线或下划线(最多 {{max}} 个字符)。', + command: '命令', + commandFallback: 'ccs auth create ', + afterRunPrefix: '执行命令后:', + afterRunStep1: '在浏览器中完成 Claude 登录', + afterRunStep2: '返回此处并刷新,即可看到新账号', + poolingHint: + '如果更倾向于共享池化 Claude OAuth 路由,可在 Accounts 页面操作按钮中使用 CLIProxy Claude pool。', + close: '关闭', + copied: '已复制!', + copyCommand: '复制命令', + }, + cliproxyModelCategory: { + google: 'Google(Gemini)', + openai: 'OpenAI(GPT)', + anthropic: 'Anthropic(Claude)', + antigravity: 'Antigravity', + other: '其他', + }, + cliproxyOverviewComponents: { + availableModels: '可用模型', + noModelsAvailable: '暂无可用模型', + startSessionHint: '启动 CLIProxy 会话后即可拉取可用模型', + totalCount: '共 {{count}} 个', + availableThroughGroupedByProvider: '通过 {{backendLabel}} 提供的模型,按提供商分组', + moreCount: '另外 {{count}} 个', + }, + categorizedModelSelector: { + selectModel: '选择模型', + noModelsAvailable: '暂无可用模型', + modelPlaceholder: '模型...', + }, + openrouterQuickStart: { + recommended: '推荐', + title: '从 OpenRouter 开始', + description: + '通过一个 API 即可访问来自 OpenAI、Anthropic、Google、Meta 等的 {{modelCountLabel}} 个模型。', + featureOneApi: '一个 API,接入全部提供商', + featureTierMapping: '模型档位映射', + createOpenRouterProfile: '创建 OpenRouter 配置', + getApiKeyAt: '在此获取 API Key:', + or: '或', + createCustomProfile: '创建自定义 API 配置', + }, + alibabaCodingPlanQuickStart: { + recommended: '推荐', + title: '试用阿里云 Coding Plan', + description: '通过 Anthropic 兼容端点接入阿里云 Model Studio Coding Plan。', + featureEndpoint: 'Anthropic 兼容端点', + featureKeyFormat: '专用 sk-sp API Key', + createAlibabaProfile: '创建 Alibaba Coding Plan 配置', + readGuideAt: '查看配置文档:', + }, + alibabaCodingPlanPromo: { + title: 'Alibaba Coding Plan', + subtitle: '通过 Anthropic 端点接入 Model Studio Coding Plan', + add: '添加', + }, + credentialHealth: { + title: '凭据健康状态', + ready: '就绪', + noCredentialsConfigured: '尚未配置凭据。请使用上方登录按钮完成认证。', + neverUsed: '从未使用', + justNow: '刚刚', + unknown: '未知', + daysAgo: '{{count}} 天前', + hoursAgo: '{{count}} 小时前', + minutesAgo: '{{count}} 分钟前', + }, + proxyStatusWidget: { + justNow: '刚刚', + minutesAgo: '{{count}} 分钟前', + hoursAgo: '{{count}} 小时前', + syncReady: '同步就绪', + noConfig: '未配置', + remoteProxy: '远程代理', + active: '已连接', + trafficAutoRouted: '流量已自动转发到远程服务器', + tooltipSync: '同步配置到 CLIProxy', + tooltipRestart: '重启', + tooltipStop: '停止', + tooltipClose: '关闭', + tooltipVersionSettings: '版本设置', + versionManagement: '版本管理', + selectVersionPlaceholder: '选择要安装的版本...', + stable: '(稳定版)', + install: '安装', + versionsAboveUnstable: '高于 {{version}} 的版本存在已知问题', + lastChecked: '上次检查 {{time}}', + notRunning: '未运行', + start: '启动', + port: '端口 {{port}}', + sessionCount: '{{count}} 个会话', + sessionCount_other: '{{count}} 个会话', + installUnstableTitle: '安装非稳定版本?', + installUnstableDesc: + '即将安装 v{{version}},该版本高于当前最大稳定版 v{{maxStable}}。', + installUnstableWarning: '该版本存在已知稳定性问题,可能导致异常行为。', + installUnstableConfirm: '确定要继续吗?', + cancel: '取消', + installAnyway: '仍要安装', + clickToDowngrade: '点击降级', + clickToUpdate: '点击更新', + }, + syncStatusCard: { + profileSync: '配置同步', + ready: '就绪', + noConfig: '未配置', + syncsProfilesDesc: '将 API 配置同步到本地 CLIProxy 配置', + runDoctorHint: '运行 ccs doctor --fix 生成配置。', + details: '详情', + syncNow: '立即同步', + }, + syncDialog: { + title: '同步配置到本地 CLIProxy', + description: '将 CCS API 配置同步到本地 CLIProxy 的 config.yaml。', + noProfiles: '没有可同步的配置。', + createProfilesFirst: '请先在「配置」页创建 API 配置。', + modelLabel: '模型:', + ready: '就绪', + profilesToSync_one: '{{count}} 个配置待同步', + profilesToSync_other: '{{count}} 个配置待同步', + cancel: '取消', + syncing: '同步中...', + synced: '已同步!', + syncNow: '立即同步', + }, + errorLogs: { + title: '错误日志', + failedRequests_one: '{{count}} 个失败请求', + failedRequests_other: '{{count}} 个失败请求', + cliproxyDiagnostics: 'CLIProxy 诊断', + showingCount: '显示 50 / {{total}} 条', + selectLog: '选择一条日志查看详情', + failedLoadContent: '加载日志内容失败', + copyAbsolutePath: '复制绝对路径', + copyRawContent: '复制原始日志', + tabOverview: '概览', + tabHeaders: '请求头', + tabRequest: '请求体', + tabResponse: '响应体', + tabRaw: '原始', + modelLabel: '模型:', + quotaResetsIn: '配额重置于 ', + method: '方法', + provider: '提供商', + version: '版本', + endpoint: '端点', + url: 'URL', + timestamp: '时间戳', + na: '无', + noHeaders: '无请求头', + noRequestBody: '无请求体内容', + noResponseBody: '无响应体内容', + rateLimitedMsg: '触发限速。请切换账号或等待配额重置。模型 {{model}} 配额已用尽。', + rateLimitedMsgNoModel: '触发限速。请切换账号或等待配额重置。', + authFailedMsg: '认证失败。请通过 CLIProxy 设置重新认证或检查 API Key。', + notFoundMsg: '端点未找到,该提供商可能不存在此端点。', + serverErrorMsg: '上游服务器错误。请稍后重试或查看提供商状态页。', + timeoutMsg: '请求超时。请检查网络或增大超时设置。', + unknown: '未知', + }, + editAccountContext: { + title: '编辑历史同步', + description: '配置「{{name}}」与其他 ccs auth 账号共享历史与连续性的方式。', + syncMode: '同步模式', + selectContextMode: '选择同步模式', + isolatedOption: '隔离(不同步)', + sharedOption: '共享(启用同步)', + sharedModeHint: '共享模式会为同一历史同步分组内的账号复用工作区上下文。', + isolatedModeHint: '隔离模式使该账号与其他 ccs auth 账号完全独立。', + historySyncGroup: '历史同步分组', + groupPlaceholder: 'default', + groupHint: '会规范为小写(空格变为短横线)。允许:字母、数字、_、-(最多 {{max}} 字符)。', + invalidGroup: '请输入以字母开头的有效分组名。', + continuityDepth: '连续性深度', + selectContinuityDepth: '选择连续性深度', + standardOption: '标准(仅项目)', + deeperOption: '更深连续性(高级)', + deeperHint: '高级模式还会同步 session-env、file-history、shell-snapshots 和 todos。', + standardHint: '标准模式仅同步项目工作区上下文。', + credentialsIsolated: '凭据与 .anthropic 在所有模式下均按账号隔离。', + cancel: '取消', + save: '保存', + saving: '保存中...', + }, + historySyncLearningMap: { + title: '历史同步说明', + description: '隔离 -> 共享 -> 更深。在每行使用「同步」进行所有更改。', + learningMap: '说明', + isolated: '隔离', + shared: '共享', + deeper: '更深', + cliproxyManaged_one: '{{count}} 个 CLIProxy Claude 池账号在操作中心 / CLIProxy 页面管理。', + cliproxyManaged_other: + '{{count}} 个 CLIProxy Claude 池账号在操作中心 / CLIProxy 页面管理。', + showDetails: '显示详情:分组、切换与旧策略', + modeSwitch: '模式切换', + modeSwitchDesc: '同步对话框可让用户在隔离/共享间切换并选择更深连续性。', + historySyncGroup: '历史同步分组', + historySyncGroupDesc: '同一分组即共享项目上下文。默认回退为 default。', + }, + accountsTable: { + name: '名称', + type: '类型', + defaultBadge: '默认', + created: '创建时间', + lastUsed: '最后使用', + historySync: '历史同步', + actions: '操作', + syncTitle: '编辑同步模式、分组与连续性深度', + confirmLegacyTitle: '确认此旧账号的当前模式为显式', + sync: '同步', + confirm: '确认', + active: '当前', + setDefault: '设为默认', + deleteAccount: '删除账号', + cannotDeleteDefault: '不能删除默认账号', + noAccounts: '未找到 CCS auth 账号。请使用 ccs auth create 添加。', + resetToDefault: '恢复为 CCS 默认', + deleteDialogTitle: '删除账号', + deleteDialogDesc: + '确定要删除账号「{{name}}」吗?将移除该配置及其所有会话数据,且无法撤销。', + cancel: '取消', + delete: '删除', + sharedGroupStandard: '共享({{group}},标准)', + sharedGroupDeeper: '共享({{group}},更深)', + sharedGroupLegacy: '共享({{group}},标准旧策略)', + isolatedLegacy: '隔离(旧默认)', + isolated: '隔离', + }, + addAccountDialog: { + title: '添加 {{displayName}} 账号', + descKiro: '选择 Kiro 认证方式,然后通过浏览器或从 Kiro IDE 导入完成认证。', + descDeviceCode: '点击认证后,验证码将显示,请在提供商网站输入。', + descOauth: '点击认证获取 OAuth URL,在任意浏览器中打开即可登录。', + powerUserEnabled: '已启用高级用户模式', + powerUserSkipped: '已在设置 > 代理 中跳过 AGY 责任清单,您需自行承担 OAuth/账号风险。', + authMethod: '认证方式', + selectKiroAuthMethod: '选择 Kiro 认证方式', + nicknameRequired: '昵称(必填)', + nicknameOptional: '昵称(选填)', + nicknamePlaceholder: '例如:工作、个人', + nicknameRequiredHint: '该提供商必填。请使用唯一易记名称(如工作、个人)。', + nicknameOptionalHint: '用于区分账号的友好名称。留空将根据邮箱自动生成。', + waitingForAuth: '等待认证中...', + deviceCodeHint: '验证码对话框即将出现,请在提供商网站输入验证码。', + browserHint: '在浏览器中完成认证后,本对话框将自动关闭。', + openUrlLabel: '在任意浏览器中打开此 URL 登录:', + copied: '已复制', + copy: '复制', + open: '打开', + popupBlocked: '弹窗被拦截。请复制上方 URL 在浏览器中手动打开。', + redirectPasteLabel: '重定向未生效?请粘贴回调 URL:', + callbackPlaceholder: '在此粘贴重定向 URL...', + submitCallback: '提交回调', + submitting: '提交中...', + preparingUrl: '正在准备登录 URL...', + cancel: '取消', + importingToken: '正在从 Kiro IDE 导入令牌...', + authenticate: '认证', + importFromIde: '从 IDE 导入', + importing: '导入中...', + }, + flowViz: { + backToProviders: '返回提供商', + showDetails: '显示详情', + hideDetails: '隐藏详情', + resetLayout: '重置布局', + provider: '提供商', + totalRequests: '总请求数', + accounts: '账号', + connectionTimeline: '连接时间线', + noRecentConnections: '暂无最近连接', + }, + quickStats: { + totalRequests: '总请求数', + successRate: '成功率', + totalTokens: '总 Token 数', + activeModels: '活跃模型数', + }, + authMonitor: { + accounts: '账号', + accountsCount: '{{count}} 个账号', + success: '成功', + failed: '失败', + successRate: '成功率', + }, + accountCard: { + resumeAccount: '恢复账号', + pauseAccount: '暂停账号', + quotaLoading: '配额...', + quota: '配额', + quotaUnavailable: '配额限制不可用', + reauthNeeded: '需要重新认证', + removeAndReadd: '移除并重新添加账号', + autoRefreshFailed: '自动刷新失败', + }, + antigravityChecklist: { + title: 'Antigravity OAuth 责任须知', + subtitle: '认证前请完成全部 4 步。', + mandatory: '必读', + completion: '完成度', + stepsCount: '{{current}}/4 步', + }, + accountSafetyWarning: { + title: 'OAuth 账号安全提示', + subtitle: 'Issue #509 · Gemini + AGY OAuth 风险', + firstLine: + 'Issue #509 记录了与 ccs agy 及 ccs gemini 与 ccs agy 共享账号使用相关的封禁/停用报告。', + secondLine: '仅在您愿意承担 OAuth 与账号访问风险的前提下继续。', + issueLabel: '查看 issue #509', + proxySettingsLabel: 'Gemini + AGY 控制:设置 > 代理', + }, + cliproxyDialog: { + name: '名称', + provider: '提供商', + account: '账号', + accountOptional: '账号(选填)', + model: '模型', + modelOptional: '模型(选填)', + defaultTarget: '默认目标', + defaultTier: '默认档位', + tierConfig: '档位配置', + selectProvider: '选择提供商...', + useDefaultAccount: '使用默认账号', + defaultSuffix: '(默认)', + opus: 'Opus', + sonnet: 'Sonnet', + haiku: 'Haiku', + claudeCode: 'Claude Code', + factoryDroid: 'Factory Droid', + cancel: '取消', + create: '创建', + creating: '创建中...', + saveChanges: '保存更改', + saving: '保存中...', + placeholderName: 'my-gemini', + placeholderModel: 'gemini-2.5-pro', + placeholderComposite: 'my-composite', + placeholderModelId: 'model-id', + placeholderAccountId: 'account-id', + editCompositeTitle: '编辑复合变体:{{name}}', + editSingleTitle: '编辑单一样式:{{name}}', + }, + profileEditor: { + profileInfo: '配置信息', + profileName: '配置名称', + filePath: '文件路径', + lastModified: '最后修改', + defaultTarget: '默认目标', + quickUsage: '快速使用', + runWithProfile: '使用配置运行', + runOnDroid: '在 Droid 上运行', + droidAliasExplicit: 'Droid 别名(显式)', + overrideToClaude: '切换为 Claude', + overrideToClaudeExplicit: '切换为 Claude(显式)', + setAsDefault: '设为默认', + provider: '提供商', + custom: '自定义', + }, + settings: { + sectionLoadFailed: '加载分区失败', + unknownError: '发生未知错误', + reloadPage: '重新加载页面', + refresh: '刷新', + copy: '复制', + copied: '已复制', + saved: '已保存', + loading: '加载中...', + configFileNotFound: '未找到配置文件', + }, + settingsTabs: { + web: '网页', + env: '环境', + think: '思考', + proxy: '代理', + auth: '认证', + backup: '备份', + }, + settingsAuth: { + failedFetchTokens: '获取认证令牌失败', + failedSaveTokens: '保存令牌失败', + tokensUpdated: '令牌已更新,请重启 CLIProxy 生效。', + failedRegenerate: '重新生成密钥失败', + secretRegenerated: '新的管理密钥已生成,请重启 CLIProxy 生效。', + failedReset: '重置令牌失败', + tokensReset: '令牌已重置为默认值,请重启 CLIProxy 生效。', + description: '配置 CLIProxy 认证令牌。修改后需要重启 CLIProxy。', + apiKey: 'API Key', + custom: '自定义', + apiKeyDesc: 'Claude Code 通过该值向 CLIProxy 鉴权', + apiKeyPlaceholder: 'API key', + managementSecret: '管理密钥', + managementSecretDesc: 'CCS Dashboard 使用该值访问 CLIProxy 管理接口', + managementSecretPlaceholder: '管理密钥', + generateSecret: '生成新的安全密钥', + resetDefaults: '重置为默认值', + resetDesc: '将 API key 和管理密钥都恢复为默认值。', + saving: '保存中...', + save: '保存', + }, + settingsGlobalEnv: { + description: '向所有非 Claude 订阅型配置注入环境变量(gemini、codex、agy、ghcp 等)', + enabled: '全局环境变量已启用', + disabled: '全局环境变量已禁用', + enabledDesc: '环境变量将注入第三方配置', + disabledDesc: '环境变量不会被注入', + envVars: '环境变量', + noneConfigured: '尚未配置环境变量', + addNew: '新增变量', + keyName: 'KEY_NAME', + value: 'value', + add: '添加', + quickAdd: '快速添加常用变量', + allConfigured: '常用变量已全部配置', + }, + settingsBackups: { + failedFetch: '获取备份失败', + failedRestore: '恢复备份失败', + restoreSuccess: '备份恢复成功', + title: '设置备份', + description: '恢复 settings.json 的历史版本。每次修改设置时会自动创建备份。', + none: '暂无备份', + noneHint: '修改设置后,备份会显示在这里', + latest: '最新', + restoring: '恢复中...', + restore: '恢复', + restoreConfirmTitle: '确认恢复备份?', + restoreConfirmPrefix: '这将用以下备份替换当前设置:', + restoreConfirmSuffix: '此操作不可撤销。', + cancel: '取消', + }, + settingsWebsearch: { + description: '面向第三方配置(gemini、codex、agy 等)的 CLI 网页搜索', + enabled: 'WebSearch 已启用', + disabled: 'WebSearch 已禁用', + checking: '正在检查状态...', + providers: '提供商', + noneConfigured: '未配置任何提供商', + noneConfiguredHint: '请先安装 CLI 工具以启用网页搜索提供商', + checkProviders: '检查可用提供商', + installed: '已安装', + notInstalled: '未安装', + model: '模型:', + howInstall: '如何安装 {{name}} CLI', + installGlobally: '全局安装', + requiresKey: '(需要 xAI API key)', + freeTier: '(提供免费额度)', + viewDocs: '查看文档', + }, + settingsProxy: { + failedLoadAgyMode: '加载 AGY 高级模式失败', + failedUpdateAgyMode: '更新 AGY 高级模式失败', + failedVerifyAgyMode: '校验 AGY 高级模式持久化失败', + notPersistedAgyMode: 'AGY 高级模式未成功持久化,配置可能被其他进程修改。', + agyModeEnabled: 'AGY 高级模式已启用。', + agyModeDisabled: 'AGY 高级模式已关闭。', + typePhraseToContinue: '请输入“{{value}}”后继续。', + description: '为基于代理的配置设置本地或远程 {{backend}} 连接', + backendPlus: 'CLIProxy Plus', + backend: 'CLIProxy', + instanceStatus: '实例状态', + connectionMode: '连接模式', + local: '本地', + remote: '远程', + localDesc: '在本机运行 {{backend}} 二进制', + remoteDesc: '连接到远程 {{backend}} 服务器', + backendBinary: '后端二进制', + stopProxyToSwitch: '请先在实例状态中停止正在运行的代理,再切换后端。', + default: '默认', + plusDesc: '完整支持包括 Kiro 和 GitHub Copilot 在内的提供商', + originalDesc: '原版二进制(仅 Gemini、Codex、Antigravity)', + variantsIncompatible: + '现有 Kiro/Copilot 变体与 CLIProxyAPI 不兼容。请切换到 CLIProxyAPIPlus 或移除这些变体。', + safety: '安全', + agyModeTitle: 'Antigravity 高级模式', + agyModeDesc: '在 Add Account 与 `ccs agy` 流程中跳过 AGY 责任确认清单。', + agyWarning: '仅在你充分理解 OAuth 封禁风险模式(#509)后使用。CCS 不对账号损失承担责任。', + finalConfirm: '需要最终确认', + finalConfirmDesc: + '启用后会跳过 Dashboard 与 CLI 的 AGY 安全检查。请先阅读 issue #509,并输入精确短语后继续。', + step1: '步骤 1', + readIssue: '阅读 issue #509', + step2: '步骤 2', + typePrefix: '输入', + typeSuffix: '以启用。', + typePhraseAria: '输入 I ACCEPT RISK 以启用 Antigravity 高级模式', + exactPhrase: '必须输入完全一致的短语。', + enableAgyMode: '启用高级模式', + toggleAgyMode: '切换 AGY 高级模式', + fallbackSettings: '回退设置', + enableFallback: '启用本地回退', + enableFallbackDesc: '远程不可达时使用本地代理', + autoStartLocal: '自动启动本地代理', + autoStartLocalDesc: '发生回退时自动启动本地代理', + advanced: '高级', + debugMode: '调试模式', + debugModeDesc: '在浏览器控制台输出开发诊断信息', + debugModeEnabled: '调试模式已开启,请在浏览器控制台查看详细日志。', + localProxy: '本地代理', + port: '端口', + autoStart: '自动启动', + localAutoStartDesc: '需要时自动启动本地代理', + remoteConfigTitle: '远程服务器配置', + host: '主机', + hostPlaceholder: '192.168.1.100 或 proxy.example.com', + defaultPort: '默认:{{value}}', + portPlaceholder: '留空使用 {{value}}', + protocol: '协议', + http: 'HTTP', + https: 'HTTPS', + apiKeyOptional: 'API Key(可选)', + apiKeyPlaceholder: '用于 /v1/* API 接口', + apiKeyDesc: '用于 /v1/chat/completions 接口请求', + managementKeyOptional: '管理密钥(可选)', + managementKeyPlaceholder: '用于 /v0/management/* 接口', + managementKeyDesc: '用于 Dashboard 管理接口。未设置时回退使用 API Key。', + testing: '测试中...', + testConnection: '测试连接', + connectedLatency: '已连接({{value}}ms)', + connectionFailed: '连接失败', + invalidPortOrEmpty: '端口必须是 1 到 65535 的整数,或留空使用默认值', + invalidLocalPort: '本地端口必须是 1 到 65535 的整数', + backendPlusApi: 'CLIProxyAPIPlus', + backendApi: 'CLIProxyAPI', + }, + settingsThinking: { + description: '为支持的模型配置扩展思考/推理。', + supportedProviders: '支持的提供商', + modeTitle: '思考模式', + modeAutoDesc: '根据模型档位自动设置思考级别', + modeOffDesc: '关闭扩展思考', + modeManualDesc: '设置持久覆盖级别,或按次使用 CLI 参数', + tierDefaults: '档位默认值', + tierDefaultsDesc: '自动模式下各模型档位的默认思考级别。', + persistentOverride: '持久覆盖', + persistentOverrideDesc: '应用到所有会话。CLI 参数仍具有更高优先级。', + enterCustomBudget: '输入自定义预算', + apply: '应用', + providerOverrides: '提供商覆盖({{count}})', + providerOverridesDesc: '为特定提供商覆盖档位默认值。可按需添加自定义提供商键。', + addProviderPlaceholder: '添加提供商键(例如 qwen)', + showWarnings: '显示告警', + showWarningsDesc: '当思考值被限制或调整时显示告警', + cliEnvOverride: 'CLI 与环境变量覆盖', + supportLine1Prefix: '思考预算:', + supportLine1Suffix: '(基于 token)', + supportLine2Prefix: '推理强度:', + supportLine2SuffixPrefix: '(后缀或 ', + supportLine2SuffixPostfix: ':medium/high/xhigh)', + supportLine3Prefix: 'Codex 后缀会固定强度(例如 ', + supportLine3Suffix: ');无后缀模型使用 Thinking mode。', + }, + analytics: { + title: '分析', + subtitle: '追踪使用情况与洞察', + month: '本月', + allTime: '全部时间', + updated: '更新于 {{value}}', + }, + analyticsSummary: { + totalTokens: '总 Token', + totalTokensSubtitle: '输入 {{input}} / 输出 {{output}}', + totalCost: '总成本', + totalCostSubtitle: '日均 ${{value}}', + cacheTokens: '缓存 Token', + cacheTokensSubtitle: '${{cost}}(占成本 {{percent}}%)', + inputCost: '输入成本', + outputCost: '输出成本', + tokensSubtitle: '{{value}} 个 token', + }, + apiProfiles: { + title: 'API 配置', + new: '新建', + searchPlaceholder: '搜索配置...', + loadingProfiles: '正在加载配置...', + failedLoadTitle: '加载配置失败', + failedLoadDesc: '无法获取 API 配置,请稍后重试。', + retry: '重试', + noProfilesYet: '还没有 API 配置', + noProfilesDesc: '创建你的第一个配置以连接自定义 API 端点', + createProfile: '创建配置', + noProfileMatch: '没有匹配“{{query}}”的配置', + profileCount: '{{count}} 个配置', + configuredCount: '{{count}} 个已配置', + deleteProfileTitle: '删除配置', + deleteProfileDesc: '确定要删除“{{name}}”吗?这会移除设置文件且无法恢复。', + delete: '删除', + unsavedChangesTitle: '未保存的更改', + unsavedChangesDesc: '你在“{{current}}”中有未保存内容,是否丢弃并切换到“{{next}}”?', + discardSwitch: '丢弃并切换', + }, + accountsPage: { + title: '账号', + managePrefix: '统一管理', + manageSuffix: '账号与池化接入流程。', + mobileManageSuffix: '的连续性配置。', + primaryActions: '主要操作', + createAccount: '创建账号', + authClaudeInPool: '在 Pool 中认证 Claude', + openClaudePoolSettings: '打开 Claude Pool 设置', + openCliProxyClaudePool: '打开 CLIProxy Claude Pool', + migrationFollowup: '迁移后续处理', + legacyContextPending: '{{count}} 个账号仍需首次模式确认。', + legacyContinuityPending: '{{count}} 个共享账号仍处于标准旧版连续性深度。', + confirmingLegacy: '正在确认旧版策略...', + confirmLegacy: '确认旧版策略({{count}})', + noLegacyFollowup: '暂无待处理的迁移后续项。', + continuityGuide: '连续性指南', + expandWhenNeeded: '按需展开查看。', + sharedStandard: '共享标准', + sharedStandardDesc: '仅同步项目工作区。适合大多数团队。', + sharedDeeper: '共享更深', + sharedDeeperPrefix: '额外包含', + isolated: '隔离', + isolatedDesc: '不建立链接。适合严格隔离场景。', + quickCommands: '快捷命令', + quickCommandsDesc: '复制后在终端运行。', + workspaceBadge: 'ccs auth 工作区', + historySyncBadge: '历史同步控制', + authAccounts: '认证账号', + tableScopePrefix: '该表仅展示', + tableScopeMiddle: '账号。模式/分组/深度调整请使用', + tableScopeSuffix: '。', + accountMatrix: '账号矩阵', + sharedTotalDesc: '共享总数:{{count}}。可执行 Sync 设置和旧版确认操作。', + loadingAccounts: '正在加载账号...', + }, + health: { + justNow: '刚刚', + secondsAgo: '{{count}} 秒前', + minutesAgo: '{{count}} 分钟前', + hoursAgo: '{{count}} 小时前', + systemHealth: '系统健康', + build: '构建 {{version}}', + lastScan: '上次扫描:', + autoRefresh: '自动刷新:', + refresh: '刷新', + checks: '检查项', + version: '版本', + platform: '平台', + liveMonitoring: '实时监控已启用', + copied: '已复制到剪贴板', + refreshing: '正在刷新健康检查...', + fix: '修复', + applyFix: '应用修复', + healthLabel: '健康', + }, + updates: { + actionRequired: '待处理', + done: '已完成', + all: '全部', + inboxTitle: '更新收件箱', + inboxSubtitle: '先处理操作,再将更新标记为完成或忽略。', + needsAction: '需处理', + doneCount: '已完成', + searchPlaceholder: '搜索操作或命令', + noNotices: '当前视图下没有通知。', + noUpdates: '暂无更新。', + published: '发布于 {{date}}', + markDone: '标记完成', + dismiss: '忽略', + reopen: '重新打开', + doNext: '下一步', + impactedIntegrations: '受影响的集成', + impactedDesc: '根据更新范围与路由匹配的相关区域。', + whyMatters: '为什么重要', + whyMattersDesc: '仅保留简要背景,不展示冗长发布说明。', + open: '打开', + progressNeedsAction: '需处理', + progressInReview: '审阅中', + progressDone: '已完成', + progressDismissed: '已忽略', + statusNew: '新', + statusStable: '稳定', + statusPlanned: '计划中', + dashboard: 'Dashboard', + cliUsage: 'CLI 用法', + }, + cliproxyPage: { + connected: '已连接', + notConnected: '未连接', + variant: '变体', + parentNotConnected: '上游未连接', + viaProvider: '通过 {{provider}}', + emptyTitle: 'CCS 配置管理', + emptyDesc: '管理 CLIProxy 提供商的 OAuth 认证、账号偏好与模型选择,配置 CCS 的请求路由。', + emptyControlPanelPrefix: '如需查看实时用量和监控,请前往', + controlPanel: '控制面板', + quickSetup: '快速设置', + accountManagement: 'CCS 级账号管理', + providers: '提供商', + variants: '变体', + providerCount: '{{count}} 个提供商', + connectedCount: '{{count}} 个已连接', + variantDisplay: '{{name}}({{provider}} 变体)', + }, + copilotPage: { + title: 'Copilot', + subtitle: 'GitHub Copilot 代理', + setup: '安装', + auth: '认证', + daemon: '守护进程', + unofficialTitle: '非官方 API - 风险自负', + unofficialItem1: '逆向接口,可能随时失效', + unofficialItem2: '过度使用可能触发账号限制', + unofficialItem3: 'CCS 不提供担保,也不承担责任', + yes: '是', + no: '否', + installed: '已安装', + missing: '缺失', + integration: '集成', + enabled: '已启用', + disabled: '已禁用', + github: 'GitHub', + connected: '已连接', + notConnected: '未连接', + authenticate: '去认证', + authenticating: '认证中...', + status: '状态', + running: '运行中', + stopped: '已停止', + port: '端口', + stopping: '停止中...', + stop: '停止', + starting: '启动中...', + start: '启动', + installing: '安装中...', + installCopilotApi: '安装 copilot-api', + proxy: '代理', + active: '活跃', + inactive: '未激活', + }, + cursorPage: { + title: 'Cursor', + beta: 'Beta', + subtitle: 'Cursor 集成专用控制面板', + unofficialTitle: '非官方 API - 风险自负', + unofficialItem1: '逆向集成可能随时失效', + unofficialItem2: '滥用或过量使用可能导致账号受限', + unofficialItem3: 'CCS 不提供担保,也不承担责任', + integration: '集成', + authentication: '认证', + daemon: '守护进程', + enabled: '已启用', + disabled: '已禁用', + expired: '已过期', + connected: '已连接', + notConnected: '未连接', + running: '运行中', + stopped: '已停止', + actions: '操作', + disableIntegration: '禁用集成', + enableIntegration: '启用集成', + autoDetectAuth: '自动检测认证', + manualAuthImport: '手动导入认证', + stopDaemon: '停止守护进程', + startDaemon: '启动守护进程', + port: '端口', + configuration: 'Cursor 配置', + lastModified: '最后修改:', + neverSaved: '从未保存', + saving: '保存中...', + save: '保存', + modelConfig: '模型配置', + settings: '设置', + info: '信息', + presets: '预设', + presetsDesc: '应用预设模型映射', + modelMapping: '模型映射', + modelMappingDesc: '为不同档位配置使用模型', + defaultModel: '默认模型', + defaultModelDesc: '未指定档位时使用。', + opusModel: 'Opus(最强)', + opusModelDesc: '复杂推理与最高质量输出。', + sonnetModel: 'Sonnet(均衡)', + sonnetModelDesc: '日常编码和通用对话。', + haikuModel: 'Haiku(快速)', + haikuModelDesc: '低延迟与轻量请求。', + runtimeSettings: '运行时设置', + autoStartDaemon: '自动启动守护进程', + autoStartDesc: '在使用集成时自动启动 Cursor 守护进程。', + ghostMode: 'Ghost 模式', + ghostModeDesc: '请求时附带 x-ghost-mode 以减少遥测。', + provider: '提供商', + filePath: '文件路径', + availableModels: '可用模型', + loadingModels: '加载模型中...', + noModels: '暂无模型元数据,请先启动守护进程后刷新。', + default: '默认', + rawConfiguration: '原始配置(JSON)', + manualImportTitle: '手动导入 Cursor 认证', + manualImportDesc: '若无法自动检测,请提供 Cursor access token 与 machine ID。', + accessToken: '访问令牌', + accessTokenPlaceholder: '粘贴 Cursor access token', + machineId: '机器 ID', + machineIdPlaceholder: '32 位十六进制(无连字符 UUID)', + cancel: '取消', + import: '导入', + selectModel: '选择模型', + useDefaultModel: '使用默认模型', + availableModelCount: '可用模型({{count}})', + invalidPort: '端口必须是 1 到 65535 之间的整数', + defaultModelRequired: '默认模型必填', + savedConfig: 'Cursor 配置与模型映射已保存', + failedSaveConfig: '保存配置失败', + modelsLoadingWait: '模型仍在加载中,请稍后再应用预设。', + noModelsAvailable: '暂无可用模型,请先启动守护进程并刷新。', + appliedCodexPreset: '已应用 GPT-5.3 Codex 预设', + appliedClaudePreset: '已应用 Claude 4.6 预设', + appliedGeminiPreset: '已应用 Gemini 3 预设', + integrationEnabled: 'Cursor 集成已启用', + integrationDisabled: 'Cursor 集成已禁用', + failedUpdateIntegration: '更新集成状态失败', + credentialsImported: 'Cursor 凭据已导入', + autoDetectFailed: '自动检测失败', + manualRequired: 'Token 和 machine ID 为必填项', + manualImportFailed: '手动导入失败', + failedStartDaemon: '启动守护进程失败', + daemonStarted: '守护进程已启动', + daemonStartedWithPid: '守护进程已启动(PID: {{pid}})', + failedStopDaemon: '停止守护进程失败', + daemonStopped: '守护进程已停止', + rawLoading: '原始设置仍在加载,请稍后重试。', + invalidJson: 'JSON 无效', + rawSaved: '原始设置已保存', + rawChanged: '原始设置已被外部修改,请刷新后重试。', + failedSaveRaw: '保存原始设置失败', + savedAll: 'Cursor 配置已保存', + }, + droidPage: { + loadingDiagnostics: '加载 Droid 诊断中...', + failedDiagnostics: '加载 Droid 诊断失败。', + overview: '概览', + byok: 'BYOK', + docs: '文档', + runtimeInstall: '运行时与安装', + status: '状态', + detected: '已检测', + notFound: '未找到', + detectionSource: '检测来源', + binaryPath: '二进制路径', + installDirectory: '安装目录', + version: '版本', + overridePath: '覆盖路径(CCS_DROID_PATH)', + configFiles: '配置文件', + path: '路径', + resolved: '解析结果', + size: '大小', + lastModified: '最后修改', + parseWarning: '解析告警:{{value}}', + readWarning: '读取告警:{{value}}', + warnings: '告警', + byokSummary: 'BYOK 概览', + activeModelSelector: '当前模型选择器', + customModels: '自定义模型', + ccsManaged: 'CCS 管理', + userManaged: '用户管理', + malformedEntries: '异常条目', + providers: '提供商', + none: '无', + customModelsTitle: '自定义模型', + modelName: '名称 / 模型', + provider: '提供商', + baseUrl: 'Base URL', + noCustomModels: '没有自定义模型', + docsAlignedNotes: '文档对齐说明', + factoryDocs: 'Factory 文档', + providerFactCheckDocs: 'Provider 校验文档', + providerValues: 'Provider 值:{{value}}', + settingsHierarchy: 'Settings 层级:{{value}}', + settingsTitle: 'Droid BYOK 设置', + fixJsonBeforeQuickSettings: '请先修复 JSON 语法,再使用快速设置。', + fixJsonBeforeReasoning: '请先修复 JSON 语法,再更新 BYOK 推理设置。', + unableUpdateReasoning: '无法更新所选 BYOK 模型的推理设置。', + fixJsonBeforeBudget: '请先修复 JSON 语法,再更新思考预算。', + anthropicOnlyBudget: '思考预算仅适用于 Anthropic BYOK 模型。', + invalidJson: 'JSON 无效:{{value}}', + saved: 'Droid 设置已保存', + changedExternally: 'Droid 设置已被外部修改,请刷新后重试。', + failedSave: '保存 Droid 设置失败', + }, + sharedPage: { + commands: '命令', + skills: '技能', + agents: '代理', + title: '共享数据', + subtitle: '在 Claude 实例间共享的命令、技能和代理', + totalShared: '共享总数', + visible: '可见', + markdownDetail: 'Markdown 详情视图', + filterPrefix: '筛选:', + configurationRequired: '需要配置', + countsUnavailable: '统计不可用', + retryCounts: '重试统计', + filterPlaceholder: '按名称、描述或路径筛选 {{tab}}', + showing: '显示 {{visible}} / {{total}} 个{{tab}}', + showingQuery: '(关键字“{{query}}”)', + refreshing: '(刷新中...)', + loadingShared: '正在加载共享{{tab}}...', + failedLoadShared: '加载共享{{tab}}失败', + retry: '重试', + noSharedFound: '未找到共享{{tab}}。请运行 `ccs sync` 或在共享目录添加内容。', + noMatch: '没有匹配“{{query}}”的{{tab}}。', + selectOne: '选择一个{{tab}}查看完整内容。', + pathLabel: '路径', + resolvedSource: '解析后来源', + loadingMarkdown: '正在加载 Markdown 内容...', + failedLoadContent: '加载内容失败', + retryContent: '重试内容加载', + noMarkdown: '暂无 Markdown 内容。', + }, + }, + }, + vi: { + translation: { + locale: { + en: 'Tiếng Anh', + 'zh-CN': 'Tiếng Trung giản thể', + vi: 'Tiếng Việt', + }, + layout: { + languageSwitcher: 'Ngôn ngữ', + }, + nav: { + general: 'Chung', + home: 'Trang chủ', + analytics: 'Phân tích', + identityAccess: 'Nhận dạng & Quyền truy cập', + apiProfiles: 'Hồ sơ API', + cliproxyPlus: 'CLIProxy Plus', + cliproxyOverview: 'Tổng quan', + controlPanel: 'Bảng điều khiển', + githubCopilot: 'GitHub Copilot', + cursorIde: 'Cursor IDE', + accounts: 'Tài khoản', + allAccounts: 'Tất cả tài khoản', + sharedData: 'Dữ liệu dùng chung', + compatibleClis: 'CLI tương thích', + factoryDroid: 'Factory Droid', + system: 'Hệ thống', + health: 'Sức khỏe', + settings: 'Cài đặt', + openrouterTooltip: 'Nổi bật: OpenRouter + Alibaba Coding Plan + Ollama', + }, + home: { + profiles: 'Hồ sơ', + cliproxy: 'CLIProxy', + accounts: 'Tài khoản', + health: 'Sức khỏe', + configurationRequired: 'Cần cấu hình', + }, + auth: { + dashboardTitle: 'Bảng điều khiển CCS', + loginDescription: 'Nhập thông tin đăng nhập của bạn để truy cập bảng điều khiển', + username: 'Tên người dùng', + password: 'Mật khẩu', + usernamePlaceholder: 'Nhập tên người dùng', + passwordPlaceholder: 'Nhập mật khẩu', + signIn: 'Đăng nhập', + signingIn: 'Đang đăng nhập...', + loginFailed: 'Đăng nhập không thành công', + }, + commonToast: { + apiKeyRequired: 'Cần có khóa API', + accountAddedPresetFailed: 'Đã thêm tài khoản nhưng không áp dụng được preset mặc định', + settingsCopied: 'Đã sao chép cài đặt vào bảng nhớ tạm', + enterPresetName: 'Vui lòng nhập tên preset để lưu', + codeCopied: 'Đã sao chép mã vào bảng nhớ tạm', + copyCodeFailed: 'Không sao chép được mã', + appliedModelAllTiers: 'Đã áp dụng mô hình cho tất cả các tier', + settingsSaved: 'Đã lưu cài đặt', + defaultTargetUpdated: 'Đã cập nhật mục tiêu mặc định', + failedUpdateDefaultTarget: + 'Không cập nhật được mục tiêu mặc định thành {{target}}{{suffix}}', + }, + profileDialog: { + editTitle: 'Chỉnh sửa hồ sơ', + createTitle: 'Tạo hồ sơ API', + name: 'Tên', + namePlaceholder: 'my-api', + baseUrl: 'URL cơ sở', + baseUrlPlaceholder: 'https://api.example.com', + apiKey: 'Khóa API', + defaultModel: 'Mô hình mặc định (ANTHROPIC_MODEL)', + defaultModelHint: 'Để trống để sử dụng: {{model}}', + modelMappingTitle: 'Ánh xạ mô hình (Opus/Sonnet/Haiku)', + modelMappingDesc: + 'Cấu hình ID mô hình riêng cho từng tier. Hữu ích với API proxy định tuyến từng loại mô hình đến backend khác nhau.', + opusModel: 'Mô hình Opus (ANTHROPIC_DEFAULT_OPUS_MODEL)', + sonnetModel: 'Mô hình Sonnet (ANTHROPIC_DEFAULT_SONNET_MODEL)', + haikuModel: 'Mô hình Haiku (ANTHROPIC_DEFAULT_HAIKU_MODEL)', + cancel: 'Hủy bỏ', + saving: 'Đang lưu...', + update: 'Cập nhật', + create: 'Tạo', + }, + modelTierMapping: { + title: 'Ánh xạ tầng mô hình', + advanced: 'Nâng cao', + description: + 'Định cấu hình các mô hình khác nhau cho các cấp độ opus/sonnet/haiku của Claude Code.', + autoSuggest: 'Tự động đề xuất dựa trên {{provider}}', + opusPlaceholder: 'vd: anthropic/claude-opus-4', + sonnetPlaceholder: 'ví dụ: anthropic/claude-sonnet-4', + haikuPlaceholder: 'vd: anthropic/claude-3.5-haiku', + footer: + 'Các biến này thiết lập ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_DEFAULT_HAIKU_MODEL.', + }, + envEditor: { + sensitive: 'nhạy cảm', + none: 'Không có biến môi trường nào được cấu hình.', + noneHint: 'Thêm biến bằng ô nhập bên dưới hoặc chỉnh sửa trực tiếp JSON.', + addVariable: 'Thêm biến môi trường', + keyPlaceholder: 'VARIABLE_NAME', + valuePlaceholder: 'giá trị', + }, + setupVariant: { + using: 'Sử dụng:', + variantNameRequired: 'Tên biến thể *', + variantNamePlaceholder: 'ví dụ: my-gemini, g3, flash', + invokeHintPrefix: 'Sử dụng tên này để gọi:', + model: 'Mô hình', + modelPlaceholder: 'ví dụ: gemini-2.5-pro, claude-opus-4-5', + choosePresetInstead: 'Hoặc chọn từ preset có sẵn', + selectModel: 'Chọn mô hình', + customModelName: 'Tên mô hình tùy chỉnh...', + enterAnyModel: 'Nhập bất kỳ tên mô hình nào được CLIProxy hỗ trợ', + defaultModel: 'Mặc định: {{model}}', + providerDefault: 'mặc định theo nhà cung cấp', + back: 'Quay lại', + skip: 'Bỏ qua', + creating: 'Đang tạo...', + createVariant: 'Tạo biến thể', + skipHint: 'Bỏ qua nếu bạn chỉ muốn thêm tài khoản mà không tạo biến thể', + }, + customPresetDialog: { + title: 'Preset tùy chỉnh', + presetNameOptional: 'Tên preset (tùy chọn)', + presetNamePlaceholder: 'ví dụ: Preset tùy chỉnh của tôi', + codexTipPrefix: 'Mẹo Codex: hậu tố', + codexTipSuffix: 'nỗ lực ghim. Các mô hình không có hậu tố sử dụng cài đặt Tư duy.', + defaultModel: 'Mô hình mặc định', + defaultModelDesc: 'Dùng khi không yêu cầu cấp mô hình cụ thể', + opusModel: 'Opus (Có khả năng nhất)', + opusModelDesc: 'Đối với các nhiệm vụ lý luận phức tạp', + sonnetModel: 'Sonnet (Cân bằng)', + sonnetModelDesc: 'Cân bằng giữa tốc độ và khả năng', + haikuModel: 'Haiku (Nhanh)', + haikuModelDesc: 'Phản hồi nhanh cho các tác vụ đơn giản', + cancel: 'Hủy bỏ', + savePreset: 'Lưu preset', + applyPreset: 'Áp dụng cài sẵn', + }, + componentModelSelector: { + selectModel: 'Chọn mô hình', + availableModelsCount: 'Mô hình khả dụng ({{count}})', + preview: 'Xem trước', + }, + copilotSettings: { + enableCopilot: 'Bật Copilot', + enableCopilotDesc: 'Cho phép sử dụng đăng ký GitHub Copilot', + basicSettings: 'Cài đặt cơ bản', + accountType: 'Loại tài khoản', + accountTypeIndividual: 'Cá nhân', + accountTypeBusiness: 'Business', + accountTypeEnterprise: 'Enterprise', + rateLimiting: 'Giới hạn tỷ lệ', + rateLimitSeconds: 'Giới hạn tốc độ (giây)', + noLimit: 'Không giới hạn', + waitOnRateLimit: 'Chờ giới hạn tỷ lệ', + waitOnRateLimitDesc: 'Chờ thay vì lỗi khi đạt đến giới hạn', + daemonSettings: 'Cài đặt Daemon', + autoStartDaemon: 'Daemon tự động khởi động', + autoStartDaemonDesc: 'Bắt đầu copilot-api khi sử dụng hồ sơ', + }, + providerModelSelector: { + selectModel: 'Chọn mô hình', + noModelsForProvider: 'Không có mô hình nào cho nhà cung cấp này', + broken: 'BROKEN', + deprecated: 'DEPRECATED', + paid: 'PAID', + freeTier: 'Gói miễn phí', + paidTier: 'Gói trả phí', + modelKnownIssues: 'Mô hình này có vấn đề đã biết', + viewIssueDetails: 'Xem chi tiết vấn đề', + modelDeprecated: 'Mô hình này không được dùng nữa', + recommended: 'Đề xuất', + allModelsCount: 'Tất cả mô hình ({{count}})', + noModelsAvailable: 'Không có mô hình khả dụng', + }, + createAuthProfileDialog: { + title: 'Tạo tài khoản mới', + description: + 'Hồ sơ xác thực yêu cầu đăng nhập Claude CLI. Chạy lệnh dưới đây trong terminal của bạn. Bạn có thể chỉnh sửa chế độ đồng bộ hóa, nhóm và độ liên tục sau này từ bảng Tài khoản.', + profileName: 'Tên hồ sơ', + profileNamePlaceholder: 'ví dụ: công việc, cá nhân, khách hàng', + invalidProfileName: + 'Tên phải bắt đầu bằng một chữ cái và chỉ chứa các chữ cái, số, dấu gạch ngang hoặc dấu gạch dưới.', + enableSharedHistory: 'Bật đồng bộ hóa lịch sử chia sẻ với các tài khoản xác thực ccs khác', + historySyncGroupOptional: 'Nhóm đồng bộ hóa lịch sử (tùy chọn)', + historySyncGroupPlaceholder: 'default, sprint-a, client-x', + historySyncGroupHint: + 'Để trống để sử dụng nhóm chia sẻ mặc định. Dấu cách được chuẩn hóa thành dấu gạch ngang.', + deeperContinuity: 'Nâng cao: chế độ liên tục sâu hơn', + deeperContinuityHint: + 'Thêm đồng bộ cho session-env, file-history, shell-snapshots và todos. Thông tin xác thực vẫn tách biệt.', + invalidContextGroup: + 'Nhóm phải bắt đầu bằng một chữ cái và chỉ sử dụng các chữ cái, số, dấu gạch ngang hoặc dấu gạch dưới (tối đa {{max}} ký tự).', + command: 'Lệnh', + commandFallback: 'ccs auth create ', + afterRunPrefix: 'Sau khi chạy lệnh:', + afterRunStep1: 'Hoàn tất đăng nhập Claude trong trình duyệt của bạn', + afterRunStep2: 'Quay lại đây và làm mới để xem tài khoản mới', + poolingHint: + 'Muốn dùng định tuyến Claude OAuth dạng pool? Dùng CLIProxy Claude pool từ nút hành động ở trang Tài khoản.', + close: 'Đóng', + copied: 'Đã sao chép!', + copyCommand: 'Sao chép lệnh', + }, + cliproxyModelCategory: { + google: 'Google (Gemini)', + openai: 'OpenAI (GPT)', + anthropic: 'Anthropic (Claude)', + antigravity: 'Antigravity', + other: 'Khác', + }, + cliproxyOverviewComponents: { + availableModels: 'Mô hình khả dụng', + noModelsAvailable: 'Không có mô hình khả dụng', + startSessionHint: 'Bắt đầu phiên CLIProxy để tải danh sách mô hình khả dụng', + totalCount: 'tổng cộng {{count}}', + availableThroughGroupedByProvider: + 'Các mô hình khả dụng qua {{backendLabel}}, được nhóm theo nhà cung cấp', + moreCount: '+{{count}} thêm', + }, + categorizedModelSelector: { + selectModel: 'Chọn mô hình', + noModelsAvailable: 'Không có mô hình khả dụng', + modelPlaceholder: 'Mô hình...', + }, + openrouterQuickStart: { + recommended: 'Đề xuất', + title: 'Bắt đầu với OpenRouter', + description: + 'Truy cập các mô hình {{modelCountLabel}} từ OpenAI, Anthropic, Google, Meta, v.v. - tất cả đều thông qua một API.', + featureOneApi: 'Một API cho mọi nhà cung cấp', + featureTierMapping: 'Ánh xạ tầng mô hình', + createOpenRouterProfile: 'Tạo hồ sơ OpenRouter', + getApiKeyAt: 'Nhận khóa API của bạn tại', + or: 'hoặc', + createCustomProfile: 'Tạo hồ sơ API tùy chỉnh', + }, + alibabaCodingPlanQuickStart: { + recommended: 'Đề xuất', + title: 'Dùng thử Alibaba Coding Plan', + description: + 'Sử dụng Alibaba Cloud Coding Plan qua endpoint tương thích Anthropic cho tác vụ lập trình.', + featureEndpoint: 'Endpoint tương thích Anthropic', + featureKeyFormat: 'Khóa API sk-sp chuyên dụng', + createAlibabaProfile: 'Tạo hồ sơ Alibaba Coding Plan', + readGuideAt: 'Xem hướng dẫn tại', + }, + alibabaCodingPlanPromo: { + title: 'Alibaba Coding Plan', + subtitle: 'Model Studio Coding Plan qua endpoint Anthropic', + add: 'Thêm', + }, + credentialHealth: { + title: 'Tình trạng thông tin xác thực', + ready: 'Sẵn sàng', + noCredentialsConfigured: + 'Không có thông tin xác thực nào được định cấu hình. Sử dụng các nút đăng nhập ở trên để xác thực.', + neverUsed: 'Chưa bao giờ sử dụng', + justNow: 'Vừa rồi', + unknown: 'Không xác định', + daysAgo: '{{count}} ngày trước', + hoursAgo: '{{count}} giờ trước', + minutesAgo: '{{count}} phút trước', + }, + proxyStatusWidget: { + justNow: 'vừa xong', + minutesAgo: '{{count}} phút trước', + hoursAgo: '{{count}} giờ trước', + syncReady: 'Sẵn sàng đồng bộ hóa', + noConfig: 'Không có cấu hình', + remoteProxy: 'Proxy từ xa', + active: 'Đang hoạt động', + trafficAutoRouted: 'Lưu lượng truy cập tự động được định tuyến đến máy chủ từ xa', + tooltipSync: 'Đồng bộ hóa hồ sơ với CLIProxy', + tooltipRestart: 'Khởi động lại', + tooltipStop: 'Dừng lại', + tooltipClose: 'Đóng', + tooltipVersionSettings: 'Cài đặt phiên bản', + versionManagement: 'Quản lý phiên bản', + selectVersionPlaceholder: 'Chọn phiên bản để cài đặt...', + stable: '(ổn định)', + install: 'Cài đặt', + versionsAboveUnstable: 'Các phiên bản trên {{version}} có vấn đề đã biết', + lastChecked: 'Đã kiểm tra lần cuối {{time}}', + notRunning: 'Không chạy', + start: 'Bắt đầu', + port: 'Cổng {{port}}', + sessionCount: '{{count}} phiên', + sessionCount_other: '{{count}} phiên', + installUnstableTitle: 'Cài đặt phiên bản không ổn định?', + installUnstableDesc: + 'Bạn sắp cài đặt v{{version}}, phiên bản này cao hơn phiên bản ổn định tối đa v{{maxStable}}.', + installUnstableWarning: + 'Phiên bản này đã biết có vấn đề về độ ổn định và có thể gây ra hành vi không mong muốn.', + installUnstableConfirm: 'Bạn có chắc chắn muốn tiếp tục không?', + cancel: 'Hủy bỏ', + installAnyway: 'Vẫn cài đặt', + clickToDowngrade: 'Bấm để hạ cấp', + clickToUpdate: 'Bấm để cập nhật', + }, + syncStatusCard: { + profileSync: 'Đồng bộ hồ sơ', + ready: 'Sẵn sàng', + noConfig: 'Không có cấu hình', + syncsProfilesDesc: 'Đồng bộ hóa cấu hình API với cấu hình CLIProxy cục bộ', + runDoctorHint: 'Chạy ccs doctor --fix để tạo cấu hình.', + details: 'Chi tiết', + syncNow: 'Đồng bộ ngay', + }, + syncDialog: { + title: 'Đồng bộ hóa hồ sơ với CLIProxy cục bộ', + description: 'Đồng bộ hóa cấu hình API CCS của bạn với CLIProxy config.yaml cục bộ.', + noProfiles: 'Không có profile nào để đồng bộ.', + createProfilesFirst: 'Trước tiên hãy tạo hồ sơ API bằng tab Hồ sơ.', + modelLabel: 'Mô hình:', + ready: 'Sẵn sàng', + profilesToSync_one: '{{count}} hồ sơ để đồng bộ hóa', + profilesToSync_other: '{{count}} hồ sơ cần đồng bộ hóa', + cancel: 'Hủy bỏ', + syncing: 'Đang đồng bộ hóa...', + synced: 'Đã đồng bộ hóa!', + syncNow: 'Đồng bộ ngay', + }, + errorLogs: { + title: 'Nhật ký lỗi', + failedRequests_one: '{{count}} yêu cầu thất bại', + failedRequests_other: '{{count}} yêu cầu thất bại', + cliproxyDiagnostics: 'Chẩn đoán CLIProxy', + showingCount: 'Hiển thị 50 trong số {{total}} nhật ký', + selectLog: 'Chọn nhật ký để xem chi tiết', + failedLoadContent: 'Không tải được nội dung nhật ký', + copyAbsolutePath: 'Sao chép đường dẫn tuyệt đối', + copyRawContent: 'Sao chép nội dung nhật ký thô', + tabOverview: 'Tổng quan', + tabHeaders: 'Tiêu đề', + tabRequest: 'Yêu cầu', + tabResponse: 'Phản hồi', + tabRaw: 'Raw', + modelLabel: 'Mô hình:', + quotaResetsIn: 'Quota sẽ được đặt lại sau', + method: 'Phương thức', + provider: 'Nhà cung cấp', + version: 'Phiên bản', + endpoint: 'Điểm cuối', + url: 'URL', + timestamp: 'Dấu thời gian', + na: 'không áp dụng', + noHeaders: 'Không có tiêu đề nào', + noRequestBody: 'Không có nội dung yêu cầu', + noResponseBody: 'Không có nội dung phản hồi', + rateLimitedMsg: + 'Bị giới hạn tốc độ. Hãy chuyển sang tài khoản khác hoặc chờ reset hạn ngạch. Mô hình {{model}} đã hết quota.', + rateLimitedMsgNoModel: + 'Bị giới hạn tốc độ. Hãy chuyển sang tài khoản khác hoặc chờ reset hạn ngạch.', + authFailedMsg: + 'Xác thực không thành công. Xác thực lại thông qua Cài đặt CLIProxy hoặc kiểm tra khóa API.', + notFoundMsg: + 'Không tìm thấy điểm cuối. Điểm cuối này có thể không tồn tại trên nhà cung cấp này.', + serverErrorMsg: + 'Lỗi máy chủ từ upstream. Hãy thử lại sau hoặc kiểm tra trang trạng thái của nhà cung cấp.', + timeoutMsg: 'Yêu cầu hết thời gian chờ. Kiểm tra kết nối mạng hoặc tăng timeout.', + unknown: 'không rõ', + }, + editAccountContext: { + title: 'Chỉnh sửa đồng bộ hóa lịch sử', + description: + 'Định cấu hình cách "{{name}}" chia sẻ lịch sử và tính liên tục với các tài khoản xác thực ccs khác.', + syncMode: 'Chế độ đồng bộ', + selectContextMode: 'Chọn chế độ ngữ cảnh', + isolatedOption: 'tách biệt (không đồng bộ)', + sharedOption: 'chia sẻ (đã bật đồng bộ)', + sharedModeHint: + 'Chế độ chia sẻ sử dụng lại ngữ cảnh không gian làm việc cho các tài khoản trong cùng một nhóm đồng bộ lịch sử.', + isolatedModeHint: + 'Chế độ biệt lập giúp tài khoản này tách biệt hoàn toàn với các tài khoản xác thực ccs khác.', + historySyncGroup: 'Nhóm đồng bộ hóa lịch sử', + groupPlaceholder: 'mặc định', + groupHint: + 'Chuẩn hóa thành chữ thường (dấu cách trở thành dấu gạch ngang). Được phép: chữ cái, số, _ , - (tối đa {{max}} ký tự).', + invalidGroup: 'Nhập tên nhóm hợp lệ bắt đầu bằng một chữ cái.', + continuityDepth: 'Độ sâu liên tục', + selectContinuityDepth: 'Chọn độ sâu liên tục', + standardOption: 'tiêu chuẩn (chỉ đồng bộ project)', + deeperOption: 'tính liên tục sâu hơn (nâng cao)', + deeperHint: + 'Chế độ nâng cao cũng đồng bộ hóa phiên-env, lịch sử tệp, ảnh chụp nhanh shell và todos.', + standardHint: 'Chế độ tiêu chuẩn chỉ đồng bộ hóa bối cảnh không gian làm việc của dự án.', + credentialsIsolated: + 'Thông tin xác thực và .anthropic vẫn được tách riêng cho mỗi tài khoản ở tất cả các chế độ.', + cancel: 'Hủy bỏ', + save: 'Lưu', + saving: 'Đang lưu...', + }, + historySyncLearningMap: { + title: 'Cách đồng bộ hóa lịch sử hoạt động', + description: + 'Tách biệt -> Chia sẻ -> Sâu hơn. Dùng Sync trên từng dòng để áp dụng thay đổi.', + learningMap: 'Bản đồ học tập', + isolated: 'Tách biệt', + shared: 'Chia sẻ', + deeper: 'Sâu hơn', + cliproxyManaged_one: + '{{count}} Tài khoản nhóm CLIProxy Claude được quản lý trong trang Action Center / CLIProxy.', + cliproxyManaged_other: + '{{count}} Tài khoản CLIProxy Claude pool được quản lý trong trang Action Center / CLIProxy.', + showDetails: 'Hiển thị chi tiết: nhóm, chuyển đổi và chính sách kế thừa', + modeSwitch: 'Chuyển đổi chế độ', + modeSwitchDesc: + 'Hộp thoại đồng bộ hóa cho phép người dùng di chuyển giữa cách ly/chia sẻ và chọn tính liên tục sâu hơn.', + historySyncGroup: 'Nhóm đồng bộ hóa lịch sử', + historySyncGroupDesc: + 'Cùng một nhóm nghĩa là chia sẻ cùng bối cảnh project. Nếu để trống sẽ dùng nhóm mặc định.', + }, + accountsTable: { + name: 'Tên', + type: 'Kiểu', + defaultBadge: 'mặc định', + created: 'Ngày tạo', + lastUsed: 'Được sử dụng lần cuối', + historySync: 'Đồng bộ hóa lịch sử', + actions: 'Hành động', + syncTitle: 'Chỉnh sửa chế độ đồng bộ, nhóm và độ sâu liên tục', + confirmLegacyTitle: 'Xác nhận chế độ hiện tại của tài khoản cũ này là rõ ràng', + sync: 'Đồng bộ hóa', + confirm: 'Xác nhận', + active: 'Đang hoạt động', + setDefault: 'Đặt mặc định', + deleteAccount: 'Xóa tài khoản', + cannotDeleteDefault: 'Không thể xóa tài khoản mặc định', + noAccounts: + 'Không tìm thấy tài khoản xác thực CCS nào. Sử dụng ccs auth create để thêm tài khoản.', + resetToDefault: 'Đặt lại về mặc định CCS', + deleteDialogTitle: 'Xóa tài khoản', + deleteDialogDesc: + 'Bạn có chắc chắn muốn xóa tài khoản "{{name}}" không? Thao tác này sẽ xóa hồ sơ và tất cả dữ liệu phiên của nó. Không thể hoàn tác hành động này.', + cancel: 'Hủy bỏ', + delete: 'Xóa', + sharedGroupStandard: 'chia sẻ ({{group}}, tiêu chuẩn)', + sharedGroupDeeper: 'chia sẻ ({{group}}, sâu hơn)', + sharedGroupLegacy: 'chia sẻ ({{group}}, kế thừa tiêu chuẩn)', + isolatedLegacy: 'tách biệt (mặc định cũ)', + isolated: 'tách biệt', + }, + addAccountDialog: { + title: 'Thêm tài khoản {{displayName}}', + descKiro: + 'Chọn phương thức xác thực Kiro, sau đó xác thực qua trình duyệt hoặc nhập từ Kiro IDE.', + descDeviceCode: + 'Nhấp vào Xác thực. Mã xác minh sẽ xuất hiện để bạn nhập vào trang web của nhà cung cấp.', + descOauth: + 'Nhấp vào Xác thực để nhận URL OAuth. Mở nó trong bất kỳ trình duyệt nào để đăng nhập.', + powerUserEnabled: 'Đã bật chế độ power user', + powerUserSkipped: + 'Danh sách kiểm tra trách nhiệm của AGY bị bỏ qua khỏi Cài đặt > Proxy. Bạn chấp nhận hoàn toàn trách nhiệm về rủi ro tài khoản/OAuth.', + authMethod: 'Phương thức xác thực', + selectKiroAuthMethod: 'Chọn phương thức xác thực Kiro', + nicknameRequired: 'Biệt danh (bắt buộc)', + nicknameOptional: 'Biệt danh (tùy chọn)', + nicknamePlaceholder: 'ví dụ: công việc, cá nhân', + nicknameRequiredHint: + 'Bắt buộc với nhà cung cấp này. Dùng tên thân thiện duy nhất (ví dụ: work, personal).', + nicknameOptionalHint: + 'Một cái tên thân thiện để xác định tài khoản này. Tự động tạo từ email nếu để trống.', + waitingForAuth: 'Đang chờ xác thực...', + deviceCodeHint: + 'Hộp thoại mã xác minh sẽ sớm xuất hiện. Nhập mã trên trang web của nhà cung cấp.', + browserHint: + 'Hoàn tất xác thực trong trình duyệt của bạn. Hộp thoại này sẽ tự động đóng lại.', + openUrlLabel: 'Mở URL này trong bất kỳ trình duyệt nào để đăng nhập:', + copied: 'Đã sao chép', + copy: 'Sao chép', + open: 'Mở', + popupBlocked: + 'Cửa sổ bật lên bị chặn. Sao chép URL ở trên và mở thủ công trong trình duyệt của bạn.', + redirectPasteLabel: 'Chuyển hướng không hoạt động? Dán URL gọi lại:', + callbackPlaceholder: 'Dán URL chuyển hướng vào đây...', + submitCallback: 'Gửi cuộc gọi lại', + submitting: 'Đang gửi...', + preparingUrl: 'Đang chuẩn bị URL đăng nhập...', + cancel: 'Hủy bỏ', + importingToken: 'Đang nhập token từ Kiro IDE...', + authenticate: 'Xác thực', + importFromIde: 'Nhập từ IDE', + importing: 'Đang nhập...', + }, + flowViz: { + backToProviders: 'Quay lại nhà cung cấp', + showDetails: 'Hiển thị chi tiết', + hideDetails: 'Ẩn chi tiết', + resetLayout: 'Đặt lại bố cục', + provider: 'Nhà cung cấp', + totalRequests: 'Tổng số yêu cầu', + accounts: 'Tài khoản', + connectionTimeline: 'Dòng thời gian kết nối', + noRecentConnections: 'Không có kết nối gần đây', + }, + quickStats: { + totalRequests: 'Tổng số yêu cầu', + successRate: 'Tỷ lệ thành công', + totalTokens: 'Tổng số token', + activeModels: 'Mô hình hoạt động', + }, + authMonitor: { + accounts: 'Tài khoản', + accountsCount: '{{count}} tài khoản', + success: 'Thành công', + failed: 'Thất bại', + successRate: 'Tỷ lệ thành công', + }, + accountCard: { + resumeAccount: 'Tiếp tục tài khoản', + pauseAccount: 'Tạm dừng tài khoản', + quotaLoading: 'Hạn ngạch...', + quota: 'hạn ngạch', + quotaUnavailable: 'Thông tin quota chưa khả dụng', + reauthNeeded: 'Cần xác thực lại', + removeAndReadd: 'Xóa và thêm lại tài khoản', + autoRefreshFailed: 'Tự động làm mới không thành công', + }, + antigravityChecklist: { + title: 'Trách nhiệm OAuth Antigravity', + subtitle: 'Hoàn thành tất cả 4 bước trước khi bạn có thể xác thực.', + mandatory: 'Bắt buộc', + completion: 'Hoàn thành', + stepsCount: '{{current}}/4 bước', + }, + accountSafetyWarning: { + title: 'Cảnh báo an toàn tài khoản OAuth', + subtitle: 'Vấn đề #509 · Rủi ro Gemini + AGY OAuth', + firstLine: + 'Issue #509 ghi nhận các trường hợp bị đình chỉ/cấm liên quan đến ccs agy và việc dùng chung tài khoản giữa ccs gemini và ccs agy.', + secondLine: + 'Chỉ tiếp tục nếu bạn chấp nhận hoàn toàn trách nhiệm về OAuth và rủi ro truy cập tài khoản.', + issueLabel: 'Đọc vấn đề #509', + proxySettingsLabel: 'Điều khiển Gemini + AGY: Cài đặt > Proxy', + }, + cliproxyDialog: { + name: 'Tên', + provider: 'Nhà cung cấp', + account: 'Tài khoản', + accountOptional: 'Tài khoản (tùy chọn)', + model: 'Mô hình', + modelOptional: 'Mô hình (tùy chọn)', + defaultTarget: 'Mục tiêu mặc định', + defaultTier: 'Cấp mặc định', + tierConfig: 'Cấu hình cấp độ', + selectProvider: 'Chọn nhà cung cấp...', + useDefaultAccount: 'Sử dụng tài khoản mặc định', + defaultSuffix: '(mặc định)', + opus: 'Opus', + sonnet: 'Sonnet', + haiku: 'Haiku', + claudeCode: 'Claude Code', + factoryDroid: 'Factory Droid', + cancel: 'Hủy bỏ', + create: 'Tạo', + creating: 'Đang tạo...', + saveChanges: 'Lưu thay đổi', + saving: 'Đang lưu...', + placeholderName: 'Gemini của tôi', + placeholderModel: 'gemini-2.5-pro', + placeholderComposite: 'my-composite', + placeholderModelId: 'model-id', + placeholderAccountId: 'account-id', + editCompositeTitle: 'Chỉnh sửa biến thể tổng hợp: {{name}}', + editSingleTitle: 'Chỉnh sửa biến thể: {{name}}', + }, + profileEditor: { + profileInfo: 'Thông tin hồ sơ', + profileName: 'Tên hồ sơ', + filePath: 'Đường dẫn tệp', + lastModified: 'Sửa đổi lần cuối', + defaultTarget: 'Mục tiêu mặc định', + quickUsage: 'Sử dụng nhanh', + runWithProfile: 'Chạy với hồ sơ', + runOnDroid: 'Chạy trên Droid', + droidAliasExplicit: 'Bí danh Droid (rõ ràng)', + overrideToClaude: 'Ghi đè lên Claude', + overrideToClaudeExplicit: 'Ghi đè lên Claude (rõ ràng)', + setAsDefault: 'Đặt làm mặc định', + provider: 'Nhà cung cấp', + custom: 'Tùy chỉnh', + }, + settings: { + sectionLoadFailed: 'Không tải được phần', + unknownError: 'Đã xảy ra lỗi không xác định', + reloadPage: 'Tải lại trang', + refresh: 'Làm mới', + copy: 'Sao chép', + copied: 'Đã sao chép', + saved: 'Đã lưu', + loading: 'Đang tải...', + configFileNotFound: 'Không tìm thấy tập tin cấu hình', + }, + settingsTabs: { + web: 'Web', + env: 'Env', + think: 'Tư duy', + proxy: 'Proxy', + auth: 'Xác thực', + backup: 'Sao lưu', + }, + settingsAuth: { + failedFetchTokens: 'Không thể tải token xác thực', + failedSaveTokens: 'Không thể lưu token', + tokensUpdated: 'Đã cập nhật token. Khởi động lại CLIProxy để áp dụng.', + failedRegenerate: 'Không thể tạo lại bí mật', + secretRegenerated: 'Bí mật quản lý mới được tạo. Khởi động lại CLIProxy để áp dụng.', + failedReset: 'Không thể đặt lại token', + tokensReset: 'Token đã được đặt lại về mặc định. Khởi động lại CLIProxy để áp dụng.', + description: + 'Cấu hình token xác thực CLIProxy. Các thay đổi yêu cầu khởi động lại CLIProxy.', + apiKey: 'Khóa API', + custom: 'Tùy chỉnh', + apiKeyDesc: 'Được Claude Code dùng để xác thực qua CLIProxy', + apiKeyPlaceholder: 'Khóa API', + managementSecret: 'Bí mật quản lý', + managementSecretDesc: 'Được bảng điều khiển CCS sử dụng để truy cập API quản lý CLIProxy', + managementSecretPlaceholder: 'Bí mật quản lý', + generateSecret: 'Tạo bí mật an toàn mới', + resetDefaults: 'Đặt lại về mặc định', + resetDesc: 'Đặt lại cả khóa API và bí mật quản lý về giá trị mặc định.', + saving: 'Đang lưu...', + save: 'Lưu', + }, + settingsGlobalEnv: { + description: + 'Biến môi trường được áp dụng cho tất cả hồ sơ không dùng Claude subscription (gemini, codex, agy, ghcp, v.v.)', + enabled: 'Đã bật Env toàn cầu', + disabled: 'Env toàn cầu bị vô hiệu hóa', + enabledDesc: 'Biến Env sẽ được áp dụng cho các hồ sơ bên thứ ba', + disabledDesc: 'Biến Env sẽ không được áp dụng', + envVars: 'Biến môi trường', + noneConfigured: 'Không có biến môi trường nào được cấu hình', + addNew: 'Thêm biến mới', + keyName: 'KEY_NAME', + value: 'giá trị', + add: 'Thêm', + quickAdd: 'Thêm nhanh các biến phổ biến', + allConfigured: 'Tất cả biến phổ biến đã được cấu hình', + }, + settingsBackups: { + failedFetch: 'Không tìm nạp được bản sao lưu', + failedRestore: 'Không thể khôi phục bản sao lưu', + restoreSuccess: 'Đã khôi phục bản sao lưu thành công', + title: 'Sao lưu cài đặt', + description: + 'Khôi phục các phiên bản trước của tệp settings.json của bạn. Bản sao lưu được tạo tự động khi cài đặt được sửa đổi.', + none: 'Không có bản sao lưu nào', + noneHint: 'Bản sao lưu sẽ xuất hiện ở đây khi bạn sửa đổi cài đặt', + latest: 'Mới nhất', + restoring: 'Đang khôi phục...', + restore: 'Khôi phục', + restoreConfirmTitle: 'Khôi phục bản sao lưu?', + restoreConfirmPrefix: 'Điều này sẽ thay thế cài đặt hiện tại của bạn bằng bản sao lưu từ', + restoreConfirmSuffix: 'Không thể hoàn tác hành động này.', + cancel: 'Hủy bỏ', + }, + settingsWebsearch: { + description: + 'Tìm kiếm trên web dựa trên CLI cho hồ sơ của bên thứ ba (gemini, codex, agy, v.v.)', + enabled: 'Đã bật Tìm kiếm trên web', + disabled: 'Tìm kiếm trên web bị vô hiệu hóa', + checking: 'Đang kiểm tra trạng thái...', + providers: 'Nhà cung cấp', + noneConfigured: 'Không có nhà cung cấp nào được cấu hình', + noneConfiguredHint: 'Cài công cụ CLI để bật các nhà cung cấp WebSearch', + checkProviders: 'Kiểm tra các nhà cung cấp', + installed: 'đã cài đặt', + notInstalled: 'chưa được cài đặt', + model: 'Mô hình:', + howInstall: 'Cách cài đặt {{name}} CLI', + installGlobally: 'Cài đặt trên toàn cầu', + requiresKey: '(cần khóa API xAI)', + freeTier: '(Có sẵn cấp MIỄN PHÍ)', + viewDocs: 'Xem tài liệu', + }, + settingsProxy: { + failedLoadAgyMode: 'Không thể tải chế độ power user AGY', + failedUpdateAgyMode: 'Không thể cập nhật chế độ power user AGY', + failedVerifyAgyMode: 'Không thể xác minh trạng thái lưu của chế độ power user AGY', + notPersistedAgyMode: + 'Chế độ power user AGY chưa được lưu bền vững. Cấu hình có thể đã bị tiến trình khác sửa.', + agyModeEnabled: 'Đã bật chế độ power user AGY.', + agyModeDisabled: 'Đã tắt chế độ power user AGY.', + typePhraseToContinue: 'Nhập "{{value}}" để tiếp tục.', + description: + 'Định cấu hình kết nối {{backend}} cục bộ hoặc từ xa cho cấu hình dựa trên proxy', + backendPlus: 'CLIProxy Plus', + backend: 'CLIProxy', + instanceStatus: 'Trạng thái phiên bản', + connectionMode: 'Chế độ kết nối', + local: 'Cục bộ', + remote: 'Từ xa', + localDesc: 'Chạy nhị phân {{backend}} trên máy này', + remoteDesc: 'Kết nối với máy chủ {{backend}} từ xa', + backendBinary: 'Tệp thực thi backend', + stopProxyToSwitch: + 'Dừng proxy đang chạy trong Trạng thái phiên bản trước khi chuyển backend.', + default: 'Mặc định', + plusDesc: 'Hỗ trợ đầy đủ nhà cung cấp, bao gồm Kiro và GitHub Copilot', + originalDesc: 'Binary gốc (chỉ Gemini, Codex, Antigravity)', + variantsIncompatible: + 'Các biến thể Kiro/Copilot hiện tại sẽ không hoạt động với CLIProxyAPI. Chuyển sang CLIProxyAPIPlus hoặc xóa các biến thể đó.', + safety: 'An toàn', + agyModeTitle: 'Chế độ power user Antigravity', + agyModeDesc: + 'Bỏ qua danh sách kiểm tra trách nhiệm AGY trong quy trình Thêm tài khoản và `ccs agy`.', + agyWarning: + 'Chỉ bật nếu bạn hiểu rõ rủi ro bị đình chỉ/cấm OAuth (#509). CCS không chịu trách nhiệm khi mất tài khoản.', + finalConfirm: 'Xác nhận cuối cùng', + finalConfirmDesc: + 'Việc bật tính năng này sẽ bỏ qua các điểm kiểm tra an toàn AGY trong cả bảng điều khiển và CLI. Xem lại vấn đề #509 và nhập cụm từ chính xác để tiếp tục.', + step1: 'Bước 1', + readIssue: 'Đọc vấn đề #509', + step2: 'Bước 2', + typePrefix: 'Nhập', + typeSuffix: 'để kích hoạt.', + typePhraseAria: 'Nhập I ACCEPT RISK để bật chế độ power user Antigravity', + exactPhrase: 'Phải nhập chính xác cụm từ.', + enableAgyMode: 'Bật chế độ power user', + toggleAgyMode: 'Chuyển đổi chế độ power user AGY', + fallbackSettings: 'Cài đặt dự phòng', + enableFallback: 'Cho phép dự phòng về cục bộ', + enableFallbackDesc: 'Sử dụng proxy cục bộ nếu không thể truy cập được từ xa', + autoStartLocal: 'Tự động khởi động proxy cục bộ', + autoStartLocalDesc: 'Tự động khởi động proxy cục bộ khi dự phòng', + advanced: 'Nâng cao', + debugMode: 'Chế độ gỡ lỗi', + debugModeDesc: 'Bật chẩn đoán dành cho nhà phát triển trong bảng điều khiển trình duyệt', + debugModeEnabled: + 'Đã bật chế độ gỡ lỗi. Kiểm tra bảng điều khiển trình duyệt để biết nhật ký chi tiết.', + localProxy: 'Proxy cục bộ', + port: 'Cổng', + autoStart: 'Tự động khởi động', + localAutoStartDesc: 'Tự động khởi động proxy cục bộ khi cần', + remoteConfigTitle: 'Cấu hình máy chủ từ xa', + host: 'Host', + hostPlaceholder: '192.168.1.100 hoặc proxy.example.com', + defaultPort: 'mặc định {{value}}', + portPlaceholder: 'Để trống để dùng {{value}}', + protocol: 'Giao thức', + http: 'HTTP', + https: 'HTTPS', + apiKeyOptional: 'Khóa API (tùy chọn)', + apiKeyPlaceholder: 'Đối với điểm cuối API /v1/*', + apiKeyDesc: 'Dùng cho các yêu cầu API đến /v1/chat/completions', + managementKeyOptional: 'Khóa quản lý (tùy chọn)', + managementKeyPlaceholder: 'Đối với điểm cuối /v0/management/*', + managementKeyDesc: 'Dùng cho API quản lý dashboard. Nếu bỏ trống sẽ dùng Khóa API.', + testing: 'Đang thử nghiệm...', + testConnection: 'Kiểm tra kết nối', + connectedLatency: 'Đã kết nối ({{value}}ms)', + connectionFailed: 'Kết nối không thành công', + invalidPortOrEmpty: 'Cổng phải là số nguyên từ 1 đến 65535 hoặc để trống theo mặc định', + invalidLocalPort: 'Cổng cục bộ phải là số nguyên từ 1 đến 65535', + backendPlusApi: 'CLIProxyAPIPlus', + backendApi: 'CLIProxyAPI', + }, + settingsThinking: { + description: 'Định cấu hình tư duy/lý luận mở rộng cho các mô hình được hỗ trợ.', + supportedProviders: 'Nhà cung cấp được hỗ trợ', + modeTitle: 'Chế độ Thinking', + modeAutoDesc: 'Tự động thiết lập tư duy dựa trên tầng mô hình', + modeOffDesc: 'Vô hiệu hóa suy nghĩ mở rộng', + modeManualDesc: 'Đặt mức ghi đè liên tục hoặc sử dụng cờ CLI mỗi lần chạy', + tierDefaults: 'Mặc định cấp độ', + tierDefaultsDesc: 'Mức độ tư duy mặc định cho từng tầng mô hình khi ở chế độ tự động.', + persistentOverride: 'Ghi đè liên tục', + persistentOverrideDesc: 'Áp dụng cho tất cả các buổi. Cờ CLI vẫn được ưu tiên.', + enterCustomBudget: 'Nhập ngân sách tùy chỉnh', + apply: 'Áp dụng', + providerOverrides: 'Ghi đè nhà cung cấp ({{count}})', + providerOverridesDesc: + 'Ghi đè mặc định cấp độ cho các nhà cung cấp cụ thể. Thêm khóa nhà cung cấp tùy chỉnh nếu cần.', + addProviderPlaceholder: 'Thêm khóa nhà cung cấp (ví dụ: qwen)', + showWarnings: 'Hiển thị cảnh báo', + showWarningsDesc: 'Hiển thị cảnh báo khi các giá trị suy nghĩ bị hạn chế hoặc điều chỉnh', + cliEnvOverride: 'Ghi đè CLI & Env', + supportLine1Prefix: 'Ngân sách suy nghĩ:', + supportLine1Suffix: '(dựa trên token)', + supportLine2Prefix: 'Nỗ lực lý luận:', + supportLine2SuffixPrefix: '(hậu tố hoặc ', + supportLine2SuffixPostfix: ': medium/high/xhigh)', + supportLine3Prefix: 'Hậu tố Codex cố định mức effort (ví dụ ', + supportLine3Suffix: '); model không hậu tố sẽ dùng chế độ Thinking.', + }, + analytics: { + title: 'Phân tích', + subtitle: 'Theo dõi việc sử dụng và thông tin chi tiết', + month: 'Tháng', + allTime: 'Tất cả thời gian', + updated: 'Đã cập nhật {{value}}', + }, + analyticsSummary: { + totalTokens: 'Tổng số token', + totalTokensSubtitle: '{{input}} vào / {{output}} ra', + totalCost: 'Tổng chi phí', + totalCostSubtitle: 'trung bình ${{value}}/ngày', + cacheTokens: 'Token bộ đệm', + cacheTokensSubtitle: '${{cost}} ({{percent}}% chi phí)', + inputCost: 'Chi phí đầu vào', + outputCost: 'Chi phí đầu ra', + tokensSubtitle: '{{value}} token', + }, + apiProfiles: { + title: 'Hồ sơ API', + new: 'Mới', + searchPlaceholder: 'Tìm kiếm hồ sơ...', + loadingProfiles: 'Đang tải hồ sơ...', + failedLoadTitle: 'Không tải được hồ sơ', + failedLoadDesc: 'Không thể tìm nạp hồ sơ API. Vui lòng thử lại.', + retry: 'Thử lại', + noProfilesYet: 'Chưa có hồ sơ API nào', + noProfilesDesc: 'Tạo hồ sơ đầu tiên của bạn để kết nối với các điểm cuối API tùy chỉnh', + createProfile: 'Tạo hồ sơ', + noProfileMatch: 'Không có hồ sơ nào khớp với "{{query}}"', + profileCount: '{{count}} hồ sơ', + profileCount_other: '{{count}} hồ sơ', + configuredCount: '{{count}} đã cấu hình', + deleteProfileTitle: 'Xóa hồ sơ', + deleteProfileDesc: + 'Bạn có chắc chắn muốn xóa "{{name}}" không? Thao tác này sẽ xóa tệp cài đặt và không thể hoàn tác.', + delete: 'Xóa', + unsavedChangesTitle: 'Những thay đổi chưa được lưu', + unsavedChangesDesc: + 'Bạn có những thay đổi chưa được lưu trong "{{current}}". Hủy và chuyển sang "{{next}}"?', + discardSwitch: 'Loại bỏ và chuyển đổi', + }, + accountsPage: { + title: 'Tài khoản', + managePrefix: 'Quản lý', + manageSuffix: 'tài khoản và nhóm đồng bộ từ một nơi.', + mobileManageSuffix: 'tính liên tục cho mỗi tài khoản.', + primaryActions: 'Hành động chính', + createAccount: 'Tạo tài khoản', + authClaudeInPool: 'Xác thực Claude trong Pool', + openClaudePoolSettings: 'Mở cài đặt Claude pool', + openCliProxyClaudePool: 'Mở CLIProxy Claude Pool', + migrationFollowup: 'Theo dõi sau di chuyển', + legacyContextPending: '{{count}} tài khoản vẫn cần xác nhận chế độ lần đầu.', + legacyContextPending_other: '{{count}} tài khoản vẫn cần xác nhận chế độ lần đầu.', + legacyContinuityPending: + '{{count}} tài khoản được chia sẻ vẫn ở mức độ liên tục kế thừa tiêu chuẩn.', + legacyContinuityPending_other: + '{{count}} tài khoản được chia sẻ vẫn ở mức độ liên tục kế thừa tiêu chuẩn.', + confirmingLegacy: 'Đang xác nhận chính sách kế thừa...', + confirmLegacy: 'Xác nhận chính sách kế thừa ({{count}})', + noLegacyFollowup: 'Không có hoạt động theo dõi kế thừa nào đang chờ xử lý.', + continuityGuide: 'Hướng dẫn liên tục', + expandWhenNeeded: 'Chỉ mở rộng khi cần thiết.', + sharedStandard: 'Tiêu chuẩn chung', + sharedStandardDesc: + 'Chỉ đồng bộ hóa không gian làm việc của dự án. Mặc định tốt nhất cho hầu hết các đội.', + sharedDeeper: 'Chia sẻ sâu hơn', + sharedDeeperPrefix: 'Thêm', + isolated: 'Tách biệt', + isolatedDesc: 'Không có liên kết. Tốt nhất cho sự tách biệt nghiêm ngặt.', + quickCommands: 'Lệnh nhanh', + quickCommandsDesc: 'Sao chép và chạy trong terminal.', + workspaceBadge: 'Không gian làm việc `ccs auth`', + historySyncBadge: 'Kiểm soát đồng bộ hóa lịch sử', + authAccounts: 'Tài khoản xác thực', + tableScopePrefix: 'Bảng này được cố ý đặt trong phạm vi', + tableScopeMiddle: 'tài khoản. Sử dụng', + tableScopeSuffix: 'để thay đổi chế độ/nhóm/độ sâu.', + accountMatrix: 'Ma trận tài khoản', + sharedTotalDesc: + 'Tổng số tài khoản chia sẻ: {{count}}. Bao gồm thao tác Sync và xác nhận kế thừa.', + loadingAccounts: 'Đang tải tài khoản...', + }, + health: { + justNow: 'vừa xong', + secondsAgo: '{{count}} giây trước', + minutesAgo: '{{count}} phút trước', + hoursAgo: '{{count}} giờ trước', + systemHealth: 'Tình trạng hệ thống', + build: 'bản dựng {{version}}', + lastScan: 'Lần quét cuối cùng:', + autoRefresh: 'Tự động làm mới:', + refresh: 'Làm mới', + checks: 'Kiểm tra', + version: 'Phiên bản', + platform: 'Nền tảng', + liveMonitoring: 'Giám sát trực tiếp đang hoạt động', + copied: 'Đã sao chép vào bảng nhớ tạm', + refreshing: 'Đang làm mới kiểm tra trạng thái...', + fix: 'Sửa chữa', + applyFix: 'Áp dụng bản sửa lỗi', + healthLabel: 'trạng thái', + }, + updates: { + actionRequired: 'Hành động bắt buộc', + done: 'Xong', + all: 'Tất cả', + inboxTitle: 'Hộp thư cập nhật', + inboxSubtitle: + 'Tập trung vào các hành động, sau đó đánh dấu các cập nhật đã hoàn tất hoặc bị loại bỏ.', + needsAction: 'Cần hành động', + doneCount: 'Xong', + searchPlaceholder: 'Tìm kiếm hành động hoặc lệnh', + noNotices: 'Không có thông báo nào khớp với chế độ xem này.', + noUpdates: 'Hiện không có bản cập nhật nào.', + published: 'Đã xuất bản {{date}}', + markDone: 'Đánh dấu hoàn tất', + dismiss: 'Ẩn', + reopen: 'Mở lại', + doNext: 'Làm tiếp theo', + impactedIntegrations: 'Tích hợp bị ảnh hưởng', + impactedDesc: 'Các khu vực liên quan dựa trên phạm vi cập nhật và định tuyến.', + whyMatters: 'Tại sao nó quan trọng', + whyMattersDesc: 'Chỉ ngữ cảnh ngắn, không có ghi chú phát hành trên tường văn bản.', + open: 'Mở', + progressNeedsAction: 'Cần hành động', + progressInReview: 'Đang xem xét', + progressDone: 'Xong', + progressDismissed: 'Bị loại bỏ', + statusNew: 'Mới', + statusStable: 'Ổn định', + statusPlanned: 'Đã lên kế hoạch', + dashboard: 'Trang tổng quan', + cliUsage: 'Cách sử dụng CLI', + }, + cliproxyPage: { + connected: 'Đã kết nối', + notConnected: 'Chưa kết nối', + variant: 'Biến thể', + parentNotConnected: 'Profile gốc chưa kết nối', + viaProvider: 'qua {{provider}}', + emptyTitle: 'Trình quản lý hồ sơ CCS', + emptyDesc: + 'Quản lý xác thực OAuth, tùy chọn tài khoản và chọn mô hình cho nhà cung cấp CLIProxy. Cấu hình cách CCS định tuyến yêu cầu đến các backend AI khác nhau.', + emptyControlPanelPrefix: + 'Để biết số liệu thống kê sử dụng trực tiếp và theo dõi thời gian thực, hãy truy cập', + controlPanel: 'Bảng điều khiển', + quickSetup: 'Thiết lập nhanh', + accountManagement: 'Quản lý tài khoản cấp CCS', + providers: 'Nhà cung cấp', + variants: 'Biến thể', + providerCount: '{{count}} nhà cung cấp', + providerCount_other: '{{count}} nhà cung cấp', + connectedCount: '{{count}} đã kết nối', + variantDisplay: '{{name}} (biến thể {{provider}})', + }, + copilotPage: { + title: 'Copilot', + subtitle: 'Proxy GitHub Copilot', + setup: 'Cài đặt', + auth: 'Xác thực', + daemon: 'Daemon', + unofficialTitle: 'API không chính thức - Bạn phải tự chịu rủi ro khi sử dụng', + unofficialItem1: 'API được thiết kế ngược - có thể bị hỏng bất cứ lúc nào', + unofficialItem2: 'Việc sử dụng quá mức có thể gây ra hạn chế tài khoản', + unofficialItem3: 'Không bảo hành, không chịu trách nhiệm từ CCS', + yes: 'Có', + no: 'Không', + installed: 'Đã cài đặt', + missing: 'Thiếu', + integration: 'Tích hợp', + enabled: 'Đã bật', + disabled: 'Đã tắt', + github: 'GitHub', + connected: 'Đã kết nối', + notConnected: 'Chưa kết nối', + authenticate: 'Xác thực', + authenticating: 'Đang xác thực...', + status: 'Trạng thái', + running: 'Đang chạy', + stopped: 'Đã dừng', + port: 'Cổng', + stopping: 'Đang dừng...', + stop: 'Dừng lại', + starting: 'Đang bắt đầu...', + start: 'Bắt đầu', + installing: 'Đang cài đặt...', + installCopilotApi: 'Cài đặt copilot-api', + proxy: 'Proxy', + active: 'Đang hoạt động', + inactive: 'Không hoạt động', + }, + cursorPage: { + title: 'Cursor', + beta: 'Beta', + subtitle: 'Bảng điều khiển tích hợp Cursor', + unofficialTitle: 'API không chính thức - Bạn phải tự chịu rủi ro khi sử dụng', + unofficialItem1: 'Tích hợp thiết kế ngược có thể bị hỏng bất cứ lúc nào', + unofficialItem2: 'Lạm dụng hoặc sử dụng quá mức có thể có nguy cơ bị hạn chế tài khoản', + unofficialItem3: 'Không bảo hành, không chịu trách nhiệm từ CCS', + integration: 'Tích hợp', + authentication: 'Xác thực', + daemon: 'Daemon', + enabled: 'Đã bật', + disabled: 'Đã tắt', + expired: 'Hết hạn', + connected: 'Đã kết nối', + notConnected: 'Chưa kết nối', + running: 'Đang chạy', + stopped: 'Đã dừng', + actions: 'Hành động', + disableIntegration: 'Vô hiệu hóa tích hợp', + enableIntegration: 'Kích hoạt tích hợp', + autoDetectAuth: 'Tự động phát hiện xác thực', + manualAuthImport: 'Nhập xác thực thủ công', + stopDaemon: 'Dừng Daemon', + startDaemon: 'Khởi động Daemon', + port: 'Cổng', + configuration: 'Cấu hình Cursor', + lastModified: 'Sửa đổi lần cuối:', + neverSaved: 'Chưa bao giờ lưu', + saving: 'Đang lưu...', + save: 'Lưu', + modelConfig: 'Cấu hình mô hình', + settings: 'Cài đặt', + info: 'Thông tin', + presets: 'Cài đặt sẵn', + presetsDesc: 'Áp dụng ánh xạ mô hình được cấu hình sẵn', + modelMapping: 'Ánh xạ mô hình', + modelMappingDesc: 'Định cấu hình mô hình nào sẽ sử dụng cho từng tầng', + defaultModel: 'Mô hình mặc định', + defaultModelDesc: 'Dùng khi không chỉ định tier cụ thể.', + opusModel: 'Opus (Có khả năng nhất)', + opusModelDesc: 'Lý luận phức tạp và phản hồi chất lượng cao nhất.', + sonnetModel: 'Sonnet (Cân bằng)', + sonnetModelDesc: 'Khối lượng công việc mã hóa chung và trò chuyện hàng ngày.', + haikuModel: 'Haiku (Nhanh)', + haikuModelDesc: 'Đường dẫn yêu cầu nhẹ, độ trễ thấp.', + runtimeSettings: 'Cài đặt thời gian chạy', + autoStartDaemon: 'Daemon tự động khởi động', + autoStartDesc: 'Tự động khởi động daemon Cursor khi dùng tích hợp.', + ghostMode: 'Chế độ ma', + ghostModeDesc: 'Gửi x-ghost-mode để giảm telemetry.', + provider: 'Nhà cung cấp', + filePath: 'Đường dẫn tệp', + availableModels: 'Mô hình khả dụng', + loadingModels: 'Đang tải mô hình...', + noModels: 'Chưa có siêu dữ liệu mô hình nào. Bắt đầu daemon và làm mới.', + default: 'Mặc định', + rawConfiguration: 'Cấu hình thô (JSON)', + manualImportTitle: 'Nhập xác thực Cursor thủ công', + manualImportDesc: + 'Cung cấp access token và machine ID của Cursor nếu không tự phát hiện được.', + accessToken: 'Access token', + accessTokenPlaceholder: 'Dán access token của Cursor', + machineId: 'ID máy', + machineIdPlaceholder: 'Hex 32 ký tự (UUID không có dấu gạch nối)', + cancel: 'Hủy bỏ', + import: 'Nhập', + selectModel: 'Chọn mô hình', + useDefaultModel: 'Sử dụng mô hình mặc định', + availableModelCount: 'Mô hình khả dụng ({{count}})', + invalidPort: 'Cổng phải là số nguyên từ 1 đến 65535', + defaultModelRequired: 'Mô hình mặc định là bắt buộc', + savedConfig: 'Đã lưu cấu hình Cursor và ánh xạ mô hình', + failedSaveConfig: 'Không lưu được cấu hình', + modelsLoadingWait: 'Mô hình vẫn đang tải. Vui lòng đợi trước khi áp dụng preset.', + noModelsAvailable: 'Chưa có mô hình nào. Hãy khởi động daemon rồi làm mới.', + appliedCodexPreset: 'Đã áp dụng preset Codex GPT-5.3', + appliedClaudePreset: 'Đã áp dụng preset Claude 4.6', + appliedGeminiPreset: 'Đã áp dụng preset Gemini 3', + integrationEnabled: 'Đã bật tích hợp Cursor', + integrationDisabled: 'Đã tắt tích hợp Cursor', + failedUpdateIntegration: 'Không cập nhật được trạng thái tích hợp', + credentialsImported: 'Đã nhập thông tin xác thực Cursor', + autoDetectFailed: 'Tự động phát hiện không thành công', + manualRequired: 'Cần token và machine ID', + manualImportFailed: 'Nhập thủ công không thành công', + failedStartDaemon: 'Không thể khởi động daemon', + daemonStarted: 'Đã khởi động daemon', + daemonStartedWithPid: 'Daemon đã khởi động (PID: {{pid}})', + failedStopDaemon: 'Không thể dừng daemon', + daemonStopped: 'Đã dừng daemon', + rawLoading: 'Cài đặt thô vẫn đang tải. Vui lòng đợi và thử lại.', + invalidJson: 'JSON không hợp lệ', + rawSaved: 'Đã lưu cài đặt thô', + rawChanged: 'Cài đặt thô đã thay đổi bên ngoài. Làm mới và thử lại.', + failedSaveRaw: 'Không lưu được cài đặt thô', + savedAll: 'Đã lưu cấu hình Cursor', + }, + droidPage: { + loadingDiagnostics: 'Đang tải chẩn đoán Droid...', + failedDiagnostics: 'Không tải được chẩn đoán Droid.', + overview: 'Tổng quan', + byok: 'BYOK', + docs: 'Tài liệu', + runtimeInstall: 'Thời gian chạy và cài đặt', + status: 'Trạng thái', + detected: 'Đã phát hiện', + notFound: 'Không tìm thấy', + detectionSource: 'Nguồn phát hiện', + binaryPath: 'Đường dẫn nhị phân', + installDirectory: 'Thư mục cài đặt', + version: 'Phiên bản', + overridePath: 'Ghi đè (CCS_DROID_PATH)', + configFiles: 'Tệp cấu hình', + path: 'Đường dẫn', + resolved: 'Đã giải quyết', + size: 'Kích cỡ', + lastModified: 'Sửa đổi lần cuối', + parseWarning: 'Cảnh báo phân tích cú pháp: {{value}}', + readWarning: 'Đọc cảnh báo: {{value}}', + warnings: 'Cảnh báo', + byokSummary: 'Tóm tắt BYOK', + activeModelSelector: 'Bộ chọn mô hình hoạt động', + customModels: 'Mô hình tùy chỉnh', + ccsManaged: 'do CCS quản lý', + userManaged: 'Do người dùng quản lý', + malformedEntries: 'Mục nhập không đúng định dạng', + providers: 'Nhà cung cấp', + none: 'không có', + customModelsTitle: 'Mô hình tùy chỉnh', + modelName: 'Tên/model', + provider: 'Nhà cung cấp', + baseUrl: 'URL cơ sở', + noCustomModels: 'Không có mô hình tùy chỉnh', + docsAlignedNotes: 'Ghi chú được căn chỉnh trong Tài liệu', + factoryDocs: 'Tài liệu Factory', + providerFactCheckDocs: 'Tài liệu kiểm tra thực tế của nhà cung cấp', + providerValues: 'Giá trị của nhà cung cấp: {{value}}', + settingsHierarchy: 'Phân cấp cài đặt: {{value}}', + settingsTitle: 'Cài đặt BYOK của Droid', + fixJsonBeforeQuickSettings: + 'Sửa cú pháp JSON trước khi sử dụng các điều khiển cài đặt nhanh.', + fixJsonBeforeReasoning: 'Sửa cú pháp JSON trước khi cập nhật cài đặt lý luận BYOK.', + unableUpdateReasoning: 'Không thể cập nhật cài đặt lý luận mô hình BYOK đã chọn.', + fixJsonBeforeBudget: 'Sửa cú pháp JSON trước khi cập nhật ngân sách tư duy.', + anthropicOnlyBudget: 'Ngân sách tư duy chỉ có sẵn cho các mô hình BYOK của Anthropic.', + invalidJson: 'JSON không hợp lệ: {{value}}', + saved: 'Đã lưu cài đặt Droid', + changedExternally: 'Cài đặt Droid đã thay đổi bên ngoài. Làm mới và thử lại.', + failedSave: 'Không lưu được cài đặt Droid', + }, + sharedPage: { + commands: 'Lệnh', + skills: 'Kỹ năng', + agents: 'Agent', + title: 'Dữ liệu dùng chung', + subtitle: 'Các lệnh, kỹ năng và agent được chia sẻ giữa các phiên Claude', + totalShared: 'Tổng số mục dùng chung', + visible: 'Hiển thị', + markdownDetail: 'Chế độ xem chi tiết Markdown', + filterPrefix: 'Lọc:', + configurationRequired: 'Cần cấu hình', + countsUnavailable: 'Không có số liệu', + retryCounts: 'Số lần thử lại', + filterPlaceholder: 'Lọc {{tab}} theo tên, mô tả hoặc đường dẫn', + showing: 'Đang hiển thị {{visible}} trong số {{total}} {{tab}}', + showingQuery: 'cho "{{query}}"', + refreshing: '(đang làm mới...)', + loadingShared: 'Đang tải {{tab}} dùng chung...', + failedLoadShared: 'Không tải được danh sách {{tab}} dùng chung', + retry: 'Thử lại', + noSharedFound: + 'Không tìm thấy {{tab}} dùng chung. Chạy `ccs sync` hoặc thêm mục trong thư mục dùng chung của bạn.', + noMatch: 'Không có {{tab}} khớp với "{{query}}".', + selectOne: 'Chọn {{tab}} để xem toàn bộ nội dung.', + pathLabel: 'Đường dẫn', + resolvedSource: 'Nguồn đã giải quyết', + loadingMarkdown: 'Đang tải nội dung Markdown...', + failedLoadContent: 'Không tải được nội dung', + retryContent: 'Thử lại nội dung', + noMarkdown: 'Không có nội dung Markdown khả dụng.', + }, + }, + }, +} as const; + +if (!i18n.isInitialized) { + i18n.use(initReactI18next).init({ + resources, + lng: getInitialLocale(), + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, + }); +} + +export default i18n; diff --git a/ui/src/lib/locales.ts b/ui/src/lib/locales.ts new file mode 100644 index 00000000..ca719231 --- /dev/null +++ b/ui/src/lib/locales.ts @@ -0,0 +1,53 @@ +export const LOCALE_STORAGE_KEY = 'ccs-ui-locale'; + +export const SUPPORTED_LOCALES = ['en', 'zh-CN', 'vi'] as const; + +export type AppLocale = (typeof SUPPORTED_LOCALES)[number]; + +export function isSupportedLocale(locale: string): locale is AppLocale { + return SUPPORTED_LOCALES.includes(locale as AppLocale); +} + +export function normalizeLocale(locale: string | null | undefined): AppLocale { + if (!locale) return 'en'; + if (locale.toLowerCase().startsWith('zh')) return 'zh-CN'; + if (locale.toLowerCase().startsWith('vi')) return 'vi'; + return 'en'; +} + +export function getStoredLocale(): AppLocale | null { + if (typeof window === 'undefined') return null; + try { + const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY); + return raw ? normalizeLocale(raw) : null; + } catch { + return null; + } +} + +export function getInitialLocale(): AppLocale { + const stored = getStoredLocale(); + if (stored) return stored; + if (typeof navigator !== 'undefined') return normalizeLocale(navigator.language); + return 'en'; +} + +export function getFormattingLocale(locale?: string): string { + if (locale) return normalizeLocale(locale); + const stored = getStoredLocale(); + if (stored) return stored; + if (typeof navigator !== 'undefined') return normalizeLocale(navigator.language); + return 'en'; +} + +export function persistLocale(locale: string): AppLocale { + const normalized = normalizeLocale(locale); + if (typeof window !== 'undefined') { + try { + window.localStorage.setItem(LOCALE_STORAGE_KEY, normalized); + } catch { + // Ignore storage errors and keep runtime locale only. + } + } + return normalized; +} diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 0a8e98f3..016d0564 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -22,36 +22,14 @@ export const MODEL_CATALOGS: Record = { presetMapping: { default: 'claude-opus-4-6-thinking', opus: 'claude-opus-4-6-thinking', - sonnet: 'claude-sonnet-4-6-thinking', - haiku: 'claude-sonnet-4-6', - }, - }, - { - id: 'claude-opus-4-5-thinking', - name: 'Claude Opus 4.5 Thinking', - description: 'Previous flagship, extended thinking', - presetMapping: { - default: 'claude-opus-4-5-thinking', - opus: 'claude-opus-4-5-thinking', - sonnet: 'claude-sonnet-4-6-thinking', - haiku: 'claude-sonnet-4-6', - }, - }, - { - id: 'claude-sonnet-4-6-thinking', - name: 'Claude Sonnet 4.6 Thinking', - description: 'Latest Sonnet with extended thinking', - presetMapping: { - default: 'claude-sonnet-4-6-thinking', - opus: 'claude-opus-4-6-thinking', - sonnet: 'claude-sonnet-4-6-thinking', + sonnet: 'claude-sonnet-4-6', haiku: 'claude-sonnet-4-6', }, }, { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', - description: 'Latest Sonnet baseline', + description: 'Latest Sonnet with thinking budget support', presetMapping: { default: 'claude-sonnet-4-6', opus: 'claude-opus-4-6-thinking', @@ -59,28 +37,6 @@ export const MODEL_CATALOGS: Record = { haiku: 'claude-sonnet-4-6', }, }, - { - id: 'claude-sonnet-4-5-thinking', - name: 'Claude Sonnet 4.5 Thinking', - description: 'Balanced with extended thinking', - presetMapping: { - default: 'claude-sonnet-4-5-thinking', - opus: 'claude-opus-4-6-thinking', - sonnet: 'claude-sonnet-4-5-thinking', - haiku: 'claude-sonnet-4-5', - }, - }, - { - id: 'claude-sonnet-4-5', - name: 'Claude Sonnet 4.5', - description: 'Fast and capable', - presetMapping: { - default: 'claude-sonnet-4-5', - opus: 'claude-opus-4-6-thinking', - sonnet: 'claude-sonnet-4-5', - haiku: 'claude-sonnet-4-5', - }, - }, { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', @@ -306,20 +262,15 @@ export const MODEL_CATALOGS: Record = { name: 'Qwen3 Max', description: 'Flagship Qwen model via iFlow', }, - { - id: 'kimi-k2.5', - name: 'Kimi K2.5', - description: 'Latest Kimi model via iFlow', - }, { id: 'kimi-k2', name: 'Kimi K2', - description: 'Kimi general model', + description: 'Kimi model currently available via iFlow', }, { - id: 'deepseek-v3.2-chat', - name: 'DeepSeek V3.2 Chat', - description: 'Stable DeepSeek chat model', + id: 'deepseek-v3.2', + name: 'DeepSeek V3.2', + description: 'Current DeepSeek V3.2 model via iFlow', }, { id: 'deepseek-r1', @@ -327,14 +278,9 @@ export const MODEL_CATALOGS: Record = { description: 'Reasoning-focused DeepSeek model', }, { - id: 'glm-4.7', - name: 'GLM 4.7', - description: 'Zhipu GLM 4.7 via iFlow', - }, - { - id: 'minimax-m2.5', - name: 'MiniMax M2.5', - description: 'MiniMax M2.5 via iFlow', + id: 'glm-4.6', + name: 'GLM 4.6', + description: 'Zhipu GLM 4.6 via iFlow', }, { id: 'qwen3-vl-plus', diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 1fbbe124..37650ad7 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -217,10 +217,15 @@ export function getClaudeResetTime< } // Known primary models to show when exhausted (removed from API response) +const AGY_DENYLIST_REGEX = /claude-(?:opus|sonnet)-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/i; + +export function isDeniedAgyModelId(modelId: string): boolean { + return AGY_DENYLIST_REGEX.test((modelId || '').trim()); +} + const KNOWN_PRIMARY_MODELS = [ - { name: 'claude-opus-4-5-thinking', displayName: 'Claude Opus 4.5 (Thinking)' }, - { name: 'claude-sonnet-4-5', displayName: 'Claude Sonnet 4.5' }, - { name: 'claude-sonnet-4-5-thinking', displayName: 'Claude Sonnet 4.5 (Thinking)' }, + { name: 'claude-opus-4-6-thinking', displayName: 'Claude Opus 4.6 (Thinking)' }, + { name: 'claude-sonnet-4-6', displayName: 'Claude Sonnet 4.6' }, { name: 'gpt-oss-120b', displayName: 'GPT-OSS 120B (Medium)' }, ]; diff --git a/ui/src/pages/accounts.tsx b/ui/src/pages/accounts.tsx index b0c5efe1..6b517db5 100644 --- a/ui/src/pages/accounts.tsx +++ b/ui/src/pages/accounts.tsx @@ -17,8 +17,10 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component import { ScrollArea } from '@/components/ui/scroll-area'; import { useAccounts, useConfirmLegacyAccountPolicies } from '@/hooks/use-accounts'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; export function AccountsPage() { + const { t } = useTranslation(); const navigate = useNavigate(); const { data, isLoading } = useAccounts(); const confirmLegacyMutation = useConfirmLegacyAccountPolicies(); @@ -59,12 +61,12 @@ export function AccountsPage() {
-

Accounts

+

{t('accountsPage.title')}

- Manage + {t('accountsPage.managePrefix')} ccs auth - accounts and pool onboarding from one panel. + {t('accountsPage.manageSuffix')}

@@ -72,7 +74,7 @@ export function AccountsPage() {

- Primary Actions + {t('accountsPage.primaryActions')}

@@ -104,7 +106,7 @@ export function AccountsPage() { {hasLegacyFollowUp ? (

- Migration Follow-up + {t('accountsPage.migrationFollowup')}

@@ -112,16 +114,14 @@ export function AccountsPage() {
{legacyContextCount > 0 && (

- {legacyContextCount} account - {legacyContextCount > 1 ? 's still need' : ' still needs'} first-time - mode confirmation. + {t('accountsPage.legacyContextPending', { count: legacyContextCount })}

)} {legacyContinuityCount > 0 && (

- {legacyContinuityCount} shared account - {legacyContinuityCount > 1 ? 's remain' : ' remains'} on standard legacy - continuity depth. + {t('accountsPage.legacyContinuityPending', { + count: legacyContinuityCount, + })}

)}
@@ -134,14 +134,14 @@ export function AccountsPage() { disabled={confirmLegacyMutation.isPending || legacyTargetCount === 0} > {confirmLegacyMutation.isPending - ? 'Confirming Legacy Policies...' - : `Confirm Legacy Policies (${legacyTargetCount})`} + ? t('accountsPage.confirmingLegacy') + : t('accountsPage.confirmLegacy', { count: legacyTargetCount })}
) : (
- No legacy follow-up pending. + {t('accountsPage.noLegacyFollowup')}
)} @@ -151,9 +151,11 @@ export function AccountsPage() { @@ -306,11 +314,11 @@ export function AccountsPage() { - Account Matrix + {t('accountsPage.accountMatrix')} {isLoading ? ( -
Loading accounts...
+
{t('accountsPage.loadingAccounts')}
) : ( )} diff --git a/ui/src/pages/analytics/components/analytics-header.tsx b/ui/src/pages/analytics/components/analytics-header.tsx index cb69b29e..4c5f3380 100644 --- a/ui/src/pages/analytics/components/analytics-header.tsx +++ b/ui/src/pages/analytics/components/analytics-header.tsx @@ -9,6 +9,7 @@ import { subDays, startOfMonth } from 'date-fns'; import { Button } from '@/components/ui/button'; import { DateRangeFilter } from '@/components/analytics/date-range-filter'; import { RefreshCw } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; interface AnalyticsHeaderProps { dateRange: DateRange | undefined; @@ -29,11 +30,13 @@ export function AnalyticsHeader({ lastUpdatedText, viewMode, }: AnalyticsHeaderProps) { + const { t } = useTranslation(); + return (
-

Analytics

-

Track usage & insights

+

{t('analytics.title')}

+

{t('analytics.subtitle')}

- {/* Search */}
setSearchQuery(e.target.value)} @@ -130,23 +110,24 @@ export function ApiPage() {
- {/* Profile List */} {isLoading ? ( -
Loading profiles...
+
+ {t('apiProfiles.loadingProfiles')} +
) : isError ? (
-

Failed to load profiles

+

{t('apiProfiles.failedLoadTitle')}

- Unable to fetch API profiles. Please try again. + {t('apiProfiles.failedLoadDesc')}

@@ -156,9 +137,9 @@ export function ApiPage() {
-

No API profiles yet

+

{t('apiProfiles.noProfilesYet')}

- Create your first profile to connect to custom API endpoints + {t('apiProfiles.noProfilesDesc')}

) : (

- No profiles match "{searchQuery}" + {t('apiProfiles.noProfileMatch', { query: searchQuery })}

)}
@@ -193,31 +174,34 @@ export function ApiPage() { )} - {/* Footer Stats */} {profiles.length > 0 && (
- - {profiles.length} profile{profiles.length !== 1 ? 's' : ''} - + {t('apiProfiles.profileCount', { count: profiles.length })} - {profiles.filter((p) => p.configured).length} configured + {t('apiProfiles.configuredCount', { + count: profiles.filter((p) => p.configured).length, + })}
)} - {/* OpenRouter Promo - always visible */} { setCreateMode('openrouter'); setCreateDialogOpen(true); }} /> + { + setCreateMode('alibaba-coding-plan'); + setCreateDialogOpen(true); + }} + />
- {/* Right Panel - Editor or QuickStart */}
{selectedProfileData ? ( { + setCreateMode('alibaba-coding-plan'); + setCreateDialogOpen(true); + }} onCustomClick={() => { setCreateMode('normal'); setCreateDialogOpen(true); @@ -242,7 +230,6 @@ export function ApiPage() {
- {/* Create Dialog */} - {/* Delete Confirmation */} deleteConfirm && handleDelete(deleteConfirm)} onCancel={() => setDeleteConfirm(null)} /> - {/* Unsaved Changes Confirmation */} { setEditorHasChanges(false); @@ -279,7 +267,6 @@ export function ApiPage() { ); } -/** Profile list item component */ function ProfileListItem({ profile, isSelected, @@ -301,14 +288,12 @@ function ProfileListItem({ )} onClick={onSelect} > - {/* Status indicator */} {profile.configured ? ( ) : ( )} - {/* Profile info */}
{profile.name}
@@ -328,7 +313,6 @@ function ProfileListItem({
- {/* Actions */}
@@ -101,6 +105,7 @@ function VariantSidebarItem({ onDelete: () => void; isDeleting?: boolean; }) { + const { t } = useTranslation(); return (
@@ -157,27 +166,25 @@ function VariantSidebarItem({ // Empty state for right panel function EmptyProviderState({ onSetup }: { onSetup: () => void }) { + const { t } = useTranslation(); return (
-

CCS Profile Manager

-

- Manage OAuth authentication, account preferences, and model selection for CLIProxy - providers. Configure how CCS routes requests to different AI backends. -

+

{t('cliproxyPage.emptyTitle')}

+

{t('cliproxyPage.emptyDesc')}

- For live usage stats and real-time monitoring, visit the{' '} + {t('cliproxyPage.emptyControlPanelPrefix')}{' '} - Control Panel + {t('cliproxyPage.controlPanel')} .

@@ -185,6 +192,7 @@ function EmptyProviderState({ onSetup }: { onSetup: () => void }) { } export function CliproxyPage() { + const { t } = useTranslation(); const queryClient = useQueryClient(); const { data: authData, isLoading: authLoading } = useCliproxyAuth(); const { data: variantsData, isFetching } = useCliproxy(); @@ -337,7 +345,9 @@ export function CliproxyPage() {
-

CCS-level account management

+

+ {t('cliproxyPage.accountManagement')} +

@@ -354,7 +364,7 @@ export function CliproxyPage() {
- Providers + {t('cliproxyPage.providers')}
{authLoading ? (
@@ -380,7 +390,7 @@ export function CliproxyPage() { <>
- Variants + {t('cliproxyPage.variants')}
{variants.map((variant) => ( @@ -408,12 +418,12 @@ export function CliproxyPage() { {/* Footer Stats */}
- - {providers.length} provider{providers.length !== 1 ? 's' : ''} - + {t('cliproxyPage.providerCount', { count: providers.length })} - {providers.filter((p) => p.authenticated).length} connected + {t('cliproxyPage.connectedCount', { + count: providers.filter((p) => p.authenticated).length, + })}
@@ -429,7 +439,10 @@ export function CliproxyPage() { // Variant selected - show ProviderEditor with variant profile name
-

Copilot

+

{t('copilotPage.title')}

-

GitHub Copilot proxy

+

{t('copilotPage.subtitle')}

{/* Status Overview */} @@ -148,18 +150,18 @@ export function CopilotPage() {
- Unofficial API - Use at Your Own Risk + {t('copilotPage.unofficialTitle')}
    -
  • Reverse-engineered API - may break anytime
  • -
  • Excessive use may trigger account restrictions
  • -
  • No warranty, no responsibility from CCS
  • +
  • {t('copilotPage.unofficialItem1')}
  • +
  • {t('copilotPage.unofficialItem2')}
  • +
  • {t('copilotPage.unofficialItem3')}
{/* Setup - Binary first, then enabled status */} - + {!status?.installed && ( @@ -182,12 +184,12 @@ export function CopilotPage() { {isInstalling ? ( <> - Installing... + {t('copilotPage.installing')} ) : ( <> - Install copilot-api + {t('copilotPage.installCopilotApi')} )} @@ -195,21 +197,27 @@ export function CopilotPage() { {status?.installed && ( )} {/* Authentication - only show after binary installed */} {status?.installed && ( - + {!status?.authenticated && ( )} @@ -226,15 +236,17 @@ export function CopilotPage() { {/* Daemon - only show after authenticated */} {status?.authenticated && ( - +
- Port: {status?.port ?? 4141} + {t('copilotPage.port')}: {status?.port ?? 4141}
{status?.daemon_running ? ( @@ -246,7 +258,7 @@ export function CopilotPage() { disabled={isStoppingDaemon} > - {isStoppingDaemon ? 'Stopping...' : 'Stop'} + {isStoppingDaemon ? t('copilotPage.stopping') : t('copilotPage.stop')} ) : ( )}
@@ -270,16 +282,16 @@ export function CopilotPage() { {/* Footer */}
- Proxy + {t('copilotPage.proxy')} {status?.daemon_running ? ( - Active + {t('copilotPage.active')} ) : ( - Inactive + {t('copilotPage.inactive')} )}
diff --git a/ui/src/pages/cursor.tsx b/ui/src/pages/cursor.tsx index fcc0e08e..2268fb3e 100644 --- a/ui/src/pages/cursor.tsx +++ b/ui/src/pages/cursor.tsx @@ -52,6 +52,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { useTranslation } from 'react-i18next'; interface CursorConfigDraft { port: string; @@ -156,6 +157,7 @@ function CursorModelSelector({ allowDefaultFallback?: boolean; onChange: (value: string) => void; }) { + const { t } = useTranslation(); const selectorValue = value || (allowDefaultFallback ? '__default' : ''); const selected = models.find((model) => model.id === value); @@ -177,12 +179,12 @@ function CursorModelSelector({ disabled={disabled} > - + {selectorValue && (
{allowDefaultFallback && selectorValue === '__default' - ? 'Use Default Model' + ? t('cursorPage.useDefaultModel') : selected?.name || selectorValue} {selected?.provider && ( @@ -197,9 +199,11 @@ function CursorModelSelector({ - Available Models ({models.length}) + {t('cursorPage.availableModelCount', { count: models.length })} - {allowDefaultFallback && Use Default Model} + {allowDefaultFallback && ( + {t('cursorPage.useDefaultModel')} + )} {models.map((model) => (
@@ -252,6 +256,7 @@ function StatusItem({ } export function CursorPage() { + const { t } = useTranslation(); const { status, statusLoading, @@ -342,8 +347,13 @@ export function CursorPage() { const canStart = Boolean(status?.enabled && status?.authenticated && !status?.token_expired); const integrationBadge = useMemo( - () => (status?.enabled ? Enabled : Disabled), - [status?.enabled] + () => + status?.enabled ? ( + {t('cursorPage.enabled')} + ) : ( + {t('cursorPage.disabled')} + ), + [status?.enabled, t] ); const handleSaveConfig = async ({ @@ -351,11 +361,11 @@ export function CursorPage() { }: { suppressSuccessToast?: boolean } = {}) => { const parsedPort = Number(effectivePort); if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { - toast.error('Port must be an integer between 1 and 65535'); + toast.error(t('cursorPage.invalidPort')); return false; } if (!effectiveModel.trim()) { - toast.error('Default model is required'); + toast.error(t('cursorPage.defaultModelRequired')); return false; } @@ -382,23 +392,23 @@ export function CursorPage() { }) ); if (!suppressSuccessToast) { - toast.success('Cursor config and model mapping saved'); + toast.success(t('cursorPage.savedConfig')); } return true; } catch (error) { - toast.error((error as Error).message || 'Failed to save config'); + toast.error((error as Error).message || t('cursorPage.failedSaveConfig')); return false; } }; const applyPreset = (preset: 'codex53' | 'claude46' | 'gemini3') => { if (modelsLoading) { - toast.error('Models are still loading. Please wait before applying a preset.'); + toast.error(t('cursorPage.modelsLoadingWait')); return; } if (models.length === 0) { - toast.error('No models available yet. Start the daemon and refresh first.'); + toast.error(t('cursorPage.noModelsAvailable')); return; } @@ -457,7 +467,7 @@ export function CursorPage() { sonnet_model: codex53, haiku_model: codexMini, })); - toast.success('Applied GPT-5.3 Codex preset'); + toast.success(t('cursorPage.appliedCodexPreset')); return; } @@ -469,7 +479,7 @@ export function CursorPage() { sonnet_model: sonnet45, haiku_model: haiku45, })); - toast.success('Applied Claude 4.6 preset'); + toast.success(t('cursorPage.appliedClaudePreset')); return; } @@ -480,30 +490,32 @@ export function CursorPage() { sonnet_model: gemini3Pro, haiku_model: gemini3Flash, })); - toast.success('Applied Gemini 3 preset'); + toast.success(t('cursorPage.appliedGeminiPreset')); }; const handleToggleEnabled = async (enabled: boolean) => { try { await updateConfigAsync({ enabled }); - toast.success(enabled ? 'Cursor integration enabled' : 'Cursor integration disabled'); + toast.success( + enabled ? t('cursorPage.integrationEnabled') : t('cursorPage.integrationDisabled') + ); } catch (error) { - toast.error((error as Error).message || 'Failed to update integration state'); + toast.error((error as Error).message || t('cursorPage.failedUpdateIntegration')); } }; const handleAutoDetectAuth = async () => { try { await autoDetectAuthAsync(); - toast.success('Cursor credentials imported'); + toast.success(t('cursorPage.credentialsImported')); } catch (error) { - toast.error((error as Error).message || 'Auto-detect failed'); + toast.error((error as Error).message || t('cursorPage.autoDetectFailed')); } }; const handleManualAuthImport = async () => { if (!manualToken.trim() || !manualMachineId.trim()) { - toast.error('Token and machine ID are required'); + toast.error(t('cursorPage.manualRequired')); return; } @@ -512,12 +524,12 @@ export function CursorPage() { accessToken: manualToken.trim(), machineId: manualMachineId.trim(), }); - toast.success('Cursor credentials imported'); + toast.success(t('cursorPage.credentialsImported')); setManualAuthOpen(false); setManualToken(''); setManualMachineId(''); } catch (error) { - toast.error((error as Error).message || 'Manual import failed'); + toast.error((error as Error).message || t('cursorPage.manualImportFailed')); } }; @@ -525,12 +537,16 @@ export function CursorPage() { try { const result = await startDaemonAsync(); if (!result.success) { - toast.error(result.error || 'Failed to start daemon'); + toast.error(result.error || t('cursorPage.failedStartDaemon')); return; } - toast.success(`Daemon started${result.pid ? ` (PID: ${result.pid})` : ''}`); + toast.success( + result.pid + ? t('cursorPage.daemonStartedWithPid', { pid: result.pid }) + : t('cursorPage.daemonStarted') + ); } catch (error) { - toast.error((error as Error).message || 'Failed to start daemon'); + toast.error((error as Error).message || t('cursorPage.failedStartDaemon')); } }; @@ -538,12 +554,12 @@ export function CursorPage() { try { const result = await stopDaemonAsync(); if (!result.success) { - toast.error(result.error || 'Failed to stop daemon'); + toast.error(result.error || t('cursorPage.failedStopDaemon')); return; } - toast.success('Daemon stopped'); + toast.success(t('cursorPage.daemonStopped')); } catch (error) { - toast.error((error as Error).message || 'Failed to stop daemon'); + toast.error((error as Error).message || t('cursorPage.failedStopDaemon')); } }; @@ -551,12 +567,12 @@ export function CursorPage() { suppressSuccessToast = false, }: { suppressSuccessToast?: boolean } = {}) => { if (!rawSettingsReady) { - toast.error('Raw settings are still loading. Please wait and try again.'); + toast.error(t('cursorPage.rawLoading')); return false; } if (!rawParseResult.isValid || !rawParseResult.settings) { - toast.error(rawParseResult.error || 'Invalid JSON'); + toast.error(rawParseResult.error || t('cursorPage.invalidJson')); return false; } @@ -567,14 +583,14 @@ export function CursorPage() { }); setRawConfigDirty(false); if (!suppressSuccessToast) { - toast.success('Raw settings saved'); + toast.success(t('cursorPage.rawSaved')); } return true; } catch (error) { if (isApiConflictError(error)) { - toast.error('Raw settings changed externally. Refresh and retry.'); + toast.error(t('cursorPage.rawChanged')); } else { - toast.error((error as Error).message || 'Failed to save raw settings'); + toast.error((error as Error).message || t('cursorPage.failedSaveRaw')); } return false; } @@ -601,7 +617,7 @@ export function CursorPage() { } if (saveConfig && saveRawSettings) { - toast.success('Cursor configuration saved'); + toast.success(t('cursorPage.savedAll')); } }; @@ -623,12 +639,12 @@ export function CursorPage() { alt="" className="w-5 h-5 object-contain shrink-0" /> -

Cursor

+

{t('cursorPage.title')}

- Beta + {t('cursorPage.beta')} {integrationBadge}
@@ -642,7 +658,7 @@ export function CursorPage() {
-

Dedicated Cursor integration controls

+

{t('cursorPage.subtitle')}

@@ -651,46 +667,48 @@ export function CursorPage() {
- Unofficial API - Use at Your Own Risk + {t('cursorPage.unofficialTitle')}
    -
  • Reverse-engineered integration may break anytime
  • -
  • Abuse or excessive usage may risk account restrictions
  • -
  • No warranty, no responsibility from CCS
  • +
  • {t('cursorPage.unofficialItem1')}
  • +
  • {t('cursorPage.unofficialItem2')}
  • +
  • {t('cursorPage.unofficialItem3')}

- Actions + {t('cursorPage.actions')}

{status?.enabled ? ( @@ -702,7 +720,7 @@ export function CursorPage() { disabled={isUpdatingConfig} > - Disable Integration + {t('cursorPage.disableIntegration')} ) : ( )} @@ -728,7 +746,7 @@ export function CursorPage() { ) : ( )} - Auto-detect Auth + {t('cursorPage.autoDetectAuth')} {status?.daemon_running ? ( @@ -754,7 +772,7 @@ export function CursorPage() { ) : ( )} - Stop Daemon + {t('cursorPage.stopDaemon')} ) : ( )}
@@ -777,7 +795,7 @@ export function CursorPage() {
- Port + {t('cursorPage.port')} {status?.port ?? config?.port ?? DEFAULT_CURSOR_PORT}
@@ -789,7 +807,7 @@ export function CursorPage() {
-

Cursor Configuration

+

{t('cursorPage.configuration')}

{rawSettings && ( cursor.settings.json @@ -798,10 +816,10 @@ export function CursorPage() {
{rawSettings && (

- Last modified:{' '} + {t('cursorPage.lastModified')}{' '} {rawSettings.exists ? new Date(rawSettings.mtime).toLocaleString() - : 'Never saved'} + : t('cursorPage.neverSaved')}

)}
@@ -828,12 +846,12 @@ export function CursorPage() { {isUpdatingConfig || isSavingRawSettings ? ( <> - Saving... + {t('cursorPage.saving')} ) : ( <> - Save + {t('cursorPage.save')} )} @@ -846,13 +864,13 @@ export function CursorPage() {
- Model Config + {t('cursorPage.modelConfig')} - Settings + {t('cursorPage.settings')} - Info + {t('cursorPage.info')}
@@ -867,10 +885,10 @@ export function CursorPage() {

- Presets + {t('cursorPage.presets')}

- Apply pre-configured model mappings + {t('cursorPage.presetsDesc')}

diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx index 0a1dea5d..382791c3 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -27,6 +27,7 @@ import { Separator } from '@/components/ui/separator'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; import { applyAnthropicBudgetTokensToDroidByokModel, applyReasoningEffortToDroidByokModel, @@ -166,6 +167,7 @@ function DetailRow({ } export function DroidPage() { + const { t } = useTranslation(); const { diagnostics, diagnosticsLoading, @@ -201,7 +203,7 @@ export function DroidPage() { const updateSettingsField = (key: string, value: unknown | null) => { if (!rawEditorParsed.valid) { - toast.error('Fix JSON syntax before using quick settings controls.'); + toast.error(t('droidPage.fixJsonBeforeQuickSettings')); return; } @@ -248,7 +250,7 @@ export function DroidPage() { const handleSaveRawSettings = async () => { if (!rawEditorValidation.valid) { - toast.error(`Invalid JSON: ${rawEditorValidation.error}`); + toast.error(t('droidPage.invalidJson', { value: rawEditorValidation.error })); return; } @@ -258,12 +260,12 @@ export function DroidPage() { expectedMtime: rawSettings?.exists ? rawSettings.mtime : undefined, }); setRawDraftText(null); - toast.success('Droid settings saved'); + toast.success(t('droidPage.saved')); } catch (error) { if (isApiConflictError(error)) { - toast.error('Droid settings changed externally. Refresh and retry.'); + toast.error(t('droidPage.changedExternally')); } else { - toast.error((error as Error).message || 'Failed to save Droid settings'); + toast.error((error as Error).message || t('droidPage.failedSave')); } } }; @@ -279,7 +281,7 @@ export function DroidPage() { return (
- Loading Droid diagnostics... + {t('droidPage.loadingDiagnostics')}
); } @@ -287,7 +289,7 @@ export function DroidPage() { if (diagnosticsError || !diagnostics) { return (
- Failed to load Droid diagnostics. + {t('droidPage.failedDiagnostics')}
); } @@ -314,9 +316,9 @@ export function DroidPage() {
- Overview - BYOK - Docs + {t('droidPage.overview')} + {t('droidPage.byok')} + {t('droidPage.docs')}
@@ -328,34 +330,40 @@ export function DroidPage() { - Runtime & Installation + {t('droidPage.runtimeInstall')}
- Status + {t('droidPage.status')} - {diagnostics.binary.installed ? 'Detected' : 'Not Found'} + {diagnostics.binary.installed + ? t('droidPage.detected') + : t('droidPage.notFound')}
- + @@ -366,7 +374,7 @@ export function DroidPage() { - Config Files + {t('droidPage.configFiles')} @@ -380,15 +388,25 @@ export function DroidPage() { )}
- - - - + + + + {file.parseError && ( -

Parse warning: {file.parseError}

+

+ {t('droidPage.parseWarning', { value: file.parseError })} +

)} {file.readError && ( -

Read warning: {file.readError}

+

+ {t('droidPage.readWarning', { value: file.readError })} +

)}
))} @@ -400,7 +418,7 @@ export function DroidPage() { - Warnings + {t('droidPage.warnings')} @@ -444,11 +462,11 @@ export function DroidPage() { disabledReason={ rawEditorParsed.valid ? null - : `BYOK reasoning controls disabled: ${rawEditorParsed.error}` + : `${t('droidPage.byok')}: ${rawEditorParsed.error}` } onEffortChange={(modelId, effort) => { if (!rawEditorParsed.valid) { - toast.error('Fix JSON syntax before updating BYOK reasoning settings.'); + toast.error(t('droidPage.fixJsonBeforeReasoning')); return; } @@ -458,7 +476,7 @@ export function DroidPage() { effort ); if (!nextSettings) { - toast.error('Unable to update selected BYOK model reasoning setting.'); + toast.error(t('droidPage.unableUpdateReasoning')); return; } @@ -466,7 +484,7 @@ export function DroidPage() { }} onAnthropicBudgetChange={(modelId, budgetTokens) => { if (!rawEditorParsed.valid) { - toast.error('Fix JSON syntax before updating thinking budget.'); + toast.error(t('droidPage.fixJsonBeforeBudget')); return; } @@ -476,7 +494,7 @@ export function DroidPage() { budgetTokens ); if (!nextSettings) { - toast.error('Thinking budget is only available for Anthropic BYOK models.'); + toast.error(t('droidPage.anthropicOnlyBudget')); return; } @@ -488,38 +506,38 @@ export function DroidPage() { - BYOK Summary + {t('droidPage.byokSummary')}
-

Providers

+

{t('droidPage.providers')}

{providerRows.length === 0 && ( - none + {t('droidPage.none')} )} {providerRows.map(([provider, count]) => ( @@ -534,20 +552,20 @@ export function DroidPage() { - Custom Models + {t('droidPage.customModelsTitle')}
- Name / Model - Provider - Base URL + {t('droidPage.modelName')} + {t('droidPage.provider')} + {t('droidPage.baseUrl')}
{customModels.length === 0 && (
- No custom models + {t('droidPage.noCustomModels')}
)} {customModels.map((model) => ( @@ -593,7 +611,7 @@ export function DroidPage() { - Docs-Aligned Notes + {t('droidPage.docsAlignedNotes')} @@ -605,7 +623,7 @@ export function DroidPage() {

- Factory Docs + {t('droidPage.factoryDocs')}

{docsLinks.map((link) => ( @@ -633,7 +651,7 @@ export function DroidPage() {

- Provider Fact-Check Docs + {t('droidPage.providerFactCheckDocs')}

{providerDocs.map((providerDoc) => ( @@ -660,10 +678,10 @@ export function DroidPage() {

- Provider values: {providerValues.join(', ')} + {t('droidPage.providerValues', { value: providerValues.join(', ') })}

- Settings hierarchy: {settingsHierarchy.join(' -> ')} + {t('droidPage.settingsHierarchy', { value: settingsHierarchy.join(' -> ') })}

@@ -686,7 +704,7 @@ export function DroidPage() { 0) return 'error'; @@ -16,14 +17,17 @@ function getOverallStatus(summary: { passed: number; warnings: number; errors: n return 'ok'; } -function formatRelativeTime(timestamp: number): string { +function formatRelativeTime( + timestamp: number, + t: (key: string, options?: Record) => string +): string { const seconds = Math.floor((Date.now() - timestamp) / 1000); - if (seconds < 5) return 'just now'; - if (seconds < 60) return `${seconds}s ago`; + if (seconds < 5) return t('health.justNow'); + if (seconds < 60) return t('health.secondsAgo', { count: seconds }); const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; + if (minutes < 60) return t('health.minutesAgo', { count: minutes }); const hours = Math.floor(minutes / 60); - return `${hours}h ago`; + return t('health.hoursAgo', { count: hours }); } function sortGroupsByIssues(groups: HealthGroup[]): HealthGroup[] { @@ -75,6 +79,7 @@ function LoadingSkeleton() { } export function HealthPage() { + const { t } = useTranslation(); const { data, isLoading, refetch, dataUpdatedAt } = useHealth(); // Use dataUpdatedAt directly instead of storing in state @@ -91,12 +96,12 @@ export function HealthPage() { const copyDoctorCommand = () => { navigator.clipboard.writeText('ccs doctor'); - toast.success('Copied to clipboard'); + toast.success(t('health.copied')); }; const handleRefresh = () => { refetch(); - toast.info('Refreshing health checks...'); + toast.info(t('health.refreshing')); }; if (isLoading && !data) { @@ -154,10 +159,12 @@ export function HealthPage() { {/* Main title */}
-

System Health

+

+ {t('health.systemHealth')} +

{data?.version && ( - build {data.version} + {t('health.build', { version: data.version })} )}
@@ -165,12 +172,12 @@ export function HealthPage() { {/* Status message */}
- Last scan: + {t('health.lastScan')} - {lastRefresh ? formatRelativeTime(lastRefresh) : '--'} + {lastRefresh ? formatRelativeTime(lastRefresh, t) : '--'} | - Auto-refresh: + {t('health.autoRefresh')} 30s
@@ -195,7 +202,7 @@ export function HealthPage() { className="gap-2" > - Refresh + {t('health.refresh')} R @@ -237,10 +244,10 @@ export function HealthPage() {
- Version {data?.version ?? '--'} + {t('health.version')} {data?.version ?? '--'} - Platform{' '} + {t('health.platform')}{' '} {typeof navigator !== 'undefined' ? navigator.platform : 'linux'} @@ -248,7 +255,7 @@ export function HealthPage() {
- Live monitoring active + {t('health.liveMonitoring')}
diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index 2b192021..a60041ae 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -9,6 +9,7 @@ import { useOverview } from '@/hooks/use-overview'; import { useSharedSummary } from '@/hooks/use-shared'; import { cn } from '@/lib/utils'; import type { LucideIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; const HEALTH_VARIANTS = { ok: 'success', @@ -62,6 +63,7 @@ function InlineStat({ } export function HomePage() { + const { t } = useTranslation(); const navigate = useNavigate(); const { data: overview, isLoading: isOverviewLoading } = useOverview(); const { data: shared, isLoading: isSharedLoading } = useSharedSummary(); @@ -134,28 +136,28 @@ export function HomePage() { {/* Right: Inline Stats */}
navigate('/providers')} /> navigate('/cliproxy')} /> navigate('/accounts')} /> - Configuration Required + {t('home.configurationRequired')} {shared.symlinkStatus.message} )} diff --git a/ui/src/pages/login.tsx b/ui/src/pages/login.tsx index 0550e9a3..87fb1e37 100644 --- a/ui/src/pages/login.tsx +++ b/ui/src/pages/login.tsx @@ -12,8 +12,10 @@ import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { AlertCircle, Loader2, Lock } from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import { useTranslation } from 'react-i18next'; export function LoginPage() { + const { t } = useTranslation(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); @@ -47,7 +49,7 @@ export function LoginPage() { await login(username, password); navigate(from, { replace: true }); } catch (err) { - setError(err instanceof Error ? err.message : 'Login failed'); + setError(err instanceof Error ? err.message : t('auth.loginFailed')); } finally { setLoading(false); } @@ -60,8 +62,8 @@ export function LoginPage() {
- CCS Dashboard - Enter your credentials to access the dashboard + {t('auth.dashboardTitle')} + {t('auth.loginDescription')}
@@ -72,26 +74,26 @@ export function LoginPage() { )}
- + setUsername(e.target.value)} - placeholder="Enter username" + placeholder={t('auth.usernamePlaceholder')} autoComplete="username" disabled={loading} required />
- + setPassword(e.target.value)} - placeholder="Enter password" + placeholder={t('auth.passwordPlaceholder')} autoComplete="current-password" disabled={loading} required @@ -101,10 +103,10 @@ export function LoginPage() { {loading ? ( <> - Signing in... + {t('auth.signingIn')} ) : ( - 'Sign In' + t('auth.signIn') )} diff --git a/ui/src/pages/settings/components/tab-navigation.tsx b/ui/src/pages/settings/components/tab-navigation.tsx index 536140c0..d0d919c6 100644 --- a/ui/src/pages/settings/components/tab-navigation.tsx +++ b/ui/src/pages/settings/components/tab-navigation.tsx @@ -6,22 +6,24 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Globe, Settings2, Server, KeyRound, Brain, Archive } from 'lucide-react'; import type { SettingsTab } from '../types'; +import { useTranslation } from 'react-i18next'; interface TabNavigationProps { activeTab: SettingsTab; onTabChange: (tab: SettingsTab) => void; } -const tabs = [ - { value: 'websearch' as const, label: 'Web', icon: Globe }, - { value: 'globalenv' as const, label: 'Env', icon: Settings2 }, - { value: 'thinking' as const, label: 'Think', icon: Brain }, - { value: 'proxy' as const, label: 'Proxy', icon: Server }, - { value: 'auth' as const, label: 'Auth', icon: KeyRound }, - { value: 'backups' as const, label: 'Backup', icon: Archive }, -] as const; - export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) { + const { t } = useTranslation(); + const tabs = [ + { value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe }, + { value: 'globalenv' as const, label: t('settingsTabs.env'), icon: Settings2 }, + { value: 'thinking' as const, label: t('settingsTabs.think'), icon: Brain }, + { value: 'proxy' as const, label: t('settingsTabs.proxy'), icon: Server }, + { value: 'auth' as const, label: t('settingsTabs.auth'), icon: KeyRound }, + { value: 'backups' as const, label: t('settingsTabs.backup'), icon: Archive }, + ] as const; + return ( onTabChange(v as SettingsTab)}> diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx index 1571fdde..57da2d80 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -21,6 +21,8 @@ import { useSettingsTab, useRawConfig } from './hooks'; import { TabNavigation } from './components/tab-navigation'; import { SectionSkeleton } from './components/section-skeleton'; import type { SettingsTab } from './types'; +import { useTranslation } from 'react-i18next'; +import i18n from '@/lib/i18n'; /** * Retry wrapper for dynamic imports with exponential backoff @@ -72,15 +74,19 @@ class SectionErrorBoundary extends Component<
-

Failed to load section

-

{this.state.error?.message || 'Unknown error occurred'}

+

+ {i18n.t('settings.sectionLoadFailed')} +

+

+ {this.state.error?.message || i18n.t('settings.unknownError')} +

@@ -93,6 +99,7 @@ class SectionErrorBoundary extends Component< // Inner component that uses context function SettingsPageInner() { + const { t } = useTranslation(); const { activeTab, setActiveTab } = useSettingsTab(); const { rawConfig, @@ -178,12 +185,12 @@ function SettingsPageInner() { {copied ? ( <> - Copied + {t('settings.copied')} ) : ( <> - Copy + {t('settings.copy')} )} @@ -203,7 +210,7 @@ function SettingsPageInner() { {rawConfigLoading ? (
- Loading... + {t('settings.loading')}
) : rawConfig ? (
-

Config file not found

+

{t('settings.configFileNotFound')}

ccs migrate diff --git a/ui/src/pages/settings/sections/auth-section.tsx b/ui/src/pages/settings/sections/auth-section.tsx index bfbbb59a..7a083599 100644 --- a/ui/src/pages/settings/sections/auth-section.tsx +++ b/ui/src/pages/settings/sections/auth-section.tsx @@ -23,6 +23,7 @@ import { Save, } from 'lucide-react'; import { useRawConfig } from '../hooks'; +import { useTranslation } from 'react-i18next'; interface TokenInfo { value: string; @@ -35,6 +36,7 @@ interface AuthTokens { } export default function AuthSection() { + const { t } = useTranslation(); const { fetchRawConfig } = useRawConfig(); // State @@ -60,16 +62,16 @@ export default function AuthSection() { // Use /raw to get unmasked values for editing const response = await fetch('/api/settings/auth/tokens/raw'); if (!response.ok) { - throw new Error('Failed to fetch auth tokens'); + throw new Error(t('settingsAuth.failedFetchTokens')); } const data = await response.json(); setTokens(data); } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : t('settings.unknownError')); } finally { setLoading(false); } - }, []); + }, [t]); // Load on mount useEffect(() => { @@ -117,16 +119,16 @@ export default function AuthSection() { if (!response.ok) { const data = await response.json(); - throw new Error(data.error || 'Failed to save tokens'); + throw new Error(data.error || t('settingsAuth.failedSaveTokens')); } - setSuccess('Tokens updated. Restart CLIProxy to apply.'); + setSuccess(t('settingsAuth.tokensUpdated')); setEditedApiKey(null); setEditedSecret(null); await fetchTokens(); await fetchRawConfig(); } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : t('settings.unknownError')); } finally { setSaving(false); } @@ -143,14 +145,14 @@ export default function AuthSection() { if (!response.ok) { const data = await response.json(); - throw new Error(data.error || 'Failed to regenerate secret'); + throw new Error(data.error || t('settingsAuth.failedRegenerate')); } - setSuccess('New management secret generated. Restart CLIProxy to apply.'); + setSuccess(t('settingsAuth.secretRegenerated')); await fetchTokens(); await fetchRawConfig(); } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : t('settings.unknownError')); } finally { setSaving(false); } @@ -167,16 +169,16 @@ export default function AuthSection() { if (!response.ok) { const data = await response.json(); - throw new Error(data.error || 'Failed to reset tokens'); + throw new Error(data.error || t('settingsAuth.failedReset')); } - setSuccess('Tokens reset to defaults. Restart CLIProxy to apply.'); + setSuccess(t('settingsAuth.tokensReset')); setEditedApiKey(null); setEditedSecret(null); await fetchTokens(); await fetchRawConfig(); } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : t('settings.unknownError')); } finally { setSaving(false); } @@ -209,7 +211,7 @@ export default function AuthSection() {
- Loading... + {t('settings.loading')}
); @@ -251,31 +253,27 @@ export default function AuthSection() { {/* Scrollable Content */}
-

- Configure CLIProxy authentication tokens. Changes require CLIProxy restart. -

+

{t('settingsAuth.description')}

{/* API Key Section */}
-

API Key

+

{t('settingsAuth.apiKey')}

{tokens.apiKey.isCustom && ( - Custom + {t('settingsAuth.custom')} )}
-

- Used by Claude Code to authenticate with CLIProxy -

+

{t('settingsAuth.apiKeyDesc')}

setEditedApiKey(e.target.value)} - placeholder="API key" + placeholder={t('settingsAuth.apiKeyPlaceholder')} disabled={saving} className="pr-20 font-mono text-sm" /> @@ -310,15 +308,15 @@ export default function AuthSection() {
-

Management Secret

+

{t('settingsAuth.managementSecret')}

{tokens.managementSecret.isCustom && ( - Custom + {t('settingsAuth.custom')} )}

- Used by CCS dashboard to access CLIProxy management APIs + {t('settingsAuth.managementSecretDesc')}

@@ -326,7 +324,7 @@ export default function AuthSection() { type={showSecret ? 'text' : 'password'} value={displaySecret} onChange={(e) => setEditedSecret(e.target.value)} - placeholder="Management secret" + placeholder={t('settingsAuth.managementSecretPlaceholder')} disabled={saving} className="pr-20 font-mono text-sm" /> @@ -359,7 +357,7 @@ export default function AuthSection() { size="sm" onClick={regenerateSecret} disabled={saving} - title="Generate new secure secret" + title={t('settingsAuth.generateSecret')} > @@ -375,11 +373,9 @@ export default function AuthSection() { className="gap-2" > - Reset to Defaults + {t('settingsAuth.resetDefaults')} -

- Resets both API key and management secret to their default values. -

+

{t('settingsAuth.resetDesc')}

@@ -394,7 +390,7 @@ export default function AuthSection() { className="flex-1" > - Refresh + {t('settings.refresh')}
diff --git a/ui/src/pages/settings/sections/backups-section.tsx b/ui/src/pages/settings/sections/backups-section.tsx index 9f22ed71..2222c5b5 100644 --- a/ui/src/pages/settings/sections/backups-section.tsx +++ b/ui/src/pages/settings/sections/backups-section.tsx @@ -22,6 +22,7 @@ import { } from '@/components/ui/alert-dialog'; import { RefreshCw, CheckCircle2, AlertCircle, RotateCcw, Clock, Archive } from 'lucide-react'; import { useRawConfig } from '../hooks'; +import { useTranslation } from 'react-i18next'; /** Duration in ms before success toast auto-dismisses */ const SUCCESS_DISPLAY_DURATION_MS = 3000; @@ -39,6 +40,7 @@ interface BackupsResponse { } export default function BackupsSection() { + const { t } = useTranslation(); const { fetchRawConfig } = useRawConfig(); // AbortController refs for cleanup @@ -66,18 +68,18 @@ export default function BackupsSection() { signal: abortControllerRef.current.signal, }); if (!response.ok) { - throw new Error('Failed to fetch backups'); + throw new Error(t('settingsBackups.failedFetch')); } const data: BackupsResponse = await response.json(); setBackups(data.backups || []); } catch (err) { // Ignore abort errors if (err instanceof Error && err.name === 'AbortError') return; - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : t('settings.unknownError')); } finally { setLoading(false); } - }, []); + }, [t]); // Restore backup (wrapped in useCallback for callback stability) const restoreBackup = useCallback( @@ -98,21 +100,21 @@ export default function BackupsSection() { if (!response.ok) { const data = await response.json(); - throw new Error(data.error || 'Failed to restore backup'); + throw new Error(data.error || t('settingsBackups.failedRestore')); } - setSuccess('Backup restored successfully'); + setSuccess(t('settingsBackups.restoreSuccess')); await fetchBackups(); await fetchRawConfig(); } catch (err) { // Ignore abort errors if (err instanceof Error && err.name === 'AbortError') return; - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : t('settings.unknownError')); } finally { setRestoring(null); } }, - [fetchBackups, fetchRawConfig] + [fetchBackups, fetchRawConfig, t] ); // Load on mount @@ -205,12 +207,9 @@ export default function BackupsSection() {
-

Settings Backups

+

{t('settingsBackups.title')}

-

- Restore previous versions of your settings.json file. Backups are created - automatically when settings are modified. -

+

{t('settingsBackups.description')}

{/* Backups List */} @@ -218,9 +217,9 @@ export default function BackupsSection() {
-

No backups available

+

{t('settingsBackups.none')}

- Backups will appear here when you modify settings + {t('settingsBackups.noneHint')}

@@ -236,7 +235,7 @@ export default function BackupsSection() {

{backup.timestamp}

{index === 0 && ( - Latest + {t('settingsBackups.latest')} )}
@@ -253,7 +252,9 @@ export default function BackupsSection() { - {restoring === backup.timestamp ? 'Restoring...' : 'Restore'} + {restoring === backup.timestamp + ? t('settingsBackups.restoring') + : t('settingsBackups.restore')}
@@ -276,7 +277,7 @@ export default function BackupsSection() { className="w-full" > - Refresh + {t('settings.refresh')}
@@ -284,17 +285,17 @@ export default function BackupsSection() { setConfirmRestore(null)}> - Restore Backup? + {t('settingsBackups.restoreConfirmTitle')} - This will replace your current settings with backup from{' '} + {t('settingsBackups.restoreConfirmPrefix')}{' '} {confirmRestore} - . This action cannot be undone. + . {t('settingsBackups.restoreConfirmSuffix')} - Cancel + {t('settingsBackups.cancel')} { if (confirmRestore) { @@ -303,7 +304,7 @@ export default function BackupsSection() { setConfirmRestore(null); }} > - Restore + {t('settingsBackups.restore')} diff --git a/ui/src/pages/settings/sections/globalenv-section.tsx b/ui/src/pages/settings/sections/globalenv-section.tsx index a055f577..e7f8bd9b 100644 --- a/ui/src/pages/settings/sections/globalenv-section.tsx +++ b/ui/src/pages/settings/sections/globalenv-section.tsx @@ -11,8 +11,10 @@ import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/input'; import { RefreshCw, CheckCircle2, AlertCircle, Plus, Trash2 } from 'lucide-react'; import { useGlobalEnvConfig, useRawConfig } from '../hooks'; +import { useTranslation } from 'react-i18next'; export default function GlobalEnvSection() { + const { t } = useTranslation(); const { config, loading, @@ -46,7 +48,7 @@ export default function GlobalEnvSection() {
- Loading... + {t('settings.loading')}
); @@ -71,7 +73,7 @@ export default function GlobalEnvSection() { {success && (
- Saved + {t('settings.saved')}
)}
@@ -79,21 +81,18 @@ export default function GlobalEnvSection() { {/* Scrollable Content */}
-

- Environment variables injected into all non-Claude subscription profiles (gemini, codex, - agy, ghcp, etc.) -

+

{t('settingsGlobalEnv.description')}

{/* Enable/Disable Toggle */}

- {config?.enabled ? 'Global Env enabled' : 'Global Env disabled'} + {config?.enabled ? t('settingsGlobalEnv.enabled') : t('settingsGlobalEnv.disabled')}

{config?.enabled - ? 'Env vars will be injected into third-party profiles' - : 'Env vars will not be injected'} + ? t('settingsGlobalEnv.enabledDesc') + : t('settingsGlobalEnv.disabledDesc')}

@@ -101,7 +100,7 @@ export default function GlobalEnvSection() { {/* Current Environment Variables */}
-

Environment Variables

+

{t('settingsGlobalEnv.envVars')}

{config?.env && Object.keys(config.env).length > 0 ? (
@@ -127,18 +126,18 @@ export default function GlobalEnvSection() {
) : (
-

No environment variables configured

+

{t('settingsGlobalEnv.noneConfigured')}

)} {/* Add New Variable */}
-

Add New Variable

+

{t('settingsGlobalEnv.addNew')}

setNewEnvKey(e.target.value.toUpperCase())} - placeholder="KEY_NAME" + placeholder={t('settingsGlobalEnv.keyName')} className="flex-1 font-mono text-sm h-9" disabled={saving} /> @@ -146,7 +145,7 @@ export default function GlobalEnvSection() { setNewEnvValue(e.target.value)} - placeholder="value" + placeholder={t('settingsGlobalEnv.value')} className="flex-1 font-mono text-sm h-9" disabled={saving} /> @@ -157,14 +156,14 @@ export default function GlobalEnvSection() { className="h-9" > - Add + {t('settingsGlobalEnv.add')}
{/* Common Variables Quick Add */}
-

Quick Add Common Variables

+

{t('settingsGlobalEnv.quickAdd')}

{[ { key: 'DISABLE_BUG_COMMAND', value: '1' }, @@ -192,7 +191,7 @@ export default function GlobalEnvSection() { (k) => config.env[k] ) && ( - All common variables are configured + {t('settingsGlobalEnv.allConfigured')} )}
@@ -214,7 +213,7 @@ export default function GlobalEnvSection() { className="w-full" > - Refresh + {t('settings.refresh')}
diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index 391a865d..ce90a1d1 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -30,6 +30,7 @@ import { api } from '@/lib/api-client'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; /** LocalStorage key for debug mode preference */ const DEBUG_MODE_KEY = 'ccs_debug_mode'; @@ -42,6 +43,7 @@ function normalizeRiskAckPhrase(value: string): string { } export default function ProxySection() { + const { t } = useTranslation(); const { config, loading, @@ -98,17 +100,17 @@ export default function ProxySection() { setAgyAckBypassLoading(true); const response = await fetch('/api/settings/auth/antigravity-risk'); if (!response.ok) { - throw new Error('Failed to load AGY power user mode'); + throw new Error(t('settingsProxy.failedLoadAgyMode')); } const data = (await response.json()) as { antigravityAckBypass?: boolean }; setAgyAckBypass(data.antigravityAckBypass === true); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to load AGY power user mode'); + toast.error(err instanceof Error ? err.message : t('settingsProxy.failedLoadAgyMode')); setAgyAckBypass(false); } finally { setAgyAckBypassLoading(false); } - }, []); + }, [t]); const persistAgyAckBypass = useCallback( async (nextValue: boolean) => { @@ -130,7 +132,7 @@ export default function ProxySection() { }; if (!response.ok) { - throw new Error(payload.error || 'Failed to update AGY power user mode'); + throw new Error(payload.error || t('settingsProxy.failedUpdateAgyMode')); } const persistedValue = payload.antigravityAckBypass === true; @@ -139,29 +141,29 @@ export default function ProxySection() { cache: 'no-store', }); if (!verifyResponse.ok) { - throw new Error('Failed to verify AGY power user mode persistence'); + throw new Error(t('settingsProxy.failedVerifyAgyMode')); } const verifyData = (await verifyResponse.json()) as { antigravityAckBypass?: boolean }; const verifiedValue = verifyData.antigravityAckBypass === true; if (verifiedValue !== nextValue) { - throw new Error( - 'AGY power user mode was not persisted. Config may have been modified by another process.' - ); + throw new Error(t('settingsProxy.notPersistedAgyMode')); } setAgyAckBypass(verifiedValue && persistedValue); setShowAgyEnableConfirm(false); setAgyEnableConfirmPhrase(''); - toast.success(nextValue ? 'AGY power user mode enabled.' : 'AGY power user mode disabled.'); + toast.success( + nextValue ? t('settingsProxy.agyModeEnabled') : t('settingsProxy.agyModeDisabled') + ); await fetchRawConfig(); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to update AGY power user mode'); + toast.error(err instanceof Error ? err.message : t('settingsProxy.failedUpdateAgyMode')); } finally { agyAckBypassSavingRef.current = false; setAgyAckBypassSaving(false); } }, - [agyAckBypassSaving, fetchRawConfig, saving] + [agyAckBypassSaving, fetchRawConfig, saving, t] ); const handleAgyAckBypassChange = useCallback( @@ -182,11 +184,11 @@ export default function ProxySection() { const confirmAgyEnable = useCallback(() => { if (!isAgyConfirmPhraseValid) { - toast.error(`Type "${RISK_ACK_PHRASE}" to continue.`); + toast.error(t('settingsProxy.typePhraseToContinue', { value: RISK_ACK_PHRASE })); return; } void persistAgyAckBypass(true); - }, [isAgyConfirmPhraseValid, persistAgyAckBypass]); + }, [isAgyConfirmPhraseValid, persistAgyAckBypass, t]); // Backend state (loaded from API) + mutation hook for proper query invalidation const [backend, setBackend] = useState<'original' | 'plus'>('plus'); @@ -262,7 +264,7 @@ export default function ProxySection() {
- Loading... + {t('settings.loading')}
); @@ -308,7 +310,7 @@ export default function ProxySection() { const parsedPort = Number(portStr); if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { - toast.error('Port must be an integer between 1 and 65535, or empty for default'); + toast.error(t('settingsProxy.invalidPortOrEmpty')); setEditedPort(null); return; } @@ -340,7 +342,7 @@ export default function ProxySection() { const parsedPort = localPortStr === '' ? CLIPROXY_DEFAULT_PORT : Number(localPortStr); if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { - toast.error('Local port must be an integer between 1 and 65535'); + toast.error(t('settingsProxy.invalidLocalPort')); setEditedLocalPort(null); return; } @@ -379,7 +381,7 @@ export default function ProxySection() { {success && (
- Saved + {t('settings.saved')}
)}
@@ -388,21 +390,23 @@ export default function ProxySection() {

- Configure local or remote {backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'} connection - for proxy-based profiles + {t('settingsProxy.description', { + backend: + backend === 'plus' ? t('settingsProxy.backendPlus') : t('settingsProxy.backend'), + })}

{/* Proxy Status Widget - Quick access to start/stop controls */} {!isRemoteMode && (
-

Instance Status

+

{t('settingsProxy.instanceStatus')}

)} {/* Mode Toggle - Card based selection */}
-

Connection Mode

+

{t('settingsProxy.connectionMode')}

{/* Local Mode Card */}

- Run {backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'} binary on this machine + {t('settingsProxy.localDesc', { + backend: + backend === 'plus' + ? t('settingsProxy.backendPlus') + : t('settingsProxy.backend'), + })}

@@ -439,10 +448,15 @@ export default function ProxySection() { - Remote + {t('settingsProxy.remote')}

- Connect to a remote {backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'} server + {t('settingsProxy.remoteDesc', { + backend: + backend === 'plus' + ? t('settingsProxy.backendPlus') + : t('settingsProxy.backend'), + })}

@@ -452,14 +466,14 @@ export default function ProxySection() {

- Backend Binary + {t('settingsProxy.backendBinary')}

{/* Warning when local proxy is running - must stop to change backend (not applicable in remote mode) */} {!isRemoteMode && isProxyRunning && ( - Stop the running proxy in Instance Status to switch backend. + {t('settingsProxy.stopProxyToSwitch')} )} @@ -475,14 +489,12 @@ export default function ProxySection() { } ${isBackendSwitchBlocked ? 'opacity-60 cursor-not-allowed' : ''}`} >
- CLIProxyAPIPlus + {t('settingsProxy.backendPlusApi')} - Default + {t('settingsProxy.default')}
-

- Full provider support including Kiro and GitHub Copilot -

+

{t('settingsProxy.plusDesc')}

{/* Original Backend Card */} @@ -496,21 +508,16 @@ export default function ProxySection() { } ${isBackendSwitchBlocked ? 'opacity-60 cursor-not-allowed' : ''}`} >
- CLIProxyAPI + {t('settingsProxy.backendApi')}
-

- Original binary (Gemini, Codex, Antigravity only) -

+

{t('settingsProxy.originalDesc')}

{/* Warning when original backend selected with Kiro/ghcp variants */} {backend === 'original' && hasKiroGhcpVariants && ( - - Existing Kiro/Copilot variants will not work with CLIProxyAPI. Switch to - CLIProxyAPIPlus or remove those variants. - + {t('settingsProxy.variantsIncompatible')} )}
@@ -519,15 +526,13 @@ export default function ProxySection() {

- Safety + {t('settingsProxy.safety')}

-

Antigravity Power User Mode

-

- Skip AGY responsibility checklist in Add Account and `ccs agy` flows. -

+

{t('settingsProxy.agyModeTitle')}

+

{t('settingsProxy.agyModeDesc')}

- Use only if you fully understand the OAuth suspension/ban risk pattern (#509). CCS - cannot assume responsibility for account loss. + {t('settingsProxy.agyWarning')}

{showAgyEnableConfirm && (

- Final confirmation required + {t('settingsProxy.finalConfirm')}

- Enabling this will skip AGY safety checkpoints in both dashboard and CLI. - Review issue #509 and type the exact phrase to proceed. + {t('settingsProxy.finalConfirmDesc')}

- Step 1 + {t('settingsProxy.step1')}

- Read issue #509 + {t('settingsProxy.readIssue')}

- Step 2 + {t('settingsProxy.step2')}

- Type{' '} + {t('settingsProxy.typePrefix')}{' '} {RISK_ACK_PHRASE} {' '} - to enable. + {t('settingsProxy.typeSuffix')}

@@ -590,10 +593,10 @@ export default function ProxySection() { placeholder={RISK_ACK_PHRASE} disabled={agyAckBypassSaving || saving} className="font-mono text-xs" - aria-label="Type I ACCEPT RISK to enable Antigravity power user mode" + aria-label={t('settingsProxy.typePhraseAria')} />

- Exact phrase required. + {t('settingsProxy.exactPhrase')}

@@ -606,7 +609,7 @@ export default function ProxySection() { }} disabled={agyAckBypassSaving || saving} > - Cancel + {t('settingsBackups.cancel')}
)} - Toggle AGY power user mode + {t('settingsProxy.toggleAgyMode')}
@@ -651,14 +654,14 @@ export default function ProxySection() { {/* Fallback Settings */}
-

Fallback Settings

+

{t('settingsProxy.fallbackSettings')}

{/* Enable Fallback */}
-

Enable fallback to local

+

{t('settingsProxy.enableFallback')}

- Use local proxy if remote is unreachable + {t('settingsProxy.enableFallbackDesc')}

-

Auto-start local proxy

+

{t('settingsProxy.autoStartLocal')}

- Automatically start local proxy on fallback + {t('settingsProxy.autoStartLocalDesc')}

- Advanced + {t('settingsProxy.advanced')}

{/* Debug Mode Toggle */}
-

Debug Mode

+

{t('settingsProxy.debugMode')}

- Enable developer diagnostics in browser console + {t('settingsProxy.debugModeDesc')}

{debugMode && (

- Debug mode enabled. Check browser console for detailed logs. + {t('settingsProxy.debugModeEnabled')}

)}
@@ -748,7 +751,7 @@ export default function ProxySection() { className="w-full" > - Refresh + {t('settings.refresh')}
diff --git a/ui/src/pages/settings/sections/proxy/local-proxy-card.tsx b/ui/src/pages/settings/sections/proxy/local-proxy-card.tsx index 9f942f4c..d9d25185 100644 --- a/ui/src/pages/settings/sections/proxy/local-proxy-card.tsx +++ b/ui/src/pages/settings/sections/proxy/local-proxy-card.tsx @@ -7,6 +7,7 @@ import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/input'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; import type { CliproxyServerConfig } from '../../types'; +import { useTranslation } from 'react-i18next'; interface LocalProxyCardProps { config: CliproxyServerConfig; @@ -25,15 +26,16 @@ export function LocalProxyCard({ onSaveLocalPort, onSaveConfig, }: LocalProxyCardProps) { + const { t } = useTranslation(); const localConfig = config.local; return (
-

Local Proxy

+

{t('settingsProxy.localProxy')}

{/* Port */}
- +
-

Auto-start

-

- Start local proxy automatically when needed -

+

{t('settingsProxy.autoStart')}

+

{t('settingsProxy.localAutoStartDesc')}

- Remote Server Configuration + {t('settingsProxy.remoteConfigTitle')}

{/* Host */}
- + setEditedHost(e.target.value)} onBlur={onSaveHost} - placeholder="192.168.1.100 or proxy.example.com" + placeholder={t('settingsProxy.hostPlaceholder')} className="font-mono" disabled={saving} /> @@ -87,9 +89,13 @@ export function RemoteProxyCard({
setEditedPort(e.target.value.replace(/\D/g, ''))} onBlur={onSavePort} - placeholder={`Leave empty for ${getDefaultPort(config.remote.protocol || 'http')}`} + placeholder={t('settingsProxy.portPlaceholder', { + value: getDefaultPort(config.remote.protocol || 'http'), + })} className="font-mono" disabled={saving} />
- +
@@ -125,36 +133,34 @@ export function RemoteProxyCard({ {/* Auth Token (API Key) */}
- + setEditedAuthToken(e.target.value)} onBlur={onSaveAuthToken} - placeholder="For /v1/* API endpoints" + placeholder={t('settingsProxy.apiKeyPlaceholder')} className="font-mono" disabled={saving} /> -

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

+

{t('settingsProxy.apiKeyDesc')}

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

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

+

{t('settingsProxy.managementKeyDesc')}

{/* Test Connection */} @@ -168,12 +174,12 @@ export function RemoteProxyCard({ {testing ? ( <> - Testing... + {t('settingsProxy.testing')} ) : ( <> - Test Connection + {t('settingsProxy.testConnection')} )} @@ -192,14 +198,14 @@ export function RemoteProxyCard({ <> - Connected ({testResult.latencyMs}ms) + {t('settingsProxy.connectedLatency', { value: testResult.latencyMs })} ) : ( <> - {testResult.error || 'Connection failed'} + {testResult.error || t('settingsProxy.connectionFailed')} )} diff --git a/ui/src/pages/settings/sections/thinking/index.tsx b/ui/src/pages/settings/sections/thinking/index.tsx index a4b09ea2..bfe4f13e 100644 --- a/ui/src/pages/settings/sections/thinking/index.tsx +++ b/ui/src/pages/settings/sections/thinking/index.tsx @@ -20,6 +20,7 @@ import { import { RefreshCw, CheckCircle2, AlertCircle, Brain, Info, ChevronDown } from 'lucide-react'; import { useThinkingConfig } from '../../hooks'; import type { ThinkingMode } from '../../types'; +import { useTranslation } from 'react-i18next'; const THINKING_LEVELS = [ { value: 'minimal', label: 'Minimal (512 tokens)' }, @@ -42,6 +43,7 @@ const THINKING_BUDGET_MIN = 0; const THINKING_BUDGET_MAX = 100000; export default function ThinkingSection() { + const { t } = useTranslation(); const { config, loading, @@ -125,7 +127,7 @@ export default function ThinkingSection() {
- Loading... + {t('settings.loading')}
); @@ -156,7 +158,7 @@ export default function ThinkingSection() { className="h-7 px-2 text-xs border-destructive/50 hover:bg-destructive/10" > - Retry + {t('sharedPage.retry')}
@@ -164,7 +166,7 @@ export default function ThinkingSection() { {success && (
- Saved + {t('settings.saved')}
)}
@@ -174,27 +176,28 @@ export default function ThinkingSection() {
-

- Configure extended thinking/reasoning for supported models. -

+

{t('settingsThinking.description')}

{/* U4: Provider support indicator */}
-

Supported Providers

+

{t('settingsThinking.supportedProviders')}

  • - Thinking budget: agy, gemini (token-based) + {t('settingsThinking.supportLine1Prefix')} agy,{' '} + gemini {t('settingsThinking.supportLine1Suffix')}
  • - Reasoning effort: codex (suffix or --effort: - medium/high/xhigh) + {t('settingsThinking.supportLine2Prefix')} codex{' '} + {t('settingsThinking.supportLine2SuffixPrefix')} + --effort + {t('settingsThinking.supportLine2SuffixPostfix')}
  • - Codex suffixes pin effort (for example -high); unsuffixed models use - Thinking mode. + {t('settingsThinking.supportLine3Prefix')} -high + {t('settingsThinking.supportLine3Suffix')}
@@ -202,7 +205,7 @@ export default function ThinkingSection() { {/* Mode Selection */}
-

Thinking Mode

+

{t('settingsThinking.modeTitle')}

{(['auto', 'off', 'manual'] as ThinkingMode[]).map((mode) => (

{mode}

- {mode === 'auto' && 'Automatically set thinking based on model tier'} - {mode === 'off' && 'Disable extended thinking'} - {mode === 'manual' && - 'Set a persistent override level or use CLI flags per run'} + {mode === 'auto' && t('settingsThinking.modeAutoDesc')} + {mode === 'off' && t('settingsThinking.modeOffDesc')} + {mode === 'manual' && t('settingsThinking.modeManualDesc')}

-

Tier Defaults

+

{t('settingsThinking.tierDefaults')}

- Default thinking level for each model tier when in auto mode. + {t('settingsThinking.tierDefaultsDesc')}

@@ -276,9 +278,9 @@ export default function ThinkingSection() { {/* Manual Override (shown when mode is manual) */} {config.mode === 'manual' && (
-

Persistent Override

+

{t('settingsThinking.persistentOverride')}

- Applied to all sessions. CLI flags still take priority. + {t('settingsThinking.persistentOverrideDesc')}

setCustomProviderInput(event.target.value)} disabled={saving} - placeholder="Add provider key (e.g. qwen)" + placeholder={t('settingsThinking.addProviderPlaceholder')} />
{providerKeys.map((provider) => ( @@ -404,7 +408,9 @@ export default function ThinkingSection() { - Default + + {t('cursorPage.default')} + {THINKING_LEVELS.map((level) => ( {level.label} @@ -425,9 +431,9 @@ export default function ThinkingSection() { {/* Show Warnings Toggle */}
-

Show Warnings

+

{t('settingsThinking.showWarnings')}

- Display warnings when thinking values are clamped or adjusted + {t('settingsThinking.showWarningsDesc')}

-

CLI & Env Override

+

{t('settingsThinking.cliEnvOverride')}

Override per session with flags or{' '} CCS_THINKING env var. @@ -465,7 +471,7 @@ export default function ThinkingSection() { className="w-full" > - Refresh + {t('settings.refresh')}

diff --git a/ui/src/pages/settings/sections/websearch/index.tsx b/ui/src/pages/settings/sections/websearch/index.tsx index 7b061fd8..537b38fa 100644 --- a/ui/src/pages/settings/sections/websearch/index.tsx +++ b/ui/src/pages/settings/sections/websearch/index.tsx @@ -10,8 +10,10 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { RefreshCw, CheckCircle2, AlertCircle, Package } from 'lucide-react'; import { useWebSearchConfig, useRawConfig } from '../../hooks'; import { ProviderCard } from './provider-card'; +import { useTranslation } from 'react-i18next'; export default function WebSearchSection() { + const { t } = useTranslation(); const { config, status, @@ -83,7 +85,7 @@ export default function WebSearchSection() {
- Loading... + {t('settings.loading')}
); @@ -108,7 +110,7 @@ export default function WebSearchSection() { {success && (
- Saved + {t('settings.saved')}
)}
@@ -116,18 +118,16 @@ export default function WebSearchSection() { {/* Scrollable Content */}
-

- CLI-based web search for third-party profiles (gemini, codex, agy, etc.) -

+

{t('settingsWebsearch.description')}

{/* Status Summary */}

- {isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'} + {isGeminiEnabled ? t('settingsWebsearch.enabled') : t('settingsWebsearch.disabled')}

{statusLoading ? ( -

Checking status...

+

{t('settingsWebsearch.checking')}

) : status?.readiness ? (

{status.readiness.message}

) : null} @@ -139,19 +139,21 @@ export default function WebSearchSection() { {/* CLI Providers */}
-

Providers

+

{t('settingsWebsearch.providers')}

{/* Empty state when no providers available */} {!status?.geminiCli && !status?.opencodeCli && !status?.grokCli && !statusLoading && (
-

No providers configured

+

+ {t('settingsWebsearch.noneConfigured')} +

- Install CLI tools to enable web search providers + {t('settingsWebsearch.noneConfiguredHint')}

)} @@ -233,7 +235,7 @@ export default function WebSearchSection() { className="w-full" > - Refresh + {t('settings.refresh')}
diff --git a/ui/src/pages/settings/sections/websearch/provider-card.tsx b/ui/src/pages/settings/sections/websearch/provider-card.tsx index 5792d680..c28977de 100644 --- a/ui/src/pages/settings/sections/websearch/provider-card.tsx +++ b/ui/src/pages/settings/sections/websearch/provider-card.tsx @@ -6,6 +6,7 @@ import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/input'; import { Terminal, ExternalLink, ChevronDown, ChevronUp, Check } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; export interface ProviderCardProps { name: string; @@ -50,6 +51,7 @@ export function ProviderCard({ docsUrl, hintColor, }: ProviderCardProps) { + const { t } = useTranslation(); const badgeClass = badgeColor === 'green' ? 'bg-green-500/10 text-green-600' @@ -98,11 +100,11 @@ export function ProviderCard({ {installed ? ( - installed + {t('settingsWebsearch.installed')} ) : ( - not installed + {t('settingsWebsearch.notInstalled')} )}
@@ -116,7 +118,9 @@ export function ProviderCard({ {enabled && modelInput !== undefined && setModelInput && onModelBlur && (
- + setModelInput(e.target.value)} @@ -128,7 +132,7 @@ export function ProviderCard({ {modelSaved && ( - Saved + {t('settings.saved')} )}
@@ -143,13 +147,16 @@ export function ProviderCard({ className={`flex items-center gap-2 text-sm hover:underline w-full py-2 ${hintTextClass}`} > {showHint ? : } - How to install {displayName} CLI + {t('settingsWebsearch.howInstall', { name: displayName })} {showHint && (

- Install globally{' '} - {badge === 'GROK_API_KEY' ? '(requires xAI API key)' : '(FREE tier available)'}: + {t('settingsWebsearch.installGlobally')}{' '} + {badge === 'GROK_API_KEY' + ? t('settingsWebsearch.requiresKey') + : t('settingsWebsearch.freeTier')} + :

{installCmd} @@ -161,7 +168,7 @@ export function ProviderCard({ className="hover:underline inline-flex items-center gap-1" > - View documentation + {t('settingsWebsearch.viewDocs')}
)} diff --git a/ui/src/pages/shared.tsx b/ui/src/pages/shared.tsx index 836816d9..8a848519 100644 --- a/ui/src/pages/shared.tsx +++ b/ui/src/pages/shared.tsx @@ -8,6 +8,8 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useSharedItemContent, useSharedItems, useSharedSummary } from '@/hooks/use-shared'; import { cn } from '@/lib/utils'; +import '@/lib/i18n'; +import { useTranslation } from 'react-i18next'; import { AlertCircle, AlertTriangle, @@ -21,14 +23,15 @@ import { type TabType = 'commands' | 'skills' | 'agents'; -const tabLabels: Record = { - commands: 'Commands', - skills: 'Skills', - agents: 'Agents', -}; - export function SharedPage() { + const { t } = useTranslation(); const [tab, setTab] = useState('commands'); + const tabLabels: Record = { + commands: t('sharedPage.commands'), + skills: t('sharedPage.skills'), + agents: t('sharedPage.agents'), + }; + const [query, setQuery] = useState(''); const [selectedItemPath, setSelectedItemPath] = useState(null); @@ -105,10 +108,8 @@ export function SharedPage() {
-

Shared Data

-

- Commands, skills, and agents shared across Claude instances -

+

{t('sharedPage.title')}

+

{t('sharedPage.subtitle')}

- + - +
- Markdown detail view - {activeQuery ? Filter: {activeQuery} : null} + {t('sharedPage.markdownDetail')} + {activeQuery ? ( + + {t('sharedPage.filterPrefix')} {activeQuery} + + ) : null}
@@ -148,7 +153,7 @@ export function SharedPage() { {summary && !summary.symlinkStatus.valid && ( - Configuration Required + {t('sharedPage.configurationRequired')} {summary.symlinkStatus.message}. Run `ccs sync` to configure. @@ -158,7 +163,7 @@ export function SharedPage() { {isSummaryError && ( - Counts unavailable + {t('sharedPage.countsUnavailable')}

{summaryErrorMessage}

@@ -170,7 +175,7 @@ export function SharedPage() { }} > - Retry counts + {t('sharedPage.retryCounts')}
@@ -200,30 +205,38 @@ export function SharedPage() { setQuery(event.target.value)} - placeholder={`Filter ${tab} by name, description, or path`} - aria-label={`Filter ${tab} by name, description, or path`} + placeholder={t('sharedPage.filterPlaceholder', { tab })} + aria-label={t('sharedPage.filterPlaceholder', { tab })} className="pl-8 h-9" />
{!isLoading && !isError && (

- Showing {filteredItems.length} of {allItems.length} {tab} - {activeQuery ? ` for "${activeQuery}"` : ''} - {isFetching ? ' (refreshing...)' : ''} + {t('sharedPage.showing', { + visible: filteredItems.length, + total: allItems.length, + tab, + })} + {activeQuery ? t('sharedPage.showingQuery', { query: activeQuery }) : ''} + {isFetching ? t('sharedPage.refreshing') : ''}

)}
{isLoading ? ( -
Loading shared {tab}...
+
+ {t('sharedPage.loadingShared', { tab })} +
) : isError ? (
-

Failed to load shared {tab}

+

+ {t('sharedPage.failedLoadShared', { tab })} +

{itemsErrorMessage}

) : hasNoItems ? (
- No shared {tab} found. Run `ccs sync` or add items in your shared directory. + {t('sharedPage.noSharedFound', { tab })}
) : hasNoMatches ? (
- No {tab} match "{activeQuery}". + {t('sharedPage.noMatch', { tab, query: activeQuery })}
) : (
@@ -277,7 +290,7 @@ export function SharedPage() {
{!selectedItem ? (
- Select a {tab.slice(0, -1)} to view full content. + {t('sharedPage.selectOne', { tab: tab.slice(0, -1) })}
) : ( <> @@ -293,11 +306,15 @@ export function SharedPage() {
- + {selectedItemContent?.contentPath && selectedItemContent.contentPath !== selectedItem.path && ( @@ -310,12 +327,12 @@ export function SharedPage() { {isContentLoading ? (

- Loading markdown content... + {t('sharedPage.loadingMarkdown')}

) : isContentError ? ( - Failed to load content + {t('sharedPage.failedLoadContent')}

{contentErrorMessage}

@@ -327,7 +344,7 @@ export function SharedPage() { }} > - Retry content + {t('sharedPage.retryContent')}
@@ -433,10 +450,11 @@ interface ParsedMarkdownDocument { } function MarkdownViewer({ content }: { content: string }) { + const { t } = useTranslation(); const parsedDocument = useMemo(() => parseMarkdownDocument(content), [content]); if (parsedDocument.blocks.length === 0 && parsedDocument.frontmatter.length === 0) { - return

No markdown content available.

; + return

{t('sharedPage.noMarkdown')}

; } return ( diff --git a/ui/src/pages/updates.tsx b/ui/src/pages/updates.tsx index 2ce8c9d1..770c3d61 100644 --- a/ui/src/pages/updates.tsx +++ b/ui/src/pages/updates.tsx @@ -17,15 +17,10 @@ import { writeNoticeProgressMap, type NoticeProgressMap, } from '@/lib/updates-notice-state'; +import { useTranslation } from 'react-i18next'; type NoticeViewMode = 'inbox' | 'done' | 'all'; -const NOTICE_VIEW_MODES: { id: NoticeViewMode; label: string }[] = [ - { id: 'inbox', label: 'Action Required' }, - { id: 'done', label: 'Done' }, - { id: 'all', label: 'All' }, -]; - function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean { if (!queryValue) { return true; @@ -49,6 +44,7 @@ function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean } export function UpdatesPage() { + const { t } = useTranslation(); const notices = useMemo( () => [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)), [] @@ -112,20 +108,18 @@ export function UpdatesPage() {
-

Updates Inbox

+

{t('updates.inboxTitle')}

-

- Focus on actions, then mark updates done or dismissed. -

+

{t('updates.inboxSubtitle')}

-

Needs Action

+

{t('updates.needsAction')}

{pendingCount}

-

Done

+

{t('updates.doneCount')}

{doneCount}

@@ -135,13 +129,17 @@ export function UpdatesPage() { setQuery(event.target.value)} - placeholder="Search actions or commands" + placeholder={t('updates.searchPlaceholder')} className="h-9 pl-8" />
- {NOTICE_VIEW_MODES.map((mode) => ( + {[ + { id: 'inbox' as const, label: t('updates.actionRequired') }, + { id: 'done' as const, label: t('updates.done') }, + { id: 'all' as const, label: t('updates.all') }, + ].map((mode) => (