Current section

Files

Jump to
cmdc lib cmdc provider.ex
Raw

lib/cmdc/provider.ex

defmodule CMDC.Provider do
@moduledoc """
req_llm 的薄封装层,负责发起流式 LLM 请求。
CMDC 不自建 Provider 体系,而是直接依赖 req_llm(18 个 Provider 开箱即用),
本模块只做:
1. `CMDC.Message` 列表转为 req_llm `Context` 格式
2. `CMDC.Tool` 模块列表转为 req_llm tool schema
3. 调用 `ReqLLM.stream_text/3` 发起流式请求
4. 启动 `CMDC.Provider.StreamBridge` 将流式 chunk 转为 gen_statem 消息
## 流式消息协议(StreamBridge → Agent)
| 消息 | 含义 |
|------|------|
| `{:cmdc_stream_chunk, StreamChunk.t()}` | 流式数据块(文本/推理/工具调用/元数据) |
| `:cmdc_stream_done` | 流正常结束 |
| `{:cmdc_stream_error, reason}` | 流出错 |
## 使用示例
{:ok, %{bridge_pid: _pid}} = CMDC.Provider.stream(
"anthropic:claude-sonnet-4-5",
messages,
tools,
agent_pid: self(),
api_key: "sk-...",
system_prompt: "你是一个专业助手。"
)
## 已知约束
使用自定义 base_url 时,**必须同时在 model map 和 opts 里传 `base_url`**
原因:req_llm 的 OpenAI provider 流式路径从 `opts[:base_url]` 读取,
而非从 model struct 读取。若只传 model map,会 fallback 到默认 OpenAI 地址。
"""
require Logger
alias CMDC.Provider.StreamBridge
@type model :: String.t() | map()
# ==========================================================================
# Public API
# ==========================================================================
@doc """
发起流式 LLM 请求,在独立进程中消费 StreamResponse 并推送给 Agent。
## 参数
- `model` — 模型标识(`"anthropic:claude-sonnet-4-5"` 或 map)
- `messages``CMDC.Message.t()` 列表
- `tools` — 工具模块列表(实现 `CMDC.Tool` behaviour)
- `opts` — 选项
- `:agent_pid`(必需)— 接收流式消息的 gen_statem 进程
- `:system_prompt` — 系统提示词
- `:temperature` — 温度参数
- `:max_tokens` — 最大生成 token 数
- `:api_key` — API Key(覆盖环境变量)
- `:base_url` — 自定义 API base URL
- `:receive_timeout` — 接收超时毫秒数
## 返回
- `{:ok, %{bridge_pid: pid()}}` — StreamBridge 进程已启动
- `{:error, reason}` — 请求失败
"""
@spec stream(model(), [CMDC.Message.t()], [module()], keyword()) ::
{:ok, %{bridge_pid: pid()}} | {:error, term()}
def stream(model, messages, tools, opts \\ []) do
agent_pid = Keyword.fetch!(opts, :agent_pid)
# 当 model 是字符串但存在自定义 base_url 时,转为 inline map spec 以消除 req_llm 未知模型 warning
model = maybe_inline_model(model, opts)
provider = extract_provider(model)
context = build_context(messages, opts, provider)
req_opts = build_req_opts(model, tools, opts)
Logger.debug(
"[Provider] stream_text — model=#{inspect(model)} " <>
"msg_count=#{length(context.messages)} " <>
"opts_keys=#{inspect(Keyword.keys(req_opts))}"
)
case ReqLLM.stream_text(model, context, req_opts) do
{:ok, stream_response} ->
bridge_pid = StreamBridge.start(stream_response, agent_pid)
{:ok, %{bridge_pid: bridge_pid}}
{:error, reason} ->
Logger.error("[Provider] stream_text 失败: #{inspect(reason, limit: :infinity)}")
{:error, reason}
end
end
# ==========================================================================
# 消息转换(供外部测试/调试使用)
# ==========================================================================
@doc """
`CMDC.Message` 列表转为 `ReqLLM.Message` struct 列表。
根据 provider 类型自动切换消息格式。
"""
@spec convert_messages([CMDC.Message.t()], atom()) :: [ReqLLM.Message.t()]
def convert_messages(messages, provider \\ :openai) do
messages
|> Enum.map(&message_to_req_llm(&1, provider))
|> List.flatten()
|> Enum.reject(&is_nil/1)
end
@doc """
将工具模块列表转为 req_llm tool schema 格式。
"""
@spec convert_tools([module()]) :: [map()]
def convert_tools(tools) when is_list(tools) do
Enum.map(tools, &tool_to_schema/1)
end
# ==========================================================================
# 私有辅助 — 请求构建
# ==========================================================================
defp build_context(messages, opts, provider) do
req_messages =
messages
|> filter_empty_assistant_messages()
|> convert_messages(provider)
all_messages =
case Keyword.get(opts, :system_prompt) do
nil -> req_messages
"" -> req_messages
prompt -> [ReqLLM.Context.system(prompt) | req_messages]
end
ReqLLM.Context.new(all_messages)
end
defp build_req_opts(model, tools, opts) do
[]
|> maybe_add(:temperature, Keyword.get(opts, :temperature))
|> maybe_add(:max_tokens, Keyword.get(opts, :max_tokens))
|> maybe_add(:api_key, Keyword.get(opts, :api_key))
|> maybe_add(:receive_timeout, Keyword.get(opts, :receive_timeout))
|> inject_base_url(model, opts)
|> maybe_add_tools(tools)
end
# req_llm OpenAI provider 流式路径从 opts[:base_url] 读取,必须显式注入
defp inject_base_url(req_opts, model, opts) do
base_url =
Keyword.get(opts, :base_url) ||
(is_map(model) && Map.get(model, :base_url)) ||
nil
maybe_add(req_opts, :base_url, base_url)
end
defp maybe_add(opts, _key, nil), do: opts
defp maybe_add(opts, key, value), do: Keyword.put(opts, key, value)
defp maybe_add_tools(opts, []), do: opts
defp maybe_add_tools(opts, tools) do
req_tools = Enum.map(tools, &to_req_llm_tool/1)
Keyword.put(opts, :tools, req_tools)
end
# ==========================================================================
# 私有辅助 — 消息转换
# ==========================================================================
defp message_to_req_llm(%CMDC.Message{role: :system, content: content} = msg, provider) do
req_msg = ReqLLM.Context.system(content || "")
maybe_attach_cache_control(req_msg, msg.metadata, provider)
end
defp message_to_req_llm(%CMDC.Message{role: :user, content: content} = msg, provider) do
req_msg = ReqLLM.Context.user(content || "")
maybe_attach_cache_control(req_msg, msg.metadata, provider)
end
defp message_to_req_llm(%CMDC.Message{role: :assistant} = msg, provider) do
has_tool_calls = msg.tool_calls && msg.tool_calls != []
tool_calls_opt =
if has_tool_calls do
[tool_calls: build_tool_calls(msg.tool_calls)]
else
[]
end
req_msg = ReqLLM.Context.assistant(msg.content || "", tool_calls_opt)
req_msg = maybe_attach_reasoning(req_msg, msg.thinking, provider)
maybe_attach_cache_control(req_msg, msg.metadata, provider)
end
defp message_to_req_llm(
%CMDC.Message{role: :tool_result, call_id: call_id, content: content} = msg,
provider
) do
req_msg = ReqLLM.Context.tool_result(call_id, content || "")
maybe_attach_cache_control(req_msg, msg.metadata, provider)
end
defp message_to_req_llm(%CMDC.Message{role: :tool_call} = msg, _provider) do
args_json =
case msg.content do
s when is_binary(s) -> s
m when is_map(m) -> Jason.encode!(m)
_ -> "{}"
end
ReqLLM.Context.assistant("", tool_calls: [{msg.name, args_json, id: msg.call_id}])
end
defp build_tool_calls(tool_calls) do
Enum.map(tool_calls, fn tc ->
args_json =
case tc.arguments do
s when is_binary(s) -> s
m when is_map(m) -> Jason.encode!(m)
_ -> "{}"
end
{tc.name, args_json, id: tc.call_id}
end)
end
defp tool_to_schema(tool_module) do
%{
type: "function",
function: %{
name: tool_module.name(),
description: tool_module.description(),
parameters: tool_module.parameters()
}
}
end
defp to_req_llm_tool(tool_module) do
ReqLLM.Tool.new!(
name: tool_module.name(),
description: tool_module.description(),
parameter_schema: tool_module.parameters(),
callback: fn _args -> {:ok, "executed"} end
)
end
# ==========================================================================
# 私有辅助 — Thinking / Reasoning
# ==========================================================================
defp maybe_attach_reasoning(req_msg, nil, _provider), do: req_msg
defp maybe_attach_reasoning(req_msg, "", _provider), do: req_msg
defp maybe_attach_reasoning(req_msg, thinking, provider) when is_binary(thinking) do
detail = %ReqLLM.Message.ReasoningDetails{
text: thinking,
provider: provider,
index: 0
}
%{req_msg | reasoning_details: [detail]}
end
# ==========================================================================
# 私有辅助 — 消息过滤
# ==========================================================================
# 过滤空 assistant 消息(Anthropic API 不接受 content 和 tool_calls 均为空的消息)
defp filter_empty_assistant_messages(messages) do
Enum.reject(messages, fn
%CMDC.Message{role: :assistant, content: content, tool_calls: tool_calls} ->
(is_nil(content) or content == "") and
(is_nil(tool_calls) or tool_calls == [])
_ ->
false
end)
end
# ==========================================================================
# 私有辅助 — Provider 识别
# ==========================================================================
# 字符串 model + 自定义 base_url → 自动转为 inline map spec(消除 req_llm 未知模型 warning)
defp maybe_inline_model(model, opts) when is_binary(model) do
base_url =
Keyword.get(opts, :base_url) ||
nil
if base_url do
{provider, model_id} = split_model_string(model)
%{provider: provider, id: model_id, base_url: base_url}
else
model
end
end
defp maybe_inline_model(model, _opts), do: model
defp split_model_string(model_str) do
case String.split(model_str, ":", parts: 2) do
[provider, model_id] -> {String.to_atom(provider), model_id}
[model_id] -> {:openai, model_id}
end
end
defp extract_provider(%{provider: p}) when is_atom(p), do: p
defp extract_provider(model) when is_binary(model) do
cond do
String.contains?(model, "anthropic:") -> :anthropic
String.contains?(model, "openai:") -> :openai
true -> :openai
end
end
defp extract_provider(_), do: :openai
# ==========================================================================
# 私有辅助 — Prompt Cache Control
# ==========================================================================
# 仅对 Anthropic provider 附加 cache_control,其他 provider 直接返回原消息
defp maybe_attach_cache_control(req_msg, %{cache_control: true}, :anthropic) do
Map.put(req_msg, :cache_control, %{"type" => "ephemeral"})
end
defp maybe_attach_cache_control(req_msg, _metadata, _provider), do: req_msg
end