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..ade27249 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -1547,28 +1547,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..ad6d9b9f 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 }; } diff --git a/src/custom-model-injection.ts b/src/custom-model-injection.ts index 9aab9422..5505ad72 100644 --- a/src/custom-model-injection.ts +++ b/src/custom-model-injection.ts @@ -141,7 +141,7 @@ export function buildCustomModelInjection( } 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, @@ -192,13 +192,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 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-contract.test.ts b/test/custom-model-injection-contract.test.ts index 1685504d..ceb6450a 100644 --- a/test/custom-model-injection-contract.test.ts +++ b/test/custom-model-injection-contract.test.ts @@ -188,18 +188,18 @@ describe('custom-model-injection contract (mock server)', () => { expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key'); }); - it('deepseek: DEEPSEEK_BASE_URL already carries the /v1 suffix dsh itself never adds, reaching the mock at the real path dsh requests', async () => { - // Confirmed by reading dsh's own bundled source: it fetches - // `${DEEPSEEK_BASE_URL}/chat/completions` verbatim, no /v1 insertion of its own — so - // this call (unlike gemini's above) passes DEEPSEEK_BASE_URL to callOpenAiCompat - // UNMODIFIED, exactly mirroring what the real harness does, rather than the test - // helping it along. + it('deepseek: the session patch carries the /v1 base URL and exact model, reaching the mock at the real path', async () => { + // dsh fetches `${baseURL}/chat/completions` verbatim, so the patch's baseURL already + // carries /v1 and is passed to callOpenAiCompat unmodified. const injection = buildCustomModelInjection(entryOrThrow('deepseek'), endpointFor(mock), 'qwen3'); - if (injection.kind !== 'env') throw new Error('unreachable'); - expect(Object.keys(injection.envOverrides).sort()).toEqual(['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL']); - expect(injection.envOverrides.DEEPSEEK_BASE_URL).toBe(`${mock.baseUrl}/v1`); + if (injection.kind !== 'configDir') throw new Error('unreachable'); + expect(injection.dirEnvVar).toBe('DSH_HOME'); + expect(Object.keys(injection.extraEnv ?? {})).toEqual(['DEEPSEEK_API_KEY']); + const provider = JSON.parse(injection.files[0].content)[0].config.providers['codeman-custom']; + expect(provider.baseURL).toBe(`${mock.baseUrl}/v1`); + expect(provider.models[0].id).toBe('qwen3'); - await callOpenAiCompat(injection.envOverrides.DEEPSEEK_BASE_URL, injection.envOverrides.DEEPSEEK_API_KEY, 'qwen3'); + await callOpenAiCompat(provider.baseURL, injection.extraEnv!.DEEPSEEK_API_KEY, 'qwen3'); expect(mock.requests[0].path).toBe('/v1/chat/completions'); expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key'); diff --git a/test/custom-model-injection.test.ts b/test/custom-model-injection.test.ts index c99fff49..ab1e51a2 100644 --- a/test/custom-model-injection.test.ts +++ b/test/custom-model-injection.test.ts @@ -179,29 +179,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', () => { diff --git a/test/deepseek-custom-endpoint.test.ts b/test/deepseek-custom-endpoint.test.ts new file mode 100644 index 00000000..7bb63408 --- /dev/null +++ b/test/deepseek-custom-endpoint.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'); + }); +});