diff --git a/package.json b/package.json index e7aa14bf..1f0dd1ef 100644 --- a/package.json +++ b/package.json @@ -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", 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'); 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/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index 78ddecdb..87c3b811 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -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'; + } } 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/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts index 038fc13c..8bbfc5d0 100644 --- a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts +++ b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts @@ -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(); + } + }); + }); }); 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',