diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 2121313b..2bcd85a6 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -4501,6 +4501,23 @@ export function registerSessionRoutes( const cmContextLength = resolveCustomModelContextLength(cmEndpoint, customModel.modelId); const cmSlotCount = cmEndpoint.modelSlotCounts?.[customModel.modelId]; + // See the dedicated route: refused on the pure build, before either confirmation, so + // nobody agrees to evict a model for an id that could never launch, and before anything + // is written. + const cmModelSpec = cmEntry.launch.params.model; + const cmInjection = buildCustomModelInjection(cmEntry, cmEndpoint, customModel.modelId, cmContextLength); + const cmForcedLaunchModel = cmInjection.kind === 'unsupported' ? undefined : cmInjection.launchModel; + if ( + cmForcedLaunchModel !== undefined && + cmModelSpec?.type === 'token' && + !matchesPattern(cmModelSpec.pattern, cmForcedLaunchModel) + ) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `Model id ${JSON.stringify(customModel.modelId)} cannot be passed to ${mode} on its command line` + ); + } + // See the dedicated route's own comment for the full reasoning: some CLIs' own fixed // overhead can exceed a small enough real context on the very first message, // regardless of contextLengthVar. Warn before creating a session that's certain to @@ -4571,19 +4588,6 @@ export function registerSessionRoutes( if (!cmApplied) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`); } - const cmModelSpec = cmEntry.launch.params.model; - if ( - cmApplied.launchModel !== undefined && - cmModelSpec?.type === 'token' && - !matchesPattern(cmModelSpec.pattern, cmApplied.launchModel) - ) { - removeConfigDir(cmApplied.configDir); - return createErrorResponse( - ApiErrorCode.INVALID_INPUT, - `Model id ${JSON.stringify(customModel.modelId)} cannot be passed to ${mode} on its command line` - ); - } - qsCustomModelEnvOverrides = { ...qsGatedEnvOverrides, ...cmApplied.envOverrides }; qsCustomModelAppliedEnvOverrides = cmApplied.envOverrides; qsCustomModelLaunchModel = cmApplied.launchModel; diff --git a/test/routes/quick-start-custom-model.test.ts b/test/routes/quick-start-custom-model.test.ts index 71e6166c..6a5aca2c 100644 --- a/test/routes/quick-start-custom-model.test.ts +++ b/test/routes/quick-start-custom-model.test.ts @@ -21,7 +21,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import fastifyCookie from '@fastify/cookie'; import { rm, readFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; @@ -195,7 +195,9 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', () expect(res.json().errorCode).toBe('OPERATION_FAILED'); }); - it('refuses a model id the CLI cannot carry on its command line, cleaning up any written config dir', async () => { + it('refuses a model id the CLI cannot carry on its command line, before writing anything', async () => { + const dirs = join(getDataDir(), 'custom-model-configs'); + const before = existsSync(dirs) ? readdirSync(dirs).length : 0; const res = await quickStart({ caseName: 'cm-badmodel', mode: 'pi', @@ -203,6 +205,7 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', () }); expect(res.json().success).toBe(false); expect(res.json().errorCode).toBe('INVALID_INPUT'); + expect(existsSync(dirs) ? readdirSync(dirs).length : 0).toBe(before); }); it('refuses customModel for a remote case', async () => { @@ -229,6 +232,21 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', () }); } + it('refuses a model id the CLI cannot launch before asking anyone to evict a model', async () => { + const other = ctx.sessions.get('test-session-1')!; + (other as unknown as { customModel: unknown }).customModel = { endpointId: 'ep1', modelId: 'llama3' }; + mockRunning([{ model: 'llama3', state: 'ready' }]); + + const res = await quickStart({ + caseName: 'cm-badmodel-swap', + mode: 'codex', + customModel: { endpointId: 'ep1', modelId: 'org/model@q4_k_m' }, + }); + + expect(res.json().requiresConfirmation).toBeUndefined(); + expect(res.json().errorCode).toBe('INVALID_INPUT'); + }); + it('asks for confirmation instead of launching when another live session is using the currently loaded model', async () => { const other = ctx.sessions.get('test-session-1')!; (other as unknown as { customModel: unknown }).customModel = { endpointId: 'ep1', modelId: 'llama3' };