From 5e11bba5149d061879002ad88ca91f404c09522a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Mar 2026 16:36:14 -0400 Subject: [PATCH] test(cliproxy): cover Claude long-context parity flows - add regression coverage for runtime, api create flags, and help text - add provider-editor tests for multi-key [1m] state and preset preservation Refs #789 --- .../cliproxy/extended-context-config.test.ts | 30 ++++ tests/unit/commands/api-command-args.test.ts | 76 +++++++++ .../unit/commands/help-command-parity.test.ts | 38 +++++ .../model-config-section.test.tsx | 7 +- .../use-provider-editor.test.tsx | 156 ++++++++++++++++++ 5 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx diff --git a/tests/unit/cliproxy/extended-context-config.test.ts b/tests/unit/cliproxy/extended-context-config.test.ts index 17e93a10..0de42ab0 100644 --- a/tests/unit/cliproxy/extended-context-config.test.ts +++ b/tests/unit/cliproxy/extended-context-config.test.ts @@ -136,6 +136,22 @@ describe('applyExtendedContextConfig', () => { expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-5-20251101[1m]'); }); + it('applies explicit Claude [1m] per tier and leaves unsupported Haiku plain', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-haiku-4-5-20251001', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + applyExtendedContextConfig(env, 'claude', true); + + expect(env.ANTHROPIC_MODEL).toBe('claude-haiku-4-5-20251001'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-20251101[1m]'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-20250929[1m]'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); + }); + it('strips existing suffixes before catalog lookup', () => { const env: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'gemini-2.5-pro(high)', @@ -169,6 +185,20 @@ describe('applyExtendedContextConfig', () => { expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); }); + it('strips stale Claude [1m] from unsupported mappings while preserving compatible ones', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-haiku-4-5-20251001[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929[1m]', + }; + + applyExtendedContextConfig(env, 'claude', true); + + expect(env.ANTHROPIC_MODEL).toBe('claude-haiku-4-5-20251001'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-20251101[1m]'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-20250929[1m]'); + }); + 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]', diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index 94032699..19727ec2 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from 'bun:test'; import { + applyClaudeExtendedContextPreference, collectUnexpectedApiArgs, + hasClaudeModelMapping, + hasExplicitClaudeExtendedContext, parseApiCommandArgs, } from '../../../src/commands/api-command/shared'; @@ -132,6 +135,27 @@ describe('api-command arg parser', () => { expect(parsed.model).toBe('-preview'); expect(parsed.errors).toEqual([]); }); + + test('parses --1m for explicit Claude long context', () => { + const parsed = parseApiCommandArgs(['my-api', '--1m']); + + expect(parsed.extendedContext).toBe(true); + expect(parsed.errors).toEqual([]); + }); + + test('parses --no-1m for explicit standard-context preference', () => { + const parsed = parseApiCommandArgs(['my-api', '--no-1m']); + + expect(parsed.extendedContext).toBe(false); + expect(parsed.errors).toEqual([]); + }); + + test('rejects conflicting --1m and --no-1m flags', () => { + const parsed = parseApiCommandArgs(['my-api', '--1m', '--no-1m']); + + expect(parsed.extendedContext).toBeUndefined(); + expect(parsed.errors).toEqual(['Cannot combine --1m and --no-1m']); + }); }); describe('collectUnexpectedApiArgs', () => { @@ -155,3 +179,55 @@ describe('collectUnexpectedApiArgs', () => { expect(parsed.errors).toEqual(['Unknown option: --bogus', 'Unexpected arguments: value']); }); }); + +describe('Claude long-context mapping helpers', () => { + test('detects Claude mappings and explicit [1m] state across all tiers', () => { + const models = { + default: 'gpt-5.4', + opus: 'claude-opus-4-6[1m]', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4-5-20251001', + }; + + expect(hasClaudeModelMapping(models)).toBe(true); + expect(hasExplicitClaudeExtendedContext(models)).toBe(true); + }); + + test('applies [1m] only to compatible Claude mappings and leaves Haiku plain', () => { + const models = applyClaudeExtendedContextPreference( + { + default: 'claude-sonnet-4-6', + opus: 'claude-opus-4-6', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4-5-20251001', + }, + true + ); + + expect(models).toEqual({ + default: 'claude-sonnet-4-6[1m]', + opus: 'claude-opus-4-6[1m]', + sonnet: 'claude-sonnet-4-6[1m]', + haiku: 'claude-haiku-4-5-20251001', + }); + }); + + test('strips [1m] from Claude mappings when standard context is requested', () => { + const models = applyClaudeExtendedContextPreference( + { + default: 'claude-sonnet-4-6[1m]', + opus: 'claude-opus-4-6[1m]', + sonnet: 'claude-sonnet-4-6[1m]', + haiku: 'claude-haiku-4-5-20251001', + }, + false + ); + + expect(models).toEqual({ + default: 'claude-sonnet-4-6', + opus: 'claude-opus-4-6', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4-5-20251001', + }); + }); +}); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index e7afd2ce..451ea81a 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test'; +import { showApiCommandHelp } from '../../../src/commands/api-command/help'; import { handleHelpCommand } from '../../../src/commands/help-command'; function stripAnsi(input: string): string { @@ -103,4 +104,41 @@ describe('help command parity', () => { true ); }); + + test('root help explains Claude [1m] as an explicit CCS suffix with upstream limits', async () => { + const lines: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map((arg) => String(arg)).join(' ')); + }; + + await handleHelpCommand(); + + const rendered = stripAnsi(lines.join('\n')); + expect(rendered.includes('Claude models: plain by default, opt-in with --1m or saved [1m]')).toBe( + true + ); + expect(rendered.includes('CCS only controls the saved [1m] suffix.')).toBe(true); + expect(rendered.includes('return 429 extra-usage errors for long-context requests')).toBe( + true + ); + }); + + test('api help documents create-time Claude [1m] flags and entitlement warning', async () => { + const lines: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map((arg) => String(arg)).join(' ')); + }; + + await showApiCommandHelp(); + + const rendered = stripAnsi(lines.join('\n')); + expect(rendered.includes('--1m / --no-1m')).toBe(true); + expect(rendered.includes('ccs api create --preset anthropic --1m')).toBe(true); + expect(rendered.includes('Plain Claude model IDs stay on standard context by default.')).toBe( + true + ); + expect(rendered.includes('some accounts can still return 429 for long-context requests')).toBe( + true + ); + }); }); diff --git a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx index 9fb5d916..ea31f449 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx @@ -26,6 +26,7 @@ describe('ModelConfigSection presets', () => { haikuModel="gpt-5-codex-mini" providerModels={[]} provider="codex" + onExtendedContextToggle={vi.fn()} onApplyPreset={onApplyPreset} onUpdateEnvValue={vi.fn()} onOpenCustomPreset={vi.fn()} @@ -55,10 +56,11 @@ describe('ModelConfigSection presets', () => { savedPresets={[]} currentModel="claude-opus-4-6-thinking" opusModel="claude-opus-4-6-thinking" - sonnetModel="claude-sonnet-4-6" - haikuModel="claude-sonnet-4-6" + sonnetModel="gemini-3-pro-preview" + haikuModel="gemini-3-flash-preview" providerModels={[]} provider="agy" + onExtendedContextToggle={vi.fn()} onApplyPreset={vi.fn()} onUpdateEnvValue={vi.fn()} onOpenCustomPreset={vi.fn()} @@ -69,5 +71,6 @@ describe('ModelConfigSection presets', () => { expect(screen.queryByText('Free Tier')).not.toBeInTheDocument(); expect(screen.queryByText('Paid Tier')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Claude Opus 4.6 Thinking' })).toBeInTheDocument(); + expect(screen.getByTestId('extended-context-toggle')).toBeInTheDocument(); }); }); diff --git a/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx new file mode 100644 index 00000000..31391e58 --- /dev/null +++ b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx @@ -0,0 +1,156 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AllProviders } from '../../../../setup/test-utils'; +import { useProviderEditor } from '@/components/cliproxy/provider-editor/use-provider-editor'; + +function createJsonResponse(body: Record, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useProviderEditor', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('derives extended-context state from all Anthropic model env keys', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/api/settings/claude/raw')) { + return Promise.resolve( + createJsonResponse({ + profile: 'claude', + settings: { + env: { + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }, + }, + mtime: 1, + path: '~/.ccs/profiles/claude/settings.json', + }) + ); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useProviderEditor('claude'), { wrapper }); + + await waitFor(() => expect(result.current.currentModel).toBe('claude-sonnet-4-6')); + expect(result.current.extendedContextEnabled).toBe(true); + }); + + it('applies [1m] across compatible Claude mappings and leaves Haiku plain', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/api/settings/claude/raw')) { + return Promise.resolve( + createJsonResponse({ + profile: 'claude', + settings: { + env: { + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }, + }, + mtime: 1, + path: '~/.ccs/profiles/claude/settings.json', + }) + ); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useProviderEditor('claude'), { wrapper }); + + await waitFor(() => expect(result.current.currentModel).toBe('claude-sonnet-4-6')); + + act(() => { + result.current.toggleExtendedContext(true); + }); + + const nextSettings = JSON.parse(result.current.rawJsonContent); + expect(nextSettings.env).toMatchObject({ + ANTHROPIC_MODEL: 'claude-sonnet-4-6[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6[1m]', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }); + }); + + it('preserves explicit long-context intent when preset-style updates replace mappings', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/api/settings/claude/raw')) { + return Promise.resolve( + createJsonResponse({ + profile: 'claude', + settings: { + env: { + ANTHROPIC_MODEL: 'claude-sonnet-4-6[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6[1m]', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }, + }, + mtime: 1, + path: '~/.ccs/profiles/claude/settings.json', + }) + ); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useProviderEditor('claude'), { wrapper }); + + await waitFor(() => expect(result.current.extendedContextEnabled).toBe(true)); + + act(() => { + result.current.updateEnvValues({ + ANTHROPIC_MODEL: 'claude-opus-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }); + }); + + const nextSettings = JSON.parse(result.current.rawJsonContent); + expect(nextSettings.env).toMatchObject({ + ANTHROPIC_MODEL: 'claude-opus-4-6[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6[1m]', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }); + }); +});