mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Merge pull request #752: fix(custom-model): context limits, Codex model override, context discovery and picker filter
This commit is contained in:
commit
3bcb55ce96
29 files changed
+1125
-102
No files matched your search
@@ -171,6 +171,15 @@ describe('codex', () => {
|
||||
expect(cx({ model: 'gpt-5', resumeSessionId: 'roll_42' })).toBe('codex --model gpt-5 resume roll_42');
|
||||
});
|
||||
|
||||
it('carries an Ollama-style `qwen3:32b` model id, and still drops a leading-flag shape', () => {
|
||||
// `model-plain-colon`: the custom-model feature forces the selected id onto this param,
|
||||
// and ids like `qwen3:32b` carry a colon. The leading-alphanumeric rule is what keeps a
|
||||
// flag from riding the flag, so `:x` and `-x` must still drop out.
|
||||
expect(cx({ model: 'qwen3:32b' })).toBe('codex --model qwen3:32b');
|
||||
expect(cx({ model: ':leading' })).toBe('codex');
|
||||
expect(cx({ model: '-yolo' })).toBe('codex');
|
||||
});
|
||||
|
||||
it('sends reasoning effort as one model_reasoning_effort config value, for every level', () => {
|
||||
for (const level of CODEX_REASONING_EFFORTS) {
|
||||
expect(cx({ reasoningEffort: level })).toBe(`codex --config model_reasoning_effort=${level}`);
|
||||
|
||||
@@ -481,6 +481,52 @@ describe('refreshAllCustomModelHosts: parallel-slot enrichment (llama.cpp splits
|
||||
const [updated] = await readCustomModelHosts(dir);
|
||||
expect(updated.modelSlotCounts).toEqual({ a: 4 });
|
||||
});
|
||||
|
||||
it('a round where only the listed figure answers resets the slot count a loaded round learned', async () => {
|
||||
// The false-warning scenario, measured on a real llama-swap: round 1 has the model
|
||||
// loaded under `--ctx-size 262144 --parallel 4` — a TOTAL window shared across slots,
|
||||
// correctly stored with its count. Round 2 finds it unloaded and takes the LISTED
|
||||
// `context_length`, which llama-swap documents as the PER-SLOT window. If the stored 4
|
||||
// survived, the floor check would divide the per-slot figure a second time
|
||||
// (65536/4 = 16384) and warn about a model that serves 65536 to every request.
|
||||
const dir = getDataDir();
|
||||
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
|
||||
fetchMock.mockImplementation(async (url: URL) => {
|
||||
if (url.pathname === '/v1/models') {
|
||||
return new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'loaded' } }] }), { status: 200 });
|
||||
}
|
||||
if (url.pathname === '/running') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
running: [{ model: 'm', state: 'ready', cmd: 'llama-server -m /models/m.gguf --ctx-size 262144 --parallel 4' }],
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected request: ${url.href}`); // the command answers both questions
|
||||
});
|
||||
|
||||
await refreshAllCustomModelHosts();
|
||||
|
||||
let [round1] = await readCustomModelHosts(dir);
|
||||
expect(round1.modelContextLengths).toEqual({ m: 262144 });
|
||||
expect(round1.modelSlotCounts).toEqual({ m: 4 });
|
||||
|
||||
// Round 2: unloaded, no /running or /props probe in order, listing its per-slot figure.
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'unloaded' }, context_length: 65536 }] }), {
|
||||
status: 200,
|
||||
})
|
||||
);
|
||||
|
||||
await refreshAllCustomModelHosts();
|
||||
|
||||
const [round2] = await readCustomModelHosts(dir);
|
||||
expect(round2.modelContextLengths).toEqual({ m: 65536 });
|
||||
// 1 slot, not the stale 4: 65536 per slot with 1 recorded slot is the honest pair, so
|
||||
// the floor check reads 65536/request — exactly what the server gives a single request.
|
||||
expect(round2.modelSlotCounts).toEqual({ m: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshAllCustomModelHosts: model-size enrichment (parsed from /v1/models description)', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parse as parseToml } from 'smol-toml';
|
||||
import { getCli } from '../src/config/cli-registry/index.js';
|
||||
import {
|
||||
buildCustomModelInjection,
|
||||
@@ -112,6 +113,31 @@ describe('buildCustomModelInjection', () => {
|
||||
expect(parsed.provider.custom.models.qwen3).toEqual({});
|
||||
});
|
||||
|
||||
it('opencode: threads a discovered context length into the model limit as the full window (issue #749)', () => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', 16384);
|
||||
if (result.kind !== 'env') throw new Error('unreachable');
|
||||
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
|
||||
// `limit.context` IS the full window to opencode — it reserves for the reply itself —
|
||||
// so the discovered total passes through; `output` only caps the reply at a quarter.
|
||||
expect(parsed.provider.custom.models.qwen3).toEqual({ limit: { context: 16384, output: 4096 } });
|
||||
});
|
||||
|
||||
it('opencode: caps the response headroom reservation at 8192 for big windows', () => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', 262144);
|
||||
if (result.kind !== 'env') throw new Error('unreachable');
|
||||
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
|
||||
expect(parsed.provider.custom.models.qwen3).toEqual({ limit: { context: 262144, output: 8192 } });
|
||||
});
|
||||
|
||||
it('opencode: never invents a limit for an unknown or non-finite context length (issue #749 regression)', () => {
|
||||
for (const contextLength of [undefined, NaN]) {
|
||||
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', contextLength);
|
||||
if (result.kind !== 'env') throw new Error('unreachable');
|
||||
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
|
||||
expect(parsed.provider.custom.models.qwen3).toEqual({});
|
||||
}
|
||||
});
|
||||
|
||||
it('codex: configDir writes an isolated config.toml with model/base_url, and the key rides as extraEnv (never a literal TOML field)', () => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3');
|
||||
expect(result.kind).toBe('configDir');
|
||||
@@ -133,6 +159,30 @@ describe('buildCustomModelInjection', () => {
|
||||
expect(result.files[0].content).toContain('model = "weird\\"model"');
|
||||
});
|
||||
|
||||
it('codex: writes the discovered window as TOP-LEVEL model_context_window with a 75% compact limit (issue #750)', () => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3', 16384);
|
||||
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||
const doc = parseToml(result.files[0].content) as Record<string, unknown>;
|
||||
// Top-level is the shape codex reads: parked under [model_providers.custom] (its
|
||||
// provider table) the same keys would be silently ignored by codex itself.
|
||||
expect(doc.model_context_window).toBe(16384);
|
||||
expect(doc.model_auto_compact_token_limit).toBe(12288); // 75% of the window, floored
|
||||
const provider = (doc.model_providers as Record<string, Record<string, unknown>>).custom;
|
||||
expect(provider.model_context_window).toBeUndefined();
|
||||
expect(provider.model_auto_compact_token_limit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('codex: writes neither context line for an unknown, non-finite or zero context length', () => {
|
||||
for (const contextLength of [undefined, NaN, 0]) {
|
||||
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3', contextLength);
|
||||
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||
const doc = parseToml(result.files[0].content) as Record<string, unknown>;
|
||||
expect(doc.model_context_window, String(contextLength)).toBeUndefined();
|
||||
expect(doc.model_auto_compact_token_limit, String(contextLength)).toBeUndefined();
|
||||
expect(doc.model).toBe('qwen3'); // the rest of the file is still written
|
||||
}
|
||||
});
|
||||
|
||||
it('pi: configDir writes .pi/agent/models.json, redirected via HOME (verified live — PI_CONFIG_DIR does nothing for pi)', () => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('pi'), endpoint, 'qwen3');
|
||||
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||
@@ -232,7 +282,9 @@ describe('launchModel (the model launch param that selects the injected provider
|
||||
});
|
||||
|
||||
it('CLIs whose config selects the model on its own declare none', () => {
|
||||
for (const id of ['claude', 'opencode', 'codex', 'gemini', 'deepseek']) {
|
||||
// codex now declares launchModel (selected-model launch override) and is covered by
|
||||
// its own restart coverage, not here.
|
||||
for (const id of ['claude', 'opencode', 'gemini', 'deepseek']) {
|
||||
const result = buildCustomModelInjection(entryOrThrow(id), endpoint, 'qwen3');
|
||||
if (result.kind === 'unsupported') throw new Error('unreachable');
|
||||
expect(result.launchModel, id).toBeUndefined();
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @fileoverview Boot recovery rebuilds a saved custom-model selection through the SAME
|
||||
* context-length resolution the routes use: the model's discovered figure, falling back to
|
||||
* the endpoint's `defaultContextLength` (`resolveCustomModelContextLength`). Before the
|
||||
* shared helper, `_rebuildCustomModelEnv` read `modelContextLengths` alone — so a session
|
||||
* on a model that only the endpoint default covers came back from a Codeman restart with
|
||||
* `CLAUDE_CODE_MAX_CONTEXT_TOKENS` (or a config file's context figure) silently gone,
|
||||
* re-arming exactly the window overflow the var exists to prevent.
|
||||
*
|
||||
* `_rebuildCustomModelEnv` reads no `this` state (it re-derives everything from the endpoint
|
||||
* store and the Session it is handed), so it is driven straight off the prototype with an
|
||||
* empty receiver — `restoreMuxSessions()` itself cannot be reached under vitest, the same
|
||||
* constraint recorded in test/session-model-recovery.test.ts.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { getDataDir } from '../src/config/instance.js';
|
||||
import { writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
|
||||
import { customModelConfigDir, removeConfigDir } from '../src/custom-model-injection-apply.js';
|
||||
import { Session } from '../src/session.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import type { CustomModelBookkeeping } from '../src/types/session.js';
|
||||
|
||||
type RebuildFn = (session: Session, saved: CustomModelBookkeeping) => Promise<Record<string, string> | undefined>;
|
||||
|
||||
/** The real method, with an empty receiver — see the file header for why that is sound. */
|
||||
function rebuildCustomModelEnv(session: Session, saved: CustomModelBookkeeping) {
|
||||
const fn = (WebServer.prototype as unknown as { _rebuildCustomModelEnv: RebuildFn })._rebuildCustomModelEnv;
|
||||
expect(typeof fn, '_rebuildCustomModelEnv vanished from WebServer.prototype').toBe('function');
|
||||
return fn.call({}, session, saved);
|
||||
}
|
||||
|
||||
function endpoint(overrides: Partial<CustomModelHost> = {}): CustomModelHost {
|
||||
return {
|
||||
id: 'ep',
|
||||
label: 'Ep',
|
||||
baseUrl: 'http://localhost:8080',
|
||||
apiKey: 'recovery-probe-key',
|
||||
models: ['qwen3'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('_rebuildCustomModelEnv: context-length fallback on boot recovery', () => {
|
||||
const configs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const id of configs.splice(0)) removeConfigDir(customModelConfigDir(id));
|
||||
});
|
||||
|
||||
it('falls back to the endpoint defaultContextLength when discovery recorded no per-model figure', async () => {
|
||||
await writeCustomModelHosts(getDataDir(), [endpoint({ defaultContextLength: 32768 })]);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
configs.push(session.id);
|
||||
|
||||
const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] });
|
||||
|
||||
expect(env?.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('32768');
|
||||
});
|
||||
|
||||
it('a discovered per-model figure still wins over the endpoint default', async () => {
|
||||
await writeCustomModelHosts(getDataDir(), [
|
||||
endpoint({ modelContextLengths: { qwen3: 131072 }, defaultContextLength: 32768 }),
|
||||
]);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
configs.push(session.id);
|
||||
|
||||
const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] });
|
||||
|
||||
expect(env?.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('131072');
|
||||
});
|
||||
|
||||
it('states nothing rather than guessing when neither source knows a window', async () => {
|
||||
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
configs.push(session.id);
|
||||
|
||||
const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] });
|
||||
|
||||
expect(env?.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops the selection to undefined (kept for clearing) when the endpoint no longer exists', async () => {
|
||||
await writeCustomModelHosts(getDataDir(), []);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
configs.push(session.id);
|
||||
|
||||
await expect(rebuildCustomModelEnv(session, { endpointId: 'gone', modelId: 'qwen3', envKeys: [] })).resolves.toBe(
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,10 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
|
||||
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
|
||||
// Only for the [hidden] CSS guard below — the filter toggles the `hidden` property, and
|
||||
// `.run-mode-option`'s own `display: flex` would silently defeat it without the
|
||||
// `!important` re-assertion in styles.css.
|
||||
const STYLES_CSS = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf-8');
|
||||
// Only for the real _showCenterStatus DOM tests below (`bootAppWithRealCenterStatus`) —
|
||||
// every other test in this file stubs _showCenterStatus itself and has no need of it.
|
||||
const PANELS_UI_JS = readFileSync(new URL('../src/web/public/panels-ui.js', import.meta.url), 'utf-8');
|
||||
@@ -55,6 +59,9 @@ function bootApp(
|
||||
<div class="modal" id="customModelPickModal">
|
||||
<h3 id="customModelPickTitle"></h3>
|
||||
<p id="customModelPickHint"></p>
|
||||
<input type="search" id="customModelPickSearch" aria-label="Search models"
|
||||
oninput="app.filterCustomModelPick(this.value)">
|
||||
<p id="customModelPickNoMatches" role="status" hidden>No models match</p>
|
||||
<div id="customModelPickList"></div>
|
||||
</div>
|
||||
<div class="modal" id="customModelSwapConfirmModal">
|
||||
@@ -532,6 +539,172 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
expect(toastMessage).toMatch(/no models discovered/i);
|
||||
});
|
||||
|
||||
it('resets and focuses the search box every time the picker opens', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
|
||||
});
|
||||
win.app = app;
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
|
||||
// The caret lands in the box, so the first keystroke filters with no click.
|
||||
expect(win.document.activeElement).toBe(search);
|
||||
// A stale query from a previous open must not survive into the next one.
|
||||
search.value = 'zzz';
|
||||
search.dispatchEvent(new win.Event('input'));
|
||||
app.closeCustomModelPickModal();
|
||||
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
expect(search.value).toBe('');
|
||||
expect(win.document.activeElement).toBe(search);
|
||||
expect(win.document.getElementById('customModelPickNoMatches')!.hasAttribute('hidden')).toBe(true);
|
||||
const visible = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')].filter(
|
||||
(b) => !b.hasAttribute('hidden')
|
||||
);
|
||||
expect(visible).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('leaves the search box unfocused on touch devices so the OS keyboard stays off the list', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
|
||||
});
|
||||
win.app = app;
|
||||
// The guard reads the global the browser defines (mobile-detection.js).
|
||||
(win as unknown as { MobileDetection: { isTouchDevice: () => boolean } }).MobileDetection = {
|
||||
isTouchDevice: () => true,
|
||||
};
|
||||
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
|
||||
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
|
||||
expect(win.document.activeElement).not.toBe(search);
|
||||
// Only the focus is skipped: the rows still render, unfiltered and functional.
|
||||
const visible = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')].filter(
|
||||
(b) => !b.hasAttribute('hidden')
|
||||
);
|
||||
expect(visible).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('filters rows case-insensitively on the model id, keeping the surviving row fully functional', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [
|
||||
{
|
||||
id: 'llama-box',
|
||||
label: 'llama.cpp',
|
||||
baseUrl: 'http://x',
|
||||
models: ['qwen3', 'llama3'],
|
||||
defaultModelId: 'qwen3',
|
||||
},
|
||||
],
|
||||
});
|
||||
let launched: unknown[] | null = null;
|
||||
app.runCustomModelEntry = async (...args: unknown[]) => {
|
||||
launched = args;
|
||||
};
|
||||
win.app = app;
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
|
||||
search.value = 'QWEN';
|
||||
search.dispatchEvent(new win.Event('input'));
|
||||
|
||||
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||
expect(buttons.filter((b) => b.hasAttribute('hidden')).map((b) => b.textContent!.trim())).toEqual(['llama3']);
|
||||
const [visible] = buttons.filter((b) => !b.hasAttribute('hidden'));
|
||||
// The surviving row keeps its Default badge and its real, callable click handler.
|
||||
expect(visible.textContent).toContain('qwen3');
|
||||
expect(visible.textContent).toContain('Default');
|
||||
(visible as unknown as HTMLButtonElement & { onclick: (e: unknown) => void }).onclick(
|
||||
new (win as any).Event('click')
|
||||
);
|
||||
expect(launched).toEqual(['claude', 'llama-box', 'qwen3']);
|
||||
});
|
||||
|
||||
it('shows the no-matches line only while a query matches nothing, and clears it with the query', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
|
||||
});
|
||||
win.app = app;
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
|
||||
const noMatches = win.document.getElementById('customModelPickNoMatches')!;
|
||||
const isHidden = () =>
|
||||
win.document.getElementById('customModelPickList')!.querySelectorAll('button[hidden]').length;
|
||||
|
||||
// Whitespace is not a query: everything stays visible, no message.
|
||||
search.value = ' ';
|
||||
search.dispatchEvent(new win.Event('input'));
|
||||
expect(noMatches.hasAttribute('hidden')).toBe(true);
|
||||
expect(isHidden()).toBe(0);
|
||||
|
||||
search.value = 'ghost';
|
||||
search.dispatchEvent(new win.Event('input'));
|
||||
expect(noMatches.hasAttribute('hidden')).toBe(false);
|
||||
expect(isHidden()).toBe(2);
|
||||
|
||||
search.value = '';
|
||||
search.dispatchEvent(new win.Event('input'));
|
||||
expect(noMatches.hasAttribute('hidden')).toBe(true);
|
||||
expect(isHidden()).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps an HTML-significant model id inert in the picker rows too, and filterable by its raw text', async () => {
|
||||
// Same vector as the Run-menu row above, now via data-model-id: escapeHtml covers
|
||||
// the double quote, and the filter reads the decoded raw id back off dataset.
|
||||
const dangerousModel = '"><img src=x onerror=alert(1)>';
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', dangerousModel] }],
|
||||
});
|
||||
win.app = app;
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
const list = win.document.getElementById('customModelPickList')!;
|
||||
expect(list.querySelector('img')).toBeNull();
|
||||
|
||||
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
|
||||
search.value = 'src=x';
|
||||
search.dispatchEvent(new win.Event('input'));
|
||||
const visible = list.querySelectorAll('button:not([hidden])');
|
||||
expect(visible).toHaveLength(1);
|
||||
expect(visible[0].getAttribute('data-model-id')).toBe(dangerousModel);
|
||||
});
|
||||
|
||||
it('re-asserts display:none !important for hidden picker rows so the filter cannot be silently defeated', () => {
|
||||
// `.run-mode-option` carries its own `display: flex` (styles.css, restated in
|
||||
// mobile.css), which outranks the UA `[hidden]` rule — without this scoped
|
||||
// !important re-assertion, toggling `hidden` would change nothing on screen.
|
||||
const rule = STYLES_CSS.match(/#customModelPickModal \.run-mode-option\[hidden\]\s*\{[^}]*\}/);
|
||||
expect(rule, 'picker rows need a [hidden] rule scoped to #customModelPickModal').toBeTruthy();
|
||||
expect(rule![0]).toMatch(/display:\s*none\s*!important/);
|
||||
});
|
||||
|
||||
it('pins "No models match" hidden with an empty query and shown only when a query hides every row, in CSS too', async () => {
|
||||
// The visible symptom: the line carries `.form-hint`, whose `display: block`
|
||||
// outranks the UA `[hidden]` rule, so `hidden` alone showed "No models match"
|
||||
// while every model was listed with an empty search box. The attribute half
|
||||
// was always right (session-ui.js's `filterCustomModelPick`); the CSS half
|
||||
// needed the same `[hidden] { display: none !important }` re-assertion the
|
||||
// rows above carry. This test pins both states AND the CSS that makes them
|
||||
// actually render.
|
||||
const rule = STYLES_CSS.match(/#customModelPickModal \.custom-model-pick-no-matches\[hidden\]\s*\{[^}]*\}/);
|
||||
expect(rule, 'the no-matches line needs a [hidden] rule scoped to #customModelPickModal').toBeTruthy();
|
||||
expect(rule![0]).toMatch(/display:\s*none\s*!important/);
|
||||
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
|
||||
});
|
||||
win.app = app;
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
const noMatches = win.document.getElementById('customModelPickNoMatches')!;
|
||||
|
||||
// Empty query, models listed: the attribute must be set, and the CSS rule
|
||||
// above is what makes that attribute actually hide the line.
|
||||
expect(noMatches.hasAttribute('hidden')).toBe(true);
|
||||
|
||||
// A non-empty query that hides every row: shown again.
|
||||
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
|
||||
search.value = 'ghost';
|
||||
search.dispatchEvent(new win.Event('input'));
|
||||
expect(noMatches.hasAttribute('hidden')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Model Endpoint Profiles: _getCustomModelCurrentlyLoaded is client-side bounded', () => {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { registerCustomModelRoutes } from '../../src/web/routes/custom-model-routes.js';
|
||||
import { webviewFetch } from '../../src/web/webview-egress.js';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
|
||||
vi.mock('../../src/web/webview-egress.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/web/webview-egress.js')>(
|
||||
@@ -417,3 +417,166 @@ describe('apiKey is never handed back to the browser', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('context-length discovery from the /v1/models listing', () => {
|
||||
// The gateway path (a Bifrost in front of llama-swap, an OpenRouter-style server):
|
||||
// no /running, no /props, just a per-model `context_length` or `context_window` in the
|
||||
// model listing. Distinct ports per test keep the file's shared data dir unambiguous.
|
||||
afterEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
type StoredHost = { id: string; modelContextLengths?: Record<string, number>; defaultContextLength?: number };
|
||||
|
||||
type HarnessApp = RouteTestHarness['app'];
|
||||
|
||||
async function endpointWith(id: string, port: number, extra: Record<string, unknown> = {}): Promise<HarnessApp> {
|
||||
const { app } = await setup();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/model-endpoints',
|
||||
payload: { id, label: 'A', baseUrl: `http://localhost:${port}`, ...extra },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function storedHost(app: HarnessApp, id: string): Promise<StoredHost | undefined> {
|
||||
const list = await app.inject({ method: 'GET', url: '/api/model-endpoints' });
|
||||
return (list.json() as StoredHost[]).find((h) => h.id === id);
|
||||
}
|
||||
|
||||
it('reads context_length (preferred) then context_window from each entry into modelContextLengths', async () => {
|
||||
const app = await endpointWith('ep-ctx-listed', 8101);
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{ id: 'both', context_length: 131072, context_window: 32768 },
|
||||
{ id: 'window-only', context_window: 8192 },
|
||||
{ id: 'no-figure' },
|
||||
],
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
);
|
||||
|
||||
const discover = await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-listed/discover-models' });
|
||||
expect(discover.json().success).toBe(true);
|
||||
|
||||
const stored = await storedHost(app, 'ep-ctx-listed');
|
||||
expect(stored?.modelContextLengths).toEqual({ both: 131072, 'window-only': 8192 });
|
||||
});
|
||||
|
||||
it('ignores non-numeric, zero, negative and absurd listed values; the schema bound itself is still accepted', async () => {
|
||||
const app = await endpointWith('ep-ctx-bad', 8102);
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{ id: 'string', context_length: '131072' },
|
||||
{ id: 'zero', context_window: 0 },
|
||||
{ id: 'negative', context_length: -10 },
|
||||
{ id: 'over-bound', context_length: 100_000_001 },
|
||||
{ id: 'fractional', context_window: 16384.5 },
|
||||
{ id: 'at-bound', context_length: 100_000_000 },
|
||||
],
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
);
|
||||
|
||||
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-bad/discover-models' });
|
||||
|
||||
const stored = await storedHost(app, 'ep-ctx-bad');
|
||||
expect(stored?.modelContextLengths).toEqual({ 'at-bound': 100_000_000 });
|
||||
});
|
||||
|
||||
it('prefers the /running launch command over the listed figure for a loaded model', async () => {
|
||||
const app = await endpointWith('ep-ctx-cmd', 8103);
|
||||
fetchMock.mockImplementation(async (url: URL) => {
|
||||
if (url.pathname === '/v1/models') {
|
||||
return new Response(
|
||||
JSON.stringify({ data: [{ id: 'cmd-wins', status: { value: 'loaded' }, context_length: 131072 }] }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.pathname === '/running') {
|
||||
return new Response(
|
||||
JSON.stringify({ running: [{ model: 'cmd-wins', state: 'ready', cmd: 'llama-server --ctx-size 16384' }] }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.pathname === '/props') return new Response('nope', { status: 404 });
|
||||
throw new Error(`unexpected request in this test: ${url.href}`);
|
||||
});
|
||||
|
||||
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-cmd/discover-models' });
|
||||
|
||||
const stored = await storedHost(app, 'ep-ctx-cmd');
|
||||
expect(stored?.modelContextLengths).toEqual({ 'cmd-wins': 16384 });
|
||||
});
|
||||
|
||||
it('keeps a previously learned value the fresh listing says nothing about, and discovery never touches defaultContextLength', async () => {
|
||||
const app = await endpointWith('ep-ctx-keep', 8104, {
|
||||
models: ['re-listed', 'untouched'],
|
||||
modelContextLengths: { 're-listed': 16384, untouched: 4096 },
|
||||
defaultContextLength: 65536,
|
||||
});
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ data: [{ id: 're-listed', context_length: 32768 }, { id: 'untouched' }] }),
|
||||
{ status: 200 }
|
||||
)
|
||||
);
|
||||
|
||||
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-keep/discover-models' });
|
||||
|
||||
const stored = await storedHost(app, 'ep-ctx-keep');
|
||||
expect(stored?.modelContextLengths).toEqual({ 're-listed': 32768, untouched: 4096 });
|
||||
expect(stored?.defaultContextLength).toBe(65536);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultContextLength (the user-set fallback discovery never overwrites)', () => {
|
||||
afterEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('stores it on create, updates it on PUT, and a PUT without it clears it', async () => {
|
||||
const { app } = await setup();
|
||||
const create = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/model-endpoints',
|
||||
payload: { id: 'ep-defctx', label: 'A', baseUrl: 'http://localhost:8105', defaultContextLength: 131072 },
|
||||
});
|
||||
expect(create.json().data.host.defaultContextLength).toBe(131072);
|
||||
|
||||
const update = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/model-endpoints/ep-defctx',
|
||||
payload: { label: 'A', baseUrl: 'http://localhost:8105', defaultContextLength: 32768 },
|
||||
});
|
||||
expect(update.json().data.host.defaultContextLength).toBe(32768);
|
||||
|
||||
const cleared = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/model-endpoints/ep-defctx',
|
||||
payload: { label: 'A', baseUrl: 'http://localhost:8105' },
|
||||
});
|
||||
expect(cleared.json().data.host.defaultContextLength).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects zero, negative, fractional, over-the-bound and non-numeric values', async () => {
|
||||
const { app } = await setup();
|
||||
for (const defaultContextLength of [0, -1, 1.5, 100_000_001, '131072']) {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/model-endpoints',
|
||||
payload: { id: 'ep-defctx-bad', label: 'A', baseUrl: 'http://localhost:8106', defaultContextLength },
|
||||
});
|
||||
expect(res.json().success, String(defaultContextLength)).toBe(false);
|
||||
expect(res.json().errorCode, String(defaultContextLength)).toBe('INVALID_INPUT');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -122,6 +122,9 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
|
||||
const { sessionId } = res.json();
|
||||
const session = ctx.sessions.get(sessionId) as unknown as Session;
|
||||
expect(session.customModel?.endpointId).toBe('ep1');
|
||||
// #750, pinned the same way as the pi/grok/omp one-shot paths below: the FIRST
|
||||
// launch already carries the selected id, not just the file on disk.
|
||||
expect(session.getCustomModelForPersist()?.launchModel).toBe('qwen3');
|
||||
expect(restartSpy).not.toHaveBeenCalled();
|
||||
|
||||
const configDir = customModelConfigDir(sessionId);
|
||||
@@ -367,6 +370,50 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().requiresContextWarning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('warns using the endpoint defaultContextLength when discovery recorded no per-model figure', async () => {
|
||||
const endpoint: CustomModelHost = {
|
||||
id: 'ep-default',
|
||||
label: 'silent gateway',
|
||||
baseUrl: 'http://192.168.1.53:8080',
|
||||
apiKey: 'k',
|
||||
defaultContextLength: 16384,
|
||||
};
|
||||
await writeCustomModelHosts(getDataDir(), [ENDPOINT, endpoint]);
|
||||
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-default-ctx',
|
||||
mode: 'claude',
|
||||
customModel: { endpointId: 'ep-default', modelId: 'some-unknown-model' },
|
||||
});
|
||||
|
||||
const body = res.json();
|
||||
expect(body.requiresContextWarning).toBe(true);
|
||||
expect(body.contextLength).toBe(16384);
|
||||
expect(ctx.sessions.size).toBe(1); // nothing was created
|
||||
});
|
||||
|
||||
it('a discovered per-model figure wins over the endpoint defaultContextLength', async () => {
|
||||
const endpoint: CustomModelHost = {
|
||||
id: 'ep-precedence',
|
||||
label: 'mixed box',
|
||||
baseUrl: 'http://192.168.1.54:8080',
|
||||
apiKey: 'k',
|
||||
modelContextLengths: { m: 131072 },
|
||||
defaultContextLength: 16384,
|
||||
};
|
||||
await writeCustomModelHosts(getDataDir(), [ENDPOINT, endpoint]);
|
||||
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-precedence',
|
||||
mode: 'claude',
|
||||
customModel: { endpointId: 'ep-precedence', modelId: 'm' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().requiresContextWarning).toBeUndefined();
|
||||
expect(ctx.sessions.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggering the actual llama-swap load (not just watching for it)', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { createMockSession } from '../mocks/index.js';
|
||||
import { getDataDir } from '../../src/config/instance.js';
|
||||
import { writeCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js';
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { webviewFetch } from '../../src/web/webview-egress.js';
|
||||
|
||||
@@ -203,6 +203,10 @@ describe('POST /api/sessions/:id/custom-model', () => {
|
||||
const { app, ctx } = await setup();
|
||||
const session = ctx.sessions.get('test-session-1')!;
|
||||
session.mode = 'pi';
|
||||
// Start from no dir at all (a sibling test's successful pi apply may have left one),
|
||||
// so the existsSync below says something about THIS request.
|
||||
const dir = join(getDataDir(), 'custom-model-configs', 'test-session-1');
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -214,8 +218,45 @@ describe('POST /api/sessions/:id/custom-model', () => {
|
||||
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||
expect(session.setCustomModel).not.toHaveBeenCalled();
|
||||
expect(session.restartCli).not.toHaveBeenCalled();
|
||||
// The config dir written before the check is cleaned up again.
|
||||
expect(existsSync(join(getDataDir(), 'custom-model-configs', 'test-session-1'))).toBe(false);
|
||||
// The pattern check runs on the pure build BEFORE any write, so a refused id leaves
|
||||
// no directory behind at all, nothing to clean up.
|
||||
expect(existsSync(dir)).toBe(false);
|
||||
});
|
||||
|
||||
it('codex: a refused forced launch id never touches the live config dir the session runs on as CODEX_HOME', async () => {
|
||||
const { app, ctx } = await setup();
|
||||
const session = ctx.sessions.get('test-session-1')!;
|
||||
session.mode = 'codex';
|
||||
|
||||
// First apply succeeds: the session now runs codex on qwen3:32b, whose config.toml
|
||||
// lives in the per-session dir the running CLI itself uses as CODEX_HOME.
|
||||
const ok = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/custom-model',
|
||||
payload: { endpointId: 'ep1', modelId: 'qwen3:32b' },
|
||||
});
|
||||
expect(ok.statusCode).toBe(200);
|
||||
const dir = join(getDataDir(), 'custom-model-configs', 'test-session-1');
|
||||
const written = join(dir, 'config.toml');
|
||||
expect(existsSync(written)).toBe(true);
|
||||
|
||||
// `@` is outside codex's `model-plain-colon` pattern. The pattern check now runs on
|
||||
// the pure build BEFORE any write, so the refusal must leave the dir — and the file
|
||||
// the live CLI reads — exactly as they were, rather than replacing and deleting the
|
||||
// CODEX_HOME of a session that stays on qwen3:32b.
|
||||
const before = readFileSync(written, 'utf8');
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/custom-model',
|
||||
payload: { endpointId: 'ep1', modelId: 'org/model@q4_k_m' },
|
||||
});
|
||||
|
||||
expect(res.json().success).toBe(false);
|
||||
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
|
||||
expect(session.restartCli).toHaveBeenCalledTimes(1);
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
expect(readFileSync(written, 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('clear removes the previous config dir the session reports', async () => {
|
||||
@@ -449,6 +490,58 @@ describe('POST /api/sessions/:id/custom-model', () => {
|
||||
expect(session.restartCli).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the endpoint defaultContextLength stands in when discovery recorded no per-model figure', async () => {
|
||||
const { app, ctx } = await setup();
|
||||
// A gateway that states nothing per model: only the user's default is known.
|
||||
const endpoint: CustomModelHost = {
|
||||
id: 'ep-default',
|
||||
label: 'silent gateway',
|
||||
baseUrl: 'http://192.168.1.53:8080',
|
||||
apiKey: 'k',
|
||||
defaultContextLength: 16384,
|
||||
};
|
||||
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, endpoint]);
|
||||
const session = ctx.sessions.get('test-session-1')!;
|
||||
session.mode = 'claude';
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/custom-model',
|
||||
payload: { endpointId: 'ep-default', modelId: 'some-unknown-model' },
|
||||
});
|
||||
|
||||
const body = res.json();
|
||||
expect(body.requiresContextWarning).toBe(true);
|
||||
expect(body.contextLength).toBe(16384);
|
||||
expect(session.setCustomModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a discovered per-model figure wins over the endpoint defaultContextLength', async () => {
|
||||
const { app, ctx } = await setup();
|
||||
// The default is small enough to warn on; the discovered figure for THIS model is
|
||||
// not, so the fallback must not leak into the per-model path.
|
||||
const endpoint: CustomModelHost = {
|
||||
id: 'ep-precedence',
|
||||
label: 'mixed box',
|
||||
baseUrl: 'http://192.168.1.54:8080',
|
||||
apiKey: 'k',
|
||||
modelContextLengths: { 'qwen3.8-27b-ud-q4_k_xl': 131072 },
|
||||
defaultContextLength: 16384,
|
||||
};
|
||||
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, endpoint]);
|
||||
const session = ctx.sessions.get('test-session-1')!;
|
||||
session.mode = 'claude';
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/custom-model',
|
||||
payload: { endpointId: 'ep-precedence', modelId: 'qwen3.8-27b-ud-q4_k_xl' },
|
||||
});
|
||||
|
||||
expect(res.json().requiresContextWarning).toBeUndefined();
|
||||
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// llama-server splits its context across --parallel slots, and Claude Code sends several
|
||||
// requests at once, so a context that clears the floor as a whole can still fail per slot.
|
||||
describe('parallel slots share the context', () => {
|
||||
|
||||
@@ -27,6 +27,8 @@ import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Session } from '../src/session.js';
|
||||
import { getCli } from '../src/config/cli-registry/index.js';
|
||||
import { buildCustomModelInjection } from '../src/custom-model-injection.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { MuxSession, SessionMode } from '../src/types.js';
|
||||
|
||||
@@ -225,3 +227,29 @@ describe('launchModel reaches the CLI through its own launch param', () => {
|
||||
expect(respawn.mock.calls[0][0].model).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Codex custom endpoint model overrides', () => {
|
||||
it('selects the local model over a native launch default and restores the default on clear', async () => {
|
||||
const entry = getCli('codex');
|
||||
if (!entry) throw new Error('Missing Codex entry');
|
||||
const injection = buildCustomModelInjection(
|
||||
entry,
|
||||
{ id: 'ep', label: 'Local', baseUrl: 'http://box:8080' },
|
||||
'qwen3'
|
||||
);
|
||||
if (injection.kind !== 'configDir') throw new Error('Expected isolated Codex config');
|
||||
const { session, respawn } = liveSession('codex', {
|
||||
codexConfig: { model: 'native-model', reasoningEffort: 'high' },
|
||||
});
|
||||
session.setCustomModel(
|
||||
{ endpointId: 'ep', modelId: 'qwen3', envKeys: ['CODEX_HOME'], launchModel: injection.launchModel },
|
||||
{ CODEX_HOME: '/isolated/codex' }
|
||||
);
|
||||
await session.restartCli();
|
||||
expect(respawn.mock.calls[0][0].codexConfig).toEqual({ model: 'qwen3', reasoningEffort: 'high' });
|
||||
session.setCustomModel(undefined);
|
||||
respawn.mockClear();
|
||||
await session.restartCli();
|
||||
expect(respawn.mock.calls[0][0].codexConfig).toEqual({ model: 'native-model', reasoningEffort: 'high' });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user