| 1 | package doctor |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | "time" |
| 7 | |
| 8 | "reasonix/internal/billing" |
| 9 | "reasonix/internal/config" |
| 10 | ) |
| 11 | |
| 12 | // BillingReport is the structured output of `reasonix doctor billing`. |
| 13 | type BillingReport struct { |
| 14 | DisplayCurrencyPref string `json:"display_currency_pref"` |
| 15 | DisplayCurrency string `json:"display_currency"` |
| 16 | FX FXReport `json:"fx"` |
| 17 | Providers []ProviderBillingInfo `json:"providers"` |
| 18 | CatalogNotes []string `json:"catalog_notes,omitempty"` |
| 19 | } |
| 20 | |
| 21 | // FXReport is retained for JSON compatibility. Runtime FX is intentionally |
| 22 | // disabled; old readers may continue to expect the object. |
| 23 | type FXReport struct { |
| 24 | Enabled bool `json:"enabled"` |
| 25 | Source string `json:"source"` |
| 26 | CachePath string `json:"cache_path,omitempty"` |
| 27 | FetchedAt time.Time `json:"fetched_at,omitempty"` |
| 28 | Stale bool `json:"stale"` |
| 29 | HasTable bool `json:"has_table"` |
| 30 | Observation string `json:"latest_observation,omitempty"` |
| 31 | } |
| 32 | |
| 33 | // ProviderBillingInfo describes one provider's frozen list-price currency. |
| 34 | type ProviderBillingInfo struct { |
| 35 | Name string `json:"name"` |
| 36 | Model string `json:"model,omitempty"` |
| 37 | BillingCurrency string `json:"billing_currency,omitempty"` |
| 38 | BillingMode string `json:"billing_mode,omitempty"` |
| 39 | PriceCurrency string `json:"price_currency,omitempty"` |
| 40 | PriceInput float64 `json:"price_input,omitempty"` |
| 41 | PriceOutput float64 `json:"price_output,omitempty"` |
| 42 | PriceCacheHit float64 `json:"price_cache_hit,omitempty"` |
| 43 | CatalogMatch bool `json:"catalog_match"` |
| 44 | CatalogSource string `json:"catalog_source,omitempty"` |
| 45 | Fingerprint string `json:"pricing_fingerprint,omitempty"` |
| 46 | CustomPrice bool `json:"custom_price"` |
| 47 | } |
| 48 | |
| 49 | // CollectBilling builds a billing diagnostics report. |
| 50 | func CollectBilling(cfg *config.Config) BillingReport { |
| 51 | if cfg == nil { |
| 52 | cfg = config.Default() |
| 53 | } |
| 54 | rep := BillingReport{ |
| 55 | DisplayCurrencyPref: prefLabel(cfg.DisplayCurrencyPref()), |
| 56 | DisplayCurrency: cfg.ExplicitDisplayCurrency(), |
| 57 | FX: FXReport{ |
| 58 | Enabled: false, |
| 59 | Source: "disabled", |
| 60 | }, |
| 61 | } |
| 62 | |
| 63 | for i := range cfg.Providers { |
| 64 | p := &cfg.Providers[i] |
| 65 | info := ProviderBillingInfo{ |
| 66 | Name: p.Name, |
| 67 | Model: p.Model, |
| 68 | BillingCurrency: p.ProviderBillingCurrency(), |
| 69 | BillingMode: p.ProviderBillingMode(), |
| 70 | } |
| 71 | price := p.PriceForModel(p.Model) |
| 72 | if price != nil { |
| 73 | info.PriceCurrency = billing.NormalizeCurrency(price.Currency) |
| 74 | info.PriceInput = price.Input |
| 75 | info.PriceOutput = price.Output |
| 76 | info.PriceCacheHit = price.CacheHit |
| 77 | card := billing.RateCard{ |
| 78 | CacheHit: price.CacheHit, Input: price.Input, Output: price.Output, |
| 79 | Currency: info.PriceCurrency, |
| 80 | } |
| 81 | info.Fingerprint = billing.PricingFingerprint(card) |
| 82 | providerKind := officialKindForBilling(p) |
| 83 | if entry, ok := billing.MatchesCatalog(providerKind, p.Model, card); ok { |
| 84 | info.CatalogMatch = true |
| 85 | info.CatalogSource = entry.DocURL |
| 86 | } else if price != nil { |
| 87 | info.CustomPrice = !info.CatalogMatch |
| 88 | } |
| 89 | } |
| 90 | rep.Providers = append(rep.Providers, info) |
| 91 | } |
| 92 | rep.CatalogNotes = []string{ |
| 93 | "User-custom prices always win over the official catalog.", |
| 94 | "MiMo Token Plan costs are pay-as-you-go equivalents, not plan invoices.", |
| 95 | "Runtime FX is disabled; only identity and official regional rate-card estimates are used.", |
| 96 | "Switching display_currency never rewrites provider billing_currency or list prices.", |
| 97 | } |
| 98 | return rep |
| 99 | } |
| 100 | |
| 101 | func prefLabel(pref string) string { |
| 102 | if pref == "" { |
| 103 | return "auto" |
| 104 | } |
| 105 | return pref |
| 106 | } |
| 107 | |
| 108 | func officialKindForBilling(p *config.ProviderEntry) string { |
| 109 | if p == nil { |
| 110 | return "" |
| 111 | } |
| 112 | name := strings.ToLower(p.Name) |
| 113 | base := strings.ToLower(p.BaseURL) |
| 114 | switch { |
| 115 | case strings.Contains(base, "deepseek") || strings.Contains(name, "deepseek"): |
| 116 | return "deepseek" |
| 117 | case strings.Contains(base, "longcat") || strings.Contains(name, "longcat"): |
| 118 | return "longcat" |
| 119 | case strings.Contains(base, "mimo") || strings.Contains(name, "mimo"): |
| 120 | return "mimo" |
| 121 | default: |
| 122 | return name |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | // RenderBillingText formats a human-readable billing doctor report. |
| 127 | func RenderBillingText(r BillingReport) string { |
| 128 | var b strings.Builder |
| 129 | fmt.Fprintf(&b, "reasonix doctor billing\n") |
| 130 | fmt.Fprintf(&b, " display preference: %s\n", r.DisplayCurrencyPref) |
| 131 | fmt.Fprintf(&b, " display resolved: %s\n", r.DisplayCurrency) |
| 132 | fmt.Fprintf(&b, " fx source: %s\n", r.FX.Source) |
| 133 | fmt.Fprintf(&b, " fx enabled: %v\n", r.FX.Enabled) |
| 134 | fmt.Fprintf(&b, " auto strategy: wallet currency, then pricing basis\n") |
| 135 | b.WriteString("\n providers:\n") |
| 136 | for _, p := range r.Providers { |
| 137 | fmt.Fprintf(&b, " - %s model=%s billing=%s mode=%s", p.Name, p.Model, p.BillingCurrency, p.BillingMode) |
| 138 | if p.PriceCurrency != "" { |
| 139 | fmt.Fprintf(&b, " price=%s/%.4g/%.4g", p.PriceCurrency, p.PriceInput, p.PriceOutput) |
| 140 | } |
| 141 | if p.CatalogMatch { |
| 142 | fmt.Fprintf(&b, " [official: %s]", p.CatalogSource) |
| 143 | } else if p.CustomPrice { |
| 144 | b.WriteString(" [custom price protected]") |
| 145 | } |
| 146 | b.WriteByte('\n') |
| 147 | if p.Fingerprint != "" { |
| 148 | fmt.Fprintf(&b, " fingerprint=%s\n", p.Fingerprint) |
| 149 | } |
| 150 | } |
| 151 | if len(r.CatalogNotes) > 0 { |
| 152 | b.WriteString("\n notes:\n") |
| 153 | for _, n := range r.CatalogNotes { |
| 154 | fmt.Fprintf(&b, " - %s\n", n) |
| 155 | } |
| 156 | } |
| 157 | return b.String() |
| 158 | } |
| 159 |