Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
39ac41b
fix(xai): authenticate hybrid discovery for OAuth and keep gateway al…
jatmn Aug 12, 2026
33592c8
fix(xai): align OAuth hybrid discovery cache with runtime metadata
jatmn Aug 12, 2026
4f6572e
fix(xai): keep Grok 4.6 PR on catalog and hybrid discovery
jatmn Aug 12, 2026
b848571
fix(xai): read discovered model context length
jatmn Aug 12, 2026
037c169
fix(xai): preserve OAuth discovery metadata
jatmn Aug 12, 2026
797a485
fix(xai): tolerate malformed discovery models
jatmn Aug 12, 2026
61a0410
fix(discovery): harden credential cache partitions
jatmn Aug 12, 2026
1c65970
fix(xai): authenticate hybrid discovery for OAuth sessions
jatmn Aug 12, 2026
a3af424
fix(xai): keep OAuth discovery header capture visible to typecheck
jatmn Aug 12, 2026
e459a57
fix(xai): constrain OAuth discovery credentials
jatmn Aug 12, 2026
a4a3fa1
test(xai): cover OAuth endpoint boundary
jatmn Aug 12, 2026
7d86ded
fix(xai): preserve credential and discovery safeguards
jatmn Aug 12, 2026
f0e6171
fix(xai): keep mirrored keys off untrusted endpoints
jatmn Aug 12, 2026
c9e3df1
fix(xai): preserve OAuth discovery safety
jatmn Aug 13, 2026
faa55b9
fix(discovery): avoid retaining credential cache keys
jatmn Aug 13, 2026
d285202
fix(discovery): remove credential partition memoization
jatmn Aug 13, 2026
381c6c4
fix(xai): normalize discovery endpoints and cache hashing
jatmn Aug 13, 2026
83d55a6
fix(xai): withhold retargeted credentials across profile pipeline
jatmn Aug 16, 2026
173bdd4
fix(test): avoid narrowed env assertion after delete
jatmn Aug 16, 2026
15a7047
fix(xai): withhold secrets from host-only and OAuth persist shapes
jatmn Aug 16, 2026
4ee22fb
fix(xai): withhold ambient headers and pooled keys on proxy launches
jatmn Aug 16, 2026
8fc6a42
fix(xai): restore distinct proxy keys on profile: xai relaunch
jatmn Aug 16, 2026
6ef6cd1
fix(xai): restore distinct proxy keys on in-session profile apply
jatmn Aug 16, 2026
e75c0e4
fix(xai): treat distinct proxy keys as aligned on retargeted apply
jatmn Aug 16, 2026
046c7ec
fix(xai): withhold profile.apiKey when restoring proxy credentials
jatmn Aug 16, 2026
17d76df
fix(xai): stop restoring unidentifiable persisted proxy keys
jatmn Aug 16, 2026
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
16 changes: 8 additions & 8 deletions src/commands/model/model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,6 @@ async function getOpenAIDiscoveryRequestOptions(
options?: { refreshXaiOAuth?: boolean },
): Promise<{
apiKey?: string
cacheKey?: string
baseUrl?: string
headers?: Record<string, string>
}> {
Expand All @@ -381,14 +380,15 @@ async function getOpenAIDiscoveryRequestOptions(
baseUrl: process.env.OPENAI_BASE_URL,
})

let apiKey = firstUsableCredential(
resolveRouteCredentialValue({
routeId,
baseUrl: request.baseUrl,
processEnv: process.env,
}),
)
return resolveDiscoveryRequestOptions(routeId ?? 'custom', {
apiKey: firstUsableCredential(
resolveRouteCredentialValue({
routeId,
baseUrl: request.baseUrl,
processEnv: process.env,
}),
),
apiKey,
baseUrl: request.baseUrl,
headers: parseCustomHeadersEnv(process.env.ANTHROPIC_CUSTOM_HEADERS),
}, options)
Expand Down
154 changes: 154 additions & 0 deletions src/integrations/discoveryService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,160 @@ describe('discoverModelsForRoute', () => {
{ id: 'llama-3.3-70b', apiName: 'llama-3.3-70b', label: 'llama-3.3-70b', contextWindow: 131072 },
])
})

test('does not send stored xAI OAuth credentials to an overridden discovery URL', async () => {
const originalXaiKey = process.env.XAI_API_KEY
const xaiCredentials = await import('../utils/xaiCredentials.js')
const tokenSpy = spyOn(xaiCredentials, 'resolveXaiAccessToken').mockResolvedValue(
'oauth-token',
)
try {
delete process.env.XAI_API_KEY
let authorization: string | null | undefined
setMockFetch(mock((_input: string | URL | Request, init?: RequestInit) => {
authorization = new Headers(init?.headers).get('authorization')
return Promise.resolve(
new Response(JSON.stringify({ data: [] }), {
headers: { 'Content-Type': 'application/json' },
}),
)
}) as unknown as typeof globalThis.fetch)

const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
await discoverModelsForRoute('xai', {
baseUrl: 'https://proxy.example/v1',
forceRefresh: true,
})

expect(tokenSpy).not.toHaveBeenCalled()
expect(authorization).not.toBe('Bearer oauth-token')
} finally {
tokenSpy.mockRestore()
if (originalXaiKey === undefined) {
delete process.env.XAI_API_KEY
} else {
process.env.XAI_API_KEY = originalXaiKey
}
}
})

test('uses the canonical xAI URL for an empty discovery override', async () => {
const originalXaiKey = process.env.XAI_API_KEY
try {
process.env.XAI_API_KEY = 'xai-api-key'
let url: string | undefined
let authorization: string | null | undefined
setMockFetch(mock((input: string | URL | Request, init?: RequestInit) => {
url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
authorization = new Headers(init?.headers).get('authorization')
return Promise.resolve(
new Response(JSON.stringify({ data: [] }), {
headers: { 'Content-Type': 'application/json' },
}),
)
}) as unknown as typeof globalThis.fetch)

const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
await discoverModelsForRoute('xai', { baseUrl: '', forceRefresh: true })

expect(url).toBe('https://api.x.ai/v1/models')
expect(authorization).toBe('Bearer xai-api-key')
} finally {
if (originalXaiKey === undefined) {
delete process.env.XAI_API_KEY
} else {
process.env.XAI_API_KEY = originalXaiKey
}
}
})

test('does not refresh xAI OAuth credentials when nonessential traffic is disabled', async () => {
const xaiCredentials = await import('../utils/xaiCredentials.js')
const tokenSpy = spyOn(xaiCredentials, 'resolveXaiAccessToken').mockResolvedValue(
'oauth-token',
)
try {
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1'
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()

const result = await discoverModelsForRoute('xai', { forceRefresh: true })

expect(tokenSpy).not.toHaveBeenCalled()
expect(result?.source).toBe('static')
} finally {
tokenSpy.mockRestore()
}
})

test('uses the stored OAuth token to read a fresh xAI discovery cache before refreshing', async () => {
const xaiCredentials = await import('../utils/xaiCredentials.js')
const readSpy = spyOn(xaiCredentials, 'readXaiCredentialsAsync').mockResolvedValue(
{
accessToken: 'cached-oauth-token',
refreshToken: 'refresh-token',
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: 'refresh-token',
}),
{ models: [{ id: 'cached-grok', apiName: 'cached-grok', label: 'cached-grok' }] },
)

const result = await discoverModelsForRoute('xai')

expect(result?.source).toBe('cache')
expect(refreshSpy).not.toHaveBeenCalled()
} finally {
readSpy.mockRestore()
refreshSpy.mockRestore()
}
})

test('does not send xAI credentials to an insecure xAI URL', async () => {
const originalXaiKey = process.env.XAI_API_KEY
const xaiCredentials = await import('../utils/xaiCredentials.js')
const tokenSpy = spyOn(xaiCredentials, 'resolveXaiAccessToken').mockResolvedValue(
'oauth-token',
)
try {
process.env.XAI_API_KEY = 'xai-api-key'
let authorization: string | null | undefined
setMockFetch(mock((_input: string | URL | Request, init?: RequestInit) => {
authorization = new Headers(init?.headers).get('authorization')
return Promise.resolve(
new Response(JSON.stringify({ data: [] }), {
headers: { 'Content-Type': 'application/json' },
}),
)
}) as unknown as typeof globalThis.fetch)

const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
await discoverModelsForRoute('xai', {
baseUrl: 'http://api.x.ai/v1',
forceRefresh: true,
})

expect(tokenSpy).not.toHaveBeenCalled()
expect(authorization).toBeNull()
} finally {
tokenSpy.mockRestore()
if (originalXaiKey === undefined) {
delete process.env.XAI_API_KEY
} else {
process.env.XAI_API_KEY = originalXaiKey
}
}
})
})

describe('probeRouteReadiness', () => {
Expand Down
50 changes: 27 additions & 23 deletions src/integrations/discoveryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ import {
} from '../utils/providerDiscovery.js'
import { firstUsableCredential, hasInvalidCredentialPlaceholder } from '../services/api/credentialPool.js'
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'
import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js'
import { isEssentialTrafficOnly } from '../utils/privacyLevel.js'

export type RouteDiscoveryResult = {
routeId: string
Expand Down Expand Up @@ -124,10 +124,10 @@ 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.
// Cache partitions need a stable opaque namespace, not password hashing or
// an authentication boundary. This runs while preparing every request, so
// keep it constant-time with respect to credential-stretching work and do
// not retain the serialized value in a process-wide memoization cache.
let fingerprint = FNV1A_128_OFFSET_BASIS
const serialized = JSON.stringify(value) ?? ''

Expand Down Expand Up @@ -167,7 +167,11 @@ function getRouteBaseUrl(
routeId: string,
options?: { baseUrl?: string },
): string | undefined {
return options?.baseUrl ?? getRouteDescriptor(routeId)?.defaultBaseUrl
const configuredBaseUrl = options?.baseUrl?.trim()
if (configuredBaseUrl) {
return configuredBaseUrl
}
return getRouteDescriptor(routeId)?.defaultBaseUrl
}

function getRouteDiscoveryApiKey(
Expand All @@ -189,6 +193,10 @@ function getRouteDiscoveryApiKey(
return undefined
}

if (routeId === 'xai' && !isCanonicalXaiInferenceBaseUrl(baseUrl)) {
return undefined
}

if (hasInvalidCredentialPlaceholder(options?.apiKey)) {
return undefined
}
Expand All @@ -208,12 +216,7 @@ function getRouteDiscoveryApiKey(
}

export async function resolveDiscoveryRequestOptions<
T extends {
apiKey?: string
cacheKey?: string
baseUrl?: string
headers?: Record<string, string>
},
T extends { apiKey?: string; cacheKey?: string; baseUrl?: string; headers?: Record<string, string> },
>(
routeId: string,
options?: T,
Expand All @@ -223,11 +226,17 @@ export async function resolveDiscoveryRequestOptions<
if (getRouteDiscoveryApiKey(routeId, next) || routeId !== 'xai') {
return next
}

if (!isCanonicalXaiInferenceBaseUrl(getRouteBaseUrl(routeId, next))) {
const baseUrl = getRouteBaseUrl(routeId, next)
// Route identity selects a catalog, but it must never authorize sending a
// stored OAuth credential to an overridden endpoint. The xAI OAuth token is
// valid only for xAI's API host.
if (!isCanonicalXaiInferenceBaseUrl(baseUrl)) {
return next
}

// Cache lookup uses the currently stored credential identity.
// Refresh only when a network discovery request is actually necessary: a
// refresh rotates the token and would otherwise turn a fresh cache entry
// into an avoidable miss.
let credentials = await readXaiCredentialsAsync()
const cacheOnly =
shouldSkipNonessentialDiscoveryTraffic() ||
Expand All @@ -236,14 +245,12 @@ export async function resolveDiscoveryRequestOptions<
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.
// Access tokens rotate, while refresh tokens and account IDs identify the
// same OAuth account. Keep cache partitions stable across token refreshes.
next.cacheKey = getXaiDiscoveryCacheIdentity(credentials) ?? token
}
return next
Expand Down Expand Up @@ -418,9 +425,6 @@ export async function discoverModelsForRoute(
}

const ttlMs = getDiscoveryCacheTtlMs(routeId)
// 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,
})
Expand Down
10 changes: 10 additions & 0 deletions src/integrations/routeMetadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@ test('isLongcatBaseUrl requires the documented HTTPS OpenAI API path', () => {
expect(isLongcatBaseUrl('https://api.longcat.chat.evil.test/openai/v1')).toBe(false)
})

test('resolveActiveRouteIdFromEnv retains xAI identity for a retargeted proxy URL', () => {
expect(
resolveActiveRouteIdFromEnv({
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_BASE_URL: 'https://proxy.example/v1',
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'xai',
}),
).toBe('xai')
})

test('resolveActiveRouteIdFromEnv keeps generic OpenAI credentials ahead of env-only LongCat', () => {
expect(resolveActiveRouteIdFromEnv({
OPENAI_API_KEY: 'generic-key',
Expand Down
32 changes: 30 additions & 2 deletions src/integrations/routeMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,11 +276,20 @@ export function isXaiBaseUrl(value: string | undefined): boolean {
}
}

/**
* xAI-specific credentials may only be sent to the documented HTTPS API
* origin. Route identity is intentionally more permissive because profiles
* can be retargeted, but a retargeted route must not carry a xAI credential.
*/
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'
const url = new URL(value)
return (
url.protocol === 'https:' &&
url.hostname.toLowerCase() === 'api.x.ai' &&
(url.port === '' || url.port === '443')
)
} catch {
return false
}
Expand Down Expand Up @@ -1023,6 +1032,14 @@ export function resolveRouteCredentialValue(
return undefined
}

if (
routeId === 'xai' &&
options?.baseUrl !== undefined &&
!isCanonicalXaiInferenceBaseUrl(options.baseUrl)
) {
return undefined
}

return getRouteCredentialValue(routeId, processEnv)
}

Expand Down Expand Up @@ -1242,6 +1259,17 @@ export function resolveActiveRouteIdFromEnv(
return matchedRoute
}

const retainedRouteId = processEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID?.trim()
// Only xAI needs this env stamp to survive a proxy URL. ApiSmart's
// dedicated key never rides OPENAI_API_KEY after persist/relaunch, and
// it has no OAuth injector keyed off route id. xAI still must keep
// identity so OAuth stays off the proxy and XAI_API_KEY values can be
// filtered from generic pools. Host matching already returned above
// when the URL is canonical.
if (retainedRouteId === 'xai') {
return 'xai'
}

if (options?.activeProfileProvider) {
const route = resolveProfileRoute(options.activeProfileProvider)
if (
Expand Down
Loading
Loading