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.