mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
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:
@@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user