Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/commands/model/model.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
33 changes: 23 additions & 10 deletions src/commands/model/model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { filterAvailableCatalogEntries } from '../../integrations/index.js'
import {
discoverModelsForRoute,
getDiscoveryCacheKey,
resolveDiscoveryRequestOptions,
} from '../../integrations/discoveryService.js'
import {
getRouteDescriptor,
Expand Down Expand Up @@ -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<string, string>
} {
}> {
const request = resolveProviderRequest({
model: process.env.OPENAI_MODEL,
baseUrl: process.env.OPENAI_BASE_URL,
})

return {
return resolveDiscoveryRequestOptions(routeId ?? 'custom', {
apiKey: firstUsableCredential(
resolveRouteCredentialValue({
routeId,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Comment thread
jatmn marked this conversation as resolved.
const cached = await getCachedModels(cacheKey, ttlMs, { includeStale: true })
const stale = await isCacheStale(cacheKey, ttlMs)
Expand Down Expand Up @@ -563,7 +570,7 @@ async function loadModelDiscoveryContext(): Promise<ModelDiscoveryContext | null
}

if (getAdditionalModelOptionsCacheScope()?.startsWith('openai:')) {
const { baseUrl } = getOpenAIDiscoveryRequestOptions()
const { baseUrl } = await getOpenAIDiscoveryRequestOptions()
const activeProfile = getActiveProviderProfile()
const legacyRouteId = routeId ?? 'custom'
const profileModelSurface = resolveProviderProfileModelSurface({
Expand Down Expand Up @@ -884,19 +891,22 @@ function ModelPickerWrapper({
})

if (discoveryContext.kind === 'descriptor') {
const discoveryOptions = await getOpenAIDiscoveryRequestOptions(
discoveryContext.routeId,
)
if (manual) {
await clearDiscoveryCache(
getDiscoveryCacheKey(
discoveryContext.routeId,
getOpenAIDiscoveryRequestOptions(discoveryContext.routeId),
discoveryOptions,
),
)
}

const result = await discoverModelsForRoute(
discoveryContext.routeId,
{
...getOpenAIDiscoveryRequestOptions(discoveryContext.routeId),
...discoveryOptions,
forceRefresh: true,
},
)
Expand Down Expand Up @@ -1223,14 +1233,17 @@ async function refreshModelsAndSummarize(): Promise<string> {
}

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(
Expand Down
2 changes: 1 addition & 1 deletion src/components/ProviderManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
2 changes: 2 additions & 0 deletions src/integrations/brands/xai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
105 changes: 104 additions & 1 deletion src/integrations/discoveryService.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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
Expand All @@ -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')
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)')
Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading