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:
JianFeeeee
2026-10-27 09:23:24 +08:00
parent 449e59dd52
commit 8f2c5572e7
123 changed files with 76 additions and 32056 deletions

View File

@ -1,170 +0,0 @@
package e2e
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
// TestChannelsDevicePollE2E 用**真二进制**跑通 channels 插件的 device-poll 登录:
//
// 发起登录(start_login) → 页面轮询(poll_login) 推进设备码换 token
// → 凭据入保险库 → add_source 把平台接成上游源 → /v1/models 里出现其模型
//
// 这是对参考仓库 dsh-our-free-model「登录→领额度→接入源」形态的端到端对齐:
// 行为(HTTP 决策分支、会话推进、最终可路由)在真进程里验证,而不是单元桩。
//
// 假上游模拟 MiniMax 的两个端点:设备码申请 + token 轮询(脚本化逐次响应)。
func TestChannelsDevicePollE2E(t *testing.T) {
// ---- 假平台上游(loopback,白名单天然放行)----
var tokenHits int32
var tokenScript atomic.Value // []scriptStep
tokenScript.Store([]scriptStep{})
plat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/oauth2/device/code":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"device_code":"DEV-E2E","user_code":"WXYZ-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
case "/oauth2/token":
i := int(atomic.AddInt32(&tokenHits, 1)) - 1
script := tokenScript.Load().([]scriptStep)
w.Header().Set("Content-Type", "application/json")
if i >= len(script) {
w.WriteHeader(500)
fmt.Fprint(w, `{"error":"unexpected"}`)
return
}
if script[i].code != 0 && script[i].code != 200 {
w.WriteHeader(script[i].code)
}
fmt.Fprint(w, script[i].body)
case "/v1/models":
// 接入源后内核会探测可达;回一个 OpenAI 形状的模型列表
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"object":"list","data":[{"id":"MiniMax-M3","object":"model"}]}`)
case "/v1/chat/completions":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)
default:
w.WriteHeader(404)
}
}))
defer plat.Close()
// ---- 真二进制 + 配置(带 plugin_dir,让 bundled channels 落地 + 授权可持久化)----
dir := t.TempDir()
addr := mustAddr(t)
pluginDir := filepath.Join(dir, "plugins") // 不预建:SeedBundled 见空目录才落地内置插件
cfg := "listen: " + addr + "\n" +
"gateway_keys:\n - sk-e2e-0001\n" +
"default_model: none\n" +
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
"plugin_dir: " + pluginDir + "\n" +
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
"sources: []\n"
cfgPath := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatal(err)
}
bin := buildBinary(t)
g := startGatewayNoModels(t, bin, addr, cfgPath)
call := func(method, path, body string) (int, map[string]interface{}) {
resp, raw := g.do(method, path, body, true)
var m map[string]interface{}
_ = json.Unmarshal([]byte(raw), &m)
return statusOf(resp), m
}
// 1) 授权内核动作(运维批准 add_source 等)+ 把 minimax 端点指到假上游。
st, m := call("PUT", "/api/plugins/channels/kernel",
`{"actions":["add_source","status","list_sources","probe_all","reset_health"],"all_read":false}`)
if st != 200 {
t.Fatalf("授权内核动作失败: status=%d body=%v", st, m)
}
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
`{"platform":"minimax","base_url":%q,"device":{"code_url":%q,"token_url":%q}}`,
plat.URL+"/v1", plat.URL+"/oauth2/device/code", plat.URL+"/oauth2/token"))
if st != 200 {
t.Fatalf("set_endpoints 失败: status=%d body=%v", st, m)
}
// 2) 发起 device-poll 登录:拿到会话 + user_code。
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"minimax"}`)
if st != 200 {
t.Fatalf("start_login status=%d body=%v", st, m)
}
sess, _ := m["session"].(map[string]interface{})
if sess == nil || sess["user_code"] != "WXYZ-1234" {
t.Fatalf("start_login 未透传 user_code: %v", m)
}
sid, _ := sess["id"].(string)
if sid == "" {
t.Fatal("无会话 id")
}
// 3) 第一次轮询:上游 pending → 会话仍 pending。
tokenScript.Store([]scriptStep{{code: 400, body: `{"error":"authorization_pending"}`}})
atomic.StoreInt32(&tokenHits, 0)
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
if m["state"] != "pending" {
t.Fatalf("轮询1 应 pending, 实得 %v", m)
}
// 4) 第二次轮询:上游发 access_token → 会话 completed,凭据入库 + 接入源。
tokenScript.Store([]scriptStep{{code: 200, body: `{"access_token":"MM-E2E-TOKEN","refresh_token":"MM-RF","nickname":"e2e-user"}`}})
atomic.StoreInt32(&tokenHits, 0)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
if m["state"] == "completed" || m["state"] == "failed" {
break
}
time.Sleep(150 * time.Millisecond)
}
if m["state"] != "completed" {
t.Fatalf("轮询2 应 completed, 实得 %v", m)
}
if m["account"] != "e2e-user" {
t.Fatalf("account 应取 nickname, 实得 %v", m)
}
// 5) 接入源的终局证据:/v1/models 里出现 channels-minimax 的模型。
deadline = time.Now().Add(5 * time.Second)
var modelsBody string
for time.Now().Before(deadline) {
resp, raw := g.do("GET", "/v1/models", "", true)
modelsBody = raw
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "MiniMax-M3") {
break
}
time.Sleep(150 * time.Millisecond)
}
if !strings.Contains(modelsBody, "MiniMax-M3") {
t.Fatalf("接入源后 /v1/models 未出现 MiniMax-M3: %s", modelsBody)
}
}
type scriptStep struct {
code int
body string
}
// startGatewayNoModels 与 startGateway 相同,但就绪探针不要求 /v1/models 返回
// 非空:本用例启动时没有任何源,/v1/models 是空列表(200 即就绪)。
func startGatewayNoModels(t *testing.T, bin, listen, cfgPath string) *gatewayUnderTest {
t.Helper()
return startGateway(t, bin, listen, cfgPath)
}
var _ = net.Listen
var _ = io.Discard

View File

@ -1,209 +0,0 @@
package e2e
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
// TestChannelsFlowsE2E 用真二进制补齐另外三条流程的端到端:
//
// Cline(WorkOS device-poll 两步:authenticate → register 换最终凭据)
// paste(opencode:直接 complete_login 贴 API key)
// device-poll 授权页(qoder:本地造 PKCE,把手动授权页给用户,再凭据完成)
//
// 终局证据与 TestChannelsDevicePollE2E 同一标准:/v1/models 出现该平台模型。
func TestChannelsFlowsE2E(t *testing.T) {
var authHits, regHits int32
plat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
// ---- Cline(WorkOS)----
case "/user_management/authorize/device":
fmt.Fprint(w, `{"device_code":"CD-E2E","user_code":"CLNE-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
case "/user_management/authenticate":
if atomic.AddInt32(&authHits, 1) == 1 {
w.WriteHeader(400)
fmt.Fprint(w, `{"error":"authorization_pending"}`)
return
}
fmt.Fprint(w, `{"access_token":"WO-E2E-AT","refresh_token":"WO-E2E-RT"}`)
case "/api/v1/auth/register":
atomic.AddInt32(&regHits, 1)
fmt.Fprint(w, `{"success":true,"data":{"accessToken":"CLINE-E2E-AT","refreshToken":"CLINE-E2E-RT","userInfo":{"email":"dev@example.com"}}}`)
// ---- 上游推理面(opencode/qoder/cline 的 base_url 都指到这里)----
case "/v1/models":
fmt.Fprint(w, `{"object":"list","data":[{"id":"big-pickle","object":"model"},{"id":"qmodel_38max","object":"model"},{"id":"stealth/space-bunny-alpha","object":"model"}]}`)
case "/v1/chat/completions":
fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)
default:
w.WriteHeader(404)
}
}))
defer plat.Close()
dir := t.TempDir()
addr := mustAddr(t)
pluginDir := filepath.Join(dir, "plugins")
cfg := "listen: " + addr + "\n" +
"gateway_keys:\n - sk-e2e-0001\n" +
"default_model: none\n" +
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
"plugin_dir: " + pluginDir + "\n" +
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
"sources: []\n"
cfgPath := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatal(err)
}
bin := buildBinary(t)
g := startGatewayNoModels(t, bin, addr, cfgPath)
call := func(method, path, body string) (int, map[string]interface{}) {
resp, raw := g.do(method, path, body, true)
var m map[string]interface{}
_ = json.Unmarshal([]byte(raw), &m)
return statusOf(resp), m
}
st, m := call("PUT", "/api/plugins/channels/kernel",
`{"actions":["add_source","status","list_sources","probe_all","reset_health"],"all_read":false}`)
if st != 200 {
t.Fatalf("授权内核动作失败: status=%d body=%v", st, m)
}
// ---------- ① Cline:WorkOS 设备码两步 ----------
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
`{"platform":"cline","base_url":%q,"device":{"code_url":%q,"token_url":%q},"register":{"url":%q}}`,
plat.URL, plat.URL+"/user_management/authorize/device",
plat.URL+"/user_management/authenticate", plat.URL+"/api/v1/auth/register"))
if st != 200 {
t.Fatalf("set_endpoints(cline) 失败: status=%d body=%v", st, m)
}
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"cline"}`)
if st != 200 {
t.Fatalf("cline start_login status=%d body=%v", st, m)
}
sess, _ := m["session"].(map[string]interface{})
if sess == nil || sess["user_code"] != "CLNE-1234" {
t.Fatalf("cline start_login 未透传 user_code: %v", m)
}
sid, _ := sess["id"].(string)
// 第一拍 pending(authorization_pending),第二拍 completed
atomic.StoreInt32(&authHits, 0)
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
if m["state"] != "pending" {
t.Fatalf("cline 轮询1 应 pending, 实得 %v", m)
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
if m["state"] == "completed" || m["state"] == "failed" {
break
}
time.Sleep(150 * time.Millisecond)
}
if m["state"] != "completed" {
t.Fatalf("cline 轮询2 应 completed, 实得 %v", m)
}
if m["account"] != "dev@example.com" {
t.Fatalf("cline account 应取 register userInfo.email, 实得 %v", m)
}
if atomic.LoadInt32(&regHits) != 1 {
t.Fatalf("register 应恰好调用一次, 实得 %d", regHits)
}
// 凭据必须是 register 的最终 token,不是 WorkOS token
var modelsBody string
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
resp, raw := g.do("GET", "/v1/models", "", true)
modelsBody = raw
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "stealth/space-bunny-alpha") {
break
}
time.Sleep(150 * time.Millisecond)
}
if !strings.Contains(modelsBody, "stealth/space-bunny-alpha") {
t.Fatalf("cline 接入后 /v1/models 未出现其模型: %s", modelsBody)
}
// ---------- ② paste(opencode):complete 直接贴 key ----------
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
`{"platform":"opencode","base_url":%q}`, plat.URL))
if st != 200 {
t.Fatalf("set_endpoints(opencode) 失败: %v", m)
}
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"opencode"}`)
if st != 200 {
t.Fatalf("opencode start_login status=%d", st)
}
if sess, _ = m["session"].(map[string]interface{}); sess == nil || sess["flow"] != "paste" {
t.Fatalf("opencode 会话 flow 应 paste: %v", m)
}
st, m = call("POST", "/api/plugins/http/channels/login/complete",
`{"platform":"opencode","credential":"OC-E2E-KEY"}`)
if st != 200 || m["ok"] != true {
t.Fatalf("opencode complete 失败: status=%d body=%v", st, m)
}
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
resp, raw := g.do("GET", "/v1/models", "", true)
modelsBody = raw
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "big-pickle") {
break
}
time.Sleep(150 * time.Millisecond)
}
if !strings.Contains(modelsBody, "big-pickle") {
t.Fatalf("opencode 接入后 /v1/models 未出现 big-pickle: %s", modelsBody)
}
// ---------- ③ device-poll(qoder):给授权页 + 凭据完成 ----------
// qoder 曾走 manual(返回 manual_hint);现已改为真实的 PKCE 授权页流程
// (本地造 challenge/verifier,服务端按 nonce 找回待授权会话)。
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
`{"platform":"qoder","base_url":%q}`, plat.URL))
if st != 200 {
t.Fatalf("set_endpoints(qoder) 失败: %v", m)
}
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"qoder"}`)
if st != 200 {
t.Fatalf("qoder start_login status=%d", st)
}
// 真流程必须给出授权页 URL(而不是「不支持自动登录」的 manual_hint),
// 且会话声明为 device-poll。
if hint, _ := m["manual_hint"].(string); hint != "" {
t.Fatalf("qoder 已不再走 manual,不应返回 manual_hint: %q", hint)
}
if sess, _ = m["session"].(map[string]interface{}); sess == nil || sess["flow"] != "device-poll" {
t.Fatalf("qoder 会话 flow 应 device-poll: %v", m)
}
if authURL, _ := m["url"].(string); !strings.Contains(authURL, "challenge=") {
t.Fatalf("qoder 授权页 URL 应带 PKCE challenge,实得 %q", authURL)
}
st, m = call("POST", "/api/plugins/http/channels/login/complete",
`{"platform":"qoder","credential":"QD-E2E-KEY"}`)
if st != 200 || m["ok"] != true {
t.Fatalf("qoder complete 失败: status=%d body=%v", st, m)
}
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
resp, raw := g.do("GET", "/v1/models", "", true)
modelsBody = raw
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "qmodel_38max") {
break
}
time.Sleep(150 * time.Millisecond)
}
if !strings.Contains(modelsBody, "qmodel_38max") {
t.Fatalf("qoder 接入后 /v1/models 未出现 qmodel_38max: %s", modelsBody)
}
}

View File

@ -187,18 +187,20 @@ func writeConfig(t *testing.T, dir, listenAddr string, upstreams map[string]*moc
sb.WriteString("runtime_file: " + filepath.Join(dir, "runtime.json") + "\n")
sb.WriteString("sources:\n")
order := []string{"good", "fallback", "image"}
prio := map[string]int{"good": 100, "fallback": 50}
for _, name := range order {
u, ok := upstreams[name]
if !ok {
continue
}
if name == "image" {
sb.WriteString(" - name: imagegen\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: flux-1\n kind: image\n priority: 80\n")
sb.WriteString(" - name: imagegen\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: flux-1\n kind: image\n")
} else {
sb.WriteString(" - name: " + name + "\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: " + name + "-m\n priority: " + fmt.Sprint(prio[name]) + "\n")
sb.WriteString(" - name: " + name + "\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: " + name + "-m\n")
}
}
// AUTO 链现在是显式配置(25970a2 移除了 models.priority)。tier 越小越先试。
sb.WriteString("auto:\n - model: good-m\n source: good\n tier: 1\n")
sb.WriteString(" - model: fallback-m\n source: fallback\n tier: 2\n")
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil {
t.Fatalf("write config: %v", err)