From 11c51410b5f5ad2ca3d3497f826f07ed4e528683 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:29:25 -0700 Subject: [PATCH] fix: Use the configured DeepSeek FIM base path Both FIM call sites now build the endpoint with the same helper. When the pathname ends with /beta, trailing slashes are stripped and the request targets completions on that base. Otherwise the helper still resolves beta/completions, covering the default root and bases written without a trailing slash. The added tests assert the /beta/ URL and JSON body, the default root URL, the concatenated stream text, and that a failed response throws the response body. Fixes #12600 --- core/llm/llms/Deepseek.ts | 19 ++- .../llm/llms/OpenAI-compatible-core.vitest.ts | 63 +++++++++ .../openai-adapters/src/apis/DeepSeek.test.ts | 127 ++++++++++++++++++ packages/openai-adapters/src/apis/DeepSeek.ts | 20 ++- 4 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 packages/openai-adapters/src/apis/DeepSeek.test.ts diff --git a/core/llm/llms/Deepseek.ts b/core/llm/llms/Deepseek.ts index f6b9304dfd1..a1439af6c1d 100644 --- a/core/llm/llms/Deepseek.ts +++ b/core/llm/llms/Deepseek.ts @@ -4,6 +4,23 @@ import { osModelsEditPrompt } from "../templates/edit.js"; import OpenAI from "./OpenAI.js"; +// Bases already rooted at /beta only need `completions`. Other bases keep the +// historical `beta/completions` suffix, including when the slash is omitted. +function deepseekFimEndpoint(apiBase: string | undefined): URL { + if (!apiBase) { + return new URL("beta/completions", apiBase); + } + + const baseUrl = new URL(apiBase); + const pathname = baseUrl.pathname.replace(/\/+$/, "") || "/"; + if (pathname.endsWith("/beta")) { + baseUrl.pathname = `${pathname}/`; + return new URL("completions", baseUrl); + } + + return new URL("beta/completions", apiBase); +} + class Deepseek extends OpenAI { static providerName = "deepseek"; protected supportsReasoningField = false; @@ -29,7 +46,7 @@ class Deepseek extends OpenAI { signal: AbortSignal, options: CompletionOptions, ): AsyncGenerator { - const endpoint = new URL("beta/completions", this.apiBase); + const endpoint = deepseekFimEndpoint(this.apiBase); const resp = await this.fetch(endpoint, { method: "POST", body: JSON.stringify({ diff --git a/core/llm/llms/OpenAI-compatible-core.vitest.ts b/core/llm/llms/OpenAI-compatible-core.vitest.ts index 5d96d099099..eb3734aad30 100644 --- a/core/llm/llms/OpenAI-compatible-core.vitest.ts +++ b/core/llm/llms/OpenAI-compatible-core.vitest.ts @@ -303,3 +303,66 @@ createOpenAISubclassTests(CometAPI, { }, modelConversionContent: "hello", }); + +describe("Deepseek FIM fallback", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test("streamFim appends completions when apiBase is normalized to /beta/", async () => { + const deepseek = new Deepseek({ + apiKey: "test-api-key", + model: "deepseek-coder", + apiBase: "https://api.deepseek.com/beta/", + }); + + const sse = [ + { choices: [{ text: "return " }] }, + { choices: [{ text: "1;" }] }, + ] + .map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`) + .join(""); + const mockFetch = vi.fn().mockResolvedValue( + new Response(sse, { + headers: { "Content-Type": "text/event-stream" }, + }), + ); + (deepseek as any).fetch = mockFetch; + (deepseek as any).useOpenAIAdapterFor = []; + + const chunks: string[] = []; + for await (const chunk of deepseek.streamFim( + "function f() {", + "}", + new AbortController().signal, + { + maxTokens: 32, + temperature: 0.2, + topP: 0.95, + frequencyPenalty: 0.1, + presencePenalty: 0, + stop: ["\n"], + }, + )) { + chunks.push(chunk); + } + + expect(chunks.join("")).toBe("return 1;"); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url.toString()).toBe("https://api.deepseek.com/beta/completions"); + expect(options.method).toBe("POST"); + expect(JSON.parse(options.body)).toEqual({ + model: "deepseek-coder", + prompt: "function f() {", + suffix: "}", + max_tokens: 32, + temperature: 0.2, + top_p: 0.95, + frequency_penalty: 0.1, + presence_penalty: 0, + stop: ["\n"], + stream: true, + }); + }); +}); diff --git a/packages/openai-adapters/src/apis/DeepSeek.test.ts b/packages/openai-adapters/src/apis/DeepSeek.test.ts new file mode 100644 index 00000000000..84142d85bfa --- /dev/null +++ b/packages/openai-adapters/src/apis/DeepSeek.test.ts @@ -0,0 +1,127 @@ +import { fetchwithRequestOptions } from "@continuedev/fetch"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { FimCreateParamsStreaming } from "./base.js"; +import { DeepSeekApi } from "./DeepSeek.js"; + +vi.mock("@continuedev/fetch", async () => { + const actual = + await vi.importActual( + "@continuedev/fetch", + ); + return { + ...actual, + fetchwithRequestOptions: vi.fn(), + }; +}); + +const fimBody: FimCreateParamsStreaming = { + model: "deepseek-coder", + prompt: "function add(a, b) {\n ", + suffix: "\n}", + max_tokens: 128, + temperature: 0.2, + top_p: 0.95, + frequency_penalty: 0.1, + presence_penalty: 0, + stop: ["\n\n"], + stream: true, +}; + +async function collectFim(apiBase?: string) { + const encoder = new TextEncoder(); + const events = [ + { choices: [{ text: "return " }] }, + { choices: [{ text: "a + b;" }], finish_reason: "stop" }, + ]; + const stream = new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(event)}\n\n`), + ); + } + controller.close(); + }, + }); + vi.mocked(fetchwithRequestOptions).mockResolvedValue( + new Response(stream, { + headers: { "Content-Type": "text/event-stream" }, + }) as any, + ); + + const api = new DeepSeekApi({ + provider: "deepseek", + apiKey: "test-api-key", + apiBase, + }); + + let text = ""; + for await (const chunk of api.fimStream( + fimBody, + new AbortController().signal, + )) { + text += chunk.choices[0]?.delta?.content ?? ""; + } + + const [url, init] = vi.mocked(fetchwithRequestOptions).mock.calls[0]; + return { url: String(url), init, text }; +} + +describe("DeepSeekApi fimStream", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test("requests /beta/completions when apiBase ends in /beta/", async () => { + const { url, init, text } = await collectFim( + "https://api.deepseek.com/beta/", + ); + + expect(url).toBe("https://api.deepseek.com/beta/completions"); + expect(init?.method).toBe("POST"); + expect(init?.headers).toEqual({ + "Content-Type": "application/json", + Accept: "application/json", + Authorization: "Bearer test-api-key", + }); + expect(JSON.parse(init?.body as string)).toEqual({ + model: "deepseek-coder", + prompt: "function add(a, b) {\n ", + suffix: "\n}", + max_tokens: 128, + temperature: 0.2, + top_p: 0.95, + frequency_penalty: 0.1, + presence_penalty: 0, + stop: ["\n\n"], + stream: true, + }); + expect(text).toBe("return a + b;"); + }); + + test("still requests /beta/completions for the default root base", async () => { + const { url, text } = await collectFim("https://api.deepseek.com/"); + + expect(url).toBe("https://api.deepseek.com/beta/completions"); + expect(text).toBe("return a + b;"); + }); + + test("surfaces a non-success FIM response", async () => { + vi.mocked(fetchwithRequestOptions).mockResolvedValue( + new Response("beta completions unavailable", { status: 400 }) as any, + ); + + const api = new DeepSeekApi({ + provider: "deepseek", + apiKey: "test-api-key", + apiBase: "https://api.deepseek.com/beta/", + }); + + const stream = api.fimStream(fimBody, new AbortController().signal); + await expect(stream.next()).rejects.toThrow("beta completions unavailable"); + + const [url] = vi.mocked(fetchwithRequestOptions).mock.calls[0]; + expect(String(url)).toBe("https://api.deepseek.com/beta/completions"); + }); +}); diff --git a/packages/openai-adapters/src/apis/DeepSeek.ts b/packages/openai-adapters/src/apis/DeepSeek.ts index 0beba18fee1..08d7f566417 100644 --- a/packages/openai-adapters/src/apis/DeepSeek.ts +++ b/packages/openai-adapters/src/apis/DeepSeek.ts @@ -6,6 +6,24 @@ import { OpenAIApi } from "./OpenAI.js"; import { FimCreateParamsStreaming } from "./base.js"; export const DEEPSEEK_API_BASE = "https://api.deepseek.com/"; + +// Bases already rooted at /beta only need `completions`. Other bases keep the +// historical `beta/completions` suffix, including when the slash is omitted. +function deepseekFimEndpoint(apiBase: string | undefined): URL { + if (!apiBase) { + return new URL("beta/completions", apiBase); + } + + const baseUrl = new URL(apiBase); + const pathname = baseUrl.pathname.replace(/\/+$/, "") || "/"; + if (pathname.endsWith("/beta")) { + baseUrl.pathname = `${pathname}/`; + return new URL("completions", baseUrl); + } + + return new URL("beta/completions", apiBase); +} + export class DeepSeekApi extends OpenAIApi { constructor(config: DeepseekConfig) { super({ @@ -19,7 +37,7 @@ export class DeepSeekApi extends OpenAIApi { body: FimCreateParamsStreaming, signal: AbortSignal, ): AsyncGenerator { - const endpoint = new URL("beta/completions", this.apiBase); + const endpoint = deepseekFimEndpoint(this.apiBase); const resp = await customFetch(this.config.requestOptions)(endpoint, { method: "POST", body: JSON.stringify({