deepseek-v4-flash is still a reasoning variant and burns tokens on chain-of-thought before producing content. deepseek-chat produces output directly — 82 tokens all went to content in testing, vs 85-102 tokens that produced empty observations on flash.
112 lines
3.3 KiB
Elixir
112 lines
3.3 KiB
Elixir
defmodule Towerops.LLM.DeepSeek do
|
|
@moduledoc """
|
|
DeepSeek chat-completion client (OpenAI-compatible).
|
|
|
|
Configure in `config/runtime.exs`:
|
|
|
|
config :towerops, Towerops.LLM.DeepSeek,
|
|
api_key: System.get_env("DEEPSEEK_API_KEY"),
|
|
base_url: System.get_env("DEEPSEEK_BASE_URL") || "https://api.deepseek.com/v1",
|
|
model: "deepseek-chat"
|
|
"""
|
|
|
|
@behaviour Towerops.LLM.Behaviour
|
|
|
|
@default_base_url "https://api.deepseek.com/v1"
|
|
@default_model "deepseek-chat"
|
|
@receive_timeout 120_000
|
|
|
|
@impl true
|
|
def complete(messages, opts \\ []) do
|
|
cfg = Application.get_env(:towerops, __MODULE__, [])
|
|
api_key = opts[:api_key] || cfg[:api_key]
|
|
base_url = present(opts[:base_url]) || present(cfg[:base_url]) || @default_base_url
|
|
model = present(opts[:model]) || present(cfg[:model]) || @default_model
|
|
|
|
if blank?(api_key) do
|
|
{:error, :missing_api_key}
|
|
else
|
|
request(base_url, api_key, model, messages, opts)
|
|
end
|
|
end
|
|
|
|
defp blank?(nil), do: true
|
|
defp blank?(""), do: true
|
|
defp blank?(s) when is_binary(s), do: String.trim(s) == ""
|
|
|
|
# Returns the value if non-empty, nil otherwise — lets `||` cascade past
|
|
# blank env-var values (a `""` env var would otherwise short-circuit
|
|
# `present(opts[:x]) || present(cfg[:x]) || @default_x`).
|
|
defp present(nil), do: nil
|
|
defp present(s) when is_binary(s), do: if(String.trim(s) == "", do: nil, else: s)
|
|
defp present(other), do: other
|
|
|
|
defp request(base_url, api_key, model, messages, opts) do
|
|
body = %{
|
|
model: model,
|
|
messages: messages,
|
|
temperature: opts[:temperature] || 0.2,
|
|
max_tokens: opts[:max_tokens] || 20_000
|
|
}
|
|
|
|
req_opts =
|
|
maybe_inject_test_plug(
|
|
method: :post,
|
|
url: base_url <> "/chat/completions",
|
|
json: body,
|
|
headers: [{"authorization", "Bearer " <> api_key}],
|
|
receive_timeout: opts[:receive_timeout] || @receive_timeout
|
|
)
|
|
|
|
case Req.request(req_opts) do
|
|
{:ok, %{status: status, body: response_body}} when status in 200..299 ->
|
|
parse_response(response_body)
|
|
|
|
{:ok, %{status: status, body: response_body}} ->
|
|
{:error, {:http, status, response_body}}
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
rescue
|
|
exception ->
|
|
{:error, Exception.message(exception)}
|
|
end
|
|
|
|
defp maybe_inject_test_plug(req_opts) do
|
|
if Application.get_env(:towerops, :env) == :test do
|
|
req_opts
|
|
|> Keyword.put(:plug, {Req.Test, __MODULE__})
|
|
|> Keyword.put_new(:retry, false)
|
|
else
|
|
req_opts
|
|
end
|
|
end
|
|
|
|
defp parse_response(%{"choices" => [%{"message" => message} | _]} = body) do
|
|
usage = body["usage"] || %{}
|
|
|
|
content =
|
|
case message do
|
|
%{"content" => c} when is_binary(c) and c != "" -> c
|
|
# Reasoning models (deepseek-v4-pro) may exhaust max_tokens during
|
|
# reasoning and leave content empty; fall back to reasoning_content.
|
|
%{"reasoning_content" => rc} when is_binary(rc) -> rc
|
|
%{"content" => c} when is_binary(c) -> c
|
|
_ -> ""
|
|
end
|
|
|
|
{:ok,
|
|
%{
|
|
content: content,
|
|
model: body["model"] || @default_model,
|
|
prompt_tokens: usage["prompt_tokens"],
|
|
completion_tokens: usage["completion_tokens"],
|
|
total_tokens: usage["total_tokens"]
|
|
}}
|
|
end
|
|
|
|
defp parse_response(body) do
|
|
{:error, {:unexpected_response, body}}
|
|
end
|
|
end
|