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