Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/unreleased/qoder-model-credits.md
Original file line number Diff line number Diff line change
@@ -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 国际版与国内版均适用。
82 changes: 80 additions & 2 deletions internal/providers/qoder/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -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"),
Expand All @@ -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 `<factor>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 {
Expand Down
103 changes: 103 additions & 0 deletions internal/providers/qoder/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"testing"
"time"

"github.com/caigee-cmd/cli2api/internal/providers"
"github.com/caigee-cmd/cli2api/internal/translate"
)

Expand Down Expand Up @@ -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)
}
}
26 changes: 26 additions & 0 deletions internal/providers/qoder/display.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
16 changes: 16 additions & 0 deletions worker/src/catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand All @@ -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);
Expand All @@ -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,
});
}
}
Expand Down
27 changes: 27 additions & 0 deletions worker/test/catalog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});