Skip to content
Open
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
19 changes: 18 additions & 1 deletion core/llm/llms/Deepseek.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,7 +46,7 @@ class Deepseek extends OpenAI {
signal: AbortSignal,
options: CompletionOptions,
): AsyncGenerator<string> {
const endpoint = new URL("beta/completions", this.apiBase);
const endpoint = deepseekFimEndpoint(this.apiBase);
const resp = await this.fetch(endpoint, {
method: "POST",
body: JSON.stringify({
Expand Down
63 changes: 63 additions & 0 deletions core/llm/llms/OpenAI-compatible-core.vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
127 changes: 127 additions & 0 deletions packages/openai-adapters/src/apis/DeepSeek.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@continuedev/fetch")>(
"@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");
});
});
20 changes: 19 additions & 1 deletion packages/openai-adapters/src/apis/DeepSeek.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -19,7 +37,7 @@ export class DeepSeekApi extends OpenAIApi {
body: FimCreateParamsStreaming,
signal: AbortSignal,
): AsyncGenerator<ChatCompletionChunk, any, unknown> {
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({
Expand Down
Loading