From 32254c1ab71d29b865f425b64683224fb3df1028 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 12 Aug 2026 10:37:18 -0700 Subject: [PATCH 01/13] feat(xai): add Grok 4.6/4.5 and hybrid catalog discovery xAI's current flagship is Grok 4.6; keep shared capability flags so gateways can reference the new models, and let /v1/models surface later Grok IDs without another catalog bump. --- src/components/ProviderManager.tsx | 2 +- src/integrations/brands/xai.ts | 2 + src/integrations/discoveryService.test.ts | 2 + src/integrations/gateways/atlas-cloud.ts | 2 + src/integrations/gateways/hicap.ts | 2 + src/integrations/gateways/openrouter.ts | 2 + src/integrations/models/xai.ts | 44 ++++++++++ src/integrations/runtimeMetadata.test.ts | 43 ++++++++++ src/integrations/vendors/xai.test.ts | 82 +++++++++++++++++++ src/integrations/vendors/xai.ts | 70 +++++++++++++++- src/services/api/client.test.ts | 2 +- src/utils/context.test.ts | 7 ++ src/utils/effort.codex.test.ts | 62 ++++++++++++++ src/utils/model/configs.ts | 26 +++--- src/utils/model/model.ts | 14 ++-- src/utils/model/modelOptions.gateways.test.ts | 4 + src/utils/providerFlag.test.ts | 2 +- src/utils/providerFlag.ts | 2 +- src/utils/providerProfile.test.ts | 2 +- src/utils/providerProfile.ts | 4 +- src/utils/providerProfiles.test.ts | 2 +- src/utils/visionUtils.test.ts | 9 ++ web/src/data/providers.ts | 2 +- 23 files changed, 358 insertions(+), 31 deletions(-) create mode 100644 src/integrations/vendors/xai.test.ts diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx index d17b0fbfb3..480ebd87c1 100644 --- a/src/components/ProviderManager.tsx +++ b/src/components/ProviderManager.tsx @@ -241,7 +241,7 @@ const GITHUB_PROVIDER_DEFAULT_BASE_URL = 'https://models.github.ai/inference' const CODEX_OAUTH_PROVIDER_NAME = 'Codex OAuth' const CODEX_OAUTH_PROVIDER_MODEL = 'codexplan' const XAI_OAUTH_PROVIDER_NAME = 'xAI OAuth' -const XAI_OAUTH_PROVIDER_MODEL = 'grok-4.3' +const XAI_OAUTH_PROVIDER_MODEL = 'grok-4.6' const XAI_OAUTH_PROVIDER_BASE_URL = 'https://api.x.ai/v1' type GithubCredentialSource = 'stored' | 'env' | 'none' diff --git a/src/integrations/brands/xai.ts b/src/integrations/brands/xai.ts index 084fe95321..81c41057b9 100644 --- a/src/integrations/brands/xai.ts +++ b/src/integrations/brands/xai.ts @@ -13,6 +13,8 @@ export default defineBrand({ supportsPreciseTokenCount: false, }, modelIds: [ + 'grok-4.6', + 'grok-4.5', 'grok-4.3', 'xai/grok-build-0.1', 'grok-4.20-0309-reasoning', diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index 65c641783b..06e0e70f0a 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -285,6 +285,8 @@ describe('discoverModelsForRoute', () => { expect(result?.models.map((model: { apiName: string }) => model.apiName)).toEqual([ 'openai/gpt-5-mini', + 'x-ai/grok-4.6', + 'x-ai/grok-4.5', 'anthropic/claude-sonnet-4', ]) expect(result?.models[0]?.label).toBe('GPT-5 Mini (via OpenRouter)') diff --git a/src/integrations/gateways/atlas-cloud.ts b/src/integrations/gateways/atlas-cloud.ts index 311f0e5066..20a3826f6e 100644 --- a/src/integrations/gateways/atlas-cloud.ts +++ b/src/integrations/gateways/atlas-cloud.ts @@ -51,6 +51,8 @@ export default defineGateway({ { id: 'google/gemini-3.5-flash', apiName: 'google/gemini-3.5-flash', aliases: ['gemini-3.5-flash'], modelDescriptorId: 'gemini-3.5-flash', label: 'Gemini 3.5 Flash', contextWindow: 1_048_576, maxOutputTokens: 65_536, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], wireFormat: 'reasoning_effort' } }, { id: 'google/gemini-3.1-pro-preview', apiName: 'google/gemini-3.1-pro-preview', modelDescriptorId: 'google/gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview', contextWindow: 1_000_000, maxOutputTokens: 64_000, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], wireFormat: 'reasoning_effort' } }, { id: 'google/gemini-3.1-flash-lite', apiName: 'google/gemini-3.1-flash-lite', modelDescriptorId: 'google/gemini-3.1-flash-lite', label: 'Gemini 3.1 Flash Lite', contextWindow: 1_048_576, maxOutputTokens: 65_536 }, + { id: 'xai/grok-4.6', apiName: 'xai/grok-4.6', aliases: ['grok-4.6', 'grok-4.6-latest'], modelDescriptorId: 'grok-4.6', label: 'Grok 4.6', contextWindow: 500_000, maxOutputTokens: 32_768, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } }, + { id: 'xai/grok-4.5', apiName: 'xai/grok-4.5', aliases: ['grok-4.5', 'grok-4.5-latest'], modelDescriptorId: 'grok-4.5', label: 'Grok 4.5', contextWindow: 500_000, maxOutputTokens: 32_768, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } }, { id: 'xai/grok-4.3', apiName: 'xai/grok-4.3', aliases: ['grok-4.3', 'grok-4.3-latest', 'grok-latest', 'grok-4', 'grok-3'], modelDescriptorId: 'grok-4.3', label: 'Grok 4.3', contextWindow: 1_000_000, maxOutputTokens: 32_768, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high'], wireFormat: 'reasoning_effort' } }, { id: 'xai/grok-build-0.1', apiName: 'xai/grok-build-0.1', aliases: ['grok-build-0.1', 'grok-code-fast-1', 'grok-code-fast', 'grok-code-fast-1-0825'], modelDescriptorId: 'xai/grok-build-0.1', label: 'Grok Build 0.1', contextWindow: 256_000, maxOutputTokens: 64_000, capabilities: { supportsReasoning: false }, transportOverrides: { openaiShim: { removeBodyFields: ['reasoning_effort'] } } }, { id: 'moonshotai/kimi-k2.7-code', apiName: 'moonshotai/kimi-k2.7-code', aliases: ['kimi-k2.7-code'], modelDescriptorId: 'kimi-k2.7-code', label: 'Kimi K2.7 Code', contextWindow: 262_144, maxOutputTokens: 32_768, capabilities: { supportsVision: true, supportsFunctionCalling: true, supportsJsonMode: true, supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high'], defaultLevel: 'medium', wireFormat: 'reasoning_effort' } }, diff --git a/src/integrations/gateways/hicap.ts b/src/integrations/gateways/hicap.ts index 0ba545dd69..f289c83541 100644 --- a/src/integrations/gateways/hicap.ts +++ b/src/integrations/gateways/hicap.ts @@ -63,6 +63,8 @@ export default defineGateway({ { id: 'hicap-glm-5.2', apiName: 'glm-5.2', aliases: ['zai-org/glm-5.2'], label: 'GLM 5.2', modelDescriptorId: 'glm-5.2', contextWindow: 1_000_000, maxOutputTokens: 131_072, capabilities: { supportsFunctionCalling: true, supportsJsonMode: true, supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], defaultLevel: 'high', wireFormat: 'zai_compatible' }, transportOverrides: { openaiShim: { preserveReasoningContent: true, requireReasoningContentOnAssistantMessages: true, reasoningContentFallback: '', thinkingRequestFormat: 'zai-compatible', maxTokensField: 'max_tokens', removeBodyFields: ['store'], enableToolStreaming: true } } }, { id: 'hicap-gpt-5.4', apiName: 'gpt-5.4', label: 'GPT-5.4', modelDescriptorId: 'gpt-5.4', contextWindow: 1_050_000, maxOutputTokens: 128_000, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], defaultLevel: 'high', wireFormat: 'reasoning_effort' }, transportOverrides: { openaiShim: { requiredApiFormat: 'responses', maxTokensField: 'max_completion_tokens' } } }, { id: 'hicap-gpt-5.5', apiName: 'gpt-5.5', label: 'GPT-5.5', modelDescriptorId: 'gpt-5.5', contextWindow: 1_050_000, maxOutputTokens: 128_000, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], defaultLevel: 'high', wireFormat: 'reasoning_effort' }, transportOverrides: { openaiShim: { requiredApiFormat: 'responses', maxTokensField: 'max_completion_tokens' } } }, + { id: 'hicap-grok-4.6', apiName: 'grok-4.6', label: 'Grok 4.6', modelDescriptorId: 'grok-4.6', contextWindow: 500_000, maxOutputTokens: 32_768, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } }, + { id: 'hicap-grok-4.5', apiName: 'grok-4.5', label: 'Grok 4.5', modelDescriptorId: 'grok-4.5', contextWindow: 500_000, maxOutputTokens: 32_768, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } }, { id: 'hicap-grok-4.3', apiName: 'grok-4.3', label: 'Grok 4.3', modelDescriptorId: 'grok-4.3', contextWindow: 1_000_000, maxOutputTokens: 32_768, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } }, { id: 'hicap-kimi-k2.7-code', apiName: 'kimi-k2.7-code', label: 'Kimi K2.7 Code', modelDescriptorId: 'kimi-k2.7-code', contextWindow: 262_144, maxOutputTokens: 262_144, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh', 'max'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } }, { id: 'hicap-minimax-m3', apiName: 'minimax-m3', label: 'MiniMax M3', modelDescriptorId: 'minimax-m3', contextWindow: 1_048_576, maxOutputTokens: 131_072, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh', 'max'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } }, diff --git a/src/integrations/gateways/openrouter.ts b/src/integrations/gateways/openrouter.ts index 44318d45db..a2262143af 100644 --- a/src/integrations/gateways/openrouter.ts +++ b/src/integrations/gateways/openrouter.ts @@ -35,6 +35,8 @@ export default defineGateway({ allowManualRefresh: true, models: [ { id: 'openrouter-gpt-5-mini', apiName: 'openai/gpt-5-mini', label: 'GPT-5 Mini (via OpenRouter)', modelDescriptorId: 'gpt-5-mini' }, + { id: 'openrouter-grok-4.6', apiName: 'x-ai/grok-4.6', label: 'Grok 4.6 (via OpenRouter)', modelDescriptorId: 'grok-4.6' }, + { id: 'openrouter-grok-4.5', apiName: 'x-ai/grok-4.5', label: 'Grok 4.5 (via OpenRouter)', modelDescriptorId: 'grok-4.5' }, ], }, usage: { supported: false }, diff --git a/src/integrations/models/xai.ts b/src/integrations/models/xai.ts index 70cc8e8cc6..c1e8409567 100644 --- a/src/integrations/models/xai.ts +++ b/src/integrations/models/xai.ts @@ -10,6 +10,50 @@ const grokCapabilities = { } export default [ + defineModel({ + id: 'grok-4.6', + label: 'Grok 4.6', + brandId: 'xai', + vendorId: 'xai', + classification: ['chat', 'reasoning', 'vision', 'coding'], + defaultModel: 'grok-4.6', + providerModelMap: { + openrouter: 'x-ai/grok-4.6', + 'atlas-cloud': 'xai/grok-4.6', + hicap: 'grok-4.6', + }, + capabilities: grokCapabilities, + reasoning: { + mode: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], + defaultLevel: 'high', + wireFormat: 'reasoning_effort', + }, + contextWindow: 500_000, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'grok-4.5', + label: 'Grok 4.5', + brandId: 'xai', + vendorId: 'xai', + classification: ['chat', 'reasoning', 'vision', 'coding'], + defaultModel: 'grok-4.5', + providerModelMap: { + openrouter: 'x-ai/grok-4.5', + 'atlas-cloud': 'xai/grok-4.5', + hicap: 'grok-4.5', + }, + capabilities: grokCapabilities, + reasoning: { + mode: 'levels', + levels: ['low', 'medium', 'high'], + defaultLevel: 'high', + wireFormat: 'reasoning_effort', + }, + contextWindow: 500_000, + maxOutputTokens: 32_768, + }), defineModel({ id: 'grok-4.3', label: 'Grok 4.3', diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index 9da44ccdb3..705f09c592 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -646,6 +646,19 @@ describe('resolveOpenAIShimRuntimeContext - Hicap catalog metadata', () => { expect(gpt55.openaiShimConfig.requiredApiFormat).toBe('responses') expect(gpt55.openaiShimConfig.maxTokensField).toBe('max_completion_tokens') + const grok46 = resolveOpenAIShimRuntimeContext({ + model: 'grok-4.6', + baseUrl: 'https://api.hicap.ai/v1', + processEnv: { CLAUDE_CODE_USE_OPENAI: '1' }, + }) + expect(grok46.catalogEntry?.id).toBe('hicap-grok-4.6') + expect(grok46.catalogEntry?.reasoning?.levels).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + ]) + const grok = resolveOpenAIShimRuntimeContext({ model: 'grok-4.3', baseUrl: 'https://api.hicap.ai/v1', @@ -661,6 +674,36 @@ describe('resolveOpenAIShimRuntimeContext - Hicap catalog metadata', () => { describe('resolveOpenAIShimRuntimeContext - xAI catalog metadata', () => { it('uses live xAI model metadata and per-model shim overrides', () => { + expect( + resolveModelRuntimeLimits({ + model: 'grok-4.6', + baseUrl: 'https://api.x.ai/v1', + processEnv: { CLAUDE_CODE_USE_OPENAI: '1' }, + }), + ).toEqual({ contextWindow: 500_000, maxOutputTokens: 32_768 }) + + const grok46 = resolveOpenAIShimRuntimeContext({ + model: 'grok-4.6-latest', + baseUrl: 'https://api.x.ai/v1', + processEnv: { CLAUDE_CODE_USE_OPENAI: '1' }, + }) + expect(grok46.routeId).toBe('xai') + expect(grok46.catalogEntry?.id).toBe('grok-4.6') + expect(grok46.catalogEntry?.reasoning?.levels).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + ]) + + expect( + resolveModelRuntimeLimits({ + model: 'grok-4.5', + baseUrl: 'https://api.x.ai/v1', + processEnv: { CLAUDE_CODE_USE_OPENAI: '1' }, + }), + ).toEqual({ contextWindow: 500_000, maxOutputTokens: 32_768 }) + expect( resolveModelRuntimeLimits({ model: 'grok-4.20-0309-reasoning', diff --git a/src/integrations/vendors/xai.test.ts b/src/integrations/vendors/xai.test.ts new file mode 100644 index 0000000000..9521b91843 --- /dev/null +++ b/src/integrations/vendors/xai.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'bun:test' +import xai from './xai.js' + +const mapModel = xai.catalog?.discovery?.mapModel + +function shape(id: string, extras: Record = {}) { + return { id, ...extras } +} + +describe('xAI vendor hybrid catalog', () => { + test('uses hybrid discovery with curated Grok 4.6 as the default', () => { + expect(mapModel).toBeDefined() + expect(xai.catalog?.source).toBe('hybrid') + expect(xai.catalog?.discovery?.kind).toBe('openai-compatible') + expect(xai.catalog?.discoveryCacheTtl).toBe('1d') + expect(xai.catalog?.discoveryRefreshMode).toBe('background-if-stale') + expect(xai.catalog?.allowManualRefresh).toBe(true) + expect(xai.defaultModel).toBe('grok-4.6') + expect(xai.catalog?.models?.map(model => model.apiName)).toEqual([ + 'grok-4.6', + 'grok-4.5', + 'grok-4.3', + 'grok-build-0.1', + 'grok-4.20-0309-reasoning', + 'grok-4.20-0309-non-reasoning', + ]) + }) + + test('keeps chat Grok IDs including later uncataloged releases', () => { + if (!mapModel) throw new Error('mapModel missing') + const keep = [ + 'grok-4.6', + 'grok-4.5', + 'grok-4.3', + 'grok-4.7', + 'grok-build-0.1', + 'grok-4.20-0309-reasoning', + 'grok-4.20-multi-agent-0309', + 'latest', + ] + for (const id of keep) { + expect(mapModel(shape(id))).toEqual({ + id, + apiName: id, + label: id, + }) + } + }) + + test('drops Imagine, voice, STT/TTS, and embedding models', () => { + if (!mapModel) throw new Error('mapModel missing') + const drop = [ + 'grok-imagine-image', + 'grok-imagine-image-quality', + 'grok-imagine-video-1.5', + 'grok-voice-think-fast-1.0', + 'grok-voice-think-fast-2.0', + 'grok-stt-1.0', + 'grok-tts-1.0', + ] + for (const id of drop) { + expect(mapModel(shape(id))).toBeNull() + } + }) + + test('drops inactive entries and missing ids', () => { + if (!mapModel) throw new Error('mapModel missing') + expect(mapModel(shape('grok-4.6', { active: false }))).toBeNull() + expect(mapModel({})).toBeNull() + expect(mapModel({ id: '' })).toBeNull() + }) + + test('forwards context_window when present', () => { + if (!mapModel) throw new Error('mapModel missing') + expect(mapModel(shape('grok-4.6', { context_window: 500000 }))).toEqual({ + id: 'grok-4.6', + apiName: 'grok-4.6', + label: 'grok-4.6', + contextWindow: 500000, + }) + }) +}) diff --git a/src/integrations/vendors/xai.ts b/src/integrations/vendors/xai.ts index 64cfe02e35..a1b5fa140e 100644 --- a/src/integrations/vendors/xai.ts +++ b/src/integrations/vendors/xai.ts @@ -1,11 +1,16 @@ import { defineVendor } from '../define.js' +// Keep chat/coding Grok IDs from https://api.x.ai/v1/models. Imagine, voice, +// STT/TTS, and embedding entries are not usable on the OpenClaude chat path. +const XAI_NON_CHAT_PATTERN = + /(imagine|voice|tts|stt|whisper|embed|speech-to-speech|speech-to-text|text-to-speech)/i + export default defineVendor({ id: 'xai', label: 'xAI', classification: 'openai-compatible', defaultBaseUrl: 'https://api.x.ai/v1', - defaultModel: 'grok-4.3', + defaultModel: 'grok-4.6', requiredEnvVars: ['XAI_API_KEY'], setup: { requiresAuth: true, @@ -37,8 +42,69 @@ export default defineVendor({ 'XAI_API_KEY is required, or sign in with `openclaude auth xai login` (browser OAuth) or `openclaude auth xai device` (remote hosts).', }, catalog: { - source: 'static', + source: 'hybrid', + discovery: { + kind: 'openai-compatible', + mapModel(raw: unknown) { + const model = raw as { + id?: string + active?: boolean + context_window?: number + } + const id = model.id?.trim() + if (!id || model.active === false) { + return null + } + if (XAI_NON_CHAT_PATTERN.test(id)) { + return null + } + if (!/^grok/i.test(id) && id.toLowerCase() !== 'latest') { + return null + } + return { + id, + apiName: id, + label: id, + ...(typeof model.context_window === 'number' + ? { contextWindow: model.context_window } + : {}), + } + }, + }, + discoveryCacheTtl: '1d', + discoveryRefreshMode: 'background-if-stale', + allowManualRefresh: true, models: [ + { + id: 'grok-4.6', + apiName: 'grok-4.6', + aliases: ['grok-4.6-latest'], + label: 'Grok 4.6', + modelDescriptorId: 'grok-4.6', + contextWindow: 500_000, + maxOutputTokens: 32_768, + reasoning: { + mode: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], + defaultLevel: 'high', + wireFormat: 'reasoning_effort', + }, + }, + { + id: 'grok-4.5', + apiName: 'grok-4.5', + aliases: ['grok-4.5-latest', 'grok-build-latest'], + label: 'Grok 4.5', + modelDescriptorId: 'grok-4.5', + contextWindow: 500_000, + maxOutputTokens: 32_768, + reasoning: { + mode: 'levels', + levels: ['low', 'medium', 'high'], + defaultLevel: 'high', + wireFormat: 'reasoning_effort', + }, + }, { id: 'grok-4.3', apiName: 'grok-4.3', diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index f1b8b1fddf..b3b921451c 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -942,7 +942,7 @@ test('env-only xAI fallback replaces stale OpenAI credentials and model env', as }) expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') - expect(process.env.OPENAI_MODEL).toBe('grok-4.3') + expect(process.env.OPENAI_MODEL).toBe('grok-4.6') expect(process.env.OPENAI_API_KEY).toBe('xai-test-key') }) diff --git a/src/utils/context.test.ts b/src/utils/context.test.ts index edf3c13092..340e9316c2 100644 --- a/src/utils/context.test.ts +++ b/src/utils/context.test.ts @@ -525,6 +525,13 @@ test('env-only xAI key uses provider-specific context and output caps before cli delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS delete process.env.OPENAI_MODEL + expect(getContextWindowForModel('grok-4.6')).toBe(500_000) + expect(getModelMaxOutputTokens('grok-4.6')).toEqual({ + default: 32_768, + upperLimit: 32_768, + }) + expect(getMaxOutputTokensForModel('grok-4.6')).toBe(32_768) + expect(getContextWindowForModel('grok-4.5')).toBe(500_000) expect(getContextWindowForModel('grok-4.3')).toBe(1_000_000) expect(getModelMaxOutputTokens('grok-4.3')).toEqual({ default: 32_768, diff --git a/src/utils/effort.codex.test.ts b/src/utils/effort.codex.test.ts index e0436fbcb1..a32f1b074e 100644 --- a/src/utils/effort.codex.test.ts +++ b/src/utils/effort.codex.test.ts @@ -810,6 +810,34 @@ test('Atlas Cloud catalog exposes only verified reasoning controls for exact mod expect(resolveAppliedEffort(model, 'max')).toBe('high') } + expect(resolveModelReasoningControl('xai/grok-4.6')).toMatchObject({ + supportsReasoning: true, + controllable: true, + source: 'metadata', + levels: ['low', 'medium', 'high', 'xhigh'], + defaultLevel: 'high', + wireFormat: 'reasoning_effort', + }) + expect(getAvailableEffortLevels('xai/grok-4.6')).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + ]) + expect(resolveAppliedEffort('xai/grok-4.6', 'xhigh')).toBe('xhigh') + expect(resolveAppliedEffort('xai/grok-4.6', 'max')).toBe('high') + + expect(resolveModelReasoningControl('xai/grok-4.5')).toMatchObject({ + supportsReasoning: true, + controllable: true, + source: 'metadata', + levels: ['low', 'medium', 'high'], + defaultLevel: 'high', + wireFormat: 'reasoning_effort', + }) + expect(getAvailableEffortLevels('xai/grok-4.5')).toEqual(['low', 'medium', 'high']) + expect(resolveAppliedEffort('xai/grok-4.5', 'xhigh')).toBe('high') + expect(resolveModelReasoningControl('xai/grok-4.3')).toMatchObject({ supportsReasoning: true, controllable: true, @@ -881,6 +909,40 @@ test('xAI catalog exposes live-verified reasoning controls for direct Grok model catalogEntries: xaiVendor.catalog?.models ?? [], }) + for (const model of ['grok-4.6', 'grok-4.6-latest']) { + expect(resolveModelReasoningControl(model)).toMatchObject({ + supportsReasoning: true, + controllable: true, + source: 'metadata', + levels: ['low', 'medium', 'high', 'xhigh'], + defaultLevel: 'high', + wireFormat: 'reasoning_effort', + }) + expect(modelSupportsEffort(model)).toBe(true) + expect(modelSupportsWireEffort(model)).toBe(true) + expect(getAvailableEffortLevels(model)).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + ]) + expect(resolveAppliedEffort(model, 'xhigh')).toBe('xhigh') + expect(resolveAppliedEffort(model, 'max')).toBe('high') + } + + for (const model of ['grok-4.5', 'grok-4.5-latest', 'grok-build-latest']) { + expect(resolveModelReasoningControl(model)).toMatchObject({ + supportsReasoning: true, + controllable: true, + source: 'metadata', + levels: ['low', 'medium', 'high'], + defaultLevel: 'high', + wireFormat: 'reasoning_effort', + }) + expect(getAvailableEffortLevels(model)).toEqual(['low', 'medium', 'high']) + expect(resolveAppliedEffort(model, 'xhigh')).toBe('high') + } + for (const model of ['grok-4.3', 'grok-4.3-latest', 'grok-latest', 'grok-4', 'grok-3']) { expect(resolveModelReasoningControl(model)).toMatchObject({ supportsReasoning: true, diff --git a/src/utils/model/configs.ts b/src/utils/model/configs.ts index 530d127a8c..95c3e4604f 100644 --- a/src/utils/model/configs.ts +++ b/src/utils/model/configs.ts @@ -53,7 +53,7 @@ export const CLAUDE_3_7_SONNET_CONFIG = { 'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct', minimax: 'MiniMax-M2.5', 'xiaomi-mimo': 'mimo-v2.5-pro', - xai: 'grok-4.3', + xai: 'grok-4.6', } as const satisfies LegacyProviderModelConfig export const CLAUDE_3_5_V2_SONNET_CONFIG = { @@ -69,7 +69,7 @@ export const CLAUDE_3_5_V2_SONNET_CONFIG = { 'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct', minimax: 'MiniMax-M2.5', 'xiaomi-mimo': 'mimo-v2.5-pro', - xai: 'grok-4.3', + xai: 'grok-4.6', } as const satisfies LegacyProviderModelConfig export const CLAUDE_3_5_HAIKU_CONFIG = { @@ -85,7 +85,7 @@ export const CLAUDE_3_5_HAIKU_CONFIG = { 'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct', minimax: 'MiniMax-M2.5', 'xiaomi-mimo': 'mimo-v2.5-pro', - xai: 'grok-4.3', + xai: 'grok-4.6', } as const satisfies LegacyProviderModelConfig export const CLAUDE_HAIKU_4_5_CONFIG = { @@ -101,7 +101,7 @@ export const CLAUDE_HAIKU_4_5_CONFIG = { 'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct', minimax: 'MiniMax-M2.5', 'xiaomi-mimo': 'mimo-v2.5-pro', - xai: 'grok-4.3', + xai: 'grok-4.6', } as const satisfies LegacyProviderModelConfig export const CLAUDE_SONNET_4_CONFIG = { @@ -117,7 +117,7 @@ export const CLAUDE_SONNET_4_CONFIG = { 'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct', minimax: 'MiniMax-M2.5', 'xiaomi-mimo': 'mimo-v2.5-pro', - xai: 'grok-4.3', + xai: 'grok-4.6', } as const satisfies LegacyProviderModelConfig export const CLAUDE_SONNET_4_5_CONFIG = { @@ -133,7 +133,7 @@ export const CLAUDE_SONNET_4_5_CONFIG = { 'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct', minimax: 'MiniMax-M2.5', 'xiaomi-mimo': 'mimo-v2.5-pro', - xai: 'grok-4.3', + xai: 'grok-4.6', } as const satisfies LegacyProviderModelConfig export const CLAUDE_OPUS_4_CONFIG = { @@ -149,7 +149,7 @@ export const CLAUDE_OPUS_4_CONFIG = { 'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct', minimax: 'MiniMax-M2.5', 'xiaomi-mimo': 'mimo-v2.5-pro', - xai: 'grok-4.3', + xai: 'grok-4.6', } as const satisfies LegacyProviderModelConfig export const CLAUDE_OPUS_4_1_CONFIG = { @@ -165,7 +165,7 @@ export const CLAUDE_OPUS_4_1_CONFIG = { 'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct', minimax: 'MiniMax-M2.5', 'xiaomi-mimo': 'mimo-v2.5-pro', - xai: 'grok-4.3', + xai: 'grok-4.6', } as const satisfies LegacyProviderModelConfig export const CLAUDE_OPUS_4_5_CONFIG = { @@ -181,7 +181,7 @@ export const CLAUDE_OPUS_4_5_CONFIG = { 'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct', minimax: 'MiniMax-M2.5', 'xiaomi-mimo': 'mimo-v2.5-pro', - xai: 'grok-4.3', + xai: 'grok-4.6', } as const satisfies LegacyProviderModelConfig export const CLAUDE_OPUS_4_6_CONFIG = { @@ -197,7 +197,7 @@ export const CLAUDE_OPUS_4_6_CONFIG = { 'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct', minimax: 'MiniMax-M2.5', 'xiaomi-mimo': 'mimo-v2.5-pro', - xai: 'grok-4.3', + xai: 'grok-4.6', } as const satisfies LegacyProviderModelConfig export const CLAUDE_OPUS_4_7_CONFIG = { @@ -213,7 +213,7 @@ export const CLAUDE_OPUS_4_7_CONFIG = { 'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct', minimax: 'MiniMax-M2.5', 'xiaomi-mimo': 'mimo-v2.5-pro', - xai: 'grok-4.3', + xai: 'grok-4.6', } as const satisfies LegacyProviderModelConfig export const CLAUDE_OPUS_4_8_CONFIG = { @@ -229,7 +229,7 @@ export const CLAUDE_OPUS_4_8_CONFIG = { 'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct', minimax: 'MiniMax-M2.5', 'xiaomi-mimo': 'mimo-v2.5-pro', - xai: 'grok-4.3', + xai: 'grok-4.6', } as const satisfies LegacyProviderModelConfig export const CLAUDE_SONNET_4_6_CONFIG = { @@ -245,7 +245,7 @@ export const CLAUDE_SONNET_4_6_CONFIG = { 'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct', minimax: 'MiniMax-M2.5', 'xiaomi-mimo': 'mimo-v2.5-pro', - xai: 'grok-4.3', + xai: 'grok-4.6', } as const satisfies LegacyProviderModelConfig // @[MODEL LAUNCH]: Register the new config here. diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index ab9bbcae97..c1123d1336 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -91,9 +91,9 @@ export function getSmallFastModel(): ModelName { if (getAPIProvider() === 'xiaomi-mimo') { return process.env.OPENAI_MODEL || 'mimo-v2-flash' } - // xAI — OPENAI_MODEL carries the active Grok model; fall back to Grok 4.3. + // xAI — OPENAI_MODEL carries the active Grok model; fall back to Grok 4.6. if (getAPIProvider() === 'xai') { - return process.env.OPENAI_MODEL || 'grok-4.3' + return process.env.OPENAI_MODEL || getRouteDefaultModel('xai') || 'grok-4.6' } return getDefaultHaikuModel() } @@ -229,7 +229,7 @@ export function getDefaultOpusModel(): ModelName { } // xAI — flagship Grok model for "opus"-equivalent. if (getAPIProvider() === 'xai') { - return process.env.OPENAI_MODEL || 'grok-4.3' + return process.env.OPENAI_MODEL || getRouteDefaultModel('xai') || 'grok-4.6' } // 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch // since 3P availability lags firstParty and these will diverge again at @@ -279,7 +279,7 @@ export function getDefaultSonnetModel(): ModelName { } // xAI — flagship Grok model for "sonnet"-equivalent. if (getAPIProvider() === 'xai') { - return process.env.OPENAI_MODEL || 'grok-4.3' + return process.env.OPENAI_MODEL || getRouteDefaultModel('xai') || 'grok-4.6' } // Default to Sonnet 4.5 for 3P since they may not have 4.6 yet if (!isFirstPartyAnthropicProvider()) { @@ -327,7 +327,7 @@ export function getDefaultHaikuModel(): ModelName { } // xAI — use the current Grok default for "haiku"-equivalent until xAI exposes a smaller live alias. if (getAPIProvider() === 'xai') { - return process.env.OPENAI_MODEL || 'grok-4.3' + return process.env.OPENAI_MODEL || getRouteDefaultModel('xai') || 'grok-4.6' } // Haiku 4.5 is available on all platforms (first-party, Foundry, Bedrock, Vertex) @@ -412,9 +412,9 @@ export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias { 'nvidia/llama-3.1-nemotron-70b-instruct' ) } - // xAI provider: always use the configured Grok model (default grok-4.3) + // xAI provider: always use the configured Grok model (default grok-4.6) if (getAPIProvider() === 'xai') { - return process.env.OPENAI_MODEL || 'grok-4.3' + return process.env.OPENAI_MODEL || getRouteDefaultModel('xai') || 'grok-4.6' } // MiniMax provider: always use the configured MiniMax model. // Keep the env-only fallback aligned with the MiniMax descriptor default diff --git a/src/utils/model/modelOptions.gateways.test.ts b/src/utils/model/modelOptions.gateways.test.ts index 2e7b38c51e..35372c7080 100644 --- a/src/utils/model/modelOptions.gateways.test.ts +++ b/src/utils/model/modelOptions.gateways.test.ts @@ -161,6 +161,8 @@ test('OpenRouter active profile cache merges with the static route catalog', asy expect(values).toContain('qwen/qwen3-32b') expect(values).toContain('openai/gpt-5-mini') + expect(values).toContain('x-ai/grok-4.6') + expect(values).toContain('x-ai/grok-4.5') }) test('Atlas Cloud canonicalizes static catalog aliases without hiding the catalog', async () => { @@ -174,6 +176,8 @@ test('Atlas Cloud canonicalizes static catalog aliases without hiding the catalo expect(values).toContain('anthropic/claude-opus-4.8') expect(values).toContain('deepseek-ai/deepseek-v4-pro') expect(values).toContain('xai/grok-build-0.1') + expect(values).toContain('xai/grok-4.6') + expect(values).toContain('xai/grok-4.5') expect(values).toContain('xai/grok-4.3') expect(values).not.toContain('claude-opus-4-8') expect(values).not.toContain('grok-code-fast-1') diff --git a/src/utils/providerFlag.test.ts b/src/utils/providerFlag.test.ts index b0545046f0..f027f6476d 100644 --- a/src/utils/providerFlag.test.ts +++ b/src/utils/providerFlag.test.ts @@ -1118,7 +1118,7 @@ describe('applyProviderFlag - xai', () => { expect(result.error).toBeUndefined() expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') expect(process.env.OPENAI_BASE_URL as string | undefined).toBe('https://api.x.ai/v1') - expect(process.env.OPENAI_MODEL).toBe('grok-4.3') + expect(process.env.OPENAI_MODEL).toBe('grok-4.6') }) test('sets OPENAI_MODEL when --model is provided', () => { diff --git a/src/utils/providerFlag.ts b/src/utils/providerFlag.ts index 1cdabcbfea..11264f82f3 100644 --- a/src/utils/providerFlag.ts +++ b/src/utils/providerFlag.ts @@ -568,7 +568,7 @@ export function applyProviderFlag( case 'xai': process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL ??= 'https://api.x.ai/v1' - process.env.OPENAI_MODEL ??= defaultModel ?? 'grok-4.3' + process.env.OPENAI_MODEL ??= defaultModel ?? 'grok-4.6' if (model) process.env.OPENAI_MODEL = model if (process.env.XAI_API_KEY && !process.env.OPENAI_API_KEY) { process.env.OPENAI_API_KEY = process.env.XAI_API_KEY diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index ac1c29696f..e9fcc004cf 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -916,7 +916,7 @@ test('xai launch uses descriptor defaults and persisted xAI key', async () => { assert.equal(env.CLAUDE_CODE_USE_OPENAI, '1') assert.equal(env.OPENAI_BASE_URL, 'https://api.x.ai/v1') - assert.equal(env.OPENAI_MODEL, 'grok-4.3') + assert.equal(env.OPENAI_MODEL, 'grok-4.6') assert.equal(env.OPENAI_API_KEY, 'xai-persisted-key') assert.equal(env.XAI_API_KEY, 'xai-persisted-key') }) diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index ca82b52d4d..8891e0d6cc 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -1076,7 +1076,7 @@ function buildXaiProfileEnv(options: { XAI_API_KEY: key, } const defaultBaseUrl = getRouteDefaultBaseUrl('xai') ?? 'https://api.x.ai/v1' - const defaultModel = getRouteDefaultModel('xai') ?? 'grok-4.3' + const defaultModel = getRouteDefaultModel('xai') ?? 'grok-4.6' const env: ProfileEnv = { OPENAI_BASE_URL: sanitizeProviderConfigValue(options.baseUrl, secretSource) || @@ -1225,7 +1225,7 @@ export function buildXaiOAuthProfileEnv(options: { }): ProfileEnv { return { OPENAI_BASE_URL: XAI_OAUTH_DEFAULT_BASE_URL, - OPENAI_MODEL: options.model ?? 'grok-4.3', + OPENAI_MODEL: options.model ?? 'grok-4.6', XAI_CREDENTIAL_SOURCE: 'oauth', } } diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index 2b07a46090..823d77bede 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -2771,7 +2771,7 @@ describe('getProviderPresetDefaults', () => { expect(defaults.provider).toBe('xai') expect(defaults.name).toBe('xAI') expect(defaults.baseUrl).toBe('https://api.x.ai/v1') - expect(defaults.model).toBe('grok-4.3') + expect(defaults.model).toBe('grok-4.6') expect(defaults.apiKey).toBe('xai-live-key') expect(defaults.requiresApiKey).toBe(true) }) diff --git a/src/utils/visionUtils.test.ts b/src/utils/visionUtils.test.ts index 961164606b..4faddfb83d 100644 --- a/src/utils/visionUtils.test.ts +++ b/src/utils/visionUtils.test.ts @@ -63,6 +63,15 @@ describe('findModelDescriptorForApiName', () => { expect(descriptor?.capabilities?.supportsVision).toBe(false) }) + test('resolves gateway Grok 4.6 names to the shared descriptor', () => { + expect(findModelDescriptorForApiName('grok-4.6')?.id).toBe('grok-4.6') + expect(findModelDescriptorForApiName('x-ai/grok-4.6')?.id).toBe('grok-4.6') + expect(findModelDescriptorForApiName('xai/grok-4.6')?.id).toBe('grok-4.6') + expect(findModelDescriptorForApiName('grok-4.5')?.id).toBe('grok-4.5') + expect(isVisionSupported('grok-4.6')).toBe(true) + expect(isVisionSupported('x-ai/grok-4.6')).toBe(true) + }) + test('does not resolve catalog aliases without a known route', () => { expect(findModelDescriptorForApiName('grok-code-fast-1-0825')).toBeUndefined() }) diff --git a/web/src/data/providers.ts b/web/src/data/providers.ts index 089c3d8b23..93f7f64a37 100644 --- a/web/src/data/providers.ts +++ b/web/src/data/providers.ts @@ -236,7 +236,7 @@ export const providers: Provider[] = [ group: 'vendors', setup: '/provider or env vars', envVars: ['XAI_API_KEY'], - notes: 'Grok models at https://api.x.ai/v1; defaults to grok-4.3.', + notes: 'Grok models at https://api.x.ai/v1; defaults to grok-4.6.', }, { id: 'deepseek', From 3c56a2753cc8ebfe5136d14f6f1b4de65d459093 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 12 Aug 2026 10:53:18 -0700 Subject: [PATCH 02/13] fix(xai): authenticate hybrid discovery for OAuth and keep gateway aliases aligned OAuth xAI sessions had no /v1/models credential, so hybrid refresh failed; also drop curated alias IDs from discovery and map grok-build-latest on Atlas/Hicap to Grok 4.5. --- src/commands/model/model.test.tsx | 24 +++ src/commands/model/model.tsx | 43 +++-- src/integrations/discoveryService.ts | 30 +++- src/integrations/gateways/atlas-cloud.ts | 2 +- src/integrations/gateways/hicap.ts | 4 +- src/integrations/runtimeMetadata.test.ts | 25 +++ src/integrations/vendors/xai.test.ts | 92 +++++++++- src/integrations/vendors/xai.ts | 218 ++++++++++++----------- 8 files changed, 311 insertions(+), 127 deletions(-) diff --git a/src/commands/model/model.test.tsx b/src/commands/model/model.test.tsx index 8dbb096b5e..14d9ef992e 100644 --- a/src/commands/model/model.test.tsx +++ b/src/commands/model/model.test.tsx @@ -1307,6 +1307,18 @@ test('/model applies auto provider surface for single-model descriptor profiles' descriptionForModel: 'Recommended · Provider: OpenRouter (openai/gpt-5-mini)', }, + { + value: 'x-ai/grok-4.6', + label: 'Grok 4.6 (via OpenRouter)', + description: 'Provider: OpenRouter', + descriptionForModel: 'Provider: OpenRouter (x-ai/grok-4.6)', + }, + { + value: 'x-ai/grok-4.5', + label: 'Grok 4.5 (via OpenRouter)', + description: 'Provider: OpenRouter', + descriptionForModel: 'Provider: OpenRouter (x-ai/grok-4.5)', + }, { value: activeProfile.model, label: activeProfile.model, @@ -1571,6 +1583,18 @@ test('/model applies providerProfileModelPickerMode provider override on descrip descriptionForModel: 'Recommended · Provider: OpenRouter (openai/gpt-5-mini)', }, + { + value: 'x-ai/grok-4.6', + label: 'Grok 4.6 (via OpenRouter)', + description: 'Provider: OpenRouter', + descriptionForModel: 'Provider: OpenRouter (x-ai/grok-4.6)', + }, + { + value: 'x-ai/grok-4.5', + label: 'Grok 4.5 (via OpenRouter)', + description: 'Provider: OpenRouter', + descriptionForModel: 'Provider: OpenRouter (x-ai/grok-4.5)', + }, { value: 'openai/gpt-oss-120b:free', label: 'openai/gpt-oss-120b:free', diff --git a/src/commands/model/model.tsx b/src/commands/model/model.tsx index 52fe0f42a7..136e5c61ca 100644 --- a/src/commands/model/model.tsx +++ b/src/commands/model/model.tsx @@ -21,6 +21,7 @@ import { import { getRouteDescriptor, isNativeVendorCatalogRoute, + isXaiBaseUrl, resolveRouteCredentialValue, resolveActiveRouteIdFromEnv, resolveRouteIdFromBaseUrl, @@ -35,6 +36,7 @@ import { resolveProviderRequest, } from '../../services/api/providerConfig.js' import { firstUsableCredential } from '../../services/api/credentialPool.js' +import { resolveXaiAccessToken } from '../../utils/xaiCredentials.js' import type { ProviderProfile } from '../../utils/config.js' import type { AppState } from '../../state/AppState.js' import { useAppState, useSetAppState } from '../../state/AppState.js' @@ -366,24 +368,29 @@ function withInactiveProfileSwitchOptions( return additions.length > 0 ? [...options, ...additions] : options } -function getOpenAIDiscoveryRequestOptions(routeId?: string | null): { +async function getOpenAIDiscoveryRequestOptions(routeId?: string | null): Promise<{ apiKey?: string baseUrl?: string headers?: Record -} { +}> { const request = resolveProviderRequest({ model: process.env.OPENAI_MODEL, baseUrl: process.env.OPENAI_BASE_URL, }) + let apiKey = firstUsableCredential( + resolveRouteCredentialValue({ + routeId, + baseUrl: request.baseUrl, + processEnv: process.env, + }), + ) + if (!apiKey && (routeId === 'xai' || isXaiBaseUrl(request.baseUrl))) { + apiKey = firstUsableCredential(await resolveXaiAccessToken()) + } + return { - apiKey: firstUsableCredential( - resolveRouteCredentialValue({ - routeId, - baseUrl: request.baseUrl, - processEnv: process.env, - }), - ), + apiKey, baseUrl: request.baseUrl, headers: parseCustomHeadersEnv(process.env.ANTHROPIC_CUSTOM_HEADERS), } @@ -499,7 +506,7 @@ async function loadDescriptorDiscoveryContext( } const ttlMs = parseDurationString(catalog.discoveryCacheTtl ?? 0) - const discoveryOptions = getOpenAIDiscoveryRequestOptions(routeId) + const discoveryOptions = await getOpenAIDiscoveryRequestOptions(routeId) const cacheKey = getDiscoveryCacheKey(routeId, discoveryOptions) const cached = await getCachedModels(cacheKey, ttlMs, { includeStale: true }) const stale = await isCacheStale(cacheKey, ttlMs) @@ -563,7 +570,7 @@ async function loadModelDiscoveryContext(): Promise { } if (discoveryContext.kind === 'descriptor') { + const discoveryOptions = await getOpenAIDiscoveryRequestOptions( + discoveryContext.routeId, + ) await clearDiscoveryCache( getDiscoveryCacheKey( discoveryContext.routeId, - getOpenAIDiscoveryRequestOptions(discoveryContext.routeId), + discoveryOptions, ), ) const result = await discoverModelsForRoute(discoveryContext.routeId, { - ...getOpenAIDiscoveryRequestOptions(discoveryContext.routeId), + ...discoveryOptions, forceRefresh: true, }) const nextOptions = mergeActiveProfileModelOptions( diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index 326d5c84be..491f84f8b7 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -32,6 +32,7 @@ import { } from '../utils/providerDiscovery.js' import { firstUsableCredential, hasInvalidCredentialPlaceholder } from '../services/api/credentialPool.js' import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' +import { resolveXaiAccessToken } from '../utils/xaiCredentials.js' import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js' import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' @@ -188,6 +189,23 @@ function getRouteDiscoveryApiKey( ) } +async function withXaiDiscoveryCredentials< + T extends { apiKey?: string; baseUrl?: string; headers?: Record }, +>(routeId: string, options?: T): Promise { + const next = { ...(options ?? {}) } as T + if (getRouteDiscoveryApiKey(routeId, next)) { + return next + } + if (routeId !== 'xai') { + return next + } + const token = firstUsableCredential(await resolveXaiAccessToken()) + if (token) { + next.apiKey = token + } + return next +} + export function getRouteDiscoveryHeaders( routeId: string, options?: { baseUrl?: string; headers?: Record }, @@ -357,8 +375,9 @@ export async function discoverModelsForRoute( } const ttlMs = getDiscoveryCacheTtlMs(routeId) - const cacheKey = getDiscoveryCacheKey(routeId, options) - if (!options?.forceRefresh && ttlMs > 0) { + const discoveryOptions = await withXaiDiscoveryCredentials(routeId, options) + const cacheKey = getDiscoveryCacheKey(routeId, discoveryOptions) + if (!discoveryOptions.forceRefresh && ttlMs > 0) { const cached = await getCachedModels(cacheKey, ttlMs) if (cached) { return { @@ -399,7 +418,7 @@ export async function discoverModelsForRoute( } try { - const discovered = await runDiscovery(routeId, options) + const discovered = await runDiscovery(routeId, discoveryOptions) if (discovered === null) { throw new Error(`Discovery failed for route ${routeId}`) } @@ -458,7 +477,8 @@ export async function refreshStartupDiscoveryForRoute( } const ttlMs = getDiscoveryCacheTtlMs(routeId) - const cacheKey = getDiscoveryCacheKey(routeId, options) + const discoveryOptions = await withXaiDiscoveryCredentials(routeId, options) + const cacheKey = getDiscoveryCacheKey(routeId, discoveryOptions) if (ttlMs > 0) { const cached = await getCachedModels(cacheKey, ttlMs) if (cached) { @@ -473,7 +493,7 @@ export async function refreshStartupDiscoveryForRoute( } return discoverModelsForRoute(routeId, { - ...options, + ...discoveryOptions, forceRefresh: true, }) } diff --git a/src/integrations/gateways/atlas-cloud.ts b/src/integrations/gateways/atlas-cloud.ts index 20a3826f6e..d7d27c469e 100644 --- a/src/integrations/gateways/atlas-cloud.ts +++ b/src/integrations/gateways/atlas-cloud.ts @@ -52,7 +52,7 @@ export default defineGateway({ { id: 'google/gemini-3.1-pro-preview', apiName: 'google/gemini-3.1-pro-preview', modelDescriptorId: 'google/gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview', contextWindow: 1_000_000, maxOutputTokens: 64_000, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], wireFormat: 'reasoning_effort' } }, { id: 'google/gemini-3.1-flash-lite', apiName: 'google/gemini-3.1-flash-lite', modelDescriptorId: 'google/gemini-3.1-flash-lite', label: 'Gemini 3.1 Flash Lite', contextWindow: 1_048_576, maxOutputTokens: 65_536 }, { id: 'xai/grok-4.6', apiName: 'xai/grok-4.6', aliases: ['grok-4.6', 'grok-4.6-latest'], modelDescriptorId: 'grok-4.6', label: 'Grok 4.6', contextWindow: 500_000, maxOutputTokens: 32_768, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } }, - { id: 'xai/grok-4.5', apiName: 'xai/grok-4.5', aliases: ['grok-4.5', 'grok-4.5-latest'], modelDescriptorId: 'grok-4.5', label: 'Grok 4.5', contextWindow: 500_000, maxOutputTokens: 32_768, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } }, + { id: 'xai/grok-4.5', apiName: 'xai/grok-4.5', aliases: ['grok-4.5', 'grok-4.5-latest', 'grok-build-latest'], modelDescriptorId: 'grok-4.5', label: 'Grok 4.5', contextWindow: 500_000, maxOutputTokens: 32_768, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } }, { id: 'xai/grok-4.3', apiName: 'xai/grok-4.3', aliases: ['grok-4.3', 'grok-4.3-latest', 'grok-latest', 'grok-4', 'grok-3'], modelDescriptorId: 'grok-4.3', label: 'Grok 4.3', contextWindow: 1_000_000, maxOutputTokens: 32_768, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high'], wireFormat: 'reasoning_effort' } }, { id: 'xai/grok-build-0.1', apiName: 'xai/grok-build-0.1', aliases: ['grok-build-0.1', 'grok-code-fast-1', 'grok-code-fast', 'grok-code-fast-1-0825'], modelDescriptorId: 'xai/grok-build-0.1', label: 'Grok Build 0.1', contextWindow: 256_000, maxOutputTokens: 64_000, capabilities: { supportsReasoning: false }, transportOverrides: { openaiShim: { removeBodyFields: ['reasoning_effort'] } } }, { id: 'moonshotai/kimi-k2.7-code', apiName: 'moonshotai/kimi-k2.7-code', aliases: ['kimi-k2.7-code'], modelDescriptorId: 'kimi-k2.7-code', label: 'Kimi K2.7 Code', contextWindow: 262_144, maxOutputTokens: 32_768, capabilities: { supportsVision: true, supportsFunctionCalling: true, supportsJsonMode: true, supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high'], defaultLevel: 'medium', wireFormat: 'reasoning_effort' } }, diff --git a/src/integrations/gateways/hicap.ts b/src/integrations/gateways/hicap.ts index f289c83541..2c96197baf 100644 --- a/src/integrations/gateways/hicap.ts +++ b/src/integrations/gateways/hicap.ts @@ -63,8 +63,8 @@ export default defineGateway({ { id: 'hicap-glm-5.2', apiName: 'glm-5.2', aliases: ['zai-org/glm-5.2'], label: 'GLM 5.2', modelDescriptorId: 'glm-5.2', contextWindow: 1_000_000, maxOutputTokens: 131_072, capabilities: { supportsFunctionCalling: true, supportsJsonMode: true, supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], defaultLevel: 'high', wireFormat: 'zai_compatible' }, transportOverrides: { openaiShim: { preserveReasoningContent: true, requireReasoningContentOnAssistantMessages: true, reasoningContentFallback: '', thinkingRequestFormat: 'zai-compatible', maxTokensField: 'max_tokens', removeBodyFields: ['store'], enableToolStreaming: true } } }, { id: 'hicap-gpt-5.4', apiName: 'gpt-5.4', label: 'GPT-5.4', modelDescriptorId: 'gpt-5.4', contextWindow: 1_050_000, maxOutputTokens: 128_000, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], defaultLevel: 'high', wireFormat: 'reasoning_effort' }, transportOverrides: { openaiShim: { requiredApiFormat: 'responses', maxTokensField: 'max_completion_tokens' } } }, { id: 'hicap-gpt-5.5', apiName: 'gpt-5.5', label: 'GPT-5.5', modelDescriptorId: 'gpt-5.5', contextWindow: 1_050_000, maxOutputTokens: 128_000, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], defaultLevel: 'high', wireFormat: 'reasoning_effort' }, transportOverrides: { openaiShim: { requiredApiFormat: 'responses', maxTokensField: 'max_completion_tokens' } } }, - { id: 'hicap-grok-4.6', apiName: 'grok-4.6', label: 'Grok 4.6', modelDescriptorId: 'grok-4.6', contextWindow: 500_000, maxOutputTokens: 32_768, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } }, - { id: 'hicap-grok-4.5', apiName: 'grok-4.5', label: 'Grok 4.5', modelDescriptorId: 'grok-4.5', contextWindow: 500_000, maxOutputTokens: 32_768, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } }, + { id: 'hicap-grok-4.6', apiName: 'grok-4.6', aliases: ['grok-4.6-latest'], label: 'Grok 4.6', modelDescriptorId: 'grok-4.6', contextWindow: 500_000, maxOutputTokens: 32_768, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } }, + { id: 'hicap-grok-4.5', apiName: 'grok-4.5', aliases: ['grok-4.5-latest', 'grok-build-latest'], label: 'Grok 4.5', modelDescriptorId: 'grok-4.5', contextWindow: 500_000, maxOutputTokens: 32_768, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } }, { id: 'hicap-grok-4.3', apiName: 'grok-4.3', label: 'Grok 4.3', modelDescriptorId: 'grok-4.3', contextWindow: 1_000_000, maxOutputTokens: 32_768, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } }, { id: 'hicap-kimi-k2.7-code', apiName: 'kimi-k2.7-code', label: 'Kimi K2.7 Code', modelDescriptorId: 'kimi-k2.7-code', contextWindow: 262_144, maxOutputTokens: 262_144, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh', 'max'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } }, { id: 'hicap-minimax-m3', apiName: 'minimax-m3', label: 'MiniMax M3', modelDescriptorId: 'minimax-m3', contextWindow: 1_048_576, maxOutputTokens: 131_072, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh', 'max'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } }, diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index 705f09c592..fd4930b22a 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -462,6 +462,17 @@ describe('resolveOpenAIShimRuntimeContext - Moonshot and Kimi Code catalog metad expect(result.catalogEntry?.reasoning?.levels).toEqual(['low', 'medium', 'high']) expect(result.catalogEntry?.reasoning?.defaultLevel).toBe('medium') }) + + it('resolves the official Grok 4.5 grok-build-latest alias on Atlas Cloud', () => { + const result = resolveOpenAIShimRuntimeContext({ + model: 'grok-build-latest', + baseUrl: 'https://api.atlascloud.ai/v1', + processEnv: { CLAUDE_CODE_USE_OPENAI: '1' }, + }) + expect(result.routeId).toBe('atlas-cloud') + expect(result.catalogEntry?.id).toBe('xai/grok-4.5') + expect(result.catalogEntry?.reasoning?.levels).toEqual(['low', 'medium', 'high']) + }) }) describe('resolveOpenAIShimRuntimeContext - GLM catalog-aware gating', () => { @@ -659,6 +670,20 @@ describe('resolveOpenAIShimRuntimeContext - Hicap catalog metadata', () => { 'xhigh', ]) + const grok46Latest = resolveOpenAIShimRuntimeContext({ + model: 'grok-4.6-latest', + baseUrl: 'https://api.hicap.ai/v1', + processEnv: { CLAUDE_CODE_USE_OPENAI: '1' }, + }) + expect(grok46Latest.catalogEntry?.id).toBe('hicap-grok-4.6') + + const grokBuildLatest = resolveOpenAIShimRuntimeContext({ + model: 'grok-build-latest', + baseUrl: 'https://api.hicap.ai/v1', + processEnv: { CLAUDE_CODE_USE_OPENAI: '1' }, + }) + expect(grokBuildLatest.catalogEntry?.id).toBe('hicap-grok-4.5') + const grok = resolveOpenAIShimRuntimeContext({ model: 'grok-4.3', baseUrl: 'https://api.hicap.ai/v1', diff --git a/src/integrations/vendors/xai.test.ts b/src/integrations/vendors/xai.test.ts index 9521b91843..8501efb655 100644 --- a/src/integrations/vendors/xai.test.ts +++ b/src/integrations/vendors/xai.test.ts @@ -1,5 +1,13 @@ -import { describe, expect, test } from 'bun:test' +import { describe, expect, mock, spyOn, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' import xai from './xai.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' +import { setClaudeConfigHomeDirForTesting } from '../../utils/envUtils.js' const mapModel = xai.catalog?.discovery?.mapModel @@ -36,7 +44,6 @@ describe('xAI vendor hybrid catalog', () => { 'grok-build-0.1', 'grok-4.20-0309-reasoning', 'grok-4.20-multi-agent-0309', - 'latest', ] for (const id of keep) { expect(mapModel(shape(id))).toEqual({ @@ -63,11 +70,36 @@ describe('xAI vendor hybrid catalog', () => { } }) - test('drops inactive entries and missing ids', () => { + test('drops curated aliases so hybrid merge does not duplicate them', () => { + if (!mapModel) throw new Error('mapModel missing') + const drop = [ + 'latest', + 'grok-4.6-latest', + 'grok-4.5-latest', + 'grok-build-latest', + 'grok-latest', + 'grok-code-fast-1', + ] + for (const id of drop) { + expect(mapModel(shape(id))).toBeNull() + } + }) + + test('drops inactive entries, missing ids, and non-positive context windows', () => { if (!mapModel) throw new Error('mapModel missing') expect(mapModel(shape('grok-4.6', { active: false }))).toBeNull() expect(mapModel({})).toBeNull() expect(mapModel({ id: '' })).toBeNull() + expect(mapModel(shape('grok-4.7', { context_window: 0 }))).toEqual({ + id: 'grok-4.7', + apiName: 'grok-4.7', + label: 'grok-4.7', + }) + expect(mapModel(shape('grok-4.7', { context_window: Number.NaN }))).toEqual({ + id: 'grok-4.7', + apiName: 'grok-4.7', + label: 'grok-4.7', + }) }) test('forwards context_window when present', () => { @@ -79,4 +111,58 @@ describe('xAI vendor hybrid catalog', () => { contextWindow: 500000, }) }) + + test('hybrid discovery authenticates with xAI OAuth when env keys are absent', async () => { + await acquireSharedMutationLock('xai.test.ts-oauth-discovery') + const originalFetch = globalThis.fetch + const originalXaiKey = process.env.XAI_API_KEY + const originalOpenAIKey = process.env.OPENAI_API_KEY + const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-xai-discovery-')) + const xaiCredentials = await import('../../utils/xaiCredentials.js') + const spy = spyOn(xaiCredentials, 'resolveXaiAccessToken').mockImplementation( + async () => 'oauth-token', + ) + setClaudeConfigHomeDirForTesting(tempDir) + try { + delete process.env.XAI_API_KEY + delete process.env.OPENAI_API_KEY + let authHeader: string | null = null + globalThis.fetch = mock((_input, init) => { + authHeader = new Headers(init?.headers as HeadersInit | undefined).get( + 'Authorization', + ) + return Promise.resolve( + new Response(JSON.stringify({ data: [{ id: 'grok-4.7' }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + }) as unknown as typeof globalThis.fetch + + const { discoverModelsForRoute } = await import( + `../discoveryService.js?ts=${Date.now()}-${Math.random()}` + ) + const result = await discoverModelsForRoute('xai', { forceRefresh: true }) + expect(authHeader).toBe('Bearer oauth-token') + expect(result?.source).toBe('network') + expect(result?.models.map(model => model.apiName)).toContain('grok-4.6') + expect(result?.models.map(model => model.apiName)).toContain('grok-4.7') + } finally { + spy.mockRestore() + globalThis.fetch = originalFetch + setClaudeConfigHomeDirForTesting(undefined) + if (originalXaiKey === undefined) { + delete process.env.XAI_API_KEY + } else { + process.env.XAI_API_KEY = originalXaiKey + } + if (originalOpenAIKey === undefined) { + delete process.env.OPENAI_API_KEY + } else { + process.env.OPENAI_API_KEY = originalOpenAIKey + } + rmSync(tempDir, { recursive: true, force: true }) + releaseSharedMutationLock() + } + }) }) diff --git a/src/integrations/vendors/xai.ts b/src/integrations/vendors/xai.ts index a1b5fa140e..e365532cb1 100644 --- a/src/integrations/vendors/xai.ts +++ b/src/integrations/vendors/xai.ts @@ -1,10 +1,121 @@ import { defineVendor } from '../define.js' +import type { ModelCatalogEntry } from '../descriptors.js' // Keep chat/coding Grok IDs from https://api.x.ai/v1/models. Imagine, voice, // STT/TTS, and embedding entries are not usable on the OpenClaude chat path. const XAI_NON_CHAT_PATTERN = /(imagine|voice|tts|stt|whisper|embed|speech-to-speech|speech-to-text|text-to-speech)/i +const XAI_CURATED_MODELS: ModelCatalogEntry[] = [ + { + id: 'grok-4.6', + apiName: 'grok-4.6', + aliases: ['grok-4.6-latest'], + label: 'Grok 4.6', + modelDescriptorId: 'grok-4.6', + contextWindow: 500_000, + maxOutputTokens: 32_768, + reasoning: { + mode: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], + defaultLevel: 'high', + wireFormat: 'reasoning_effort', + }, + }, + { + id: 'grok-4.5', + apiName: 'grok-4.5', + aliases: ['grok-4.5-latest', 'grok-build-latest'], + label: 'Grok 4.5', + modelDescriptorId: 'grok-4.5', + contextWindow: 500_000, + maxOutputTokens: 32_768, + reasoning: { + mode: 'levels', + levels: ['low', 'medium', 'high'], + defaultLevel: 'high', + wireFormat: 'reasoning_effort', + }, + }, + { + id: 'grok-4.3', + apiName: 'grok-4.3', + aliases: ['grok-4.3-latest', 'grok-latest', 'grok-4', 'grok-3'], + label: 'Grok 4.3', + modelDescriptorId: 'grok-4.3', + contextWindow: 1_000_000, + maxOutputTokens: 32_768, + reasoning: { mode: 'levels', levels: ['low', 'medium', 'high'], wireFormat: 'reasoning_effort' }, + }, + { + id: 'grok-build-0.1', + apiName: 'grok-build-0.1', + aliases: ['grok-code-fast-1', 'grok-code-fast', 'grok-code-fast-1-0825'], + label: 'Grok Build 0.1', + modelDescriptorId: 'xai/grok-build-0.1', + contextWindow: 256_000, + maxOutputTokens: 64_000, + capabilities: { supportsReasoning: false }, + transportOverrides: { openaiShim: { endpointPath: '/responses', removeBodyFields: ['reasoning_effort'] } }, + }, + { + id: 'grok-4.20-0309-reasoning', + apiName: 'grok-4.20-0309-reasoning', + aliases: [ + 'grok-4.20-reasoning-latest', + 'grok-4.20', + 'grok-4.20-reasoning', + 'grok-4.20-0309', + 'grok-4.20-beta-0309-reasoning', + 'grok-4.20-beta', + 'grok-4.20-beta-0309', + 'grok-4.20-beta-latest', + 'grok-4.20-beta-latest-reasoning', + 'grok-4.20-beta-reasoning', + 'grok-4.20-experimental-beta-0304-reasoning', + 'grok-4.20-experimental-beta-0304', + 'grok-4.20-experimental-beta-reasoning-latest', + 'grok-4.20-experimental-beta-latest', + 'grok-4.20-reasoning-gv2', + ], + label: 'Grok 4.20 Reasoning', + modelDescriptorId: 'grok-4.20-0309-reasoning', + contextWindow: 1_000_000, + maxOutputTokens: 32_768, + reasoning: { mode: 'always-on', wireFormat: 'none' }, + transportOverrides: { openaiShim: { endpointPath: '/responses', removeBodyFields: ['reasoning_effort'] } }, + }, + { + id: 'grok-4.20-0309-non-reasoning', + apiName: 'grok-4.20-0309-non-reasoning', + aliases: [ + 'grok-4.20-non-reasoning', + 'grok-4.20-non-reasoning-latest', + 'grok-4.20-beta-non-reasoning', + 'grok-4.20-beta-latest-non-reasoning', + 'grok-4.20-experimental-beta-0304-non-reasoning', + 'grok-4.20-experimental-beta-non-reasoning-latest', + 'grok-4.20-beta-0309-non-reasoning', + 'grok-4.20-non-reasoning-gv2', + ], + label: 'Grok 4.20 Non-Reasoning', + modelDescriptorId: 'grok-4.20-0309-non-reasoning', + contextWindow: 1_000_000, + maxOutputTokens: 32_768, + capabilities: { supportsReasoning: false }, + }, +] + +const XAI_DISCOVERY_SUPPRESSED_IDS = new Set( + ['latest', ...XAI_CURATED_MODELS.flatMap(model => model.aliases ?? [])].map( + id => id.toLowerCase(), + ), +) + +function isPositiveFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 +} + export default defineVendor({ id: 'xai', label: 'xAI', @@ -58,14 +169,17 @@ export default defineVendor({ if (XAI_NON_CHAT_PATTERN.test(id)) { return null } - if (!/^grok/i.test(id) && id.toLowerCase() !== 'latest') { + if (!/^grok/i.test(id)) { + return null + } + if (XAI_DISCOVERY_SUPPRESSED_IDS.has(id.toLowerCase())) { return null } return { id, apiName: id, label: id, - ...(typeof model.context_window === 'number' + ...(isPositiveFiniteNumber(model.context_window) ? { contextWindow: model.context_window } : {}), } @@ -74,105 +188,7 @@ export default defineVendor({ discoveryCacheTtl: '1d', discoveryRefreshMode: 'background-if-stale', allowManualRefresh: true, - models: [ - { - id: 'grok-4.6', - apiName: 'grok-4.6', - aliases: ['grok-4.6-latest'], - label: 'Grok 4.6', - modelDescriptorId: 'grok-4.6', - contextWindow: 500_000, - maxOutputTokens: 32_768, - reasoning: { - mode: 'levels', - levels: ['low', 'medium', 'high', 'xhigh'], - defaultLevel: 'high', - wireFormat: 'reasoning_effort', - }, - }, - { - id: 'grok-4.5', - apiName: 'grok-4.5', - aliases: ['grok-4.5-latest', 'grok-build-latest'], - label: 'Grok 4.5', - modelDescriptorId: 'grok-4.5', - contextWindow: 500_000, - maxOutputTokens: 32_768, - reasoning: { - mode: 'levels', - levels: ['low', 'medium', 'high'], - defaultLevel: 'high', - wireFormat: 'reasoning_effort', - }, - }, - { - id: 'grok-4.3', - apiName: 'grok-4.3', - aliases: ['grok-4.3-latest', 'grok-latest', 'grok-4', 'grok-3'], - label: 'Grok 4.3', - modelDescriptorId: 'grok-4.3', - contextWindow: 1_000_000, - maxOutputTokens: 32_768, - reasoning: { mode: 'levels', levels: ['low', 'medium', 'high'], wireFormat: 'reasoning_effort' }, - }, - { - id: 'grok-build-0.1', - apiName: 'grok-build-0.1', - aliases: ['grok-code-fast-1', 'grok-code-fast', 'grok-code-fast-1-0825'], - label: 'Grok Build 0.1', - modelDescriptorId: 'xai/grok-build-0.1', - contextWindow: 256_000, - maxOutputTokens: 64_000, - capabilities: { supportsReasoning: false }, - transportOverrides: { openaiShim: { endpointPath: '/responses', removeBodyFields: ['reasoning_effort'] } }, - }, - { - id: 'grok-4.20-0309-reasoning', - apiName: 'grok-4.20-0309-reasoning', - aliases: [ - 'grok-4.20-reasoning-latest', - 'grok-4.20', - 'grok-4.20-reasoning', - 'grok-4.20-0309', - 'grok-4.20-beta-0309-reasoning', - 'grok-4.20-beta', - 'grok-4.20-beta-0309', - 'grok-4.20-beta-latest', - 'grok-4.20-beta-latest-reasoning', - 'grok-4.20-beta-reasoning', - 'grok-4.20-experimental-beta-0304-reasoning', - 'grok-4.20-experimental-beta-0304', - 'grok-4.20-experimental-beta-reasoning-latest', - 'grok-4.20-experimental-beta-latest', - 'grok-4.20-reasoning-gv2', - ], - label: 'Grok 4.20 Reasoning', - modelDescriptorId: 'grok-4.20-0309-reasoning', - contextWindow: 1_000_000, - maxOutputTokens: 32_768, - reasoning: { mode: 'always-on', wireFormat: 'none' }, - transportOverrides: { openaiShim: { endpointPath: '/responses', removeBodyFields: ['reasoning_effort'] } }, - }, - { - id: 'grok-4.20-0309-non-reasoning', - apiName: 'grok-4.20-0309-non-reasoning', - aliases: [ - 'grok-4.20-non-reasoning', - 'grok-4.20-non-reasoning-latest', - 'grok-4.20-beta-non-reasoning', - 'grok-4.20-beta-latest-non-reasoning', - 'grok-4.20-experimental-beta-0304-non-reasoning', - 'grok-4.20-experimental-beta-non-reasoning-latest', - 'grok-4.20-beta-0309-non-reasoning', - 'grok-4.20-non-reasoning-gv2', - ], - label: 'Grok 4.20 Non-Reasoning', - modelDescriptorId: 'grok-4.20-0309-non-reasoning', - contextWindow: 1_000_000, - maxOutputTokens: 32_768, - capabilities: { supportsReasoning: false }, - }, - ], + models: XAI_CURATED_MODELS, }, usage: { supported: false }, }) From 8cd790592f5218989bf74d4a650cac78939d5c53 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 12 Aug 2026 11:04:20 -0700 Subject: [PATCH 03/13] fix(xai): align OAuth hybrid discovery cache with runtime metadata OAuth-only xAI sessions hashed the access token into discovery writes, but runtime limit lookups only used env credentials, so uncataloged Grok IDs fell back to default windows. Mirror stored OAuth on cache reads and isolate discovery tests from the shared config home. --- src/integrations/discoveryService.test.ts | 3 ++ src/integrations/discoveryService.ts | 4 +- src/integrations/runtimeMetadata.test.ts | 62 ++++++++++++++++++++++- src/integrations/runtimeMetadata.ts | 22 +++++--- src/integrations/vendors/xai.test.ts | 17 ++++--- 5 files changed, 93 insertions(+), 15 deletions(-) diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index 06e0e70f0a..96947ad210 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -8,6 +8,7 @@ import { releaseSharedMutationLock, } from '../test/sharedMutationLock.js' import { publicBuildVersion } from '../utils/version.js' +import { setClaudeConfigHomeDirForTesting } from '../utils/envUtils.js' const originalFetch = globalThis.fetch const originalEnv = { @@ -76,6 +77,7 @@ beforeEach(async () => { await acquireSharedMutationLock('discoveryService.test.ts') mock.restore() tempDir = mkdtempSync(join(tmpdir(), 'openclaude-discovery-service-test-')) + setClaudeConfigHomeDirForTesting(tempDir) process.env.CLAUDE_CONFIG_DIR = tempDir delete process.env.OPENROUTER_API_KEY delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC @@ -88,6 +90,7 @@ afterEach(() => { mock.restore() globalThis.fetch = originalFetch rmSync(tempDir, { recursive: true, force: true }) + setClaudeConfigHomeDirForTesting(undefined) restoreEnvValue('CLAUDE_CONFIG_DIR') restoreEnvValue('OPENROUTER_API_KEY') restoreEnvValue('OPENAI_BASE_URL') diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index 491f84f8b7..e59d4e078a 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -16,6 +16,7 @@ import { resolveRouteIdFromBaseUrl } from './index.js' import { getRouteDescriptor, isCanonicalApismartInferenceBaseUrl, + isXaiBaseUrl, resolveActiveRouteIdFromEnv, resolveRouteCredentialValue, } from './routeMetadata.js' @@ -196,7 +197,8 @@ async function withXaiDiscoveryCredentials< if (getRouteDiscoveryApiKey(routeId, next)) { return next } - if (routeId !== 'xai') { + const baseUrl = getRouteBaseUrl(routeId, next) + if (routeId !== 'xai' && !isXaiBaseUrl(baseUrl)) { return next } const token = firstUsableCredential(await resolveXaiAccessToken()) diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index fd4930b22a..df3bf64339 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, it, expect } from 'bun:test' +import { describe, it, expect, spyOn } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock, @@ -163,6 +163,66 @@ describe('resolveModelRuntimeLimits', () => { ).toBe(2_000_000) }) }) + + it('uses stored xAI OAuth credentials when reading discovered runtime limits', async () => { + await withTempConfigDir(async () => { + const originalXaiKey = process.env.XAI_API_KEY + const originalOpenAIKey = process.env.OPENAI_API_KEY + const xaiCredentials = await import('../utils/xaiCredentials.js') + const readSpy = spyOn(xaiCredentials, 'readXaiCredentials').mockImplementation( + () => + ({ + accessToken: 'oauth-token', + refreshToken: 'refresh-token', + tokenEndpoint: 'https://auth.x.ai/oauth/token', + }) as ReturnType, + ) + try { + delete process.env.XAI_API_KEY + delete process.env.OPENAI_API_KEY + const baseUrl = 'https://api.x.ai/v1' + await setCachedModels( + getDiscoveryCacheKey('xai', { + baseUrl, + apiKey: 'oauth-token', + }), + { + models: [ + { + id: 'grok-4.7', + apiName: 'grok-4.7', + label: 'grok-4.7', + contextWindow: 500_000, + }, + ], + }, + ) + + expect( + resolveModelRuntimeLimits({ + model: 'grok-4.7', + baseUrl, + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: baseUrl, + }, + }).contextWindow, + ).toBe(500_000) + } finally { + readSpy.mockRestore() + if (originalXaiKey === undefined) { + delete process.env.XAI_API_KEY + } else { + process.env.XAI_API_KEY = originalXaiKey + } + if (originalOpenAIKey === undefined) { + delete process.env.OPENAI_API_KEY + } else { + process.env.OPENAI_API_KEY = originalOpenAIKey + } + } + }) + }) }) describe('AIMLAPI runtime attribution', () => { diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index d5bdcf51aa..208e28a708 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -19,6 +19,7 @@ import { } from './registry.js' import { getRouteDescriptor, + isXaiBaseUrl, resolveRouteCredentialValue, resolveActiveRouteIdFromEnv, resolveRouteIdFromBaseUrl, @@ -26,6 +27,7 @@ import { } from './routeMetadata.js' import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' import { firstUsableCredential } from '../services/api/credentialPool.js' +import { readXaiCredentials } from '../utils/xaiCredentials.js' import { ZAI_GLM_OPENAI_SHIM } from './transport/zaiGlmShim.js' import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js' @@ -454,15 +456,21 @@ function findCachedCatalogEntryForApiName( } const baseUrl = runtimeEnv.OPENAI_BASE_URL ?? runtimeEnv.OPENAI_API_BASE + let apiKey = firstUsableCredential( + resolveRouteCredentialValue({ + routeId, + baseUrl, + processEnv: runtimeEnv, + }), + ) + // Mirror picker/discovery OAuth injection so hybrid cache partitions match + // for xAI sessions that have a stored token but no env API key. + if (!apiKey && (routeId === 'xai' || isXaiBaseUrl(baseUrl))) { + apiKey = firstUsableCredential(readXaiCredentials()?.accessToken) + } const cacheKey = getDiscoveryCacheKey(routeId, { baseUrl, - apiKey: firstUsableCredential( - resolveRouteCredentialValue({ - routeId, - baseUrl, - processEnv: runtimeEnv, - }), - ), + apiKey, headers: parseCustomHeadersEnv(runtimeEnv.ANTHROPIC_CUSTOM_HEADERS), }) const cached = getCachedModelsSync(cacheKey, getDiscoveryCacheTtlMs(routeId)) diff --git a/src/integrations/vendors/xai.test.ts b/src/integrations/vendors/xai.test.ts index 8501efb655..8b66f14e42 100644 --- a/src/integrations/vendors/xai.test.ts +++ b/src/integrations/vendors/xai.test.ts @@ -119,7 +119,7 @@ describe('xAI vendor hybrid catalog', () => { const originalOpenAIKey = process.env.OPENAI_API_KEY const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-xai-discovery-')) const xaiCredentials = await import('../../utils/xaiCredentials.js') - const spy = spyOn(xaiCredentials, 'resolveXaiAccessToken').mockImplementation( + const tokenSpy = spyOn(xaiCredentials, 'resolveXaiAccessToken').mockImplementation( async () => 'oauth-token', ) setClaudeConfigHomeDirForTesting(tempDir) @@ -132,10 +132,15 @@ describe('xAI vendor hybrid catalog', () => { 'Authorization', ) return Promise.resolve( - new Response(JSON.stringify({ data: [{ id: 'grok-4.7' }] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), + new Response( + JSON.stringify({ + data: [{ id: 'grok-4.7', context_window: 500000 }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ), ) }) as unknown as typeof globalThis.fetch @@ -148,7 +153,7 @@ describe('xAI vendor hybrid catalog', () => { expect(result?.models.map(model => model.apiName)).toContain('grok-4.6') expect(result?.models.map(model => model.apiName)).toContain('grok-4.7') } finally { - spy.mockRestore() + tokenSpy.mockRestore() globalThis.fetch = originalFetch setClaudeConfigHomeDirForTesting(undefined) if (originalXaiKey === undefined) { From b71c9af8cfb439367ff38a16d98605058bbc13d6 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 12 Aug 2026 11:44:07 -0700 Subject: [PATCH 04/13] fix(xai): keep Grok 4.6 PR on catalog and hybrid discovery Move OAuth /v1/models auth and cache-key alignment out of this branch so the catalog, xAI hybrid vendor, and gateway references stay reviewable on their own. --- src/commands/model/model.tsx | 43 ++++++--------- src/integrations/discoveryService.ts | 32 ++--------- src/integrations/runtimeMetadata.test.ts | 62 +-------------------- src/integrations/runtimeMetadata.ts | 22 +++----- src/integrations/vendors/xai.test.ts | 69 +----------------------- 5 files changed, 29 insertions(+), 199 deletions(-) diff --git a/src/commands/model/model.tsx b/src/commands/model/model.tsx index 136e5c61ca..52fe0f42a7 100644 --- a/src/commands/model/model.tsx +++ b/src/commands/model/model.tsx @@ -21,7 +21,6 @@ import { import { getRouteDescriptor, isNativeVendorCatalogRoute, - isXaiBaseUrl, resolveRouteCredentialValue, resolveActiveRouteIdFromEnv, resolveRouteIdFromBaseUrl, @@ -36,7 +35,6 @@ import { resolveProviderRequest, } from '../../services/api/providerConfig.js' import { firstUsableCredential } from '../../services/api/credentialPool.js' -import { resolveXaiAccessToken } from '../../utils/xaiCredentials.js' import type { ProviderProfile } from '../../utils/config.js' import type { AppState } from '../../state/AppState.js' import { useAppState, useSetAppState } from '../../state/AppState.js' @@ -368,29 +366,24 @@ function withInactiveProfileSwitchOptions( return additions.length > 0 ? [...options, ...additions] : options } -async function getOpenAIDiscoveryRequestOptions(routeId?: string | null): Promise<{ +function getOpenAIDiscoveryRequestOptions(routeId?: string | null): { apiKey?: string baseUrl?: string headers?: Record -}> { +} { const request = resolveProviderRequest({ model: process.env.OPENAI_MODEL, baseUrl: process.env.OPENAI_BASE_URL, }) - let apiKey = firstUsableCredential( - resolveRouteCredentialValue({ - routeId, - baseUrl: request.baseUrl, - processEnv: process.env, - }), - ) - if (!apiKey && (routeId === 'xai' || isXaiBaseUrl(request.baseUrl))) { - apiKey = firstUsableCredential(await resolveXaiAccessToken()) - } - return { - apiKey, + apiKey: firstUsableCredential( + resolveRouteCredentialValue({ + routeId, + baseUrl: request.baseUrl, + processEnv: process.env, + }), + ), baseUrl: request.baseUrl, headers: parseCustomHeadersEnv(process.env.ANTHROPIC_CUSTOM_HEADERS), } @@ -506,7 +499,7 @@ async function loadDescriptorDiscoveryContext( } const ttlMs = parseDurationString(catalog.discoveryCacheTtl ?? 0) - const discoveryOptions = await getOpenAIDiscoveryRequestOptions(routeId) + const discoveryOptions = getOpenAIDiscoveryRequestOptions(routeId) const cacheKey = getDiscoveryCacheKey(routeId, discoveryOptions) const cached = await getCachedModels(cacheKey, ttlMs, { includeStale: true }) const stale = await isCacheStale(cacheKey, ttlMs) @@ -570,7 +563,7 @@ async function loadModelDiscoveryContext(): Promise { } if (discoveryContext.kind === 'descriptor') { - const discoveryOptions = await getOpenAIDiscoveryRequestOptions( - discoveryContext.routeId, - ) await clearDiscoveryCache( getDiscoveryCacheKey( discoveryContext.routeId, - discoveryOptions, + getOpenAIDiscoveryRequestOptions(discoveryContext.routeId), ), ) const result = await discoverModelsForRoute(discoveryContext.routeId, { - ...discoveryOptions, + ...getOpenAIDiscoveryRequestOptions(discoveryContext.routeId), forceRefresh: true, }) const nextOptions = mergeActiveProfileModelOptions( diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index e59d4e078a..326d5c84be 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -16,7 +16,6 @@ import { resolveRouteIdFromBaseUrl } from './index.js' import { getRouteDescriptor, isCanonicalApismartInferenceBaseUrl, - isXaiBaseUrl, resolveActiveRouteIdFromEnv, resolveRouteCredentialValue, } from './routeMetadata.js' @@ -33,7 +32,6 @@ import { } from '../utils/providerDiscovery.js' import { firstUsableCredential, hasInvalidCredentialPlaceholder } from '../services/api/credentialPool.js' import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' -import { resolveXaiAccessToken } from '../utils/xaiCredentials.js' import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js' import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' @@ -190,24 +188,6 @@ function getRouteDiscoveryApiKey( ) } -async function withXaiDiscoveryCredentials< - T extends { apiKey?: string; baseUrl?: string; headers?: Record }, ->(routeId: string, options?: T): Promise { - const next = { ...(options ?? {}) } as T - if (getRouteDiscoveryApiKey(routeId, next)) { - return next - } - const baseUrl = getRouteBaseUrl(routeId, next) - if (routeId !== 'xai' && !isXaiBaseUrl(baseUrl)) { - return next - } - const token = firstUsableCredential(await resolveXaiAccessToken()) - if (token) { - next.apiKey = token - } - return next -} - export function getRouteDiscoveryHeaders( routeId: string, options?: { baseUrl?: string; headers?: Record }, @@ -377,9 +357,8 @@ export async function discoverModelsForRoute( } const ttlMs = getDiscoveryCacheTtlMs(routeId) - const discoveryOptions = await withXaiDiscoveryCredentials(routeId, options) - const cacheKey = getDiscoveryCacheKey(routeId, discoveryOptions) - if (!discoveryOptions.forceRefresh && ttlMs > 0) { + const cacheKey = getDiscoveryCacheKey(routeId, options) + if (!options?.forceRefresh && ttlMs > 0) { const cached = await getCachedModels(cacheKey, ttlMs) if (cached) { return { @@ -420,7 +399,7 @@ export async function discoverModelsForRoute( } try { - const discovered = await runDiscovery(routeId, discoveryOptions) + const discovered = await runDiscovery(routeId, options) if (discovered === null) { throw new Error(`Discovery failed for route ${routeId}`) } @@ -479,8 +458,7 @@ export async function refreshStartupDiscoveryForRoute( } const ttlMs = getDiscoveryCacheTtlMs(routeId) - const discoveryOptions = await withXaiDiscoveryCredentials(routeId, options) - const cacheKey = getDiscoveryCacheKey(routeId, discoveryOptions) + const cacheKey = getDiscoveryCacheKey(routeId, options) if (ttlMs > 0) { const cached = await getCachedModels(cacheKey, ttlMs) if (cached) { @@ -495,7 +473,7 @@ export async function refreshStartupDiscoveryForRoute( } return discoverModelsForRoute(routeId, { - ...discoveryOptions, + ...options, forceRefresh: true, }) } diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index df3bf64339..fd4930b22a 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, it, expect, spyOn } from 'bun:test' +import { describe, it, expect } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock, @@ -163,66 +163,6 @@ describe('resolveModelRuntimeLimits', () => { ).toBe(2_000_000) }) }) - - it('uses stored xAI OAuth credentials when reading discovered runtime limits', async () => { - await withTempConfigDir(async () => { - const originalXaiKey = process.env.XAI_API_KEY - const originalOpenAIKey = process.env.OPENAI_API_KEY - const xaiCredentials = await import('../utils/xaiCredentials.js') - const readSpy = spyOn(xaiCredentials, 'readXaiCredentials').mockImplementation( - () => - ({ - accessToken: 'oauth-token', - refreshToken: 'refresh-token', - tokenEndpoint: 'https://auth.x.ai/oauth/token', - }) as ReturnType, - ) - try { - delete process.env.XAI_API_KEY - delete process.env.OPENAI_API_KEY - const baseUrl = 'https://api.x.ai/v1' - await setCachedModels( - getDiscoveryCacheKey('xai', { - baseUrl, - apiKey: 'oauth-token', - }), - { - models: [ - { - id: 'grok-4.7', - apiName: 'grok-4.7', - label: 'grok-4.7', - contextWindow: 500_000, - }, - ], - }, - ) - - expect( - resolveModelRuntimeLimits({ - model: 'grok-4.7', - baseUrl, - processEnv: { - CLAUDE_CODE_USE_OPENAI: '1', - OPENAI_BASE_URL: baseUrl, - }, - }).contextWindow, - ).toBe(500_000) - } finally { - readSpy.mockRestore() - if (originalXaiKey === undefined) { - delete process.env.XAI_API_KEY - } else { - process.env.XAI_API_KEY = originalXaiKey - } - if (originalOpenAIKey === undefined) { - delete process.env.OPENAI_API_KEY - } else { - process.env.OPENAI_API_KEY = originalOpenAIKey - } - } - }) - }) }) describe('AIMLAPI runtime attribution', () => { diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 208e28a708..d5bdcf51aa 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -19,7 +19,6 @@ import { } from './registry.js' import { getRouteDescriptor, - isXaiBaseUrl, resolveRouteCredentialValue, resolveActiveRouteIdFromEnv, resolveRouteIdFromBaseUrl, @@ -27,7 +26,6 @@ import { } from './routeMetadata.js' import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' import { firstUsableCredential } from '../services/api/credentialPool.js' -import { readXaiCredentials } from '../utils/xaiCredentials.js' import { ZAI_GLM_OPENAI_SHIM } from './transport/zaiGlmShim.js' import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js' @@ -456,21 +454,15 @@ function findCachedCatalogEntryForApiName( } const baseUrl = runtimeEnv.OPENAI_BASE_URL ?? runtimeEnv.OPENAI_API_BASE - let apiKey = firstUsableCredential( - resolveRouteCredentialValue({ - routeId, - baseUrl, - processEnv: runtimeEnv, - }), - ) - // Mirror picker/discovery OAuth injection so hybrid cache partitions match - // for xAI sessions that have a stored token but no env API key. - if (!apiKey && (routeId === 'xai' || isXaiBaseUrl(baseUrl))) { - apiKey = firstUsableCredential(readXaiCredentials()?.accessToken) - } const cacheKey = getDiscoveryCacheKey(routeId, { baseUrl, - apiKey, + apiKey: firstUsableCredential( + resolveRouteCredentialValue({ + routeId, + baseUrl, + processEnv: runtimeEnv, + }), + ), headers: parseCustomHeadersEnv(runtimeEnv.ANTHROPIC_CUSTOM_HEADERS), }) const cached = getCachedModelsSync(cacheKey, getDiscoveryCacheTtlMs(routeId)) diff --git a/src/integrations/vendors/xai.test.ts b/src/integrations/vendors/xai.test.ts index 8b66f14e42..56f775f6a3 100644 --- a/src/integrations/vendors/xai.test.ts +++ b/src/integrations/vendors/xai.test.ts @@ -1,13 +1,5 @@ -import { describe, expect, mock, spyOn, test } from 'bun:test' -import { mkdtempSync, rmSync } from 'fs' -import { tmpdir } from 'os' -import { join } from 'path' +import { describe, expect, test } from 'bun:test' import xai from './xai.js' -import { - acquireSharedMutationLock, - releaseSharedMutationLock, -} from '../../test/sharedMutationLock.js' -import { setClaudeConfigHomeDirForTesting } from '../../utils/envUtils.js' const mapModel = xai.catalog?.discovery?.mapModel @@ -111,63 +103,4 @@ describe('xAI vendor hybrid catalog', () => { contextWindow: 500000, }) }) - - test('hybrid discovery authenticates with xAI OAuth when env keys are absent', async () => { - await acquireSharedMutationLock('xai.test.ts-oauth-discovery') - const originalFetch = globalThis.fetch - const originalXaiKey = process.env.XAI_API_KEY - const originalOpenAIKey = process.env.OPENAI_API_KEY - const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-xai-discovery-')) - const xaiCredentials = await import('../../utils/xaiCredentials.js') - const tokenSpy = spyOn(xaiCredentials, 'resolveXaiAccessToken').mockImplementation( - async () => 'oauth-token', - ) - setClaudeConfigHomeDirForTesting(tempDir) - try { - delete process.env.XAI_API_KEY - delete process.env.OPENAI_API_KEY - let authHeader: string | null = null - globalThis.fetch = mock((_input, init) => { - authHeader = new Headers(init?.headers as HeadersInit | undefined).get( - 'Authorization', - ) - return Promise.resolve( - new Response( - JSON.stringify({ - data: [{ id: 'grok-4.7', context_window: 500000 }], - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }, - ), - ) - }) as unknown as typeof globalThis.fetch - - const { discoverModelsForRoute } = await import( - `../discoveryService.js?ts=${Date.now()}-${Math.random()}` - ) - const result = await discoverModelsForRoute('xai', { forceRefresh: true }) - expect(authHeader).toBe('Bearer oauth-token') - expect(result?.source).toBe('network') - expect(result?.models.map(model => model.apiName)).toContain('grok-4.6') - expect(result?.models.map(model => model.apiName)).toContain('grok-4.7') - } finally { - tokenSpy.mockRestore() - globalThis.fetch = originalFetch - setClaudeConfigHomeDirForTesting(undefined) - if (originalXaiKey === undefined) { - delete process.env.XAI_API_KEY - } else { - process.env.XAI_API_KEY = originalXaiKey - } - if (originalOpenAIKey === undefined) { - delete process.env.OPENAI_API_KEY - } else { - process.env.OPENAI_API_KEY = originalOpenAIKey - } - rmSync(tempDir, { recursive: true, force: true }) - releaseSharedMutationLock() - } - }) }) From 5331a8adb85e78f5346db0e4419f43606898ef00 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 12 Aug 2026 12:38:01 -0700 Subject: [PATCH 05/13] fix(xai): read discovered model context length --- src/integrations/vendors/xai.test.ts | 10 +++++----- src/integrations/vendors/xai.ts | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/integrations/vendors/xai.test.ts b/src/integrations/vendors/xai.test.ts index 56f775f6a3..955376c950 100644 --- a/src/integrations/vendors/xai.test.ts +++ b/src/integrations/vendors/xai.test.ts @@ -77,26 +77,26 @@ describe('xAI vendor hybrid catalog', () => { } }) - test('drops inactive entries, missing ids, and non-positive context windows', () => { + test('drops inactive entries, missing ids, and non-positive context limits', () => { if (!mapModel) throw new Error('mapModel missing') expect(mapModel(shape('grok-4.6', { active: false }))).toBeNull() expect(mapModel({})).toBeNull() expect(mapModel({ id: '' })).toBeNull() - expect(mapModel(shape('grok-4.7', { context_window: 0 }))).toEqual({ + expect(mapModel(shape('grok-4.7', { context_length: 0 }))).toEqual({ id: 'grok-4.7', apiName: 'grok-4.7', label: 'grok-4.7', }) - expect(mapModel(shape('grok-4.7', { context_window: Number.NaN }))).toEqual({ + expect(mapModel(shape('grok-4.7', { context_length: Number.NaN }))).toEqual({ id: 'grok-4.7', apiName: 'grok-4.7', label: 'grok-4.7', }) }) - test('forwards context_window when present', () => { + test('forwards the /v1/models context_length when present', () => { if (!mapModel) throw new Error('mapModel missing') - expect(mapModel(shape('grok-4.6', { context_window: 500000 }))).toEqual({ + expect(mapModel(shape('grok-4.6', { context_length: 500000 }))).toEqual({ id: 'grok-4.6', apiName: 'grok-4.6', label: 'grok-4.6', diff --git a/src/integrations/vendors/xai.ts b/src/integrations/vendors/xai.ts index e365532cb1..9e94d68512 100644 --- a/src/integrations/vendors/xai.ts +++ b/src/integrations/vendors/xai.ts @@ -160,7 +160,7 @@ export default defineVendor({ const model = raw as { id?: string active?: boolean - context_window?: number + context_length?: number } const id = model.id?.trim() if (!id || model.active === false) { @@ -179,8 +179,8 @@ export default defineVendor({ id, apiName: id, label: id, - ...(isPositiveFiniteNumber(model.context_window) - ? { contextWindow: model.context_window } + ...(isPositiveFiniteNumber(model.context_length) + ? { contextWindow: model.context_length } : {}), } }, From ed9c38c881f623ceab1bf4d10ceaee599ca3b9e8 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 12 Aug 2026 12:44:27 -0700 Subject: [PATCH 06/13] fix(xai): preserve OAuth discovery metadata --- src/integrations/discoveryService.ts | 22 +++++++++++++++++----- src/integrations/models/xai.ts | 1 - src/integrations/routeMetadata.ts | 10 ++++++++++ src/integrations/runtimeMetadata.test.ts | 2 +- src/integrations/runtimeMetadata.ts | 16 +++++++++------- src/integrations/vendors/xai.ts | 1 - src/utils/context.test.ts | 6 +----- 7 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index 326d5c84be..d1ff9046c0 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -16,6 +16,7 @@ import { resolveRouteIdFromBaseUrl } from './index.js' import { getRouteDescriptor, isCanonicalApismartInferenceBaseUrl, + isCanonicalXaiInferenceBaseUrl, resolveActiveRouteIdFromEnv, resolveRouteCredentialValue, } from './routeMetadata.js' @@ -34,6 +35,7 @@ import { firstUsableCredential, hasInvalidCredentialPlaceholder } from '../servi import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js' import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' +import { resolveXaiAccessToken } from '../utils/xaiCredentials.js' export type RouteDiscoveryResult = { routeId: string @@ -188,6 +190,14 @@ function getRouteDiscoveryApiKey( ) } +async function withXaiDiscoveryCredentials }>(routeId: string, options?: T): Promise { + const next = { ...(options ?? {}) } as T + if (getRouteDiscoveryApiKey(routeId, next) || routeId !== 'xai' || !isCanonicalXaiInferenceBaseUrl(getRouteBaseUrl(routeId, next))) return next + const token = firstUsableCredential(await resolveXaiAccessToken()) + if (token) next.apiKey = token + return next +} + export function getRouteDiscoveryHeaders( routeId: string, options?: { baseUrl?: string; headers?: Record }, @@ -357,8 +367,9 @@ export async function discoverModelsForRoute( } const ttlMs = getDiscoveryCacheTtlMs(routeId) - const cacheKey = getDiscoveryCacheKey(routeId, options) - if (!options?.forceRefresh && ttlMs > 0) { + const discoveryOptions = await withXaiDiscoveryCredentials(routeId, options) + const cacheKey = getDiscoveryCacheKey(routeId, discoveryOptions) + if (!discoveryOptions.forceRefresh && ttlMs > 0) { const cached = await getCachedModels(cacheKey, ttlMs) if (cached) { return { @@ -399,7 +410,7 @@ export async function discoverModelsForRoute( } try { - const discovered = await runDiscovery(routeId, options) + const discovered = await runDiscovery(routeId, discoveryOptions) if (discovered === null) { throw new Error(`Discovery failed for route ${routeId}`) } @@ -458,7 +469,8 @@ export async function refreshStartupDiscoveryForRoute( } const ttlMs = getDiscoveryCacheTtlMs(routeId) - const cacheKey = getDiscoveryCacheKey(routeId, options) + const discoveryOptions = await withXaiDiscoveryCredentials(routeId, options) + const cacheKey = getDiscoveryCacheKey(routeId, discoveryOptions) if (ttlMs > 0) { const cached = await getCachedModels(cacheKey, ttlMs) if (cached) { @@ -473,7 +485,7 @@ export async function refreshStartupDiscoveryForRoute( } return discoverModelsForRoute(routeId, { - ...options, + ...discoveryOptions, forceRefresh: true, }) } diff --git a/src/integrations/models/xai.ts b/src/integrations/models/xai.ts index c1e8409567..36d668360d 100644 --- a/src/integrations/models/xai.ts +++ b/src/integrations/models/xai.ts @@ -30,7 +30,6 @@ export default [ wireFormat: 'reasoning_effort', }, contextWindow: 500_000, - maxOutputTokens: 32_768, }), defineModel({ id: 'grok-4.5', diff --git a/src/integrations/routeMetadata.ts b/src/integrations/routeMetadata.ts index e46984b0ce..99260dbf6e 100644 --- a/src/integrations/routeMetadata.ts +++ b/src/integrations/routeMetadata.ts @@ -276,6 +276,16 @@ export function isXaiBaseUrl(value: string | undefined): boolean { } } +export function isCanonicalXaiInferenceBaseUrl(value: string | undefined): boolean { + if (!value?.trim()) return true + try { + const parsed = new URL(value) + return parsed.protocol === 'https:' && parsed.hostname.toLowerCase() === 'api.x.ai' + } catch { + return false + } +} + export function isXiaomiMimoBaseUrl(value: string | undefined): boolean { const trimmed = value?.trim() if (!trimmed) { diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index fd4930b22a..ca9e2311b5 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -705,7 +705,7 @@ describe('resolveOpenAIShimRuntimeContext - xAI catalog metadata', () => { baseUrl: 'https://api.x.ai/v1', processEnv: { CLAUDE_CODE_USE_OPENAI: '1' }, }), - ).toEqual({ contextWindow: 500_000, maxOutputTokens: 32_768 }) + ).toEqual({ contextWindow: 500_000 }) const grok46 = resolveOpenAIShimRuntimeContext({ model: 'grok-4.6-latest', diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index d5bdcf51aa..3b02061da7 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -19,6 +19,7 @@ import { } from './registry.js' import { getRouteDescriptor, + isCanonicalXaiInferenceBaseUrl, resolveRouteCredentialValue, resolveActiveRouteIdFromEnv, resolveRouteIdFromBaseUrl, @@ -27,6 +28,7 @@ import { import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' import { firstUsableCredential } from '../services/api/credentialPool.js' import { ZAI_GLM_OPENAI_SHIM } from './transport/zaiGlmShim.js' +import { readXaiCredentials } from '../utils/xaiCredentials.js' import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js' function resolveRouteOpenAIShimConfig( @@ -454,15 +456,15 @@ function findCachedCatalogEntryForApiName( } const baseUrl = runtimeEnv.OPENAI_BASE_URL ?? runtimeEnv.OPENAI_API_BASE + let apiKey = firstUsableCredential( + resolveRouteCredentialValue({ routeId, baseUrl, processEnv: runtimeEnv }), + ) + if (!apiKey && routeId === 'xai' && isCanonicalXaiInferenceBaseUrl(baseUrl)) { + apiKey = firstUsableCredential(readXaiCredentials()?.accessToken) + } const cacheKey = getDiscoveryCacheKey(routeId, { baseUrl, - apiKey: firstUsableCredential( - resolveRouteCredentialValue({ - routeId, - baseUrl, - processEnv: runtimeEnv, - }), - ), + apiKey, headers: parseCustomHeadersEnv(runtimeEnv.ANTHROPIC_CUSTOM_HEADERS), }) const cached = getCachedModelsSync(cacheKey, getDiscoveryCacheTtlMs(routeId)) diff --git a/src/integrations/vendors/xai.ts b/src/integrations/vendors/xai.ts index 9e94d68512..71bbecb8fc 100644 --- a/src/integrations/vendors/xai.ts +++ b/src/integrations/vendors/xai.ts @@ -14,7 +14,6 @@ const XAI_CURATED_MODELS: ModelCatalogEntry[] = [ label: 'Grok 4.6', modelDescriptorId: 'grok-4.6', contextWindow: 500_000, - maxOutputTokens: 32_768, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], diff --git a/src/utils/context.test.ts b/src/utils/context.test.ts index 340e9316c2..8ce2bd2b3e 100644 --- a/src/utils/context.test.ts +++ b/src/utils/context.test.ts @@ -526,11 +526,7 @@ test('env-only xAI key uses provider-specific context and output caps before cli delete process.env.OPENAI_MODEL expect(getContextWindowForModel('grok-4.6')).toBe(500_000) - expect(getModelMaxOutputTokens('grok-4.6')).toEqual({ - default: 32_768, - upperLimit: 32_768, - }) - expect(getMaxOutputTokensForModel('grok-4.6')).toBe(32_768) + expect(getModelMaxOutputTokens('grok-4.6').upperLimit).toBe(500_000) expect(getContextWindowForModel('grok-4.5')).toBe(500_000) expect(getContextWindowForModel('grok-4.3')).toBe(1_000_000) expect(getModelMaxOutputTokens('grok-4.3')).toEqual({ From 02d82d9315d4c082f2b655d401943e3464ec40e4 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 12 Aug 2026 13:08:26 -0700 Subject: [PATCH 07/13] fix(xai): tolerate malformed discovery models --- src/integrations/discoveryService.ts | 2 ++ src/integrations/vendors/xai.test.ts | 2 ++ src/integrations/vendors/xai.ts | 5 ++++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index d1ff9046c0..8df3ef1a6a 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -118,6 +118,8 @@ function normalizeDiscoveryCacheHeaders( } function hashDiscoveryCachePartition(value: unknown): string { + // codeql[js/insufficient-password-hash]: This is a non-reversible cache + // namespace fingerprint for high-entropy API credentials, never password storage. return createHash('sha256') .update(JSON.stringify(value)) .digest('hex') diff --git a/src/integrations/vendors/xai.test.ts b/src/integrations/vendors/xai.test.ts index 955376c950..dfe7ad6a5d 100644 --- a/src/integrations/vendors/xai.test.ts +++ b/src/integrations/vendors/xai.test.ts @@ -80,7 +80,9 @@ describe('xAI vendor hybrid catalog', () => { test('drops inactive entries, missing ids, and non-positive context limits', () => { if (!mapModel) throw new Error('mapModel missing') expect(mapModel(shape('grok-4.6', { active: false }))).toBeNull() + expect(mapModel(null)).toBeNull() expect(mapModel({})).toBeNull() + expect(mapModel({ id: 1 })).toBeNull() expect(mapModel({ id: '' })).toBeNull() expect(mapModel(shape('grok-4.7', { context_length: 0 }))).toEqual({ id: 'grok-4.7', diff --git a/src/integrations/vendors/xai.ts b/src/integrations/vendors/xai.ts index 71bbecb8fc..99e3a2dff0 100644 --- a/src/integrations/vendors/xai.ts +++ b/src/integrations/vendors/xai.ts @@ -156,12 +156,15 @@ export default defineVendor({ discovery: { kind: 'openai-compatible', mapModel(raw: unknown) { + if (!raw || typeof raw !== 'object') { + return null + } const model = raw as { id?: string active?: boolean context_length?: number } - const id = model.id?.trim() + const id = typeof model.id === 'string' ? model.id.trim() : '' if (!id || model.active === false) { return null } From 063238c22f5286e28add2a6ba2ee13fe1844cb68 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 12 Aug 2026 13:17:08 -0700 Subject: [PATCH 08/13] fix(discovery): harden credential cache partitions --- src/integrations/discoveryService.ts | 32 ++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index 8df3ef1a6a..f504cfa0b8 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto' +import { pbkdf2Sync } from 'node:crypto' import { getCachedModels, isCacheStale, @@ -117,13 +117,31 @@ function normalizeDiscoveryCacheHeaders( .sort(([leftName], [rightName]) => leftName.localeCompare(rightName)) } +const DISCOVERY_CACHE_PARTITION_SALT = 'openclaude:discovery-cache:v1' +const DISCOVERY_CACHE_PARTITION_ITERATIONS = 120_000 +const DISCOVERY_CACHE_PARTITION_CACHE_LIMIT = 64 +const discoveryCachePartitions = new Map() + function hashDiscoveryCachePartition(value: unknown): string { - // codeql[js/insufficient-password-hash]: This is a non-reversible cache - // namespace fingerprint for high-entropy API credentials, never password storage. - return createHash('sha256') - .update(JSON.stringify(value)) - .digest('hex') - .slice(0, 16) + const serialized = JSON.stringify(value) + const cached = discoveryCachePartitions.get(serialized) + if (cached) return cached + + // Discovery results can be account-specific, so a credential is used only + // to derive an opaque cache namespace. Use a password KDF in case a cache + // filename is exposed; never persist the credential itself. + const partition = pbkdf2Sync( + serialized, + DISCOVERY_CACHE_PARTITION_SALT, + DISCOVERY_CACHE_PARTITION_ITERATIONS, + 16, + 'sha256', + ).toString('hex') + if (discoveryCachePartitions.size >= DISCOVERY_CACHE_PARTITION_CACHE_LIMIT) { + discoveryCachePartitions.delete(discoveryCachePartitions.keys().next().value!) + } + discoveryCachePartitions.set(serialized, partition) + return partition } export function getDiscoveryCacheKey( From 3bb0cd18d7acd5bf94639a8993d4fa79c65a839c Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 12 Aug 2026 17:09:43 -0700 Subject: [PATCH 09/13] fix(xai): unify discovery OAuth cache handling --- src/commands/model/model.tsx | 33 +++++++---- src/integrations/discoveryService.test.ts | 45 +++++++++++++- src/integrations/discoveryService.ts | 71 ++++++++++++++++++----- src/integrations/runtimeMetadata.test.ts | 46 ++++++++++++++- src/integrations/runtimeMetadata.ts | 6 +- src/integrations/vendors/xai.test.ts | 4 +- src/integrations/vendors/xai.ts | 2 +- 7 files changed, 178 insertions(+), 29 deletions(-) diff --git a/src/commands/model/model.tsx b/src/commands/model/model.tsx index 52fe0f42a7..2d8c579c77 100644 --- a/src/commands/model/model.tsx +++ b/src/commands/model/model.tsx @@ -17,6 +17,7 @@ import { filterAvailableCatalogEntries } from '../../integrations/index.js' import { discoverModelsForRoute, getDiscoveryCacheKey, + resolveDiscoveryRequestOptions, } from '../../integrations/discoveryService.js' import { getRouteDescriptor, @@ -366,17 +367,21 @@ function withInactiveProfileSwitchOptions( return additions.length > 0 ? [...options, ...additions] : options } -function getOpenAIDiscoveryRequestOptions(routeId?: string | null): { +async function getOpenAIDiscoveryRequestOptions( + routeId?: string | null, + options?: { refreshXaiOAuth?: boolean }, +): Promise<{ apiKey?: string + cacheKey?: string baseUrl?: string headers?: Record -} { +}> { const request = resolveProviderRequest({ model: process.env.OPENAI_MODEL, baseUrl: process.env.OPENAI_BASE_URL, }) - return { + return resolveDiscoveryRequestOptions(routeId ?? 'custom', { apiKey: firstUsableCredential( resolveRouteCredentialValue({ routeId, @@ -386,7 +391,7 @@ function getOpenAIDiscoveryRequestOptions(routeId?: string | null): { ), baseUrl: request.baseUrl, headers: parseCustomHeadersEnv(process.env.ANTHROPIC_CUSTOM_HEADERS), - } + }, options) } // Reconciles fast-mode state when /model picks a new target — both the regular @@ -499,7 +504,9 @@ async function loadDescriptorDiscoveryContext( } const ttlMs = parseDurationString(catalog.discoveryCacheTtl ?? 0) - const discoveryOptions = getOpenAIDiscoveryRequestOptions(routeId) + const discoveryOptions = await getOpenAIDiscoveryRequestOptions(routeId, { + refreshXaiOAuth: false, + }) const cacheKey = getDiscoveryCacheKey(routeId, discoveryOptions) const cached = await getCachedModels(cacheKey, ttlMs, { includeStale: true }) const stale = await isCacheStale(cacheKey, ttlMs) @@ -563,7 +570,7 @@ async function loadModelDiscoveryContext(): Promise { } if (discoveryContext.kind === 'descriptor') { + const discoveryOptions = await getOpenAIDiscoveryRequestOptions( + discoveryContext.routeId, + ) await clearDiscoveryCache( getDiscoveryCacheKey( discoveryContext.routeId, - getOpenAIDiscoveryRequestOptions(discoveryContext.routeId), + discoveryOptions, ), ) const result = await discoverModelsForRoute(discoveryContext.routeId, { - ...getOpenAIDiscoveryRequestOptions(discoveryContext.routeId), + ...discoveryOptions, forceRefresh: true, }) const nextOptions = mergeActiveProfileModelOptions( diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index 96947ad210..7d4b4dc169 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -1,7 +1,8 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test' import { mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' +import { setCachedModels } from './discoveryCache.js' import { _clearRegistryForTesting, ensureIntegrationsLoaded, registerGateway } from './index.js' import { acquireSharedMutationLock, @@ -608,6 +609,48 @@ describe('discoverModelsForRoute', () => { expect(globalThis.fetch).not.toHaveBeenCalled() }) + test('reads xAI OAuth cache identity without refreshing when discovery traffic is disabled', async () => { + process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1' + const xaiCredentials = await import('../utils/xaiCredentials.js') + const readSpy = spyOn(xaiCredentials, 'readXaiCredentials').mockReturnValue({ + accessToken: 'cached-oauth-token', + refreshToken: 'stable-account-identity', + tokenEndpoint: 'https://auth.x.ai/oauth/token', + }) + const refreshSpy = spyOn(xaiCredentials, 'resolveXaiAccessToken').mockResolvedValue( + 'refreshed-oauth-token', + ) + try { + const { discoverModelsForRoute, getDiscoveryCacheKey } = + await loadDiscoveryServiceModule() + await setCachedModels( + getDiscoveryCacheKey('xai', { + baseUrl: 'https://api.x.ai/v1', + apiKey: 'cached-oauth-token', + cacheKey: 'stable-account-identity', + }), + { + models: [ + { + id: 'grok-4.7', + apiName: 'grok-4.7', + label: 'grok-4.7', + }, + ], + }, + ) + + const result = await discoverModelsForRoute('xai', { forceRefresh: true }) + + expect(result?.source).toBe('cache') + expect(result?.models.map(model => model.apiName)).toContain('grok-4.7') + expect(refreshSpy).not.toHaveBeenCalled() + } finally { + readSpy.mockRestore() + refreshSpy.mockRestore() + } + }) + test('startup refresh mode performs discovery for startup routes and then reuses cache', async () => { const { refreshStartupDiscoveryForRoute } = await loadDiscoveryServiceModule() diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index f504cfa0b8..0bca4e71c7 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -35,7 +35,10 @@ import { firstUsableCredential, hasInvalidCredentialPlaceholder } from '../servi import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js' import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' -import { resolveXaiAccessToken } from '../utils/xaiCredentials.js' +import { + readXaiCredentials, + resolveXaiAccessToken, +} from '../utils/xaiCredentials.js' export type RouteDiscoveryResult = { routeId: string @@ -149,14 +152,16 @@ export function getDiscoveryCacheKey( options?: { baseUrl?: string apiKey?: string + cacheKey?: string headers?: Record }, ): string { const discoveryApiKey = getRouteDiscoveryApiKey(routeId, options) + const cacheIdentity = options?.cacheKey ?? discoveryApiKey const partition = { baseUrl: normalizeDiscoveryCacheBaseUrl(getRouteBaseUrl(routeId, options)), - apiKeyHash: discoveryApiKey - ? hashDiscoveryCachePartition(discoveryApiKey) + apiKeyHash: cacheIdentity + ? hashDiscoveryCachePartition(cacheIdentity) : '', headers: normalizeDiscoveryCacheHeaders( getRouteDiscoveryHeaders(routeId, options), @@ -210,11 +215,40 @@ function getRouteDiscoveryApiKey( ) } -async function withXaiDiscoveryCredentials }>(routeId: string, options?: T): Promise { +export async function resolveDiscoveryRequestOptions< + T extends { + apiKey?: string + cacheKey?: string + baseUrl?: string + headers?: Record + }, +>( + routeId: string, + options?: T, + resolverOptions?: { refreshXaiOAuth?: boolean }, +): Promise { const next = { ...(options ?? {}) } as T - if (getRouteDiscoveryApiKey(routeId, next) || routeId !== 'xai' || !isCanonicalXaiInferenceBaseUrl(getRouteBaseUrl(routeId, next))) return next - const token = firstUsableCredential(await resolveXaiAccessToken()) - if (token) next.apiKey = token + if (getRouteDiscoveryApiKey(routeId, next) || routeId !== 'xai') { + return next + } + + if (!isCanonicalXaiInferenceBaseUrl(getRouteBaseUrl(routeId, next))) { + return next + } + + const credentials = readXaiCredentials() + const token = firstUsableCredential( + shouldSkipNonessentialDiscoveryTraffic() || + resolverOptions?.refreshXaiOAuth === false + ? credentials?.accessToken + : await resolveXaiAccessToken(), + ) + if (token) { + next.apiKey = token + // The access token can rotate while the OAuth account does not. Keep the + // cache partition tied to a stable account identity, not a bearer token. + next.cacheKey = credentials?.accountId ?? credentials?.refreshToken ?? token + } return next } @@ -387,9 +421,14 @@ export async function discoverModelsForRoute( } const ttlMs = getDiscoveryCacheTtlMs(routeId) - const discoveryOptions = await withXaiDiscoveryCredentials(routeId, options) - const cacheKey = getDiscoveryCacheKey(routeId, discoveryOptions) - if (!discoveryOptions.forceRefresh && ttlMs > 0) { + // Cache-only reads must not refresh an OAuth token: discovery can be + // disabled by privacy policy, and a refresh can rotate the bearer before a + // fresh cached result is checked. + const cachedOptions = await resolveDiscoveryRequestOptions(routeId, options, { + refreshXaiOAuth: false, + }) + const cacheKey = getDiscoveryCacheKey(routeId, cachedOptions) + if (!cachedOptions.forceRefresh && ttlMs > 0) { const cached = await getCachedModels(cacheKey, ttlMs) if (cached) { return { @@ -430,12 +469,14 @@ export async function discoverModelsForRoute( } try { + const discoveryOptions = await resolveDiscoveryRequestOptions(routeId, options) + const discoveryCacheKey = getDiscoveryCacheKey(routeId, discoveryOptions) const discovered = await runDiscovery(routeId, discoveryOptions) if (discovered === null) { throw new Error(`Discovery failed for route ${routeId}`) } - await setCachedModels(cacheKey, { models: discovered }) + await setCachedModels(discoveryCacheKey, { models: discovered }) return { routeId, models: mergeCatalogEntries(staticEntries, discovered), @@ -489,8 +530,10 @@ export async function refreshStartupDiscoveryForRoute( } const ttlMs = getDiscoveryCacheTtlMs(routeId) - const discoveryOptions = await withXaiDiscoveryCredentials(routeId, options) - const cacheKey = getDiscoveryCacheKey(routeId, discoveryOptions) + const cachedOptions = await resolveDiscoveryRequestOptions(routeId, options, { + refreshXaiOAuth: false, + }) + const cacheKey = getDiscoveryCacheKey(routeId, cachedOptions) if (ttlMs > 0) { const cached = await getCachedModels(cacheKey, ttlMs) if (cached) { @@ -505,7 +548,7 @@ export async function refreshStartupDiscoveryForRoute( } return discoverModelsForRoute(routeId, { - ...discoveryOptions, + ...options, forceRefresh: true, }) } diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index ca9e2311b5..71338e16f5 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, it, expect } from 'bun:test' +import { describe, it, expect, spyOn } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock, @@ -73,6 +73,50 @@ describe('resolveModelRuntimeLimits', () => { ).toBe(1_000_000) }) }) + + it('uses the stable xAI OAuth cache identity for discovered runtime limits', async () => { + await withTempConfigDir(async () => { + const xaiCredentials = await import('../utils/xaiCredentials.js') + const readSpy = spyOn(xaiCredentials, 'readXaiCredentials').mockReturnValue({ + accessToken: 'rotating-access-token', + refreshToken: 'stable-account-identity', + tokenEndpoint: 'https://auth.x.ai/oauth/token', + }) + try { + const baseUrl = 'https://api.x.ai/v1' + await setCachedModels( + getDiscoveryCacheKey('xai', { + baseUrl, + apiKey: 'rotating-access-token', + cacheKey: 'stable-account-identity', + }), + { + models: [ + { + id: 'grok-4.7', + apiName: 'grok-4.7', + label: 'grok-4.7', + contextWindow: 500_000, + }, + ], + }, + ) + + expect( + resolveModelRuntimeLimits({ + model: 'grok-4.7', + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: baseUrl, + XAI_CREDENTIAL_SOURCE: 'oauth', + }, + }).contextWindow, + ).toBe(500_000) + } finally { + readSpy.mockRestore() + } + }) + }) it('uses built-in Z.AI GLM-5.2 runtime limits', () => { const limits = resolveModelRuntimeLimits({ model: 'glm-5.2', diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 3b02061da7..094c09733f 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -459,12 +459,16 @@ function findCachedCatalogEntryForApiName( let apiKey = firstUsableCredential( resolveRouteCredentialValue({ routeId, baseUrl, processEnv: runtimeEnv }), ) + let cacheIdentity: string | undefined if (!apiKey && routeId === 'xai' && isCanonicalXaiInferenceBaseUrl(baseUrl)) { - apiKey = firstUsableCredential(readXaiCredentials()?.accessToken) + const credentials = readXaiCredentials() + apiKey = firstUsableCredential(credentials?.accessToken) + cacheIdentity = credentials?.accountId ?? credentials?.refreshToken ?? apiKey } const cacheKey = getDiscoveryCacheKey(routeId, { baseUrl, apiKey, + cacheKey: cacheIdentity, headers: parseCustomHeadersEnv(runtimeEnv.ANTHROPIC_CUSTOM_HEADERS), }) const cached = getCachedModelsSync(cacheKey, getDiscoveryCacheTtlMs(routeId)) diff --git a/src/integrations/vendors/xai.test.ts b/src/integrations/vendors/xai.test.ts index dfe7ad6a5d..fd055f4153 100644 --- a/src/integrations/vendors/xai.test.ts +++ b/src/integrations/vendors/xai.test.ts @@ -35,7 +35,6 @@ describe('xAI vendor hybrid catalog', () => { 'grok-4.7', 'grok-build-0.1', 'grok-4.20-0309-reasoning', - 'grok-4.20-multi-agent-0309', ] for (const id of keep) { expect(mapModel(shape(id))).toEqual({ @@ -56,6 +55,9 @@ describe('xAI vendor hybrid catalog', () => { 'grok-voice-think-fast-2.0', 'grok-stt-1.0', 'grok-tts-1.0', + // This model requires xAI's Responses API and does not support the + // generic OpenAI-compatible chat transport used by live discovery. + 'grok-4.20-multi-agent-0309', ] for (const id of drop) { expect(mapModel(shape(id))).toBeNull() diff --git a/src/integrations/vendors/xai.ts b/src/integrations/vendors/xai.ts index 99e3a2dff0..dd03b05095 100644 --- a/src/integrations/vendors/xai.ts +++ b/src/integrations/vendors/xai.ts @@ -4,7 +4,7 @@ import type { ModelCatalogEntry } from '../descriptors.js' // Keep chat/coding Grok IDs from https://api.x.ai/v1/models. Imagine, voice, // STT/TTS, and embedding entries are not usable on the OpenClaude chat path. const XAI_NON_CHAT_PATTERN = - /(imagine|voice|tts|stt|whisper|embed|speech-to-speech|speech-to-text|text-to-speech)/i + /(imagine|voice|tts|stt|whisper|embed|speech-to-speech|speech-to-text|text-to-speech|multi-agent)/i const XAI_CURATED_MODELS: ModelCatalogEntry[] = [ { From 8f7ff932666842e5e817d4b43f238db62da74af7 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 12 Aug 2026 17:27:36 -0700 Subject: [PATCH 10/13] fix(xai): preserve OAuth discovery cache identity --- src/integrations/discoveryService.test.ts | 37 +++++++++++++++ src/integrations/discoveryService.ts | 50 ++++++++------------ src/integrations/runtimeMetadata.ts | 7 ++- src/utils/xaiCredentials.ts | 56 ++++++++++++++++++++--- 4 files changed, 111 insertions(+), 39 deletions(-) diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index 7d4b4dc169..9da47283bf 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -651,6 +651,43 @@ describe('discoverModelsForRoute', () => { } }) + test('uses the persisted xAI OAuth cache identity after token rotation', async () => { + const xaiCredentials = await import('../utils/xaiCredentials.js') + const initialCredentials = { + accessToken: 'old-access-token', + refreshToken: 'old-refresh-token', + tokenEndpoint: 'https://auth.x.ai/oauth/token', + } + const refreshedCredentials = { + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + cacheIdentity: 'old-refresh-token', + tokenEndpoint: 'https://auth.x.ai/oauth/token', + } + const readSpy = spyOn(xaiCredentials, 'readXaiCredentials') + .mockReturnValueOnce(initialCredentials) + .mockReturnValue(refreshedCredentials) + const refreshSpy = spyOn(xaiCredentials, 'resolveXaiAccessToken').mockResolvedValue( + 'new-access-token', + ) + try { + const { resolveDiscoveryRequestOptions } = + await loadDiscoveryServiceModule() + const result = await resolveDiscoveryRequestOptions('xai', { + baseUrl: 'https://api.x.ai/v1', + }) + + expect(result).toMatchObject({ + apiKey: 'new-access-token', + cacheKey: 'old-refresh-token', + }) + expect(refreshSpy).toHaveBeenCalledTimes(1) + } finally { + readSpy.mockRestore() + refreshSpy.mockRestore() + } + }) + test('startup refresh mode performs discovery for startup routes and then reuses cache', async () => { const { refreshStartupDiscoveryForRoute } = await loadDiscoveryServiceModule() diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index 0bca4e71c7..773ac9dc22 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -1,4 +1,4 @@ -import { pbkdf2Sync } from 'node:crypto' +import { createHash } from 'node:crypto' import { getCachedModels, isCacheStale, @@ -36,6 +36,7 @@ import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js' import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' import { + getXaiDiscoveryCacheIdentity, readXaiCredentials, resolveXaiAccessToken, } from '../utils/xaiCredentials.js' @@ -120,31 +121,15 @@ function normalizeDiscoveryCacheHeaders( .sort(([leftName], [rightName]) => leftName.localeCompare(rightName)) } -const DISCOVERY_CACHE_PARTITION_SALT = 'openclaude:discovery-cache:v1' -const DISCOVERY_CACHE_PARTITION_ITERATIONS = 120_000 -const DISCOVERY_CACHE_PARTITION_CACHE_LIMIT = 64 -const discoveryCachePartitions = new Map() - function hashDiscoveryCachePartition(value: unknown): string { - const serialized = JSON.stringify(value) - const cached = discoveryCachePartitions.get(serialized) - if (cached) return cached - // Discovery results can be account-specific, so a credential is used only - // to derive an opaque cache namespace. Use a password KDF in case a cache - // filename is exposed; never persist the credential itself. - const partition = pbkdf2Sync( - serialized, - DISCOVERY_CACHE_PARTITION_SALT, - DISCOVERY_CACHE_PARTITION_ITERATIONS, - 16, - 'sha256', - ).toString('hex') - if (discoveryCachePartitions.size >= DISCOVERY_CACHE_PARTITION_CACHE_LIMIT) { - discoveryCachePartitions.delete(discoveryCachePartitions.keys().next().value!) - } - discoveryCachePartitions.set(serialized, partition) - return partition + // to derive an opaque cache namespace. Credentials are high-entropy bearer + // secrets, not passwords, so a single hash avoids retaining them in memory + // and preserves cache keys created before this OAuth support was added. + return createHash('sha256') + .update(JSON.stringify(value)) + .digest('hex') + .slice(0, 16) } export function getDiscoveryCacheKey( @@ -236,18 +221,23 @@ export async function resolveDiscoveryRequestOptions< return next } - const credentials = readXaiCredentials() - const token = firstUsableCredential( + let credentials = readXaiCredentials() + const cacheOnly = shouldSkipNonessentialDiscoveryTraffic() || - resolverOptions?.refreshXaiOAuth === false - ? credentials?.accessToken - : await resolveXaiAccessToken(), + resolverOptions?.refreshXaiOAuth === false + const token = firstUsableCredential( + cacheOnly ? credentials?.accessToken : await resolveXaiAccessToken(), ) + if (!cacheOnly) { + // A refresh can rotate the refresh token. Re-read the persisted blob so + // discovery writes under the same stable identity subsequent readers use. + credentials = readXaiCredentials() ?? credentials + } if (token) { next.apiKey = token // The access token can rotate while the OAuth account does not. Keep the // cache partition tied to a stable account identity, not a bearer token. - next.cacheKey = credentials?.accountId ?? credentials?.refreshToken ?? token + next.cacheKey = getXaiDiscoveryCacheIdentity(credentials) ?? token } return next } diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 094c09733f..cc0af95203 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -28,7 +28,10 @@ import { import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' import { firstUsableCredential } from '../services/api/credentialPool.js' import { ZAI_GLM_OPENAI_SHIM } from './transport/zaiGlmShim.js' -import { readXaiCredentials } from '../utils/xaiCredentials.js' +import { + getXaiDiscoveryCacheIdentity, + readXaiCredentials, +} from '../utils/xaiCredentials.js' import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js' function resolveRouteOpenAIShimConfig( @@ -463,7 +466,7 @@ function findCachedCatalogEntryForApiName( if (!apiKey && routeId === 'xai' && isCanonicalXaiInferenceBaseUrl(baseUrl)) { const credentials = readXaiCredentials() apiKey = firstUsableCredential(credentials?.accessToken) - cacheIdentity = credentials?.accountId ?? credentials?.refreshToken ?? apiKey + cacheIdentity = getXaiDiscoveryCacheIdentity(credentials) ?? apiKey } const cacheKey = getDiscoveryCacheKey(routeId, { baseUrl, diff --git a/src/utils/xaiCredentials.ts b/src/utils/xaiCredentials.ts index 8d90946c3f..356a614642 100644 --- a/src/utils/xaiCredentials.ts +++ b/src/utils/xaiCredentials.ts @@ -25,6 +25,9 @@ const XAI_TOKEN_REFRESH_RETRY_COOLDOWN_MS = 60_000 export type XaiCredentialBlob = { accessToken: string refreshToken: string + // An opaque, persisted cache namespace. Unlike a bearer or a rotated refresh + // token, it remains stable for the lifetime of one OAuth login. + cacheIdentity?: string idToken?: string expiresAt?: number tokenEndpoint: string @@ -39,6 +42,8 @@ let inFlightXaiRefresh: | Promise<{ refreshed: boolean; credentials?: XaiCredentialBlob }> | null = null let inMemoryLastRefreshFailureAt: number | null = null +let cachedXaiCredentials: XaiCredentialBlob | undefined +let hasCachedXaiCredentials = false function getXaiSecureStorage() { return getSecureStorage({ allowPlainTextFallback: false }) @@ -56,6 +61,7 @@ function normalizeXaiCredentialBlob( return { accessToken, refreshToken, + cacheIdentity: asTrimmedString(record.cacheIdentity), tokenEndpoint, idToken: asTrimmedString(record.idToken), email: asTrimmedString(record.email), @@ -78,6 +84,25 @@ function normalizeXaiCredentialBlob( } } +function cacheXaiCredentials( + credentials: XaiCredentialBlob | undefined, +): XaiCredentialBlob | undefined { + cachedXaiCredentials = credentials + hasCachedXaiCredentials = true + return credentials +} + +export function getXaiDiscoveryCacheIdentity( + credentials: XaiCredentialBlob | undefined, +): string | undefined { + return ( + credentials?.cacheIdentity ?? + credentials?.accountId ?? + credentials?.refreshToken ?? + credentials?.accessToken + ) +} + function effectiveExpiresAt(blob: XaiCredentialBlob): number | undefined { return blob.expiresAt ?? deriveExpiresMsFromJwt(blob.accessToken) } @@ -102,11 +127,14 @@ function isWithinRefreshFailureCooldown( export function readXaiCredentials(): XaiCredentialBlob | undefined { if (isBareMode()) return undefined + if (hasCachedXaiCredentials) return cachedXaiCredentials try { const data = getXaiSecureStorage().read() - return normalizeXaiCredentialBlob(data?.[XAI_STORAGE_KEY]) + return cacheXaiCredentials( + normalizeXaiCredentialBlob(data?.[XAI_STORAGE_KEY]), + ) } catch { - return undefined + return cacheXaiCredentials(undefined) } } @@ -114,11 +142,14 @@ export async function readXaiCredentialsAsync(): Promise< XaiCredentialBlob | undefined > { if (isBareMode()) return undefined + if (hasCachedXaiCredentials) return cachedXaiCredentials try { const data = await getXaiSecureStorage().readAsync() - return normalizeXaiCredentialBlob(data?.[XAI_STORAGE_KEY]) + return cacheXaiCredentials( + normalizeXaiCredentialBlob(data?.[XAI_STORAGE_KEY]), + ) } catch { - return undefined + return cacheXaiCredentials(undefined) } } @@ -147,6 +178,7 @@ export function saveXaiCredentials( const result = storage.update(next as typeof previous) if (result.success) { const stored = normalizeXaiCredentialBlob(next[XAI_STORAGE_KEY]) + cacheXaiCredentials(stored) inMemoryLastRefreshFailureAt = stored?.lastRefreshFailureAt ?? null } return result @@ -162,7 +194,10 @@ export function clearXaiCredentials(): { const next = { ...(previous as Record) } delete next[XAI_STORAGE_KEY] const result = storage.update(next as typeof previous) - if (result.success) inMemoryLastRefreshFailureAt = null + if (result.success) { + cacheXaiCredentials(undefined) + inMemoryLastRefreshFailureAt = null + } return result } @@ -177,10 +212,17 @@ function persistRefreshFailure( if (!result.success) inMemoryLastRefreshFailureAt = occurredAt } -function toBlob(tokens: XaiOAuthTokens, previous?: XaiCredentialBlob): XaiCredentialBlob { +function toBlob( + tokens: XaiOAuthTokens, + previous?: XaiCredentialBlob, + options?: { preserveCacheIdentity?: boolean }, +): XaiCredentialBlob { return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken || previous?.refreshToken || '', + cacheIdentity: options?.preserveCacheIdentity + ? getXaiDiscoveryCacheIdentity(previous) + : tokens.accountId ?? tokens.refreshToken, tokenEndpoint: tokens.tokenEndpoint, idToken: tokens.idToken ?? previous?.idToken, email: tokens.email ?? previous?.email, @@ -220,7 +262,7 @@ export async function refreshXaiAccessTokenIfNeeded(options?: { refreshToken: current.refreshToken, tokenEndpoint: current.tokenEndpoint, }) - const next = toBlob(tokens, current) + const next = toBlob(tokens, current, { preserveCacheIdentity: true }) const save = saveXaiCredentials(next) if (!save.success) { throw new Error( From 9cc9dafeab6c084fb2300ab4f947e826c18b7802 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 12 Aug 2026 18:10:16 -0700 Subject: [PATCH 11/13] fix(discovery): use native cache namespace hashing --- src/integrations/discoveryService.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index 773ac9dc22..0c5d444216 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto' import { getCachedModels, isCacheStale, @@ -124,12 +123,12 @@ function normalizeDiscoveryCacheHeaders( function hashDiscoveryCachePartition(value: unknown): string { // Discovery results can be account-specific, so a credential is used only // to derive an opaque cache namespace. Credentials are high-entropy bearer - // secrets, not passwords, so a single hash avoids retaining them in memory - // and preserves cache keys created before this OAuth support was added. - return createHash('sha256') - .update(JSON.stringify(value)) - .digest('hex') - .slice(0, 16) + // secrets, not passwords. Use Bun's native SHA-256 implementation for this + // opaque filename namespace: it preserves the legacy digest format without + // treating a credential as a password-verification input. + const hasher = new Bun.CryptoHasher('sha256') + hasher.update(JSON.stringify(value)) + return hasher.digest('hex').slice(0, 16) } export function getDiscoveryCacheKey( From 3d43636f2f06abaa9919ec1b00f8f82ca9e89e6b Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 12 Aug 2026 18:43:33 -0700 Subject: [PATCH 12/13] fix(xai): keep OAuth discovery nonblocking --- src/integrations/discoveryService.test.ts | 8 +- src/integrations/discoveryService.ts | 21 +-- src/integrations/runtimeMetadata.test.ts | 5 +- src/integrations/runtimeMetadata.ts | 7 +- src/utils/secureStorage/linuxSecretStorage.ts | 22 ++- .../secureStorage/platformStorage.test.ts | 41 ++++- .../secureStorage/windowsCredentialStorage.ts | 147 +++++++++++------- src/utils/xaiCredentials.test.ts | 99 ++++++++++++ src/utils/xaiCredentials.ts | 65 ++++++-- 9 files changed, 320 insertions(+), 95 deletions(-) create mode 100644 src/utils/xaiCredentials.test.ts diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index 9da47283bf..4838f93a33 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -612,7 +612,7 @@ describe('discoverModelsForRoute', () => { test('reads xAI OAuth cache identity without refreshing when discovery traffic is disabled', async () => { process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1' const xaiCredentials = await import('../utils/xaiCredentials.js') - const readSpy = spyOn(xaiCredentials, 'readXaiCredentials').mockReturnValue({ + const readSpy = spyOn(xaiCredentials, 'readXaiCredentialsAsync').mockResolvedValue({ accessToken: 'cached-oauth-token', refreshToken: 'stable-account-identity', tokenEndpoint: 'https://auth.x.ai/oauth/token', @@ -664,9 +664,9 @@ describe('discoverModelsForRoute', () => { cacheIdentity: 'old-refresh-token', tokenEndpoint: 'https://auth.x.ai/oauth/token', } - const readSpy = spyOn(xaiCredentials, 'readXaiCredentials') - .mockReturnValueOnce(initialCredentials) - .mockReturnValue(refreshedCredentials) + const readSpy = spyOn(xaiCredentials, 'readXaiCredentialsAsync') + .mockResolvedValueOnce(initialCredentials) + .mockResolvedValue(refreshedCredentials) const refreshSpy = spyOn(xaiCredentials, 'resolveXaiAccessToken').mockResolvedValue( 'new-access-token', ) diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index 0c5d444216..8c7722d731 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -1,3 +1,4 @@ +import { createHmac } from 'node:crypto' import { getCachedModels, isCacheStale, @@ -36,7 +37,7 @@ import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js' import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' import { getXaiDiscoveryCacheIdentity, - readXaiCredentials, + readXaiCredentialsAsync, resolveXaiAccessToken, } from '../utils/xaiCredentials.js' @@ -122,13 +123,13 @@ function normalizeDiscoveryCacheHeaders( function hashDiscoveryCachePartition(value: unknown): string { // Discovery results can be account-specific, so a credential is used only - // to derive an opaque cache namespace. Credentials are high-entropy bearer - // secrets, not passwords. Use Bun's native SHA-256 implementation for this - // opaque filename namespace: it preserves the legacy digest format without - // treating a credential as a password-verification input. - const hasher = new Bun.CryptoHasher('sha256') - hasher.update(JSON.stringify(value)) - return hasher.digest('hex').slice(0, 16) + // to derive an opaque cache namespace. This runs in the shipped Node CLI as + // well as Bun during development, so keep it runtime-portable. HMAC is a + // one-pass namespace derivation, not password verification. + return createHmac('sha256', 'openclaude-discovery-cache-v1') + .update(JSON.stringify(value)) + .digest('hex') + .slice(0, 16) } export function getDiscoveryCacheKey( @@ -220,7 +221,7 @@ export async function resolveDiscoveryRequestOptions< return next } - let credentials = readXaiCredentials() + let credentials = await readXaiCredentialsAsync() const cacheOnly = shouldSkipNonessentialDiscoveryTraffic() || resolverOptions?.refreshXaiOAuth === false @@ -230,7 +231,7 @@ export async function resolveDiscoveryRequestOptions< if (!cacheOnly) { // A refresh can rotate the refresh token. Re-read the persisted blob so // discovery writes under the same stable identity subsequent readers use. - credentials = readXaiCredentials() ?? credentials + credentials = (await readXaiCredentialsAsync()) ?? credentials } if (token) { next.apiKey = token diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index 71338e16f5..0a74027457 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -16,6 +16,7 @@ import { getDiscoveryCacheKey, getRouteDiscoveryHeaders, } from './discoveryService' +import { setClaudeConfigHomeDirForTesting } from '../utils/envUtils.js' const originalConfigDir = process.env.CLAUDE_CONFIG_DIR @@ -24,6 +25,7 @@ async function withTempConfigDir(fn: () => Promise): Promise { let tempDir: string | null = null try { tempDir = mkdtempSync(join(tmpdir(), 'openclaude-runtime-metadata-test-')) + setClaudeConfigHomeDirForTesting(tempDir) process.env.CLAUDE_CONFIG_DIR = tempDir return await fn() } finally { @@ -36,6 +38,7 @@ async function withTempConfigDir(fn: () => Promise): Promise { if (tempDir) { rmSync(tempDir, { recursive: true, force: true }) } + setClaudeConfigHomeDirForTesting(undefined) } finally { releaseSharedMutationLock() } @@ -77,7 +80,7 @@ describe('resolveModelRuntimeLimits', () => { it('uses the stable xAI OAuth cache identity for discovered runtime limits', async () => { await withTempConfigDir(async () => { const xaiCredentials = await import('../utils/xaiCredentials.js') - const readSpy = spyOn(xaiCredentials, 'readXaiCredentials').mockReturnValue({ + const readSpy = spyOn(xaiCredentials, 'getCachedXaiCredentials').mockReturnValue({ accessToken: 'rotating-access-token', refreshToken: 'stable-account-identity', tokenEndpoint: 'https://auth.x.ai/oauth/token', diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index cc0af95203..889e9d2179 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -29,8 +29,8 @@ import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' import { firstUsableCredential } from '../services/api/credentialPool.js' import { ZAI_GLM_OPENAI_SHIM } from './transport/zaiGlmShim.js' import { + getCachedXaiCredentials, getXaiDiscoveryCacheIdentity, - readXaiCredentials, } from '../utils/xaiCredentials.js' import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js' @@ -464,7 +464,10 @@ function findCachedCatalogEntryForApiName( ) let cacheIdentity: string | undefined if (!apiKey && routeId === 'xai' && isCanonicalXaiInferenceBaseUrl(baseUrl)) { - const credentials = readXaiCredentials() + // Runtime limit resolution is synchronous request planning. Discovery + // populates this memory cache asynchronously; do not launch a credential + // store subprocess here merely to recover optional dynamic metadata. + const credentials = getCachedXaiCredentials() apiKey = firstUsableCredential(credentials?.accessToken) cacheIdentity = getXaiDiscoveryCacheIdentity(credentials) ?? apiKey } diff --git a/src/utils/secureStorage/linuxSecretStorage.ts b/src/utils/secureStorage/linuxSecretStorage.ts index 525bbc39f4..7a86adc809 100644 --- a/src/utils/secureStorage/linuxSecretStorage.ts +++ b/src/utils/secureStorage/linuxSecretStorage.ts @@ -1,4 +1,4 @@ -import { execaSync } from 'execa' +import { execa, execaSync } from 'execa' import { jsonParse, jsonStringify } from '../slowOperations.js' import { CREDENTIALS_SERVICE_SUFFIX, @@ -35,8 +35,24 @@ export const linuxSecretStorage: SecureStorage = { return null }, async readAsync(): Promise { - // Reusing sync implementation for simplicity as it wraps a CLI call - return this.read() + try { + const username = getUsername() + const serviceName = getSecureStorageServiceName( + CREDENTIALS_SERVICE_SUFFIX, + ) + const result = await execa( + 'secret-tool', + ['lookup', 'service', serviceName, 'account', username], + { reject: false }, + ) + + if (result.exitCode === 0 && result.stdout) { + return jsonParse(result.stdout) + } + } catch { + // fall through + } + return null }, update(data: SecureStorageData): { success: boolean; warning?: string } { try { diff --git a/src/utils/secureStorage/platformStorage.test.ts b/src/utils/secureStorage/platformStorage.test.ts index a48894925f..46f2eddc5e 100644 --- a/src/utils/secureStorage/platformStorage.test.ts +++ b/src/utils/secureStorage/platformStorage.test.ts @@ -37,8 +37,11 @@ function execaResult(overrides: Partial = {}): MockExecaResult }; } -// Mock execaSync +// Mock execa process launches. const mockExecaSync = mock((..._args: MockExecaArgs) => execaResult()); +const mockExeca = mock((..._args: MockExecaArgs) => + Promise.resolve(execaResult()), +); function getExecaCall(index: number): MockExecaArgs { const call = mockExecaSync.mock.calls[index]; @@ -46,6 +49,12 @@ function getExecaCall(index: number): MockExecaArgs { return call; } +function getAsyncExecaCall(index: number): MockExecaArgs { + const call = mockExeca.mock.calls[index]; + expect(call).toBeDefined(); + return call; +} + function getCommandArgs(index: number): readonly string[] { const args = getExecaCall(index)[1]; expect(Array.isArray(args)).toBe(true); @@ -90,6 +99,7 @@ describe("Secure Storage Platform Implementations", () => { mock.restore(); mock.module("execa", () => ({ ...realExeca, + execa: mockExeca, execaSync: mockExecaSync, })); const moduleSuffix = `?platformStorageTest=${Date.now()}-${Math.random()}`; @@ -107,8 +117,10 @@ describe("Secure Storage Platform Implementations", () => { process.env = { ...originalEnv }; setClaudeConfigHomeDirForTesting(undefined); mockExecaSync.mockClear(); + mockExeca.mockClear(); // Default mock behavior mockExecaSync.mockImplementation(() => execaResult()); + mockExeca.mockImplementation(() => Promise.resolve(execaResult())); }); afterEach(() => { @@ -372,6 +384,19 @@ describe("Secure Storage Platform Implementations", () => { warning: "dpapi failed", }); }); + + test("readAsync uses the asynchronous PowerShell path", async () => { + mockExeca.mockResolvedValueOnce( + execaResult({ stdout: JSON.stringify(testData) }), + ); + + const result = await windowsCredentialStorage.readAsync(); + + expect(result).toEqual(testData); + expect(mockExeca).toHaveBeenCalledTimes(1); + expect(mockExecaSync).not.toHaveBeenCalled(); + expect(getAsyncExecaCall(0)[0]).toBe("powershell.exe"); + }); }); describe("Linux secret-tool Interaction", () => { @@ -387,6 +412,20 @@ describe("Secure Storage Platform Implementations", () => { expect(result).toEqual(testData); }); + + test("readAsync parses stdout without using the synchronous process path", async () => { + mockExeca.mockResolvedValueOnce( + execaResult({ stdout: JSON.stringify(testData) }), + ); + + const result = await linuxSecretStorage.readAsync(); + + expect(result).toEqual(testData); + expect(mockExeca).toHaveBeenCalledTimes(1); + expect(mockExecaSync).not.toHaveBeenCalled(); + expect(getAsyncExecaCall(0)[0]).toBe("secret-tool"); + }); + }); describe("Platform Selection", () => { diff --git a/src/utils/secureStorage/windowsCredentialStorage.ts b/src/utils/secureStorage/windowsCredentialStorage.ts index 0fde3f4ff9..06de2b41bb 100644 --- a/src/utils/secureStorage/windowsCredentialStorage.ts +++ b/src/utils/secureStorage/windowsCredentialStorage.ts @@ -1,4 +1,4 @@ -import { execaSync } from 'execa' +import { execa, execaSync } from 'execa' import { join } from 'path' import { getClaudeConfigHomeDir } from '../envUtils.js' import { jsonParse, jsonStringify } from '../slowOperations.js' @@ -37,7 +37,7 @@ function shouldUseLegacyPasswordVault(): boolean { function runPowerShell( script: string, options?: { input?: string }, -): ReturnType | null { +): PowerShellResult | null { try { return execaSync('powershell.exe', ['-Command', script], { input: options?.input, @@ -48,12 +48,27 @@ function runPowerShell( } } -function commandOutputToString( - output: - | ReturnType['stdout'] - | ReturnType['stderr'] - | undefined, -): string { +async function runPowerShellAsync( + script: string, + options?: { input?: string }, +): Promise { + try { + return await execa('powershell.exe', ['-Command', script], { + input: options?.input, + reject: false, + }) + } catch { + return null + } +} + +type PowerShellResult = { + exitCode?: number + stdout?: unknown + stderr?: unknown +} + +function commandOutputToString(output: unknown): string { if (typeof output === 'string') { return output } @@ -70,7 +85,7 @@ function commandOutputToString( } function getFailureWarning( - result: ReturnType | null, + result: PowerShellResult | null, fallback: string, ): string { const stderr = commandOutputToString(result?.stderr).trim() @@ -85,14 +100,10 @@ function getFailureWarning( return fallback } -function readLegacyPasswordVault(): SecureStorageData | null { - if (!shouldUseLegacyPasswordVault()) { - return null - } - +function getLegacyPasswordVaultScript(): string { const resourceName = getLegacyResourceName().replace(/"/g, '`"') const username = getUsername().replace(/"/g, '`"') - const script = ` + return ` Add-Type -AssemblyName System.Runtime.WindowsRuntime try { $vault = New-Object Windows.Security.Credentials.PasswordVault @@ -103,8 +114,11 @@ function readLegacyPasswordVault(): SecureStorageData | null { exit 1 } ` +} - const result = runPowerShell(script) +function parseCredentialOutput( + result: PowerShellResult | null, +): SecureStorageData | null { const stdout = commandOutputToString(result?.stdout) if (result?.exitCode === 0 && stdout) { try { @@ -113,62 +127,77 @@ function readLegacyPasswordVault(): SecureStorageData | null { return null } } - return null } -export const windowsCredentialStorage: SecureStorage = { - name: 'credential-locker-dpapi', - read(): SecureStorageData | null { - const filePath = escapePowerShellSingleQuoted( - getWindowsSecureStorageFilePath(), - ) - const entropy = escapePowerShellSingleQuoted( - getWindowsSecureStorageEntropy(), - ) - const script = ` - try { - Add-Type -AssemblyName System.Security - $path = '${filePath}' - if (!(Test-Path -LiteralPath $path)) { - exit 1 - } +function readLegacyPasswordVault(): SecureStorageData | null { + if (!shouldUseLegacyPasswordVault()) { + return null + } - $protectedBase64 = [System.IO.File]::ReadAllText( - $path, - [System.Text.Encoding]::UTF8 - ).Trim() - if (-not $protectedBase64) { - exit 1 - } + return parseCredentialOutput(runPowerShell(getLegacyPasswordVaultScript())) +} - $protectedBytes = [Convert]::FromBase64String($protectedBase64) - $entropyBytes = [System.Text.Encoding]::UTF8.GetBytes('${entropy}') - $bytes = [System.Security.Cryptography.ProtectedData]::Unprotect( - $protectedBytes, - $entropyBytes, - [System.Security.Cryptography.DataProtectionScope]::CurrentUser - ) - [Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($bytes)) - } catch { +async function readLegacyPasswordVaultAsync(): Promise { + if (!shouldUseLegacyPasswordVault()) { + return null + } + + return parseCredentialOutput( + await runPowerShellAsync(getLegacyPasswordVaultScript()), + ) +} + +function getDpapiReadScript(): string { + const filePath = escapePowerShellSingleQuoted( + getWindowsSecureStorageFilePath(), + ) + const entropy = escapePowerShellSingleQuoted( + getWindowsSecureStorageEntropy(), + ) + return ` + try { + Add-Type -AssemblyName System.Security + $path = '${filePath}' + if (!(Test-Path -LiteralPath $path)) { exit 1 } - ` - const result = runPowerShell(script) - const stdout = commandOutputToString(result?.stdout) - if (result?.exitCode === 0 && stdout) { - try { - return jsonParse(stdout) - } catch { - return readLegacyPasswordVault() + $protectedBase64 = [System.IO.File]::ReadAllText( + $path, + [System.Text.Encoding]::UTF8 + ).Trim() + if (-not $protectedBase64) { + exit 1 } + + $protectedBytes = [Convert]::FromBase64String($protectedBase64) + $entropyBytes = [System.Text.Encoding]::UTF8.GetBytes('${entropy}') + $bytes = [System.Security.Cryptography.ProtectedData]::Unprotect( + $protectedBytes, + $entropyBytes, + [System.Security.Cryptography.DataProtectionScope]::CurrentUser + ) + [Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($bytes)) + } catch { + exit 1 } + ` +} - return readLegacyPasswordVault() +export const windowsCredentialStorage: SecureStorage = { + name: 'credential-locker-dpapi', + read(): SecureStorageData | null { + return ( + parseCredentialOutput(runPowerShell(getDpapiReadScript())) ?? + readLegacyPasswordVault() + ) }, async readAsync(): Promise { - return this.read() + return ( + parseCredentialOutput(await runPowerShellAsync(getDpapiReadScript())) ?? + (await readLegacyPasswordVaultAsync()) + ) }, update(data: SecureStorageData): { success: boolean; warning?: string } { const filePath = escapePowerShellSingleQuoted( diff --git a/src/utils/xaiCredentials.test.ts b/src/utils/xaiCredentials.test.ts new file mode 100644 index 0000000000..cb177e081c --- /dev/null +++ b/src/utils/xaiCredentials.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' + +type StorageData = Record + +const originalEnv = { ...process.env } +const originalArgv = [...process.argv] +let storageData: StorageData = {} +let readAsync: () => Promise = async () => storageData + +const credential = { + accessToken: 'xai-access-token', + refreshToken: 'xai-refresh-token', + tokenEndpoint: 'https://auth.x.ai/oauth/token', +} + +async function importFreshXaiCredentials() { + mock.module('./secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + name: 'mock-secure-storage', + read: () => storageData, + readAsync, + update: (next: StorageData) => { + storageData = next + return { success: true } + }, + delete: () => { + storageData = {} + return true + }, + }), + })) + + return import(`./xaiCredentials.ts?ts=${Date.now()}-${Math.random()}`) +} + +beforeEach(async () => { + await acquireSharedMutationLock('utils/xaiCredentials.test.ts') + process.env = { ...originalEnv } + delete process.env.CLAUDE_CODE_SIMPLE + process.argv = originalArgv.filter(arg => arg !== '--bare') + storageData = {} + readAsync = async () => storageData +}) + +afterEach(() => { + try { + process.env = { ...originalEnv } + process.argv = [...originalArgv] + storageData = {} + mock.restore() + } finally { + releaseSharedMutationLock() + } +}) + +test('retries a failed xAI credential read instead of caching not-logged-in', async () => { + let readCount = 0 + readAsync = async () => { + readCount++ + return readCount === 1 ? null : { xai: credential } + } + const { readXaiCredentialsAsync } = await importFreshXaiCredentials() + + expect(await readXaiCredentialsAsync()).toBeUndefined() + expect(await readXaiCredentialsAsync()).toEqual(credential) + expect(readCount).toBe(2) +}) + +test('does not let a stale async credential read overwrite an in-process login', async () => { + let resolveRead: ((data: StorageData | null) => void) | undefined + readAsync = () => + new Promise(resolve => { + resolveRead = resolve + }) + const { + getCachedXaiCredentials, + readXaiCredentialsAsync, + saveXaiCredentials, + } = await importFreshXaiCredentials() + const staleCredential = { + ...credential, + accessToken: 'stale-access-token', + } + const freshCredential = { + ...credential, + accessToken: 'fresh-access-token', + } + + const pendingRead = readXaiCredentialsAsync() + expect(saveXaiCredentials(freshCredential).success).toBe(true) + resolveRead?.({ xai: staleCredential }) + + expect(await pendingRead).toMatchObject(freshCredential) + expect(getCachedXaiCredentials()).toMatchObject(freshCredential) +}) diff --git a/src/utils/xaiCredentials.ts b/src/utils/xaiCredentials.ts index 356a614642..89ea54490d 100644 --- a/src/utils/xaiCredentials.ts +++ b/src/utils/xaiCredentials.ts @@ -21,6 +21,7 @@ import { export const XAI_STORAGE_KEY = 'xai' as const const XAI_TOKEN_REFRESH_SKEW_MS = 60_000 const XAI_TOKEN_REFRESH_RETRY_COOLDOWN_MS = 60_000 +const XAI_CREDENTIAL_CACHE_TTL_MS = 30_000 export type XaiCredentialBlob = { accessToken: string @@ -43,7 +44,10 @@ let inFlightXaiRefresh: | null = null let inMemoryLastRefreshFailureAt: number | null = null let cachedXaiCredentials: XaiCredentialBlob | undefined -let hasCachedXaiCredentials = false +let cachedXaiCredentialsAt = 0 +let xaiCredentialsGeneration = 0 +let inFlightXaiCredentialRead: Promise | null = + null function getXaiSecureStorage() { return getSecureStorage({ allowPlainTextFallback: false }) @@ -88,10 +92,25 @@ function cacheXaiCredentials( credentials: XaiCredentialBlob | undefined, ): XaiCredentialBlob | undefined { cachedXaiCredentials = credentials - hasCachedXaiCredentials = true + cachedXaiCredentialsAt = credentials ? Date.now() : 0 + xaiCredentialsGeneration++ return credentials } +function hasFreshCachedXaiCredentials(): boolean { + return ( + cachedXaiCredentials !== undefined && + Date.now() - cachedXaiCredentialsAt < XAI_CREDENTIAL_CACHE_TTL_MS + ) +} + +// This is deliberately memory-only: callers on synchronous request-planning +// paths can use a recently loaded OAuth identity without blocking on keychain, +// libsecret, or Credential Locker I/O. +export function getCachedXaiCredentials(): XaiCredentialBlob | undefined { + return hasFreshCachedXaiCredentials() ? cachedXaiCredentials : undefined +} + export function getXaiDiscoveryCacheIdentity( credentials: XaiCredentialBlob | undefined, ): string | undefined { @@ -127,14 +146,14 @@ function isWithinRefreshFailureCooldown( export function readXaiCredentials(): XaiCredentialBlob | undefined { if (isBareMode()) return undefined - if (hasCachedXaiCredentials) return cachedXaiCredentials + const cached = getCachedXaiCredentials() + if (cached) return cached try { const data = getXaiSecureStorage().read() - return cacheXaiCredentials( - normalizeXaiCredentialBlob(data?.[XAI_STORAGE_KEY]), - ) + const credentials = normalizeXaiCredentialBlob(data?.[XAI_STORAGE_KEY]) + return credentials ? cacheXaiCredentials(credentials) : undefined } catch { - return cacheXaiCredentials(undefined) + return undefined } } @@ -142,15 +161,31 @@ export async function readXaiCredentialsAsync(): Promise< XaiCredentialBlob | undefined > { if (isBareMode()) return undefined - if (hasCachedXaiCredentials) return cachedXaiCredentials - try { - const data = await getXaiSecureStorage().readAsync() - return cacheXaiCredentials( - normalizeXaiCredentialBlob(data?.[XAI_STORAGE_KEY]), - ) - } catch { - return cacheXaiCredentials(undefined) + const cached = getCachedXaiCredentials() + if (cached) return cached + if (inFlightXaiCredentialRead) { + return inFlightXaiCredentialRead } + + const generation = xaiCredentialsGeneration + const read = getXaiSecureStorage() + .readAsync() + .then(data => { + const credentials = normalizeXaiCredentialBlob(data?.[XAI_STORAGE_KEY]) + if (generation !== xaiCredentialsGeneration) { + return getCachedXaiCredentials() + } + return credentials ? cacheXaiCredentials(credentials) : undefined + }) + .catch(() => undefined) + + inFlightXaiCredentialRead = read + void read.finally(() => { + if (inFlightXaiCredentialRead === read) { + inFlightXaiCredentialRead = null + } + }) + return read } export function saveXaiCredentials( From 449a3c72bc8ab09e1097292d100955b155258992 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 12 Aug 2026 18:57:26 -0700 Subject: [PATCH 13/13] fix(discovery): use opaque cache fingerprints --- src/integrations/discoveryService.test.ts | 18 +++++++++++++ src/integrations/discoveryService.ts | 31 ++++++++++++++--------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index 4838f93a33..c00179b9b7 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -221,6 +221,24 @@ describe('discoverModelsForRoute', () => { expect(callCount).toBe(2) }) + test('uses opaque cache partitions for credential-scoped discovery', async () => { + const { getDiscoveryCacheKey } = await loadDiscoveryServiceModule() + + const first = getDiscoveryCacheKey('custom', { + baseUrl: 'https://example.test/v1', + apiKey: 'discovery-cache-secret-a', + }) + const second = getDiscoveryCacheKey('custom', { + baseUrl: 'https://example.test/v1', + apiKey: 'discovery-cache-secret-b', + }) + + expect(first).toMatch(/^custom:[0-9a-f]{32}$/) + expect(first).not.toContain('discovery-cache-secret-a') + expect(second).not.toContain('discovery-cache-secret-b') + expect(first).not.toBe(second) + }) + test('preserves stale cache data when refresh fails', async () => { const { discoverModelsForRoute } = await loadDiscoveryServiceModule() diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index 8c7722d731..4487936a1b 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -1,4 +1,3 @@ -import { createHmac } from 'node:crypto' import { getCachedModels, isCacheStale, @@ -121,15 +120,23 @@ function normalizeDiscoveryCacheHeaders( .sort(([leftName], [rightName]) => leftName.localeCompare(rightName)) } -function hashDiscoveryCachePartition(value: unknown): string { - // Discovery results can be account-specific, so a credential is used only - // to derive an opaque cache namespace. This runs in the shipped Node CLI as - // well as Bun during development, so keep it runtime-portable. HMAC is a - // one-pass namespace derivation, not password verification. - return createHmac('sha256', 'openclaude-discovery-cache-v1') - .update(JSON.stringify(value)) - .digest('hex') - .slice(0, 16) +const FNV1A_128_OFFSET_BASIS = 0x6c62272e07bb014262b821756295c58dn +const FNV1A_128_PRIME = 0x0000000001000000000000000000013bn + +function fingerprintDiscoveryCachePartition(value: unknown): string { + // Discovery results can be account-specific. This only needs a stable, + // opaque local cache namespace; it is not password storage, authentication, + // integrity protection, or a security boundary. Keep raw credentials out of + // the cache key without retaining them in a process-wide memoization cache. + let fingerprint = FNV1A_128_OFFSET_BASIS + const serialized = JSON.stringify(value) ?? '' + + for (const byte of new TextEncoder().encode(serialized)) { + fingerprint ^= BigInt(byte) + fingerprint = BigInt.asUintN(128, fingerprint * FNV1A_128_PRIME) + } + + return fingerprint.toString(16).padStart(32, '0') } export function getDiscoveryCacheKey( @@ -146,14 +153,14 @@ export function getDiscoveryCacheKey( const partition = { baseUrl: normalizeDiscoveryCacheBaseUrl(getRouteBaseUrl(routeId, options)), apiKeyHash: cacheIdentity - ? hashDiscoveryCachePartition(cacheIdentity) + ? fingerprintDiscoveryCachePartition(cacheIdentity) : '', headers: normalizeDiscoveryCacheHeaders( getRouteDiscoveryHeaders(routeId, options), ), } - return `${routeId}:${hashDiscoveryCachePartition(partition)}` + return `${routeId}:${fingerprintDiscoveryCachePartition(partition)}` } function getRouteBaseUrl(