mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 10:18:14 +00:00
Merge pull request #496 from kaitranntt/dev
release: merge dev to main for v7.39.0 production release
This commit is contained in:
12 files changed
+441
-30
No files matched your search
+1
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user