From f6260e428d0d66e8c463f4b9749f9e1bd9c91f55 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 17 Mar 2026 10:40:36 +0100 Subject: [PATCH 1/2] reasoning effort logic --- .../dashboard-server/src/proxy-server.test.ts | 25 ++++ .../dashboard-server/src/routes/models.ts | 75 ++++++++++- .../src/adapters/cloudFetchAdapter.test.ts | 49 +++++++ packages/dashboard/src/adapters/types.ts | 1 + .../src/components/SpawnModal.test.tsx | 127 ++++++++++++++++++ .../dashboard/src/components/SpawnModal.tsx | 28 ++-- .../src/components/settings/SettingsPage.tsx | 7 +- .../dashboard/src/lib/model-options.test.ts | 21 +++ packages/dashboard/src/lib/model-options.ts | 119 ++++++++++++++++ .../dashboard/src/providers/AgentProvider.tsx | 7 +- 10 files changed, 443 insertions(+), 16 deletions(-) create mode 100644 packages/dashboard/src/adapters/cloudFetchAdapter.test.ts create mode 100644 packages/dashboard/src/lib/model-options.test.ts create mode 100644 packages/dashboard/src/lib/model-options.ts diff --git a/packages/dashboard-server/src/proxy-server.test.ts b/packages/dashboard-server/src/proxy-server.test.ts index 3df0d2f..d48ef2e 100644 --- a/packages/dashboard-server/src/proxy-server.test.ts +++ b/packages/dashboard-server/src/proxy-server.test.ts @@ -286,6 +286,31 @@ describe('Dashboard Server', () => { expect(data.ok).toBe(true); }); + it('should expose Codex reasoning metadata from /api/models', async () => { + const address = server.server.address(); + if (!address || typeof address === 'string') { + throw new Error('Server address not available'); + } + const port = address.port; + + const response = await fetch(`http://localhost:${port}/api/models`); + const data = await response.json(); + + expect(response.ok).toBe(true); + expect(data.success).toBe(true); + expect(Array.isArray(data.modelOptions?.Codex)).toBe(true); + + const codexMini = data.modelOptions.Codex.find((model: { value: string }) => model.value === 'gpt-5.1-codex-mini'); + expect(codexMini).toBeDefined(); + expect(codexMini.defaultReasoningEffort).toBe('high'); + expect(codexMini.reasoningEfforts).toEqual(['medium', 'high']); + + const codexFrontier = data.modelOptions.Codex.find((model: { value: string }) => model.value === 'gpt-5.4'); + expect(codexFrontier).toBeDefined(); + expect(codexFrontier.defaultReasoningEffort).toBe('xhigh'); + expect(codexFrontier.reasoningEfforts).toEqual(['low', 'medium', 'high', 'xhigh']); + }); + it('should proxy /api/brokers/* routes in proxy mode', async () => { const address = server.server.address(); if (!address || typeof address === 'string') { diff --git a/packages/dashboard-server/src/routes/models.ts b/packages/dashboard-server/src/routes/models.ts index 0afca1c..dba62d8 100644 --- a/packages/dashboard-server/src/routes/models.ts +++ b/packages/dashboard-server/src/routes/models.ts @@ -1,5 +1,70 @@ import type { Application } from 'express'; +type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh'; + +type ModelOption = { + value: string; + label: string; + reasoningEfforts?: ReasoningEffort[]; + defaultReasoningEffort?: ReasoningEffort; +}; + +type ModelOptionsResponse = Record; +type DefaultModelsResponse = Record; +type ConfigModule = typeof import('@agent-relay/config') & { + getDefaultReasoningEffort?: (cli: string, model: string) => ReasoningEffort | undefined; + getSupportedReasoningEfforts?: (cli: string, model: string) => ReasoningEffort[] | undefined; +}; + +function inferCodexReasoningEfforts(model: string): ReasoningEffort[] | undefined { + if (model === 'gpt-5.1-codex-mini') { + return ['medium', 'high']; + } + + if (model.startsWith('gpt-5')) { + return ['low', 'medium', 'high', 'xhigh']; + } + + return undefined; +} + +function enrichCodexModelOptions( + modelOptions: ModelOptionsResponse, + config: ConfigModule, +): ModelOptionsResponse { + const codexOptions = Array.isArray(modelOptions.Codex) ? modelOptions.Codex : null; + if (!codexOptions) { + return modelOptions; + } + + const enrichedCodexOptions = codexOptions.map((option) => { + const reasoningEfforts = + option.reasoningEfforts + ?? config.getSupportedReasoningEfforts?.('codex', option.value) + ?? inferCodexReasoningEfforts(option.value); + + if (!reasoningEfforts || reasoningEfforts.length === 0) { + return option; + } + + const defaultReasoningEffort = + option.defaultReasoningEffort + ?? config.getDefaultReasoningEffort?.('codex', option.value) + ?? reasoningEfforts[reasoningEfforts.length - 1]; + + return { + ...option, + reasoningEfforts, + defaultReasoningEffort, + }; + }); + + return { + ...modelOptions, + Codex: enrichedCodexOptions, + }; +} + /** * Model options route. * Serves model options from @agent-relay/config (generated from cli-registry.yaml). @@ -7,11 +72,15 @@ import type { Application } from 'express'; export function registerModelsRoutes(app: Application): void { app.get('/api/models', async (_req, res) => { try { - const { ModelOptions, DefaultModels } = await import('@agent-relay/config'); + const config = await import('@agent-relay/config') as ConfigModule; + const modelOptions = enrichCodexModelOptions( + config.ModelOptions as ModelOptionsResponse, + config, + ); return res.json({ success: true, - modelOptions: ModelOptions, - defaultModels: DefaultModels, + modelOptions, + defaultModels: config.DefaultModels as DefaultModelsResponse, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/packages/dashboard/src/adapters/cloudFetchAdapter.test.ts b/packages/dashboard/src/adapters/cloudFetchAdapter.test.ts new file mode 100644 index 0000000..1501b48 --- /dev/null +++ b/packages/dashboard/src/adapters/cloudFetchAdapter.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createCloudApiAdapter, setCloudCsrfToken } from './cloudFetchAdapter'; + +describe('createCloudApiAdapter', () => { + afterEach(() => { + vi.unstubAllGlobals(); + setCloudCsrfToken(null); + }); + + it('includes reasoningEffort in workspace spawn requests when provided', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + headers: new Headers(), + json: async () => ({ + name: 'codex-1', + sandboxId: 'sandbox-1', + status: 'online', + cli: 'codex', + workspaceId: 'ws-1', + createdAt: '2026-03-17T00:00:00.000Z', + }), + })); + + vi.stubGlobal('fetch', fetchMock); + + const adapter = createCloudApiAdapter(); + await adapter.spawnAgent('ws-1', { + name: 'codex-1', + provider: 'codex', + model: 'gpt-5.4', + reasoningEffort: 'xhigh', + cwd: 'repo-a', + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('/api/workspaces/ws-1/agents'); + expect(init?.method).toBe('POST'); + expect(JSON.parse(String(init?.body))).toEqual({ + name: 'codex-1', + provider: 'codex', + model: 'gpt-5.4', + reasoningEffort: 'xhigh', + cwd: 'repo-a', + }); + }); +}); diff --git a/packages/dashboard/src/adapters/types.ts b/packages/dashboard/src/adapters/types.ts index 9bd495d..5d934e0 100644 --- a/packages/dashboard/src/adapters/types.ts +++ b/packages/dashboard/src/adapters/types.ts @@ -471,6 +471,7 @@ export interface CloudApiAdapter { task?: string; cwd?: string; model?: string; + reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'; } ): Promise< CloudApiResult<{ diff --git a/packages/dashboard/src/components/SpawnModal.test.tsx b/packages/dashboard/src/components/SpawnModal.test.tsx index 201e023..3395ee4 100644 --- a/packages/dashboard/src/components/SpawnModal.test.tsx +++ b/packages/dashboard/src/components/SpawnModal.test.tsx @@ -220,4 +220,131 @@ describe('SpawnModal', () => { expect(config.cwd).toBe('/custom/path'); }); }); + + describe('model selection', () => { + it('falls back to a supported Codex model and applies the default reasoning effort override', async () => { + const onSpawn = vi.fn().mockResolvedValue(true); + + renderSpawnModal({ + onSpawn, + agentDefaults: { + defaultCliType: 'codex', + defaultModels: { + codex: 'gpt-5.1-codex-mini', + }, + }, + modelOptions: { + codex: [ + { + value: 'gpt-5.4', + label: 'GPT-5.4', + reasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'xhigh', + }, + { + value: 'gpt-5.1-codex-max', + label: 'GPT-5.1 Codex Max', + reasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'xhigh', + }, + ], + }, + registryDefaultModels: { + codex: 'gpt-5.4', + }, + }); + + const modelSelect = await screen.findByLabelText('Model') as HTMLSelectElement; + expect(modelSelect.value).toBe('gpt-5.4'); + + fireEvent.submit(getForm()); + + await waitFor(() => { + expect(onSpawn).toHaveBeenCalled(); + }); + + const config = onSpawn.mock.calls[0][0]; + expect(config.command).toBe('codex --model gpt-5.4 -c model_reasoning_effort="xhigh"'); + }); + + it('applies the Codex mini reasoning effort override when mini is selected', async () => { + const onSpawn = vi.fn().mockResolvedValue(true); + + renderSpawnModal({ + onSpawn, + agentDefaults: { + defaultCliType: 'codex', + defaultModels: { + codex: 'gpt-5.1-codex-mini', + }, + }, + modelOptions: { + codex: [ + { + value: 'gpt-5.1-codex-mini', + label: 'GPT-5.1 Codex Mini', + reasoningEfforts: ['medium', 'high'], + defaultReasoningEffort: 'high', + }, + { + value: 'gpt-5.4', + label: 'GPT-5.4', + reasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'xhigh', + }, + ], + }, + registryDefaultModels: { + codex: 'gpt-5.4', + }, + }); + + const modelSelect = await screen.findByLabelText('Model') as HTMLSelectElement; + expect(modelSelect.value).toBe('gpt-5.1-codex-mini'); + + fireEvent.submit(getForm()); + + await waitFor(() => { + expect(onSpawn).toHaveBeenCalled(); + }); + + const config = onSpawn.mock.calls[0][0]; + expect(config.command).toBe('codex --model gpt-5.1-codex-mini -c model_reasoning_effort="high"'); + }); + + it('falls back to a supported OpenCode model when a saved default is no longer offered', async () => { + const onSpawn = vi.fn().mockResolvedValue(true); + + renderSpawnModal({ + onSpawn, + agentDefaults: { + defaultCliType: 'opencode', + defaultModels: { + opencode: 'openai/gpt-5.1-codex', + }, + }, + modelOptions: { + opencode: [ + { value: 'openai/gpt-5.2', label: 'GPT-5.2' }, + { value: 'openai/gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' }, + ], + }, + registryDefaultModels: { + opencode: 'openai/gpt-5.2', + }, + }); + + const modelSelect = await screen.findByLabelText('Model') as HTMLSelectElement; + expect(modelSelect.value).toBe('openai/gpt-5.2'); + + fireEvent.submit(getForm()); + + await waitFor(() => { + expect(onSpawn).toHaveBeenCalled(); + }); + + const config = onSpawn.mock.calls[0][0]; + expect(config.command).toBe('opencode --model openai/gpt-5.2'); + }); + }); }); diff --git a/packages/dashboard/src/components/SpawnModal.tsx b/packages/dashboard/src/components/SpawnModal.tsx index 99819a9..578f5b3 100644 --- a/packages/dashboard/src/components/SpawnModal.tsx +++ b/packages/dashboard/src/components/SpawnModal.tsx @@ -7,6 +7,7 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useDashboardConfig } from '../adapters'; +import { buildCommandWithModel, resolveSupportedModel } from '../lib/model-options'; /** * Model options are fetched from the server (/api/models) which sources them @@ -67,6 +68,8 @@ export interface SpawnModalProps { export interface ModelOption { value: string; label: string; + reasoningEfforts?: Array<'low' | 'medium' | 'high' | 'xhigh'>; + defaultReasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'; } const EMPTY_MODEL_OPTIONS: ModelOption[] = []; @@ -173,10 +176,8 @@ export function SpawnModal({ }, [modelOptions]); const getDefaultModelForCli = useCallback((cli: string): string => { - return agentDefaults?.defaultModels?.[cli] - ?? registryDefaultModels?.[cli] - ?? getModelsForCli(cli)[0]?.value - ?? ''; + const options = getModelsForCli(cli); + return resolveSupportedModel(options, agentDefaults?.defaultModels?.[cli], registryDefaultModels?.[cli]); }, [agentDefaults, registryDefaultModels, getModelsForCli]); const [selectedTemplate, setSelectedTemplate] = useState(AGENT_TEMPLATES[0]); @@ -197,8 +198,14 @@ export function SpawnModal({ /** Get selected model for the current template */ const getSelectedModel = useCallback((cli: string): string => { - return selectedModels[cli] ?? getDefaultModelForCli(cli); - }, [selectedModels, getDefaultModelForCli]); + const options = getModelsForCli(cli); + return resolveSupportedModel( + options, + selectedModels[cli], + agentDefaults?.defaultModels?.[cli], + registryDefaultModels?.[cli], + ); + }, [selectedModels, agentDefaults, registryDefaultModels, getModelsForCli]); const setModelForCli = useCallback((cli: string, model: string) => { setSelectedModels(prev => ({ ...prev, [cli]: model })); @@ -213,11 +220,16 @@ export function SpawnModal({ if (template?.supportsModelSelection) { const model = getSelectedModel(selectedTemplate.id); if (model) { - return `${selectedTemplate.command} --model ${model}`; + return buildCommandWithModel( + selectedTemplate.command, + selectedTemplate.id, + model, + getModelsForCli(selectedTemplate.id), + ); } } return selectedTemplate.command; - }, [selectedTemplate, customCommand, getSelectedModel]); + }, [selectedTemplate, customCommand, getSelectedModel, getModelsForCli]); const shadowMode = useMemo(() => deriveShadowMode(effectiveCommand), [effectiveCommand]); diff --git a/packages/dashboard/src/components/settings/SettingsPage.tsx b/packages/dashboard/src/components/settings/SettingsPage.tsx index 7fd1768..aa20ed7 100644 --- a/packages/dashboard/src/components/settings/SettingsPage.tsx +++ b/packages/dashboard/src/components/settings/SettingsPage.tsx @@ -12,6 +12,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useDashboardConfig, type DashboardFeatures } from '../../adapters'; +import { resolveSupportedModel } from '../../lib/model-options'; import type { Settings, CliType } from './types'; import type { ModelOption } from '../SpawnModal'; @@ -410,7 +411,11 @@ export function SettingsPage({ description={`Default model when spawning ${label} agents`} >