From 4e4c949967f3aa112795e1bf0cc3ee8ebdbd5b3b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 18 Jul 2026 19:04:07 -0400 Subject: [PATCH] fix(cliproxy): harden xAI provider contracts --- .../__tests__/model-catalog-compat.test.ts | 12 ++++++++ src/cliproxy/__tests__/model-catalog.test.js | 17 +++++++++++ .../oauth-process-error-parser.test.ts | 20 +++++++++++++ src/cliproxy/auth/oauth-process.ts | 21 +++++++++++++- src/cliproxy/model-catalog.ts | 5 +--- src/cliproxy/services/catalog-cache.ts | 8 +++-- ui/src/lib/model-catalogs.ts | 13 +++++---- .../unit/ui/lib/model-catalogs-xai.test.ts | 29 ++++++++++++++++++- 8 files changed, 110 insertions(+), 15 deletions(-) diff --git a/src/cliproxy/__tests__/model-catalog-compat.test.ts b/src/cliproxy/__tests__/model-catalog-compat.test.ts index d7e4dfda..ff8bce9f 100644 --- a/src/cliproxy/__tests__/model-catalog-compat.test.ts +++ b/src/cliproxy/__tests__/model-catalog-compat.test.ts @@ -63,6 +63,18 @@ describe('model-catalog compatibility lookups', () => { }); }); + it('does not export extended-context capability from live xAI context length', () => { + const catalog = mergeCatalog('xai', [ + { + id: 'grok-4.3', + display_name: 'Grok 4.3', + context_length: 2_000_000, + }, + ]); + + expect(catalog?.models[0]?.extendedContext).toBeUndefined(); + }); + it('does not re-add stale static-only models when live catalog data is present', () => { const catalog = mergeCatalog('gemini', [ { diff --git a/src/cliproxy/__tests__/model-catalog.test.js b/src/cliproxy/__tests__/model-catalog.test.js index 29ac5d5d..4377ed32 100644 --- a/src/cliproxy/__tests__/model-catalog.test.js +++ b/src/cliproxy/__tests__/model-catalog.test.js @@ -87,6 +87,23 @@ describe('Model Catalog', () => { 'grok-composer-2.5-fast', ]); }); + + it('does not expose Claude [1m] suffix support for xAI model IDs', () => { + const { MODEL_CATALOG, supportsExtendedContext } = modelCatalog; + + for (const model of MODEL_CATALOG.xai.models) { + assert.notStrictEqual( + model.extendedContext, + true, + `${model.id} must not advertise extended-context metadata` + ); + assert.strictEqual( + supportsExtendedContext('xai', model.id), + false, + `${model.id} must not advertise [1m] suffix support` + ); + } + }); }); describe('AGY models', () => { diff --git a/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts b/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts index 9ae13c83..f89398c3 100644 --- a/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts @@ -7,6 +7,7 @@ import { extractDeviceCodePrompt, getExpectedLocalCallback, getKiroBuilderIdSelectionInput, + resolveDeviceCodeVerificationUrl, validateManualCallbackUrl, } from '../oauth-process'; @@ -24,6 +25,18 @@ Then enter this code: ABCD-1234 verificationUrl: 'https://accounts.x.ai/oauth2/device?user_code=ABCD-1234', }); }); + + it('preserves a parsed xAI verification URL exactly', () => { + const parsedUrl = 'https://accounts.x.ai/oauth2/device?user_code=ABCD-1234&source=cliproxy'; + + expect(resolveDeviceCodeVerificationUrl('xai', 'ABCD-1234', parsedUrl)).toBe(parsedUrl); + }); + + it('uses an xAI-specific device URL when upstream output omits the URL', () => { + expect(resolveDeviceCodeVerificationUrl('xai', 'ABCD-1234', null)).toBe( + 'https://accounts.x.ai/oauth2/device?user_code=ABCD-1234' + ); + }); }); describe('oauth-process stderr parsing', () => { @@ -49,6 +62,13 @@ describe('oauth-process stderr parsing', () => { expect(extractLikelyAuthFailureFromStderr('ghcp', stderr)).toBe('state mismatch'); }); + it('extracts the current upstream xAI authentication failure format', () => { + const stderr = + 'time="2026-07-18T00:00:00Z" level=error msg="xAI authentication failed: xai device code expired"'; + + expect(extractLikelyAuthFailureFromStderr('xai', stderr)).toBe('xai device code expired'); + }); + it('caps extracted message length to prevent noisy broadcasts', () => { const longSuffix = 'x'.repeat(400); const stderr = `level=error msg="Authentication failed: ${longSuffix}"`; diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index f94b77f7..edb3023b 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -143,6 +143,20 @@ export function extractDeviceCodePrompt(output: string): { }; } +export function resolveDeviceCodeVerificationUrl( + provider: CLIProxyProvider, + userCode: string, + parsedUrl: string | null +): string { + if (parsedUrl) { + return parsedUrl; + } + if (provider === 'xai') { + return `https://accounts.x.ai/oauth2/device?user_code=${encodeURIComponent(userCode)}`; + } + return 'https://github.com/login/device'; +} + export function isLoopbackHost(hostname: string): boolean { const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase(); return ( @@ -415,7 +429,11 @@ async function handleStdout( state.deviceCodeDisplayed = true; log(`Parsed device code: ${state.userCode}`); - const verificationUrl = devicePrompt.verificationUrl || 'https://github.com/login/device'; + const verificationUrl = resolveDeviceCodeVerificationUrl( + options.provider, + state.userCode, + devicePrompt.verificationUrl + ); // Emit device code event for WebSocket broadcast to UI const deviceCodePrompt: DeviceCodePrompt = { @@ -525,6 +543,7 @@ export function extractLikelyAuthFailureFromLogs( }); const providerPatterns: Partial> = { + xai: [/xai authentication failed:\s*(.+)/i], ghcp: [ /github copilot authentication failed:\s*(.+)/i, /failed to verify copilot access[^:]*:\s*(.+)/i, diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index f698b590..f300fe13 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -325,7 +325,6 @@ export const MODEL_CATALOG: Partial> = id: 'grok-4.3', name: 'Grok 4.3', description: 'General-purpose Grok model with a one-million-token context window', - extendedContext: true, thinking: { type: 'levels', levels: ['none', 'low', 'medium', 'high'], @@ -336,19 +335,16 @@ export const MODEL_CATALOG: Partial> = id: 'grok-4.20-0309-reasoning', name: 'Grok 4.20 0309 Reasoning', description: 'Reasoning model with a two-million-token context window', - extendedContext: true, }, { id: 'grok-4.20-0309-non-reasoning', name: 'Grok 4.20 0309 Non Reasoning', description: 'Non-reasoning model with a two-million-token context window', - extendedContext: true, }, { id: 'grok-4.20-multi-agent-0309', name: 'Grok 4.20 Multi Agent 0309', description: 'Multi-agent model with a two-million-token context window', - extendedContext: true, thinking: { type: 'levels', levels: ['low', 'medium', 'high'] }, }, { @@ -765,6 +761,7 @@ export function supportsThinking(provider: CLIProxyProvider, modelId: string): b * Returns true if model has extendedContext: true in catalog. */ export function supportsExtendedContext(provider: CLIProxyProvider, modelId: string): boolean { + if (provider === 'xai') return false; const model = findModel(provider, modelId); return model?.extendedContext === true; } diff --git a/src/cliproxy/services/catalog-cache.ts b/src/cliproxy/services/catalog-cache.ts index 429a41e0..0c6511cd 100644 --- a/src/cliproxy/services/catalog-cache.ts +++ b/src/cliproxy/services/catalog-cache.ts @@ -215,13 +215,15 @@ function mapThinking(remote?: RemoteThinkingSupport): ThinkingSupport | undefine } /** Map RemoteModelInfo to ModelEntry */ -function mapRemoteToModelEntry(remote: RemoteModelInfo): ModelEntry { +function mapRemoteToModelEntry(provider: CLIProxyProvider, 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) { + // xAI context length is inherent to the model ID; its API does not accept + // Claude's [1m] model suffix. + if (provider !== 'xai' && remote.context_length && remote.context_length >= 1_000_000) { entry.extendedContext = true; } const thinking = mapThinking(remote.thinking); @@ -264,7 +266,7 @@ export function mergeCatalog( const mergedModels: ModelEntry[] = []; for (const remote of filteredRemoteModels) { - const remoteEntry = mapRemoteToModelEntry(remote); + const remoteEntry = mapRemoteToModelEntry(provider, remote); const staticEntry = staticMap.get(remote.id.toLowerCase()); if (staticEntry) { const mergedThinking = remoteEntry.thinking diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 97df35d4..34c82ca4 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -407,25 +407,21 @@ export const MODEL_CATALOGS: Record = { id: 'grok-4.3', name: 'Grok 4.3', description: 'General-purpose Grok model with a one-million-token context window', - extendedContext: true, }, { id: 'grok-4.20-0309-reasoning', name: 'Grok 4.20 0309 Reasoning', description: 'Reasoning model with a two-million-token context window', - extendedContext: true, }, { id: 'grok-4.20-0309-non-reasoning', name: 'Grok 4.20 0309 Non Reasoning', description: 'Non-reasoning model with a two-million-token context window', - extendedContext: true, }, { id: 'grok-4.20-multi-agent-0309', name: 'Grok 4.20 Multi Agent 0309', description: 'Multi-agent model with a two-million-token context window', - extendedContext: true, }, { id: 'grok-3-mini', @@ -1124,7 +1120,8 @@ export function buildUiCatalog( provider: string, liveCatalog: ProviderCatalog | undefined ): ProviderCatalog | undefined { - const staticCatalog = MODEL_CATALOGS[provider.toLowerCase()]; + const normalizedProvider = provider.toLowerCase(); + const staticCatalog = MODEL_CATALOGS[normalizedProvider]; if (!liveCatalog || liveCatalog.models.length === 0) { return staticCatalog; } @@ -1145,7 +1142,10 @@ export function buildUiCatalog( issueUrl: staticModel?.issueUrl, deprecated: staticModel?.deprecated, deprecationReason: staticModel?.deprecationReason, - extendedContext: model.extendedContext ?? staticModel?.extendedContext, + extendedContext: + normalizedProvider === 'xai' + ? undefined + : (model.extendedContext ?? staticModel?.extendedContext), presetMapping: staticModel?.presetMapping, }; }); @@ -1344,5 +1344,6 @@ export function supportsExtendedContext( modelId: string, catalogOverride?: ProviderCatalog ): boolean { + if (provider.toLowerCase() === 'xai') return false; return findCatalogModel(provider, modelId, catalogOverride)?.extendedContext === true; } diff --git a/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts b/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts index 64a4062b..bb88a8c5 100644 --- a/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts +++ b/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { MODEL_CATALOGS } from '@/lib/model-catalogs'; +import { buildUiCatalog, MODEL_CATALOGS, supportsExtendedContext } from '@/lib/model-catalogs'; describe('xAI model catalog defaults', () => { it('mirrors the CLIProxyAPI text catalog and default tier routing', () => { @@ -27,4 +27,31 @@ describe('xAI model catalog defaults', () => { haiku: 'grok-composer-2.5-fast', }); }); + + it('does not expose Claude [1m] suffix support for xAI model IDs', () => { + const catalog = MODEL_CATALOGS.xai; + + for (const model of catalog.models) { + expect(model.extendedContext).not.toBe(true); + expect(supportsExtendedContext('xai', model.id)).toBe(false); + } + }); + + it('strips generic extended-context metadata from live xAI catalogs', () => { + const catalog = buildUiCatalog('xai', { + provider: 'xai', + displayName: 'xAI (Grok)', + defaultModel: 'grok-4.3', + models: [ + { + id: 'grok-4.3', + name: 'Grok 4.3', + extendedContext: true, + }, + ], + }); + + expect(catalog?.models[0]?.extendedContext).toBeUndefined(); + expect(supportsExtendedContext('xai', 'grok-4.3', catalog)).toBe(false); + }); });