fix: refuse an unlaunchable model id before quick-start asks to evict a model (#752)

The dedicated route got this order at merge and quick-start kept the old one, so
a user could agree to a swap only to get the 400. It also runs on the pure build
now, so a refused id writes no config dir to clean up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-04 17:29:08 +01:00
1 parent e68afdd2ac
commit 345c901e81
2 files changed
+37 -15

No files matched your search

+17 -13
View File
@@ -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;
+20 -2
View File
@@ -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' };