Merge pull request #674 from kaitranntt/dev

chore(release): sync dev to main v7.51.0
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-03-03 13:36:25 -05:00
commit d7d9eb0891
171 files changed
+10582 -1968

No files matched your search

+13 -4
View File
@@ -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 }}
+4 -3
View File
@@ -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 }}
+2 -1
View File
@@ -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: |
+23 -1
View File
@@ -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) |
+115
View File
@@ -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:
- `<key>_one`
- `<key>_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 `<Trans />` 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 `<locale>.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.
+4 -4
View File
@@ -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
}
+1
View File
@@ -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
@@ -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 <profile>` 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
+3 -1
View File
@@ -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.
```
+1 -1
View File
@@ -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",
+39
View File
@@ -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()) {
+164
View File
@@ -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<string, string> {
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<string, string> = {};
for (const [profileName, accountName] of Object.entries(
parsed.continuity_inherit_from_account as Record<string, unknown>
)) {
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, string>
): string | undefined {
const candidates = new Set<string>(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<ProfileContinuityInheritanceResult> {
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 {};
}
}
+72 -4
View File
@@ -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<void> {
// 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<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {
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<void> {
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;
+76 -3
View File
@@ -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<AccountInfo | null> {
// 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<Accou
resolve(registerAccountFromToken(provider, tokenDir, nickname));
} else {
const failureReason = extractLikelyAuthFailureFromStderr(provider, state.stderrData);
// Emit device code failure event for UI
if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
deviceCodeEvents.emit('deviceCode:failed', {
sessionId: state.sessionId,
error: 'Token not found after authentication',
error: failureReason || 'Token not found after authentication',
});
}
@@ -484,7 +556,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
callbackPort,
tokenDir,
nickname,
verbose
verbose,
failureReason || undefined
);
resolve(account);
}
+14 -3
View File
@@ -5,6 +5,7 @@ import type { CLIProxyProvider } from './types';
import type { ModelEntry, ProviderCatalog, ThinkingSupport } from './model-catalog';
import { MODEL_CATALOG } from './model-catalog';
import type { RemoteModelInfo, RemoteThinkingSupport } from './management-api-types';
import { getDeniedModelIdReasonForProvider } from './model-id-normalizer';
const CACHE_FILE_NAME = 'model-catalog-cache.json';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -143,11 +144,18 @@ export function mergeCatalog(
provider: CLIProxyProvider,
remoteModels: RemoteModelInfo[]
): ProviderCatalog | undefined {
const filteredRemoteModels =
provider === 'agy'
? remoteModels.filter(
(remoteModel) => !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<string, ModelEntry>();
@@ -161,7 +169,7 @@ export function mergeCatalog(
const mergedIds = new Set<string>();
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);
}
+14
View File
@@ -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`;
}
+82 -17
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<string, string>;
@@ -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<string, string>;
+18 -8
View File
@@ -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);
+4
View File
@@ -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<string,
isComposite,
compositeTiers,
compositeDefaultTier,
claudeConfigDir,
} = config;
// Build base env vars - check remote mode first
@@ -264,6 +267,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
const mergedEnv = {
...baseEnv,
...effectiveEnvVarsFiltered,
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
...webSearchEnv,
...imageAnalysisEnv,
CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider
+18
View File
@@ -53,6 +53,7 @@ import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unifi
import { installImageAnalyzerHook } from '../../utils/hooks';
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
import { isKiroAuthMethod, KiroAuthMethod, normalizeKiroAuthMethod } from '../auth/auth-types';
import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance';
// Import modular components
import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager';
@@ -795,6 +796,21 @@ export async function execClaudeWithCLIProxy(
// 9. Setup tool sanitization proxy
let toolSanitizationProxy: ToolSanitizationProxy | null = null;
let toolSanitizationPort: number | null = null;
let inheritedClaudeConfigDir = cfg.claudeConfigDir;
if (!inheritedClaudeConfigDir && cfg.profileName) {
const continuityInheritance = await resolveProfileContinuityInheritance({
profileName: cfg.profileName,
profileType: 'cliproxy',
target: 'claude',
});
inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
log(
`Continuity inheritance active: profile "${cfg.profileName}" -> 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) {
+17 -41
View File
@@ -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<Record<CLIProxyProvider, ProviderCatalog>> =
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()));
}
+12 -10
View File
@@ -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<voi
const currentModel = getCurrentModel(provider);
const settingsPath = getProviderSettingsPath(provider);
const normalizedCurrentModel = currentModel
? stripCodexEffortSuffix(currentModel, provider)
? canonicalizeModelForProvider(provider, currentModel)
: undefined;
console.error('');
+141 -6
View File
@@ -20,6 +20,44 @@ type ProviderLike = CLIProxyProvider | string | null | undefined;
const CLAUDE_DOTTED_VERSION_REGEX = /claude-(sonnet|opus|haiku)-(\d+)\.(\d+)(?=(?:$|-|\[|\(|\/))/gi;
const CLAUDE_DOTTED_THINKING_REGEX =
/claude-(sonnet|opus|haiku)-(\d+)\.(\d+)-thinking(?=(?:$|-|\[|\(|\/))/gi;
const DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX =
/claude-sonnet-4(?:[.-])6-thinking(?=(?:$|-|\[|\(|\/))/gi;
const CANONICAL_ANTIGRAVITY_SONNET_46_MODEL = 'claude-sonnet-4-6';
const DENIED_ANTIGRAVITY_MODEL_REGEX =
/claude-(?:opus|sonnet)-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/i;
const DENIED_ANTIGRAVITY_OPUS_45_REGEX =
/claude-opus-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi;
const DENIED_ANTIGRAVITY_SONNET_45_REGEX =
/claude-sonnet-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi;
const CANONICAL_ANTIGRAVITY_OPUS_46_MODEL = 'claude-opus-4-6-thinking';
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
const IFLOW_LEGACY_MODEL_ALIASES: Readonly<Record<string, string>> = 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,
+35 -6
View File
@@ -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,
+5 -2
View File
@@ -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);
}
/**
+44 -3
View File
@@ -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<CliproxyStats |
return null;
}
const data = (await response.json()) as UsageApiResponse;
const data = (await response.json()) as CliproxyUsageApiResponse;
const usage = data.usage;
// Extract models, providers, and per-account stats from the nested API structure
@@ -210,6 +213,44 @@ export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats |
}
}
/**
* Fetch raw usage response from CLIProxyAPI management API
* Returns the unprocessed API response for transformation by cliproxy-usage-transformer
*/
export async function fetchCliproxyUsageRaw(
port?: number
): Promise<CliproxyUsageApiResponse | null> {
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;
+14 -1
View File
@@ -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(
+2
View File
@@ -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;
}
/**
+2
View File
@@ -659,6 +659,8 @@ async function showHelp(): Promise<void> {
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')}`);
+2 -2
View File
@@ -160,10 +160,10 @@ export async function handleConfigCommand(args: string[]): Promise<void> {
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}`;
+9 -1
View File
@@ -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 <name>', '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)'],
+75
View File
@@ -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<string, string> | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined;
}
const normalized: Record<string, string> = {};
for (const [profileName, accountName] of Object.entries(value as Record<string, unknown>)) {
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<UnifiedConfig>): ContinuityConfig | undefined {
const legacyMap = normalizeContinuityInheritanceMap(
(partial as Partial<UnifiedConfig> & { 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>): 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>): 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<string, string> {
const config = loadOrCreateUnifiedConfig();
return config.continuity?.inherit_from_account ?? {};
}
/**
* Get cliproxy safety configuration.
* Returns defaults if not configured.
+11
View File
@@ -418,6 +418,15 @@ export interface GlobalEnvConfig {
env: Record<string, string>;
}
/**
* 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<string, string>;
}
/**
* 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) */
+8 -3
View File
@@ -39,7 +39,10 @@ export async function getCopilotStatus(config: CopilotConfig): Promise<CopilotSt
* Generate environment variables for Claude Code to use copilot-api.
* Uses model mapping for opus/sonnet/haiku tiers if configured.
*/
export function generateCopilotEnv(config: CopilotConfig): Record<string, string> {
export function generateCopilotEnv(
config: CopilotConfig,
claudeConfigDir?: string
): Record<string, string> {
// 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<string, string
// Disable non-essential traffic to avoid rate limiting
DISABLE_NON_ESSENTIAL_MODEL_CALLS: '1',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
};
}
@@ -71,7 +75,8 @@ export function generateCopilotEnv(config: CopilotConfig): Record<string, string
*/
export async function executeCopilotProfile(
config: CopilotConfig,
claudeArgs: string[]
claudeArgs: string[],
claudeConfigDir?: string
): Promise<number> {
// 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();
+5 -2
View File
@@ -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,
+14
View File
@@ -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
*/
+19 -1
View File
@@ -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<Record<string, ProviderPresetId>> = 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',
+2
View File
@@ -51,6 +51,8 @@ export interface Config {
profile_targets?: Record<string, TargetType>;
/** User-defined CLIProxy profile variants (optional) */
cliproxy?: CLIProxyVariantsConfig;
/** Legacy continuity inheritance mapping (profile -> source account) */
continuity_inherit_from_account?: Record<string, string>;
}
/**
+20 -2
View File
@@ -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)
+2
View File
@@ -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<ServerInstanc
const cleanup = () => {
wsCleanup();
stopAutoSyncWatcher().catch(() => {});
shutdownUsageAggregator();
};
// Start listening
+54 -1
View File
@@ -151,6 +151,19 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
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<string, ModelPricing> = {
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<string, ModelPricing> = Object.entries(
PRICING_REGISTRY
).reduce<Record<string, ModelPricing>>((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 {
+12 -18
View File
@@ -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<voi
},
});
} else {
res.status(400).json({ error: 'Authentication failed or was cancelled' });
res.status(400).json({
error: getStartAuthFailureMessage(provider as CLIProxyProvider),
});
}
} catch (error) {
respondInternalError(res, error, 'Failed to start OAuth flow.');
+131 -88
View File
@@ -51,6 +51,11 @@ import {
} from '../../cliproxy/platform-detector';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
import {
MODEL_ENV_VAR_KEYS,
canonicalizeModelIdForProvider,
getDeniedModelIdReasonForProvider,
} from '../../cliproxy/model-id-normalizer';
const router = Router();
@@ -72,6 +77,16 @@ function buildQuotaRateLimitKey(req: Request, provider: string): string {
function isQuotaRouteRateLimited(req: Request, provider: string): boolean {
const key = buildQuotaRateLimitKey(req, provider);
const now = Date.now();
// Evict stale entries to prevent unbounded memory growth
if (quotaRateLimits.size > 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<void> =
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<void> => {
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<void>
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<void>
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<void> =
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<void>
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<void> => {
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<void> =>
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<voi
res.type('text/plain').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' });
}
});
@@ -462,7 +447,8 @@ router.get('/config.yaml', async (_req: Request, res: Response): Promise<void> =
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<void> =>
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<void> =>
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<void> => {
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<voi
return;
}
if (model.length > 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<voi
}
// Read and update settings
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
settings.env = settings.env || {};
settings.env.ANTHROPIC_MODEL = model;
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
env?: Record<string, unknown>;
[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<void> => {
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<void> => {
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<void> => {
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' });
}
});
+18 -18
View File
@@ -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 });
}
+7 -19
View File
@@ -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 });
}
});
+41 -38
View File
@@ -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<CliproxyServerConfig>;
// 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);
+197 -14
View File
@@ -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 | undefined>
): 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 };
}
+178 -50
View File
@@ -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<Settings, 'env'>
): 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) {
+1 -18
View File
@@ -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
+95 -13
View File
@@ -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<T>(key: string, ttl: number, loader: () => Promise<
/** Cached loader for daily usage data */
export async function getCachedDailyData(): Promise<DailyUsage[]> {
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<MonthlyUsage[]> {
return getCachedData('monthly', CACHE_TTL.monthly, async () => {
return await loadMonthlyUsageData();
return (await refreshFromSourceCoalesced()).monthly;
});
}
/** Cached loader for session data */
export async function getCachedSessionData(): Promise<SessionUsage[]> {
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<HourlyUsage[]> {
return getCachedData('hourly', CACHE_TTL.daily, async () => {
return await loadHourlyUsageData();
return (await refreshFromSourceCoalesced()).hourly;
});
}
@@ -457,9 +516,12 @@ export async function getCachedHourlyData(): Promise<HourlyUsage[]> {
*/
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<void> {
// Ensure periodic sync is running for subsequent updates.
startCliproxySync();
await refreshFromSourceCoalesced(true);
}
@@ -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<typeof setInterval> | 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<void> {
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;
}
}
@@ -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<T>(
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<string, Map<string, ModelAccumulator>>();
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<string, ModelAccumulator>;
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)
);
}
+4 -1
View File
@@ -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
+8 -4
View File
@@ -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<void> {
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 {
@@ -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);
});
});
+21
View File
@@ -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');
});
});
@@ -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<typeof configLoader.loadOrCreateUnifiedConfig>);
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<typeof configLoader.loadOrCreateUnifiedConfig>);
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<typeof configLoader.loadOrCreateUnifiedConfig>);
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<typeof configLoader.loadOrCreateUnifiedConfig>);
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<typeof configLoader.loadOrCreateUnifiedConfig>);
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<typeof configLoader.loadOrCreateUnifiedConfig>);
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<typeof configLoader.loadOrCreateUnifiedConfig>);
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<typeof configLoader.loadOrCreateUnifiedConfig>);
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<typeof configLoader.loadOrCreateUnifiedConfig>);
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<typeof configLoader.loadOrCreateUnifiedConfig>);
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({});
});
});
@@ -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');
});
});
@@ -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:
+40 -3
View File
@@ -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'
);
});
});
});
@@ -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<string, string>;
};
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<string, string>;
presets?: Array<Record<string, string>>;
};
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<string, string>;
presets: Array<Record<string, string>>;
};
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<string, string>;
presets?: Array<Record<string, string>>;
};
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<string, string>;
presets?: Array<Record<string, string>>;
};
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');
@@ -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');
});
});
@@ -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');
});
});
+43 -56
View File
@@ -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);
});
});
});
@@ -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');
});
});
});
@@ -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);
});
});
@@ -201,9 +201,7 @@ describe('ToolSanitizationProxy Integration', () => {
});
expect(lastRequest).not.toBeNull();
expect((lastRequest!.body as Record<string, unknown>).model).toBe(
'claude-sonnet-4-6-thinking'
);
expect((lastRequest!.body as Record<string, unknown>).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<string, unknown>).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<string, unknown>).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}`,
@@ -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',
@@ -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();
});
});
+63
View File
@@ -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);
});
+78
View File
@@ -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 });
}
});
});
@@ -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');
});
});
@@ -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');
});
});
@@ -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<string, string>): 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<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
const onError = (error: Error) => reject(error);
server.once('error', onError);
server.once('listening', () => {
server.off('error', onError);
resolve();
});
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-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<string, string>;
};
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<string, string>;
};
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<string, string>;
};
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<string, string>;
};
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');
});
});
@@ -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();
});
});
@@ -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);
});
});
@@ -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<string, string>;
};
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<string, string>;
};
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<string, string>;
};
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<string, string>;
};
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');
});
});
@@ -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<string, unknown>): 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<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
const onError = (error: Error) => reject(error);
server.once('error', onError);
server.once('listening', () => {
server.off('error', onError);
resolve();
});
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-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<string, string>;
presets: Array<Record<string, string>>;
};
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<string, string>;
presets: Array<Record<string, string>>;
};
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<string, string> };
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<Record<string, string>>;
};
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<Record<string, string>> };
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<string, string>;
presets: Array<Record<string, string>>;
};
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<string, string>;
presets: Array<Record<string, string>>;
};
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<string, string>;
};
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<Record<string, string>> };
expect(body.presets.length).toBe(0);
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
presets: Array<Record<string, string>>;
};
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<Record<string, unknown>> };
};
expect(body.settings.presets[0]?.default).toBe(123);
expect(body.settings.presets[0]?.sonnet).toBe('claude-sonnet-4-6');
});
});
@@ -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();
});
});
+55 -65
View File
@@ -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`).
+12
View File
@@ -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=="],
+2
View File
@@ -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",
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AlibabaCloud</title><path d="M14.752 4.64h5.274C22.242 4.64 24 6.475 24 8.691V15.8a3.947 3.947 0 01-3.974 3.975h-5.274l1.299-1.835 3.822-1.222c.688-.23 1.146-.918 1.146-1.605v-5.81c0-.687-.458-1.375-1.146-1.605L16.05 6.475l-1.3-1.835zM2.98 15.111c0 .688.46 1.376 1.147 1.606l3.822 1.146 1.3 1.835H3.974A3.947 3.947 0 010 15.723V8.69c0-2.216 1.758-4.05 3.975-4.05h5.273L7.95 6.474 4.127 7.697c-.688.23-1.146.918-1.146 1.606v5.808z" fill="#FF6A00"></path><path d="M16.051 11.213H8.025v1.835h8.026v-1.835z" fill="#FF6A00"></path></svg>

After

Width:  |  Height:  |  Size: 656 B

+1
View File
@@ -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';
@@ -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 <code className="font-mono">ccs agy</code>{' '}
and shared-account usage between <code className="font-mono">ccs gemini</code> and{' '}
<code className="font-mono">ccs agy</code>.
</>
);
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 (
<section
+40 -28
View File
@@ -25,6 +25,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Check, CheckCheck, Pencil, RotateCcw, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { EditAccountContextDialog } from '@/components/account/edit-account-context-dialog';
import {
useSetDefaultAccount,
@@ -40,6 +41,7 @@ interface AccountsTableProps {
}
export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
const { t } = useTranslation();
const setDefaultMutation = useSetDefaultAccount();
const deleteMutation = useDeleteAccount();
const resetDefaultMutation = useResetDefaultAccount();
@@ -50,14 +52,14 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
const columns: ColumnDef<Account>[] = [
{
accessorKey: 'name',
header: 'Name',
header: t('accountsTable.name'),
size: 200,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span className="font-medium">{row.original.name}</span>
{row.original.name === defaultAccount && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded border border-primary/20">
default
{t('accountsTable.defaultBadge')}
</span>
)}
</div>
@@ -65,7 +67,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
},
{
accessorKey: 'type',
header: 'Type',
header: t('accountsTable.type'),
size: 100,
cell: ({ row }) => (
<span className="capitalize text-muted-foreground">{row.original.type || 'oauth'}</span>
@@ -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 <span className="text-muted-foreground/50">-</span>;
@@ -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 <span className="text-muted-foreground">shared ({group}, deeper)</span>;
return (
<span className="text-muted-foreground">
{t('accountsTable.sharedGroupDeeper', { group })}
</span>
);
}
if (row.original.continuity_inferred) {
return (
<span className="text-amber-700 dark:text-amber-400">
shared ({group}, standard legacy)
{t('accountsTable.sharedGroupLegacy', { group })}
</span>
);
}
return <span className="text-muted-foreground">shared ({group}, standard)</span>;
return (
<span className="text-muted-foreground">
{t('accountsTable.sharedGroupStandard', { group })}
</span>
);
}
if (row.original.context_inferred) {
return (
<span className="text-amber-700 dark:text-amber-400">isolated (legacy default)</span>
<span className="text-amber-700 dark:text-amber-400">
{t('accountsTable.isolatedLegacy')}
</span>
);
}
return <span className="text-muted-foreground">isolated</span>;
return <span className="text-muted-foreground">{t('accountsTable.isolated')}</span>;
},
},
{
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')}
>
<Pencil className="w-3.5 h-3.5 mr-1" />
Sync
{t('accountsTable.sync')}
</Button>
)}
{!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')}
>
<CheckCheck className="w-3 h-3 mr-1" />
Confirm
{t('accountsTable.confirm')}
</Button>
)}
<Button
@@ -191,7 +203,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
onClick={() => setDefaultMutation.mutate(row.original.name)}
>
<Check className={`w-3 h-3 mr-1 ${isDefault ? 'opacity-50' : ''}`} />
{isDefault ? 'Active' : 'Set Default'}
{isDefault ? t('accountsTable.active') : t('accountsTable.setDefault')}
</Button>
<Button
variant="ghost"
@@ -199,7 +211,11 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
disabled={isDefault || isPending}
onClick={() => setDeleteTarget(row.original.name)}
title={isDefault ? 'Cannot delete default account' : 'Delete account'}
title={
isDefault
? t('accountsTable.cannotDeleteDefault')
: t('accountsTable.deleteAccount')
}
>
<Trash2 className="w-4 h-4" />
</Button>
@@ -218,10 +234,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
if (data.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
No CCS auth accounts found. Use{' '}
<code className="text-sm bg-muted px-1 rounded">ccs auth create</code> to add accounts.
</div>
<div className="text-center py-8 text-muted-foreground">{t('accountsTable.noAccounts')}</div>
);
}
@@ -279,7 +292,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
disabled={resetDefaultMutation.isPending}
>
<RotateCcw className="w-4 h-4 mr-2" />
Reset to CCS Default
{t('accountsTable.resetToDefault')}
</Button>
</div>
)}
@@ -293,14 +306,13 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Account</AlertDialogTitle>
<AlertDialogTitle>{t('accountsTable.deleteDialogTitle')}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the account &quot;{deleteTarget}&quot;? This will
remove the profile and all its session data. This action cannot be undone.
{t('accountsTable.deleteDialogDesc', { name: deleteTarget ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t('accountsTable.cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
@@ -310,7 +322,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
}
}}
>
Delete
{t('accountsTable.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -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<KiroAuthMethod>(DEFAULT_KIRO_AUTH_METHOD);
const { t } = useTranslation();
const wasAuthenticatingRef = useRef(false);
const authFlow = useCliproxyAuthFlow();
const kiroImportMutation = useKiroImport();
@@ -318,13 +320,13 @@ export function AddAccountDialog({
}}
>
<DialogHeader>
<DialogTitle>Add {displayName} Account</DialogTitle>
<DialogTitle>{t('addAccountDialog.title', { displayName })}</DialogTitle>
<DialogDescription>
{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')}
</DialogDescription>
</DialogHeader>
@@ -344,10 +346,9 @@ export function AddAccountDialog({
<div className="rounded-lg border border-amber-400/35 bg-amber-50/70 p-3 text-xs text-amber-900 dark:border-amber-800/60 dark:bg-amber-950/25 dark:text-amber-100">
<div className="mb-1.5 flex items-center gap-1.5 font-semibold">
<ShieldAlert className="h-3.5 w-3.5" />
Power user mode enabled
{t('addAccountDialog.powerUserEnabled')}
</div>
AGY responsibility checklist is skipped from Settings {'>'} Proxy. You accept full
responsibility for OAuth/account risk.
{t('addAccountDialog.powerUserSkipped')}
</div>
)}
@@ -367,7 +368,7 @@ export function AddAccountDialog({
{/* Kiro auth method */}
{isKiro && !showAuthUI && (
<div className="space-y-2">
<Label htmlFor="kiro-auth-method">Auth Method</Label>
<Label htmlFor="kiro-auth-method">{t('addAccountDialog.authMethod')}</Label>
<Select
value={kiroAuthMethod}
onValueChange={(value) => {
@@ -376,7 +377,7 @@ export function AddAccountDialog({
}}
>
<SelectTrigger id="kiro-auth-method">
<SelectValue placeholder="Select Kiro auth method" />
<SelectValue placeholder={t('addAccountDialog.selectKiroAuthMethod')} />
</SelectTrigger>
<SelectContent>
{KIRO_AUTH_METHOD_OPTIONS.map((option) => (
@@ -394,7 +395,9 @@ export function AddAccountDialog({
{!showAuthUI && (
<div className="space-y-2">
<Label htmlFor="nickname">
{requiresNickname ? 'Nickname (required)' : 'Nickname (optional)'}
{requiresNickname
? t('addAccountDialog.nicknameRequired')
: t('addAccountDialog.nicknameOptional')}
</Label>
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-muted-foreground" />
@@ -405,15 +408,15 @@ export function AddAccountDialog({
setNickname(e.target.value);
setLocalError(null);
}}
placeholder="e.g., work, personal"
placeholder={t('addAccountDialog.nicknamePlaceholder')}
disabled={isPending}
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
{requiresNickname
? 'Required for this provider. Use a unique friendly name (e.g., work, personal).'
: 'A friendly name to identify this account. Auto-generated from email if left empty.'}
? t('addAccountDialog.nicknameRequiredHint')
: t('addAccountDialog.nicknameOptionalHint')}
</p>
</div>
)}
@@ -425,12 +428,12 @@ export function AddAccountDialog({
<div className="text-center">
<p className="text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 inline mr-2 animate-spin" />
Waiting for authentication...
{t('addAccountDialog.waitingForAuth')}
</p>
<p className="text-xs text-muted-foreground mt-1">
{authFlow.isDeviceCodeFlow
? 'A verification code dialog will appear shortly. Enter the code on the provider website.'
: 'Complete the authentication in your browser. This dialog closes automatically.'}
? t('addAccountDialog.deviceCodeHint')
: t('addAccountDialog.browserHint')}
</p>
</div>
@@ -438,7 +441,7 @@ export function AddAccountDialog({
{authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
<div className="space-y-3">
<div className="space-y-2">
<Label className="text-xs">Open this URL in any browser to sign in:</Label>
<Label className="text-xs">{t('addAccountDialog.openUrlLabel')}</Label>
<div className="p-3 bg-muted rounded-md">
<p className="text-xs text-muted-foreground break-all font-mono line-clamp-3">
{authFlow.authUrl}
@@ -448,12 +451,12 @@ export function AddAccountDialog({
{copied ? (
<>
<Check className="w-3 h-3 mr-1" />
Copied
{t('addAccountDialog.copied')}
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copy
{t('addAccountDialog.copy')}
</>
)}
</Button>
@@ -464,15 +467,12 @@ export function AddAccountDialog({
if (!authFlow.authUrl) return;
const popup = window.open(authFlow.authUrl, '_blank');
if (!popup || popup.closed || typeof popup.closed === 'undefined') {
toast.warning(
'Popup blocked. Copy the URL above and open it manually in your browser.',
{ duration: 5000 }
);
toast.warning(t('addAccountDialog.popupBlocked'), { duration: 5000 });
}
}}
>
<ExternalLink className="w-3 h-3 mr-1" />
Open
{t('addAccountDialog.open')}
</Button>
</div>
</div>
@@ -481,13 +481,13 @@ export function AddAccountDialog({
{/* Callback paste field */}
<div className="space-y-2">
<Label htmlFor="callback-url" className="text-xs">
Redirect didn&apos;t work? Paste the callback URL:
{t('addAccountDialog.redirectPasteLabel')}
</Label>
<Input
id="callback-url"
value={callbackUrl}
onChange={(e) => setCallbackUrl(e.target.value)}
placeholder="Paste the redirect URL here..."
placeholder={t('addAccountDialog.callbackPlaceholder')}
className="font-mono text-xs"
/>
<Button
@@ -499,10 +499,10 @@ export function AddAccountDialog({
{authFlow.isSubmittingCallback ? (
<>
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
Submitting...
{t('addAccountDialog.submitting')}
</>
) : (
'Submit Callback'
t('addAccountDialog.submitCallback')
)}
</Button>
</div>
@@ -511,7 +511,7 @@ export function AddAccountDialog({
{!authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
<p className="text-xs text-center text-muted-foreground">
Preparing sign-in URL...
{t('addAccountDialog.preparingUrl')}
</p>
)}
</div>
@@ -524,26 +524,26 @@ export function AddAccountDialog({
{kiroImportMutation.isPending && (
<p className="text-sm text-center text-muted-foreground">
<Loader2 className="w-4 h-4 inline mr-2 animate-spin" />
Importing token from Kiro IDE...
{t('addAccountDialog.importingToken')}
</p>
)}
{/* Action buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" onClick={handleCancel}>
Cancel
{t('addAccountDialog.cancel')}
</Button>
{isKiro && !showAuthUI && (
<Button variant="outline" onClick={handleKiroImport} disabled={isPending}>
{kiroImportMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Importing...
{t('addAccountDialog.importing')}
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Import from IDE
{t('addAccountDialog.importFromIde')}
</>
)}
</Button>
@@ -560,7 +560,7 @@ export function AddAccountDialog({
}
>
<ExternalLink className="w-4 h-4 mr-2" />
Authenticate
{t('addAccountDialog.authenticate')}
</Button>
)}
</div>
@@ -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({
<AlertTriangle className="h-4 w-4" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold leading-5">Antigravity OAuth Responsibility</p>
<p className="text-xs text-muted-foreground">
Complete all 4 steps before you can authenticate.
</p>
<p className="text-sm font-semibold leading-5">{t('antigravityChecklist.title')}</p>
<p className="text-xs text-muted-foreground">{t('antigravityChecklist.subtitle')}</p>
</div>
</div>
<Badge variant="outline" className="border-rose-500/40 text-rose-700 dark:text-rose-300">
Mandatory
{t('antigravityChecklist.mandatory')}
</Badge>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Completion</span>
<span>{completedSteps}/4 steps</span>
<span>{t('antigravityChecklist.completion')}</span>
<span>{t('antigravityChecklist.stepsCount', { current: completedSteps })}</span>
</div>
<Progress value={progressValue} className="h-2" />
</div>
@@ -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 <name>';
: t('createAuthProfileDialog.commandFallback');
const handleCopy = async () => {
if (!isValidName || (shareContext && !isValidContextGroup)) return;
@@ -74,27 +76,23 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create New Account</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
<DialogTitle>{t('createAuthProfileDialog.title')}</DialogTitle>
<DialogDescription>{t('createAuthProfileDialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Label htmlFor="profile-name">{t('createAuthProfileDialog.profileName')}</Label>
<Input
id="profile-name"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
placeholder="e.g., work, personal, client"
placeholder={t('createAuthProfileDialog.profileNamePlaceholder')}
autoComplete="off"
/>
{profileName && !isValidName && (
<p className="text-xs text-destructive">
Name must start with a letter and contain only letters, numbers, dashes, or
underscores.
{t('createAuthProfileDialog.invalidProfileName')}
</p>
)}
</div>
@@ -107,22 +105,24 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
onCheckedChange={(checked) => setShareContext(checked === true)}
/>
<Label htmlFor="share-context" className="cursor-pointer">
Enable shared history sync with other ccs auth accounts
{t('createAuthProfileDialog.enableSharedHistory')}
</Label>
</div>
{shareContext && (
<div className="space-y-2 pl-6">
<Label htmlFor="context-group">History Sync Group (optional)</Label>
<Label htmlFor="context-group">
{t('createAuthProfileDialog.historySyncGroupOptional')}
</Label>
<Input
id="context-group"
value={contextGroup}
onChange={(e) => setContextGroup(e.target.value)}
placeholder="default, sprint-a, client-x"
placeholder={t('createAuthProfileDialog.historySyncGroupPlaceholder')}
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the default shared group. Spaces are normalized to dashes.
{t('createAuthProfileDialog.historySyncGroupHint')}
</p>
<div className="flex items-center gap-2 pt-1">
<Checkbox
@@ -131,17 +131,17 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
onCheckedChange={(checked) => setDeeperContinuity(checked === true)}
/>
<Label htmlFor="deeper-continuity" className="cursor-pointer">
Advanced: deeper continuity mode
{t('createAuthProfileDialog.deeperContinuity')}
</Label>
</div>
<p className="text-xs text-muted-foreground">
Adds sync for <code>session-env</code>, <code>file-history</code>,{' '}
<code>shell-snapshots</code>, and <code>todos</code>. Credentials stay isolated.
{t('createAuthProfileDialog.deeperContinuityHint')}
</p>
{contextGroup.trim().length > 0 && !isValidContextGroup && (
<p className="text-xs text-destructive">
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,
})}
</p>
)}
</div>
@@ -149,7 +149,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
</div>
<div className="space-y-2">
<Label>Command</Label>
<Label>{t('createAuthProfileDialog.command')}</Label>
<div className="flex items-center gap-2 p-3 bg-muted rounded-md font-mono text-sm">
<Terminal className="w-4 h-4 text-muted-foreground shrink-0" />
<code className="flex-1 break-all">{command}</code>
@@ -170,20 +170,17 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
</div>
<div className="text-sm text-muted-foreground space-y-1">
<p>After running the command:</p>
<p>{t('createAuthProfileDialog.afterRunPrefix')}</p>
<ol className="list-decimal list-inside pl-2 space-y-0.5">
<li>Complete the Claude login in your browser</li>
<li>Return here and refresh to see the new account</li>
<li>{t('createAuthProfileDialog.afterRunStep1')}</li>
<li>{t('createAuthProfileDialog.afterRunStep2')}</li>
</ol>
<p className="pt-1">
Prefer pooled Claude OAuth routing instead? Use CLIProxy Claude pool from the Accounts
page action button.
</p>
<p className="pt-1">{t('createAuthProfileDialog.poolingHint')}</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={handleClose}>
Close
{t('createAuthProfileDialog.close')}
</Button>
<Button
onClick={handleCopy}
@@ -192,12 +189,12 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
{copied ? (
<>
<Check className="w-4 h-4 mr-2" />
Copied!
{t('createAuthProfileDialog.copied')}
</>
) : (
<>
<Copy className="w-4 h-4 mr-2" />
Copy Command
{t('createAuthProfileDialog.copyCommand')}
</>
)}
</Button>
@@ -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<ContextMode>(
account.context_mode === 'shared' ? 'shared' : 'isolated'
@@ -78,89 +80,86 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit History Sync</DialogTitle>
<DialogTitle>{t('editAccountContext.title')}</DialogTitle>
<DialogDescription>
Configure how "{account.name}" shares history and continuity with other
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
accounts.
{t('editAccountContext.description', { name: account.name })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="context-mode">Sync Mode</Label>
<Label htmlFor="context-mode">{t('editAccountContext.syncMode')}</Label>
<Select value={mode} onValueChange={(value) => setMode(value as ContextMode)}>
<SelectTrigger id="context-mode">
<SelectValue placeholder="Select context mode" />
<SelectValue placeholder={t('editAccountContext.selectContextMode')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="isolated">isolated (no sync)</SelectItem>
<SelectItem value="shared">shared (sync enabled)</SelectItem>
<SelectItem value="isolated">{t('editAccountContext.isolatedOption')}</SelectItem>
<SelectItem value="shared">{t('editAccountContext.sharedOption')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{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')}
</p>
</div>
{mode === 'shared' && (
<div className="space-y-2">
<Label htmlFor="context-group">History Sync Group</Label>
<Label htmlFor="context-group">{t('editAccountContext.historySyncGroup')}</Label>
<Input
id="context-group"
value={group}
onChange={(event) => setGroup(event.target.value)}
placeholder="default"
placeholder={t('editAccountContext.groupPlaceholder')}
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
Normalized to lowercase (spaces become dashes). Allowed: letters, numbers, `_`, `-`
(max {MAX_CONTEXT_GROUP_LENGTH} chars).
{t('editAccountContext.groupHint', { max: MAX_CONTEXT_GROUP_LENGTH })}
</p>
{!isSharedGroupValid && (
<p className="text-xs text-destructive">
Enter a valid group name that starts with a letter.
</p>
<p className="text-xs text-destructive">{t('editAccountContext.invalidGroup')}</p>
)}
</div>
)}
{mode === 'shared' && (
<div className="space-y-2">
<Label htmlFor="continuity-mode">Continuity Depth</Label>
<Label htmlFor="continuity-mode">{t('editAccountContext.continuityDepth')}</Label>
<Select
value={continuityMode}
onValueChange={(value) => setContinuityMode(value as ContinuityMode)}
>
<SelectTrigger id="continuity-mode">
<SelectValue placeholder="Select continuity depth" />
<SelectValue placeholder={t('editAccountContext.selectContinuityDepth')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">standard (projects only)</SelectItem>
<SelectItem value="deeper">deeper continuity (advanced)</SelectItem>
<SelectItem value="standard">{t('editAccountContext.standardOption')}</SelectItem>
<SelectItem value="deeper">{t('editAccountContext.deeperOption')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{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')}
</p>
</div>
)}
<p className="text-xs text-muted-foreground">
Credentials and `.anthropic` remain isolated per account in all modes.
{t('editAccountContext.credentialsIsolated')}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={updateContextMutation.isPending}>
Cancel
{t('editAccountContext.cancel')}
</Button>
<Button onClick={handleSave} disabled={!canSubmit || updateContextMutation.isPending}>
{updateContextMutation.isPending ? 'Saving...' : 'Save'}
{updateContextMutation.isPending
? t('editAccountContext.saving')
: t('editAccountContext.save')}
</Button>
</DialogFooter>
</DialogContent>
@@ -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({
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{account.paused ? 'Resume account' : 'Pause account'}
{account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -266,7 +268,7 @@ export function AccountCard({
{quotaLoading ? (
<div className="flex items-center gap-1 text-[8px] text-muted-foreground">
<Loader2 className="w-2.5 h-2.5 animate-spin" />
<span>Quota...</span>
<span>{t('accountCard.quotaLoading')}</span>
</div>
) : minQuotaValue !== null ? (
<TooltipProvider>
@@ -275,7 +277,7 @@ export function AccountCard({
<div className="space-y-0.5 cursor-help">
<div className="flex items-center justify-between">
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
Quota
{t('accountCard.quota')}
</span>
<span
className={cn(
@@ -320,21 +322,23 @@ export function AccountCard({
</Tooltip>
</TooltipProvider>
) : quota?.success ? (
<div className="text-[8px] text-muted-foreground/60">Quota limits unavailable</div>
<div className="text-[8px] text-muted-foreground/60">
{t('accountCard.quotaUnavailable')}
</div>
) : quota?.needsReauth ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-[8px] text-amber-600 dark:text-amber-400">
<KeyRound className="w-2.5 h-2.5" />
<span>Reauth needed</span>
<span>{t('accountCard.reauthNeeded')}</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[200px]">
<p className="text-xs">
{quota.error?.includes('No refresh token')
? 'Remove and re-add account'
: quota.error || 'Auto-refresh failed'}
? t('accountCard.removeAndReadd')
: quota.error || t('accountCard.autoRefreshFailed')}
</p>
</TooltipContent>
</Tooltip>
@@ -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 (
<div className="h-full flex items-center justify-center rounded-xl bg-muted/20 dark:bg-zinc-900/40 border border-border/30 dark:border-white/[0.05]">
<div className="text-xs text-muted-foreground font-mono">No recent connections</div>
<div className="text-xs text-muted-foreground font-mono">
{t('flowViz.noRecentConnections')}
</div>
</div>
);
}
@@ -30,7 +34,7 @@ export function ConnectionTimeline({ events, privacyMode }: ConnectionTimelinePr
<div className="flex items-center gap-2 mb-3 px-2">
<Activity className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
Connection Timeline
{t('flowViz.connectionTimeline')}
</span>
</div>
@@ -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 (
<div className="flex items-center justify-between px-3 py-1.5">
{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"
>
<ChevronRight className="w-3.5 h-3.5 rotate-180 transition-transform group-hover:-translate-x-0.5" />
<span>Back to providers</span>
<span>{t('flowViz.backToProviders')}</span>
</button>
) : (
<div />
@@ -44,7 +46,7 @@ export function FlowVizHeader({
)}
>
{showDetails ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
<span>{showDetails ? 'Hide Details' : 'Show Details'}</span>
<span>{showDetails ? t('flowViz.hideDetails') : t('flowViz.showDetails')}</span>
</button>
{hasCustomPositions && (
<button
@@ -52,7 +54,7 @@ export function FlowVizHeader({
className="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 border border-border/60 hover:border-border bg-background hover:bg-muted/50 shadow-sm"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>Reset layout</span>
<span>{t('flowViz.resetLayout')}</span>
</button>
)}
</div>
@@ -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({
<h3 className="text-sm font-semibold text-foreground tracking-tight">
{providerData.displayName}
</h3>
<p className="text-[10px] text-muted-foreground font-medium uppercase">Provider</p>
<p className="text-[10px] text-muted-foreground font-medium uppercase">
{t('flowViz.provider')}
</p>
</div>
</div>
<div className="space-y-2 relative z-10">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Total Requests</span>
<span className="text-muted-foreground">{t('flowViz.totalRequests')}</span>
<span className="text-foreground font-mono">{totalRequests.toLocaleString()}</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Accounts</span>
<span className="text-muted-foreground">{t('flowViz.accounts')}</span>
<span className="text-foreground font-mono">{accounts.length}</span>
</div>
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full mt-2 overflow-hidden">
@@ -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({
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<div>
<CardTitle className="text-base">How History Sync Works</CardTitle>
<CardTitle className="text-base">{t('historySyncLearningMap.title')}</CardTitle>
<CardDescription className="mt-1">
Isolated -&gt; Shared -&gt; Deeper. Use <code>Sync</code> per row for all changes.
{t('historySyncLearningMap.description')}
</CardDescription>
</div>
<Badge variant="outline">Learning Map</Badge>
<Badge variant="outline">{t('historySyncLearningMap.learningMap')}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
{cliproxyCount > 0 && (
<div className="rounded-md border border-blue-300/60 bg-blue-50/40 px-3 py-2 text-xs text-blue-800 dark:border-blue-900/40 dark:bg-blue-900/10 dark:text-blue-300">
{cliproxyCount} CLIProxy Claude pool account{cliproxyCount > 1 ? 's are' : ' is'}
managed in Action Center / CLIProxy page.
{t('historySyncLearningMap.cliproxyManaged', { count: cliproxyCount })}
</div>
)}
<div className="grid gap-2 sm:grid-cols-[1fr_auto_1fr_auto_1fr] sm:items-center">
<StageTile title="Isolated" count={isolatedCount} icon={Unlink} tone="isolated" />
<StageTile
title={t('historySyncLearningMap.isolated')}
count={isolatedCount}
icon={Unlink}
tone="isolated"
/>
<div className="hidden sm:flex justify-center text-muted-foreground">
<ArrowRight className="h-4 w-4" />
</div>
<StageTile title="Shared" count={sharedStandardCount} icon={Link2} tone="shared" />
<StageTile
title={t('historySyncLearningMap.shared')}
count={sharedStandardCount}
icon={Link2}
tone="shared"
/>
<div className="hidden sm:flex justify-center text-muted-foreground">
<ArrowRight className="h-4 w-4" />
</div>
<StageTile title="Deeper" count={deeperSharedCount} icon={Waves} tone="deeper" />
<StageTile
title={t('historySyncLearningMap.deeper')}
count={deeperSharedCount}
icon={Waves}
tone="deeper"
/>
</div>
<Collapsible open={open} onOpenChange={setOpen}>
@@ -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"
>
<span>Show details: groups, switching, and legacy policy</span>
<span>{t('historySyncLearningMap.showDetails')}</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
@@ -128,21 +144,20 @@ export function HistorySyncLearningMap({
<div className="rounded-md border bg-muted/20 p-2.5 text-xs">
<div className="flex items-center gap-2">
<ArrowRightLeft className="h-3.5 w-3.5 text-muted-foreground" />
<p className="font-semibold">Mode Switch</p>
<p className="font-semibold">{t('historySyncLearningMap.modeSwitch')}</p>
</div>
<p className="mt-1 text-muted-foreground">
Sync dialog lets users move between isolated/shared and choose deeper continuity.
{t('historySyncLearningMap.modeSwitchDesc')}
</p>
</div>
<div className="rounded-md border bg-muted/20 p-2.5 text-xs">
<div className="flex items-center gap-2">
<Layers3 className="h-3.5 w-3.5 text-muted-foreground" />
<p className="font-semibold">History Sync Group</p>
<p className="font-semibold">{t('historySyncLearningMap.historySyncGroup')}</p>
</div>
<p className="mt-1 text-muted-foreground">
Same group means shared project context lane. Default fallback is{' '}
<code>default</code>.
{t('historySyncLearningMap.historySyncGroupDesc')}
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{groupsToShow.map((group) => (
@@ -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),
}),
},
];
@@ -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<string, { name: string; color: string }> = {
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<string, { key: string; color: string }> = {
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 (
<div className={cn('flex items-center gap-2 text-sm text-muted-foreground', className)}>
<Cpu className="w-4 h-4" />
<span>No models available</span>
<span>{t('categorizedModelSelector.noModelsAvailable')}</span>
</div>
);
}
@@ -92,7 +96,7 @@ export function CategorizedModelSelector({
return (
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className={cn('w-[320px]', className)}>
<SelectValue placeholder={placeholder}>
<SelectValue placeholder={resolvedPlaceholder}>
{value && (
<div className="flex items-center gap-2">
<span className="truncate">{value}</span>
@@ -104,7 +108,9 @@ export function CategorizedModelSelector({
{sortedCategories.map(({ category, display, models }) => (
<SelectGroup key={category}>
<SelectLabel className="flex items-center justify-between px-2 py-1.5">
<span className={cn('font-semibold', display.color)}>{display.name}</span>
<span className={cn('font-semibold', display.color)}>
{t(`cliproxyModelCategory.${display.key}`)}
</span>
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 ml-2">
{models.length}
</Badge>
@@ -129,6 +135,8 @@ export function CategorizedModelSelectorCompact({
onChange,
disabled,
}: Omit<CategorizedModelSelectorProps, 'placeholder' | 'className'>) {
const { t } = useTranslation();
return (
<CategorizedModelSelector
modelsData={modelsData}
@@ -136,7 +144,7 @@ export function CategorizedModelSelectorCompact({
value={value}
onChange={onChange}
disabled={disabled}
placeholder="Model..."
placeholder={t('categorizedModelSelector.modelPlaceholder')}
className="w-[200px]"
/>
);
+72 -34
View File
@@ -14,9 +14,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 { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
import { usePrivacy } from '@/contexts/privacy-context';
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
import { isDeniedAgyModelId } from '@/lib/utils';
const singleProviderSchema = z.object({
name: z
@@ -67,8 +70,15 @@ const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({
value: id,
label: getProviderDisplayName(id),
}));
const AGY_DENYLIST_MESSAGE =
'Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.';
function isDeniedAgyModelForProvider(provider: string, modelId: string | undefined): boolean {
return provider === 'agy' && typeof modelId === 'string' && isDeniedAgyModelId(modelId);
}
export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
const { t } = useTranslation();
const createMutation = useCreateVariant();
const { data: authData } = useCliproxyAuth();
const { privacyMode } = usePrivacy();
@@ -97,6 +107,12 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
const providerAccounts = providerAuth?.accounts || [];
const onSubmitSingle = async (data: SingleProviderFormData) => {
if (isDeniedAgyModelForProvider(data.provider, data.model)) {
singleForm.setError('model', { message: AGY_DENYLIST_MESSAGE });
toast.error(AGY_DENYLIST_MESSAGE);
return;
}
try {
await createMutation.mutateAsync(data);
singleForm.reset();
@@ -107,6 +123,14 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
};
const onSubmitComposite = async (data: CompositeFormData) => {
for (const tier of ['opus', 'sonnet', 'haiku'] as const) {
const tierConfig = data.tiers[tier];
if (!isDeniedAgyModelForProvider(tierConfig.provider, tierConfig.model)) continue;
compositeForm.setError(`tiers.${tier}.model`, { message: AGY_DENYLIST_MESSAGE });
toast.error(AGY_DENYLIST_MESSAGE);
return;
}
try {
await createMutation.mutateAsync({
name: data.name,
@@ -151,8 +175,12 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
{mode === 'single' ? (
<form onSubmit={singleForm.handleSubmit(onSubmitSingle)} className="space-y-4">
<div>
<Label htmlFor="name">Name</Label>
<Input id="name" {...singleForm.register('name')} placeholder="my-gemini" />
<Label htmlFor="name">{t('cliproxyDialog.name')}</Label>
<Input
id="name"
{...singleForm.register('name')}
placeholder={t('cliproxyDialog.placeholderName')}
/>
{singleForm.formState.errors.name && (
<span className="text-xs text-red-500">
{singleForm.formState.errors.name.message}
@@ -161,13 +189,13 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
</div>
<div>
<Label htmlFor="provider">Provider</Label>
<Label htmlFor="provider">{t('cliproxyDialog.provider')}</Label>
<select
id="provider"
{...singleForm.register('provider')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="">Select provider...</option>
<option value="">{t('cliproxyDialog.selectProvider')}</option>
{providerOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
@@ -183,17 +211,17 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
{selectedProvider && providerAccounts.length > 0 && (
<div>
<Label htmlFor="account">Account</Label>
<Label htmlFor="account">{t('cliproxyDialog.account')}</Label>
<select
id="account"
{...singleForm.register('account')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="">Use default account</option>
<option value="">{t('cliproxyDialog.useDefaultAccount')}</option>
{providerAccounts.map((acc) => (
<option key={acc.id} value={acc.id}>
{privacyMode ? '••••••' : acc.email || acc.id}
{acc.isDefault ? ' (default)' : ''}
{acc.isDefault ? ` ${t('cliproxyDialog.defaultSuffix')}` : ''}
</option>
))}
</select>
@@ -201,39 +229,45 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
)}
<div>
<Label htmlFor="model">Model (optional)</Label>
<Input id="model" {...singleForm.register('model')} placeholder="gemini-2.5-pro" />
<Label htmlFor="model">{t('cliproxyDialog.modelOptional')}</Label>
<Input
id="model"
{...singleForm.register('model')}
placeholder={t('cliproxyDialog.placeholderModel')}
/>
</div>
<div>
<Label htmlFor="target">Default Target</Label>
<Label htmlFor="target">{t('cliproxyDialog.defaultTarget')}</Label>
<select
id="target"
{...singleForm.register('target')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="claude">Claude Code</option>
<option value="droid">Factory Droid</option>
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
{t('cliproxyDialog.cancel')}
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create'}
{createMutation.isPending
? t('cliproxyDialog.creating')
: t('cliproxyDialog.create')}
</Button>
</div>
</form>
) : (
<form onSubmit={compositeForm.handleSubmit(onSubmitComposite)} className="space-y-4">
<div>
<Label htmlFor="comp-name">Name</Label>
<Label htmlFor="comp-name">{t('cliproxyDialog.name')}</Label>
<Input
id="comp-name"
{...compositeForm.register('name')}
placeholder="my-composite"
placeholder={t('cliproxyDialog.placeholderComposite')}
/>
{compositeForm.formState.errors.name && (
<span className="text-xs text-red-500">
@@ -243,17 +277,17 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
</div>
<div>
<Label>Tier Configuration</Label>
<Label>{t('cliproxyDialog.tierConfig')}</Label>
<Tabs defaultValue="opus" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="opus">Opus</TabsTrigger>
<TabsTrigger value="sonnet">Sonnet</TabsTrigger>
<TabsTrigger value="haiku">Haiku</TabsTrigger>
<TabsTrigger value="opus">{t('cliproxyDialog.opus')}</TabsTrigger>
<TabsTrigger value="sonnet">{t('cliproxyDialog.sonnet')}</TabsTrigger>
<TabsTrigger value="haiku">{t('cliproxyDialog.haiku')}</TabsTrigger>
</TabsList>
{(['opus', 'sonnet', 'haiku'] as const).map((tier) => (
<TabsContent key={tier} value={tier} className="space-y-3">
<div>
<Label htmlFor={`${tier}-provider`}>Provider</Label>
<Label htmlFor={`${tier}-provider`}>{t('cliproxyDialog.provider')}</Label>
<select
id={`${tier}-provider`}
{...compositeForm.register(`tiers.${tier}.provider`)}
@@ -267,11 +301,11 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
</select>
</div>
<div>
<Label htmlFor={`${tier}-model`}>Model</Label>
<Label htmlFor={`${tier}-model`}>{t('cliproxyDialog.model')}</Label>
<Input
id={`${tier}-model`}
{...compositeForm.register(`tiers.${tier}.model`)}
placeholder="model-id"
placeholder={t('cliproxyDialog.placeholderModelId')}
/>
{compositeForm.formState.errors.tiers?.[tier]?.model && (
<span className="text-xs text-red-500">
@@ -280,11 +314,13 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
)}
</div>
<div>
<Label htmlFor={`${tier}-account`}>Account (optional)</Label>
<Label htmlFor={`${tier}-account`}>
{t('cliproxyDialog.accountOptional')}
</Label>
<Input
id={`${tier}-account`}
{...compositeForm.register(`tiers.${tier}.account`)}
placeholder="account-id"
placeholder={t('cliproxyDialog.placeholderAccountId')}
/>
</div>
</TabsContent>
@@ -293,36 +329,38 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
</div>
<div>
<Label htmlFor="default-tier">Default Tier</Label>
<Label htmlFor="default-tier">{t('cliproxyDialog.defaultTier')}</Label>
<select
id="default-tier"
{...compositeForm.register('default_tier')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="opus">Opus</option>
<option value="sonnet">Sonnet</option>
<option value="haiku">Haiku</option>
<option value="opus">{t('cliproxyDialog.opus')}</option>
<option value="sonnet">{t('cliproxyDialog.sonnet')}</option>
<option value="haiku">{t('cliproxyDialog.haiku')}</option>
</select>
</div>
<div>
<Label htmlFor="composite-target">Default Target</Label>
<Label htmlFor="composite-target">{t('cliproxyDialog.defaultTarget')}</Label>
<select
id="composite-target"
{...compositeForm.register('target')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="claude">Claude Code</option>
<option value="droid">Factory Droid</option>
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
{t('cliproxyDialog.cancel')}
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create'}
{createMutation.isPending
? t('cliproxyDialog.creating')
: t('cliproxyDialog.create')}
</Button>
</div>
</form>
@@ -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
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Edit {isComposite ? 'Composite' : 'Single'} Variant: {variant.name}
{isComposite
? t('cliproxyDialog.editCompositeTitle', { name: variant.name })
: t('cliproxyDialog.editSingleTitle', { name: variant.name })}
</DialogTitle>
</DialogHeader>
{isComposite ? (
<form onSubmit={compositeForm.handleSubmit(onSubmitComposite)} className="space-y-4">
<div>
<Label>Tier Configuration</Label>
<Label>{t('cliproxyDialog.tierConfig')}</Label>
<Tabs defaultValue="opus" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="opus">Opus</TabsTrigger>
<TabsTrigger value="sonnet">Sonnet</TabsTrigger>
<TabsTrigger value="haiku">Haiku</TabsTrigger>
<TabsTrigger value="opus">{t('cliproxyDialog.opus')}</TabsTrigger>
<TabsTrigger value="sonnet">{t('cliproxyDialog.sonnet')}</TabsTrigger>
<TabsTrigger value="haiku">{t('cliproxyDialog.haiku')}</TabsTrigger>
</TabsList>
{(['opus', 'sonnet', 'haiku'] as const).map((tier) => (
<TabsContent key={tier} value={tier} className="space-y-3">
<div>
<Label htmlFor={`edit-${tier}-provider`}>Provider</Label>
<Label htmlFor={`edit-${tier}-provider`}>
{t('cliproxyDialog.provider')}
</Label>
<select
id={`edit-${tier}-provider`}
{...compositeForm.register(`tiers.${tier}.provider`)}
@@ -295,11 +326,11 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
</select>
</div>
<div>
<Label htmlFor={`edit-${tier}-model`}>Model</Label>
<Label htmlFor={`edit-${tier}-model`}>{t('cliproxyDialog.model')}</Label>
<Input
id={`edit-${tier}-model`}
{...compositeForm.register(`tiers.${tier}.model`)}
placeholder="model-id"
placeholder={t('cliproxyDialog.placeholderModelId')}
/>
{compositeForm.formState.errors.tiers?.[tier]?.model && (
<span className="text-xs text-red-500">
@@ -308,11 +339,13 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
)}
</div>
<div>
<Label htmlFor={`edit-${tier}-account`}>Account (optional)</Label>
<Label htmlFor={`edit-${tier}-account`}>
{t('cliproxyDialog.accountOptional')}
</Label>
<Input
id={`edit-${tier}-account`}
{...compositeForm.register(`tiers.${tier}.account`)}
placeholder="account-id"
placeholder={t('cliproxyDialog.placeholderAccountId')}
/>
</div>
</TabsContent>
@@ -321,43 +354,45 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
</div>
<div>
<Label htmlFor="edit-default-tier">Default Tier</Label>
<Label htmlFor="edit-default-tier">{t('cliproxyDialog.defaultTier')}</Label>
<select
id="edit-default-tier"
{...compositeForm.register('default_tier')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="opus">Opus</option>
<option value="sonnet">Sonnet</option>
<option value="haiku">Haiku</option>
<option value="opus">{t('cliproxyDialog.opus')}</option>
<option value="sonnet">{t('cliproxyDialog.sonnet')}</option>
<option value="haiku">{t('cliproxyDialog.haiku')}</option>
</select>
</div>
<div>
<Label htmlFor="edit-composite-target">Default Target</Label>
<Label htmlFor="edit-composite-target">{t('cliproxyDialog.defaultTarget')}</Label>
<select
id="edit-composite-target"
{...compositeForm.register('target')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="claude">Claude Code</option>
<option value="droid">Factory Droid</option>
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
{t('cliproxyDialog.cancel')}
</Button>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
{updateMutation.isPending
? t('cliproxyDialog.saving')
: t('cliproxyDialog.saveChanges')}
</Button>
</div>
</form>
) : (
<form onSubmit={singleForm.handleSubmit(onSubmitSingle)} className="space-y-4">
<div>
<Label htmlFor="edit-provider">Provider</Label>
<Label htmlFor="edit-provider">{t('cliproxyDialog.provider')}</Label>
<select
id="edit-provider"
{...singleForm.register('provider')}
@@ -372,37 +407,43 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
</div>
<div>
<Label htmlFor="edit-model">Model</Label>
<Input id="edit-model" {...singleForm.register('model')} placeholder="model-id" />
</div>
<div>
<Label htmlFor="edit-account">Account (optional)</Label>
<Label htmlFor="edit-model">{t('cliproxyDialog.model')}</Label>
<Input
id="edit-account"
{...singleForm.register('account')}
placeholder="account-id"
id="edit-model"
{...singleForm.register('model')}
placeholder={t('cliproxyDialog.placeholderModelId')}
/>
</div>
<div>
<Label htmlFor="edit-target">Default Target</Label>
<Label htmlFor="edit-account">{t('cliproxyDialog.accountOptional')}</Label>
<Input
id="edit-account"
{...singleForm.register('account')}
placeholder={t('cliproxyDialog.placeholderAccountId')}
/>
</div>
<div>
<Label htmlFor="edit-target">{t('cliproxyDialog.defaultTarget')}</Label>
<select
id="edit-target"
{...singleForm.register('target')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="claude">Claude Code</option>
<option value="droid">Factory Droid</option>
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
{t('cliproxyDialog.cancel')}
</Button>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
{updateMutation.isPending
? t('cliproxyDialog.saving')
: t('cliproxyDialog.saveChanges')}
</Button>
</div>
</form>
@@ -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 (
<Card>
<CardHeader>
<CardTitle className="text-base">Credential Health</CardTitle>
<CardTitle className="text-base">{t('credentialHealth.title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
@@ -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 (
<Card>
<CardHeader>
<CardTitle className="text-base">Credential Health</CardTitle>
<CardTitle className="text-base">{t('credentialHealth.title')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground text-center py-4">
No credentials configured. Use the login buttons above to authenticate.
{t('credentialHealth.noCredentialsConfigured')}
</p>
</CardContent>
</Card>
@@ -180,7 +187,7 @@ export function CredentialHealthList() {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Credential Health</CardTitle>
<CardTitle className="text-base">{t('credentialHealth.title')}</CardTitle>
</CardHeader>
<CardContent className="p-0">
{credentials.map((cred, i) => (
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff. Show more