Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

222 lines
6.2 KiB
Go

package pricing
import (
"slices"
"strings"
)
type Match[T any] struct {
Value T
Pattern string
OK bool
}
// NormalizeModelName converts a model id's dots to dashes so agents
// that report dotted ids (e.g. opencode's claude-opus-4.7) match the
// dashed LiteLLM pricing keys (claude-opus-4-7). Use only as a
// fallback after an exact match.
func NormalizeModelName(model string) string {
return strings.ReplaceAll(model, ".", "-")
}
// canonicalize strips any provider prefix (after the last '/') and
// converts the string to lowercase, removing all non-alphanumeric characters.
func canonicalize(s string) string {
if idx := strings.LastIndex(s, "/"); idx != -1 {
s = s[idx+1:]
}
var sb strings.Builder
for _, r := range strings.ToLower(s) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
sb.WriteRune(r)
}
}
return sb.String()
}
// Resolve looks up model in m, falling back to NormalizeModelName when
// there is no exact match, then case-insensitive matches, and finally
// canonical matches with curated trailing decorations stripped.
func Resolve[T any](m map[string]T, model string) (T, bool) {
match := ResolveMatch(model, m)
return match.Value, match.OK
}
// ResolveMatch is Resolve plus the pricing pattern/key that supplied the
// value. Pattern is empty when no value is resolved.
func ResolveMatch[T any](model string, m map[string]T) Match[T] {
var zero T
// 1. Exact match
if v, ok := m[model]; ok {
return Match[T]{Value: v, Pattern: model, OK: true}
}
// 2. Exact match on normalized (dotted to dashed)
if norm := NormalizeModelName(model); norm != model {
if v, ok := m[norm]; ok {
return Match[T]{Value: v, Pattern: norm, OK: true}
}
}
// 3. Case-insensitive exact match
lowerModel := strings.ToLower(model)
for k, v := range m {
if strings.ToLower(k) == lowerModel {
return Match[T]{Value: v, Pattern: k, OK: true}
}
}
lowerNorm := strings.ToLower(NormalizeModelName(model))
for k, v := range m {
if strings.ToLower(k) == lowerNorm {
return Match[T]{Value: v, Pattern: k, OK: true}
}
}
// 4. Canonical match with curated decoration stripping
v, pattern, ok := resolveCanonicalMatch(m, model)
if !ok {
return Match[T]{Value: zero}
}
return Match[T]{Value: v, Pattern: pattern, OK: true}
}
// resolveCanonicalMatch matches the canonicalized model name exactly against
// canonicalized keys, retrying with a trailing bracketed or parenthesized
// decoration removed ("claude-fable-5[1m]", "Gemini 3.5 Flash (Medium)") and
// then a trailing -YYYYMMDD release date removed. Earlier (less-stripped)
// candidates win. Arbitrary substring matching is deliberately avoided: a
// shorter pricing key inside a longer model name (gpt-5.5 inside
// gpt-5.5-codex) would silently misprice a distinct model that should stay
// unpriced.
//
// Keys are ranked so resolution is deterministic when several keys
// canonicalize alike: a key whose provider prefix matches the model's wins,
// then an unqualified key, then a provider-qualified key for an unqualified
// model. Distinct keys tied within one rank are ambiguous and stay unresolved.
// Keys whose provider conflicts with a qualified model are never considered.
func resolveCanonicalMatch[T any](m map[string]T, model string) (T, string, bool) {
var zero T
candidates := canonicalCandidates(model)
if len(candidates) == 0 {
return zero, "", false
}
const ranks = 3
modelProvider := canonicalProvider(model)
counts := make([][ranks]int, len(candidates))
vals := make([][ranks]T, len(candidates))
patterns := make([][ranks]string, len(candidates))
for k, v := range m {
keyProvider := canonicalProvider(k)
if keyProvider != "" && modelProvider != "" &&
keyProvider != modelProvider {
continue
}
kCanon := canonicalize(k)
if kCanon == "" {
continue
}
rank := keyRank(modelProvider, keyProvider)
for i, c := range candidates {
if kCanon == c {
counts[i][rank]++
vals[i][rank] = v
patterns[i][rank] = k
break
}
}
}
for i := range candidates {
for r := range ranks {
if counts[i][r] == 1 {
return vals[i][r], patterns[i][r], true
}
if counts[i][r] > 1 {
return zero, "", false
}
}
}
return zero, "", false
}
// keyRank orders canonical matches: same-provider key (0), unqualified
// key (1), provider-qualified key for an unqualified model (2).
func keyRank(modelProvider, keyProvider string) int {
switch keyProvider {
case "":
return 1
case modelProvider:
return 0
default:
return 2
}
}
// canonicalCandidates returns the canonical forms of model to try, in
// decreasing specificity: as reported, with one trailing bracketed or
// parenthesized decoration removed, and additionally with a trailing
// -YYYYMMDD release date removed.
func canonicalCandidates(model string) []string {
var candidates []string
add := func(s string) {
c := canonicalize(s)
if c != "" && !slices.Contains(candidates, c) {
candidates = append(candidates, c)
}
}
add(model)
undecorated := stripTrailingGroup(model)
add(undecorated)
add(stripTrailingDate(undecorated))
return candidates
}
// canonicalProvider returns the canonicalized provider prefix of a
// model name ("openai/gpt-5.5" -> "openai"), or "" when unqualified.
func canonicalProvider(s string) string {
idx := strings.LastIndex(s, "/")
if idx <= 0 {
return ""
}
var sb strings.Builder
for _, r := range strings.ToLower(s[:idx]) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
sb.WriteRune(r)
}
}
return sb.String()
}
// stripTrailingGroup removes one trailing parenthesized or bracketed
// decoration: "Gemini 3.5 Flash (Medium)" -> "Gemini 3.5 Flash",
// "claude-fable-5[1m]" -> "claude-fable-5".
func stripTrailingGroup(s string) string {
t := strings.TrimRight(s, " ")
var open string
switch {
case strings.HasSuffix(t, ")"):
open = "("
case strings.HasSuffix(t, "]"):
open = "["
default:
return s
}
i := strings.LastIndex(t, open)
if i <= 0 {
return s
}
return strings.TrimRight(t[:i], " ")
}
// stripTrailingDate removes a trailing -YYYYMMDD release-date suffix:
// "claude-opus-4-7-20260101" -> "claude-opus-4-7".
func stripTrailingDate(s string) string {
i := strings.LastIndex(s, "-")
if i <= 0 || len(s)-i-1 != 8 {
return s
}
for _, r := range s[i+1:] {
if r < '0' || r > '9' {
return s
}
}
return s[:i]
}