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` | 健康检查(无需认证) |
| GET | `/.well-known/agent.json` | A2A Agent Card(无需认证) |
### Session 生命周期
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/v1/sessions` | 创建 Session(支持 v0.2 `userData` / `promptMode`|
| GET | `/v1/sessions/:id` | 查询 Session 概况 |
| GET | `/v1/sessions/:id/status` | 完整状态:pendingTools / pendingApprovals / queues |
| DELETE | `/v1/sessions/:id` | 删除 Session |
### Prompt / Stream
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/v1/sessions/:id/prompt` | 发送 prompt(异步 202) |
| GET | `/v1/sessions/:id/events` | SSE 事件流 |
### HITL
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/v1/sessions/:id/approve` | 审批通过 |
| POST | `/v1/sessions/:id/reject` | 审批拒绝 |
| POST | `/v1/sessions/:id/respond` | 回答 Agent 提问 |
### A2A 协议
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/v1/a2a/tasks/send` | JSON-RPC 同步 |
| POST | `/v1/a2a/tasks/sendSubscribe` | JSON-RPC SSE 流式 |
| POST | `/v1/a2a/tasks/sendWithWebhook` | JSON-RPC webhook 异步 |
| GET | `/v1/a2a/tasks/:task_id` | (12M v0.4.1) 兜底查询 task 状态(ETS 10min 缓存) |
### 控制面(v0.2 — Guardrails 第 18 章)
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/v1/sessions/:id/switch_model` | (RFC C8) 切换 LLM |
| POST | `/v1/sessions/:id/attach_tool` | (RFC C9) 运行时挂工具 |
| DELETE | `/v1/sessions/:id/tools/:name` | (RFC C9) 运行时卸工具 |
| POST | `/v1/sessions/:id/steer` | 中段注入 |
| POST | `/v1/sessions/:id/abort` | (RFC B6) 安全中止 |
### 信息查询
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/v1/sessions/:id/stats` | 用量统计 |
| GET | `/v1/sessions/:id/messages` | 对话历史 |
| POST | `/v1/sessions/:id/tools` | 注册回调工具 |
"""
use Plug.Router
alias CMDCGateway.{CallbackTool, Meter, SessionStore, SSEHandler}
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 CMDCGateway.Plugs.Guardrails
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
# ==========================================================================
# A2A Agent Card
# ==========================================================================
get "/.well-known/agent.json" do
send_json(conn, 200, CMDCGateway.AgentCard.build(conn))
end
# ==========================================================================
# A2A 协议
# ==========================================================================
post "/v1/a2a/tasks/send" do
rpc_request(conn, fn req ->
api_key = conn.assigns[:api_key] || "default"
tenant_id = conn.assigns[:tenant_id] || "default"
params = Map.get(req, "params", %{})
case CMDCGateway.A2A.handle_send(params, api_key, tenant_id) do
{:ok, result} ->
rpc_success(conn, req, result)
{:error, err} ->
rpc_error(conn, req, err)
end
end)
end
post "/v1/a2a/tasks/sendSubscribe" do
rpc_request(conn, fn req ->
api_key = conn.assigns[:api_key] || "default"
tenant_id = conn.assigns[:tenant_id] || "default"
params = Map.get(req, "params", %{})
case CMDCGateway.A2A.start_subscribe(params, api_key, tenant_id) do
{:ok, %{task_id: task_id, session_id: session_id}} ->
SSEHandler.stream_a2a(conn, session_id, task_id)
{:error, err} ->
rpc_error(conn, req, err)
end
end)
end
# Webhook 异步模式
post "/v1/a2a/tasks/sendWithWebhook" do
rpc_request(conn, fn req ->
api_key = conn.assigns[:api_key] || "default"
tenant_id = conn.assigns[:tenant_id] || "default"
params = Map.get(req, "params", %{})
case CMDCGateway.A2A.start_with_webhook(params, api_key, tenant_id) do
{:ok, result} ->
rpc_success(conn, req, result)
{:error, err} ->
rpc_error(conn, req, err)
end
end)
end
# tasks/get 兜底查询(ETS 缓存最近 10 分钟 task 状态)
get "/v1/a2a/tasks/:task_id" do
case CMDCGateway.TaskStore.get(task_id) do
{:ok, entry} ->
send_json(conn, 200, %{
taskId: entry.task_id,
status: Atom.to_string(entry.status),
lastEvent: Atom.to_string(entry.last_event),
payload: entry.payload,
updatedAtMs: entry.updated_at_ms,
ttlUntilMs: entry.ttl_until_ms
})
{:error, :not_found} ->
send_json(conn, 404, %{
error: "task_not_found",
message:
"Task #{task_id} not found in TaskStore cache. May have expired (TTL 10min) or never existed."
})
end
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"))
|> put_user_data(Map.get(body, "userData"))
|> put_prompt_mode(Map.get(body, "promptMode"))
child_spec = %{
id: session_id,
start: {CMDC.SessionServer, :start_link, [create_opts]},
restart: :temporary
}
case DynamicSupervisor.start_child(CMDCGateway.AgentSupervisor, child_spec) 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
# 完整状态:pending / queues
get "/v1/sessions/:id/status" do
with_session(conn, id, fn _meta, session_pid ->
status = CMDC.status(session_pid)
send_json(conn, 200, %{
sessionId: id,
state: status.state,
turns: Map.get(status, :turns),
toolCalls: Map.get(status, :tool_calls),
totalTokens: Map.get(status, :total_tokens),
uptimeMs: Map.get(status, :uptime_ms),
timestampMs: Map.get(status, :timestamp_ms),
model: Map.get(status, :model),
pendingTools: Map.get(status, :pending_tools, []),
pendingApprovals: Map.get(status, :pending_approvals, []),
queues: format_queues(Map.get(status, :queues, %{}))
})
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
# ==========================================================================
# v0.2 控制面 — switch_model / attach_tool / detach_tool / steer / abort
# ==========================================================================
post "/v1/sessions/:id/switch_model" do
with_session(conn, id, fn _meta, session_pid ->
model = conn.body_params["model"]
cond do
is_nil(model) or model == "" ->
send_json(conn, 400, %{error: "bad_request", message: "Missing 'model'"})
not is_binary(model) ->
send_json(conn, 400, %{error: "bad_request", message: "'model' must be string"})
true ->
# CMDC.switch_model 是 gen_statem cast → 总是返回 :ok(异步生效)
:ok = CMDC.switch_model(session_pid, model)
send_json(conn, 202, %{ok: true, action: "switch_model", model: model, async: true})
end
end)
end
post "/v1/sessions/:id/attach_tool" do
with_session(conn, id, fn _meta, session_pid ->
tool_name = conn.body_params["tool"]
case resolve_tool_or_error(tool_name) do
{:ok, mod} ->
case CMDC.attach_tool(session_pid, mod) do
:ok ->
send_json(conn, 200, %{ok: true, action: "attach_tool", tool: inspect(mod)})
{:error, reason} ->
send_json(conn, 422, %{error: "attach_failed", reason: inspect(reason)})
end
{:error, msg} ->
send_json(conn, 400, %{error: "bad_request", message: msg})
end
end)
end
delete "/v1/sessions/:id/tools/:tool_name" do
with_session(conn, id, fn _meta, session_pid ->
case CMDC.detach_tool(session_pid, tool_name) do
:ok ->
send_json(conn, 200, %{ok: true, action: "detach_tool", tool: tool_name})
{:error, reason} ->
send_json(conn, 422, %{error: "detach_failed", reason: inspect(reason)})
end
end)
end
post "/v1/sessions/:id/steer" do
with_session(conn, id, fn _meta, session_pid ->
text = conn.body_params["text"] || ""
if text == "" do
send_json(conn, 400, %{error: "bad_request", message: "Missing 'text'"})
else
case CMDC.steer(session_pid, text) do
{:ok, ref} when is_reference(ref) ->
send_json(conn, 200, %{ok: true, action: "steer", ref: inspect(ref)})
{:error, reason} ->
send_json(conn, 422, %{error: "steer_failed", reason: inspect(reason)})
end
end
end)
end
post "/v1/sessions/:id/abort" do
with_session(conn, id, fn _meta, session_pid ->
body = conn.body_params || %{}
opts =
[]
|> put_opt(:reason, parse_reason(Map.get(body, "reason")))
|> put_opt(:kill_tools, parse_kill_tools(Map.get(body, "killTools")))
|> put_opt(:clear_queue, Map.get(body, "clearQueue"))
:ok = CMDC.abort(session_pid, opts)
send_json(conn, 202, %{ok: true, action: "abort", opts: Map.new(opts), async: true})
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
# ==========================================================================
# JSON-RPC 2.0 helpers(A2A 协议用)
# ==========================================================================
defp rpc_request(conn, fun) do
case conn.body_params do
%{"jsonrpc" => "2.0"} = req ->
fun.(req)
_ ->
send_json(conn, 400, %{
jsonrpc: "2.0",
id: nil,
error: %{code: -32_600, message: "Invalid Request (expect jsonrpc: \"2.0\")"}
})
end
end
defp rpc_success(conn, req, result) do
send_json(conn, 200, %{
jsonrpc: "2.0",
id: Map.get(req, "id"),
result: result
})
end
defp rpc_error(conn, req, %{code: code} = err) do
http_status = if code in [-32_600, -32_601, -32_602], do: 400, else: 200
send_json(conn, http_status, %{
jsonrpc: "2.0",
id: Map.get(req, "id"),
error: err
})
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
# userData 透传到 :user_data
defp put_user_data(opts, nil), do: opts
defp put_user_data(opts, data) when is_map(data) do
Keyword.put(opts, :user_data, data)
end
defp put_user_data(opts, _), do: opts
# promptMode 透传
defp put_prompt_mode(opts, nil), do: opts
defp put_prompt_mode(opts, mode) when mode in ["full", "task", "minimal", "none"] do
Keyword.put(opts, :prompt_mode, String.to_atom(mode))
end
defp put_prompt_mode(opts, _), do: opts
# v0.2 abort opts — 安全解析 reason / killTools
defp parse_reason(nil), do: nil
defp parse_reason(""), do: nil
defp parse_reason(s) when is_binary(s), do: s
defp parse_reason(_), do: nil
defp parse_kill_tools(nil), do: nil
defp parse_kill_tools("none"), do: :none
defp parse_kill_tools("killable"), do: :killable
defp parse_kill_tools("all"), do: :all
defp parse_kill_tools(_), do: nil
# 解析 attach_tool 的 tool 字段
defp resolve_tool_or_error(nil),
do: {:error, "Missing 'tool' field"}
defp resolve_tool_or_error(""),
do: {:error, "Empty 'tool' field"}
defp resolve_tool_or_error(name) when is_binary(name) do
case safe_to_module(name) do
nil -> {:error, "Unknown tool module: #{name}"}
mod -> {:ok, mod}
end
end
defp resolve_tool_or_error(_), do: {:error, "'tool' must be string"}
# 把 :prompt_queue / :steering_queue atom-key 转 camelCase string-key
defp format_queues(queues) when is_map(queues) do
%{
promptQueue: Map.get(queues, :prompt_queue, 0),
steeringQueue: Map.get(queues, :steering_queue, 0)
}
end
defp format_queues(_), do: %{promptQueue: 0, steeringQueue: 0}
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