mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
Phase 2 收尾(入向)。ChatRequest 从"客户端发什么就是什么"改为
UnifiedRequest(最大集合)的别名,内核真正用上了那个超集类型。
改掉的具体缺陷:
1. 私有字段泄漏归零(v1 的核心问题)
v1 的 disable_thinking 是内核私有开关,却被序列化进交给适配器的 body,
迫使 10 个适配器各自写 req.disable_thinking = nil 来清理。
现在它是中立的 thinking 字段({enabled, budget_tokens}),客户端与
适配器都认这个词:
- gateway/chat.go:thinkingFromClient() 把 disable_thinking 翻译成
thinking.enabled=false;冲突时中立字段优先(它能带预算,布尔不能)
- deepseek.lua:thinking.enabled=false → extra_body.thinking.type=disabled
- anthropic.lua:thinking.enabled=true + budget → 原生 {type,budget_tokens}
(enabled=false 时不传该字段——Anthropic 没有 type=disabled 这个写法)
- 其余 7 个适配器:把"清理内核私有字段"改成"清理中立字段"
2. Tools/ToolChoice 改 json.RawMessage
原为 []interface{}(OpenAI 原始形状 = 内核在假设某个协议)。
内核从不解释工具 schema——它原样交给适配器,所以原始形状不损失任何
内核侧统一性。ToolDef 结构化类型保留在包内供新适配器选用。
3. 删除 MarshalJSON 后门
v1 手工 splice ExtraBody 进 OpenAI 形状的 body;现在 ExtraBody 是
UnifiedRequest 上一个正正经经声明的字段。
渐进迁移:ChatRequest 保留为别名(不是新类型),49 处引用零改动编译,
语义变更不被机械改名淹没。
测试:新增 thinking_test.go(翻译链 4 例 + 泄漏回归 + tools 透传);
vm_test.go 两处迁移到中立语义。golden 字节级未漂移。
249 lines
7.7 KiB
Go
249 lines
7.7 KiB
Go
package lua
|
|
|
|
// Outbound HTTP for plugins.
|
|
//
|
|
// The billing plugin never needed this: everything it reports arrives on a hook
|
|
// payload that the gateway produced while forwarding a request. A quota plugin
|
|
// is the first thing that must REACH OUT on its own — Command Code exposes
|
|
// /alpha/billing/credits and friends, and there is no way to learn a plan's
|
|
// remaining balance without asking.
|
|
//
|
|
// That capability is also the sharpest edge in the plugin model. A plugin is a
|
|
// single .lua file the WebUI installs from a textarea: untrusted, browser-facing
|
|
// code that runs in the gateway process. Handing it a general-purpose HTTP
|
|
// client would make every install a standing SSRF primitive against whatever
|
|
// the gateway can reach — the cloud metadata endpoint, the cluster control
|
|
// plane, whatever else sits on 127.0.0.1 in this deployment. Reading the
|
|
// environment for credentials is a second door out.
|
|
//
|
|
// So the bridge is deliberately narrow:
|
|
//
|
|
// - Destination must match a host that a CONFIGURED SOURCE already talks to.
|
|
// Those are operator-declared, not plugin-declared: a plugin cannot widen
|
|
// its own reach by inventing a URL, and an existing source's host is
|
|
// exactly the set of upstreams this deployment already trusts with API keys.
|
|
// - http:// on a non-loopback host is refused. Credentials belong in HTTPS.
|
|
// - Loopback needs no explicit opt-in: it is the only way a plugin can read
|
|
// the gateway's own API (the billing rules UI does exactly that), and it
|
|
// cannot leave the machine.
|
|
// - Every request is bounded by a timeout and the response is size-capped, so
|
|
// a plugin cannot wedge a worker goroutine or exhaust memory.
|
|
// - Credentials are NEVER read from the environment here. A plugin that needs
|
|
// a key must be given it (state, or the caller's own config), which keeps
|
|
// the grant visible to the operator instead of silently ambient.
|
|
//
|
|
// Redirects are NOT followed: the allowlist is checked per hop, and a redirect
|
|
// to an unlisted host would otherwise walk straight past it. A 3xx is returned
|
|
// as-is so the plugin can see it.
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
golua "github.com/aarzilli/golua/lua"
|
|
)
|
|
|
|
// httpDefaultTimeout bounds a whole request; httpMaxResponseBytes caps the body
|
|
// a plugin can pull into the gateway's heap.
|
|
const (
|
|
httpDefaultTimeout = 15 * time.Second
|
|
httpMaxTimeout = 60 * time.Second
|
|
httpMaxResponse = 4 << 20 // 4 MiB
|
|
)
|
|
|
|
// httpClient is shared by every plugin request. It deliberately has no
|
|
// ProxyFromEnvironment: an operator who proxies upstreams configures the proxy
|
|
// per source, and a plugin request should not silently inherit one.
|
|
var httpClient = &http.Client{
|
|
Timeout: httpDefaultTimeout,
|
|
Transport: &http.Transport{
|
|
Proxy: nil,
|
|
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
|
ForceAttemptHTTP2: true,
|
|
MaxIdleConns: 16,
|
|
IdleConnTimeout: 60 * time.Second,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: time.Second,
|
|
},
|
|
// See above: a redirect is a hop the allowlist has not seen.
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
|
|
// httpGlobal is the Lua name of the outbound bridge.
|
|
const httpGlobal = "__llmsproxy_http"
|
|
|
|
// httpAllowlist is the set of hosts plugins may contact: the host of every
|
|
// configured source's base_url.
|
|
type httpAllowlist struct {
|
|
hosts map[string]bool
|
|
}
|
|
|
|
func newHTTPAllowlist(hosts []string) *httpAllowlist {
|
|
m := make(map[string]bool, len(hosts))
|
|
for _, h := range hosts {
|
|
if h != "" {
|
|
m[strings.ToLower(h)] = true
|
|
}
|
|
}
|
|
return &httpAllowlist{hosts: m}
|
|
}
|
|
|
|
// allows reports whether host:port may be contacted. Ports 80/443 are implied
|
|
// by the bare host so a source configured as https://api.example.com/v1 does not
|
|
// have to spell the port.
|
|
func (a *httpAllowlist) allows(host string, port string) bool {
|
|
host = strings.ToLower(host)
|
|
if host == "" {
|
|
return false
|
|
}
|
|
if a.hosts[host] {
|
|
return true
|
|
}
|
|
switch port {
|
|
case "", "80", "443":
|
|
return a.hosts[host]
|
|
}
|
|
return a.hosts[net.JoinHostPort(host, port)]
|
|
}
|
|
|
|
// checkURL validates a plugin-supplied URL against the allowlist. It returns
|
|
// the parsed URL plus a reason when it is refused.
|
|
func (a *httpAllowlist) checkURL(raw string) (*url.URL, error) {
|
|
u, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bad url: %v", err)
|
|
}
|
|
switch strings.ToLower(u.Scheme) {
|
|
case "https":
|
|
case "http":
|
|
// Plain HTTP is only tolerated on loopback, where it stays on this
|
|
// machine and is how the gateway's own plain-HTTP dev port is reached.
|
|
if !isLoopbackHost(u.Hostname()) {
|
|
return nil, fmt.Errorf("refusing plain http to non-loopback host %q: use https", u.Hostname())
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported scheme %q", u.Scheme)
|
|
}
|
|
if !a.allows(u.Hostname(), u.Port()) && !isLoopbackHost(u.Hostname()) {
|
|
// Loopback needs no entry: it cannot leave the machine, and the billing
|
|
// rules UI reads the gateway's own API over plain HTTP in dev setups.
|
|
return nil, fmt.Errorf("host %q is not an allowed outbound destination "+
|
|
"(allowed: the hosts of configured sources, plus loopback)", u.Hostname())
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func isLoopbackHost(h string) bool {
|
|
if h == "localhost" {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(strings.Trim(h, "[]"))
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|
|
|
|
// httpRequestSpec is the decoded argument table Lua passes to __llmsproxy_http.
|
|
type httpRequestSpec struct {
|
|
URL string
|
|
Method string
|
|
Headers map[string]string
|
|
Body string
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// luaHTTPFn builds the golua closure. It takes one Lua table argument and
|
|
// returns (responseTable, nil) or (nil, errorMessage) — never raises, so a
|
|
// plugin mistake cannot take the gateway with it.
|
|
func luaHTTPFn(L *golua.State, allow *httpAllowlist) golua.LuaGoFunction {
|
|
return func(L *golua.State) int {
|
|
spec, err := readHTTPRequestSpec(L)
|
|
if err != nil {
|
|
L.PushNil()
|
|
L.PushString(err.Error())
|
|
return 2
|
|
}
|
|
u, err := allow.checkURL(spec.URL)
|
|
if err != nil {
|
|
L.PushNil()
|
|
L.PushString(err.Error())
|
|
return 2
|
|
}
|
|
resp, err := performHTTP(u, spec)
|
|
if err != nil {
|
|
L.PushNil()
|
|
L.PushString(err.Error())
|
|
return 2
|
|
}
|
|
pushHTTPResponse(L, resp)
|
|
L.PushNil()
|
|
return 2
|
|
}
|
|
}
|
|
|
|
func performHTTP(u *url.URL, spec httpRequestSpec) (*httpResult, error) {
|
|
method := strings.ToUpper(spec.Method)
|
|
if method == "" {
|
|
method = http.MethodGet
|
|
}
|
|
timeout := spec.Timeout
|
|
if timeout <= 0 || timeout > httpMaxTimeout {
|
|
timeout = httpDefaultTimeout
|
|
}
|
|
|
|
var body io.Reader
|
|
if spec.Body != "" {
|
|
body = strings.NewReader(spec.Body)
|
|
}
|
|
req, err := http.NewRequest(method, u.String(), body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build request: %v", err)
|
|
}
|
|
for k, v := range spec.Headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
if req.Header.Get("User-Agent") == "" {
|
|
req.Header.Set("User-Agent", "ModelRouter-plugin")
|
|
}
|
|
|
|
client := *httpClient
|
|
client.Timeout = timeout
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, httpMaxResponse+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read body: %v", err)
|
|
}
|
|
if len(data) > httpMaxResponse {
|
|
return nil, fmt.Errorf("response exceeds %d byte cap", httpMaxResponse)
|
|
}
|
|
return &httpResult{
|
|
Status: resp.StatusCode,
|
|
Headers: flattenHeaders(resp.Header),
|
|
Body: string(data),
|
|
}, nil
|
|
}
|
|
|
|
type httpResult struct {
|
|
Status int
|
|
Headers map[string]string
|
|
Body string
|
|
}
|
|
|
|
func flattenHeaders(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
|
|
}
|