diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index f5529ba3..5c65279e 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -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(), diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index c3061e78..9cd58853 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -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: { diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 6707d4aa..1777eccc 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -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' }; diff --git a/src/custom-model-injection-apply.ts b/src/custom-model-injection-apply.ts index 43cfd5ad..76807c88 100644 --- a/src/custom-model-injection-apply.ts +++ b/src/custom-model-injection-apply.ts @@ -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 }; } diff --git a/src/custom-model-injection.ts b/src/custom-model-injection.ts index 9aab9422..2394936b 100644 --- a/src/custom-model-injection.ts +++ b/src/custom-model-injection.ts @@ -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( 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 } { 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"`, diff --git a/src/web/public/index.html b/src/web/public/index.html index 6611e6c3..3c6fb573 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1033,6 +1033,14 @@ diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 1e5961d4..cfac3c40 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -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 ` - `; }) .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() { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index cd9039c0..c1647f28 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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 diff --git a/test/Deepseek-CustomEndpoint.test.ts b/test/Deepseek-CustomEndpoint.test.ts new file mode 100644 index 00000000..7bb63408 --- /dev/null +++ b/test/Deepseek-CustomEndpoint.test.ts @@ -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'); + }); +}); diff --git a/test/custom-model-injection-apply.test.ts b/test/custom-model-injection-apply.test.ts index 9f870100..4f4a887c 100644 --- a/test/custom-model-injection-apply.test.ts +++ b/test/custom-model-injection-apply.test.ts @@ -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); }); }); diff --git a/test/custom-model-injection.test.ts b/test/custom-model-injection.test.ts index c99fff49..05037dfb 100644 --- a/test/custom-model-injection.test.ts +++ b/test/custom-model-injection.test.ts @@ -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(); diff --git a/test/custom-model-run-menu-ui.test.ts b/test/custom-model-run-menu-ui.test.ts index 967e4457..5fbd2c89 100644 --- a/test/custom-model-run-menu-ui.test.ts +++ b/test/custom-model-run-menu-ui.test.ts @@ -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(