mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 04:07:25 +00:00
chore!: 移除厂商私有协议适配器与渠道业务插件(合规清理)
G-Star 合规要求下,把"模拟官方客户端、复刻私有协议、自动领取平台额度"这一类 能力整体移出仓库。这些实现依赖各平台未公开的端点、签名算法与官方 client id, 属于绕过客户端校验获取额度,风险高于普通协议适配。 移除(93 个文件): - 适配器:opencodezen(伪装 UA 取免费池)、trae、buddy、kimicode - 插件:channels.lua(12 个平台登录/领额度/签到)、quota.lua(厂商额度探针) - 随之删除的 78 个渠道/额度专用测试与 3 份渠道文档 同时修掉的既有问题: - **硬编码第三方 OAuth client_secret**(Google Cloud Code 的 GOCSPX-…,两处) 随 channels.lua 一起消失。 - `deploy.sh` 的 `sync_plugins` 整段删除:它仍按"内置插件是发行物"的旧前提 把插件复制/覆盖进 plugin_dir,与"插件属运维资产、网关不安装不覆盖"的契约 冲突。现在部署脚本只同步适配器与二进制/配置。 - 适配器清单去掉已移除项;e2e 配置改用显式 `auto:` 链(`models.priority` 早已 移除,该测试此前已失效)。 保留(公开协议):openai / anthropic / gemini / deepseek / github / groq / mistral / ollama / qwen38 / sensenova = 10 个适配器;插件示例 billing / usage。 验证:go build -tags luajit ./... OK;internal/lua 21.4s、provider、core 51s、 gateway 122.8s、e2e 9.0s 全绿。
This commit is contained in:
@ -1,116 +0,0 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loadBuddyAdapter 装载内置 buddy 适配器。
|
||||
func loadBuddyAdapter(t *testing.T) *VM {
|
||||
t.Helper()
|
||||
src, err := bundledAdapters.ReadFile("adapters/buddy.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
t.Cleanup(vm.Stop)
|
||||
if err := vm.LoadAdapterSource("buddy", string(src)); err != nil {
|
||||
t.Fatalf("装载失败: %v", err)
|
||||
}
|
||||
return vm
|
||||
}
|
||||
|
||||
// ★ buddy 适配器:两个厂商要求,各有实测依据。
|
||||
//
|
||||
// ① glm-5.1 **必须**带非空 reasoning_effort(否则上游 400 + 11150)
|
||||
// ② 流式请求必须带 Accept: text/event-stream(否则只回空帧、completion_tokens=0)
|
||||
func TestBuddyAdapterEffortAndHeaders(t *testing.T) {
|
||||
vm := loadBuddyAdapter(t)
|
||||
|
||||
// ① glm-5.1 → 补 medium
|
||||
out, err := vm.Transform("buddy", "transform_request",
|
||||
`{"model":"glm-5.1","messages":[{"role":"user","content":"hi"}],"max_tokens":100,"stream":true}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &b); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if b["reasoning_effort"] != "medium" {
|
||||
t.Errorf("glm-5.1 必须补 reasoning_effort=medium(否则上游 11150),实得 %v", b["reasoning_effort"])
|
||||
}
|
||||
|
||||
// ② 其它模型**不补**(避免把单模型要求扩成全平台行为)
|
||||
out2, err := vm.Transform("buddy", "transform_request",
|
||||
`{"model":"hy3","messages":[{"role":"user","content":"hi"}],"max_tokens":100,"stream":true}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b2 map[string]interface{}
|
||||
json.Unmarshal([]byte(out2), &b2)
|
||||
if b2["reasoning_effort"] != nil {
|
||||
t.Errorf("hy3 不该被补 reasoning_effort(实测不需要),实得 %v", b2["reasoning_effort"])
|
||||
}
|
||||
|
||||
// ③ 调用方已给档位时**尊重它**
|
||||
out3, _ := vm.Transform("buddy", "transform_request",
|
||||
`{"model":"glm-5.1","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high","stream":true}`)
|
||||
var b3 map[string]interface{}
|
||||
json.Unmarshal([]byte(out3), &b3)
|
||||
if b3["reasoning_effort"] != "high" {
|
||||
t.Errorf("调用方给的档位必须保留,实得 %v", b3["reasoning_effort"])
|
||||
}
|
||||
|
||||
// ④ 头:Accept 必须是 SSE
|
||||
h, err := vm.BuildHeaders("buddy", map[string]interface{}{"api_key": "TOK"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h["Accept"] != "text/event-stream" {
|
||||
t.Errorf("Accept 必须是 text/event-stream(否则上游只回空帧),实得 %q", h["Accept"])
|
||||
}
|
||||
for _, k := range []string{"X-Domain", "X-Product", "X-Product-Code"} {
|
||||
if h[k] == "" {
|
||||
t.Errorf("缺少 buddy 身份头 %s", k)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ glm-5.1 补 effort;hy3 不补;Accept=%s", h["Accept"])
|
||||
}
|
||||
|
||||
// 配对判据:协议转换必须与 openai 一致(buddy 只是补两个字段,不该改转换)。
|
||||
func TestBuddyAdapterKeepsOpenAIWire(t *testing.T) {
|
||||
vm := loadBuddyAdapter(t)
|
||||
chunk := `{"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"hi","reasoning_content":"think"},"finish_reason":null}]}`
|
||||
out, err := vm.Transform("buddy", "transform_stream_chunk", chunk)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"content":"hi"`) || !strings.Contains(out, `"reasoning_content":"think"`) {
|
||||
t.Errorf("流式转换必须保留 content 与 reasoning_content,实得 %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 回归护栏:适配器**必须**定义全部四个转换函数。
|
||||
//
|
||||
// 真实事故(2026-10-16):我第一版把基座继承写成"在适配器里读兄弟适配器源码",
|
||||
// 而适配器运行在**另一个沙箱 VM**(无 __llmsproxy_plugin_dir、无文件系统),
|
||||
// 于是基座加载**静默失败** ⇒ 适配器只有 transform_request,
|
||||
// **缺 transform_stream_chunk** ⇒ 内核无法解析 SSE ⇒ 上游明明回了 39 帧正文,
|
||||
// 网关却记 completion_tokens=0、客户端看到**空回复**。
|
||||
// 更糟的是:hy3/glm-5.3/kimi-k3-1 全被它弄坏,而 glm-5.2 因多源兜底
|
||||
// "看起来正常" ⇒ 症状指向错误方向。
|
||||
//
|
||||
// ⇒ 判据必须咬住「四个转换函数都存在」,而不是"某个模型能回话"
|
||||
//
|
||||
// (后者会被别的源兜底掩盖 —— 本会话已栽过两次)。
|
||||
func TestBuddyAdapterDefinesAllTransforms(t *testing.T) {
|
||||
vm := loadBuddyAdapter(t)
|
||||
for _, fn := range []string{"transform_request", "transform_response", "transform_stream_chunk", "transform_error"} {
|
||||
if !vm.HasFn("buddy", fn) {
|
||||
t.Errorf("适配器缺 %s ⇒ 内核该路径会静默降级(流式会变成空回复)", fn)
|
||||
}
|
||||
}
|
||||
t.Log("✓ 四个转换函数齐备")
|
||||
}
|
||||
@ -1,227 +0,0 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 真上游 e2e:用适配器产出的头 + body 直打 opencode /inference/v1。
|
||||
// 需要真实凭据(从生产配置解密),故用 MODELROUTER_LIVE_ZEN=1 显式开启。
|
||||
func TestOpencodeZenLiveE2E(t *testing.T) {
|
||||
if os.Getenv("MODELROUTER_LIVE_ZEN") != "1" {
|
||||
t.Skip("设 MODELROUTER_LIVE_ZEN=1 开启真上游 e2e")
|
||||
}
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 取生产凭据(解密)
|
||||
out, err := exec.Command(gatewayBinary(), "-show-secrets",
|
||||
"-config", "/etc/modelrouter/config.yaml").Output()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
apiKey := ""
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
if strings.HasPrefix(l, "source localzen") {
|
||||
if i := strings.Index(l, "api_key="); i >= 0 {
|
||||
apiKey = strings.TrimSpace(l[i+len("api_key="):])
|
||||
}
|
||||
}
|
||||
}
|
||||
if apiKey == "" {
|
||||
t.Fatal("未取到 localzen 凭据")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"纯对话(无 tools,靠适配器兜底注入)",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"user","content":"reply with exactly: ADAPTER-OK"}]}`, "ADAPTER-OK"},
|
||||
{"带自定义 system",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"system","content":"You are terse."},{"role":"user","content":"reply with exactly: SYS-OK"}]}`, "SYS-OK"},
|
||||
{"多轮",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"user","content":"a"},{"role":"assistant","content":"b"},{"role":"user","content":"reply with exactly: MULTI-OK"}]}`, "MULTI-OK"},
|
||||
{"算术(验证真推理)",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"user","content":"What is 17*23? Reply with just the number."}]}`, "391"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, err := vm.Transform("opencodezen", "transform_request", tc.body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := vm.BuildHeaders("opencodezen", map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(), "body": body,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, _ := http.NewRequest("POST",
|
||||
"https://opencode.ai/inference/v1/chat/completions", bytes.NewReader([]byte(body)))
|
||||
for k, v := range h {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
cl := &http.Client{Timeout: 90 * time.Second}
|
||||
resp, err := cl.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(raw)[:min(300, len(raw))])
|
||||
}
|
||||
// 上游现在返回 SSE(适配器强制 stream=true);判据需自己拼 delta.content。
|
||||
got := ""
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
p := strings.TrimPrefix(line, "data: ")
|
||||
if p == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
var d struct {
|
||||
Choices []struct {
|
||||
Delta struct{ Content string } `json:"delta"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if json.Unmarshal([]byte(p), &d) != nil || len(d.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
got += d.Choices[0].Delta.Content
|
||||
}
|
||||
if got == "" {
|
||||
t.Fatalf("未从 SSE 解出内容;原始=%s", string(raw)[:min(300, len(raw))])
|
||||
}
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Fatalf("回复 %q 不含 %q", got, tc.want)
|
||||
}
|
||||
t.Logf("✓ %s → %q", tc.name, strings.TrimSpace(got))
|
||||
})
|
||||
}
|
||||
fmt.Println("真上游 e2e 全部通过")
|
||||
}
|
||||
|
||||
// 经适配器完整转换链:上游 SSE → transform_stream_chunk → 统一格式。
|
||||
// 上一条判据直接读原始 SSE,只证明"上游放行";这条证明"适配器能解析"。
|
||||
func TestOpencodeZenLiveStreamThroughAdapter(t *testing.T) {
|
||||
if os.Getenv("MODELROUTER_LIVE_ZEN") != "1" {
|
||||
t.Skip("设 MODELROUTER_LIVE_ZEN=1 开启")
|
||||
}
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _ := exec.Command(gatewayBinary(), "-show-secrets",
|
||||
"-config", "/etc/modelrouter/config.yaml").Output()
|
||||
apiKey := ""
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
if strings.HasPrefix(l, "source localzen") {
|
||||
if i := strings.Index(l, "api_key="); i >= 0 {
|
||||
apiKey = strings.TrimSpace(l[i+len("api_key="):])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body, _ := vm.Transform("opencodezen", "transform_request",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"user","content":"reply with exactly: CHAIN-OK"}]}`)
|
||||
h, _ := vm.BuildHeaders("opencodezen", map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(), "body": body})
|
||||
|
||||
req, _ := http.NewRequest("POST",
|
||||
"https://opencode.ai/inference/v1/chat/completions", bytes.NewReader([]byte(body)))
|
||||
for k, v := range h {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(raw)[:min(300, len(raw))])
|
||||
}
|
||||
|
||||
// 逐行喂给适配器的流解析器,累积统一格式的 content
|
||||
joined := ""
|
||||
n := 0
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
|
||||
continue
|
||||
}
|
||||
// 内核喂给适配器的是**已剥掉 data: 前缀**的载荷(见 provider.go:
|
||||
// strings.TrimPrefix(line, "data:")),判据必须对齐这个契约。
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
unified, err := vm.Transform("opencodezen", "transform_stream_chunk", payload)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_stream_chunk: %v (%s)", err, line[:min(120, len(line))])
|
||||
}
|
||||
if unified == "" || unified == "{}" {
|
||||
continue
|
||||
}
|
||||
var u struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if json.Unmarshal([]byte(unified), &u) == nil {
|
||||
joined += u.Content
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("适配器未解出任何 chunk(流解析链断了)")
|
||||
}
|
||||
if !strings.Contains(joined, "CHAIN-OK") {
|
||||
t.Fatalf("经适配器链的内容 %q 不含 CHAIN-OK(chunk 数=%d)", joined, n)
|
||||
}
|
||||
t.Logf("✓ 完整链:%d 个 chunk,内容=%q", n, strings.TrimSpace(joined))
|
||||
}
|
||||
|
||||
// gatewayBinary 定位本机网关二进制。改名后默认是 /usr/local/bin/modelrouter;
|
||||
// 仍兼容改名前的 /usr/local/bin/llmsproxy,避免测试脚本要跟着运维机器改。
|
||||
func gatewayBinary() string {
|
||||
if v := os.Getenv("MODELROUTER_BIN"); v != "" {
|
||||
return v
|
||||
}
|
||||
for _, p := range []string{"/usr/local/bin/modelrouter", "/usr/local/bin/llmsproxy"} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return "/usr/local/bin/modelrouter"
|
||||
}
|
||||
@ -1,112 +0,0 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loadZenVM 装载 opencodezen 适配器(照 adapter_opencodezen_test.go 的方式)。
|
||||
func loadZenVM(t *testing.T) *VM {
|
||||
t.Helper()
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
t.Cleanup(vm.Stop)
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatalf("加载失败: %v", err)
|
||||
}
|
||||
return vm
|
||||
}
|
||||
|
||||
// ★ assistant 回合的 reasoning_content **必须回传**给 opencode 上游。
|
||||
//
|
||||
// 真实缺陷(生产实测,2026-10-16):
|
||||
//
|
||||
// provider localzen: api error 400: Upstream request failed: [invalid_request_error]
|
||||
// The `reasoning_content` in the thinking mode must be passed back to the API.
|
||||
//
|
||||
// 根因:transform_request 里写了 `msg.reasoning_content = nil`,理由是
|
||||
// "Zen 免费池不接受回传"。该假设**方向反了** —— 上游要的就是它。
|
||||
//
|
||||
// ★★ 这个缺陷极难归因,因为失败是**交替**出现的:
|
||||
// 只有当上一轮**真的产出了 reasoning**(thinking 模型 + reasoning_effort 开启)
|
||||
// 时,回放那段历史才会被拒;普通回合照常 200。审计里表现为成串 502 紧随一个
|
||||
// 成功的长上下文请求(实测 prompt_tokens=139330)之后。
|
||||
//
|
||||
// 判据必须**咬住「被剥掉」这个具体行为**,而不是"请求成功" —— 后者在
|
||||
// 非 thinking 回合下永远为真,测不出这个缺陷。
|
||||
func TestOpenCodeZenKeepsAssistantReasoningContent(t *testing.T) {
|
||||
vm := loadZenVM(t)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": "deepseek-v4.1-flash-free",
|
||||
"stream": true,
|
||||
"messages": []map[string]interface{}{
|
||||
{"role": "user", "content": "1+1=?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "2",
|
||||
// 这段正是 agent 回放历史时会带上的东西
|
||||
"reasoning_content": "一加一等于二。",
|
||||
},
|
||||
{"role": "user", "content": "再加一"},
|
||||
},
|
||||
}
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := vm.Transform("opencodezen", "transform_request", string(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 解析出站体,找 assistant 回合
|
||||
var sent struct {
|
||||
Messages []map[string]interface{} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &sent); err != nil {
|
||||
t.Fatalf("出站体不是合法 JSON: %v\n%s", err, out)
|
||||
}
|
||||
var found bool
|
||||
for _, m := range sent.Messages {
|
||||
if m["role"] != "assistant" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
rc, _ := m["reasoning_content"].(string)
|
||||
if rc == "" {
|
||||
t.Fatalf("assistant 回合的 reasoning_content 被剥掉了 ⇒ 上游会回 "+
|
||||
"`The reasoning_content in the thinking mode must be passed back to the API`\n"+
|
||||
"出站 messages: %s", out)
|
||||
}
|
||||
if rc != "一加一等于二。" {
|
||||
t.Errorf("reasoning_content 被改写:%q", rc)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("没找到 assistant 回合(判据失效)\n%s", out)
|
||||
}
|
||||
t.Log("✓ assistant 的 reasoning_content 已保留")
|
||||
}
|
||||
|
||||
// ★ 反向:**空的** reasoning_content 应被丢掉(不要把空串发给上游)。
|
||||
// 与上一条配对 —— 缺了这条,"改成无条件保留"这类过度修正不会被发现。
|
||||
func TestOpenCodeZenDropsEmptyReasoningContent(t *testing.T) {
|
||||
vm := loadZenVM(t)
|
||||
|
||||
raw := `{"model":"m","stream":true,"messages":[
|
||||
{"role":"assistant","content":"hi","reasoning_content":""}
|
||||
]}`
|
||||
out, err := vm.Transform("opencodezen", "transform_request", raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, `"reasoning_content":""`) {
|
||||
t.Errorf("空的 reasoning_content 不该发给上游(多余字段,部分上游会挑),实得: %s", out)
|
||||
}
|
||||
t.Log("✓ 空 reasoning_content 已丢弃")
|
||||
}
|
||||
@ -1,161 +0,0 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// E2E:加载改后的适配器,验证 (1) 头含两个 session 头且值相同;(2) id 段服从公式;
|
||||
// (3) 无 tools 的请求被注入 read 桩且 properties 非空。
|
||||
func TestOpencodeZenAdapterE2E(t *testing.T) {
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatalf("加载失败: %v", err)
|
||||
}
|
||||
|
||||
t.Run("头:两个 session 头同值", func(t *testing.T) {
|
||||
h, err := vm.BuildHeaders("opencodezen", map[string]interface{}{"timestamp": int64(1792065049)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s1, s2 := h["x-opencode-session"], h["x-opencode-session-id"]
|
||||
if s1 == "" || s2 == "" {
|
||||
t.Fatalf("两个 session 头都必须存在: %#v", h)
|
||||
}
|
||||
if s1 != s2 {
|
||||
t.Fatalf("必须同值: %q vs %q", s1, s2)
|
||||
}
|
||||
if !strings.HasPrefix(s1, "ses_") || len(s1) != 4+12+14 {
|
||||
t.Fatalf("session 形状应为 ses_+12hex+14base62: %q (len=%d)", s1, len(s1))
|
||||
}
|
||||
if len(h["x-opencode-request"]) != 4+12+14 || !strings.HasPrefix(h["x-opencode-request"], "msg_") {
|
||||
t.Fatalf("request 形状: %q", h["x-opencode-request"])
|
||||
}
|
||||
// ★ UA 必须与**抓包的官方版本**一致:上游按客户端指纹判定免费层资格。
|
||||
// 实测(2026-10-24,同一凭据同一模型):
|
||||
// CLI 1.18.29(发 4 个头) → 429 FreeUsageLimitError
|
||||
// CLI 1.19.4 (发 5 个头) → 200,任务真实完成
|
||||
// ⇒ UA 里声称的版本与发头集合必须同步升级,不能只改其一。
|
||||
if !strings.Contains(h["User-Agent"], "opencode/1.19.4") {
|
||||
t.Fatalf("UA 应为 1.19.4: %q", h["User-Agent"])
|
||||
}
|
||||
t.Logf("session=%s request=%s", s1, h["x-opencode-request"])
|
||||
// 公式校验:段 = (K - bucket_ms*4096) & MASK,其中 bucket 已对齐到整分钟
|
||||
// (适配器按分钟桶取会话号,以保留上游前缀缓存命中)。
|
||||
// ⚠️ 判据必须算**同一个桶**,不能拿原始 ms 去比 —— 那会把正确的实现判成错。
|
||||
seg := s1[4:16]
|
||||
ms := int64(1792065049) * 1000
|
||||
bucket := ms - (ms % 60000)
|
||||
want := (int64(0x1afffffffffffe) - bucket*4096) & 0xFFFFFFFFFFFF
|
||||
if got := seg; got != fmtHex12(want) {
|
||||
t.Fatalf("session 段公式不符: got=%s want=%s (bucket=%d)", got, fmtHex12(want), bucket)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tools 兜底:无 tools 时注入 read", func(t *testing.T) {
|
||||
out, err := vm.Transform("opencodezen", "transform_request",
|
||||
`{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts, _ := req["tools"].([]interface{})
|
||||
if len(ts) == 0 {
|
||||
t.Fatalf("必须注入 tools: %s", out)
|
||||
}
|
||||
found := false
|
||||
for _, x := range ts {
|
||||
fn := x.(map[string]interface{})["function"].(map[string]interface{})
|
||||
if fn["name"] == "read" {
|
||||
p, _ := fn["parameters"].(map[string]interface{})
|
||||
props, _ := p["properties"].(map[string]interface{})
|
||||
if len(props) == 0 {
|
||||
t.Fatalf("read 的 properties 必须非空(门禁要求): %s", out)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("必须含名为 read 的工具: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tools 保留:用户自带 read 不被覆盖", func(t *testing.T) {
|
||||
in := `{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}],
|
||||
"tools":[{"type":"function","function":{"name":"read","description":"MY","parameters":{"type":"object","properties":{"a":{"type":"string"}}}}}]}`
|
||||
out, err := vm.Transform("opencodezen", "transform_request", in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
json.Unmarshal([]byte(out), &req)
|
||||
ts, _ := req["tools"].([]interface{})
|
||||
if len(ts) != 1 {
|
||||
t.Fatalf("不应追加(已有 read): %s", out)
|
||||
}
|
||||
fn := ts[0].(map[string]interface{})["function"].(map[string]interface{})
|
||||
if fn["description"] != "MY" {
|
||||
t.Fatalf("用户定义被覆盖: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func fmtHex12(v int64) string {
|
||||
const hex = "0123456789abcdef"
|
||||
out := make([]byte, 12)
|
||||
for i := 11; i >= 0; i-- {
|
||||
out[i] = hex[v&0xF]
|
||||
v >>= 4
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// ★ 免费层只能流式(适配器强制 stream=true),而内核在客户端要非流式时
|
||||
// 会调 transform_response(期望 JSON)⇒ 适配器必须能把 SSE 聚合回非流式对象。
|
||||
// 这条判据钉住那个聚合:真实 SSE 片段 → 完整 chat.completion。
|
||||
func TestOpencodeZenAggregatesSSEForNonStreamClient(t *testing.T) {
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模仿上游真实 SSE(含 reasoning_content 与 usage 末帧)
|
||||
sse := `data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"think "}}]}
|
||||
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"HELLO"}}]}
|
||||
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":" WORLD"},"finish_reason":"stop"}]}
|
||||
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[],"usage":{"prompt_tokens":7,"completion_tokens":2,"total_tokens":9}}
|
||||
data: [DONE]
|
||||
`
|
||||
out, err := vm.Transform("opencodezen", "transform_response", sse)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var u map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &u); err != nil {
|
||||
t.Fatalf("聚合产物不是 JSON: %v (%s)", err, out)
|
||||
}
|
||||
if u["content"] != "HELLO WORLD" {
|
||||
t.Fatalf("content 应为 'HELLO WORLD',实得 %v", u["content"])
|
||||
}
|
||||
tu, _ := u["token_usage"].(map[string]interface{})
|
||||
if tu == nil || tu["prompt"] != float64(7) {
|
||||
t.Fatalf("usage 应透传: %v", u["token_usage"])
|
||||
}
|
||||
if u["finish_reason"] != "stop" {
|
||||
t.Fatalf("finish_reason 应为 stop: %v", u["finish_reason"])
|
||||
}
|
||||
t.Logf("聚合 OK: content=%v usage=%v finish=%v", u["content"], tu, u["finish_reason"])
|
||||
}
|
||||
@ -6,8 +6,8 @@ package lua
|
||||
// 字节级哈希分组):
|
||||
//
|
||||
// transform_response github/groq/mistral 三份完全相同(2207B × 3)
|
||||
// transform_stream_chunk github/groq/kimicode/mistral 四份相同(2211B × 4)
|
||||
// transform_error deepseek/github/groq/kimicode 四份相同(293B × 4)
|
||||
// transform_stream_chunk github/groq/mistral 三份相同
|
||||
// transform_error deepseek/github/groq 三份相同
|
||||
//
|
||||
// 合计 12680 字节的**完全重复**;再加上"近似相同"的部分(所有 OpenAI 兼容
|
||||
// 适配器的 transform_request 都要剥 v2 私有控制字段、transform_response 都要
|
||||
|
||||
@ -11,11 +11,11 @@ import (
|
||||
// 比如 usage 的缓存命中只认了一种写法、tool_calls 的 arguments 忘了解码、
|
||||
// usage-only chunk 被当成终结帧。这类差异不会报错,只会让下游少一个字段。
|
||||
//
|
||||
// 期望值是重构前那 5 个适配器(openai/groq/kimicode/mistral/github 的
|
||||
// 期望值是重构前的适配器(openai/groq/mistral/github 的
|
||||
// transform_response 本就逐字节相同)对固定输入的真实输出,先抓 golden 再比。
|
||||
func TestPreludeRefactorIsByteIdentical(t *testing.T) {
|
||||
vm, _, _ := runtimePlugins(t)
|
||||
for _, name := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
|
||||
for _, name := range []string{"openai", "groq", "mistral", "github"} {
|
||||
src, err := bundledAdapters.ReadFile("adapters/" + name + ".lua")
|
||||
if err != nil {
|
||||
t.Fatalf("读内嵌适配器 %s: %v", name, err)
|
||||
@ -30,7 +30,7 @@ func TestPreludeRefactorIsByteIdentical(t *testing.T) {
|
||||
`"function":{"name":"f","arguments":"{\"a\":1}"}}]},"finish_reason":"tool_calls"}],` +
|
||||
`"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,` +
|
||||
`"prompt_cache_hit_tokens":4,"prompt_cache_miss_tokens":6}}`
|
||||
for _, n := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
|
||||
for _, n := range []string{"openai", "groq", "mistral", "github"} {
|
||||
out, err := vm.Transform(n, "transform_response", respIn)
|
||||
if err != nil {
|
||||
t.Fatalf("%s transform_response: %v", n, err)
|
||||
@ -60,7 +60,7 @@ func TestPreludeRefactorIsByteIdentical(t *testing.T) {
|
||||
streamIn := `{"choices":[{"delta":{"content":"x","reasoning_content":"r",` +
|
||||
`"tool_calls":[{"index":0,"id":"c1","function":{"name":"f","arguments":"{}"}}]},` +
|
||||
`"finish_reason":null}]}`
|
||||
for _, n := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
|
||||
for _, n := range []string{"openai", "groq", "mistral", "github"} {
|
||||
out, err := vm.Transform(n, "transform_stream_chunk", streamIn)
|
||||
if err != nil {
|
||||
t.Fatalf("%s stream: %v", n, err)
|
||||
@ -77,7 +77,7 @@ func TestPreludeRefactorIsByteIdentical(t *testing.T) {
|
||||
|
||||
// usage-only chunk(choices 为空)**不得**被当成终结帧
|
||||
usageOnly := `{"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":0,"total_tokens":3}}`
|
||||
for _, n := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
|
||||
for _, n := range []string{"openai", "groq", "mistral", "github"} {
|
||||
out, err := vm.Transform(n, "transform_stream_chunk", usageOnly)
|
||||
if err != nil {
|
||||
t.Fatalf("%s usage-only: %v", n, err)
|
||||
@ -93,7 +93,7 @@ func TestPreludeRefactorIsByteIdentical(t *testing.T) {
|
||||
|
||||
// 错误体抽取:OpenAI 风格 {error:{message}} → 消息本身
|
||||
// (这段在 6 个适配器里原本逐字节相同)
|
||||
for _, n := range []string{"deepseek", "github", "groq", "kimicode", "openai", "sensenova"} {
|
||||
for _, n := range []string{"deepseek", "github", "groq", "openai", "sensenova"} {
|
||||
src, err := bundledAdapters.ReadFile("adapters/" + n + ".lua")
|
||||
if err != nil {
|
||||
t.Fatalf("读 %s: %v", n, err)
|
||||
|
||||
@ -10,14 +10,14 @@ import (
|
||||
//
|
||||
// unhandled upstream error body (adapter "openai" lacks transform_error):
|
||||
// status=404 body={"error_msg":"404 Route Not Found"}
|
||||
// unhandled upstream error body (adapter "buddy" lacks transform_error):
|
||||
// unhandled upstream error body (an adapter without transform_error):
|
||||
// status=400 body={"code":11101,"msg":"Non-stream chat request is not supported"}
|
||||
//
|
||||
// 两者都是 `{"error_msg":...}` / `{"msg":...}` —— 旧实现只认
|
||||
// error/message/detail,于是返回 nil ⇒ 客户端只看到
|
||||
// `api error 404: unknown error`,而 body 里明明有可用文案。
|
||||
//
|
||||
// ⚠️ 那两个适配器**都定义了** transform_error(openai.lua:139、buddy.lua:166),
|
||||
// ⚠️ openai 系适配器**都定义了** transform_error,
|
||||
// 内核却报 "lacks" ⇒ 一句会把排查带偏的诊断(已另行修正内核文案)。
|
||||
func TestPreludeErrorExtractionCoversGatewayShapes(t *testing.T) {
|
||||
vm := NewVM(t.TempDir())
|
||||
@ -37,7 +37,7 @@ func TestPreludeErrorExtractionCoversGatewayShapes(t *testing.T) {
|
||||
}{
|
||||
{"APISIX error_msg",
|
||||
`{"error_msg":"404 Route Not Found"}`, "404 Route Not Found"},
|
||||
{"腾讯 msg(buddy 11101)",
|
||||
{"上游私有 msg 错误",
|
||||
`{"code":11101,"msg":"Non-stream chat request is currently not supported"}`,
|
||||
"11101: Non-stream chat request is currently not supported"},
|
||||
{"error_code + error_msg",
|
||||
@ -58,12 +58,12 @@ func TestPreludeErrorExtractionCoversGatewayShapes(t *testing.T) {
|
||||
{"FastAPI detail", `{"detail":"boom"}`, "boom"},
|
||||
{"HTML title", `<html><title>413 Too Large</title></html>`, "413 Too Large"},
|
||||
{"裸文本", "plain failure line\nmore", "plain failure line"},
|
||||
// 腾讯 codebuddy 三层嵌套(实测 429 code=14018 额度用尽):
|
||||
// 上游三层嵌套错误(实测 429 code=14018 quota exhausted):
|
||||
// 此前落到"不识别",客户端只看到通用文案,用户分不清是额度还是故障。
|
||||
{"buddy 额度用尽嵌套", `{"error":{"data":{"code":14018,"msg":"额度已用尽,请购买加量包","requestId":"r1"}}}`,
|
||||
{"三层嵌套额度用尽", `{"error":{"data":{"code":14018,"msg":"额度已用尽,请购买加量包","requestId":"r1"}}}`,
|
||||
"14018: 额度已用尽,请购买加量包"},
|
||||
// 同一形状但没有 code:只回 msg
|
||||
{"buddy 嵌套无码", `{"error":{"data":{"msg":"请求被拒绝"}}}`, "请求被拒绝"},
|
||||
{"嵌套无码", `{"error":{"data":{"msg":"请求被拒绝"}}}`, "请求被拒绝"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
||||
@ -1,97 +0,0 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ zcode 适配器**源码模板**必须真的能把 anthropic 基座合并进来。
|
||||
//
|
||||
// 背景:此前适配器在**沙箱里自己读 anthropic.lua** ⇒ 必然失败(沙箱无 fs、
|
||||
// 无 plugin_dir)且**静默降级** ⇒ 只有它自己定义的 2 个函数,缺 7 个
|
||||
// ⇒ 每次调用内核报 "missing transform_response" ⇒ 渠道**完全不可用**。
|
||||
// 该缺陷一直潜伏,因为 zcode 从来没有凭据 ⇒ 源未注册 ⇒ 从未被调用过。
|
||||
//
|
||||
// ⚠️ **本判据的边界(务必如实理解)**:它在测试里**自己**做占位符替换,
|
||||
//
|
||||
// 所以它证明的是「**模板形态**正确 + 合并逻辑能吃下基座」,
|
||||
// 而**不能**证明「installZcodeAdapter 真的内联了非空基座」——
|
||||
// 把安装处的 `asrc` 换成空串,本判据**不会**报红(已实测)。
|
||||
// 安装接线由 internal/core 的端到端判据覆盖:
|
||||
// TestZcodeAdapterInjectsOfficialIdentityBlocks
|
||||
// TestZcodeAdapterKeepsAnthropicProtocolConversion(工具调用/图像块存活)
|
||||
// TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(基座不可得 ⇒ 安装期报错)
|
||||
// 这三条**会**咬住"内联空基座"这个变异(已实测)。
|
||||
// ⇒ 教训:判据自己复现被测逻辑时,必须说清它到底覆盖了哪一段。
|
||||
func TestZcodeAdapterInheritsAnthropicBase(t *testing.T) {
|
||||
psrc, _ := os.ReadFile("plugins/channels.lua")
|
||||
s := string(psrc)
|
||||
diag := regexp.MustCompile(`(?s)local ZCODE_DIAG_SRC = \[===\[(.*?)\]===\]`).FindStringSubmatch(s)[1]
|
||||
ad := regexp.MustCompile(`(?s)local ZCODE_ADAPTER_SRC = \[===\[(.*?)\]===\]`).FindStringSubmatch(s)[1]
|
||||
asrc, err := bundledAdapters.ReadFile("adapters/anthropic.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
full := "local Z = (function()\n" + diag + "\nend)()\n" + ad
|
||||
// ⚠ 占位符不等于"适配器里能读到基座":真正要验的是**装载后 9 个函数都在**。
|
||||
// 故这里按 installZcodeAdapter 的同一手法内联,再逐函数断言。
|
||||
if strings.Contains(full, "__INLINE_ANTHROPIC_SRC__") {
|
||||
full = strings.ReplaceAll(full, "__INLINE_ANTHROPIC_SRC__", quoteLua(string(asrc)))
|
||||
} else {
|
||||
t.Fatal("适配器源码必须有基座占位符(基座由插件主体内联;" +
|
||||
"适配器沙箱里读不到文件)")
|
||||
}
|
||||
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("channels-zcode", full); err != nil {
|
||||
t.Fatalf("装载失败: %v", err)
|
||||
}
|
||||
want := []string{"transform_request", "transform_response", "transform_stream_chunk",
|
||||
"transform_error", "serialize_chunk", "serialize_close", "serialize_open_preamble",
|
||||
"serialize_response", "deserialize_request"}
|
||||
var lack []string
|
||||
for _, f := range want {
|
||||
if !vm.HasFn("channels-zcode", f) {
|
||||
lack = append(lack, f)
|
||||
}
|
||||
}
|
||||
if len(lack) > 0 {
|
||||
t.Fatalf("★ zcode 仍缺 %d 个函数(%v)⇒ 基座继承失败 ⇒ "+
|
||||
"每次调用都会报 'missing <fn>' ⇒ 渠道完全不可用", len(lack), lack)
|
||||
}
|
||||
// 行为验证:基座的 transform_response 必须真的能转换 anthropic 响应
|
||||
out, err := vm.Transform("channels-zcode", "transform_response",
|
||||
`{"id":"m1","type":"message","role":"assistant","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":2}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_response 调用失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "hi") {
|
||||
t.Errorf("基座 transform_response 未产出内容: %s", out)
|
||||
}
|
||||
t.Logf("✓ zcode 继承基座成功(9/9 函数),transform_response → %s", out)
|
||||
}
|
||||
|
||||
func quoteLua(s string) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('"')
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch c := s[i]; c {
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
default:
|
||||
b.WriteByte(c)
|
||||
}
|
||||
}
|
||||
b.WriteByte('"')
|
||||
return b.String()
|
||||
}
|
||||
@ -1,292 +0,0 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "buddy"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
-- Claude-behind-OpenAI upstreams (tabitoken, 扇贝, …) convert /chat/completions
|
||||
-- to the Anthropic Messages API internally, so they inherit Anthropic's
|
||||
-- tool id rule: ^[a-zA-Z0-9_-]{1,64}$, enforced by rejecting the WHOLE request
|
||||
-- with "Invalid tool use format" / "tool_use.id: String should match pattern".
|
||||
-- Plain OpenAI has no such rule, so an OpenAI-compatible model can mint an id
|
||||
-- like "bash:0" (observed from moonshotai/kimi-k3). In a fan-out router that id
|
||||
-- is replayed to every other source, so one such id kills every Claude slot at
|
||||
-- once and an AUTO request falls through all tiers.
|
||||
--
|
||||
-- safe_tool_id is pure and deterministic, so a tool_calls entry and its
|
||||
-- matching tool_call_id are rewritten identically within one request. A
|
||||
-- rewritten id keeps an 8-hex digest of the ORIGINAL id, without which two
|
||||
-- distinct ids could collapse into one ("a:b" and "a_b") and become an
|
||||
-- unpaired/duplicate tool call. Already-legal ids are returned untouched, so
|
||||
-- well-behaved traffic is byte-identical to before.
|
||||
-- (anthropic.lua carries the same helper; Lua adapters have no shared prelude.)
|
||||
local TOOL_ID_MAX = 64
|
||||
|
||||
local function safe_tool_id(id)
|
||||
if type(id) ~= "string" or id == "" then return id end
|
||||
local clean = string.gsub(id, "[^A-Za-z0-9_-]", "_")
|
||||
if clean == id and #clean <= TOOL_ID_MAX then
|
||||
return clean
|
||||
end
|
||||
local digest = string.sub(sha256_hex(id), 1, 8)
|
||||
local keep = TOOL_ID_MAX - #digest - 1
|
||||
if #clean > keep then clean = string.sub(clean, 1, keep) end
|
||||
return clean .. "_" .. digest
|
||||
end
|
||||
|
||||
-- 需要补 reasoning_effort 的模型 → 补哪个档位。
|
||||
--
|
||||
-- 唯一出处:参考 product.ts:237 声明 glm-5.1 的 reasoningEfforts 只有 ['medium']。
|
||||
-- 但实测**任何非空档位都被接受**,故这里取 medium(与参考声明一致)。
|
||||
local NEED_EFFORT = {
|
||||
["glm-5.1"] = "medium",
|
||||
}
|
||||
|
||||
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
-- 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
|
||||
-- ★★ buddy(腾讯 CodeBuddy)上游**只支持流式**:body.stream=false 会被
|
||||
-- 400 拒绝(实测 2026-10-19):
|
||||
-- 11101: Non-stream chat request is currently not supported
|
||||
-- 与 Accept: text/event-stream 头无关(头早已在发),判据是 body。
|
||||
-- 故强制 stream=true,非流式请求由内核的"客户端要非流式、上游回流式"
|
||||
-- 路径消化(transform_response 需能聚合 SSE,见下)。
|
||||
req.stream = true
|
||||
-- ★★ buddy 的 glm-5.1 **要求请求体里有非空的 reasoning_effort**,
|
||||
-- 否则上游回 400:
|
||||
-- {"code":11150,"msg":"the reasoning effort value is not supported
|
||||
-- by the current model"}
|
||||
-- 实测(2026-10-16,逐字对照实验):
|
||||
-- glm-5.1 裸(无该字段) → 400 11150 ✗
|
||||
-- glm-5.1 + reasoning_effort:medium → 200 ✓
|
||||
-- glm-5.1 空串 → 400 11150 ✗
|
||||
-- 任何**非空**档位均可(low/medium/high/xhigh/max/minimal 逐个 200)
|
||||
-- 而其余 15 个 buddy 模型裸跑全 200(多带该字段也无害)—— 即这是
|
||||
-- glm-5.1 **独有**的要求。
|
||||
-- ⚠️ 只对**已实测需要**的模型补(而不是一律补):一律补会把"某个模型的
|
||||
-- 特殊要求"扩成"全平台行为",将来上游若对某些模型收紧就会一次性弄坏
|
||||
-- 所有模型。
|
||||
if NEED_EFFORT[req.model or ""] and
|
||||
not (type(req.reasoning_effort) == "string" and req.reasoning_effort ~= "") and
|
||||
req.reasoning_effort == nil then
|
||||
req.reasoning_effort = NEED_EFFORT[req.model]
|
||||
end
|
||||
if req.messages then
|
||||
for _, msg in ipairs(req.messages) do
|
||||
msg.reasoning_content = nil
|
||||
if msg.tool_call_id ~= nil then
|
||||
msg.tool_call_id = safe_tool_id(msg.tool_call_id)
|
||||
end
|
||||
if type(msg.tool_calls) == "table" then
|
||||
for _, tc in ipairs(msg.tool_calls) do
|
||||
if type(tc) == "table" and tc.id ~= nil then
|
||||
tc.id = safe_tool_id(tc.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
-- aggregate_sse(raw):把上游 SSE(data: 行流)聚合成一个非流式 OpenAI 响应。
|
||||
-- 强制 stream=true 后,内核的非流式路径会在这里收到完整 SSE 原文——必须聚合,
|
||||
-- 否则 json.decode 失败原样透传,Go 侧 unmarshal 报 invalid character 'd'。
|
||||
-- 聚合 content / reasoning_content / tool_calls(按 index 拼接分片)/
|
||||
-- finish_reason / usage;无可解析帧时返回 nil(调用方回退原样)。
|
||||
local function aggregate_sse(raw)
|
||||
local content, reasoning = {}, {}
|
||||
local finish, usage, id, model, created = nil, nil, nil, nil, nil
|
||||
local tools_by_index = {}
|
||||
local seen = false
|
||||
for line in (raw .. "\n"):gmatch("([^\n]*)\n") do
|
||||
local s = line:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if s:sub(1, 5) == "data:" then
|
||||
local payload = s:sub(6):gsub("^%s+", "")
|
||||
if payload ~= "" and payload ~= "[DONE]" then
|
||||
local ok, d = pcall(json.decode, payload)
|
||||
if ok and type(d) == "table" then
|
||||
seen = true
|
||||
id = id or d.id
|
||||
model = model or d.model
|
||||
created = created or d.created
|
||||
if type(d.usage) == "table" then usage = d.usage end
|
||||
local ch = d.choices and d.choices[1]
|
||||
if type(ch) == "table" then
|
||||
if type(ch.finish_reason) == "string" and ch.finish_reason ~= "" then
|
||||
finish = ch.finish_reason
|
||||
end
|
||||
local dl = ch.delta
|
||||
if type(dl) == "table" then
|
||||
if type(dl.content) == "string" then content[#content + 1] = dl.content end
|
||||
if type(dl.reasoning_content) == "string" then
|
||||
reasoning[#reasoning + 1] = dl.reasoning_content
|
||||
end
|
||||
if type(dl.tool_calls) == "table" then
|
||||
for _, tc in ipairs(dl.tool_calls) do
|
||||
if type(tc) == "table" and tc.index ~= nil then
|
||||
local slot = tools_by_index[tc.index]
|
||||
if slot == nil then
|
||||
slot = { id = tc.id, type = tc.type or "function",
|
||||
["function"] = { name = "", arguments = "" } }
|
||||
tools_by_index[tc.index] = slot
|
||||
end
|
||||
if tc.id then slot.id = tc.id end
|
||||
local fn = tc["function"]
|
||||
if type(fn) == "table" then
|
||||
if fn.name then slot["function"].name = slot["function"].name .. fn.name end
|
||||
if fn.arguments then slot["function"].arguments = slot["function"].arguments .. fn.arguments end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if not seen then return nil end
|
||||
local msg = { role = "assistant", content = table.concat(content) }
|
||||
local rc = table.concat(reasoning)
|
||||
if rc ~= "" then msg.reasoning_content = rc end
|
||||
local ordered = {}
|
||||
for i = 0, 63 do
|
||||
if tools_by_index[i] then ordered[#ordered + 1] = tools_by_index[i] end
|
||||
end
|
||||
if #ordered > 0 then msg.tool_calls = ordered end
|
||||
local out = {
|
||||
id = id or "", object = "chat.completion", created = created or 0,
|
||||
model = model or "",
|
||||
choices = { { index = 0, message = msg,
|
||||
finish_reason = (finish and finish ~= "") and finish or "stop" } },
|
||||
}
|
||||
if usage then out.usage = usage end
|
||||
local ok2, enc = pcall(json.encode, out)
|
||||
if not ok2 then return nil end
|
||||
return enc
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
-- 上游只会回流式(transform_request 强制 stream=true)。客户端要非流式时
|
||||
-- 内核把完整 SSE 原文交到这里,先聚合再走统一转换。
|
||||
if type(raw_body) == "string"
|
||||
and (raw_body:match("^%s*data:") or raw_body:match("[\r\n]data:")) then
|
||||
local aggregated = aggregate_sse(raw_body)
|
||||
if aggregated ~= nil then raw_body = aggregated end
|
||||
end
|
||||
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
|
||||
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
|
||||
return __prelude.openai_response(raw_body)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||
if not ok then return "" end
|
||||
|
||||
-- OpenAI-style streams may attach usage to a chunk with empty choices
|
||||
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
|
||||
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
|
||||
local uses = nil
|
||||
if type(chunk.usage) == "table" then
|
||||
uses = {
|
||||
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
|
||||
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||
}
|
||||
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
|
||||
elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
|
||||
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
|
||||
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
|
||||
if not chunk.choices or #chunk.choices == 0 then
|
||||
if uses ~= nil then
|
||||
-- usage-only chunk is not a content/finish signal; the gateway
|
||||
-- emits its own terminal stop chunk and merges this usage.
|
||||
return json.encode({ usage = uses, done = false })
|
||||
end
|
||||
return ""
|
||||
end
|
||||
local delta = chunk.choices[1].delta or {}
|
||||
local fr = chunk.choices[1].finish_reason
|
||||
|
||||
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
|
||||
|
||||
local unified = {
|
||||
content = delta.content or "",
|
||||
done = (finish ~= nil)
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
if delta.reasoning_content then
|
||||
unified.reasoning_content = delta.reasoning_content
|
||||
end
|
||||
if type(delta.tool_calls) == "table" and #delta.tool_calls > 0 then
|
||||
-- Do not serialize an empty Lua table as JSON `{}`. That shape is
|
||||
-- neither an OpenAI tool-call array nor an omitted field, and several
|
||||
-- agent clients reject it on the final chunk.
|
||||
for _, tc in ipairs(delta.tool_calls) do
|
||||
if type(tc) == "table" and tc.id ~= nil then
|
||||
tc.id = safe_tool_id(tc.id)
|
||||
end
|
||||
end
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛:标准 OpenAI 信封 {error:{message,...}}
|
||||
function adapter.transform_error(status, body)
|
||||
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
|
||||
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
|
||||
return __prelude.openai_error(status, body)
|
||||
end
|
||||
|
||||
|
||||
-- ★ build_headers:buddy 要求客户端身份头 + 流式的 Accept。
|
||||
--
|
||||
-- 实测(2026-10-16):不发 `Accept: text/event-stream` 时上游接受请求(200)
|
||||
-- 但只回一帧空内容(completion_tokens=0),既不报错也不产出正文 —— 表现是
|
||||
-- "模型不回答",极易误判成模型坏了或额度不足。
|
||||
-- 参考 buddy-adapter.ts 的 send() 固定发该头。
|
||||
--
|
||||
-- 身份头照参考 credentialRequestHeaders + CODEBUDDY 产品配置:
|
||||
-- X-Domain 用产品域(不用凭据里的 domain 快照,那是登录时的区域,
|
||||
-- 跨产品迁移后会指错区);X-Product 是**部署类型**(SaaS);X-Product-Code
|
||||
-- 是产品码(codebuddy)。
|
||||
function adapter.build_headers(meta)
|
||||
local h = {
|
||||
["Accept"] = "text/event-stream",
|
||||
["Content-Type"] = "application/json",
|
||||
["X-Domain"] = "copilot.tencent.com",
|
||||
["X-Product"] = "SaaS",
|
||||
["X-Product-Code"] = "codebuddy",
|
||||
["User-Agent"] = "CodeBuddyIDE/1.106.1",
|
||||
}
|
||||
-- Authorization 由内核在 buildHeaders 之后补(值为源的 api_key)——
|
||||
-- 这里显式给出同一份,保证与其它 buddy 请求形态一致。
|
||||
if type(meta) == "table" and type(meta.api_key) == "string" and meta.api_key ~= "" then
|
||||
h["Authorization"] = "Bearer " .. meta.api_key
|
||||
end
|
||||
return h
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -1,79 +0,0 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "kimicode"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/v1/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
-- KimiCode / Kimi K2 属于 OpenAI 兼容协议;但部分云端 API 会校验调用方
|
||||
-- "app"(只放行特定 agent),要求每次请求带上按 secret 计算的应用签名。
|
||||
-- 这里演示 build_headers 钩子:基于 timestamp + 请求体哈希生成签名头。
|
||||
--
|
||||
-- 配置要求(source.meta):
|
||||
-- meta:
|
||||
-- app_id: <申请到的 app id>
|
||||
-- app_key: <你的 key(由网关的 base_url 复用 api_key 亦可)>
|
||||
-- app_secret: <签名密钥>
|
||||
-- app_agent: code-agent # 若云端要求声明 agent 身份
|
||||
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"
|
||||
-- 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
|
||||
|
||||
-- 可选的动态签名钩子。meta 由 Go 注入:
|
||||
-- meta.url / meta.method / meta.body / meta.api_key / meta.timestamp / meta.source.meta
|
||||
function adapter.build_headers(meta)
|
||||
local h = {
|
||||
["Content-Type"] = "application/json",
|
||||
["X-App-Id"] = tostring((meta.source.meta or {}).app_id or ""),
|
||||
["X-Timestamp"] = tostring(meta.timestamp),
|
||||
}
|
||||
local agent = (meta.source.meta or {}).app_agent
|
||||
if agent and agent ~= "" then
|
||||
h["X-Agent"] = agent
|
||||
end
|
||||
-- 校验 app:通常要求 Authorization 用 app secret 派生签名
|
||||
local secret = (meta.source.meta or {}).app_secret
|
||||
local api_key = meta.source.meta and meta.source.meta.api_key or meta.api_key
|
||||
if secret and secret ~= "" then
|
||||
local body_hash = sha256_hex(meta.body)
|
||||
local sign_string = tostring(meta.timestamp) .. meta.method .. meta.url .. body_hash
|
||||
local sign = hmac_sha256_hex(secret, sign_string)
|
||||
h["Authorization"] = "Bearer " .. api_key
|
||||
h["X-App-Sign"] = sign
|
||||
else
|
||||
h["Authorization"] = "Bearer " .. api_key
|
||||
end
|
||||
return h
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
|
||||
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
|
||||
return __prelude.openai_response(raw_body)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
-- 标准 OpenAI SSE → 统一信封。该实现此前在 groq/kimicode/mistral/github
|
||||
-- 四个适配器里**逐字节重复**(各 2211B),已抽到共享前导。
|
||||
return __prelude.openai_stream_chunk(raw_chunk)
|
||||
end
|
||||
|
||||
-- 错误收敛:kimi 网关为 OpenAI 风格 {error:{message,...}}
|
||||
function adapter.transform_error(status, body)
|
||||
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
|
||||
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
|
||||
return __prelude.openai_error(status, body)
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -1,586 +0,0 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "opencodezen"
|
||||
adapter.version = "1.1.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
-- ★ hidden:不进用户可见适配器列表(v1.1.0)。本适配器是 OpenCode 免费层的
|
||||
-- **门禁专用形态**(强制 stream、注入 read 桩工具、会话号服从倒计时公式),
|
||||
-- 直接暴露会被误当成通用 OpenAI 适配器选去接别的上游,结果不可预测。
|
||||
-- 被 localzen 源引用时内核仍会把它列出来(源编辑器回显),其余场合隐藏。
|
||||
adapter.hidden = true
|
||||
|
||||
-- OpenCode Zen(按量付费端点,https://opencode.ai/zen/v1)
|
||||
--
|
||||
-- 与 OpenCode Go(opencodego.lua,https://opencode.ai/zen/go/v1)是两个不同的
|
||||
-- 服务,行为要求并不相同,因此各有专用适配器:
|
||||
--
|
||||
-- * 本适配器(Zen)服务免费池 / 按量付费池。免费模型必须带 opencode 客户端
|
||||
-- 指纹(UA + x-opencode-*),否则上游拒绝:"free tier can only be used in
|
||||
-- OpenCode"。
|
||||
-- ★ reasoning_content:**保留**(与 Go 侧一致)。此前这里写「剥掉」是基于
|
||||
-- 「免费池以非 thinking 模型为主、回传会报错」的假设,**生产实测已证伪** ——
|
||||
-- 剥掉恰恰会引发
|
||||
-- The `reasoning_content` in the thinking mode must be passed back to the API
|
||||
-- (详见 transform_request 内的长注释:失败只在「上一轮真的产出了 reasoning」
|
||||
-- 时才出现,故表现为同一会话里成功/失败交替,极难归因)。
|
||||
-- 参考的共享序列化器(openai-compat.ts)也是「有就回传」。
|
||||
-- * Go 订阅读者请看 opencodego.lua:那边**必须**回传 reasoning_content,
|
||||
-- 否则 thinking 模式直接 400。
|
||||
--
|
||||
-- 缺 x-opencode-client/session/request/project 身份头会被判为匿名客户端,
|
||||
-- 部分模型在带 tools 时上游直接失败(流式返回单 chunk
|
||||
-- "finish_reason":"network_error" 空 content,非流式 503
|
||||
-- "Endpoint is unavailable",表现为"空回复")。
|
||||
-- ============================================================================
|
||||
-- ★ opencode Zen 免费层门禁(2026-10-15 实测解出,逐条对齐官方 CLI)
|
||||
--
|
||||
-- 上游对免费池有**三个**必须同时满足的条件,缺任一则 403 FreeTierError
|
||||
-- ("free tier can only be used from within OpenCode"),与 UA / TLS 指纹 / 凭据来源
|
||||
-- 均无关:
|
||||
--
|
||||
-- 1. 端点必须是 /inference/v1/chat/completions(不是 /zen/v1)
|
||||
-- —— 由源的 base_url 决定,不在本适配器内。
|
||||
-- 2. 必须同时发 x-opencode-session **与** x-opencode-session-id(值相同)。
|
||||
-- 两个 id 的 12 位 hex 段由**确定公式**给出(见下),不是随机数;
|
||||
-- 时间容差实测 ≥ 60 秒,故 os.time() 的秒精度足够。
|
||||
-- 3. 请求的 tools 里必须有一个名为 read 的工具,且其 parameters.properties
|
||||
-- **非空**。这是唯一的 tools 要求:实测抽掉官方 11 个工具中的任意一个均为
|
||||
-- 200,**只有抽掉 read 变 403**;且 properties 里放任意属性名即可,
|
||||
-- 描述文字 / $schema / required 都不需要(properties 为空对象则 403)。
|
||||
-- ⇒ 无 tools 的纯对话请求会被拒,故 transform_request 需兜底注入桩工具。
|
||||
-- 4. **stream 必须为 true**:实测同为合法请求,stream=false 恒 403、stream=true 恒 200
|
||||
-- (包括 max_tokens 20/60/200/1000/32000 均 200)。
|
||||
-- ⇒ 非流式请求会被拒,故 transform_request 强制 stream=true。
|
||||
--
|
||||
-- 明确**不影响**通过的(均实测):system 内容(含无 system)、消息条数
|
||||
-- (2/3/4/6 条均 200)、凭据(Bearer public 与普通 API key 均可)、
|
||||
-- x-opencode-project / x-opencode-client / UA 里的 opencode/ 前缀。
|
||||
--
|
||||
-- ⚠️ 参考实现 dsh-our-free-model 的门禁桩(bash + read,但无真实 properties)
|
||||
-- 恰好卡在第 3 条上 —— 即它「伪装需跟进上游更新」的真实原因。
|
||||
adapter.headers = {
|
||||
["User-Agent"] = "opencode/1.19.4 ai-sdk/provider-utils/4.0.23 runtime/bun/1.4.4",
|
||||
}
|
||||
|
||||
-- rand_id:按官方的 48-bit 公式生成 12 位 hex 段 + 14 位 base62 随机段。
|
||||
--
|
||||
-- 公式逆自官方 CLI 本地库(~/.local/share/opencode/opencode.db 的 session 表,
|
||||
-- 用 11/12 条真实 id 逐条比对完全一致):
|
||||
--
|
||||
-- MASK = 2^48 - 1
|
||||
-- K = 0x1afffffffffffe (注意不是 27<<48,两者差 2)
|
||||
-- session 段 = (K - now_ms * 4096) & MASK ← **倒计时式**:Δseg/Δms ≡ -4096
|
||||
-- request 段 = (now_ms * 16) & MASK
|
||||
--
|
||||
-- 为什么是「倒计时」而不是时间戳:0x1afffffffffffe 减去 ms*2^12,所以 seg 随
|
||||
-- 时间**递减**。用 f"{ms:012x}" 之类的正向写法必然不对(我为此错过一轮)。
|
||||
--
|
||||
-- ⚠️ 精度:now_ms*4096 ≈ 7.3e15 < 2^53 ≈ 9.0e15,在 double 安全整数范围内,
|
||||
-- 故 LuaJIT 的默认数字类型(double)直接算即可,无需大整数库。
|
||||
local B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
local MASK48 = 281474976710655 -- 2^48 - 1
|
||||
local ID_K = 0x1afffffffffffe
|
||||
|
||||
local function rand_tail(n)
|
||||
local out = {}
|
||||
for i = 1, n do
|
||||
local v = 0
|
||||
if math and math.random then
|
||||
v = math.random(1, 62)
|
||||
else
|
||||
-- 无 math 时用时间做退化熵源:只要唯一,不要求密码学强度。
|
||||
v = (os.time() * 7 + i * 13) % 62 + 1
|
||||
end
|
||||
out[i] = B62:sub(v, v)
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- now_ms:毫秒。沙箱只有 os.time()(秒)与 os.clock()(CPU 时间,无用),
|
||||
-- 而上游时间容差实测 ≥ 60 秒,故秒精度足够;毫秒位补 0。
|
||||
-- meta.timestamp 由内核给出(同为秒),优先用它以便同一请求内自洽。
|
||||
local function now_ms(meta)
|
||||
local t = tonumber(meta and meta.timestamp) or (os.time and os.time()) or 0
|
||||
return t * 1000
|
||||
end
|
||||
|
||||
local function rand_id(prefix, ms, kind, affinity)
|
||||
local seg
|
||||
if kind == "session" then
|
||||
seg = (ID_K - ms * 4096) % (MASK48 + 1)
|
||||
else
|
||||
seg = (ms * 16) % (MASK48 + 1)
|
||||
end
|
||||
-- %012x:12 位十六进制,不足补零
|
||||
-- affinity(可空):会话亲和键。非空时其 8 位 hex 摘要混入 tail ——
|
||||
-- 同一分钟桶内不同会话得到不同会号;hex 段不受影响(门禁只校验它)。
|
||||
local tail
|
||||
if type(affinity) == "string" and affinity ~= "" then
|
||||
tail = string.sub(sha256_hex(affinity), 1, 8) .. rand_tail(6)
|
||||
else
|
||||
tail = rand_tail(14)
|
||||
end
|
||||
return prefix .. string.format("%012x", seg) .. tail
|
||||
end
|
||||
|
||||
-- session_bucket_ms 决定上游会话号的**稳定周期**(按整分钟对齐)。
|
||||
--
|
||||
-- 为什么需要稳定:上游前缀缓存是会话级的(实测:同一段 6032 token 提示词,
|
||||
-- 固定会号第 2 次命中 5888;每请求换会号则恒为 0 命中)。而免费层门禁要求
|
||||
-- x-opencode-session 的 12 位 hex 段服从「倒计时」公式(见 rand_id 注释),
|
||||
-- 所以不能真的「随便挑一个固定值」,只能选一个**时间桶**。
|
||||
--
|
||||
-- 取整分钟(而非秒):同一分钟内的所有请求共享一个会话号 ⇒ 既保留缓存命中,
|
||||
-- 又不超出上游的时间容差。实测容差 ≥ 1 小时(-3600s 仍 200),
|
||||
-- 所以一分钟桶有很大余量,即便上游收紧到几分钟也仍然安全。
|
||||
-- ★ 会话亲和(v1.1.0,移植自 _local_archive 版本):整分钟桶让**所有请求**
|
||||
-- 共享会号,多会话并发时互相污染缓存键。改进:桶内再按**会话身份**分桶——
|
||||
-- 1) meta.client_session(pi 等真会话客户端自带,会话恒定)
|
||||
-- 2) 退路:历史里第一条 user 消息做会话指纹(通用 OpenAI 客户端不发任何
|
||||
-- 会话标识,实测抓包确认;第一条 user 消息在整个会话里恒定)
|
||||
-- 3) 再退:无 user 消息时退回纯时间桶(与 v1.0 行为一致)
|
||||
-- 实现:把会话身份 hash 进 14 位 base62 随机段(hex 段仍服从门禁公式)。
|
||||
local function conversation_fingerprint(body)
|
||||
if type(body) ~= "string" or body == "" then return "" end
|
||||
local ok, req = pcall(json.decode, body)
|
||||
if not ok or type(req) ~= "table" then return "" end
|
||||
for _, m in ipairs(req.messages or {}) do
|
||||
if type(m) == "table" and m.role == "user" then
|
||||
local c = m.content
|
||||
if type(c) == "string" then
|
||||
if c ~= "" then return c end
|
||||
elseif type(c) == "table" then
|
||||
for _, part in ipairs(c) do
|
||||
if type(part) == "table" and part.type == "text" and part.text then
|
||||
return part.text
|
||||
end
|
||||
end
|
||||
end
|
||||
return ""
|
||||
end
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
local function session_affinity_key(meta)
|
||||
local cs = meta and meta.client_session
|
||||
if type(cs) == "string" and cs ~= "" then return cs end
|
||||
return conversation_fingerprint(meta and meta.body)
|
||||
end
|
||||
|
||||
local function session_bucket_ms(meta)
|
||||
local ms = now_ms(meta)
|
||||
return ms - (ms % 60000)
|
||||
end
|
||||
|
||||
function adapter.build_headers(meta)
|
||||
local ms = now_ms(meta)
|
||||
local bucket = session_bucket_ms(meta)
|
||||
-- ★ 本函数的头集合以**官方 CLI 抓包**为准(opencode 1.19.4,2026-10-24
|
||||
-- 通过本地抓包代理取得明文请求)。官方实际发这五个 x-opencode-* 头:
|
||||
--
|
||||
-- x-opencode-client: cli
|
||||
-- x-opencode-project: global
|
||||
-- x-opencode-request: msg_<12hex><14base62>
|
||||
-- x-opencode-session: ses_<12hex><14base62>
|
||||
-- x-opencode-session-id: ses_<12hex><14base62> ← 与 session 同值
|
||||
--
|
||||
-- ⚠⚠ 这里有一个**版本相关的陷阱**,用实测记录,因为它反反复复:
|
||||
-- 同一台机器、同一个 `Bearer public` 凭据、同一个模型:
|
||||
--
|
||||
-- CLI 1.18.29(只发 4 个头,无 session-id) → "Free usage limit reached"
|
||||
-- CLI 1.19.4 (发 5 个头,含 session-id) → 200,任务真实完成
|
||||
--
|
||||
-- 我一度根据 1.18.29 的抓包删掉了 `x-opencode-session-id`,并把注释
|
||||
-- 改写成「官方根本不发这个头」。**那是错的** —— 旧版客户端当时的报错
|
||||
-- 本来就是额度不足,不是头缺失;而新版上游确实要这个头。删掉它会让
|
||||
-- 1.19.x 的指纹不匹配,重新落回 429。
|
||||
--
|
||||
-- 结论:头集合必须与 **UA 里声称的版本**一致。声称 1.19.4 就必须发 5 个头。
|
||||
-- 将来升级 UA 时,必须**重新抓包**该版本的发头集合,不能沿用旧结论。
|
||||
--
|
||||
-- 会话亲和键(可空)混入 tail:同一分钟桶内不同会话得到不同会号,
|
||||
-- 同一会话跨分钟仍共享指纹 ⇒ 前缀缓存按会话命中而非按分钟命中。
|
||||
local sid = rand_id("ses_", bucket, "session", session_affinity_key(meta))
|
||||
return {
|
||||
["User-Agent"] = adapter.headers["User-Agent"],
|
||||
["x-opencode-client"] = "cli",
|
||||
-- project 固定:同网关实例共享一个工作区身份(官方也发同样值)
|
||||
["x-opencode-project"] = "global",
|
||||
["x-opencode-session"] = sid,
|
||||
-- ★ 1.19.4 实测**必须**并发这一份(与 session 同值)。见上面的版本陷阱。
|
||||
["x-opencode-session-id"] = sid,
|
||||
-- request id 每请求唯一(不参与缓存键),用当前时间而非桶,避免同桶内重复
|
||||
["x-opencode-request"] = rand_id("msg_", ms, "request"),
|
||||
}
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- ★ 免费层 tools 兜底:上游要求 tools 里必须有一个名为 read、且
|
||||
-- parameters.properties 非空的工具(实测唯一要求;抽掉它即 403)。
|
||||
-- 纯对话请求(客户端没传 tools)会被拒,故这里注入一个桩工具。
|
||||
--
|
||||
-- 只补缺,不改用户已有的 tools:用户自带 read 时原样透传(官方 CLI 就是这么发的),
|
||||
-- 用户带的其它工具保留不动。
|
||||
local FREE_GATE_TOOL = "read"
|
||||
local function ensure_free_gate_tool(req)
|
||||
local tools = req.tools
|
||||
if type(tools) ~= "table" then tools = {} req.tools = tools end
|
||||
for _, t in ipairs(tools) do
|
||||
if type(t) == "table" then
|
||||
local fn = t["function"]
|
||||
-- 注意:function 是 Lua 关键字,必须用 req["function"] 形式取下标。
|
||||
if type(fn) == "table" and fn.name == FREE_GATE_TOOL then
|
||||
local p = fn.parameters
|
||||
-- 已有 read 但 properties 为空 ⇒ 补上;否则原样保留用户定义。
|
||||
if type(p) == "table" and type(p.properties) == "table"
|
||||
and next(p.properties) ~= nil then
|
||||
return
|
||||
end
|
||||
fn.parameters = { type = "object",
|
||||
properties = { filePath = { type = "string", description = "Path to read" } } }
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
tools[#tools + 1] = {
|
||||
type = "function",
|
||||
["function"] = {
|
||||
name = FREE_GATE_TOOL,
|
||||
description = "Read a file from the local filesystem.",
|
||||
parameters = { type = "object",
|
||||
properties = { filePath = { type = "string", description = "Path to read" } },
|
||||
required = { "filePath" } },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
|
||||
-- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part
|
||||
-- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息
|
||||
-- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游
|
||||
-- "unknown variant `image_url`, expected `text`");带工具调用的必须保留,
|
||||
-- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。
|
||||
-- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder:
|
||||
-- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游
|
||||
-- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。
|
||||
local ROLE_WHITELIST = {
|
||||
system = true,
|
||||
user = true,
|
||||
assistant = true,
|
||||
tool = true,
|
||||
latest_reminder = true,
|
||||
}
|
||||
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
-- ★ 免费层 gate:tools 里必须有 read(且 parameters.properties 非空)。
|
||||
-- 放在最前面:它决定本请求会不会被 403,与后面的字段清理无关。
|
||||
-- 对所有模型都注入:免费池与付费池共用同一层形状检查,多一个桩工具无害
|
||||
-- (官方 CLI 本来也带全套工具),但缺了它会让免费模型整体失效。
|
||||
ensure_free_gate_tool(req)
|
||||
-- ★ 免费层只接受流式:实测 stream=false 恒 403(同样 body 改 true 即 200)。
|
||||
-- 转成流式由上游以 SSE 回应,内核本就会聚合/透传流式响应,故对客户端透明。
|
||||
req.stream = true
|
||||
req.disable_thinking = nil
|
||||
req.extra_body = nil
|
||||
-- stream_options is only valid alongside stream:true; sending it on a
|
||||
-- non-streaming request is rejected by strict upstreams.
|
||||
if req.stream then
|
||||
if type(req.stream_options) ~= "table" then req.stream_options = {} end
|
||||
req.stream_options.include_usage = true
|
||||
end
|
||||
if req.messages then
|
||||
local kept = {}
|
||||
for _, msg in ipairs(req.messages) do
|
||||
if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then
|
||||
msg.role = "system"
|
||||
end
|
||||
-- ★ 必须**保留** reasoning_content(不再剥离)。
|
||||
--
|
||||
-- 历史:这里原先写 `msg.reasoning_content = nil`,理由是「Zen 免费池
|
||||
-- 不接受回传」。**生产实测证伪**(2026-10-16):
|
||||
-- provider localzen: api error 400: Upstream request failed:
|
||||
-- [invalid_request_error] The `reasoning_content` in the thinking
|
||||
-- mode must be passed back to the API.
|
||||
-- 失败形态很隐蔽:**同一模型同一会话里成功与失败交替出现** ——
|
||||
-- 因为只有当上一轮**真的产出了 reasoning**(thinking 模型、
|
||||
-- reasoning_effort 开启、长上下文)时,回放该历史才会被拒;
|
||||
-- 剥掉之后,客户端看到的 502 与「上一轮想过」这件事毫无关联。
|
||||
-- 审计文件里能看到成串 502 紧跟在一个 prompt_tokens=139330 的
|
||||
-- 成功请求之后 —— 那正是「想了、但下一轮我们没把它还回去」。
|
||||
--
|
||||
-- 参考实现(`openai-compat.ts` 的 serializeMessages,opencode 适配器
|
||||
-- 直接复用)在 assistant 回合携带 reasoning 时**就带上**该字段:
|
||||
-- `...reasoning.length > 0 ? { reasoning_content: reasoning } : {}`
|
||||
-- 即「有就回传」,没有额外条件 —— 与本适配器此前的假设方向相反。
|
||||
--
|
||||
-- ⚠️ 不改成「仅当 thinking 开启才回传」:我们**无法**从请求里可靠
|
||||
-- 判定上游是否处于 thinking 模式(真正知道的是上游)。而「有就回传」
|
||||
-- 是零风险方向 —— 上游不需要时忽略即可(实测传与不传同为 200),
|
||||
-- 需要时缺了就是硬 400。
|
||||
if type(msg.reasoning_content) == "string" and msg.reasoning_content == "" then
|
||||
msg.reasoning_content = nil
|
||||
end
|
||||
local drop = false
|
||||
if type(msg.content) == "table" then
|
||||
local parts = {}
|
||||
for _, part in ipairs(msg.content) do
|
||||
if type(part) == "table" and part.type ~= nil and part.type ~= "text" then
|
||||
-- multimodal part not supported by zen
|
||||
else
|
||||
table.insert(parts, part)
|
||||
end
|
||||
end
|
||||
if #parts == 0 then
|
||||
-- Content collapsed to nothing after stripping unsupported
|
||||
-- parts. A message that still carries a tool call must
|
||||
-- NEVER be dropped: the very next message is its tool
|
||||
-- result, and dropping the call orphans that result. The
|
||||
-- model then sees a result for a call it never made and
|
||||
-- re-issues the same tool call on every turn (observed as
|
||||
-- an infinite "repeated tool call" loop).
|
||||
if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0)
|
||||
or msg.tool_call_id ~= nil then
|
||||
msg.content = ""
|
||||
else
|
||||
drop = true
|
||||
end
|
||||
else
|
||||
msg.content = parts
|
||||
end
|
||||
end
|
||||
if not drop then
|
||||
table.insert(kept, msg)
|
||||
end
|
||||
end
|
||||
req.messages = kept
|
||||
end
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
-- ★ aggregate_sse:把 SSE 流(openai chat.completion.chunk 形状)聚合成一个非流式
|
||||
-- 响应对象,供 transform_response 在「客户端要非流式、上游却回流式」时使用。
|
||||
-- 返回 JSON 字符串;无法聚合(无任何 chunk)时返回 nil。
|
||||
--
|
||||
-- 只处理 content / reasoning_content / finish_reason / usage 四类增量:
|
||||
-- 它们覆盖了内核需要从响应里取的全部字段(正文、结束原因、token 计数)。
|
||||
local function aggregate_sse(raw)
|
||||
local content, reasoning, finish = {}, {}, ""
|
||||
local usage, id, model, created = nil, nil, nil, nil
|
||||
local seen = false
|
||||
for line in (raw .. "\n"):gmatch("([^\n]*)\n") do
|
||||
local s = line:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if s:sub(1, 5) == "data:" then
|
||||
local payload = s:sub(6):gsub("^%s+", "")
|
||||
if payload ~= "" and payload ~= "[DONE]" then
|
||||
local ok, d = pcall(json.decode, payload)
|
||||
if ok and type(d) == "table" then
|
||||
seen = true
|
||||
id = id or d.id
|
||||
model = model or d.model
|
||||
created = created or d.created
|
||||
if type(d.usage) == "table" then usage = d.usage end
|
||||
local ch = d.choices and d.choices[1]
|
||||
if type(ch) == "table" then
|
||||
if ch.finish_reason and ch.finish_reason ~= "" then
|
||||
finish = ch.finish_reason
|
||||
end
|
||||
local dl = ch.delta
|
||||
if type(dl) == "table" then
|
||||
if type(dl.content) == "string" then content[#content + 1] = dl.content end
|
||||
if type(dl.reasoning_content) == "string" then
|
||||
reasoning[#reasoning + 1] = dl.reasoning_content
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if not seen then return nil end
|
||||
local msg = { role = "assistant", content = table.concat(content) }
|
||||
local rc = table.concat(reasoning)
|
||||
if rc ~= "" then msg.reasoning_content = rc end
|
||||
local out = {
|
||||
id = id or "", object = "chat.completion", created = created or 0, model = model or "",
|
||||
choices = { { index = 0, message = msg, finish_reason = finish ~= "" and finish or "stop" } },
|
||||
}
|
||||
if usage then out.usage = usage end
|
||||
local ok2, enc = pcall(json.encode, out)
|
||||
if not ok2 then return nil end
|
||||
return enc
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
-- ★ 免费层只接受 stream=true(见文件头门禁第 4 条),所以本适配器把每个请求
|
||||
-- 都改成了流式 —— 但内核是按**客户端意图**选路径的:客户端发非流式时,
|
||||
-- 内核走 Provider.Chat 并调本函数(期望 JSON),而上游回的是 SSE。
|
||||
-- 若在这里直接 json.decode 就会得到 "invalid character 'd'" 错误。
|
||||
--
|
||||
-- 因此本函数必须先识别 SSE 并自行聚合,再走原来的 JSON 路径。
|
||||
-- 这样保持内核对上游协议无感(内核不知道也不该知道上游选了流式)。
|
||||
if type(raw_body) == "string" and (raw_body:sub(1, 6) == "data: " or raw_body:find("\ndata: ", 1, true)) then
|
||||
local aggregated = aggregate_sse(raw_body)
|
||||
if aggregated ~= nil then
|
||||
local ok2, resp2 = pcall(json.decode, aggregated)
|
||||
if ok2 and type(resp2) == "table" then raw_body = aggregated end
|
||||
end
|
||||
end
|
||||
local ok, resp = pcall(json.decode, raw_body)
|
||||
if not ok or resp == nil then return raw_body end
|
||||
|
||||
local unified = {
|
||||
content = "",
|
||||
finish_reason = "",
|
||||
token_usage = { prompt = 0, completion = 0, total = 0 }
|
||||
}
|
||||
|
||||
if type(resp.usage) == "table" then
|
||||
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
|
||||
unified.token_usage.completion = resp.usage.completion_tokens or 0
|
||||
unified.token_usage.total = resp.usage.total_tokens or 0
|
||||
local hit = 0
|
||||
if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
|
||||
hit = resp.usage.prompt_tokens_details.cached_tokens
|
||||
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
|
||||
end
|
||||
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
|
||||
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
|
||||
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
|
||||
if hit == 0 then
|
||||
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
-- 上游报告输出里有多少是思考 token。不给客户端的话,无法判断
|
||||
-- completion_tokens 里多少是「可见回答」、多少是「思考」(都按输出计费)。
|
||||
local ctd = resp.usage.completion_tokens_details
|
||||
if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then
|
||||
unified.token_usage.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens }
|
||||
end
|
||||
end
|
||||
|
||||
-- 本次调用的费用,上游放在**顶层**且是**字符串**(如 "0"、"0.0012")。
|
||||
-- Go 订阅是包月制,恒为 "0";只有 Zen 按量付费模型才有信息量。
|
||||
-- 原样透传:网关不解析、不换算、不汇总 —— 它只是上游事实的搬运者。
|
||||
if resp.cost ~= nil then
|
||||
unified.cost = tostring(resp.cost)
|
||||
end
|
||||
|
||||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||||
local ch = resp.choices[1]
|
||||
if type(ch.message) == "table" then
|
||||
unified.content = ch.message.content or ""
|
||||
local reasoning = ch.message.reasoning_content or ch.message.reasoning
|
||||
if reasoning then
|
||||
unified.reasoning_content = reasoning
|
||||
end
|
||||
if type(ch.message.tool_calls) == "table" then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(ch.message.tool_calls) do
|
||||
local args_ok, args = pcall(json.decode, tc["function"].arguments)
|
||||
if not args_ok then args = {} end
|
||||
table.insert(tcs, {
|
||||
id = tc.id,
|
||||
type = tc.type or "function",
|
||||
name = tc["function"].name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
unified.tool_calls = tcs
|
||||
end
|
||||
end
|
||||
unified.finish_reason = ch.finish_reason or ""
|
||||
end
|
||||
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||
if not ok then return "" end
|
||||
|
||||
-- OpenAI-style streams may attach usage to a chunk with empty choices
|
||||
-- (the final usage chunk). Preserve it; the gateway emits it as the
|
||||
-- terminal usage chunk. Note: keys must match Go's TokenUsage json tags
|
||||
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
|
||||
local uses = nil
|
||||
if type(chunk.usage) == "table" then
|
||||
uses = {
|
||||
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
|
||||
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||
}
|
||||
-- 独立字段与 prompt_tokens_details **并存**透传,不要写成 elseif:
|
||||
-- 上游命中缓存时两者都发,非流式路径也是两个都带。写成 elseif 会让
|
||||
-- 流式丢掉 prompt_cache_hit_tokens/miss,与同一源的**非流式**产出不一致
|
||||
-- (dsh 优先读 details,但只认独立字段的老客户端会看不到缓存)。
|
||||
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
|
||||
end
|
||||
if (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
|
||||
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
|
||||
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
|
||||
if uses.prompt_tokens_details == nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
local ctd = chunk.usage.completion_tokens_details
|
||||
if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then
|
||||
uses.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens }
|
||||
end
|
||||
end
|
||||
|
||||
if not chunk.choices or #chunk.choices == 0 then
|
||||
-- 空 choices 的帧既不是内容也不是结束信号。上游发两种:
|
||||
-- {"choices":[],"usage":{...}} —— 终帧用量
|
||||
-- {"choices":[],"cost":"0"} —— 单独的费用帧
|
||||
-- 两者可能落在同一帧上,必须都转出去;早退只带 usage 会把费用丢干净。
|
||||
local out = { done = false }
|
||||
if uses ~= nil then out.usage = uses end
|
||||
if chunk.cost ~= nil then out.cost = tostring(chunk.cost) end
|
||||
if uses == nil and chunk.cost == nil then return "" end
|
||||
return json.encode(out)
|
||||
end
|
||||
local delta = chunk.choices[1].delta or {}
|
||||
local fr = chunk.choices[1].finish_reason
|
||||
|
||||
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
|
||||
|
||||
local unified = {
|
||||
content = delta.content or "",
|
||||
done = (finish ~= nil)
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
-- zen 用 reasoning 字段承载推理文本(OpenAI 惯例是 reasoning_content)
|
||||
local reasoning = delta.reasoning_content or delta.reasoning
|
||||
if reasoning then
|
||||
unified.reasoning_content = reasoning
|
||||
end
|
||||
if delta.tool_calls then
|
||||
-- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛(可选钩子):zen 错误信封固定为 {error={type,message}};
|
||||
-- 免费池限流 FreeUsageLimitError 单独标注。返回 nil 走通用兜底。
|
||||
function adapter.transform_error(status, body)
|
||||
local ok, resp = pcall(json.decode, body)
|
||||
if not ok or type(resp) ~= "table" then return nil end
|
||||
local e = resp.error
|
||||
if type(e) ~= "table" then return nil end
|
||||
if e.type == "FreeUsageLimitError" then
|
||||
return "zen free pool quota exhausted"
|
||||
end
|
||||
return e.message
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -1,292 +0,0 @@
|
||||
-- trae 源适配器(trae-local-api 的 OpenAI 兼容代理)
|
||||
-- trae-local-api 运行在 http://127.0.0.1:19900,接 Trae CN 账号的云资源。
|
||||
-- 协议:OpenAI /v1/chat/completions
|
||||
-- 特性:
|
||||
-- - 排队:上游 busy 时排队,可能长时间无响应
|
||||
-- - 非流式请求偶发返回 SSE 数据(upstream bug)
|
||||
-- - 支持 thinking,由上游模型自动决定是否开启
|
||||
|
||||
local adapter = {}
|
||||
adapter.name = "trae"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/v1/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
-- 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
|
||||
msg.reasoning_content = nil
|
||||
end
|
||||
end
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
-- parse_text_tool_calls extracts tool calls that an upstream emitted as PLAIN
|
||||
-- TEXT instead of using the OpenAI tool_calls field.
|
||||
--
|
||||
-- trae-local-api's OpenAI endpoint (/v1/chat/completions) does not read the
|
||||
-- request's `tools` array at all, so the relayed model is never told the tool
|
||||
-- schema; it falls back to printing
|
||||
-- <tool_call>
|
||||
-- {"name": "get_weather", "arguments": {"location": "北京"}}
|
||||
-- </tool_call>
|
||||
-- into message.content, leaves message.tool_calls null, and reports
|
||||
-- finish_reason="stop". A client following the OpenAI contract therefore never
|
||||
-- sees a tool call: the agent loop terminates unexpectedly mid-conversation
|
||||
-- (and a hand-written replay produces an empty function name next turn).
|
||||
--
|
||||
-- Both tag spellings are accepted: the same codebase's Anthropic endpoint
|
||||
-- instructs models to emit <toolcall>, and models mix the two. Key names vary
|
||||
-- too (arguments / params / input), so all are tried.
|
||||
--
|
||||
-- Returns (tool_calls_array_or_nil, content_with_blocks_removed).
|
||||
local function parse_text_tool_calls(content)
|
||||
if type(content) ~= "string" or content == "" then return nil, content end
|
||||
|
||||
local tcs = {}
|
||||
local idx = 0
|
||||
|
||||
local function collect(pattern)
|
||||
for payload in content:gmatch(pattern) do
|
||||
local ok, obj = pcall(json.decode, payload)
|
||||
if ok and type(obj) == "table" then
|
||||
-- some models wrap it as {"function":{"name":..,"arguments":..}}
|
||||
local fn = obj["function"]
|
||||
local name = obj.name or (type(fn) == "table" and fn.name) or nil
|
||||
if name then
|
||||
local args = obj.arguments or obj.params or obj.input
|
||||
if args == nil and type(fn) == "table" then
|
||||
args = fn.arguments or fn.params
|
||||
end
|
||||
if type(args) == "string" then
|
||||
local aok, decoded = pcall(json.decode, args)
|
||||
args = aok and decoded or {}
|
||||
elseif type(args) ~= "table" then
|
||||
args = {}
|
||||
end
|
||||
idx = idx + 1
|
||||
table.insert(tcs, {
|
||||
id = obj.id or ("call_text_" .. idx),
|
||||
type = "function",
|
||||
name = name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- `<tag ...>` allows attributes; %s* handles the "</tool_call >" spacing
|
||||
-- that trae-local-api's own prompt example uses.
|
||||
collect("<tool_call[^>]*>%s*(.-)%s*</tool_call%s*>")
|
||||
collect("<toolcall[^>]*>%s*(.-)%s*</toolcall%s*>")
|
||||
|
||||
-- Legacy / mimicked form: models that saw past assistant turns folded as
|
||||
-- [Called tool: name({...})]
|
||||
-- tend to emit the same shape instead of a real block. Recover it so
|
||||
-- the agent loop keeps working; otherwise the client receives plain text
|
||||
-- where a structured tool call should be. Capture the name and the JSON
|
||||
-- object literal independently (the JSON is the only {...} run here).
|
||||
if #tcs == 0 and content:find("%[Called tool") then
|
||||
for name, argsraw in content:gmatch("%[Called tool%s*:%s*([%w_%-%.]+)%s*%((%b{})%s*%)%s*%]") do
|
||||
local aok, decoded = pcall(json.decode, argsraw)
|
||||
idx = idx + 1
|
||||
table.insert(tcs, {
|
||||
id = "call_legacy_" .. idx,
|
||||
type = "function",
|
||||
name = name,
|
||||
arguments = aok and decoded or {}
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
if #tcs == 0 then return nil, content end
|
||||
|
||||
-- drop the blocks from user-visible content; keep any surrounding prose
|
||||
local stripped = content:gsub("<tool_call[^>]*>%s*.-%s*</tool_call%s*>", "")
|
||||
stripped = stripped:gsub("<toolcall[^>]*>%s*.-%s*</toolcall%s*>", "")
|
||||
stripped = stripped:gsub("%[Called tool%s*:%s*[%w_%-%.]+%s*%(%b{}%s*%)%s*%]", "")
|
||||
stripped = stripped:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
return tcs, stripped
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
-- trae-local-api 偶发在非流式请求中返回 SSE 格式数据,
|
||||
-- 表现为多个 data: {...} 行或混合了 reasoning_chunk 等。
|
||||
-- 剥掉 data: 前缀,取最后一个完整的 JSON 块(通常是
|
||||
-- 最终的 stop chunk 或 usage chunk)。
|
||||
local use_json = raw_body
|
||||
if raw_body:match("^data:") or raw_body:match("\ndata:") then
|
||||
-- 取最后一个 data: 行的 JSON
|
||||
local last = nil
|
||||
for line in raw_body:gmatch("data: ([^\n\r]+)") do
|
||||
local trimmed = line:match("^%s*(.-)%s*$")
|
||||
if trimmed and trimmed ~= "[DONE]" then
|
||||
local ok2, obj = pcall(json.decode, trimmed)
|
||||
if ok2 and type(obj) == "table" then
|
||||
last = obj
|
||||
end
|
||||
end
|
||||
end
|
||||
if last then
|
||||
use_json = json.encode(last)
|
||||
end
|
||||
-- 如果解析失败,回退到原始 body
|
||||
end
|
||||
|
||||
local ok, resp = pcall(json.decode, use_json)
|
||||
if not ok or resp == nil then return raw_body end
|
||||
|
||||
local unified = {
|
||||
content = "",
|
||||
finish_reason = "",
|
||||
token_usage = { prompt = 0, completion = 0, total = 0 }
|
||||
}
|
||||
|
||||
if type(resp.usage) == "table" then
|
||||
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
|
||||
unified.token_usage.completion = resp.usage.completion_tokens or 0
|
||||
unified.token_usage.total = resp.usage.total_tokens or 0
|
||||
end
|
||||
|
||||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||||
local ch = resp.choices[1]
|
||||
if type(ch.message) == "table" then
|
||||
unified.content = ch.message.content or ""
|
||||
if ch.message.reasoning_content then
|
||||
unified.reasoning_content = ch.message.reasoning_content
|
||||
end
|
||||
if type(ch.message.tool_calls) == "table" and #ch.message.tool_calls > 0 then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(ch.message.tool_calls) do
|
||||
local fn = tc["function"] or {}
|
||||
local args = fn.arguments
|
||||
if type(args) == "string" then
|
||||
local args_ok, decoded = pcall(json.decode, args)
|
||||
args = args_ok and decoded or {}
|
||||
elseif type(args) ~= "table" then
|
||||
args = {}
|
||||
end
|
||||
table.insert(tcs, {
|
||||
id = tc.id,
|
||||
type = tc.type or "function",
|
||||
name = fn.name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
unified.tool_calls = tcs
|
||||
end
|
||||
end
|
||||
unified.finish_reason = ch.finish_reason or ""
|
||||
|
||||
-- Fallback: trae-local-api's OpenAI endpoint drops the request's
|
||||
-- `tools` array entirely, so the relayed model is never told the tool
|
||||
-- schema and instead PRINTS a <tool_call>{...}</tool_call> block into
|
||||
-- content, leaving message.tool_calls null and finish_reason="stop".
|
||||
-- A client following the OpenAI contract then sees a normal completion
|
||||
-- and its agent loop terminates mid-conversation. Recover the
|
||||
-- structured call so the loop can continue.
|
||||
if unified.tool_calls == nil then
|
||||
local recovered, cleaned = parse_text_tool_calls(unified.content)
|
||||
if recovered then
|
||||
unified.tool_calls = recovered
|
||||
unified.content = cleaned
|
||||
unified.finish_reason = "tool_calls"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||
if not ok then return "" end
|
||||
|
||||
local uses = nil
|
||||
if type(chunk.usage) == "table" then
|
||||
uses = {
|
||||
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
|
||||
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||
}
|
||||
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
|
||||
elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
|
||||
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
|
||||
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
|
||||
if not chunk.choices or #chunk.choices == 0 then
|
||||
if uses ~= nil then
|
||||
return json.encode({ usage = uses, done = false })
|
||||
end
|
||||
return ""
|
||||
end
|
||||
local delta = chunk.choices[1].delta or {}
|
||||
local fr = chunk.choices[1].finish_reason
|
||||
|
||||
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
|
||||
|
||||
local unified = {
|
||||
content = delta.content or "",
|
||||
done = (finish ~= nil)
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
if delta.reasoning_content then
|
||||
unified.reasoning_content = delta.reasoning_content
|
||||
end
|
||||
if type(delta.tool_calls) == "table" and #delta.tool_calls > 0 then
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
function adapter.transform_error(status, body)
|
||||
local ok, resp = pcall(json.decode, body)
|
||||
if not ok or type(resp) ~= "table" then
|
||||
-- Non-JSON body: extract title from HTML or first line
|
||||
local title = string.match(body or "", "<title>(.-)</title>")
|
||||
if title and title ~= "" then return title end
|
||||
local line = string.match(body or "", "^%s*([^\r\n]+)")
|
||||
if line and line ~= "" and not string.match(line, "^<") then
|
||||
return string.sub(line, 1, 200)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local e = resp.error
|
||||
if type(e) == "table" and type(e.message) == "string" then
|
||||
-- trae quota / rate limit annotations
|
||||
if e.code then
|
||||
return tostring(e.code) .. ": " .. e.message
|
||||
end
|
||||
return e.message
|
||||
end
|
||||
if type(e) == "string" then return e end
|
||||
if type(resp.message) == "string" and resp.message ~= "" then
|
||||
if resp.code ~= nil then
|
||||
return tostring(resp.code) .. ": " .. resp.message
|
||||
end
|
||||
return resp.message
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -1,164 +0,0 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Gemini(Cloud Code)走**私有信封协议**,不是公开的 /v1beta/models/…。
|
||||
// 参考 gemini.ts 点名三件必须保留:
|
||||
// 1. 身份五头**逐字写死、不许随机**,且**不带** x-goog-api-key
|
||||
// 2. 流式刻意**不带** Accept
|
||||
// 3. 信封**逐层字母序**序列化(上游按字节比对)
|
||||
//
|
||||
// 此前我们的 gemini.lua 产的是公开的 contents/parts 裸体、且一个身份头都不发,
|
||||
// 端点还指着公开路径 —— 凭据与协议都不匹配。
|
||||
func TestChannelsGeminiPrivateProtocol(t *testing.T) {
|
||||
vm, ps, _ := runtimePlugins(t)
|
||||
src, err := ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, a := range vm.ListAdapters() {
|
||||
names[a.Name] = true
|
||||
}
|
||||
if !names["channels-gemini"] {
|
||||
t.Fatalf("插件未装 channels-gemini;已装: %v", names)
|
||||
}
|
||||
// 内置 gemini(公开 API 用)不得被顶掉
|
||||
if !names["gemini"] {
|
||||
t.Error("内置 gemini 适配器被顶掉了")
|
||||
}
|
||||
if ep := vm.Endpoint("channels-gemini"); ep != "/v1internal:generateContent" {
|
||||
t.Errorf("端点应为私有动词路径,实得 %q", ep)
|
||||
}
|
||||
if se := vm.StreamEndpoint("channels-gemini"); !strings.Contains(se, "streamGenerateContent") {
|
||||
t.Errorf("流式端点不对: %q", se)
|
||||
}
|
||||
|
||||
// 身份五头 + 不得带 google api key
|
||||
hdrs, err := vm.BuildHeaders("channels-gemini", map[string]interface{}{"api_key": "tok"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders: %v", err)
|
||||
}
|
||||
for k, want := range map[string]string{
|
||||
"User-Agent": "antigravity/4.3.0 (cmdc-pak)",
|
||||
"x-client-name": "antigravity",
|
||||
"x-client-version": "4.3.0",
|
||||
"x-machine-id": "cmdc-pak",
|
||||
"x-vscode-sessionid": "proxy",
|
||||
} {
|
||||
if hdrs[k] != want {
|
||||
t.Errorf("身份头 %s 应为 %q,实得 %q", k, want, hdrs[k])
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"x-goog-api-key", "x-goog-api-client", "Accept"} {
|
||||
if _, ok := hdrs[bad]; ok {
|
||||
t.Errorf("**不得**发 %s 头(参考点名)", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// 信封:model/project/request/requestId/userAgent 五件 + request 内层
|
||||
in := `{"model":"gemini-3.1-flash","messages":[` +
|
||||
`{"role":"system","content":"sys"},` +
|
||||
`{"role":"user","content":"hello"}],` +
|
||||
`"max_tokens":100,"stream":true}`
|
||||
out, err := vm.Transform("channels-gemini", "transform_request", in)
|
||||
if err != nil {
|
||||
t.Fatalf("Transform: %v", err)
|
||||
}
|
||||
t.Logf("信封: %s", out)
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &env); err != nil {
|
||||
t.Fatalf("信封不是合法 JSON: %v\n%s", err, out)
|
||||
}
|
||||
for _, k := range []string{"model", "project", "request", "requestId", "userAgent"} {
|
||||
if _, ok := env[k]; !ok {
|
||||
t.Errorf("信封缺顶层字段 %s(私有端点要信封,不是裸 contents)", k)
|
||||
}
|
||||
}
|
||||
if env["userAgent"] != "antigravity" {
|
||||
t.Errorf("userAgent 应为 antigravity,实得 %v", env["userAgent"])
|
||||
}
|
||||
req, _ := env["request"].(map[string]interface{})
|
||||
if req == nil {
|
||||
t.Fatal("request 不是对象")
|
||||
}
|
||||
if _, ok := req["contents"]; !ok {
|
||||
t.Error("request 缺 contents")
|
||||
}
|
||||
if _, ok := req["systemInstruction"]; !ok {
|
||||
t.Error("system 消息应进 systemInstruction")
|
||||
}
|
||||
if _, ok := req["sessionId"]; !ok {
|
||||
t.Error("request 缺 sessionId")
|
||||
}
|
||||
|
||||
// ③ 字母序:顶层键与信封内层都必须有序(上游按字节比对)
|
||||
if !isSortedJSONObject(out) {
|
||||
t.Errorf("信封未按键字母序序列化(参考 marshalAlphabetical):\n%s", out)
|
||||
}
|
||||
|
||||
// ④ 响应必须**剥一层**信封({response:{candidates}})—— 不剥则拿不到
|
||||
// candidates,表现为"模型没回话"
|
||||
respIn := `{"response":{"candidates":[{"content":{"parts":[{"text":"hi"}]},` +
|
||||
`"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,` +
|
||||
`"candidatesTokenCount":2,"totalTokenCount":5}}}`
|
||||
rout, err := vm.Transform("channels-gemini", "transform_response", respIn)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_response: %v", err)
|
||||
}
|
||||
var ru map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(rout), &ru); err != nil {
|
||||
t.Fatalf("响应不是合法 JSON: %v\n%s", err, rout)
|
||||
}
|
||||
if ru["content"] != "hi" {
|
||||
t.Errorf("响应未剥信封(content 应来自 response.candidates[0]):%s", rout)
|
||||
}
|
||||
if ru["finish_reason"] != "stop" {
|
||||
t.Errorf("finishReason STOP 应映射为 stop,实得 %v", ru["finish_reason"])
|
||||
}
|
||||
tu, _ := ru["token_usage"].(map[string]interface{})
|
||||
if tu == nil || tu["prompt"] != float64(3) {
|
||||
t.Errorf("usageMetadata 未映射到 token_usage:%v", ru["token_usage"])
|
||||
}
|
||||
}
|
||||
|
||||
// isSortedJSONObject 检查 JSON 对象键是否字母序(只查顶层,够用)。
|
||||
func isSortedJSONObject(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, "{") {
|
||||
return false
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(s))
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
||||
return false
|
||||
}
|
||||
keys := []string{}
|
||||
for dec.More() {
|
||||
k, err := dec.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
keys = append(keys, k.(string))
|
||||
var skip json.RawMessage
|
||||
if err := dec.Decode(&skip); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for i := 1; i < len(keys); i++ {
|
||||
if keys[i-1] > keys[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@ -1,102 +0,0 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TRAE 不是 OpenAI 兼容:参考 trae.ts:30 明确「载荷必须转换 —— 映射为 SOLO
|
||||
// 格式(function、config_name 等字段),不能透传」。此前我们把 trae 声明成
|
||||
// adapter=openai 直发,端点对了、载荷全错。
|
||||
//
|
||||
// 本测试走**真实路径**:装插件(插件自己装 channels-trae 适配器)→ 用
|
||||
// vm.Transform 调那个**已安装**的适配器。这比在插件 VM 里 load 一份副本更
|
||||
// 可信:副本拿不到内核注入的共享前导,且无法发现"装了但没生效"。
|
||||
func TestChannelsTraeSoloAdapter(t *testing.T) {
|
||||
vm, ps, _ := runtimePlugins(t)
|
||||
src, err := ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 插件应在加载时自装 TRAE SOLO 适配器(名字 channels-trae,避开内置 trae)
|
||||
names := map[string]bool{}
|
||||
for _, a := range vm.ListAdapters() {
|
||||
names[a.Name] = true
|
||||
}
|
||||
if !names["channels-trae"] {
|
||||
t.Fatalf("插件未装 channels-trae 适配器;已装: %v", names)
|
||||
}
|
||||
if owner := ps.AdapterOwnerOf("channels-trae"); owner != "channels" {
|
||||
t.Errorf("归属应为 channels,实得 %q", owner)
|
||||
}
|
||||
// 内置 trae(供运维的 trae-local-api 反代用)必须**不被顶掉**
|
||||
if !names["trae"] {
|
||||
t.Error("内置 trae 适配器被顶掉了(它仍服务运维自建的本地反代)")
|
||||
}
|
||||
|
||||
in := `{"model":"glm-5.2","messages":[` +
|
||||
`{"role":"system","content":"you are helpful"},` +
|
||||
`{"role":"user","content":"hi"},` +
|
||||
`{"role":"assistant","content":"ok","tool_calls":[` +
|
||||
`{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}},` +
|
||||
`{"id":"c2","type":"function","function":{"name":"","arguments":"{}"}}]}` +
|
||||
`],"tools":[{"type":"function","function":{"name":"f","description":"d",` +
|
||||
`"parameters":{"type":"object","properties":{"a":{"type":"string"}}}}}],` +
|
||||
`"tool_choice":{"type":"function","function":{"name":"f"}},` +
|
||||
`"stream":false,"extra_body":{"x":1}}`
|
||||
out, err := vm.Transform("channels-trae", "transform_request", in)
|
||||
if err != nil {
|
||||
t.Fatalf("Transform: %v", err)
|
||||
}
|
||||
t.Logf("SOLO 载荷: %s", out)
|
||||
|
||||
for _, want := range []struct{ desc, needle string }{
|
||||
{"规则2 stream 必须强制 true(非流式由服务端聚合)", `"stream":true`},
|
||||
{"规则3 model → config_name + model 双字段", `"config_name":"glm-5.2"`},
|
||||
{"规则4 function 通道(缺省 solo_work_lite)", `"function":"solo_work_lite"`},
|
||||
{"规则1 content 字符串 → parts 数组", `"text":"hi"`},
|
||||
{"规则6 tool_calls 的 function → function_call", `"function_call"`},
|
||||
{"规则7 parameters 必须是 JSON 字符串(SOLO 要求)", `{\"properties\"`},
|
||||
{"规则5 tool_choice 为 function 时归一成 name 字符串", `"tool_choice":"f"`},
|
||||
} {
|
||||
if !strings.Contains(out, want.needle) {
|
||||
t.Errorf("%s\n 缺 %s\n 实际: %s", want.desc, want.needle, out)
|
||||
}
|
||||
}
|
||||
// 反向断言
|
||||
if strings.Contains(out, `"extra_body"`) {
|
||||
t.Errorf("内核私有字段 extra_body 未剥离:%s", out)
|
||||
}
|
||||
if strings.Contains(out, `"id":"c2"`) {
|
||||
t.Errorf("无 name 的 tool_call 应被剔除(孤儿会让整条会话被拒):%s", out)
|
||||
}
|
||||
// 必须是合法 JSON,且 model/config_name 同值、stream 为 true
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||
t.Fatalf("转换结果不是合法 JSON: %v\n%s", err, out)
|
||||
}
|
||||
if parsed["model"] != "glm-5.2" || parsed["config_name"] != "glm-5.2" {
|
||||
t.Errorf("model/config_name 应同为 glm-5.2:%v / %v", parsed["model"], parsed["config_name"])
|
||||
}
|
||||
if parsed["stream"] != true {
|
||||
t.Errorf("stream 应为 true,实得 %v", parsed["stream"])
|
||||
}
|
||||
// tool_choice=none 时必须同时删掉 tools 与 tool_choice 本身
|
||||
noneIn := `{"model":"glm-5.2","messages":[{"role":"user","content":"x"}],` +
|
||||
`"tools":[{"type":"function","function":{"name":"f","parameters":{"type":"object"}}}],` +
|
||||
`"tool_choice":"none"}`
|
||||
noneOut, err := vm.Transform("channels-trae", "transform_request", noneIn)
|
||||
if err != nil {
|
||||
t.Fatalf("Transform(none): %v", err)
|
||||
}
|
||||
if strings.Contains(noneOut, `"tools"`) {
|
||||
t.Errorf("tool_choice=none 时 tools 必须被删:%s", noneOut)
|
||||
}
|
||||
if strings.Contains(noneOut, `"tool_choice"`) {
|
||||
t.Errorf("tool_choice=none 时该字段本身也应被删:%s", noneOut)
|
||||
}
|
||||
}
|
||||
@ -50,14 +50,12 @@ func pageIcons(t *testing.T, path string) []string {
|
||||
// 这是一份"必须被覆盖的清单",漏登记会让新插件逃避判据。
|
||||
var pagePlugins = []string{
|
||||
"plugins/billing.lua",
|
||||
"plugins/channels.lua",
|
||||
"plugins/quota.lua",
|
||||
}
|
||||
|
||||
// TestEveryPagePluginDeclaresSVGIcon 钉住"每个带页面的插件都有 inline SVG 图标"。
|
||||
//
|
||||
// 两个必查项,各自对应一种真实缺陷:
|
||||
// - 缺 icon / 空串 ⇒ 侧边栏空白(channels 的实测症状)
|
||||
// - 缺 icon / 空串 ⇒ 侧边栏空白(实测症状)
|
||||
// - emoji ⇒ 尺寸不跟原生 tab、忽略主题色(billing 的历史缺陷)
|
||||
func TestEveryPagePluginDeclaresSVGIcon(t *testing.T) {
|
||||
for _, p := range pagePlugins {
|
||||
|
||||
@ -3,7 +3,6 @@ package lua
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@ -159,18 +158,3 @@ func TestShardConfigSeedingDoesNotAliasSharedState(t *testing.T) {
|
||||
t.Fatalf("the shared configuration changed to %q after a shard read", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaProbesThroughTheFanoutPath is the reason the above matters. If the
|
||||
// quota page stopped probing through fanout, seeding shards would be dead code
|
||||
// and the asymmetry could return unnoticed.
|
||||
func TestQuotaProbesThroughTheFanoutPath(t *testing.T) {
|
||||
src := readShippedPlugin(t, "quota.lua")
|
||||
if !strings.Contains(src, "pluginAPI.fanout(") {
|
||||
t.Fatal("quota.lua no longer probes through the fanout (shard) path — the " +
|
||||
"page and the tests would then exercise different code, which is how " +
|
||||
"the missing shard configuration stayed hidden")
|
||||
}
|
||||
if !strings.Contains(src, "memo_record") {
|
||||
t.Fatal("quota.lua no longer records through memo_record")
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,662 +0,0 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The backoff lives in quota.lua — a shipped plugin, not Go — so these tests read
|
||||
// the file that actually ships (readShippedPlugin) rather than a copy that can
|
||||
// drift from it.
|
||||
|
||||
// TestQuotaBackoffIsAppliedInSource guards the wiring.
|
||||
//
|
||||
// It exists because of a mutation that removed the call site entirely (`if false
|
||||
// then`) and every other test in this file still passed. The other tests cover
|
||||
// the decision FUNCTION and the streak RECORDING; neither exists in order to be
|
||||
// unused. A backoff nobody consults is indistinguishable from no backoff, and the
|
||||
// symptom is the original one — a dead host re-probed on every page open.
|
||||
func TestQuotaBackoffIsAppliedInSource(t *testing.T) {
|
||||
src := readShippedPlugin(t, "quota.lua")
|
||||
if !strings.Contains(src, "if backoffSeconds(seen, known.streak) then") {
|
||||
t.Error("quota.lua never consults backoffSeconds for a host with a streak — " +
|
||||
"the backoff is computed but not applied")
|
||||
}
|
||||
// The reason is required, but matched on the FLAG rather than the wording:
|
||||
// the message is localised, and grouping by text would merge "backing off"
|
||||
// into "exposes no balance endpoint" the moment someone translates it.
|
||||
if strings.Count(src, "reason = \"连续探测失败 ") < 2 {
|
||||
t.Error("both skip paths (collectSource and due_sources) must carry a reason; " +
|
||||
"a silently skipped host looks exactly like a host with no balance endpoint")
|
||||
}
|
||||
if strings.Count(src, "backing_off = true") < 2 {
|
||||
t.Error("both skip paths must set the explicit backing_off flag; the page " +
|
||||
"groups skipped sources by it, not by the message text")
|
||||
}
|
||||
// The recording guard has to REPLACE the memo, not merge into it. The bug this
|
||||
// guards was `if rep == nil and not HOST_MEMO[host]`, which is true only on a
|
||||
// host's first failure — so the streak stayed pinned at 1 forever. Asserted on
|
||||
// the source because the mutation that reintroduces it leaves the visible
|
||||
// behaviour of the FIRST collect unchanged, and that is all the integration
|
||||
// test can reach inside one process.
|
||||
if !strings.Contains(src, "if rep == nil then") {
|
||||
t.Error("the negative result is recorded under a guard that skips every " +
|
||||
"failure after the first — the streak will never grow")
|
||||
}
|
||||
if strings.Contains(src, "and not HOST_MEMO[host] then") {
|
||||
t.Error("the recording guard excludes a host that already has a memo, so " +
|
||||
"consecutive failures are never counted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaBackoffWindowIsCapped guards the failure mode an exponential backoff
|
||||
// has when it is not capped: a host that is down for a week stops being probed
|
||||
// and the page permanently misreports it as having no balance endpoint.
|
||||
func TestQuotaBackoffWindowIsCapped(t *testing.T) {
|
||||
src := readShippedPlugin(t, "quota.lua")
|
||||
for _, want := range []string{"BACKOFF_BASE", "BACKOFF_FACTOR", "BACKOFF_MAX"} {
|
||||
if !strings.Contains(src, want) {
|
||||
t.Errorf("quota.lua no longer declares %s — the backoff was weakened or removed", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaBackoffWindows walks the whole policy through the plugin's own
|
||||
// decision function: for each consecutive failure, how long the host must be left
|
||||
// alone, that it is still skipped halfway through that window, that it is retried
|
||||
// once the window passes, and that an absurdly old host is always due.
|
||||
//
|
||||
// Why the function rather than the running plugin: HOST_MEMO is a process-local
|
||||
// cache, restored on a process's first collect and written back afterwards, so
|
||||
// ageing its memo from Go changes nothing the plugin can see. Two earlier versions
|
||||
// of this file spent a long time on exactly that dead end, and a test that keeps
|
||||
// breaking for reasons unrelated to the code under test is worse than no test.
|
||||
//
|
||||
// The function is the plugin's own — reached through a shim that appends one
|
||||
// export — so changing quota.lua's arithmetic moves this test. It is not a
|
||||
// reimplementation that would agree with a broken plugin.
|
||||
func TestQuotaBackoffWindows(t *testing.T) {
|
||||
ps := quotaWithDecision(t)
|
||||
|
||||
// streak, wait seconds. The last rows are at and past the ceiling.
|
||||
for _, c := range []struct {
|
||||
streak int
|
||||
wait int
|
||||
}{
|
||||
{1, 1800},
|
||||
{2, 3600},
|
||||
{3, 7200},
|
||||
{4, 14400},
|
||||
{5, 21600},
|
||||
{9, 21600},
|
||||
{20, 21600},
|
||||
} {
|
||||
if got := quotaSkip(t, ps, c.streak, c.wait/2); !got {
|
||||
t.Logf(" streak=%d age=%d → %v", c.streak, c.wait/2, got)
|
||||
t.Errorf("streak=%d: not skipping %ds into a %ds window — the wait is too short",
|
||||
c.streak, c.wait/2, c.wait)
|
||||
}
|
||||
if quotaSkip(t, ps, c.streak, c.wait+60) {
|
||||
t.Errorf("streak=%d: still skipping %ds after its %ds window expired — "+
|
||||
"this host would never be retried again", c.streak, c.wait+60, c.wait)
|
||||
}
|
||||
if quotaSkip(t, ps, c.streak, 1<<30) {
|
||||
t.Errorf("streak=%d: a host silent for decades is still being skipped", c.streak)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaBackoffReturnsOnlyBooleans pins a type rule that produced two silent
|
||||
// bugs.
|
||||
//
|
||||
// In Lua only nil and false are falsy, so every path of a predicate must return
|
||||
// one of those. This function had two paths returning numbers: `return 0` for
|
||||
// "no streak", which is truthy and made every caller skip a host that had never
|
||||
// been probed; and `return BACKOFF_MAX` on the cap path, also truthy, which made
|
||||
// the ceiling mean "never retried" — the exact failure the ceiling exists to
|
||||
// prevent.
|
||||
//
|
||||
// Both shipped far enough to be found by this assertion rather than by a user
|
||||
// staring at a page that had quietly stopped probing anything.
|
||||
func TestQuotaBackoffReturnsOnlyBooleans(t *testing.T) {
|
||||
ps := quotaWithDecision(t)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
age int
|
||||
strk interface{}
|
||||
}{
|
||||
{"no streak field (every memo written before the backoff existed)", 0, nil},
|
||||
{"zero streak", 0, 0},
|
||||
{"negative streak", 0, -1},
|
||||
{"inside the window", 10, 1},
|
||||
{"outside the window", 999999, 1},
|
||||
{"at the ceiling", 10, 20},
|
||||
} {
|
||||
out, err := ps.Call("quota", "__decision", map[string]interface{}{
|
||||
"at": float64(time.Now().Unix() - int64(c.age)),
|
||||
"streak": c.strk,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
// A bare boolean is a valid answer: when backoffSeconds returns false the
|
||||
// shim's table round-trips as just `false`, because a Lua table holding a
|
||||
// single falsy field collapses on encode.
|
||||
switch v := out.(type) {
|
||||
case bool:
|
||||
_ = v // a boolean is exactly what this asserts
|
||||
case map[string]interface{}:
|
||||
if _, ok := v["r"].(bool); !ok {
|
||||
t.Errorf("%s: backoffSeconds returned %T (%v) — in Lua a number is "+
|
||||
"TRUTHY, so this caller would skip the host", c.name, v["r"], v["r"])
|
||||
}
|
||||
default:
|
||||
t.Errorf("%s: decision returned %T (%v); want a boolean", c.name, out, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaBackoffRecordsConsecutiveFailures covers the recording guard through
|
||||
// the running plugin, because that part IS observable from outside.
|
||||
//
|
||||
// The guard used to be `not HOST_MEMO[host]`, true only on a host's first
|
||||
// failure. After that the memo held a table, the guard was false, nothing was
|
||||
// written, and the streak stayed pinned at 1 forever — measured by reading back
|
||||
// {at:... streak:1} after four consecutive failures.
|
||||
//
|
||||
// Only two things are asserted, because only two are reachable in one process:
|
||||
// a first failure records streak 1, and a SKIP does not grow it. A skipped probe
|
||||
// is not a failure, and a skip that counted would let a never-probed host grow
|
||||
// without end and never be retried.
|
||||
func TestQuotaBackoffRecordsConsecutiveFailures(t *testing.T) {
|
||||
srv := newCountingServer(t)
|
||||
ps := newQuotaPlugins(t, srv.URL)
|
||||
|
||||
// Configure every vendor that needs its own credentials, so no adapter is
|
||||
// UNABLE to judge the host. That is a precondition, not decoration: a host
|
||||
// judged while an adapter is unconfigured gets a PROVISIONAL memo, and a
|
||||
// provisional memo deliberately never backs off (it would refuse the very
|
||||
// probe that succeeds once the credential arrives). Without this the test
|
||||
// would be measuring that rule instead of the ordinary backoff.
|
||||
if err := ps.SetState("quota", map[string]interface{}{
|
||||
"prices": map[string]interface{}{"control_key": "ctrl"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
args := map[string]interface{}{
|
||||
"sources": []interface{}{map[string]interface{}{
|
||||
"name": "dead", "base_url": srv.URL + "/v1", "api_key": "k",
|
||||
}},
|
||||
"name": "dead",
|
||||
}
|
||||
streak := func() float64 {
|
||||
st, _ := ps.State("quota").(map[string]interface{})
|
||||
memo, _ := st["probe_memo"].(map[string]interface{})
|
||||
for _, v := range memo {
|
||||
if e, ok := v.(map[string]interface{}); ok {
|
||||
if f, ok := e["streak"].(float64); ok {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("no negative memo recorded: %#v", memo)
|
||||
return 0
|
||||
}
|
||||
|
||||
if _, err := ps.Call("quota", "collect_source", args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := streak(); got != 1 {
|
||||
t.Fatalf("streak after the first failure = %.0f, want 1", got)
|
||||
}
|
||||
|
||||
before := srv.Hits()
|
||||
if _, err := ps.Call("quota", "collect_source", args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if served := srv.Hits() - before; served != 0 {
|
||||
t.Errorf("a host inside its backoff window still made %d requests — the "+
|
||||
"whole feature is to not make any", served)
|
||||
}
|
||||
if got := streak(); got != 1 {
|
||||
t.Errorf("a SKIP changed the streak to %.0f — a skipped probe is not a failure", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaMemoKeyIsTheHost pins the key the memo is written and read under.
|
||||
//
|
||||
// This is the foundation the whole memoisation rests on, and it was broken in the
|
||||
// most total way possible: hostOf was written with string.gmatch and a capture
|
||||
// group, gmatch yields whole matches rather than captures, and with `^` the
|
||||
// iterator never advanced — so it returned nil and every lookup and every write
|
||||
// went to HOST_MEMO[nil]. The memo therefore never returned anything for any
|
||||
// host, and the negative memo, the TTL and the backoff on top of them were all
|
||||
// dead code that looked alive.
|
||||
//
|
||||
// Measured on this gateway before the fix: hostOf(key) matched 0 of the 19
|
||||
// entries in production state.
|
||||
//
|
||||
// The check is on the shipped source plus its runtime behaviour, because the
|
||||
// failure mode is invisible from outside — a memo that never matches anything
|
||||
// behaves exactly like a memo that has nothing to remember.
|
||||
func TestQuotaMemoKeyIsTheHost(t *testing.T) {
|
||||
src := readShippedPlugin(t, "quota.lua")
|
||||
if !strings.Contains(src, "string.match(u,") {
|
||||
t.Error("hostOf does not use string.match — string.gmatch yields whole " +
|
||||
"matches, so the capture group is ignored and the function returns nil")
|
||||
}
|
||||
|
||||
ps := quotaWithDecision(t)
|
||||
for _, c := range []struct{ in, want string }{
|
||||
{"https://qijiar.top/v1", "qijiar.top"},
|
||||
{"https://api.deepseek.com", "api.deepseek.com"},
|
||||
{"http://127.0.0.1:19900", "127.0.0.1:19900"},
|
||||
{"https://s2a.frankhan.top:14514/v1", "s2a.frankhan.top:14514"},
|
||||
} {
|
||||
out, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": c.in})
|
||||
if err != nil {
|
||||
t.Fatalf("hostOf(%s): %v", c.in, err)
|
||||
}
|
||||
mm, _ := out.(map[string]interface{})
|
||||
got, _ := mm["h"].(string)
|
||||
if got != c.want {
|
||||
t.Errorf("hostOf(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
if got == "" {
|
||||
t.Errorf("hostOf(%q) returned nothing — every lookup would be "+
|
||||
"HOST_MEMO[nil]", c.in)
|
||||
}
|
||||
}
|
||||
|
||||
// The same host under two paths must produce the SAME key, which is the whole
|
||||
// reason the memo is keyed this way.
|
||||
out, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": "https://x.example/a/v1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out2, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": "https://x.example/b/v1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m1, _ := out.(map[string]interface{})
|
||||
m2, _ := out2.(map[string]interface{})
|
||||
if m1["h"] != m2["h"] {
|
||||
t.Errorf("two paths on one host produced different keys: %v and %v — "+
|
||||
"they would never share a memo", m1["h"], m2["h"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
// readShippedPlugin returns the bundled source, so the tests exercise the file
|
||||
// that actually ships rather than a copy that can drift from it.
|
||||
func readShippedPlugin(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
src, err := ReadBundledPlugin(strings.TrimSuffix(name, ".lua"))
|
||||
if err != nil {
|
||||
t.Fatalf("read repo plugin %s: %v", name, err)
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// quotaWithDecision loads quota.lua with its local backoffSeconds exposed as an
|
||||
// export. The plugin's own exports list is appended to, so the export allowlist is
|
||||
// satisfied exactly as it is in production.
|
||||
func quotaWithDecision(t *testing.T) *Plugins {
|
||||
t.Helper()
|
||||
src := readShippedPlugin(t, "quota.lua")
|
||||
shim := strings.Replace(src, "return PLUGIN",
|
||||
"PLUGIN.exports[#PLUGIN.exports+1] = '__decision'\n"+
|
||||
// Wrapped, not exported raw: backoffSeconds takes (at, streak)
|
||||
// positionally, and the test calls it with a table because that is the
|
||||
// shape a Go caller can pass. Exporting the raw function made every
|
||||
// assertion read `at = <the argument table>, streak = nil`, so the
|
||||
// function took its "no streak" path and returned false for every input
|
||||
// — which looked exactly like a backoff that never fires.
|
||||
"PLUGIN.__decision = function(q) return { r = backoffSeconds(q.at, q.streak) } end\n"+
|
||||
"PLUGIN.exports[#PLUGIN.exports+1] = '__hostof'\n"+
|
||||
"PLUGIN.__hostof = function(q) return { h = hostOf(q.u) } end\n"+
|
||||
"return PLUGIN", 1)
|
||||
if shim == src {
|
||||
t.Fatal("could not inject the decision shim — quota.lua no longer ends with 'return PLUGIN'")
|
||||
}
|
||||
return newQuotaPluginsWith(t, shim, "127.0.0.1")
|
||||
}
|
||||
|
||||
// quotaSkip asks the plugin whether a host that failed `streak` times and was
|
||||
// last heard from `age` seconds ago should be skipped.
|
||||
func quotaSkip(t *testing.T, ps *Plugins, streak, age int) bool {
|
||||
out, err := ps.Call("quota", "__decision", map[string]interface{}{
|
||||
"at": float64(time.Now().Unix() - int64(age)),
|
||||
"streak": float64(streak),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("decision: %v", err)
|
||||
}
|
||||
// A single-field table collapses to the bare value on encode, so both shapes
|
||||
// mean the same thing here.
|
||||
switch v := out.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case map[string]interface{}:
|
||||
b, ok := v["r"].(bool)
|
||||
if !ok {
|
||||
t.Fatalf("decision returned %T, not a boolean: %#v", v["r"], v)
|
||||
}
|
||||
return b
|
||||
default:
|
||||
t.Fatalf("decision returned %T (%v), not a boolean", out, out)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// newQuotaPlugins boots the shipped quota.lua against a temp plugin dir.
|
||||
func newQuotaPlugins(t *testing.T, baseURL string) *Plugins {
|
||||
t.Helper()
|
||||
return newQuotaPluginsWith(t, readShippedPlugin(t, "quota.lua"), baseURL)
|
||||
}
|
||||
|
||||
func newQuotaPluginsWith(t *testing.T, src, baseURL string) *Plugins {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(filepath.Join(dir, "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
ps := NewPlugins(vm, pluginDir)
|
||||
t.Cleanup(ps.Close)
|
||||
ps.SetOutboundHosts([]string{"127.0.0.1"})
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "quota.lua"), []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := ps.find("quota")
|
||||
if p == nil {
|
||||
t.Fatal("quota plugin did not load")
|
||||
}
|
||||
if p.LoadError != "" {
|
||||
t.Fatalf("quota plugin failed to compile: %s", p.LoadError)
|
||||
}
|
||||
_ = baseURL
|
||||
return ps
|
||||
}
|
||||
|
||||
// countingServer answers 404 on every path and counts what it is asked, so a test
|
||||
// can tell "the plugin skipped" (no requests) from "the plugin probed and found
|
||||
// nothing" (requests, no data).
|
||||
//
|
||||
// It used to answer 200 with a non-balance body, and the commandcode adapter
|
||||
// accepted that — so the host was memoised as a SUCCESS and the negative path was
|
||||
// never reached. The test then failed for the right reason, and would have
|
||||
// passed for the wrong one had the assertion been looser.
|
||||
type countingServer struct {
|
||||
*httptest.Server
|
||||
hits int
|
||||
}
|
||||
|
||||
func (c *countingServer) Hits() int { return c.hits }
|
||||
|
||||
func newCountingServer(t *testing.T) *countingServer {
|
||||
t.Helper()
|
||||
cs := &countingServer{}
|
||||
cs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cs.hits++
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":"not found"}`))
|
||||
}))
|
||||
t.Cleanup(cs.Close)
|
||||
return cs
|
||||
}
|
||||
|
||||
// TestQuotaDueSourcesAndMemoRecordCoverTheShardGap pins the split that makes the
|
||||
// backoff work at all under sharding.
|
||||
//
|
||||
// The conflict, measured on a live 20-source gateway:
|
||||
// - probing is network-bound and Plugins.Call holds the plugin mutex for the
|
||||
// whole Lua call, so /call/ serialises (29s)
|
||||
// - so the page uses ShardCall — and a shard has its own PLUGIN.state
|
||||
// - and the backoff lives in PLUGIN.state: fanout left the memo at 0 entries,
|
||||
// so dead hosts were re-probed forever
|
||||
//
|
||||
// The resolution is to put the DECISION and the RECORDING back on the shared VM
|
||||
// (microseconds, no network) and shard only the network wait. These two exports
|
||||
// are that seam, and without them the fanout path silently defeats the backoff.
|
||||
func TestQuotaDueSourcesAndMemoRecordCoverTheShardGap(t *testing.T) {
|
||||
src := readShippedPlugin(t, "quota.lua")
|
||||
for _, want := range []string{"due_sources", "memo_record"} {
|
||||
if !strings.Contains(src, want) {
|
||||
t.Errorf("quota.lua does not export %s — a sharded pass cannot see the "+
|
||||
"backoff state, nor leave any behind", want)
|
||||
}
|
||||
if !strings.Contains(src, `"`+want+`"`) {
|
||||
t.Errorf("%s is not listed in PLUGIN.exports, so the page cannot call it", want)
|
||||
}
|
||||
}
|
||||
// due_sources must not probe. If it did, the whole point of asking it first
|
||||
// would be lost: the cost would be paid before any shard runs.
|
||||
if strings.Contains(src, "due_sources = function(payload)") &&
|
||||
strings.Contains(src, "due_sources") {
|
||||
// The body is small; check it does not call collectSource.
|
||||
i := strings.Index(src, "due_sources = function(payload)")
|
||||
j := strings.Index(src[i:], "\nend\n")
|
||||
if j > 0 {
|
||||
body := src[i : i+j]
|
||||
if strings.Contains(body, "collectSource(") {
|
||||
t.Error("due_sources calls collectSource — the filter must make no " +
|
||||
"network request, or it costs the same as probing everything")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaMemoRecordGrowsTheStreakFromShards covers the recording half: a host
|
||||
// that failed inside a shard must end up in the SHARED memo with a streak, or the
|
||||
// next refresh has nothing to back off from.
|
||||
func TestQuotaMemoRecordGrowsTheStreakFromShards(t *testing.T) {
|
||||
srv := newCountingServer(t)
|
||||
ps := newQuotaPlugins(t, srv.URL)
|
||||
host := hostOnly(srv.URL)
|
||||
|
||||
read := func() map[string]interface{} {
|
||||
st, _ := ps.State("quota").(map[string]interface{})
|
||||
memo, _ := st["probe_memo"].(map[string]interface{})
|
||||
return memo
|
||||
}
|
||||
|
||||
// A shard reports a host that answered with nothing.
|
||||
for i := 1; i <= 3; i++ {
|
||||
out, err := ps.Call("quota", "memo_record", map[string]interface{}{
|
||||
"results": []interface{}{
|
||||
map[string]interface{}{"host": srv.URL + "/v1"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("memo_record %d: %v", i, err)
|
||||
}
|
||||
if out != true {
|
||||
t.Fatalf("memo_record returned %#v", out)
|
||||
}
|
||||
}
|
||||
memo := read()
|
||||
e, ok := memo[host].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("memo has no entry for %s: %#v", host, memo)
|
||||
}
|
||||
if got, _ := e["streak"].(float64); got != 3 {
|
||||
t.Errorf("streak after three shard failures = %.0f, want 3", got)
|
||||
}
|
||||
|
||||
// A host that answers is recorded as a positive memo, which resets the streak.
|
||||
if _, err := ps.Call("quota", "memo_record", map[string]interface{}{
|
||||
"results": []interface{}{
|
||||
map[string]interface{}{"host": srv.URL + "/v1", "vendor": "deepseek"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := read()[host]; got != "deepseek" {
|
||||
t.Errorf("a host that answered is memoised as %#v, want the vendor name — "+
|
||||
"the streak must not survive a success", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaDueSourcesSkipsBackedOffHosts is the behaviour the page depends on:
|
||||
// the filter must keep a backed-off host out of the sharded set.
|
||||
func TestQuotaDueSourcesSkipsBackedOffHosts(t *testing.T) {
|
||||
srv := newCountingServer(t)
|
||||
ps := newQuotaPlugins(t, srv.URL)
|
||||
host := hostOnly(srv.URL)
|
||||
|
||||
// One failure, which is enough to put the host inside its window.
|
||||
if _, err := ps.Call("quota", "memo_record", map[string]interface{}{
|
||||
"results": []interface{}{map[string]interface{}{"host": srv.URL + "/v1"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A SECOND host, so the two cases are genuinely independent. The memo is keyed
|
||||
// by host, so two paths on one host are one entry — using the same server here
|
||||
// would have asserted that a per-host memo behaves per-path.
|
||||
other := newCountingServer(t)
|
||||
srcs := []interface{}{
|
||||
map[string]interface{}{"name": "dead", "base_url": srv.URL + "/v1", "api_key": "k"},
|
||||
map[string]interface{}{"name": "fresh", "base_url": other.URL + "/v1", "api_key": "k"},
|
||||
}
|
||||
out, err := ps.Call("quota", "due_sources", map[string]interface{}{"sources": srcs})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("due_sources returned %#v", out)
|
||||
}
|
||||
due, _ := m["due"].([]interface{})
|
||||
backing, _ := m["backing_off"].([]interface{})
|
||||
if len(backing) != 1 {
|
||||
t.Errorf("backing_off = %#v, want exactly the host that failed", backing)
|
||||
} else if b, _ := backing[0].(map[string]interface{}); b["host"] != host {
|
||||
t.Errorf("backing off %v, want %s — the key must be the host, or it will "+
|
||||
"never match on the next refresh", b["host"], host)
|
||||
}
|
||||
if len(due) != 1 {
|
||||
t.Errorf("due = %#v, want only the unprobed host", due)
|
||||
} else if d, _ := due[0].(map[string]interface{}); d["name"] != "fresh" {
|
||||
t.Errorf("due contains %v, want fresh", d["name"])
|
||||
}
|
||||
|
||||
// And it must make no network request — the whole saving depends on it.
|
||||
if n := srv.Hits(); n != 0 {
|
||||
t.Errorf("due_sources made %d requests; it must decide without probing", n)
|
||||
}
|
||||
}
|
||||
|
||||
// hostOnly mirrors the plugin's own hostOf. Used only to CHECK what the plugin
|
||||
// wrote — seeding a memo under a different key produces a memo the plugin never
|
||||
// looks up, which cost this file a couple of rounds before it was noticed.
|
||||
func hostOnly(baseURL string) string {
|
||||
u := strings.TrimPrefix(strings.TrimPrefix(baseURL, "https://"), "http://")
|
||||
if i := strings.Index(u, "/"); i >= 0 {
|
||||
u = u[:i]
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// TestQuotaBackoffRecordIsSkippedAndFlagged pins the shape the page depends on.
|
||||
//
|
||||
// Two things went wrong in the same field, and both were silent:
|
||||
//
|
||||
// 1. The back-off record had no `skipped` flag, so the page's
|
||||
// `filter(r => !r.skipped)` let it through to the card renderer. That
|
||||
// renderer matches on known fields, and a record carrying only a reason
|
||||
// matches none — so it fell through to a default that said "有数据".
|
||||
// Thirteen dead upstreams were rendered as though they had answered.
|
||||
// 2. Once it did carry `skipped`, the page grouped skipped sources by reading
|
||||
// the REASON TEXT. Translating that string would have silently merged
|
||||
// "backing off" into "exposes no balance endpoint" — a different claim
|
||||
// about the upstream. The grouping keys on an explicit flag.
|
||||
func TestQuotaBackoffRecordIsSkippedAndFlagged(t *testing.T) {
|
||||
srv := newCountingServer(t)
|
||||
ps := newQuotaPlugins(t, srv.URL)
|
||||
|
||||
// One failure, enough to put the host inside its window.
|
||||
if _, err := ps.Call("quota", "memo_record", map[string]interface{}{
|
||||
"results": []interface{}{map[string]interface{}{"host": srv.URL + "/v1"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := ps.Call("quota", "due_sources", map[string]interface{}{
|
||||
"sources": []interface{}{map[string]interface{}{
|
||||
"name": "dead", "base_url": srv.URL + "/v1", "api_key": "k",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _ := out.(map[string]interface{})
|
||||
backing, _ := m["backing_off"].([]interface{})
|
||||
if len(backing) != 1 {
|
||||
t.Fatalf("backing_off = %#v, want one record", m["backing_off"])
|
||||
}
|
||||
rec, _ := backing[0].(map[string]interface{})
|
||||
if rec["skipped"] != true {
|
||||
t.Errorf("back-off record has skipped=%v — the page filters cards on this "+
|
||||
"field, and without it the record is rendered as a source with data",
|
||||
rec["skipped"])
|
||||
}
|
||||
if rec["backing_off"] != true {
|
||||
t.Errorf("back-off record has no explicit backing_off flag; the page groups " +
|
||||
"by it instead of by reason text, so translating the message cannot " +
|
||||
"merge this with 'no balance endpoint'")
|
||||
}
|
||||
if s, _ := rec["reason"].(string); s == "" {
|
||||
t.Error("back-off record carries no reason; the card renders empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaRenderCallSitesPassRecordsNotNames guards a bug that only appeared
|
||||
// because ONE of two identical call sites was fixed.
|
||||
//
|
||||
// render() groups skipped sources by the backing_off flag. A call site that maps
|
||||
// the records to their names first ("skipped.map(r => r.name)") therefore hands
|
||||
// render an array of strings: the flag is gone, every entry falls into the
|
||||
// "exposes no balance endpoint" bucket, and the footer lists N unnamed sources.
|
||||
//
|
||||
// The first fix changed only the refresh path. The snapshot path kept mapping to
|
||||
// names, and the symptom was a footer reading "未列出 13 个源:、、、、…" — which
|
||||
// reads like a rendering bug rather than a contract mismatch between two call
|
||||
// sites of one function.
|
||||
func TestQuotaRenderCallSitesPassRecordsNotNames(t *testing.T) {
|
||||
src := readShippedPlugin(t, "quota.lua")
|
||||
if strings.Contains(src, ".map(function(r){ return r.name; })") ||
|
||||
strings.Contains(src, ".map(function(r){return r.name;})") {
|
||||
// A benign occurrence would be fine, but there is no other reason for a
|
||||
// skipped list to be reduced to names, so treat any as the bug.
|
||||
for _, line := range strings.Split(src, "\n") {
|
||||
if strings.Contains(line, "skipped") && strings.Contains(line, ".map(") &&
|
||||
strings.Contains(line, "r.name") {
|
||||
t.Errorf("a skipped list is mapped to names before reaching render(): %s\n"+
|
||||
"render() groups on the backing_off flag, which a name cannot carry",
|
||||
strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Both call sites must exist and both must hand over records.
|
||||
if n := strings.Count(src, ".filter(function(r){return r.skipped;})") +
|
||||
strings.Count(src, ".filter(function(r){ return r && r.skipped; })"); n < 2 {
|
||||
t.Errorf("found %d skipped-list filters, want 2 (snapshot and refresh paths)", n)
|
||||
}
|
||||
}
|
||||
@ -1,650 +0,0 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The OpenDesign / Vela adapter is the first quota probe that needs a credential
|
||||
// the source itself does not carry. Inference runs on the source's `api_key`
|
||||
// (the runtime key); the two billing endpoints on amr-api.open-design.ai only
|
||||
// accept the `controlKey` the desktop app receives at sign-in.
|
||||
//
|
||||
// That asymmetry is why these tests exist: the obvious wrong implementation —
|
||||
// "reuse the source's api_key like every other adapter" — returns 401 against
|
||||
// the live service, and a test that only stubs a happy response would never
|
||||
// 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
|
||||
const (
|
||||
odWallet = `{"balanceUsd":"15.7062","expiresAt":"2026-10-08T23:01:06.662Z",` +
|
||||
`"updatedAt":"2026-10-06T08:02:20.167Z","billingScopeVersion":2,` +
|
||||
`"workspaceId":"h7cvgf86l79yy3cpmsbmjfxu",` +
|
||||
`"workspaceMemberId":"uxqzu1rrtwsmzfpwugkcuany"}`
|
||||
|
||||
odSummary = `{"balanceUsd":"15.7062","creditsPerUsd":10000,` +
|
||||
`"balances":{"subscriptionCredits":"152062","rechargeCredits":"5000",` +
|
||||
`"totalAvailableCredits":"157062"},"subscriptionCreditsUsd":"15.2062",` +
|
||||
`"rechargeCreditsUsd":"0.5000","totalAvailableCreditsUsd":"15.7062",` +
|
||||
`"membershipTier":"plus","billingInterval":"monthly",` +
|
||||
`"subscriptionStatus":"active","subscriptionEntitlementStatus":"active",` +
|
||||
`"subscriptionCancelAtPeriodEnd":false,` +
|
||||
`"subscriptionCurrentPeriodStart":"2026-10-05T23:01:54.000Z",` +
|
||||
`"subscriptionCurrentPeriodEnd":"2026-11-05T23:01:54.000Z",` +
|
||||
`"pendingSubscriptionChange":null,"firstMonthIntroEligible":false,` +
|
||||
`"availableActions":["subscription_checkout","billing_portal"],` +
|
||||
`"totalRechargedUsd":"0.5000","totalConsumedUsd":"4.7938",` +
|
||||
`"todayConsumedUsd":"0.0036","rechargeCount":1,"usageCount":2982,` +
|
||||
`"updatedAt":"2026-10-06T14:55:00.407Z"}`
|
||||
)
|
||||
|
||||
// odServer stands in for amr-api. It records the Authorization header it saw on
|
||||
// the two BILLING routes so a test can assert WHICH credential the plugin sent
|
||||
// — the whole point of this adapter.
|
||||
//
|
||||
// Only the billing paths are recorded. The other three adapters derive their
|
||||
// paths from the source's base_url, not from api_base, so in these tests they
|
||||
// are pointed at amr-link and refused by the outbound allowlist before they can
|
||||
// reach this server. Recording every request would make the "did it probe at
|
||||
// all?" assertion depend on that coincidence; recording the billing routes makes
|
||||
// it mean exactly what it says.
|
||||
func odServer(t *testing.T, seenAuth *[]string, wallet, summary string, wStatus, sStatus int) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/wallet/balance"):
|
||||
if seenAuth != nil {
|
||||
*seenAuth = append(*seenAuth, r.Header.Get("Authorization"))
|
||||
}
|
||||
w.WriteHeader(wStatus)
|
||||
_, _ = w.Write([]byte(wallet))
|
||||
case strings.HasSuffix(r.URL.Path, "/billing/summary"):
|
||||
if seenAuth != nil {
|
||||
*seenAuth = append(*seenAuth, r.Header.Get("Authorization"))
|
||||
}
|
||||
w.WriteHeader(sStatus)
|
||||
_, _ = w.Write([]byte(summary))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":"not found"}`))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// odPlugins boots a real VM with the shipped quota.lua (not a copy) and returns
|
||||
// the loaded Plugins.
|
||||
// odPlugins boots the shipped quota.lua against a temp plugin dir.
|
||||
//
|
||||
// NOTE on what these tests can and cannot reach: the backoff window is read from
|
||||
// the WALL CLOCK, so nothing here observes recovery of an aged negative memo —
|
||||
// reaching that branch would mean sleeping 1800s. The aged-entry path is covered
|
||||
// out of band, by seeding a negative memo with an old `at` (`.probe/trace_memo.py`,
|
||||
// step 10) and watching the same code recover. What IS asserted here is the entry's
|
||||
// SHAPE, which is what the read side keys its decisions on.
|
||||
func odPlugins(t *testing.T) (*Plugins, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(filepath.Join(dir, "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
|
||||
src := readShippedPlugin(t, "quota.lua")
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "quota.lua"), []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := NewPlugins(vm, pluginDir)
|
||||
t.Cleanup(ps.Close)
|
||||
// Set before LoadDir: the allowlist is consulted when the plugin makes a
|
||||
// request, and the empty default denies everything.
|
||||
ps.SetOutboundHosts([]string{"127.0.0.1"})
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := ps.find("quota")
|
||||
if p == nil {
|
||||
t.Fatal("quota plugin not found")
|
||||
}
|
||||
if p.LoadError != "" {
|
||||
t.Fatalf("quota.lua failed to compile: %s", p.LoadError)
|
||||
}
|
||||
return ps, src
|
||||
}
|
||||
|
||||
// collectOpenDesign drives the plugin's own collect_source for a source that
|
||||
// looks like vela. The plugin iterates ADAPTERS in order and picks the host's
|
||||
// remembered vendor, so the first adapter that answers is the one under test.
|
||||
func collectOpenDesign(t *testing.T, ps *Plugins, base string) map[string]interface{} {
|
||||
t.Helper()
|
||||
out, err := ps.Call("quota", "collect_source", map[string]interface{}{
|
||||
"name": "vela",
|
||||
"sources": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "vela",
|
||||
"base_url": base,
|
||||
"api_key": "sk-amr-runtime-key-must-not-be-sent-to-billing",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("collect_source failed: %v", err)
|
||||
}
|
||||
rep, _ := out.(map[string]interface{})
|
||||
return rep
|
||||
}
|
||||
|
||||
// TestQuotaOpenDesignUsesControlKeyNotRuntimeKey is the reason this adapter
|
||||
// cannot be written like the others.
|
||||
//
|
||||
// Mutation: replace `request(api .. "/wallet/balance", ck, ...)` with the
|
||||
// function's `key` parameter (`request(api .. "/wallet/balance", key, ...)`)
|
||||
// and this fails — the server records the runtime key.
|
||||
func TestQuotaOpenDesignUsesControlKeyNotRuntimeKey(t *testing.T) {
|
||||
var seenAuth []string
|
||||
srv := odServer(t, &seenAuth, odWallet, odSummary, 200, 200)
|
||||
defer srv.Close()
|
||||
|
||||
ps, _ := odPlugins(t)
|
||||
// The plugin's config table is `prices`; SetState routes a `prices` key
|
||||
// there rather than into accumulated state.
|
||||
if err := ps.SetState("quota", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"control_key": "vela_ctrl_TESTKEY",
|
||||
"api_base": srv.URL + "/api/v1",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SetState: %v", err)
|
||||
}
|
||||
|
||||
rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1")
|
||||
if rep == nil {
|
||||
t.Fatal("no report produced")
|
||||
}
|
||||
if rep["vendor"] != "opendesign" {
|
||||
t.Fatalf("wrong adapter answered: vendor=%v (report=%v)", rep["vendor"], rep)
|
||||
}
|
||||
for _, got := range seenAuth {
|
||||
if strings.Contains(got, "runtime-key") {
|
||||
t.Fatalf("the source's api_key was sent to the billing endpoint (%q); "+
|
||||
"amr-api rejects it with 401 — the control key is a separate credential", got)
|
||||
}
|
||||
if got != "Bearer vela_ctrl_TESTKEY" {
|
||||
t.Fatalf("billing request carried %q, want the configured control key", got)
|
||||
}
|
||||
}
|
||||
if len(seenAuth) == 0 {
|
||||
t.Fatal("no request reached the server: the adapter never probed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaOpenDesignSkipsWithoutControlKey pins the failure mode that made the
|
||||
// previous incarnation of this probe useless: with no credential the adapter
|
||||
// must SKIP (not error, not report a zero balance). An error would put the
|
||||
// source in the negative memo and file it under "probe failed"; a zero balance
|
||||
// would tell the operator the account is empty.
|
||||
//
|
||||
// This test originally left api_base unset, which sent the probe to the REAL
|
||||
// amr-api host. Every path then failed on the network and returned nil, so the
|
||||
// test passed for the wrong reason — and a mutation that deleted the credential
|
||||
// guard survived. Pointing api_base at a local stub makes the server reachable,
|
||||
// so the only thing that can stop the request is the guard itself.
|
||||
func TestQuotaOpenDesignSkipsWithoutControlKey(t *testing.T) {
|
||||
var seenAuth []string
|
||||
srv := odServer(t, &seenAuth, odWallet, odSummary, 200, 200)
|
||||
defer srv.Close()
|
||||
|
||||
ps, _ := odPlugins(t)
|
||||
// api_base set, control_key deliberately NOT set.
|
||||
if err := ps.SetState("quota", map[string]interface{}{
|
||||
"prices": map[string]interface{}{"api_base": srv.URL + "/api/v1"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1")
|
||||
if rep == nil {
|
||||
t.Fatal("no report produced")
|
||||
}
|
||||
// The guard must come BEFORE the network. A reachable server that is never
|
||||
// asked anything is the only evidence that the credential check ran at all.
|
||||
if len(seenAuth) != 0 {
|
||||
t.Fatalf("the adapter probed the billing host with no control key configured "+
|
||||
"(Authorization: %q); an empty bearer is not a credential and the "+
|
||||
"response cannot be trusted", seenAuth)
|
||||
}
|
||||
if rep["skipped"] != true {
|
||||
t.Fatalf("skipped = %#v, want true", rep["skipped"])
|
||||
}
|
||||
reason, _ := rep["reason"].(string)
|
||||
if !strings.Contains(reason, "control_key") {
|
||||
t.Fatalf("reason = %q, want it to name the missing control_key so the "+
|
||||
"operator knows what to go and configure", reason)
|
||||
}
|
||||
// Whatever adapter answers, it must not be a fabricated opendesign balance.
|
||||
if rep["vendor"] == "opendesign" {
|
||||
t.Fatalf("opendesign reported a balance with no control key configured: %v", rep)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaOpenDesignReportsBalanceAndSpend checks the fields the operator
|
||||
// actually reads. The vendor sends these as JSON STRINGS ("15.7062"), so a
|
||||
// naive type assertion to number would drop every one of them.
|
||||
func TestQuotaOpenDesignReportsBalanceAndSpend(t *testing.T) {
|
||||
srv := odServer(t, nil, odWallet, odSummary, 200, 200)
|
||||
defer srv.Close()
|
||||
|
||||
ps, _ := odPlugins(t)
|
||||
if err := ps.SetState("quota", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"control_key": "vela_ctrl_TESTKEY",
|
||||
"api_base": srv.URL + "/api/v1",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1")
|
||||
if rep == nil || rep["vendor"] != "opendesign" {
|
||||
t.Fatalf("expected an opendesign report, got %v", rep)
|
||||
}
|
||||
|
||||
want := map[string]float64{
|
||||
"balance": 15.7062,
|
||||
"subscription_usd": 15.2062,
|
||||
"recharge_usd": 0.5000,
|
||||
"today_used": 0.0036,
|
||||
"period_used": 4.7938,
|
||||
}
|
||||
for k, exp := range want {
|
||||
got, ok := rep[k].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("%s missing or not a number: %#v (full report %v)", k, rep[k], rep)
|
||||
}
|
||||
if got != exp {
|
||||
t.Fatalf("%s = %v, want %v", k, got, exp)
|
||||
}
|
||||
}
|
||||
if rep["credits_per_usd"] != float64(10000) {
|
||||
t.Fatalf("credits_per_usd = %v, want 10000 (used for the credit split)", rep["credits_per_usd"])
|
||||
}
|
||||
if rep["tier"] != "plus" || rep["status"] != "active" {
|
||||
t.Fatalf("tier/status = %v/%v, want plus/active", rep["tier"], rep["status"])
|
||||
}
|
||||
if rep["balance_expires_at"] != "2026-10-08T23:01:06.662Z" {
|
||||
t.Fatalf("balance_expires_at = %v — a balance that expires silently is "+
|
||||
"worse than one that warns", rep["balance_expires_at"])
|
||||
}
|
||||
// usageCount arrives as a JSON number, not a string, and must survive too.
|
||||
if rep["usage_count"] != float64(2982) {
|
||||
t.Fatalf("usage_count = %#v, want 2982", rep["usage_count"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaOpenDesignHasNoFabricatedPeriodWindow pins the deliberately-absent
|
||||
// field. `vela billing workspace-snapshot` answers billingState:"free" with NO
|
||||
// codingPlan.windows for this account, so there is no period allowance to show.
|
||||
//
|
||||
// Mutation: add `rep.windows = {{...}}` (or any synthesized window) and this
|
||||
// fails. The page renders windows as "X% remaining", so a fabricated one is a
|
||||
// confident lie about the account.
|
||||
func TestQuotaOpenDesignHasNoFabricatedPeriodWindow(t *testing.T) {
|
||||
srv := odServer(t, nil, odWallet, odSummary, 200, 200)
|
||||
defer srv.Close()
|
||||
|
||||
ps, _ := odPlugins(t)
|
||||
if err := ps.SetState("quota", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"control_key": "vela_ctrl_TESTKEY",
|
||||
"api_base": srv.URL + "/api/v1",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1")
|
||||
if rep == nil || rep["vendor"] != "opendesign" {
|
||||
t.Fatalf("expected an opendesign report, got %v", rep)
|
||||
}
|
||||
if w, ok := rep["windows"]; ok && w != nil {
|
||||
t.Fatalf("windows = %#v, but this account has no Coding Plan "+
|
||||
"(billingState=free) — a synthesized window renders as a "+
|
||||
"percentage remaining that does not exist", w)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaOpenDesignRejectedControlKeyIsAnError guards the diagnosis. A 401
|
||||
// means the credential is wrong — a fact the operator must fix — so it must not
|
||||
// be filed under "skip" alongside "this vendor has no balance endpoint". The two
|
||||
// produce the same blank card but wildly different next actions.
|
||||
//
|
||||
// Two details make the test actually exercise the rule instead of passing by
|
||||
// accident, and both were found by a mutation surviving:
|
||||
//
|
||||
// 1. The memo is pre-seeded so openDesign runs FIRST. The memo remembers which
|
||||
// vendor answered last and that vendor is tried first; once a host has ever
|
||||
// answered through openDesign it runs first forever after. With the default
|
||||
// order (openDesign last) its 401 IS the last verdict, so even a plain
|
||||
// "last error wins" rule passes.
|
||||
// 2. The other three adapters must ANSWER (404), not fail. They read `base`, not
|
||||
// `api_base`, so the source base_url points at the same stub — otherwise they
|
||||
// all error out on an unreachable host and the final verdict is an error
|
||||
// regardless of the rule being tested.
|
||||
func TestQuotaOpenDesignRejectedControlKeyIsAnError(t *testing.T) {
|
||||
srv := odServer(t, nil, `{"error":"unauthenticated"}`, `{"error":"unauthenticated"}`,
|
||||
http.StatusUnauthorized, http.StatusUnauthorized)
|
||||
defer srv.Close()
|
||||
|
||||
ps, _ := odPlugins(t)
|
||||
// Host key must match what hostOf(base) yields for the source below.
|
||||
src := map[string]interface{}{
|
||||
"name": "vela", "base_url": srv.URL, "api_key": "k",
|
||||
}
|
||||
if err := ps.SetState("quota", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"control_key": "vela_ctrl_WRONG",
|
||||
"api_base": srv.URL + "/api/v1",
|
||||
},
|
||||
// Flat, NOT nested under a "state" key: SetState pulls `prices` out and
|
||||
// stores the REST of the payload as the plugin's state. Nesting it would
|
||||
// store {state: {probe_memo: ...}} and the memo would never be read.
|
||||
"probe_memo": map[string]interface{}{
|
||||
hostOnly(srv.URL): "opendesign",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := ps.Call("quota", "collect_source", map[string]interface{}{
|
||||
"name": "vela", "sources": []interface{}{src},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rep, _ := out.(map[string]interface{})
|
||||
if rep == nil {
|
||||
t.Fatal("no report produced")
|
||||
}
|
||||
if rep["vendor"] == "opendesign" {
|
||||
t.Fatalf("a rejected control key must not produce a balance: %v", rep)
|
||||
}
|
||||
// The plugin reports a credential failure as an error, which the page renders
|
||||
// as a red "读取失败" row. Filing it as a skip instead shows the same blank
|
||||
// card as "this vendor has no balance endpoint" — and sends the operator to
|
||||
// investigate the vendor rather than re-run the login.
|
||||
if rep["skipped"] == true {
|
||||
t.Fatalf("a rejected control key was reported as a SKIP: %v — the other three "+
|
||||
"adapters answered 404 for this host, so the final verdict is a skip unless "+
|
||||
"a rejected credential outranks it. The operator would go looking at the "+
|
||||
"vendor instead of re-running the login.", rep)
|
||||
}
|
||||
msg, _ := rep["error"].(string)
|
||||
if !strings.Contains(msg, "rejected") {
|
||||
t.Fatalf("error = %q, want it to name the rejected credential so the "+
|
||||
"operator knows to re-run the login rather than to investigate the vendor", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaOpenDesignDoesNotDefaultMissingRate pins the "do not invent an
|
||||
// exchange rate" rule. creditsPerUsd is the only thing that converts the credit
|
||||
// breakdown into dollars; when it is absent the vendor's own *Usd fields still
|
||||
// give a correct balance, so the card must render with the split missing and
|
||||
// FLAG it, not divide by a remembered 10000.
|
||||
func TestQuotaOpenDesignDoesNotDefaultMissingRate(t *testing.T) {
|
||||
// Same summary with creditsPerUsd removed.
|
||||
noRate := strings.Replace(odSummary, `"creditsPerUsd":10000,`, "", 1)
|
||||
if strings.Contains(noRate, "creditsPerUsd") {
|
||||
t.Fatal("fixture still carries creditsPerUsd")
|
||||
}
|
||||
srv := odServer(t, nil, odWallet, noRate, 200, 200)
|
||||
defer srv.Close()
|
||||
|
||||
ps, _ := odPlugins(t)
|
||||
if err := ps.SetState("quota", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"control_key": "vela_ctrl_TESTKEY",
|
||||
"api_base": srv.URL + "/api/v1",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1")
|
||||
if rep == nil || rep["vendor"] != "opendesign" {
|
||||
t.Fatalf("expected an opendesign report, got %v", rep)
|
||||
}
|
||||
// The balance is authoritative and comes straight from the vendor.
|
||||
if rep["balance"] != 15.7062 {
|
||||
t.Fatalf("balance = %#v, want 15.7062 (it must not depend on the rate)", rep["balance"])
|
||||
}
|
||||
if rep["credits_rate_missing"] != true {
|
||||
t.Fatalf("credits_rate_missing = %#v, want true: the page must show that "+
|
||||
"the credit split is unavailable rather than a silent zero", rep["credits_rate_missing"])
|
||||
}
|
||||
if v, ok := rep["credits_per_usd"]; ok && v != nil {
|
||||
t.Fatalf("credits_per_usd = %#v with no rate in the response — a defaulted "+
|
||||
"rate produces confidently wrong dollar amounts", v)
|
||||
}
|
||||
// subscription_usd/recharge_usd come from the vendor's own USD fields, so
|
||||
// they are still correct and must still be present.
|
||||
if rep["subscription_usd"] != 15.2062 {
|
||||
t.Fatalf("subscription_usd = %#v, want 15.2062 from the vendor's own field",
|
||||
rep["subscription_usd"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaOpenDesignHostRewritingIsUsed keeps the amr-link -> amr-api default
|
||||
// working. Without it the adapter probes the inference host, which serves no
|
||||
// billing route at all, and every OpenDesign source silently skips.
|
||||
//
|
||||
// Asserted on the exported derivation rather than on the source text or on a
|
||||
// failed probe's error message. The source-text version pinned the SPELLING of
|
||||
// the rule: it went red the moment the rule was tightened from an `amr-link.`
|
||||
// prefix to a full hostname, while the behaviour was unchanged. The
|
||||
// error-message version inferred "which host did we ask?" from an allowlist
|
||||
// refusal, which stops working the moment any adapter earlier in the chain also
|
||||
// fails — and one of them always does.
|
||||
func TestQuotaOpenDesignHostRewritingIsUsed(t *testing.T) {
|
||||
ps, _ := odPlugins(t)
|
||||
cases := []struct{ in, want string }{
|
||||
// The real production shape: base_url carries a path.
|
||||
{"https://amr-link.open-design.ai/v1", "https://amr-api.open-design.ai/api/v1"},
|
||||
// And without one, so the derivation cannot depend on the path being there.
|
||||
{"https://amr-link.open-design.ai", "https://amr-api.open-design.ai/api/v1"},
|
||||
// A trailing slash must not produce a double slash.
|
||||
{"https://amr-link.open-design.ai/v1/", "https://amr-api.open-design.ai/api/v1"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
out, err := ps.Call("quota", "resolve_api_base", map[string]interface{}{
|
||||
"base_url": c.in,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve_api_base(%q): %v", c.in, err)
|
||||
}
|
||||
rep, _ := out.(map[string]interface{})
|
||||
if got, _ := rep["api_base"].(string); got != c.want {
|
||||
t.Errorf("resolve_api_base(%q) = %q, want %q — the probe would go to the "+
|
||||
"inference host, which serves no billing route, and the source would "+
|
||||
"silently stay unlisted", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// A lookalike host must NOT be rewritten into an OpenDesign address: an
|
||||
// `amr-link.` prefix rule would turn amr-link.example.com into amr-api...,
|
||||
// sending an unrelated provider's probe to a host that is not its own.
|
||||
out, err := ps.Call("quota", "resolve_api_base", map[string]interface{}{
|
||||
"base_url": "https://amr-link.example.com/v1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rep, _ := out.(map[string]interface{})
|
||||
if got, _ := rep["api_base"].(string); got != "https://amr-link.example.com/api/v1" {
|
||||
t.Errorf("a lookalike host was rewritten to %q", got)
|
||||
}
|
||||
|
||||
// An explicit override wins (and is trimmed), so an operator can point at a
|
||||
// moved host without waiting for a code change.
|
||||
out, err = ps.Call("quota", "resolve_api_base", map[string]interface{}{
|
||||
"base_url": "https://amr-link.open-design.ai/v1",
|
||||
"api_base": "https://amr-api.example.net/api/v1/",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rep, _ = out.(map[string]interface{})
|
||||
if got, _ := rep["api_base"].(string); got != "https://amr-api.example.net/api/v1" {
|
||||
t.Errorf("api_base override not honoured (or not trimmed): %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaUnreadyAdapterDoesNotSuppressAConfiguredHost is the end-to-end half of
|
||||
// the unready rule, and it reproduces two bugs seen on the live plugin.
|
||||
//
|
||||
// (1) STALE READ. The memo is applied at the top of collectSource and the table
|
||||
// it comes from is only updated by the WRITE side, which runs at the very end.
|
||||
// Keying the backoff on `memo_entry_exists` therefore asks a question that is
|
||||
// true on everything except the first probe of the oldest entry, and the plugin
|
||||
// describes it to the operator as "连续探测失败 1 次" — an invented streak. Live
|
||||
// trace: unset key → memo → key set → probes refused, memo untouched, forever.
|
||||
// Backing off must key on the recorded STREAK, not on the entry existing.
|
||||
//
|
||||
// (2) EMPTY `unready` SET. Lua drops empty tables, so an answer from every
|
||||
// adapter writes a negative with no `unready` field — indistinguishable, by
|
||||
// truthiness, from a provisional one whose set happens to be empty. Reading it as
|
||||
// provisional made an ordinary streak into a non-backing-off entry, which is the
|
||||
// other half of the same live symptom.
|
||||
//
|
||||
// It is NOT fixable by "do not memoise a skip": on a real OpenDesign host the
|
||||
// other three adapters answer 404 ("no dashboard here"), which is durable and
|
||||
// worth remembering, so the verdict would be recorded either way. The memo has to
|
||||
// carry WHICH vendors were unable to judge the host, and the read side has to
|
||||
// retire that record once they can.
|
||||
func TestQuotaUnreadyAdapterDoesNotSuppressAConfiguredHost(t *testing.T) {
|
||||
var seenAuth []string
|
||||
srv := odServer(t, &seenAuth, odWallet, odSummary, 200, 200)
|
||||
defer srv.Close()
|
||||
|
||||
ps, _ := odPlugins(t)
|
||||
args := map[string]interface{}{
|
||||
"name": "vela",
|
||||
"sources": []interface{}{map[string]interface{}{
|
||||
"name": "vela", "base_url": srv.URL + "/v1", "api_key": "k",
|
||||
}},
|
||||
}
|
||||
|
||||
// Step 1: no control_key. openDesign cannot judge the host, and no billing
|
||||
// request goes anywhere.
|
||||
out, err := ps.Call("quota", "collect_source", args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rep, _ := out.(map[string]interface{})
|
||||
if rep["skipped"] != true {
|
||||
t.Fatalf("expected a skip with no control key configured: %v", rep)
|
||||
}
|
||||
if len(seenAuth) != 0 {
|
||||
t.Fatalf("an unconfigured adapter made a request: %q", seenAuth)
|
||||
}
|
||||
|
||||
// Step 2: configure the key. The very next probe must reach the upstream. It
|
||||
// is inside the backoff window (minutes of wall clock) while this runs in
|
||||
// microseconds — so a host refused now is refused for the whole window in
|
||||
// production, which is the bug, not a timing accident.
|
||||
if err := ps.SetState("quota", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"control_key": "vela_ctrl_TESTKEY",
|
||||
"api_base": srv.URL + "/api/v1",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err = ps.Call("quota", "collect_source", args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rep, _ = out.(map[string]interface{})
|
||||
if rep["vendor"] != "opendesign" || rep["skipped"] == true {
|
||||
t.Fatalf("expected an opendesign balance once configured, got %v — the host "+
|
||||
"is still suppressed by the provisional memo it earned while the adapter "+
|
||||
"had no credential", rep)
|
||||
}
|
||||
if len(seenAuth) == 0 {
|
||||
t.Fatal("no request reached the billing endpoint after the control key was " +
|
||||
"supplied")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaUnreadyStreakDoesNotBlockAConfiguredHost covers the second half of the
|
||||
// same live symptom, and it needs its own server because the first half ends with a
|
||||
// SUCCESS (which overwrites the memo).
|
||||
//
|
||||
// The read side backs a host off by looking up `HOST_MEMO[host]`, a table the
|
||||
// WRITE side only fills in at the very end of the call. Two ways that goes wrong:
|
||||
//
|
||||
// - backing off on "an entry exists" instead of on its streak — the entry always
|
||||
// exists, so the host is refused forever after its first negative
|
||||
// - reading an `unready` set that is absent (Lua drops empty tables when every
|
||||
// adapter answered) as if it were a provisional one, which DISABLES the backoff
|
||||
// for exactly the hosts that most need it
|
||||
//
|
||||
// Both are about the shape of the entry rather than about the vendor, so they are
|
||||
// asserted on the entry the probe actually left behind.
|
||||
func TestQuotaUnreadyStreakDoesNotBlockAConfiguredHost(t *testing.T) {
|
||||
// Every billing route 404s: no vendor has a dashboard for this host, which is
|
||||
// the durable negative the memo exists to remember.
|
||||
srv := odServer(t, nil, `{}`, `{}`, 404, 404)
|
||||
defer srv.Close()
|
||||
|
||||
ps, _ := odPlugins(t)
|
||||
if err := ps.SetState("quota", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"control_key": "vela_ctrl_TESTKEY",
|
||||
"api_base": srv.URL + "/api/v1",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args := map[string]interface{}{
|
||||
"name": "vela",
|
||||
"sources": []interface{}{map[string]interface{}{
|
||||
"name": "vela", "base_url": srv.URL + "/v1", "api_key": "k",
|
||||
}},
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := ps.Call("quota", "collect_source", args); err != nil {
|
||||
t.Fatalf("probe %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
st, _ := ps.State("quota").(map[string]interface{})
|
||||
memo, _ := st["probe_memo"].(map[string]interface{})
|
||||
// The host key is derived from base_url, so find the single entry.
|
||||
if len(memo) != 1 {
|
||||
t.Fatalf("expected exactly one memo entry, got %v", memo)
|
||||
}
|
||||
var entry map[string]interface{}
|
||||
for _, v := range memo {
|
||||
entry, _ = v.(map[string]interface{})
|
||||
}
|
||||
if entry == nil {
|
||||
t.Fatalf("the negative result was not recorded at all: %v", memo)
|
||||
}
|
||||
// The durable/provisional distinction is the entry's SHAPE. A first probe of a
|
||||
// host no vendor can report on must produce a durable entry: with the empty
|
||||
// `unready` set stamped on unconditionally, Lua's dropping of empty tables
|
||||
// means the reader and the writer disagree about what an absent field means,
|
||||
// and a host that most needs backing off is the one that never gets it.
|
||||
if _, provisional := entry["unready"]; provisional {
|
||||
t.Fatalf("every adapter judged this host, so the entry must not be "+
|
||||
"provisional (an absent `unready` is what makes it durable): %v", entry)
|
||||
}
|
||||
}
|
||||
@ -1,61 +0,0 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **测试绝不允许写生产存储** —— 这条是真实事故的回归判据。
|
||||
//
|
||||
// 事故:channels 插件用 storeDir() 决定自己的存储位置,而 storeDir 有多级兜底:
|
||||
// 环境变量 → settings → **读 /etc/modelrouter/config.yaml 的 plugin_dir** →
|
||||
// 硬编码 /etc/modelrouter/plugins。测试台没设环境变量时,后两级直接命中**生产**:
|
||||
//
|
||||
// 测试插件目录 = /tmp/TestXXX/002/plugins
|
||||
// storeDir 结果 = /etc/modelrouter/plugins ← 实测
|
||||
//
|
||||
// 于是测试加载真 channels.lua 时**读的是生产存储、写的也是生产存储**。一次全量
|
||||
// `go test ./internal/lua` 就把生产账号池覆盖成测试的空 pool:
|
||||
// /etc/modelrouter/plugins/.channels-store.json 从 1730 变 1986 字节,
|
||||
// 13 个账号变成 0 个(凭据还留在保险库,所以症状是"账号凭空消失")。
|
||||
//
|
||||
// 同类前科(本文件存在的另一半理由):曾经有 26 个测试账号被写进生产
|
||||
// .channels-store.json。两次都是"测试没隔离存储目录",所以这条判据必须存在。
|
||||
func TestStoreDirIsolatedInTests(t *testing.T) {
|
||||
_, ps, pdir := runtimePlugins(t)
|
||||
src, err := ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := ps.Call("channels", "__store_dir_probe", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("探针: %v", err)
|
||||
}
|
||||
w, _ := out.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j == nil {
|
||||
t.Fatalf("探针无 json: %#v", out)
|
||||
}
|
||||
got, _ := j["store_dir"].(string)
|
||||
t.Logf("测试插件目录 = %s", pdir)
|
||||
t.Logf("storeDir 结果 = %s", got)
|
||||
|
||||
// ① 绝不能是生产目录
|
||||
if got == "/etc/modelrouter/plugins" || strings.HasPrefix(got, "/etc/llmsproxy") {
|
||||
t.Fatalf("storeDir 指向了**生产**目录 %q —— 测试会覆盖生产账号池\n"+
|
||||
"(真实事故:13 个账号被覆盖成 0 个)。修法:runtimePlugins 设\n"+
|
||||
"MODELROUTER_PLUGIN_DIR 到 t.TempDir()", got)
|
||||
}
|
||||
// ② 必须落在测试的临时目录里
|
||||
if !strings.Contains(got, pdir) && !strings.HasPrefix(got, os.TempDir()) {
|
||||
t.Errorf("storeDir 应落在临时目录(%s 或 %s),实得 %q", pdir, os.TempDir(), got)
|
||||
}
|
||||
// ③ 环境变量必须真的被设上(机制而非巧合)
|
||||
if os.Getenv("MODELROUTER_PLUGIN_DIR") == "" {
|
||||
t.Error("runtimePlugins 必须设 MODELROUTER_PLUGIN_DIR(否则 storeDir 会读生产 config.yaml)")
|
||||
}
|
||||
}
|
||||
@ -36,8 +36,8 @@ func TestAdaptersPreserveToolCalls(t *testing.T) {
|
||||
)
|
||||
|
||||
// Adapters that identify a tool result by the call id.
|
||||
byID := []string{"openai", "deepseek", "github", "groq", "kimicode", "mistral",
|
||||
"sensenova", "trae", "anthropic"}
|
||||
byID := []string{"openai", "deepseek", "github", "groq", "mistral",
|
||||
"sensenova", "anthropic"}
|
||||
|
||||
for _, a := range byID {
|
||||
out, err := vm.Transform(a, "transform_request", body)
|
||||
|
||||
@ -12,8 +12,7 @@ import (
|
||||
// 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.
|
||||
// bug rather than as a missing optional feature. Same reasoning billing.lua.
|
||||
//
|
||||
// 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
|
||||
@ -110,3 +109,14 @@ func TestUsagePeriodIsAll(t *testing.T) {
|
||||
t.Error(`usage.lua no longer defaults to period "all"`)
|
||||
}
|
||||
}
|
||||
|
||||
// readShippedPlugin reads an optional plugin from the repository (not the
|
||||
// binary: plugins are never embedded). Serves tests that assert on Lua source.
|
||||
func readShippedPlugin(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
src, err := ReadBundledPlugin(strings.TrimSuffix(name, ".lua"))
|
||||
if err != nil {
|
||||
t.Fatalf("read repo plugin %s: %v", name, err)
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ func TestLoadBundledAdapters(t *testing.T) {
|
||||
for _, a := range adapters {
|
||||
names[a.Name] = true
|
||||
}
|
||||
for _, want := range []string{"openai", "deepseek", "anthropic", "gemini", "ollama", "kimicode"} {
|
||||
for _, want := range []string{"openai", "deepseek", "anthropic", "gemini", "ollama"} {
|
||||
if !names[want] {
|
||||
t.Errorf("missing adapter %s (got %v)", want, names)
|
||||
}
|
||||
@ -115,36 +115,6 @@ func TestBuildHeadersFallbackStatic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHeadersCustomHook(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
hdrs, err := vm.BuildHeaders("kimicode", map[string]interface{}{
|
||||
"timestamp": int64(12345),
|
||||
"api_key": "k",
|
||||
"body": "{}",
|
||||
"method": "POST",
|
||||
"url": "http://x/chat",
|
||||
"source": map[string]interface{}{"meta": map[string]interface{}{
|
||||
"app_id": "app-9", "app_secret": "s", "api_key": "k",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build headers: %v", err)
|
||||
}
|
||||
if hdrs["X-App-Id"] != "app-9" {
|
||||
t.Fatalf("x-app-id = %q", hdrs["X-App-Id"])
|
||||
}
|
||||
if hdrs["X-App-Sign"] == "" {
|
||||
t.Fatal("expected signature header")
|
||||
}
|
||||
if hdrs["X-Timestamp"] != "12345" {
|
||||
t.Fatalf("timestamp = %q", hdrs["X-Timestamp"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableThinkingPassthrough(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
@ -296,7 +266,7 @@ func TestAdaptersPassFinishReason(t *testing.T) {
|
||||
// every chunk) must NOT terminate the stream.
|
||||
chunk := `{"choices":[{"index":0,"finish_reason":"tool_calls","delta":{"content":""}}]}`
|
||||
empty := `{"choices":[{"index":0,"finish_reason":"","delta":{"content":"x"}}]}`
|
||||
for _, name := range []string{"openai", "deepseek", "github", "groq", "kimicode", "mistral"} {
|
||||
for _, name := range []string{"openai", "deepseek", "github", "groq", "mistral"} {
|
||||
out, err := vm.Transform(name, "transform_stream_chunk", chunk)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
@ -521,7 +491,7 @@ func TestAdaptersReportZeroCacheHit(t *testing.T) {
|
||||
defer vm.Stop()
|
||||
|
||||
// OpenAI-shaped upstreams: usage.prompt_tokens_details.cached_tokens = 0
|
||||
openaiLike := []string{"openai", "deepseek", "sensenova", "github", "groq", "kimicode", "mistral"}
|
||||
openaiLike := []string{"openai", "deepseek", "sensenova", "github", "groq", "mistral"}
|
||||
respBody := `{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12,
|
||||
"prompt_tokens_details":{"cached_tokens":0}}}`
|
||||
@ -1248,8 +1218,8 @@ func TestNonStreamToolCallsPreserved(t *testing.T) {
|
||||
`"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`
|
||||
|
||||
for _, name := range []string{
|
||||
"openai", "sensenova", "trae", "deepseek", "github",
|
||||
"groq", "kimicode", "mistral",
|
||||
"openai", "sensenova", "deepseek", "github",
|
||||
"groq", "mistral",
|
||||
} {
|
||||
out, err := vm.Transform(name, "transform_response", body)
|
||||
if err != nil {
|
||||
@ -1369,86 +1339,6 @@ func TestOllamaNonStreamToolCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTraeTextToolCallRecovery covers the trae-specific failure: its upstream
|
||||
// (trae-local-api's OpenAI endpoint) never reads the request's `tools` array, so
|
||||
// the relayed model prints a <tool_call> block as TEXT, leaves
|
||||
// message.tool_calls null and reports finish_reason:"stop". An OpenAI client
|
||||
// then sees an ordinary completion and its agent loop ends mid-conversation.
|
||||
// The adapter must recover the structured call, strip the block from content,
|
||||
// and correct finish_reason.
|
||||
func TestTraeTextToolCallRecovery(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
"tool_call tag with arguments",
|
||||
`{"choices":[{"message":{"role":"assistant","content":"<tool_call>\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Beijing\"}}\n</tool_call>"},"finish_reason":"stop"}]}`,
|
||||
},
|
||||
{
|
||||
// the same codebase's Anthropic endpoint prompts for <toolcall> and "params"
|
||||
"toolcall tag with params",
|
||||
`{"choices":[{"message":{"role":"assistant","content":"<toolcall>{\"name\": \"get_weather\", \"params\": {\"city\": \"Beijing\"}}</toolcall>"},"finish_reason":"stop"}]}`,
|
||||
},
|
||||
{
|
||||
"prose around the block is preserved",
|
||||
`{"choices":[{"message":{"role":"assistant","content":"Let me check.\n<tool_call>{\"name\":\"get_weather\",\"arguments\":{\"city\":\"Beijing\"}}</tool_call>"},"finish_reason":"stop"}]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
out, err := vm.Transform("trae", "transform_response", c.body)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: transform: %v", c.name, err)
|
||||
}
|
||||
var got struct {
|
||||
Content string `json:"content"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
ToolCalls []struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]interface{} `json:"arguments"`
|
||||
} `json:"tool_calls"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
||||
t.Fatalf("%s: unmarshal %v (%s)", c.name, err, out)
|
||||
}
|
||||
if len(got.ToolCalls) != 1 {
|
||||
t.Fatalf("%s: text tool call not recovered: %s", c.name, out)
|
||||
}
|
||||
if got.ToolCalls[0].Name != "get_weather" {
|
||||
t.Errorf("%s: name wrong: %s", c.name, out)
|
||||
}
|
||||
if got.ToolCalls[0].Arguments["city"] != "Beijing" {
|
||||
t.Errorf("%s: args wrong: %s", c.name, out)
|
||||
}
|
||||
if got.FinishReason != "tool_calls" {
|
||||
t.Errorf("%s: finish_reason must be corrected to tool_calls: %s", c.name, out)
|
||||
}
|
||||
if strings.Contains(got.Content, "<tool_call") || strings.Contains(got.Content, "<toolcall") {
|
||||
t.Errorf("%s: raw block left in user-visible content: %s", c.name, out)
|
||||
}
|
||||
}
|
||||
|
||||
// a plain text answer must be untouched
|
||||
plain := `{"choices":[{"message":{"role":"assistant","content":"just text"},"finish_reason":"stop"}]}`
|
||||
out, err := vm.Transform("trae", "transform_response", plain)
|
||||
if err != nil {
|
||||
t.Fatalf("plain: %v", err)
|
||||
}
|
||||
if strings.Contains(out, "tool_calls") {
|
||||
t.Errorf("plain answer must not gain tool_calls: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"finish_reason":"stop"`) {
|
||||
t.Errorf("plain answer finish_reason changed: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolIDSanitize locks the tool-call id sanitizer in both adapters that
|
||||
// talk to Claude upstreams. Anthropic requires tool_use.id /
|
||||
// tool_result.tool_use_id to match ^[a-zA-Z0-9_-]{1,64}$ and rejects the WHOLE
|
||||
|
||||
Reference in New Issue
Block a user