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..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/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..c00179b9b7 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -1,13 +1,15 @@ -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, 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 +78,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 +91,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') @@ -217,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() @@ -285,6 +307,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)') @@ -603,6 +627,85 @@ 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, 'readXaiCredentialsAsync').mockResolvedValue({ + 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('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, 'readXaiCredentialsAsync') + .mockResolvedValueOnce(initialCredentials) + .mockResolvedValue(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 326d5c84be..4487936a1b 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto' import { getCachedModels, isCacheStale, @@ -16,6 +15,7 @@ import { resolveRouteIdFromBaseUrl } from './index.js' import { getRouteDescriptor, isCanonicalApismartInferenceBaseUrl, + isCanonicalXaiInferenceBaseUrl, resolveActiveRouteIdFromEnv, resolveRouteCredentialValue, } from './routeMetadata.js' @@ -34,6 +34,11 @@ import { firstUsableCredential, hasInvalidCredentialPlaceholder } from '../servi import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js' import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' +import { + getXaiDiscoveryCacheIdentity, + readXaiCredentialsAsync, + resolveXaiAccessToken, +} from '../utils/xaiCredentials.js' export type RouteDiscoveryResult = { routeId: string @@ -115,11 +120,23 @@ function normalizeDiscoveryCacheHeaders( .sort(([leftName], [rightName]) => leftName.localeCompare(rightName)) } -function hashDiscoveryCachePartition(value: unknown): string { - return createHash('sha256') - .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( @@ -127,21 +144,23 @@ 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 + ? fingerprintDiscoveryCachePartition(cacheIdentity) : '', headers: normalizeDiscoveryCacheHeaders( getRouteDiscoveryHeaders(routeId, options), ), } - return `${routeId}:${hashDiscoveryCachePartition(partition)}` + return `${routeId}:${fingerprintDiscoveryCachePartition(partition)}` } function getRouteBaseUrl( @@ -188,6 +207,48 @@ function getRouteDiscoveryApiKey( ) } +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') { + return next + } + + if (!isCanonicalXaiInferenceBaseUrl(getRouteBaseUrl(routeId, next))) { + return next + } + + let credentials = await readXaiCredentialsAsync() + const cacheOnly = + shouldSkipNonessentialDiscoveryTraffic() || + 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 = (await readXaiCredentialsAsync()) ?? 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 = getXaiDiscoveryCacheIdentity(credentials) ?? token + } + return next +} + export function getRouteDiscoveryHeaders( routeId: string, options?: { baseUrl?: string; headers?: Record }, @@ -357,8 +418,14 @@ export async function discoverModelsForRoute( } const ttlMs = getDiscoveryCacheTtlMs(routeId) - const cacheKey = getDiscoveryCacheKey(routeId, options) - if (!options?.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 { @@ -399,12 +466,14 @@ export async function discoverModelsForRoute( } try { - const discovered = await runDiscovery(routeId, options) + 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), @@ -458,7 +527,10 @@ export async function refreshStartupDiscoveryForRoute( } const ttlMs = getDiscoveryCacheTtlMs(routeId) - const cacheKey = getDiscoveryCacheKey(routeId, options) + const cachedOptions = await resolveDiscoveryRequestOptions(routeId, options, { + refreshXaiOAuth: false, + }) + const cacheKey = getDiscoveryCacheKey(routeId, cachedOptions) if (ttlMs > 0) { const cached = await getCachedModels(cacheKey, ttlMs) if (cached) { diff --git a/src/integrations/gateways/atlas-cloud.ts b/src/integrations/gateways/atlas-cloud.ts index 311f0e5066..d7d27c469e 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', '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 0ba545dd69..2c96197baf 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', 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/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..36d668360d 100644 --- a/src/integrations/models/xai.ts +++ b/src/integrations/models/xai.ts @@ -10,6 +10,49 @@ 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, + }), + 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/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 9da44ccdb3..0a74027457 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, @@ -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() } @@ -73,6 +76,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, 'getCachedXaiCredentials').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', @@ -462,6 +509,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', () => { @@ -646,6 +704,33 @@ 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 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', @@ -661,6 +746,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 }) + + 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/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index d5bdcf51aa..889e9d2179 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,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 { + getCachedXaiCredentials, + getXaiDiscoveryCacheIdentity, +} from '../utils/xaiCredentials.js' import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js' function resolveRouteOpenAIShimConfig( @@ -454,15 +459,22 @@ function findCachedCatalogEntryForApiName( } const baseUrl = runtimeEnv.OPENAI_BASE_URL ?? runtimeEnv.OPENAI_API_BASE + let apiKey = firstUsableCredential( + resolveRouteCredentialValue({ routeId, baseUrl, processEnv: runtimeEnv }), + ) + let cacheIdentity: string | undefined + if (!apiKey && routeId === 'xai' && isCanonicalXaiInferenceBaseUrl(baseUrl)) { + // 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 + } const cacheKey = getDiscoveryCacheKey(routeId, { baseUrl, - apiKey: firstUsableCredential( - resolveRouteCredentialValue({ - routeId, - baseUrl, - processEnv: runtimeEnv, - }), - ), + 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 new file mode 100644 index 0000000000..fd055f4153 --- /dev/null +++ b/src/integrations/vendors/xai.test.ts @@ -0,0 +1,110 @@ +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', + ] + 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', + // 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() + } + }) + + 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 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', + apiName: 'grok-4.7', + label: 'grok-4.7', + }) + 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 the /v1/models context_length when present', () => { + if (!mapModel) throw new Error('mapModel missing') + expect(mapModel(shape('grok-4.6', { context_length: 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..dd03b05095 100644 --- a/src/integrations/vendors/xai.ts +++ b/src/integrations/vendors/xai.ts @@ -1,11 +1,126 @@ 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|multi-agent)/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, + 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', 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,76 +152,45 @@ 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', - models: [ - { - 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'] } }, + source: 'hybrid', + 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 = typeof model.id === 'string' ? model.id.trim() : '' + if (!id || model.active === false) { + return null + } + if (XAI_NON_CHAT_PATTERN.test(id)) { + return null + } + if (!/^grok/i.test(id)) { + return null + } + if (XAI_DISCOVERY_SUPPRESSED_IDS.has(id.toLowerCase())) { + return null + } + return { + id, + apiName: id, + label: id, + ...(isPositiveFiniteNumber(model.context_length) + ? { contextWindow: model.context_length } + : {}), + } }, - { - 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 }, - }, - ], + }, + discoveryCacheTtl: '1d', + discoveryRefreshMode: 'background-if-stale', + allowManualRefresh: true, + models: XAI_CURATED_MODELS, }, usage: { supported: false }, }) 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..8ce2bd2b3e 100644 --- a/src/utils/context.test.ts +++ b/src/utils/context.test.ts @@ -525,6 +525,9 @@ 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').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({ 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/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/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/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 8d90946c3f..89ea54490d 100644 --- a/src/utils/xaiCredentials.ts +++ b/src/utils/xaiCredentials.ts @@ -21,10 +21,14 @@ 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 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 +43,11 @@ let inFlightXaiRefresh: | Promise<{ refreshed: boolean; credentials?: XaiCredentialBlob }> | null = null let inMemoryLastRefreshFailureAt: number | null = null +let cachedXaiCredentials: XaiCredentialBlob | undefined +let cachedXaiCredentialsAt = 0 +let xaiCredentialsGeneration = 0 +let inFlightXaiCredentialRead: Promise | null = + null function getXaiSecureStorage() { return getSecureStorage({ allowPlainTextFallback: false }) @@ -56,6 +65,7 @@ function normalizeXaiCredentialBlob( return { accessToken, refreshToken, + cacheIdentity: asTrimmedString(record.cacheIdentity), tokenEndpoint, idToken: asTrimmedString(record.idToken), email: asTrimmedString(record.email), @@ -78,6 +88,40 @@ function normalizeXaiCredentialBlob( } } +function cacheXaiCredentials( + credentials: XaiCredentialBlob | undefined, +): XaiCredentialBlob | undefined { + cachedXaiCredentials = credentials + 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 { + return ( + credentials?.cacheIdentity ?? + credentials?.accountId ?? + credentials?.refreshToken ?? + credentials?.accessToken + ) +} + function effectiveExpiresAt(blob: XaiCredentialBlob): number | undefined { return blob.expiresAt ?? deriveExpiresMsFromJwt(blob.accessToken) } @@ -102,9 +146,12 @@ function isWithinRefreshFailureCooldown( export function readXaiCredentials(): XaiCredentialBlob | undefined { if (isBareMode()) return undefined + const cached = getCachedXaiCredentials() + if (cached) return cached try { const data = getXaiSecureStorage().read() - return normalizeXaiCredentialBlob(data?.[XAI_STORAGE_KEY]) + const credentials = normalizeXaiCredentialBlob(data?.[XAI_STORAGE_KEY]) + return credentials ? cacheXaiCredentials(credentials) : undefined } catch { return undefined } @@ -114,12 +161,31 @@ export async function readXaiCredentialsAsync(): Promise< XaiCredentialBlob | undefined > { if (isBareMode()) return undefined - try { - const data = await getXaiSecureStorage().readAsync() - return normalizeXaiCredentialBlob(data?.[XAI_STORAGE_KEY]) - } catch { - return 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( @@ -147,6 +213,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 +229,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 +247,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 +297,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( 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',