mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
fix(custom-model): pass context limits and selected model to CLIs, and add model picker filter
- opencode: write limit.context/output from the discovered context length (#749) - codex: override the native launch model when a custom endpoint is selected (#750), and set model_context_window - model picker: add a search/filter box with a no-matches line
This commit is contained in:
1 parent
f1f7eec4d6
commit
ed67596387
13 files changed
+427
-54
No files matched your search
@@ -0,0 +1,64 @@
|
||||
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getCli } from '../src/config/cli-registry/index.js';
|
||||
import { buildCustomModelInjection } from '../src/custom-model-injection.js';
|
||||
import {
|
||||
applyCustomModelInjection,
|
||||
removeConfigDir,
|
||||
customModelConfigDir,
|
||||
} from '../src/custom-model-injection-apply.js';
|
||||
|
||||
afterEach(() => {
|
||||
removeConfigDir(customModelConfigDir('deepseek-probe'));
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('DeepSeek custom endpoint isolation', () => {
|
||||
it('uses the chosen model and limits without changing installed profiles or embedding the key', () => {
|
||||
const realHome = join(homedir(), '.dsh');
|
||||
const profile = join(realHome, 'profiles', 'dsh-tui');
|
||||
mkdirSync(profile, { recursive: true });
|
||||
const original = join(profile, 'cordis.patch.yml');
|
||||
writeFileSync(original, 'original profile');
|
||||
vi.stubEnv('DSH_HOME', realHome);
|
||||
const entry = getCli('deepseek');
|
||||
if (!entry) throw new Error('Missing DeepSeek entry');
|
||||
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
|
||||
const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
|
||||
if (!applied?.configDir) throw new Error('Missing isolated profile');
|
||||
const content = readFileSync(join(applied.configDir, 'cordis.patch.yml'), 'utf8');
|
||||
expect(content).not.toContain(endpoint.apiKey);
|
||||
expect(applied.envOverrides.DEEPSEEK_API_KEY).toBe(endpoint.apiKey);
|
||||
const patch = JSON.parse(content);
|
||||
const provider = patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'];
|
||||
expect(provider.api).toBe('openai-completions');
|
||||
expect(provider.baseURL).toBe('http://box:8080/v1');
|
||||
expect(provider.models).toEqual([
|
||||
{ id: 'chosen-model', name: 'chosen-model', contextWindow: 131072, maxTokens: 8192 },
|
||||
]);
|
||||
expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config).toEqual({
|
||||
provider: 'codeman-custom',
|
||||
model: 'chosen-model',
|
||||
});
|
||||
expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(realHome, 'profiles'));
|
||||
removeConfigDir(applied.configDir);
|
||||
expect(readFileSync(original, 'utf8')).toBe('original profile');
|
||||
});
|
||||
|
||||
it('gives Codex its known context and compaction budget', () => {
|
||||
const entry = getCli('codex');
|
||||
if (!entry) throw new Error('Missing Codex entry');
|
||||
const injection = buildCustomModelInjection(
|
||||
entry,
|
||||
{ id: 'local', label: 'Local', baseUrl: 'http://box' },
|
||||
'chosen-model',
|
||||
131072
|
||||
);
|
||||
if (injection.kind !== 'configDir') throw new Error('Expected isolated Codex config');
|
||||
expect(injection.files[0].content).toContain('model_context_window = 131072');
|
||||
expect(injection.files[0].content).toContain('model_auto_compact_token_limit = 98304');
|
||||
expect(injection.launchModel).toBe('chosen-model');
|
||||
});
|
||||
});
|
||||
@@ -53,9 +53,12 @@ describe('applyCustomModelInjection: context length', () => {
|
||||
expect(applied?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deepseek: has no contextLengthVar declared, so a passed-in length is a no-op', () => {
|
||||
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', 'sess-ctx-3', 16384);
|
||||
expect(Object.keys(applied?.envOverrides ?? {}).sort()).toEqual(['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL']);
|
||||
it('deepseek: passes the known context through its isolated model catalogue', () => {
|
||||
const sessionId = 'sess-deepseek-context';
|
||||
sessionsToClean.push(sessionId);
|
||||
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId, 32768);
|
||||
expect(Object.keys(applied?.envOverrides ?? {}).sort()).toEqual(['DEEPSEEK_API_KEY', 'DSH_HOME']);
|
||||
expect(readFileSync(join(applied!.configDir!, 'cordis.patch.yml'), 'utf8')).toContain('"contextWindow": 32768');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,11 +106,12 @@ describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => {
|
||||
expect(applied?.envOverrides.HOME).toBe(customModelConfigDir(sessionId));
|
||||
});
|
||||
|
||||
it('deepseek: no configDirVar declared, so no config dir is created at all', () => {
|
||||
it('deepseek: creates an isolated profile patch', () => {
|
||||
const sessionId = 'sess-cfgdir-deepseek';
|
||||
sessionsToClean.push(sessionId);
|
||||
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId);
|
||||
expect(applied?.configDir).toBeUndefined();
|
||||
expect(existsSync(customModelConfigDir(sessionId))).toBe(false);
|
||||
expect(applied?.configDir).toBe(customModelConfigDir(sessionId));
|
||||
expect(existsSync(join(applied!.configDir!, 'cordis.patch.yml'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -112,6 +112,31 @@ describe('buildCustomModelInjection', () => {
|
||||
expect(parsed.provider.custom.models.qwen3).toEqual({});
|
||||
});
|
||||
|
||||
it('opencode: threads a discovered context length into the model limit with response headroom (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);
|
||||
// One quarter held back for the reply: context 16384-4096, output 4096 — the sum is the
|
||||
// real window, so a full-size prompt plus a max-length reply never exceeds the server.
|
||||
expect(parsed.provider.custom.models.qwen3).toEqual({ limit: { context: 12288, 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: 253952, 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');
|
||||
@@ -179,29 +204,25 @@ describe('buildCustomModelInjection', () => {
|
||||
expect(result.extraEnv).toEqual({ XAI_API_KEY: 'my-key' });
|
||||
});
|
||||
|
||||
it('deepseek: env kind sets base URL (with a /v1 suffix appended) and key, no model var', () => {
|
||||
// appendV1Suffix is REQUIRED here, not cosmetic: confirmed by reading dsh's own
|
||||
// bundled source (@deepseek-ai/dsh-llm-deepseek) that it builds the request URL as
|
||||
// `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own, while
|
||||
// llama-swap/llama.cpp only serves "/v1/chat/completions" — without this, every
|
||||
// request 404s (confirmed live; this is the fix for the originally-reported
|
||||
// "dsh: HTTP_404: DeepSeek API error (HTTP 404)").
|
||||
it('deepseek: isolates the selected model route and passes its key by reference', () => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3');
|
||||
if (result.kind !== 'env') throw new Error('unreachable');
|
||||
expect(result.envOverrides).toEqual({
|
||||
DEEPSEEK_BASE_URL: 'http://192.168.1.50:8080/v1',
|
||||
DEEPSEEK_API_KEY: 'my-key',
|
||||
});
|
||||
if (result.kind !== 'configDir') throw new Error('Expected isolated DeepSeek config');
|
||||
expect(result.dirEnvVar).toBe('DSH_HOME');
|
||||
expect(result.extraEnv).toEqual({ DEEPSEEK_API_KEY: 'my-key' });
|
||||
expect(result.files[0].content).not.toContain('my-key');
|
||||
const patch = JSON.parse(result.files[0].content);
|
||||
expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config.model).toBe('qwen3');
|
||||
});
|
||||
|
||||
it('deepseek: appending the /v1 suffix is idempotent against a baseUrl that already ends in /v1', () => {
|
||||
it('deepseek: preserves an existing version suffix', () => {
|
||||
const result = buildCustomModelInjection(
|
||||
entryOrThrow('deepseek'),
|
||||
{ ...endpoint, baseUrl: 'http://192.168.1.50:8080/v1' },
|
||||
{ ...endpoint, baseUrl: endpoint.baseUrl + '/v1' },
|
||||
'qwen3'
|
||||
);
|
||||
if (result.kind !== 'env') throw new Error('unreachable');
|
||||
expect(result.envOverrides.DEEPSEEK_BASE_URL).toBe('http://192.168.1.50:8080/v1');
|
||||
if (result.kind !== 'configDir') throw new Error('Expected isolated DeepSeek config');
|
||||
expect(result.files[0].content).toContain('http://192.168.1.50:8080/v1');
|
||||
expect(result.files[0].content).not.toContain('/v1/v1');
|
||||
});
|
||||
|
||||
it('antigravity: unsupported', () => {
|
||||
@@ -232,7 +253,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();
|
||||
|
||||
@@ -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,121 @@ 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('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/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Model Endpoint Profiles: _getCustomModelCurrentlyLoaded is client-side bounded', () => {
|
||||
|
||||
@@ -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