Files
ModelRouter/internal/gateway/plugin_http_routes.go
JianFeeeee e4111337cd feat(gateway): /v1/messages 端到端 —— 内核不含任何 Anthropic 出站字段名
Phase 2 收尾。证明「对外协议由适配器决定、内核只调度」不是纸面主张:
Anthropic 客户端打 /v1/messages,拿到的是 Anthropic 形状。

- internal/gateway/messages.go:入向把 Anthropic 请求解析进统一格式
  (content blocks 原样保留——只有适配器知道自己的上游接受哪种块类型;
  system 支持字符串与 [{type:text,text}] 两种写法)。
  调度/配额/AUTO 链/冷却/审计/插件 hook 全部复用 /v1/chat/completions
  的同一条路径(serveUnified),差别只有两端线格式。
- internal/lua/adapters/anthropic.lua:新增 serialize_response /
  serialize_open_preamble / serialize_chunk / serialize_close,
  把统一格式译回 Anthropic Messages 形状(含 content 分块、
  stop_reason 反向映射、input/output_tokens 改名)。
- 流式是 message_start → content_block_delta → message_delta →
  message_stop;DoneMarker 为空所以内核**不会**擅自加 [DONE]。

内核出向零 Anthropic 字段名:解析是表驱动的,序列化属于适配器。

测试:
- messages_test.go 5 个端到端用例(真实 core+gateway + 只懂 Anthropic 的
  假上游):形状/stop_reason/块顺序/usage 命名、流式事件序列、入向校验、
  鉴权、以及「未声明钩子时回落 OpenAI 兜底」。
- 修正两个 serializer 测试的前提断言(anthropic 现在确实声明了钩子,
  这正是 Phase 2 的意图),并发测试改为按适配器各自校验形状。

golden 字节级未漂移;go test -tags luajit ./... 全绿。
2026-10-11 09:51:27 +08:00

208 lines
6.3 KiB
Go

package gateway
// Mount for plugin-registered HTTP endpoints.
//
// The gateway owns the listener, the authentication, the role check and the
// response framing; the plugin owns the logic. That split is the point: a plugin
// cannot open a socket, cannot bypass auth, and cannot choose a path outside
// /api/plugins/http/.
//
// Why this exists rather than "plugins can call each other": some things are
// worth querying from OUTSIDE the browser — a CI job asking whether the upstream
// balance is above zero, a monitoring probe, another tool on the box. billing
// never wanted this (it only receives hooks); a quota plugin does. Hard-coding a
// route per plugin would mean a gateway change for every such plugin, and an
// arbitrary plugin's code in the main mux is not something the routing table
// should ever grow.
import (
"net/http"
"strconv"
"strings"
"llmsproxy/internal/lua"
)
// pluginHTTPPrefix is the mount point. It is deliberately narrow: a plugin's
// routes live under one prefix, which makes them greppable, makes them easy to
// revoke wholesale, and means a plugin cannot shadow /api/keys or /api/stats.
const pluginHTTPPrefix = "/api/plugins/http/"
// handlePluginHTTP dispatches a request to whichever plugin registered the
// longest matching path.
//
// Longest-match rather than first-match: a plugin may register /health and
// another /health/deep, and both must work. With registration order deciding it,
// the outcome would depend on load order and change when a plugin is added.
func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, pluginHTTPPrefix)
ps := g.core.Plugins()
if ps == nil {
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
return
}
matches := ps.HTTPHandlers()
var chosen *lua.HTTPHandler
var params map[string]string
for i := range matches {
h := matches[i]
if p, ok := matchHandlerPath(h.Path, rest); ok {
if chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path) {
chosen, params = &h, p
}
}
}
if chosen == nil {
writeError(w, http.StatusNotFound, "not_found",
"no plugin serves this path")
return
}
if !strings.EqualFold(r.Method, chosen.Method) {
w.Header().Set("Allow", chosen.Method)
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
"this endpoint accepts "+chosen.Method+" only")
return
}
role := reqRole(r.Context())
if !chosen.Allows(role, reqKeyName(r.Context())) {
writeError(w, http.StatusForbidden, "forbidden",
handlerAccessHint(*chosen))
return
}
body, err := readBody(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
req := &lua.HandlerRequest{
Method: r.Method,
Path: rest,
Query: flattenQuery(r.URL.Query()),
Params: params,
Headers: flattenHeader(r.Header),
Body: body,
Role: role,
Key: reqKey(r.Context()),
}
resp, err := ps.InvokeHandler(handlerOwner(chosen), chosen.Handler, req)
if err != nil {
// A plugin error is a 502, not a 500: the gateway is fine, the plugin is
// not, and the operator needs to tell those apart in their logs.
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
return
}
lua.WriteHandlerResponse(w, resp)
}
// handlerAccessHint explains, in the refusal, exactly which declaration would
// open this endpoint. A 403 with no reason is the kind of thing an operator
// opens the source to work out, and then the fix is a guess.
func handlerAccessHint(h lua.HTTPHandler) string {
switch {
case len(h.Keys) > 0:
return "this endpoint allows only the named keys (" +
strings.Join(h.Keys, ", ") + "); authenticate with one of them"
case len(h.Roles) > 0:
return "this endpoint requires role " + strings.Join(h.Roles, " or ") +
" (widen it in the plugin's http declaration, or set public=true)"
default:
return "this endpoint declares no roles, so it is admin-only; " +
"set roles=… or keys=… in the plugin's http declaration"
}
}
// handlerOwner returns the plugin name that registered h. The gateway stores the
// owner alongside the handler precisely so this does not have to be guessed from
// the path prefix.
func handlerOwner(h *lua.HTTPHandler) string { return h.Plugin }
// segmentCount is how deep a route is, used for longest-match. A path with more
// segments wins, so /health/deep beats /health when both are registered.
func segmentCount(p string) int { return strings.Count(p, "/") }
// matchHandlerPath matches a route pattern against a concrete path, capturing
// ":name" segments.
//
// Returns ok=false on a segment-count mismatch or a missing value for a
// param — a route must not match with an empty capture, or two different URLs
// would reach the same handler indistinguishably.
func matchHandlerPath(pattern, path string) (map[string]string, bool) {
pp := strings.Split(strings.Trim(pattern, "/"), "/")
sp := strings.Split(strings.Trim(path, "/"), "/")
if len(pp) != len(sp) {
return nil, false
}
var params map[string]string
for i := range pp {
if strings.HasPrefix(pp[i], ":") {
name := pp[i][1:]
if sp[i] == "" {
return nil, false
}
if params == nil {
params = map[string]string{}
}
params[name] = sp[i]
continue
}
if pp[i] != sp[i] {
return nil, false
}
}
return params, true
}
func flattenQuery(q map[string][]string) map[string]string {
out := make(map[string]string, len(q))
for k, v := range q {
if len(v) > 0 {
out[k] = v[0]
}
}
return out
}
func flattenHeader(h http.Header) map[string]string {
out := make(map[string]string, len(h))
for k := range h {
out[strings.ToLower(k)] = h.Get(k)
}
return out
}
func readBody(r *http.Request) (string, error) {
if r.Body == nil {
return "", nil
}
defer r.Body.Close()
// A cap, because the handler runs inside the gateway process: an unbounded
// body from an authenticated caller is a memory exhaustion vector.
const maxBody = 4 << 20
buf := make([]byte, 0, 4096)
tmp := make([]byte, 32*1024)
for {
n, err := r.Body.Read(tmp)
if n > 0 {
if len(buf)+n > maxBody {
return "", &bodyTooLarge{}
}
buf = append(buf, tmp[:n]...)
}
if err != nil {
break
}
}
return string(buf), nil
}
type bodyTooLarge struct{}
func (bodyTooLarge) Error() string { return "request body exceeds 4 MiB" }
// limitBody is kept for the numeric form some callers want in a log line.
var _ = strconv.Itoa