diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index d11bec1d..14c87e4e 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -126,9 +126,10 @@ CCS provides: - Keep `round-robin` as the default until the user explicitly changes it - Never infer routing strategy from account count, tier mix, or paused/default account state - Auto-failover when account exhausted -- Tier detection: free/paid/unknown +- Tier detection: free/pro/ultra/unknown +- Distinguish entitlement failures from temporary capacity exhaustion - Pre-flight quota checks before session start -- Dashboard UI with pause/resume toggles and tier badges +- Dashboard UI with pause/resume toggles, tier badges, and quota-detail guidance ### FR-010: Docker Deployment - Multi-stage Dockerfile with bun 1.2.21 and node:20-bookworm-slim @@ -279,7 +280,7 @@ CCS provides: ### v7.14 Release (Complete) - [x] Hybrid quota management with auto-failover - [x] `ccs cliproxy pause/resume/status` commands -- [x] API tier detection (free/paid/unknown) +- [x] API tier detection (free/pro/ultra/unknown) - [x] Dashboard pause/resume toggles and tier badges - [x] Pre-flight quota checks before session start diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 8ef5aee2..4f977fdd 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-04-08 +Last Updated: 2026-04-10 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,10 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-10**: **#765** `/providers` now includes a first-class Hugging Face preset for API Profiles. CCS exposes Hugging Face Inference Providers through the existing OpenAI-compatible profile flow with the official router endpoint `https://router.huggingface.co/v1`, a short `hf` default profile name, and `hf` preset alias support for both the dashboard chooser and `ccs api create --preset hf`. +- **2026-04-10**: **#944** Image Analysis auth readiness no longer collapses to native Read when merged runtime-status dependency overrides include a missing initializer value. CCS now preserves default dependency functions when override entries are `undefined`, still reads token-backed auth status directly in the local readiness path, and includes regression coverage for the missing-initializer case that previously surfaced as `deps.initializeAccounts is not a function`. +- **2026-04-10**: **#945** CCS now normalizes Gemini CLI and Antigravity tier signals around an explicit `free / pro / ultra / unknown` model, preserves raw tier ids such as `g1-pro-tier`, enriches Gemini quota responses with provider entitlement evidence, classifies `MODEL_CAPACITY_EXHAUSTED` separately from auth/entitlement failures, fixes the Antigravity CLI quota table so live quota-derived tiers no longer collapse back to stale `unknown`, adds Gemini tier ids to CLI quota output, extends Gemini Flash Lite grouping to cover `gemini-3.1-flash-lite-preview`, and allows Gemini account surfaces to render the same tier badge semantics as Antigravity. +- **2026-04-09**: **#938** Cliproxy model routing now exposes backend-pinned short prefixes for overlapping OAuth backends. CCS repairs managed OAuth auth-file prefixes for Gemini CLI (`gcli`) and Antigravity (`agy`), enriches `/api/cliproxy/catalog` with routing hints that show whether an unprefixed model is safe, shadowed, or prefix-only, upgrades `ccs cliproxy catalog` plus interactive variant model pickers to surface the pinned names, and updates the `ccs config` Cliproxy model selection UI so users can see the preferred call name and current effective backend before saving settings. - **2026-04-08**: **#931** `/cliproxy` model pickers now source their provider catalogs from CLIProxy management model definitions instead of treating the UI catalog file as the dropdown source of truth. CCS now refreshes live model definitions for Gemini, Codex, Claude, Antigravity, Qwen, iFlow, Kiro, GitHub Copilot, and Kimi through `/api/cliproxy/catalog`, overlays CCS-only preset/default metadata on top of those upstream models, keeps `/api/cliproxy/models` as the live availability feed, and falls back to cached/static catalogs when the proxy is unavailable so the dashboard never goes blank. - **2026-04-08**: **#929** Image Analysis hardening now makes the managed `ccs-image-analysis` MCP path authoritative on healthy Claude-target launches, suppresses stale CCS-managed image `Read` hooks instead of letting them compete with MCP, keeps the legacy hook available only as compatibility fallback when MCP provisioning fails, and extends self-heal to dashboard provisioning plus `ccs doctor --fix` so stale hook files and missing isolated MCP sync are repaired automatically. - **2026-04-07**: CLIProxy routing strategy is now a first-class CCS surface. Users can inspect and explicitly change `round-robin` vs `fill-first` from `ccs cliproxy routing` and from a native `/cliproxy` dashboard card. Local mode now persists the chosen startup default into CCS-managed CLIProxy config generation, while untouched installs remain on `round-robin`. CCS deliberately does not infer strategy from account composition. diff --git a/package.json b/package.json index b0b350af..cc8f24d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.67.0", + "version": "7.67.0-dev.10", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", diff --git a/src/api/services/index.ts b/src/api/services/index.ts index fb6ad360..8b213e17 100644 --- a/src/api/services/index.ts +++ b/src/api/services/index.ts @@ -64,6 +64,12 @@ export { // OpenRouter catalog and picker export { isOpenRouterUrl, fetchOpenRouterModels, type OpenRouterModel } from './openrouter-catalog'; export { pickOpenRouterModel, type OpenRouterSelection } from './openrouter-picker'; +export { + getLocalRuntimeReadiness, + type LocalRuntimeId, + type LocalRuntimeReadiness, + type LocalRuntimeStatus, +} from './local-runtime-readiness'; // Provider presets for CLI export { diff --git a/src/api/services/local-runtime-readiness.ts b/src/api/services/local-runtime-readiness.ts new file mode 100644 index 00000000..c8bc2350 --- /dev/null +++ b/src/api/services/local-runtime-readiness.ts @@ -0,0 +1,123 @@ +export type LocalRuntimeId = 'ollama' | 'llamacpp'; +export type LocalRuntimeStatus = 'ready' | 'missing-model' | 'offline'; + +export interface LocalRuntimeReadiness { + id: LocalRuntimeId; + name: string; + endpoint: string; + status: LocalRuntimeStatus; + commandHint: string; + recommendedModel: string | null; + recommendedModelInstalled: boolean; + detectedModelCount: number; +} + +interface LocalRuntimeDefinition { + id: LocalRuntimeId; + name: string; + endpoint: string; + modelsUrl: string; + commandHint: string; + pullCommandHint?: string; + recommendedModel: string | null; + parseModelIds: (payload: unknown) => string[]; +} + +const LOCAL_RUNTIME_DEFINITIONS: LocalRuntimeDefinition[] = [ + { + id: 'ollama', + name: 'Ollama', + endpoint: 'http://127.0.0.1:11434', + modelsUrl: 'http://127.0.0.1:11434/api/tags', + commandHint: 'ollama serve', + pullCommandHint: 'ollama pull', + recommendedModel: 'gemma4:e4b', + parseModelIds: (payload) => { + const models = (payload as { models?: Array<{ name?: string }> })?.models; + return Array.isArray(models) + ? models + .map((model) => (typeof model?.name === 'string' ? model.name.trim() : '')) + .filter(Boolean) + : []; + }, + }, + { + id: 'llamacpp', + name: 'llama.cpp', + endpoint: 'http://127.0.0.1:8080', + modelsUrl: 'http://127.0.0.1:8080/v1/models', + commandHint: './server --host 0.0.0.0 --port 8080 -m model.gguf', + recommendedModel: null, + parseModelIds: (payload) => { + const models = (payload as { data?: Array<{ id?: string }> })?.data; + return Array.isArray(models) + ? models + .map((model) => (typeof model?.id === 'string' ? model.id.trim() : '')) + .filter(Boolean) + : []; + }, + }, +]; + +async function fetchModelIds(definition: LocalRuntimeDefinition): Promise { + const response = await fetch(definition.modelsUrl, { + signal: AbortSignal.timeout(1500), + }); + + if (!response.ok) { + throw new Error(`${definition.id} readiness probe failed (${response.status})`); + } + + return definition.parseModelIds(await response.json()); +} + +function toReadiness( + definition: LocalRuntimeDefinition, + modelIds: string[] +): LocalRuntimeReadiness { + const recommendedModelInstalled = definition.recommendedModel + ? modelIds.some((modelId) => modelId === definition.recommendedModel) + : modelIds.length > 0; + + const status: LocalRuntimeStatus = + modelIds.length === 0 || !recommendedModelInstalled ? 'missing-model' : 'ready'; + + return { + id: definition.id, + name: definition.name, + endpoint: definition.endpoint, + status, + commandHint: + status === 'missing-model' && definition.recommendedModel && definition.pullCommandHint + ? `${definition.pullCommandHint} ${definition.recommendedModel}` + : definition.commandHint, + recommendedModel: definition.recommendedModel, + recommendedModelInstalled, + detectedModelCount: modelIds.length, + }; +} + +function toOffline(definition: LocalRuntimeDefinition): LocalRuntimeReadiness { + return { + id: definition.id, + name: definition.name, + endpoint: definition.endpoint, + status: 'offline', + commandHint: definition.commandHint, + recommendedModel: definition.recommendedModel, + recommendedModelInstalled: false, + detectedModelCount: 0, + }; +} + +export async function getLocalRuntimeReadiness(): Promise { + return Promise.all( + LOCAL_RUNTIME_DEFINITIONS.map(async (definition) => { + try { + return toReadiness(definition, await fetchModelIds(definition)); + } catch { + return toOffline(definition); + } + }) + ); +} diff --git a/src/cliproxy/accounts/types.ts b/src/cliproxy/accounts/types.ts index d93b20c4..3d098f54 100644 --- a/src/cliproxy/accounts/types.ts +++ b/src/cliproxy/accounts/types.ts @@ -2,7 +2,7 @@ * Shared types and constants for account management */ -import { CLIProxyProvider } from '../types'; +import type { CLIProxyProvider } from '../types'; /** Account tier for quota management: ultra > pro > free */ export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown'; diff --git a/src/cliproxy/auth-utils.ts b/src/cliproxy/auth-utils.ts index 77e371b1..d7f19c2a 100644 --- a/src/cliproxy/auth-utils.ts +++ b/src/cliproxy/auth-utils.ts @@ -16,12 +16,44 @@ export function sanitizeEmail(email: string): string { * Check if token is expired based on the expired timestamp. * Returns false if timestamp is missing or invalid (fail-open for quota display). */ -export function isTokenExpired(expiredStr?: string): boolean { - if (!expiredStr) return false; +export function getTokenExpiryTimestamp(expiredValue?: string | number | null): number | null { + if (expiredValue === undefined || expiredValue === null || expiredValue === '') { + return null; + } + + const normalizeNumericTimestamp = (value: number): number | null => { + if (!Number.isFinite(value) || value <= 0) { + return null; + } + + // Support Unix seconds from older token stores while preserving millisecond timestamps. + return value < 1_000_000_000_000 ? value * 1000 : value; + }; + try { - const expiredDate = new Date(expiredStr); - return expiredDate.getTime() < Date.now(); + if (typeof expiredValue === 'number') { + return normalizeNumericTimestamp(expiredValue); + } + + const trimmed = expiredValue.trim(); + if (!trimmed) { + return null; + } + + if (/^\d+$/.test(trimmed)) { + const numericTimestamp = Number(trimmed); + return normalizeNumericTimestamp(numericTimestamp); + } + + const expiredDate = new Date(trimmed); + const expiredAt = expiredDate.getTime(); + return Number.isNaN(expiredAt) ? null : expiredAt; } catch { - return false; + return null; } } + +export function isTokenExpired(expiredValue?: string | number | null): boolean { + const expiredAt = getTokenExpiryTimestamp(expiredValue); + return expiredAt !== null ? expiredAt < Date.now() : false; +} diff --git a/src/cliproxy/catalog-routing.ts b/src/cliproxy/catalog-routing.ts new file mode 100644 index 00000000..93973777 --- /dev/null +++ b/src/cliproxy/catalog-routing.ts @@ -0,0 +1,32 @@ +import { + buildCliproxyRoutingHints, + type CliproxyProviderRoutingHints, +} from '../shared/cliproxy-model-routing'; +import { fetchCliproxyModels } from './stats-fetcher'; +import { + getResolvedCatalogSnapshot, + type CatalogSource, + type ResolvedCatalogSnapshot, +} from './catalog-cache'; +import type { ProviderCatalog } from './model-catalog'; +import type { CLIProxyProvider } from './types'; + +export interface CatalogRoutingSnapshot { + catalogs: Partial>; + source: CatalogSource; + cacheAge: string | null; + routing: Partial>; +} + +export async function getCatalogRoutingSnapshot(): Promise { + const snapshot: ResolvedCatalogSnapshot = await getResolvedCatalogSnapshot(); + const modelsResponse = snapshot.source === 'live' ? await fetchCliproxyModels() : null; + const routing = buildCliproxyRoutingHints(snapshot.catalogs, modelsResponse?.models ?? []); + + return { + catalogs: snapshot.catalogs, + source: snapshot.source, + cacheAge: snapshot.cacheAge, + routing, + }; +} diff --git a/src/cliproxy/gemini-cli-quota-normalizer.ts b/src/cliproxy/gemini-cli-quota-normalizer.ts index 1d5425b9..3fad0de9 100644 --- a/src/cliproxy/gemini-cli-quota-normalizer.ts +++ b/src/cliproxy/gemini-cli-quota-normalizer.ts @@ -20,7 +20,7 @@ const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [ id: 'gemini-flash-lite-series', label: 'Gemini Flash Lite Series', preferredModelId: 'gemini-2.5-flash-lite', - modelIds: ['gemini-2.5-flash-lite'], + modelIds: ['gemini-2.5-flash-lite', 'gemini-3.1-flash-lite-preview'], }, { id: 'gemini-flash-series', diff --git a/src/cliproxy/managed-model-prefixes.ts b/src/cliproxy/managed-model-prefixes.ts new file mode 100644 index 00000000..54a97e6d --- /dev/null +++ b/src/cliproxy/managed-model-prefixes.ts @@ -0,0 +1,156 @@ +import { getManagedModelPrefix } from '../shared/cliproxy-model-routing'; +import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver'; +import { mapExternalProviderName } from './provider-capabilities'; +import type { CLIProxyProvider } from './types'; + +const MANAGED_PREFIX_REQUEST_TIMEOUT_MS = 3000; + +interface ManagementAuthFileRecord { + account_type?: string; + name: string; + provider?: string; + type?: string; +} + +interface AuthFileMetadata { + prefix: string | null; + provider: CLIProxyProvider | null; +} + +export interface ManagedPrefixSyncResult { + checked: number; + updated: number; +} + +function normalizeProvider(record: ManagementAuthFileRecord): CLIProxyProvider | null { + const providerName = record.provider?.trim() || record.type?.trim() || ''; + return providerName ? mapExternalProviderName(providerName) : null; +} + +async function fetchManagementEndpoint(path: string, init: RequestInit = {}): Promise { + const target = getProxyTarget(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), MANAGED_PREFIX_REQUEST_TIMEOUT_MS); + + try { + return await fetch(buildProxyUrl(target, path), { + ...init, + headers: buildManagementHeaders(target, init.headers as Record | undefined), + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } +} + +async function listAuthFiles(): Promise { + const response = await fetchManagementEndpoint('/v0/management/auth-files'); + + if (!response.ok) { + throw new Error(`auth file listing failed with status ${response.status}`); + } + + const data = (await response.json()) as { files?: ManagementAuthFileRecord[] }; + return Array.isArray(data.files) ? data.files : []; +} + +async function patchAuthFilePrefix(name: string, prefix: string): Promise { + const response = await fetchManagementEndpoint('/v0/management/auth-files/fields', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name, prefix }), + }); + + if (!response.ok) { + throw new Error(`auth file prefix patch failed for ${name} with status ${response.status}`); + } +} + +async function readAuthFileMetadata(name: string): Promise { + const response = await fetchManagementEndpoint( + `/v0/management/auth-files/download?name=${encodeURIComponent(name)}` + ); + + if (!response.ok) { + throw new Error(`auth file download failed for ${name} with status ${response.status}`); + } + + const content = await response.text(); + try { + const parsed = JSON.parse(content) as { prefix?: unknown; provider?: unknown; type?: unknown }; + const providerName = + typeof parsed.provider === 'string' + ? parsed.provider + : typeof parsed.type === 'string' + ? parsed.type + : ''; + return { + prefix: typeof parsed.prefix === 'string' ? parsed.prefix.trim() : null, + provider: providerName ? mapExternalProviderName(providerName) : null, + }; + } catch { + return { prefix: null, provider: null }; + } +} + +export async function ensureManagedModelPrefixes( + providers?: CLIProxyProvider[] +): Promise { + const allowedProviders = new Set( + (providers ?? []) + .map((provider) => provider.trim()) + .filter((provider) => getManagedModelPrefix(provider)) + ); + + if (providers && allowedProviders.size === 0) { + return { checked: 0, updated: 0 }; + } + + const files = await listAuthFiles(); + let checked = 0; + let updated = 0; + + for (const record of files) { + if ((record.account_type || '').trim().toLowerCase() !== 'oauth') { + continue; + } + + const provider = normalizeProvider(record); + if (!provider) { + continue; + } + + if (allowedProviders.size > 0 && !allowedProviders.has(provider)) { + continue; + } + + const prefix = getManagedModelPrefix(provider); + if (!prefix) { + continue; + } + + try { + checked += 1; + const { prefix: currentPrefix, provider: fileProvider } = await readAuthFileMetadata( + record.name + ); + if (fileProvider !== provider) { + continue; + } + if (currentPrefix === prefix) { + continue; + } + if (currentPrefix && currentPrefix !== prefix) { + continue; + } + await patchAuthFilePrefix(record.name, prefix); + updated += 1; + } catch { + // Best-effort repair: skip files that cannot be read or patched. + } + } + + return { checked, updated }; +} diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 241c3b4e..8a4e4564 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -11,6 +11,11 @@ import { migrateDeniedAntigravityModelAliases, normalizeModelIdForProvider, } from './model-id-normalizer'; +import { + AGY_GEMINI_PRO_COMPATIBILITY_IDS, + AGY_GEMINI_PRO_HIGH_ID, + AGY_GEMINI_PRO_LOW_ID, +} from '../shared/agy-gemini-pro-compatibility'; import { stripModelConfigurationSuffixes } from '../shared/extended-context-utils'; import { GEMINI_MINOR_VERSION_COMPATIBILITY_IDS } from '../shared/gemini-minor-version-compatibility'; @@ -114,11 +119,19 @@ export const MODEL_CATALOG: Partial> = }, }, { - id: 'gemini-3.1-pro-preview', - name: 'Gemini 3.1 Pro', - description: 'Google latest Gemini Pro model via Antigravity', + id: AGY_GEMINI_PRO_HIGH_ID, + name: 'Gemini 3.1 Pro High', + description: 'Current Antigravity Gemini Pro route with higher reasoning budget', nativeImageInput: true, - thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, + thinking: { type: 'none' }, + extendedContext: true, + }, + { + id: AGY_GEMINI_PRO_LOW_ID, + name: 'Gemini 3.1 Pro Low', + description: 'Current Antigravity Gemini Pro route with the lighter quota tier', + nativeImageInput: true, + thinking: { type: 'none' }, extendedContext: true, }, { @@ -465,6 +478,16 @@ export function findModel(provider: CLIProxyProvider, modelId: string): ModelEnt if (compatibilityId) { lookupCandidates.add(compatibilityId); } + + if (isAntigravityProvider(provider)) { + const agyCompatibilityId = + AGY_GEMINI_PRO_COMPATIBILITY_IDS[ + candidate as keyof typeof AGY_GEMINI_PRO_COMPATIBILITY_IDS + ]; + if (agyCompatibilityId) { + lookupCandidates.add(agyCompatibilityId); + } + } } return catalog.models.find((m) => lookupCandidates.has(m.id.toLowerCase())); diff --git a/src/cliproxy/provider-entitlement-evidence.ts b/src/cliproxy/provider-entitlement-evidence.ts new file mode 100644 index 00000000..9b36576e --- /dev/null +++ b/src/cliproxy/provider-entitlement-evidence.ts @@ -0,0 +1,69 @@ +import type { AccountTier } from './accounts/types'; +import type { + ProviderAccessState, + ProviderCapacityState, + ProviderEntitlementEvidence, + ProviderEntitlementSource, +} from './provider-entitlement-types'; + +const RAW_TIER_LABELS: Record = { + 'free-tier': 'Free', + 'legacy-tier': 'Legacy', + 'standard-tier': 'Standard', + 'g1-pro-tier': 'Pro', + 'g1-ultra-tier': 'Ultra', +}; + +export function normalizeProviderTierId(rawTierId: string | null | undefined): AccountTier { + if (!rawTierId) return 'unknown'; + const normalized = rawTierId.trim().toLowerCase(); + if (!normalized) return 'unknown'; + if (normalized.includes('ultra')) return 'ultra'; + if (normalized.includes('pro')) return 'pro'; + if (normalized.includes('free') || normalized.includes('legacy')) return 'free'; + return 'unknown'; +} + +export function getProviderTierLabel(rawTierId: string | null | undefined): string | null { + if (!rawTierId) return null; + const normalized = rawTierId.trim().toLowerCase(); + return normalized ? (RAW_TIER_LABELS[normalized] ?? rawTierId.trim()) : null; +} + +export function buildProviderEntitlementEvidence(input: { + normalizedTier: AccountTier; + rawTierId?: string | null; + rawTierLabel?: string | null; + source: ProviderEntitlementSource; + confidence: 'high' | 'medium' | 'low'; + accessState: ProviderAccessState; + capacityState: ProviderCapacityState; + notes?: string | null; + lastVerifiedAt?: number; +}): ProviderEntitlementEvidence { + const rawTierId = input.rawTierId?.trim() || null; + return { + normalizedTier: input.normalizedTier, + rawTierId, + rawTierLabel: input.rawTierLabel ?? getProviderTierLabel(rawTierId), + source: input.source, + confidence: input.confidence, + accessState: input.accessState, + capacityState: input.capacityState, + notes: input.notes ?? null, + lastVerifiedAt: input.lastVerifiedAt ?? Date.now(), + }; +} + +export function isModelCapacityExhausted( + message: string | null | undefined, + detail: string | null | undefined, + errorCode: string | null | undefined +): boolean { + const haystack = `${message || ''} ${detail || ''} ${errorCode || ''}`.toLowerCase(); + return ( + haystack.includes('model_capacity_exhausted') || + haystack.includes('no capacity available') || + haystack.includes('capacity exhausted') + ); +} diff --git a/src/cliproxy/provider-entitlement-types.ts b/src/cliproxy/provider-entitlement-types.ts new file mode 100644 index 00000000..ef07b8db --- /dev/null +++ b/src/cliproxy/provider-entitlement-types.ts @@ -0,0 +1,33 @@ +import type { AccountTier } from './accounts/types'; + +export type ProviderEntitlementSource = + | 'runtime_api' + | 'runtime_inference' + | 'registry_cache' + | 'official_docs'; + +export type ProviderAccessState = + | 'entitled' + | 'not_entitled' + | 'capacity_exhausted' + | 'temporarily_unavailable' + | 'unknown'; + +export type ProviderCapacityState = + | 'available' + | 'capacity_exhausted' + | 'rate_limited' + | 'temporarily_unavailable' + | 'unknown'; + +export interface ProviderEntitlementEvidence { + normalizedTier: AccountTier; + rawTierId: string | null; + rawTierLabel: string | null; + source: ProviderEntitlementSource; + confidence: 'high' | 'medium' | 'low'; + accessState: ProviderAccessState; + capacityState: ProviderCapacityState; + lastVerifiedAt: number; + notes?: string | null; +} diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index 0ec6afef..27d80d6a 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -8,14 +8,21 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { getAuthDir } from './config-generator'; -import { getProviderAccounts, getPausedDir } from './account-manager'; -import { sanitizeEmail, isTokenExpired } from './auth-utils'; +import { getProviderAccounts, getPausedDir, setAccountTier } from './account-manager'; +import { getTokenExpiryTimestamp, sanitizeEmail, isTokenExpired } from './auth-utils'; import { refreshGeminiToken } from './auth/gemini-token-refresh'; import { buildGeminiCliBucketsFromParsedBuckets, type GeminiCliParsedBucket, } from './gemini-cli-quota-normalizer'; import type { GeminiCliQuotaResult, GeminiCliBucket } from './quota-types'; +import { + buildProviderEntitlementEvidence, + getProviderTierLabel, + isModelCapacityExhausted, + normalizeProviderTierId, +} from './provider-entitlement-evidence'; +import type { ProviderEntitlementEvidence } from './provider-entitlement-types'; /** Google Cloud Code API endpoints */ const GEMINI_CLI_API_BASE = 'https://cloudcode-pa.googleapis.com'; @@ -25,20 +32,13 @@ const GEMINI_CLI_CODE_ASSIST_URL = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERS const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320; const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]'; const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI'; -const GEMINI_CLI_TIER_LABELS: Record = { - 'free-tier': 'Free', - 'legacy-tier': 'Legacy', - 'standard-tier': 'Standard', - 'g1-pro-tier': 'Pro', - 'g1-ultra-tier': 'Ultra', -}; /** Auth data extracted from Gemini CLI auth file */ interface GeminiCliAuthData { accessToken: string; projectId: string | null; isExpired: boolean; - expiresAt: string | null; + expiresAt: string | number | null; } /** Raw bucket from API response */ @@ -90,6 +90,7 @@ interface GeminiCliSupplementaryInfo { tierLabel: string | null; tierId: string | null; creditBalance: number | null; + normalizedTier: 'free' | 'pro' | 'ultra' | 'unknown'; } /** @@ -130,17 +131,23 @@ function extractAccessToken(data: Record): string | null { * Extract expiry from Gemini auth file data * Handles both flat (expired) and nested (token.expiry) structures */ -function extractExpiry(data: Record): string | null { +function extractExpiry(data: Record): string | number | null { // Flat structure: { expired: "..." } if (typeof data.expired === 'string') { return data.expired; } + if (typeof data.expired === 'number') { + return data.expired; + } // Nested structure: { token: { expiry: "..." } } if (data.token && typeof data.token === 'object') { const token = data.token as Record; if (typeof token.expiry === 'string') { return token.expiry; } + if (typeof token.expiry === 'number') { + return token.expiry; + } } return null; } @@ -272,8 +279,7 @@ function resolveGeminiCliTierId(payload: GeminiCliCodeAssistResponse | null): st function resolveGeminiCliTierLabel(payload: GeminiCliCodeAssistResponse | null): string | null { const tierId = resolveGeminiCliTierId(payload); - if (!tierId) return null; - return GEMINI_CLI_TIER_LABELS[tierId] ?? tierId; + return getProviderTierLabel(tierId); } function resolveGeminiCliCreditBalance(payload: GeminiCliCodeAssistResponse | null): number | null { @@ -334,7 +340,7 @@ async function fetchGeminiCliSupplementary( if (verbose) { console.error(`[i] Gemini CLI supplementary metadata unavailable: HTTP ${response.status}`); } - return { tierLabel: null, tierId: null, creditBalance: null }; + return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' }; } const payload = (await response.json()) as GeminiCliCodeAssistResponse; @@ -342,6 +348,7 @@ async function fetchGeminiCliSupplementary( tierLabel: resolveGeminiCliTierLabel(payload), tierId: resolveGeminiCliTierId(payload), creditBalance: resolveGeminiCliCreditBalance(payload), + normalizedTier: normalizeProviderTierId(resolveGeminiCliTierId(payload)), }; } catch (error) { clearTimeout(timeoutId); @@ -349,7 +356,7 @@ async function fetchGeminiCliSupplementary( const message = error instanceof Error ? error.message : 'Unknown error'; console.error(`[i] Gemini CLI supplementary metadata skipped: ${message}`); } - return { tierLabel: null, tierId: null, creditBalance: null }; + return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' }; } } @@ -365,6 +372,7 @@ function buildGeminiCliFailureResult( retryable?: boolean; needsReauth?: boolean; isForbidden?: boolean; + entitlement?: ProviderEntitlementEvidence; } ): GeminiCliQuotaResult { return { @@ -384,6 +392,7 @@ function buildGeminiCliFailureResult( retryable: options.retryable, needsReauth: options.needsReauth, isForbidden: options.isForbidden, + entitlement: options.entitlement, }; } @@ -543,10 +552,37 @@ function buildGeminiCliHttpFailureResult( actionHint: buildGeminiCliForbiddenActionHint(parsed), isForbidden: true, retryable: false, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'medium', + accessState: 'not_entitled', + capacityState: 'unknown', + }), }); } if (status === 429) { + if (isModelCapacityExhausted(parsed.message, parsed.errorDetail, parsed.errorCode)) { + return buildGeminiCliFailureResult(accountId, projectId, { + error: parsed.message || 'Model capacity exhausted for this account right now', + httpStatus: 429, + errorCode: 'capacity_exhausted', + errorDetail: parsed.errorDetail, + actionHint: + 'Retry later or switch to another Gemini model. This indicates temporary model capacity, not an authentication failure.', + retryable: true, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'medium', + accessState: 'entitled', + capacityState: 'capacity_exhausted', + notes: 'Upstream returned MODEL_CAPACITY_EXHAUSTED for this model.', + }), + }); + } + return buildGeminiCliFailureResult(accountId, projectId, { error: parsed.message || 'Rate limited - try again later', httpStatus: 429, @@ -554,6 +590,13 @@ function buildGeminiCliHttpFailureResult( errorDetail: parsed.errorDetail, actionHint: 'Retry after a short delay.', retryable: true, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'low', + accessState: 'unknown', + capacityState: 'rate_limited', + }), }); } @@ -677,6 +720,10 @@ async function fetchWithAuthData( if (verbose) console.error(`[i] Gemini CLI buckets found: ${buckets.length}`); + if (supplementary.normalizedTier !== 'unknown') { + setAccountTier('gemini', accountId, supplementary.normalizedTier); + } + return { success: true, buckets, @@ -684,6 +731,15 @@ async function fetchWithAuthData( tierLabel: supplementary.tierLabel, tierId: supplementary.tierId, creditBalance: supplementary.creditBalance, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: supplementary.normalizedTier, + rawTierId: supplementary.tierId, + rawTierLabel: supplementary.tierLabel, + source: supplementary.tierId ? 'runtime_api' : 'runtime_inference', + confidence: supplementary.tierId ? 'high' : 'medium', + accessState: 'entitled', + capacityState: 'available', + }), lastUpdated: Date.now(), accountId, }; @@ -736,10 +792,10 @@ export async function fetchGeminiCliQuota( // Proactive refresh: refresh if expired OR expiring within 5 minutes const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; + const expiresAt = getTokenExpiryTimestamp(authData.expiresAt); const shouldRefresh = - authData.isExpired || - !authData.expiresAt || - new Date(authData.expiresAt).getTime() - Date.now() < REFRESH_LEAD_TIME_MS; + authData.isExpired || expiresAt === null || expiresAt - Date.now() < REFRESH_LEAD_TIME_MS; + let refreshedBeforeQuotaFetch = false; if (shouldRefresh) { if (verbose) @@ -748,9 +804,10 @@ export async function fetchGeminiCliQuota( ? '[i] Token expired, refreshing...' : '[i] Token expiring soon, proactive refresh...' ); - const refreshResult = await refreshGeminiToken(); + const refreshResult = await refreshGeminiToken(accountId); if (refreshResult.success) { + refreshedBeforeQuotaFetch = true; if (verbose) console.error('[i] Token refreshed successfully'); // Re-read auth data after successful refresh const refreshedAuthData = readGeminiCliAuthData(accountId); @@ -776,10 +833,10 @@ export async function fetchGeminiCliQuota( // First attempt with current token const result = await fetchWithAuthData(authData, accountId, verbose); - // If 401 error and we haven't refreshed yet, try refresh and retry - if (result.needsReauth && result.error?.includes('expired')) { + // Retry once with an account-scoped refresh when the quota endpoint rejects auth. + if (result.needsReauth && !refreshedBeforeQuotaFetch) { if (verbose) console.error('[i] Got 401, attempting refresh and retry...'); - const refreshResult = await refreshGeminiToken(); + const refreshResult = await refreshGeminiToken(accountId); if (refreshResult.success) { const refreshedAuthData = readGeminiCliAuthData(accountId); if (refreshedAuthData) { diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 1365ae42..dbe30a75 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -17,6 +17,12 @@ import { type AccountTier, } from './account-manager'; import { sanitizeEmail, isTokenExpired } from './auth-utils'; +import { + buildProviderEntitlementEvidence, + getProviderTierLabel, + normalizeProviderTierId, +} from './provider-entitlement-evidence'; +import type { ProviderEntitlementEvidence } from './provider-entitlement-types'; import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver'; /** Individual model quota info */ @@ -67,6 +73,8 @@ export interface QuotaResult { projectId?: string; /** Detected account tier based on model access */ tier?: AccountTier; + /** Richer provider entitlement evidence derived from live/runtime signals */ + entitlement?: ProviderEntitlementEvidence; } /** Google Cloud Code API endpoints */ @@ -175,6 +183,9 @@ interface ManagedResponse { interface ProjectLookupResult { projectId: string | null; tier?: AccountTier; + rawTierId?: string | null; + rawTierLabel?: string | null; + entitlement?: ProviderEntitlementEvidence; error?: string; errorCode?: string; errorDetail?: string; @@ -209,7 +220,14 @@ function buildAntigravityFailure( bodyText?: string ): Pick< QuotaResult, - 'error' | 'errorCode' | 'errorDetail' | 'actionHint' | 'retryable' | 'httpStatus' | 'needsReauth' + | 'error' + | 'errorCode' + | 'errorDetail' + | 'actionHint' + | 'retryable' + | 'httpStatus' + | 'needsReauth' + | 'entitlement' > & { isForbidden?: boolean } { const detail = normalizeErrorDetail(bodyText || ''); @@ -222,6 +240,13 @@ function buildAntigravityFailure( 'Re-authenticate this account. If CLIProxy is running, retry after the proxy finishes refreshing the token.', needsReauth: true, errorDetail: detail, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'medium', + accessState: 'unknown', + capacityState: 'unknown', + }), }; } @@ -233,6 +258,13 @@ function buildAntigravityFailure( actionHint: 'This account does not have Gemini Code Assist quota access.', isForbidden: true, errorDetail: detail, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'medium', + accessState: 'not_entitled', + capacityState: 'unknown', + }), }; } @@ -244,6 +276,13 @@ function buildAntigravityFailure( actionHint: 'Retry later. This looks temporary.', retryable: true, errorDetail: detail, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'low', + accessState: 'unknown', + capacityState: 'rate_limited', + }), }; } @@ -255,6 +294,13 @@ function buildAntigravityFailure( actionHint: 'Retry later. This looks temporary.', retryable: true, errorDetail: detail, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'low', + accessState: 'unknown', + capacityState: 'temporarily_unavailable', + }), }; } @@ -266,6 +312,13 @@ function buildAntigravityFailure( actionHint: 'Retry later. The provider appears unavailable.', retryable: true, errorDetail: detail, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'low', + accessState: 'unknown', + capacityState: 'temporarily_unavailable', + }), }; } @@ -275,6 +328,13 @@ function buildAntigravityFailure( error: `API error: ${status}`, errorCode: 'quota_request_failed', errorDetail: detail, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'low', + accessState: 'unknown', + capacityState: 'unknown', + }), }; } @@ -282,9 +342,38 @@ function buildAntigravityFailure( error: 'Quota request failed', errorCode: 'quota_request_failed', errorDetail: detail, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'low', + accessState: 'unknown', + capacityState: 'unknown', + }), }; } +function mergeAntigravityTierEvidence( + entitlement: ProviderEntitlementEvidence | undefined, + tier: AccountTier, + rawTierId: string | null, + rawTierLabel: string | null +): ProviderEntitlementEvidence | undefined { + if (tier === 'unknown' && !entitlement) { + return undefined; + } + + return buildProviderEntitlementEvidence({ + normalizedTier: tier, + rawTierId, + rawTierLabel, + source: rawTierId ? 'runtime_api' : (entitlement?.source ?? 'runtime_inference'), + confidence: rawTierId ? 'high' : (entitlement?.confidence ?? 'medium'), + accessState: entitlement?.accessState ?? 'unknown', + capacityState: entitlement?.capacityState ?? 'unknown', + notes: entitlement?.notes ?? null, + }); +} + async function readManagedResponse( response: Response, viaManagement: boolean @@ -518,21 +607,6 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | * API returns: "g1-ultra-tier", "g1-pro-tier", "standard-tier", etc. * Priority: ultra > pro > free */ -function mapTierString(tierStr: string | undefined): AccountTier { - if (!tierStr) return 'unknown'; - const normalized = tierStr.toLowerCase(); - // Match "g1-ultra-tier" or "ultra" anywhere in string - if (normalized.includes('ultra')) return 'ultra'; - // Match "g1-pro-tier" or "pro" anywhere in string - if (normalized.includes('pro')) return 'pro'; - // Match free/legacy tiers - if (normalized.includes('free') || normalized.includes('legacy')) { - return 'free'; - } - // "standard-tier" and other unknown values = unknown - return 'unknown'; -} - /** * Get project ID and tier via loadCodeAssist endpoint * Uses paidTier.id for accurate tier detection (g1-ultra-tier, g1-pro-tier) @@ -568,6 +642,14 @@ async function getProjectId(accountId: string, accessToken: string): Promise { - if (providedTarget) { - return providedTarget; + const resolvedTarget = resolvePresetDefaultTarget(preset, providedTarget); + if (resolvedTarget) { + if (preset?.defaultTarget && !providedTarget) { + console.log(info(`Using preset default target: ${preset.defaultTarget}`)); + } + return resolvedTarget; } if (yes) { return 'claude'; @@ -282,6 +287,19 @@ async function resolveDefaultTarget( return useDroidByDefault ? 'droid' : 'claude'; } +export function resolvePresetDefaultTarget( + preset: Pick | null, + providedTarget: TargetType | undefined +): TargetType | null { + if (providedTarget) { + return providedTarget; + } + if (preset?.defaultTarget) { + return preset.defaultTarget; + } + return null; +} + async function resolveClaudeLongContextPreference( models: ModelMapping, explicitPreference: boolean | undefined, @@ -349,7 +367,7 @@ export async function handleApiCreateCommand(args: string[]): Promise { parsedArgs.name, parsedArgs.yes ); - const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes); + const target = await resolveDefaultTarget(null, parsedArgs.target, parsedArgs.yes); if (name && apiProfileExists(name) && !parsedArgs.force) { console.log(fail(`API '${name}' already exists`)); @@ -464,7 +482,7 @@ export async function handleApiCreateCommand(args: string[]): Promise { const finalModels = hasClaudeMappings ? applyClaudeExtendedContextPreference(models, shouldEnableClaudeLongContext) : models; - const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes); + const target = await resolveDefaultTarget(preset, parsedArgs.target, parsedArgs.yes); if (parsedArgs.extendedContext !== undefined && !hasClaudeMappings) { console.log(''); diff --git a/src/commands/api-command/help.ts b/src/commands/api-command/help.ts index 78c6dcc4..000a1840 100644 --- a/src/commands/api-command/help.ts +++ b/src/commands/api-command/help.ts @@ -91,6 +91,9 @@ export async function showApiCommandHelp(writeLine: HelpWriter = console.log): P writeLine(` ${color('ccs api create --preset openrouter', 'command')}`); writeLine(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`); writeLine(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`); + writeLine( + ` ${color('ccs api create hf-router --preset hf', 'command')} ${dim('# defaults to droid for generic chat completions')}` + ); writeLine(` ${color('ccs api create --preset glm', 'command')}`); writeLine(''); writeLine(subheader('Claude Long Context')); diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 8bee3556..13374e65 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -6,9 +6,12 @@ import { getResolvedCatalog, refreshCatalogFromProxy, } from '../../cliproxy/catalog-cache'; +import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing'; +import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes'; import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; import type { CLIProxyProvider } from '../../cliproxy/types'; import type { RemoteModelInfo } from '../../cliproxy/management-api-types'; +import type { CliproxyProviderRoutingHints } from '../../shared/cliproxy-model-routing'; /** Fetch model definitions from CLIProxyAPI for all syncable providers */ async function fetchRemoteCatalogs( @@ -44,7 +47,17 @@ export async function handleCatalogStatus(verbose: boolean): Promise { console.log(header('Model Catalog')); console.log(''); - const cacheAge = getCacheAge(); + let routingSnapshot: Awaited> | null = null; + if (verbose) { + try { + await ensureManagedModelPrefixes(); + routingSnapshot = await getCatalogRoutingSnapshot(); + } catch { + routingSnapshot = null; + } + } + + const cacheAge = routingSnapshot?.cacheAge ?? getCacheAge(); if (cacheAge) { console.log(` Cache: ${color('synced', 'success')} (${cacheAge})`); } else { @@ -55,14 +68,14 @@ export async function handleCatalogStatus(verbose: boolean): Promise { console.log(subheader('Providers:')); for (const provider of SYNCABLE_PROVIDERS) { - const catalog = getResolvedCatalog(provider); + const catalog = routingSnapshot?.catalogs[provider] ?? getResolvedCatalog(provider); if (catalog) { const count = catalog.models.length; - console.log(` ${color(catalog.displayName.padEnd(20), 'command')} ${count} models`); + const routing = routingSnapshot?.routing[provider]; + const suffix = renderRoutingSummary(routing); + console.log(` ${color(catalog.displayName.padEnd(20), 'command')} ${count} models${suffix}`); if (verbose) { - for (const model of catalog.models) { - console.log(dim(` - ${model.id} (${model.name})`)); - } + renderVerboseRouting(provider, catalog.models, routing); } } } @@ -74,6 +87,62 @@ export async function handleCatalogStatus(verbose: boolean): Promise { console.log(''); } +function renderRoutingSummary(routing: CliproxyProviderRoutingHints | undefined): string { + if (!routing) { + return ''; + } + + const parts = [`prefix ${routing.prefix}`]; + if (routing.shadowedCount > 0) { + parts.push(`${routing.shadowedCount} shadowed`); + } + if (routing.prefixOnlyCount > 0) { + parts.push(`${routing.prefixOnlyCount} prefix-only`); + } + return parts.length > 0 ? ` ${dim(`(${parts.join(', ')})`)}` : ''; +} + +function renderVerboseRouting( + provider: CLIProxyProvider, + models: Array<{ id: string; name: string }>, + routing: CliproxyProviderRoutingHints | undefined +): void { + if (!routing) { + for (const model of models) { + console.log(dim(` - ${model.id} (${model.name})`)); + } + return; + } + + const routingMap = new Map(routing.models.map((hint) => [hint.modelId, hint])); + for (const model of models) { + const hint = routingMap.get(model.id); + console.log(dim(` - ${model.id} (${model.name})`)); + if (!hint) { + continue; + } + + console.log( + dim(` ${hint.pinnedAvailable ? 'preferred' : 'suggested'}: ${hint.recommendedModelId}`) + ); + if (hint.unprefixedStatus === 'safe') { + console.log(dim(` unprefixed: resolves to ${routing.displayName}`)); + continue; + } + + if (hint.unprefixedStatus === 'shadowed' && hint.effectiveDisplayName) { + console.log(dim(` unprefixed: currently resolves to ${hint.effectiveDisplayName}`)); + continue; + } + + console.log(dim(` unprefixed: not advertised, use ${hint.recommendedModelId}`)); + } + + if (provider === 'gemini' || provider === 'agy') { + console.log(dim(` short prefix stays backend-pinned even when unprefixed names overlap.`)); + } +} + /** Refresh catalog from CLIProxyAPI */ export async function handleCatalogRefresh(verbose: boolean): Promise { await initUI(); diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 9e1eae44..56a2863d 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -36,7 +36,7 @@ export async function showHelp(): Promise { [ 'Catalog Commands:', [ - ['catalog', 'Show catalog status (cached vs static)'], + ['catalog', 'Show catalog status, routing hints, and pinned short prefixes'], ['catalog refresh', 'Sync models from remote CLIProxy'], ['catalog reset', 'Clear cache, revert to static catalog'], ], @@ -85,7 +85,7 @@ export async function showHelp(): Promise { [ ['--backend ', 'Use specific backend: original | plus (default: from config)'], ['--target ', 'Default target for created/edited variants: claude | droid'], - ['--verbose, -v', 'Show detailed quota fetch diagnostics'], + ['--verbose, -v', 'Show detailed diagnostics including routing hints and quota fetches'], ], ], ]; @@ -100,6 +100,7 @@ export async function showHelp(): Promise { } console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.')); + console.log(dim(' Routing: use gcli/ or agy/ to keep overlapping models pinned.')); console.log(''); console.log(subheader('Notes:')); console.log(` Default fallback version: ${color(getFallbackVersion(), 'info')}`); diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index eac0690b..5df3faaf 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -103,6 +103,13 @@ function formatCliAccountLabel(account: { id: string; email?: string; nickname?: return account.nickname ? `${account.nickname} (${displayName})` : displayName; } +function resolveDisplayedTier( + accountTier: string | undefined, + liveTier: string | undefined +): string { + return (liveTier && liveTier !== 'unknown' ? liveTier : accountTier) || 'unknown'; +} + interface QuotaFailureDisplayEntry { tone: 'error' | 'info' | 'dim'; text: string; @@ -363,7 +370,7 @@ function displayAntigravityQuotaSection( if (isOnCooldown(provider, account.id)) statusParts.push(color('COOLDOWN', 'warning')); const defaultMark = account.isDefault ? color('*', 'success') : ' '; - const tier = account.tier || 'unknown'; + const tier = resolveDisplayedTier(account.tier, quota?.entitlement?.normalizedTier); const status = statusParts.join(', '); rows.push([defaultMark, formatCliAccountLabel(account), tier, avgQuota, status]); @@ -641,6 +648,9 @@ function displayGeminiCliQuotaSection( if (quota.tierLabel) { console.log(` Tier: ${dim(quota.tierLabel)}`); } + if (quota.entitlement?.rawTierId) { + console.log(` Tier ID: ${dim(quota.entitlement.rawTierId)}`); + } if (quota.creditBalance !== null && quota.creditBalance !== undefined) { console.log(` Credits: ${dim(quota.creditBalance.toLocaleString())}`); } @@ -783,6 +793,7 @@ const QUOTA_PROVIDER_RUNTIME: Record(); + +async function ensureVariantManagedModelPrefixes(provider: CLIProxyProvider): Promise { + if (variantManagedPrefixProviders.has(provider)) { + return; + } + + await ensureManagedModelPrefixes([provider]); + variantManagedPrefixProviders.add(provider); +} + function parseTargetValue(rawValue: string): TargetType | null { const normalized = rawValue.trim().toLowerCase(); if (isPersistedTargetType(normalized)) { @@ -115,6 +129,16 @@ function formatModelOption(model: ModelEntry): string { return `${model.name}${tierBadge}`; } +function getSelectableModelId( + modelId: string, + routing: CliproxyProviderRoutingHints | undefined +): string { + const hint = routing?.models.find( + (entry) => entry.modelId.toLowerCase() === modelId.toLowerCase() + ); + return hint?.recommendedModelId ?? modelId; +} + function getBackendLabel(backend: CLIProxyBackend): string { return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; } @@ -174,9 +198,18 @@ async function selectTierConfig( // Select model let model: string | undefined; if (supportsModelConfig(provider as CLIProxyProvider)) { + try { + await ensureVariantManagedModelPrefixes(provider as CLIProxyProvider); + } catch { + // Keep interactive model selection available even when prefix repair fails. + } + const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider]; const catalog = getProviderCatalog(provider as CLIProxyProvider); if (catalog) { - const modelOptions = catalog.models.map((m) => ({ id: m.id, label: formatModelOption(m) })); + const modelOptions = catalog.models.map((m) => ({ + id: getSelectableModelId(m.id, routing), + label: formatModelOption(m), + })); const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); model = await InteractivePrompt.selectFromList(`Model for ${tierName}:`, modelOptions, { defaultIndex: defaultIdx >= 0 ? defaultIdx : 0, @@ -426,9 +459,18 @@ export async function handleCreate( let model = parsedArgs.model; if (!model) { if (supportsModelConfig(provider as CLIProxyProvider)) { + try { + await ensureVariantManagedModelPrefixes(provider as CLIProxyProvider); + } catch { + // Keep variant creation available even when prefix repair fails. + } + const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider]; const catalog = getProviderCatalog(provider as CLIProxyProvider); if (catalog) { - const modelOptions = catalog.models.map((m) => ({ id: m.id, label: formatModelOption(m) })); + const modelOptions = catalog.models.map((m) => ({ + id: getSelectableModelId(m.id, routing), + label: formatModelOption(m), + })); const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); model = await InteractivePrompt.selectFromList('Select model:', modelOptions, { defaultIndex: defaultIdx >= 0 ? defaultIdx : 0, @@ -667,10 +709,18 @@ export async function handleEdit( if (changeModel) { const providerForModel = newProvider || (variant.provider as CLIProxyProfileName); if (supportsModelConfig(providerForModel as CLIProxyProvider)) { + try { + await ensureVariantManagedModelPrefixes(providerForModel as CLIProxyProvider); + } catch { + // Keep edit flow available even when prefix repair fails. + } + const routing = (await getCatalogRoutingSnapshot()).routing[ + providerForModel as CLIProxyProvider + ]; const catalog = getProviderCatalog(providerForModel as CLIProxyProvider); if (catalog) { const modelOptions = catalog.models.map((m) => ({ - id: m.id, + id: getSelectableModelId(m.id, routing), label: formatModelOption(m), })); const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); diff --git a/src/commands/update-command.ts b/src/commands/update-command.ts index c78329c9..a7354102 100644 --- a/src/commands/update-command.ts +++ b/src/commands/update-command.ts @@ -7,8 +7,15 @@ import { spawn } from 'child_process'; import { initUI, header, ok, fail, warn, info, color } from '../utils/ui'; -import { detectPackageManager } from '../utils/package-manager-detector'; -import { compareVersionsWithPrerelease } from '../utils/update-checker'; +import { + buildPackageManagerEnv, + detectCurrentInstall, + formatManualUpdateCommand, + readInstalledPackageState, + type CurrentInstall, + type InstalledPackageState, +} from '../utils/package-manager-detector'; +import { compareVersionsWithPrerelease, type UpdateResult } from '../utils/update-checker'; import { getVersion } from '../utils/version'; /** @@ -19,17 +26,75 @@ export interface UpdateOptions { beta?: boolean; } -// Version (from centralized utility) -const CCS_VERSION = getVersion(); +type TargetTag = 'latest' | 'dev'; -/** - * Handle the update command - * Checks for updates and installs the latest version - */ -export async function handleUpdateCommand(options: UpdateOptions = {}): Promise { - await initUI(); +export interface UpdateCommandDeps { + initUI: typeof initUI; + getVersion: typeof getVersion; + detectCurrentInstall: typeof detectCurrentInstall; + buildPackageManagerEnv: typeof buildPackageManagerEnv; + formatManualUpdateCommand: typeof formatManualUpdateCommand; + readInstalledPackageState: typeof readInstalledPackageState; + compareVersionsWithPrerelease: typeof compareVersionsWithPrerelease; + checkForUpdates: ( + currentVersion: string, + interactive: boolean, + channel: 'npm' | 'direct', + targetTag: TargetTag + ) => Promise; + spawn: typeof spawn; +} + +async function loadCheckForUpdates( + currentVersion: string, + interactive: boolean, + channel: 'npm' | 'direct', + targetTag: TargetTag +): Promise { + const { checkForUpdates } = await import('../utils/update-checker'); + return checkForUpdates(currentVersion, interactive, channel, targetTag); +} + +const defaultDeps: UpdateCommandDeps = { + initUI, + getVersion, + detectCurrentInstall, + buildPackageManagerEnv, + formatManualUpdateCommand, + readInstalledPackageState, + compareVersionsWithPrerelease, + checkForUpdates: loadCheckForUpdates, + spawn, +}; + +async function resolveTargetVersion( + currentVersion: string, + targetTag: TargetTag, + deps: UpdateCommandDeps +): Promise { + const result = await deps.checkForUpdates(currentVersion, true, 'npm', targetTag); + + if (result.status === 'update_available' && result.latest) { + return result.latest; + } + + if (result.status === 'no_update') { + return currentVersion; + } + + return undefined; +} + +export async function handleUpdateCommand( + options: UpdateOptions = {}, + injectedDeps: Partial = {} +): Promise { + const deps = { ...defaultDeps, ...injectedDeps }; + await deps.initUI(); const { force = false, beta = false } = options; - const targetTag = beta ? 'dev' : 'latest'; + const targetTag: TargetTag = beta ? 'dev' : 'latest'; + const currentInstall = deps.detectCurrentInstall(); + const currentVersion = deps.getVersion(); console.log(''); console.log(header('Checking for updates...')); @@ -39,21 +104,25 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< if (force) { console.log(info(`Force reinstall from @${targetTag} channel...`)); console.log(''); - await performNpmUpdate(targetTag, true); + const expectedVersion = await resolveTargetVersion(currentVersion, targetTag, deps); + await performNpmUpdate(currentInstall, targetTag, true, expectedVersion, deps); return; } - const { checkForUpdates } = await import('../utils/update-checker'); - - const updateResult = await checkForUpdates(CCS_VERSION, true, 'npm', targetTag); + const updateResult = await deps.checkForUpdates(currentVersion, true, 'npm', targetTag); if (updateResult.status === 'check_failed') { - handleCheckFailed(updateResult.message ?? 'Update check failed', targetTag); + handleCheckFailed( + updateResult.message ?? 'Update check failed', + targetTag, + currentInstall, + deps + ); return; } if (updateResult.status === 'no_update') { - handleNoUpdate(updateResult.reason); + handleNoUpdate(updateResult.reason, currentVersion); return; } @@ -65,7 +134,7 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< const isDowngrade = updateResult.latest && updateResult.current && - compareVersionsWithPrerelease(updateResult.latest, updateResult.current) < 0; + deps.compareVersionsWithPrerelease(updateResult.latest, updateResult.current) < 0; // This happens when stable user requests @dev but @dev base is older if (isDowngrade && beta) { @@ -89,13 +158,18 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< console.log(''); } - await performNpmUpdate(targetTag); + await performNpmUpdate(currentInstall, targetTag, false, updateResult.latest, deps); } /** * Handle failed update check */ -function handleCheckFailed(message: string, targetTag: string = 'latest'): void { +function handleCheckFailed( + message: string, + targetTag: string = 'latest', + currentInstall: CurrentInstall = defaultDeps.detectCurrentInstall(), + deps: UpdateCommandDeps = defaultDeps +): void { console.log(fail(message)); console.log(''); console.log(warn('Possible causes:')); @@ -105,27 +179,7 @@ function handleCheckFailed(message: string, targetTag: string = 'latest'): void console.log(''); console.log('Try again later or update manually:'); - const packageManager = detectPackageManager(); - let manualCommand: string; - - switch (packageManager) { - case 'npm': - manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`; - break; - case 'yarn': - manualCommand = `yarn global add @kaitranntt/ccs@${targetTag}`; - break; - case 'pnpm': - manualCommand = `pnpm add -g @kaitranntt/ccs@${targetTag}`; - break; - case 'bun': - manualCommand = `bun add -g @kaitranntt/ccs@${targetTag}`; - break; - default: - manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`; - } - - console.log(color(` ${manualCommand}`, 'command')); + console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); console.log(''); process.exit(1); } @@ -133,9 +187,7 @@ function handleCheckFailed(message: string, targetTag: string = 'latest'): void /** * Handle no update available */ -function handleNoUpdate(reason: string | undefined): void { - const version = getVersion(); - +function handleNoUpdate(reason: string | undefined, version: string): void { let message = `You are already on the latest version (${version})`; switch (reason) { @@ -155,17 +207,155 @@ function handleNoUpdate(reason: string | undefined): void { } /** - * Perform update via npm/yarn/pnpm/bun + * Perform update verification against the current install. */ -async function performNpmUpdate( - targetTag: string = 'latest', - isReinstall: boolean = false +async function verifyCurrentInstallVersion( + currentInstall: CurrentInstall, + targetTag: string, + expectedVersion?: string, + previousState?: InstalledPackageState, + isReinstall: boolean = false, + deps: UpdateCommandDeps = defaultDeps ): Promise { - const packageManager = detectPackageManager(); + const nextState = deps.readInstalledPackageState(currentInstall); + const installedVersion = nextState.version; + if (!installedVersion) { + console.log(''); + console.log( + fail('Update finished, but CCS could not verify the current installation version.') + ); + console.log(''); + console.log('Current install remains ambiguous. Re-run manually:'); + console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log(''); + process.exit(1); + return; + } + + const installChanged = + previousState !== undefined && + (previousState.version !== nextState.version || + previousState.packageJsonMtimeMs !== nextState.packageJsonMtimeMs || + previousState.scriptMtimeMs !== nextState.scriptMtimeMs); + + if (expectedVersion && installedVersion !== expectedVersion) { + const postUpdateResult = await deps.checkForUpdates( + installedVersion, + true, + 'npm', + targetTag as TargetTag + ); + + if (postUpdateResult.status === 'no_update') { + return; + } + + if ( + postUpdateResult.status === 'update_available' && + postUpdateResult.latest === installedVersion + ) { + return; + } + + const comparison = deps.compareVersionsWithPrerelease(installedVersion, expectedVersion); + if (comparison < 0 || installedVersion === previousState?.version) { + console.log(''); + console.log( + fail( + `Update completed outside the current installation. Current binary still reports ${installedVersion}; expected ${expectedVersion}.` + ) + ); + if (previousState?.version && previousState.version === installedVersion) { + console.log( + warn( + `The current install path did not change from ${previousState.version}; another package manager likely updated a different copy of CCS.` + ) + ); + } + console.log(''); + console.log('Re-run manually against the current install:'); + console.log( + color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command') + ); + console.log(''); + process.exit(1); + return; + } + } + + if ( + isReinstall && + previousState?.version && + installedVersion === previousState.version && + !installChanged + ) { + console.log(''); + console.log( + warn( + `Reinstall completed, but CCS could not prove that the current installation changed from ${previousState.version}. Verify the current binary manually if this reinstall was meant to repair a same-version install.` + ) + ); + } +} + +function runChildProcess( + deps: UpdateCommandDeps, + command: string, + args: string[], + options: { + isWindows: boolean; + env: NodeJS.ProcessEnv; + filterCleanupWarnings?: boolean; + } +): Promise { + return new Promise((resolve, reject) => { + const { isWindows, env, filterCleanupWarnings = false } = options; + const child = isWindows + ? deps.spawn(`${command} ${args.join(' ')}`, [], { + stdio: ['inherit', 'inherit', 'pipe'], + shell: true, + env: { ...env, NODE_NO_WARNINGS: '1' }, + }) + : deps.spawn(command, args, { stdio: 'inherit', env }); + + if (isWindows && filterCleanupWarnings && child.stderr) { + let stderrBuffer = ''; + child.stderr.on('data', (data: Buffer) => { + stderrBuffer += data.toString(); + const lines = stderrBuffer.split('\n'); + stderrBuffer = lines.pop() || ''; + for (const line of lines) { + if (!/npm warn cleanup/i.test(line)) { + process.stderr.write(line + '\n'); + } + } + }); + child.stderr.on('close', () => { + if (stderrBuffer && !/npm warn cleanup/i.test(stderrBuffer)) { + process.stderr.write(stderrBuffer); + } + }); + } + + child.on('error', reject); + child.on('exit', (code) => resolve(code ?? 0)); + }); +} + +async function performNpmUpdate( + currentInstall: CurrentInstall, + targetTag: string = 'latest', + isReinstall: boolean = false, + expectedVersion?: string, + deps: UpdateCommandDeps = defaultDeps +): Promise { + const packageManager = currentInstall.manager; let updateCommand: string; let updateArgs: string[]; let cacheCommand: string | null; let cacheArgs: string[] | null; + const childEnv = deps.buildPackageManagerEnv(currentInstall); + const previousState = deps.readInstalledPackageState(currentInstall); switch (packageManager) { case 'npm': @@ -206,72 +396,6 @@ async function performNpmUpdate( const isWindows = process.platform === 'win32'; - const performUpdate = (): void => { - // On Windows, use shell with full command string to avoid deprecation warning - // Also suppress Node deprecation warnings that may come from package managers - // Pipe stderr on Windows to filter npm cleanup warnings (EPERM on native modules) - const child = isWindows - ? spawn(`${updateCommand} ${updateArgs.join(' ')}`, [], { - stdio: ['inherit', 'inherit', 'pipe'], - shell: true, - env: { ...process.env, NODE_NO_WARNINGS: '1' }, - }) - : spawn(updateCommand, updateArgs, { stdio: 'inherit' }); - - // On Windows, filter stderr to hide npm cleanup warnings (EPERM on bcrypt.node etc.) - // These warnings are cosmetic - update succeeds despite file locking by antivirus/indexing - // Use line-buffering to handle chunk splitting (data events don't guarantee message boundaries) - if (isWindows && child.stderr) { - let stderrBuffer = ''; - child.stderr.on('data', (data: Buffer) => { - stderrBuffer += data.toString(); - const lines = stderrBuffer.split('\n'); - stderrBuffer = lines.pop() || ''; // Keep incomplete line in buffer - for (const line of lines) { - // Skip npm cleanup warnings (EPERM, ENOTEMPTY, EBUSY on native module prebuilds) - if (!/npm warn cleanup/i.test(line)) { - process.stderr.write(line + '\n'); - } - } - }); - child.stderr.on('close', () => { - // Flush remaining buffer on stream close - if (stderrBuffer && !/npm warn cleanup/i.test(stderrBuffer)) { - process.stderr.write(stderrBuffer); - } - }); - } - - child.on('exit', (code) => { - if (code === 0) { - console.log(''); - console.log(ok(`${isReinstall ? 'Reinstall' : 'Update'} successful!`)); - console.log(''); - console.log(`Run ${color('ccs --version', 'command')} to verify`); - console.log(info(`Tip: Use ${color('ccs config', 'command')} for web-based configuration`)); - console.log(''); - } else { - console.log(''); - console.log(fail(`${isReinstall ? 'Reinstall' : 'Update'} failed`)); - console.log(''); - console.log('Try manually:'); - console.log(color(` ${updateCommand} ${updateArgs.join(' ')}`, 'command')); - console.log(''); - } - process.exit(code || 0); - }); - - child.on('error', () => { - console.log(''); - console.log(fail(`Failed to run ${packageManager} ${isReinstall ? 'reinstall' : 'update'}`)); - console.log(''); - console.log('Try manually:'); - console.log(color(` ${updateCommand} ${updateArgs.join(' ')}`, 'command')); - console.log(''); - process.exit(1); - }); - }; - if (cacheCommand && cacheArgs) { // For bun on Windows, we pre-remove instead of cache clear const isBunPreRemove = packageManager === 'bun' && cacheArgs.includes('remove'); @@ -283,27 +407,62 @@ async function performNpmUpdate( : 'Cache clearing failed, proceeding anyway...'; console.log(info(stepMessage)); - // On Windows, use shell with full command string to avoid deprecation warning - const cacheChild = isWindows - ? spawn(`${cacheCommand} ${cacheArgs.join(' ')}`, [], { - stdio: 'inherit', - shell: true, - env: { ...process.env, NODE_NO_WARNINGS: '1' }, - }) - : spawn(cacheCommand, cacheArgs, { stdio: 'inherit' }); - - cacheChild.on('exit', (code) => { - if (code !== 0) { + try { + const cacheCode = await runChildProcess(deps, cacheCommand, cacheArgs, { + isWindows, + env: childEnv, + }); + if (cacheCode !== 0) { console.log(warn(failMessage)); } - performUpdate(); + } catch { + console.log(warn(failMessage)); + } + } + + try { + const exitCode = await runChildProcess(deps, updateCommand, updateArgs, { + isWindows, + env: childEnv, + filterCleanupWarnings: true, }); - cacheChild.on('error', () => { - console.log(warn(failMessage)); - performUpdate(); - }); - } else { - performUpdate(); + if (exitCode === 0) { + if (expectedVersion || previousState?.version) { + await verifyCurrentInstallVersion( + currentInstall, + targetTag, + expectedVersion, + previousState, + isReinstall, + deps + ); + } + console.log(''); + console.log(ok(`${isReinstall ? 'Reinstall' : 'Update'} successful!`)); + console.log(''); + console.log(`Run ${color('ccs --version', 'command')} to verify`); + console.log(info(`Tip: Use ${color('ccs config', 'command')} for web-based configuration`)); + console.log(''); + } else { + console.log(''); + console.log(fail(`${isReinstall ? 'Reinstall' : 'Update'} failed`)); + console.log(''); + console.log('Try manually:'); + console.log( + color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command') + ); + console.log(''); + } + + process.exit(exitCode || 0); + } catch { + console.log(''); + console.log(fail(`Failed to run ${packageManager} ${isReinstall ? 'reinstall' : 'update'}`)); + console.log(''); + console.log('Try manually:'); + console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log(''); + process.exit(1); } } diff --git a/src/shared/agy-gemini-pro-compatibility.ts b/src/shared/agy-gemini-pro-compatibility.ts new file mode 100644 index 00000000..88b4215d --- /dev/null +++ b/src/shared/agy-gemini-pro-compatibility.ts @@ -0,0 +1,15 @@ +export const AGY_GEMINI_PRO_HIGH_ID = 'gemini-3.1-pro-high'; +export const AGY_GEMINI_PRO_LOW_ID = 'gemini-3.1-pro-low'; + +export const AGY_GEMINI_PRO_COMPATIBILITY_IDS = Object.freeze({ + 'gemini-3-pro-high': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3.1-pro-high': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3-pro-low': AGY_GEMINI_PRO_LOW_ID, + 'gemini-3.1-pro-low': AGY_GEMINI_PRO_LOW_ID, + 'gemini-3-pro-preview': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3-pro-preview-customtools': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3.1-pro-preview': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3.1-pro-preview-customtools': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3-1-pro-preview': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3-1-pro-preview-customtools': AGY_GEMINI_PRO_HIGH_ID, +} satisfies Record); diff --git a/src/shared/cliproxy-model-routing.ts b/src/shared/cliproxy-model-routing.ts new file mode 100644 index 00000000..b6194ac4 --- /dev/null +++ b/src/shared/cliproxy-model-routing.ts @@ -0,0 +1,218 @@ +export const MANAGED_MODEL_PREFIXES = { + gemini: 'gcli', + agy: 'agy', +} as const; + +export type ManagedModelPrefixProvider = keyof typeof MANAGED_MODEL_PREFIXES; +export type ModelRoutingStatus = 'safe' | 'shadowed' | 'prefix-only'; + +export interface CatalogLikeModel { + id: string; + name?: string; +} + +export interface CatalogLikeProvider { + provider: string; + displayName: string; + models: CatalogLikeModel[]; +} + +export interface MergedModelLike { + id: string; + owned_by?: string; + type?: string; +} + +export interface CliproxyModelRoutingHint { + modelId: string; + modelName: string; + prefix: string; + pinnedModelId: string; + recommendedModelId: string; + pinnedAvailable: boolean; + unprefixedStatus: ModelRoutingStatus; + effectiveProvider: string | null; + effectiveDisplayName: string | null; + effectiveOwnedBy: string | null; + summary: string; +} + +export interface CliproxyProviderRoutingHints { + provider: string; + displayName: string; + prefix: string; + safeCount: number; + shadowedCount: number; + prefixOnlyCount: number; + models: CliproxyModelRoutingHint[]; +} + +const PROVIDER_OWNER_HINTS: Record = { + gemini: ['google'], + agy: ['antigravity'], + claude: ['anthropic'], + codex: ['openai'], + qwen: ['alibaba', 'qwen'], + iflow: ['iflow'], + kimi: ['kimi', 'moonshot'], + kiro: ['kiro', 'aws'], + ghcp: ['github', 'copilot'], +}; + +function normalize(value: string | null | undefined): string { + return typeof value === 'string' ? value.trim().toLowerCase() : ''; +} + +function getManagedPrefix(provider: string): string | null { + const normalizedProvider = normalize(provider); + if (normalizedProvider in MANAGED_MODEL_PREFIXES) { + return MANAGED_MODEL_PREFIXES[normalizedProvider as ManagedModelPrefixProvider]; + } + return null; +} + +function getDisplayName( + provider: string, + catalogs: Partial> +): string | null { + const normalizedProvider = normalize(provider); + const catalog = catalogs[normalizedProvider]; + return catalog?.displayName?.trim() || null; +} + +function inferProvider(model: MergedModelLike): string | null { + const type = normalize(model.type); + const owner = normalize(model.owned_by); + + const directMatches: Record = { + antigravity: 'agy', + 'github-copilot': 'ghcp', + copilot: 'ghcp', + anthropic: 'claude', + 'gemini-cli': 'gemini', + }; + + if (type && directMatches[type]) { + return directMatches[type]; + } + + for (const [provider, hints] of Object.entries(PROVIDER_OWNER_HINTS)) { + if (hints.some((hint) => type.includes(hint) || owner.includes(hint))) { + return provider; + } + } + + return null; +} + +function buildSummary( + providerDisplayName: string, + hint: Pick< + CliproxyModelRoutingHint, + 'modelId' | 'pinnedModelId' | 'pinnedAvailable' | 'unprefixedStatus' | 'effectiveDisplayName' + > +): string { + if (!hint.pinnedAvailable) { + return `${hint.modelId} does not currently advertise a live pinned route for ${hint.pinnedModelId}. Reconnect or refresh managed prefixes before treating it as pinned.`; + } + + if (hint.unprefixedStatus === 'safe') { + return `${hint.modelId} currently resolves to ${providerDisplayName}. Use ${hint.pinnedModelId} to keep it pinned.`; + } + + if (hint.unprefixedStatus === 'shadowed' && hint.effectiveDisplayName) { + return `${hint.modelId} currently resolves to ${hint.effectiveDisplayName}. Use ${hint.pinnedModelId} to force ${providerDisplayName}.`; + } + + return `${hint.modelId} is not advertised unprefixed right now. Use ${hint.pinnedModelId} to target ${providerDisplayName}.`; +} + +export function buildCliproxyRoutingHints( + catalogs: Partial>, + mergedModels: MergedModelLike[] +): Partial> { + const mergedModelMap = new Map(); + for (const model of mergedModels) { + const key = normalize(model.id); + if (key && !mergedModelMap.has(key)) { + mergedModelMap.set(key, model); + } + } + + const result: Partial> = {}; + + for (const [providerKey, catalog] of Object.entries(catalogs)) { + if (!catalog) { + continue; + } + + const prefix = getManagedPrefix(providerKey); + if (!prefix) { + continue; + } + + let safeCount = 0; + let shadowedCount = 0; + let prefixOnlyCount = 0; + + const models = catalog.models.map((model) => { + const pinnedCandidates = mergedModels + .filter((candidate) => normalize(candidate.id).endsWith(`/${normalize(model.id)}`)) + .filter((candidate) => inferProvider(candidate) === providerKey) + .map((candidate) => candidate.id) + .sort((left, right) => left.localeCompare(right)); + const managedPinnedId = `${prefix}/${model.id}`; + const pinnedAvailable = pinnedCandidates.includes(managedPinnedId); + const mergedModel = mergedModelMap.get(normalize(model.id)); + const effectiveProvider = mergedModel ? inferProvider(mergedModel) : null; + const effectiveDisplayName = + effectiveProvider && getDisplayName(effectiveProvider, catalogs) + ? getDisplayName(effectiveProvider, catalogs) + : mergedModel?.owned_by?.trim() || null; + + let unprefixedStatus: ModelRoutingStatus = 'prefix-only'; + if (!mergedModel) { + prefixOnlyCount += 1; + } else if (effectiveProvider === providerKey) { + unprefixedStatus = 'safe'; + safeCount += 1; + } else { + unprefixedStatus = 'shadowed'; + shadowedCount += 1; + } + + const hint: CliproxyModelRoutingHint = { + modelId: model.id, + modelName: model.name?.trim() || model.id, + prefix, + pinnedModelId: managedPinnedId, + recommendedModelId: managedPinnedId, + pinnedAvailable, + unprefixedStatus, + effectiveProvider, + effectiveDisplayName, + effectiveOwnedBy: mergedModel?.owned_by?.trim() || null, + summary: '', + }; + + hint.summary = buildSummary(catalog.displayName, hint); + return hint; + }); + + result[providerKey] = { + provider: providerKey, + displayName: catalog.displayName, + prefix, + safeCount, + shadowedCount, + prefixOnlyCount, + models, + }; + } + + return result; +} + +export function getManagedModelPrefix(provider: string): string | null { + return getManagedPrefix(provider); +} diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index 0baa2270..b94fe6e8 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -6,10 +6,12 @@ */ export type PresetCategory = 'recommended' | 'alternative'; +export type ProviderPresetTarget = 'claude' | 'droid'; export const PROVIDER_PRESET_IDS = [ 'openrouter', 'alibaba-coding-plan', + 'huggingface', 'ollama', 'llamacpp', 'anthropic', @@ -36,6 +38,7 @@ export interface ProviderPresetDefinition { apiKeyHint: string; category: PresetCategory; requiresApiKey: boolean; + defaultTarget?: ProviderPresetTarget; /** Additional env vars for thinking mode, etc. */ extraEnv?: Record; /** Enable always thinking mode. */ @@ -57,6 +60,7 @@ export const PROVIDER_PRESET_ALIASES: Readonly> kimi: 'km', alibaba: 'alibaba-coding-plan', acp: 'alibaba-coding-plan', + hf: 'huggingface', }); const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ @@ -135,6 +139,20 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ featured: true, icon: '/assets/providers/claude.svg', }, + { + id: 'huggingface', + name: 'Hugging Face', + description: 'Inference Providers router via OpenAI-compatible chat completions', + baseUrl: 'https://router.huggingface.co/v1', + defaultProfileName: 'hf', + defaultModel: 'openai/gpt-oss-120b:fastest', + apiKeyPlaceholder: 'hf_...', + apiKeyHint: 'Create a User Access Token at hf.co/settings/tokens', + category: 'alternative', + requiresApiKey: true, + defaultTarget: 'droid', + badge: 'Router', + }, { id: 'glm', name: 'GLM', diff --git a/src/utils/hooks/image-analysis-runtime-status.ts b/src/utils/hooks/image-analysis-runtime-status.ts index 9ec8a388..4322b9e5 100644 --- a/src/utils/hooks/image-analysis-runtime-status.ts +++ b/src/utils/hooks/image-analysis-runtime-status.ts @@ -37,6 +37,13 @@ const defaultDeps: ImageAnalysisRuntimeStatusDeps = { isCliproxyRunning: () => isCliproxyRunning(), }; +function mergeDefinedDeps(defaults: T, overrides: Partial): T { + const definedOverrides = Object.fromEntries( + Object.entries(overrides as Record).filter(([, value]) => value !== undefined) + ) as Partial; + return { ...defaults, ...definedOverrides }; +} + async function resolveAuthReadiness( status: ImageAnalysisStatus, deps: ImageAnalysisRuntimeStatusDeps @@ -160,7 +167,7 @@ export async function hydrateImageAnalysisRuntimeStatus( baseStatus: ImageAnalysisStatus, deps: Partial = {} ): Promise { - const resolvedDeps = { ...defaultDeps, ...deps }; + const resolvedDeps = mergeDefinedDeps(defaultDeps, deps); const authStatus = await resolveAuthReadiness(baseStatus, resolvedDeps); const proxyStatus = await resolveProxyReadiness(baseStatus, resolvedDeps); const mergedStatus = { diff --git a/src/utils/package-manager-detector.ts b/src/utils/package-manager-detector.ts index ac5840a0..606c58ed 100644 --- a/src/utils/package-manager-detector.ts +++ b/src/utils/package-manager-detector.ts @@ -1,75 +1,344 @@ /** * Package Manager Detector Utilities * - * Cross-platform package manager detection utilities for CCS. - * Now only supports npm-based installation (npm/yarn/pnpm/bun). + * Detect the package manager and install root that own the CURRENT CCS binary. + * This is intentionally different from "which package manager has CCS installed + * somewhere on the machine" because self-update must target the current install. */ -import * as path from 'path'; import * as fs from 'fs'; -import { spawnSync } from 'child_process'; +import * as path from 'path'; + +export type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'; + +export interface CurrentInstall { + manager: PackageManager; + scriptPath: string; + resolvedScriptPath: string; + packageRoot: string | null; + prefix: string | null; + detectionSource: 'path' | 'package-root' | 'default'; +} + +export interface InstalledPackageState { + version: string | null; + packageJsonMtimeMs: number | null; + scriptMtimeMs: number | null; +} + +const CCS_PACKAGE_NAME = '@kaitranntt/ccs'; + +function resolveScriptPath(scriptPath: string): string { + if (path.win32.isAbsolute(scriptPath)) { + return scriptPath; + } + + try { + return fs.realpathSync(scriptPath); + } catch { + return path.resolve(scriptPath); + } +} + +function findPackageRoot(scriptPath: string): string | null { + let currentDir = path.dirname(scriptPath); + + for (let i = 0; i < 8; i++) { + const packageJsonPath = path.join(currentDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + name?: string; + }; + if (packageJson.name === CCS_PACKAGE_NAME) { + return currentDir; + } + } catch { + // Ignore malformed package.json and keep walking upward. + } + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + + return null; +} + +function getPrefixBeforeMarker(packageRoot: string, marker: string): string | null { + const index = packageRoot.lastIndexOf(marker); + return index >= 0 ? packageRoot.slice(0, index) || path.parse(packageRoot).root : null; +} + +function inferInstallFromPath( + targetPath: string +): Pick | null { + const normalizedPath = targetPath.split(path.sep).join('/'); + + if ( + normalizedPath.includes(`/install/global/node_modules/@kaitranntt/ccs`) || + normalizedPath.includes(`/.bun/install/global/node_modules/@kaitranntt/ccs`) + ) { + return { + manager: 'bun', + prefix: getPrefixBeforeMarker( + targetPath, + `${path.sep}install${path.sep}global${path.sep}node_modules` + ), + }; + } + + if (normalizedPath.includes(`/global/node_modules/@kaitranntt/ccs`)) { + return { + manager: 'yarn', + prefix: getPrefixBeforeMarker(targetPath, `${path.sep}global${path.sep}node_modules`), + }; + } + + if ( + normalizedPath.includes('/global/') && + normalizedPath.includes('/.pnpm/') && + normalizedPath.includes('/node_modules/@kaitranntt/ccs') + ) { + const pnpmVirtualStoreMatch = targetPath.match( + new RegExp( + `${path.sep.replace(/\\/g, '\\\\')}global${path.sep.replace(/\\/g, '\\\\')}[^${path.sep.replace( + /\\/g, + '\\\\' + )}]+${path.sep.replace(/\\/g, '\\\\')}\\.pnpm${path.sep.replace(/\\/g, '\\\\')}` + ) + ); + if (pnpmVirtualStoreMatch) { + return { + manager: 'pnpm', + prefix: getPrefixBeforeMarker(targetPath, `${path.sep}global${path.sep}`), + }; + } + } + + if ( + normalizedPath.includes('/global/') && + normalizedPath.includes('/node_modules/@kaitranntt/ccs') + ) { + const pnpmFlatMatch = normalizedPath.match(/\/global\/([^/]+)\/node_modules\/@kaitranntt\/ccs/); + + if (!pnpmFlatMatch || pnpmFlatMatch[1] === 'lib') { + return null; + } + + return { + manager: 'pnpm', + prefix: getPrefixBeforeMarker(targetPath, `${path.sep}global${path.sep}`), + }; + } + + if ( + normalizedPath.includes('/.pnpm/') && + normalizedPath.includes('/node_modules/@kaitranntt/ccs') + ) { + return { + manager: 'pnpm', + prefix: null, + }; + } + + if (normalizedPath.includes('/lib/node_modules/@kaitranntt/ccs')) { + return { + manager: 'npm', + prefix: getPrefixBeforeMarker(targetPath, `${path.sep}lib${path.sep}node_modules`), + }; + } + + if ( + normalizedPath.includes('/node_modules/@kaitranntt/ccs') && + !normalizedPath.includes('/global/node_modules/@kaitranntt/ccs') && + !normalizedPath.includes('/install/global/node_modules/@kaitranntt/ccs') && + !normalizedPath.includes('/.pnpm/') + ) { + return { + manager: 'npm', + prefix: getPrefixBeforeMarker(targetPath, `${path.sep}node_modules`), + }; + } + + return null; +} /** - * Detect which package manager was used for installation + * Detect the current install owner from the path of the running script. + * Defaults to npm when the path is ambiguous because npm's global layout is + * the safest fallback for the existing manual remediation commands. */ -export function detectPackageManager(): 'npm' | 'yarn' | 'pnpm' | 'bun' { - const scriptPath = process.argv[1]; +export function detectCurrentInstall(scriptPath: string = process.argv[1] || ''): CurrentInstall { + const resolvedScriptPath = resolveScriptPath(scriptPath); + const pathMatch = inferInstallFromPath(resolvedScriptPath) ?? inferInstallFromPath(scriptPath); + const packageRoot = findPackageRoot(resolvedScriptPath); - // Check if script path contains package manager indicators - if (scriptPath.includes('.pnpm')) return 'pnpm'; - if (scriptPath.includes('yarn')) return 'yarn'; - if (scriptPath.includes('bun')) return 'bun'; - - // Check parent directories for lock files - const binDir = path.dirname(scriptPath); - - let checkDir = binDir; - for (let i = 0; i < 5; i++) { - if (fs.existsSync(path.join(checkDir, 'pnpm-lock.yaml'))) return 'pnpm'; - if (fs.existsSync(path.join(checkDir, 'yarn.lock'))) return 'yarn'; - if (fs.existsSync(path.join(checkDir, 'bun.lockb'))) return 'bun'; - checkDir = path.dirname(checkDir); + if (pathMatch) { + return { + manager: pathMatch.manager, + scriptPath, + resolvedScriptPath, + packageRoot, + prefix: pathMatch.prefix, + detectionSource: 'path', + }; } - // Check if package managers are available on the system - try { - const yarnResult = spawnSync('yarn', ['global', 'list', '--pattern', '@kaitranntt/ccs'], { - encoding: 'utf8', - shell: true, - timeout: 5000, - }); - if (yarnResult.status === 0 && yarnResult.stdout.includes('@kaitranntt/ccs')) { - return 'yarn'; + if (packageRoot) { + const packageRootMatch = inferInstallFromPath(packageRoot); + if (packageRootMatch) { + return { + manager: packageRootMatch.manager, + scriptPath, + resolvedScriptPath, + packageRoot, + prefix: packageRootMatch.prefix, + detectionSource: 'package-root', + }; } - } catch (_err) { - // Continue to next check } + return { + manager: 'npm', + scriptPath, + resolvedScriptPath, + packageRoot, + prefix: null, + detectionSource: 'default', + }; +} + +/** + * Backward-compatible helper for callers that only need the package manager. + */ +export function detectPackageManager(scriptPath: string = process.argv[1] || ''): PackageManager { + return detectCurrentInstall(scriptPath).manager; +} + +export function buildPackageManagerEnv( + install: CurrentInstall, + baseEnv: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { + if (!install.prefix) { + return { ...baseEnv }; + } + + switch (install.manager) { + case 'npm': + return { + ...baseEnv, + npm_config_prefix: install.prefix, + NPM_CONFIG_PREFIX: install.prefix, + }; + case 'yarn': + return { + ...baseEnv, + YARN_GLOBAL_FOLDER: install.prefix, + }; + case 'pnpm': + return { + ...baseEnv, + PNPM_HOME: install.prefix, + }; + case 'bun': + return { + ...baseEnv, + BUN_INSTALL: install.prefix, + }; + default: + return { ...baseEnv }; + } +} + +export function readInstalledPackageVersion(install: CurrentInstall): string | null { + return readInstalledPackageState(install).version; +} + +function readFileMtimeMs(filePath: string): number | null { try { - const pnpmResult = spawnSync('pnpm', ['list', '-g', '--pattern', '@kaitranntt/ccs'], { - encoding: 'utf8', - shell: true, - timeout: 5000, - }); - if (pnpmResult.status === 0 && pnpmResult.stdout.includes('@kaitranntt/ccs')) { - return 'pnpm'; - } - } catch (_err) { - // Continue to next check + return fs.statSync(filePath).mtimeMs; + } catch { + return null; + } +} + +export function readInstalledPackageState(install: CurrentInstall): InstalledPackageState { + if (!install.packageRoot) { + return { + version: null, + packageJsonMtimeMs: null, + scriptMtimeMs: readFileMtimeMs(install.resolvedScriptPath), + }; } + const packageJsonPath = path.join(install.packageRoot, 'package.json'); + const packageJsonMtimeMs = readFileMtimeMs(packageJsonPath); + const scriptMtimeMs = readFileMtimeMs(install.resolvedScriptPath); + try { - const bunResult = spawnSync('bun', ['pm', 'ls', '-g', '--pattern', '@kaitranntt/ccs'], { - encoding: 'utf8', - shell: true, - timeout: 5000, - }); - if (bunResult.status === 0 && bunResult.stdout.includes('@kaitranntt/ccs')) { - return 'bun'; - } - } catch (_err) { - // Continue to default - } - - return 'npm'; + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + version?: string; + }; + + return { + version: typeof packageJson.version === 'string' ? packageJson.version : null, + packageJsonMtimeMs, + scriptMtimeMs, + }; + } catch { + return { + version: null, + packageJsonMtimeMs, + scriptMtimeMs, + }; + } +} + +function quoteForShell(value: string): string { + if (/^[A-Za-z0-9_./:-]+$/.test(value)) { + return value; + } + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function quoteForCmd(value: string): string { + return value.replace(/'/g, "''"); +} + +function formatWindowsEnvCommand(envVar: string, value: string, command: string): string { + return `powershell -NoProfile -Command "$env:${envVar}='${quoteForCmd(value)}'; ${command}"`; +} + +export function formatManualUpdateCommand( + targetTag: string, + install: CurrentInstall = detectCurrentInstall(), + platform: NodeJS.Platform = process.platform +): string { + const command = { + npm: `npm install -g @kaitranntt/ccs@${targetTag}`, + yarn: `yarn global add @kaitranntt/ccs@${targetTag}`, + pnpm: `pnpm add -g @kaitranntt/ccs@${targetTag}`, + bun: `bun add -g @kaitranntt/ccs@${targetTag}`, + }[install.manager]; + + if (!install.prefix) { + return command; + } + + const envVar = { + npm: 'NPM_CONFIG_PREFIX', + yarn: 'YARN_GLOBAL_FOLDER', + pnpm: 'PNPM_HOME', + bun: 'BUN_INSTALL', + }[install.manager]; + + if (platform === 'win32') { + return formatWindowsEnvCommand(envVar, install.prefix, command); + } + + return `${envVar}=${quoteForShell(install.prefix)} ${command}`; } diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts index 019e0c6e..002a17a5 100644 --- a/src/utils/update-checker.ts +++ b/src/utils/update-checker.ts @@ -21,7 +21,7 @@ interface UpdateCache { dismissed_version: string | null; } -interface UpdateResult { +export interface UpdateResult { status: 'update_available' | 'no_update' | 'check_failed'; reason?: string; latest?: string; diff --git a/src/web-server/index.ts b/src/web-server/index.ts index fd2de5a6..9f5ff51f 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -13,6 +13,8 @@ import { WebSocketServer } from 'ws'; import { setupWebSocket } from './websocket'; import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware'; import { requestLoggingMiddleware } from './middleware/request-logging-middleware'; +import { ensureManagedModelPrefixes } from '../cliproxy/managed-model-prefixes'; +import { getProxyTarget } from '../cliproxy/proxy-target-resolver'; import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync'; import { shutdownUsageAggregator } from './usage/aggregator'; import { createLogger } from '../services/logging'; @@ -119,6 +121,14 @@ export async function startServer(options: ServerOptions): Promise { + logger.warn('cliproxy.prefix_sync_failed', 'Managed model prefix repair failed', { + error: error instanceof Error ? error.message : String(error), + }); + }); + } + // Combined cleanup function const cleanup = () => { wsCleanup(); diff --git a/src/web-server/routes/catalog-routes.ts b/src/web-server/routes/catalog-routes.ts index c2cfba08..ea27811b 100644 --- a/src/web-server/routes/catalog-routes.ts +++ b/src/web-server/routes/catalog-routes.ts @@ -1,5 +1,5 @@ import { Router, Request, Response } from 'express'; -import { getResolvedCatalogSnapshot } from '../../cliproxy/catalog-cache'; +import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing'; const router = Router(); @@ -9,9 +9,10 @@ const router = Router(); */ router.get('/', async (_req: Request, res: Response): Promise => { try { - const snapshot = await getResolvedCatalogSnapshot(); + const snapshot = await getCatalogRoutingSnapshot(); res.json({ catalogs: snapshot.catalogs, + routing: snapshot.routing, source: snapshot.source, cache: { synced: snapshot.source !== 'static' || snapshot.cacheAge !== null, diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 1744da18..0b2bdf01 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -32,6 +32,7 @@ import { buildManagementHeaders, } from '../../cliproxy/proxy-target-resolver'; import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; +import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { tryKiroImport } from '../../cliproxy/auth/kiro-import'; import { @@ -677,6 +678,12 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise } }); +router.get('/local-runtime-readiness', async (_req: Request, res: Response): Promise => { + try { + res.json({ runtimes: await getLocalRuntimeReadiness() }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + router.post('/cliproxy-bridge', (req: Request, res: Response): void => { const shape = validatePayloadShape(req.body, ['provider', 'name', 'target']); if (!shape.ok) { diff --git a/tests/integration/update-command-install-origin.test.ts b/tests/integration/update-command-install-origin.test.ts new file mode 100644 index 00000000..294a45f3 --- /dev/null +++ b/tests/integration/update-command-install-origin.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +let tempRoot = ''; +let currentPackageRoot = ''; +let currentPrefix = ''; +let bunPackageRoot = ''; +let fakeBinDir = ''; +let originalArgv1 = ''; +let originalPath = ''; +let originalConsoleLog: typeof console.log; +let originalProcessExit: typeof process.exit; +let logLines: string[] = []; +let exitCodes: number[] = []; + +function writePackage(root: string, version: string): void { + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: '@kaitranntt/ccs', version }, null, 2) + ); +} + +function readPackageVersion(root: string): string { + return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version as string; +} + +function writeExecutable(filePath: string, contents: string): void { + writeFileSync(filePath, contents); + chmodSync(filePath, 0o755); +} + +async function loadHandleUpdateCommand() { + const mod = await import(`../../src/commands/update-command?test=${Date.now()}-${Math.random()}`); + return mod.handleUpdateCommand; +} + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'ccs-update-origin-')); + currentPrefix = join(tempRoot, 'prefix'); + currentPackageRoot = join(currentPrefix, 'lib', 'node_modules', '@kaitranntt', 'ccs'); + bunPackageRoot = join( + tempRoot, + '.bun', + 'install', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + fakeBinDir = join(tempRoot, 'bin'); + + mkdirSync(join(currentPackageRoot, 'dist'), { recursive: true }); + mkdirSync(fakeBinDir, { recursive: true }); + writePackage(currentPackageRoot, '7.67.0-dev.5'); + writePackage(bunPackageRoot, '0.0.0-stale'); + + originalArgv1 = process.argv[1] ?? ''; + process.argv[1] = join(currentPackageRoot, 'dist', 'ccs.js'); + + originalPath = process.env.PATH ?? ''; + process.env.PATH = `${fakeBinDir}:${originalPath}`; + + logLines = []; + exitCodes = []; + originalConsoleLog = console.log; + console.log = (...args: unknown[]) => { + logLines.push(args.map(String).join(' ')); + }; + + originalProcessExit = process.exit; + process.exit = ((code?: number) => { + exitCodes.push(code ?? 0); + }) as typeof process.exit; +}); + +afterEach(() => { + console.log = originalConsoleLog; + process.exit = originalProcessExit; + process.argv[1] = originalArgv1; + process.env.PATH = originalPath; + if (tempRoot) { + rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +async function waitForExitCode(expectedCode: number): Promise { + for (let attempt = 0; attempt < 50; attempt++) { + if (exitCodes.includes(expectedCode)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`process.exit(${expectedCode}) was not observed`); +} + +describe('update-command install origin integration', () => { + it('updates the current npm-owned install instead of drifting to another manager', async () => { + writeExecutable( + join(fakeBinDir, 'npm'), + `#!/bin/sh +if [ "$npm_config_prefix" = "${currentPrefix}" ] || [ "$NPM_CONFIG_PREFIX" = "${currentPrefix}" ]; then + cat > "${join(currentPackageRoot, 'package.json')}" <<'EOF' +{"name":"@kaitranntt/ccs","version":"7.67.0-dev.9"} +EOF + exit 0 +fi +exit 13 +` + ); + + writeExecutable( + join(fakeBinDir, 'bun'), + `#!/bin/sh +cat > "${join(bunPackageRoot, 'package.json')}" <<'EOF' +{"name":"@kaitranntt/ccs","version":"7.67.0-dev.9"} +EOF +exit 0 +` + ); + + const handleUpdateCommand = await loadHandleUpdateCommand(); + + await handleUpdateCommand( + { beta: true }, + { + initUI: async () => {}, + getVersion: () => '7.67.0-dev.5', + compareVersionsWithPrerelease: (left: string, right: string) => left.localeCompare(right), + checkForUpdates: async () => ({ + status: 'update_available', + current: '7.67.0-dev.5', + latest: '7.67.0-dev.9', + }), + } + ); + await waitForExitCode(0); + + expect(readPackageVersion(currentPackageRoot)).toBe('7.67.0-dev.9'); + expect(readPackageVersion(bunPackageRoot)).toBe('0.0.0-stale'); + expect(logLines.join('\n')).toContain('Updating via npm'); + }); + + it('fails if the update exits 0 but the current install stays stale', async () => { + writeExecutable( + join(fakeBinDir, 'npm'), + `#!/bin/sh +exit 0 +` + ); + + writeExecutable( + join(fakeBinDir, 'bun'), + `#!/bin/sh +cat > "${join(bunPackageRoot, 'package.json')}" <<'EOF' +{"name":"@kaitranntt/ccs","version":"7.67.0-dev.9"} +EOF +exit 0 +` + ); + + const handleUpdateCommand = await loadHandleUpdateCommand(); + + await handleUpdateCommand( + { beta: true }, + { + initUI: async () => {}, + getVersion: () => '7.67.0-dev.5', + compareVersionsWithPrerelease: (left: string, right: string) => left.localeCompare(right), + checkForUpdates: async () => ({ + status: 'update_available', + current: '7.67.0-dev.5', + latest: '7.67.0-dev.9', + }), + } + ); + await waitForExitCode(1); + + expect(readPackageVersion(currentPackageRoot)).toBe('7.67.0-dev.5'); + expect(logLines.join('\n')).toContain('outside the current installation'); + expect(logLines.join('\n')).toContain('NPM_CONFIG_PREFIX='); + }); +}); diff --git a/tests/npm/cli.test.js b/tests/npm/cli.test.js index d32055b4..77c3a0a5 100644 --- a/tests/npm/cli.test.js +++ b/tests/npm/cli.test.js @@ -1,13 +1,24 @@ const assert = require('assert'); +const fs = require('fs'); const { execSync } = require('child_process'); const path = require('path'); const { createTestEnvironment } = require('../shared/fixtures/test-environment'); describe('npm CLI', () => { - const ccsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js'); + const distCcsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js'); + const srcCcsPath = path.join(__dirname, '..', '..', 'src', 'ccs.ts'); let testEnv; let testCcsHome; + function buildCliCommand(args = '') { + if (fs.existsSync(distCcsPath)) { + return `node "${distCcsPath}" ${args}`; + } + + // Some test files rebuild or clean dist during the same Bun process. + return `bun "${srcCcsPath}" ${args}`; + } + beforeAll(() => { // Create isolated test environment testEnv = createTestEnvironment(); @@ -30,7 +41,7 @@ describe('npm CLI', () => { // Helper to run CLI with test environment function runCli(args, options = {}) { - return execSync(`node "${ccsPath}" ${args}`, { + return execSync(buildCliCommand(args), { ...options, env: { ...process.env, CCS_HOME: testCcsHome } }); diff --git a/tests/npm/special-commands.test.js b/tests/npm/special-commands.test.js index 84f3f4d6..13726274 100644 --- a/tests/npm/special-commands.test.js +++ b/tests/npm/special-commands.test.js @@ -1,25 +1,36 @@ const assert = require('assert'); +const fs = require('fs'); const { execSync } = require('child_process'); const path = require('path'); describe('integration: special commands', () => { - const ccsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js'); + const distCcsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js'); + const srcCcsPath = path.join(__dirname, '..', '..', 'src', 'ccs.ts'); + + function buildCliCommand(args = '') { + if (fs.existsSync(distCcsPath)) { + return `node "${distCcsPath}" ${args}`; + } + + // Some tests rebuild or clean dist during the same Bun run. + return `bun "${srcCcsPath}" ${args}`; + } it('shows version with --version', () => { - const output = execSync(`node ${ccsPath} --version`, { encoding: 'utf8' }); + const output = execSync(buildCliCommand('--version'), { encoding: 'utf8' }); assert(output.includes('CCS (Claude Code Switch)')); assert(/v\d+\.\d+\.\d+/.test(output)); }); it('shows version with -v', () => { - const output = execSync(`node ${ccsPath} -v`, { encoding: 'utf8' }); + const output = execSync(buildCliCommand('-v'), { encoding: 'utf8' }); assert(/v\d+\.\d+\.\d+/.test(output)); }); it('shows help with --help', function() { // Note: Requires claude installation, so we just test that it doesn't crash try { - const output = execSync(`node ${ccsPath} --help`, { + const output = execSync(buildCliCommand('--help'), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); @@ -31,14 +42,14 @@ describe('integration: special commands', () => { }); it('handles --install command', () => { - const output = execSync(`node ${ccsPath} --install`, { encoding: 'utf8' }); + const output = execSync(buildCliCommand('--install'), { encoding: 'utf8' }); assert(output.includes('Feature not available')); assert(output.includes('under development')); assert(output.includes('.claude/ integration testing')); }); it('handles --uninstall command', () => { - const output = execSync(`node ${ccsPath} --uninstall`, { encoding: 'utf8' }); + const output = execSync(buildCliCommand('--uninstall'), { encoding: 'utf8' }); assert(output.includes('Uninstalling CCS')); assert(output.includes('[OK] Uninstall complete!') || output.includes('Nothing to uninstall')); }); @@ -47,7 +58,7 @@ describe('integration: special commands', () => { it.skip('parses --force flag without error', function() { // Skip: requires network/child process // Note: This will fail at update check (no network in test), but proves flag parsing works try { - execSync(`node ${ccsPath} update --force`, { + execSync(buildCliCommand('update --force'), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 @@ -62,7 +73,7 @@ describe('integration: special commands', () => { it.skip('parses --beta flag without error', function() { // Skip: requires network/child process try { - execSync(`node ${ccsPath} update --beta`, { + execSync(buildCliCommand('update --beta'), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 @@ -75,7 +86,7 @@ describe('integration: special commands', () => { it.skip('parses combined --force --beta flags', function() { // Skip: requires network/child process try { - execSync(`node ${ccsPath} update --force --beta`, { + execSync(buildCliCommand('update --force --beta'), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 @@ -89,7 +100,7 @@ describe('integration: special commands', () => { it.skip('shows appropriate error for direct install with --beta', function() { // Skip: requires network/child process // Test direct install rejection of --beta flag try { - execSync(`node ${ccsPath} update --beta`, { + execSync(buildCliCommand('update --beta'), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 @@ -111,4 +122,4 @@ describe('integration: special commands', () => { } }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/api/profile-writer-anthropic.test.ts b/tests/unit/api/profile-writer-anthropic.test.ts index 6eab981b..0416c321 100644 --- a/tests/unit/api/profile-writer-anthropic.test.ts +++ b/tests/unit/api/profile-writer-anthropic.test.ts @@ -85,6 +85,52 @@ describe('profile-writer Anthropic direct', () => { expect(settings.env.ANTHROPIC_API_KEY).toBeUndefined(); }); + it('persists droid as the saved target for generic API profiles', () => { + const result = createApiProfile( + 'hf-target', + 'https://router.huggingface.co/v1', + 'hf_testkey123', + { + default: 'openai/gpt-oss-120b:fastest', + opus: 'openai/gpt-oss-120b:fastest', + sonnet: 'openai/gpt-oss-120b:fastest', + haiku: 'openai/gpt-oss-120b:fastest', + }, + 'droid' + ); + + expect(result.success).toBe(true); + + const configPath = path.join(tempHome, '.ccs', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + + expect(config.profiles['hf-target']).toBe('~/.ccs/hf-target.settings.json'); + expect(config.profile_targets['hf-target']).toBe('droid'); + }); + + it('does not persist a non-default target entry when the target is claude', () => { + const result = createApiProfile( + 'hf-target-claude', + 'https://router.huggingface.co/v1', + 'hf_testkey123', + { + default: 'openai/gpt-oss-120b:fastest', + opus: 'openai/gpt-oss-120b:fastest', + sonnet: 'openai/gpt-oss-120b:fastest', + haiku: 'openai/gpt-oss-120b:fastest', + }, + 'claude' + ); + + expect(result.success).toBe(true); + + const configPath = path.join(tempHome, '.ccs', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + + expect(config.profiles['hf-target-claude']).toBe('~/.ccs/hf-target-claude.settings.json'); + expect(config.profile_targets?.['hf-target-claude']).toBeUndefined(); + }); + it('preserves OpenRouter ANTHROPIC_API_KEY blank behavior', () => { const result = createApiProfile( 'openrouter-test', diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts index 7a233f27..e672b496 100644 --- a/tests/unit/api/provider-presets.test.ts +++ b/tests/unit/api/provider-presets.test.ts @@ -73,6 +73,25 @@ describe('provider-presets', () => { expect(preset?.defaultProfileName).toBe('qwen-api'); }); + it('resolves Hugging Face preset metadata', () => { + const preset = getPresetById('huggingface'); + expect(preset?.id).toBe('huggingface'); + expect(preset?.baseUrl).toBe('https://router.huggingface.co/v1'); + expect(preset?.defaultProfileName).toBe('hf'); + expect(preset?.defaultModel).toBe('openai/gpt-oss-120b:fastest'); + expect(preset?.defaultTarget).toBe('droid'); + expect(preset?.apiKeyPlaceholder).toBe('hf_...'); + }); + + it('resolves hf alias to the Hugging Face preset', () => { + const preset = getPresetById('hf'); + expect(preset?.id).toBe('huggingface'); + }); + + it('treats hf alias as a valid preset id', () => { + expect(isValidPresetId('hf')).toBe(true); + }); + it('keeps Anthropic direct last in the recommended preset order and reuses the Claude logo', () => { const recommendedPresetIds = PROVIDER_PRESETS.filter( (preset) => preset.category === 'recommended' diff --git a/tests/unit/cliproxy/auth-utils.test.ts b/tests/unit/cliproxy/auth-utils.test.ts index a6003b80..f6944882 100644 --- a/tests/unit/cliproxy/auth-utils.test.ts +++ b/tests/unit/cliproxy/auth-utils.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect } from 'bun:test'; -import { sanitizeEmail, isTokenExpired } from '../../../src/cliproxy/auth-utils'; +import { + getTokenExpiryTimestamp, + isTokenExpired, + sanitizeEmail, +} from '../../../src/cliproxy/auth-utils'; describe('Auth Utilities', () => { describe('sanitizeEmail', () => { @@ -75,14 +79,30 @@ describe('Auth Utilities', () => { expect(isTokenExpired(futureISO)).toBe(false); }); - it('should handle Unix timestamp strings', () => { - // JavaScript Date can parse numeric strings as timestamps + it('should handle Unix timestamp strings deterministically', () => { const pastTimestamp = String(Date.now() - 86400000); // Yesterday - // Note: Date parsing of pure numbers as strings is inconsistent - // This test documents the actual behavior - const result = isTokenExpired(pastTimestamp); - // The behavior depends on how Date parses the string - expect(typeof result).toBe('boolean'); + expect(isTokenExpired(pastTimestamp)).toBe(true); + }); + + it('should handle Unix timestamps provided as numbers', () => { + const futureTimestamp = Date.now() + 86400000; + expect(isTokenExpired(futureTimestamp)).toBe(false); + }); + + it('should treat Unix-seconds values as seconds, not milliseconds', () => { + const futureUnixSeconds = Math.floor((Date.now() + 60000) / 1000); + expect(isTokenExpired(futureUnixSeconds)).toBe(false); + expect(isTokenExpired(String(futureUnixSeconds))).toBe(false); + }); + + it('should expose normalized expiry timestamps for string and numeric inputs', () => { + const futureTimestamp = Date.now() + 60000; + expect(getTokenExpiryTimestamp(futureTimestamp)).toBe(futureTimestamp); + expect(getTokenExpiryTimestamp(String(futureTimestamp))).toBe(futureTimestamp); + const futureUnixSeconds = Math.floor((Date.now() + 60000) / 1000); + expect(getTokenExpiryTimestamp(futureUnixSeconds)).toBe(futureUnixSeconds * 1000); + expect(getTokenExpiryTimestamp(String(futureUnixSeconds))).toBe(futureUnixSeconds * 1000); + expect(getTokenExpiryTimestamp('not-a-date')).toBeNull(); }); }); }); diff --git a/tests/unit/cliproxy/managed-model-prefixes.test.ts b/tests/unit/cliproxy/managed-model-prefixes.test.ts new file mode 100644 index 00000000..cb2e12d9 --- /dev/null +++ b/tests/unit/cliproxy/managed-model-prefixes.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test'; +import { ensureManagedModelPrefixes } from '../../../src/cliproxy/managed-model-prefixes'; + +const originalFetch = global.fetch; + +interface MockAuthFileRecord { + account_type?: string; + name: string; + provider?: string; + type?: string; +} + +interface DownloadResponse { + body: string; + status?: number; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function textResponse(body: string, status = 200): Response { + return new Response(body, { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function installFetchMock(options: { + files: MockAuthFileRecord[]; + downloads?: Record; + patchStatuses?: Record; +}) { + const requests: Array<{ url: string; method: string; body: string | undefined }> = []; + + global.fetch = mock((input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const method = init?.method ?? 'GET'; + const body = typeof init?.body === 'string' ? init.body : undefined; + + requests.push({ url, method, body }); + + if (url.endsWith('/v0/management/auth-files') && method === 'GET') { + return Promise.resolve(jsonResponse({ files: options.files })); + } + + if (url.includes('/v0/management/auth-files/download') && method === 'GET') { + const name = new URL(url).searchParams.get('name'); + if (!name) { + return Promise.reject(new Error(`Missing auth file name for ${url}`)); + } + + const response = options.downloads?.[name]; + if (response instanceof Error) { + return Promise.reject(response); + } + if (!response) { + return Promise.resolve(textResponse('{}', 404)); + } + + return Promise.resolve(textResponse(response.body, response.status ?? 200)); + } + + if (url.endsWith('/v0/management/auth-files/fields') && method === 'PATCH') { + const payload = JSON.parse(body ?? '{}') as { name?: string }; + const status = (payload.name && options.patchStatuses?.[payload.name]) ?? 200; + return Promise.resolve(jsonResponse({ ok: status < 400 }, status)); + } + + return Promise.reject(new Error(`Unexpected fetch ${method} ${url}`)); + }) as typeof fetch; + + return requests; +} + +afterEach(() => { + global.fetch = originalFetch; +}); + +describe('ensureManagedModelPrefixes', () => { + it('patches missing managed prefixes for matching oauth providers', async () => { + const requests = installFetchMock({ + files: [ + { account_type: 'oauth', name: 'gemini-main', provider: 'gemini' }, + { account_type: 'oauth', name: 'agy-main', provider: 'antigravity' }, + { account_type: 'apikey', name: 'gemini-key', provider: 'gemini' }, + ], + downloads: { + 'gemini-main': { body: JSON.stringify({ prefix: null, provider: 'gemini' }) }, + 'agy-main': { body: JSON.stringify({ prefix: null, provider: 'antigravity' }) }, + }, + }); + + const result = await ensureManagedModelPrefixes(['gemini']); + + expect(result).toEqual({ checked: 1, updated: 1 }); + + const patchRequest = requests.find( + (request) => + request.url.endsWith('/v0/management/auth-files/fields') && request.method === 'PATCH' + ); + expect(patchRequest?.body).toBe(JSON.stringify({ name: 'gemini-main', prefix: 'gcli' })); + + const downloadedNames = requests + .filter((request) => request.url.includes('/v0/management/auth-files/download')) + .map((request) => new URL(request.url).searchParams.get('name')); + expect(downloadedNames).toEqual(['gemini-main']); + }); + + it('returns immediately when called for providers without managed prefixes', async () => { + const fetchMock = mock(() => Promise.reject(new Error('should not fetch'))); + global.fetch = fetchMock as typeof fetch; + + const result = await ensureManagedModelPrefixes(['codex']); + + expect(result).toEqual({ checked: 0, updated: 0 }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('skips files that already have the managed prefix or a different custom prefix', async () => { + const requests = installFetchMock({ + files: [ + { account_type: 'oauth', name: 'gemini-managed', provider: 'gemini' }, + { account_type: 'oauth', name: 'gemini-custom', provider: 'gemini' }, + ], + downloads: { + 'gemini-managed': { body: JSON.stringify({ prefix: 'gcli', provider: 'gemini' }) }, + 'gemini-custom': { body: JSON.stringify({ prefix: 'team-a', provider: 'gemini' }) }, + }, + }); + + const result = await ensureManagedModelPrefixes(['gemini']); + + expect(result).toEqual({ checked: 2, updated: 0 }); + expect( + requests.some( + (request) => + request.url.endsWith('/v0/management/auth-files/fields') && request.method === 'PATCH' + ) + ).toBe(false); + }); + + it('skips patching when the downloaded auth file belongs to a different provider', async () => { + const requests = installFetchMock({ + files: [{ account_type: 'oauth', name: 'gemini-shadowed', provider: 'gemini' }], + downloads: { + 'gemini-shadowed': { + body: JSON.stringify({ prefix: null, provider: 'antigravity' }), + }, + }, + }); + + const result = await ensureManagedModelPrefixes(['gemini']); + + expect(result).toEqual({ checked: 1, updated: 0 }); + expect( + requests.some( + (request) => + request.url.endsWith('/v0/management/auth-files/fields') && request.method === 'PATCH' + ) + ).toBe(false); + }); + + it('swallows read and patch failures so later files can still be repaired', async () => { + const requests = installFetchMock({ + files: [ + { account_type: 'oauth', name: 'gemini-unreadable', provider: 'gemini' }, + { account_type: 'oauth', name: 'gemini-patch-fails', provider: 'gemini' }, + { account_type: 'oauth', name: 'gemini-success', provider: 'gemini' }, + ], + downloads: { + 'gemini-unreadable': new Error('network down'), + 'gemini-patch-fails': { body: JSON.stringify({ prefix: null, provider: 'gemini' }) }, + 'gemini-success': { body: JSON.stringify({ prefix: null, provider: 'gemini' }) }, + }, + patchStatuses: { + 'gemini-patch-fails': 500, + }, + }); + + const result = await ensureManagedModelPrefixes(['gemini']); + + expect(result).toEqual({ checked: 3, updated: 1 }); + + const patchPayloads = requests + .filter( + (request) => + request.url.endsWith('/v0/management/auth-files/fields') && request.method === 'PATCH' + ) + .map((request) => request.body); + expect(patchPayloads).toEqual([ + JSON.stringify({ name: 'gemini-patch-fails', prefix: 'gcli' }), + JSON.stringify({ name: 'gemini-success', prefix: 'gcli' }), + ]); + }); +}); diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index b379f5ab..17068475 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -91,11 +91,20 @@ describe('Model Catalog', () => { assert.strictEqual(ids.includes('claude-sonnet-4-5'), false); }); - it('includes Gemini 3.1 Pro (free via Antigravity)', () => { + it('includes Gemini 3.1 Pro High via Antigravity', () => { const { MODEL_CATALOG } = modelCatalog; - const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3.1-pro-preview'); - assert(gem3, 'Should include Gemini 3.1 Pro'); - assert.strictEqual(gem3.name, 'Gemini 3.1 Pro'); + const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3.1-pro-high'); + assert(gem3, 'Should include Gemini 3.1 Pro High'); + assert.strictEqual(gem3.name, 'Gemini 3.1 Pro High'); + // AGY models are all free - no paid tier + assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier'); + }); + + it('includes Gemini 3.1 Pro Low via Antigravity', () => { + const { MODEL_CATALOG } = modelCatalog; + const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3.1-pro-low'); + assert(gem3, 'Should include Gemini 3.1 Pro Low'); + assert.strictEqual(gem3.name, 'Gemini 3.1 Pro Low'); // AGY models are all free - no paid tier assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier'); }); @@ -108,9 +117,9 @@ describe('Model Catalog', () => { assert.strictEqual(flash.tier, undefined, 'AGY models should not have paid tier'); }); - it('has 4 models total', () => { + it('has 5 models total', () => { const { MODEL_CATALOG } = modelCatalog; - assert.strictEqual(MODEL_CATALOG.agy.models.length, 4); + assert.strictEqual(MODEL_CATALOG.agy.models.length, 5); }); }); @@ -262,13 +271,15 @@ describe('Model Catalog', () => { assert.strictEqual(legacySonnet?.id, 'claude-sonnet-4-6'); }); - it('treats Gemini 3 and 3.1 preview IDs as the same catalog family', () => { + it('maps legacy Antigravity Gemini Pro aliases onto the current 3.1 high/low models', () => { const { findModel, getSuggestedReplacementModel } = modelCatalog; const legacyAgyGemini = findModel('agy', 'gemini-3-pro-preview'); + const legacyAgyGeminiLow = findModel('agy', 'gemini-3-pro-low'); const legacyGemini = findModel('gemini', 'gemini-3-pro-preview'); const currentGemini = findModel('gemini', 'gemini-3.1-pro-preview'); - assert.strictEqual(legacyAgyGemini?.id, 'gemini-3.1-pro-preview'); + assert.strictEqual(legacyAgyGemini?.id, 'gemini-3.1-pro-high'); + assert.strictEqual(legacyAgyGeminiLow?.id, 'gemini-3.1-pro-low'); assert.strictEqual(legacyGemini?.id, 'gemini-3.1-pro-preview'); assert.strictEqual(currentGemini?.id, 'gemini-3.1-pro-preview'); assert.strictEqual( @@ -371,7 +382,7 @@ describe('Model Catalog', () => { const sonnetThinkingIdx = models.findIndex((m) => m.id === 'claude-sonnet-4-6'); // Find indices of the remaining non-Claude model - const geminiIdx = models.findIndex((m) => m.id === 'gemini-3.1-pro-preview'); + const geminiIdx = models.findIndex((m) => m.id === 'gemini-3.1-pro-high'); // Primary Claude choices should appear ahead of Gemini fallback. assert(opusIdx < geminiIdx, 'Opus should be above Gemini'); diff --git a/tests/unit/cliproxy/model-routing-hints.test.ts b/tests/unit/cliproxy/model-routing-hints.test.ts new file mode 100644 index 00000000..a9033769 --- /dev/null +++ b/tests/unit/cliproxy/model-routing-hints.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'bun:test'; +import { + buildCliproxyRoutingHints, + getManagedModelPrefix, +} from '../../../src/shared/cliproxy-model-routing'; + +describe('cliproxy model routing hints', () => { + it('uses short managed prefixes for overlapping Gemini and Antigravity models', () => { + const routing = buildCliproxyRoutingHints( + { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + models: [{ id: 'gemini-3-flash-preview', name: 'Gemini Flash' }], + }, + agy: { + provider: 'agy', + displayName: 'Antigravity', + models: [{ id: 'gemini-3-flash', name: 'Gemini 3 Flash' }], + }, + }, + [ + { id: 'gemini-3-flash-preview', owned_by: 'antigravity', type: 'antigravity' }, + { id: 'gemini-3-flash', owned_by: 'antigravity', type: 'antigravity' }, + ] + ); + + expect(getManagedModelPrefix('gemini')).toBe('gcli'); + expect(getManagedModelPrefix('agy')).toBe('agy'); + + expect(routing.gemini?.models[0]).toMatchObject({ + recommendedModelId: 'gcli/gemini-3-flash-preview', + pinnedAvailable: false, + unprefixedStatus: 'shadowed', + effectiveProvider: 'agy', + effectiveDisplayName: 'Antigravity', + }); + + expect(routing.agy?.models[0]).toMatchObject({ + recommendedModelId: 'agy/gemini-3-flash', + pinnedAvailable: false, + unprefixedStatus: 'safe', + effectiveProvider: 'agy', + }); + }); + + it('marks models as prefix-only when they are not advertised unprefixed', () => { + const routing = buildCliproxyRoutingHints( + { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + models: [{ id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro' }], + }, + }, + [] + ); + + expect(routing.gemini?.prefixOnlyCount).toBe(1); + expect(routing.gemini?.models[0]).toMatchObject({ + recommendedModelId: 'gcli/gemini-3.1-pro-preview', + pinnedAvailable: false, + unprefixedStatus: 'prefix-only', + effectiveProvider: null, + }); + }); + + it('does not promote custom auth-file prefixes as managed pinned model ids', () => { + const routing = buildCliproxyRoutingHints( + { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + models: [{ id: 'gemini-3-flash-preview', name: 'Gemini Flash' }], + }, + }, + [{ id: 'team-a/gemini-3-flash-preview', owned_by: 'google', type: 'gemini-cli' }] + ); + + expect(routing.gemini?.models[0]).toMatchObject({ + pinnedModelId: 'gcli/gemini-3-flash-preview', + recommendedModelId: 'gcli/gemini-3-flash-preview', + pinnedAvailable: false, + unprefixedStatus: 'prefix-only', + }); + }); + + it('marks the managed pinned route as available when the live model list advertises it', () => { + const routing = buildCliproxyRoutingHints( + { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + models: [{ id: 'gemini-3-flash-preview', name: 'Gemini Flash' }], + }, + }, + [{ id: 'gcli/gemini-3-flash-preview', owned_by: 'google', type: 'gemini-cli' }] + ); + + expect(routing.gemini?.models[0]).toMatchObject({ + pinnedModelId: 'gcli/gemini-3-flash-preview', + recommendedModelId: 'gcli/gemini-3-flash-preview', + pinnedAvailable: true, + unprefixedStatus: 'prefix-only', + effectiveProvider: null, + }); + expect(routing.gemini?.models[0]?.summary).toContain( + 'Use gcli/gemini-3-flash-preview to target Gemini.' + ); + }); +}); diff --git a/tests/unit/cliproxy/quota-fetcher-antigravity-failure.test.ts b/tests/unit/cliproxy/quota-fetcher-antigravity-failure.test.ts new file mode 100644 index 00000000..fd2363ed --- /dev/null +++ b/tests/unit/cliproxy/quota-fetcher-antigravity-failure.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'bun:test'; + +async function loadAntigravityQuotaTestExports() { + const moduleId = Date.now() + Math.random(); + const mod = await import(`../../../src/cliproxy/quota-fetcher?agy-quota-fetcher=${moduleId}`); + return mod.__testExports; +} + +describe('Antigravity quota failure metadata', () => { + it('marks 403 failures as not entitled', async () => { + const { buildAntigravityFailure } = await loadAntigravityQuotaTestExports(); + + const result = buildAntigravityFailure(403, 'forbidden'); + + expect(result.entitlement).toMatchObject({ + accessState: 'not_entitled', + capacityState: 'unknown', + }); + }); + + it('marks 429 failures as rate limited', async () => { + const { buildAntigravityFailure } = await loadAntigravityQuotaTestExports(); + + const result = buildAntigravityFailure(429, 'rate limited'); + + expect(result.entitlement).toMatchObject({ + accessState: 'unknown', + capacityState: 'rate_limited', + }); + }); + + it('preserves entitlement evidence when project lookup fails before quota fetch', async () => { + const moduleId = Date.now() + Math.random(); + const { fetchAccountQuota } = await import(`../../../src/cliproxy/quota-fetcher?agy-early=${moduleId}`); + const { getProviderAuthDir } = await import( + `../../../src/cliproxy/config-generator?agy-config=${moduleId}` + ); + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-failure-')); + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + + try { + const authDir = getProviderAuthDir('agy'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, 'antigravity-user@example.com.json'), + JSON.stringify({ + type: 'antigravity', + email: 'user@example.com', + project_id: 'project-x', + access_token: 'token', + }) + ); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ error: { message: 'forbidden' } }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + })) as typeof fetch; + + try { + const result = await fetchAccountQuota('agy', 'user@example.com'); + expect(result.success).toBe(false); + expect(result.entitlement).toMatchObject({ + accessState: 'not_entitled', + capacityState: 'unknown', + }); + } finally { + globalThis.fetch = originalFetch; + } + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('attaches entitlement evidence when project lookup returns an invalid 2xx payload', async () => { + const moduleId = Date.now() + Math.random(); + const { fetchAccountQuota } = await import( + `../../../src/cliproxy/quota-fetcher?agy-invalid-project=${moduleId}` + ); + const { getProviderAuthDir } = await import( + `../../../src/cliproxy/config-generator?agy-config=${moduleId}` + ); + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-invalid-project-')); + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + + try { + const authDir = getProviderAuthDir('agy'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, 'antigravity-user@example.com.json'), + JSON.stringify({ + type: 'antigravity', + email: 'user@example.com', + access_token: 'token', + }) + ); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response('', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as typeof fetch; + + try { + const result = await fetchAccountQuota('agy', 'user@example.com'); + expect(result.success).toBe(false); + expect(result.errorCode).toBe('provider_unavailable'); + expect(result.entitlement).toMatchObject({ + accessState: 'unknown', + capacityState: 'temporarily_unavailable', + }); + } finally { + globalThis.fetch = originalFetch; + } + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('preserves live tier evidence when quota fetch fails after a successful project lookup', async () => { + const moduleId = Date.now() + Math.random(); + const { fetchAccountQuota } = await import( + `../../../src/cliproxy/quota-fetcher?agy-invalid-models=${moduleId}` + ); + const { getProviderAuthDir } = await import( + `../../../src/cliproxy/config-generator?agy-config=${moduleId}` + ); + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-invalid-models-')); + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + + try { + const authDir = getProviderAuthDir('agy'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, 'antigravity-user@example.com.json'), + JSON.stringify({ + type: 'antigravity', + email: 'user@example.com', + access_token: 'token', + }) + ); + + const originalFetch = globalThis.fetch; + let requestCount = 0; + globalThis.fetch = (async () => { + requestCount += 1; + if (requestCount === 1) { + return new Response( + JSON.stringify({ + cloudaicompanionProject: { id: 'project-x' }, + paidTier: { id: 'g1-pro-tier' }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + return new Response('', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + try { + const result = await fetchAccountQuota('agy', 'user@example.com'); + expect(result.success).toBe(false); + expect(result.entitlement).toMatchObject({ + normalizedTier: 'pro', + rawTierId: 'g1-pro-tier', + rawTierLabel: 'Pro', + accessState: 'unknown', + capacityState: 'temporarily_unavailable', + }); + } finally { + globalThis.fetch = originalFetch; + } + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index 8c2d1b18..86271f88 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -27,10 +27,10 @@ describe('Gemini CLI Quota Fetcher', () => { let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken; let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir; - function writeGeminiToken(token: Record): string { + function writeGeminiToken(token: Record, filename = 'gemini-test.json'): string { const authDir = getProviderAuthDir('gemini'); fs.mkdirSync(authDir, { recursive: true }); - const tokenPath = path.join(authDir, 'gemini-test.json'); + const tokenPath = path.join(authDir, filename); fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2)); return tokenPath; } @@ -183,6 +183,16 @@ describe('Gemini CLI Quota Fetcher', () => { ]); }); + it('should keep Gemini 3.1 Flash Lite preview inside the Flash Lite family', () => { + const rawBuckets = [{ model_id: 'gemini-3.1-flash-lite-preview', remaining_fraction: 0.65 }]; + + const buckets = buildGeminiCliBuckets(rawBuckets); + + expect(buckets).toHaveLength(1); + expect(buckets[0].label).toBe('Gemini Flash Lite Series'); + expect(buckets[0].modelIds).toContain('gemini-3.1-flash-lite-preview'); + }); + it('should recognize Gemini 3.1 preview IDs during the rollout', () => { const rawBuckets = [ { model_id: 'gemini-3.1-flash-preview', remaining_fraction: 0.7 }, @@ -403,6 +413,13 @@ describe('Gemini CLI Quota Fetcher', () => { expect(result.tierLabel).toBe('Pro'); expect(result.tierId).toBe('g1-pro-tier'); expect(result.creditBalance).toBe(12); + expect(result.entitlement).toMatchObject({ + normalizedTier: 'pro', + rawTierId: 'g1-pro-tier', + rawTierLabel: 'Pro', + accessState: 'entitled', + capacityState: 'available', + }); expect(result.buckets.map((bucket) => bucket.label)).toEqual([ 'Gemini Flash Lite Series', 'Gemini Flash Series', @@ -649,6 +666,216 @@ describe('Gemini CLI Quota Fetcher', () => { expect(result.error).toBe('Gemini quota service unavailable (HTTP 502)'); expect(result.errorDetail).toBe('[HTML error response omitted]'); }); + + it('refreshes the requested Gemini account instead of the default account', async () => { + writeGeminiToken( + { + type: 'gemini', + email: 'default@example.com', + project_id: 'default-project', + token: { + access_token: 'default-access-token', + refresh_token: 'default-refresh-token', + expiry: Date.now() + 60 * 60 * 1000, + client_id: 'default-client-id', + client_secret: 'default-client-secret', + token_uri: GOOGLE_TOKEN_URL, + }, + }, + 'gemini-default.json' + ); + + writeGeminiToken( + { + type: 'gemini', + email: 'target@example.com', + project_id: 'target-project', + token: { + access_token: 'target-stale-token', + refresh_token: 'target-refresh-token', + expiry: Date.now() - 1000, + client_id: 'target-client-id', + client_secret: 'target-client-secret', + token_uri: GOOGLE_TOKEN_URL, + }, + }, + 'gemini-target.json' + ); + + mockFetch([ + { + url: GOOGLE_TOKEN_URL, + method: 'POST', + response: { access_token: 'target-fresh-token', expires_in: 1800 }, + }, + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 200, + response: { + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.88 }], + }, + }, + { + url: GEMINI_CODE_ASSIST_URL, + method: 'POST', + status: 503, + response: { error: { message: 'supplementary unavailable' } }, + }, + ]); + + const result = await fetchGeminiCliQuota('target@example.com'); + + expect(result.success).toBe(true); + + const [refreshRequest, quotaRequest] = getCapturedFetchRequests(); + expect(refreshRequest.url).toBe(GOOGLE_TOKEN_URL); + expect(refreshRequest.body).toContain('refresh_token=target-refresh-token'); + expect(refreshRequest.body).not.toContain('default-refresh-token'); + expect(quotaRequest.headers.Authorization).toBe('Bearer target-fresh-token'); + }); + + it('retries a 401 quota failure after a transient proactive refresh failure', async () => { + writeGeminiToken( + { + type: 'gemini', + email: 'retry@example.com', + project_id: 'retry-project', + token: { + access_token: 'retry-stale-token', + refresh_token: 'retry-refresh-token', + expiry: Date.now() + 60 * 1000, + client_id: 'retry-client-id', + client_secret: 'retry-client-secret', + token_uri: GOOGLE_TOKEN_URL, + }, + }, + 'gemini-retry.json' + ); + + mockFetch([ + { + url: GOOGLE_TOKEN_URL, + method: 'POST', + response: { access_token: 'unused-default', expires_in: 1800 }, + }, + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 200, + response: { + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }], + }, + }, + { + url: GEMINI_CODE_ASSIST_URL, + method: 'POST', + status: 503, + response: { error: { message: 'supplementary unavailable' } }, + }, + ]); + + const originalFetch = globalThis.fetch; + let refreshAttempt = 0; + let quotaAttempt = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + + if (url === GOOGLE_TOKEN_URL) { + refreshAttempt += 1; + return refreshAttempt === 1 + ? new Response(JSON.stringify({ error: 'temporarily_unavailable' }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }) + : new Response(JSON.stringify({ access_token: 'retry-fresh-token', expires_in: 1800 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (url === GEMINI_QUOTA_URL) { + quotaAttempt += 1; + return quotaAttempt === 1 + ? new Response( + JSON.stringify({ + error: { + message: 'Session expired', + status: 'UNAUTHENTICATED', + }, + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + } + ) + : new Response( + JSON.stringify({ + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + return originalFetch(input, init); + }) as typeof fetch; + + try { + const result = await fetchGeminiCliQuota('retry@example.com'); + + expect(result.success).toBe(true); + expect(refreshAttempt).toBe(2); + expect(quotaAttempt).toBe(2); + + const storedToken = JSON.parse( + fs.readFileSync(path.join(getProviderAuthDir('gemini'), 'gemini-retry.json'), 'utf8') + ) as { token?: { access_token?: string } }; + expect(storedToken.token?.access_token).toBe('retry-fresh-token'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('classifies model capacity exhaustion separately from generic rate limits', async () => { + writeActiveGeminiAccount('capacity@example.com'); + + mockFetch([ + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 429, + response: { + error: { + code: 429, + message: 'No capacity available for model gemini-3.1-pro-preview on the server', + status: 'RESOURCE_EXHAUSTED', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'MODEL_CAPACITY_EXHAUSTED', + metadata: { model: 'gemini-3.1-pro-preview' }, + }, + ], + }, + }, + }, + ]); + + const result = await fetchGeminiCliQuota('capacity@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(429); + expect(result.errorCode).toBe('capacity_exhausted'); + expect(result.retryable).toBe(true); + expect(result.entitlement).toMatchObject({ + accessState: 'entitled', + capacityState: 'capacity_exhausted', + }); + }); }); describe('direct Gemini error helper coverage', () => { diff --git a/tests/unit/cliproxy/session-tracker-port.test.js b/tests/unit/cliproxy/session-tracker-port.test.js index 1826a2aa..e5a99d94 100644 --- a/tests/unit/cliproxy/session-tracker-port.test.js +++ b/tests/unit/cliproxy/session-tracker-port.test.js @@ -15,7 +15,10 @@ const testHome = path.join( os.tmpdir(), `ccs-test-session-port-${Date.now()}-${Math.random().toString(36).slice(2)}` ); +const originalCcsHome = process.env.CCS_HOME; +const originalCcsDir = process.env.CCS_DIR; process.env.CCS_HOME = testHome; +delete process.env.CCS_DIR; const { getExistingProxy, @@ -28,6 +31,7 @@ const { deleteSessionLockForPort, } = require('../../../dist/cliproxy/session-tracker'); const { CLIPROXY_DEFAULT_PORT } = require('../../../dist/cliproxy/config-generator'); +const { setGlobalConfigDir } = require('../../../dist/utils/config-manager'); describe('Session Tracker Port-Specific', function () { const variantPort1 = 8318; @@ -35,6 +39,11 @@ describe('Session Tracker Port-Specific', function () { let cliproxyDir; beforeEach(function () { + // Reassert test isolation because other files mutate CCS_DIR/CCS_HOME in the same Bun process. + process.env.CCS_HOME = testHome; + delete process.env.CCS_DIR; + setGlobalConfigDir(undefined); + // Create test directories cliproxyDir = path.join(testHome, '.ccs', 'cliproxy'); fs.mkdirSync(cliproxyDir, { recursive: true }); @@ -49,6 +58,8 @@ describe('Session Tracker Port-Specific', function () { }); afterEach(function () { + setGlobalConfigDir(undefined); + // Clean up session files try { const files = fs.readdirSync(cliproxyDir); @@ -69,7 +80,11 @@ describe('Session Tracker Port-Specific', function () { } catch { // Ignore cleanup errors } - delete process.env.CCS_HOME; + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + setGlobalConfigDir(undefined); }); describe('Session Lock Path', function () { diff --git a/tests/unit/cliproxy/session-tracker.test.js b/tests/unit/cliproxy/session-tracker.test.js index ac4d7de2..edc75d57 100644 --- a/tests/unit/cliproxy/session-tracker.test.js +++ b/tests/unit/cliproxy/session-tracker.test.js @@ -12,7 +12,10 @@ const os = require('os'); // Set test isolation environment before importing const testHome = path.join(os.tmpdir(), `ccs-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); +const originalCcsHome = process.env.CCS_HOME; +const originalCcsDir = process.env.CCS_DIR; process.env.CCS_HOME = testHome; +delete process.env.CCS_DIR; const { getExistingProxy, @@ -24,6 +27,7 @@ const { stopProxy, getProxyStatus, } = require('../../../dist/cliproxy/session-tracker'); +const { setGlobalConfigDir } = require('../../../dist/utils/config-manager'); describe('Session Tracker', function () { const testPort = 18317; @@ -31,6 +35,11 @@ describe('Session Tracker', function () { let cliproxyDir; beforeEach(function () { + // Reassert test isolation because other files mutate CCS_DIR/CCS_HOME in the same Bun process. + process.env.CCS_HOME = testHome; + delete process.env.CCS_DIR; + setGlobalConfigDir(undefined); + // Create test directories cliproxyDir = path.join(testHome, '.ccs', 'cliproxy'); fs.mkdirSync(cliproxyDir, { recursive: true }); @@ -51,6 +60,8 @@ describe('Session Tracker', function () { }); afterEach(function () { + setGlobalConfigDir(undefined); + // Clean up lock files try { const files = fs.readdirSync(cliproxyDir); @@ -71,7 +82,11 @@ describe('Session Tracker', function () { } catch { // Ignore cleanup errors } - delete process.env.CCS_HOME; + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + setGlobalConfigDir(undefined); }); describe('getExistingProxy', function () { diff --git a/tests/unit/commands/api-create-target-resolution.test.ts b/tests/unit/commands/api-create-target-resolution.test.ts new file mode 100644 index 00000000..eab0437f --- /dev/null +++ b/tests/unit/commands/api-create-target-resolution.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'bun:test'; + +import { resolvePresetDefaultTarget } from '../../../src/commands/api-command/create-command'; + +describe('api create target resolution', () => { + it('uses the preset default target when no explicit target is provided', () => { + expect(resolvePresetDefaultTarget({ defaultTarget: 'droid' }, undefined)).toBe('droid'); + }); + + it('lets an explicit target override the preset default target', () => { + expect(resolvePresetDefaultTarget({ defaultTarget: 'droid' }, 'claude')).toBe('claude'); + }); + + it('returns null when neither an explicit target nor a preset default exists', () => { + expect(resolvePresetDefaultTarget(null, undefined)).toBeNull(); + }); +}); diff --git a/tests/unit/commands/cliproxy-quota-subcommand.test.ts b/tests/unit/commands/cliproxy-quota-subcommand.test.ts index 0b49cfef..f6bd033d 100644 --- a/tests/unit/commands/cliproxy-quota-subcommand.test.ts +++ b/tests/unit/commands/cliproxy-quota-subcommand.test.ts @@ -75,4 +75,12 @@ describe('cliproxy quota subcommand failure formatting', () => { }, ]); }); + + it('prefers live quota tier over stale account tier', async () => { + const { resolveDisplayedTier } = await loadQuotaCommandTestExports(); + + expect(resolveDisplayedTier('unknown', 'pro')).toBe('pro'); + expect(resolveDisplayedTier('pro', 'ultra')).toBe('ultra'); + expect(resolveDisplayedTier('pro', 'unknown')).toBe('pro'); + }); }); diff --git a/tests/unit/commands/update-command-current-install.test.ts b/tests/unit/commands/update-command-current-install.test.ts new file mode 100644 index 00000000..ef540deb --- /dev/null +++ b/tests/unit/commands/update-command-current-install.test.ts @@ -0,0 +1,262 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { handleUpdateCommand, type UpdateCommandDeps } from '../../../src/commands/update-command'; +import type { UpdateResult } from '../../../src/utils/update-checker'; + +let logLines: string[] = []; +let spawnCalls: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = []; +let exitCodes: number[] = []; +let originalConsoleLog: typeof console.log; +let originalProcessExit: typeof process.exit; + +type InstalledState = { + version: string | null; + packageJsonMtimeMs: number | null; + scriptMtimeMs: number | null; +}; + +type Scenario = { + beforeState: InstalledState; + afterState: InstalledState; +}; + +let scenario: Scenario; +let updateCheckResult: UpdateResult; +let currentInstallOverride: ReturnType; +let stateReads = 0; + +function installDescriptor() { + return { + manager: 'npm' as const, + scriptPath: '/tmp/ccs-prefix/bin/ccs', + resolvedScriptPath: '/tmp/ccs-prefix/lib/node_modules/@kaitranntt/ccs/dist/ccs.js', + packageRoot: '/tmp/ccs-prefix/lib/node_modules/@kaitranntt/ccs', + prefix: '/tmp/ccs-prefix', + detectionSource: 'path' as const, + }; +} + +function createDeps(overrides: Partial = {}): UpdateCommandDeps { + return { + initUI: async () => {}, + getVersion: () => '7.67.0-dev.5', + detectCurrentInstall: () => currentInstallOverride, + buildPackageManagerEnv: () => { + if (currentInstallOverride.manager === 'npm') { + return { + PATH: '/usr/bin', + npm_config_prefix: '/tmp/ccs-prefix', + NPM_CONFIG_PREFIX: '/tmp/ccs-prefix', + }; + } + + if (currentInstallOverride.manager === 'bun') { + return { PATH: '/usr/bin', BUN_INSTALL: '/tmp/bun-prefix' }; + } + + if (currentInstallOverride.manager === 'yarn') { + return { PATH: '/usr/bin', YARN_GLOBAL_FOLDER: '/tmp/yarn-prefix' }; + } + + return { PATH: '/usr/bin', PNPM_HOME: '/tmp/pnpm-prefix' }; + }, + formatManualUpdateCommand: () => { + if (currentInstallOverride.manager === 'npm') { + return 'NPM_CONFIG_PREFIX=/tmp/ccs-prefix npm install -g @kaitranntt/ccs@dev'; + } + + if (currentInstallOverride.manager === 'bun') { + return 'BUN_INSTALL=/tmp/bun-prefix bun add -g @kaitranntt/ccs@dev'; + } + + if (currentInstallOverride.manager === 'yarn') { + return 'YARN_GLOBAL_FOLDER=/tmp/yarn-prefix yarn global add @kaitranntt/ccs@dev'; + } + + return 'PNPM_HOME=/tmp/pnpm-prefix pnpm add -g @kaitranntt/ccs@dev'; + }, + readInstalledPackageState: () => { + stateReads += 1; + return stateReads === 1 ? scenario.beforeState : scenario.afterState; + }, + compareVersionsWithPrerelease: (left: string, right: string) => left.localeCompare(right), + checkForUpdates: async () => updateCheckResult, + spawn: ((command: string, args: string[], options?: { env?: NodeJS.ProcessEnv }) => { + spawnCalls.push({ command, args, env: options?.env }); + return { + stderr: undefined, + on: (event: string, callback: (code?: number) => void) => { + if (event === 'exit') { + callback(0); + } + }, + }; + }) as typeof UpdateCommandDeps.prototype.spawn, + ...overrides, + }; +} + +beforeEach(() => { + logLines = []; + spawnCalls = []; + exitCodes = []; + stateReads = 0; + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.9', packageJsonMtimeMs: 200, scriptMtimeMs: 200 }, + }; + updateCheckResult = { + status: 'update_available', + current: '7.67.0-dev.5', + latest: '7.67.0-dev.9', + }; + currentInstallOverride = installDescriptor(); + + originalConsoleLog = console.log; + originalProcessExit = process.exit; + + console.log = (...args: unknown[]) => { + logLines.push(args.map(String).join(' ')); + }; + + process.exit = ((code?: number) => { + exitCodes.push(code ?? 0); + }) as typeof process.exit; +}); + +afterEach(() => { + console.log = originalConsoleLog; + process.exit = originalProcessExit; +}); + +describe('update-command current install handling', () => { + it('updates through the current install manager and prefix', async () => { + await handleUpdateCommand({ beta: true }, createDeps()); + + const installCall = spawnCalls.find((call) => call.args.includes('install')); + + expect(installCall?.command).toBe('npm'); + expect(installCall?.args).toEqual(['install', '-g', '@kaitranntt/ccs@dev']); + expect(installCall?.env?.npm_config_prefix).toBe('/tmp/ccs-prefix'); + expect(exitCodes).toContain(0); + }); + + it('fails when another manager updated elsewhere but the current binary stayed stale', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + }; + + await handleUpdateCommand({ beta: true }, createDeps()); + + expect(logLines.join('\n')).toContain('outside the current installation'); + expect(logLines.join('\n')).toContain( + 'NPM_CONFIG_PREFIX=/tmp/ccs-prefix npm install -g @kaitranntt/ccs@dev' + ); + expect(exitCodes).toContain(1); + }); + + it('keeps force mode under exact target-version verification', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + }; + + await handleUpdateCommand({ force: true, beta: true }, createDeps()); + + expect(logLines.join('\n')).toContain('outside the current installation'); + expect(exitCodes).toContain(1); + }); + + it('warns but succeeds when target resolution says no update and the current install stays unchanged', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + }; + updateCheckResult = { status: 'no_update' }; + + await handleUpdateCommand({ force: true, beta: true }, createDeps()); + + expect(logLines.join('\n')).toContain('could not prove that the current installation changed'); + expect(exitCodes).toContain(0); + }); + + it('warns but succeeds when target version resolution fails and the current install stays unchanged', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + }; + updateCheckResult = { status: 'check_failed', message: 'network' }; + + await handleUpdateCommand({ force: true, beta: true }, createDeps()); + + expect(logLines.join('\n')).toContain('could not prove that the current installation changed'); + expect(exitCodes).toContain(0); + }); + + it('uses the injected version in the no-update message', async () => { + updateCheckResult = { status: 'no_update' }; + + await handleUpdateCommand( + {}, + createDeps({ + getVersion: () => '9.9.9-test.1', + }) + ); + + expect(logLines.join('\n')).toContain('latest version (9.9.9-test.1)'); + expect(exitCodes).toContain(0); + }); + + it('accepts a newer installed version when the dist-tag moves during update', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.1-dev.0', packageJsonMtimeMs: 200, scriptMtimeMs: 200 }, + }; + + await handleUpdateCommand({ beta: true }, createDeps()); + + expect(logLines.join('\n')).not.toContain('outside the current installation'); + expect(exitCodes).toContain(0); + }); + + it('accepts force reinstall when the version stays the same but the current install files change', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 200, scriptMtimeMs: 200 }, + }; + updateCheckResult = { status: 'no_update' }; + + await handleUpdateCommand({ force: true, beta: true }, createDeps()); + + expect(logLines.join('\n')).not.toContain( + 'could not verify that the current installation changed' + ); + expect(exitCodes).toContain(0); + }); + + it.each([ + ['bun', 'add', 'BUN_INSTALL', '/tmp/bun-prefix'], + ['yarn', 'global', 'YARN_GLOBAL_FOLDER', '/tmp/yarn-prefix'], + ['pnpm', 'add', 'PNPM_HOME', '/tmp/pnpm-prefix'], + ])( + 'routes updates through the current %s install and env', + async (manager, expectedArg, envKey, envValue) => { + currentInstallOverride = { + ...installDescriptor(), + manager: manager as 'bun' | 'yarn' | 'pnpm', + prefix: envValue, + }; + + await handleUpdateCommand({ beta: true }, createDeps()); + + const updateCall = spawnCalls.find( + (call) => + call.command === manager && call.args.some((arg) => arg.includes('@kaitranntt/ccs@dev')) + ); + + expect(updateCall?.args).toContain(expectedArg); + expect(updateCall?.env?.[envKey]).toBe(envValue); + expect(exitCodes).toContain(0); + } + ); +}); diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts index ccb02d26..e94d34cc 100644 --- a/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts +++ b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { describe, expect, it } from 'bun:test'; import { hydrateImageAnalysisRuntimeStatus } from '../../../../src/utils/hooks/image-analysis-runtime-status'; import type { ImageAnalysisStatus } from '../../../../src/utils/hooks/image-analysis-backend-resolver'; @@ -88,6 +91,41 @@ describe('image-analysis-runtime-status', () => { expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis'); }); + it('uses getAuthStatus when initializeAccounts is omitted from an override', async () => { + const tempCcsHome = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-runtime-status-')); + const originalCcsHome = process.env.CCS_HOME; + const originalCcsDir = process.env.CCS_DIR; + delete process.env.CCS_DIR; + process.env.CCS_HOME = tempCcsHome; + + try { + const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { + getProxyTarget: () => ({ host: '127.0.0.1', port: 8317, protocol: 'http', isRemote: false }), + initializeAccounts: undefined as unknown as () => void, + getAuthStatus: () => ({ + provider: 'ghcp', + authenticated: true, + tokenDir: '/tmp/auth', + tokenFiles: ['github-copilot-test.json'], + accounts: [], + defaultAccount: undefined, + }), + isCliproxyRunning: async () => true, + }); + + expect(status.authReadiness).toBe('ready'); + expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis'); + } finally { + if (originalCcsHome === undefined) delete process.env.CCS_HOME; + else process.env.CCS_HOME = originalCcsHome; + + if (originalCcsDir === undefined) delete process.env.CCS_DIR; + else process.env.CCS_DIR = originalCcsDir; + + rmSync(tempCcsHome, { recursive: true, force: true }); + } + }); + it('treats an unreachable remote proxy as unavailable', async () => { const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { getProxyTarget: () => ({ diff --git a/tests/unit/utils/package-manager-detector.test.ts b/tests/unit/utils/package-manager-detector.test.ts new file mode 100644 index 00000000..693e0920 --- /dev/null +++ b/tests/unit/utils/package-manager-detector.test.ts @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + buildPackageManagerEnv, + detectCurrentInstall, + formatManualUpdateCommand, + readInstalledPackageVersion, +} from '../../../src/utils/package-manager-detector'; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function writePackage(root: string, version: string): void { + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: '@kaitranntt/ccs', version }, null, 2) + ); +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe('package-manager-detector', () => { + it('detects npm installs from the current binary path and keeps the custom prefix', () => { + const tempRoot = makeTempDir('ccs-install-detector-npm-'); + const packageRoot = join(tempRoot, 'prefix', 'lib', 'node_modules', '@kaitranntt', 'ccs'); + const scriptPath = join(packageRoot, 'dist', 'ccs.js'); + + writePackage(packageRoot, '7.67.0-dev.5'); + + const install = detectCurrentInstall(scriptPath); + + expect(install.manager).toBe('npm'); + expect(install.prefix).toBe(join(tempRoot, 'prefix')); + expect(install.packageRoot).toBe(packageRoot); + expect(readInstalledPackageVersion(install)).toBe('7.67.0-dev.5'); + }); + + it('detects bun installs from the resolved package path', () => { + const tempRoot = makeTempDir('ccs-install-detector-bun-'); + const packageRoot = join( + tempRoot, + '.bun', + 'install', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + const scriptPath = join(packageRoot, 'dist', 'ccs.js'); + + writePackage(packageRoot, '7.67.0-dev.9'); + + const install = detectCurrentInstall(scriptPath); + + expect(install.manager).toBe('bun'); + expect(install.prefix).toBe(join(tempRoot, '.bun')); + expect(readInstalledPackageVersion(install)).toBe('7.67.0-dev.9'); + }); + + it('detects custom bun install roots that still use install/global/node_modules', () => { + const tempRoot = makeTempDir('ccs-install-detector-custom-bun-'); + const packageRoot = join( + tempRoot, + 'custom-bun-root', + 'install', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + + writePackage(packageRoot, '7.67.0-dev.9'); + + const install = detectCurrentInstall(join(packageRoot, 'dist', 'ccs.js')); + + expect(install.manager).toBe('bun'); + expect(install.prefix).toBe(join(tempRoot, 'custom-bun-root')); + }); + + it('detects custom yarn global layouts', () => { + const tempRoot = makeTempDir('ccs-install-detector-custom-yarn-'); + const packageRoot = join( + tempRoot, + 'custom-yarn-root', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + + writePackage(packageRoot, '7.67.0-dev.9'); + + const install = detectCurrentInstall(join(packageRoot, 'dist', 'ccs.js')); + + expect(install.manager).toBe('yarn'); + expect(install.prefix).toBe(join(tempRoot, 'custom-yarn-root')); + }); + + it('detects custom pnpm global layouts that include a store version segment', () => { + const tempRoot = makeTempDir('ccs-install-detector-custom-pnpm-'); + const packageRoot = join( + tempRoot, + 'custom-pnpm-root', + 'global', + '5', + '.pnpm', + '@kaitranntt+ccs@7.67.0-dev.9', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + + writePackage(packageRoot, '7.67.0-dev.9'); + + const install = detectCurrentInstall(join(packageRoot, 'dist', 'ccs.js')); + + expect(install.manager).toBe('pnpm'); + expect(install.prefix).toBe(join(tempRoot, 'custom-pnpm-root')); + }); + + it('detects pnpm global layouts without a visible .pnpm segment in the script path', () => { + const tempRoot = makeTempDir('ccs-install-detector-pnpm-global-flat-'); + const packageRoot = join( + tempRoot, + 'custom-pnpm-root', + 'global', + '5', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + + writePackage(packageRoot, '7.67.0-dev.9'); + + const install = detectCurrentInstall(join(packageRoot, 'dist', 'ccs.js')); + + expect(install.manager).toBe('pnpm'); + expect(install.prefix).toBe(join(tempRoot, 'custom-pnpm-root')); + }); + + it('detects Windows npm globals without a lib directory', () => { + const install = detectCurrentInstall( + 'C:/Program Files/node-prefix/node_modules/@kaitranntt/ccs/dist/ccs.js' + ); + + expect(install.manager).toBe('npm'); + expect(install.prefix).toBe('C:/Program Files/node-prefix'); + }); + + it('formats manual npm update commands with the current prefix', () => { + const install = { + manager: 'npm' as const, + scriptPath: '/tmp/prefix/bin/ccs', + resolvedScriptPath: '/tmp/prefix/lib/node_modules/@kaitranntt/ccs/dist/ccs.js', + packageRoot: '/tmp/prefix/lib/node_modules/@kaitranntt/ccs', + prefix: '/tmp/prefix', + detectionSource: 'path' as const, + }; + + expect(formatManualUpdateCommand('dev', install)).toBe( + 'NPM_CONFIG_PREFIX=/tmp/prefix npm install -g @kaitranntt/ccs@dev' + ); + }); + + it('formats Windows-safe manual npm update commands for prefixes with spaces', () => { + const install = { + manager: 'npm' as const, + scriptPath: 'C:/Tools/CCS/ccs.cmd', + resolvedScriptPath: 'C:/Program Files/CCS/lib/node_modules/@kaitranntt/ccs/dist/ccs.js', + packageRoot: 'C:/Program Files/CCS/lib/node_modules/@kaitranntt/ccs', + prefix: 'C:/Program Files/CCS', + detectionSource: 'path' as const, + }; + + expect(formatManualUpdateCommand('dev', install, 'win32')).toBe( + `powershell -NoProfile -Command "$env:NPM_CONFIG_PREFIX='C:/Program Files/CCS'; npm install -g @kaitranntt/ccs@dev"` + ); + }); + + it('builds manager-specific env overrides for the current install', () => { + const install = { + manager: 'npm' as const, + scriptPath: '/tmp/prefix/bin/ccs', + resolvedScriptPath: '/tmp/prefix/lib/node_modules/@kaitranntt/ccs/dist/ccs.js', + packageRoot: '/tmp/prefix/lib/node_modules/@kaitranntt/ccs', + prefix: '/tmp/prefix', + detectionSource: 'path' as const, + }; + + const env = buildPackageManagerEnv(install, { PATH: '/usr/bin' }); + + expect(env.PATH).toBe('/usr/bin'); + expect(env.npm_config_prefix).toBe('/tmp/prefix'); + expect(env.NPM_CONFIG_PREFIX).toBe('/tmp/prefix'); + }); +}); diff --git a/tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts b/tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts new file mode 100644 index 00000000..46c89b25 --- /dev/null +++ b/tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts @@ -0,0 +1,141 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import type { Server } from 'http'; +import profileRoutes from '../../../src/web-server/routes/profile-routes'; + +describe('profile-routes local runtime readiness', () => { + let server: Server; + let baseUrl = ''; + const originalFetch = globalThis.fetch; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/profiles', profileRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + + if (url.startsWith(baseUrl)) { + return originalFetch(input); + } + + if (url.includes('11434/api/tags')) { + return new Response( + JSON.stringify({ + models: [{ name: 'gemma4:e4b' }, { name: 'qwen3-coder:latest' }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + if (url.includes('8080/v1/models')) { + return new Response( + JSON.stringify({ + data: [{ id: 'Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf' }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + throw new Error(`Unexpected URL: ${url}`); + }) as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('reports local runtimes as ready when their endpoints respond with models', async () => { + const response = await fetch(`${baseUrl}/api/profiles/local-runtime-readiness`); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + runtimes: Array<{ + id: string; + status: string; + recommendedModelInstalled: boolean; + }>; + }; + + expect(body.runtimes).toHaveLength(2); + expect(body.runtimes).toContainEqual( + expect.objectContaining({ + id: 'ollama', + status: 'ready', + recommendedModelInstalled: true, + }) + ); + expect(body.runtimes).toContainEqual( + expect.objectContaining({ + id: 'llamacpp', + status: 'ready', + }) + ); + }); + + it('reports setup guidance when local endpoints are unavailable', async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith(baseUrl)) { + return originalFetch(input); + } + throw new Error('connect ECONNREFUSED'); + }) as typeof fetch; + + const response = await fetch(`${baseUrl}/api/profiles/local-runtime-readiness`); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + runtimes: Array<{ + id: string; + status: string; + commandHint: string; + }>; + }; + + expect(body.runtimes).toContainEqual( + expect.objectContaining({ + id: 'ollama', + status: 'offline', + commandHint: 'ollama serve', + }) + ); + expect(body.runtimes).toContainEqual( + expect.objectContaining({ + id: 'llamacpp', + status: 'offline', + commandHint: './server --host 0.0.0.0 --port 8080 -m model.gguf', + }) + ); + }); +}); diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index b4f65156..debb9d8b 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -249,7 +249,7 @@ export function AccountCard({ )} - + diff --git a/ui/src/components/account/flow-viz/flow-viz-header.tsx b/ui/src/components/account/flow-viz/flow-viz-header.tsx index 3d66d3bd..58a3c37c 100644 --- a/ui/src/components/account/flow-viz/flow-viz-header.tsx +++ b/ui/src/components/account/flow-viz/flow-viz-header.tsx @@ -4,12 +4,15 @@ import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; -import { ChevronRight, Eye, EyeOff, RotateCcw } from 'lucide-react'; +import { ChevronRight, Eye, EyeOff, ListFilter, RotateCcw } from 'lucide-react'; interface FlowVizHeaderProps { onBack?: () => void; showDetails: boolean; onToggleDetails: () => void; + showPausedAccounts: boolean; + pausedAccountsCount: number; + onTogglePausedAccounts: () => void; hasCustomPositions: boolean; onResetPositions: () => void; } @@ -18,6 +21,9 @@ export function FlowVizHeader({ onBack, showDetails, onToggleDetails, + showPausedAccounts, + pausedAccountsCount, + onTogglePausedAccounts, hasCustomPositions, onResetPositions, }: FlowVizHeaderProps) { @@ -48,6 +54,24 @@ export function FlowVizHeader({ {showDetails ? : } {showDetails ? t('flowViz.hideDetails') : t('flowViz.showDetails')} + {pausedAccountsCount > 0 && ( + + )} {hasCustomPositions && ( -