Current section
Files
Jump to
Current section
Files
lib/cmdc/config.ex
defmodule CMDC.Config do
@moduledoc """
CMDC 会话的类型化配置 struct。
通过 `CMDC.Config.new/1` 构建一次,在整个系统中传递。
所有字段有已知类型和合理默认值,不硬编码任何领域逻辑。
## 优先级(高者优先)
1. 传给 `new/1` 的会话覆盖
2. Application env — `config :cmdc, key: value`
3. 内置默认值
## 多 Provider 路由配置示例
config :cmdc,
default_model: "anthropic:claude-sonnet-4-5",
providers: [
{CMDC.Provider, [
api_key: "sk-ant-xxx",
base_url: "https://api.anthropic.com",
models: ~r/^claude-/
]},
{CMDC.Provider, [
api_key: "sk-openai-yyy",
models: :default
]}
]
`models` 支持:正则(`~r/^claude-/`)、字符串列表(`["gpt-4o"]`)、`:default`(兜底)。
"""
@type provider_entry :: {module(), keyword()}
@type t :: %__MODULE__{
data_dir: String.t(),
default_model: String.t(),
default_tools: [module()],
provider: module() | nil,
provider_opts: keyword(),
providers: [provider_entry()]
}
defstruct data_dir: nil,
default_model: "anthropic:claude-sonnet-4-5",
default_tools: [],
provider: nil,
provider_opts: [],
providers: []
@schema NimbleOptions.new!(
data_dir: [
type: {:or, [:string, nil]},
default: nil,
doc: "CMDC 数据根目录(会话、日志)。nil 则使用 `~/.cmdc`。"
],
default_model: [
type: :string,
default: "anthropic:claude-sonnet-4-5",
doc: "默认 LLM 模型标识符,格式 `\"provider:model_id\"` 或纯模型 ID。"
],
default_tools: [
type: {:list, :atom},
default: [],
doc: "全局默认工具模块列表。Blueprint 声明的 tools 会覆盖此列表。"
],
provider: [
type: {:or, [:atom, nil]},
default: nil,
doc: "单 Provider 模块(向后兼容)。推荐改用 providers 多路由配置。"
],
provider_opts: [
type: :keyword_list,
default: [],
doc: "单 Provider 的初始化选项(向后兼容),如 `[api_key: \"sk-xxx\"]`。"
],
providers: [
type: {:list, :any},
default: [],
doc: """
多 Provider 路由表,按 models 规则自动路由。每项格式:
`{ModuleName, [api_key: \"sk-xxx\", models: ~r/^claude-/]}`
"""
]
)
# ==========================================================================
# Public API
# ==========================================================================
@doc """
从 Application env + 可选覆盖构建 Config struct,校验失败时抛出异常。
## 示例
iex> CMDC.Config.new!(data_dir: "/tmp/cmdc")
%CMDC.Config{data_dir: "/tmp/cmdc", ...}
"""
@spec new!(keyword() | map()) :: t()
def new!(overrides \\ [])
def new!(overrides) when is_map(overrides), do: overrides |> Keyword.new() |> new!()
def new!(overrides) when is_list(overrides) do
app_env = Application.get_all_env(:cmdc)
merged = Keyword.merge(app_env, overrides)
validated = NimbleOptions.validate!(merged, @schema)
data_dir = validated[:data_dir] || default_data_dir()
struct!(__MODULE__, Keyword.put(validated, :data_dir, data_dir))
end
@doc "从 Application env + 可选覆盖构建 Config struct,返回 `{:ok, t()}` 或 `{:error, ...}`。"
@spec new(keyword() | map()) :: {:ok, t()} | {:error, NimbleOptions.ValidationError.t()}
def new(overrides \\ []) do
{:ok, new!(overrides)}
rescue
e in NimbleOptions.ValidationError -> {:error, e}
end
@doc """
根据模型名解析对应的 Provider 模块和 opts。
路由规则:
1. 若配置了 `providers`,按 `models` 规则查找第一个匹配项
2. 若无匹配,返回 `:default` 标记的兜底接入点
3. 均未匹配,回退到 `provider` + `provider_opts` 单 Provider 配置
"""
@spec resolve_provider(t(), String.t()) :: provider_entry()
def resolve_provider(
%__MODULE__{providers: [], provider: provider, provider_opts: opts},
_model
) do
mod = provider || Application.get_env(:cmdc, :provider, CMDC.Provider)
env_opts = Application.get_env(:cmdc, :provider_opts, [])
resolved_opts = if opts != [], do: opts, else: env_opts
{mod, resolved_opts}
end
def resolve_provider(%__MODULE__{providers: providers} = config, model) do
match_provider(providers, model) ||
find_default_provider(providers) ||
{config.provider || CMDC.Provider, config.provider_opts}
end
@doc "从 default_model 字符串中提取纯模型 ID(去除 provider 前缀)。"
@spec default_model_id(t()) :: String.t()
def default_model_id(%__MODULE__{default_model: model}) when is_binary(model) do
case String.split(model, ":", parts: 2) do
[_provider, model_id] -> model_id
[model_id] -> model_id
end
end
@doc "返回当前平台的默认数据目录(`~/.cmdc`)。"
@spec default_data_dir() :: String.t()
def default_data_dir do
Path.join(System.user_home!(), ".cmdc")
end
# ==========================================================================
# 派生路径
# ==========================================================================
@doc "数据目录的展开绝对路径。"
@spec data_dir(t()) :: String.t()
def data_dir(%__MODULE__{data_dir: dir}), do: Path.expand(dir)
@doc "会话存储目录路径。"
@spec sessions_dir(t()) :: String.t()
def sessions_dir(%__MODULE__{} = c), do: Path.join(data_dir(c), "sessions")
@doc "日志目录路径。"
@spec logs_dir(t()) :: String.t()
def logs_dir(%__MODULE__{} = c), do: Path.join(data_dir(c), "logs")
@doc "确保数据目录树存在(目录不存在时自动创建)。"
@spec ensure_dirs!(t()) :: :ok
def ensure_dirs!(%__MODULE__{} = c) do
for dir <- [data_dir(c), sessions_dir(c), logs_dir(c)] do
File.mkdir_p!(dir)
end
:ok
end
# ==========================================================================
# 私有辅助 — Provider 路由
# ==========================================================================
defp match_provider(providers, model) do
Enum.find_value(providers, fn {mod, opts} ->
if models_match?(Keyword.get(opts, :models), model) do
{mod, strip_routing_keys(opts)}
end
end)
end
defp find_default_provider(providers) do
Enum.find_value(providers, fn {mod, opts} ->
if Keyword.get(opts, :models) == :default do
{mod, strip_routing_keys(opts)}
end
end)
end
defp models_match?(nil, _model), do: false
defp models_match?(:default, _model), do: false
defp models_match?(%Regex{} = regex, model), do: Regex.match?(regex, model)
defp models_match?(models, model) when is_list(models) do
Enum.any?(models, fn
%Regex{} = regex -> Regex.match?(regex, model)
exact -> exact == model
end)
end
defp strip_routing_keys(opts) do
Keyword.drop(opts, [:name, :models])
end
end