Merge pull request #496 from kaitranntt/dev

release: merge dev to main for v7.39.0 production release
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-02-07 05:45:35 -05:00
commit 57f6bab9e9
12 files changed
+441 -30

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.39.0",
"version": "7.39.0-dev.5",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
+33 -1
View File
@@ -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,
+8 -3
View File
@@ -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
+9 -4
View File
@@ -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 };
}
+5 -2
View File
@@ -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');
+18 -5
View File
@@ -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];
+4 -2
View File
@@ -78,7 +78,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
{
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<Record<CLIProxyProvider, ProviderCatalog>> =
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',
+122 -8
View File
@@ -439,10 +439,61 @@ export class ToolSanitizationProxy {
(upstreamRes) => {
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
// If no changes were made, just pipe through
// 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;
// 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) => {
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());
}
});
upstreamRes.on('end', () => {
try {
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(
lifecycle.hasMessageStart,
lifecycle.hasMessageDelta,
lifecycle.hasMessageStop
)
);
}
clientRes.end();
} catch {
// Client may have disconnected — safe to ignore
}
resolve();
});
upstreamRes.on('error', reject);
return;
}
@@ -451,6 +502,7 @@ export class ToolSanitizationProxy {
let buffer = '';
upstreamRes.on('data', (chunk: Buffer) => {
lifecycle.hasData = true;
buffer += chunk.toString('utf8');
// Process complete SSE events
@@ -460,18 +512,41 @@ export class ToolSanitizationProxy {
for (const event of events) {
if (!event.trim()) continue;
lifecycle.update(event);
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()) {
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 (!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(
lifecycle.hasMessageStart,
lifecycle.hasMessageDelta,
lifecycle.hasMessageStop
)
);
}
clientRes.end();
} catch {
// Client may have disconnected — safe to ignore
}
clientRes.end();
resolve();
});
@@ -542,4 +617,43 @@ 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 lifecycle events that upstream already sent to avoid protocol violations.
*/
private buildSyntheticErrorResponse(
upstreamSentMessageStart = false,
upstreamSentMessageDelta = false,
upstreamSentMessageStop = 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}`
);
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';
}
}
@@ -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');
});
});
@@ -490,4 +490,221 @@ 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 lifecycle events (upstream already sent them)
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();
}
});
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');
// 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();
}
});
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 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();
}
});
});
});
+1 -2
View File
@@ -23,8 +23,7 @@ const badgeVariants = cva(
);
interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
+4 -2
View File
@@ -15,8 +15,10 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
{
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',