Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions packages/dashboard-server/src/proxy-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
75 changes: 72 additions & 3 deletions packages/dashboard-server/src/routes/models.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,86 @@
import type { Application } from 'express';

type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh';

type ModelOption = {
value: string;
label: string;
reasoningEfforts?: ReasoningEffort[];
defaultReasoningEffort?: ReasoningEffort;
};

type ModelOptionsResponse = Record<string, ModelOption[]>;
type DefaultModelsResponse = Record<string, string>;
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).
*/
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);
Expand Down
49 changes: 49 additions & 0 deletions packages/dashboard/src/adapters/cloudFetchAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
1 change: 1 addition & 0 deletions packages/dashboard/src/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,7 @@ export interface CloudApiAdapter {
task?: string;
cwd?: string;
model?: string;
reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh';
}
): Promise<
CloudApiResult<{
Expand Down
127 changes: 127 additions & 0 deletions packages/dashboard/src/components/SpawnModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
28 changes: 20 additions & 8 deletions packages/dashboard/src/components/SpawnModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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]);
Expand All @@ -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 }));
Expand All @@ -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]);

Expand Down
7 changes: 6 additions & 1 deletion packages/dashboard/src/components/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -410,7 +411,11 @@ export function SettingsPage({
description={`Default model when spawning ${label} agents`}
>
<select
value={settings.agentDefaults?.defaultModels?.[id] ?? registryDefaultModels?.[id] ?? models[0]?.value ?? ''}
value={resolveSupportedModel(
models,
settings.agentDefaults?.defaultModels?.[id],
registryDefaultModels?.[id],
)}
onChange={(e) => updateSettings((prev) => ({
...prev,
agentDefaults: {
Expand Down
Loading
Loading