mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b8fcaf434 | |||
| 47fe759025 | |||
| d795266572 |
57
internal/core/channels_buddy_catalog_filter_test.go
Normal file
57
internal/core/channels_buddy_catalog_filter_test.go
Normal file
@ -0,0 +1,57 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 远端模型目录混着「不可调用」的条目,必须过滤掉。
|
||||
//
|
||||
// 真实缺陷(生产实测):buddy 源声明了 24 个模型,其中
|
||||
//
|
||||
// nes-gf / codewise-completions / codewise-rewrite / codewise-jump /
|
||||
// codewise-default-model-v2 / hunyuan-image-alpha / auto
|
||||
//
|
||||
// 全部调不通(`glm-5.1` 也是 404)。用户选中就报错,而错误文案指向**模型**,
|
||||
// 极易被误判成"这个模型坏了"。
|
||||
//
|
||||
// 参考实现(buddy.ts 的 isChatModel / isAutoSelectAlias)记录了**四类**判据,
|
||||
// 且都是实测依据:
|
||||
//
|
||||
// ① 自动选择别名 auto/default(**只按字面量**,前缀匹配会误伤 default-model)
|
||||
// ② 补全/NES 专用前缀 nes- / completion- / codewise-,或 supportsExtra=true
|
||||
// (参考实测 codewise-default-model-v2 → `code 11102 service info not found`)
|
||||
// ③ maxOutputTokens >0 且 ≤256(补全用途;对话模型普遍 ≥24000)
|
||||
// ④ 带 text-to-image 标签(如 hunyuan-image-alpha)
|
||||
func TestBuddyCatalogFilterDropsNonChatModels(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// 判据一:四类规则必须都在源码里(防止有人"精简"掉其中一条)
|
||||
for _, rule := range []string{
|
||||
`id == "auto" or id == "default"`, // ①
|
||||
`id:sub(1, 4) == "nes-"`, // ②
|
||||
`id:sub(1, 11) == "completion-"`, // ②
|
||||
`id:sub(1, 9) == "codewise-"`, // ②
|
||||
`m.supportsExtra == true`, // ②
|
||||
`mo > 0 and mo <= 256`, // ③
|
||||
`t == "text-to-image"`, // ④
|
||||
} {
|
||||
if !strings.Contains(s, rule) {
|
||||
t.Errorf("buddy 目录过滤缺少规则:%s\n"+
|
||||
"(这四类都是参考用真实请求验证过的「列出来就会让用户报错」的条目,"+
|
||||
"删任何一条都会把不可调用模型放回选择器)", rule)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据二:`default-model` / `fast-model` 必须**不在**被过滤的形态里。
|
||||
// 参考注释明确警告:只过滤 auto/default 两个字面量,不做前缀匹配 ——
|
||||
// 前缀会误伤这两个国际版别名(它们被 craft agent 引用,是官方推荐入口)。
|
||||
if strings.Contains(s, `id:sub(1, 7) == "default"`) {
|
||||
t.Error("buddy 过滤把 default 做成了**前缀**匹配 ⇒ 会误伤 default-model(参考点名警告)")
|
||||
}
|
||||
t.Log("✓ buddy 目录过滤四类规则齐备,且未误伤 default-model 类别名")
|
||||
}
|
||||
76
internal/core/channels_endpoint_url_test.go
Normal file
76
internal/core/channels_endpoint_url_test.go
Normal file
@ -0,0 +1,76 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 源的 chat URL = `base_url + endpoint`(内核 provider.go ChatURL)。
|
||||
//
|
||||
// 真实缺陷(生产实测):多个渠道平台的 `base_url` 里塞了**完整路径**,
|
||||
// 而适配器默认 endpoint 又是 "/chat/completions" ⇒ 实际请求变成
|
||||
//
|
||||
// https://copilot.tencent.com/v2/chat/completions/chat/completions
|
||||
//
|
||||
// ⇒ 上游回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞,不是模型错误)。
|
||||
//
|
||||
// 受影响平台 8 个:buddy / cline / lobsterai / opencode / qoder / loomy /
|
||||
// raccoon(zcode 当时已用 endpoint 覆盖,形态正确)。
|
||||
//
|
||||
// ★ 为什么这个缺陷能活很久:**模型名被多个源服务**。
|
||||
//
|
||||
// `deepseek-v4.1-flash` 同时由 4 个源提供(alittokenplan/vela/oin/channels-buddy…)
|
||||
// ⇒ 网关在 buddy 源失败后会**继续尝试下一个源**并成功 ⇒ 用户看到"能回话",
|
||||
// 于是"buddy 通道是好的"这个结论**完全没有证据**。
|
||||
// 而 `hy3` 只由 buddy 一个源提供 ⇒ 一调就暴露真因。
|
||||
// ⇒ 判据必须断言**源的 URL 形态**(结构不变量),不能靠"某个模型能调通"。
|
||||
//
|
||||
// 判据:base_url 不得自带任何 chat 路径段(它只应是 host[+协议公共前缀]),
|
||||
// 路径必须在 `endpoint` 里。
|
||||
func TestChannelSourceBaseURLHasNoChatPath(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// 只在平台定义区里找 base_url(避免误判注释/文档里的示例)
|
||||
declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`)
|
||||
// 已知适配器会把这些路径各追加一次;出现即说明 base_url 侵占了 endpoint 的地盘
|
||||
banned := []string{"/chat/completions", "/v1/messages", "/generateContent", "/api/chat"}
|
||||
|
||||
found := 0
|
||||
for _, m := range declRe.FindAllStringSubmatch(s, -1) {
|
||||
url := m[1]
|
||||
found++
|
||||
for _, b := range banned {
|
||||
if strings.Contains(url, b) {
|
||||
t.Errorf("base_url 侵占了 endpoint 的路径:%s\n"+
|
||||
" 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),"+
|
||||
"适配器默认 endpoint=\"/chat/completions\" ⇒ 实际会请求 %s%s\n"+
|
||||
" 修法:base_url 只留 host,把 `%s` 移到该平台的 `endpoint = \"...\"`",
|
||||
url, url, b, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
t.Fatal("没扫到任何 base_url 声明(判据失效)")
|
||||
}
|
||||
t.Logf("✓ %d 个 base_url 均不含 chat 路径段", found)
|
||||
}
|
||||
|
||||
// ★ 反过来也要咬:说明该缺陷**确实已被修好**、而不是判据太松。
|
||||
// 用一段**故意的坏数据**验证判据的判别力(变异测试的静态等价物)。
|
||||
func TestChannelBaseURLJudgeBitesOnBadInput(t *testing.T) {
|
||||
bad := ` base_url = "https://copilot.tencent.com/v2/chat/completions",`
|
||||
declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`)
|
||||
m := declRe.FindStringSubmatch(bad)
|
||||
if m == nil {
|
||||
t.Fatal("判据的正则匹配不到坏数据 ⇒ 判据无判别力(形同虚设)")
|
||||
}
|
||||
if !strings.Contains(m[1], "/chat/completions") {
|
||||
t.Fatal("坏数据未被识别 ⇒ 判据无判别力")
|
||||
}
|
||||
t.Log("✓ 判据对坏数据有判别力")
|
||||
}
|
||||
@ -27,8 +27,12 @@ func TestUpstreamGapsAreDocumented(t *testing.T) {
|
||||
s := string(src)
|
||||
|
||||
t.Run("qoder 走明文端点且不经过信封", func(t *testing.T) {
|
||||
// 明文端点(OpenAI 兼容):参考 QODER_CHAT_PATH
|
||||
if !strings.Contains(s, "https://api2-v2.qoder.sh/model/v1/chat/completions") {
|
||||
// 明文端点(OpenAI 兼容):参考 QODER_CHAT_PATH。
|
||||
// ⚠ 断言 **URL 的两半**而不是那一整串字面量:本仓已把"路径从 base_url
|
||||
// 移到 endpoint"(内核 URL = base_url + endpoint)—— 继续钉死整串会让
|
||||
// 一个纯粹的形态重构把这条**语义**判据弄红,那是判据在咬自己。
|
||||
if !strings.Contains(s, `base_url = "https://api2-v2.qoder.sh"`) ||
|
||||
!strings.Contains(s, `endpoint = "/model/v1/chat/completions"`) {
|
||||
t.Fatal("qoder 的 chat 端点变了 —— 若改成加密端点 agent_chat_generation," +
|
||||
"则**必须**同时实现信封解包(qoder-envelope)与 WASM 签名," +
|
||||
"否则响应会被当成畸形数据")
|
||||
|
||||
112
internal/lua/adapter_opencodezen_reasoning_test.go
Normal file
112
internal/lua/adapter_opencodezen_reasoning_test.go
Normal file
@ -0,0 +1,112 @@
|
||||
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 已丢弃")
|
||||
}
|
||||
@ -11,8 +11,14 @@ adapter.endpoint = "/chat/completions"
|
||||
--
|
||||
-- * 本适配器(Zen)服务免费池 / 按量付费池。免费模型必须带 opencode 客户端
|
||||
-- 指纹(UA + x-opencode-*),否则上游拒绝:"free tier can only be used in
|
||||
-- OpenCode"。Zen 的免费池以非 thinking 模型为主,历史上回传
|
||||
-- reasoning_content 会引出上游报错,故这里剥掉(Go 侧相反,见 opencodego.lua)。
|
||||
-- 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。
|
||||
--
|
||||
@ -219,26 +225,50 @@ function adapter.transform_request(raw_body)
|
||||
if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then
|
||||
msg.role = "system"
|
||||
end
|
||||
-- Zen 免费池不接受回传 reasoning_content(Go 侧相反)
|
||||
msg.reasoning_content = nil
|
||||
-- ★ 必须**保留** 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
|
||||
-- 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).
|
||||
-- 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 = ""
|
||||
|
||||
@ -344,7 +344,13 @@ local PLATFORMS = {
|
||||
},
|
||||
hosts = { "api.workos.com", "api.cline.bot", "app.cline.bot" },
|
||||
-- cline 的 chat 路径是 /api/v1/chat/completions(参考 CLINE_CHAT_PATH)。
|
||||
base_url = "https://api.cline.bot/api/v1/chat/completions",
|
||||
base_url = "https://api.cline.bot",
|
||||
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
|
||||
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
|
||||
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
|
||||
-- 完整路径,实际请求就变成 .../api/v1/chat/completions/chat/completions ⇒ 上游
|
||||
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
|
||||
endpoint = "/api/v1/chat/completions",
|
||||
-- 远端模型目录(照参考 cline-models.ts:**必须同时打两个端点**)。
|
||||
-- 关键:/api/v1/models 的 460 个 id 里**没有** cline-free/*,免费模型只由
|
||||
-- recommended-models 下发;只打前者会看不到任何免费模型。
|
||||
@ -414,7 +420,13 @@ local PLATFORMS = {
|
||||
},
|
||||
hosts = { "copilot.tencent.com", "www.codebuddy.cn" },
|
||||
-- buddy 的 chat 端点含 /v2 前缀(参考 CHAT_API_BASE = .../v2 + /chat/completions)。
|
||||
base_url = "https://copilot.tencent.com/v2/chat/completions",
|
||||
base_url = "https://copilot.tencent.com",
|
||||
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
|
||||
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
|
||||
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
|
||||
-- 完整路径,实际请求就变成 .../v2/chat/completions/chat/completions ⇒ 上游
|
||||
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
|
||||
endpoint = "/v2/chat/completions",
|
||||
-- 远端模型目录(照参考 buddy.ts CONFIG_PATH + buddy-oauth.ts fetchModels)。
|
||||
-- 关键:必须带 product 身份头,否则 WorkBuddy 会以 CodeBuddy 身份请求。
|
||||
models_remote = {
|
||||
@ -688,7 +700,13 @@ local PLATFORMS = {
|
||||
fallback = "2026.9.4",
|
||||
},
|
||||
-- lobsterai 的 chat 路径是 /api/proxy/v1/chat/completions(参考 LOBSTERAI_CHAT_PATH)。
|
||||
base_url = "https://lobsterai-server.youdao.com/api/proxy/v1/chat/completions",
|
||||
base_url = "https://lobsterai-server.youdao.com",
|
||||
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
|
||||
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
|
||||
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
|
||||
-- 完整路径,实际请求就变成 .../api/proxy/v1/chat/completions/chat/completions ⇒ 上游
|
||||
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
|
||||
endpoint = "/api/proxy/v1/chat/completions",
|
||||
-- 远端模型目录(照参考 lobsterai.ts LOBSTERAI_MODELS_PATH)
|
||||
models_remote = {
|
||||
-- 参考真值 LOBSTERAI_API_BASE(lobsterai-server.youdao.com)。
|
||||
@ -727,7 +745,17 @@ local PLATFORMS = {
|
||||
vendor = "opencode",
|
||||
flow = "paste",
|
||||
-- opencode 的 chat 路径是 /v1/chat/completions(参考 chatPath)。
|
||||
base_url = "https://opencode.ai/zen/v1/chat/completions",
|
||||
-- ★ base_url 必须保留 `/zen` 段:参考 opencode-product.ts:56 的
|
||||
-- `baseUrl: 'https://opencode.ai/zen'` + chatPath `/v1/chat/completions`。
|
||||
-- 实测(无凭据):
|
||||
-- https://opencode.ai/zen/v1/chat/completions → 500(路由存在,鉴权前先炸)
|
||||
-- https://opencode.ai/v1/chat/completions → 404 + HTML(网页)
|
||||
base_url = "https://opencode.ai/zen",
|
||||
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:内核的 URL 是
|
||||
-- `base_url + endpoint`(provider.go ChatURL),而适配器默认 endpoint 是
|
||||
-- "/chat/completions" ⇒ 若 base_url 已含完整路径,实际请求就变成
|
||||
-- .../v1/chat/completions/chat/completions ⇒ 404 Route Not Found。
|
||||
endpoint = "/v1/chat/completions",
|
||||
-- 远端模型目录(照参考 opencode-product.ts modelsPath)
|
||||
models_remote = {
|
||||
endpoints = { { url = "https://opencode.ai/v1/models", method = "GET" } },
|
||||
@ -853,7 +881,13 @@ local PLATFORMS = {
|
||||
interval = 2,
|
||||
},
|
||||
-- qoder 的 chat 路径是 /model/v1/chat/completions(参考 QODER_CHAT_PATH)。
|
||||
base_url = "https://api2-v2.qoder.sh/model/v1/chat/completions",
|
||||
base_url = "https://api2-v2.qoder.sh",
|
||||
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
|
||||
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
|
||||
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
|
||||
-- 完整路径,实际请求就变成 .../model/v1/chat/completions/chat/completions ⇒ 上游
|
||||
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
|
||||
endpoint = "/model/v1/chat/completions",
|
||||
-- Qoder 的错误信封是**双层编码**的,且 10605(排队) 与 110(额度耗尽)
|
||||
-- 语义相反 —— 通用 openai 适配器只能看到外层,产出用户看不懂的原因串。
|
||||
-- 故用本插件自装的 channels-qoder(厂商码留在插件域,内核零厂商名)。
|
||||
@ -1007,9 +1041,24 @@ local PLATFORMS = {
|
||||
access_key_secret = "zsak6eadrbawz683wf5r3m2snrwj868r",
|
||||
app_id = "GM3LOOMY",
|
||||
},
|
||||
-- loomy 的 chat 路径是 {apiBase}/chat/completions(参考 loomy-adapter.ts:494
|
||||
-- 实测标准 OpenAI 兼容)。
|
||||
base_url = "https://loomy.xunfei.cn/chat/completions",
|
||||
-- loomy 的 chat 端是 {apiBase}/chat/completions(参考 loomy-adapter.ts:494)。
|
||||
--
|
||||
-- ★ host 是 **loomyad.xunfei.cn/api/v1**(参考 LOOMY_API_BASE),不是
|
||||
-- `loomy.xunfei.cn` —— 后者是 **Web 前端/OAuth 回调**域(本块的
|
||||
-- wechat.redirect_uri 正用它)。实测:
|
||||
-- POST https://loomy.xunfei.cn/chat/completions
|
||||
-- → 200 + `<!DOCTYPE html>`(网页!根本不是 API)
|
||||
-- POST https://loomyad.xunfei.cn/api/v1/chat/completions
|
||||
-- → 200 + `{"code":"100002","desc":"缺少 token"}`(正常 JSON)
|
||||
-- 写成前端域时,无论路径怎么拼都不可能是 API。
|
||||
--
|
||||
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:内核的 URL 是
|
||||
-- `base_url + endpoint`(provider.go ChatURL),而适配器默认 endpoint 是
|
||||
-- "/chat/completions" ⇒ 若 base_url 已含完整路径,实际请求就变成
|
||||
-- .../chat/completions/chat/completions ⇒ 上游回
|
||||
-- `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
|
||||
base_url = "https://loomyad.xunfei.cn/api/v1",
|
||||
endpoint = "/chat/completions",
|
||||
adapter = "openai",
|
||||
-- 兜底目录(照参考 LOOMY_FALLBACK_MODELS)。此前 "loomy-default" 是我编的。
|
||||
models = {
|
||||
@ -1044,7 +1093,13 @@ local PLATFORMS = {
|
||||
},
|
||||
-- 推理是标准 OpenAI 兼容(实测 POST /api/web/llm/v2/chat/completions)
|
||||
-- raccoon 的 chat 端点是 /api/web/llm/v2/chat/completions。
|
||||
base_url = "https://xiaohuanxiong.com/api/web/llm/v2/chat/completions",
|
||||
base_url = "https://xiaohuanxiong.com",
|
||||
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
|
||||
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
|
||||
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
|
||||
-- 完整路径,实际请求就变成 .../api/web/llm/v2/chat/completions/chat/completions ⇒ 上游
|
||||
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
|
||||
endpoint = "/api/web/llm/v2/chat/completions",
|
||||
adapter = "openai",
|
||||
-- 兜底目录(照参考 RACCOON_FALLBACK_MODELS)。此前 "raccoon-default" 是我编的。
|
||||
-- 注意 sn- 前缀是本平台的真实命名约定(sensenova 系列)。
|
||||
@ -3161,17 +3216,80 @@ local function parseRemoteModels(kind, body, provider)
|
||||
end
|
||||
end
|
||||
|
||||
elseif kind == "buddy_config" then
|
||||
-- 照参考 parseModelsFromConfig:结果在 data.models / data.agents 结构里。
|
||||
-- 多形态兜底(顶层 data / 直接 models),因为该端点历史上变过形状。
|
||||
local d = rec.data or rec
|
||||
local arr = d.models or d.agents or rec.models
|
||||
if type(arr) == "table" then
|
||||
for _, m in ipairs(arr) do
|
||||
local id = type(m) == "table" and (m.id or m.modelId or m.name)
|
||||
if type(id) == "string" and id ~= "" then out[#out+1] = id end
|
||||
elseif kind == "buddy_config" then
|
||||
-- 照参考 parseModelsFromConfig:结果在 data.models / data.agents 结构里。
|
||||
-- 多形态兜底(顶层 data / 直接 models),因为该端点历史上变过形状。
|
||||
--
|
||||
-- ★★ 必须过滤「不可调用」的条目 —— 远端 data.models 里混着三类**不是对话
|
||||
-- 模型**的东西,列进模型选择器只会让用户选中后报错(实测生产:glm-5.1
|
||||
-- 与 glm-5.2 之外,codewise-* / nes-gf / hunyuan-image-alpha 同样调不通)。
|
||||
-- 判据与命名逐条照参考 buddy.ts 的 isChatModel / isAutoSelectAlias:
|
||||
-- ① 自动选择类内部别名:`auto` / `default`
|
||||
-- ⚠️ 只按**这两个字面量**过滤,不做前缀匹配 —— 前缀会误伤
|
||||
-- `default-model` / `fast-model`(国际版抽象别名,但被 craft agent
|
||||
-- 引用,是官方推荐入口)。
|
||||
-- ② 补全/NES 专用:前缀 `nes-` / `completion-` / `codewise-`,
|
||||
-- 或带 `supportsExtra` 标记。参考实测 codewise-default-model-v2
|
||||
-- 返回 `code 11102 model service info not found`(后端未开放)。
|
||||
-- ③ 输出上限过小(>0 且 ≤256):补全用途,对话模型普遍 ≥24000。
|
||||
-- ④ 带 `text-to-image` 标签的生成式模型(如 hunyuan-image-alpha)。
|
||||
-- ⚠️ ③④ 需要元数据,故先把 models 建成 id→meta 映射。
|
||||
local d = rec.data or rec
|
||||
local arr = d.models or d.agents or rec.models
|
||||
local metaById = {}
|
||||
if type(arr) == "table" then
|
||||
for _, m in ipairs(arr) do
|
||||
if type(m) == "table" and type(m.id) == "string" and m.id ~= "" then
|
||||
metaById[m.id] = m
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- 被 agent 引用的模型(craft/ask/plan/cli)。参考据此给 `agentReferenced`
|
||||
-- 打标:`hy4-preview-f` 只由本端点下发且被 agent 引用,白名单重建会丢掉它。
|
||||
local agentRef = {}
|
||||
if type(d.agents) == "table" then
|
||||
for _, ag in ipairs(d.agents) do
|
||||
if type(ag) == "table" and type(ag.models) == "table" then
|
||||
for _, mid in ipairs(ag.models) do
|
||||
if type(mid) == "string" then agentRef[mid] = true end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local function buddyIsChatModel(id)
|
||||
if id == "auto" or id == "default" then return false end
|
||||
if id:sub(1, 4) == "nes-" or id:sub(1, 11) == "completion-"
|
||||
or id:sub(1, 9) == "codewise-" then return false end
|
||||
local m = metaById[id]
|
||||
if type(m) == "table" then
|
||||
if m.supportsExtra == true then return false end
|
||||
local mo = tonumber(m.maxOutputTokens)
|
||||
if mo and mo > 0 and mo <= 256 then return false end
|
||||
if type(m.tags) == "table" then
|
||||
for _, t in ipairs(m.tags) do
|
||||
if t == "text-to-image" then return false end
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
local seen = {}
|
||||
local function pushId(id)
|
||||
if type(id) ~= "string" or id == "" then return end
|
||||
if seen[id] or not buddyIsChatModel(id) then return end
|
||||
seen[id] = true
|
||||
out[#out + 1] = id
|
||||
end
|
||||
-- models 与 agents 两处都收(agents 里是被引用的清单,可能含远端
|
||||
-- data.models 没有的新变体)。models 优先,保持顺序权威。
|
||||
if type(arr) == "table" then
|
||||
for _, m in ipairs(arr) do
|
||||
local id = type(m) == "table" and (m.id or m.modelId or m.name) or nil
|
||||
if type(id) == "string" then pushId(id) end
|
||||
end
|
||||
end
|
||||
for id in pairs(agentRef) do pushId(id) end
|
||||
|
||||
|
||||
elseif kind == "codearts_builtin" then
|
||||
-- 照参考 fetchCodeArtsRemoteModels 的第 2 个端点:字段是 builtinModels
|
||||
@ -11117,7 +11235,11 @@ function plugin.__inject_refresh_platform_probe(args)
|
||||
label = id,
|
||||
flow = "manual",
|
||||
adapter = "openai",
|
||||
base_url = "http://127.0.0.1:1/v1/chat/completions",
|
||||
base_url = "http://127.0.0.1:1/v1",
|
||||
-- 照正式平台同样的分工:base_url 只到路径前缀,路径放 endpoint
|
||||
-- (内核 URL = base_url + endpoint)。测试夹具也照此写,
|
||||
-- 免得将来有人把这行当范例复制进正式平台。
|
||||
endpoint = "/chat/completions",
|
||||
refresh = { style = tostring(args.style or "google"), url = tostring(args.refresh_url or "") },
|
||||
models = { "m1" },
|
||||
hosts = { "127.0.0.1" },
|
||||
|
||||
Reference in New Issue
Block a user