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
222 lines
6.2 KiB
Go
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]
|
|
}
|