diff --git a/internal/qoder/models.go b/internal/qoder/models.go index ada585cd..696e5c8b 100644 --- a/internal/qoder/models.go +++ b/internal/qoder/models.go @@ -39,19 +39,37 @@ type ModelInfo struct { AlwaysThinks bool `json:"-"` DefaultEffort string `json:"-"` // Free is set on a model that costs the plan no credits, as Qoder's - // client reads it: is_free, or a price_factor of 0. + // client shows it: a price_factor of 0 (see free). Free bool `json:"-"` } -// free reads whether the listing marks a model free: is_free true, or a -// price_factor (the credits a request costs, as a multiple) of 0. Either -// may come in snake or camel case. +// promotion is a listing entry's running discount: a price_factor of 0 +// while it is active, with a price before it, is a discount, not a free +// model. +type promotion struct { + Active bool `json:"active"` + Before *float64 `json:"before_promotion_price_factor"` + BeforeC *float64 `json:"beforePromotionPriceFactor"` +} + +// free reads whether a model costs the plan no credits: a price_factor (the +// credits a request costs, as a multiple; priceFactor in camel case) of 0, +// the price Qoder's own client shows ("0×"). is_free is no word on that: +// Qoder's listing has it true on Qwen3.8-Max at 0.5×, an off-peak discount +// (错峰 4 折) on it, and Qoder's client shows the price, not it. It is taken +// only from a listing with no price at all. A price of 0 for the while an +// active promotion lasts, with a price before it, is a discount, not a free +// model; a limited-time free model (Qwen3.8-Flash: 0×, its +// original_price_factor 0.1 struck through) is free while it is. The same +// rule as the plugin's freeOf (packages/qoder/index.mjs). func (m *ModelInfo) free(raw json.RawMessage) { var v struct { - IsFree *bool `json:"is_free"` - IsFreeC *bool `json:"isFree"` - PriceFactor *float64 `json:"price_factor"` - PriceC *float64 `json:"priceFactor"` + IsFree *bool `json:"is_free"` + IsFreeC *bool `json:"isFree"` + PriceFactor *float64 `json:"price_factor"` + PriceC *float64 `json:"priceFactor"` + Promotion *promotion `json:"promotion"` + PromotionT *promotion `json:"prommotion"` } if json.Unmarshal(raw, &v) != nil { return @@ -62,7 +80,22 @@ func (m *ModelInfo) free(raw json.RawMessage) { if v.PriceFactor == nil { v.PriceFactor = v.PriceC } - m.Free = v.IsFree != nil && *v.IsFree || v.PriceFactor != nil && *v.PriceFactor == 0 + if v.Promotion == nil { + v.Promotion = v.PromotionT + } + if v.PriceFactor != nil { + if *v.PriceFactor != 0 { + m.Free = false + return + } + p := v.Promotion + if p != nil && p.Before == nil { + p.Before = p.BeforeC + } + m.Free = !(p != nil && p.Active && p.Before != nil && *p.Before > 0) + return + } + m.Free = v.IsFree != nil && *v.IsFree } // effortOrder ranks Qoder's effort names, lowest first. diff --git a/internal/qoder/models_test.go b/internal/qoder/models_test.go index ff2aae3b..337927bf 100644 --- a/internal/qoder/models_test.go +++ b/internal/qoder/models_test.go @@ -2,8 +2,8 @@ package qoder import "testing" -// The listing marks a model free with is_free or a price_factor of 0, in -// snake or camel case; Qoder's client shows either as free. +// A model at a price_factor of 0 is free, in snake or camel case; is_free +// counts only on a listing with no price. func TestParseModelsFree(t *testing.T) { body := []byte(`{"chat":[ {"key":"flag","enable":true,"is_free":true,"price_factor":1}, @@ -17,7 +17,40 @@ func TestParseModelsFree(t *testing.T) { if err != nil { t.Fatal(err) } - want := map[string]bool{"flag": true, "camel-flag": true, "zero": true, "camel-zero": true, "paid": false, "unsaid": false} + want := map[string]bool{"flag": false, "camel-flag": true, "zero": true, "camel-zero": true, "paid": false, "unsaid": false} + if len(ms) != len(want) { + t.Fatalf("models %+v", ms) + } + for _, m := range ms { + if m.Free != want[m.ID] { + t.Errorf("%s: Free = %v, want %v", m.ID, m.Free, want[m.ID]) + } + } +} + +// Qoder's listing as it came on 2026-10-02: its app shows Qwen3.8-Max at +// 0.5× with an off-peak badge (错峰 4 折), Qwen3.8-Flash at 0× with 0.1× +// struck through — both carry is_free true. Only Flash is free. +func TestParseModelsFreeIsThePrice(t *testing.T) { + offPeak := `{"active":false,"badge":{"en":"Off-Peak 60% off","zh":"错峰 4 折"},"timezone":"Asia/Singapore","rule_id":"idle_time_model_credit_discount","discount_factor":0.4,"before_promotion_price_factor":0.5,"window_start":"22:00","window_end":"08:00"}` + body := []byte(`{"chat":[ + {"key":"qmodel_38max","display_name":"Qwen3.8-Max","enable":true,"is_reasoning":true,"price_factor":0.5,"is_free":true,"promotion":` + offPeak + `}, + {"key":"qfmodel","display_name":"Qwen3.8-Flash","enable":true,"is_reasoning":true,"price_factor":0.0,"original_price_factor":0.1,"is_free":true}, + {"key":"qmodel_latest","display_name":"Qwen3.7-Max","enable":true,"price_factor":0.5,"original_price_factor":0.5}, + {"key":"kmodel_latest","display_name":"Kimi-K3","enable":true,"price_factor":1.4,"is_free":false}, + {"key":"window","enable":true,"price_factor":0,"is_free":true,"promotion":{"active":true,"discount_factor":0.0001,"before_promotion_price_factor":0.5}}, + {"key":"window-camel","enable":true,"priceFactor":0,"promotion":{"active":true,"beforePromotionPriceFactor":1}}, + {"key":"over","enable":true,"price_factor":0,"promotion":{"active":false,"before_promotion_price_factor":0.5}}, + {"key":"nothing","enable":true,"price_factor":0,"promotion":{"active":true,"before_promotion_price_factor":0}}, + {"key":"unpriced","enable":true,"is_free":true}, + {"key":"unpriced2","enable":true,"is_free":false} + ]}`) + ms, err := ParseModels(body, ProviderKey) + if err != nil { + t.Fatal(err) + } + want := map[string]bool{"qmodel_38max": false, "qfmodel": true, "qmodel_latest": false, "kmodel_latest": false, + "window": false, "window-camel": false, "over": true, "nothing": true, "unpriced": true, "unpriced2": false} if len(ms) != len(want) { t.Fatalf("models %+v", ms) }