Skip to content
Merged
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
209 changes: 0 additions & 209 deletions pkg/modelsdev/catalog.go
Original file line number Diff line number Diff line change
@@ -1,222 +1,13 @@
package modelsdev

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing this file’s entire pricing implementation without simultaneously updating the package contract leaves pkg/modelsdev/README.md and the public API spec claiming FindPricing still exists, which will break users and downstream automation that treat those docs/specs as authoritative.

💡 Update the contract docs or keep the API until callers are migrated

This PR deletes FindPricing and all of its behavior/tests, but the package README still documents FindPricing as part of the public API and still shows a compiling usage example. The repo also has broader docs that describe AIC pricing as being sourced from the models.dev catalog. If this removal is intentional, the docs/spec surface needs to be updated in the same change so consumers do not merge a broken contract.

Suggested follow-up in this PR:

- remove the `FindPricing` entry and example from `pkg/modelsdev/README.md`
- update any user-facing docs that describe gh-aw pricing lookup via models.dev
- explain the replacement source of pricing data in the PR body / changelog

Right now this is a breaking API deletion disguised as dead-code cleanup.


import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
Comment on lines 3 to 4
"time"

"github.com/github/gh-aw/pkg/logger"
"github.com/github/gh-aw/pkg/syncutil"
)

const (
fetchTimeout = 5 * time.Second
maxBodyBytes = 4 * 1024 * 1024 // 4 MiB safety cap
)

// catalogURL is a variable so tests can override it with a local HTTP server.
var catalogURL = "https://models.dev/catalog.json"

// modelIDReplacer normalizes separator characters in model IDs so that IDs
// differing only in ".", "_", or "-" compare equal.
var modelIDReplacer = strings.NewReplacer(".", "-", "_", "-")

var pkgLog = logger.New("modelsdev:catalog")

// rawCatalog mirrors the top-level models.dev catalog JSON structure.
type rawCatalog struct {
Providers map[string]rawProvider `json:"providers"`
}

type rawProvider struct {
Models map[string]rawModel `json:"models"`
}

type rawModel struct {
// Cost values are per-million-token numbers (or pre-normalized strings) in the catalog.
Cost map[string]json.RawMessage `json:"cost"`
}

// pricingCache maps normalizedProvider → normalizedModel → per-token pricing.
type pricingCache = map[string]map[string]map[string]float64

var (
catalogCache syncutil.OnceLoader[pricingCache]

// httpClientFactory is overridable for tests.
httpClientFactory = func() *http.Client {
return &http.Client{Timeout: fetchTimeout}
}
)

// FindPricing looks up per-token pricing for the given provider/model from the downloaded
// models.dev catalog. Returns (nil, false) when the catalog is unavailable or the model
// is not found.
func FindPricing(ctx context.Context, provider, model string) (map[string]float64, bool) {
catalog := ensureCatalog(ctx)
if len(catalog) == 0 {
return nil, false
}

normalizedProvider := NormalizeProvider(provider)
trimmedModel := strings.TrimSpace(model)
if trimmedModel == "" {
return nil, false
}
normalizedModel := strings.ToLower(trimmedModel)
comparableModel := NormalizeComparableModelID(normalizedModel)

pkgLog.Printf("FindPricing: looking up provider=%q model=%q", normalizedProvider, normalizedModel)

// Provider-scoped exact match.
if normalizedProvider != "" {
if providerModels, ok := catalog[normalizedProvider]; ok {
if pricing, ok := providerModels[normalizedModel]; ok {
pkgLog.Printf("FindPricing: provider-scoped exact match for %q/%q", normalizedProvider, normalizedModel)
return pricing, true
}
// Comparable (dot/underscore-normalized) model ID match.
for mn, pricing := range providerModels {
if NormalizeComparableModelID(mn) == comparableModel {
pkgLog.Printf("FindPricing: provider-scoped comparable match %q for %q", mn, normalizedModel)
return pricing, true
}
}
}
}

// Cross-provider fallback (when provider is unknown or empty).
for _, providerModels := range catalog {
if pricing, ok := providerModels[normalizedModel]; ok {
pkgLog.Printf("FindPricing: cross-provider fallback match for model %q", normalizedModel)
return pricing, true
}
for mn, pricing := range providerModels {
if NormalizeComparableModelID(mn) == comparableModel {
pkgLog.Printf("FindPricing: cross-provider comparable match %q for %q", mn, normalizedModel)
return pricing, true
}
}
}

pkgLog.Printf("FindPricing: no pricing found for provider=%q model=%q", normalizedProvider, normalizedModel)
return nil, false
}

// ensureCatalog downloads and normalizes the models.dev pricing catalog at most once per
// process. Network failures are logged and result in an empty (non-nil) cache so
// subsequent calls are instant no-ops.
func ensureCatalog(ctx context.Context) pricingCache {
downloaded, _ := catalogCache.Get(func() (pricingCache, error) {
downloaded, err := downloadAndParseCatalog(ctx)
if err != nil {
pkgLog.Printf("models.dev catalog download failed (pricing fallback unavailable): %v", err)
return pricingCache{}, nil
} else {
total := 0
for _, models := range downloaded {
total += len(models)
}
pkgLog.Printf("Downloaded models.dev catalog: %d providers, %d total models", len(downloaded), total)
}
return downloaded, nil
})
return downloaded
}

func downloadAndParseCatalog(ctx context.Context) (pricingCache, error) {
reqCtx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel()

req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, catalogURL, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}

resp, err := httpClientFactory().Do(req)
if err != nil {
return nil, fmt.Errorf("GET %s: %w", catalogURL, err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected HTTP %d from %s", resp.StatusCode, catalogURL)
}

body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}

return parseCatalog(body)
}

// parseCatalog parses the raw models.dev catalog JSON and normalizes pricing to per-token
// float64 values. Numeric catalog values are in USD per-million tokens and are divided by
// 1,000,000; string values are treated as already per-token.
func parseCatalog(data []byte) (pricingCache, error) {
var raw rawCatalog
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parsing models.dev catalog JSON: %w", err)
}

parsed := make(pricingCache)
for providerName, provider := range raw.Providers {
normalizedProvider := NormalizeProvider(providerName)
if normalizedProvider == "" {
continue
}
if parsed[normalizedProvider] == nil {
parsed[normalizedProvider] = make(map[string]map[string]float64)
}
for modelName, model := range provider.Models {
trimmedModel := strings.TrimSpace(modelName)
if trimmedModel == "" {
continue
}
normalizedModel := strings.ToLower(trimmedModel)
pricing := parseCostMap(model.Cost)
if len(pricing) > 0 {
parsed[normalizedProvider][normalizedModel] = pricing
}
}
}
return parsed, nil
}

// parseCostMap converts a raw cost map from models.dev (per-million numbers or
// already-normalized per-token strings) into per-token float64 values.
func parseCostMap(raw map[string]json.RawMessage) map[string]float64 {
if len(raw) == 0 {
return nil
}
result := make(map[string]float64, len(raw))
for key, val := range raw {
if len(val) == 0 {
continue
}
// Attempt numeric decode — models.dev stores prices per million tokens.
var f float64
if err := json.Unmarshal(val, &f); err == nil {
result[key] = f / 1_000_000 // convert per-million → per-token
continue
}
// Fall back to string decode (pre-normalized per-token string values).
var s string
if err := json.Unmarshal(val, &s); err == nil {
if parsed, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
result[key] = parsed
}
}
}
return result
}

// NormalizeProvider maps provider aliases (e.g. "github", "copilot", "github_models")
// to their canonical form ("github-copilot") and lower-cases all other values.
func NormalizeProvider(provider string) string {
Expand Down
139 changes: 0 additions & 139 deletions pkg/modelsdev/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,150 +3,11 @@
package modelsdev

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// sampleCatalog is a minimal models.dev catalog JSON used in tests.
const sampleCatalog = `{
"providers": {
"anthropic": {
"models": {
"claude-new-model": {
"cost": {"input": 3.0, "output": 15.0}
},
"claude-no-cost": {}
}
},
"openai": {
"models": {
"gpt-99": {
"cost": {"input": 2.5, "output": 10.0, "cache_read": 1.25}
}
}
},
"unknown-provider": {
"models": {
"some-model": {
"cost": {"input": 1.0}
}
}
}
}
}`

func TestParseCatalog(t *testing.T) {
parsed, err := parseCatalog([]byte(sampleCatalog))
require.NoError(t, err)

// Anthropic claude-new-model should be present with per-token pricing.
require.Contains(t, parsed, "anthropic")
require.Contains(t, parsed["anthropic"], "claude-new-model")
pricing := parsed["anthropic"]["claude-new-model"]
assert.InDelta(t, 3.0/1_000_000, pricing["input"], 1e-15)
assert.InDelta(t, 15.0/1_000_000, pricing["output"], 1e-15)

// Models without cost should be excluded.
assert.NotContains(t, parsed["anthropic"], "claude-no-cost")

// OpenAI gpt-99 should be present.
require.Contains(t, parsed, "openai")
require.Contains(t, parsed["openai"], "gpt-99")
oaiPricing := parsed["openai"]["gpt-99"]
assert.InDelta(t, 2.5/1_000_000, oaiPricing["input"], 1e-15)
assert.InDelta(t, 1.25/1_000_000, oaiPricing["cache_read"], 1e-15)

// unknown-provider is lowercased and retained (normalizeProvider does not filter).
assert.Contains(t, parsed, "unknown-provider")
}

func TestParseCostMap(t *testing.T) {
cases := []struct {
name string
raw map[string]json.RawMessage
want map[string]float64
}{
{
name: "numeric per-million values",
raw: map[string]json.RawMessage{
"input": json.RawMessage("3.0"),
"output": json.RawMessage("15.0"),
},
want: map[string]float64{"input": 3.0 / 1_000_000, "output": 15.0 / 1_000_000},
},
{
name: "string per-token values",
raw: map[string]json.RawMessage{
"input": json.RawMessage(`"0.000003"`),
},
want: map[string]float64{"input": 0.000003},
},
{
name: "empty map",
raw: nil,
want: nil,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := parseCostMap(tc.raw)
if tc.want == nil {
assert.Nil(t, got)
return
}
for k, v := range tc.want {
assert.InDeltaf(t, v, got[k], 1e-15, "key %q", k)
}
})
}
}

func TestFindPricing(t *testing.T) {
origURL := catalogURL
origFactory := httpClientFactory
t.Cleanup(func() {
catalogCache.Reset()
catalogURL = origURL
httpClientFactory = origFactory
})

catalogCache.Reset()

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(sampleCatalog))
}))
defer srv.Close()

catalogURL = srv.URL
httpClientFactory = func() *http.Client { return srv.Client() }

t.Run("found_exact_provider_and_model", func(t *testing.T) {
pricing, ok := FindPricing(context.Background(), "anthropic", "claude-new-model")
require.True(t, ok)
assert.InDelta(t, 3.0/1_000_000, pricing["input"], 1e-15)
})

t.Run("cross_provider_fallback", func(t *testing.T) {
pricing, ok := FindPricing(context.Background(), "", "gpt-99")
require.True(t, ok)
assert.Contains(t, pricing, "input")
})

t.Run("not_found_returns_false", func(t *testing.T) {
pricing, ok := FindPricing(context.Background(), "anthropic", "does-not-exist")
assert.False(t, ok)
assert.Nil(t, pricing)
})
}

func TestNormalizeProvider(t *testing.T) {
cases := []struct{ input, want string }{
{"github", "github-copilot"},
Expand Down
Loading
Loading