mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 08:15:17 +00:00
feat: hybrid CLIProxy catalog sync + agent teams env fix (#486)
* fix(teams): propagate CLAUDE_CONFIG_DIR to tmux session for agent teammates
When CCS launches Claude with CLAUDE_CONFIG_DIR pointing to the isolated
instance path, agent team teammates spawned via tmux split-window don't
inherit it because tmux creates new panes from its own session environment.
This causes teammates to use ~/.claude/ instead of ~/.ccs/instances/{profile}/,
creating a split-brain in shared state (tasks, teams, mailbox).
Sets key CCS env vars in the tmux session environment via `tmux setenv` so
all new panes inherit the correct config directory.
* refactor(teams): use spawnSync array args instead of execSync string
Avoids shell interpretation of env values by passing args as array
to spawnSync instead of interpolating into a shell command string.
* chore(release): 7.38.0-dev.1 [skip ci]
* feat(cliproxy): add hybrid catalog sync with CLIProxyAPI (#485)
* chore(release): 7.38.0 [skip ci]
## [7.38.0](https://github.com/kaitranntt/ccs/compare/v7.37.1...v7.38.0) (2026-02-07)
### Features
* **release:** v7.38.0 - Extended Context, Qwen Models, Bug Fixes ([#480](https://github.com/kaitranntt/ccs/issues/480)) ([b454834](https://github.com/kaitranntt/ccs/commit/b4548341750804339c87c48259dd8741425d2acf)), closes [#472](https://github.com/kaitranntt/ccs/issues/472) [#474](https://github.com/kaitranntt/ccs/issues/474) [#103](https://github.com/kaitranntt/ccs/issues/103) [#478](https://github.com/kaitranntt/ccs/issues/478) [#482](https://github.com/kaitranntt/ccs/issues/482)
* feat(cliproxy): add hybrid catalog sync with CLIProxyAPI
Add `ccs cliproxy catalog` command family for syncing model catalogs
from CLIProxyAPI's model-definitions endpoint. Cached locally with
24h TTL, merges remote models with static catalog preserving
broken/deprecated flags. Dashboard API endpoint at /api/cliproxy/catalog.
Closes #477
---------
Co-authored-by: semantic-release-bot <semantic-release-bot@martynus.net>
* chore(reviewer): upgrade review model from opus 4.5 to opus 4.6
- update default model in code-reviewer.ts
- update REVIEW_MODEL, ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL
in ai-review.yml workflow
- aligns reviewer with AGY model catalog default
* chore(release): 7.38.0-dev.2 [skip ci]
* fix(hooks): add image analysis env vars for settings-based profiles (#484)
* chore(release): 7.38.0 [skip ci]
## [7.38.0](https://github.com/kaitranntt/ccs/compare/v7.37.1...v7.38.0) (2026-02-07)
### Features
* **release:** v7.38.0 - Extended Context, Qwen Models, Bug Fixes ([#480](https://github.com/kaitranntt/ccs/issues/480)) ([b454834](https://github.com/kaitranntt/ccs/commit/b4548341750804339c87c48259dd8741425d2acf)), closes [#472](https://github.com/kaitranntt/ccs/issues/472) [#474](https://github.com/kaitranntt/ccs/issues/474) [#103](https://github.com/kaitranntt/ccs/issues/103) [#478](https://github.com/kaitranntt/ccs/issues/478) [#482](https://github.com/kaitranntt/ccs/issues/482)
* fix(hooks): add image analysis env vars for settings-based profiles
Image analysis hook was injected into settings files for API profiles
but the CCS_IMAGE_ANALYSIS_* env vars were never set, causing the hook
to skip. Add getImageAnalysisHookEnv() call to both settings-based and
GLMT proxy execution paths, matching CLIProxy profile behavior.
Closes #440
* fix(hooks): add hook env vars to copilot executor
Copilot profile had same gap as settings-based profiles: hooks installed
but webSearch/imageAnalysis env vars and CCS_PROFILE_TYPE never set.
Add missing env var injection matching other profile flows.
---------
Co-authored-by: semantic-release-bot <semantic-release-bot@martynus.net>
* chore(release): 7.38.0-dev.3 [skip ci]
---------
Co-authored-by: Carlos Umanzor <cumanzor@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: semantic-release-bot <semantic-release-bot@martynus.net>
This commit is contained in:
15 files changed
+526
-7
No files matched your search
@@ -70,10 +70,10 @@ jobs:
|
|||||||
# CLIProxy environment for model routing
|
# CLIProxy environment for model routing
|
||||||
env:
|
env:
|
||||||
ANTHROPIC_BASE_URL: http://localhost:8317
|
ANTHROPIC_BASE_URL: http://localhost:8317
|
||||||
REVIEW_MODEL: gemini-claude-opus-4-5-thinking
|
REVIEW_MODEL: gemini-claude-opus-4-6-thinking
|
||||||
ANTHROPIC_AUTH_TOKEN: ccs-internal-managed
|
ANTHROPIC_AUTH_TOKEN: ccs-internal-managed
|
||||||
ANTHROPIC_MODEL: gemini-claude-opus-4-5-thinking
|
ANTHROPIC_MODEL: gemini-claude-opus-4-6-thinking
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: gemini-claude-opus-4-5-thinking
|
ANTHROPIC_DEFAULT_OPUS_MODEL: gemini-claude-opus-4-6-thinking
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: gemini-claude-sonnet-4-5-thinking
|
ANTHROPIC_DEFAULT_SONNET_MODEL: gemini-claude-sonnet-4-5-thinking
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: gemini-claude-sonnet-4-5
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: gemini-claude-sonnet-4-5
|
||||||
DISABLE_BUG_COMMAND: "1"
|
DISABLE_BUG_COMMAND: "1"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kaitranntt/ccs",
|
"name": "@kaitranntt/ccs",
|
||||||
"version": "7.38.0",
|
"version": "7.38.0-dev.3",
|
||||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"cli",
|
"cli",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface PRContext {
|
|||||||
// Config
|
// Config
|
||||||
const MAX_DIFF_LINES = 10000;
|
const MAX_DIFF_LINES = 10000;
|
||||||
const CLIPROXY_URL = process.env.CLIPROXY_URL || 'http://localhost:8317';
|
const CLIPROXY_URL = process.env.CLIPROXY_URL || 'http://localhost:8317';
|
||||||
const MODEL = process.env.REVIEW_MODEL || 'gemini-claude-opus-4-5-thinking';
|
const MODEL = process.env.REVIEW_MODEL || 'gemini-claude-opus-4-6-thinking';
|
||||||
|
|
||||||
// System prompt for code review - new style
|
// System prompt for code review - new style
|
||||||
const CODE_REVIEWER_SYSTEM_PROMPT = `You are the CCS AGY Code Reviewer, an expert AI assistant reviewing pull requests for the CCS CLI project.
|
const CODE_REVIEWER_SYSTEM_PROMPT = `You are the CCS AGY Code Reviewer, an expert AI assistant reviewing pull requests for the CCS CLI project.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from './utils/websearch-manager';
|
} from './utils/websearch-manager';
|
||||||
import { getGlobalEnvConfig } from './config/unified-config-loader';
|
import { getGlobalEnvConfig } from './config/unified-config-loader';
|
||||||
import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector';
|
import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector';
|
||||||
|
import { getImageAnalysisHookEnv } from './utils/hooks';
|
||||||
import { fail, info } from './utils/ui';
|
import { fail, info } from './utils/ui';
|
||||||
|
|
||||||
// Import centralized error handling
|
// Import centralized error handling
|
||||||
@@ -165,10 +166,12 @@ async function execClaudeWithProxy(
|
|||||||
const isWindows = process.platform === 'win32';
|
const isWindows = process.platform === 'win32';
|
||||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||||
const webSearchEnv = getWebSearchHookEnv();
|
const webSearchEnv = getWebSearchHookEnv();
|
||||||
|
const imageAnalysisEnv = getImageAnalysisHookEnv(profileName);
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
...envVars,
|
...envVars,
|
||||||
...webSearchEnv,
|
...webSearchEnv,
|
||||||
|
...imageAnalysisEnv,
|
||||||
CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider
|
CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -619,6 +622,7 @@ async function main(): Promise<void> {
|
|||||||
// Use --settings flag (backward compatible)
|
// Use --settings flag (backward compatible)
|
||||||
const expandedSettingsPath = getSettingsPath(profileInfo.name);
|
const expandedSettingsPath = getSettingsPath(profileInfo.name);
|
||||||
const webSearchEnv = getWebSearchHookEnv();
|
const webSearchEnv = getWebSearchHookEnv();
|
||||||
|
const imageAnalysisEnv = getImageAnalysisHookEnv(profileInfo.name);
|
||||||
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
|
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
|
||||||
const globalEnvConfig = getGlobalEnvConfig();
|
const globalEnvConfig = getGlobalEnvConfig();
|
||||||
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
|
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
|
||||||
@@ -640,6 +644,7 @@ async function main(): Promise<void> {
|
|||||||
...globalEnv,
|
...globalEnv,
|
||||||
...settingsEnv, // Explicitly inject all settings env vars
|
...settingsEnv, // Explicitly inject all settings env vars
|
||||||
...webSearchEnv,
|
...webSearchEnv,
|
||||||
|
...imageAnalysisEnv,
|
||||||
CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider
|
CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider
|
||||||
};
|
};
|
||||||
execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs], envVars);
|
execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs], envVars);
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { getCcsDir } from '../utils/config-manager';
|
||||||
|
import type { CLIProxyProvider } from './types';
|
||||||
|
import type { ModelEntry, ProviderCatalog, ThinkingSupport } from './model-catalog';
|
||||||
|
import { MODEL_CATALOG } from './model-catalog';
|
||||||
|
import type { RemoteModelInfo, RemoteThinkingSupport } from './management-api-types';
|
||||||
|
|
||||||
|
const CACHE_FILE_NAME = 'model-catalog-cache.json';
|
||||||
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
/** Cache structure stored on disk */
|
||||||
|
interface CatalogCacheData {
|
||||||
|
providers: Record<string, RemoteModelInfo[]>;
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Channel name → CCS provider mapping */
|
||||||
|
const CHANNEL_TO_PROVIDER: Record<string, CLIProxyProvider> = {
|
||||||
|
antigravity: 'agy',
|
||||||
|
claude: 'claude',
|
||||||
|
gemini: 'gemini',
|
||||||
|
codex: 'codex',
|
||||||
|
qwen: 'qwen',
|
||||||
|
iflow: 'iflow',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** CCS provider → channel name mapping (reverse) */
|
||||||
|
export const PROVIDER_TO_CHANNEL: Record<string, string> = Object.fromEntries(
|
||||||
|
Object.entries(CHANNEL_TO_PROVIDER).map(([k, v]) => [v, k])
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Providers to sync from CLIProxyAPI */
|
||||||
|
export const SYNCABLE_PROVIDERS: CLIProxyProvider[] = ['agy', 'gemini', 'codex', 'claude'];
|
||||||
|
|
||||||
|
function getCacheFilePath(): string {
|
||||||
|
return path.join(getCcsDir(), CACHE_FILE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read cached catalog data, null if expired or missing */
|
||||||
|
export function getCachedCatalog(): CatalogCacheData | null {
|
||||||
|
try {
|
||||||
|
const filePath = getCacheFilePath();
|
||||||
|
if (!fs.existsSync(filePath)) return null;
|
||||||
|
const data = JSON.parse(fs.readFileSync(filePath, 'utf8')) as CatalogCacheData;
|
||||||
|
if (Date.now() - data.fetchedAt > CACHE_TTL_MS) return null;
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save catalog data to cache */
|
||||||
|
export function setCachedCatalog(providers: Record<string, RemoteModelInfo[]>): void {
|
||||||
|
try {
|
||||||
|
const filePath = getCacheFilePath();
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify({ providers, fetchedAt: Date.now() }));
|
||||||
|
} catch {
|
||||||
|
// Ignore cache write errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete cache file */
|
||||||
|
export function clearCatalogCache(): boolean {
|
||||||
|
try {
|
||||||
|
const filePath = getCacheFilePath();
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get cache age in human-readable format, or null if no cache */
|
||||||
|
export function getCacheAge(): string | null {
|
||||||
|
try {
|
||||||
|
const filePath = getCacheFilePath();
|
||||||
|
if (!fs.existsSync(filePath)) return null;
|
||||||
|
const data = JSON.parse(fs.readFileSync(filePath, 'utf8')) as CatalogCacheData;
|
||||||
|
const ageMs = Date.now() - data.fetchedAt;
|
||||||
|
const hours = Math.floor(ageMs / (60 * 60 * 1000));
|
||||||
|
const minutes = Math.floor((ageMs % (60 * 60 * 1000)) / (60 * 1000));
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m ago`;
|
||||||
|
return `${minutes}m ago`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map remote thinking support to CCS ThinkingSupport */
|
||||||
|
function mapThinking(remote?: RemoteThinkingSupport): ThinkingSupport | undefined {
|
||||||
|
if (!remote) return undefined;
|
||||||
|
// If levels are provided, it's a levels-type thinking
|
||||||
|
if (remote.levels && remote.levels.length > 0) {
|
||||||
|
return {
|
||||||
|
type: 'levels',
|
||||||
|
levels: remote.levels,
|
||||||
|
dynamicAllowed: remote.dynamic_allowed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// If min/max budget are provided, it's budget-type
|
||||||
|
if (remote.min !== undefined || remote.max !== undefined) {
|
||||||
|
return {
|
||||||
|
type: 'budget',
|
||||||
|
min: remote.min,
|
||||||
|
max: remote.max,
|
||||||
|
zeroAllowed: remote.zero_allowed,
|
||||||
|
dynamicAllowed: remote.dynamic_allowed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { type: 'none' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map RemoteModelInfo to ModelEntry */
|
||||||
|
function mapRemoteToModelEntry(remote: RemoteModelInfo): ModelEntry {
|
||||||
|
const entry: ModelEntry = {
|
||||||
|
id: remote.id,
|
||||||
|
name: remote.display_name || remote.id,
|
||||||
|
};
|
||||||
|
if (remote.description) entry.description = remote.description;
|
||||||
|
if (remote.context_length && remote.context_length >= 1_000_000) {
|
||||||
|
entry.extendedContext = true;
|
||||||
|
}
|
||||||
|
const thinking = mapThinking(remote.thinking);
|
||||||
|
if (thinking) entry.thinking = thinking;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge remote models with static catalog for a provider.
|
||||||
|
* Remote fields override static where present.
|
||||||
|
* Static-only fields preserved: broken, deprecated, deprecationReason, issueUrl, tier.
|
||||||
|
* Models in static but not in remote → kept.
|
||||||
|
* Models in remote but not in static → added.
|
||||||
|
*/
|
||||||
|
export function mergeCatalog(
|
||||||
|
provider: CLIProxyProvider,
|
||||||
|
remoteModels: RemoteModelInfo[]
|
||||||
|
): ProviderCatalog | undefined {
|
||||||
|
const staticCatalog = MODEL_CATALOG[provider];
|
||||||
|
if (!staticCatalog && remoteModels.length === 0) return undefined;
|
||||||
|
|
||||||
|
const displayName = staticCatalog?.displayName || provider;
|
||||||
|
const defaultModel = staticCatalog?.defaultModel || (remoteModels[0]?.id ?? '');
|
||||||
|
|
||||||
|
// Build map of static models by lowercase ID for fast lookup
|
||||||
|
const staticMap = new Map<string, ModelEntry>();
|
||||||
|
if (staticCatalog) {
|
||||||
|
for (const model of staticCatalog.models) {
|
||||||
|
staticMap.set(model.id.toLowerCase(), model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process remote models: merge with static entries
|
||||||
|
const mergedIds = new Set<string>();
|
||||||
|
const mergedModels: ModelEntry[] = [];
|
||||||
|
|
||||||
|
for (const remote of remoteModels) {
|
||||||
|
const remoteEntry = mapRemoteToModelEntry(remote);
|
||||||
|
const staticEntry = staticMap.get(remote.id.toLowerCase());
|
||||||
|
mergedIds.add(remote.id.toLowerCase());
|
||||||
|
|
||||||
|
if (staticEntry) {
|
||||||
|
// Merge: remote overrides, static fills gaps
|
||||||
|
mergedModels.push({
|
||||||
|
...remoteEntry,
|
||||||
|
// Preserve static-only fields
|
||||||
|
tier: staticEntry.tier,
|
||||||
|
broken: staticEntry.broken,
|
||||||
|
issueUrl: staticEntry.issueUrl,
|
||||||
|
deprecated: staticEntry.deprecated,
|
||||||
|
deprecationReason: staticEntry.deprecationReason,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
mergedModels.push(remoteEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add static-only models not in remote
|
||||||
|
if (staticCatalog) {
|
||||||
|
for (const model of staticCatalog.models) {
|
||||||
|
if (!mergedIds.has(model.id.toLowerCase())) {
|
||||||
|
mergedModels.push(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
displayName,
|
||||||
|
defaultModel,
|
||||||
|
models: mergedModels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get resolved catalog for a provider.
|
||||||
|
* Uses cached remote data if available, falls back to static.
|
||||||
|
*/
|
||||||
|
export function getResolvedCatalog(provider: CLIProxyProvider): ProviderCatalog | undefined {
|
||||||
|
const cached = getCachedCatalog();
|
||||||
|
if (cached && cached.providers[provider]) {
|
||||||
|
return mergeCatalog(provider, cached.providers[provider]);
|
||||||
|
}
|
||||||
|
return MODEL_CATALOG[provider];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all resolved catalogs (for Dashboard).
|
||||||
|
*/
|
||||||
|
export function getAllResolvedCatalogs(): Partial<Record<CLIProxyProvider, ProviderCatalog>> {
|
||||||
|
const result: Partial<Record<CLIProxyProvider, ProviderCatalog>> = {};
|
||||||
|
const cached = getCachedCatalog();
|
||||||
|
|
||||||
|
// Get all known providers from both static and cache
|
||||||
|
const providers = new Set<CLIProxyProvider>();
|
||||||
|
for (const p of Object.keys(MODEL_CATALOG) as CLIProxyProvider[]) providers.add(p);
|
||||||
|
if (cached) {
|
||||||
|
for (const p of Object.keys(cached.providers) as CLIProxyProvider[]) providers.add(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
const catalog = getResolvedCatalog(provider);
|
||||||
|
if (catalog) result[provider] = catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -176,9 +176,22 @@ export type {
|
|||||||
ManagementApiErrorCode,
|
ManagementApiErrorCode,
|
||||||
ClaudeKeyPatch,
|
ClaudeKeyPatch,
|
||||||
SyncStatus,
|
SyncStatus,
|
||||||
|
RemoteModelInfo,
|
||||||
|
RemoteThinkingSupport,
|
||||||
} from './management-api-types';
|
} from './management-api-types';
|
||||||
export { ManagementApiClient, createManagementClient } from './management-api-client';
|
export { ManagementApiClient, createManagementClient } from './management-api-client';
|
||||||
|
|
||||||
|
// Catalog cache (model catalog sync with CLIProxyAPI)
|
||||||
|
export {
|
||||||
|
getResolvedCatalog,
|
||||||
|
getAllResolvedCatalogs,
|
||||||
|
getCacheAge,
|
||||||
|
clearCatalogCache,
|
||||||
|
setCachedCatalog,
|
||||||
|
SYNCABLE_PROVIDERS,
|
||||||
|
PROVIDER_TO_CHANNEL,
|
||||||
|
} from './catalog-cache';
|
||||||
|
|
||||||
// Sync module (profile sync to remote CLIProxy)
|
// Sync module (profile sync to remote CLIProxy)
|
||||||
export type { SyncableProfile, SyncPreviewItem } from './sync';
|
export type { SyncableProfile, SyncPreviewItem } from './sync';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import type {
|
|||||||
ClaudeKey,
|
ClaudeKey,
|
||||||
GetClaudeKeysResponse,
|
GetClaudeKeysResponse,
|
||||||
ClaudeKeyPatch,
|
ClaudeKeyPatch,
|
||||||
|
RemoteModelInfo,
|
||||||
|
GetModelDefinitionsResponse,
|
||||||
} from './management-api-types';
|
} from './management-api-types';
|
||||||
|
|
||||||
/** Default timeout for management operations (longer than health check) */
|
/** Default timeout for management operations (longer than health check) */
|
||||||
@@ -200,6 +202,19 @@ export class ManagementApiClient {
|
|||||||
await this.request('DELETE', `/v0/management/claude-api-key?api-key=${encodedKey}`);
|
await this.request('DELETE', `/v0/management/claude-api-key?api-key=${encodedKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get model definitions for a channel from CLIProxyAPI.
|
||||||
|
* GET /v0/management/model-definitions/:channel
|
||||||
|
*/
|
||||||
|
async getModelDefinitions(channel: string): Promise<RemoteModelInfo[]> {
|
||||||
|
const encodedChannel = encodeURIComponent(channel);
|
||||||
|
const response = await this.request<GetModelDefinitionsResponse>(
|
||||||
|
'GET',
|
||||||
|
`/v0/management/model-definitions/${encodedChannel}`
|
||||||
|
);
|
||||||
|
return response.data?.models ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make an HTTP request to the Management API.
|
* Make an HTTP request to the Management API.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -121,3 +121,36 @@ export interface SyncStatus {
|
|||||||
/** Remote CLIProxy URL */
|
/** Remote CLIProxy URL */
|
||||||
remoteUrl?: string;
|
remoteUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remote model thinking support from CLIProxyAPI model-definitions endpoint
|
||||||
|
*/
|
||||||
|
export interface RemoteThinkingSupport {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
zero_allowed?: boolean;
|
||||||
|
dynamic_allowed?: boolean;
|
||||||
|
levels?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remote model info from CLIProxyAPI model-definitions endpoint
|
||||||
|
*/
|
||||||
|
export interface RemoteModelInfo {
|
||||||
|
id: string;
|
||||||
|
display_name?: string;
|
||||||
|
description?: string;
|
||||||
|
context_length?: number;
|
||||||
|
max_completion_tokens?: number;
|
||||||
|
thinking?: RemoteThinkingSupport;
|
||||||
|
owned_by?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response from GET /v0/management/model-definitions/:channel
|
||||||
|
*/
|
||||||
|
export interface GetModelDefinitionsResponse {
|
||||||
|
channel: string;
|
||||||
|
models: RemoteModelInfo[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { initUI, header, subheader, color, dim } from '../../utils/ui';
|
||||||
|
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||||
|
import { createManagementClient } from '../../cliproxy/management-api-client';
|
||||||
|
import {
|
||||||
|
getCacheAge,
|
||||||
|
setCachedCatalog,
|
||||||
|
clearCatalogCache,
|
||||||
|
SYNCABLE_PROVIDERS,
|
||||||
|
PROVIDER_TO_CHANNEL,
|
||||||
|
getResolvedCatalog,
|
||||||
|
} from '../../cliproxy/catalog-cache';
|
||||||
|
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
|
import type { RemoteModelInfo } from '../../cliproxy/management-api-types';
|
||||||
|
|
||||||
|
/** Fetch model definitions from CLIProxyAPI for all syncable providers */
|
||||||
|
async function fetchRemoteCatalogs(
|
||||||
|
verbose: boolean
|
||||||
|
): Promise<Record<string, RemoteModelInfo[]> | null> {
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
const remote = config.cliproxy_server?.remote;
|
||||||
|
|
||||||
|
if (!remote?.host) {
|
||||||
|
if (verbose) console.log(dim(' No remote CLIProxy configured'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createManagementClient(remote);
|
||||||
|
|
||||||
|
// Check health first
|
||||||
|
const health = await client.health();
|
||||||
|
if (!health.healthy) {
|
||||||
|
console.log(color(` [!] CLIProxy unreachable: ${health.error || 'unknown error'}`, 'warning'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(dim(` Connected to ${client.getBaseUrl()}`));
|
||||||
|
if (health.version) console.log(dim(` CLIProxy version: ${health.version}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, RemoteModelInfo[]> = {};
|
||||||
|
|
||||||
|
for (const provider of SYNCABLE_PROVIDERS) {
|
||||||
|
const channel = PROVIDER_TO_CHANNEL[provider];
|
||||||
|
if (!channel) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.getModelDefinitions(channel);
|
||||||
|
if (response && response.length > 0) {
|
||||||
|
result[provider] = response;
|
||||||
|
if (verbose) console.log(dim(` ${provider}: ${response.length} models`));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (verbose) console.log(dim(` ${provider}: fetch failed (skipped)`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(result).length > 0 ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show catalog status */
|
||||||
|
export async function handleCatalogStatus(verbose: boolean): Promise<void> {
|
||||||
|
await initUI();
|
||||||
|
console.log('');
|
||||||
|
console.log(header('Model Catalog'));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const cacheAge = getCacheAge();
|
||||||
|
if (cacheAge) {
|
||||||
|
console.log(` Cache: ${color('synced', 'success')} (${cacheAge})`);
|
||||||
|
} else {
|
||||||
|
console.log(` Cache: ${color('static only', 'warning')} (no sync)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(subheader('Providers:'));
|
||||||
|
|
||||||
|
for (const provider of SYNCABLE_PROVIDERS) {
|
||||||
|
const catalog = getResolvedCatalog(provider);
|
||||||
|
if (catalog) {
|
||||||
|
const count = catalog.models.length;
|
||||||
|
console.log(` ${color(catalog.displayName.padEnd(20), 'command')} ${count} models`);
|
||||||
|
if (verbose) {
|
||||||
|
for (const model of catalog.models) {
|
||||||
|
console.log(dim(` - ${model.id} (${model.name})`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
if (!cacheAge) {
|
||||||
|
console.log(dim(' Run "ccs cliproxy catalog refresh" to sync from CLIProxy'));
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh catalog from CLIProxyAPI */
|
||||||
|
export async function handleCatalogRefresh(verbose: boolean): Promise<void> {
|
||||||
|
await initUI();
|
||||||
|
console.log('');
|
||||||
|
console.log(header('Catalog Refresh'));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const result = await fetchRemoteCatalogs(verbose);
|
||||||
|
if (!result) {
|
||||||
|
console.log(' Failed to fetch remote catalogs. Static catalog unchanged.');
|
||||||
|
console.log('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCachedCatalog(result);
|
||||||
|
|
||||||
|
// Show summary
|
||||||
|
let totalModels = 0;
|
||||||
|
for (const [provider, models] of Object.entries(result)) {
|
||||||
|
const merged = getResolvedCatalog(provider as CLIProxyProvider);
|
||||||
|
const mergedCount = merged?.models.length ?? 0;
|
||||||
|
console.log(
|
||||||
|
` ${color(provider.padEnd(12), 'command')} ${models.length} remote -> ${mergedCount} merged`
|
||||||
|
);
|
||||||
|
totalModels += mergedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(` ${color('[OK]', 'success')} Catalog synced (${totalModels} total models)`);
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset catalog cache */
|
||||||
|
export async function handleCatalogReset(): Promise<void> {
|
||||||
|
await initUI();
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const cleared = clearCatalogCache();
|
||||||
|
if (cleared) {
|
||||||
|
console.log(` ${color('[OK]', 'success')} Catalog cache cleared. Using static catalog.`);
|
||||||
|
} else {
|
||||||
|
console.log(' No cache to clear.');
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
@@ -30,6 +30,14 @@ export async function showHelp(): Promise<void> {
|
|||||||
['remove <name>', 'Remove a CLIProxy variant profile'],
|
['remove <name>', 'Remove a CLIProxy variant profile'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'Catalog Commands:',
|
||||||
|
[
|
||||||
|
['catalog', 'Show catalog status (cached vs static)'],
|
||||||
|
['catalog refresh', 'Sync models from remote CLIProxy'],
|
||||||
|
['catalog reset', 'Clear cache, revert to static catalog'],
|
||||||
|
],
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'Local Sync:',
|
'Local Sync:',
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ import { handleCreate, handleRemove } from './variant-subcommand';
|
|||||||
import { handleProxyStatus, handleStop } from './proxy-lifecycle-subcommand';
|
import { handleProxyStatus, handleStop } from './proxy-lifecycle-subcommand';
|
||||||
import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand';
|
import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand';
|
||||||
import { showHelp } from './help-subcommand';
|
import { showHelp } from './help-subcommand';
|
||||||
|
import {
|
||||||
|
handleCatalogStatus,
|
||||||
|
handleCatalogRefresh,
|
||||||
|
handleCatalogReset,
|
||||||
|
} from './catalog-subcommand';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse --backend flag from args
|
* Parse --backend flag from args
|
||||||
@@ -166,6 +171,21 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Catalog commands
|
||||||
|
if (command === 'catalog') {
|
||||||
|
const subcommand = remainingArgs[1];
|
||||||
|
if (subcommand === 'refresh') {
|
||||||
|
await handleCatalogRefresh(verbose);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (subcommand === 'reset') {
|
||||||
|
await handleCatalogReset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleCatalogStatus(verbose);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Sync command
|
// Sync command
|
||||||
if (command === 'sync') {
|
if (command === 'sync') {
|
||||||
await handleSync(remainingArgs.slice(1));
|
await handleSync(remainingArgs.slice(1));
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { isDaemonRunning, startDaemon } from './copilot-daemon';
|
|||||||
import { ensureCopilotApi } from './copilot-package-manager';
|
import { ensureCopilotApi } from './copilot-package-manager';
|
||||||
import { CopilotStatus } from './types';
|
import { CopilotStatus } from './types';
|
||||||
import { fail, info, ok } from '../utils/ui';
|
import { fail, info, ok } from '../utils/ui';
|
||||||
|
import { getWebSearchHookEnv } from '../utils/websearch-manager';
|
||||||
|
import { getImageAnalysisHookEnv } from '../utils/hooks';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get full copilot status (auth + daemon).
|
* Get full copilot status (auth + daemon).
|
||||||
@@ -133,11 +135,16 @@ export async function executeCopilotProfile(
|
|||||||
const globalEnvConfig = getGlobalEnvConfig();
|
const globalEnvConfig = getGlobalEnvConfig();
|
||||||
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
|
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
|
||||||
|
|
||||||
// Merge with current environment (global env first, copilot overrides)
|
// Merge with current environment (global env first, copilot overrides, then hook env vars)
|
||||||
|
const webSearchEnv = getWebSearchHookEnv();
|
||||||
|
const imageAnalysisEnv = getImageAnalysisHookEnv('copilot');
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
...globalEnv,
|
...globalEnv,
|
||||||
...copilotEnv,
|
...copilotEnv,
|
||||||
|
...webSearchEnv,
|
||||||
|
...imageAnalysisEnv,
|
||||||
|
CCS_PROFILE_TYPE: 'copilot',
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(info(`Using GitHub Copilot proxy (model: ${config.model})`));
|
console.log(info(`Using GitHub Copilot proxy (model: ${config.model})`));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Cross-platform shell execution utilities for CCS.
|
* Cross-platform shell execution utilities for CCS.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn, ChildProcess } from 'child_process';
|
import { spawn, spawnSync, ChildProcess } from 'child_process';
|
||||||
import { ErrorManager } from './error-manager';
|
import { ErrorManager } from './error-manager';
|
||||||
import { getWebSearchHookEnv } from './websearch-manager';
|
import { getWebSearchHookEnv } from './websearch-manager';
|
||||||
|
|
||||||
@@ -82,6 +82,21 @@ export function execClaude(
|
|||||||
? { ...baseEnv, ...envVars, ...webSearchEnv }
|
? { ...baseEnv, ...envVars, ...webSearchEnv }
|
||||||
: { ...baseEnv, ...webSearchEnv };
|
: { ...baseEnv, ...webSearchEnv };
|
||||||
|
|
||||||
|
// propagate key env vars to tmux session so agent team teammates
|
||||||
|
// (spawned via tmux split-window) inherit the correct config dir
|
||||||
|
if (process.env.TMUX && envVars) {
|
||||||
|
const tmuxPropagateVars = ['CLAUDE_CONFIG_DIR', 'CCS_PROFILE_TYPE', 'CCS_WEBSEARCH_SKIP'];
|
||||||
|
for (const key of tmuxPropagateVars) {
|
||||||
|
if (envVars[key]) {
|
||||||
|
try {
|
||||||
|
spawnSync('tmux', ['setenv', key, envVars[key] ?? ''], { stdio: 'ignore' });
|
||||||
|
} catch {
|
||||||
|
// tmux setenv can fail if not in a tmux session; safe to ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let child: ChildProcess;
|
let child: ChildProcess;
|
||||||
if (needsShell) {
|
if (needsShell) {
|
||||||
// When shell needed: concatenate into string to avoid DEP0190 warning
|
// When shell needed: concatenate into string to avoid DEP0190 warning
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { getAllResolvedCatalogs, getCacheAge } from '../../cliproxy/catalog-cache';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cliproxy/catalog - Get merged model catalogs
|
||||||
|
* Returns resolved catalogs (cached + static merged)
|
||||||
|
*/
|
||||||
|
router.get('/', (_req: Request, res: Response): void => {
|
||||||
|
try {
|
||||||
|
const catalogs = getAllResolvedCatalogs();
|
||||||
|
const cacheAge = getCacheAge();
|
||||||
|
res.json({
|
||||||
|
catalogs,
|
||||||
|
cache: {
|
||||||
|
synced: cacheAge !== null,
|
||||||
|
age: cacheAge,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -24,6 +24,7 @@ import miscRoutes from './misc-routes';
|
|||||||
import cliproxyServerRoutes from './proxy-routes';
|
import cliproxyServerRoutes from './proxy-routes';
|
||||||
import authRoutes from './auth-routes';
|
import authRoutes from './auth-routes';
|
||||||
import persistRoutes from './persist-routes';
|
import persistRoutes from './persist-routes';
|
||||||
|
import catalogRoutes from './catalog-routes';
|
||||||
|
|
||||||
// Create the main API router
|
// Create the main API router
|
||||||
export const apiRoutes = Router();
|
export const apiRoutes = Router();
|
||||||
@@ -53,6 +54,7 @@ apiRoutes.use('/cliproxy', variantRoutes);
|
|||||||
apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes);
|
apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes);
|
||||||
apiRoutes.use('/cliproxy', cliproxyStatsRoutes);
|
apiRoutes.use('/cliproxy', cliproxyStatsRoutes);
|
||||||
apiRoutes.use('/cliproxy/sync', cliproxySyncRoutes);
|
apiRoutes.use('/cliproxy/sync', cliproxySyncRoutes);
|
||||||
|
apiRoutes.use('/cliproxy/catalog', catalogRoutes);
|
||||||
apiRoutes.use('/cliproxy/openai-compat', providerRoutes);
|
apiRoutes.use('/cliproxy/openai-compat', providerRoutes);
|
||||||
|
|
||||||
// ==================== WebSearch ====================
|
// ==================== WebSearch ====================
|
||||||
|
|||||||
Reference in new issue
Block a user