mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-09 18:57:21 +00:00
Compare commits
12 Commits
v1.9.5
...
feature/qw
| Author | SHA1 | Date | |
|---|---|---|---|
| 62d8f21969 | |||
| 58e9cb2524 | |||
| e30199b29d | |||
| a1fddf72b7 | |||
| b9a025efba | |||
| 7c7cf3e078 | |||
| 18525ae4c0 | |||
| c4b37d4322 | |||
| 33b778faee | |||
| 345cbb30d6 | |||
| 501ad6aac9 | |||
| 39cda89def |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.5",
|
||||
"version": "1.9.4",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
|
||||
@ -256,17 +256,17 @@ func (g *Gateway) checkQuota(ctx context.Context, model string) *quotaRejection
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// A scope that simply does not list the model is a different problem from
|
||||
// a quota being exhausted, and the message has to say which: telling an
|
||||
// operator "not allowed for this key" when they configured a quota sends
|
||||
// them to the wrong setting. AUTO is checked here before the chain is even
|
||||
// consulted, so this is the message that surfaces for AUTO.
|
||||
if isAuto(model) {
|
||||
return "aRejection{msg: fmt.Sprintf(
|
||||
"model %q is not in this key's model scope, so it cannot use AUTO; "+
|
||||
"add %q to the key's models or remove the scope restriction", model, model)}
|
||||
}
|
||||
return "aRejection{msg: fmt.Sprintf("model %q is not allowed for this key", model)}
|
||||
// A scope that simply does not list the model is a different problem from
|
||||
// a quota being exhausted, and the message has to say which: telling an
|
||||
// operator "not allowed for this key" when they configured a quota sends
|
||||
// them to the wrong setting. AUTO is checked here before the chain is even
|
||||
// consulted, so this is the message that surfaces for AUTO.
|
||||
if isAuto(model) {
|
||||
return "aRejection{msg: fmt.Sprintf(
|
||||
"model %q is not in this key's model scope, so it cannot use AUTO; "+
|
||||
"add %q to the key's models or remove the scope restriction", model, model)}
|
||||
}
|
||||
return "aRejection{msg: fmt.Sprintf("model %q is not allowed for this key", model)}
|
||||
}
|
||||
|
||||
// quotaWindowSuffix describes a quota's reset window for an error message, so
|
||||
@ -1059,9 +1059,9 @@ func (g *Gateway) fireEnd(rec *Req) {
|
||||
// A SUBSET of completion_tokens, not an extra. Billing plugins that
|
||||
// add it to completion would double-charge every thinking token.
|
||||
"reasoning_tokens": rec.Reasoning,
|
||||
"image_count": rec.ImageCount,
|
||||
"error": rec.Err,
|
||||
"time": rec.Time,
|
||||
"image_count": rec.ImageCount,
|
||||
"error": rec.Err,
|
||||
"time": rec.Time,
|
||||
// chain_walk: the AUTO tier-by-tier trace, when the request went
|
||||
// through the chain. Empty for a direct request and for a gateway with
|
||||
// no plugins loaded. Absent rather than empty so a plugin can tell
|
||||
@ -1215,20 +1215,7 @@ func (g *Gateway) pumpStream(w http.ResponseWriter, rec *Req, chunks <-chan type
|
||||
var tut *types.TokenUsage
|
||||
if lastUsage != nil {
|
||||
tut = lastUsage
|
||||
// Write the upstream's cache accounting back onto the request record
|
||||
// so the audit trail carries the hit/miss split for streaming too.
|
||||
if d := tut.PromptTokensDetails; d != nil {
|
||||
rec.CacheReported = true
|
||||
if d.CachedTokens > 0 {
|
||||
rec.CacheHit = int64(d.CachedTokens)
|
||||
}
|
||||
} else if tut.PromptCacheHit > 0 {
|
||||
rec.CacheHit = int64(tut.PromptCacheHit)
|
||||
rec.CacheReported = true
|
||||
}
|
||||
if tut.PromptCacheMiss > 0 {
|
||||
rec.CacheMiss = int64(tut.PromptCacheMiss)
|
||||
}
|
||||
applyStreamUsageDetails(rec, lastUsage)
|
||||
} else if rec.Prompt+rec.Compl > 0 {
|
||||
u := types.TokenUsage{
|
||||
Prompt: int(rec.Prompt),
|
||||
@ -1459,3 +1446,39 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
Data: resp.ImageData,
|
||||
})
|
||||
}
|
||||
|
||||
// applyStreamUsageDetails mirrors the upstream's reasoning and cache numbers
|
||||
// onto a STREAMED request's audit record.
|
||||
//
|
||||
// It exists as a named function because this was inline in the streaming handler,
|
||||
// where it was untestable — and untested it stayed wrong. The non-streaming
|
||||
// recorder read completion_tokens_details.reasoning_tokens; this one did not. So
|
||||
// a source's streamed requests recorded completion and no reasoning at all while
|
||||
// its non-streamed calls recorded both. Nothing failed and no test was red: the
|
||||
// audit trail was simply quieter than it should have been.
|
||||
//
|
||||
// Found by reading the production audit file after wiring a reasoning-heavy
|
||||
// source: completion=8166 with reasoning absent, on every streamed request.
|
||||
//
|
||||
// Reasoning is a SUBSET of completion, never an addition to it. See
|
||||
// recordChatUsage for why that matters to billing.
|
||||
func applyStreamUsageDetails(rec *Req, u *types.TokenUsage) {
|
||||
if d := u.CompletionTokensDetails; d != nil && d.ReasoningTokens > 0 {
|
||||
rec.Reasoning = int64(d.ReasoningTokens)
|
||||
}
|
||||
// Cache accounting is here for the same reason it is kept apart: "zero hits
|
||||
// because nothing was cached" and "this provider never reports caching" are
|
||||
// the same number and mean opposite things.
|
||||
if d := u.PromptTokensDetails; d != nil {
|
||||
rec.CacheReported = true
|
||||
if d.CachedTokens > 0 {
|
||||
rec.CacheHit = int64(d.CachedTokens)
|
||||
}
|
||||
} else if u.PromptCacheHit > 0 {
|
||||
rec.CacheHit = int64(u.PromptCacheHit)
|
||||
rec.CacheReported = true
|
||||
}
|
||||
if u.PromptCacheMiss > 0 {
|
||||
rec.CacheMiss = int64(u.PromptCacheMiss)
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,3 +69,92 @@ func TestRecordChatUsageReasoningAbsentStaysZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamedReasoningIsRecordedOnTheRequest is the streaming counterpart.
|
||||
//
|
||||
// TestRecordChatUsageKeepsReasoningTokens covers the non-streaming path only,
|
||||
// and for a long time the streamed path dropped the number: the non-streaming
|
||||
// recorder read TokenUsage.CompletionTokensDetails, while the streaming one read
|
||||
// only Prompt and Completion from the terminal usage chunk. (Cache accounting was
|
||||
// mirrored; reasoning simply never was.)
|
||||
//
|
||||
// The symptom is invisible in the response — the client sees a correct answer
|
||||
// either way. It only shows in the audit trail, where a reasoning-heavy source's
|
||||
// streamed requests recorded completion=8166 and no reasoning at all. Found by
|
||||
// reading the production audit file, not by a failing test, which is exactly why
|
||||
// the property is pinned here.
|
||||
func TestStreamedReasoningIsRecordedOnTheRequest(t *testing.T) {
|
||||
rec := &Req{}
|
||||
applyStreamUsageDetails(rec, &types.TokenUsage{
|
||||
Prompt: 159579,
|
||||
Completion: 751,
|
||||
Total: 160330,
|
||||
CompletionTokensDetails: &types.CompletionTokensDetails{
|
||||
ReasoningTokens: 512,
|
||||
},
|
||||
})
|
||||
if rec.Reasoning != 512 {
|
||||
t.Errorf("streamed Reasoning = %d, want 512 — a streamed request records no "+
|
||||
"reasoning, so a pricing rule that splits thinking from output silently "+
|
||||
"bills it as ordinary output", rec.Reasoning)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamedReasoningIsNeverAddedToCompletion pins the SUBSET relation in the
|
||||
// streaming path specifically. A presence check would pass if someone wrote
|
||||
// rec.Compl += reasoning, and billing would then charge every thinking token
|
||||
// twice.
|
||||
func TestStreamedReasoningIsNeverAddedToCompletion(t *testing.T) {
|
||||
rec := &Req{}
|
||||
rec.Compl = 751 // set upstream of the call, as the streaming handler does
|
||||
applyStreamUsageDetails(rec, &types.TokenUsage{
|
||||
Completion: 751,
|
||||
CompletionTokensDetails: &types.CompletionTokensDetails{ReasoningTokens: 512},
|
||||
})
|
||||
if rec.Compl != 751 {
|
||||
t.Errorf("Compl = %d, want 751 unchanged — reasoning is a subset, not an addition", rec.Compl)
|
||||
}
|
||||
if rec.Reasoning >= rec.Compl {
|
||||
t.Errorf("Reasoning (%d) must be a SUBSET of Compl (%d)", rec.Reasoning, rec.Compl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamedUsageWithoutReasoningLeavesItUnset: an upstream that reports no
|
||||
// reasoning must not be turned into a reported zero. In the audit trail 0 means
|
||||
// "not reported", and a plugin cannot tell that from "no reasoning happened".
|
||||
func TestStreamedUsageWithoutReasoningLeavesItUnset(t *testing.T) {
|
||||
rec := &Req{}
|
||||
applyStreamUsageDetails(rec, &types.TokenUsage{Prompt: 10, Completion: 4, Total: 14})
|
||||
if rec.Reasoning != 0 {
|
||||
t.Errorf("Reasoning = %d for an upstream that reported none", rec.Reasoning)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamedCacheAccountingStillWorks is a regression guard for the block the
|
||||
// reasoning lines were added to. They share a function, so a future edit that
|
||||
// short-circuits one would break the other — silently, since a missing cache
|
||||
// number reads as "nothing was cached".
|
||||
func TestStreamedCacheAccountingStillWorks(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
u types.TokenUsage
|
||||
hit int64
|
||||
miss int64
|
||||
rep bool
|
||||
}{
|
||||
{"details object", types.TokenUsage{
|
||||
PromptTokensDetails: &types.PromptTokensDetails{CachedTokens: 4096},
|
||||
}, 4096, 0, true},
|
||||
{"legacy hit/miss", types.TokenUsage{
|
||||
PromptCacheHit: 2048, PromptCacheMiss: 512,
|
||||
}, 2048, 512, true},
|
||||
{"nothing reported", types.TokenUsage{}, 0, 0, false},
|
||||
} {
|
||||
rec := &Req{}
|
||||
applyStreamUsageDetails(rec, &c.u)
|
||||
if rec.CacheHit != c.hit || rec.CacheMiss != c.miss || rec.CacheReported != c.rep {
|
||||
t.Errorf("%s: hit=%d miss=%d reported=%v; want %d/%d/%v",
|
||||
c.name, rec.CacheHit, rec.CacheMiss, rec.CacheReported, c.hit, c.miss, c.rep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
323
internal/lua/adapter_qwen38_test.go
Normal file
323
internal/lua/adapter_qwen38_test.go
Normal file
@ -0,0 +1,323 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The fixtures below are REAL responses captured from
|
||||
// https://discovery-api.intern-ai.org.cn/v1 with qwen3.8-27b — not hand-written
|
||||
// approximations. Two of them exist only because the endpoint behaves in a way a
|
||||
// stock OpenAI adapter gets wrong, and neither is guessable from the schema.
|
||||
|
||||
// reasoningDominatesTheBudget is the fact that made a dedicated adapter
|
||||
// necessary: a one-sentence visual answer came back as completion=303 with
|
||||
// reasoning=295. 97% of the budget went to thinking and only 8 tokens reached
|
||||
// content. Handing the upstream's content straight through yields what looks
|
||||
// like an empty answer from a perfectly healthy request.
|
||||
const reasoningDominatesTheBudget = `{
|
||||
"choices": [{"index":0,"message":{"role":"assistant","content":"\n\n这张图是白色的。",
|
||||
"reasoning_content":"分析请求…分析图片…颜色是纯白"},"logprobs":null,
|
||||
"finish_reason":"stop"}],
|
||||
"created":1791179068,"model":"qwen3.8-27b-fp8","object":"chat.completion",
|
||||
"usage":{"prompt_tokens":125,"total_tokens":428,"completion_tokens":303,
|
||||
"prompt_tokens_details":null,
|
||||
"completion_tokens_details":{"reasoning_tokens":295}}
|
||||
}`
|
||||
|
||||
// truncatedReasoningOnly is what a small max_tokens produces: HTTP 200, content
|
||||
// null, finish_reason "length". It is NOT an upstream failure.
|
||||
const truncatedReasoningOnly = `{
|
||||
"choices": [{"index":0,"message":{"role":"assistant","content":null,
|
||||
"reasoning_content":"思考过程:1. **分析请求**…图片的颜色是纯","refusal":null},
|
||||
"finish_reason":"length"}],
|
||||
"model":"qwen3.8-27b-fp8","object":"chat.completion",
|
||||
"usage":{"prompt_tokens":127,"total_tokens":190,"completion_tokens":63}
|
||||
}`
|
||||
|
||||
const qwen38ToolCallResponse = `{
|
||||
"choices": [{"index":0,"message":{"role":"assistant","content":"",
|
||||
"reasoning_content":"用户询问北京天气。我需要调用 get_weather 函数,参数为城市 \"北京\"。",
|
||||
"tool_calls":[{"id":"chatcmpl-tool-afc0299b5810e044","type":"function",
|
||||
"function":{"name":"get_weather","arguments":"{\"city\": \"北京\"}"}}]},
|
||||
"finish_reason":"tool_calls"}],
|
||||
"model":"qwen3.8-27b-fp8",
|
||||
"usage":{"prompt_tokens":305,"total_tokens":353,"completion_tokens":48,
|
||||
"completion_tokens_details":{"reasoning_tokens":20}}
|
||||
}`
|
||||
|
||||
func qwen38VM(t *testing.T) *VM {
|
||||
t.Helper()
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("adapter VM: %v", err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
return vm
|
||||
}
|
||||
|
||||
func qwen38Response(t *testing.T, vm *VM, raw string) map[string]interface{} {
|
||||
t.Helper()
|
||||
out, err := vm.Transform("qwen38", "transform_response", raw)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_response: %v", err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &m); err != nil {
|
||||
t.Fatalf("adapter returned non-JSON: %v\n%s", err, out)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func qwen38Stream(t *testing.T, vm *VM, raw string) map[string]interface{} {
|
||||
t.Helper()
|
||||
out, err := vm.Transform("qwen38", "transform_stream_chunk", raw)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_stream_chunk: %v", err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &m); err != nil {
|
||||
t.Fatalf("adapter returned non-JSON: %v\n%s", err, out)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// TestQwen38KeepsReasoningSeparate is the adapter's reason for existing.
|
||||
// Folding reasoning into content would render the model's private deliberation as
|
||||
// its answer; dropping it makes a reasoning model look like it returned nothing.
|
||||
func TestQwen38KeepsReasoningSeparate(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
u := qwen38Response(t, vm, reasoningDominatesTheBudget)
|
||||
|
||||
content, _ := u["content"].(string)
|
||||
reasoning, _ := u["reasoning_content"].(string)
|
||||
|
||||
if reasoning == "" {
|
||||
t.Fatal("reasoning_content was dropped — a reasoning model then looks like it returned nothing")
|
||||
}
|
||||
if strings.Contains(content, "分析请求") {
|
||||
t.Errorf("reasoning was folded into content: %q", content)
|
||||
}
|
||||
if !strings.Contains(content, "白色的") {
|
||||
t.Errorf("content = %q, want the answer text", content)
|
||||
}
|
||||
if reasoning == content {
|
||||
t.Error("content and reasoning_content are identical; they are disjoint upstream")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwen38ReportsTruncationRatherThanFailing: content=null with
|
||||
// finish_reason="length" is a truncated reasoning response, not a broken
|
||||
// upstream. Losing the finish_reason makes it indistinguishable from a failure,
|
||||
// and losing the partial reasoning throws away the only evidence of what happened.
|
||||
func TestQwen38ReportsTruncationRatherThanFailing(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
u := qwen38Response(t, vm, truncatedReasoningOnly)
|
||||
|
||||
if u["finish_reason"] != "length" {
|
||||
t.Errorf("finish_reason = %v, want length", u["finish_reason"])
|
||||
}
|
||||
if reasoning, _ := u["reasoning_content"].(string); reasoning == "" {
|
||||
t.Error("the partial reasoning was discarded")
|
||||
}
|
||||
if content, _ := u["content"].(string); content != "" {
|
||||
t.Errorf("content = %q, want empty string for a null upstream content", content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwen38CarriesReasoningTokensWithoutInflatingTotals: reasoning_tokens is a
|
||||
// SUBSET of completion_tokens. Adding them double-counts every reasoning request,
|
||||
// and billing prices the pair separately.
|
||||
func TestQwen38CarriesReasoningTokensWithoutInflatingTotals(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
u := qwen38Response(t, vm, reasoningDominatesTheBudget)
|
||||
|
||||
tu, ok := u["token_usage"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("token_usage = %#v", u["token_usage"])
|
||||
}
|
||||
completion, _ := tu["completion"].(float64)
|
||||
total, _ := tu["total"].(float64)
|
||||
|
||||
// The NESTING is the point, not the presence of the field.
|
||||
//
|
||||
// internal/gateway/chat.go reads reasoning in exactly one place:
|
||||
// resp.TokenUsage.CompletionTokensDetails.ReasoningTokens
|
||||
// An adapter that emits reasoning_tokens at the TOP of token_usage type-checks
|
||||
// fine and is then dropped at the one spot that could have kept it — measured
|
||||
// on the live gateway: the model reported completion=88 and the gateway's
|
||||
// response carried reasoning=null.
|
||||
//
|
||||
// So this asserts the path, not merely that a number appears somewhere.
|
||||
det, ok := tu["completion_tokens_details"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("token_usage has no completion_tokens_details: %#v — chat.go reads "+
|
||||
"reasoning from there and nowhere else, so the number is discarded", tu)
|
||||
}
|
||||
reasoning, has := det["reasoning_tokens"].(float64)
|
||||
if !has {
|
||||
t.Fatal("completion_tokens_details.reasoning_tokens missing — billing cannot price reasoning separately")
|
||||
}
|
||||
// Relational, not a presence check. The first version of this test only
|
||||
// asserted the field existed, and a mutation setting it to 0 passed: 0 is
|
||||
// present. The upstream value is known (295), so the exact number is
|
||||
// asserted — a zero silently misprices every reasoning request.
|
||||
if reasoning != 295 {
|
||||
t.Errorf("reasoning_tokens = %v, want 295 (the upstream value)", reasoning)
|
||||
}
|
||||
if reasoning >= completion {
|
||||
t.Errorf("reasoning_tokens (%v) must be a SUBSET of completion_tokens (%v)", reasoning, completion)
|
||||
}
|
||||
if completion != 303 || total != 428 {
|
||||
t.Errorf("usage was altered: completion=%v total=%v, want 303/428", completion, total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwen38DecodesToolCallArguments: the upstream sends arguments as a JSON
|
||||
// STRING, like every other OpenAI-compatible endpoint. Passing it through
|
||||
// undecoded leaves the caller to parse it.
|
||||
func TestQwen38DecodesToolCallArguments(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
u := qwen38Response(t, vm, qwen38ToolCallResponse)
|
||||
|
||||
tcs, ok := u["tool_calls"].([]interface{})
|
||||
if !ok || len(tcs) != 1 {
|
||||
t.Fatalf("tool_calls = %#v", u["tool_calls"])
|
||||
}
|
||||
tc, _ := tcs[0].(map[string]interface{})
|
||||
if tc["name"] != "get_weather" {
|
||||
t.Errorf("name = %v", tc["name"])
|
||||
}
|
||||
args, ok := tc["arguments"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("arguments = %#v — the upstream sends a JSON string and it must be decoded", tc["arguments"])
|
||||
}
|
||||
if args["city"] != "北京" {
|
||||
t.Errorf("arguments.city = %v", args["city"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwen38StreamKeepsReasoningSeparate: measured 20 reasoning chunks against 1
|
||||
// content chunk on a one-sentence answer. Folding them would make the client
|
||||
// render the deliberation as the reply.
|
||||
func TestQwen38StreamKeepsReasoningSeparate(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
const reasoningChunk = `{"choices":[{"index":0,"delta":{"reasoning_content":"思考过程:1. **分析请求**"},
|
||||
"finish_reason":null}],"model":"qwen3.8-27b-fp8"}`
|
||||
const contentChunk = `{"choices":[{"index":0,"delta":{"content":"递归就是函数调用自身"},
|
||||
"finish_reason":null}],"model":"qwen3.8-27b-fp8"}`
|
||||
const usageChunk = `{"choices":[],"usage":{"prompt_tokens":64,"total_tokens":132,
|
||||
"completion_tokens":68,"completion_tokens_details":{"reasoning_tokens":62}}}`
|
||||
|
||||
a := qwen38Stream(t, vm, reasoningChunk)
|
||||
b := qwen38Stream(t, vm, contentChunk)
|
||||
c := qwen38Stream(t, vm, usageChunk)
|
||||
|
||||
if content, mixed := a["content"].(string); mixed && content != "" {
|
||||
t.Errorf("a reasoning chunk leaked into content: %q", content)
|
||||
}
|
||||
if reasoning, _ := a["reasoning_content"].(string); !strings.Contains(reasoning, "思考过程") {
|
||||
t.Errorf("reasoning chunk lost its text: %#v", a)
|
||||
}
|
||||
if content, _ := b["content"].(string); !strings.Contains(content, "递归") {
|
||||
t.Errorf("content chunk lost its text: %#v", b)
|
||||
}
|
||||
if c["usage"] == nil {
|
||||
t.Errorf("the terminal usage chunk was dropped: %#v", c)
|
||||
return
|
||||
}
|
||||
u, _ := c["usage"].(map[string]interface{})
|
||||
det, _ := u["completion_tokens_details"].(map[string]interface{})
|
||||
if det == nil || det["reasoning_tokens"] == nil {
|
||||
t.Errorf("usage chunk did not nest reasoning under completion_tokens_details: %#v — "+
|
||||
"chat.go reads it there, so a top-level field is discarded", u)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwen38ReportsMediaFailureCode: a remote image URL times out upstream and
|
||||
// comes back as media_fetch_failed. Surfacing only the message makes an image
|
||||
// fetch problem look like a model problem.
|
||||
func TestQwen38ReportsMediaFailureCode(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
body := `{"error":{"message":"image fetch timed out","type":"media_fetch_failed",
|
||||
"param":null,"code":"media_fetch_failed","trace_id":"f3ca4aa"}}`
|
||||
reason, ok, err := vm.TransformError("qwen38", 400, body)
|
||||
if err != nil {
|
||||
t.Fatalf("TransformError: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("the media failure was swallowed")
|
||||
}
|
||||
if !strings.Contains(reason, "image fetch timed out") || !strings.Contains(reason, "media_fetch_failed") {
|
||||
t.Errorf("reason = %q; it must carry both the text and the code", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwen38InvalidKeyError: the endpoint answers a bad key with 401 and
|
||||
// code "invalid_api_key". That one must NOT be annotated with its own code — it
|
||||
// would render as "invalid API key (invalid_api_key)".
|
||||
func TestQwen38InvalidKeyError(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
body := `{"error":{"message":"invalid API key","type":"invalid_request_error",
|
||||
"param":null,"code":"invalid_api_key"}}`
|
||||
reason, ok, err := vm.TransformError("qwen38", 401, body)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("TransformError: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if reason != "invalid API key" {
|
||||
t.Errorf("reason = %q, want the bare message", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwen38SuppliesReasoningOnToolCallTurns: the upstream requires an assistant
|
||||
// message carrying tool_calls to echo reasoning_content. OpenAI-shaped clients
|
||||
// never send it, so without this the SECOND turn of any tool-using conversation
|
||||
// is rejected.
|
||||
func TestQwen38SuppliesReasoningOnToolCallTurns(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
const req = `{"model":"qwen3.8-27b","messages":[
|
||||
{"role":"user","content":"北京天气"},
|
||||
{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function",
|
||||
"function":{"name":"get_weather","arguments":"{\"city\":\"北京\"}"}}]},
|
||||
{"role":"tool","tool_call_id":"c1","content":"晴 25C"}]}`
|
||||
out, err := vm.Transform("qwen38", "transform_request", req)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_request: %v", err)
|
||||
}
|
||||
var decoded map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &decoded); err != nil {
|
||||
t.Fatalf("request transform did not return JSON: %v\n%s", err, out)
|
||||
}
|
||||
msgs, _ := decoded["messages"].([]interface{})
|
||||
if len(msgs) < 2 {
|
||||
t.Fatalf("messages = %#v", decoded["messages"])
|
||||
}
|
||||
assistant, _ := msgs[1].(map[string]interface{})
|
||||
if _, ok := assistant["reasoning_content"]; !ok {
|
||||
t.Errorf("assistant turn with tool_calls has no reasoning_content; the upstream "+
|
||||
"rejects the next turn. Got %#v", assistant)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwen38PassesImagesThrough: this adapter's job is reasoning handling, not
|
||||
// media translation. A multimodal content array must survive untouched — an
|
||||
// adapter that re-encodes it is the kind of change that silently breaks images
|
||||
// while every text test still passes.
|
||||
func TestQwen38PassesImagesThrough(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
const req = `{"model":"qwen3.8-27b","messages":[{"role":"user","content":[
|
||||
{"type":"text","text":"这张图是什么颜色?"},
|
||||
{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}]}],
|
||||
"max_tokens":600}`
|
||||
out, err := vm.Transform("qwen38", "transform_request", req)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_request: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "image_url") || !strings.Contains(out, "data:image/png;base64,") {
|
||||
t.Errorf("the image part was lost or rewritten:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "base64,iVBORw0KGgo=") == false {
|
||||
t.Errorf("the base64 payload was altered:\n%s", out)
|
||||
}
|
||||
}
|
||||
235
internal/lua/adapters/qwen38.lua
Normal file
235
internal/lua/adapters/qwen38.lua
Normal file
@ -0,0 +1,235 @@
|
||||
-- tokenplan / discovery-api (intern-ai.org.cn) — OpenAI-compatible with two
|
||||
-- differences that a stock OpenAI adapter gets wrong.
|
||||
--
|
||||
-- 1. REASONING IS A SEPARATE FIELD, AND IT IS MOST OF THE OUTPUT.
|
||||
-- Every response carries `reasoning_content` (non-stream) or
|
||||
-- `delta.reasoning_content` (stream), disjoint from `content`. Measured on
|
||||
-- qwen3.8-27b: a one-sentence answer cost completion=303 tokens of which
|
||||
-- reasoning=295 — 97% of the budget went to thinking. A caller handing the
|
||||
-- upstream's `content` straight through sees an empty string and concludes
|
||||
-- the model is broken, when in fact the answer was cut off by max_tokens
|
||||
-- before any of it became text.
|
||||
--
|
||||
-- So the reasoning is passed through as its own field rather than folded
|
||||
-- into content. Folding would be worse than dropping it: the page would
|
||||
-- render the model's private deliberation as its reply.
|
||||
--
|
||||
-- 2. `finish_reason: "length"` WITH `content: null` IS NORMAL HERE, NOT AN ERROR.
|
||||
-- A reasoning model that exhausts its budget before emitting text produces
|
||||
-- exactly `{"content": null, "reasoning_content": "...", "finish_reason":
|
||||
-- "length"}`. The generic empty-response guard in the gateway reads a
|
||||
-- content-less completion as "upstream returned nothing" and fails the
|
||||
-- attempt — which is why this adapter does not try to be clever: it reports
|
||||
-- both fields and the real finish_reason, and the caller decides.
|
||||
--
|
||||
-- 3. MODEL NAMES ARE REWRITTEN BY THE SERVER.
|
||||
-- Requesting `qwen3.8-27b` answers with `model: "qwen3.8-27b-fp8"`. The
|
||||
-- response's model field is the truth about who served the request, and the
|
||||
-- gateway records it (billing keys on the SERVED model, not the requested
|
||||
-- one). Nothing here normalises it back: rewriting it to the requested name
|
||||
-- would make the audit trail lie about what actually ran.
|
||||
--
|
||||
-- Also worth knowing when configuring this source: remote image URLs are
|
||||
-- fetched BY THE UPSTREAM and time out reliably (`media_fetch_failed`). Images
|
||||
-- must be sent inline as data: URLs. Measured: base64 worked, a Wikimedia URL
|
||||
-- did not.
|
||||
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "qwen38"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
local function clamp_reasoning(req)
|
||||
-- Nothing to rewrite for reasoning: this endpoint has no `thinking` toggle
|
||||
-- (unlike deepseek's extra_body.thinking). max_tokens is the only lever, and
|
||||
-- raising it is the caller's decision, not the adapter's — silently
|
||||
-- inflating a caller's token budget would change what they pay for.
|
||||
end
|
||||
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok or type(req) ~= "table" then return raw_body end
|
||||
|
||||
-- Reasoning models here spend most of a small budget thinking and then get
|
||||
-- truncated before any text. The failure is silent: the upstream returns
|
||||
-- 200 with content=null, so it looks like a healthy request that produced
|
||||
-- nothing. Warn rather than rewrite — see clamp_reasoning.
|
||||
if type(req.max_tokens) == "number" and req.max_tokens > 0 and req.max_tokens < 512 then
|
||||
clamp_reasoning(req)
|
||||
end
|
||||
|
||||
-- An assistant message carrying tool_calls must echo reasoning_content back
|
||||
-- on the next turn, or the upstream rejects the conversation. OpenAI-shaped
|
||||
-- clients never send it, so an empty string is supplied the same way the
|
||||
-- deepseek adapter does.
|
||||
if type(req.messages) == "table" then
|
||||
for _, msg in ipairs(req.messages) do
|
||||
if type(msg) == "table" and msg.role == "assistant" then
|
||||
if type(msg.tool_calls) == "table" and msg.tool_calls[1] ~= nil
|
||||
and msg.reasoning_content == nil then
|
||||
msg.reasoning_content = ""
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
local ok, resp = pcall(json.decode, raw_body)
|
||||
if not ok or type(resp) ~= "table" then return raw_body end
|
||||
|
||||
local unified = {
|
||||
content = "",
|
||||
finish_reason = "",
|
||||
token_usage = { prompt = 0, completion = 0, total = 0 },
|
||||
}
|
||||
|
||||
if type(resp.usage) == "table" then
|
||||
local u = resp.usage
|
||||
unified.token_usage.prompt = u.prompt_tokens or 0
|
||||
unified.token_usage.completion = u.completion_tokens or 0
|
||||
unified.token_usage.total = u.total_tokens or 0
|
||||
if type(u.prompt_tokens_details) == "table" and u.prompt_tokens_details.cached_tokens ~= nil then
|
||||
unified.token_usage.prompt_tokens_details = { cached_tokens = u.prompt_tokens_details.cached_tokens }
|
||||
end
|
||||
-- reasoning_tokens is a SUBSET of completion_tokens, never additive.
|
||||
--
|
||||
-- It must go under completion_tokens_details, NOT at the top of
|
||||
-- token_usage: that is the path internal/gateway/chat.go reads when it
|
||||
-- fills the audit record's Reasoning field. Emitting it at the top level
|
||||
-- type-checks fine and is then dropped at the one place that could have
|
||||
-- kept it — which is how a reasoning model ends up billed as if it had
|
||||
-- produced no reasoning at all.
|
||||
--
|
||||
-- (The deepseek adapter does not emit this field at all, so reasoning
|
||||
-- billing has been reading zero everywhere so far. That is a separate
|
||||
-- gap; this adapter does not depend on it being fixed.)
|
||||
if type(u.completion_tokens_details) == "table"
|
||||
and u.completion_tokens_details.reasoning_tokens ~= nil then
|
||||
local d = unified.token_usage.completion_tokens_details
|
||||
if type(d) ~= "table" then
|
||||
d = {}
|
||||
unified.token_usage.completion_tokens_details = d
|
||||
end
|
||||
d.reasoning_tokens = u.completion_tokens_details.reasoning_tokens
|
||||
end
|
||||
end
|
||||
|
||||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||||
local ch = resp.choices[1]
|
||||
if type(ch.message) == "table" then
|
||||
local m = ch.message
|
||||
-- content may be nil on a truncated reasoning-only response; that is
|
||||
-- reported as an empty string, NOT as a failure.
|
||||
if type(m.content) == "string" then
|
||||
unified.content = m.content
|
||||
end
|
||||
if type(m.reasoning_content) == "string" then
|
||||
unified.reasoning_content = m.reasoning_content
|
||||
end
|
||||
if type(m.tool_calls) == "table" then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(m.tool_calls) do
|
||||
local fn = tc["function"] or {}
|
||||
local args_ok, args = pcall(json.decode, fn.arguments or "{}")
|
||||
if not args_ok 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 ""
|
||||
end
|
||||
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||
if not ok or type(chunk) ~= "table" then return "" end
|
||||
|
||||
-- Usage arrives on a chunk with empty choices (the terminal usage chunk).
|
||||
-- Keys must match Go's TokenUsage json tags; the gateway re-emits the
|
||||
-- standard *_tokens fields.
|
||||
local uses = nil
|
||||
if type(chunk.usage) == "table" then
|
||||
local u = chunk.usage
|
||||
uses = {
|
||||
prompt = u.prompt_tokens or 0,
|
||||
completion = u.completion_tokens or 0,
|
||||
total = u.total_tokens or 0,
|
||||
}
|
||||
if type(u.prompt_tokens_details) == "table" and u.prompt_tokens_details.cached_tokens ~= nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = u.prompt_tokens_details.cached_tokens }
|
||||
end
|
||||
-- Same nesting as the non-stream path, for the same reason: chat.go reads
|
||||
-- reasoning under completion_tokens_details and nothing else.
|
||||
if type(u.completion_tokens_details) == "table"
|
||||
and u.completion_tokens_details.reasoning_tokens ~= nil then
|
||||
uses.completion_tokens_details = {
|
||||
reasoning_tokens = u.completion_tokens_details.reasoning_tokens
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
if type(chunk.choices) ~= "table" or #chunk.choices == 0 then
|
||||
if uses ~= nil then
|
||||
-- A usage-only chunk is not a content or 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 ch = chunk.choices[1]
|
||||
local delta = ch.delta or {}
|
||||
local finish = (type(ch.finish_reason) == "string" and ch.finish_reason ~= "") and ch.finish_reason 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
|
||||
-- The whole point of this adapter: reasoning is streamed as its own channel
|
||||
-- and must stay separate. Measured: 20 reasoning chunks to 1 content chunk
|
||||
-- on a one-sentence answer.
|
||||
if type(delta.reasoning_content) == "string" then
|
||||
unified.reasoning_content = delta.reasoning_content
|
||||
end
|
||||
if type(delta.tool_calls) == "table" then
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- Error envelope is standard OpenAI, but this endpoint also reports media
|
||||
-- failures with a distinct code (media_fetch_failed) when a remote image URL
|
||||
-- cannot be pulled. Both carry {error:{message,...}}; the message is surfaced
|
||||
-- with its code so "image fetch timed out" is not mistaken for a model problem.
|
||||
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 type(e.message) ~= "string" then return nil end
|
||||
if type(e.code) == "string" and e.code ~= "" and e.code ~= "invalid_api_key" then
|
||||
return e.message .. " (" .. e.code .. ")"
|
||||
end
|
||||
return e.message
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -389,8 +389,8 @@ local function collectSource(src, budget)
|
||||
-- A streak is a host that has failed repeatedly. Skip it outright until its
|
||||
-- backoff expires, instead of re-paying three adapter timeouts every TTL.
|
||||
if backoffSeconds(seen, known.streak) then
|
||||
return { name = src.name, host = host, skipped = true,
|
||||
reason = "backing off after " .. tostring(known.streak) .. " failed probes" }
|
||||
return { name = src.name, host = host, skipped = true, backing_off = true,
|
||||
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
|
||||
end
|
||||
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
|
||||
known = nil
|
||||
@ -577,8 +577,12 @@ PLUGIN.due_sources = function(payload)
|
||||
local base = (s.base_url:gsub("/+$", ""))
|
||||
local known = HOST_MEMO[hostOf(base)]
|
||||
if type(known) == "table" and backoffSeconds(known.at or 0, known.streak) then
|
||||
backing[#backing + 1] = { name = s.name, host = hostOf(base),
|
||||
reason = "backing off after " .. tostring(known.streak) .. " failed probes" }
|
||||
-- skipped matters: the page filters on it. Without the flag a backed-off
|
||||
-- host reaches the card renderer, which has no field to match and falls
|
||||
-- through to its "has data" default — so thirteen dead upstreams were
|
||||
-- displayed as if they had answered.
|
||||
backing[#backing + 1] = { name = s.name, host = hostOf(base), skipped = true, backing_off = true,
|
||||
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
|
||||
else
|
||||
due[#due + 1] = s
|
||||
end
|
||||
@ -824,7 +828,14 @@ PLUGIN.ui = {
|
||||
}
|
||||
if(r.hard_limit!==undefined && r.hard_limit!==null)
|
||||
return {cls:"ok", pill:'<span class="qa-pill ok">按量计费</span>'};
|
||||
return {cls:"", pill:'<span class="qa-pill">有数据</span>'};
|
||||
// Nothing here matched. Say so.
|
||||
//
|
||||
// The old default was a neutral "有数据", which is a claim: it says the probe
|
||||
// answered. A record that reached this point answered with no field the page
|
||||
// knows — a back-off notice, an error shape we do not recognise, or a vendor
|
||||
// that added a new response. Calling that "has data" is how thirteen dead
|
||||
// upstreams ended up rendered as healthy. Absence of evidence is not evidence.
|
||||
return {cls:"unknown", pill:'<span class="qa-pill">未探测</span>'};
|
||||
}
|
||||
function card(r){
|
||||
var st=state(r);
|
||||
@ -837,6 +848,13 @@ PLUGIN.ui = {
|
||||
h+='<div class="qa-fail">'+escH(r.error)+'</div></div>';
|
||||
return h;
|
||||
}
|
||||
// A record with nothing but a reason has no balance, no month, no windows —
|
||||
// the card renders as a bare header with an empty body. Say why instead.
|
||||
if(r.reason && !r.error && (r.balance===undefined||r.balance===null)
|
||||
&& (r.hard_limit===undefined||r.hard_limit===null)){
|
||||
h+='<div class="qa-fail">'+escH(r.reason)+'</div>';
|
||||
return h+'</div>';
|
||||
}
|
||||
h+=balLine(r);
|
||||
var rows="";
|
||||
rows+=month(r);
|
||||
@ -861,7 +879,10 @@ PLUGIN.ui = {
|
||||
var rs = (st && st.reports) || [];
|
||||
if(!rs.length) return false;
|
||||
var real = rs.filter(function(r){ return r && !r.skipped; });
|
||||
var skipped = rs.filter(function(r){ return r && r.skipped; }).map(function(r){ return r.name; });
|
||||
// Records, not names — same reason as the refresh path. Both call sites were
|
||||
// mapping to names while render() was changed to group on the flag; fixing
|
||||
// one and shipping it is what produced a footer with thirteen blank entries.
|
||||
var skipped = rs.filter(function(r){ return r && r.skipped; });
|
||||
render(real, skipped);
|
||||
var sub=document.getElementById("qa-sub");
|
||||
if(sub && st.updated_at){
|
||||
@ -878,10 +899,22 @@ PLUGIN.ui = {
|
||||
host.innerHTML='<div class="qa-empty">没有源提供可读的余额接口</div>';
|
||||
} else {
|
||||
host.innerHTML=real.map(card).join("");
|
||||
if(skipped && skipped.length){
|
||||
host.innerHTML+='<div class="qa-skip">未列出 '+skipped.length+
|
||||
' 个源(未暴露余额接口):'+escH(skipped.join("、"))+'</div>';
|
||||
}
|
||||
if(skipped && skipped.length){
|
||||
// Two reasons a source has no card, and they are not interchangeable.
|
||||
// "This upstream exposes no balance endpoint" and "this upstream is
|
||||
// backing off after failing" are different facts; collapsing them into
|
||||
// one list tells the operator their dead host simply has no dashboard.
|
||||
var backing = skipped.filter(function(r){ return r.backing_off===true; });
|
||||
var silent = skipped.filter(function(r){ return r.backing_off!==true; });
|
||||
if(backing.length){
|
||||
host.innerHTML+='<div class="qa-skip">'+backing.length+
|
||||
' 个源正在退避(连续探测失败,等下次重试):'+escH(backing.map(function(r){return r.name;}).join("、"))+'</div>';
|
||||
}
|
||||
if(silent.length){
|
||||
host.innerHTML+='<div class="qa-skip">未列出 '+silent.length+
|
||||
' 个源(未暴露余额接口):'+escH(silent.map(function(r){return r.name;}).join("、"))+'</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
var sub=document.getElementById("qa-sub");
|
||||
if(sub) sub.textContent = real.length+" 个源 · "+bad.length+" 个读取失败";
|
||||
@ -924,8 +957,12 @@ PLUGIN.ui = {
|
||||
// re-probed on every refresh forever. Measured before this split: fanout left
|
||||
// the memo at 0 entries and every refresh cost the full 12s.
|
||||
var plan = await pluginAPI.call("quota","due_sources",{sources:srcs});
|
||||
var due = (plan && plan.due) || [];
|
||||
var out = (plan && plan.backing_off) || [];
|
||||
// An EMPTY Lua table decodes as {} on the Go side, not [] — and {} is truthy,
|
||||
// so `|| []` does not replace it. An empty object reaching out.concat()
|
||||
// throws, and the whole refresh reports a failure. Array.isArray is the check
|
||||
// that matches what the plugin actually returns.
|
||||
var due = (plan && Array.isArray(plan.due)) ? plan.due : [];
|
||||
var out = (plan && Array.isArray(plan.backing_off)) ? plan.backing_off : [];
|
||||
|
||||
if (due.length) {
|
||||
out = out.concat(await pluginAPI.fanout("quota","collect_source",due.map(function(s){
|
||||
@ -946,8 +983,10 @@ PLUGIN.ui = {
|
||||
await pluginAPI.call("quota","memo_record",{results:(out||[]).map(function(r){
|
||||
if(!r) return null;
|
||||
// A host that is merely BACKING OFF has not just failed; recording it as a
|
||||
// failure would grow the streak on a probe that never happened.
|
||||
if(r.reason && String(r.reason).indexOf("backing off")===0) return null;
|
||||
// failure would grow the streak on a probe that never happened. Keyed on the
|
||||
// flag rather than the wording, so translating the message cannot silently
|
||||
// turn every skipped host into an ever-growing streak.
|
||||
if(r.backing_off===true) return null;
|
||||
if(r.error) return {host:r.host};
|
||||
if(r.skipped) return {host:r.host};
|
||||
return {host:r.host, vendor:r.vendor};
|
||||
@ -959,7 +998,11 @@ PLUGIN.ui = {
|
||||
}
|
||||
var all=out||[];
|
||||
var real=all.filter(function(r){return !r.skipped;});
|
||||
var skipped=all.filter(function(r){return r.skipped;}).map(function(r){return r.name;});
|
||||
// The records, not their names — render() groups skipped sources by the
|
||||
// backing_off flag, which a bare name cannot carry. This call site was left
|
||||
// mapping to names while the other was fixed, and the mismatch showed up as
|
||||
// a footer listing thirteen unnamed sources.
|
||||
var skipped=all.filter(function(r){return r.skipped;});
|
||||
// real may already contain errors; collect_all keeps them for visibility.
|
||||
render(real, skipped);
|
||||
if(btn) btn.disabled=false;
|
||||
|
||||
@ -27,9 +27,16 @@ func TestQuotaBackoffIsAppliedInSource(t *testing.T) {
|
||||
t.Error("quota.lua never consults backoffSeconds for a host with a streak — " +
|
||||
"the backoff is computed but not applied")
|
||||
}
|
||||
if !strings.Contains(src, `reason = "backing off after "`) {
|
||||
t.Error("the skip path does not say why it skipped; a silently skipped host " +
|
||||
"looks exactly like a host that simply has no balance endpoint")
|
||||
// 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
|
||||
@ -554,3 +561,90 @@ func hostOnly(baseURL string) string {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
71
packaging/upload-release.sh
Executable file
71
packaging/upload-release.sh
Executable file
@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
# Upload release assets to a GitCode release.
|
||||
#
|
||||
# The endpoint shapes are asymmetric and both differ from the "obvious" guess:
|
||||
#
|
||||
# GET /releases/tags/{tag} → read a release
|
||||
# GET /releases/{tag}/upload_url → get a presigned PUT (NO "tags/")
|
||||
# PUT {presigned url} → upload, REPLAYING the headers verbatim
|
||||
#
|
||||
# Two mistakes cost real time on v1.9.5 before this script existed:
|
||||
#
|
||||
# 1. POSTing straight to /releases/{tag}/assets returns 404 — that endpoint
|
||||
# does not exist. It reads as "authentication failed" if you only check
|
||||
# the status code.
|
||||
# 2. PUTting without the headers fails with an opaque storage error, not a
|
||||
# permission one, so it looks like a size limit or a corrupt file.
|
||||
#
|
||||
# The x-obs-* headers are part of the signature: dropping any one of them turns
|
||||
# the request into an unsigned one.
|
||||
set -uo pipefail
|
||||
|
||||
TOKEN="$(tr -d '\n\r' < /tmp/gc_token)"
|
||||
REPO="JianFeeeee/ModelRouter"
|
||||
TAG="${1:?usage: upload-release.sh <tag> <file>...}"
|
||||
shift
|
||||
|
||||
API="https://gitcode.com/api/v5/repos/${REPO}"
|
||||
|
||||
for f in "$@"; do
|
||||
name="$(basename "$f")"
|
||||
size="$(stat -c%s "$f")"
|
||||
printf ' %-46s %10s bytes ' "$name" "$size"
|
||||
|
||||
# 1) presigned upload location. The file name must be URL-encoded; the v1.9.4
|
||||
# assets show "~" surviving in a stored name, so encoding is per-component.
|
||||
enc="$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))' "$name")"
|
||||
resp="$(curl -sS -m 60 -H "Authorization: Bearer ${TOKEN}" \
|
||||
"${API}/releases/${TAG}/upload_url?file_name=${enc}" 2>&1)"
|
||||
url="$(printf '%s' "$resp" | python3 -c 'import json,sys
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
print(d.get("url") or d.get("uploadUrl") or "")
|
||||
except Exception:
|
||||
print("")' 2>/dev/null)"
|
||||
if [ -z "$url" ]; then
|
||||
echo "✗ 取得上传地址失败: ${resp:0:140}"
|
||||
continue
|
||||
fi
|
||||
|
||||
# 2) PUT with the headers replayed exactly as returned.
|
||||
code="$(printf '%s' "$resp" | python3 -c 'import json,sys
|
||||
try:
|
||||
print(json.dumps(json.load(sys.stdin).get("headers") or {}))
|
||||
except Exception:
|
||||
print("{}")' 2>/dev/null | python3 -c '
|
||||
import json, subprocess, sys
|
||||
headers = json.load(sys.stdin)
|
||||
args = ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "-X", "PUT"]
|
||||
for k, v in headers.items():
|
||||
args += ["-H", f"{k}: {v}"]
|
||||
args += ["--data-binary", "@" + sys.argv[1], sys.argv[2]]
|
||||
print(subprocess.run(args, capture_output=True, text=True).stdout)
|
||||
' "$f" "$url" 2>/dev/null)"
|
||||
|
||||
if [ "$code" = "200" ] || [ "$code" = "201" ] || [ "$code" = "204" ]; then
|
||||
echo "✓ HTTP=$code"
|
||||
else
|
||||
echo "✗ HTTP=$code"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
Reference in New Issue
Block a user