From f3e2e52788dddbf14191a438bf88313d89e8a663 Mon Sep 17 00:00:00 2001 From: Brian Genisio Date: Thu, 3 Sep 2026 10:23:14 -0400 Subject: [PATCH] Preserve DeepSeek model prefixes Co-authored-by: Cursor --- lib/llm/model-ref.js | 5 ++++- lib/llm/openai.js | 14 ++++++++------ lib/llm/provider.js | 15 +++++++-------- tests/llm-openai.test.js | 27 +++++++++++++++++++++++---- 4 files changed, 42 insertions(+), 19 deletions(-) diff --git a/lib/llm/model-ref.js b/lib/llm/model-ref.js index abe302f..a729d3c 100644 --- a/lib/llm/model-ref.js +++ b/lib/llm/model-ref.js @@ -26,6 +26,7 @@ const PROVIDERS = { * @property {string} provider * @property {string} modelId * @property {string} prefix + * @property {string} modelPrefix * @property {string} raw * @property {string} apiKeyName */ @@ -56,7 +57,8 @@ export function parseModelRef(modelRef) { throw modelRefError(raw); } - const prefix = raw.slice(0, slash).trim().toLowerCase().replace(/^~/, ''); + const modelPrefix = raw.slice(0, slash).trim(); + const prefix = modelPrefix.toLowerCase().replace(/^~/, ''); const modelId = raw.slice(slash + 1).trim(); if (!prefix || !modelId) { throw modelRefError(raw); @@ -71,6 +73,7 @@ export function parseModelRef(modelRef) { provider: provider.name, modelId, prefix, + modelPrefix, raw, apiKeyName: provider.apiKeyName, }; diff --git a/lib/llm/openai.js b/lib/llm/openai.js index 8f70858..efc31d2 100644 --- a/lib/llm/openai.js +++ b/lib/llm/openai.js @@ -6,8 +6,8 @@ * OpenAI-compatible host. When both DeepSeek vars are unset, DeepSeek * traffic reuses the OpenAI key/URL (production proxy hack in * lib/llm/provider.js). OpenAI model ids may still use an `openai/` - * prefix; it is stripped before the API call. DeepSeek needs the full - * `~deepseek/…` model name on the wire. + * prefix; it is stripped before the API call. DeepSeek preserves the + * `deepseek/` or `~deepseek/` prefix supplied by the caller. */ import OpenAI from 'openai'; @@ -26,14 +26,16 @@ export function normalizeOpenAiModelId(modelId = '') { } /** - * DeepSeek hosts expect the full `~deepseek/…` model name, not a bare id. - * `deepseek/…` is accepted as an alias and rewritten to `~deepseek/…`. + * DeepSeek hosts expect the full prefixed model name, not a bare id. + * Preserve whether the caller supplied `deepseek/` or `~deepseek/`. * @param {string} [modelId] * @returns {string} */ export function normalizeDeepSeekModelId(modelId = '') { - const rest = String(modelId).trim().replace(/^(~deepseek|deepseek)\//i, ''); - return rest ? `~deepseek/${rest}` : ''; + const value = String(modelId).trim(); + const match = value.match(/^(~deepseek|deepseek)\//i); + if (match) return `${match[1]}/${value.slice(match[0].length)}`; + return value ? `~deepseek/${value}` : ''; } /** diff --git a/lib/llm/provider.js b/lib/llm/provider.js index 6e9ce4e..57e79f1 100644 --- a/lib/llm/provider.js +++ b/lib/llm/provider.js @@ -1,8 +1,7 @@ /** * Resolve an LLM provider from a session model ref (`provider/model-id`). * - * The prefix selects the adapter; the remainder is the API model id - * (DeepSeek keeps a `~deepseek/` prefix on the wire). + * The prefix selects the adapter; the remainder is the API model id. * API keys and base URLs still come from the environment. */ @@ -27,7 +26,7 @@ function envVarIsSet(value) { * * In production, DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL are left empty on * purpose. The OpenAI base URL is not api.openai.com; it is an internal - * proxy that routes by model name, including `~deepseek/…`. + * proxy that routes by model name, including `deepseek/…` and `~deepseek/…`. * * This fallback is safe ONLY when both DeepSeek vars are unset/blank. If * either is set, the operator meant a dedicated DeepSeek (or OpenRouter) @@ -58,16 +57,16 @@ export function requiredApiKeyName(modelRef, env = process.env) { /** * @param {NodeJS.ProcessEnv} env - * @param {string} modelId + * @param {{ modelId: string, modelPrefix: string }} parsed * @returns {import('./types.js').LlmProvider} */ -function createDeepSeekProvider(env, modelId) { +function createDeepSeekProvider(env, parsed) { // PRODUCTION HACK: see shouldFallbackDeepSeekToOpenAi(). Dedicated DeepSeek // credentials win; otherwise reuse the OpenAI proxy key/URL. // Dedicated DeepSeek must set DEEPSEEK_BASE_URL. Without it the OpenAI SDK - // would send ~deepseek/… to api.openai.com. + // would send a DeepSeek model to api.openai.com. const fallback = shouldFallbackDeepSeekToOpenAi(env); - return createOpenAiProvider(env, modelId, { + return createOpenAiProvider(env, `${parsed.modelPrefix}/${parsed.modelId}`, { name: 'deepseek', apiKeyEnv: fallback ? 'OPENAI_API_KEY' : 'DEEPSEEK_API_KEY', baseUrlEnv: fallback ? 'OPENAI_BASE_URL' : 'DEEPSEEK_BASE_URL', @@ -89,7 +88,7 @@ export function createLlmProvider(env = process.env, modelRef) { case 'openai': return createOpenAiProvider(env, parsed.modelId); case 'deepseek': - return createDeepSeekProvider(env, parsed.modelId); + return createDeepSeekProvider(env, parsed); case 'gemini': return createGeminiProvider(env, parsed.modelId); default: { diff --git a/tests/llm-openai.test.js b/tests/llm-openai.test.js index 689b33b..e6b7551 100644 --- a/tests/llm-openai.test.js +++ b/tests/llm-openai.test.js @@ -34,11 +34,11 @@ describe('normalizeOpenAiModelId', () => { }); describe('normalizeDeepSeekModelId', () => { - it('rewrites deepseek aliases to ~deepseek/…', () => { + it('preserves the supplied DeepSeek prefix', () => { expect(normalizeDeepSeekModelId('deepseek-v4-flash-latest')) .toBe('~deepseek/deepseek-v4-flash-latest'); expect(normalizeDeepSeekModelId('deepseek/deepseek-v4-flash-latest')) - .toBe('~deepseek/deepseek-v4-flash-latest'); + .toBe('deepseek/deepseek-v4-flash-latest'); expect(normalizeDeepSeekModelId('~deepseek/deepseek-v4-flash-latest')) .toBe('~deepseek/deepseek-v4-flash-latest'); }); @@ -180,10 +180,10 @@ describe('createLlmProvider deepseek', () => { expect(result).toEqual({ text: 'Paris', requestId: 'chatcmpl-ds' }); expect(logSpy).toHaveBeenCalledWith( - '[llm] request {"provider":"deepseek","model":"~deepseek/deepseek-v4-flash-latest","baseURL":"https://api.deepseek.test/v1","messageCount":1}', + '[llm] request {"provider":"deepseek","model":"deepseek/deepseek-v4-flash-latest","baseURL":"https://api.deepseek.test/v1","messageCount":1}', ); expect(createMock).toHaveBeenCalledWith({ - model: '~deepseek/deepseek-v4-flash-latest', + model: 'deepseek/deepseek-v4-flash-latest', messages: [{ role: 'user', content: 'Capital of France?' }], }); logSpy.mockRestore(); @@ -200,6 +200,25 @@ describe('createLlmProvider deepseek', () => { }); }); + it('sends a deepseek/ model ref with its original prefix', async () => { + createMock.mockResolvedValue({ + id: 'chatcmpl-ds-alias', + choices: [{ message: { content: 'Paris' } }], + }); + const llm = createLlmProvider({ + DEEPSEEK_API_KEY: 'sk-deepseek', + DEEPSEEK_BASE_URL: 'https://api.deepseek.test/v1', + }, 'deepseek/deepseek-v4-flash-latest'); + + await llm.complete({ model: llm.model, messages: [{ role: 'user', content: 'Hi' }] }); + + expect(llm.model).toBe('deepseek/deepseek-v4-flash-latest'); + expect(createMock).toHaveBeenCalledWith({ + model: 'deepseek/deepseek-v4-flash-latest', + messages: [{ role: 'user', content: 'Hi' }], + }); + }); + it('falls back to OPENAI_* when both DeepSeek env vars are unset (prod proxy hack)', () => { createLlmProvider({ OPENAI_API_KEY: 'sk-openai',