Packages

CMDC Gateway — HTTP + SSE + WebSocket 协议网关,接入 CMDC Agent 能力

Current section

Files

Jump to
cmdc_gateway lib cmdc_gateway router.ex
Raw

lib/cmdc_gateway/router.ex

defmodule CMDCGateway.Router do
@moduledoc """
Gateway 主路由。
## 端点列表
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/healthz` | 健康检查(无需认证) |
| POST | `/v1/sessions` | 创建 Session |
| GET | `/v1/sessions/:id` | 查询 Session 状态 |
| DELETE | `/v1/sessions/:id` | 删除 Session |
| POST | `/v1/sessions/:id/prompt` | 发送 prompt(异步 202) |
| GET | `/v1/sessions/:id/events` | SSE 事件流 |
| POST | `/v1/sessions/:id/approve` | 审批通过 |
| POST | `/v1/sessions/:id/reject` | 审批拒绝 |
| POST | `/v1/sessions/:id/respond` | 回答 Agent 提问 |
| GET | `/v1/sessions/:id/stats` | 用量统计 |
| GET | `/v1/sessions/:id/messages` | 对话历史 |
| POST | `/v1/sessions/:id/tools` | 注册回调工具 |
"""
use Plug.Router
alias CMDCGateway.{SessionStore, Meter, SSEHandler, CallbackTool}
plug Corsica,
origins: "*",
allow_headers: ["content-type", "x-api-key", "authorization"],
allow_methods: ["GET", "POST", "DELETE", "OPTIONS"]
plug :match
plug Plug.Parsers,
parsers: [:json],
pass: ["application/json"],
json_decoder: Jason
plug CMDCGateway.Plugs.Auth
plug CMDCGateway.Plugs.RateLimitPlug
plug :dispatch
# ==========================================================================
# Health Check
# ==========================================================================
get "/healthz" do
health = %{
status: "ok",
version: CMDCGateway.version(),
sessions: %{active: SessionStore.count()},
meter: %{tracked_keys: length(Meter.all_usage())},
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
}
send_json(conn, 200, health)
end
# ==========================================================================
# 8.5 — Session CRUD
# ==========================================================================
post "/v1/sessions" do
body = conn.body_params
session_id = Map.get(body, "sessionId", generate_id())
create_opts =
[]
|> put_opt(:session_id, session_id)
|> put_opt(:model, Map.get(body, "model"))
|> put_opt(:system_prompt, Map.get(body, "systemPrompt"))
|> put_opt(:working_dir, Map.get(body, "workingDir", "."))
|> put_opt(:max_turns, Map.get(body, "maxTurns"))
|> put_opt(:max_tokens, Map.get(body, "maxTokens"))
|> put_tools(Map.get(body, "tools"))
|> put_plugins(Map.get(body, "plugins"))
|> put_blueprint(Map.get(body, "blueprint"))
|> put_provider_opts(Map.get(body, "providerOpts"))
|> put_skills_dirs(Map.get(body, "skillsDirs"))
case CMDC.create_agent(create_opts) do
{:ok, session_pid} ->
api_key = conn.assigns[:api_key] || "default"
tenant_id = conn.assigns[:tenant_id] || "default"
SessionStore.create(session_id, session_pid,
api_key: api_key,
tenant_id: tenant_id,
model: Map.get(body, "model"),
blueprint: Map.get(body, "blueprint")
)
send_json(conn, 201, %{
sessionId: session_id,
status: "created"
})
{:error, reason} ->
send_json(conn, 422, %{error: "create_failed", message: inspect(reason)})
end
end
get "/v1/sessions/:id" do
with_session(conn, id, fn _meta, session_pid ->
status = CMDC.status(session_pid)
send_json(conn, 200, %{
sessionId: id,
state: status.state,
turns: status.turns,
toolCalls: status.tool_calls,
totalTokens: status.total_tokens,
uptimeMs: status.uptime_ms
})
end)
end
delete "/v1/sessions/:id" do
case SessionStore.get(id) do
{:ok, %{pid: session_pid}} ->
CallbackTool.cleanup(id)
SessionStore.delete(id)
try do
CMDC.stop(session_pid)
catch
:exit, _ -> :ok
end
send_json(conn, 200, %{sessionId: id, status: "deleted"})
{:error, :not_found} ->
send_json(conn, 404, %{error: "not_found", message: "Session #{id} not found"})
end
end
# ==========================================================================
# 8.6 — Prompt API(异步)
# ==========================================================================
post "/v1/sessions/:id/prompt" do
with_session(conn, id, fn _meta, session_pid ->
text = conn.body_params["text"] || conn.body_params["prompt"] || ""
if text == "" do
send_json(conn, 400, %{error: "bad_request", message: "Missing 'text' field"})
else
result = CMDC.prompt(session_pid, text)
api_key = conn.assigns[:api_key] || "default"
Meter.record_prompt(api_key)
request_id = generate_id()
send_json(conn, 202, %{
requestId: request_id,
sessionId: id,
queued: Map.get(result, :queued, false)
})
end
end)
end
# ==========================================================================
# 8.7 — SSE Events
# ==========================================================================
get "/v1/sessions/:id/events" do
case SessionStore.get(id) do
{:ok, _meta} ->
SSEHandler.stream(conn, id)
{:error, :not_found} ->
send_json(conn, 404, %{error: "not_found", message: "Session #{id} not found"})
end
end
# ==========================================================================
# 8.10 — Control API (approve / reject / respond)
# ==========================================================================
post "/v1/sessions/:id/approve" do
with_session(conn, id, fn _meta, session_pid ->
approval_id = conn.body_params["approvalId"]
if is_nil(approval_id) do
send_json(conn, 400, %{error: "bad_request", message: "Missing 'approvalId'"})
else
CMDC.approve(session_pid, approval_id)
send_json(conn, 200, %{ok: true, action: "approve", approvalId: approval_id})
end
end)
end
post "/v1/sessions/:id/reject" do
with_session(conn, id, fn _meta, session_pid ->
approval_id = conn.body_params["approvalId"]
if is_nil(approval_id) do
send_json(conn, 400, %{error: "bad_request", message: "Missing 'approvalId'"})
else
CMDC.reject(session_pid, approval_id)
send_json(conn, 200, %{ok: true, action: "reject", approvalId: approval_id})
end
end)
end
post "/v1/sessions/:id/respond" do
with_session(conn, id, fn _meta, _session_pid ->
ref = conn.body_params["ref"]
response = conn.body_params["response"]
if is_nil(ref) || is_nil(response) do
send_json(conn, 400, %{error: "bad_request", message: "Missing 'ref' or 'response'"})
else
CMDC.EventBus.broadcast(id, {:user_responded, id, ref, response})
send_json(conn, 200, %{ok: true, action: "respond", ref: ref})
end
end)
end
# ==========================================================================
# 8.11 — Stats & History API
# ==========================================================================
get "/v1/sessions/:id/stats" do
with_session(conn, id, fn meta, session_pid ->
status = CMDC.status(session_pid)
api_key = meta.api_key
usage = Meter.get_usage(api_key)
send_json(conn, 200, %{
sessionId: id,
state: status.state,
turns: status.turns,
toolCalls: status.tool_calls,
totalTokens: status.total_tokens,
uptimeMs: status.uptime_ms,
meter: %{
promptCount: usage.prompt_count,
totalPromptTokens: usage.total_prompt_tokens,
totalCompletionTokens: usage.total_completion_tokens,
totalTokens: usage.total_tokens
}
})
end)
end
get "/v1/sessions/:id/messages" do
with_session(conn, id, fn _meta, session_pid ->
agent_pid = CMDC.agent_pid(session_pid)
messages = CMDC.Agent.get_messages(agent_pid)
json_messages =
Enum.map(messages, fn msg ->
%{
id: msg.id,
role: msg.role,
content: msg.content,
toolCalls: msg.tool_calls,
callId: msg.call_id,
name: msg.name,
isError: msg.is_error
}
end)
send_json(conn, 200, %{sessionId: id, messages: json_messages})
end)
end
# ==========================================================================
# 8.12 — CallbackTool Registration
# ==========================================================================
post "/v1/sessions/:id/tools" do
case SessionStore.get(id) do
{:ok, _meta} ->
case CallbackTool.register(id, conn.body_params) do
{:ok, _module} ->
send_json(conn, 201, %{
ok: true,
sessionId: id,
toolName: conn.body_params["name"]
})
{:error, reason} ->
send_json(conn, 422, %{error: "registration_failed", message: to_string(reason)})
end
{:error, :not_found} ->
send_json(conn, 404, %{error: "not_found", message: "Session #{id} not found"})
end
end
# ==========================================================================
# WS Upgrade (handled by Cowboy dispatch, not Plug.Router)
# ==========================================================================
# WebSocket 通过 Cowboy dispatch 配置路由到 WSHandler,
# 不经过 Plug.Router。见 Application 中的 cowboy_dispatch 配置。
# ==========================================================================
# 兜底 404
# ==========================================================================
match _ do
send_json(conn, 404, %{
error: "not_found",
message: "No route matches #{conn.method} #{conn.request_path}"
})
end
# ==========================================================================
# Helpers
# ==========================================================================
defp send_json(conn, status, body) do
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(body))
end
defp with_session(conn, session_id, fun) do
case SessionStore.get(session_id) do
{:ok, %{pid: session_pid} = meta} ->
if Process.alive?(session_pid) do
fun.(meta, session_pid)
else
SessionStore.delete(session_id)
send_json(conn, 404, %{error: "session_dead", message: "Session process has terminated"})
end
{:error, :not_found} ->
send_json(conn, 404, %{error: "not_found", message: "Session #{session_id} not found"})
end
end
defp generate_id do
:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)
end
# -----
# Option builders for create_agent
# -----
defp put_opt(opts, _key, nil), do: opts
defp put_opt(opts, key, value), do: Keyword.put(opts, key, value)
defp put_tools(opts, nil), do: opts
defp put_tools(opts, tools) when is_list(tools) do
modules =
tools
|> Enum.map(&resolve_tool_module/1)
|> Enum.reject(&is_nil/1)
Keyword.put(opts, :tools, modules)
end
defp put_tools(opts, _), do: opts
defp put_plugins(opts, nil), do: opts
defp put_plugins(opts, plugins) when is_list(plugins) do
modules =
plugins
|> Enum.map(&resolve_plugin_module/1)
|> Enum.reject(&is_nil/1)
Keyword.put(opts, :plugins, modules)
end
defp put_plugins(opts, _), do: opts
defp put_blueprint(opts, nil), do: opts
defp put_blueprint(opts, blueprint) when is_binary(blueprint) do
case safe_to_module(blueprint) do
nil -> opts
mod -> Keyword.put(opts, :blueprint, mod)
end
end
defp put_blueprint(opts, _), do: opts
defp put_provider_opts(opts, nil), do: opts
defp put_provider_opts(opts, provider_opts) when is_map(provider_opts) do
kw = Enum.map(provider_opts, fn {k, v} -> {String.to_existing_atom(k), v} end)
Keyword.put(opts, :provider_opts, kw)
rescue
ArgumentError -> opts
end
defp put_provider_opts(opts, _), do: opts
defp put_skills_dirs(opts, nil), do: opts
defp put_skills_dirs(opts, dirs) when is_list(dirs), do: Keyword.put(opts, :skills_dirs, dirs)
defp put_skills_dirs(opts, _), do: opts
defp resolve_tool_module(name) when is_binary(name), do: safe_to_module(name)
defp resolve_tool_module(_), do: nil
defp resolve_plugin_module(name) when is_binary(name), do: safe_to_module(name)
defp resolve_plugin_module(_), do: nil
defp safe_to_module(str) do
module =
if String.starts_with?(str, "Elixir.") do
String.to_existing_atom(str)
else
String.to_existing_atom("Elixir." <> str)
end
if Code.ensure_loaded?(module), do: module, else: nil
rescue
ArgumentError -> nil
end
end