From 5c9c95ab811f22e384296af5e6010b56f7efd38d Mon Sep 17 00:00:00 2001 From: Luis Ezcurdia Date: Mon, 23 Mar 2026 13:13:24 -0600 Subject: [PATCH] feat: Add Deepseek support --- .env.example | 3 +- guides/deepseek.md | 164 ++++++++++++++++++++ lib/req_llm/providers/deepseek.ex | 52 +++++++ mix.exs | 1 + test/providers/deepseek_test.exs | 247 ++++++++++++++++++++++++++++++ 5 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 guides/deepseek.md create mode 100644 lib/req_llm/providers/deepseek.ex create mode 100644 test/providers/deepseek_test.exs diff --git a/.env.example b/.env.example index 402d70a1..376a4ad6 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,5 @@ OPENAI_API_KEY= OPENROUTER_API_KEY= GOOGLE_API_KEY= XAI_API_KEY= -GROQ_API_KEY= \ No newline at end of file +GROQ_API_KEY= +DEEPSEEK_API_KEY= diff --git a/guides/deepseek.md b/guides/deepseek.md new file mode 100644 index 00000000..02101877 --- /dev/null +++ b/guides/deepseek.md @@ -0,0 +1,164 @@ +# DeepSeek + +Use DeepSeek AI models through their OpenAI-compatible API. + +## Overview + +DeepSeek provides powerful language models including: + +- **deepseek-chat** - General purpose conversational model +- **deepseek-reasoner** - Reasoning and problem-solving capabilities + +## Prerequisites + +1. Sign up at https://platform.deepseek.com/ +2. Create an API key +3. Add the key to your environment: + + ```bash + # .env + DEEPSEEK_API_KEY=your-api-key-here + ``` + +## Usage + +### Basic Generation + +Since DeepSeek models are not yet in the LLMDB catalog, use an inline model spec: + +```elixir +# Using inline model spec (recommended) +{:ok, response} = ReqLLM.generate_text( + %{provider: :deepseek, id: "deepseek-chat"}, + "Hello, how are you?" +) + +# Or normalize first +model = ReqLLM.model!(%{provider: :deepseek, id: "deepseek-chat"}) +{:ok, response} = ReqLLM.generate_text(model, "Hello!") +``` + +### Code Generation + +```elixir +model = ReqLLM.model!(%{provider: :deepseek, id: "deepseek-reasoner"}) + +{:ok, response} = ReqLLM.generate_text( + model, + "Write a Python function to calculate fibonacci numbers", + temperature: 0.2, + max_tokens: 2000 +) +``` + +### Streaming + +```elixir +model = ReqLLM.model!(%{provider: :deepseek, id: "deepseek-chat"}) + +{:ok, stream} = ReqLLM.stream_text(model, "Tell me a story about space exploration") + +for chunk <- stream do + IO.write(chunk.text || "") +end +``` + +### With System Context + +```elixir +context = ReqLLM.Context.new([ + ReqLLM.Context.system("You are a helpful coding assistant."), + ReqLLM.Context.user("How do I parse JSON in Elixir?") +]) + +model = ReqLLM.model!(%{provider: :deepseek, id: "deepseek-reasoner"}) + +{:ok, response} = ReqLLM.generate_text(model, context) +``` + +## Helper Module + +For convenience, create a wrapper module: + +```elixir +defmodule MyApp.DeepSeek do + def chat(prompt, opts \\ []) do + model = ReqLLM.model!(%{provider: :deepseek, id: "deepseek-chat"}) + ReqLLM.generate_text(model, prompt, opts) + end + + def think(prompt, opts \\ []) do + model = ReqLLM.model!(%{provider: :deepseek, id: "deepseek-reasoner"}) + ReqLLM.generate_text(model, prompt, Keyword.merge([temperature: 0.2], opts)) + end + + def stream_chat(prompt, opts \\ []) do + model = ReqLLM.model!(%{provider: :deepseek, id: "deepseek-chat"}) + ReqLLM.stream_text(model, prompt, opts) + end +end + +# Usage +MyApp.DeepSeek.chat("Explain quantum computing") +MyApp.DeepSeek.think("Write a React component for a todo list") +``` + +## Configuration + +### Environment Variables + +- `DEEPSEEK_API_KEY` - Required. Your DeepSeek API key + +### Per-Request API Key + +```elixir +ReqLLM.generate_text( + %{provider: :deepseek, id: "deepseek-chat"}, + "Hello!", + api_key: "sk-..." +) +``` + +## Available Models + +| Model | Use Case | Context Window | +|-------|----------|----------------| +| `deepseek-chat` | General conversation, Q&A | 64K tokens | +| `deepseek-reasoner` | Complex reasoning tasks | 64K tokens | + +Check https://platform.deepseek.com/docs for the latest model information. + +## Troubleshooting + +### `{:error, :not_found}` when using string spec + +DeepSeek models are not yet in the LLMDB registry. Use an inline model spec instead: + +```elixir +# ❌ Won't work (model not in LLMDB) +ReqLLM.generate_text("deepseek:deepseek-chat", "Hello!") + +# ✅ Works (inline model spec) +ReqLLM.generate_text( + %{provider: :deepseek, id: "deepseek-chat"}, + "Hello!" +) +``` + +### Authentication Errors + +- Ensure `DEEPSEEK_API_KEY` is set in your `.env` file +- Check that the API key is valid at https://platform.deepseek.com/ + +### Rate Limits + +DeepSeek API has rate limits. If you encounter rate limiting: +- Implement exponential backoff +- Consider batching requests +- Check your plan limits at https://platform.deepseek.com/ + +## Resources + +- [DeepSeek Platform](https://platform.deepseek.com/) +- [DeepSeek API Documentation](https://platform.deepseek.com/docs) +- [Model Specs Guide](model-specs.md) - For more on inline model specifications diff --git a/lib/req_llm/providers/deepseek.ex b/lib/req_llm/providers/deepseek.ex new file mode 100644 index 00000000..d294acad --- /dev/null +++ b/lib/req_llm/providers/deepseek.ex @@ -0,0 +1,52 @@ +defmodule ReqLLM.Providers.Deepseek do + @moduledoc """ + DeepSeek AI provider – OpenAI-compatible Chat Completions API. + + ## Implementation + + Uses built-in OpenAI-style encoding/decoding defaults. + DeepSeek is fully OpenAI-compatible, so no custom request/response handling is needed. + + ## Authentication + + Requires a DeepSeek API key from https://platform.deepseek.com/ + + ## Configuration + + # Add to .env file (automatically loaded) + DEEPSEEK_API_KEY=your-api-key + + ## Examples + + # Basic usage + ReqLLM.generate_text("deepseek:deepseek-chat", "Hello!") + + # With custom parameters + ReqLLM.generate_text("deepseek:deepseek-reasoner", "Write a function", + temperature: 0.2, + max_tokens: 2000 + ) + + # Streaming + ReqLLM.stream_text("deepseek:deepseek-chat", "Tell me a story") + |> Enum.each(&IO.write/1) + + ## Models + + DeepSeek offers several models including: + + - `deepseek-chat` - General purpose conversational model + - `deepseek-reasoner` - Reasoning and problem-solving + + See https://platform.deepseek.com/docs for full model documentation. + """ + + use ReqLLM.Provider, + id: :deepseek, + default_base_url: "https://api.deepseek.com", + default_env_key: "DEEPSEEK_API_KEY" + + use ReqLLM.Provider.Defaults + + @provider_schema [] +end diff --git a/mix.exs b/mix.exs index 39abbf83..249b6427 100644 --- a/mix.exs +++ b/mix.exs @@ -62,6 +62,7 @@ defmodule ReqLLM.MixProject do "guides/ollama.md", "guides/amazon_bedrock.md", "guides/cerebras.md", + "guides/deepseek.md", "guides/meta.md", "guides/zenmux.md", "guides/zai.md", diff --git a/test/providers/deepseek_test.exs b/test/providers/deepseek_test.exs new file mode 100644 index 00000000..17e687e8 --- /dev/null +++ b/test/providers/deepseek_test.exs @@ -0,0 +1,247 @@ +defmodule ReqLLM.Providers.DeepseekTest do + @moduledoc """ + Provider-level tests for DeepSeek implementation. + + Tests the provider contract, configuration, and OpenAI-compatible + request/response handling without making live API calls. + """ + + use ReqLLM.ProviderCase, provider: ReqLLM.Providers.Deepseek + + alias ReqLLM.Providers.Deepseek + + defp deepseek_model(model_id \\ "deepseek-chat", opts \\ []) do + %LLMDB.Model{ + id: "deepseek:#{model_id}", + model: model_id, + name: Keyword.get(opts, :name, "Deepseek Test Model"), + provider: :deepseek, + family: Keyword.get(opts, :family, "test"), + capabilities: Keyword.get(opts, :capabilities, %{chat: true, tools: %{enabled: true}}), + limits: Keyword.get(opts, :limits, %{context: 64_000, output: 8192}) + } + end + + describe "provider contract" do + test "provider identity and configuration" do + assert Deepseek.provider_id() == :deepseek + assert is_binary(Deepseek.base_url()) + assert Deepseek.base_url() == "https://api.deepseek.com" + end + + test "provider uses correct default environment key" do + assert Deepseek.default_env_key() == "DEEPSEEK_API_KEY" + end + + test "provider schema is empty (pure OpenAI-compatible)" do + schema_keys = Deepseek.provider_schema().schema |> Keyword.keys() + assert schema_keys == [] + end + + test "provider_extended_generation_schema includes all core keys" do + extended_schema = Deepseek.provider_extended_generation_schema() + extended_keys = extended_schema.schema |> Keyword.keys() + + core_keys = ReqLLM.Provider.Options.all_generation_keys() + core_without_meta = Enum.reject(core_keys, &(&1 == :provider_options)) + + for core_key <- core_without_meta do + assert core_key in extended_keys, + "Extended schema missing core key: #{core_key}" + end + end + end + + describe "request preparation" do + test "prepare_request for :chat creates /chat/completions request" do + model = deepseek_model() + prompt = "Hello world" + opts = [temperature: 0.7, max_tokens: 100] + + {:ok, request} = Deepseek.prepare_request(:chat, model, prompt, opts) + + assert %Req.Request{} = request + assert request.url.path == "/chat/completions" + assert request.method == :post + end + + test "prepare_request rejects unsupported operations" do + model = deepseek_model() + context = context_fixture() + + {:error, error} = Deepseek.prepare_request(:unsupported, model, context, []) + assert %ReqLLM.Error.Invalid.Parameter{} = error + end + end + + describe "authentication wiring" do + test "attach adds Bearer authorization header" do + model = deepseek_model() + request = Req.new() + + attached = Deepseek.attach(request, model, []) + + auth_header = attached.headers["authorization"] + assert auth_header != nil + assert String.starts_with?(List.first(auth_header), "Bearer ") + end + + test "attach adds pipeline steps" do + model = deepseek_model() + request = Req.new() + + attached = Deepseek.attach(request, model, []) + + request_steps = Keyword.keys(attached.request_steps) + response_steps = Keyword.keys(attached.response_steps) + + assert :llm_encode_body in request_steps + assert :llm_decode_response in response_steps + end + end + + describe "base_url configuration" do + test "uses default base_url when not overridden" do + model = deepseek_model() + {:ok, request} = Deepseek.prepare_request(:chat, model, "Hello", []) + + assert request.options[:base_url] == "https://api.deepseek.com" + end + + test "respects base_url option override" do + model = deepseek_model() + custom_url = "https://custom.deepseek.com/v1" + {:ok, request} = Deepseek.prepare_request(:chat, model, "Hello", base_url: custom_url) + + assert request.options[:base_url] == custom_url + end + end + + describe "body encoding" do + test "encode_body produces valid OpenAI-compatible JSON" do + model = deepseek_model() + context = context_fixture() + + mock_request = %Req.Request{ + options: [ + context: context, + model: model.model, + stream: false, + temperature: 0.7 + ] + } + + updated_request = Deepseek.encode_body(mock_request) + + assert is_binary(updated_request.body) + decoded = Jason.decode!(updated_request.body) + + assert decoded["model"] == "deepseek-chat" + assert is_list(decoded["messages"]) + assert decoded["stream"] == false + end + + test "encode_body handles tools correctly" do + model = deepseek_model() + context = context_fixture() + + tool = + ReqLLM.Tool.new!( + name: "get_weather", + description: "Get the weather", + parameter_schema: [ + location: [type: :string, required: true, doc: "City name"] + ], + callback: fn _ -> {:ok, "sunny"} end + ) + + mock_request = %Req.Request{ + options: [ + context: context, + model: model.model, + stream: false, + tools: [tool] + ] + } + + updated_request = Deepseek.encode_body(mock_request) + decoded = Jason.decode!(updated_request.body) + + assert is_list(decoded["tools"]) + assert length(decoded["tools"]) == 1 + assert hd(decoded["tools"])["function"]["name"] == "get_weather" + end + end + + describe "response decoding" do + test "decode_response parses OpenAI-format response" do + mock_response_body = + openai_format_json_fixture( + model: "deepseek-chat", + content: "Hello from DeepSeek!" + ) + + mock_resp = %Req.Response{ + status: 200, + body: mock_response_body + } + + context = context_fixture() + + mock_req = %Req.Request{ + options: [ + context: context, + model: "deepseek-chat", + operation: :chat + ] + } + + {_req, decoded_resp} = Deepseek.decode_response({mock_req, mock_resp}) + + assert %ReqLLM.Response{} = decoded_resp.body + assert ReqLLM.Response.text(decoded_resp.body) == "Hello from DeepSeek!" + end + + test "decode_response handles API errors" do + error_body = %{ + "error" => %{ + "message" => "Invalid API key", + "type" => "authentication_error" + } + } + + mock_resp = %Req.Response{ + status: 401, + body: error_body + } + + context = context_fixture() + + mock_req = %Req.Request{ + options: [context: context, id: "deepseek-chat"] + } + + {_req, error} = Deepseek.decode_response({mock_req, mock_resp}) + + assert %ReqLLM.Error.API.Response{} = error + assert error.status == 401 + end + end + + describe "streaming support" do + test "attach_stream builds streaming request" do + model = deepseek_model() + context = context_fixture() + opts = [temperature: 0.7] + + {:ok, finch_request} = Deepseek.attach_stream(model, context, opts, MyApp.Finch) + + assert %Finch.Request{} = finch_request + assert finch_request.method == "POST" + assert String.contains?(finch_request.path, "/chat/completions") + + headers_map = Map.new(finch_request.headers) + assert headers_map["Authorization"] == "Bearer test-key-12345" + end + end +end