mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-05 06:59:33 +01:00
fix(custom-model): pass context limits and selected model to CLIs, add model picker filter and DeepSeek support
- 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 - deepseek harness: add a deepseek-patch-json injection template - model picker: add a search/filter box with a no-matches line Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f1f7eec4d6
commit
941df574ab
13 files changed
+427
-54
No files matched your search
@@ -580,7 +580,7 @@ const capabilitiesSchema = z
|
||||
kind: z.literal('configDir'),
|
||||
dirEnvVar: envName,
|
||||
fileName: z.string().min(1).max(80),
|
||||
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml']),
|
||||
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml', 'deepseek-patch-json']),
|
||||
launchModel: launchModelTemplate,
|
||||
})
|
||||
.strict(),
|
||||
|
||||
@@ -841,6 +841,7 @@ const CODEX: CliEntry = {
|
||||
dirEnvVar: 'CODEX_HOME',
|
||||
fileName: 'config.toml',
|
||||
template: 'codex-toml',
|
||||
launchModel: '{modelId}',
|
||||
},
|
||||
// CODEX_HOME already matches the CODEX_ allowedPrefix above, so it was ALREADY
|
||||
// reachable via plain envOverrides before this feature existed. It is arguably
|
||||
@@ -1547,28 +1548,12 @@ const DEEPSEEK: CliEntry = {
|
||||
// privilege rather than granting it, and clamping it here was a real regression
|
||||
// (test/deepseek-mode.test.ts) fixed before this shipped.
|
||||
privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'],
|
||||
// Reuses the already-existing DEEPSEEK_BASE_URL/DEEPSEEK_API_KEY keys above. No
|
||||
// modelVars — dsh's model is a profile-composition entry (see `model: { source: 'none'
|
||||
// }` above), not an env var, so forcing a specific model name may not fully work;
|
||||
// verify against a real profile before shipping.
|
||||
//
|
||||
// ⚠️ appendV1Suffix is REQUIRED, not optional-nice-to-have: without it every request
|
||||
// 404s. Confirmed live and by reading dsh's own bundled source
|
||||
// (@deepseek-ai/dsh-llm-deepseek): it builds the request URL as
|
||||
// `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own (its real public
|
||||
// API, https://api.deepseek.com, expects the caller's base URL to already carry any
|
||||
// needed prefix), while llama-swap/llama.cpp only serves the OpenAI-conventional
|
||||
// "/v1/chat/completions" — a bare POST to ".../chat/completions" 404s live, and the
|
||||
// 404 reported here originally ("dsh: HTTP_404: DeepSeek API error (HTTP 404)")
|
||||
// matches dsh's own error-message template for exactly this failure. See the
|
||||
// customModelInjection doc comment in cli-registry/types.ts for the full reasoning,
|
||||
// including why claude/gemini must NOT get this.
|
||||
// The installed profile supplies the transport; a per-session patch selects its exact model.
|
||||
customModelInjection: {
|
||||
kind: 'env',
|
||||
baseUrlVar: 'DEEPSEEK_BASE_URL',
|
||||
apiKeyVar: 'DEEPSEEK_API_KEY',
|
||||
modelVars: [],
|
||||
appendV1Suffix: true,
|
||||
kind: 'configDir',
|
||||
dirEnvVar: 'DSH_HOME',
|
||||
fileName: 'cordis.patch.yml',
|
||||
template: 'deepseek-patch-json',
|
||||
},
|
||||
},
|
||||
overlays: {
|
||||
|
||||
@@ -888,7 +888,7 @@ export interface CliCapabilities {
|
||||
kind: 'configDir';
|
||||
dirEnvVar: string;
|
||||
fileName: string;
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml';
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json';
|
||||
launchModel?: string;
|
||||
}
|
||||
| { kind: 'unsupported' };
|
||||
|
||||
@@ -46,6 +46,14 @@ export function applyConfigDirInjection(baseDir: string, injection: ConfigDirInj
|
||||
writeFileSync(filePath, file.content, { encoding: 'utf8', mode: 0o600 });
|
||||
chmodSync(filePath, 0o600);
|
||||
}
|
||||
if (injection.dirEnvVar === 'DSH_HOME') {
|
||||
// Reuse installed profile bundles without changing the user's profile or credentials.
|
||||
const profiles = join(baseDir, 'profiles');
|
||||
if (!existsSync(profiles)) {
|
||||
const realHome = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh');
|
||||
symlinkSync(join(realHome, 'profiles'), profiles, platform() === 'win32' ? 'junction' : 'dir');
|
||||
}
|
||||
}
|
||||
return { [injection.dirEnvVar]: baseDir, ...injection.extraEnv };
|
||||
}
|
||||
|
||||
@@ -284,5 +292,6 @@ export function applyCustomModelInjection(
|
||||
}
|
||||
const configDir = customModelConfigDir(sessionId);
|
||||
const envOverrides = applyConfigDirInjection(configDir, injection);
|
||||
|
||||
return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel };
|
||||
}
|
||||
@@ -136,12 +136,12 @@ export function buildCustomModelInjection(
|
||||
}
|
||||
|
||||
case 'configContentEnv': {
|
||||
const content = renderConfigContent(cap.template, endpoint, modelId, apiKey);
|
||||
const content = renderConfigContent(cap.template, endpoint, modelId, apiKey, contextLength);
|
||||
return withLaunchModel({ kind: 'env', envOverrides: { [cap.envVar]: content } }, cap.launchModel, modelId);
|
||||
}
|
||||
|
||||
case 'configDir': {
|
||||
const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey);
|
||||
const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey, contextLength);
|
||||
return withLaunchModel(
|
||||
{ kind: 'configDir', dirEnvVar: cap.dirEnvVar, files: [{ relPath: cap.fileName, content }], extraEnv },
|
||||
cap.launchModel,
|
||||
@@ -164,24 +164,47 @@ function withLaunchModel<T extends EnvInjection | ConfigDirInjection>(
|
||||
return { ...result, launchModel: template.split('{modelId}').join(modelId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* One quarter of the window is held back for the reply, capped at 8192 tokens (a small local
|
||||
* model's window must not lose a disproportionate slice to the reservation). Returns undefined
|
||||
* for an unknown or non-finite size — opencode then keeps its own default guess, which is the
|
||||
* pre-fix behaviour; never invent a limit here.
|
||||
*/
|
||||
function opencodeModelLimit(contextLength: number | undefined): { context: number; output: number } | undefined {
|
||||
if (contextLength === undefined || !Number.isFinite(contextLength)) return undefined;
|
||||
const total = Math.trunc(contextLength);
|
||||
if (total <= 0) return undefined;
|
||||
const output = Math.max(1, Math.min(8192, Math.floor(total / 4)));
|
||||
return { context: total - output, output };
|
||||
}
|
||||
|
||||
function renderConfigContent(
|
||||
template: 'opencode-json',
|
||||
endpoint: CustomModelEndpoint,
|
||||
modelId: string,
|
||||
apiKey: string
|
||||
apiKey: string,
|
||||
contextLength?: number
|
||||
): string {
|
||||
switch (template) {
|
||||
case 'opencode-json':
|
||||
case 'opencode-json': {
|
||||
// `limit` carries the discovered context window (issue #749): without it opencode assumes
|
||||
// a large default window for an unrecognized model id and eventually overflows a much
|
||||
// smaller local server. `context` is what opencode caps the PROMPT at, so the response
|
||||
// headroom from `opencodeModelLimit` is subtracted up front — `output` is opencode's own
|
||||
// cap on the reply, so setting them from the same discovered total keeps a single turn
|
||||
// inside the real window.
|
||||
const limit = opencodeModelLimit(contextLength);
|
||||
return JSON.stringify({
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
provider: {
|
||||
custom: {
|
||||
options: { baseURL: withV1Suffix(endpoint.baseUrl), apiKey },
|
||||
models: { [modelId]: {} },
|
||||
models: { [modelId]: limit ? { limit } : {} },
|
||||
},
|
||||
},
|
||||
model: `custom/${modelId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,13 +215,53 @@ const CODEX_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY';
|
||||
export const GROK_CUSTOM_MODEL_NAME = 'codeman-custom';
|
||||
|
||||
function renderConfigFile(
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml',
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json',
|
||||
endpoint: CustomModelEndpoint,
|
||||
modelId: string,
|
||||
apiKey: string
|
||||
apiKey: string,
|
||||
contextLength?: number
|
||||
): { content: string; extraEnv?: Record<string, string> } {
|
||||
const baseUrl = withV1Suffix(endpoint.baseUrl);
|
||||
switch (template) {
|
||||
case 'deepseek-patch-json': {
|
||||
const route = { provider: 'codeman-custom', model: modelId };
|
||||
return {
|
||||
content: JSON.stringify(
|
||||
[
|
||||
{
|
||||
id: 'llm-pi-ai',
|
||||
name: '@deepseek-ai/dsh-llm-pi-ai',
|
||||
config: {
|
||||
providers: {
|
||||
'codeman-custom': {
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
api: 'openai-completions',
|
||||
baseURL: baseUrl,
|
||||
models: [
|
||||
{
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0
|
||||
? {
|
||||
contextWindow: Math.trunc(contextLength),
|
||||
maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: 'dsh-tui', name: '@deepseek-harness-tui/dsh-tui', config: route },
|
||||
{ id: 'agent-default-model', name: '@deepseek-ai/dsh-agent-default-model', config: route },
|
||||
],
|
||||
null,
|
||||
2
|
||||
),
|
||||
extraEnv: { DEEPSEEK_API_KEY: apiKey },
|
||||
};
|
||||
}
|
||||
case 'codex-toml': {
|
||||
// Verified against real codex (>= Feb 2026): `model` is a top-level STRING, never
|
||||
// a `[model].default` table — codex rejects that with "invalid type: map, expected
|
||||
@@ -217,6 +280,12 @@ function renderConfigFile(
|
||||
const content = [
|
||||
`model = ${quoted(modelId)}`,
|
||||
`model_provider = "custom"`,
|
||||
...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0
|
||||
? [
|
||||
`model_context_window = ${Math.trunc(contextLength)}`,
|
||||
`model_auto_compact_token_limit = ${Math.max(1, Math.floor(contextLength * 0.75))}`,
|
||||
]
|
||||
: []),
|
||||
'',
|
||||
'[model_providers.custom]',
|
||||
`name = "Custom Endpoint"`,
|
||||
|
||||
@@ -1033,6 +1033,14 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="form-hint" id="customModelPickHint"></p>
|
||||
<!-- Case-insensitive filter over the rendered rows (app.filterCustomModelPick):
|
||||
a display layer only, the rows themselves are never re-rendered. Reset and
|
||||
focused on every open, same in-memory rule as the rail's search box. -->
|
||||
<input type="search" id="customModelPickSearch" class="session-sidebar-filter-input custom-model-pick-search"
|
||||
placeholder="Search models" aria-label="Search models"
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="search"
|
||||
oninput="app.filterCustomModelPick(this.value)">
|
||||
<p class="form-hint custom-model-pick-no-matches" id="customModelPickNoMatches" role="status" hidden>No models match</p>
|
||||
<div id="customModelPickList" class="run-mode-custom-models"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1088,13 +1088,53 @@ Object.assign(CodemanApp.prototype, {
|
||||
const promotion = m === currentlyLoaded ? 'Currently loaded' : m === lastUsed ? 'Last used' : null;
|
||||
const tags = [promotion, m === host.defaultModelId ? 'Default' : null].filter(Boolean);
|
||||
const arg = escapeHtml(JSON.stringify(m));
|
||||
// data-model-id is the filter's haystack (escapeHtml covers `"` as well as
|
||||
// `<>&`, so a hostile model id cannot break out of the attribute); the
|
||||
// filter reads it back through dataset, which decodes it.
|
||||
return `
|
||||
<button class="run-mode-option" onclick="app.chooseCustomModelAndRun(${arg})">
|
||||
<button class="run-mode-option" data-model-id="${escapeHtml(m)}" onclick="app.chooseCustomModelAndRun(${arg})">
|
||||
<span class="run-mode-dot ${escapeHtml(mode)}"></span>${escapeHtml(m)}${tags.map((t) => ` <span class="set-scope">${escapeHtml(t)}</span>`).join('')}
|
||||
</button>`;
|
||||
})
|
||||
.join('');
|
||||
modal.classList.add('active');
|
||||
// A fresh open always starts unfiltered with the caret in the search box — the
|
||||
// list was just rebuilt, so clearing the input is the whole reset; focus is the
|
||||
// user's next keystroke away. Inside the generation guard above, so a
|
||||
// superseded probe can neither focus nor clear a modal that now shows another
|
||||
// endpoint's models.
|
||||
const search = document.getElementById('customModelPickSearch');
|
||||
if (search) {
|
||||
search.value = '';
|
||||
search.focus();
|
||||
}
|
||||
this.filterCustomModelPick('');
|
||||
},
|
||||
|
||||
/**
|
||||
* View filter for the open "Choose a model" picker: case-insensitive substring
|
||||
* over each row's model id, driven from the search input's own `oninput`.
|
||||
* Deliberately toggles `hidden` on the already-rendered buttons rather than
|
||||
* re-rendering from a second copy of the model list: the loaded/last-used
|
||||
* promotion, the Default pill, the escaped inline click argument and the async
|
||||
* generation guard all then stay exactly where they are, and clearing the
|
||||
* query restores the list byte for byte. ⚠️ `.run-mode-option` carries its own
|
||||
* `display: flex` (styles.css, restated in mobile.css), which outranks the UA
|
||||
* `[hidden]` rule, so styles.css re-asserts `display: none !important` for
|
||||
* these rows — without it the filter would silently do nothing.
|
||||
*/
|
||||
filterCustomModelPick(value) {
|
||||
const list = document.getElementById('customModelPickList');
|
||||
if (!list) return;
|
||||
const query = String(value || '').trim().toLowerCase();
|
||||
let visible = 0;
|
||||
for (const btn of list.querySelectorAll('button[data-model-id]')) {
|
||||
const match = !query || btn.dataset.modelId.toLowerCase().includes(query);
|
||||
btn.hidden = !match;
|
||||
if (match) visible += 1;
|
||||
}
|
||||
const noMatches = document.getElementById('customModelPickNoMatches');
|
||||
if (noMatches) noMatches.hidden = visible > 0;
|
||||
},
|
||||
|
||||
closeCustomModelPickModal() {
|
||||
|
||||
@@ -7807,6 +7807,27 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* The picker's search box reuses .session-sidebar-filter-input's theme-token
|
||||
styling (same reasoning as the .set-scope pill above) — only its own spacing
|
||||
and the no-matches line are added here. */
|
||||
#customModelPickModal .custom-model-pick-search {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#customModelPickModal .custom-model-pick-no-matches {
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
/* A filtered-out row's `hidden` must actually hide it: `.run-mode-option` sets
|
||||
`display: flex` (and mobile.css restates the class in a media query), which
|
||||
outranks the UA `[hidden] { display: none }` rule, so the filter would
|
||||
silently do nothing without this re-assertion. Scoped to this modal's rows;
|
||||
the Run menu's own .run-mode-option entries never take `hidden`. */
|
||||
#customModelPickModal .run-mode-option[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Custom Model Endpoint Profiles: llama-swap model-swap confirmation — replaces a native
|
||||
confirm() popup (docs/custom-model-endpoints-plan.md) so it looks and feels like the
|
||||
rest of the app instead of a browser chrome dialog. Shares the context-window-too-small
|
||||
|
||||
@@ -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