mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +00:00
Merge pull request #953 from kaitranntt/dev
feat(release): promote dev to main
This commit is contained in:
95 files changed
+5772
-701
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string[]> {
|
||||
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<LocalRuntimeReadiness[]> {
|
||||
return Promise.all(
|
||||
LOCAL_RUNTIME_DEFINITIONS.map(async (definition) => {
|
||||
try {
|
||||
return toReadiness(definition, await fetchModelIds(definition));
|
||||
} catch {
|
||||
return toOffline(definition);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Record<CLIProxyProvider, ProviderCatalog>>;
|
||||
source: CatalogSource;
|
||||
cacheAge: string | null;
|
||||
routing: Partial<Record<string, CliproxyProviderRoutingHints>>;
|
||||
}
|
||||
|
||||
export async function getCatalogRoutingSnapshot(): Promise<CatalogRoutingSnapshot> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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<Response> {
|
||||
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<string, string> | undefined),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function listAuthFiles(): Promise<ManagementAuthFileRecord[]> {
|
||||
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<void> {
|
||||
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<AuthFileMetadata> {
|
||||
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<ManagedPrefixSyncResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
},
|
||||
},
|
||||
{
|
||||
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()));
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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')
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
'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, unknown>): string | null {
|
||||
* Extract expiry from Gemini auth file data
|
||||
* Handles both flat (expired) and nested (token.expiry) structures
|
||||
*/
|
||||
function extractExpiry(data: Record<string, unknown>): string | null {
|
||||
function extractExpiry(data: Record<string, unknown>): 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<string, unknown>;
|
||||
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) {
|
||||
|
||||
+146
-19
@@ -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<Pro
|
||||
error: 'Invalid quota response from provider',
|
||||
errorCode: 'provider_unavailable',
|
||||
retryable: true,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
notes: 'Provider returned a 2xx response with an empty or invalid project payload.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -586,14 +668,27 @@ async function getProjectId(accountId: string, accessToken: string): Promise<Pro
|
||||
errorCode: 'account_unprovisioned',
|
||||
actionHint: 'Complete sign-in in the Antigravity app, then retry quota refresh.',
|
||||
isUnprovisioned: true,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'medium',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'unknown',
|
||||
notes: 'Project provisioning is incomplete for this account.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Extract tier - paidTier reflects actual subscription status, takes priority
|
||||
const tierStr = data.paidTier?.id || data.currentTier?.id;
|
||||
const tier = mapTierString(tierStr);
|
||||
const rawTierId = (data.paidTier?.id || data.currentTier?.id || '').trim() || null;
|
||||
const tier = normalizeProviderTierId(rawTierId);
|
||||
|
||||
return { projectId: projectId.trim(), tier };
|
||||
return {
|
||||
projectId: projectId.trim(),
|
||||
tier,
|
||||
rawTierId,
|
||||
rawTierLabel: getProviderTierLabel(rawTierId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -633,6 +728,14 @@ async function fetchAvailableModels(
|
||||
error: 'Invalid quota response from provider',
|
||||
errorCode: 'provider_unavailable',
|
||||
retryable: true,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
notes: 'Provider returned a 2xx response with an empty or invalid quota payload.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -727,6 +830,8 @@ export async function fetchAccountQuota(
|
||||
// Get project ID and tier - prefer stored project ID, but always call API for tier
|
||||
let projectId = authData.projectId;
|
||||
let apiTier: AccountTier = 'unknown';
|
||||
let rawTierId: string | null = null;
|
||||
let rawTierLabel: string | null = null;
|
||||
|
||||
// Always call loadCodeAssist to get accurate tier from API.
|
||||
// If the file token is stale, the helper retries through CLIProxy management auth.
|
||||
@@ -747,6 +852,7 @@ export async function fetchAccountQuota(
|
||||
httpStatus: lastProjectResult.httpStatus,
|
||||
needsReauth: lastProjectResult.needsReauth,
|
||||
isUnprovisioned: lastProjectResult.isUnprovisioned,
|
||||
entitlement: lastProjectResult.entitlement,
|
||||
isExpired: authData.isExpired,
|
||||
expiresAt: authData.expiresAt || undefined,
|
||||
};
|
||||
@@ -755,6 +861,8 @@ export async function fetchAccountQuota(
|
||||
// Use API project ID if available, else fallback to stored
|
||||
projectId = lastProjectResult.projectId || projectId;
|
||||
apiTier = lastProjectResult.tier || 'unknown';
|
||||
rawTierId = lastProjectResult.rawTierId || null;
|
||||
rawTierLabel = lastProjectResult.rawTierLabel || null;
|
||||
|
||||
if (verbose) console.error(`[i] Project ID: ${projectId || 'not found'}`);
|
||||
|
||||
@@ -769,12 +877,27 @@ export async function fetchAccountQuota(
|
||||
if (result.success) {
|
||||
const finalTier = apiTier !== 'unknown' ? apiTier : 'unknown';
|
||||
result.tier = finalTier;
|
||||
result.entitlement = buildProviderEntitlementEvidence({
|
||||
normalizedTier: finalTier,
|
||||
rawTierId,
|
||||
rawTierLabel,
|
||||
source: rawTierId ? 'runtime_api' : 'runtime_inference',
|
||||
confidence: rawTierId ? 'high' : 'medium',
|
||||
accessState: 'entitled',
|
||||
capacityState: 'available',
|
||||
});
|
||||
if (finalTier !== 'unknown') {
|
||||
setAccountTier(provider, accountId, finalTier);
|
||||
}
|
||||
} else {
|
||||
result.isExpired = authData.isExpired;
|
||||
result.expiresAt = authData.expiresAt || undefined;
|
||||
result.entitlement = mergeAntigravityTierEvidence(
|
||||
result.entitlement,
|
||||
apiTier,
|
||||
rawTierId,
|
||||
rawTierLabel
|
||||
);
|
||||
}
|
||||
|
||||
if (verbose && result.error) {
|
||||
@@ -868,6 +991,10 @@ export async function fetchAllProviderQuotas(
|
||||
return results;
|
||||
}
|
||||
|
||||
export const __testExports = {
|
||||
buildAntigravityFailure,
|
||||
};
|
||||
|
||||
/**
|
||||
* Find available account with remaining quota
|
||||
* Used by preflight check for auto-switching
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* Supports Antigravity, Codex, Claude, Gemini CLI, and GitHub Copilot OAuth providers.
|
||||
*/
|
||||
|
||||
import type { ProviderEntitlementEvidence } from './provider-entitlement-types';
|
||||
|
||||
/** Supported quota providers */
|
||||
export type QuotaProvider = 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp';
|
||||
|
||||
@@ -200,6 +202,8 @@ export interface GeminiCliQuotaResult extends QuotaErrorMetadata {
|
||||
tierId?: string | null;
|
||||
/** Available Google One AI credits when reported by the API */
|
||||
creditBalance?: number | null;
|
||||
/** Richer provider entitlement evidence derived from live/runtime signals */
|
||||
entitlement?: ProviderEntitlementEvidence;
|
||||
/** Timestamp of fetch */
|
||||
lastUpdated: number;
|
||||
/** Error message if fetch failed */
|
||||
|
||||
@@ -265,11 +265,16 @@ async function resolveModelConfiguration(
|
||||
}
|
||||
|
||||
async function resolveDefaultTarget(
|
||||
preset: ProviderPreset | null,
|
||||
providedTarget: TargetType | undefined,
|
||||
yes: boolean | undefined
|
||||
): Promise<TargetType> {
|
||||
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<ProviderPreset, 'defaultTarget'> | 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<void> {
|
||||
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<void> {
|
||||
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('');
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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<void> {
|
||||
console.log(header('Model Catalog'));
|
||||
console.log('');
|
||||
|
||||
const cacheAge = getCacheAge();
|
||||
let routingSnapshot: Awaited<ReturnType<typeof getCatalogRoutingSnapshot>> | 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await initUI();
|
||||
|
||||
@@ -36,7 +36,7 @@ export async function showHelp(): Promise<void> {
|
||||
[
|
||||
'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<void> {
|
||||
[
|
||||
['--backend <type>', 'Use specific backend: original | plus (default: from config)'],
|
||||
['--target <cli>', '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<void> {
|
||||
}
|
||||
|
||||
console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.'));
|
||||
console.log(dim(' Routing: use gcli/<model> or agy/<model> to keep overlapping models pinned.'));
|
||||
console.log('');
|
||||
console.log(subheader('Notes:'));
|
||||
console.log(` Default fallback version: ${color(getFallbackVersion(), 'info')}`);
|
||||
|
||||
@@ -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<QuotaSupportedProvider, QuotaProviderRuntim
|
||||
|
||||
export const __testExports = {
|
||||
getQuotaFailureDisplayEntries,
|
||||
resolveDisplayedTier,
|
||||
};
|
||||
|
||||
export async function handleQuotaStatus(
|
||||
|
||||
@@ -10,7 +10,10 @@ import * as path from 'path';
|
||||
import { getProviderAccounts } from '../../cliproxy/account-manager';
|
||||
import { triggerOAuth } from '../../cliproxy/auth/oauth-handler';
|
||||
import { CLIProxyProfileName, CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||
import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing';
|
||||
import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../../cliproxy/model-catalog';
|
||||
import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes';
|
||||
import type { CliproxyProviderRoutingHints } from '../../shared/cliproxy-model-routing';
|
||||
import { CLIProxyProvider, CLIProxyBackend } from '../../cliproxy/types';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata';
|
||||
@@ -42,6 +45,17 @@ interface CliproxyProfileArgs {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const variantManagedPrefixProviders = new Set<CLIProxyProvider>();
|
||||
|
||||
async function ensureVariantManagedModelPrefixes(provider: CLIProxyProvider): Promise<void> {
|
||||
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);
|
||||
|
||||
+292
-133
@@ -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<void> {
|
||||
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<UpdateResult>;
|
||||
spawn: typeof spawn;
|
||||
}
|
||||
|
||||
async function loadCheckForUpdates(
|
||||
currentVersion: string,
|
||||
interactive: boolean,
|
||||
channel: 'npm' | 'direct',
|
||||
targetTag: TargetTag
|
||||
): Promise<UpdateResult> {
|
||||
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<string | undefined> {
|
||||
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<UpdateCommandDeps> = {}
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>);
|
||||
@@ -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<string, string[]> = {
|
||||
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<Record<string, CatalogLikeProvider>>
|
||||
): 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<string, string> = {
|
||||
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<Record<string, CatalogLikeProvider>>,
|
||||
mergedModels: MergedModelLike[]
|
||||
): Partial<Record<string, CliproxyProviderRoutingHints>> {
|
||||
const mergedModelMap = new Map<string, MergedModelLike>();
|
||||
for (const model of mergedModels) {
|
||||
const key = normalize(model.id);
|
||||
if (key && !mergedModelMap.has(key)) {
|
||||
mergedModelMap.set(key, model);
|
||||
}
|
||||
}
|
||||
|
||||
const result: Partial<Record<string, CliproxyProviderRoutingHints>> = {};
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
/** Enable always thinking mode. */
|
||||
@@ -57,6 +60,7 @@ export const PROVIDER_PRESET_ALIASES: Readonly<Record<string, ProviderPresetId>>
|
||||
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',
|
||||
|
||||
@@ -37,6 +37,13 @@ const defaultDeps: ImageAnalysisRuntimeStatusDeps = {
|
||||
isCliproxyRunning: () => isCliproxyRunning(),
|
||||
};
|
||||
|
||||
function mergeDefinedDeps<T extends object>(defaults: T, overrides: Partial<T>): T {
|
||||
const definedOverrides = Object.fromEntries(
|
||||
Object.entries(overrides as Record<string, unknown>).filter(([, value]) => value !== undefined)
|
||||
) as Partial<T>;
|
||||
return { ...defaults, ...definedOverrides };
|
||||
}
|
||||
|
||||
async function resolveAuthReadiness(
|
||||
status: ImageAnalysisStatus,
|
||||
deps: ImageAnalysisRuntimeStatusDeps
|
||||
@@ -160,7 +167,7 @@ export async function hydrateImageAnalysisRuntimeStatus(
|
||||
baseStatus: ImageAnalysisStatus,
|
||||
deps: Partial<ImageAnalysisRuntimeStatusDeps> = {}
|
||||
): Promise<ImageAnalysisStatus> {
|
||||
const resolvedDeps = { ...defaultDeps, ...deps };
|
||||
const resolvedDeps = mergeDefinedDeps(defaultDeps, deps);
|
||||
const authStatus = await resolveAuthReadiness(baseStatus, resolvedDeps);
|
||||
const proxyStatus = await resolveProxyReadiness(baseStatus, resolvedDeps);
|
||||
const mergedStatus = {
|
||||
|
||||
@@ -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<CurrentInstall, 'manager' | 'prefix'> | 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}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ServerInstanc
|
||||
// Start auto-sync watcher (if enabled in config)
|
||||
startAutoSyncWatcher();
|
||||
|
||||
if (!getProxyTarget().isRemote) {
|
||||
void ensureManagedModelPrefixes().catch((error) => {
|
||||
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();
|
||||
|
||||
@@ -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<void> => {
|
||||
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,
|
||||
|
||||
@@ -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<voi
|
||||
});
|
||||
|
||||
if (account) {
|
||||
try {
|
||||
await ensureManagedModelPrefixes([account.provider]);
|
||||
} catch {
|
||||
// Keep OAuth success path non-fatal when prefix repair cannot run.
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
account: {
|
||||
@@ -1010,7 +1017,11 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
pendingManualAuthState.delete(state);
|
||||
try {
|
||||
await ensureManagedModelPrefixes([account.provider]);
|
||||
} catch {
|
||||
// Keep manual callback success path non-fatal when prefix repair cannot run.
|
||||
}
|
||||
res.json({
|
||||
status: 'ok',
|
||||
account: {
|
||||
@@ -1021,6 +1032,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
|
||||
isDefault: account.isDefault,
|
||||
},
|
||||
});
|
||||
pendingManualAuthState.delete(state);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1155,7 +1167,11 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
|
||||
}
|
||||
|
||||
if (parsed.state) {
|
||||
pendingManualAuthState.delete(parsed.state);
|
||||
try {
|
||||
await ensureManagedModelPrefixes([account.provider]);
|
||||
} catch {
|
||||
// Keep manual callback success path non-fatal when prefix repair cannot run.
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -1168,6 +1184,9 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
|
||||
isDefault: account.isDefault,
|
||||
},
|
||||
});
|
||||
if (parsed.state) {
|
||||
pendingManualAuthState.delete(parsed.state);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-na
|
||||
import {
|
||||
createApiProfile,
|
||||
createCliproxyBridgeProfile,
|
||||
getLocalRuntimeReadiness,
|
||||
removeApiProfile,
|
||||
updateApiProfileTarget,
|
||||
discoverApiProfileOrphans,
|
||||
@@ -86,6 +87,14 @@ router.get('/cliproxy-bridge/providers', (_req: Request, res: Response): void =>
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/local-runtime-readiness', async (_req: Request, res: Response): Promise<void> => {
|
||||
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) {
|
||||
|
||||
@@ -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<void> {
|
||||
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=');
|
||||
});
|
||||
});
|
||||
+13
-2
@@ -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 }
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, DownloadResponse | Error>;
|
||||
patchStatuses?: Record<string, number>;
|
||||
}) {
|
||||
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' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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, unknown>): string {
|
||||
function writeGeminiToken(token: Record<string, unknown>, 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', () => {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<typeof installDescriptor>;
|
||||
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> = {}): 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);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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: () => ({
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<void>((resolve, reject) => {
|
||||
server = app.listen(0, '127.0.0.1');
|
||||
const onError = (error: Error) => reject(error);
|
||||
server.once('error', onError);
|
||||
server.once('listening', () => {
|
||||
server.off('error', onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -249,7 +249,7 @@ export function AccountCard({
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<TooltipContent side="top" className="sm:max-w-sm">
|
||||
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -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 ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
<span>{showDetails ? t('flowViz.hideDetails') : t('flowViz.showDetails')}</span>
|
||||
</button>
|
||||
{pausedAccountsCount > 0 && (
|
||||
<button
|
||||
onClick={onTogglePausedAccounts}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-xs font-medium transition-all duration-200 px-3 py-1.5 rounded-md border shadow-sm',
|
||||
showPausedAccounts
|
||||
? 'bg-background text-muted-foreground hover:text-foreground border-border/60 hover:border-border hover:bg-muted/50'
|
||||
: 'bg-amber-500/15 text-amber-700 border-amber-500/40 hover:bg-amber-500/20 dark:bg-amber-500/20 dark:text-amber-300 dark:border-amber-500/30'
|
||||
)}
|
||||
>
|
||||
<ListFilter className="w-3.5 h-3.5" />
|
||||
<span>
|
||||
{showPausedAccounts
|
||||
? t('flowViz.hidePausedAccounts', { count: pausedAccountsCount })
|
||||
: t('flowViz.showPausedAccounts', { count: pausedAccountsCount })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{hasCustomPositions && (
|
||||
<button
|
||||
onClick={onResetPositions}
|
||||
|
||||
@@ -32,17 +32,41 @@ export function AccountFlowViz({
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [hoveredAccount, setHoveredAccount] = useState<number | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [showPausedAccounts, setShowPausedAccounts] = useState(true);
|
||||
const [paths, setPaths] = useState<string[]>([]);
|
||||
|
||||
const { privacyMode } = usePrivacy();
|
||||
const { accounts } = providerData;
|
||||
const maxRequests = Math.max(...accounts.map((a) => a.successCount + a.failureCount), 1);
|
||||
const totalRequests = accounts.reduce((acc, a) => acc + a.successCount + a.failureCount, 0);
|
||||
const pausedAccountsCount = useMemo(
|
||||
() => accounts.filter((account) => account.paused).length,
|
||||
[accounts]
|
||||
);
|
||||
const visibleAccounts = useMemo(
|
||||
() => (showPausedAccounts ? accounts : accounts.filter((account) => !account.paused)),
|
||||
[accounts, showPausedAccounts]
|
||||
);
|
||||
const visibleAccountIds = useMemo(
|
||||
() => new Set(visibleAccounts.map((account) => account.id)),
|
||||
[visibleAccounts]
|
||||
);
|
||||
const maxRequests = Math.max(...visibleAccounts.map((a) => a.successCount + a.failureCount), 1);
|
||||
const totalRequests = visibleAccounts.reduce(
|
||||
(acc, a) => acc + a.successCount + a.failureCount,
|
||||
0
|
||||
);
|
||||
const visibleProviderData = useMemo(
|
||||
() => ({
|
||||
...providerData,
|
||||
accounts: visibleAccounts,
|
||||
totalRequests,
|
||||
}),
|
||||
[providerData, visibleAccounts, totalRequests]
|
||||
);
|
||||
|
||||
const calculatePaths = useCallback(() => {
|
||||
const newPaths = calculateBezierPaths({ containerRef, svgRef, accounts });
|
||||
if (newPaths.length > 0) setPaths(newPaths);
|
||||
}, [accounts]);
|
||||
const newPaths = calculateBezierPaths({ containerRef, svgRef, accounts: visibleAccounts });
|
||||
setPaths(newPaths);
|
||||
}, [visibleAccounts]);
|
||||
|
||||
const storageKey = `ccs-flow-positions-${providerData.provider}`;
|
||||
const {
|
||||
@@ -55,12 +79,20 @@ export function AccountFlowViz({
|
||||
resetPositions,
|
||||
hasCustomPositions,
|
||||
} = useDragPositions({ storageKey, onDrag: calculatePaths });
|
||||
const containerExpansion = useContainerExpansion(dragOffsets);
|
||||
const pulsingAccounts = usePulseAnimation(accounts);
|
||||
const visibleDragOffsets = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(dragOffsets).filter(([id]) => id === 'provider' || visibleAccountIds.has(id))
|
||||
),
|
||||
[dragOffsets, visibleAccountIds]
|
||||
);
|
||||
const containerExpansion = useContainerExpansion(visibleDragOffsets);
|
||||
const hasVisibleCustomPositions = Object.keys(visibleDragOffsets).length > 0;
|
||||
const pulsingAccounts = usePulseAnimation(visibleAccounts);
|
||||
|
||||
const connectionEvents = useMemo(
|
||||
() => generateConnectionEvents(accounts).slice(0, MAX_TIMELINE_EVENTS),
|
||||
[accounts]
|
||||
() => generateConnectionEvents(visibleAccounts).slice(0, MAX_TIMELINE_EVENTS),
|
||||
[visibleAccounts]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -88,19 +120,22 @@ export function AccountFlowViz({
|
||||
}, [showDetails, calculatePaths]);
|
||||
|
||||
const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280';
|
||||
const zones = useMemo(() => splitAccountsIntoZones(accounts), [accounts]);
|
||||
const zones = useMemo(() => splitAccountsIntoZones(visibleAccounts), [visibleAccounts]);
|
||||
const { leftAccounts, rightAccounts, topAccounts, bottomAccounts } = zones;
|
||||
const hasRightAccounts = rightAccounts.length > 0;
|
||||
const hasTopAccounts = topAccounts.length > 0;
|
||||
const hasBottomAccounts = bottomAccounts.length > 0;
|
||||
const providerSize = useMemo(() => getProviderSizeClass(accounts.length), [accounts.length]);
|
||||
const providerSize = useMemo(
|
||||
() => getProviderSizeClass(visibleAccounts.length),
|
||||
[visibleAccounts.length]
|
||||
);
|
||||
|
||||
const renderAccountCards = (
|
||||
accountList: typeof accounts,
|
||||
accountList: typeof visibleAccounts,
|
||||
zone: 'left' | 'right' | 'top' | 'bottom'
|
||||
) =>
|
||||
accountList.map((account) => {
|
||||
const originalIndex = accounts.findIndex((a) => a.id === account.id);
|
||||
const originalIndex = visibleAccounts.findIndex((a) => a.id === account.id);
|
||||
return (
|
||||
<AccountCard
|
||||
key={account.id}
|
||||
@@ -129,7 +164,13 @@ export function AccountFlowViz({
|
||||
onBack={onBack}
|
||||
showDetails={showDetails}
|
||||
onToggleDetails={() => setShowDetails(!showDetails)}
|
||||
hasCustomPositions={hasCustomPositions}
|
||||
showPausedAccounts={showPausedAccounts}
|
||||
pausedAccountsCount={pausedAccountsCount}
|
||||
onTogglePausedAccounts={() => {
|
||||
setHoveredAccount(null);
|
||||
setShowPausedAccounts(!showPausedAccounts);
|
||||
}}
|
||||
hasCustomPositions={hasCustomPositions && hasVisibleCustomPositions}
|
||||
onResetPositions={resetPositions}
|
||||
/>
|
||||
|
||||
@@ -148,7 +189,7 @@ export function AccountFlowViz({
|
||||
>
|
||||
<FlowPaths
|
||||
paths={paths}
|
||||
accounts={accounts}
|
||||
accounts={visibleAccounts}
|
||||
maxRequests={maxRequests}
|
||||
hoveredAccount={hoveredAccount}
|
||||
pulsingAccounts={pulsingAccounts}
|
||||
@@ -168,10 +209,12 @@ export function AccountFlowViz({
|
||||
|
||||
<div className={cn('z-10 flex items-center flex-shrink-0', providerSize)}>
|
||||
<ProviderCard
|
||||
providerData={providerData}
|
||||
providerData={visibleProviderData}
|
||||
providerColor={providerColor}
|
||||
totalRequests={totalRequests}
|
||||
maxRequests={maxRequests}
|
||||
showVisibleMetrics={!showPausedAccounts && pausedAccountsCount > 0}
|
||||
hiddenPausedCount={showPausedAccounts ? 0 : pausedAccountsCount}
|
||||
isDragging={draggingId === 'provider'}
|
||||
offset={getOffset('provider')}
|
||||
hoveredAccount={hoveredAccount}
|
||||
|
||||
@@ -14,6 +14,8 @@ interface ProviderCardProps {
|
||||
providerColor: string;
|
||||
totalRequests: number;
|
||||
maxRequests: number;
|
||||
showVisibleMetrics?: boolean;
|
||||
hiddenPausedCount?: number;
|
||||
isDragging: boolean;
|
||||
offset: DragOffset;
|
||||
hoveredAccount: number | null;
|
||||
@@ -30,6 +32,8 @@ export function ProviderCard({
|
||||
providerColor,
|
||||
totalRequests,
|
||||
maxRequests,
|
||||
showVisibleMetrics = false,
|
||||
hiddenPausedCount = 0,
|
||||
isDragging,
|
||||
offset,
|
||||
hoveredAccount,
|
||||
@@ -42,6 +46,7 @@ export function ProviderCard({
|
||||
}: ProviderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { accounts } = providerData;
|
||||
const progressDenominator = Math.max(1, maxRequests * Math.max(1, accounts.length));
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -125,22 +130,31 @@ export function ProviderCard({
|
||||
|
||||
<div className="space-y-2 relative z-10">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">{t('flowViz.totalRequests')}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{showVisibleMetrics ? t('flowViz.visibleTotalRequests') : t('flowViz.totalRequests')}
|
||||
</span>
|
||||
<span className="text-foreground font-mono">{totalRequests.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">{t('flowViz.accounts')}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{showVisibleMetrics ? t('flowViz.visibleAccounts') : t('flowViz.accounts')}
|
||||
</span>
|
||||
<span className="text-foreground font-mono">{accounts.length}</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full mt-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(100, (totalRequests / (maxRequests * accounts.length)) * 100)}%`,
|
||||
width: `${Math.min(100, (totalRequests / progressDenominator) * 100)}%`,
|
||||
backgroundColor: providerColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{showVisibleMetrics && hiddenPausedCount > 0 && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t('flowViz.excludingPausedAccounts', { count: hiddenPausedCount })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -250,7 +250,7 @@ export function AccountQuotaPanel({
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-xs">
|
||||
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="sm:max-w-sm">
|
||||
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -306,23 +306,8 @@ export function AccountQuotaPanel({
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-[260px]">
|
||||
<div className="space-y-1 text-xs">
|
||||
<p>{failureInfo.summary}</p>
|
||||
{failureInfo.actionHint && (
|
||||
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
|
||||
)}
|
||||
{failureInfo.technicalDetail && (
|
||||
<p className="font-mono text-[11px] text-muted-foreground">
|
||||
{failureInfo.technicalDetail}
|
||||
</p>
|
||||
)}
|
||||
{failureInfo.rawDetail && (
|
||||
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
|
||||
{failureInfo.rawDetail}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="sm:max-w-sm">
|
||||
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -59,6 +59,19 @@ function getCompactAudienceBadgeLabel(audience: 'business' | 'personal' | 'unkno
|
||||
return '?';
|
||||
}
|
||||
|
||||
function resolveEffectiveTier(
|
||||
tier: AccountTier | undefined,
|
||||
quota: UnifiedQuotaResult | undefined
|
||||
): AccountTier | undefined {
|
||||
if (quota && 'entitlement' in quota) {
|
||||
const entitlementTier = quota.entitlement?.normalizedTier;
|
||||
if (entitlementTier && entitlementTier !== 'unknown') {
|
||||
return entitlementTier;
|
||||
}
|
||||
}
|
||||
return tier;
|
||||
}
|
||||
|
||||
export function AccountSurfaceCard({
|
||||
mode,
|
||||
provider,
|
||||
@@ -85,11 +98,14 @@ export function AccountSurfaceCard({
|
||||
const identity = getAccountIdentityPresentation(accountId, email, tokenFile);
|
||||
const title = displayEmail || identity.email || accountId;
|
||||
const normalizedProvider = provider.toLowerCase();
|
||||
const effectiveTier = resolveEffectiveTier(tier, quota);
|
||||
const showTierBadge =
|
||||
(normalizedProvider === 'agy' || normalizedProvider === 'antigravity') &&
|
||||
tier &&
|
||||
tier !== 'unknown' &&
|
||||
tier !== 'free';
|
||||
(normalizedProvider === 'agy' ||
|
||||
normalizedProvider === 'antigravity' ||
|
||||
normalizedProvider === 'gemini') &&
|
||||
effectiveTier &&
|
||||
effectiveTier !== 'unknown' &&
|
||||
effectiveTier !== 'free';
|
||||
const isCompact = mode === 'compact';
|
||||
const defaultCompactMetaBadges = (
|
||||
<>
|
||||
@@ -97,10 +113,10 @@ export function AccountSurfaceCard({
|
||||
<span
|
||||
className={cn(
|
||||
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0',
|
||||
getTierBadgeClass(tier)
|
||||
getTierBadgeClass(effectiveTier)
|
||||
)}
|
||||
>
|
||||
{tier}
|
||||
{effectiveTier}
|
||||
</span>
|
||||
)}
|
||||
{identity.audienceLabel && (
|
||||
@@ -143,12 +159,12 @@ export function AccountSurfaceCard({
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 text-[7px] font-bold uppercase px-1 py-px rounded ring-1 ring-background',
|
||||
tier === 'ultra'
|
||||
effectiveTier === 'ultra'
|
||||
? 'bg-violet-500/20 text-violet-600 dark:bg-violet-500/30 dark:text-violet-300'
|
||||
: 'bg-yellow-500/20 text-yellow-700 dark:bg-yellow-500/25 dark:text-yellow-400'
|
||||
)}
|
||||
>
|
||||
{tier === 'ultra' ? 'U' : 'P'}
|
||||
{effectiveTier === 'ultra' ? 'U' : 'P'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getRequestedModelId } from '@/lib/provider-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
AiProviderEntryView,
|
||||
@@ -241,9 +242,13 @@ export function ProviderEntryCard({
|
||||
key={`${model.name}:${model.alias}`}
|
||||
className="rounded-md border bg-muted/20 px-3 py-2"
|
||||
>
|
||||
<span className="font-medium">{model.name}</span>
|
||||
<span className="mx-2 text-muted-foreground">→</span>
|
||||
<span className="text-muted-foreground">{model.alias}</span>
|
||||
<span className="font-medium">{getRequestedModelId(model)}</span>
|
||||
{model.alias.trim() ? (
|
||||
<>
|
||||
<span className="mx-2 text-muted-foreground">→</span>
|
||||
<span className="text-muted-foreground">{model.name}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||
import { getAiProviderFamilyVisual } from '@/lib/provider-config';
|
||||
import {
|
||||
formatRequestedUpstreamModelRules,
|
||||
getAiProviderFamilyVisual,
|
||||
getRequestedUpstreamModelRuleErrors,
|
||||
parseRequestedUpstreamModelRules,
|
||||
} from '@/lib/provider-config';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
@@ -192,22 +197,7 @@ function parseKeyValueLines(value: string): Array<{ key: string; value: string }
|
||||
}
|
||||
|
||||
function parseModelAliasLines(value: string) {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
return { name: line.trim(), alias: '' };
|
||||
}
|
||||
|
||||
return {
|
||||
name: line.slice(0, separatorIndex).trim(),
|
||||
alias: line.slice(separatorIndex + 1).trim(),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.name.length > 0 || item.alias.length > 0);
|
||||
return parseRequestedUpstreamModelRules(value);
|
||||
}
|
||||
|
||||
function TextArea({
|
||||
@@ -241,9 +231,7 @@ function formatExcludedModels(entry?: AiProviderEntryView | null): string {
|
||||
}
|
||||
|
||||
function formatModelAliases(entry?: AiProviderEntryView | null): string {
|
||||
return (entry?.models || [])
|
||||
.map((item) => (item.alias.trim() ? `${item.name}=${item.alias}` : item.name))
|
||||
.join('\n');
|
||||
return formatRequestedUpstreamModelRules(entry?.models);
|
||||
}
|
||||
|
||||
function ChecklistCard({
|
||||
@@ -297,6 +285,10 @@ export function ProviderEntryDialog({
|
||||
const [headers, setHeaders] = useState(() => formatHeaders(entry));
|
||||
const [excludedModels, setExcludedModels] = useState(() => formatExcludedModels(entry));
|
||||
const [modelAliases, setModelAliases] = useState(() => formatModelAliases(entry));
|
||||
const modelRuleErrors = useMemo(
|
||||
() => getRequestedUpstreamModelRuleErrors(modelAliases),
|
||||
[modelAliases]
|
||||
);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(() =>
|
||||
Boolean(
|
||||
entry?.headers.length || entry?.excludedModels.length || entry?.proxyUrl || entry?.prefix
|
||||
@@ -311,6 +303,10 @@ export function ProviderEntryDialog({
|
||||
}, [entry?.secretConfigured, isEditing, supportsOpenAiCompat]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (modelRuleErrors.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextApiKey = apiKey.trim();
|
||||
const nextApiKeys = parseDelimitedLines(apiKeys);
|
||||
const preserveSecrets =
|
||||
@@ -472,6 +468,9 @@ export function ProviderEntryDialog({
|
||||
placeholder={guide.aliasesPlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{guide.aliasesHelper}</p>
|
||||
{modelRuleErrors.length > 0 ? (
|
||||
<p className="text-xs text-destructive">{modelRuleErrors[0]}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -573,7 +572,11 @@ export function ProviderEntryDialog({
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleSubmit()} disabled={isSaving}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={isSaving || modelRuleErrors.length > 0}
|
||||
>
|
||||
{isSaving
|
||||
? 'Saving...'
|
||||
: supportsOpenAiCompat
|
||||
|
||||
@@ -20,6 +20,25 @@ import { FlexibleModelSelector } from '../provider-model-selector';
|
||||
import type { CustomPresetDialogProps, ModelMappingValues } from './types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function normalizePresetValues(
|
||||
values: ModelMappingValues,
|
||||
routing: CustomPresetDialogProps['routing']
|
||||
): ModelMappingValues {
|
||||
const toPreferredModelId = (modelId: string): string => {
|
||||
const hint = routing?.models.find(
|
||||
(entry) => entry.modelId.toLowerCase() === modelId.toLowerCase()
|
||||
);
|
||||
return hint?.recommendedModelId ?? modelId;
|
||||
};
|
||||
|
||||
return {
|
||||
default: toPreferredModelId(values.default),
|
||||
opus: toPreferredModelId(values.opus),
|
||||
sonnet: toPreferredModelId(values.sonnet),
|
||||
haiku: toPreferredModelId(values.haiku),
|
||||
};
|
||||
}
|
||||
|
||||
export function CustomPresetDialog({
|
||||
open,
|
||||
onClose,
|
||||
@@ -29,15 +48,18 @@ export function CustomPresetDialog({
|
||||
isSaving,
|
||||
catalog,
|
||||
allModels,
|
||||
routing,
|
||||
}: CustomPresetDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [values, setValues] = useState<ModelMappingValues>(currentValues);
|
||||
const [values, setValues] = useState<ModelMappingValues>(
|
||||
normalizePresetValues(currentValues, routing)
|
||||
);
|
||||
const [presetName, setPresetName] = useState('');
|
||||
|
||||
// Reset values when dialog opens with current values
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
setValues(currentValues);
|
||||
setValues(normalizePresetValues(currentValues, routing));
|
||||
setPresetName('');
|
||||
} else {
|
||||
onClose();
|
||||
@@ -78,6 +100,7 @@ export function CustomPresetDialog({
|
||||
onChange={(model) => setValues({ ...values, default: model })}
|
||||
catalog={catalog}
|
||||
allModels={allModels}
|
||||
routing={routing}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label={t('customPresetDialog.opusModel')}
|
||||
@@ -86,6 +109,7 @@ export function CustomPresetDialog({
|
||||
onChange={(model) => setValues({ ...values, opus: model })}
|
||||
catalog={catalog}
|
||||
allModels={allModels}
|
||||
routing={routing}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label={t('customPresetDialog.sonnetModel')}
|
||||
@@ -94,6 +118,7 @@ export function CustomPresetDialog({
|
||||
onChange={(model) => setValues({ ...values, sonnet: model })}
|
||||
catalog={catalog}
|
||||
allModels={allModels}
|
||||
routing={routing}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label={t('customPresetDialog.haikuModel')}
|
||||
@@ -102,6 +127,7 @@ export function CustomPresetDialog({
|
||||
onChange={(model) => setValues({ ...values, haiku: model })}
|
||||
catalog={catalog}
|
||||
allModels={allModels}
|
||||
routing={routing}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
|
||||
@@ -33,6 +33,7 @@ export function ProviderEditor({
|
||||
displayName,
|
||||
authStatus,
|
||||
catalog,
|
||||
routing,
|
||||
logoProvider,
|
||||
baseProvider,
|
||||
isRemoteMode,
|
||||
@@ -263,6 +264,7 @@ export function ProviderEditor({
|
||||
sonnetModel={sonnetModel}
|
||||
haikuModel={haikuModel}
|
||||
providerModels={providerModels}
|
||||
routing={routing}
|
||||
extendedContextEnabled={extendedContextEnabled}
|
||||
onExtendedContextToggle={toggleExtendedContext}
|
||||
onApplyPreset={handleApplyPreset}
|
||||
@@ -349,6 +351,7 @@ export function ProviderEditor({
|
||||
isSaving={createPresetMutation.isPending}
|
||||
catalog={catalog}
|
||||
allModels={providerModels}
|
||||
routing={routing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,10 @@ import type { ModelConfigSectionProps } from './types';
|
||||
|
||||
type CatalogPresetModel = NonNullable<ModelConfigSectionProps['catalog']>['models'][number];
|
||||
|
||||
function getPresetUpdates(model: CatalogPresetModel): Record<string, string> {
|
||||
function getPresetUpdates(
|
||||
model: CatalogPresetModel,
|
||||
toPreferredModelId: (modelId: string) => string
|
||||
): Record<string, string> {
|
||||
const mapping = model.presetMapping || {
|
||||
default: model.id,
|
||||
opus: model.id,
|
||||
@@ -25,10 +28,10 @@ function getPresetUpdates(model: CatalogPresetModel): Record<string, string> {
|
||||
};
|
||||
|
||||
return {
|
||||
ANTHROPIC_MODEL: mapping.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku,
|
||||
ANTHROPIC_MODEL: toPreferredModelId(mapping.default),
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: toPreferredModelId(mapping.opus),
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: toPreferredModelId(mapping.sonnet),
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: toPreferredModelId(mapping.haiku),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,6 +43,7 @@ export function ModelConfigSection({
|
||||
sonnetModel,
|
||||
haikuModel,
|
||||
providerModels,
|
||||
routing,
|
||||
provider,
|
||||
extendedContextEnabled,
|
||||
onExtendedContextToggle,
|
||||
@@ -49,6 +53,15 @@ export function ModelConfigSection({
|
||||
onDeletePreset,
|
||||
isDeletePending,
|
||||
}: ModelConfigSectionProps) {
|
||||
const pinningReady = (routing?.models ?? []).some((hint) => hint.pinnedAvailable);
|
||||
const routingHintMap = useMemo(
|
||||
() =>
|
||||
new Map((routing?.models ?? []).map((hint) => [hint.modelId.toLowerCase(), hint] as const)),
|
||||
[routing]
|
||||
);
|
||||
const toPreferredModelId = (modelId: string): string =>
|
||||
routingHintMap.get(modelId.toLowerCase())?.recommendedModelId ?? modelId;
|
||||
|
||||
const extendedContextModels = useMemo(() => {
|
||||
if (!catalog) return [];
|
||||
|
||||
@@ -126,7 +139,7 @@ export function ModelConfigSection({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1"
|
||||
onClick={() => onApplyPreset(getPresetUpdates(model))}
|
||||
onClick={() => onApplyPreset(getPresetUpdates(model, toPreferredModelId))}
|
||||
>
|
||||
<Zap
|
||||
className={`w-3 h-3 ${'iconClassName' in group ? group.iconClassName : ''}`}
|
||||
@@ -148,10 +161,10 @@ export function ModelConfigSection({
|
||||
className="text-xs h-7 gap-1 pr-6"
|
||||
onClick={() => {
|
||||
onApplyPreset({
|
||||
ANTHROPIC_MODEL: preset.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: preset.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: preset.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: preset.haiku,
|
||||
ANTHROPIC_MODEL: toPreferredModelId(preset.default),
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: toPreferredModelId(preset.opus),
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: toPreferredModelId(preset.sonnet),
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: toPreferredModelId(preset.haiku),
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -195,6 +208,21 @@ export function ModelConfigSection({
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Configure which models to use for each tier
|
||||
</p>
|
||||
{routing ? (
|
||||
<p className="text-[11px] text-muted-foreground mb-3 rounded-md border bg-muted/30 px-2.5 py-2">
|
||||
{pinningReady ? (
|
||||
<>
|
||||
Preferred pinned model names use the <code>{routing.prefix}/</code> prefix.
|
||||
Unprefixed names can still resolve to a different backend when providers overlap.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Managed pinning for <code>{routing.prefix}/</code> is not currently advertised by
|
||||
the proxy. Unprefixed names may still be ambiguous until prefix repair completes.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{provider === 'codex' && (
|
||||
<p className="text-[11px] text-muted-foreground mb-3 rounded-md border bg-muted/30 px-2.5 py-2">
|
||||
Codex tip: suffixes <code>-medium</code>, <code>-high</code>, and <code>-xhigh</code>{' '}
|
||||
@@ -209,6 +237,7 @@ export function ModelConfigSection({
|
||||
onChange={(model) => onUpdateEnvValue('ANTHROPIC_MODEL', model)}
|
||||
catalog={catalog}
|
||||
allModels={providerModels}
|
||||
routing={routing}
|
||||
/>
|
||||
{/* Extended Context Toggle - shows when any saved mapping supports it */}
|
||||
{extendedContextModels.length > 0 && onExtendedContextToggle && (
|
||||
@@ -226,6 +255,7 @@ export function ModelConfigSection({
|
||||
onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_OPUS_MODEL', model)}
|
||||
catalog={catalog}
|
||||
allModels={providerModels}
|
||||
routing={routing}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Sonnet (Balanced)"
|
||||
@@ -234,6 +264,7 @@ export function ModelConfigSection({
|
||||
onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_SONNET_MODEL', model)}
|
||||
catalog={catalog}
|
||||
allModels={providerModels}
|
||||
routing={routing}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Haiku (Fast)"
|
||||
@@ -242,6 +273,7 @@ export function ModelConfigSection({
|
||||
onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_HAIKU_MODEL', model)}
|
||||
catalog={catalog}
|
||||
allModels={providerModels}
|
||||
routing={routing}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ModelConfigSection } from './model-config-section';
|
||||
import { AccountsSection } from './accounts-section';
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { ProviderCatalog } from '../provider-model-selector';
|
||||
import type { OAuthAccount } from '@/lib/api-client';
|
||||
import type { OAuthAccount, CliproxyProviderRoutingHints } from '@/lib/api-client';
|
||||
import { QUOTA_SUPPORTED_PROVIDERS, type QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
|
||||
|
||||
interface ModelConfigTabProps {
|
||||
@@ -28,6 +28,7 @@ interface ModelConfigTabProps {
|
||||
sonnetModel?: string;
|
||||
haikuModel?: string;
|
||||
providerModels: Array<{ id: string; owned_by: string }>;
|
||||
routing?: CliproxyProviderRoutingHints;
|
||||
/** Whether extended context (1M tokens) is enabled */
|
||||
extendedContextEnabled?: boolean;
|
||||
/** Callback when extended context toggle changes */
|
||||
@@ -71,6 +72,7 @@ export function ModelConfigTab({
|
||||
sonnetModel,
|
||||
haikuModel,
|
||||
providerModels,
|
||||
routing,
|
||||
extendedContextEnabled,
|
||||
onExtendedContextToggle,
|
||||
onApplyPreset,
|
||||
@@ -160,6 +162,7 @@ export function ModelConfigTab({
|
||||
sonnetModel={sonnetModel}
|
||||
haikuModel={haikuModel}
|
||||
providerModels={providerModels}
|
||||
routing={routing}
|
||||
provider={provider}
|
||||
extendedContextEnabled={extendedContextEnabled}
|
||||
onExtendedContextToggle={onExtendedContextToggle}
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { AuthStatus, OAuthAccount, CliTarget } from '@/lib/api-client';
|
||||
import type {
|
||||
AuthStatus,
|
||||
OAuthAccount,
|
||||
CliTarget,
|
||||
CliproxyProviderRoutingHints,
|
||||
} from '@/lib/api-client';
|
||||
import type { ProviderCatalog } from '../provider-model-selector';
|
||||
|
||||
export interface SettingsResponse {
|
||||
@@ -20,6 +25,7 @@ export interface ProviderEditorProps {
|
||||
displayName: string;
|
||||
authStatus: AuthStatus;
|
||||
catalog?: ProviderCatalog;
|
||||
routing?: CliproxyProviderRoutingHints;
|
||||
/** Provider type for logo display (defaults to provider) */
|
||||
logoProvider?: string;
|
||||
/** Base provider for model filtering (defaults to provider). For variants, this is the parent provider. */
|
||||
@@ -92,6 +98,7 @@ export interface CustomPresetDialogProps {
|
||||
isSaving?: boolean;
|
||||
catalog?: ProviderCatalog;
|
||||
allModels: { id: string; owned_by: string }[];
|
||||
routing?: CliproxyProviderRoutingHints;
|
||||
}
|
||||
|
||||
export interface RawEditorSectionProps {
|
||||
@@ -118,6 +125,7 @@ export interface ModelConfigSectionProps {
|
||||
sonnetModel?: string;
|
||||
haikuModel?: string;
|
||||
providerModels: Array<{ id: string; owned_by: string }>;
|
||||
routing?: CliproxyProviderRoutingHints;
|
||||
/** Provider name for display */
|
||||
provider: string;
|
||||
/** Whether extended context (1M tokens) is enabled */
|
||||
|
||||
@@ -11,8 +11,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { CliproxyProviderRoutingHints } from '@/lib/api-client';
|
||||
import { getCodexEffortDisplay } from '@/lib/codex-effort';
|
||||
import { getResolvedCatalogModels } from '@/lib/model-catalogs';
|
||||
import { getResolvedCatalogModels, getSupplementalCatalogModels } from '@/lib/model-catalogs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Model entry from catalog */
|
||||
@@ -291,9 +292,27 @@ interface FlexibleModelSelectorProps {
|
||||
onChange: (model: string) => void;
|
||||
catalog?: ProviderCatalog;
|
||||
allModels: { id: string; owned_by: string }[];
|
||||
routing?: CliproxyProviderRoutingHints;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function normalizeModelValue(
|
||||
value: string | undefined,
|
||||
routing?: CliproxyProviderRoutingHints
|
||||
): string {
|
||||
if (!value) return '';
|
||||
if (!routing?.prefix) return value;
|
||||
const prefix = `${routing.prefix}/`;
|
||||
return value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
||||
}
|
||||
|
||||
function getPreferredOptionValue(
|
||||
modelId: string,
|
||||
routingHint: CliproxyProviderRoutingHints['models'][number] | undefined
|
||||
): string {
|
||||
return routingHint?.recommendedModelId ?? modelId;
|
||||
}
|
||||
|
||||
export function FlexibleModelSelector({
|
||||
label,
|
||||
description,
|
||||
@@ -301,6 +320,7 @@ export function FlexibleModelSelector({
|
||||
onChange,
|
||||
catalog,
|
||||
allModels,
|
||||
routing,
|
||||
disabled,
|
||||
}: FlexibleModelSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -309,48 +329,141 @@ export function FlexibleModelSelector({
|
||||
() => getResolvedCatalogModels(catalog, allModels),
|
||||
[allModels, catalog]
|
||||
);
|
||||
const supplementalModels = useMemo(
|
||||
() => getSupplementalCatalogModels(catalog?.provider ?? '', catalog, allModels),
|
||||
[allModels, catalog]
|
||||
);
|
||||
const catalogModelIds = new Set(resolvedCatalogModels.map((model) => model.id));
|
||||
const routingHints = useMemo(
|
||||
() =>
|
||||
new Map((routing?.models ?? []).map((hint) => [hint.modelId.toLowerCase(), hint] as const)),
|
||||
[routing]
|
||||
);
|
||||
const recommendedOptionValues = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
resolvedCatalogModels.map((model) =>
|
||||
getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))
|
||||
)
|
||||
),
|
||||
[resolvedCatalogModels, routingHints]
|
||||
);
|
||||
const selectedRoutingHint = useMemo(
|
||||
() => routingHints.get(normalizeModelValue(value, routing).toLowerCase()),
|
||||
[routing, routingHints, value]
|
||||
);
|
||||
|
||||
const recommendedOptions = resolvedCatalogModels.map((model) => ({
|
||||
value: model.id,
|
||||
value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
|
||||
groupKey: 'recommended',
|
||||
searchText: `${model.id} ${model.name}`,
|
||||
searchText: `${model.id} ${model.name} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`,
|
||||
keywords: [model.tier ?? '', catalog?.provider ?? ''],
|
||||
triggerContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{model.id}</span>
|
||||
<span className="truncate font-mono text-xs">
|
||||
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||
</span>
|
||||
{routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? (
|
||||
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
|
||||
{routingHints.get(model.id.toLowerCase())?.prefix}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||
</div>
|
||||
),
|
||||
itemContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{model.id}</span>
|
||||
<span className="truncate font-mono text-xs">
|
||||
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||
</span>
|
||||
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
Shadowed
|
||||
</Badge>
|
||||
) : null}
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
Prefix only
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const allModelOptions = allModels
|
||||
const allModelOptions = supplementalModels
|
||||
.filter((model) => !catalogModelIds.has(model.id))
|
||||
.filter(
|
||||
(model) =>
|
||||
!recommendedOptionValues.has(
|
||||
getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))
|
||||
)
|
||||
)
|
||||
.map((model) => ({
|
||||
value: model.id,
|
||||
value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
|
||||
groupKey: 'all',
|
||||
searchText: model.id,
|
||||
searchText: `${model.id} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`,
|
||||
keywords: [model.owned_by],
|
||||
triggerContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{model.id}</span>
|
||||
<span className="truncate font-mono text-xs">
|
||||
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||
</span>
|
||||
{routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? (
|
||||
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
|
||||
{routingHints.get(model.id.toLowerCase())?.prefix}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||
</div>
|
||||
),
|
||||
itemContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{model.id}</span>
|
||||
<span className="truncate font-mono text-xs">
|
||||
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||
</span>
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
Shadowed
|
||||
</Badge>
|
||||
) : null}
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
Prefix only
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
const selectedValueMissing =
|
||||
Boolean(value) &&
|
||||
!recommendedOptions.some((option) => option.value === value) &&
|
||||
!allModelOptions.some((option) => option.value === value);
|
||||
const legacySelectedOption = value
|
||||
? {
|
||||
value,
|
||||
groupKey: 'current',
|
||||
searchText: value,
|
||||
triggerContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{value}</span>
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
Current
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
itemContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{value}</span>
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
Current
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
: null;
|
||||
const hasAvailableModels = recommendedOptions.length + allModelOptions.length > 0;
|
||||
|
||||
return (
|
||||
@@ -372,23 +485,68 @@ export function FlexibleModelSelector({
|
||||
}
|
||||
triggerClassName="h-9"
|
||||
groups={[
|
||||
...(selectedValueMissing && legacySelectedOption
|
||||
? [
|
||||
{
|
||||
key: 'current',
|
||||
label: <span className="text-xs text-muted-foreground">Current value</span>,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'recommended',
|
||||
label: (
|
||||
<span className="text-xs text-primary">{t('providerModelSelector.recommended')}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'all',
|
||||
label: (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('providerModelSelector.allModelsCount', { count: allModels.length })}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...(allModelOptions.length > 0
|
||||
? [
|
||||
{
|
||||
key: 'all',
|
||||
label: (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('providerModelSelector.allModelsCount', {
|
||||
count: allModelOptions.length,
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
options={[
|
||||
...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []),
|
||||
...recommendedOptions,
|
||||
...allModelOptions,
|
||||
]}
|
||||
options={[...recommendedOptions, ...allModelOptions]}
|
||||
/>
|
||||
{selectedRoutingHint ? (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border px-2.5 py-2 text-[11px]',
|
||||
selectedRoutingHint.unprefixedStatus === 'safe'
|
||||
? 'border-border/70 bg-muted/25 text-muted-foreground'
|
||||
: 'border-amber-300/60 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-950/25 dark:text-amber-100'
|
||||
)}
|
||||
>
|
||||
<div className="font-medium">
|
||||
{selectedRoutingHint.pinnedAvailable
|
||||
? 'Preferred pinned model:'
|
||||
: 'Pinned route status:'}{' '}
|
||||
<code>
|
||||
{selectedRoutingHint.pinnedAvailable
|
||||
? selectedRoutingHint.recommendedModelId
|
||||
: selectedRoutingHint.pinnedModelId}
|
||||
</code>
|
||||
</div>
|
||||
<p className="mt-1 leading-5">{selectedRoutingHint.summary}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{value && !selectedRoutingHint && normalizeModelValue(value, routing) !== value ? (
|
||||
<div className="rounded-md border border-amber-300/60 bg-amber-50 px-2.5 py-2 text-[11px] text-amber-900 dark:border-amber-500/30 dark:bg-amber-950/25 dark:text-amber-100">
|
||||
Pinned model is not currently advertised by the proxy: <code>{value}</code>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,16 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
|
||||
import { useLocalRuntimeReadiness } from '@/hooks/use-local-runtime-readiness';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ArrowRight,
|
||||
BrainCircuit,
|
||||
CloudCog,
|
||||
ExternalLink,
|
||||
HardDriveDownload,
|
||||
KeyRound,
|
||||
Laptop,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Zap,
|
||||
@@ -22,6 +26,8 @@ interface OpenRouterQuickStartProps {
|
||||
onAlibabaCodingPlanClick: () => void;
|
||||
onCliproxyClick: () => void;
|
||||
onCustomClick: () => void;
|
||||
onOllamaClick: () => void;
|
||||
onLlamacppClick: () => void;
|
||||
}
|
||||
|
||||
interface QuickStartCardProps {
|
||||
@@ -38,6 +44,24 @@ interface QuickStartCardProps {
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
type LocalRuntimeStatus = 'ready' | 'missing-model' | 'offline';
|
||||
|
||||
type LocalRuntimeView = {
|
||||
id: 'ollama' | 'llamacpp';
|
||||
endpoint: string;
|
||||
status: LocalRuntimeStatus;
|
||||
commandHint: string;
|
||||
detectedModelCount: number;
|
||||
};
|
||||
|
||||
type LocalRuntimeCardState = {
|
||||
badge: string;
|
||||
badgeClassName: string;
|
||||
actionLabel: string;
|
||||
description: string;
|
||||
footer: string;
|
||||
};
|
||||
|
||||
function QuickStartCard({
|
||||
badge,
|
||||
badgeClassName,
|
||||
@@ -84,6 +108,55 @@ function QuickStartCard({
|
||||
);
|
||||
}
|
||||
|
||||
function getLocalRuntimeCardState(
|
||||
runtime: LocalRuntimeView | undefined,
|
||||
label: string,
|
||||
t: (key: string, options?: Record<string, unknown>) => string
|
||||
): LocalRuntimeCardState {
|
||||
if (!runtime) {
|
||||
return {
|
||||
badge: t('openrouterQuickStart.localRuntimeCheckingBadge'),
|
||||
badgeClassName: 'bg-muted text-muted-foreground',
|
||||
actionLabel: t('openrouterQuickStart.localRuntimeSetupAction', { label }),
|
||||
description: t('openrouterQuickStart.localRuntimeCheckingDescription'),
|
||||
footer: t('openrouterQuickStart.localRuntimeCheckingFooter'),
|
||||
};
|
||||
}
|
||||
|
||||
if (runtime.status === 'ready') {
|
||||
return {
|
||||
badge: t('openrouterQuickStart.localRuntimeReadyBadge'),
|
||||
badgeClassName:
|
||||
'bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200',
|
||||
actionLabel: t('openrouterQuickStart.localRuntimeUseAction', { label }),
|
||||
description: t('openrouterQuickStart.localRuntimeReadyDescription'),
|
||||
footer: t('openrouterQuickStart.localRuntimeReadyFooter', { endpoint: runtime.endpoint }),
|
||||
};
|
||||
}
|
||||
|
||||
if (runtime.status === 'missing-model') {
|
||||
return {
|
||||
badge: t('openrouterQuickStart.localRuntimeNeedsModelBadge'),
|
||||
badgeClassName: 'bg-amber-500/10 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200',
|
||||
actionLabel: t('openrouterQuickStart.localRuntimeFinishAction', { label }),
|
||||
description: t('openrouterQuickStart.localRuntimeNeedsModelDescription'),
|
||||
footer: t('openrouterQuickStart.localRuntimeMissingModelFooter', {
|
||||
command: runtime.commandHint,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
badge: t('openrouterQuickStart.localRuntimeOfflineBadge'),
|
||||
badgeClassName: 'bg-muted text-muted-foreground',
|
||||
actionLabel: t('openrouterQuickStart.localRuntimeSetupAction', { label }),
|
||||
description: t('openrouterQuickStart.localRuntimeOfflineDescription'),
|
||||
footer: t('openrouterQuickStart.localRuntimeOfflineFooter', {
|
||||
command: runtime.commandHint,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function OpenRouterQuickStart({
|
||||
hasProfiles,
|
||||
profileCount,
|
||||
@@ -91,31 +164,47 @@ export function OpenRouterQuickStart({
|
||||
onAlibabaCodingPlanClick,
|
||||
onCliproxyClick,
|
||||
onCustomClick,
|
||||
onOllamaClick,
|
||||
onLlamacppClick,
|
||||
}: OpenRouterQuickStartProps) {
|
||||
const { t } = useTranslation();
|
||||
const { modelCount, isLoading } = useOpenRouterReady();
|
||||
const { data: localRuntimeData } = useLocalRuntimeReadiness();
|
||||
const modelCountLabel = isLoading ? '300+' : `${modelCount}+`;
|
||||
const profileSummaryLabel = hasProfiles
|
||||
? t('openrouterQuickStart.profileCount', { count: profileCount })
|
||||
: t('openrouterQuickStart.recommended');
|
||||
? t('openrouterQuickStart.profileSummaryCount', { count: profileCount })
|
||||
: t('openrouterQuickStart.profileSummaryEmpty');
|
||||
const summaryTitle = hasProfiles
|
||||
? t('openrouterQuickStart.selectProfileTitle')
|
||||
: t('apiProfiles.noProfilesYet');
|
||||
? t('openrouterQuickStart.summaryExistingTitle')
|
||||
: t('openrouterQuickStart.summaryEmptyTitle');
|
||||
const summaryDescription = hasProfiles
|
||||
? t('openrouterQuickStart.summaryDescriptionWithProfiles', { count: profileCount })
|
||||
: t('openrouterQuickStart.summaryDescriptionNoProfiles');
|
||||
? t('openrouterQuickStart.summaryExistingDescription', { count: profileCount })
|
||||
: t('openrouterQuickStart.summaryEmptyDescription');
|
||||
const ollamaRuntime = localRuntimeData?.runtimes.find((runtime) => runtime.id === 'ollama');
|
||||
const llamacppRuntime = localRuntimeData?.runtimes.find((runtime) => runtime.id === 'llamacpp');
|
||||
const ollamaCardState = getLocalRuntimeCardState(
|
||||
ollamaRuntime,
|
||||
t('openrouterQuickStart.localOllamaLabel'),
|
||||
t
|
||||
);
|
||||
const llamacppCardState = getLocalRuntimeCardState(
|
||||
llamacppRuntime,
|
||||
t('openrouterQuickStart.localLlamacppLabel'),
|
||||
t
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-auto bg-muted/20 p-4 sm:p-6">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||
<Card className="border-dashed bg-background/90 shadow-sm">
|
||||
<CardContent className="flex flex-col gap-4 p-5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{profileSummaryLabel}</Badge>
|
||||
<Badge variant="outline">
|
||||
{t('openrouterQuickStart.openrouterModelsBadge', { modelCountLabel })}
|
||||
{t('openrouterQuickStart.summaryBadgeDefaultQuality')}
|
||||
</Badge>
|
||||
<Badge variant="outline">{t('openrouterQuickStart.summaryBadgeLocalLane')}</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold">{summaryTitle}</h2>
|
||||
@@ -130,50 +219,175 @@ export function OpenRouterQuickStart({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<QuickStartCard
|
||||
badge={t('openrouterQuickStart.recommended')}
|
||||
title={t('openrouterQuickStart.title')}
|
||||
description={t('openrouterQuickStart.description', { modelCountLabel })}
|
||||
visual={
|
||||
<div className="rounded-lg bg-accent/10 p-2">
|
||||
<img src="/icons/openrouter.svg" alt="OpenRouter" className="h-5 w-5" />
|
||||
</div>
|
||||
}
|
||||
highlights={[
|
||||
{
|
||||
icon: <Zap className="h-3.5 w-3.5 text-accent" />,
|
||||
label: t('openrouterQuickStart.featureOneApi'),
|
||||
},
|
||||
{
|
||||
icon: <Sparkles className="h-3.5 w-3.5 text-accent" />,
|
||||
label: t('openrouterQuickStart.featureTierMapping'),
|
||||
},
|
||||
]}
|
||||
actionLabel={t('openrouterQuickStart.createOpenRouterProfile')}
|
||||
actionClassName="w-full bg-accent text-white hover:bg-accent/90"
|
||||
onAction={onOpenRouterClick}
|
||||
footer={
|
||||
<>
|
||||
{t('openrouterQuickStart.getApiKeyAt')}{' '}
|
||||
<a
|
||||
href="https://openrouter.ai/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-accent hover:underline"
|
||||
>
|
||||
openrouter.ai/keys
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BrainCircuit className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.12em] text-foreground/70">
|
||||
{t('openrouterQuickStart.qualityLanesTitle')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<QuickStartCard
|
||||
badge={t('openrouterQuickStart.recommended')}
|
||||
title={t('openrouterQuickStart.title')}
|
||||
description={t('openrouterQuickStart.description', { modelCountLabel })}
|
||||
visual={
|
||||
<div className="rounded-lg bg-accent/10 p-2">
|
||||
<img src="/icons/openrouter.svg" alt="OpenRouter" className="h-5 w-5" />
|
||||
</div>
|
||||
}
|
||||
highlights={[
|
||||
{
|
||||
icon: <Zap className="h-3.5 w-3.5 text-accent" />,
|
||||
label: t('openrouterQuickStart.featureOneApi'),
|
||||
},
|
||||
{
|
||||
icon: <Sparkles className="h-3.5 w-3.5 text-accent" />,
|
||||
label: t('openrouterQuickStart.qualityLaneHighlightDefaultQuality'),
|
||||
},
|
||||
]}
|
||||
actionLabel={t('openrouterQuickStart.createOpenRouterProfile')}
|
||||
actionClassName="w-full bg-accent text-white hover:bg-accent/90"
|
||||
onAction={onOpenRouterClick}
|
||||
footer={
|
||||
<>
|
||||
{t('openrouterQuickStart.getApiKeyAt')}{' '}
|
||||
<a
|
||||
href="https://openrouter.ai/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-accent hover:underline"
|
||||
>
|
||||
openrouter.ai/keys
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<QuickStartCard
|
||||
badge={t('alibabaCodingPlanQuickStart.recommended')}
|
||||
badgeClassName="bg-orange-500/10 text-orange-700 dark:bg-orange-500/20 dark:text-orange-200"
|
||||
title={t('alibabaCodingPlanQuickStart.title')}
|
||||
description={t('openrouterQuickStart.alibabaLaneDescription')}
|
||||
visual={
|
||||
<div className="rounded-lg bg-orange-500/10 p-2">
|
||||
<img
|
||||
src="/assets/providers/alibabacloud-color.svg"
|
||||
alt="Alibaba Coding Plan"
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
highlights={[
|
||||
{
|
||||
icon: <CloudCog className="h-3.5 w-3.5 text-orange-600" />,
|
||||
label: t('alibabaCodingPlanQuickStart.featureEndpoint'),
|
||||
},
|
||||
{
|
||||
icon: <KeyRound className="h-3.5 w-3.5 text-orange-600" />,
|
||||
label: t('openrouterQuickStart.alibabaLaneHighlightQuality'),
|
||||
},
|
||||
]}
|
||||
actionLabel={t('alibabaCodingPlanQuickStart.createAlibabaProfile')}
|
||||
actionClassName="w-full bg-orange-600 text-white hover:bg-orange-600/90"
|
||||
onAction={onAlibabaCodingPlanClick}
|
||||
footer={
|
||||
<>
|
||||
{t('alibabaCodingPlanQuickStart.readGuideAt')}{' '}
|
||||
<a
|
||||
href="https://www.alibabacloud.com/help/en/model-studio/coding-plan"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-orange-700 hover:underline dark:text-orange-400"
|
||||
>
|
||||
Alibaba Cloud Model Studio
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Laptop className="h-4 w-4 text-emerald-600" />
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.12em] text-foreground/70">
|
||||
{t('openrouterQuickStart.localRuntimesTitle')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<QuickStartCard
|
||||
badge={ollamaCardState.badge}
|
||||
badgeClassName={ollamaCardState.badgeClassName}
|
||||
title={t('openrouterQuickStart.localOllamaTitle')}
|
||||
description={ollamaCardState.description}
|
||||
visual={
|
||||
<div className="rounded-lg bg-emerald-500/10 p-2">
|
||||
<img src="/icons/ollama.svg" alt="Ollama" className="h-5 w-5" />
|
||||
</div>
|
||||
}
|
||||
highlights={[
|
||||
{
|
||||
icon: <HardDriveDownload className="h-3.5 w-3.5 text-emerald-600" />,
|
||||
label: t('openrouterQuickStart.localOllamaHighlight'),
|
||||
},
|
||||
{
|
||||
icon: <Sparkles className="h-3.5 w-3.5 text-emerald-600" />,
|
||||
label:
|
||||
ollamaRuntime?.detectedModelCount && ollamaRuntime.detectedModelCount > 0
|
||||
? t('openrouterQuickStart.localDetectedModels', {
|
||||
count: ollamaRuntime.detectedModelCount,
|
||||
})
|
||||
: t('openrouterQuickStart.localNoModelsDetected'),
|
||||
},
|
||||
]}
|
||||
actionLabel={ollamaCardState.actionLabel}
|
||||
actionClassName="w-full bg-emerald-600 text-white hover:bg-emerald-600/90"
|
||||
onAction={onOllamaClick}
|
||||
footer={<span>{ollamaCardState.footer}</span>}
|
||||
/>
|
||||
|
||||
<QuickStartCard
|
||||
badge={llamacppCardState.badge}
|
||||
badgeClassName={llamacppCardState.badgeClassName}
|
||||
title={t('openrouterQuickStart.localLlamacppTitle')}
|
||||
description={llamacppCardState.description}
|
||||
visual={
|
||||
<div className="rounded-lg bg-sky-500/10 p-2">
|
||||
<img src="/assets/providers/llama-cpp.svg" alt="llama.cpp" className="h-5 w-5" />
|
||||
</div>
|
||||
}
|
||||
highlights={[
|
||||
{
|
||||
icon: <SlidersHorizontal className="h-3.5 w-3.5 text-sky-600" />,
|
||||
label: t('openrouterQuickStart.localLlamacppHighlight'),
|
||||
},
|
||||
{
|
||||
icon: <Laptop className="h-3.5 w-3.5 text-sky-600" />,
|
||||
label:
|
||||
llamacppRuntime?.detectedModelCount && llamacppRuntime.detectedModelCount > 0
|
||||
? t('openrouterQuickStart.localDetectedModels', {
|
||||
count: llamacppRuntime.detectedModelCount,
|
||||
})
|
||||
: t('openrouterQuickStart.localWaitingServer'),
|
||||
},
|
||||
]}
|
||||
actionLabel={llamacppCardState.actionLabel}
|
||||
actionClassName="w-full bg-sky-600 text-white hover:bg-sky-600/90"
|
||||
onAction={onLlamacppClick}
|
||||
footer={<span>{llamacppCardState.footer}</span>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<QuickStartCard
|
||||
badge={t('openrouterQuickStart.runtimeProviderBadge')}
|
||||
badgeClassName="bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200"
|
||||
title={t('openrouterQuickStart.runtimeProviderTitle')}
|
||||
description={t('openrouterQuickStart.runtimeProviderDescription')}
|
||||
description={t('openrouterQuickStart.runtimeProviderManagedDescription')}
|
||||
visual={
|
||||
<div className="rounded-lg bg-emerald-500/10 p-2">
|
||||
<SlidersHorizontal className="h-5 w-5 text-emerald-700 dark:text-emerald-300" />
|
||||
@@ -194,50 +408,6 @@ export function OpenRouterQuickStart({
|
||||
onAction={onCliproxyClick}
|
||||
footer={<span>{t('openrouterQuickStart.runtimeProviderFooter')}</span>}
|
||||
/>
|
||||
|
||||
<QuickStartCard
|
||||
badge={t('alibabaCodingPlanQuickStart.recommended')}
|
||||
badgeClassName="bg-orange-500/10 text-orange-700 dark:bg-orange-500/20 dark:text-orange-200"
|
||||
className="lg:col-span-2"
|
||||
title={t('alibabaCodingPlanQuickStart.title')}
|
||||
description={t('alibabaCodingPlanQuickStart.description')}
|
||||
visual={
|
||||
<div className="rounded-lg bg-orange-500/10 p-2">
|
||||
<img
|
||||
src="/assets/providers/alibabacloud-color.svg"
|
||||
alt="Alibaba Coding Plan"
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
highlights={[
|
||||
{
|
||||
icon: <CloudCog className="h-3.5 w-3.5 text-orange-600" />,
|
||||
label: t('alibabaCodingPlanQuickStart.featureEndpoint'),
|
||||
},
|
||||
{
|
||||
icon: <KeyRound className="h-3.5 w-3.5 text-orange-600" />,
|
||||
label: t('alibabaCodingPlanQuickStart.featureKeyFormat'),
|
||||
},
|
||||
]}
|
||||
actionLabel={t('alibabaCodingPlanQuickStart.createAlibabaProfile')}
|
||||
actionClassName="w-full bg-orange-600 text-white hover:bg-orange-600/90"
|
||||
onAction={onAlibabaCodingPlanClick}
|
||||
footer={
|
||||
<>
|
||||
{t('alibabaCodingPlanQuickStart.readGuideAt')}{' '}
|
||||
<a
|
||||
href="https://www.alibabacloud.com/help/en/model-studio/coding-plan"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-orange-700 hover:underline dark:text-orange-400"
|
||||
>
|
||||
Alibaba Cloud Model Studio
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,13 +80,14 @@ interface ProfileCreateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: (name: string) => void;
|
||||
initialMode?: 'normal' | 'openrouter' | 'alibaba-coding-plan';
|
||||
initialMode?: ProviderPreset['id'] | 'normal';
|
||||
}
|
||||
|
||||
// Common URL mistakes to warn about
|
||||
const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions'];
|
||||
const CUSTOM_PRESET_ID = 'custom';
|
||||
const DEFAULT_PRESET_ID: ProviderPreset['id'] = 'openrouter';
|
||||
const LOCAL_PRESET_IDS = new Set<ProviderPreset['id']>(['ollama', 'llamacpp']);
|
||||
|
||||
const EMPTY_FORM_VALUES: FormData = {
|
||||
name: '',
|
||||
@@ -100,6 +101,12 @@ const EMPTY_FORM_VALUES: FormData = {
|
||||
};
|
||||
|
||||
const RECOMMENDED_PRESETS = getPresetsByCategory('recommended');
|
||||
const FEATURED_PREMIUM_PRESETS = RECOMMENDED_PRESETS.filter(
|
||||
(preset) => !LOCAL_PRESET_IDS.has(preset.id)
|
||||
);
|
||||
const LOCAL_RUNTIME_PRESETS = RECOMMENDED_PRESETS.filter((preset) =>
|
||||
LOCAL_PRESET_IDS.has(preset.id)
|
||||
);
|
||||
const QUICK_TEMPLATE_PRESETS = PROVIDER_PRESETS.filter(
|
||||
(preset) => preset.category !== 'recommended'
|
||||
);
|
||||
@@ -150,6 +157,7 @@ export function ProfileCreateDialog({
|
||||
opusModel: preset.defaultModel,
|
||||
sonnetModel: preset.defaultModel,
|
||||
haikuModel: preset.defaultModel,
|
||||
target: preset.defaultTarget ?? 'claude',
|
||||
});
|
||||
},
|
||||
[reset]
|
||||
@@ -314,7 +322,7 @@ export function ProfileCreateDialog({
|
||||
</Label>
|
||||
<div className="-mx-1 overflow-x-auto pb-1">
|
||||
<div className="flex min-w-max items-stretch gap-2 px-1">
|
||||
{RECOMMENDED_PRESETS.map((preset) => (
|
||||
{FEATURED_PREMIUM_PRESETS.map((preset) => (
|
||||
<CompactPresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
@@ -350,6 +358,23 @@ export function ProfileCreateDialog({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium uppercase tracking-[0.12em] text-foreground/70">
|
||||
Local Runtimes
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{LOCAL_RUNTIME_PRESETS.map((preset) => (
|
||||
<CompactPresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isSelected={selectedPreset === preset.id}
|
||||
onClick={() => handlePresetSelect(preset.id)}
|
||||
density="compact"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,12 +21,15 @@ import {
|
||||
type ModelTier,
|
||||
type UnifiedQuotaResult,
|
||||
} from '@/lib/utils';
|
||||
import type { ProviderEntitlementEvidence } from '@/lib/api-client';
|
||||
|
||||
interface QuotaTooltipContentProps {
|
||||
quota: UnifiedQuotaResult | null | undefined;
|
||||
resetTime: string | null;
|
||||
}
|
||||
|
||||
const lowQuotaTextClass = 'text-red-700 dark:text-red-400';
|
||||
|
||||
function formatPlanLabel(planType: string | null | undefined): string | null {
|
||||
if (!planType) return null;
|
||||
const normalized = planType
|
||||
@@ -75,6 +78,35 @@ function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): s
|
||||
}
|
||||
}
|
||||
|
||||
function renderEntitlementRows(entitlement: ProviderEntitlementEvidence | undefined) {
|
||||
if (!entitlement) return null;
|
||||
|
||||
const rows: Array<{ label: string; value: string | null }> = [];
|
||||
if (entitlement.rawTierLabel) {
|
||||
rows.push({ label: 'Tier', value: entitlement.rawTierLabel });
|
||||
} else if (entitlement.normalizedTier !== 'unknown') {
|
||||
rows.push({ label: 'Tier', value: entitlement.normalizedTier });
|
||||
}
|
||||
if (entitlement.rawTierId) {
|
||||
rows.push({ label: 'Tier ID', value: entitlement.rawTierId });
|
||||
}
|
||||
if (entitlement.accessState !== 'entitled' || entitlement.capacityState !== 'available') {
|
||||
rows.push({
|
||||
label: 'State',
|
||||
value: `${entitlement.accessState.replaceAll('_', ' ')} / ${entitlement.capacityState.replaceAll('_', ' ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return rows.map((row) => (
|
||||
<div key={row.label} className="flex justify-between gap-4">
|
||||
<span className="text-muted-foreground">{row.label}</span>
|
||||
<span className="font-mono">{row.value}</span>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders provider-specific quota tooltip content
|
||||
* Uses type guards for proper TypeScript narrowing
|
||||
@@ -86,22 +118,35 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
|
||||
if (!quota.success) {
|
||||
const failureInfo = getQuotaFailureInfo(quota);
|
||||
const failureToneClass =
|
||||
failureInfo?.tone === 'destructive'
|
||||
? 'text-destructive'
|
||||
: failureInfo?.tone === 'warning'
|
||||
? 'text-amber-700 dark:text-amber-300'
|
||||
: 'text-foreground';
|
||||
|
||||
return (
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium text-destructive">
|
||||
{failureInfo?.label || quota.error || 'Failed to load quota'}
|
||||
</p>
|
||||
<p className="text-destructive/90">{failureInfo?.summary || quota.error}</p>
|
||||
<div className="max-w-sm space-y-2 text-xs">
|
||||
<div className="space-y-1">
|
||||
<p className={cn('font-semibold tracking-tight', failureToneClass)}>
|
||||
{failureInfo?.label || quota.error || 'Failed to load quota'}
|
||||
</p>
|
||||
<p className="leading-relaxed text-foreground/90">
|
||||
{failureInfo?.summary || quota.error}
|
||||
</p>
|
||||
</div>
|
||||
{failureInfo?.actionHint && (
|
||||
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
|
||||
<div className="rounded-md border border-border/70 bg-muted/35 px-2.5 py-2 text-foreground/80">
|
||||
{failureInfo.actionHint}
|
||||
</div>
|
||||
)}
|
||||
{failureInfo?.technicalDetail && (
|
||||
<p className="font-mono text-[11px] text-muted-foreground">
|
||||
<div className="rounded-md border border-border/60 bg-muted/25 px-2 py-1.5 font-mono text-[11px] text-foreground/75">
|
||||
{failureInfo.technicalDetail}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{failureInfo?.rawDetail && (
|
||||
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
|
||||
<pre className="max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border/70 bg-muted/55 px-2.5 py-2 font-mono text-[11px] leading-relaxed text-foreground/85">
|
||||
{failureInfo.rawDetail}
|
||||
</pre>
|
||||
)}
|
||||
@@ -116,7 +161,8 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
|
||||
|
||||
return (
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="text-xs space-y-1.5">
|
||||
{renderEntitlementRows(quota.entitlement)}
|
||||
<p className="font-medium">Model Quotas:</p>
|
||||
{tierOrder.map((tier, idx) => {
|
||||
const models = groups.get(tier);
|
||||
@@ -124,13 +170,13 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length);
|
||||
return (
|
||||
<div key={tier}>
|
||||
{!isFirst && <div className="border-t border-border/40 my-1" />}
|
||||
{!isFirst && <div className="my-1 border-t border-border/40" />}
|
||||
{models.map((m) => (
|
||||
<div key={m.name} className="flex justify-between gap-4">
|
||||
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
|
||||
<span className={cn('truncate', m.exhausted && lowQuotaTextClass)}>
|
||||
{m.displayName}
|
||||
</span>
|
||||
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
|
||||
<span className={cn('font-mono', m.exhausted && lowQuotaTextClass)}>
|
||||
{m.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -159,7 +205,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="text-xs space-y-1.5">
|
||||
<p className="font-medium">Rate Limits:</p>
|
||||
{quota.planType && <p className="text-muted-foreground">Plan: {quota.planType}</p>}
|
||||
{orderedWindows.map((w, index) => (
|
||||
@@ -167,7 +213,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
key={`${w.label}-${w.resetAt ?? 'no-reset'}-${index}`}
|
||||
className="flex justify-between gap-4"
|
||||
>
|
||||
<span className={cn(w.remainingPercent < 20 && 'text-red-500')}>
|
||||
<span className={cn(w.remainingPercent < 20 && lowQuotaTextClass)}>
|
||||
{getCodexWindowDisplayLabel(w, orderedWindows)}
|
||||
</span>
|
||||
<span className="font-mono">{w.remainingPercent}%</span>
|
||||
@@ -228,14 +274,14 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
null;
|
||||
|
||||
return (
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="text-xs space-y-1.5">
|
||||
<p className="font-medium">Rate Limits:</p>
|
||||
{orderedWindows.map((window, index) => (
|
||||
<div
|
||||
key={`${window.rateLimitType}-${window.resetAt ?? 'no-reset'}-${window.status}-${index}`}
|
||||
className="flex justify-between gap-4"
|
||||
>
|
||||
<span className={cn(window.remainingPercent < 20 && 'text-red-500')}>
|
||||
<span className={cn(window.remainingPercent < 20 && lowQuotaTextClass)}>
|
||||
{getClaudeWindowDisplayLabel(window.rateLimitType, window.label)}
|
||||
</span>
|
||||
<span className="font-mono">{window.remainingPercent}%</span>
|
||||
@@ -253,10 +299,13 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
// Gemini provider tooltip
|
||||
if (isGeminiQuotaResult(quota)) {
|
||||
const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime);
|
||||
const hasEntitlementTier =
|
||||
!!quota.entitlement?.rawTierLabel || quota.entitlement?.normalizedTier !== 'unknown';
|
||||
|
||||
return (
|
||||
<div className="text-xs space-y-1">
|
||||
{quota.tierLabel && (
|
||||
<div className="text-xs space-y-1.5">
|
||||
{renderEntitlementRows(quota.entitlement)}
|
||||
{!hasEntitlementTier && quota.tierLabel && (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-muted-foreground">Tier</span>
|
||||
<span className="font-mono">{quota.tierLabel}</span>
|
||||
@@ -272,7 +321,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
{quota.buckets.map((b) => (
|
||||
<div key={b.id} className="space-y-0.5">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className={cn(b.remainingPercent < 20 && 'text-red-500')}>
|
||||
<span className={cn(b.remainingPercent < 20 && lowQuotaTextClass)}>
|
||||
{b.label}
|
||||
{b.tokenType ? ` (${b.tokenType})` : ''}
|
||||
</span>
|
||||
@@ -306,7 +355,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
const planLabel = formatPlanLabel(quota.planType);
|
||||
|
||||
return (
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="text-xs space-y-1.5">
|
||||
<p className="font-medium">Quota Snapshots:</p>
|
||||
{planLabel && <p className="text-muted-foreground">Plan: {planLabel}</p>}
|
||||
{snapshotRows.map(({ label, snapshot }) => {
|
||||
@@ -314,8 +363,8 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
return (
|
||||
<div key={label} className="space-y-0.5">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className={cn(isLow && 'text-red-500')}>{label}</span>
|
||||
<span className={cn('font-mono', isLow && 'text-red-500')}>
|
||||
<span className={cn(isLow && lowQuotaTextClass)}>{label}</span>
|
||||
<span className={cn('font-mono', isLow && lowQuotaTextClass)}>
|
||||
{snapshot.unlimited
|
||||
? 'Unlimited'
|
||||
: `${formatQuotaPercent(snapshot.percentRemaining)}%`}
|
||||
@@ -344,9 +393,11 @@ function ResetTimeIndicator({ resetTime }: { resetTime: string | null }) {
|
||||
if (!resetTime) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">Resets {formatResetTime(resetTime)}</span>
|
||||
<div className="flex items-center gap-1.5 border-t border-border/60 pt-1">
|
||||
<Clock className="h-3 w-3 text-sky-600 dark:text-sky-300" />
|
||||
<span className="font-medium text-sky-600 dark:text-sky-300">
|
||||
Resets {formatResetTime(resetTime)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -364,19 +415,19 @@ function CodexResetIndicators({
|
||||
if (!hasSpecificReset && !fallbackResetTime) return null;
|
||||
|
||||
return (
|
||||
<div className="pt-1 border-t border-border/50 space-y-1">
|
||||
<div className="space-y-1 border-t border-border/60 pt-1">
|
||||
{fiveHourResetTime && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">
|
||||
<Clock className="h-3 w-3 text-sky-600 dark:text-sky-300" />
|
||||
<span className="font-medium text-sky-600 dark:text-sky-300">
|
||||
5h resets {formatResetTime(fiveHourResetTime)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{weeklyResetTime && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3 text-indigo-400" />
|
||||
<span className="text-indigo-400 font-medium">
|
||||
<Clock className="h-3 w-3 text-indigo-600 dark:text-indigo-300" />
|
||||
<span className="font-medium text-indigo-600 dark:text-indigo-300">
|
||||
Weekly resets {formatResetTime(weeklyResetTime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
'z-50 w-80 max-w-[calc(100vw-2rem)] rounded-lg border border-border/70 bg-popover p-4 text-popover-foreground shadow-xl shadow-black/10 outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -32,7 +32,7 @@ function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimiti
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
sideOffset = 6,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
@@ -42,13 +42,13 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
|
||||
'animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit max-w-[calc(100vw-2rem)] origin-(--radix-tooltip-content-transform-origin) rounded-lg border border-border/70 bg-popover px-3 py-2 text-left text-xs leading-relaxed text-popover-foreground shadow-xl shadow-black/10',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 fill-popover stroke-border stroke-[0.75px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
|
||||
@@ -47,6 +47,11 @@ const MAX_POLL_DURATION = 5 * 60 * 1000;
|
||||
/** Fail visibly after repeated poll transport errors instead of retrying forever */
|
||||
const MAX_POLL_FAILURES = 3;
|
||||
|
||||
function invalidateCliproxyRoutingData(queryClient: ReturnType<typeof useQueryClient>): void {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] });
|
||||
}
|
||||
|
||||
async function parseResponseBody(response: Response): Promise<Record<string, unknown>> {
|
||||
const text = await response.text();
|
||||
if (!text) return {};
|
||||
@@ -167,6 +172,7 @@ export function useCliproxyAuthFlow() {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['account-quota'] });
|
||||
invalidateCliproxyRoutingData(queryClient);
|
||||
toast.success(`${provider} authentication successful`);
|
||||
openedAuthUrlRef.current = false;
|
||||
setState(INITIAL_STATE);
|
||||
@@ -301,6 +307,7 @@ export function useCliproxyAuthFlow() {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['account-quota'] });
|
||||
invalidateCliproxyRoutingData(queryClient);
|
||||
// Note: No toast here - DeviceCodeDialog's useDeviceCode hook handles success toast
|
||||
// via deviceCodeCompleted WebSocket event to avoid duplicate toasts
|
||||
openedAuthUrlRef.current = false;
|
||||
@@ -467,6 +474,7 @@ export function useCliproxyAuthFlow() {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['account-quota'] });
|
||||
invalidateCliproxyRoutingData(queryClient);
|
||||
toast.success(`${currentProvider} authentication successful`);
|
||||
setState(INITIAL_STATE);
|
||||
} else {
|
||||
|
||||
@@ -14,6 +14,17 @@ import {
|
||||
} from '@/lib/api-client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
function invalidateCliproxyRoutingQueries(queryClient: ReturnType<typeof useQueryClient>): void {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] });
|
||||
}
|
||||
|
||||
function invalidateCliproxyAccountQueries(queryClient: ReturnType<typeof useQueryClient>): void {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
invalidateCliproxyRoutingQueries(queryClient);
|
||||
}
|
||||
|
||||
export function useCliproxy() {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy'],
|
||||
@@ -128,8 +139,7 @@ export function useSetDefaultAccount() {
|
||||
mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) =>
|
||||
api.cliproxy.accounts.setDefault(provider, accountId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
invalidateCliproxyAccountQueries(queryClient);
|
||||
toast.success('Default account updated');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
@@ -145,8 +155,7 @@ export function useRemoveAccount() {
|
||||
mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) =>
|
||||
api.cliproxy.accounts.remove(provider, accountId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
invalidateCliproxyAccountQueries(queryClient);
|
||||
toast.success('Account removed');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
@@ -162,8 +171,7 @@ export function usePauseAccount() {
|
||||
mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) =>
|
||||
api.cliproxy.accounts.pause(provider, accountId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
invalidateCliproxyAccountQueries(queryClient);
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] });
|
||||
toast.success('Account paused');
|
||||
},
|
||||
@@ -180,8 +188,7 @@ export function useResumeAccount() {
|
||||
mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) =>
|
||||
api.cliproxy.accounts.resume(provider, accountId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
invalidateCliproxyAccountQueries(queryClient);
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] });
|
||||
toast.success('Account resumed');
|
||||
},
|
||||
@@ -198,8 +205,7 @@ export function useSoloAccount() {
|
||||
mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) =>
|
||||
api.cliproxy.accounts.solo(provider, accountId),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
invalidateCliproxyAccountQueries(queryClient);
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] });
|
||||
const pausedCount = data.paused.length;
|
||||
toast.success(
|
||||
@@ -219,8 +225,7 @@ export function useBulkPauseAccounts() {
|
||||
mutationFn: ({ provider, accountIds }: { provider: string; accountIds: string[] }) =>
|
||||
api.cliproxy.accounts.bulkPause(provider, accountIds),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
invalidateCliproxyAccountQueries(queryClient);
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] });
|
||||
toast.success(
|
||||
`Paused ${data.succeeded.length} account${data.succeeded.length !== 1 ? 's' : ''}`
|
||||
@@ -244,8 +249,7 @@ export function useBulkResumeAccounts() {
|
||||
mutationFn: ({ provider, accountIds }: { provider: string; accountIds: string[] }) =>
|
||||
api.cliproxy.accounts.bulkResume(provider, accountIds),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
invalidateCliproxyAccountQueries(queryClient);
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] });
|
||||
toast.success(
|
||||
`Resumed ${data.succeeded.length} account${data.succeeded.length !== 1 ? 's' : ''}`
|
||||
@@ -270,8 +274,7 @@ export function useStartAuth() {
|
||||
mutationFn: ({ provider, nickname }: { provider: string; nickname?: string }) =>
|
||||
api.cliproxy.auth.start(provider, nickname),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
invalidateCliproxyAccountQueries(queryClient);
|
||||
toast.success(`Account added for ${variables.provider}`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
@@ -297,8 +300,7 @@ export function useKiroImport() {
|
||||
return useMutation({
|
||||
mutationFn: () => api.cliproxy.auth.kiroImport(),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
invalidateCliproxyAccountQueries(queryClient);
|
||||
if (data.account) {
|
||||
toast.success(`Imported Kiro account: ${data.account.email || data.account.id}`);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api-client';
|
||||
|
||||
export function useLocalRuntimeReadiness() {
|
||||
return useQuery({
|
||||
queryKey: ['profiles', 'local-runtime-readiness'],
|
||||
queryFn: () => api.profiles.getLocalRuntimeReadiness(),
|
||||
staleTime: 15_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -9,9 +9,11 @@ import type {
|
||||
ListAiProvidersResult,
|
||||
UpsertAiProviderEntryInput,
|
||||
} from '../../../src/cliproxy/ai-providers';
|
||||
import type { ProviderEntitlementEvidence } from '../../../src/cliproxy/provider-entitlement-types';
|
||||
|
||||
export const API_BASE_URL = '/api';
|
||||
export const API_CONFLICT_ERROR_CODE = 'CONFLICT';
|
||||
export type { ProviderEntitlementEvidence };
|
||||
|
||||
export class ApiConflictError extends Error {
|
||||
readonly code = API_CONFLICT_ERROR_CODE;
|
||||
@@ -239,6 +241,23 @@ export interface Profile {
|
||||
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
||||
}
|
||||
|
||||
export type LocalRuntimeStatus = 'ready' | 'missing-model' | 'offline';
|
||||
|
||||
export interface LocalRuntimeReadiness {
|
||||
id: 'ollama' | 'llamacpp';
|
||||
name: string;
|
||||
endpoint: string;
|
||||
status: LocalRuntimeStatus;
|
||||
commandHint: string;
|
||||
recommendedModel: string | null;
|
||||
recommendedModelInstalled: boolean;
|
||||
detectedModelCount: number;
|
||||
}
|
||||
|
||||
export interface LocalRuntimeReadinessResponse {
|
||||
runtimes: LocalRuntimeReadiness[];
|
||||
}
|
||||
|
||||
export interface CreateProfile {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
@@ -468,6 +487,30 @@ export interface CliproxyCatalogModel {
|
||||
presetMapping?: CliproxyCatalogPresetMapping;
|
||||
}
|
||||
|
||||
export interface CliproxyModelRoutingHint {
|
||||
modelId: string;
|
||||
modelName: string;
|
||||
prefix: string;
|
||||
pinnedModelId: string;
|
||||
recommendedModelId: string;
|
||||
pinnedAvailable: boolean;
|
||||
unprefixedStatus: 'safe' | 'shadowed' | 'prefix-only';
|
||||
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[];
|
||||
}
|
||||
|
||||
export interface CliproxyProviderCatalog {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
@@ -477,6 +520,7 @@ export interface CliproxyProviderCatalog {
|
||||
|
||||
export interface CliproxyCatalogResponse {
|
||||
catalogs: Partial<Record<string, CliproxyProviderCatalog>>;
|
||||
routing?: Partial<Record<string, CliproxyProviderRoutingHints>>;
|
||||
source: 'live' | 'cache' | 'static';
|
||||
cache: {
|
||||
synced: boolean;
|
||||
@@ -520,6 +564,8 @@ export interface QuotaResult {
|
||||
retryable?: boolean;
|
||||
/** True if token is expired and needs re-authentication */
|
||||
needsReauth?: boolean;
|
||||
/** Richer provider entitlement evidence derived from live/runtime signals */
|
||||
entitlement?: ProviderEntitlementEvidence;
|
||||
}
|
||||
|
||||
/** Codex rate limit window */
|
||||
@@ -683,6 +729,8 @@ export interface GeminiCliQuotaResult {
|
||||
tierId?: string | null;
|
||||
/** Available Google One AI credits when reported by the API */
|
||||
creditBalance?: number | null;
|
||||
/** Richer provider entitlement evidence derived from live/runtime signals */
|
||||
entitlement?: ProviderEntitlementEvidence;
|
||||
/** Timestamp of fetch */
|
||||
lastUpdated: number;
|
||||
/** Upstream HTTP status when available */
|
||||
@@ -999,6 +1047,8 @@ export interface CliproxyRestartResult {
|
||||
export const api = {
|
||||
profiles: {
|
||||
list: () => request<{ profiles: Profile[] }>('/profiles'),
|
||||
getLocalRuntimeReadiness: () =>
|
||||
request<LocalRuntimeReadinessResponse>('/profiles/local-runtime-readiness'),
|
||||
create: (data: CreateProfile) =>
|
||||
request('/profiles', {
|
||||
method: 'POST',
|
||||
|
||||
+224
-57
@@ -19,7 +19,7 @@ const resources = {
|
||||
home: 'Home',
|
||||
analytics: 'Analytics',
|
||||
identityAccess: 'Identity & Access',
|
||||
apiProfiles: 'API Profiles',
|
||||
apiProfiles: 'Profiles',
|
||||
cliproxyPlus: 'CLIProxy Plus',
|
||||
cliproxyOverview: 'Overview',
|
||||
controlPanel: 'Control Panel',
|
||||
@@ -269,21 +269,59 @@ const resources = {
|
||||
description:
|
||||
'Access {{modelCountLabel}} models from OpenAI, Anthropic, Google, Meta and more - all through one API.',
|
||||
featureOneApi: 'One API, all providers',
|
||||
featureTierMapping: 'Model tier mapping',
|
||||
profileCount: '{{count}} profile',
|
||||
profileCount_other: '{{count}} profiles',
|
||||
selectProfileTitle: 'Select an API profile',
|
||||
summaryDescriptionWithProfiles:
|
||||
'You already have {{count}} profile in this workspace. Select one from the left rail to edit it, or create another profile from here.',
|
||||
summaryDescriptionWithProfiles_other:
|
||||
'You already have {{count}} profiles in this workspace. Select one from the left rail to edit it, or create another profile from here.',
|
||||
summaryDescriptionNoProfiles:
|
||||
'Use API Profiles for Anthropic-compatible endpoints. Use AI Providers for Gemini, Codex, Claude, Vertex, and OpenAI-compatible connectors.',
|
||||
openrouterModelsBadge: '{{modelCountLabel}} OpenRouter models',
|
||||
profileSummaryCount: '{{count}} profile',
|
||||
profileSummaryCount_other: '{{count}} profiles',
|
||||
profileSummaryEmpty: 'Premium quality + local control',
|
||||
summaryBadgeDefaultQuality: 'Best quality by default',
|
||||
summaryBadgeLocalLane: 'Local lane when task fit wins',
|
||||
summaryExistingTitle: 'Choose a profile or add another lane',
|
||||
summaryEmptyTitle: 'Choose your first profile lane',
|
||||
summaryExistingDescription:
|
||||
'You already have {{count}} profile in this workspace. Keep premium-quality providers for serious coding and add local runtimes when privacy, cost, or offline work matters.',
|
||||
summaryExistingDescription_other:
|
||||
'You already have {{count}} profiles in this workspace. Keep premium-quality providers for serious coding and add local runtimes when privacy, cost, or offline work matters.',
|
||||
summaryEmptyDescription:
|
||||
'Pick the lane that matches the work. Premium providers stay the default for reliable coding. Local runtimes are best for privacy, cheaper transforms, and experimentation.',
|
||||
qualityLanesTitle: 'Best quality lanes',
|
||||
qualityLaneHighlightDefaultQuality: 'Best default lane for high-stakes coding quality',
|
||||
alibabaLaneDescription:
|
||||
'Strong direct coding profile when you want a dedicated premium lane outside the OpenRouter catalog.',
|
||||
alibabaLaneHighlightQuality: 'Good when you want premium quality with a dedicated endpoint',
|
||||
localRuntimesTitle: 'Local runtimes',
|
||||
localOllamaTitle: 'Ollama + Gemma 4',
|
||||
localOllamaLabel: 'Ollama',
|
||||
localOllamaHighlight: 'Best for private prompts, local cleanup, and cheap batch transforms',
|
||||
localLlamacppTitle: 'llama.cpp',
|
||||
localLlamacppLabel: 'llama.cpp',
|
||||
localLlamacppHighlight:
|
||||
'Best for custom GGUF setups and advanced self-hosted local workflows',
|
||||
localDetectedModels: '{{count}} local model detected',
|
||||
localDetectedModels_other: '{{count}} local models detected',
|
||||
localNoModelsDetected: 'No local models detected yet',
|
||||
localWaitingServer: 'Waiting for a local server and model list',
|
||||
localRuntimeCheckingBadge: 'Checking',
|
||||
localRuntimeReadyBadge: 'Ready',
|
||||
localRuntimeNeedsModelBadge: 'Needs model',
|
||||
localRuntimeOfflineBadge: 'Offline',
|
||||
localRuntimeCheckingDescription:
|
||||
'Checking the local runtime status before showing setup guidance.',
|
||||
localRuntimeReadyDescription:
|
||||
'Best for private prompts, offline workflows, and cheaper batch transforms.',
|
||||
localRuntimeNeedsModelDescription:
|
||||
'The runtime is up, but the recommended local model still needs to be pulled or loaded.',
|
||||
localRuntimeOfflineDescription:
|
||||
'Use this lane for privacy-sensitive work, cheap local transforms, and offline sessions.',
|
||||
localRuntimeCheckingFooter: 'Checking the local runtime...',
|
||||
localRuntimeReadyFooter: 'Endpoint ready at {{endpoint}}',
|
||||
localRuntimeMissingModelFooter: 'Run `{{command}}` to finish local setup.',
|
||||
localRuntimeOfflineFooter: 'Run `{{command}}` to bring the local endpoint online.',
|
||||
localRuntimeSetupAction: 'Set up {{label}}',
|
||||
localRuntimeUseAction: 'Use {{label}}',
|
||||
localRuntimeFinishAction: 'Finish {{label}} setup',
|
||||
runtimeProviderBadge: 'Runtime provider setup',
|
||||
runtimeProviderTitle: 'Open AI Providers',
|
||||
runtimeProviderDescription:
|
||||
'Manage shared provider keys and runtime connectors before creating standalone API Profiles.',
|
||||
runtimeProviderManagedDescription:
|
||||
'Use this when you need CLIProxy-managed connectors, provider secrets, and advanced routing controls.',
|
||||
runtimeProviderFeatureConnectors:
|
||||
'Gemini, Codex, Claude, Vertex, and OpenAI-compatible connectors',
|
||||
runtimeProviderFeatureSecrets: 'Shared secrets stay outside individual API profiles',
|
||||
@@ -291,7 +329,6 @@ const resources = {
|
||||
'Best when multiple profiles should share one provider configuration.',
|
||||
createOpenRouterProfile: 'Create OpenRouter Profile',
|
||||
getApiKeyAt: 'Get your API key at',
|
||||
or: 'or',
|
||||
createCustomProfile: 'Create Custom API Profile',
|
||||
},
|
||||
alibabaCodingPlanQuickStart: {
|
||||
@@ -673,6 +710,11 @@ const resources = {
|
||||
backToProviders: 'Back to providers',
|
||||
showDetails: 'Show Details',
|
||||
hideDetails: 'Hide Details',
|
||||
showPausedAccounts: 'Show Paused ({{count}})',
|
||||
hidePausedAccounts: 'Hide Paused ({{count}})',
|
||||
visibleTotalRequests: 'Visible Requests',
|
||||
visibleAccounts: 'Visible Accounts',
|
||||
excludingPausedAccounts: 'Excluding {{count}} paused',
|
||||
resetLayout: 'Reset layout',
|
||||
provider: 'Provider',
|
||||
totalRequests: 'Total Requests',
|
||||
@@ -1012,7 +1054,7 @@ const resources = {
|
||||
tokensSubtitle: '{{value}} tokens',
|
||||
},
|
||||
apiProfiles: {
|
||||
title: 'API Profiles',
|
||||
title: 'Profiles',
|
||||
new: 'New',
|
||||
searchPlaceholder: 'Search profiles...',
|
||||
loadingProfiles: 'Loading profiles...',
|
||||
@@ -1623,26 +1665,59 @@ const resources = {
|
||||
description:
|
||||
'通过一个 API 即可访问来自 OpenAI、Anthropic、Google、Meta 等的 {{modelCountLabel}} 个模型。',
|
||||
featureOneApi: '一个 API,接入全部提供商',
|
||||
featureTierMapping: '模型档位映射',
|
||||
profileCount: '{{count}} 个 API 配置',
|
||||
profileCount_other: '{{count}} 个 API 配置',
|
||||
selectProfileTitle: '选择一个 API 配置',
|
||||
summaryDescriptionWithProfiles:
|
||||
'此工作区中已存在 {{count}} 个 API 配置。请从左侧栏选择一个进行编辑,或在此继续创建新的配置。',
|
||||
summaryDescriptionWithProfiles_other:
|
||||
'此工作区中已存在 {{count}} 个 API 配置。请从左侧栏选择一个进行编辑,或在此继续创建新的配置。',
|
||||
summaryDescriptionNoProfiles:
|
||||
'API 配置适用于 Anthropic 兼容端点。AI Providers 适用于 Gemini、Codex、Claude、Vertex 以及 OpenAI 兼容连接器。',
|
||||
openrouterModelsBadge: '{{modelCountLabel}} 个 OpenRouter 模型',
|
||||
profileSummaryCount: '{{count}} 个配置',
|
||||
profileSummaryCount_other: '{{count}} 个配置',
|
||||
profileSummaryEmpty: '高级质量 + 本地控制',
|
||||
summaryBadgeDefaultQuality: '默认最佳质量',
|
||||
summaryBadgeLocalLane: '任务合适时使用本地通道',
|
||||
summaryExistingTitle: '选择一个配置,或添加新的通道',
|
||||
summaryEmptyTitle: '选择你的第一条配置通道',
|
||||
summaryExistingDescription:
|
||||
'此工作区中已存在 {{count}} 个配置。对于严肃编码工作请优先使用高质量提供商,而在隐私、成本或离线场景下再增加本地运行时。',
|
||||
summaryExistingDescription_other:
|
||||
'此工作区中已存在 {{count}} 个配置。对于严肃编码工作请优先使用高质量提供商,而在隐私、成本或离线场景下再增加本地运行时。',
|
||||
summaryEmptyDescription:
|
||||
'根据任务选择通道。高级提供商仍是可靠编码的默认选择;本地运行时更适合隐私、低成本转换和实验。',
|
||||
qualityLanesTitle: '高质量通道',
|
||||
qualityLaneHighlightDefaultQuality: '高风险编码任务的默认最佳通道',
|
||||
alibabaLaneDescription:
|
||||
'当你想要一个独立于 OpenRouter 目录之外的高级编码通道时,这是一个强力的直连配置。',
|
||||
alibabaLaneHighlightQuality: '适合需要独立端点的高级质量场景',
|
||||
localRuntimesTitle: '本地运行时',
|
||||
localOllamaTitle: 'Ollama + Gemma 4',
|
||||
localOllamaLabel: 'Ollama',
|
||||
localOllamaHighlight: '最适合隐私敏感提示、本地清理和低成本批处理转换',
|
||||
localLlamacppTitle: 'llama.cpp',
|
||||
localLlamacppLabel: 'llama.cpp',
|
||||
localLlamacppHighlight: '最适合自定义 GGUF 部署和高级自托管本地工作流',
|
||||
localDetectedModels: '已检测到 {{count}} 个本地模型',
|
||||
localDetectedModels_other: '已检测到 {{count}} 个本地模型',
|
||||
localNoModelsDetected: '尚未检测到本地模型',
|
||||
localWaitingServer: '正在等待本地服务和模型列表',
|
||||
localRuntimeCheckingBadge: '检测中',
|
||||
localRuntimeReadyBadge: '就绪',
|
||||
localRuntimeNeedsModelBadge: '缺少模型',
|
||||
localRuntimeOfflineBadge: '离线',
|
||||
localRuntimeCheckingDescription: '正在检查本地运行时状态,然后再显示设置引导。',
|
||||
localRuntimeReadyDescription: '最适合私有提示、离线工作流和更便宜的批量转换。',
|
||||
localRuntimeNeedsModelDescription: '运行时已启动,但推荐的本地模型仍需要拉取或加载。',
|
||||
localRuntimeOfflineDescription: '这个通道适合隐私敏感工作、低成本本地转换和离线会话。',
|
||||
localRuntimeCheckingFooter: '正在检查本地运行时...',
|
||||
localRuntimeReadyFooter: '端点已就绪:{{endpoint}}',
|
||||
localRuntimeMissingModelFooter: '运行 `{{command}}` 完成本地设置。',
|
||||
localRuntimeOfflineFooter: '运行 `{{command}}` 启动本地端点。',
|
||||
localRuntimeSetupAction: '设置 {{label}}',
|
||||
localRuntimeUseAction: '使用 {{label}}',
|
||||
localRuntimeFinishAction: '完成 {{label}} 设置',
|
||||
runtimeProviderBadge: '运行时提供商设置',
|
||||
runtimeProviderTitle: '打开 AI Providers',
|
||||
runtimeProviderDescription: '先管理共享的提供商密钥和运行时连接器,再创建独立的 API 配置。',
|
||||
runtimeProviderManagedDescription:
|
||||
'当你需要 CLIProxy 管理的连接器、提供商密钥和高级路由控制时使用这里。',
|
||||
runtimeProviderFeatureConnectors: 'Gemini、Codex、Claude、Vertex 以及 OpenAI 兼容连接器',
|
||||
runtimeProviderFeatureSecrets: '共享密钥与单个 API 配置分离存放',
|
||||
runtimeProviderFooter: '当多个配置需要共享同一提供商设置时最适合使用。',
|
||||
createOpenRouterProfile: '创建 OpenRouter 配置',
|
||||
getApiKeyAt: '在此获取 API Key:',
|
||||
or: '或',
|
||||
createCustomProfile: '创建自定义 API 配置',
|
||||
},
|
||||
alibabaCodingPlanQuickStart: {
|
||||
@@ -1986,6 +2061,11 @@ const resources = {
|
||||
backToProviders: '返回提供商',
|
||||
showDetails: '显示详情',
|
||||
hideDetails: '隐藏详情',
|
||||
showPausedAccounts: '显示已暂停 ({{count}})',
|
||||
hidePausedAccounts: '隐藏已暂停 ({{count}})',
|
||||
visibleTotalRequests: '可见请求数',
|
||||
visibleAccounts: '可见账号',
|
||||
excludingPausedAccounts: '已排除 {{count}} 个已暂停',
|
||||
resetLayout: '重置布局',
|
||||
provider: '提供商',
|
||||
totalRequests: '总请求数',
|
||||
@@ -2930,21 +3010,60 @@ const resources = {
|
||||
description:
|
||||
'Truy cập các mô hình {{modelCountLabel}} từ OpenAI, Anthropic, Google, Meta, v.v. - tất cả đều thông qua một API.',
|
||||
featureOneApi: 'Một API cho mọi nhà cung cấp',
|
||||
featureTierMapping: 'Ánh xạ tầng mô hình',
|
||||
profileCount: '{{count}} hồ sơ',
|
||||
profileCount_other: '{{count}} hồ sơ',
|
||||
selectProfileTitle: 'Chọn một hồ sơ API',
|
||||
summaryDescriptionWithProfiles:
|
||||
'Bạn đã có {{count}} hồ sơ trong workspace này. Chọn một hồ sơ ở thanh bên trái để chỉnh sửa, hoặc tạo thêm hồ sơ mới tại đây.',
|
||||
summaryDescriptionWithProfiles_other:
|
||||
'Bạn đã có {{count}} hồ sơ trong workspace này. Chọn một hồ sơ ở thanh bên trái để chỉnh sửa, hoặc tạo thêm hồ sơ mới tại đây.',
|
||||
summaryDescriptionNoProfiles:
|
||||
'Dùng API Profiles cho các endpoint tương thích Anthropic. Dùng AI Providers cho Gemini, Codex, Claude, Vertex và các connector tương thích OpenAI.',
|
||||
openrouterModelsBadge: '{{modelCountLabel}} mô hình OpenRouter',
|
||||
profileSummaryCount: '{{count}} hồ sơ',
|
||||
profileSummaryCount_other: '{{count}} hồ sơ',
|
||||
profileSummaryEmpty: 'Chất lượng cao + kiểm soát cục bộ',
|
||||
summaryBadgeDefaultQuality: 'Mặc định chất lượng tốt nhất',
|
||||
summaryBadgeLocalLane: 'Dùng local khi đúng bài toán',
|
||||
summaryExistingTitle: 'Chọn hồ sơ hoặc thêm một lane mới',
|
||||
summaryEmptyTitle: 'Chọn lane hồ sơ đầu tiên của bạn',
|
||||
summaryExistingDescription:
|
||||
'Bạn đã có {{count}} hồ sơ trong workspace này. Hãy giữ các nhà cung cấp chất lượng cao cho công việc code nghiêm túc, và chỉ thêm runtime local khi cần quyền riêng tư, chi phí thấp hoặc làm việc offline.',
|
||||
summaryExistingDescription_other:
|
||||
'Bạn đã có {{count}} hồ sơ trong workspace này. Hãy giữ các nhà cung cấp chất lượng cao cho công việc code nghiêm túc, và chỉ thêm runtime local khi cần quyền riêng tư, chi phí thấp hoặc làm việc offline.',
|
||||
summaryEmptyDescription:
|
||||
'Chọn lane phù hợp với công việc. Nhà cung cấp premium vẫn là mặc định cho độ tin cậy khi code; runtime local phù hợp hơn cho quyền riêng tư, batch rẻ và thử nghiệm.',
|
||||
qualityLanesTitle: 'Lane chất lượng cao',
|
||||
qualityLaneHighlightDefaultQuality: 'Lane mặc định tốt nhất cho các tác vụ code quan trọng',
|
||||
alibabaLaneDescription:
|
||||
'Hồ sơ coding trực tiếp mạnh mẽ khi bạn muốn một lane premium riêng ngoài catalog OpenRouter.',
|
||||
alibabaLaneHighlightQuality: 'Phù hợp khi bạn cần chất lượng premium với endpoint riêng',
|
||||
localRuntimesTitle: 'Runtime local',
|
||||
localOllamaTitle: 'Ollama + Gemma 4',
|
||||
localOllamaLabel: 'Ollama',
|
||||
localOllamaHighlight:
|
||||
'Tốt nhất cho prompt riêng tư, dọn dẹp local và batch transform chi phí thấp',
|
||||
localLlamacppTitle: 'llama.cpp',
|
||||
localLlamacppLabel: 'llama.cpp',
|
||||
localLlamacppHighlight:
|
||||
'Tốt nhất cho thiết lập GGUF tùy biến và workflow local tự host nâng cao',
|
||||
localDetectedModels: 'Đã phát hiện {{count}} mô hình local',
|
||||
localDetectedModels_other: 'Đã phát hiện {{count}} mô hình local',
|
||||
localNoModelsDetected: 'Chưa phát hiện mô hình local nào',
|
||||
localWaitingServer: 'Đang chờ máy chủ local và danh sách mô hình',
|
||||
localRuntimeCheckingBadge: 'Đang kiểm tra',
|
||||
localRuntimeReadyBadge: 'Sẵn sàng',
|
||||
localRuntimeNeedsModelBadge: 'Thiếu mô hình',
|
||||
localRuntimeOfflineBadge: 'Ngoại tuyến',
|
||||
localRuntimeCheckingDescription:
|
||||
'Đang kiểm tra trạng thái runtime local trước khi hiển thị hướng dẫn thiết lập.',
|
||||
localRuntimeReadyDescription:
|
||||
'Phù hợp nhất cho prompt riêng tư, workflow offline và batch transform rẻ hơn.',
|
||||
localRuntimeNeedsModelDescription:
|
||||
'Runtime đã chạy, nhưng mô hình local được khuyến nghị vẫn cần được tải hoặc nạp.',
|
||||
localRuntimeOfflineDescription:
|
||||
'Lane này phù hợp cho công việc nhạy cảm về riêng tư, transform local giá rẻ và phiên offline.',
|
||||
localRuntimeCheckingFooter: 'Đang kiểm tra runtime local...',
|
||||
localRuntimeReadyFooter: 'Endpoint sẵn sàng tại {{endpoint}}',
|
||||
localRuntimeMissingModelFooter: 'Chạy `{{command}}` để hoàn tất thiết lập local.',
|
||||
localRuntimeOfflineFooter: 'Chạy `{{command}}` để đưa endpoint local lên.',
|
||||
localRuntimeSetupAction: 'Thiết lập {{label}}',
|
||||
localRuntimeUseAction: 'Dùng {{label}}',
|
||||
localRuntimeFinishAction: 'Hoàn tất thiết lập {{label}}',
|
||||
runtimeProviderBadge: 'Thiết lập nhà cung cấp runtime',
|
||||
runtimeProviderTitle: 'Mở AI Providers',
|
||||
runtimeProviderDescription:
|
||||
'Quản lý khóa nhà cung cấp dùng chung và các connector runtime trước khi tạo API Profiles độc lập.',
|
||||
runtimeProviderManagedDescription:
|
||||
'Dùng mục này khi bạn cần connector do CLIProxy quản lý, secret nhà cung cấp và điều khiển routing nâng cao.',
|
||||
runtimeProviderFeatureConnectors:
|
||||
'Gemini, Codex, Claude, Vertex và các connector tương thích OpenAI',
|
||||
runtimeProviderFeatureSecrets: 'Khóa dùng chung tách biệt khỏi từng API Profile',
|
||||
@@ -2952,7 +3071,6 @@ const resources = {
|
||||
'Phù hợp nhất khi nhiều hồ sơ cần dùng chung một cấu hình nhà cung cấp.',
|
||||
createOpenRouterProfile: 'Tạo hồ sơ OpenRouter',
|
||||
getApiKeyAt: 'Nhận khóa API của bạn tại',
|
||||
or: 'hoặc',
|
||||
createCustomProfile: 'Tạo hồ sơ API tùy chỉnh',
|
||||
},
|
||||
alibabaCodingPlanQuickStart: {
|
||||
@@ -3345,6 +3463,11 @@ const resources = {
|
||||
backToProviders: 'Quay lại nhà cung cấp',
|
||||
showDetails: 'Hiển thị chi tiết',
|
||||
hideDetails: 'Ẩn chi tiết',
|
||||
showPausedAccounts: 'Hiện tạm dừng ({{count}})',
|
||||
hidePausedAccounts: 'Ẩn tạm dừng ({{count}})',
|
||||
visibleTotalRequests: 'Yêu cầu hiển thị',
|
||||
visibleAccounts: 'Tài khoản hiển thị',
|
||||
excludingPausedAccounts: 'Đang loại trừ {{count}} tài khoản tạm dừng',
|
||||
resetLayout: 'Đặt lại bố cục',
|
||||
provider: 'Nhà cung cấp',
|
||||
totalRequests: 'Tổng số yêu cầu',
|
||||
@@ -4314,21 +4437,61 @@ const resources = {
|
||||
description:
|
||||
'OpenAI、Anthropic、Google、Meta などの {{modelCountLabel}} モデルを、1 つの API で利用できます。',
|
||||
featureOneApi: '1 つの API で全プロバイダー',
|
||||
featureTierMapping: 'モデルティアマッピング',
|
||||
profileCount: '{{count}} 個の API プロファイル',
|
||||
profileCount_other: '{{count}} 個の API プロファイル',
|
||||
selectProfileTitle: 'API プロファイルを選択',
|
||||
summaryDescriptionWithProfiles:
|
||||
'このワークスペースには既に {{count}} 個の API プロファイルがあります。左側レールから 1 つ選んで編集するか、ここから新しいプロファイルを作成してください。',
|
||||
summaryDescriptionWithProfiles_other:
|
||||
'このワークスペースには既に {{count}} 個の API プロファイルがあります。左側レールから 1 つ選んで編集するか、ここから新しいプロファイルを作成してください。',
|
||||
summaryDescriptionNoProfiles:
|
||||
'API Profiles は Anthropic 互換エンドポイント向けです。AI Providers は Gemini、Codex、Claude、Vertex、OpenAI 互換コネクタ向けです。',
|
||||
openrouterModelsBadge: '{{modelCountLabel}} 個の OpenRouter モデル',
|
||||
profileSummaryCount: '{{count}} 件のプロファイル',
|
||||
profileSummaryCount_other: '{{count}} 件のプロファイル',
|
||||
profileSummaryEmpty: '高品質 + ローカル制御',
|
||||
summaryBadgeDefaultQuality: '既定で最高品質',
|
||||
summaryBadgeLocalLane: 'タスクに合うときだけ local を使う',
|
||||
summaryExistingTitle: 'プロファイルを選ぶか、新しいレーンを追加',
|
||||
summaryEmptyTitle: '最初のプロファイルレーンを選択',
|
||||
summaryExistingDescription:
|
||||
'このワークスペースには既に {{count}} 件のプロファイルがあります。重要なコーディング作業には高品質プロバイダーを使い、プライバシー・コスト・オフライン要件があるときだけローカルランタイムを追加してください。',
|
||||
summaryExistingDescription_other:
|
||||
'このワークスペースには既に {{count}} 件のプロファイルがあります。重要なコーディング作業には高品質プロバイダーを使い、プライバシー・コスト・オフライン要件があるときだけローカルランタイムを追加してください。',
|
||||
summaryEmptyDescription:
|
||||
'作業に合うレーンを選んでください。Premium プロバイダーは信頼性の高いコーディング向けの既定値で、ローカルランタイムはプライバシー、低コスト変換、実験向けです。',
|
||||
qualityLanesTitle: '高品質レーン',
|
||||
qualityLaneHighlightDefaultQuality: '重要なコーディング作業向けの既定ベストレーン',
|
||||
alibabaLaneDescription:
|
||||
'OpenRouter カタログの外で、専用の premium コーディングレーンが欲しいときに強い直接接続プロファイルです。',
|
||||
alibabaLaneHighlightQuality: '専用エンドポイントで premium 品質が必要なときに最適',
|
||||
localRuntimesTitle: 'ローカルランタイム',
|
||||
localOllamaTitle: 'Ollama + Gemma 4',
|
||||
localOllamaLabel: 'Ollama',
|
||||
localOllamaHighlight: 'プライベートなプロンプト、ローカル整理、低コストの一括変換に最適',
|
||||
localLlamacppTitle: 'llama.cpp',
|
||||
localLlamacppLabel: 'llama.cpp',
|
||||
localLlamacppHighlight:
|
||||
'カスタム GGUF 構成や高度なセルフホスト型ローカルワークフローに最適',
|
||||
localDetectedModels: '{{count}} 件のローカルモデルを検出',
|
||||
localDetectedModels_other: '{{count}} 件のローカルモデルを検出',
|
||||
localNoModelsDetected: 'ローカルモデルはまだ検出されていません',
|
||||
localWaitingServer: 'ローカルサーバーとモデル一覧を待機中',
|
||||
localRuntimeCheckingBadge: '確認中',
|
||||
localRuntimeReadyBadge: '準備完了',
|
||||
localRuntimeNeedsModelBadge: 'モデルが必要',
|
||||
localRuntimeOfflineBadge: 'オフライン',
|
||||
localRuntimeCheckingDescription:
|
||||
'セットアップ案内を表示する前に、ローカルランタイムの状態を確認しています。',
|
||||
localRuntimeReadyDescription:
|
||||
'プライベートなプロンプト、オフライン作業、より安価な一括変換に適しています。',
|
||||
localRuntimeNeedsModelDescription:
|
||||
'ランタイムは起動していますが、推奨ローカルモデルの取得または読み込みがまだ必要です。',
|
||||
localRuntimeOfflineDescription:
|
||||
'このレーンは、プライバシー重視の作業、安価なローカル変換、オフラインセッションに向いています。',
|
||||
localRuntimeCheckingFooter: 'ローカルランタイムを確認しています...',
|
||||
localRuntimeReadyFooter: '{{endpoint}} でエンドポイント準備完了',
|
||||
localRuntimeMissingModelFooter:
|
||||
'ローカルセットアップを完了するには `{{command}}` を実行してください。',
|
||||
localRuntimeOfflineFooter:
|
||||
'ローカルエンドポイントを起動するには `{{command}}` を実行してください。',
|
||||
localRuntimeSetupAction: '{{label}} をセットアップ',
|
||||
localRuntimeUseAction: '{{label}} を使う',
|
||||
localRuntimeFinishAction: '{{label}} のセットアップを完了',
|
||||
runtimeProviderBadge: 'ランタイムプロバイダー設定',
|
||||
runtimeProviderTitle: 'AI Providers を開く',
|
||||
runtimeProviderDescription:
|
||||
'個別の API プロファイルを作成する前に、共有プロバイダーキーとランタイムコネクタを管理します。',
|
||||
runtimeProviderManagedDescription:
|
||||
'CLIProxy 管理のコネクタ、プロバイダーシークレット、高度なルーティング制御が必要なときに使います。',
|
||||
runtimeProviderFeatureConnectors: 'Gemini、Codex、Claude、Vertex、OpenAI 互換コネクタ',
|
||||
runtimeProviderFeatureSecrets:
|
||||
'共有シークレットを個別の API プロファイルから切り離して管理',
|
||||
@@ -4336,7 +4499,6 @@ const resources = {
|
||||
'複数のプロファイルで 1 つのプロバイダー設定を共有したい場合に最適です。',
|
||||
createOpenRouterProfile: 'OpenRouter プロファイルを作成',
|
||||
getApiKeyAt: 'API キー取得:',
|
||||
or: 'または',
|
||||
createCustomProfile: 'カスタム API プロファイルを作成',
|
||||
},
|
||||
alibabaCodingPlanQuickStart: {
|
||||
@@ -4729,6 +4891,11 @@ const resources = {
|
||||
backToProviders: 'プロバイダーに戻る',
|
||||
showDetails: '詳細を表示',
|
||||
hideDetails: '詳細を隠す',
|
||||
showPausedAccounts: '一時停止を表示 ({{count}})',
|
||||
hidePausedAccounts: '一時停止を非表示 ({{count}})',
|
||||
visibleTotalRequests: '表示中のリクエスト',
|
||||
visibleAccounts: '表示中のアカウント',
|
||||
excludingPausedAccounts: '一時停止 {{count}} 件を除外中',
|
||||
resetLayout: 'レイアウトをリセット',
|
||||
provider: 'プロバイダー',
|
||||
totalRequests: '総リクエスト数',
|
||||
|
||||
@@ -5,10 +5,16 @@
|
||||
|
||||
import type { ModelEntry, ProviderCatalog } from '@/components/cliproxy/provider-model-selector';
|
||||
import { stripModelConfigurationSuffixes } from '@/lib/extended-context-utils';
|
||||
import {
|
||||
AGY_GEMINI_PRO_COMPATIBILITY_IDS,
|
||||
AGY_GEMINI_PRO_HIGH_ID,
|
||||
AGY_GEMINI_PRO_LOW_ID,
|
||||
} from '@shared/agy-gemini-pro-compatibility';
|
||||
import { GEMINI_MINOR_VERSION_COMPATIBILITY_IDS } from '@shared/gemini-minor-version-compatibility';
|
||||
|
||||
const GEMINI_PREVIEW_MODEL_ID_PATTERN =
|
||||
/^gemini-(\d+(?:[.-]\d+)*)-(pro|flash)-preview(-customtools)?$/i;
|
||||
const MANAGED_MODEL_PREFIXES = ['agy/', 'gcli/'] as const;
|
||||
|
||||
export type CatalogAvailableModel = {
|
||||
id: string;
|
||||
@@ -29,6 +35,29 @@ function normalizeModelId(modelId: string): string {
|
||||
return stripModelConfigurationSuffixes(modelId).toLowerCase();
|
||||
}
|
||||
|
||||
function stripManagedModelPrefix(modelId: string): string {
|
||||
const trimmedModelId = modelId.trim();
|
||||
const normalizedModelId = trimmedModelId.toLowerCase();
|
||||
|
||||
for (const prefix of MANAGED_MODEL_PREFIXES) {
|
||||
if (normalizedModelId.startsWith(prefix)) {
|
||||
return trimmedModelId.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
|
||||
return trimmedModelId;
|
||||
}
|
||||
|
||||
function stripCustomtoolsSuffix(modelId: string): string {
|
||||
return modelId.replace(/-customtools$/i, '');
|
||||
}
|
||||
|
||||
function getAgyGeminiProCompatibilityId(modelId: string): string | undefined {
|
||||
return AGY_GEMINI_PRO_COMPATIBILITY_IDS[
|
||||
normalizeModelId(modelId) as keyof typeof AGY_GEMINI_PRO_COMPATIBILITY_IDS
|
||||
];
|
||||
}
|
||||
|
||||
function parseGeminiPreviewModelId(modelId: string): GeminiPreviewModelInfo | null {
|
||||
const normalizedId = normalizeModelId(modelId);
|
||||
const match = normalizedId.match(GEMINI_PREVIEW_MODEL_ID_PATTERN);
|
||||
@@ -142,14 +171,26 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gemini-3.1-pro-preview',
|
||||
name: 'Gemini Pro',
|
||||
description: 'Resolves to the best advertised Gemini Pro preview via Antigravity',
|
||||
id: AGY_GEMINI_PRO_HIGH_ID,
|
||||
name: 'Gemini Pro High',
|
||||
description: 'Current Antigravity Gemini Pro route with higher reasoning budget',
|
||||
extendedContext: true,
|
||||
presetMapping: {
|
||||
default: 'gemini-3.1-pro-preview',
|
||||
opus: 'gemini-3.1-pro-preview',
|
||||
sonnet: 'gemini-3.1-pro-preview',
|
||||
default: AGY_GEMINI_PRO_HIGH_ID,
|
||||
opus: AGY_GEMINI_PRO_HIGH_ID,
|
||||
sonnet: AGY_GEMINI_PRO_HIGH_ID,
|
||||
haiku: 'gemini-3-1-flash-preview',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: AGY_GEMINI_PRO_LOW_ID,
|
||||
name: 'Gemini Pro Low',
|
||||
description: 'Current Antigravity Gemini Pro route with the lighter quota tier',
|
||||
extendedContext: true,
|
||||
presetMapping: {
|
||||
default: AGY_GEMINI_PRO_LOW_ID,
|
||||
opus: AGY_GEMINI_PRO_LOW_ID,
|
||||
sonnet: AGY_GEMINI_PRO_LOW_ID,
|
||||
haiku: 'gemini-3-1-flash-preview',
|
||||
},
|
||||
},
|
||||
@@ -160,8 +201,8 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
extendedContext: true,
|
||||
presetMapping: {
|
||||
default: 'gemini-3-1-flash-preview',
|
||||
opus: 'gemini-3.1-pro-preview',
|
||||
sonnet: 'gemini-3.1-pro-preview',
|
||||
opus: AGY_GEMINI_PRO_HIGH_ID,
|
||||
sonnet: AGY_GEMINI_PRO_HIGH_ID,
|
||||
haiku: 'gemini-3-1-flash-preview',
|
||||
},
|
||||
},
|
||||
@@ -684,6 +725,13 @@ function findCatalogModelInCatalog(catalog: ProviderCatalog | undefined, modelId
|
||||
if (!catalog) return undefined;
|
||||
|
||||
const normalizedModelId = normalizeModelId(modelId);
|
||||
if (catalog.provider === 'agy') {
|
||||
const agyCompatibilityId = getAgyGeminiProCompatibilityId(normalizedModelId);
|
||||
if (agyCompatibilityId) {
|
||||
const compatibilityMatch = catalog.models.find((model) => model.id === agyCompatibilityId);
|
||||
if (compatibilityMatch) return compatibilityMatch;
|
||||
}
|
||||
}
|
||||
const compatibilityModelId =
|
||||
GEMINI_MINOR_VERSION_COMPATIBILITY_IDS[
|
||||
normalizedModelId.toLowerCase() as keyof typeof GEMINI_MINOR_VERSION_COMPATIBILITY_IDS
|
||||
@@ -835,18 +883,24 @@ export function getResolvedCatalogModels(
|
||||
) {
|
||||
if (!catalog) return [];
|
||||
|
||||
const recommendedCatalog = MODEL_CATALOGS[catalog.provider.toLowerCase()] ?? catalog;
|
||||
const seenModelIds = new Set<string>();
|
||||
|
||||
return catalog.models
|
||||
return recommendedCatalog.models
|
||||
.map((model) => {
|
||||
const resolvedModelId = resolveCatalogModelId(model.id, availableModels);
|
||||
const resolvedPresetModelMapping = model.presetMapping
|
||||
? resolvePresetMapping(model.presetMapping, availableModels)
|
||||
: undefined;
|
||||
const liveModelMatch = catalog.models.find(
|
||||
(catalogModel) => normalizeModelId(catalogModel.id) === normalizeModelId(resolvedModelId)
|
||||
);
|
||||
|
||||
return {
|
||||
...model,
|
||||
id: resolvedModelId,
|
||||
name: liveModelMatch?.name || model.name,
|
||||
description: liveModelMatch?.description ?? model.description,
|
||||
presetMapping: resolvedPresetModelMapping,
|
||||
};
|
||||
})
|
||||
@@ -857,6 +911,70 @@ export function getResolvedCatalogModels(
|
||||
});
|
||||
}
|
||||
|
||||
export function getSupplementalCatalogModels(
|
||||
provider: string,
|
||||
catalog: ProviderCatalog | undefined,
|
||||
availableModels: CatalogAvailableModel[] = []
|
||||
) {
|
||||
const normalizedProvider = provider.trim().toLowerCase();
|
||||
if (!catalog || !normalizedProvider) return [];
|
||||
|
||||
const staticCatalog = MODEL_CATALOGS[normalizedProvider] ?? catalog;
|
||||
const recommendedModels = getResolvedCatalogModels(catalog, availableModels);
|
||||
const recommendedIds = new Set(recommendedModels.map((model) => normalizeModelId(model.id)));
|
||||
const recommendedCanonicalIds = new Set(
|
||||
recommendedModels
|
||||
.map((model) => findCatalogModelInCatalog(staticCatalog, model.id)?.id)
|
||||
.filter((modelId): modelId is string => Boolean(modelId))
|
||||
.map((modelId) => normalizeModelId(modelId))
|
||||
);
|
||||
const seenCanonicalIds = new Set<string>();
|
||||
const normalizedRawIds = new Set(availableModels.map((model) => normalizeModelId(model.id)));
|
||||
|
||||
return availableModels.filter((availableModel) => {
|
||||
const normalizedAvailableModelId = normalizeModelId(availableModel.id);
|
||||
const strippedModelId = stripManagedModelPrefix(availableModel.id);
|
||||
const baseModelId = stripCustomtoolsSuffix(strippedModelId);
|
||||
const matchedModel =
|
||||
findCatalogModelInCatalog(staticCatalog, strippedModelId) ??
|
||||
findCatalogModelInCatalog(staticCatalog, baseModelId);
|
||||
|
||||
if (recommendedIds.has(normalizeModelId(strippedModelId))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedAvailableModelId !== normalizeModelId(strippedModelId) &&
|
||||
normalizedRawIds.has(normalizeModelId(strippedModelId))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedBaseModelId = normalizeModelId(baseModelId);
|
||||
if (
|
||||
normalizedBaseModelId !== normalizeModelId(strippedModelId) &&
|
||||
normalizedRawIds.has(normalizedBaseModelId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const canonicalId = matchedModel ? normalizeModelId(matchedModel.id) : normalizedBaseModelId;
|
||||
if (matchedModel && recommendedCanonicalIds.has(canonicalId)) {
|
||||
return false;
|
||||
}
|
||||
if (seenCanonicalIds.has(canonicalId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!matchedModel && normalizedProvider === 'agy') {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenCanonicalIds.add(canonicalId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function supportsExtendedContext(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
PROVIDER_CAPABILITIES,
|
||||
getProvidersByOAuthFlow,
|
||||
} from '../../../src/cliproxy/provider-capabilities';
|
||||
import type { AiProviderFamilyId } from '../../../src/cliproxy/ai-providers';
|
||||
import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/cliproxy/ai-providers';
|
||||
|
||||
// Monorepo contract: UI consumes provider capability constants directly from backend
|
||||
// to enforce one source of truth and prevent provider drift across surfaces.
|
||||
@@ -115,6 +115,75 @@ export function getAiProviderFamilyVisual(familyId: AiProviderFamilyId): Provide
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse UI model rules that use the requested=upstream convention into the
|
||||
* provider config shape where `name` is upstream and `alias` is client-visible.
|
||||
*/
|
||||
export function parseRequestedUpstreamModelRules(value: string): AiProviderModelAlias[] {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
return { name: line.trim(), alias: '' };
|
||||
}
|
||||
|
||||
const requested = line.slice(0, separatorIndex).trim();
|
||||
const upstream = line.slice(separatorIndex + 1).trim();
|
||||
if (!upstream) {
|
||||
return { name: requested, alias: '' };
|
||||
}
|
||||
|
||||
return {
|
||||
name: upstream,
|
||||
alias: requested,
|
||||
};
|
||||
})
|
||||
.filter((item) => item.name.length > 0 || item.alias.length > 0);
|
||||
}
|
||||
|
||||
export function getRequestedUpstreamModelRuleErrors(value: string): string[] {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((line, index) => ({ line: line.trim(), lineNumber: index + 1 }))
|
||||
.filter(({ line }) => line.length > 0 && line.includes('='))
|
||||
.flatMap(({ line, lineNumber }) => {
|
||||
const separatorIndex = line.indexOf('=');
|
||||
const requested = line.slice(0, separatorIndex).trim();
|
||||
const upstream = line.slice(separatorIndex + 1).trim();
|
||||
if (requested && upstream) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [`Line ${lineNumber}: use requested=upstream or a plain model name.`];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format stored provider config back into the UI-facing requested=upstream form.
|
||||
*/
|
||||
export function formatRequestedUpstreamModelRules(
|
||||
models: Array<Partial<AiProviderModelAlias>> | null | undefined
|
||||
): string {
|
||||
return (models || [])
|
||||
.map((item) => {
|
||||
const requested = item.alias?.trim() || '';
|
||||
const upstream = item.name?.trim() || '';
|
||||
return requested ? `${requested}=${upstream}` : upstream;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the client-visible model ID for previews and generated settings.
|
||||
*/
|
||||
export function getRequestedModelId(model: AiProviderModelAlias): string {
|
||||
const requested = model.alias.trim();
|
||||
return requested || model.name.trim();
|
||||
}
|
||||
|
||||
export function getProviderLogoAsset(provider: unknown): string | undefined {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
if (!isProviderVisualId(normalized)) {
|
||||
|
||||
@@ -771,6 +771,25 @@ export function getQuotaFailureInfo(
|
||||
const technicalDetail = buildQuotaTechnicalDetail(quota);
|
||||
const rawDetail = buildQuotaRawDetail(quota, summary, technicalDetail);
|
||||
const lowerSummary = summary.toLowerCase();
|
||||
const entitlement = 'entitlement' in quota ? quota.entitlement : undefined;
|
||||
|
||||
if (
|
||||
quota.errorCode === 'capacity_exhausted' ||
|
||||
entitlement?.capacityState === 'capacity_exhausted' ||
|
||||
lowerSummary.includes('no capacity available') ||
|
||||
lowerSummary.includes('capacity exhausted')
|
||||
) {
|
||||
return {
|
||||
label: 'Capacity',
|
||||
summary,
|
||||
actionHint:
|
||||
actionHint ||
|
||||
'Retry later or switch to another model. This is a temporary provider capacity issue.',
|
||||
technicalDetail,
|
||||
rawDetail,
|
||||
tone: 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
quota.needsReauth ||
|
||||
@@ -808,6 +827,7 @@ export function getQuotaFailureInfo(
|
||||
}
|
||||
|
||||
if (
|
||||
entitlement?.accessState === 'not_entitled' ||
|
||||
quota.isForbidden ||
|
||||
quota.httpStatus === 403 ||
|
||||
errorCode === 'quota_api_forbidden' ||
|
||||
|
||||
+19
-6
@@ -34,6 +34,7 @@ import {
|
||||
import { useOpenRouterModels } from '@/hooks/use-openrouter-models';
|
||||
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
||||
import type { ApiProfileExportBundle, Profile } from '@/lib/api-client';
|
||||
import type { ProviderPreset } from '@/lib/provider-presets';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -53,9 +54,7 @@ export function ApiPage() {
|
||||
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [createMode, setCreateMode] = useState<'normal' | 'openrouter' | 'alibaba-coding-plan'>(
|
||||
'normal'
|
||||
);
|
||||
const [createMode, setCreateMode] = useState<ProviderPreset['id'] | 'normal'>('normal');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [editorHasChanges, setEditorHasChanges] = useState(false);
|
||||
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
|
||||
@@ -211,12 +210,14 @@ export function ApiPage() {
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
<div className="w-80 border-r flex flex-col bg-muted/30">
|
||||
<div className="p-4 border-b bg-background">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-5 h-5 text-primary" />
|
||||
<h1 className="font-semibold">{t('apiProfiles.title')}</h1>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold">Profiles</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -251,6 +252,10 @@ export function ApiPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mb-3 text-xs leading-4 text-muted-foreground">
|
||||
Premium APIs, local runtimes, custom endpoints
|
||||
</p>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -400,6 +405,14 @@ export function ApiPage() {
|
||||
setCreateMode('alibaba-coding-plan');
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
onOllamaClick={() => {
|
||||
setCreateMode('ollama');
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
onLlamacppClick={() => {
|
||||
setCreateMode('llamacpp');
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
onCustomClick={() => {
|
||||
setCreateMode('normal');
|
||||
setCreateDialogOpen(true);
|
||||
|
||||
@@ -17,7 +17,13 @@ import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { getAiProviderFamilyVisual } from '@/lib/provider-config';
|
||||
import {
|
||||
formatRequestedUpstreamModelRules,
|
||||
getAiProviderFamilyVisual,
|
||||
getRequestedUpstreamModelRuleErrors,
|
||||
getRequestedModelId,
|
||||
parseRequestedUpstreamModelRules,
|
||||
} from '@/lib/provider-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FamilyRail, ProviderEntryDialog } from '@/components/cliproxy/ai-providers';
|
||||
import {
|
||||
@@ -339,22 +345,7 @@ function parseKeyValueLines(value: string): Array<{ key: string; value: string }
|
||||
}
|
||||
|
||||
function parseModelAliasLines(value: string) {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
return { name: line.trim(), alias: '' };
|
||||
}
|
||||
|
||||
return {
|
||||
name: line.slice(0, separatorIndex).trim(),
|
||||
alias: line.slice(separatorIndex + 1).trim(),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.name.length > 0 || item.alias.length > 0);
|
||||
return parseRequestedUpstreamModelRules(value);
|
||||
}
|
||||
|
||||
function formatHeaders(entry?: AiProviderEntryView | null): string {
|
||||
@@ -366,9 +357,7 @@ function formatExcludedModels(entry?: AiProviderEntryView | null): string {
|
||||
}
|
||||
|
||||
function formatModelAliases(entry?: AiProviderEntryView | null): string {
|
||||
return (entry?.models || [])
|
||||
.map((item) => (item.alias.trim() ? `${item.name}=${item.alias}` : item.name))
|
||||
.join('\n');
|
||||
return formatRequestedUpstreamModelRules(entry?.models);
|
||||
}
|
||||
|
||||
function buildEntryEditorDraft(entry: AiProviderEntryView): EntryEditorDraft {
|
||||
@@ -398,6 +387,12 @@ function buildRawConfigModelArray(value: string) {
|
||||
return parsed.length > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function formatRawConfigModelArray(value: unknown): string {
|
||||
return Array.isArray(value)
|
||||
? formatRequestedUpstreamModelRules(value as Array<{ name?: string; alias?: string }>)
|
||||
: '';
|
||||
}
|
||||
|
||||
function buildExcludedModelsArray(value: string) {
|
||||
const parsed = parseDelimitedLines(value);
|
||||
return parsed.length > 0 ? parsed : undefined;
|
||||
@@ -484,16 +479,7 @@ function parseEntryConfigDraft(
|
||||
.join('\n')
|
||||
: '',
|
||||
excludedModelsText: '',
|
||||
modelAliasesText: Array.isArray(record.models)
|
||||
? (record.models as Array<{ name?: string; alias?: string }>)
|
||||
.map((item) =>
|
||||
item.alias?.trim()
|
||||
? `${item.name?.trim() || ''}=${item.alias.trim()}`
|
||||
: item.name?.trim() || ''
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: '',
|
||||
modelAliasesText: formatRawConfigModelArray(record.models),
|
||||
apiKey: '',
|
||||
apiKeysText: apiKeys.join('\n'),
|
||||
};
|
||||
@@ -515,16 +501,7 @@ function parseEntryConfigDraft(
|
||||
excludedModelsText: Array.isArray(record['excluded-models'])
|
||||
? (record['excluded-models'] as string[]).join('\n')
|
||||
: '',
|
||||
modelAliasesText: Array.isArray(record.models)
|
||||
? (record.models as Array<{ name?: string; alias?: string }>)
|
||||
.map((item) =>
|
||||
item.alias?.trim()
|
||||
? `${item.name?.trim() || ''}=${item.alias.trim()}`
|
||||
: item.name?.trim() || ''
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: '',
|
||||
modelAliasesText: formatRawConfigModelArray(record.models),
|
||||
apiKey:
|
||||
typeof record['api-key'] === 'string' && record['api-key'] !== STORED_SECRET_PLACEHOLDER
|
||||
? record['api-key']
|
||||
@@ -579,7 +556,7 @@ function buildSettingsPreview(
|
||||
item.name.trim()
|
||||
);
|
||||
if (primaryModel?.name) {
|
||||
env.ANTHROPIC_MODEL = primaryModel.name;
|
||||
env.ANTHROPIC_MODEL = getRequestedModelId(primaryModel);
|
||||
}
|
||||
|
||||
return { env };
|
||||
@@ -702,6 +679,10 @@ function EntryInspector({
|
||||
() => parseModelAliasLines(draft.modelAliasesText),
|
||||
[draft.modelAliasesText]
|
||||
);
|
||||
const modelRuleErrors = useMemo(
|
||||
() => getRequestedUpstreamModelRuleErrors(draft.modelAliasesText),
|
||||
[draft.modelAliasesText]
|
||||
);
|
||||
const headerRules = useMemo(() => parseKeyValueLines(draft.headersText), [draft.headersText]);
|
||||
const excludedModelRules = useMemo(
|
||||
() => parseDelimitedLines(draft.excludedModelsText),
|
||||
@@ -753,7 +734,11 @@ function EntryInspector({
|
||||
entry.secretConfigured,
|
||||
family.id,
|
||||
]);
|
||||
const canSave = isRawJsonValid && missingRequiredFields.length === 0 && hasChanges;
|
||||
const canSave =
|
||||
isRawJsonValid &&
|
||||
missingRequiredFields.length === 0 &&
|
||||
modelRuleErrors.length === 0 &&
|
||||
hasChanges;
|
||||
|
||||
const updateDraft = (updater: (current: EntryEditorDraft) => EntryEditorDraft) => {
|
||||
setDraft((current) => updater(current));
|
||||
@@ -1091,6 +1076,11 @@ function EntryInspector({
|
||||
rows={6}
|
||||
/>
|
||||
</EntryEditorField>
|
||||
{modelRuleErrors.length > 0 ? (
|
||||
<div className="mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{modelRuleErrors[0]}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-background p-4">
|
||||
|
||||
@@ -264,6 +264,7 @@ export function CliproxyPage() {
|
||||
const isRemoteMode = authData?.source === 'remote';
|
||||
const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]);
|
||||
const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]);
|
||||
const routingHints = catalogData?.routing ?? {};
|
||||
const fetchedCatalogsReady = Boolean(catalogData);
|
||||
|
||||
// Wrapper to persist provider selection to localStorage
|
||||
@@ -305,6 +306,7 @@ export function CliproxyPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] });
|
||||
};
|
||||
|
||||
const handlePauseToggle = (provider: string, accountId: string, paused: boolean) => {
|
||||
@@ -459,6 +461,7 @@ export function CliproxyPage() {
|
||||
})}
|
||||
authStatus={parentAuthForVariant}
|
||||
catalog={catalogs[selectedVariantData.provider]}
|
||||
routing={routingHints[selectedVariantData.provider]}
|
||||
logoProvider={selectedVariantData.provider}
|
||||
baseProvider={selectedVariantData.provider}
|
||||
defaultTarget={selectedVariantData.target}
|
||||
@@ -512,6 +515,7 @@ export function CliproxyPage() {
|
||||
displayName={selectedStatus.displayName}
|
||||
authStatus={selectedStatus}
|
||||
catalog={catalogs[selectedStatus.provider]}
|
||||
routing={routingHints[selectedStatus.provider]}
|
||||
isRemoteMode={isRemoteMode}
|
||||
topNotice={
|
||||
showAccountSafetyWarning ? (
|
||||
|
||||
@@ -131,11 +131,21 @@ describe('AccountCard grouped quota tooltip', () => {
|
||||
);
|
||||
|
||||
await userEvent.hover(screen.getByText('Business'));
|
||||
expect((await screen.findAllByText('Plan: team')).length).toBeGreaterThan(0);
|
||||
const businessPlan = (await screen.findAllByText('Plan: team')).find((node) =>
|
||||
node.closest('[data-slot="tooltip-content"]')
|
||||
);
|
||||
expect(businessPlan).toBeInTheDocument();
|
||||
expect(screen.getAllByText('5h usage limit').length).toBeGreaterThan(0);
|
||||
const tooltipContent = businessPlan.closest('[data-slot="tooltip-content"]');
|
||||
expect(tooltipContent?.className).toContain('bg-popover');
|
||||
expect(tooltipContent?.className).toContain('text-popover-foreground');
|
||||
expect(tooltipContent?.className).toContain('max-w-[calc(100vw-2rem)]');
|
||||
|
||||
await userEvent.hover(screen.getByText('Personal'));
|
||||
expect((await screen.findAllByText('Plan: plus')).length).toBeGreaterThan(0);
|
||||
const personalPlan = (await screen.findAllByText('Plan: plus')).find((node) =>
|
||||
node.closest('[data-slot="tooltip-content"]')
|
||||
);
|
||||
expect(personalPlan).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Weekly usage limit').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, userEvent } from '@tests/setup/test-utils';
|
||||
import { AccountFlowViz } from '@/components/account-flow-viz';
|
||||
import type { ProviderData } from '@/components/account/flow-viz/types';
|
||||
|
||||
vi.mock(import('react-i18next'), async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { count?: number }) => {
|
||||
const translations: Record<string, string> = {
|
||||
'flowViz.showDetails': 'Show Details',
|
||||
'flowViz.hideDetails': 'Hide Details',
|
||||
'flowViz.backToProviders': 'Back to providers',
|
||||
'flowViz.resetLayout': 'Reset layout',
|
||||
'flowViz.provider': 'Provider',
|
||||
'flowViz.totalRequests': 'Total Requests',
|
||||
'flowViz.accounts': 'Accounts',
|
||||
'flowViz.visibleTotalRequests': 'Visible Requests',
|
||||
'flowViz.visibleAccounts': 'Visible Accounts',
|
||||
};
|
||||
|
||||
if (key === 'flowViz.showPausedAccounts') {
|
||||
return `Show Paused (${options?.count ?? 0})`;
|
||||
}
|
||||
|
||||
if (key === 'flowViz.hidePausedAccounts') {
|
||||
return `Hide Paused (${options?.count ?? 0})`;
|
||||
}
|
||||
|
||||
if (key === 'flowViz.excludingPausedAccounts') {
|
||||
return `Excluding ${options?.count ?? 0} paused`;
|
||||
}
|
||||
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/components/account/flow-viz/account-card', () => ({
|
||||
AccountCard: ({
|
||||
account,
|
||||
originalIndex,
|
||||
zone,
|
||||
}: {
|
||||
account: { email: string; paused?: boolean };
|
||||
originalIndex: number;
|
||||
zone: string;
|
||||
}) => (
|
||||
<div data-account-index={originalIndex} data-zone={zone}>
|
||||
{account.email}
|
||||
{account.paused ? ' (paused)' : ' (active)'}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/provider-icon', () => ({
|
||||
ProviderIcon: ({ provider }: { provider: string }) => <div>{provider}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/account/flow-viz/connection-timeline', () => ({
|
||||
ConnectionTimeline: ({ events }: { events: Array<{ id: string }> }) => (
|
||||
<div>{events.length} timeline events</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/account/flow-viz/flow-paths', () => ({
|
||||
FlowPaths: ({ accounts }: { accounts: Array<{ id: string }> }) => (
|
||||
<div>paths:{accounts.map((account) => account.id).join(',')}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/account/flow-viz/path-utils', () => ({
|
||||
calculateBezierPaths: () => [],
|
||||
}));
|
||||
|
||||
const providerData: ProviderData = {
|
||||
provider: 'codex',
|
||||
displayName: 'OpenAI Codex',
|
||||
totalRequests: 7,
|
||||
accounts: [
|
||||
{
|
||||
id: 'paused-account',
|
||||
email: 'paused@company.com',
|
||||
provider: 'codex',
|
||||
successCount: 4,
|
||||
failureCount: 0,
|
||||
color: '#f59e0b',
|
||||
paused: true,
|
||||
},
|
||||
{
|
||||
id: 'active-account',
|
||||
email: 'active@company.com',
|
||||
provider: 'codex',
|
||||
successCount: 2,
|
||||
failureCount: 1,
|
||||
color: '#10a37f',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('AccountFlowViz paused account visibility', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(window.localStorage.getItem).mockReturnValue(null);
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn(() => 0)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('hides and restores paused accounts from the provider detail visualization', async () => {
|
||||
render(<AccountFlowViz providerData={providerData} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Show Details' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Hide Paused (1)' })).toBeInTheDocument();
|
||||
expect(screen.getByText('active@company.com (active)')).toBeInTheDocument();
|
||||
expect(screen.getByText('paused@company.com (paused)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Total Requests')).toBeInTheDocument();
|
||||
expect(screen.getByText('Accounts')).toBeInTheDocument();
|
||||
expect(screen.getByText('7')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByText('7 timeline events')).toBeInTheDocument();
|
||||
expect(screen.getByText('paths:paused-account,active-account')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Hide Paused (1)' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Show Paused (1)' })).toBeInTheDocument();
|
||||
expect(screen.getByText('active@company.com (active)')).toBeInTheDocument();
|
||||
expect(screen.queryByText('paused@company.com (paused)')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Visible Requests')).toBeInTheDocument();
|
||||
expect(screen.getByText('Visible Accounts')).toBeInTheDocument();
|
||||
expect(screen.getByText('Excluding 1 paused')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
expect(screen.getByText('3 timeline events')).toBeInTheDocument();
|
||||
expect(screen.getByText('paths:active-account')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Show Paused (1)' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Hide Paused (1)' })).toBeInTheDocument();
|
||||
expect(screen.getByText('paused@company.com (paused)')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Excluding 1 paused')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ignores hidden paused-account drag offsets when deciding whether reset layout should stay visible', async () => {
|
||||
vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
|
||||
key === 'ccs-flow-positions-codex'
|
||||
? JSON.stringify({
|
||||
'paused-account': { x: 0, y: 160 },
|
||||
})
|
||||
: null
|
||||
);
|
||||
|
||||
render(<AccountFlowViz providerData={providerData} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Reset layout' })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Hide Paused (1)' }));
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Reset layout' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render, screen, userEvent } from '@tests/setup/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AccountQuotaPanel } from '@/components/account/shared/account-quota-panel';
|
||||
import type { GeminiCliQuotaResult } from '@/lib/api-client';
|
||||
|
||||
function createGeminiFailureQuota(): GeminiCliQuotaResult {
|
||||
return {
|
||||
success: false,
|
||||
buckets: [],
|
||||
projectId: 'test-project',
|
||||
lastUpdated: Date.now(),
|
||||
error: 'Request had invalid authentication credentials.',
|
||||
httpStatus: 401,
|
||||
errorCode: 'UNAUTHENTICATED',
|
||||
errorDetail:
|
||||
'{"error":{"code":401,"message":"Request had invalid authentication credentials.","status":"UNAUTHENTICATED"}}',
|
||||
actionHint: 'Run ccs gemini --auth to reconnect this account.',
|
||||
needsReauth: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AccountQuotaPanel failure tooltip', () => {
|
||||
it('renders the shared failure tooltip content with viewport-safe shell classes', async () => {
|
||||
render(
|
||||
<AccountQuotaPanel
|
||||
provider="gemini"
|
||||
quota={createGeminiFailureQuota()}
|
||||
quotaLoading={false}
|
||||
mode="detailed"
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.hover(screen.getByText('Reauth'));
|
||||
|
||||
const summary = (
|
||||
await screen.findAllByText('Request had invalid authentication credentials.')
|
||||
).find((node) => node.closest('[data-slot="tooltip-content"]'));
|
||||
expect(summary).toBeInTheDocument();
|
||||
const tooltipContent = summary.closest('[data-slot="tooltip-content"]');
|
||||
const actionHint = screen
|
||||
.getAllByText('Run ccs gemini --auth to reconnect this account.')
|
||||
.find((node) => node.closest('[data-slot="tooltip-content"]') === tooltipContent);
|
||||
const technicalDetail = screen
|
||||
.getAllByText('HTTP 401 | UNAUTHENTICATED')
|
||||
.find((node) => node.closest('[data-slot="tooltip-content"]') === tooltipContent);
|
||||
expect(actionHint).toBeInTheDocument();
|
||||
expect(technicalDetail).toBeInTheDocument();
|
||||
expect(tooltipContent?.className).toContain('max-w-[calc(100vw-2rem)]');
|
||||
expect(tooltipContent?.className).toContain('bg-popover');
|
||||
expect(tooltipContent?.className).toContain('text-popover-foreground');
|
||||
});
|
||||
});
|
||||
@@ -110,4 +110,146 @@ describe('ModelConfigSection presets', () => {
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-9-flash-preview',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies managed short prefixes when routing guidance is available', async () => {
|
||||
const onApplyPreset = vi.fn();
|
||||
|
||||
render(
|
||||
<ModelConfigSection
|
||||
catalog={MODEL_CATALOGS.gemini}
|
||||
savedPresets={[]}
|
||||
currentModel="gemini-3-flash-preview"
|
||||
opusModel="gemini-3.1-pro-preview"
|
||||
sonnetModel="gemini-3.1-pro-preview"
|
||||
haikuModel="gemini-3-flash-preview"
|
||||
providerModels={[]}
|
||||
routing={{
|
||||
provider: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
prefix: 'gcli',
|
||||
safeCount: 0,
|
||||
shadowedCount: 2,
|
||||
prefixOnlyCount: 0,
|
||||
models: [
|
||||
{
|
||||
modelId: 'gemini-3.1-pro-preview',
|
||||
modelName: 'Gemini Pro',
|
||||
prefix: 'gcli',
|
||||
pinnedModelId: 'gcli/gemini-3.1-pro-preview',
|
||||
recommendedModelId: 'gcli/gemini-3.1-pro-preview',
|
||||
pinnedAvailable: true,
|
||||
unprefixedStatus: 'shadowed',
|
||||
effectiveProvider: 'agy',
|
||||
effectiveDisplayName: 'Antigravity',
|
||||
effectiveOwnedBy: 'antigravity',
|
||||
summary: 'shadowed by Antigravity',
|
||||
},
|
||||
{
|
||||
modelId: 'gemini-3-flash-preview',
|
||||
modelName: 'Gemini Flash',
|
||||
prefix: 'gcli',
|
||||
pinnedModelId: 'gcli/gemini-3-flash-preview',
|
||||
recommendedModelId: 'gcli/gemini-3-flash-preview',
|
||||
pinnedAvailable: true,
|
||||
unprefixedStatus: 'shadowed',
|
||||
effectiveProvider: 'agy',
|
||||
effectiveDisplayName: 'Antigravity',
|
||||
effectiveOwnedBy: 'antigravity',
|
||||
summary: 'shadowed by Antigravity',
|
||||
},
|
||||
],
|
||||
}}
|
||||
provider="gemini"
|
||||
onExtendedContextToggle={vi.fn()}
|
||||
onApplyPreset={onApplyPreset}
|
||||
onUpdateEnvValue={vi.fn()}
|
||||
onOpenCustomPreset={vi.fn()}
|
||||
onDeletePreset={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Gemini Flash' }));
|
||||
|
||||
expect(onApplyPreset).toHaveBeenCalledWith({
|
||||
ANTHROPIC_MODEL: 'gcli/gemini-3-flash-preview',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gcli/gemini-3.1-pro-preview',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gcli/gemini-3.1-pro-preview',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gcli/gemini-3-flash-preview',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes saved presets through preferred pinned model ids when live pinning is available', async () => {
|
||||
const onApplyPreset = vi.fn();
|
||||
|
||||
render(
|
||||
<ModelConfigSection
|
||||
catalog={MODEL_CATALOGS.gemini}
|
||||
savedPresets={[
|
||||
{
|
||||
name: 'legacy',
|
||||
default: 'gemini-3-flash-preview',
|
||||
opus: 'gemini-3.1-pro-preview',
|
||||
sonnet: 'gemini-3.1-pro-preview',
|
||||
haiku: 'gemini-3-flash-preview',
|
||||
},
|
||||
]}
|
||||
currentModel="gemini-3-flash-preview"
|
||||
opusModel="gemini-3.1-pro-preview"
|
||||
sonnetModel="gemini-3.1-pro-preview"
|
||||
haikuModel="gemini-3-flash-preview"
|
||||
providerModels={[]}
|
||||
routing={{
|
||||
provider: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
prefix: 'gcli',
|
||||
safeCount: 0,
|
||||
shadowedCount: 2,
|
||||
prefixOnlyCount: 0,
|
||||
models: [
|
||||
{
|
||||
modelId: 'gemini-3.1-pro-preview',
|
||||
modelName: 'Gemini Pro',
|
||||
prefix: 'gcli',
|
||||
pinnedModelId: 'gcli/gemini-3.1-pro-preview',
|
||||
recommendedModelId: 'gcli/gemini-3.1-pro-preview',
|
||||
pinnedAvailable: true,
|
||||
unprefixedStatus: 'shadowed',
|
||||
effectiveProvider: 'agy',
|
||||
effectiveDisplayName: 'Antigravity',
|
||||
effectiveOwnedBy: 'antigravity',
|
||||
summary: 'shadowed by Antigravity',
|
||||
},
|
||||
{
|
||||
modelId: 'gemini-3-flash-preview',
|
||||
modelName: 'Gemini Flash',
|
||||
prefix: 'gcli',
|
||||
pinnedModelId: 'gcli/gemini-3-flash-preview',
|
||||
recommendedModelId: 'gcli/gemini-3-flash-preview',
|
||||
pinnedAvailable: true,
|
||||
unprefixedStatus: 'shadowed',
|
||||
effectiveProvider: 'agy',
|
||||
effectiveDisplayName: 'Antigravity',
|
||||
effectiveOwnedBy: 'antigravity',
|
||||
summary: 'shadowed by Antigravity',
|
||||
},
|
||||
],
|
||||
}}
|
||||
provider="gemini"
|
||||
onExtendedContextToggle={vi.fn()}
|
||||
onApplyPreset={onApplyPreset}
|
||||
onUpdateEnvValue={vi.fn()}
|
||||
onOpenCustomPreset={vi.fn()}
|
||||
onDeletePreset={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'legacy' }));
|
||||
|
||||
expect(onApplyPreset).toHaveBeenCalledWith({
|
||||
ANTHROPIC_MODEL: 'gcli/gemini-3-flash-preview',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gcli/gemini-3.1-pro-preview',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gcli/gemini-3.1-pro-preview',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gcli/gemini-3-flash-preview',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ProviderEntryCard } from '@/components/cliproxy/ai-providers/provider-entry-card';
|
||||
import { render, screen } from '../../../setup/test-utils';
|
||||
|
||||
describe('ProviderEntryCard', () => {
|
||||
it('renders mapped models as requested to upstream', () => {
|
||||
render(
|
||||
<ProviderEntryCard
|
||||
family={{
|
||||
id: 'openai-compatibility',
|
||||
displayName: 'OpenAI-Compatible',
|
||||
description: 'Connectors',
|
||||
authMode: 'connector',
|
||||
routePath: '/api/provider/openai-compat',
|
||||
status: 'ready',
|
||||
supportsNamedEntries: true,
|
||||
entries: [],
|
||||
}}
|
||||
entry={{
|
||||
id: 'openai-compatibility:0',
|
||||
index: 0,
|
||||
name: 'openrouter',
|
||||
label: 'openrouter',
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
headers: [],
|
||||
excludedModels: [],
|
||||
models: [{ name: 'gpt-5', alias: 'claude-sonnet-4-5' }],
|
||||
apiKeysMasked: ['...1234'],
|
||||
secretConfigured: true,
|
||||
}}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('claude-sonnet-4-5')).toBeInTheDocument();
|
||||
expect(screen.getByText('gpt-5')).toBeInTheDocument();
|
||||
expect(screen.getByText('→')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FlexibleModelSelector } from '@/components/cliproxy/provider-model-selector';
|
||||
import { buildUiCatalogs } from '@/lib/model-catalogs';
|
||||
import { render, screen, userEvent } from '@tests/setup/test-utils';
|
||||
|
||||
const noisyAgyModels = [
|
||||
{ id: 'agy/gemini-3.1-pro-high', owned_by: 'antigravity' },
|
||||
{ id: 'gemini-3.1-pro-high', owned_by: 'antigravity' },
|
||||
{ id: 'gemini-3.1-pro-low', owned_by: 'antigravity' },
|
||||
{ id: 'gemini-3.1-pro-preview', owned_by: 'antigravity' },
|
||||
{ id: 'gemini-3.1-pro-preview-customtools', owned_by: 'antigravity' },
|
||||
{ id: 'gemini-3-pro-preview', owned_by: 'antigravity' },
|
||||
{ id: 'gemini-3-1-flash-preview', owned_by: 'antigravity' },
|
||||
{ id: 'gemini-3-1-flash-preview-customtools', owned_by: 'antigravity' },
|
||||
{ id: 'gpt-oss-120b-medium', owned_by: 'antigravity' },
|
||||
];
|
||||
|
||||
const catalog = buildUiCatalogs({
|
||||
agy: {
|
||||
provider: 'agy',
|
||||
displayName: 'Antigravity',
|
||||
defaultModel: 'claude-opus-4-6-thinking',
|
||||
models: noisyAgyModels.map(({ id }) => ({ id, name: id })),
|
||||
},
|
||||
}).agy;
|
||||
|
||||
describe('FlexibleModelSelector', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => false),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the picker focused on curated Antigravity live routes', async () => {
|
||||
render(
|
||||
<FlexibleModelSelector
|
||||
label="Primary model"
|
||||
value={undefined}
|
||||
onChange={vi.fn()}
|
||||
catalog={catalog}
|
||||
allModels={noisyAgyModels}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /select model/i }));
|
||||
|
||||
expect(screen.getByText('gemini-3.1-pro-high')).toBeInTheDocument();
|
||||
expect(screen.getByText('gemini-3.1-pro-low')).toBeInTheDocument();
|
||||
expect(screen.getByText('gemini-3-1-flash-preview')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText('gemini-3.1-pro-preview')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('gemini-3-pro-preview')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('agy/gemini-3.1-pro-high')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('gemini-3-1-flash-preview-customtools')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('gpt-oss-120b-medium')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/All Models \(/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves a filtered legacy value under the current-value fallback group', async () => {
|
||||
render(
|
||||
<FlexibleModelSelector
|
||||
label="Primary model"
|
||||
value="gemini-3.1-pro-preview"
|
||||
onChange={vi.fn()}
|
||||
catalog={catalog}
|
||||
allModels={noisyAgyModels}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /gemini-3\.1-pro-preview/i }));
|
||||
|
||||
expect(screen.getByText('Current value')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('gemini-3.1-pro-preview').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('gemini-3.1-pro-high')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { OpenRouterQuickStart } from '@/components/profiles/openrouter-quick-start';
|
||||
import { render, screen } from '@tests/setup/test-utils';
|
||||
import { render, screen, userEvent } from '@tests/setup/test-utils';
|
||||
|
||||
vi.mock('@/hooks/use-openrouter-models', () => ({
|
||||
useOpenRouterReady: () => ({
|
||||
@@ -10,12 +10,42 @@ vi.mock('@/hooks/use-openrouter-models', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-local-runtime-readiness', () => ({
|
||||
useLocalRuntimeReadiness: () => ({
|
||||
data: {
|
||||
runtimes: [
|
||||
{
|
||||
id: 'ollama',
|
||||
status: 'offline',
|
||||
endpoint: 'http://127.0.0.1:11434',
|
||||
commandHint: 'ollama serve',
|
||||
recommendedModel: 'gemma4:e4b',
|
||||
recommendedModelInstalled: false,
|
||||
detectedModelCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'llamacpp',
|
||||
status: 'ready',
|
||||
endpoint: 'http://127.0.0.1:8080',
|
||||
commandHint: './server --host 0.0.0.0 --port 8080 -m model.gguf',
|
||||
recommendedModel: null,
|
||||
recommendedModelInstalled: true,
|
||||
detectedModelCount: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('OpenRouterQuickStart', () => {
|
||||
const props = {
|
||||
onOpenRouterClick: vi.fn(),
|
||||
onAlibabaCodingPlanClick: vi.fn(),
|
||||
onCliproxyClick: vi.fn(),
|
||||
onCustomClick: vi.fn(),
|
||||
onOllamaClick: vi.fn(),
|
||||
onLlamacppClick: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -25,16 +55,30 @@ describe('OpenRouterQuickStart', () => {
|
||||
it('prompts the user to select an existing API profile instead of showing the empty-state copy', () => {
|
||||
render(<OpenRouterQuickStart hasProfiles profileCount={1} {...props} />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Select an API profile' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Choose a profile or add another lane' })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('1 profile')).toBeInTheDocument();
|
||||
expect(screen.getByText(/You already have 1 profile in this workspace\./)).toBeInTheDocument();
|
||||
expect(screen.queryByText('No API profiles yet')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the original empty-state title when no API profiles exist', () => {
|
||||
it('shows the new local lane with readiness-aware calls to action', async () => {
|
||||
render(<OpenRouterQuickStart hasProfiles={false} profileCount={0} {...props} />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'No API profiles yet' })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Recommended')).not.toHaveLength(0);
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Choose your first profile lane' })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Ollama + Gemma 4')).toBeInTheDocument();
|
||||
expect(screen.getByText('llama.cpp')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Run `ollama serve` to bring the local endpoint online.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Endpoint ready at http://127.0.0.1:8080')).toBeInTheDocument();
|
||||
expect(screen.getByText('No local models detected yet')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 local model detected')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Set up Ollama' }));
|
||||
expect(props.onOllamaClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ describe('ProfileCreateDialog', () => {
|
||||
mutateAsync.mockReset();
|
||||
});
|
||||
|
||||
it('keeps advanced presets collapsed until explicitly opened and deselects custom after choosing a template', async () => {
|
||||
it('keeps More Presets visible by default and deselects custom after choosing a template', async () => {
|
||||
render(
|
||||
<ProfileCreateDialog
|
||||
open
|
||||
@@ -44,22 +44,15 @@ describe('ProfileCreateDialog', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText('Featured Providers')).toBeInTheDocument();
|
||||
expect(screen.getByText('More Presets')).toBeInTheDocument();
|
||||
expect(screen.getByText('Local Runtimes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alibaba Coding Plan')).toBeVisible();
|
||||
const morePresetsToggle = screen.getByRole('button', { name: /More Presets/i });
|
||||
expect(morePresetsToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(1);
|
||||
expect(screen.getByText('Hugging Face')).toBeVisible();
|
||||
expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(2);
|
||||
|
||||
const customButton = screen.getByRole('button', { name: /Custom Endpoint/i });
|
||||
await userEvent.click(customButton);
|
||||
|
||||
expect(morePresetsToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(1);
|
||||
|
||||
await userEvent.click(morePresetsToggle);
|
||||
|
||||
expect(morePresetsToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(2);
|
||||
|
||||
const glmButton = screen.getByText('GLM').closest('button');
|
||||
expect(glmButton).not.toBeNull();
|
||||
if (!glmButton) {
|
||||
@@ -67,10 +60,42 @@ describe('ProfileCreateDialog', () => {
|
||||
}
|
||||
await userEvent.click(glmButton);
|
||||
|
||||
expect(morePresetsToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await waitFor(() => {
|
||||
expect(customButton).not.toHaveClass('border-primary');
|
||||
});
|
||||
expect(glmButton).toHaveClass('border-primary');
|
||||
});
|
||||
|
||||
it('supports opening directly into the Ollama preset from the providers landing page', async () => {
|
||||
render(
|
||||
<ProfileCreateDialog open onOpenChange={vi.fn()} onSuccess={vi.fn()} initialMode="ollama" />
|
||||
);
|
||||
|
||||
expect(screen.getByDisplayValue('ollama')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('http://localhost:11434')).toBeInTheDocument();
|
||||
expect(screen.getByText('Local Runtimes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('steers the Hugging Face preset to the droid target by default', async () => {
|
||||
render(
|
||||
<ProfileCreateDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
initialMode="openrouter"
|
||||
/>
|
||||
);
|
||||
|
||||
const huggingFaceButton = screen.getByText('Hugging Face').closest('button');
|
||||
expect(huggingFaceButton).not.toBeNull();
|
||||
if (!huggingFaceButton) {
|
||||
throw new Error('Hugging Face preset button not found');
|
||||
}
|
||||
|
||||
await userEvent.click(huggingFaceButton);
|
||||
|
||||
expect(screen.getByDisplayValue('hf')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('https://router.huggingface.co/v1')).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox')).toHaveTextContent('Factory Droid');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from '@tests/setup/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card';
|
||||
import type { GeminiCliQuotaResult } from '@/lib/api-client';
|
||||
|
||||
function createGeminiQuotaResult(
|
||||
overrides: Partial<GeminiCliQuotaResult> = {}
|
||||
): GeminiCliQuotaResult {
|
||||
return {
|
||||
success: true,
|
||||
buckets: [],
|
||||
projectId: 'project-123',
|
||||
tierLabel: 'Pro',
|
||||
tierId: 'g1-pro-tier',
|
||||
creditBalance: 12,
|
||||
entitlement: {
|
||||
normalizedTier: 'pro',
|
||||
rawTierId: 'g1-pro-tier',
|
||||
rawTierLabel: 'Pro',
|
||||
source: 'runtime_api',
|
||||
confidence: 'high',
|
||||
accessState: 'entitled',
|
||||
capacityState: 'available',
|
||||
lastVerifiedAt: Date.now(),
|
||||
notes: null,
|
||||
},
|
||||
lastUpdated: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AccountSurfaceCard', () => {
|
||||
it('prefers live quota entitlement tier over a stale account tier for Gemini badges', () => {
|
||||
render(
|
||||
<AccountSurfaceCard
|
||||
mode="compact"
|
||||
provider="gemini"
|
||||
accountId="user@example.com"
|
||||
email="user@example.com"
|
||||
displayEmail="user@example.com"
|
||||
tier="unknown"
|
||||
quota={createGeminiQuotaResult()}
|
||||
showQuota={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('pro')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,17 @@ function createGeminiQuotaResult(
|
||||
tierLabel: 'Pro',
|
||||
tierId: 'g1-pro-tier',
|
||||
creditBalance: 12,
|
||||
entitlement: {
|
||||
normalizedTier: 'pro',
|
||||
rawTierId: 'g1-pro-tier',
|
||||
rawTierLabel: 'Pro',
|
||||
source: 'runtime_api',
|
||||
confidence: 'high',
|
||||
accessState: 'entitled',
|
||||
capacityState: 'available',
|
||||
lastVerifiedAt: Date.now(),
|
||||
notes: null,
|
||||
},
|
||||
lastUpdated: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
@@ -54,6 +65,8 @@ describe('QuotaTooltipContent', () => {
|
||||
|
||||
expect(screen.getByText('Tier')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tier ID')).toBeInTheDocument();
|
||||
expect(screen.getByText('g1-pro-tier')).toBeInTheDocument();
|
||||
expect(screen.getByText('Credits')).toBeInTheDocument();
|
||||
expect(screen.getByText('12')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gemini Flash Lite Series')).toBeInTheDocument();
|
||||
@@ -90,4 +103,29 @@ describe('QuotaTooltipContent', () => {
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders failure summaries, action hints, and raw details with readable structure', () => {
|
||||
const quota = createGeminiQuotaResult({
|
||||
success: false,
|
||||
buckets: [],
|
||||
error: 'Request had invalid authentication credentials.',
|
||||
httpStatus: 401,
|
||||
errorCode: 'UNAUTHENTICATED',
|
||||
errorDetail:
|
||||
'{"error":{"code":401,"message":"Request had invalid authentication credentials.","status":"UNAUTHENTICATED"}}',
|
||||
actionHint: 'Run ccs gemini --auth to reconnect this account.',
|
||||
needsReauth: true,
|
||||
});
|
||||
|
||||
const { container } = render(<QuotaTooltipContent quota={quota} resetTime={null} />);
|
||||
|
||||
expect(screen.getByText('Reauth')).toBeInTheDocument();
|
||||
expect(screen.getByText('Request had invalid authentication credentials.')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Run ccs gemini --auth to reconnect this account.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('HTTP 401 | UNAUTHENTICATED')).toBeInTheDocument();
|
||||
expect(screen.getByText(/"status":"UNAUTHENTICATED"/)).toBeInTheDocument();
|
||||
expect(container.firstChild).not.toHaveClass('min-w-[16rem]');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
MODEL_CATALOGS,
|
||||
findCatalogModel,
|
||||
getResolvedCatalogModels,
|
||||
getSupplementalCatalogModels,
|
||||
resolveCatalogModelId,
|
||||
} from '@/lib/model-catalogs';
|
||||
import { applyDefaultPreset } from '@/lib/preset-utils';
|
||||
@@ -126,30 +127,64 @@ describe('claude preset utils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves Gemini preview presets to the best live family match', () => {
|
||||
it('maps legacy Antigravity Gemini Pro aliases to the current catalog entries', () => {
|
||||
expect(findCatalogModel('agy', 'gemini-3.1-pro-preview')?.id).toBe('gemini-3.1-pro-high');
|
||||
expect(findCatalogModel('agy', 'gemini-3-pro-preview')?.id).toBe('gemini-3.1-pro-high');
|
||||
expect(findCatalogModel('agy', 'gemini-3-pro-low')?.id).toBe('gemini-3.1-pro-low');
|
||||
});
|
||||
|
||||
it('resolves Antigravity Gemini presets to the current live-safe model ids', () => {
|
||||
const availableModels = [
|
||||
{ id: 'gemini-3.9-pro-preview-customtools', owned_by: 'antigravity' },
|
||||
{ id: 'gemini-3.9-pro-preview', owned_by: 'antigravity' },
|
||||
{ id: 'gemini-3-9-flash-preview-customtools', owned_by: 'antigravity' },
|
||||
{ id: 'gemini-3-9-flash-preview', owned_by: 'antigravity' },
|
||||
{ id: 'agy/gemini-3.1-pro-high', owned_by: 'antigravity' },
|
||||
{ id: 'gemini-3.1-pro-high', owned_by: 'antigravity' },
|
||||
{ id: 'gemini-3.1-pro-low', owned_by: 'antigravity' },
|
||||
{ id: 'gemini-3.1-pro-preview', owned_by: 'antigravity' },
|
||||
{ id: 'gemini-3.1-pro-preview-customtools', owned_by: 'antigravity' },
|
||||
{ id: 'gemini-3-pro-preview', owned_by: 'antigravity' },
|
||||
{ id: 'gemini-3-1-flash-preview-customtools', owned_by: 'antigravity' },
|
||||
{ id: 'gemini-3-1-flash-preview', owned_by: 'antigravity' },
|
||||
{ id: 'gpt-oss-120b-medium', owned_by: 'antigravity' },
|
||||
];
|
||||
|
||||
expect(resolveCatalogModelId('gemini-3.1-pro-preview', availableModels)).toBe(
|
||||
'gemini-3.9-pro-preview'
|
||||
);
|
||||
expect(resolveCatalogModelId('gemini-3-flash-preview', availableModels)).toBe(
|
||||
'gemini-3-9-flash-preview'
|
||||
);
|
||||
expect(findCatalogModel('agy', 'gemini-3.9-pro-preview')?.id).toBe('gemini-3.1-pro-preview');
|
||||
expect(findCatalogModel('agy', 'gemini-3.1-pro-preview')?.id).toBe('gemini-3.1-pro-high');
|
||||
|
||||
const resolvedAgyModels = getResolvedCatalogModels(MODEL_CATALOGS.agy, availableModels);
|
||||
expect(resolvedAgyModels.find((model) => model.name === 'Gemini Pro')?.id).toBe(
|
||||
'gemini-3.9-pro-preview'
|
||||
expect(resolvedAgyModels.find((model) => model.name === 'Gemini Pro High')?.id).toBe(
|
||||
'gemini-3.1-pro-high'
|
||||
);
|
||||
expect(resolvedAgyModels.find((model) => model.name === 'Gemini Pro Low')?.id).toBe(
|
||||
'gemini-3.1-pro-low'
|
||||
);
|
||||
expect(resolvedAgyModels.find((model) => model.name === 'Gemini Flash')?.id).toBe(
|
||||
'gemini-3-9-flash-preview'
|
||||
'gemini-3-1-flash-preview'
|
||||
);
|
||||
expect(resolvedAgyModels.find((model) => model.name === 'Gemini Flash')?.presetMapping).toEqual(
|
||||
expect.objectContaining({
|
||||
opus: 'gemini-3.1-pro-high',
|
||||
sonnet: 'gemini-3.1-pro-high',
|
||||
})
|
||||
);
|
||||
|
||||
const supplementalAgyModels = getSupplementalCatalogModels(
|
||||
'agy',
|
||||
MODEL_CATALOGS.agy,
|
||||
availableModels
|
||||
);
|
||||
expect(supplementalAgyModels.map((model) => model.id)).not.toContain('gpt-oss-120b-medium');
|
||||
expect(supplementalAgyModels.map((model) => model.id)).not.toContain('gemini-3-pro-preview');
|
||||
expect(supplementalAgyModels.map((model) => model.id)).not.toContain('gemini-3.1-pro-preview');
|
||||
expect(supplementalAgyModels.map((model) => model.id)).not.toContain('agy/gemini-3.1-pro-high');
|
||||
expect(supplementalAgyModels.map((model) => model.id)).not.toContain(
|
||||
'gemini-3-1-flash-preview-customtools'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves newly advertised non-Antigravity models in the supplemental list', () => {
|
||||
const supplementalCodexModels = getSupplementalCatalogModels('codex', MODEL_CATALOGS.codex, [
|
||||
{ id: 'gpt-5.9-codex', owned_by: 'openai' },
|
||||
]);
|
||||
|
||||
expect(supplementalCodexModels).toEqual([{ id: 'gpt-5.9-codex', owned_by: 'openai' }]);
|
||||
});
|
||||
|
||||
it('does not silently swap Gemini Flash presets to flash-lite', () => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatRequestedUpstreamModelRules,
|
||||
getRequestedUpstreamModelRuleErrors,
|
||||
getRequestedModelId,
|
||||
parseRequestedUpstreamModelRules,
|
||||
} from '@/lib/provider-config';
|
||||
|
||||
describe('provider model mapping helpers', () => {
|
||||
it('parses requested=upstream rules into stored upstream+alias pairs', () => {
|
||||
expect(
|
||||
parseRequestedUpstreamModelRules('claude-sonnet-4-5=gpt-4.1\nminimax/minimax-m2.7')
|
||||
).toEqual([
|
||||
{ name: 'gpt-4.1', alias: 'claude-sonnet-4-5' },
|
||||
{ name: 'minimax/minimax-m2.7', alias: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('formats stored model rules back into requested=upstream text', () => {
|
||||
expect(
|
||||
formatRequestedUpstreamModelRules([
|
||||
{ name: 'gpt-4.1', alias: 'claude-sonnet-4-5' },
|
||||
{ name: 'minimax/minimax-m2.7', alias: '' },
|
||||
])
|
||||
).toBe('claude-sonnet-4-5=gpt-4.1\nminimax/minimax-m2.7');
|
||||
});
|
||||
|
||||
it('prefers the requested alias for generated settings previews', () => {
|
||||
expect(getRequestedModelId({ name: 'gpt-4.1', alias: 'claude-sonnet-4-5' })).toBe(
|
||||
'claude-sonnet-4-5'
|
||||
);
|
||||
expect(getRequestedModelId({ name: 'minimax/minimax-m2.7', alias: '' })).toBe(
|
||||
'minimax/minimax-m2.7'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed requested=upstream lines instead of coercing them', () => {
|
||||
expect(getRequestedUpstreamModelRuleErrors('claude-sonnet-4-5=\n=gpt-5\nqwen3-coder')).toEqual([
|
||||
'Line 1: use requested=upstream or a plain model name.',
|
||||
'Line 2: use requested=upstream or a plain model name.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,13 @@ describe('provider preset metadata', () => {
|
||||
expect(getPresetById('glmt')?.id).toBe('glm');
|
||||
});
|
||||
|
||||
it('maps hf alias to the Hugging Face preset', () => {
|
||||
const preset = getPresetById('hf');
|
||||
expect(preset?.id).toBe('huggingface');
|
||||
expect(preset?.baseUrl).toBe('https://router.huggingface.co/v1');
|
||||
expect(preset?.defaultTarget).toBe('droid');
|
||||
});
|
||||
|
||||
it('uses the llama.cpp provider logo asset for the local llama.cpp preset', () => {
|
||||
expect(getPresetById('llamacpp')?.icon).toBe('/assets/providers/llama-cpp.svg');
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user