mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +00:00
Merge pull request #674 from kaitranntt/dev
chore(release): sync dev to main v7.51.0
This commit is contained in:
171 files changed
+10582
-1968
No files matched your search
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')}`);
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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)'],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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=="],
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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 "{deleteTarget}"? 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'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 -> Shared -> 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]"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
Reference in new issue
Block a user