diff --git a/changelog/unreleased/qoder-model-credits.md b/changelog/unreleased/qoder-model-credits.md new file mode 100644 index 0000000..ef38eae --- /dev/null +++ b/changelog/unreleased/qoder-model-credits.md @@ -0,0 +1,7 @@ +### English + +- Show Qoder model pricing on the account's model list. Qoder reports a per-model `price_factor` (the multiplier its own client renders as `0.50x Credit`) plus `is_free`/`tags`, but the worker dropped them, so the console showed no price for any Qoder account. The worker now forwards them and the adapter renders the multiplier as the console's credits text. The free badge follows the Qoder client's own rule — the `limited_time_free` tag (or a zero factor) is free, a positive factor is priced — so a model that reports `is_free` alongside a real multiplier (Qwen3.8-Max: `is_free` + `0.5`) shows its multiplier instead of the contradictory "免费 / x0.5" pair. Applies to Qoder Global and Qoder CN. + +### 中文 + +- 账号的模型列表现在会显示 Qoder 模型价格。Qoder 每个模型都带 `price_factor`(其客户端显示为 `0.50x Credit` 的那个倍率)以及 `is_free`/`tags`,但 worker 之前把它们丢掉了,所以控制台对所有 Qoder 账号都不显示价格。现在 worker 透传这些字段,适配层把倍率渲染为控制台的额度文案。免费标记对齐 Qoder 客户端自身的规则——带 `limited_time_free` 标签(或倍率为 0)才算免费,正倍率即视为计费——因此像 Qwen3.8-Max 这种「`is_free` 为真但同时带 0.5 倍率」的模型会显示倍率,而不再出现自相矛盾的「免费 / x0.5」。Qoder 国际版与国内版均适用。 diff --git a/internal/providers/qoder/adapter.go b/internal/providers/qoder/adapter.go index 6bf8580..c81caf1 100644 --- a/internal/providers/qoder/adapter.go +++ b/internal/providers/qoder/adapter.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" "sync" "time" @@ -163,8 +164,8 @@ func ModelInfos(entries []map[string]any) []providers.ModelInfo { PublicModel: publicID, NativeModel: native, DisplayName: stringField(entry, "display_name"), - Credits: stringField(entry, "credits"), - Free: boolField(entry, "free"), + Credits: qoderEntryCredits(entry), + Free: qoderEntryFree(entry), Capabilities: providers.ModelCapabilities{ ContextWindow: numberField(entry, "context_length"), Reasoning: boolField(entry, "is_reasoning"), @@ -180,6 +181,83 @@ func ModelInfos(entries []map[string]any) []providers.ModelInfo { return out } +// qoderEntryCredits renders a catalog entry's price as the console credits text, +// mirroring the Qoder client label: a model tagged `limited_time_free` reads as +// "0" (the console then shows its free badge); otherwise the numeric +// `price_factor` renders as `x`. An explicit upstream `credits` string +// wins. Returns "" when Qoder reported neither. +func qoderEntryCredits(entry map[string]any) string { + if explicit := stringField(entry, "credits"); explicit != "" { + return explicit + } + if qoderEntryLimitedTimeFree(entry) { + return "0" + } + factor, ok := floatField(entry, "price_factor") + if !ok { + return "" + } + if factor <= 0 { + return "0" + } + return "x" + strconv.FormatFloat(factor, 'f', -1, 64) +} + +// qoderEntryLimitedTimeFree is the Qoder client's own free signal: the +// `limited_time_free` tag. +func qoderEntryLimitedTimeFree(entry map[string]any) bool { + for _, tag := range stringSliceField(entry, "tags") { + if strings.EqualFold(strings.TrimSpace(tag), "limited_time_free") { + return true + } + } + return false +} + +// qoderEntryFree reports whether the model should carry the console's free +// badge. It is derived from the same credits it renders, so the badge can never +// contradict a positive multiplier: a `limited_time_free` tag or a zero factor +// is free, and a positive factor is not (Qwen3.8-Max reports is_free=true with +// a 0.5 factor and the Qoder client still shows "0.50x Credit"). +func qoderEntryFree(entry map[string]any) bool { + if qoderEntryLimitedTimeFree(entry) { + return true + } + factor, ok := floatField(entry, "price_factor") + return ok && factor <= 0 +} + +func stringSliceField(entry map[string]any, key string) []string { + if typed, ok := entry[key].([]string); ok { + return typed + } + raw, ok := entry[key].([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, item := range raw { + if text, ok := item.(string); ok { + out = append(out, text) + } + } + return out +} + +func floatField(entry map[string]any, key string) (float64, bool) { + switch value := entry[key].(type) { + case float64: + return value, true + case int: + return float64(value), true + case json.Number: + f, err := value.Float64() + return f, err == nil + default: + return 0, false + } +} + func CatalogIDsFromInfos(models []providers.ModelInfo) []string { ids := make([]string, 0, len(models)*3) for _, model := range models { diff --git a/internal/providers/qoder/adapter_test.go b/internal/providers/qoder/adapter_test.go index 78a41eb..98f2d02 100644 --- a/internal/providers/qoder/adapter_test.go +++ b/internal/providers/qoder/adapter_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/caigee-cmd/cli2api/internal/providers" "github.com/caigee-cmd/cli2api/internal/translate" ) @@ -267,3 +268,105 @@ func TestBuildChatPayloadTokenPrecedenceAndOmission(t *testing.T) { } } } + +func TestModelInfosMapsQoderPriceFields(t *testing.T) { + models := ModelInfos([]map[string]any{ + {"id": "kimi-k3", "mapped_key": "kmodel_latest", "display_name": "Kimi-K3", "price_factor": 1.4}, + {"id": "qwen3.7-plus", "mapped_key": "qmodel", "display_name": "Qwen3.7-Plus", "price_factor": 0.1}, + {"id": "qwen3.8-flash", "mapped_key": "qfmodel", "display_name": "Qwen3.8-Flash", "price_factor": 0.0, "is_free": true}, + {"id": "qwen3.8-max", "mapped_key": "qmodel_38max", "display_name": "Qwen3.8-Max", "price_factor": 0.5, "is_free": true}, + {"id": "auto", "mapped_key": "auto", "display_name": "Auto", "price_factor": 0.5}, + // No price data at all: neither credits nor free may be invented. + {"id": "no-price", "mapped_key": "npmodel", "display_name": "NoPrice"}, + }) + + byID := map[string]providers.ModelInfo{} + for _, m := range models { + byID[m.PublicModel] = m + } + + if got := byID["kimi-k3"].Credits; got != "x1.4" { + t.Errorf("kimi-k3 credits = %q, want x1.4", got) + } + if got := byID["qwen3.7-plus"].Credits; got != "x0.1" { + t.Errorf("qwen3.7-plus credits = %q, want x0.1", got) + } + if got := byID["auto"].Credits; got != "x0.5" { + t.Errorf("auto credits = %q, want x0.5", got) + } + + free := byID["qwen3.8-flash"] + if !free.Free || free.Credits != "0" { + t.Errorf("qwen3.8-flash free=%v credits=%q, want free + \"0\"", free.Free, free.Credits) + } + // A dual is_free + positive factor model (Qwen3.8-Max) is NOT free: the + // Qoder client still labels it with its multiplier. + dual := byID["qwen3.8-max"] + if dual.Free || dual.Credits != "x0.5" { + t.Errorf("qwen3.8-max free=%v credits=%q, want not-free + x0.5", dual.Free, dual.Credits) + } + for id, m := range byID { + if id == "qwen3.8-flash" { + continue + } + if m.Free { + t.Errorf("%s must not be flagged free", id) + } + } + if priced := byID["no-price"]; priced.Credits != "" || priced.Free { + t.Errorf("no-price must stay unpriced: credits=%q free=%v", priced.Credits, priced.Free) + } +} + +func TestModelInfosPrefersExplicitCreditsText(t *testing.T) { + models := ModelInfos([]map[string]any{ + {"id": "x", "mapped_key": "xk", "display_name": "X", "credits": "2x credits", "price_factor": 0.5}, + }) + if len(models) != 1 || models[0].Credits != "2x credits" { + t.Fatalf("explicit credits text must win: %+v", models) + } +} + +func TestApplyModelPricingMapsWorkerFields(t *testing.T) { + entry := map[string]any{"id": "kimi-k3", "price_factor": 1.4} + ApplyModelPricing(entry) + if entry["credits"] != "x1.4" { + t.Errorf("credits = %v, want x1.4", entry["credits"]) + } + if _, ok := entry["free"]; ok { + t.Errorf("paid model must not be flagged free: %v", entry["free"]) + } + + freeEntry := map[string]any{"id": "qwen3.8-flash", "price_factor": 0.0, "is_free": true} + ApplyModelPricing(freeEntry) + if freeEntry["credits"] != "0" || freeEntry["free"] != true { + t.Errorf("free model = %+v, want credits 0 + free", freeEntry) + } + + // is_free=true with a positive factor (Qwen3.8-Max) must NOT be free and + // must show the multiplier, matching the Qoder client label. + dual := map[string]any{"id": "qwen3.8-max", "price_factor": 0.5, "is_free": true} + ApplyModelPricing(dual) + if dual["credits"] != "x0.5" { + t.Errorf("dual credits = %v, want x0.5", dual["credits"]) + } + if _, ok := dual["free"]; ok { + t.Errorf("dual model must not be flagged free: %v", dual["free"]) + } + + // limited_time_free tag is the Qoder free signal. + tagged := map[string]any{"id": "tagged", "price_factor": 0.5, "tags": []any{"limited_time_free"}} + ApplyModelPricing(tagged) + if tagged["credits"] != "0" || tagged["free"] != true { + t.Errorf("limited_time_free = %+v, want credits 0 + free", tagged) + } + + unpriced := map[string]any{"id": "unknown"} + ApplyModelPricing(unpriced) + if _, ok := unpriced["credits"]; ok { + t.Errorf("unpriced model must not gain credits: %+v", unpriced) + } + if _, ok := unpriced["free"]; ok { + t.Errorf("unpriced model must not be flagged free: %+v", unpriced) + } +} diff --git a/internal/providers/qoder/display.go b/internal/providers/qoder/display.go index 6ef95ed..3ab34c9 100644 --- a/internal/providers/qoder/display.go +++ b/internal/providers/qoder/display.go @@ -29,5 +29,31 @@ func (s DisplayCatalog) Models(ctx context.Context, id string, refresh bool) ([] } return nil, nil } + for _, entry := range entries { + ApplyModelPricing(entry) + } return entries, nil } + +// ApplyModelPricing writes the display catalog's credits/free keys onto a raw +// Qoder worker model entry. The worker forwards `price_factor` (the multiplier +// the Qoder client renders as e.g. "1.50x") and `is_free`; the console and +// gateway read `credits`/`free`. Qoder reports the price per model, so no +// cross-region reconciliation is needed. Keys already present are left as-is, +// and nothing is written when Qoder reported no price, so an unpriced model is +// never shown as free. +func ApplyModelPricing(entry map[string]any) { + if entry == nil { + return + } + if _, ok := entry["credits"]; !ok { + if credits := qoderEntryCredits(entry); credits != "" { + entry["credits"] = credits + } + } + if _, ok := entry["free"]; !ok { + if qoderEntryFree(entry) { + entry["free"] = true + } + } +} diff --git a/worker/src/catalog.mjs b/worker/src/catalog.mjs index de58187..2f15915 100644 --- a/worker/src/catalog.mjs +++ b/worker/src/catalog.mjs @@ -6,6 +6,13 @@ function cleanString(value) { return typeof value === "string" ? value.trim() : ""; } +// numberOrUndefined keeps a numeric upstream field only when it is actually a +// finite number, so an absent price_factor stays absent instead of becoming 0 +// (which the console would read as "free"). +function numberOrUndefined(value) { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + export function createModelCatalogSnapshot(entries) { const routes = new Map(); const models = []; @@ -24,6 +31,12 @@ export function createModelCatalogSnapshot(entries) { max_input_tokens: Number(rawEntry.max_input_tokens) || undefined, is_reasoning: typeof rawEntry.is_reasoning === "boolean" ? rawEntry.is_reasoning : undefined, is_vl: typeof rawEntry.is_vl === "boolean" ? rawEntry.is_vl : undefined, + // Qoder reports each model's price multiplier and free flag; the Go + // adapter renders them (credits text + free badge). Kept as undefined + // when upstream omits them so the console shows nothing rather than 0. + price_factor: numberOrUndefined(rawEntry.price_factor), + is_free: typeof rawEntry.is_free === "boolean" ? rawEntry.is_free : undefined, + tags: Array.isArray(rawEntry.tags) && rawEntry.tags.length > 0 ? rawEntry.tags.map(String) : undefined, }; routes.set(id, route); @@ -39,6 +52,9 @@ export function createModelCatalogSnapshot(entries) { owned_by: "qoder", context_length: route.max_input_tokens, is_reasoning: route.is_reasoning, + price_factor: route.price_factor, + is_free: route.is_free, + tags: route.tags, }); } } diff --git a/worker/test/catalog.test.mjs b/worker/test/catalog.test.mjs index 1206213..cd4fa01 100644 --- a/worker/test/catalog.test.mjs +++ b/worker/test/catalog.test.mjs @@ -22,3 +22,30 @@ test("permits native Qoder keys and excludes disabled catalog entries", () => { assert.equal(resolveCatalogModel(snapshot, "gmodel")?.display_name, "GLM-5.3"); assert.equal(resolveCatalogModel(snapshot, "old"), null); }); + +test("surfaces Qoder price_factor and is_free without inventing values", () => { + const snapshot = createModelCatalogSnapshot([ + { key: "kmodel_latest", display_name: "Kimi-K3", price_factor: 1.4, is_free: false }, + { key: "qfmodel", display_name: "Qwen3.8-Flash", price_factor: 0, is_free: true }, + // No price reported: must stay absent, not become 0 (which reads as free). + { key: "npmodel", display_name: "NoPrice" }, + ]); + + const byId = Object.fromEntries(snapshot.models.map((m) => [m.id, m])); + assert.equal(byId["kimi-k3"].price_factor, 1.4); + assert.equal(byId["kimi-k3"].is_free, false); + assert.equal(byId["qwen3.8-flash"].price_factor, 0); + assert.equal(byId["qwen3.8-flash"].is_free, true); + assert.equal(byId["noprice"].price_factor, undefined); + assert.equal(byId["noprice"].is_free, undefined); +}); + +test("forwards tags so the free badge follows Qoder's limited_time_free signal", () => { + const snapshot = createModelCatalogSnapshot([ + { key: "a", display_name: "Tagged", price_factor: 0.5, is_free: true, tags: ["limited_time_free"] }, + { key: "b", display_name: "Dual", price_factor: 0.5, is_free: true }, + ]); + const byId = Object.fromEntries(snapshot.models.map((m) => [m.id, m])); + assert.deepEqual(byId["tagged"].tags, ["limited_time_free"]); + assert.equal(byId["dual"].tags, undefined); +});