Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion lib/llm/model-ref.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const PROVIDERS = {
* @property {string} provider
* @property {string} modelId
* @property {string} prefix
* @property {string} modelPrefix
* @property {string} raw
* @property {string} apiKeyName
*/
Expand Down Expand Up @@ -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);
Expand All @@ -71,6 +73,7 @@ export function parseModelRef(modelRef) {
provider: provider.name,
modelId,
prefix,
modelPrefix,
raw,
apiKeyName: provider.apiKeyName,
};
Expand Down
14 changes: 8 additions & 6 deletions lib/llm/openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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}` : '';
}

/**
Expand Down
15 changes: 7 additions & 8 deletions lib/llm/provider.js
Original file line number Diff line number Diff line change
@@ -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.
*/

Expand All @@ -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)
Expand Down Expand Up @@ -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',
Expand All @@ -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: {
Expand Down
27 changes: 23 additions & 4 deletions tests/llm-openai.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down Expand Up @@ -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();
Expand All @@ -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',
Expand Down