diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx
index 966fad8f..bb9760f4 100644
--- a/desktop/src/components/controls/ModelSelector.test.tsx
+++ b/desktop/src/components/controls/ModelSelector.test.tsx
@@ -1240,8 +1240,8 @@ describe('ModelSelector', () => {
it('replaces a stale Grok runtime model with the current official default', async () => {
const grokModels: ModelInfo[] = [{
- id: 'grok-4.6',
- name: 'Grok 4.6',
+ id: 'grok-4.7',
+ name: 'Grok 4.7',
description: "SpaceXAI's latest frontier model",
context: '500000',
defaultReasoningEffort: 'high',
@@ -1272,11 +1272,11 @@ describe('ModelSelector', () => {
render()
expect(screen.queryByText('grok-build')).not.toBeInTheDocument()
- expect(screen.getByRole('button', { name: 'Grok 4.6, Grok Official' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Grok 4.7, Grok Official' })).toBeInTheDocument()
await waitFor(() => {
expect(useSessionRuntimeStore.getState().selections['session-stale-grok']).toEqual({
providerId: 'grok-official',
- modelId: 'grok-4.6',
+ modelId: 'grok-4.7',
effortLevel: 'high',
})
})
diff --git a/desktop/src/constants/grokOfficialProvider.ts b/desktop/src/constants/grokOfficialProvider.ts
index 3ca4979c..3c58eb89 100644
--- a/desktop/src/constants/grokOfficialProvider.ts
+++ b/desktop/src/constants/grokOfficialProvider.ts
@@ -1,18 +1,34 @@
import type { ModelInfo } from '../types/settings'
export const GROK_OFFICIAL_PROVIDER_ID = 'grok-official'
-export const GROK_OFFICIAL_DEFAULT_MODEL_ID = 'grok-4.6'
+export const GROK_OFFICIAL_DEFAULT_MODEL_ID = 'grok-4.7'
export const GROK_OFFICIAL_PROVIDER_NAME = 'Grok Official'
export const GROK_OFFICIAL_MODELS: ModelInfo[] = [
{
id: GROK_OFFICIAL_DEFAULT_MODEL_ID,
- name: 'Grok 4.6',
+ name: 'Grok 4.7',
description: "SpaceXAI's latest frontier model",
context: '500000',
defaultReasoningEffort: 'high',
supportedReasoningEfforts: ['xhigh', 'high', 'medium', 'low'],
},
+ {
+ id: 'grok-4.7-build-fast',
+ name: 'Grok 4.7 Fast',
+ description: 'Fast variant. 2x the price.',
+ context: '500000',
+ defaultReasoningEffort: 'high',
+ supportedReasoningEfforts: ['xhigh', 'high', 'medium', 'low'],
+ },
+ {
+ id: 'grok-4.6',
+ name: 'Grok 4.6',
+ description: 'Grok 4.6 frontier model',
+ context: '500000',
+ defaultReasoningEffort: 'high',
+ supportedReasoningEfforts: ['xhigh', 'high', 'medium', 'low'],
+ },
{
id: 'grok-4.5',
name: 'Grok 4.5',
@@ -21,11 +37,4 @@ export const GROK_OFFICIAL_MODELS: ModelInfo[] = [
defaultReasoningEffort: 'high',
supportedReasoningEfforts: ['low', 'medium', 'high'],
},
- {
- id: 'grok-composer-2.5-fast',
- name: 'Composer 2.5',
- description: 'Grok coding model',
- context: '200000',
- supportedReasoningEfforts: [],
- },
]
diff --git a/desktop/src/lib/runtimeSelection.test.ts b/desktop/src/lib/runtimeSelection.test.ts
index f5226266..d1f4cdd9 100644
--- a/desktop/src/lib/runtimeSelection.test.ts
+++ b/desktop/src/lib/runtimeSelection.test.ts
@@ -104,18 +104,16 @@ describe('normalizeRuntimeSelection', () => {
})
})
- it('removes effort from a non-reasoning Grok model', () => {
+ it('keeps xhigh for Grok models that support it', () => {
expect(normalizeRuntimeSelection({
providerId: 'grok-official',
- modelId: 'grok-composer-2.5-fast',
+ modelId: 'grok-4.7',
effortLevel: 'xhigh',
})).toEqual({
providerId: 'grok-official',
- modelId: 'grok-composer-2.5-fast',
+ modelId: 'grok-4.7',
+ effortLevel: 'xhigh',
})
- })
-
- it('keeps xhigh for grok-4.6 which supports it', () => {
expect(normalizeRuntimeSelection({
providerId: 'grok-official',
modelId: 'grok-4.6',
diff --git a/desktop/src/stores/providerStore.test.ts b/desktop/src/stores/providerStore.test.ts
index 2e4ace1c..151feb15 100644
--- a/desktop/src/stores/providerStore.test.ts
+++ b/desktop/src/stores/providerStore.test.ts
@@ -231,7 +231,7 @@ describe('providerStore runtime refresh', () => {
const { useProviderStore } = await import('./providerStore')
await useProviderStore.getState().activateProvider('grok-official')
- expect(settingsSetModelMock).toHaveBeenCalledWith('grok-4.6')
+ expect(settingsSetModelMock).toHaveBeenCalledWith('grok-4.7')
expect(settingsFetchAllMock).toHaveBeenCalled()
})
diff --git a/desktop/src/stores/sessionRuntimeStore.test.ts b/desktop/src/stores/sessionRuntimeStore.test.ts
index 84b42d84..8f022b00 100644
--- a/desktop/src/stores/sessionRuntimeStore.test.ts
+++ b/desktop/src/stores/sessionRuntimeStore.test.ts
@@ -4,7 +4,7 @@ import { useSessionRuntimeStore } from './sessionRuntimeStore'
const EXPECTED_GROK_SELECTION = {
providerId: 'grok-official',
- modelId: 'grok-4.6',
+ modelId: 'grok-4.7',
effortLevel: 'high',
}
@@ -61,20 +61,23 @@ describe('sessionRuntimeStore runtime cleanup', () => {
expect(useSessionRuntimeStore.getState().selections[metadata.id]).toEqual({ providerId: 'kimi', modelId: 'k3' })
})
- it('discards retired Grok selections before persisting them', () => {
- useSessionRuntimeStore.getState().setSelection('session-grok', {
- providerId: 'grok-official',
- modelId: 'grok-build',
- effortLevel: 'max',
- })
+ it.each(['grok-build', 'grok-composer-2.5-fast'])(
+ 'discards the retired Grok model %s before persisting it',
+ (retiredModelId) => {
+ useSessionRuntimeStore.getState().setSelection('session-grok', {
+ providerId: 'grok-official',
+ modelId: retiredModelId,
+ effortLevel: 'max',
+ })
- expect(useSessionRuntimeStore.getState().selections['session-grok']).toEqual(
- EXPECTED_GROK_SELECTION,
- )
- expect(JSON.parse(localStorage.getItem('cc-haha-session-runtime')!)).toEqual({
- 'session-grok': EXPECTED_GROK_SELECTION,
- })
- })
+ expect(useSessionRuntimeStore.getState().selections['session-grok']).toEqual(
+ EXPECTED_GROK_SELECTION,
+ )
+ expect(JSON.parse(localStorage.getItem('cc-haha-session-runtime')!)).toEqual({
+ 'session-grok': EXPECTED_GROK_SELECTION,
+ })
+ },
+ )
it('does not let retired Grok session metadata restore the removed model', () => {
useSessionRuntimeStore.getState().syncFromSessions([{
diff --git a/desktop/src/stores/sessionRuntimeStore.ts b/desktop/src/stores/sessionRuntimeStore.ts
index 416540f7..330f896a 100644
--- a/desktop/src/stores/sessionRuntimeStore.ts
+++ b/desktop/src/stores/sessionRuntimeStore.ts
@@ -20,6 +20,9 @@ const RETIRED_GROK_MODEL_IDS = new Set([
'grok-4.3',
'grok-4.20-reasoning',
'grok-4.20-non-reasoning',
+ // Dropped from the live /v1/models feed, so a session still pinned to it
+ // would send an ID the gateway no longer serves.
+ 'grok-composer-2.5-fast',
])
export const DRAFT_RUNTIME_SELECTION_KEY = '__draft__'
diff --git a/src/server/__tests__/provider-runtime-env.test.ts b/src/server/__tests__/provider-runtime-env.test.ts
index f7b22e18..9f48c1c5 100644
--- a/src/server/__tests__/provider-runtime-env.test.ts
+++ b/src/server/__tests__/provider-runtime-env.test.ts
@@ -95,10 +95,10 @@ describe('providerRuntimeEnv', () => {
CC_HAHA_IMAGE_PROVIDER_KIND: 'grok_oauth',
CC_HAHA_IMAGE_PROVIDER_ID: 'grok-official',
CC_HAHA_IMAGE_MODEL: 'grok-imagine-image-quality',
- ANTHROPIC_MODEL: 'grok-4.6',
- ANTHROPIC_DEFAULT_HAIKU_MODEL: 'grok-4.6',
- ANTHROPIC_DEFAULT_SONNET_MODEL: 'grok-4.6',
- ANTHROPIC_DEFAULT_OPUS_MODEL: 'grok-4.6',
+ ANTHROPIC_MODEL: 'grok-4.7',
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: 'grok-4.7',
+ ANTHROPIC_DEFAULT_SONNET_MODEL: 'grok-4.7',
+ ANTHROPIC_DEFAULT_OPUS_MODEL: 'grok-4.7',
DISABLE_AUTOUPDATER: '1',
})
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBeUndefined()
diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts
index 21e1de70..d56451fd 100644
--- a/src/server/__tests__/providers.test.ts
+++ b/src/server/__tests__/providers.test.ts
@@ -732,10 +732,10 @@ describe('ProviderService', () => {
apiFormat: 'openai_chat',
runtimeKind: 'grok_oauth',
models: {
- main: 'grok-4.6',
- haiku: 'grok-4.6',
- sonnet: 'grok-4.6',
- opus: 'grok-4.6',
+ main: 'grok-4.7',
+ haiku: 'grok-4.7',
+ sonnet: 'grok-4.7',
+ opus: 'grok-4.7',
},
})
@@ -749,10 +749,10 @@ describe('ProviderService', () => {
expect(env.GROK_OAUTH_FILE).toBe(
path.join(tmpDir, 'cc-haha', 'grok-oauth.json'),
)
- expect(env.ANTHROPIC_MODEL).toBe('grok-4.6')
- expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('grok-4.6')
- expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('grok-4.6')
- expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('grok-4.6')
+ expect(env.ANTHROPIC_MODEL).toBe('grok-4.7')
+ expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('grok-4.7')
+ expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('grok-4.7')
+ expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('grok-4.7')
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBeUndefined()
expect(env.OPENAI_CODEX_OAUTH_FILE).toBeUndefined()
})
diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts
index d73942fd..639c6457 100644
--- a/src/server/__tests__/settings.test.ts
+++ b/src/server/__tests__/settings.test.ts
@@ -1485,10 +1485,13 @@ describe('Models API', () => {
}
expect(body.provider).toEqual({ id: 'grok-official', name: 'Grok Official' })
expect(body.models.map((model) => model.id)).toEqual([
+ 'grok-4.7',
+ 'grok-4.7-build-fast',
'grok-4.6',
'grok-4.5',
- 'grok-composer-2.5-fast',
])
+ expect(body.models.find((model) => model.id === 'grok-4.7')?.context).toBe('500000')
+ expect(body.models.find((model) => model.id === 'grok-4.7-build-fast')?.context).toBe('500000')
expect(body.models.find((model) => model.id === 'grok-4.6')?.context).toBe('500000')
expect(body.models.find((model) => model.id === 'grok-4.5')?.context).toBe('500000')
})
diff --git a/src/server/services/grokOfficialProvider.test.ts b/src/server/services/grokOfficialProvider.test.ts
index b3015de4..a7d206ef 100644
--- a/src/server/services/grokOfficialProvider.test.ts
+++ b/src/server/services/grokOfficialProvider.test.ts
@@ -30,6 +30,8 @@ describe('Grok Official runtime environment', () => {
expect(windows['grok-next-preview']).toBe(375_000)
expect(windows['grok-window-unknown']).toBeUndefined()
+ expect(windows['grok-4.7']).toBe(500_000)
+ expect(windows['grok-4.7-build-fast']).toBe(500_000)
expect(windows['grok-4.6']).toBe(500_000)
expect(windows['grok-4.5']).toBe(500_000)
})
diff --git a/src/server/services/grokOfficialProvider.ts b/src/server/services/grokOfficialProvider.ts
index 79c9b7b4..da1d79c6 100644
--- a/src/server/services/grokOfficialProvider.ts
+++ b/src/server/services/grokOfficialProvider.ts
@@ -4,8 +4,8 @@ import {
GROK_DEFAULT_SONNET_MODEL,
GROK_MODEL_CATALOG,
getGrokContextWindowForModel,
+ getGrokRuntimeModelCatalog,
} from '../../services/grokAuth/models.js'
-import { getGrokRuntimeModelCatalog } from '../../services/grokAuth/modelCatalog.js'
import { GROK_OAUTH_FILE_ENV_KEY } from '../../services/grokAuth/storage.js'
import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js'
import {
diff --git a/src/services/grokAuth/fetch.test.ts b/src/services/grokAuth/fetch.test.ts
index d0fec191..87fa6e9d 100644
--- a/src/services/grokAuth/fetch.test.ts
+++ b/src/services/grokAuth/fetch.test.ts
@@ -9,6 +9,11 @@ import {
} from './fetch.js'
import { GROK_OAUTH_FILE_ENV_KEY } from './storage.js'
import { GROK_OAUTH_TOKEN_ENDPOINT } from './client.js'
+import {
+ GROK_MODEL_CATALOG,
+ setGrokRuntimeModelCatalog,
+ type GrokModelCatalogEntry,
+} from './models.js'
import { isRetryableStreamTransportError } from '../api/withRetry.js'
describe('Grok Responses fetch adapter', () => {
@@ -27,6 +32,7 @@ describe('Grok Responses fetch adapter', () => {
})
afterEach(async () => {
+ setGrokRuntimeModelCatalog(GROK_MODEL_CATALOG)
if (original === undefined) delete process.env[GROK_OAUTH_FILE_ENV_KEY]
else process.env[GROK_OAUTH_FILE_ENV_KEY] = original
await fs.rm(tmpDir, { recursive: true, force: true })
@@ -81,12 +87,15 @@ describe('Grok Responses fetch adapter', () => {
})
test('drops Claude reasoning effort for Grok models that reject it', async () => {
+ setGrokRuntimeModelCatalog([
+ liveModel('grok-4.8', { supportsReasoningEffort: false }),
+ ])
let upstreamBody: Record | undefined
const fetchOverride: typeof fetch = async (_input, init) => {
upstreamBody = JSON.parse(String(init?.body))
return new Response([
'event: response.completed',
- 'data: {"response":{"id":"resp_no_effort","object":"response","created_at":1,"model":"grok-composer-2.5-fast","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}',
+ 'data: {"response":{"id":"resp_no_effort","object":"response","created_at":1,"model":"grok-4.8","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}',
'',
].join('\n'), { headers: { 'Content-Type': 'text/event-stream' } })
}
@@ -94,7 +103,7 @@ describe('Grok Responses fetch adapter', () => {
const response = await buildGrokFetch(fetchOverride, 'test')(
'https://api.anthropic.com/v1/messages',
{ method: 'POST', body: JSON.stringify({
- model: 'grok-composer-2.5-fast',
+ model: 'grok-4.8',
max_tokens: 64,
output_config: { effort: 'max' },
messages: [{ role: 'user', content: 'hello' }],
@@ -105,6 +114,72 @@ describe('Grok Responses fetch adapter', () => {
expect(upstreamBody?.reasoning).toBeUndefined()
})
+ test('honors the effort set the live catalog declares for a model this build predates', async () => {
+ // Regression: `resolveGrokReasoningEffort` used to read only the bundled
+ // catalog, so the live feed's per-model capability declaration was ignored
+ // for every model newer than this build.
+ setGrokRuntimeModelCatalog([
+ liveModel('grok-4.8', {
+ supportsReasoningEffort: true,
+ reasoningEffort: 'high',
+ reasoningEfforts: ['high', 'medium', 'low'],
+ }),
+ ])
+ let upstreamBody: Record | undefined
+ const fetchOverride: typeof fetch = async (_input, init) => {
+ upstreamBody = JSON.parse(String(init?.body))
+ return new Response([
+ 'event: response.completed',
+ 'data: {"response":{"id":"resp_live_effort","object":"response","created_at":1,"model":"grok-4.8","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}',
+ '',
+ ].join('\n'), { headers: { 'Content-Type': 'text/event-stream' } })
+ }
+
+ const response = await buildGrokFetch(fetchOverride, 'test')(
+ 'https://api.anthropic.com/v1/messages',
+ { method: 'POST', body: JSON.stringify({
+ model: 'grok-4.8',
+ max_tokens: 64,
+ output_config: { effort: 'xhigh' },
+ messages: [{ role: 'user', content: 'hello' }],
+ }) },
+ )
+
+ expect(response.status).toBe(200)
+ // xhigh is not in the live set, so it must clamp to the declared default
+ // rather than being forwarded or dropped.
+ expect(upstreamBody?.reasoning).toEqual({ effort: 'high' })
+ })
+
+ test('forwards the selected effort for a model neither catalog describes', async () => {
+ // The old fallback returned undefined here, and the transform reads that as
+ // "the model rejects effort" and deletes the whole reasoning block — so a
+ // model launched after this build ran at the upstream default no matter what
+ // the user selected.
+ let upstreamBody: Record | undefined
+ const fetchOverride: typeof fetch = async (_input, init) => {
+ upstreamBody = JSON.parse(String(init?.body))
+ return new Response([
+ 'event: response.completed',
+ 'data: {"response":{"id":"resp_unknown_effort","object":"response","created_at":1,"model":"grok-4.9-preview","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}',
+ '',
+ ].join('\n'), { headers: { 'Content-Type': 'text/event-stream' } })
+ }
+
+ const response = await buildGrokFetch(fetchOverride, 'test')(
+ 'https://api.anthropic.com/v1/messages',
+ { method: 'POST', body: JSON.stringify({
+ model: 'grok-4.9-preview',
+ max_tokens: 64,
+ output_config: { effort: 'xhigh' },
+ messages: [{ role: 'user', content: 'hello' }],
+ }) },
+ )
+
+ expect(response.status).toBe(200)
+ expect(upstreamBody?.reasoning).toEqual({ effort: 'xhigh' })
+ })
+
test('routes remotely discovered model IDs without silently replacing them', async () => {
let upstreamBody: Record | undefined
let upstreamHeaders: Headers | undefined
@@ -377,3 +452,17 @@ describe('Grok Responses fetch adapter', () => {
expect(calls).toBe(1)
})
})
+
+/** A `/v1/models` row as the CLI proxy advertises it. */
+function liveModel(
+ value: string,
+ overrides: Partial,
+): GrokModelCatalogEntry {
+ return {
+ value,
+ label: value,
+ description: '',
+ contextWindow: 500_000,
+ ...overrides,
+ }
+}
diff --git a/src/services/grokAuth/fetch.ts b/src/services/grokAuth/fetch.ts
index 2248fe9b..224ce4a2 100644
--- a/src/services/grokAuth/fetch.ts
+++ b/src/services/grokAuth/fetch.ts
@@ -4,7 +4,11 @@ import { openaiResponsesStreamToAnthropic } from '../../server/proxy/streaming/o
import { openaiResponsesStreamToAnthropicResponse } from '../../server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.js'
import type { AnthropicRequest } from '../../server/proxy/transform/types.js'
import { ensureFreshGrokTokens, forceRefreshGrokTokens } from './refresh.js'
-import { resolveGrokModel, resolveGrokReasoningEffort } from './models.js'
+import {
+ getGrokRuntimeModelCatalog,
+ resolveGrokModel,
+ resolveGrokReasoningEffort,
+} from './models.js'
import { getGrokOAuthTokens } from './storage.js'
export const GROK_CLI_BASE_URL = 'https://cli-chat-proxy.grok.com/v1'
@@ -55,6 +59,7 @@ export function buildGrokFetch(
const reasoningEffort = resolveGrokReasoningEffort(
requestedModel,
transformedBody.reasoning?.effort,
+ getGrokRuntimeModelCatalog(),
)
if (reasoningEffort) {
transformedBody.reasoning = {
diff --git a/src/services/grokAuth/modelCatalog.test.ts b/src/services/grokAuth/modelCatalog.test.ts
index 64832848..442c1c02 100644
--- a/src/services/grokAuth/modelCatalog.test.ts
+++ b/src/services/grokAuth/modelCatalog.test.ts
@@ -79,7 +79,7 @@ describe('Grok model catalog', () => {
forceRefresh: true,
fetchOverride: async () => new Response('nope', { status: 503 }),
})
- expect(models[0]?.value).toBe('grok-4.6')
+ expect(models[0]?.value).toBe('grok-4.7')
expect(models.some((model) => model.value === 'grok-4.5')).toBe(true)
})
diff --git a/src/services/grokAuth/modelCatalog.ts b/src/services/grokAuth/modelCatalog.ts
index aa1fcf5e..1cabc2d6 100644
--- a/src/services/grokAuth/modelCatalog.ts
+++ b/src/services/grokAuth/modelCatalog.ts
@@ -6,6 +6,7 @@ import { createModelCatalogCache } from '../modelCatalogCache.js'
import { ensureFreshGrokTokens } from './refresh.js'
import {
GROK_MODEL_CATALOG,
+ setGrokRuntimeModelCatalog,
type GrokModelCatalogEntry,
} from './models.js'
@@ -16,7 +17,6 @@ const catalogCache = createModelCatalogCache({
ttlMs: MODEL_CATALOG_TTL_MS,
failureBackoffMs: MODEL_CATALOG_FAILURE_BACKOFF_MS,
})
-let runtimeCatalog: readonly GrokModelCatalogEntry[] = GROK_MODEL_CATALOG
export async function fetchGrokModelCatalog(
fetchOverride: typeof fetch = globalThis.fetch,
@@ -38,7 +38,7 @@ export async function fetchGrokModelCatalog(
.map(normalizeRemoteModel)
.filter((model): model is GrokModelCatalogEntry => model !== null)
if (!models.length) throw new Error('Grok models endpoint returned no models')
- runtimeCatalog = models
+ setGrokRuntimeModelCatalog(models)
return models
}
@@ -55,17 +55,13 @@ export async function getGrokModelCatalog(options?: {
fallback: GROK_MODEL_CATALOG,
...(options?.forceRefresh ? { forceRefresh: true } : {}),
})
- runtimeCatalog = models
+ setGrokRuntimeModelCatalog(models)
return models
}
-export function getGrokRuntimeModelCatalog(): readonly GrokModelCatalogEntry[] {
- return runtimeCatalog
-}
-
export function clearGrokModelCatalogCache(): void {
catalogCache.clear()
- runtimeCatalog = GROK_MODEL_CATALOG
+ setGrokRuntimeModelCatalog(GROK_MODEL_CATALOG)
}
function extractModelRows(body: unknown): unknown[] {
diff --git a/src/services/grokAuth/models.test.ts b/src/services/grokAuth/models.test.ts
index d57df462..93367b59 100644
--- a/src/services/grokAuth/models.test.ts
+++ b/src/services/grokAuth/models.test.ts
@@ -1,38 +1,83 @@
-import { describe, expect, test } from 'bun:test'
+import { afterEach, describe, expect, test } from 'bun:test'
import {
GROK_DEFAULT_MAIN_MODEL,
GROK_MODEL_CATALOG,
getGrokContextWindowForModel,
+ getGrokRuntimeModelCatalog,
resolveGrokModel,
resolveGrokReasoningEffort,
+ setGrokRuntimeModelCatalog,
} from './models.js'
describe('Grok model catalog', () => {
- test('keeps CLI-only aliases out of the picker fallback and defaults to Grok 4.6', () => {
+ afterEach(() => setGrokRuntimeModelCatalog(GROK_MODEL_CATALOG))
+
+ test('mirrors the live picker fallback and defaults to Grok 4.7', () => {
expect(GROK_MODEL_CATALOG.map((model) => model.value)).toEqual([
+ 'grok-4.7',
+ 'grok-4.7-build-fast',
'grok-4.6',
'grok-4.5',
- 'grok-composer-2.5-fast',
])
- expect(GROK_DEFAULT_MAIN_MODEL).toBe('grok-4.6')
+ expect(GROK_DEFAULT_MAIN_MODEL).toBe('grok-4.7')
expect(resolveGrokModel('claude-opus-4-1')).toBe(GROK_DEFAULT_MAIN_MODEL)
})
test('preserves remote model IDs and resolves only Claude compatibility aliases', () => {
- expect(resolveGrokModel('grok-composer-2.5-fast')).toBe('grok-composer-2.5-fast')
expect(resolveGrokModel('grok')).toBe(GROK_DEFAULT_MAIN_MODEL)
expect(resolveGrokModel('grok-next-preview')).toBe('grok-next-preview')
expect(resolveGrokModel('unknown-model')).toBe('unknown-model')
+ expect(getGrokContextWindowForModel('grok-4.7')).toBe(500_000)
+ expect(getGrokContextWindowForModel('grok-4.7-build-fast')).toBe(500_000)
expect(getGrokContextWindowForModel('grok-4.6')).toBe(500_000)
expect(getGrokContextWindowForModel('grok-4.5')).toBe(500_000)
expect(getGrokContextWindowForModel('unknown-model')).toBeNull()
})
- test('normalizes reasoning effort through the selected model catalog', () => {
- expect(resolveGrokReasoningEffort('grok-4.6', 'xhigh')).toBe('xhigh')
- expect(resolveGrokReasoningEffort('grok-4.6', 'max')).toBe('high')
+ test('normalizes reasoning effort through the bundled catalog', () => {
+ expect(resolveGrokReasoningEffort('grok-4.7', 'xhigh')).toBe('xhigh')
+ expect(resolveGrokReasoningEffort('grok-4.7', 'max')).toBe('high')
expect(resolveGrokReasoningEffort('grok-4.5', 'low')).toBe('low')
expect(resolveGrokReasoningEffort('grok-4.5', 'max')).toBe('high')
- expect(resolveGrokReasoningEffort('grok-composer-2.5-fast', 'high')).toBeUndefined()
+ })
+
+ test('resolves effort for a model only the live catalog describes', () => {
+ // Regression: a model newer than this build is absent from the bundled
+ // catalog, so resolving against bundled entries alone returned undefined and
+ // the request transform deleted the whole reasoning block.
+ setGrokRuntimeModelCatalog([
+ {
+ value: 'grok-4.8',
+ label: 'Grok 4.8',
+ description: '',
+ contextWindow: 500_000,
+ supportsReasoningEffort: true,
+ reasoningEffort: 'high',
+ reasoningEfforts: ['xhigh', 'high', 'low'],
+ },
+ ])
+ const catalog = getGrokRuntimeModelCatalog()
+ expect(resolveGrokReasoningEffort('grok-4.8', 'xhigh', catalog)).toBe('xhigh')
+ expect(resolveGrokReasoningEffort('grok-4.8', 'low', catalog)).toBe('low')
+ expect(resolveGrokReasoningEffort('grok-4.8', 'medium', catalog)).toBe('high')
+ })
+
+ test('honors a live declaration that a model rejects reasoning effort', () => {
+ setGrokRuntimeModelCatalog([
+ {
+ value: 'grok-4.8',
+ label: 'Grok 4.8',
+ description: '',
+ supportsReasoningEffort: false,
+ },
+ ])
+ expect(
+ resolveGrokReasoningEffort('grok-4.8', 'high', getGrokRuntimeModelCatalog()),
+ ).toBeUndefined()
+ })
+
+ test('forwards the requested effort for a model neither catalog describes', () => {
+ expect(resolveGrokReasoningEffort('grok-4.9-preview', 'xhigh')).toBe('xhigh')
+ expect(resolveGrokReasoningEffort('grok-4.9-preview', undefined)).toBeUndefined()
})
})
diff --git a/src/services/grokAuth/models.ts b/src/services/grokAuth/models.ts
index c8a1c4df..b2eda1c8 100644
--- a/src/services/grokAuth/models.ts
+++ b/src/services/grokAuth/models.ts
@@ -1,4 +1,4 @@
-export const GROK_DEFAULT_MAIN_MODEL = 'grok-4.6'
+export const GROK_DEFAULT_MAIN_MODEL = 'grok-4.7'
export const GROK_DEFAULT_SONNET_MODEL = GROK_DEFAULT_MAIN_MODEL
export const GROK_DEFAULT_HAIKU_MODEL = GROK_DEFAULT_MAIN_MODEL
export const GROK_DEFAULT_MODEL = GROK_DEFAULT_MAIN_MODEL
@@ -17,7 +17,19 @@ export type GrokModelCatalogEntry = {
export const GROK_MODEL_CATALOG: GrokModelCatalogEntry[] = [
{
- ...model('grok-4.6', 'Grok 4.6', "SpaceXAI's latest frontier model", 500_000),
+ ...model('grok-4.7', 'Grok 4.7', "SpaceXAI's latest frontier model", 500_000),
+ supportsReasoningEffort: true,
+ reasoningEffort: 'high',
+ reasoningEfforts: ['xhigh', 'high', 'medium', 'low'],
+ },
+ {
+ ...model('grok-4.7-build-fast', 'Grok 4.7 Fast', 'Fast variant. 2x the price.', 500_000),
+ supportsReasoningEffort: true,
+ reasoningEffort: 'high',
+ reasoningEfforts: ['xhigh', 'high', 'medium', 'low'],
+ },
+ {
+ ...model('grok-4.6', 'Grok 4.6', 'Grok 4.6 frontier model', 500_000),
supportsReasoningEffort: true,
reasoningEffort: 'high',
reasoningEfforts: ['xhigh', 'high', 'medium', 'low'],
@@ -28,10 +40,6 @@ export const GROK_MODEL_CATALOG: GrokModelCatalogEntry[] = [
reasoningEffort: 'high',
reasoningEfforts: ['high', 'medium', 'low'],
},
- {
- ...model('grok-composer-2.5-fast', 'Composer 2.5', 'Grok coding model', 200_000),
- supportsReasoningEffort: false,
- },
]
function model(
@@ -44,6 +52,28 @@ function model(
return { value, label, description, contextWindow, source }
}
+/**
+ * The catalog as last seen from the live `/v1/models` feed, falling back to the
+ * bundled entries until that fetch lands.
+ *
+ * This state lives here rather than in `modelCatalog.ts` because that module
+ * builds its request endpoint from `fetch.ts`, and the request transform in
+ * `fetch.ts` needs to read the live catalog. Keeping the pointer in the
+ * dependency-free catalog module is what lets both sides share it without an
+ * import cycle.
+ */
+let runtimeCatalog: readonly GrokModelCatalogEntry[] = GROK_MODEL_CATALOG
+
+export function setGrokRuntimeModelCatalog(
+ models: readonly GrokModelCatalogEntry[],
+): void {
+ runtimeCatalog = models
+}
+
+export function getGrokRuntimeModelCatalog(): readonly GrokModelCatalogEntry[] {
+ return runtimeCatalog
+}
+
const EXPLICIT_MODELS = new Set(GROK_MODEL_CATALOG.map((entry) => entry.value))
const CLAUDE_COMPATIBILITY_ALIASES = new Set([
'default',
@@ -76,18 +106,35 @@ export function getGrokContextWindowForModel(modelId: string): number | null {
return GROK_MODEL_CATALOG.find((model) => model.value === resolved)?.contextWindow ?? null
}
+/**
+ * `catalog` is the live `/v1/models` view. The bundled catalog is only a
+ * fallback, so a model that exists upstream but predates this build must be
+ * resolved against the live entries or every future model launch silently
+ * degrades its effort to the upstream default.
+ */
export function resolveGrokReasoningEffort(
modelId: string,
requestedEffort: unknown,
+ catalog: readonly GrokModelCatalogEntry[] = GROK_MODEL_CATALOG,
): string | undefined {
const resolved = resolveGrokModel(modelId)
- const model = GROK_MODEL_CATALOG.find((entry) => entry.value === resolved)
- if (model?.supportsReasoningEffort === false) return undefined
- if (
- typeof requestedEffort === 'string' &&
- model?.reasoningEfforts?.includes(requestedEffort)
- ) {
- return requestedEffort
+ const model =
+ catalog.find((entry) => entry.value === resolved) ??
+ GROK_MODEL_CATALOG.find((entry) => entry.value === resolved)
+ if (model) {
+ if (model.supportsReasoningEffort === false) return undefined
+ if (
+ typeof requestedEffort === 'string' &&
+ model.reasoningEfforts?.includes(requestedEffort)
+ ) {
+ return requestedEffort
+ }
+ return model.reasoningEffort
}
- return model?.reasoningEffort
+ // Absent from both catalogs, so upstream advertises a model this build does
+ // not describe. The requested effort already went through the OpenAI effort
+ // vocabulary and the server validated it against that same live catalog, so
+ // forward it rather than silently sending a different effort than the user
+ // selected.
+ return typeof requestedEffort === 'string' ? requestedEffort : undefined
}
diff --git a/src/utils/effort.agent.test.ts b/src/utils/effort.agent.test.ts
index 3798c7b1..9d5f4595 100644
--- a/src/utils/effort.agent.test.ts
+++ b/src/utils/effort.agent.test.ts
@@ -9,6 +9,10 @@ import {
resolveAppliedEffort,
toPersistableEffort,
} from './effort.js'
+import {
+ GROK_MODEL_CATALOG,
+ setGrokRuntimeModelCatalog,
+} from 'src/services/grokAuth/models.js'
describe('agent effort values', () => {
test('accepts all named agent effort levels including xhigh', () => {
@@ -65,14 +69,13 @@ describe('agent effort values', () => {
const originalOverride = process.env.CLAUDE_CODE_EFFORT_LEVEL
delete process.env.CLAUDE_CODE_EFFORT_LEVEL
try {
- expect(modelSupportsEffort('grok-4.6')).toBe(true)
- expect(modelSupportsXHighEffort('grok-4.6')).toBe(true)
- expect(resolveAppliedEffort('grok-4.6', 'xhigh')).toBe('xhigh')
- expect(resolveAppliedEffort('grok-4.6', 'max')).toBe('high')
+ expect(modelSupportsEffort('grok-4.7')).toBe(true)
+ expect(modelSupportsXHighEffort('grok-4.7')).toBe(true)
+ expect(resolveAppliedEffort('grok-4.7', 'xhigh')).toBe('xhigh')
+ expect(resolveAppliedEffort('grok-4.7', 'max')).toBe('high')
+ expect(modelSupportsXHighEffort('grok-4.7-build-fast')).toBe(true)
expect(modelSupportsXHighEffort('grok-4.5')).toBe(false)
expect(resolveAppliedEffort('grok-4.5', 'xhigh')).toBe('high')
- expect(modelSupportsEffort('grok-composer-2.5-fast')).toBe(false)
- expect(resolveAppliedEffort('grok-composer-2.5-fast', 'xhigh')).toBe('high')
} finally {
if (originalOverride === undefined) {
delete process.env.CLAUDE_CODE_EFFORT_LEVEL
@@ -82,6 +85,43 @@ describe('agent effort values', () => {
}
})
+ test('reads Grok effort capability from the live catalog for an unknown model', () => {
+ // Regression: an ID absent from the bundled catalog used to fall through to
+ // the Claude heuristics, which report a third-party model as
+ // effort-incapable and strip the parameter entirely.
+ const originalOverride = process.env.CLAUDE_CODE_EFFORT_LEVEL
+ delete process.env.CLAUDE_CODE_EFFORT_LEVEL
+ setGrokRuntimeModelCatalog([
+ {
+ value: 'grok-4.8',
+ label: 'Grok 4.8',
+ description: '',
+ supportsReasoningEffort: true,
+ reasoningEffort: 'high',
+ reasoningEfforts: ['xhigh', 'high', 'low'],
+ },
+ {
+ value: 'grok-4.8-non-reasoning',
+ label: 'Grok 4.8 Non-Reasoning',
+ description: '',
+ supportsReasoningEffort: false,
+ },
+ ])
+ try {
+ expect(modelSupportsEffort('grok-4.8')).toBe(true)
+ expect(modelSupportsXHighEffort('grok-4.8')).toBe(true)
+ expect(resolveAppliedEffort('grok-4.8', 'xhigh')).toBe('xhigh')
+ expect(modelSupportsEffort('grok-4.8-non-reasoning')).toBe(false)
+ } finally {
+ setGrokRuntimeModelCatalog(GROK_MODEL_CATALOG)
+ if (originalOverride === undefined) {
+ delete process.env.CLAUDE_CODE_EFFORT_LEVEL
+ } else {
+ process.env.CLAUDE_CODE_EFFORT_LEVEL = originalOverride
+ }
+ }
+ })
+
test('lets request-scoped Agent effort override session env only when marked', () => {
const originalOverride = process.env.CLAUDE_CODE_EFFORT_LEVEL
try {
diff --git a/src/utils/effort.ts b/src/utils/effort.ts
index 28ea44bb..977c71de 100644
--- a/src/utils/effort.ts
+++ b/src/utils/effort.ts
@@ -15,7 +15,7 @@ import {
getOpenAIModelCatalogEntry,
isOpenAIResponsesModel,
} from 'src/services/openaiAuth/models.js'
-import { GROK_MODEL_CATALOG } from 'src/services/grokAuth/models.js'
+import { GROK_MODEL_CATALOG, getGrokRuntimeModelCatalog } from 'src/services/grokAuth/models.js'
export type EffortLevel = RuntimeEffortLevel | 'xhigh'
@@ -42,7 +42,14 @@ function shouldTrustBuiltInClaudeCapabilityList(): boolean {
function getGrokCatalogEntry(model: string): (typeof GROK_MODEL_CATALOG)[number] | undefined {
const normalized = model.trim().toLowerCase()
- return GROK_MODEL_CATALOG.find((entry) => entry.value === normalized)
+ // The live `/v1/models` feed is authoritative; the bundled entries are only a
+ // fallback. Without this, a Grok model newer than this build matches no entry
+ // and falls through to the Claude heuristics below, which report it as
+ // effort-incapable and silently strip the parameter.
+ return (
+ getGrokRuntimeModelCatalog().find((entry) => entry.value === normalized) ??
+ GROK_MODEL_CATALOG.find((entry) => entry.value === normalized)
+ )
}
// @[MODEL LAUNCH]: Add the new model to the allowlist if it supports the effort parameter.