From 93eeba3762d6cdd5d4a57eb19e2dcb7056df074f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 11 Oct 2026 09:35:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(types):=20=E7=BB=9F=E4=B8=80=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E8=90=BD=E5=9C=B0=E4=B8=BA=E5=86=85=E6=A0=B8=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E4=BD=BF=E7=94=A8=E7=9A=84=E7=B1=BB=E5=9E=8B=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E7=A7=81=E6=9C=89=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E6=B3=84=E6=BC=8F=E5=BD=92=E9=9B=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 字节级未漂移。 --- internal/gateway/chat.go | 112 ++++++++++++++----- internal/gateway/thinking_test.go | 133 +++++++++++++++++++++++ internal/lua/adapters/anthropic.lua | 20 +++- internal/lua/adapters/deepseek.lua | 11 +- internal/lua/adapters/github.lua | 8 +- internal/lua/adapters/groq.lua | 8 +- internal/lua/adapters/kimicode.lua | 8 +- internal/lua/adapters/mistral.lua | 8 +- internal/lua/adapters/openai.lua | 8 +- internal/lua/adapters/sensenova.lua | 8 +- internal/lua/adapters/trae.lua | 8 +- internal/lua/billing_test.go | 2 +- internal/lua/billing_ui_test.go | 22 ++-- internal/lua/plugin_access_persist.go | 2 +- internal/lua/plugin_http.go | 2 +- internal/lua/plugin_http_args.go | 2 +- internal/lua/plugin_http_handler_test.go | 7 +- internal/lua/plugin_http_test.go | 1 + internal/lua/plugin_visibility_test.go | 2 +- internal/lua/quota_opendesign_test.go | 5 +- internal/lua/usage_plugin_test.go | 22 ++-- internal/lua/vm_test.go | 42 +++++-- internal/types/maxwire.go | 31 +++++- internal/types/maxwire_test.go | 4 +- internal/types/types.go | 62 +++++------ 25 files changed, 417 insertions(+), 121 deletions(-) create mode 100644 internal/gateway/thinking_test.go diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index 08432f6..d848911 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -21,21 +21,79 @@ import ( ) // chatRequest mirrors the OpenAI chat completions request the gateway accepts. +// chatRequest is the gateway's IN-BOUND client shape for /v1/chat/completions. +// +// It is deliberately an OpenAI wire struct, NOT the internal unified format: +// this is one client protocol, and the kernel parses it before anything else +// knows which protocol the rest of the request will speak. What reaches an +// adapter is types.UnifiedRequest (the maximal set), which Thinking populates. type chatRequest struct { - Model string `json:"model"` - Messages []types.ChatMessage `json:"messages"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - Stream bool `json:"stream,omitempty"` - Tools []interface{} `json:"tools,omitempty"` - ToolChoice interface{} `json:"tool_choice,omitempty"` + Model string `json:"model"` + Messages []types.ChatMessage `json:"messages"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` + Tools []interface{} `json:"tools,omitempty"` + ToolChoice interface{} `json:"tool_choice,omitempty"` + // DisableThinking is the OpenAI-ecosystem spelling of "turn thinking off". + // It is translated into the protocol-neutral Thinking field on the way in + // (see thinkingFromClient), so that flag never travels under this name. DisableThinking bool `json:"disable_thinking"` ExtraBody map[string]interface{} `json:"extra_body,omitempty"` + // Thinking accepts the neutral shape directly, so a client already speaking + // the unified format (or an adapter-served protocol that names it that way) + // does not have to round-trip through disable_thinking. + Thinking *types.ThinkingConfig `json:"thinking,omitempty"` + // PromptCacheKey 是 OpenAI 原生的缓存键;部分客户端用它携带会话标识。 PromptCacheKey string `json:"prompt_cache_key,omitempty"` } +// thinkingFromClient folds the client's spelling of "no thinking" into the +// neutral Thinking field. +// +// Precedence: an explicit `thinking` object wins, because it carries a budget +// the boolean cannot express. disable_thinking only ever sets Enabled=false — +// it says nothing about how much, and inventing a budget there would silently +// override the upstream's default. +func (r *chatRequest) thinkingFromClient() *types.ThinkingConfig { + if r.Thinking != nil { + return r.Thinking + } + if r.DisableThinking { + no := false + return &types.ThinkingConfig{Enabled: &no} + } + return nil +} + +// rawTools / rawToolChoice re-encode the loosely-typed client fields into the +// RawMessage the internal format carries. The kernel never interprets them: +// they are the client's payload, passed through to the adapter, which is the +// only layer that knows the tool schema of any particular protocol. +func (r *chatRequest) rawTools() json.RawMessage { + if len(r.Tools) == 0 { + return nil + } + b, err := json.Marshal(r.Tools) + if err != nil { + return nil + } + return b +} + +func (r *chatRequest) rawToolChoice() json.RawMessage { + if r.ToolChoice == nil { + return nil + } + b, err := json.Marshal(r.ToolChoice) + if err != nil { + return nil + } + return b +} + // ChatCompletion is the non-streaming OpenAI response object. type ChatCompletion struct { ID string `json:"id"` @@ -424,15 +482,15 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) { done := g.stats.Begin() defer done() inner := &types.ChatRequest{ - Messages: req.Messages, - Temperature: req.Temperature, - MaxTokens: req.MaxTokens, - Stream: req.Stream, - Tools: req.Tools, - ToolChoice: req.ToolChoice, - DisableThinking: req.DisableThinking, - ExtraBody: req.ExtraBody, - ClientSession: clientSession, + Messages: req.Messages, + Temperature: req.Temperature, + MaxTokens: req.MaxTokens, + Stream: req.Stream, + Tools: req.rawTools(), + ToolChoice: req.rawToolChoice(), + Thinking: req.thinkingFromClient(), + ExtraBody: req.ExtraBody, + ClientSession: clientSession, } rec := &Req{ Key: keyID(reqKey(ctx)), @@ -484,16 +542,16 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) { defer done() inner := &types.ChatRequest{ - Model: effective, - Messages: req.Messages, - Temperature: req.Temperature, - MaxTokens: req.MaxTokens, - Stream: req.Stream, - Tools: req.Tools, - ToolChoice: req.ToolChoice, - DisableThinking: req.DisableThinking, - ExtraBody: req.ExtraBody, - ClientSession: clientSession, + Model: effective, + Messages: req.Messages, + Temperature: req.Temperature, + MaxTokens: req.MaxTokens, + Stream: req.Stream, + Tools: req.rawTools(), + ToolChoice: req.rawToolChoice(), + Thinking: req.thinkingFromClient(), + ExtraBody: req.ExtraBody, + ClientSession: clientSession, } rec := &Req{ Key: keyID(reqKey(ctx)), @@ -586,7 +644,7 @@ func estimatePromptTokens(req *types.ChatRequest) int64 { } b, _ := json.Marshal(struct { Messages []types.ChatMessage `json:"messages"` - Tools []interface{} `json:"tools,omitempty"` + Tools json.RawMessage `json:"tools,omitempty"` }{Messages: req.Messages, Tools: req.Tools}) if len(b) == 0 { return 0 diff --git a/internal/gateway/thinking_test.go b/internal/gateway/thinking_test.go new file mode 100644 index 0000000..eb42dd1 --- /dev/null +++ b/internal/gateway/thinking_test.go @@ -0,0 +1,133 @@ +package gateway + +import ( + "encoding/json" + "testing" + + "llmsproxy/internal/types" +) + +// TestThinkingFromClient 把「客户端措辞 → 中立语义」这条翻译链钉死。 +// +// v1 的问题:disable_thinking 是内核私有字段,被序列化进交给适配器的 body, +// 于是 10 个适配器各自写 req.disable_thinking = nil 来清理它。v2 把它换成 +// 双方都认可的 thinking 字段,这条测试确保翻译只发生一次、且在适配器之前。 +func TestThinkingFromClient(t *testing.T) { + cases := []struct { + name string + req chatRequest + want *types.ThinkingConfig + }{ + { + name: "无任何字段 → 不带 thinking(上游用自己默认)", + req: chatRequest{}, + want: nil, + }, + { + name: "disable_thinking=true → enabled=false", + req: chatRequest{DisableThinking: true}, + want: &types.ThinkingConfig{Enabled: boolPtr(false)}, + }, + { + name: "中立 thinking 显式给出 → 原样透传", + req: chatRequest{Thinking: &types.ThinkingConfig{Enabled: boolPtr(true), BudgetTokens: 4096}}, + want: &types.ThinkingConfig{Enabled: boolPtr(true), BudgetTokens: 4096}, + }, + { + name: "两者冲突时,中立的 thinking 优先(它带预算,布尔表达不了)", + req: chatRequest{ + DisableThinking: true, + Thinking: &types.ThinkingConfig{Enabled: boolPtr(true), BudgetTokens: 1024}, + }, + want: &types.ThinkingConfig{Enabled: boolPtr(true), BudgetTokens: 1024}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := c.req.thinkingFromClient() + if c.want == nil { + if got != nil { + t.Fatalf("want nil, got %+v", got) + } + return + } + if got == nil { + t.Fatal("want non-nil thinking, got nil") + } + if (got.Enabled == nil) != (c.want.Enabled == nil) { + t.Fatalf("Enabled presence mismatch: got %v want %v", got.Enabled, c.want.Enabled) + } + if got.Enabled != nil && *got.Enabled != *c.want.Enabled { + t.Errorf("Enabled = %v, want %v", *got.Enabled, *c.want.Enabled) + } + if got.BudgetTokens != c.want.BudgetTokens { + t.Errorf("BudgetTokens = %d, want %d", got.BudgetTokens, c.want.BudgetTokens) + } + }) + } +} + +// TestDisableThinkingNeverReachesAdapterBody 是那条泄漏的回归测试:内核私有 +// 字段不得出现在交给适配器的 body 里。 +func TestDisableThinkingNeverReachesAdapterBody(t *testing.T) { + var r chatRequest + if err := json.Unmarshal([]byte(`{"model":"m","messages":[],"disable_thinking":true}`), &r); err != nil { + t.Fatal(err) + } + inner := &types.ChatRequest{ + Model: "m", + Thinking: r.thinkingFromClient(), + Tools: r.rawTools(), + ToolChoice: r.rawToolChoice(), + ExtraBody: r.ExtraBody, + ClientSession: "", + } + body, err := json.Marshal(inner) + if err != nil { + t.Fatal(err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(body, &m); err != nil { + t.Fatal(err) + } + if _, leaked := m["disable_thinking"]; leaked { + t.Errorf("内核私有字段 disable_thinking 泄漏进适配器 body(v1 回归): %s", body) + } + if _, ok := m["thinking"]; !ok { + t.Errorf("中立 thinking 字段缺失: %s", body) + } +} + +// TestToolsPassthroughRaw 确认 tools/tool_choice 以原始形状交给适配器: +// 内核不解释工具 schema,那是适配器的职责。 +func TestToolsPassthroughRaw(t *testing.T) { + var r chatRequest + body := `{"model":"m","messages":[],"tools":[{"type":"function","function":{"name":"f","parameters":{"type":"object"}}}],"tool_choice":"auto"}` + if err := json.Unmarshal([]byte(body), &r); err != nil { + t.Fatal(err) + } + tools := r.rawTools() + if len(tools) == 0 { + t.Fatal("tools 未透传") + } + var decoded []map[string]interface{} + if err := json.Unmarshal(tools, &decoded); err != nil { + t.Fatalf("tools 不是合法 JSON: %v (%s)", err, tools) + } + if len(decoded) != 1 || decoded[0]["type"] != "function" { + t.Errorf("tools 形状被内核改写: %s", tools) + } + if tc := string(r.rawToolChoice()); tc != `"auto"` { + t.Errorf("tool_choice = %s, want \"auto\"", tc) + } + // 空值不应变成 "null" 出现在 body 里 + var empty chatRequest + if got := empty.rawTools(); got != nil { + t.Errorf("无 tools 时应返回 nil, got %s", got) + } + if got := empty.rawToolChoice(); got != nil { + t.Errorf("无 tool_choice 时应返回 nil, got %s", got) + } +} + +func boolPtr(v bool) *bool { return &v } diff --git a/internal/lua/adapters/anthropic.lua b/internal/lua/adapters/anthropic.lua index 266e2f7..578ebb8 100644 --- a/internal/lua/adapters/anthropic.lua +++ b/internal/lua/adapters/anthropic.lua @@ -254,12 +254,24 @@ function adapter.transform_request(raw_body) end end - -- ── thinking (opt-in via extra_body) ────────────────────── - -- Default: OFF. Client sends extra_body.thinking to enable. - -- Example: {"extra_body": {"thinking": {"type": "enabled", "budget_tokens": 4096}}} - if type(req.extra_body) == "table" and type(req.extra_body.thinking) == "table" then + -- ── thinking ───────────────────────────────────────────── + -- Anthropic 的原生字段就是 {type, budget_tokens},所以中立语义几乎直译。 + -- + -- 中立格式(v2):{ enabled = true/false, budget_tokens = N } + -- enabled=false → 不传该字段(Anthropic 默认关闭思考,传 type=disabled + -- 是非法的,这也是过去只能「不传」的原因) + -- enabled=true → { type="enabled", budget_tokens=N 或默认 } + -- + -- 仍接受 extra_body.thinking 的原生写法:已有部署的配置不因此失效。 + local th = req.thinking + if type(th) == "table" and th.enabled ~= false then + local budget = th.budget_tokens + if budget == nil or budget <= 0 then budget = 4096 end -- Anthropic 必填 + anthropic_req.thinking = { type = "enabled", budget_tokens = budget } + elseif type(req.extra_body) == "table" and type(req.extra_body.thinking) == "table" then anthropic_req.thinking = req.extra_body.thinking end + req.thinking = nil -- ── system ─────────────────────────────────────────────── if system ~= "" then diff --git a/internal/lua/adapters/deepseek.lua b/internal/lua/adapters/deepseek.lua index 310c59e..e22b9d4 100644 --- a/internal/lua/adapters/deepseek.lua +++ b/internal/lua/adapters/deepseek.lua @@ -15,11 +15,18 @@ function adapter.transform_request(raw_body) req.model = "deepseek-v4-flash" end req.stream = req.stream or false - if req.disable_thinking then + -- 中立的 thinking 语义 → DeepSeek 自己的协议写法。 + -- 客户端说「关掉思考」时 thinking.enabled == false;上游的表达方式是 + -- extra_body.thinking.type = "disabled"。v2 之前这里读的是内核私有字段 + -- req.disable_thinking,而那个字段曾被内核泄漏进本函数收到的 body。 + if type(req.thinking) == "table" and req.thinking.enabled == false then req.extra_body = req.extra_body or {} req.extra_body.thinking = { type = "disabled" } + elseif type(req.thinking) == "table" and req.thinking.budget_tokens ~= nil then + req.extra_body = req.extra_body or {} + req.extra_body.thinking = { type = "enabled", budget_tokens = req.thinking.budget_tokens } end - req.disable_thinking = nil + req.thinking = nil -- V4 thinking 模式要求:带 tool_calls 的 assistant 消息必须回传 reasoning_content。 -- OpenAI 兼容客户端不会发该字段,补空串即可通过校验。 diff --git a/internal/lua/adapters/github.lua b/internal/lua/adapters/github.lua index 0db6d9b..03b609b 100644 --- a/internal/lua/adapters/github.lua +++ b/internal/lua/adapters/github.lua @@ -14,7 +14,13 @@ function adapter.transform_request(raw_body) req.temperature = req.temperature or 0.7 req.max_tokens = req.max_tokens or 4096 req.stream = req.stream or false - req.disable_thinking = nil + -- The gateway's private control fields are GONE from the body (v2): the + -- neutral `thinking` field carries that intent and each adapter maps it in + -- its own protocol terms. These upstreams have no thinking switch at all, + -- so the field is simply dropped here — which is what the nil-ing below + -- used to do, except the field being dropped is no longer the kernel's + -- private one. + req.thinking = nil req.extra_body = nil return json.encode(req) end diff --git a/internal/lua/adapters/groq.lua b/internal/lua/adapters/groq.lua index d8858bc..53fd8ad 100644 --- a/internal/lua/adapters/groq.lua +++ b/internal/lua/adapters/groq.lua @@ -13,7 +13,13 @@ function adapter.transform_request(raw_body) req.temperature = req.temperature or 0.7 req.max_tokens = req.max_tokens or 4096 req.stream = req.stream or false - req.disable_thinking = nil + -- The gateway's private control fields are GONE from the body (v2): the + -- neutral `thinking` field carries that intent and each adapter maps it in + -- its own protocol terms. These upstreams have no thinking switch at all, + -- so the field is simply dropped here — which is what the nil-ing below + -- used to do, except the field being dropped is no longer the kernel's + -- private one. + req.thinking = nil req.extra_body = nil return json.encode(req) end diff --git a/internal/lua/adapters/kimicode.lua b/internal/lua/adapters/kimicode.lua index 9d10c54..3402898 100644 --- a/internal/lua/adapters/kimicode.lua +++ b/internal/lua/adapters/kimicode.lua @@ -19,7 +19,13 @@ function adapter.transform_request(raw_body) local ok, req = pcall(json.decode, raw_body) if not ok then return raw_body end req.model = req.model or "kimi-k2" - req.disable_thinking = nil + -- The gateway's private control fields are GONE from the body (v2): the + -- neutral `thinking` field carries that intent and each adapter maps it in + -- its own protocol terms. These upstreams have no thinking switch at all, + -- so the field is simply dropped here — which is what the nil-ing below + -- used to do, except the field being dropped is no longer the kernel's + -- private one. + req.thinking = nil req.extra_body = nil return json.encode(req) end diff --git a/internal/lua/adapters/mistral.lua b/internal/lua/adapters/mistral.lua index 75004b7..d90986b 100644 --- a/internal/lua/adapters/mistral.lua +++ b/internal/lua/adapters/mistral.lua @@ -13,7 +13,13 @@ function adapter.transform_request(raw_body) req.temperature = req.temperature or 0.7 req.max_tokens = req.max_tokens or 4096 req.stream = req.stream or false - req.disable_thinking = nil + -- The gateway's private control fields are GONE from the body (v2): the + -- neutral `thinking` field carries that intent and each adapter maps it in + -- its own protocol terms. These upstreams have no thinking switch at all, + -- so the field is simply dropped here — which is what the nil-ing below + -- used to do, except the field being dropped is no longer the kernel's + -- private one. + req.thinking = nil req.extra_body = nil return json.encode(req) end diff --git a/internal/lua/adapters/openai.lua b/internal/lua/adapters/openai.lua index 2e1e0b0..3022e58 100644 --- a/internal/lua/adapters/openai.lua +++ b/internal/lua/adapters/openai.lua @@ -39,7 +39,13 @@ end function adapter.transform_request(raw_body) local ok, req = pcall(json.decode, raw_body) if not ok then return raw_body end - req.disable_thinking = nil + -- The gateway's private control fields are GONE from the body (v2): the + -- neutral `thinking` field carries that intent and each adapter maps it in + -- its own protocol terms. These upstreams have no thinking switch at all, + -- so the field is simply dropped here — which is what the nil-ing below + -- used to do, except the field being dropped is no longer the kernel's + -- private one. + req.thinking = nil req.extra_body = nil if req.messages then for _, msg in ipairs(req.messages) do diff --git a/internal/lua/adapters/sensenova.lua b/internal/lua/adapters/sensenova.lua index 7df098d..1840e14 100644 --- a/internal/lua/adapters/sensenova.lua +++ b/internal/lua/adapters/sensenova.lua @@ -12,7 +12,13 @@ adapter.headers = {} function adapter.transform_request(raw_body) local ok, req = pcall(json.decode, raw_body) if not ok then return raw_body end - req.disable_thinking = nil + -- The gateway's private control fields are GONE from the body (v2): the + -- neutral `thinking` field carries that intent and each adapter maps it in + -- its own protocol terms. These upstreams have no thinking switch at all, + -- so the field is simply dropped here — which is what the nil-ing below + -- used to do, except the field being dropped is no longer the kernel's + -- private one. + req.thinking = nil req.extra_body = nil if req.messages then for _, msg in ipairs(req.messages) do diff --git a/internal/lua/adapters/trae.lua b/internal/lua/adapters/trae.lua index 45949cc..9a60006 100644 --- a/internal/lua/adapters/trae.lua +++ b/internal/lua/adapters/trae.lua @@ -15,7 +15,13 @@ adapter.headers = {} function adapter.transform_request(raw_body) local ok, req = pcall(json.decode, raw_body) if not ok then return raw_body end - req.disable_thinking = nil + -- The gateway's private control fields are GONE from the body (v2): the + -- neutral `thinking` field carries that intent and each adapter maps it in + -- its own protocol terms. These upstreams have no thinking switch at all, + -- so the field is simply dropped here — which is what the nil-ing below + -- used to do, except the field being dropped is no longer the kernel's + -- private one. + req.thinking = nil req.extra_body = nil if req.messages then for _, msg in ipairs(req.messages) do diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go index 1bf2913..cfb018b 100644 --- a/internal/lua/billing_test.go +++ b/internal/lua/billing_test.go @@ -1067,7 +1067,7 @@ func TestReasoningBilledAtCompletionRateByDefault(t *testing.T) { "model": "m", "source": "s", "key": "***aaaaaa", "ok": true, "prompt_tokens": 1000, "completion_tokens": 1000, "reasoning_tokens": 800, // all of it thinking; still billed as output - "time": 1750000000000, + "time": 1750000000000, }) st := stateOf(t, ps) cost := st["total"].(map[string]interface{})["cost"].(float64) diff --git a/internal/lua/billing_ui_test.go b/internal/lua/billing_ui_test.go index 2c52980..8d11ad4 100644 --- a/internal/lua/billing_ui_test.go +++ b/internal/lua/billing_ui_test.go @@ -427,11 +427,11 @@ func TestBillingUIShowsRecentUnpriced(t *testing.T) { } src := string(raw) for _, needle := range []string{ - "unpriced_by_day", // the tally is written - "recentUnpriced", // it is reduced to a window - "unpriced_recent", // it reaches the view - "unpriced7d", // the card labels it - "unpricedClean", // and says so when the window is clear + "unpriced_by_day", // the tally is written + "recentUnpriced", // it is reduced to a window + "unpriced_recent", // it reaches the view + "unpriced7d", // the card labels it + "unpricedClean", // and says so when the window is clear } { if !strings.Contains(src, needle) { t.Errorf("billing UI lacks %q", needle) @@ -450,9 +450,9 @@ func TestBillingRulesUIExposesReasoningPrice(t *testing.T) { } src := string(raw) for _, needle := range []string{ - `class='m-r'`, // the input, in both the existing rows and the "+ model" row + `class='m-r'`, // the input, in both the existing rows and the "+ model" row `models[n].reasoning`, // collected back out of the DOM on save - `reasoningHint`, // and it says what leaving it blank means + `reasoningHint`, // and it says what leaving it blank means } { if !strings.Contains(src, needle) { t.Errorf("rules editor lacks %q", needle) @@ -481,11 +481,11 @@ func TestBillingRulesUIExposesLongContextStep(t *testing.T) { } src := string(raw) for _, needle := range []string{ - `class='m-lc-a'`, // threshold - `class='m-lc-p'`, // absolute rate - `class='m-lc-m'`, // multiplier + `class='m-lc-a'`, // threshold + `class='m-lc-p'`, // absolute rate + `class='m-lc-m'`, // multiplier `models[n].long_context`, // collected back out on save - `lcHint`, // explains that blank = no step + `lcHint`, // explains that blank = no step } { if !strings.Contains(src, needle) { t.Errorf("rules editor lacks %q", needle) diff --git a/internal/lua/plugin_access_persist.go b/internal/lua/plugin_access_persist.go index 822f7e8..4e71398 100644 --- a/internal/lua/plugin_access_persist.go +++ b/internal/lua/plugin_access_persist.go @@ -112,4 +112,4 @@ func (ps *Plugins) sortedOverrideKeys() []string { } sort.Strings(keys) return keys -} \ No newline at end of file +} diff --git a/internal/lua/plugin_http.go b/internal/lua/plugin_http.go index 167626d..bd34fea 100644 --- a/internal/lua/plugin_http.go +++ b/internal/lua/plugin_http.go @@ -245,4 +245,4 @@ func flattenHeaders(h http.Header) map[string]string { out[strings.ToLower(k)] = h.Get(k) } return out -} \ No newline at end of file +} diff --git a/internal/lua/plugin_http_args.go b/internal/lua/plugin_http_args.go index 6d05e9b..9f82c7e 100644 --- a/internal/lua/plugin_http_args.go +++ b/internal/lua/plugin_http_args.go @@ -119,4 +119,4 @@ func asFloat(v interface{}) (float64, bool) { default: return 0, false } -} \ No newline at end of file +} diff --git a/internal/lua/plugin_http_handler_test.go b/internal/lua/plugin_http_handler_test.go index 03444c2..0327f40 100644 --- a/internal/lua/plugin_http_handler_test.go +++ b/internal/lua/plugin_http_handler_test.go @@ -335,10 +335,10 @@ func TestHandlerResponseFramingIsKernelOwned(t *testing.T) { writeHTTPHandlerResponse(w, &HandlerResponse{ Status: 200, Body: "hello", Headers: map[string]string{ - "Content-Length": "99999", - "Connection": "close", + "Content-Length": "99999", + "Connection": "close", "Transfer-Encoding": "chunked", - "X-Custom": "kept", + "X-Custom": "kept", }, }) if got := w.Header().Get("Content-Length"); got != "" { @@ -398,6 +398,7 @@ func TestParseHandlerResponseShapes(t *testing.T) { t.Errorf("decoded %+v", r) } } + // TestEndpointAccessReportsPerEndpointOverride guards the reporting side of the // override store. // diff --git a/internal/lua/plugin_http_test.go b/internal/lua/plugin_http_test.go index 33f232c..e950466 100644 --- a/internal/lua/plugin_http_test.go +++ b/internal/lua/plugin_http_test.go @@ -149,6 +149,7 @@ func TestPluginHTTPErrorPathIsPairNotRaise(t *testing.T) { t.Fatal("a missing argument table must be an error, not a panic") } } + // The bridge must be reachable under the name plugins actually call. // // This exists because the registration once used the CONSTANT'S IDENTIFIER as diff --git a/internal/lua/plugin_visibility_test.go b/internal/lua/plugin_visibility_test.go index 04ae804..3c5c791 100644 --- a/internal/lua/plugin_visibility_test.go +++ b/internal/lua/plugin_visibility_test.go @@ -190,4 +190,4 @@ func pageIDs(pages []*UIPage) []string { } } return out -} \ No newline at end of file +} diff --git a/internal/lua/quota_opendesign_test.go b/internal/lua/quota_opendesign_test.go index b843c3b..0dc3f23 100644 --- a/internal/lua/quota_opendesign_test.go +++ b/internal/lua/quota_opendesign_test.go @@ -20,8 +20,9 @@ import ( // notice. // // Live responses this fixture copies (both 200, ~1.3s): -// GET https://amr-api.open-design.ai/api/v1/wallet/balance -// GET https://amr-api.open-design.ai/api/v1/billing/summary +// +// GET https://amr-api.open-design.ai/api/v1/wallet/balance +// GET https://amr-api.open-design.ai/api/v1/billing/summary const ( odWallet = `{"balanceUsd":"15.7062","expiresAt":"2026-10-08T23:01:06.662Z",` + `"updatedAt":"2026-10-06T08:02:20.167Z","billingScopeVersion":2,` + diff --git a/internal/lua/usage_plugin_test.go b/internal/lua/usage_plugin_test.go index 745298b..e52819c 100644 --- a/internal/lua/usage_plugin_test.go +++ b/internal/lua/usage_plugin_test.go @@ -9,17 +9,17 @@ import ( // usage plugin safe to ship, and both are easy to break by accident because they // live in a Lua file nobody compiles. // -// 1) SHIPPED. `usage` is the reason /v1/usage exists at all, and it only exists -// for deployments that receive it. A gateway build that does not embed it -// answers 404 and the frontend shows "后台未开放消费接口" — which reads as a -// bug rather than as a missing optional feature. Same reasoning billing.lua and -// quota.lua are embedded. +// 1. SHIPPED. `usage` is the reason /v1/usage exists at all, and it only exists +// for deployments that receive it. A gateway build that does not embed it +// answers 404 and the frontend shows "后台未开放消费接口" — which reads as a +// bug rather than as a missing optional feature. Same reasoning billing.lua and +// quota.lua are embedded. // -// 2) SELF-SCOPED. The handler reports the CALLER's usage, so it must look the -// caller up by the key in the request, never by a key from its own -// configuration. A plugin that read a configured key would report one -// tenant's spend to every tenant — the exact leak this plugin avoids by -// passing the request's key straight through. +// 2. SELF-SCOPED. The handler reports the CALLER's usage, so it must look the +// caller up by the key in the request, never by a key from its own +// configuration. A plugin that read a configured key would report one +// tenant's spend to every tenant — the exact leak this plugin avoids by +// passing the request's key straight through. func TestUsagePluginShipsAndGuardsItsOwnKey(t *testing.T) { src := readShippedPlugin(t, "usage.lua") @@ -109,4 +109,4 @@ func TestUsagePeriodIsAll(t *testing.T) { if !strings.Contains(readShippedPlugin(t, "usage.lua"), `local PERIOD = "all"`) { t.Error(`usage.lua no longer defaults to period "all"`) } -} \ No newline at end of file +} diff --git a/internal/lua/vm_test.go b/internal/lua/vm_test.go index 4f39844..ca8d8b9 100644 --- a/internal/lua/vm_test.go +++ b/internal/lua/vm_test.go @@ -88,12 +88,15 @@ func TestTransformRequest(t *testing.T) { t.Fatal(err) } defer vm.Stop() - out, err := vm.Transform("openai", "transform_request", `{"model":"x","disable_thinking":true,"messages":[]}`) + // v2: adapters strip the NEUTRAL field, because that is what the kernel now + // hands them. (The v1 assertion checked disable_thinking, which was the + // kernel's own private field — it no longer appears in the body at all.) + out, err := vm.Transform("openai", "transform_request", `{"model":"x","thinking":{"enabled":true},"messages":[]}`) if err != nil { t.Fatalf("transform: %v", err) } - if strings.Contains(out, "disable_thinking") { - t.Fatalf("disable_thinking not stripped: %s", out) + if strings.Contains(out, "thinking") { + t.Fatalf("中立 thinking 字段未被剥除,会泄给上游: %s", out) } } @@ -148,33 +151,56 @@ func TestDisableThinkingPassthrough(t *testing.T) { t.Fatal(err) } defer vm.Stop() - body := `{"model":"x","disable_thinking":true,"messages":[{"role":"user","content":"hi"}]}` + // v2: the client speaks the NEUTRAL field. disable_thinking was the kernel's + // private spelling and no longer reaches an adapter at all. + body := `{"model":"x","thinking":{"enabled":false},"messages":[{"role":"user","content":"hi"}]}` out, err := vm.Transform("deepseek", "transform_request", body) if err != nil { t.Fatalf("deepseek transform: %v", err) } var req struct { ExtraBody map[string]interface{} `json:"extra_body"` + Thinking map[string]interface{} `json:"thinking"` } if err := json.Unmarshal([]byte(out), &req); err != nil { t.Fatalf("unmarshal: %v (%s)", err, out) } thinking, ok := req.ExtraBody["thinking"].(map[string]interface{}) if !ok { - t.Fatalf("deepseek should emit extra_body.thinking on disable_thinking: %s", out) + t.Fatalf("deepseek 应把中立的 thinking.enabled=false 映射成 extra_body.thinking: %s", out) } if thinking["type"] != "disabled" { t.Fatalf("thinking.type = %v", thinking["type"]) } + // 关键回归:映射之后,中立字段本身必须已被剥掉,不能随 body 泄给上游。 + if req.Thinking != nil { + t.Fatalf("中立 thinking 字段未被剥除,泄漏进了上游 body: %s", out) + } - // anthropic: thinking is opt-in (Issue 3) — never emitted by default, and - // disable_thinking is not a trigger either. + // anthropic: thinking 是 opt-in —— enabled=false 时不传该字段 + // (Anthropic 没有 type=disabled 这个写法,传了会被拒)。 out2, err := vm.Transform("anthropic", "transform_request", body) if err != nil { t.Fatalf("anthropic transform: %v", err) } if strings.Contains(out2, "thinking") { - t.Fatalf("anthropic should drop thinking when disable_thinking: %s", out2) + t.Fatalf("anthropic 在 thinking.enabled=false 时不应发出 thinking: %s", out2) + } + + // 新增覆盖:enabled=true + budget 应直译成 Anthropic 的原生形状 + outThinking, err := vm.Transform("anthropic", "transform_request", + `{"model":"x","messages":[{"role":"user","content":"hi"}],"thinking":{"enabled":true,"budget_tokens":2048}}`) + if err != nil { + t.Fatalf("anthropic transform: %v", err) + } + var ar struct { + Thinking map[string]interface{} `json:"thinking"` + } + if err := json.Unmarshal([]byte(outThinking), &ar); err != nil { + t.Fatalf("unmarshal: %v (%s)", err, outThinking) + } + if ar.Thinking["type"] != "enabled" || ar.Thinking["budget_tokens"] != float64(2048) { + t.Fatalf("anthropic 原生 thinking 未按中立语义直译: %s", outThinking) } out3, err := vm.Transform("anthropic", "transform_request", `{"model":"x","messages":[{"role":"user","content":"hi"}]}`) if err != nil { diff --git a/internal/types/maxwire.go b/internal/types/maxwire.go index d460726..100abe0 100644 --- a/internal/types/maxwire.go +++ b/internal/types/maxwire.go @@ -53,9 +53,20 @@ type UnifiedRequest struct { StopSequences []string `json:"stop_sequences,omitempty"` // Anthropic // ---- 工具调用 ---- - Tools []ToolDef `json:"tools,omitempty"` - ToolChoice any `json:"tool_choice,omitempty"` - ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` // OpenAI + // Tools 保留 json.RawMessage(宽松的原始形状)而不是结构化的 []ToolDef, + // 原因是**渐进迁移**:内核现有的 12 个适配器与 gateway 都按 []interface{} + // /json.RawMessage 形状序列化请求到适配器。若此处改成 []ToolDef,则 + // 所有适配器与调用点立即编译失败,必须同步改写 19 个非测试点 + 30 个测试。 + // 而 Tools 的真实消费者是适配器(transform_request),内核本身从不解析它, + // 所以用原始形状不损失任何内核侧的统一性——它本就是"原样交给适配器的载荷"。 + // + // ToolDef 类型保留在此包内供新适配器选用(maximal-set 的结构化语义), + // 但内核序列化路径暂不强推它。 + Tools json.RawMessage `json:"tools,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + + // ParallelToolCalls *bool 允许显式 false(OpenAI 语义)。 + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` // OpenAI // ---- 结构化输出 ---- ResponseFormat *ResponseFormat `json:"response_format,omitempty"` // OpenAI @@ -69,6 +80,9 @@ type UnifiedRequest struct { System string `json:"system,omitempty"` // ---- 推理/思考 ---- + // Thinking 取代 v1 的内核私有字段 DisableThinking:那个字段曾被序列化进 + // 交给适配器的 body,迫使 10 个适配器各自写 req.disable_thinking = nil。 + // 这里它是双方都认可的对称语义(客户端要什么 / 上游怎么映射)。 // Thinking 是"要不要思考、思考预算多少"的语义; // 适配器各自映射(Anthropic thinking.budget_tokens、Gemini // thinkingConfig.thinkingBudget、DeepSeek extra_body.thinking.type)。 @@ -173,3 +187,14 @@ type UnifiedDelta struct { // Cost 上游自报费用。 Cost string `json:"cost,omitempty"` } + +// ChatRequest is the historical name for the in-bound request type. +// +// Deprecated: it is an alias of UnifiedRequest. The rename happened because the +// kernel's internal format stopped being "whatever the client sent" once the +// maximal set landed — it is now the maximal set, and the client's own protocol +// shape only survives until the adapter sees it. +// +// Kept as an alias (not a distinct type) so the 49 existing references keep +// compiling while call sites migrate deliberately rather than in one sweep. +type ChatRequest = UnifiedRequest diff --git a/internal/types/maxwire_test.go b/internal/types/maxwire_test.go index ebba5c9..7a21df2 100644 --- a/internal/types/maxwire_test.go +++ b/internal/types/maxwire_test.go @@ -34,8 +34,8 @@ func TestMaximalSetCoversEveryProtocol(t *testing.T) { Seed: i64(42), Stop: []string{"\n"}, StopSequences: []string{"\n\n"}, - Tools: []ToolDef{{Name: "f", Type: "function"}}, - ToolChoice: "auto", + Tools: json.RawMessage(`[{"type":"function","function":{"name":"f"}}]`), + ToolChoice: json.RawMessage(`"auto"`), ParallelToolCalls: b(true), ResponseFormat: &ResponseFormat{Type: "json_object"}, System: "you are helpful", diff --git a/internal/types/types.go b/internal/types/types.go index 51a5f16..da42e23 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -17,42 +17,32 @@ import ( // provider package (which pulls in the Lua runtime). var ErrBusy = errors.New("provider busy") -// ---- OpenAI wire request (gateway input) ---- - -type ChatRequest struct { - Model string `json:"model"` - Messages []ChatMessage `json:"messages"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - Stream bool `json:"stream,omitempty"` - Tools []interface{} `json:"tools,omitempty"` - ToolChoice interface{} `json:"tool_choice,omitempty"` - DisableThinking bool `json:"disable_thinking"` - ExtraBody map[string]interface{} `json:"-"` - - // ClientSession 是客户端自带的会话标识(请求头 x-session-affinity 等)。 - // 不参与序列化:它只用于让适配器生成稳定的上游会话,不应泄漏给上游 body。 - ClientSession string `json:"-"` -} - -func (r *ChatRequest) MarshalJSON() ([]byte, error) { - type Alias ChatRequest - data, err := json.Marshal((*Alias)(r)) - if err != nil { - return nil, err - } - if len(r.ExtraBody) == 0 { - return data, nil - } - var raw map[string]interface{} - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - for k, v := range r.ExtraBody { - raw[k] = v - } - return json.Marshal(raw) -} +// ---- in-bound request ---- +// +// ChatRequest now lives in maxwire.go as an alias of UnifiedRequest: the +// kernel's internal format is the maximal set (OpenAI / Anthropic / Gemini / +// Ollama / DeepSeek fields unioned), not "whatever an OpenAI client sent". The +// type stays named ChatRequest at every call site for now — renaming 19 +// non-test + 30 test references in one sweep would bury the semantic change in +// mechanical noise, and an alias makes the migration free when it happens. +// +// The v1 struct that lived here is gone. Its fields and their fate: +// +// Model/Messages/Temperature/MaxTokens/Stream → UnifiedRequest, same tags +// Tools/ToolChoice → json.RawMessage (raw passthrough +// to the adapter; the kernel never +// parses them) +// DisableThinking (json:"disable_thinking") → REPLACED by Thinking. This was +// the private field that leaked into +// the body handed to adapters, forcing +// 10 adapters to nil it out. +// ExtraBody (hand-injected by MarshalJSON) → a real, declared field on +// UnifiedRequest — no more +// MarshalJSON back door +// ClientSession (json:"-") → kept, still never serialized +// +// The custom MarshalJSON is gone with it: its only job was splicing ExtraBody +// into an OpenAI-shaped body, which is no longer how the type serializes. // ChatMessage supports both plain string content and multimodal arrays // (RawMessage preserves whatever the client sent for the adapter to process).