towerops/lib/towerops/llm/deepseek.ex
Graham McIntire 9a10e01a24 fix(llm): switch to deepseek-chat (standard model, no reasoning overhead)
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.
2026-05-11 10:23:18 -05:00

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