feat(provider): support Grok 4.7 and resolve effort from the live catalog

Grok 4.7 shipped with a faster variant (grok-4.7-build-fast), and the
gateway's /v1/models feed no longer advertises grok-composer-2.5-fast.

Resolving reasoning effort against the bundled catalog alone gave a model
newer than this build no effort at all, and the request transform reads
that as a model which rejects the parameter and strips the whole reasoning
block. Selecting xhigh on 4.7 therefore ran at the upstream default, while
the picker and the server-side validation both accepted the choice. Effort
now resolves against the live catalog first, and a model neither catalog
describes keeps the requested effort instead of losing it.

The runtime catalog pointer moves into the dependency-free catalog module
so the request transform can read it without an import cycle back through
fetch.ts.

Both bundled catalogs gain 4.7 and its fast variant and default to
grok-4.7, and the retired composer model migrates pinned sessions to that
default rather than sending an ID the gateway no longer serves.
This commit is contained in:
程序员阿江(Relakkes)
2026-09-22 00:43:44 +08:00
parent 0820247d47
commit a26deab8d7
19 changed files with 338 additions and 91 deletions
@@ -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(<ModelSelector runtimeKey="session-stale-grok" />)
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',
})
})
+18 -9
View File
@@ -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: [],
},
]
+4 -6
View File
@@ -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',
+1 -1
View File
@@ -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()
})
+17 -14
View File
@@ -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([{
@@ -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__'
@@ -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()
+8 -8
View File
@@ -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()
})
+4 -1
View File
@@ -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')
})
@@ -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)
})
+1 -1
View File
@@ -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 {
+91 -2
View File
@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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>,
): GrokModelCatalogEntry {
return {
value,
label: value,
description: '',
contextWindow: 500_000,
...overrides,
}
}
+6 -1
View File
@@ -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 = {
+1 -1
View File
@@ -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)
})
+4 -8
View File
@@ -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<GrokModelCatalogEntry[]>({
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[] {
+54 -9
View File
@@ -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()
})
})
+61 -14
View File
@@ -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
}
+46 -6
View File
@@ -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 {
+9 -2
View File
@@ -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.