From db88290b9110ba78f484d707ae3e64521db4e622 Mon Sep 17 00:00:00 2001 From: Carlos Umanzor Date: Fri, 6 Feb 2026 13:10:46 -0600 Subject: [PATCH 01/20] 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. --- src/utils/shell-executor.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index e8bb02d9..77135399 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -4,7 +4,7 @@ * Cross-platform shell execution utilities for CCS. */ -import { spawn, ChildProcess } from 'child_process'; +import { spawn, ChildProcess, execSync } from 'child_process'; import { ErrorManager } from './error-manager'; import { getWebSearchHookEnv } from './websearch-manager'; @@ -82,6 +82,21 @@ export function execClaude( ? { ...baseEnv, ...envVars, ...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 { + execSync(`tmux setenv ${key} ${JSON.stringify(envVars[key])}`, { stdio: 'ignore' }); + } catch { + // tmux setenv can fail if not in a tmux session; safe to ignore + } + } + } + } + let child: ChildProcess; if (needsShell) { // When shell needed: concatenate into string to avoid DEP0190 warning From 0f7d5f97576d68b9c73ea952a4ea5853b0993467 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 6 Feb 2026 20:32:44 -0500 Subject: [PATCH 02/20] 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. --- src/utils/shell-executor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 77135399..6afa2620 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -4,7 +4,7 @@ * Cross-platform shell execution utilities for CCS. */ -import { spawn, ChildProcess, execSync } from 'child_process'; +import { spawn, spawnSync, ChildProcess } from 'child_process'; import { ErrorManager } from './error-manager'; import { getWebSearchHookEnv } from './websearch-manager'; @@ -89,7 +89,7 @@ export function execClaude( for (const key of tmuxPropagateVars) { if (envVars[key]) { try { - execSync(`tmux setenv ${key} ${JSON.stringify(envVars[key])}`, { stdio: 'ignore' }); + spawnSync('tmux', ['setenv', key, envVars[key] ?? ''], { stdio: 'ignore' }); } catch { // tmux setenv can fail if not in a tmux session; safe to ignore } From 9e22018deea379e6cf16aba7e3bb1eff33e8c249 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 7 Feb 2026 02:15:26 +0000 Subject: [PATCH 03/20] chore(release): 7.38.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7892d647..a5b4e2a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.37.1-dev.6", + "version": "7.38.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From c8a099509a878f24a211f01ee0000f01020c4d6b Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Fri, 6 Feb 2026 23:16:54 -0500 Subject: [PATCH 04/20] 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 --- CHANGELOG.md | 6 + src/cliproxy/catalog-cache.ts | 233 ++++++++++++++++++++ src/cliproxy/index.ts | 13 ++ src/cliproxy/management-api-client.ts | 15 ++ src/cliproxy/management-api-types.ts | 33 +++ src/commands/cliproxy/catalog-subcommand.ts | 142 ++++++++++++ src/commands/cliproxy/help-subcommand.ts | 8 + src/commands/cliproxy/index.ts | 20 ++ src/web-server/routes/catalog-routes.ts | 26 +++ src/web-server/routes/index.ts | 2 + 10 files changed, 498 insertions(+) create mode 100644 src/cliproxy/catalog-cache.ts create mode 100644 src/commands/cliproxy/catalog-subcommand.ts create mode 100644 src/web-server/routes/catalog-routes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c6de20c..3672a883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [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) + ## [7.37.1](https://github.com/kaitranntt/ccs/compare/v7.37.0...v7.37.1) (2026-02-05) ### Bug Fixes diff --git a/src/cliproxy/catalog-cache.ts b/src/cliproxy/catalog-cache.ts new file mode 100644 index 00000000..3045bc6a --- /dev/null +++ b/src/cliproxy/catalog-cache.ts @@ -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; + fetchedAt: number; +} + +/** Channel name → CCS provider mapping */ +const CHANNEL_TO_PROVIDER: Record = { + antigravity: 'agy', + claude: 'claude', + gemini: 'gemini', + codex: 'codex', + qwen: 'qwen', + iflow: 'iflow', +}; + +/** CCS provider → channel name mapping (reverse) */ +export const PROVIDER_TO_CHANNEL: Record = 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): 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(); + 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(); + 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> { + const result: Partial> = {}; + const cached = getCachedCatalog(); + + // Get all known providers from both static and cache + const providers = new Set(); + 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; +} diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index a504a0f8..0975e3cb 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -176,9 +176,22 @@ export type { ManagementApiErrorCode, ClaudeKeyPatch, SyncStatus, + RemoteModelInfo, + RemoteThinkingSupport, } from './management-api-types'; 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) export type { SyncableProfile, SyncPreviewItem } from './sync'; export { diff --git a/src/cliproxy/management-api-client.ts b/src/cliproxy/management-api-client.ts index 52e89a1b..26f9a6ae 100644 --- a/src/cliproxy/management-api-client.ts +++ b/src/cliproxy/management-api-client.ts @@ -13,6 +13,8 @@ import type { ClaudeKey, GetClaudeKeysResponse, ClaudeKeyPatch, + RemoteModelInfo, + GetModelDefinitionsResponse, } from './management-api-types'; /** 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}`); } + /** + * Get model definitions for a channel from CLIProxyAPI. + * GET /v0/management/model-definitions/:channel + */ + async getModelDefinitions(channel: string): Promise { + const encodedChannel = encodeURIComponent(channel); + const response = await this.request( + 'GET', + `/v0/management/model-definitions/${encodedChannel}` + ); + return response.data?.models ?? []; + } + /** * Make an HTTP request to the Management API. */ diff --git a/src/cliproxy/management-api-types.ts b/src/cliproxy/management-api-types.ts index f0a9092a..aa8423e5 100644 --- a/src/cliproxy/management-api-types.ts +++ b/src/cliproxy/management-api-types.ts @@ -121,3 +121,36 @@ export interface SyncStatus { /** Remote CLIProxy URL */ 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[]; +} diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts new file mode 100644 index 00000000..8ff99159 --- /dev/null +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -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 | 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 = {}; + + 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 { + 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 { + 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 { + 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(''); +} diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 74e0a597..1c970232 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -30,6 +30,14 @@ export async function showHelp(): Promise { ['remove ', '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:', [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 176901c3..dc87d32d 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -23,6 +23,11 @@ import { handleCreate, handleRemove } from './variant-subcommand'; import { handleProxyStatus, handleStop } from './proxy-lifecycle-subcommand'; import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand'; import { showHelp } from './help-subcommand'; +import { + handleCatalogStatus, + handleCatalogRefresh, + handleCatalogReset, +} from './catalog-subcommand'; /** * Parse --backend flag from args @@ -166,6 +171,21 @@ export async function handleCliproxyCommand(args: string[]): Promise { 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 if (command === 'sync') { await handleSync(remainingArgs.slice(1)); diff --git a/src/web-server/routes/catalog-routes.ts b/src/web-server/routes/catalog-routes.ts new file mode 100644 index 00000000..4ff2d9af --- /dev/null +++ b/src/web-server/routes/catalog-routes.ts @@ -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; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 6152aafb..c488a9c9 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -24,6 +24,7 @@ import miscRoutes from './misc-routes'; import cliproxyServerRoutes from './proxy-routes'; import authRoutes from './auth-routes'; import persistRoutes from './persist-routes'; +import catalogRoutes from './catalog-routes'; // Create the main API router export const apiRoutes = Router(); @@ -53,6 +54,7 @@ apiRoutes.use('/cliproxy', variantRoutes); apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes); apiRoutes.use('/cliproxy', cliproxyStatsRoutes); apiRoutes.use('/cliproxy/sync', cliproxySyncRoutes); +apiRoutes.use('/cliproxy/catalog', catalogRoutes); apiRoutes.use('/cliproxy/openai-compat', providerRoutes); // ==================== WebSearch ==================== From 42d68dde111470599040e52feaddd3769af4d64a Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 6 Feb 2026 23:19:49 -0500 Subject: [PATCH 05/20] 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 --- .github/workflows/ai-review.yml | 6 +++--- scripts/code-reviewer.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 934f0bb2..a3e86981 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -70,10 +70,10 @@ jobs: # CLIProxy environment for model routing env: 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_MODEL: gemini-claude-opus-4-5-thinking - ANTHROPIC_DEFAULT_OPUS_MODEL: gemini-claude-opus-4-5-thinking + ANTHROPIC_MODEL: gemini-claude-opus-4-6-thinking + ANTHROPIC_DEFAULT_OPUS_MODEL: gemini-claude-opus-4-6-thinking ANTHROPIC_DEFAULT_SONNET_MODEL: gemini-claude-sonnet-4-5-thinking ANTHROPIC_DEFAULT_HAIKU_MODEL: gemini-claude-sonnet-4-5 DISABLE_BUG_COMMAND: "1" diff --git a/scripts/code-reviewer.ts b/scripts/code-reviewer.ts index fe81ccc8..31ffa710 100755 --- a/scripts/code-reviewer.ts +++ b/scripts/code-reviewer.ts @@ -26,7 +26,7 @@ interface PRContext { // Config const MAX_DIFF_LINES = 10000; 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 const CODE_REVIEWER_SYSTEM_PROMPT = `You are the CCS AGY Code Reviewer, an expert AI assistant reviewing pull requests for the CCS CLI project. From 02df67c12f0c995b4bafccb87d5d03c22f3ab4d6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 7 Feb 2026 04:21:38 +0000 Subject: [PATCH 06/20] chore(release): 7.38.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a5b4e2a8..922d76f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.38.0-dev.1", + "version": "7.38.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From b0dff7a148c795994c2e3f871db9f60dd1cc737a Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Fri, 6 Feb 2026 23:33:12 -0500 Subject: [PATCH 07/20] 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 --- src/ccs.ts | 5 +++++ src/copilot/copilot-executor.ts | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/ccs.ts b/src/ccs.ts index 1d447e48..5150ff00 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -14,6 +14,7 @@ import { } from './utils/websearch-manager'; import { getGlobalEnvConfig } from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector'; +import { getImageAnalysisHookEnv } from './utils/hooks'; import { fail, info } from './utils/ui'; // Import centralized error handling @@ -165,10 +166,12 @@ async function execClaudeWithProxy( const isWindows = process.platform === 'win32'; const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); const webSearchEnv = getWebSearchHookEnv(); + const imageAnalysisEnv = getImageAnalysisHookEnv(profileName); const env = { ...process.env, ...envVars, ...webSearchEnv, + ...imageAnalysisEnv, CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider }; @@ -619,6 +622,7 @@ async function main(): Promise { // Use --settings flag (backward compatible) const expandedSettingsPath = getSettingsPath(profileInfo.name); const webSearchEnv = getWebSearchHookEnv(); + const imageAnalysisEnv = getImageAnalysisHookEnv(profileInfo.name); // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles const globalEnvConfig = getGlobalEnvConfig(); const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; @@ -640,6 +644,7 @@ async function main(): Promise { ...globalEnv, ...settingsEnv, // Explicitly inject all settings env vars ...webSearchEnv, + ...imageAnalysisEnv, CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider }; execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs], envVars); diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index d2364aba..98908496 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -13,6 +13,8 @@ import { isDaemonRunning, startDaemon } from './copilot-daemon'; import { ensureCopilotApi } from './copilot-package-manager'; import { CopilotStatus } from './types'; import { fail, info, ok } from '../utils/ui'; +import { getWebSearchHookEnv } from '../utils/websearch-manager'; +import { getImageAnalysisHookEnv } from '../utils/hooks'; /** * Get full copilot status (auth + daemon). @@ -133,11 +135,16 @@ export async function executeCopilotProfile( const globalEnvConfig = getGlobalEnvConfig(); 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 = { ...process.env, ...globalEnv, ...copilotEnv, + ...webSearchEnv, + ...imageAnalysisEnv, + CCS_PROFILE_TYPE: 'copilot', }; console.log(info(`Using GitHub Copilot proxy (model: ${config.model})`)); From 6b7b20148d52239903c66241a1ccf6fee1d961dc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 7 Feb 2026 04:40:28 +0000 Subject: [PATCH 08/20] chore(release): 7.38.0-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 922d76f1..2b00fe32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.38.0-dev.2", + "version": "7.38.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From fe263707c7b5228567877da976d8ae1dd480da2d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 7 Feb 2026 04:55:27 +0000 Subject: [PATCH 09/20] chore(release): 7.38.0-dev.4 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2b00fe32..97e2818c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.38.0-dev.3", + "version": "7.38.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 215c00e9830e4a604231dd64c01154c91574ace9 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 7 Feb 2026 03:34:14 -0500 Subject: [PATCH 10/20] feat(cliproxy): delegate kiro/ghcp token refresh to CLIProxyAPIPlus (#488) CLIProxyAPIPlus already handles background token refresh for kiro (every 1 min via background_refresh.go) and other providers. CCS was incorrectly returning "not implemented" errors for these providers, causing noisy worker logs and preventing proper token expiry tracking. Changes: - Classify providers into CCS-managed (gemini), CLIProxy-delegated (codex, agy, kiro, ghcp, qwen, iflow), and not-implemented (claude) - Delegated providers return success with delegated flag instead of error - Skip refresh_token requirement for delegated providers in expiry checker - Filter delegated providers from background worker refresh loop Closes #487 --- .../auth/provider-refreshers/index.ts | 34 ++++++++++++++++++- src/cliproxy/auth/token-expiry-checker.ts | 11 ++++-- src/cliproxy/auth/token-manager.ts | 13 ++++--- src/cliproxy/auth/token-refresh-worker.ts | 7 ++-- 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts index 89df2ab2..3c642340 100644 --- a/src/cliproxy/auth/provider-refreshers/index.ts +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -2,7 +2,12 @@ * Provider Token Refreshers * * Exports refresh functions for each OAuth provider. - * Currently only Gemini is implemented; others return placeholder errors. + * + * Refresh responsibility: + * - CCS-managed: gemini (CCS refreshes tokens directly via Google OAuth) + * - CLIProxy-delegated: codex, agy, kiro, ghcp, qwen, iflow + * (CLIProxyAPIPlus handles refresh automatically in background) + * - Not implemented: claude */ import { CLIProxyProvider } from '../../types'; @@ -13,6 +18,29 @@ export interface ProviderRefreshResult { success: boolean; error?: string; expiresAt?: number; + /** True if refresh is delegated to CLIProxy (not handled by CCS) */ + delegated?: boolean; +} + +/** + * Providers where CLIProxyAPIPlus owns token refresh. + * CLIProxyAPIPlus runs background refresh automatically (e.g. kiro: every 1 min). + * CCS should not attempt to refresh these — just trust CLIProxy. + */ +const CLIPROXY_DELEGATED_REFRESH: CLIProxyProvider[] = [ + 'codex', + 'agy', + 'kiro', + 'ghcp', + 'qwen', + 'iflow', +]; + +/** + * Check if a provider's token refresh is delegated to CLIProxy + */ +export function isRefreshDelegated(provider: CLIProxyProvider): boolean { + return CLIPROXY_DELEGATED_REFRESH.includes(provider); } /** @@ -35,6 +63,10 @@ export async function refreshToken( case 'iflow': case 'kiro': case 'ghcp': + // CLIProxyAPIPlus handles refresh for these providers automatically. + // No action needed from CCS — report success with delegated flag. + return { success: true, delegated: true }; + case 'claude': return { success: false, diff --git a/src/cliproxy/auth/token-expiry-checker.ts b/src/cliproxy/auth/token-expiry-checker.ts index 066f1d9b..a8f13177 100644 --- a/src/cliproxy/auth/token-expiry-checker.ts +++ b/src/cliproxy/auth/token-expiry-checker.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import { CLIProxyProvider } from '../types'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { getProviderAccounts, getAccountTokenPath } from '../account-manager'; +import { isRefreshDelegated } from './provider-refreshers'; /** Preemptive refresh time: refresh tokens 45 minutes before expiry */ export const PREEMPTIVE_REFRESH_MINUTES = 45; @@ -74,9 +75,13 @@ export function getTokenExpiryInfo( const content = fs.readFileSync(tokenPath, 'utf-8'); const data: TokenData = JSON.parse(content); - // Validate refresh_token exists (required for refresh) - if (!data.refresh_token || typeof data.refresh_token !== 'string') { - return null; + // Validate refresh_token exists (required for CCS-managed refresh). + // CLIProxy-delegated providers may not expose refresh_token in token files — + // CLIProxyAPIPlus manages refresh internally, so skip this check for them. + if (!isRefreshDelegated(provider)) { + if (!data.refresh_token || typeof data.refresh_token !== 'string') { + return null; + } } // Calculate expiry time with validation diff --git a/src/cliproxy/auth/token-manager.ts b/src/cliproxy/auth/token-manager.ts index 41ef2092..bec57b39 100644 --- a/src/cliproxy/auth/token-manager.ts +++ b/src/cliproxy/auth/token-manager.ts @@ -317,6 +317,13 @@ export function displayAuthStatus(): void { * Ensure OAuth token is valid for provider, refreshing if expired or expiring soon. * This prevents UND_ERR_SOCKET errors caused by expired tokens during API calls. * + * Refresh responsibility: + * - gemini: CCS refreshes directly via Google OAuth + * - codex, agy, kiro, ghcp, qwen, iflow: CLIProxyAPIPlus handles refresh + * automatically in background (e.g. kiro refreshes every 1 min). + * CCS only checks if token file exists (authentication state). + * - claude: not yet implemented + * * @param provider The CLIProxy provider * @param verbose Log progress if true * @returns Object with valid status and whether refresh occurred @@ -325,14 +332,12 @@ export async function ensureTokenValid( provider: CLIProxyProvider, verbose = false ): Promise<{ valid: boolean; refreshed: boolean; error?: string }> { - // Currently only Gemini uses oauth_creds.json with expiry_date - // Other providers (agy, codex) use CLIProxyAPI's internal auth management if (provider === 'gemini') { const { ensureGeminiTokenValid } = await import('./gemini-token-refresh'); return ensureGeminiTokenValid(verbose); } - // For other providers, assume token is valid if authenticated - // CLIProxyAPI handles token refresh internally for antigravity/codex + // For CLIProxy-delegated providers, token refresh is handled by CLIProxyAPIPlus. + // CCS only verifies the token file exists (authentication state). return { valid: isAuthenticated(provider), refreshed: false }; } diff --git a/src/cliproxy/auth/token-refresh-worker.ts b/src/cliproxy/auth/token-refresh-worker.ts index 071e63d9..4e1c5304 100644 --- a/src/cliproxy/auth/token-refresh-worker.ts +++ b/src/cliproxy/auth/token-refresh-worker.ts @@ -7,7 +7,7 @@ import { CLIProxyProvider } from '../types'; import { getAllTokenExpiryInfo, TokenExpiryInfo } from './token-expiry-checker'; -import { refreshToken } from './provider-refreshers'; +import { refreshToken, isRefreshDelegated } from './provider-refreshers'; /** Worker configuration */ export interface TokenRefreshConfig { @@ -182,7 +182,10 @@ export class TokenRefreshWorker { try { const tokens = getAllTokenExpiryInfo(); - const tokensNeedingRefresh = tokens.filter((t) => t.needsRefresh); + // Skip CLIProxy-delegated providers — they handle their own refresh + const tokensNeedingRefresh = tokens.filter( + (t) => t.needsRefresh && !isRefreshDelegated(t.provider) + ); if (tokensNeedingRefresh.length === 0) { this.log('[OK] All tokens valid, no refresh needed'); From 984e0074689219a447b5fa393e8bc0d00f2d801d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 7 Feb 2026 08:35:20 +0000 Subject: [PATCH 11/20] chore(release): 7.39.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e7aa14bf..ee2be469 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.39.0", + "version": "7.39.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 545c8b9515d5afeda17976f418e22037cc9acef9 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 7 Feb 2026 04:09:30 -0500 Subject: [PATCH 12/20] fix(cliproxy): guard against empty upstream SSE responses in agy profile (#489) * fix(cliproxy): guard against empty upstream SSE responses in agy profile When CLIProxyAPIPlus drops unsigned thinking blocks during sub-agent execution, the response stream can contain no content_block_start or message_delta events. This causes Claude Code CLI to crash with "No assistant message found". Add empty response detection in the tool sanitization proxy's streaming handler. Both the pipe-through and SSE-processing paths now track whether meaningful content was received. If upstream sent data but no content blocks on a 200 OK, a synthetic minimal valid SSE response is injected to prevent the client crash and surface a clear error message. Closes #350 * fix(cliproxy): improve empty response detection and add tests Address code review findings: - Remove message_delta from content detection (lifecycle event, not content); only content_block_start indicates actual content - Add try-catch in end handlers to handle client disconnects gracefully - Add 3 integration tests: empty stream injection, normal stream passthrough, 4xx/5xx non-injection * fix(cliproxy): avoid duplicate message_start in synthetic response Track whether upstream already sent a message_start event. When injecting the synthetic error response, omit message_start if upstream already sent one, preventing duplicate events in the SSE stream. Addresses PR review feedback from ccs-reviewer[bot]. * test(cliproxy): add SSE processing path and real failure mode tests Address code review observations: - Add test exercising SSE processing path (sanitized tool names) for empty response detection, ensuring both code paths are covered - Add test mirroring real failure mode where upstream sends only message_start then ends abruptly (no message_delta/message_stop) - Document duplicate message_start assumption with inline comment --- src/cliproxy/tool-sanitization-proxy.ts | 103 ++++++++- ...ool-sanitization-proxy-integration.test.ts | 204 ++++++++++++++++++ 2 files changed, 299 insertions(+), 8 deletions(-) diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index 78ddecdb..76887a7b 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -439,10 +439,45 @@ export class ToolSanitizationProxy { (upstreamRes) => { clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers); - // If no changes were made, just pipe through + // Track whether upstream emitted any content (guards against empty proxy responses) + let hasReceivedContent = false; + let hasReceivedData = false; + let hasReceivedMessageStart = false; + const isSuccessResponse = + (upstreamRes.statusCode || 200) >= 200 && (upstreamRes.statusCode || 200) < 300; + + // If no changes were made, intercept to detect empty responses. + // In the real failure case (issue #350), upstream sends message_start but + // NOT message_delta/message_stop, so the synthetic response completes the + // stream. If upstream DID send message_stop, Claude Code treats the second + // synthetic block as additional content in the same conversation turn. if (!mapper.hasChanges()) { - upstreamRes.pipe(clientRes); - upstreamRes.on('end', () => resolve()); + upstreamRes.on('data', (chunk: Buffer) => { + hasReceivedData = true; + const chunkStr = chunk.toString('utf8'); + if (chunkStr.includes('"content_block_start"')) { + hasReceivedContent = true; + } + if (chunkStr.includes('"message_start"')) { + hasReceivedMessageStart = true; + } + clientRes.write(chunk); + }); + upstreamRes.on('end', () => { + try { + if (!hasReceivedContent && isSuccessResponse && hasReceivedData) { + this.writeLog( + 'warn', + '[tool-sanitization-proxy] Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.' + ); + clientRes.write(this.buildSyntheticErrorResponse(hasReceivedMessageStart)); + } + clientRes.end(); + } catch { + // Client may have disconnected — safe to ignore + } + resolve(); + }); upstreamRes.on('error', reject); return; } @@ -451,6 +486,7 @@ export class ToolSanitizationProxy { let buffer = ''; upstreamRes.on('data', (chunk: Buffer) => { + hasReceivedData = true; buffer += chunk.toString('utf8'); // Process complete SSE events @@ -460,18 +496,42 @@ export class ToolSanitizationProxy { for (const event of events) { if (!event.trim()) continue; + if (event.includes('"content_block_start"')) { + hasReceivedContent = true; + } + if (event.includes('"message_start"')) { + hasReceivedMessageStart = true; + } + const processedEvent = this.processSSEEvent(event, mapper); clientRes.write(processedEvent + '\n\n'); } }); upstreamRes.on('end', () => { - // Process any remaining buffer - if (buffer.trim()) { - const processedEvent = this.processSSEEvent(buffer, mapper); - clientRes.write(processedEvent + '\n\n'); + try { + // Process any remaining buffer + if (buffer.trim()) { + if (buffer.includes('"content_block_start"')) { + hasReceivedContent = true; + } + const processedEvent = this.processSSEEvent(buffer, mapper); + clientRes.write(processedEvent + '\n\n'); + } + + // Safety net: if upstream sent data but no content blocks, inject synthetic response + if (!hasReceivedContent && isSuccessResponse && hasReceivedData) { + this.writeLog( + 'warn', + '[tool-sanitization-proxy] Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.' + ); + clientRes.write(this.buildSyntheticErrorResponse(hasReceivedMessageStart)); + } + + clientRes.end(); + } catch { + // Client may have disconnected — safe to ignore } - clientRes.end(); resolve(); }); @@ -542,4 +602,31 @@ export class ToolSanitizationProxy { return processedLines.join('\n'); } + + /** + * Build a synthetic minimal SSE response when upstream returns empty content. + * Prevents Claude Code from crashing with "No assistant message found". + * Omits message_start if upstream already sent one to avoid duplicate events. + */ + private buildSyntheticErrorResponse(upstreamSentMessageStart = false): string { + const events: string[] = []; + + // Only include message_start if upstream didn't already send one + if (!upstreamSentMessageStart) { + const msgId = `msg_synthetic_${Date.now()}`; + events.push( + `event: message_start\ndata: {"type":"message_start","message":{"id":"${msgId}","type":"message","role":"assistant","content":[],"model":"unknown","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}` + ); + } + + events.push( + `event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + `event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"[Proxy Error] The upstream API returned an empty response. This typically occurs when the proxy drops unsigned thinking blocks during sub-agent execution. Please retry the request."}}`, + `event: content_block_stop\ndata: {"type":"content_block_stop","index":0}`, + `event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`, + `event: message_stop\ndata: {"type":"message_stop"}` + ); + + return events.join('\n\n') + '\n\n'; + } } diff --git a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts index 038fc13c..f0055f31 100644 --- a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts +++ b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts @@ -490,4 +490,208 @@ describe('ToolSanitizationProxy Integration', () => { } }); }); + + describe('Empty Response Safety Net (Issue #350)', () => { + it('injects synthetic response when streaming 200 has no content blocks', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + // Upstream sends message_start + message_delta + message_stop but NO content blocks + mockResponse = { + status: 200, + stream: true, + body: [ + 'event: message_start\n', + 'data: {"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"test","stop_reason":null}}\n\n', + 'event: message_delta\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":10,"output_tokens":0}}\n\n', + 'event: message_stop\n', + 'data: {"type":"message_stop"}\n\n', + ], + }; + + try { + const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + stream: true, + tools: [{ name: 'valid_tool' }], + }), + }); + + const text = await response.text(); + // Should contain the synthetic error message + expect(text).toContain('[Proxy Error]'); + expect(text).toContain('content_block_start'); + expect(text).toContain('content_block_delta'); + expect(text).toContain('content_block_stop'); + // Should NOT have duplicate message_start (upstream already sent one) + const messageStartCount = (text.match(/"type":"message_start"/g) || []).length; + expect(messageStartCount).toBe(1); // Only the upstream one, not a synthetic duplicate + } finally { + proxy.stop(); + } + }); + + it('does NOT inject synthetic response when content blocks exist', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + mockResponse = { + status: 200, + stream: true, + body: [ + 'event: message_start\n', + 'data: {"type":"message_start","message":{"id":"msg_ok","type":"message","role":"assistant","content":[],"model":"test"}}\n\n', + 'event: content_block_start\n', + 'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n', + 'event: content_block_delta\n', + 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}\n\n', + 'event: content_block_stop\n', + 'data: {"type":"content_block_stop","index":0}\n\n', + 'event: message_delta\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n', + 'event: message_stop\n', + 'data: {"type":"message_stop"}\n\n', + ], + }; + + try { + const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + stream: true, + tools: [{ name: 'valid_tool' }], + }), + }); + + const text = await response.text(); + expect(text).toContain('Hello'); + expect(text).not.toContain('[Proxy Error]'); + } finally { + proxy.stop(); + } + }); + + it('does NOT inject synthetic response for 4xx/5xx errors', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + // 429 rate limit with SSE body but no content blocks + mockResponse = { + status: 429, + stream: true, + body: [ + 'event: error\n', + 'data: {"type":"error","error":{"type":"rate_limit_error","message":"Too many requests"}}\n\n', + ], + }; + + try { + const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + stream: true, + tools: [{ name: 'valid_tool' }], + }), + }); + + expect(response.status).toBe(429); + const text = await response.text(); + expect(text).not.toContain('[Proxy Error]'); + expect(text).toContain('rate_limit_error'); + } finally { + proxy.stop(); + } + }); + + it('injects synthetic response via SSE processing path (with sanitized tool names)', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + // Upstream returns empty content — no content_block_start events + // Using sanitized tool name ('foo__bar__bar' → 'foo__bar') triggers the SSE processing path + mockResponse = { + status: 200, + stream: true, + body: [ + 'event: message_start\n', + 'data: {"type":"message_start","message":{"id":"msg_sse","type":"message","role":"assistant","content":[],"model":"test","stop_reason":null}}\n\n', + 'event: message_delta\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":10,"output_tokens":0}}\n\n', + 'event: message_stop\n', + 'data: {"type":"message_stop"}\n\n', + ], + }; + + try { + const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + stream: true, + tools: [{ name: 'foo__bar__bar' }], // Triggers sanitization → SSE processing path + }), + }); + + const text = await response.text(); + expect(text).toContain('[Proxy Error]'); + expect(text).toContain('content_block_start'); + const messageStartCount = (text.match(/"type":"message_start"/g) || []).length; + expect(messageStartCount).toBe(1); + } finally { + proxy.stop(); + } + }); + + it('injects synthetic response when upstream sends only message_start (real failure mode)', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + // Real failure: upstream sends message_start then abruptly ends — no message_delta or message_stop + mockResponse = { + status: 200, + stream: true, + body: [ + 'event: message_start\n', + 'data: {"type":"message_start","message":{"id":"msg_abrupt","type":"message","role":"assistant","content":[],"model":"test","stop_reason":null}}\n\n', + ], + }; + + try { + const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + stream: true, + tools: [{ name: 'valid_tool' }], + }), + }); + + const text = await response.text(); + expect(text).toContain('[Proxy Error]'); + expect(text).toContain('content_block_start'); + expect(text).toContain('message_delta'); + expect(text).toContain('message_stop'); + // Only 1 message_start — upstream's original + const messageStartCount = (text.match(/"type":"message_start"/g) || []).length; + expect(messageStartCount).toBe(1); + } finally { + proxy.stop(); + } + }); + }); }); From 2599136876db839dd07bad3f3cd08c8a595dd20c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 7 Feb 2026 09:10:32 +0000 Subject: [PATCH 13/20] chore(release): 7.39.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ee2be469..a2554615 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.39.0-dev.1", + "version": "7.39.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 43cd19a52b128363c7202590869fb4bd9349b859 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 7 Feb 2026 04:19:44 -0500 Subject: [PATCH 14/20] fix(cliproxy): disable 1M extended context for opus 4.6 (256k limit) (#492) * fix(cliproxy): disable 1M extended context for gemini-claude-opus-4-6-thinking Antigravity backend only supports 256k context window for this model, not 1M as previously declared. Set extendedContext: false with TODO comment for easy re-enable when backend adds support. Closes #490 * fix(cliproxy): strip [1m] suffix at runtime for models without extended context Users with existing [1m] in saved agy.settings.json will now have it automatically stripped at runtime when the model no longer supports extended context. Prevents misleading context window claims. --- .../config/extended-context-config.ts | 23 +++++++++++++++---- src/cliproxy/model-catalog.ts | 6 +++-- .../cliproxy/extended-context-config.test.ts | 19 +++++++++++++++ ui/src/components/ui/badge.tsx | 3 +-- ui/src/lib/model-catalogs.ts | 6 +++-- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/cliproxy/config/extended-context-config.ts b/src/cliproxy/config/extended-context-config.ts index 3021419c..e69a5592 100644 --- a/src/cliproxy/config/extended-context-config.ts +++ b/src/cliproxy/config/extended-context-config.ts @@ -85,7 +85,25 @@ export function applyExtendedContextConfig( const baseModel = envVars.ANTHROPIC_MODEL || ''; const cleanModelId = stripModelSuffixes(baseModel); + // Tier model env vars to apply/strip extended context suffix + const tierModels = [ + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + ] as const; + if (!shouldApplyExtendedContext(provider, cleanModelId, extendedContextOverride)) { + // Strip [1m] suffix from models that no longer support extended context + // (e.g., user had it enabled before backend dropped support) + if (envVars.ANTHROPIC_MODEL?.toLowerCase().endsWith('[1m]')) { + envVars.ANTHROPIC_MODEL = envVars.ANTHROPIC_MODEL.replace(/\[1m\]$/i, ''); + } + for (const tierVar of tierModels) { + const model = envVars[tierVar]; + if (model?.toLowerCase().endsWith('[1m]')) { + envVars[tierVar] = model.replace(/\[1m\]$/i, ''); + } + } return; } @@ -95,11 +113,6 @@ export function applyExtendedContextConfig( } // Apply to tier models if they support extended context - const tierModels = [ - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', - ] as const; for (const tierVar of tierModels) { const model = envVars[tierVar]; diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 82b1371e..3c0e02fe 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -78,7 +78,7 @@ export const MODEL_CATALOG: Partial> = { id: 'gemini-claude-opus-4-6-thinking', name: 'Claude Opus 4.6 Thinking', - description: 'Latest flagship, 1M context, extended thinking', + description: 'Latest flagship, extended thinking', thinking: { type: 'budget', min: 1024, @@ -86,7 +86,9 @@ export const MODEL_CATALOG: Partial> = zeroAllowed: true, dynamicAllowed: true, }, - extendedContext: true, + // TODO: Re-enable when Antigravity backend supports 1M context (currently 256k) + // extendedContext: true, + extendedContext: false, }, { id: 'gemini-claude-opus-4-5-thinking', diff --git a/tests/unit/cliproxy/extended-context-config.test.ts b/tests/unit/cliproxy/extended-context-config.test.ts index 76d11610..e7b2f722 100644 --- a/tests/unit/cliproxy/extended-context-config.test.ts +++ b/tests/unit/cliproxy/extended-context-config.test.ts @@ -151,4 +151,23 @@ describe('applyExtendedContextConfig', () => { applyExtendedContextConfig(env, 'gemini', undefined); expect(env.ANTHROPIC_MODEL).toBeUndefined(); }); + + it('strips [1m] suffix from models that no longer support extended context', () => { + // Simulates user who had [1m] in saved settings before support was removed + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gemini-claude-opus-4-6-thinking[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-6-thinking[1m]', + }; + applyExtendedContextConfig(env, 'agy', undefined); + expect(env.ANTHROPIC_MODEL).toBe('gemini-claude-opus-4-6-thinking'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-claude-opus-4-6-thinking'); + }); + + it('strips [1m] suffix when --no-1m is explicit even if model has it', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gemini-2.5-pro[1m]', + }; + applyExtendedContextConfig(env, 'gemini', false); + expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro'); + }); }); diff --git a/ui/src/components/ui/badge.tsx b/ui/src/components/ui/badge.tsx index 45811bfd..ab895f8e 100644 --- a/ui/src/components/ui/badge.tsx +++ b/ui/src/components/ui/badge.tsx @@ -23,8 +23,7 @@ const badgeVariants = cva( ); interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} + extends React.HTMLAttributes, VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { return
; diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index bbd70c1d..b9244d66 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -15,8 +15,10 @@ export const MODEL_CATALOGS: Record = { { id: 'gemini-claude-opus-4-6-thinking', name: 'Claude Opus 4.6 Thinking', - description: 'Latest flagship, 1M context, extended thinking', - extendedContext: true, + description: 'Latest flagship, extended thinking', + // TODO: Re-enable when Antigravity backend supports 1M context (currently 256k) + // extendedContext: true, + extendedContext: false, presetMapping: { default: 'gemini-claude-opus-4-6-thinking', opus: 'gemini-claude-opus-4-6-thinking', From 17029c0ca26fd7404b0977df2462fc5782249e1b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 7 Feb 2026 09:20:42 +0000 Subject: [PATCH 15/20] chore(release): 7.39.0-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a2554615..965f9e82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.39.0-dev.2", + "version": "7.39.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 152f5432ae26e42e07ea422b882298033ed66c7f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 7 Feb 2026 04:24:54 -0500 Subject: [PATCH 16/20] refactor(cliproxy): deduplicate message_delta/message_stop in synthetic SSE response Track hasReceivedMessageDelta and hasReceivedMessageStop in both streaming paths (pipe-through and SSE-processing). Conditionally omit these events from buildSyntheticErrorResponse() when upstream already sent them, preventing protocol violations. Closes #491 --- src/cliproxy/tool-sanitization-proxy.ts | 52 ++++++++++++++++--- ...ool-sanitization-proxy-integration.test.ts | 19 +++++-- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index 76887a7b..0ba8497c 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -443,6 +443,8 @@ export class ToolSanitizationProxy { let hasReceivedContent = false; let hasReceivedData = false; let hasReceivedMessageStart = false; + let hasReceivedMessageDelta = false; + let hasReceivedMessageStop = false; const isSuccessResponse = (upstreamRes.statusCode || 200) >= 200 && (upstreamRes.statusCode || 200) < 300; @@ -461,6 +463,12 @@ export class ToolSanitizationProxy { if (chunkStr.includes('"message_start"')) { hasReceivedMessageStart = true; } + if (chunkStr.includes('"message_delta"')) { + hasReceivedMessageDelta = true; + } + if (chunkStr.includes('"message_stop"')) { + hasReceivedMessageStop = true; + } clientRes.write(chunk); }); upstreamRes.on('end', () => { @@ -470,7 +478,13 @@ export class ToolSanitizationProxy { 'warn', '[tool-sanitization-proxy] Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.' ); - clientRes.write(this.buildSyntheticErrorResponse(hasReceivedMessageStart)); + clientRes.write( + this.buildSyntheticErrorResponse( + hasReceivedMessageStart, + hasReceivedMessageDelta, + hasReceivedMessageStop + ) + ); } clientRes.end(); } catch { @@ -502,6 +516,12 @@ export class ToolSanitizationProxy { if (event.includes('"message_start"')) { hasReceivedMessageStart = true; } + if (event.includes('"message_delta"')) { + hasReceivedMessageDelta = true; + } + if (event.includes('"message_stop"')) { + hasReceivedMessageStop = true; + } const processedEvent = this.processSSEEvent(event, mapper); clientRes.write(processedEvent + '\n\n'); @@ -525,7 +545,13 @@ export class ToolSanitizationProxy { 'warn', '[tool-sanitization-proxy] Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.' ); - clientRes.write(this.buildSyntheticErrorResponse(hasReceivedMessageStart)); + clientRes.write( + this.buildSyntheticErrorResponse( + hasReceivedMessageStart, + hasReceivedMessageDelta, + hasReceivedMessageStop + ) + ); } clientRes.end(); @@ -606,9 +632,13 @@ export class ToolSanitizationProxy { /** * Build a synthetic minimal SSE response when upstream returns empty content. * Prevents Claude Code from crashing with "No assistant message found". - * Omits message_start if upstream already sent one to avoid duplicate events. + * Omits lifecycle events that upstream already sent to avoid protocol violations. */ - private buildSyntheticErrorResponse(upstreamSentMessageStart = false): string { + private buildSyntheticErrorResponse( + upstreamSentMessageStart = false, + upstreamSentMessageDelta = false, + upstreamSentMessageStop = false + ): string { const events: string[] = []; // Only include message_start if upstream didn't already send one @@ -622,11 +652,19 @@ export class ToolSanitizationProxy { events.push( `event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, `event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"[Proxy Error] The upstream API returned an empty response. This typically occurs when the proxy drops unsigned thinking blocks during sub-agent execution. Please retry the request."}}`, - `event: content_block_stop\ndata: {"type":"content_block_stop","index":0}`, - `event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`, - `event: message_stop\ndata: {"type":"message_stop"}` + `event: content_block_stop\ndata: {"type":"content_block_stop","index":0}` ); + if (!upstreamSentMessageDelta) { + events.push( + `event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}` + ); + } + + if (!upstreamSentMessageStop) { + events.push(`event: message_stop\ndata: {"type":"message_stop"}`); + } + return events.join('\n\n') + '\n\n'; } } diff --git a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts index f0055f31..8bbfc5d0 100644 --- a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts +++ b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts @@ -528,9 +528,13 @@ describe('ToolSanitizationProxy Integration', () => { expect(text).toContain('content_block_start'); expect(text).toContain('content_block_delta'); expect(text).toContain('content_block_stop'); - // Should NOT have duplicate message_start (upstream already sent one) + // Should NOT have duplicate lifecycle events (upstream already sent them) const messageStartCount = (text.match(/"type":"message_start"/g) || []).length; - expect(messageStartCount).toBe(1); // Only the upstream one, not a synthetic duplicate + expect(messageStartCount).toBe(1); + const messageDeltaCount = (text.match(/"type":"message_delta"/g) || []).length; + expect(messageDeltaCount).toBe(1); + const messageStopCount = (text.match(/"type":"message_stop"/g) || []).length; + expect(messageStopCount).toBe(1); } finally { proxy.stop(); } @@ -648,8 +652,13 @@ describe('ToolSanitizationProxy Integration', () => { const text = await response.text(); expect(text).toContain('[Proxy Error]'); expect(text).toContain('content_block_start'); + // No duplicate lifecycle events const messageStartCount = (text.match(/"type":"message_start"/g) || []).length; expect(messageStartCount).toBe(1); + const messageDeltaCount = (text.match(/"type":"message_delta"/g) || []).length; + expect(messageDeltaCount).toBe(1); + const messageStopCount = (text.match(/"type":"message_stop"/g) || []).length; + expect(messageStopCount).toBe(1); } finally { proxy.stop(); } @@ -686,9 +695,13 @@ describe('ToolSanitizationProxy Integration', () => { expect(text).toContain('content_block_start'); expect(text).toContain('message_delta'); expect(text).toContain('message_stop'); - // Only 1 message_start — upstream's original + // Only 1 of each — upstream sent message_start, synthetic provides the rest const messageStartCount = (text.match(/"type":"message_start"/g) || []).length; expect(messageStartCount).toBe(1); + const messageDeltaCount = (text.match(/"type":"message_delta"/g) || []).length; + expect(messageDeltaCount).toBe(1); + const messageStopCount = (text.match(/"type":"message_stop"/g) || []).length; + expect(messageStopCount).toBe(1); } finally { proxy.stop(); } From 42a3eb150630b3a466b7ec9063a2256ecc38c60e Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 7 Feb 2026 04:30:42 -0500 Subject: [PATCH 17/20] refactor(cliproxy): check lifecycle events in remaining SSE buffer Add message_start/message_delta/message_stop detection to the remaining-buffer block in the SSE processing path for defensive completeness. Addresses code review feedback on PR #493. --- src/cliproxy/tool-sanitization-proxy.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index 0ba8497c..5276d8bd 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -535,6 +535,15 @@ export class ToolSanitizationProxy { if (buffer.includes('"content_block_start"')) { hasReceivedContent = true; } + if (buffer.includes('"message_start"')) { + hasReceivedMessageStart = true; + } + if (buffer.includes('"message_delta"')) { + hasReceivedMessageDelta = true; + } + if (buffer.includes('"message_stop"')) { + hasReceivedMessageStop = true; + } const processedEvent = this.processSSEEvent(buffer, mapper); clientRes.write(processedEvent + '\n\n'); } From 68f37efda90216865022e50ffda583b8a4c1da7c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 7 Feb 2026 09:39:23 +0000 Subject: [PATCH 18/20] chore(release): 7.39.0-dev.4 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 965f9e82..00930e3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.39.0-dev.3", + "version": "7.39.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 1b3440b93962e347797105b9bf92c2aaeb954a27 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 7 Feb 2026 05:40:24 -0500 Subject: [PATCH 19/20] refactor(cliproxy): extract SSE lifecycle tracker and add backpressure handling Consolidate 5 duplicate boolean tracking variables into a lifecycle object with an update() method, reducing event detection from 4 locations to centralized calls. Add pause/drain/resume backpressure to the passthrough path to prevent unbounded memory buffering with slow clients. --- src/cliproxy/tool-sanitization-proxy.ts | 86 ++++++++++--------------- 1 file changed, 33 insertions(+), 53 deletions(-) diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index 5276d8bd..87c3b811 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -439,12 +439,21 @@ export class ToolSanitizationProxy { (upstreamRes) => { clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers); - // Track whether upstream emitted any content (guards against empty proxy responses) - let hasReceivedContent = false; - let hasReceivedData = false; - let hasReceivedMessageStart = false; - let hasReceivedMessageDelta = false; - let hasReceivedMessageStop = false; + // Track upstream SSE lifecycle events (guards against empty proxy responses) + const lifecycle = { + hasContent: false, + hasData: false, + hasMessageStart: false, + hasMessageDelta: false, + hasMessageStop: false, + /** Scan text for SSE lifecycle events and update tracking flags */ + update(text: string) { + if (text.includes('"content_block_start"')) this.hasContent = true; + if (text.includes('"message_start"')) this.hasMessageStart = true; + if (text.includes('"message_delta"')) this.hasMessageDelta = true; + if (text.includes('"message_stop"')) this.hasMessageStop = true; + }, + }; const isSuccessResponse = (upstreamRes.statusCode || 200) >= 200 && (upstreamRes.statusCode || 200) < 300; @@ -455,34 +464,27 @@ export class ToolSanitizationProxy { // synthetic block as additional content in the same conversation turn. if (!mapper.hasChanges()) { upstreamRes.on('data', (chunk: Buffer) => { - hasReceivedData = true; - const chunkStr = chunk.toString('utf8'); - if (chunkStr.includes('"content_block_start"')) { - hasReceivedContent = true; + lifecycle.hasData = true; + lifecycle.update(chunk.toString('utf8')); + // Respect backpressure: pause upstream if client can't keep up + const canContinue = clientRes.write(chunk); + if (!canContinue) { + upstreamRes.pause(); + clientRes.once('drain', () => upstreamRes.resume()); } - if (chunkStr.includes('"message_start"')) { - hasReceivedMessageStart = true; - } - if (chunkStr.includes('"message_delta"')) { - hasReceivedMessageDelta = true; - } - if (chunkStr.includes('"message_stop"')) { - hasReceivedMessageStop = true; - } - clientRes.write(chunk); }); upstreamRes.on('end', () => { try { - if (!hasReceivedContent && isSuccessResponse && hasReceivedData) { + if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) { this.writeLog( 'warn', '[tool-sanitization-proxy] Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.' ); clientRes.write( this.buildSyntheticErrorResponse( - hasReceivedMessageStart, - hasReceivedMessageDelta, - hasReceivedMessageStop + lifecycle.hasMessageStart, + lifecycle.hasMessageDelta, + lifecycle.hasMessageStop ) ); } @@ -500,7 +502,7 @@ export class ToolSanitizationProxy { let buffer = ''; upstreamRes.on('data', (chunk: Buffer) => { - hasReceivedData = true; + lifecycle.hasData = true; buffer += chunk.toString('utf8'); // Process complete SSE events @@ -510,18 +512,7 @@ export class ToolSanitizationProxy { for (const event of events) { if (!event.trim()) continue; - if (event.includes('"content_block_start"')) { - hasReceivedContent = true; - } - if (event.includes('"message_start"')) { - hasReceivedMessageStart = true; - } - if (event.includes('"message_delta"')) { - hasReceivedMessageDelta = true; - } - if (event.includes('"message_stop"')) { - hasReceivedMessageStop = true; - } + lifecycle.update(event); const processedEvent = this.processSSEEvent(event, mapper); clientRes.write(processedEvent + '\n\n'); @@ -532,33 +523,22 @@ export class ToolSanitizationProxy { try { // Process any remaining buffer if (buffer.trim()) { - if (buffer.includes('"content_block_start"')) { - hasReceivedContent = true; - } - if (buffer.includes('"message_start"')) { - hasReceivedMessageStart = true; - } - if (buffer.includes('"message_delta"')) { - hasReceivedMessageDelta = true; - } - if (buffer.includes('"message_stop"')) { - hasReceivedMessageStop = true; - } + lifecycle.update(buffer); const processedEvent = this.processSSEEvent(buffer, mapper); clientRes.write(processedEvent + '\n\n'); } // Safety net: if upstream sent data but no content blocks, inject synthetic response - if (!hasReceivedContent && isSuccessResponse && hasReceivedData) { + if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) { this.writeLog( 'warn', '[tool-sanitization-proxy] Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.' ); clientRes.write( this.buildSyntheticErrorResponse( - hasReceivedMessageStart, - hasReceivedMessageDelta, - hasReceivedMessageStop + lifecycle.hasMessageStart, + lifecycle.hasMessageDelta, + lifecycle.hasMessageStop ) ); } From cff5133e144a0c0f9af22cf78e1712e98d22de14 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 7 Feb 2026 10:41:42 +0000 Subject: [PATCH 20/20] chore(release): 7.39.0-dev.5 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 00930e3f..1f0dd1ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.39.0-dev.4", + "version": "7.39.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",