Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 1 addition & 6 deletions backend/src/services/opencode-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { OpenCodeClient } from './opencode/client'

interface OpenCodeConfigResponse {
model?: string
small_model?: string
}

interface OpenCodeProviderResponse {
Expand Down Expand Up @@ -76,7 +75,6 @@ export async function resolveOpenCodeModel(
directory: string | undefined,
options?: {
preferredModel?: string | null
preferSmallModel?: boolean
},
): Promise<ResolvedOpenCodeModel> {
const [config, providersResponse] = await Promise.all([
Expand All @@ -86,10 +84,7 @@ export async function resolveOpenCodeModel(

const availableModels = buildAvailableModels(providersResponse)
const defaultModels = providersResponse.default ?? {}
const configCandidates = options?.preferSmallModel
? [config.small_model, config.model]
: [config.model, config.small_model]
const candidates = uniqueCandidates([options?.preferredModel, ...configCandidates])
const candidates = uniqueCandidates([options?.preferredModel, config.model])

for (const candidate of candidates) {
if (availableModels.has(candidate)) {
Expand Down
97 changes: 15 additions & 82 deletions backend/test/services/opencode-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,75 +36,11 @@ describe('resolveOpenCodeModel', () => {
})
})

it('falls back to the provider default when the preferred model is unavailable', async () => {
it('falls back to the configured model when the preferred model is unavailable', async () => {
const mockClient = {
getJson: vi.fn().mockImplementation((path: string) => {
if (path === '/config') {
return Promise.resolve({ model: 'openai/gpt-5.4' })
}
if (path === '/config/providers') {
return Promise.resolve({
providers: [
{ id: 'openai', models: { 'gpt-5.3-codex-spark': {}, 'gpt-5-mini': {} } },
],
default: { openai: 'gpt-5.3-codex-spark' },
})
}
throw new Error(`Unexpected path: ${path}`)
}),
} as unknown as OpenCodeClient

const result = await resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project', {
preferredModel: 'openai/gpt-5.4',
})

expect(result).toEqual({
providerID: 'openai',
modelID: 'gpt-5.3-codex-spark',
model: 'openai/gpt-5.3-codex-spark',
})
})

it('prefers the configured small model when requested', async () => {
const mockClient = {
getJson: vi.fn().mockImplementation((path: string) => {
if (path === '/config') {
return Promise.resolve({
model: 'openai/gpt-5',
small_model: 'openai/gpt-5-mini',
})
}
if (path === '/config/providers') {
return Promise.resolve({
providers: [
{ id: 'openai', models: { 'gpt-5': {}, 'gpt-5-mini': {} } },
],
default: { openai: 'gpt-5' },
})
}
throw new Error(`Unexpected path: ${path}`)
}),
} as unknown as OpenCodeClient

const result = await resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project', {
preferSmallModel: true,
})

expect(result).toEqual({
providerID: 'openai',
modelID: 'gpt-5-mini',
model: 'openai/gpt-5-mini',
})
})

it('falls back to config.model when small_model is unavailable', async () => {
const mockClient = {
getJson: vi.fn().mockImplementation((path: string) => {
if (path === '/config') {
return Promise.resolve({
model: 'openai/gpt-5',
small_model: 'openai/gpt-5-unavailable',
})
return Promise.resolve({ model: 'openai/gpt-5' })
}
if (path === '/config/providers') {
return Promise.resolve({
Expand All @@ -119,7 +55,7 @@ describe('resolveOpenCodeModel', () => {
} as unknown as OpenCodeClient

const result = await resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project', {
preferSmallModel: true,
preferredModel: 'openai/retired',
})

expect(result).toEqual({
Expand All @@ -129,19 +65,16 @@ describe('resolveOpenCodeModel', () => {
})
})

it('falls back to provider default only after all configured candidates fail', async () => {
it('falls back to the provider default when the configured model is unavailable', async () => {
const mockClient = {
getJson: vi.fn().mockImplementation((path: string) => {
if (path === '/config') {
return Promise.resolve({
model: 'openai/gpt-5-configured',
small_model: 'openai/gpt-5-small-unavailable',
})
return Promise.resolve({ model: 'openai/gpt-5-unavailable' })
}
if (path === '/config/providers') {
return Promise.resolve({
providers: [
{ id: 'openai', models: { 'gpt-5-mini': {}, 'gpt-5-turbo': {}, 'gpt-5-configured': {} } },
{ id: 'openai', models: { 'gpt-5-mini': {}, 'gpt-5-turbo': {} } },
],
default: { openai: 'gpt-5-mini' },
})
Expand All @@ -151,45 +84,45 @@ describe('resolveOpenCodeModel', () => {
} as unknown as OpenCodeClient

const result = await resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project', {
preferSmallModel: true,
preferredModel: 'openai/retired',
})

expect(result).toEqual({
providerID: 'openai',
modelID: 'gpt-5-configured',
model: 'openai/gpt-5-configured',
modelID: 'gpt-5-mini',
model: 'openai/gpt-5-mini',
})
})

it('falls back to provider default when both small_model and model are unavailable', async () => {
it('never selects the configured small_model', async () => {
const mockClient = {
getJson: vi.fn().mockImplementation((path: string) => {
if (path === '/config') {
return Promise.resolve({
model: 'openai/gpt-5-unavailable',
small_model: 'openai/gpt-5-also-unavailable',
small_model: 'openai/gpt-5-mini',
})
}
if (path === '/config/providers') {
return Promise.resolve({
providers: [
{ id: 'openai', models: { 'gpt-5-mini': {}, 'gpt-5-turbo': {} } },
],
default: { openai: 'gpt-5-mini' },
default: { openai: 'gpt-5-turbo' },
})
}
throw new Error(`Unexpected path: ${path}`)
}),
} as unknown as OpenCodeClient

const result = await resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project', {
preferSmallModel: true,
preferredModel: 'openai/retired',
})

expect(result).toEqual({
providerID: 'openai',
modelID: 'gpt-5-mini',
model: 'openai/gpt-5-mini',
modelID: 'gpt-5-turbo',
model: 'openai/gpt-5-turbo',
})
})

Expand Down
52 changes: 52 additions & 0 deletions backend/test/services/schedules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,58 @@ describe('ScheduleService', () => {
)
})

it('submits the resolved fallback model when the stored job model is gone', async () => {
const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never)
const runWithSession: ScheduleRun = {
...baseRun,
sessionId: 'ses-fallback-model',
sessionTitle: 'Scheduled: Weekly engineering summary',
logText: 'Run started. Waiting for assistant response...',
}
mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession)
mocks.getScheduleRunById.mockReturnValue(runWithSession)
mocks.getScheduleJobById.mockReturnValue({ ...job, model: 'openai/retired' })
mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', modelID: 'gpt-5' })

let capturedPromptBody: string | undefined
routeForward(({ path, method, body }) => {
if (path === '/session' && method === 'POST') {
return Promise.resolve(jsonResponse({ id: 'ses-fallback-model' }))
}

if (path === '/session/ses-fallback-model/prompt_async' && method === 'POST') {
capturedPromptBody = body
return Promise.resolve(new Response(null, { status: 204 }))
}

if (path === '/session/ses-fallback-model/message' && method === 'GET') {
return Promise.resolve(jsonResponse([
{
info: { role: 'assistant', sessionID: 'ses-fallback-model', time: { completed: Date.now() } },
parts: [{ type: 'text', text: 'Done.' }],
},
]))
}

throw new Error(`Unexpected proxy request: ${method} ${path}`)
})

await service.runJob(42, 7, 'manual')

await vi.waitFor(() => {
expect(capturedPromptBody).toBeDefined()
})

expect(mocks.resolveOpenCodeModel).toHaveBeenCalledWith(
expect.anything(),
repo.fullPath,
{ preferredModel: 'openai/retired' },
)
expect(JSON.parse(capturedPromptBody!)).toMatchObject({
model: { providerID: 'openai', modelID: 'gpt-5' },
})
})

it('sends session and prompt_async JSON POSTs with Content-Type: application/json', async () => {
const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never)
const runWithSession: ScheduleRun = {
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/components/schedules/JobDetailTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
hasSkillMetadata,
} from '@/components/schedules/schedule-utils'
import { Bot, CalendarClock, Clock3, History, Loader2, Pencil, Play, Sparkles, Trash2 } from 'lucide-react'
import { useScheduleModels } from '@/hooks/useScheduleModels'
import { resolveScheduleModel } from '@/lib/schedules/schedule-model'

interface JobDetailTabProps {
selectedJob: ScheduleJob | undefined
Expand All @@ -33,6 +35,9 @@ export function JobDetailTab({
runningRun,
isJobFetching,
}: JobDetailTabProps) {
const { availableModelKeys, configDefaultModel } = useScheduleModels(Boolean(selectedJob))
const resolvedModel = resolveScheduleModel(selectedJob?.model, availableModelKeys, configDefaultModel)

if (!selectedJob) {
return (
<div className="flex h-full items-center justify-center">
Expand Down Expand Up @@ -124,7 +129,7 @@ export function JobDetailTab({
</div>
<div>
<p className="text-muted-foreground">Model</p>
<p className="font-medium break-all">{selectedJob.model ?? 'Workspace default'}</p>
<p className="font-medium break-all">{resolvedModel ?? 'Workspace default'}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
Expand Down
Loading