diff --git a/Makefile b/Makefile index 86afaab..d894b74 100644 --- a/Makefile +++ b/Makefile @@ -72,7 +72,7 @@ check-eof: clean: rm -f coverage.out integration_coverage.out - go clean -testcache + go clean -cache -testcache install-hooks: ./scripts/install-hooks.sh diff --git a/QUICKSTART.md b/QUICKSTART.md index 5236fa6..74faedd 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -559,6 +559,34 @@ totalCost += result.Usage.Cost totalTokens += result.Usage.TotalTokens ``` +Supported models are validated via the built-in model catalog: + +```go +fmt.Println(dsgo.IsValidModel("openai/gpt-4o-mini")) +for _, m := range dsgo.ListModelsByProvider("openai") { + pricing, ok := dsgo.DefaultCostCalculator.GetPricing(m.ID) + if !ok { + fmt.Println(m.ID) + continue + } + + fmt.Printf("%s price=%+v ctx=%d out=%d tools=%v json=%v\n", + m.ID, + pricing, + m.Limits.ContextTokens, + m.Limits.OutputTokens, + m.Capabilities.ToolCall, + m.Capabilities.StructuredOutput, + ) +} +``` + +If you register a custom provider via `dsgo.RegisterLM`, you must also register its supported models: + +```go +_ = dsgo.RegisterModel(dsgo.Model{ID: "myprovider/my-model"}) +``` + ### Caching DSGo includes automatic LRU caching: diff --git a/dsgo.go b/dsgo.go index aac4cd2..3b8d839 100644 --- a/dsgo.go +++ b/dsgo.go @@ -6,9 +6,11 @@ package dsgo import ( "github.com/assagman/dsgo/internal/core" + "github.com/assagman/dsgo/internal/cost" "github.com/assagman/dsgo/internal/env" "github.com/assagman/dsgo/internal/logging" "github.com/assagman/dsgo/internal/mcp" + "github.com/assagman/dsgo/internal/modelcatalog" "github.com/assagman/dsgo/internal/module" signature_typed "github.com/assagman/dsgo/internal/signature_typed" @@ -69,6 +71,17 @@ type ( ToolParameter = core.ToolParameter ) +// Re-export model catalog and cost types +type ( + Model = modelcatalog.Model + Pricing = modelcatalog.Pricing + Limits = modelcatalog.Limits + Capabilities = modelcatalog.Capabilities + Modalities = modelcatalog.Modalities + Metadata = modelcatalog.Metadata + CostCalculator = cost.Calculator +) + // Re-export module types type ( Predict = module.Predict @@ -163,6 +176,7 @@ var ( WithModel = core.WithModel WithTimeout = core.WithTimeout WithLM = core.WithLM + WithSkipModelValidation = core.WithSkipModelValidation WithAPIKey = core.WithAPIKey WithMaxRetries = core.WithMaxRetries WithTracing = core.WithTracing @@ -198,6 +212,20 @@ var ( StripMarkers = core.StripMarkers ) +// Re-export model catalog and cost functions +var ( + RegisterModel = modelcatalog.RegisterModel + RegisterAlias = modelcatalog.RegisterAlias + ResolveModel = modelcatalog.Resolve + IsValidModel = modelcatalog.IsValid + IsValidCanonicalModel = modelcatalog.IsValidCanonical + ListModels = modelcatalog.ListModels + ListModelsByProvider = modelcatalog.ListModelsByProvider + GetModel = modelcatalog.GetModel + DefaultCostCalculator = cost.DefaultCalculator + CalculateCost = cost.Calculate +) + // Re-export module functions var ( NewPredict = module.NewPredict diff --git a/dsgo_test.go b/dsgo_test.go index 5d63415..b7d5afe 100644 --- a/dsgo_test.go +++ b/dsgo_test.go @@ -44,7 +44,7 @@ func TestPackageInit(t *testing.T) { }, { name: "OpenRouter provider registered", - model: "openrouter/anthropic/claude-3.5-sonnet", + model: "openrouter/anthropic/claude-3.7-sonnet", shouldWork: true, expectedError: "", skipIfNoAPIKey: true, @@ -110,6 +110,40 @@ func TestPackageInit(t *testing.T) { } } +// TestModelCatalog verifies model catalog and cost APIs +func TestModelCatalog(t *testing.T) { + if !IsValidModel("openai/gpt-4o") { + t.Fatal("expected openai/gpt-4o to be valid") + } + if !IsValidModel("gpt-4o") { + t.Fatal("expected alias gpt-4o to be valid") + } + if IsValidModel("openai/does-not-exist") { + t.Fatal("expected unknown model to be invalid") + } + + models := ListModelsByProvider("openai") + if len(models) == 0 { + t.Fatal("expected some openai models") + } + + found := false + for _, m := range models { + if m.ID == "openai/gpt-4o" { + found = true + break + } + } + if !found { + t.Fatal("expected openai/gpt-4o to be listed") + } + + pricing, ok := DefaultCostCalculator.GetPricing("openai/gpt-4o") + if !ok || pricing.PromptPrice == 0 { + t.Fatal("expected pricing for openai/gpt-4o") + } +} + // TestReexportedTypes verifies that all re-exported types are accessible and properly typed func TestReexportedTypes(t *testing.T) { // This test ensures that the type aliases in dsgo.go compile and are accessible diff --git a/examples/README.md b/examples/README.md index 7080f6a..3beabff 100644 --- a/examples/README.md +++ b/examples/README.md @@ -17,5 +17,5 @@ Runnable examples for DSGo. | `yaml_program/` | Declarative pipeline builder | `cd examples/yaml_program && go run main.go` | YAML config, MCP | Notes: -- Use provider-prefixed model IDs (e.g. `openai/gpt-4o-mini`, `openrouter/anthropic/claude-3-opus`). +- Use provider-prefixed model IDs (e.g. `openai/gpt-4o-mini`, `openrouter/google/gemini-2.5-flash-lite-preview-09-2025`). - Most examples require `OPENAI_API_KEY` or `OPENROUTER_API_KEY`. diff --git a/examples/caching/main.go b/examples/caching/main.go index 2b097a3..8c7dda7 100644 --- a/examples/caching/main.go +++ b/examples/caching/main.go @@ -17,7 +17,7 @@ func getModelName() string { if model := os.Getenv("EXAMPLES_DEFAULT_MODEL"); model != "" { return model } - return "openrouter/amazon/nova-2-lite-v1" + return "openrouter/google/gemini-2.5-flash-lite-preview-09-2025" } // SentimentInput defines input for sentiment classification diff --git a/examples/codebase_analysis/main.go b/examples/codebase_analysis/main.go index f9d28b4..94568ce 100644 --- a/examples/codebase_analysis/main.go +++ b/examples/codebase_analysis/main.go @@ -24,7 +24,7 @@ func getModelName() string { if model := os.Getenv("EXAMPLES_DEFAULT_MODEL"); model != "" { return model } - return "openrouter/amazon/nova-2-lite-v1" // default fallback + return "openrouter/google/gemini-2.5-flash-lite-preview-09-2025" // default fallback } // ============================================================================ diff --git a/examples/filesystem_mcp/go.mod b/examples/filesystem_mcp/go.mod index 67ab55a..01b3b75 100644 --- a/examples/filesystem_mcp/go.mod +++ b/examples/filesystem_mcp/go.mod @@ -5,3 +5,11 @@ go 1.25 replace github.com/assagman/dsgo => ../.. require github.com/assagman/dsgo v0.0.0-00010101000000-000000000000 + +require ( + github.com/openai/openai-go/v3 v3.13.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect +) diff --git a/examples/filesystem_mcp/go.sum b/examples/filesystem_mcp/go.sum new file mode 100644 index 0000000..cb0b239 --- /dev/null +++ b/examples/filesystem_mcp/go.sum @@ -0,0 +1,12 @@ +github.com/openai/openai-go/v3 v3.13.0 h1:arSFmVHcBHNVYG5iqspPJrLoin0Qqn2JcCLWWcTcM1Q= +github.com/openai/openai-go/v3 v3.13.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= diff --git a/examples/modules/parallel/parallel b/examples/modules/parallel/parallel deleted file mode 100755 index f2fcb75..0000000 Binary files a/examples/modules/parallel/parallel and /dev/null differ diff --git a/examples/package_analysis/go.mod b/examples/package_analysis/go.mod index 4ed8353..52a579e 100644 --- a/examples/package_analysis/go.mod +++ b/examples/package_analysis/go.mod @@ -7,6 +7,14 @@ require ( github.com/assagman/dsgo/examples/shared v0.0.0 ) +require ( + github.com/openai/openai-go/v3 v3.13.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect +) + replace github.com/assagman/dsgo => ../.. replace github.com/assagman/dsgo/examples/shared => ../shared diff --git a/examples/package_analysis/go.sum b/examples/package_analysis/go.sum new file mode 100644 index 0000000..cb0b239 --- /dev/null +++ b/examples/package_analysis/go.sum @@ -0,0 +1,12 @@ +github.com/openai/openai-go/v3 v3.13.0 h1:arSFmVHcBHNVYG5iqspPJrLoin0Qqn2JcCLWWcTcM1Q= +github.com/openai/openai-go/v3 v3.13.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= diff --git a/examples/package_analysis/main.go b/examples/package_analysis/main.go index 855b9e0..bd08aff 100644 --- a/examples/package_analysis/main.go +++ b/examples/package_analysis/main.go @@ -34,7 +34,7 @@ const ( FieldNextSteps = "next_steps" // Model - ModelName = "openrouter/amazon/nova-2-lite-v1" + ModelName = "openrouter/minimax/minimax-m2" ) func main() { diff --git a/examples/react_experiment/main.go b/examples/react_experiment/main.go index 512daeb..d72df19 100644 --- a/examples/react_experiment/main.go +++ b/examples/react_experiment/main.go @@ -18,14 +18,12 @@ func main() { ctx := context.Background() models := []string{ - "openrouter/amazon/nova-2-lite-v1", + "openrouter/google/gemini-2.5-flash-lite-preview-09-2025", "openrouter/openai/gpt-4o-mini", "openrouter/openai/gpt-5-mini-2025-08-07", "openrouter/openai/gpt-5-nano-2025-08-07", "openrouter/openai/gpt-4.1-2025-04-14", "openrouter/google/gemini-2.5-flash", - "openrouter/google/gemini-2.5-flash-lite-preview-09-2025", - "openrouter/anthropic/claude-haiku-4.5", "openrouter/x-ai/grok-code-fast-1", "openrouter/deepseek/deepseek-v3.2", "openrouter/qwen/qwen3-next-80b-a3b-instruct", diff --git a/examples/snowflake_trainer/README.md b/examples/snowflake_trainer/README.md index 213d100..2dd173f 100644 --- a/examples/snowflake_trainer/README.md +++ b/examples/snowflake_trainer/README.md @@ -34,7 +34,7 @@ export EXA_API_KEY="..." export TAVILY_API_KEY="..." # Optional Configuration -export TRAINER_MODEL="openrouter/anthropic/claude-3.5-sonnet" +export TRAINER_MODEL="openrouter/google/gemini-2.5-flash-lite-preview-09-2025" export TRAINER_MAX_WORKERS="6" export TRAINER_TOPIC="Snowflake Data Cloud" ``` diff --git a/examples/snowflake_trainer/go.mod b/examples/snowflake_trainer/go.mod index 95206a6..a564825 100644 --- a/examples/snowflake_trainer/go.mod +++ b/examples/snowflake_trainer/go.mod @@ -4,4 +4,12 @@ go 1.25 require github.com/assagman/dsgo v0.0.0 +require ( + github.com/openai/openai-go/v3 v3.13.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect +) + replace github.com/assagman/dsgo => ../.. diff --git a/examples/snowflake_trainer/go.sum b/examples/snowflake_trainer/go.sum new file mode 100644 index 0000000..cb0b239 --- /dev/null +++ b/examples/snowflake_trainer/go.sum @@ -0,0 +1,12 @@ +github.com/openai/openai-go/v3 v3.13.0 h1:arSFmVHcBHNVYG5iqspPJrLoin0Qqn2JcCLWWcTcM1Q= +github.com/openai/openai-go/v3 v3.13.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= diff --git a/examples/snowflake_trainer/main.go b/examples/snowflake_trainer/main.go index 58a1189..65f8f9a 100644 --- a/examples/snowflake_trainer/main.go +++ b/examples/snowflake_trainer/main.go @@ -18,7 +18,7 @@ import ( ) const ( - DefaultModel = "openrouter/google/gemini-2.5-flash" + DefaultModel = "openrouter/minimax/minimax-m2" DefaultTopic = "Snowflake Data Cloud Platform" Timeout = 15 * time.Minute ) diff --git a/examples/yaml_program/pipeline_todo.yaml b/examples/yaml_program/pipeline_todo.yaml index 63b37ba..2f3faf2 100644 --- a/examples/yaml_program/pipeline_todo.yaml +++ b/examples/yaml_program/pipeline_todo.yaml @@ -13,7 +13,7 @@ # go run . pipeline_todo.yaml name: pipeline_todo -description: Generates a repo-aware numbered TODO list from a task +description: Project TODO list generation via codebase analysis and web research model: name: openrouter/z-ai/glm-4.6 @@ -33,7 +33,7 @@ mcp: filesystem: type: filesystem allowed_dirs: - - ~/source/me/dsgo/example-generator/ + - . signatures: codebase_analysis: @@ -139,4 +139,4 @@ pipeline: inputs: task: | - Review cost operations + Review project code quality diff --git a/integration/cache_behavior_test.go b/integration/cache_behavior_test.go index c0fd618..c73559c 100644 --- a/integration/cache_behavior_test.go +++ b/integration/cache_behavior_test.go @@ -92,7 +92,7 @@ func TestCacheIntegration_MockProvider_CacheHitAvoidsSecondRequest(t *testing.T) t.Cleanup(globalConfigMu.Unlock) t.Cleanup(dsgo.ResetConfig) - server, recorder := newValidatedMockServer(t, validateChatCompletionRequest("gpt-4"), func(w http.ResponseWriter, r *http.Request, _ []byte) { + server, recorder := newValidatedMockServer(t, validateChatCompletionRequest("gpt-4o"), func(w http.ResponseWriter, r *http.Request, _ []byte) { w.WriteHeader(http.StatusOK) _, _ = fmt.Fprint(w, `{ "id":"test", @@ -112,7 +112,7 @@ func TestCacheIntegration_MockProvider_CacheHitAvoidsSecondRequest(t *testing.T) // Wire mock provider to server. t.Setenv("DSGO_MOCK_BASE_URL", server.URL) - lm, err := dsgo.NewLM(context.Background(), "mock/gpt-4") + lm, err := dsgo.NewLM(context.Background(), "mock/gpt-4o") if err != nil { t.Fatalf("failed to create mock LM: %v", err) } @@ -147,7 +147,7 @@ func TestCacheIntegration_MockProvider_CacheTTLExpires(t *testing.T) { t.Cleanup(globalConfigMu.Unlock) t.Cleanup(dsgo.ResetConfig) - server, recorder := newValidatedMockServer(t, validateChatCompletionRequest("gpt-4"), func(w http.ResponseWriter, r *http.Request, _ []byte) { + server, recorder := newValidatedMockServer(t, validateChatCompletionRequest("gpt-4o"), func(w http.ResponseWriter, r *http.Request, _ []byte) { w.WriteHeader(http.StatusOK) _, _ = fmt.Fprint(w, `{ "id":"test", @@ -171,7 +171,7 @@ func TestCacheIntegration_MockProvider_CacheTTLExpires(t *testing.T) { t.Fatal("expected DefaultCache to be configured") } - lm, err := dsgo.NewLM(context.Background(), "mock/gpt-4") + lm, err := dsgo.NewLM(context.Background(), "mock/gpt-4o") if err != nil { t.Fatalf("failed to create mock LM: %v", err) } @@ -202,7 +202,7 @@ func TestCacheIntegration_MockProvider_CacheClearForcesRefetch(t *testing.T) { t.Cleanup(globalConfigMu.Unlock) t.Cleanup(dsgo.ResetConfig) - server, recorder := newValidatedMockServer(t, validateChatCompletionRequest("gpt-4"), func(w http.ResponseWriter, r *http.Request, _ []byte) { + server, recorder := newValidatedMockServer(t, validateChatCompletionRequest("gpt-4o"), func(w http.ResponseWriter, r *http.Request, _ []byte) { w.WriteHeader(http.StatusOK) _, _ = fmt.Fprint(w, `{ "id":"test", @@ -221,7 +221,7 @@ func TestCacheIntegration_MockProvider_CacheClearForcesRefetch(t *testing.T) { t.Fatal("expected DefaultCache to be configured") } - lm, err := dsgo.NewLM(context.Background(), "mock/gpt-4") + lm, err := dsgo.NewLM(context.Background(), "mock/gpt-4o") if err != nil { t.Fatalf("failed to create mock LM: %v", err) } diff --git a/integration/models_dev_sync_test.go b/integration/models_dev_sync_test.go new file mode 100644 index 0000000..4b71926 --- /dev/null +++ b/integration/models_dev_sync_test.go @@ -0,0 +1,227 @@ +package integration + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "os" + "sort" + "strings" + "testing" + "time" + + dsgo "github.com/assagman/dsgo" +) + +const modelsDevAPIURL = "https://models.dev/api.json" + +type modelsDevProvider struct { + Models map[string]modelsDevModel `json:"models"` +} + +type modelsDevModel struct { + ID string `json:"id"` + Name string `json:"name"` + Family string `json:"family"` + Attachment bool `json:"attachment"` + Reasoning bool `json:"reasoning"` + ToolCall bool `json:"tool_call"` + StructuredOutput bool `json:"structured_output"` + Temperature bool `json:"temperature"` + Knowledge string `json:"knowledge"` + ReleaseDate string `json:"release_date"` + LastUpdated string `json:"last_updated"` + OpenWeights bool `json:"open_weights"` + Cost struct { + Input float64 `json:"input"` + Output float64 `json:"output"` + CacheRead float64 `json:"cache_read"` + } `json:"cost"` + Limit struct { + Context int `json:"context"` + Output int `json:"output"` + } `json:"limit"` +} + +func TestModelCatalog_SyncedWithModelsDev(t *testing.T) { + if os.Getenv("DSGO_SKIP_MODELS_DEV_SYNC") == "1" { + t.Skip("DSGO_SKIP_MODELS_DEV_SYNC=1") + } + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsDevAPIURL, nil) + if err != nil { + t.Fatalf("http.NewRequest: %v", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("fetch %s: %v", modelsDevAPIURL, err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + t.Errorf("close models.dev response body: %v", err) + } + }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("fetch %s: unexpected status %s", modelsDevAPIURL, resp.Status) + } + + var root map[string]modelsDevProvider + if err := json.NewDecoder(resp.Body).Decode(&root); err != nil { + t.Fatalf("decode models.dev api: %v", err) + } + + openaiProvider, ok := root["openai"] + if !ok { + t.Fatalf("models.dev response missing provider 'openai'") + } + openrouterProvider, ok := root["openrouter"] + if !ok { + t.Fatalf("models.dev response missing provider 'openrouter'") + } + + verifyProvider(t, "openai", openaiProvider.Models, true) + verifyProvider(t, "openrouter", openrouterProvider.Models, false) +} + +func verifyProvider(t *testing.T, provider string, remote map[string]modelsDevModel, expectAlias bool) { + t.Helper() + + catalogModels := dsgo.ListModelsByProvider(provider) + catalog := make(map[string]dsgo.Model, len(catalogModels)) + for _, m := range catalogModels { + catalog[m.ID] = m + } + + remoteIDs := make(map[string]modelsDevModel, len(remote)) + for key, entry := range remote { + id := entry.ID + if id == "" { + id = key + } + remoteIDs[provider+"/"+id] = entry + } + + var missing []string + for id := range remoteIDs { + if _, ok := catalog[id]; !ok { + missing = append(missing, id) + } + } + + var extra []string + for id := range catalog { + if _, ok := remoteIDs[id]; !ok { + extra = append(extra, id) + } + } + + sort.Strings(missing) + sort.Strings(extra) + + if len(missing) > 0 || len(extra) > 0 { + msg := fmt.Sprintf("provider=%s: catalog out of sync with models.dev", provider) + if len(missing) > 0 { + msg += fmt.Sprintf("\nmissing (%d): %s", len(missing), strings.Join(limitList(missing, 25), ", ")) + } + if len(extra) > 0 { + msg += fmt.Sprintf("\nextra (%d): %s", len(extra), strings.Join(limitList(extra, 25), ", ")) + } + t.Fatal(msg) + } + + for id, remoteModel := range remoteIDs { + local := catalog[id] + + if expectAlias { + if len(local.Aliases) == 0 { + t.Fatalf("%s: expected at least one alias", id) + } + // For OpenAI we register the raw model id as alias. + if local.Aliases[0] != strings.TrimPrefix(id, provider+"/") { + t.Fatalf("%s: alias[0]=%q want %q", id, local.Aliases[0], strings.TrimPrefix(id, provider+"/")) + } + } else if len(local.Aliases) != 0 { + t.Fatalf("%s: expected no aliases, got %v", id, local.Aliases) + } + + // Modalities are intentionally forced to text-only for now. + if !stringSliceEqual(local.Modalities.Input, []string{"text"}) { + t.Fatalf("%s: Modalities.Input=%v want [text]", id, local.Modalities.Input) + } + if !stringSliceEqual(local.Modalities.Output, []string{"text"}) { + t.Fatalf("%s: Modalities.Output=%v want [text]", id, local.Modalities.Output) + } + + assertFloatClose(t, id+" pricing.prompt", local.Pricing.PromptPrice, remoteModel.Cost.Input) + assertFloatClose(t, id+" pricing.completion", local.Pricing.CompletionPrice, remoteModel.Cost.Output) + assertFloatClose(t, id+" pricing.cache_read", local.Pricing.CacheReadPrice, remoteModel.Cost.CacheRead) + + if local.Limits.ContextTokens != remoteModel.Limit.Context { + t.Fatalf("%s: Limits.ContextTokens=%d want %d", id, local.Limits.ContextTokens, remoteModel.Limit.Context) + } + if local.Limits.OutputTokens != remoteModel.Limit.Output { + t.Fatalf("%s: Limits.OutputTokens=%d want %d", id, local.Limits.OutputTokens, remoteModel.Limit.Output) + } + + wantCaps := dsgo.Capabilities{ + Attachment: remoteModel.Attachment, + Reasoning: remoteModel.Reasoning, + ToolCall: remoteModel.ToolCall, + StructuredOutput: remoteModel.StructuredOutput, + Temperature: remoteModel.Temperature, + } + if local.Capabilities != wantCaps { + t.Fatalf("%s: Capabilities=%+v want %+v", id, local.Capabilities, wantCaps) + } + + wantMeta := dsgo.Metadata{ + Name: remoteModel.Name, + Family: remoteModel.Family, + Knowledge: remoteModel.Knowledge, + ReleaseDate: remoteModel.ReleaseDate, + LastUpdated: remoteModel.LastUpdated, + OpenWeights: remoteModel.OpenWeights, + } + if local.Metadata != wantMeta { + t.Fatalf("%s: Metadata=%+v want %+v", id, local.Metadata, wantMeta) + } + } +} + +func assertFloatClose(t *testing.T, label string, got, want float64) { + t.Helper() + if math.IsNaN(got) || math.IsNaN(want) { + t.Fatalf("%s: NaN not allowed (got=%v want=%v)", label, got, want) + } + if math.Abs(got-want) > 1e-9 { + t.Fatalf("%s: got=%v want=%v", label, got, want) + } +} + +func stringSliceEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func limitList(in []string, n int) []string { + if len(in) <= n { + return in + } + out := append([]string(nil), in[:n]...) + out = append(out, fmt.Sprintf("... and %d more", len(in)-n)) + return out +} diff --git a/integration/provider_http_mock_test.go b/integration/provider_http_mock_test.go index a467559..9ec5294 100644 --- a/integration/provider_http_mock_test.go +++ b/integration/provider_http_mock_test.go @@ -426,9 +426,10 @@ func TestCostCalculation_OpenAI(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Check if pricing is available - pricing, ok := calc.GetPricing(tt.provider, tt.model) + modelKey := tt.provider + "/" + tt.model + pricing, ok := calc.GetPricing(modelKey) if !ok { - t.Skipf("pricing not available for model %s/%s", tt.provider, tt.model) + t.Skipf("pricing not available for model %s", modelKey) } // Calculate cost - just verify it's positive for these tokens @@ -473,9 +474,10 @@ func TestCostCalculation_OpenRouter(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - pricing, ok := calc.GetPricing(tt.provider, tt.model) + modelKey := tt.provider + "/" + tt.model + pricing, ok := calc.GetPricing(modelKey) if !ok { - t.Skipf("pricing not available for model %s/%s", tt.provider, tt.model) + t.Skipf("pricing not available for model %s", modelKey) } // Cost should be calculable (even if > 0) @@ -1210,7 +1212,7 @@ func (o *openrouterProvider_internal) IsOpenAI() bool { return false func (o *openrouterProvider_internal) SetCache(cache dsgo.Cache) { o.Cache = cache } // calculateCost calculates the cost based on model pricing and token counts -func calculateCost(pricing *cost.ModelPricing, promptTokens, completionTokens int) float64 { +func calculateCost(pricing *cost.Pricing, promptTokens, completionTokens int) float64 { if pricing == nil { return 0 } diff --git a/internal/core/configure.go b/internal/core/configure.go index a0148e4..70dbaa3 100644 --- a/internal/core/configure.go +++ b/internal/core/configure.go @@ -83,6 +83,14 @@ func WithTracing(enable bool) Option { } } +// WithSkipModelValidation enables or disables strict model validation in NewLM. +// Use this as an escape hatch for using models not yet in the catalog. +func WithSkipModelValidation(skip bool) Option { + return func(s *Settings) { + s.SkipModelValidation = skip + } +} + // WithCollector sets the default collector for LM observability. func WithCollector(collector Collector) Option { return func(s *Settings) { diff --git a/internal/core/lm_factory.go b/internal/core/lm_factory.go index bfd1ea3..4dd1f63 100644 --- a/internal/core/lm_factory.go +++ b/internal/core/lm_factory.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" "sync" + + "github.com/assagman/dsgo/internal/modelcatalog" ) // LMFactory is a function that creates an LM instance for a given model. @@ -20,6 +22,12 @@ var ( func RegisterLM(provider string, factory LMFactory) { registryLock.Lock() defer registryLock.Unlock() + + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + return + } + lmRegistry[provider] = factory } @@ -36,16 +44,16 @@ func RegisterLM(provider string, factory LMFactory) { // - NewLM(ctx, "openrouter/meta-llama/llama-3.3-70b-instruct") -> uses openrouter provider with model "meta-llama/llama-3.3-70b-instruct" func NewLM(ctx context.Context, model string) (LM, error) { if model == "" { - return nil, fmt.Errorf("model string is required - provide a valid model like 'openai/gpt-4o' or 'openrouter/z-ai/glm-4.6'. Example: dsgo.NewLM(ctx, \"openai/gpt-4o\")") + return nil, fmt.Errorf("model string is required - provide a valid model like 'openai/gpt-4o' or 'openrouter/google/gemini-2.5-flash'. Example: dsgo.NewLM(ctx, \"openai/gpt-4o\")") } // Parse provider and model from model string parts := strings.SplitN(model, "/", 2) if len(parts) < 2 { - return nil, fmt.Errorf("model string must include provider: format 'provider/model' (e.g., 'openai/gpt-4o' or 'openrouter/z-ai/glm-4.6'). Example: dsgo.NewLM(ctx, \"openai/gpt-4o\")") + return nil, fmt.Errorf("model string must include provider: format 'provider/model' (e.g., 'openai/gpt-4o' or 'openrouter/google/gemini-2.5-flash'). Example: dsgo.NewLM(ctx, \"openai/gpt-4o\")") } - provider := parts[0] + provider := strings.ToLower(parts[0]) targetModel := parts[1] // Get factory for provider @@ -57,6 +65,25 @@ func NewLM(ctx context.Context, model string) (LM, error) { return nil, fmt.Errorf("provider '%s' not registered for model '%s'. Available providers: %v. Example: dsgo.NewLM(ctx, \"openai/gpt-4o\")", provider, targetModel, getRegisteredProviders()) } + canonicalModel := provider + "/" + targetModel + + // Check if model is valid (unless validation is skipped) + if !GetSettings().SkipModelValidation && !modelcatalog.IsValidCanonical(canonicalModel) { + candidates := modelcatalog.ListModelsByProvider(provider) + if len(candidates) > 0 { + max := 5 + if len(candidates) < max { + max = len(candidates) + } + examples := make([]string, 0, max) + for i := 0; i < max; i++ { + examples = append(examples, candidates[i].ID) + } + return nil, fmt.Errorf("model '%s' is not supported. Use dsgo.ListModels() to see supported models. Examples for provider '%s': %v", canonicalModel, provider, examples) + } + return nil, fmt.Errorf("model '%s' is not supported. Use dsgo.RegisterModel() to add custom models, or dsgo.ListModels() to see supported models", canonicalModel) + } + // Create base LM baseLM := factory(targetModel) diff --git a/internal/core/lm_factory_test.go b/internal/core/lm_factory_test.go index c123d3e..555437e 100644 --- a/internal/core/lm_factory_test.go +++ b/internal/core/lm_factory_test.go @@ -2,7 +2,10 @@ package core import ( "context" + "strings" "testing" + + "github.com/assagman/dsgo/internal/modelcatalog" ) func TestRegisterLM(t *testing.T) { @@ -81,6 +84,14 @@ func TestNewLM(t *testing.T) { return &mockLM{} }) + // Authoritative model catalog requires explicit registration. + if err := modelcatalog.RegisterModel(modelcatalog.Model{ID: "testprovider/test-model", Limits: modelcatalog.Limits{ContextTokens: 1, OutputTokens: 1}, Modalities: modelcatalog.Modalities{Input: []string{"text"}, Output: []string{"text"}}, Metadata: modelcatalog.Metadata{Name: "Test Model", Family: "test"}}); err != nil { + t.Fatalf("RegisterModel(testprovider/test-model) err = %v", err) + } + if err := modelcatalog.RegisterModel(modelcatalog.Model{ID: "provider2/model-2", Limits: modelcatalog.Limits{ContextTokens: 1, OutputTokens: 1}, Modalities: modelcatalog.Modalities{Input: []string{"text"}, Output: []string{"text"}}, Metadata: modelcatalog.Metadata{Name: "Model 2", Family: "test"}}); err != nil { + t.Fatalf("RegisterModel(provider2/model-2) err = %v", err) + } + ctx := context.Background() t.Run("Success", func(t *testing.T) { @@ -129,7 +140,7 @@ func TestNewLM(t *testing.T) { if err == nil { t.Error("expected error when model string is empty") } - if err.Error() != "model string is required - provide a valid model like 'openai/gpt-4o' or 'openrouter/z-ai/glm-4.6'. Example: dsgo.NewLM(ctx, \"openai/gpt-4o\")" { + if !strings.Contains(err.Error(), "model string is required") { t.Errorf("unexpected error message: %v", err) } }) @@ -144,6 +155,61 @@ func TestNewLM(t *testing.T) { if errMsg == "" { t.Error("expected non-empty error message") } + if !strings.Contains(errMsg, "unknownprovider") { + t.Errorf("expected error to mention the missing provider, got: %s", errMsg) + } + }) + + t.Run("MixedCaseProviderRegistration", func(t *testing.T) { + // Register with mixed case + RegisterLM("MixedCaseProvider", func(model string) LM { + return &mockLM{} + }) + + // Should be able to look up with lowercase + lm, err := NewLM(ctx, "mixedcaseprovider/some-model") + if err == nil { + // This fails if model validation is strict and model not in catalog + // But for this test we mainly care that provider lookup succeeded. + // However, NewLM will fail on model validation. + // So we must register the model too or check error type. + // Let's check error. If it says "provider not registered", test fails. + // If it says "model not supported", test passes (provider found). + _ = lm // Silence unused variable error + } + + if err != nil && strings.Contains(err.Error(), "provider 'mixedcaseprovider' not registered") { + t.Errorf("expected provider to be found, but got error: %v", err) + } + }) + + t.Run("SkipModelValidation", func(t *testing.T) { + // Enable skip validation + Configure(WithSkipModelValidation(true)) + + // Register provider + RegisterLM("skipprovider", func(model string) LM { + // Use mockLM (no fields) as simple mock + return &mockLM{} + }) + + // Try to create unknown model + lm, err := NewLM(ctx, "skipprovider/unknown-model") + if err != nil { + t.Errorf("expected success when skipping validation, got error: %v", err) + } + if lm == nil { + t.Error("expected LM to be created") + } + + // Reset config + Configure(WithSkipModelValidation(false)) + + // Should fail now + _, err = NewLM(ctx, "skipprovider/unknown-model-2") + if err == nil { + t.Error("expected error when validation is enabled") + } }) t.Run("ContextCancellation", func(t *testing.T) { @@ -259,6 +325,9 @@ func TestLMFactory_WithCollector(t *testing.T) { } RegisterLM("test-provider", testLMFactory) RegisterLM("openrouter", testLMFactory) + if err := modelcatalog.RegisterModel(modelcatalog.Model{ID: "test-provider/test-model", Limits: modelcatalog.Limits{ContextTokens: 1, OutputTokens: 1}, Modalities: modelcatalog.Modalities{Input: []string{"text"}, Output: []string{"text"}}, Metadata: modelcatalog.Metadata{Name: "Test Model", Family: "test"}}); err != nil { + t.Fatalf("RegisterModel(test-provider/test-model) err = %v", err) + } ctx := context.Background() collector := NewMemoryCollector(10) @@ -318,6 +387,9 @@ func TestLMFactory_WithoutCollector(t *testing.T) { } RegisterLM("test-provider", testLMFactory) RegisterLM("openrouter", testLMFactory) + if err := modelcatalog.RegisterModel(modelcatalog.Model{ID: "test-provider/test-model", Limits: modelcatalog.Limits{ContextTokens: 1, OutputTokens: 1}, Modalities: modelcatalog.Modalities{Input: []string{"text"}, Output: []string{"text"}}, Metadata: modelcatalog.Metadata{Name: "Test Model", Family: "test"}}); err != nil { + t.Fatalf("RegisterModel(test-provider/test-model) err = %v", err) + } ctx := context.Background() @@ -369,6 +441,9 @@ func TestNewLM_WithCache(t *testing.T) { RegisterLM("openrouter", func(model string) LM { return NewMockLM() }) + if err := modelcatalog.RegisterModel(modelcatalog.Model{ID: "test-provider/test-model", Limits: modelcatalog.Limits{ContextTokens: 1, OutputTokens: 1}, Modalities: modelcatalog.Modalities{Input: []string{"text"}, Output: []string{"text"}}, Metadata: modelcatalog.Metadata{Name: "Test Model", Family: "test"}}); err != nil { + t.Fatalf("RegisterModel(test-provider/test-model) err = %v", err) + } ctx := context.Background() @@ -425,9 +500,9 @@ func TestNewLM_WithModelStringArg(t *testing.T) { model string wantError bool }{ - {"explicit openai gpt-4", "openai/gpt-4", false}, - {"explicit openai gpt-4-turbo", "openai/gpt-4-turbo", false}, - {"explicit meta model via openrouter", "openrouter/meta-llama/llama-3.3-70b-instruct", false}, + {"explicit openai gpt-4o", "openai/gpt-4o", false}, + {"explicit openai gpt-4o-mini", "openai/gpt-4o-mini", false}, + {"explicit meta model via openrouter", "openrouter/meta-llama/llama-3.3-70b-instruct:free", false}, {"explicit unknown model", "unknownprovider/model", true}, } diff --git a/internal/core/lm_wrapper.go b/internal/core/lm_wrapper.go index 17a5426..ad1a039 100644 --- a/internal/core/lm_wrapper.go +++ b/internal/core/lm_wrapper.go @@ -183,23 +183,21 @@ func (w *lmWrapper) Stream(ctx context.Context, messages []Message, options *Gen return outChunkChan, outErrChan } -func (w *lmWrapper) calculateCost(ctx context.Context, provider, modelName string, promptTokens, completionTokens int) float64 { +func (w *lmWrapper) calculateCost(ctx context.Context, canonicalModel string, promptTokens, completionTokens int) float64 { if promptTokens == 0 && completionTokens == 0 { return 0 } - calculatedCost, ok := w.calculator.CalculateIfKnown(provider, modelName, promptTokens, completionTokens) - if ok { - return calculatedCost + // Check if we have pricing for this model + if w.calculator.HasPricing(canonicalModel) { + return w.calculator.Calculate(canonicalModel, promptTokens, completionTokens) } - // Use provider/model as the warning key to avoid duplicate warnings per provider - warnKey := provider + "/" + modelName - if _, loaded := missingPricingWarned.LoadOrStore(warnKey, struct{}{}); !loaded { + // Use canonical model as the warning key to avoid duplicate warnings + if _, loaded := missingPricingWarned.LoadOrStore(canonicalModel, struct{}{}); !loaded { logging.GetLogger().Warn(ctx, "No pricing information for model", map[string]any{ "module": "cost", - "provider": provider, - "model": modelName, + "model": canonicalModel, "prompt_tokens": promptTokens, "completion_tokens": completionTokens, }) @@ -269,8 +267,8 @@ func (w *lmWrapper) buildHistoryEntry( // Calculate cost (best-effort) provider := w.getProvider() - modelName := w.lm.Name() - entry.Usage.Cost = w.calculateCost(ctx, provider, modelName, result.Usage.PromptTokens, result.Usage.CompletionTokens) + canonicalModel := canonicalModelID(provider, w.lm.Name()) + entry.Usage.Cost = w.calculateCost(ctx, canonicalModel, result.Usage.PromptTokens, result.Usage.CompletionTokens) // Wire provider-specific metadata if result.Metadata != nil { @@ -360,3 +358,15 @@ func (w *lmWrapper) extractProviderFromModel() string { // Default to unknown return "unknown" } + +func canonicalModelID(provider, name string) string { + provider = strings.ToLower(strings.TrimSpace(provider)) + name = strings.ToLower(strings.TrimSpace(name)) + if provider == "" { + return name + } + if strings.HasPrefix(name, provider+"/") { + return name + } + return provider + "/" + name +} diff --git a/internal/core/lm_wrapper_test.go b/internal/core/lm_wrapper_test.go index 187b273..fe57090 100644 --- a/internal/core/lm_wrapper_test.go +++ b/internal/core/lm_wrapper_test.go @@ -78,7 +78,7 @@ func TestNewLMWrapper(t *testing.T) { func TestLMWrapper_Generate_Success(t *testing.T) { mock := &mockWrapperLM{ - name: "gpt-4", + name: "gpt-4o", generateFunc: func(ctx context.Context, messages []Message, options *GenerateOptions) (*GenerateResult, error) { return &GenerateResult{ Content: "Hello, world!", @@ -142,8 +142,8 @@ func TestLMWrapper_Generate_Success(t *testing.T) { t.Error("Expected session ID to be set") } - if entry.Model != "gpt-4" { - t.Errorf("Expected model 'gpt-4', got '%s'", entry.Model) + if entry.Model != "gpt-4o" { + t.Errorf("Expected model 'gpt-4o', got '%s'", entry.Model) } if entry.Provider != "openai" { @@ -830,7 +830,7 @@ func TestLMWrapper_FullObservabilityIntegration(t *testing.T) { // Create mock with full metadata mock := &mockWrapperLM{ - name: "gpt-4", + name: "openai/gpt-4o-mini", generateFunc: func(ctx context.Context, messages []Message, options *GenerateOptions) (*GenerateResult, error) { return &GenerateResult{ Content: "Test response", @@ -925,7 +925,7 @@ func TestLMWrapper_FullObservabilityIntegration(t *testing.T) { func TestLMWrapper_Stream_Success(t *testing.T) { mock := &mockStreamSuccessLM{ - name: "gpt-4", + name: "gpt-4o", } memCollector := NewMemoryCollector(10) @@ -1356,7 +1356,30 @@ func TestLMWrapper_Generate_UnknownPricing_Warns(t *testing.T) { if msg != "No pricing information for model" { t.Fatalf("Unexpected warn message: %q", msg) } - if fields["model"] != "totally-unknown-model-12345" { + if fields["model"] != "unknown/totally-unknown-model-12345" { t.Fatalf("Expected model field set, got %v", fields["model"]) } } + +func TestCanonicalModelID(t *testing.T) { + tests := []struct { + provider string + name string + want string + }{ + {"openai", "gpt-4o", "openai/gpt-4o"}, + {"openai", "openai/gpt-4o", "openai/gpt-4o"}, + {"openrouter", "openai/gpt-4o", "openrouter/openai/gpt-4o"}, + {"openrouter", "openrouter/openai/gpt-4o", "openrouter/openai/gpt-4o"}, + {"mixedCase", "SomeModel", "mixedcase/somemodel"}, + {"", "model", "model"}, + {"provider", "", "provider/"}, + } + + for _, tt := range tests { + got := canonicalModelID(tt.provider, tt.name) + if got != tt.want { + t.Errorf("canonicalModelID(%q, %q) = %q, want %q", tt.provider, tt.name, got, tt.want) + } + } +} diff --git a/internal/core/settings.go b/internal/core/settings.go index 1050370..af3a184 100644 --- a/internal/core/settings.go +++ b/internal/core/settings.go @@ -84,6 +84,12 @@ type Settings struct { // EnableTracing enables detailed tracing and diagnostics. EnableTracing bool + // SkipModelValidation disables the strict model catalog validation in NewLM. + // + // If true, NewLM will accept any model string as long as it has a provider prefix, + // bypassing the IsValidCanonical check. + SkipModelValidation bool + // Collector is the default collector for LM observability. Collector Collector @@ -110,13 +116,14 @@ var ( // globalSettings is the singleton instance of Settings. var globalSettings = &Settings{ - DefaultTimeout: 30 * time.Second, - APIKey: make(map[string]string), - MaxRetries: 3, - EnableTracing: false, - CacheTTL: 0, // No expiry by default - CacheConfig: DefaultCacheConfiguration(), - Logger: nil, // Will be set by logging package initialization + DefaultTimeout: 30 * time.Second, + APIKey: make(map[string]string), + MaxRetries: 3, + EnableTracing: false, + SkipModelValidation: false, + CacheTTL: 0, // No expiry by default + CacheConfig: DefaultCacheConfiguration(), + Logger: nil, // Will be set by logging package initialization StructuredOutput: StructuredOutputConfig{ Enabled: true, // Structured outputs enabled by default MaxAttempts: 3, // 1 initial + up to 2 retries @@ -192,19 +199,20 @@ func GetSettings() Settings { maps.Copy(apiKeyCopy, globalSettings.APIKey) return Settings{ - DefaultLM: globalSettings.DefaultLM, - DefaultProvider: globalSettings.DefaultProvider, - DefaultModel: globalSettings.DefaultModel, - DefaultTimeout: globalSettings.DefaultTimeout, - APIKey: apiKeyCopy, - MaxRetries: globalSettings.MaxRetries, - EnableTracing: globalSettings.EnableTracing, - Collector: globalSettings.Collector, - DefaultCache: globalSettings.DefaultCache, - CacheTTL: globalSettings.CacheTTL, - CacheConfig: globalSettings.CacheConfig, - Logger: globalSettings.Logger, - StructuredOutput: globalSettings.StructuredOutput, + DefaultLM: globalSettings.DefaultLM, + DefaultProvider: globalSettings.DefaultProvider, + DefaultModel: globalSettings.DefaultModel, + DefaultTimeout: globalSettings.DefaultTimeout, + APIKey: apiKeyCopy, + MaxRetries: globalSettings.MaxRetries, + EnableTracing: globalSettings.EnableTracing, + SkipModelValidation: globalSettings.SkipModelValidation, + Collector: globalSettings.Collector, + DefaultCache: globalSettings.DefaultCache, + CacheTTL: globalSettings.CacheTTL, + CacheConfig: globalSettings.CacheConfig, + Logger: globalSettings.Logger, + StructuredOutput: globalSettings.StructuredOutput, } } @@ -268,6 +276,13 @@ func (s *Settings) SetEnableTracing(enable bool) { s.EnableTracing = enable } +// SetSkipModelValidation enables or disables strict model validation. +func (s *Settings) SetSkipModelValidation(skip bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.SkipModelValidation = skip +} + // SetCollector sets the default collector for LM observability. func (s *Settings) SetCollector(collector Collector) { s.mu.Lock() @@ -325,6 +340,7 @@ func (s *Settings) Reset() { s.APIKey = make(map[string]string) s.MaxRetries = 3 s.EnableTracing = false + s.SkipModelValidation = false s.Collector = nil s.CacheTTL = 0 s.CacheConfig = DefaultCacheConfiguration() diff --git a/internal/cost/cost.go b/internal/cost/cost.go index c465353..141e90c 100644 --- a/internal/cost/cost.go +++ b/internal/cost/cost.go @@ -1,306 +1,93 @@ package cost -import "strings" +import ( + "strings" + "sync" -// ModelPricing represents the pricing for a model -type ModelPricing struct { - PromptPrice float64 // Price per 1M prompt tokens (USD) - CompletionPrice float64 // Price per 1M completion tokens (USD) -} - -// defaultPricing contains pricing for common models. -// Keys use the format "provider/model" where provider is the routing provider -// (e.g., "openai", "openrouter") and model is the model identifier. -// -// This explicit format ensures accurate pricing when the same model is available -// through different providers with different pricing (e.g., openrouter/google/gemini-2.5-flash -// may have different costs). -var defaultPricing = map[string]ModelPricing{ - // OpenAI provider - direct API access - "openai/gpt-oss-120b:exacto": { - PromptPrice: 0.05, - CompletionPrice: 0.24, - }, - "openai/gpt-4": { - PromptPrice: 30.00, - CompletionPrice: 60.00, - }, - "openai/gpt-4o": { - PromptPrice: 2.5, - CompletionPrice: 10, - }, - "openai/gpt-4o-mini": { - PromptPrice: 0.15, - CompletionPrice: 0.60, - }, - "openai/gpt-3.5-turbo": { - PromptPrice: 0.50, - CompletionPrice: 1.50, - }, - "openai/o1-preview": { - PromptPrice: 15.00, - CompletionPrice: 60.00, - }, - "openai/o1-mini": { - PromptPrice: 3.00, - CompletionPrice: 12.00, - }, + "github.com/assagman/dsgo/internal/modelcatalog" +) - // OpenRouter provider - pricing reflects OpenRouter's rates - "openrouter/deepseek/deepseek-v3.1-terminus": { - PromptPrice: 0.23, - CompletionPrice: 0.90, - }, - "openrouter/z-ai/glm-4.6:exacto": { - PromptPrice: 0.60, - CompletionPrice: 1.90, - }, - "openrouter/minimax/minimax-m2:free": { - PromptPrice: 0.00, - CompletionPrice: 0.00, - }, - "openrouter/google/gemini-2.5-flash": { - PromptPrice: 0.30, - CompletionPrice: 2.50, - }, - "openrouter/meta/llama-3.1-405b": { - PromptPrice: 2.70, - CompletionPrice: 2.70, - }, - "openrouter/meta/llama-3.1-70b": { - PromptPrice: 0.35, - CompletionPrice: 0.40, - }, - "openrouter/meta/llama-3.1-8b": { - PromptPrice: 0.06, - CompletionPrice: 0.06, - }, - - // Mock provider - for testing purposes, uses OpenAI-equivalent pricing - "mock/gpt-4o-mini": { - PromptPrice: 0.15, - CompletionPrice: 0.60, - }, - - // OpenRouter provider - OpenAI models accessed via OpenRouter - // Note: Pricing may differ from direct OpenAI access - "openrouter/openai/gpt-4": { - PromptPrice: 30.00, - CompletionPrice: 60.00, - }, - "openrouter/openai/gpt-4o": { - PromptPrice: 2.5, - CompletionPrice: 10, - }, - "openrouter/openai/gpt-4o-mini": { - PromptPrice: 0.15, - CompletionPrice: 0.60, - }, -} +// Pricing is an alias for modelcatalog.Pricing for convenience. +type Pricing = modelcatalog.Pricing -// Calculator calculates costs for LM usage +// Calculator calculates costs for LM usage. +// +// It is safe for concurrent use via its methods. +// Callers should not mutate underlying maps directly. type Calculator struct { - pricing map[string]ModelPricing + mu sync.RWMutex + overrides map[string]Pricing } -// NewCalculator creates a new cost calculator +// NewCalculator creates a new cost calculator. func NewCalculator() *Calculator { - // Copy default pricing - pricing := make(map[string]ModelPricing) - for k, v := range defaultPricing { - pricing[k] = v - } - return &Calculator{ - pricing: pricing, - } + return &Calculator{overrides: make(map[string]Pricing)} } -// SetModelPricing sets custom pricing for a provider/model combination. -// The key should be in format "provider/model" (e.g., "openrouter/google/gemini-2.5-flash"). -func (c *Calculator) SetModelPricing(key string, pricing ModelPricing) { - c.pricing[key] = pricing -} +// SetModelPricing sets custom pricing for a model (overrides catalog pricing). +func (c *Calculator) SetModelPricing(model string, pricing Pricing) { + modelKey := normalizeModelKey(model) + + c.mu.Lock() + defer c.mu.Unlock() -// buildKey constructs a pricing lookup key from provider and model. -// Returns "provider/model" format for consistent lookups. -func buildKey(provider, model string) string { - if provider == "" { - return model + if c.overrides == nil { + c.overrides = make(map[string]Pricing) } - return provider + "/" + model + c.overrides[modelKey] = pricing } // Calculate calculates the cost for the given usage. -// The provider parameter specifies the routing provider (e.g., "openai", "openrouter"). -// The model parameter is the model identifier (e.g., "gpt-4o", "google/gemini-2.5-flash"). // Returns cost in USD. -// -// If provider is empty, pricing is resolved across all providers by pattern matching -// for backwards compatibility. Prefer passing an explicit provider for correct, -// provider-specific pricing. -// -// Note: If the model has no pricing information, this returns 0. -func (c *Calculator) Calculate(provider, model string, promptTokens, completionTokens int) float64 { - key := buildKey(provider, model) - pricing, ok := c.pricing[key] +func (c *Calculator) Calculate(model string, promptTokens, completionTokens int) float64 { + pricing, ok := c.GetPricing(model) if !ok { - // Try to find a match by prefix or partial match - pricing = c.findPricingByPattern(provider, model) + return 0 } promptCost := float64(promptTokens) * pricing.PromptPrice / 1_000_000 completionCost := float64(completionTokens) * pricing.CompletionPrice / 1_000_000 - return promptCost + completionCost } -// CalculateIfKnown calculates cost only when pricing is available. -// The provider parameter specifies the routing provider (e.g., "openai", "openrouter"). -// The model parameter is the model identifier (e.g., "gpt-4o", "google/gemini-2.5-flash"). -// -// If provider is empty, pricing is resolved across all providers by pattern matching. -// -// It returns (cost, true) when the model's pricing is known (including free models -// with 0 pricing), and (0, false) when the model is unknown. -func (c *Calculator) CalculateIfKnown(provider, model string, promptTokens, completionTokens int) (float64, bool) { - pricing, ok := c.GetPricing(provider, model) - if !ok { - return 0, false - } +// GetPricing returns the pricing for a model. +// First checks overrides, then falls back to modelcatalog. +func (c *Calculator) GetPricing(model string) (Pricing, bool) { + modelKey := normalizeModelKey(model) - promptCost := float64(promptTokens) * pricing.PromptPrice / 1_000_000 - completionCost := float64(completionTokens) * pricing.CompletionPrice / 1_000_000 + c.mu.RLock() + if override, ok := c.overrides[modelKey]; ok { + c.mu.RUnlock() + return override, true + } + c.mu.RUnlock() - return promptCost + completionCost, true + return modelcatalog.GetPricing(modelKey) } -// extractMatchTarget extracts the model portion from a pricing key for comparison. -// If providerPrefix is set, it strips that prefix. Otherwise, it extracts everything -// after the first "/" (e.g., "openai/gpt-4o" → "gpt-4o"). -func extractMatchTarget(pricingKeyLower, providerPrefix string) string { - if providerPrefix != "" { - return strings.TrimPrefix(pricingKeyLower, providerPrefix) - } - parts := strings.SplitN(pricingKeyLower, "/", 2) - if len(parts) == 2 { - return parts[1] - } - return pricingKeyLower +// HasPricing checks if pricing is available for a model. +func (c *Calculator) HasPricing(model string) bool { + _, ok := c.GetPricing(model) + return ok } -// findPricingByPattern attempts to find pricing by matching provider/model patterns. -// It falls back to pattern matching within the same provider's models (if provider is specified) -// or across all providers (if provider is empty). -func (c *Calculator) findPricingByPattern(provider, model string) ModelPricing { - modelLower := strings.ToLower(model) - - // Build provider prefix for scoped matching - providerPrefix := "" - if provider != "" { - providerPrefix = strings.ToLower(provider) + "/" +func normalizeModelKey(model string) string { + model = strings.TrimSpace(model) + if model == "" { + return "" } - // First pass: try exact matches within the provider scope - for pricingKey, pricing := range c.pricing { - pricingKeyLower := strings.ToLower(pricingKey) - - // If provider is specified, only match within that provider's namespace - if providerPrefix != "" && !strings.HasPrefix(pricingKeyLower, providerPrefix) { - continue - } - - matchTarget := extractMatchTarget(pricingKeyLower, providerPrefix) - - // Check for exact match first - if matchTarget == modelLower { - return pricing - } + if canonical, ok := modelcatalog.Resolve(model); ok { + return canonical } - // Second pass: try prefix matches or contains matches - find the best matching model - // Case A: incoming "gpt-4o-something" could match "gpt-4o" (incoming is longer) - // Case B: incoming "gpt-4" could match "openai/gpt-4" (incoming is contained in match target) - // Prefer longer/more specific matches over shorter ones - var bestMatch ModelPricing - var longestMatchLen int - - for pricingKey, pricing := range c.pricing { - pricingKeyLower := strings.ToLower(pricingKey) - - // If provider is specified, only match within that provider's namespace - if providerPrefix != "" && !strings.HasPrefix(pricingKeyLower, providerPrefix) { - continue - } - - matchTarget := extractMatchTarget(pricingKeyLower, providerPrefix) - - // Case A: Incoming model is longer - check if it starts with a known model - // e.g., incoming "gpt-4o-something" starts with "gpt-4o" - if len(matchTarget) < len(modelLower) && strings.HasPrefix(modelLower, matchTarget) { - // Track the longest match for maximum specificity - if len(matchTarget) > longestMatchLen { - longestMatchLen = len(matchTarget) - bestMatch = pricing - } - continue - } - - // Case B: Incoming model is an exact component match in the multi-component target - // e.g., incoming "gpt-4" should match target "openai/gpt-4" (after provider prefix is stripped) - // Split matchTarget by / and check if any component matches exactly - if strings.Contains(matchTarget, "/") { - parts := strings.Split(matchTarget, "/") - for _, part := range parts { - if part == modelLower { - // Exact component match - prefer longer targets for specificity - if len(matchTarget) > longestMatchLen { - longestMatchLen = len(matchTarget) - bestMatch = pricing - } - break - } - } - } - } - - if longestMatchLen > 0 { - return bestMatch - } - - // No match found - return zero cost - return ModelPricing{} -} - -// HasPricing checks if pricing is available for a provider/model combination. -func (c *Calculator) HasPricing(provider, model string) bool { - key := buildKey(provider, model) - if _, ok := c.pricing[key]; ok { - return true - } - pricing := c.findPricingByPattern(provider, model) - return pricing.PromptPrice > 0 || pricing.CompletionPrice > 0 -} - -// GetPricing returns the pricing for a provider/model combination. -// If provider is empty, pricing is resolved across all providers by pattern matching. -func (c *Calculator) GetPricing(provider, model string) (ModelPricing, bool) { - key := buildKey(provider, model) - if pricing, ok := c.pricing[key]; ok { - return pricing, true - } - pricing := c.findPricingByPattern(provider, model) - if pricing.PromptPrice > 0 || pricing.CompletionPrice > 0 { - return pricing, true - } - return ModelPricing{}, false + return strings.ToLower(model) } -// DefaultCalculator is the global default calculator instance +// DefaultCalculator is the global default calculator instance. var DefaultCalculator = NewCalculator() // Calculate is a convenience function using the default calculator. -// The provider parameter specifies the routing provider (e.g., "openai", "openrouter"). -// The model parameter is the model identifier (e.g., "gpt-4o", "google/gemini-2.5-flash"). -func Calculate(provider, model string, promptTokens, completionTokens int) float64 { - return DefaultCalculator.Calculate(provider, model, promptTokens, completionTokens) +func Calculate(model string, promptTokens, completionTokens int) float64 { + return DefaultCalculator.Calculate(model, promptTokens, completionTokens) } diff --git a/internal/cost/cost_test.go b/internal/cost/cost_test.go index 734a377..9058a4a 100644 --- a/internal/cost/cost_test.go +++ b/internal/cost/cost_test.go @@ -1,7 +1,9 @@ package cost import ( + "fmt" "math" + "sync" "testing" ) @@ -9,40 +11,42 @@ func TestCalculate(t *testing.T) { t.Parallel() tests := []struct { name string - provider string model string promptTokens int completionTokens int wantCost float64 }{ { - name: "openai gpt-4o", - provider: "openai", - model: "gpt-4o", + name: "gpt-4o standard", + model: "openai/gpt-4o", promptTokens: 1000, completionTokens: 500, - wantCost: 0.0075, // (1000 * 2.5 + 500 * 10) / 1M = 0.0075 + wantCost: 0.0075, // (1000 * 2.5 + 500 * 10) / 1M }, { - name: "openai gpt-3.5-turbo", - provider: "openai", + name: "gpt-3.5-turbo alias resolves", model: "gpt-3.5-turbo", promptTokens: 10000, completionTokens: 5000, - wantCost: 0.0125, // (10000 * 0.5 + 5000 * 1.5) / 1M = 0.0125 + wantCost: 0.0125, // (10000 * 0.5 + 5000 * 1.5) / 1M }, { - name: "openrouter llama-3.1-70b", - provider: "openrouter", - model: "meta/llama-3.1-70b", - promptTokens: 100000, - completionTokens: 50000, - wantCost: 0.055000, // (100000 * 0.35 + 50000 * 0.40) / 1M = 0.055 + name: "gpt-4o-mini alias resolves", + model: "gpt-4o-mini", + promptTokens: 1000, + completionTokens: 500, + wantCost: 0.00045, // (1000 * 0.15 + 500 * 0.6) / 1M + }, + { + name: "openrouter gemini", + model: "openrouter/google/gemini-2.5-flash", + promptTokens: 1000, + completionTokens: 500, + wantCost: 0.00155, // (1000 * 0.30 + 500 * 2.50) / 1M }, { name: "zero tokens", - provider: "openai", - model: "gpt-4o", + model: "openai/gpt-4o", promptTokens: 0, completionTokens: 0, wantCost: 0.0, @@ -54,7 +58,7 @@ func TestCalculate(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() calc := NewCalculator() - got := calc.Calculate(tt.provider, tt.model, tt.promptTokens, tt.completionTokens) + got := calc.Calculate(tt.model, tt.promptTokens, tt.completionTokens) if math.Abs(got-tt.wantCost) > 0.000001 { t.Errorf("Calculate() = %f, want %f", got, tt.wantCost) @@ -63,48 +67,9 @@ func TestCalculate(t *testing.T) { } } -func TestCalculateIfKnown(t *testing.T) { - t.Parallel() - calc := NewCalculator() - - t.Run("known model", func(t *testing.T) { - t.Parallel() - got, ok := calc.CalculateIfKnown("openai", "gpt-4o", 1000, 500) - if !ok { - t.Fatal("CalculateIfKnown(openai, gpt-4o) ok=false") - } - want := 0.0075 - if math.Abs(got-want) > 0.000001 { - t.Errorf("CalculateIfKnown() = %f, want %f", got, want) - } - }) - - t.Run("unknown model", func(t *testing.T) { - t.Parallel() - got, ok := calc.CalculateIfKnown("unknown", "completely-unknown-model", 1000, 500) - if ok { - t.Fatal("CalculateIfKnown(unknown, completely-unknown-model) ok=true") - } - if got != 0 { - t.Errorf("CalculateIfKnown() = %f, want 0", got) - } - }) - - t.Run("free model", func(t *testing.T) { - t.Parallel() - got, ok := calc.CalculateIfKnown("openrouter", "minimax/minimax-m2:free", 1000, 500) - if !ok { - t.Fatal("CalculateIfKnown(openrouter, minimax/minimax-m2:free) ok=false") - } - if got != 0 { - t.Errorf("CalculateIfKnown() = %f, want 0", got) - } - }) -} - func TestDefaultCalculate(t *testing.T) { t.Parallel() - cost := Calculate("openai", "gpt-4o", 1000, 500) + cost := Calculate("openai/gpt-4o", 1000, 500) expected := 0.0075 if math.Abs(cost-expected) > 0.000001 { @@ -116,101 +81,66 @@ func TestSetModelPricing(t *testing.T) { t.Parallel() calc := NewCalculator() - customPricing := ModelPricing{ + customPricing := Pricing{ PromptPrice: 10.0, CompletionPrice: 20.0, } calc.SetModelPricing("custom/custom-model", customPricing) - cost := calc.Calculate("custom", "custom-model", 1000, 500) - expected := 0.020 // (1000 * 10 + 500 * 20) / 1M = 0.02 + cost := calc.Calculate("custom/custom-model", 1000, 500) + expected := 0.020 // (1000 * 10 + 500 * 20) / 1M if math.Abs(cost-expected) > 0.000001 { t.Errorf("Calculate() = %f, want %f", cost, expected) } } -func TestHasPricing(t *testing.T) { +func TestSetModelPricing_OverrideTakesPrecedence(t *testing.T) { t.Parallel() calc := NewCalculator() - tests := []struct { - name string - provider string - model string - want bool - }{ - {"known model", "openai", "gpt-4o", true}, - {"known model openai gpt-3.5-turbo", "openai", "gpt-3.5-turbo", true}, - {"unknown model", "unknown", "unknown-model-xyz", false}, - } + // Override an existing catalog model. + calc.SetModelPricing("openai/gpt-4o-mini", Pricing{PromptPrice: 100, CompletionPrice: 200}) - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := calc.HasPricing(tt.provider, tt.model) - if got != tt.want { - t.Errorf("HasPricing(%q, %q) = %v, want %v", tt.provider, tt.model, got, tt.want) - } - }) + cost := calc.Calculate("gpt-4o-mini", 1000, 500) + expected := 0.2 // (1000 * 100 + 500 * 200) / 1M + if math.Abs(cost-expected) > 0.000001 { + t.Errorf("Calculate() = %f, want %f", cost, expected) } } -func TestGetPricing(t *testing.T) { +func TestGetPricing_UnknownModel(t *testing.T) { t.Parallel() calc := NewCalculator() - t.Run("known model", func(t *testing.T) { - t.Parallel() - pricing, ok := calc.GetPricing("openai", "gpt-4o") - if !ok { - t.Error("GetPricing(openai, gpt-4o) returned ok=false") - } - if pricing.PromptPrice != 2.5 { - t.Errorf("PromptPrice = %f, want 2.5", pricing.PromptPrice) - } - if pricing.CompletionPrice != 10.0 { - t.Errorf("CompletionPrice = %f, want 10.0", pricing.CompletionPrice) - } - }) - - t.Run("unknown model", func(t *testing.T) { - t.Parallel() - _, ok := calc.GetPricing("unknown", "unknown-model") - if ok { - t.Error("GetPricing(unknown, unknown-model) returned ok=true") - } - }) + _, ok := calc.GetPricing("unknown/model") + if ok { + t.Fatal("expected ok=false for unknown model") + } } -func TestFindPricingByPattern(t *testing.T) { +func TestHasPricing(t *testing.T) { t.Parallel() calc := NewCalculator() tests := []struct { - name string - provider string - model string - expectNonZero bool + name string + model string + want bool }{ - {"exact match", "openai", "gpt-4o", true}, - {"case insensitive", "OPENAI", "GPT-4O", true}, - {"contains pattern", "openai", "gpt-4o-something", true}, - {"no match", "unknown", "completely-unknown-model", false}, - {"cross provider no match", "other", "gpt-4o", false}, // openai model shouldn't match other provider + {"known model", "openai/gpt-4o", true}, + {"known alias", "gpt-3.5-turbo", true}, + {"unknown model", "unknown-model-xyz", false}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - pricing := calc.findPricingByPattern(tt.provider, tt.model) - hasNonZero := pricing.PromptPrice > 0 || pricing.CompletionPrice > 0 - - if hasNonZero != tt.expectNonZero { - t.Errorf("findPricingByPattern(%q, %q) hasNonZero = %v, want %v", tt.provider, tt.model, hasNonZero, tt.expectNonZero) + got := calc.HasPricing(tt.model) + if got != tt.want { + t.Errorf("HasPricing(%q) = %v, want %v", tt.model, got, tt.want) } }) } @@ -219,206 +149,119 @@ func TestFindPricingByPattern(t *testing.T) { func TestCalculatorConcurrency(t *testing.T) { calc := NewCalculator() - // Test concurrent calculations done := make(chan bool) - for i := 0; i < 10; i++ { + for range 10 { go func() { - _ = calc.Calculate("openai", "gpt-4o", 1000, 500) + _ = calc.Calculate("openai/gpt-4o", 1000, 500) done <- true }() } - for i := 0; i < 10; i++ { + for range 10 { <-done } } -func TestCalculate_PatternMatch(t *testing.T) { +func TestCalculate_UnknownModel(t *testing.T) { t.Parallel() - calc := NewCalculator() - // Test Calculate with model that doesn't have exact key but matches pattern - cost := calc.Calculate("openai", "gpt-4o-something-new", 1000, 500) - - // Should match "openai/gpt-4o" pricing via pattern matching - expected := 0.0075 // (1000 * 2.5 + 500 * 10) / 1M = 0.0075 - - if math.Abs(cost-expected) > 0.000001 { - t.Errorf("Calculate() with pattern match = %f, want %f", cost, expected) - } - - // Verify non-zero cost was calculated (proves pattern matching worked) - if cost == 0 { - t.Error("Calculate() with pattern match returned 0, expected non-zero cost") - } -} - -func TestGetPricing_PatternMatch(t *testing.T) { - t.Parallel() calc := NewCalculator() - - // Test GetPricing with model that matches via pattern - pricing, ok := calc.GetPricing("openrouter", "meta/llama-3.1-70b-derivative") - - if !ok { - t.Error("GetPricing() with pattern match returned ok=false, expected ok=true") + tests := []struct { + name string + model string + promptTokens int + completionTokens int + wantCost float64 + }{ + {"completely unknown model", "unknown/model", 1000, 500, 0.0}, + {"empty model name", "", 1000, 500, 0.0}, + {"whitespace model name", " ", 1000, 500, 0.0}, } - // Should match "openrouter/meta/llama-3.1-70b" pricing - if pricing.PromptPrice != 0.35 { - t.Errorf("PromptPrice = %f, want 0.35", pricing.PromptPrice) - } - if pricing.CompletionPrice != 0.40 { - t.Errorf("CompletionPrice = %f, want 0.40", pricing.CompletionPrice) + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := calc.Calculate(tt.model, tt.promptTokens, tt.completionTokens) + if got != tt.wantCost { + t.Errorf("Calculate() = %f, want %f", got, tt.wantCost) + } + }) } } -func TestGetPricing_EmptyProviderFallback(t *testing.T) { +func TestCalculate_EdgeCases(t *testing.T) { t.Parallel() - calc := NewCalculator() - - // Test backwards compatibility: empty provider should still find pricing - // by searching across all providers - pricing, ok := calc.GetPricing("", "gpt-4o") - - if !ok { - t.Error("GetPricing(\"\", \"gpt-4o\") returned ok=false, expected ok=true (cross-provider fallback)") - } - // Should find OpenAI pricing by pattern match - if pricing.PromptPrice != 2.5 { - t.Errorf("PromptPrice = %f, want 2.5", pricing.PromptPrice) - } - if pricing.CompletionPrice != 10.0 { - t.Errorf("CompletionPrice = %f, want 10.0", pricing.CompletionPrice) - } -} - -func TestGetPricing_ProviderScopedMatch(t *testing.T) { - t.Parallel() calc := NewCalculator() - // Test that "openrouter", "gpt-4" matches the nested key "openrouter/openai/gpt-4" - pricing, ok := calc.GetPricing("openrouter", "openai/gpt-4") - - if !ok { - t.Error("GetPricing(\"openrouter\", \"openai/gpt-4\") returned ok=false") - } + // Negative tokens should not panic + t.Run("negative tokens", func(t *testing.T) { + _ = calc.Calculate("openai/gpt-4o", -1000, -500) + }) - if pricing.PromptPrice != 30.0 { - t.Errorf("PromptPrice = %f, want 30.0", pricing.PromptPrice) - } - if pricing.CompletionPrice != 60.0 { - t.Errorf("CompletionPrice = %f, want 60.0", pricing.CompletionPrice) - } + // Very large tokens + t.Run("very large tokens", func(t *testing.T) { + cost := calc.Calculate("openai/gpt-4o", 1_000_000_000, 0) + if cost < 2000.0 { + t.Errorf("Calculate() = %f, want at least 2000.0", cost) + } + }) } -func TestCalculate_ProviderScoping(t *testing.T) { +func TestCalculator_NilOverrides(t *testing.T) { t.Parallel() - calc := NewCalculator() - // Test that provider scoping prevents cross-provider matches - // gpt-4o should match openai but NOT be matched for openrouter provider - // (openrouter has its own openai/gpt-4o entry, but we test the scoping) - cost := calc.Calculate("openai", "gpt-4o", 1000, 500) - expected := 0.0075 // (1000 * 2.5 + 500 * 10) / 1M + calc := &Calculator{overrides: nil} + // Should still work by falling back to catalog + cost := calc.Calculate("openai/gpt-4o", 1000, 500) + expected := 0.0075 // (1000 * 2.5 + 500 * 10) / 1M if math.Abs(cost-expected) > 0.000001 { - t.Errorf("Calculate(\"openai\", \"gpt-4o\") = %f, want %f", cost, expected) - } - - // Same model via openrouter should use openrouter's pricing - cost2 := calc.Calculate("openrouter", "openai/gpt-4o", 1000, 500) - expected2 := 0.0075 // Same pricing in this case - - if math.Abs(cost2-expected2) > 0.000001 { - t.Errorf("Calculate(\"openrouter\", \"openai/gpt-4o\") = %f, want %f", cost2, expected2) - } -} - -func TestCalculateIfKnown_EmptyProviderFallback(t *testing.T) { - t.Parallel() - calc := NewCalculator() - - // Test that empty provider still returns ok=true for known models - got, ok := calc.CalculateIfKnown("", "gpt-4o", 1000, 500) - if !ok { - t.Fatal("CalculateIfKnown(\"\", \"gpt-4o\") ok=false, expected true (cross-provider fallback)") - } - - expected := 0.0075 - if math.Abs(got-expected) > 0.000001 { - t.Errorf("CalculateIfKnown(\"\", \"gpt-4o\") = %f, want %f", got, expected) + t.Errorf("Calculate with nil overrides = %f, want %f", cost, expected) } -} - -func TestGetPricing_OpenRouterGPT4(t *testing.T) { - t.Parallel() - calc := NewCalculator() - - // Test that "openrouter"/"gpt-4" can find "openrouter/openai/gpt-4" via pattern matching - pricing, ok := calc.GetPricing("openrouter", "gpt-4") + ok := calc.HasPricing("openai/gpt-4o") if !ok { - t.Error("GetPricing(\"openrouter\", \"gpt-4\") returned ok=false, expected ok=true (should match openrouter/openai/gpt-4)") + t.Error("HasPricing with nil overrides = false, want true (from catalog)") } - if pricing.PromptPrice != 30.0 { - t.Errorf("PromptPrice = %f, want 30.0", pricing.PromptPrice) - } - if pricing.CompletionPrice != 60.0 { - t.Errorf("CompletionPrice = %f, want 60.0", pricing.CompletionPrice) + // Unknown model should return 0 + cost = calc.Calculate("unknown/model", 1000, 500) + if cost != 0 { + t.Errorf("Calculate unknown model = %f, want 0", cost) } } -func TestGetPricing_OpenRouterGPT4o(t *testing.T) { +func TestConcurrentSetAndGet(t *testing.T) { t.Parallel() - calc := NewCalculator() - // Test that "openrouter"/"gpt-4o" can find "openrouter/openai/gpt-4o" via Case B component matching - pricing, ok := calc.GetPricing("openrouter", "gpt-4o") - - if !ok { - t.Error("GetPricing(\"openrouter\", \"gpt-4o\") returned ok=false, expected ok=true (should match openrouter/openai/gpt-4o)") - } - - if pricing.PromptPrice != 2.5 { - t.Errorf("PromptPrice = %f, want 2.5", pricing.PromptPrice) - } - if pricing.CompletionPrice != 10.0 { - t.Errorf("CompletionPrice = %f, want 10.0", pricing.CompletionPrice) - } -} - -func TestGetPricing_LongestPrefixWins(t *testing.T) { - t.Parallel() calc := NewCalculator() + var wg sync.WaitGroup - // Test that "gpt-4o-derivative" matches "gpt-4o" (longer prefix) not "gpt-4" (shorter prefix) - // This confirms longest-match-wins semantics in Case A - pricing, ok := calc.GetPricing("openai", "gpt-4o-derivative") - - if !ok { - t.Error("GetPricing(\"openai\", \"gpt-4o-derivative\") returned ok=false, expected ok=true") + // Concurrent writers + for i := 0; i < 10; i++ { + wg.Add(1) + i := i + go func() { + defer wg.Done() + calc.SetModelPricing(fmt.Sprintf("concurrent/model-%d", i), Pricing{ + PromptPrice: float64(i), + CompletionPrice: float64(i * 2), + }) + }() } - // Should match openai/gpt-4o pricing (2.5/10.0) not openai/gpt-4 (30.0/60.0) - if pricing.PromptPrice != 2.5 { - t.Errorf("PromptPrice = %f, want 2.5 (should match gpt-4o, not gpt-4)", pricing.PromptPrice) - } - if pricing.CompletionPrice != 10.0 { - t.Errorf("CompletionPrice = %f, want 10.0 (should match gpt-4o, not gpt-4)", pricing.CompletionPrice) + // Concurrent readers + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = calc.Calculate("openai/gpt-4o", 1000, 500) + _ = calc.HasPricing("openai/gpt-4o") + _, _ = calc.GetPricing("openai/gpt-4o") + }() } -} - -func TestGetPricing_NoCrossProviderLeak(t *testing.T) { - t.Parallel() - calc := NewCalculator() - // Verify that a known model (gpt-4o) is NOT found when using a different provider. - // This ensures provider scoping prevents cross-provider matches. - _, ok := calc.GetPricing("other", "gpt-4o") - if ok { - t.Error("GetPricing(\"other\", \"gpt-4o\") returned ok=true, expected ok=false (no cross-provider leak)") - } + wg.Wait() } diff --git a/internal/modelcatalog/catalog.go b/internal/modelcatalog/catalog.go new file mode 100644 index 0000000..da65e1c --- /dev/null +++ b/internal/modelcatalog/catalog.go @@ -0,0 +1,377 @@ +package modelcatalog + +import ( + "fmt" + "sort" + "strings" + "sync" +) + +// Pricing represents model pricing in USD per 1M tokens. +// +// Note: not all models expose cache pricing; CacheReadPrice/CacheWritePrice will be zero in that case. +type Pricing struct { + PromptPrice float64 // Price per 1M prompt tokens (USD) + CompletionPrice float64 // Price per 1M completion tokens (USD) + CacheReadPrice float64 // Price per 1M cached prompt tokens (USD) + CacheWritePrice float64 // Price per 1M tokens written to cache (USD) +} + +// Limits represents token limits for a model. +type Limits struct { + ContextTokens int + OutputTokens int +} + +// Capabilities describes a model's supported features. +type Capabilities struct { + Attachment bool + Reasoning bool + ToolCall bool + StructuredOutput bool + Temperature bool +} + +// Modalities describes supported input and output modalities. +type Modalities struct { + Input []string + Output []string +} + +// Metadata contains additional information about a model. +type Metadata struct { + Name string + Family string + Knowledge string + ReleaseDate string + LastUpdated string + OpenWeights bool +} + +// Model describes a supported model identifier. +// +// ID must be in canonical form: "provider/model". +// For OpenRouter, the model portion may contain additional slashes +// (e.g. "openrouter/google/gemini-2.5-flash"). +// +// The catalog is authoritative for dsgo.NewLM/core.NewLM: models must be present +// here to be considered valid. +type Model struct { + ID string + Aliases []string + Pricing Pricing + Limits Limits + Capabilities Capabilities + Modalities Modalities + Metadata Metadata +} + +var ( + mu sync.RWMutex + models = map[string]Model{} // canonical (lowercased) -> model + aliasMap = map[string]string{} // alias (lowercased) -> canonical (lowercased) +) + +// RegisterModel registers a supported model. +// +// The catalog is authoritative for dsgo.NewLM/core.NewLM: models must be present +// here to be considered valid. +// +// Required fields: +// - Limits.ContextTokens must be positive. +// - Modalities.Input and Modalities.Output must be populated. +// - Metadata.Name must be populated. +// +// Registration is idempotent: re-registering the exact same model is allowed. +// Attempting to re-register an existing model with different aliases returns an error. +func RegisterModel(m Model) error { + canonical, err := normalizeCanonicalID(m.ID) + if err != nil { + return fmt.Errorf("invalid model id %q: %w", m.ID, err) + } + + aliases := make([]string, 0, len(m.Aliases)) + for _, a := range m.Aliases { + a = strings.TrimSpace(a) + if a == "" { + continue + } + aliases = append(aliases, strings.ToLower(a)) + } + + // Normalize stored form + m.ID = canonical + m.Aliases = aliases + m.Modalities.Input = normalizeStringSlice(m.Modalities.Input) + m.Modalities.Output = normalizeStringSlice(m.Modalities.Output) + + if m.Limits.ContextTokens <= 0 { + return fmt.Errorf("model %q: Limits.ContextTokens must be positive", canonical) + } + if len(m.Modalities.Input) == 0 || len(m.Modalities.Output) == 0 { + return fmt.Errorf("model %q: Modalities.Input/Output must be populated", canonical) + } + if strings.TrimSpace(m.Metadata.Name) == "" { + return fmt.Errorf("model %q: Metadata.Name must be populated", canonical) + } + + mu.Lock() + defer mu.Unlock() + + if existing, ok := models[canonical]; ok { + if modelsEquivalent(existing, m) { + return nil + } + return fmt.Errorf("model %q already registered", canonical) + } + + models[canonical] = m + aliasMap[canonical] = canonical + for _, a := range aliases { + if err := registerAliasLocked(a, canonical); err != nil { + delete(models, canonical) + delete(aliasMap, canonical) + // best-effort cleanup for any aliases already registered + for _, prev := range aliases { + if aliasMap[prev] == canonical { + delete(aliasMap, prev) + } + } + return err + } + } + + return nil +} + +// RegisterAlias adds an alias for an existing canonical model. +// +// Registration is idempotent: re-registering the same alias->canonical mapping is allowed. +func RegisterAlias(alias string, canonical string) error { + alias = strings.TrimSpace(alias) + if alias == "" { + return fmt.Errorf("alias is required") + } + canonicalNorm, err := normalizeCanonicalID(canonical) + if err != nil { + return fmt.Errorf("invalid canonical model id %q: %w", canonical, err) + } + + mu.Lock() + defer mu.Unlock() + + if _, ok := models[canonicalNorm]; !ok { + return fmt.Errorf("cannot register alias for unknown model %q", canonicalNorm) + } + + return registerAliasLocked(strings.ToLower(alias), canonicalNorm) +} + +func registerAliasLocked(alias, canonical string) error { + if existing, ok := aliasMap[alias]; ok { + if existing == canonical { + return nil + } + return fmt.Errorf("alias %q already registered for %q", alias, existing) + } + aliasMap[alias] = canonical + return nil +} + +// Resolve returns the canonical model id for a canonical id or alias. +func Resolve(idOrAlias string) (string, bool) { + idOrAlias = strings.TrimSpace(idOrAlias) + if idOrAlias == "" { + return "", false + } + + key := strings.ToLower(idOrAlias) + + mu.RLock() + canonical, ok := aliasMap[key] + mu.RUnlock() + if ok { + return canonical, true + } + + // If it's already canonical, normalize and check. + canonical, err := normalizeCanonicalID(idOrAlias) + if err != nil { + return "", false + } + + mu.RLock() + _, ok = models[canonical] + mu.RUnlock() + if !ok { + return "", false + } + return canonical, true +} + +// IsValidCanonical reports whether the given model id is a known canonical model. +func IsValidCanonical(modelID string) bool { + canonical, err := normalizeCanonicalID(modelID) + if err != nil { + return false + } + mu.RLock() + _, ok := models[canonical] + mu.RUnlock() + return ok +} + +// IsValid reports whether the provided id is either a known canonical id or a registered alias. +func IsValid(idOrAlias string) bool { + _, ok := Resolve(idOrAlias) + return ok +} + +// GetPricing returns the pricing for a model by canonical ID or alias. +// Returns zero pricing and false if the model is not found. +func GetPricing(idOrAlias string) (Pricing, bool) { + m, ok := GetModel(idOrAlias) + if !ok { + return Pricing{}, false + } + return m.Pricing, true +} + +// GetModel returns the model by canonical ID or alias. +func GetModel(idOrAlias string) (Model, bool) { + canonical, ok := Resolve(idOrAlias) + if !ok { + return Model{}, false + } + + mu.RLock() + m, ok := models[canonical] + mu.RUnlock() + if !ok { + return Model{}, false + } + return m, true +} + +// ListModels returns all registered models, sorted by canonical ID. +func ListModels() []Model { + mu.RLock() + out := make([]Model, 0, len(models)) + for _, m := range models { + out = append(out, m) + } + mu.RUnlock() + + sort.Slice(out, func(i, j int) bool { + return out[i].ID < out[j].ID + }) + return out +} + +// ListModelsByProvider returns all models for the given provider. +func ListModelsByProvider(provider string) []Model { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + return nil + } + prefix := provider + "/" + + mu.RLock() + out := make([]Model, 0) + for _, m := range models { + if strings.HasPrefix(m.ID, prefix) { + out = append(out, m) + } + } + mu.RUnlock() + + sort.Slice(out, func(i, j int) bool { + return out[i].ID < out[j].ID + }) + return out +} + +func normalizeCanonicalID(id string) (string, error) { + id = strings.TrimSpace(id) + if id == "" { + return "", fmt.Errorf("model id is required") + } + parts := strings.SplitN(id, "/", 2) + if len(parts) < 2 { + return "", fmt.Errorf("model id must be in form provider/model") + } + provider := strings.ToLower(strings.TrimSpace(parts[0])) + model := strings.TrimSpace(parts[1]) + if provider == "" || model == "" { + return "", fmt.Errorf("model id must be in form provider/model") + } + return provider + "/" + strings.ToLower(model), nil +} + +func modelsEquivalent(a, b Model) bool { + if a.ID != b.ID { + return false + } + + if a.Pricing != b.Pricing { + return false + } + if a.Limits != b.Limits { + return false + } + if a.Capabilities != b.Capabilities { + return false + } + if a.Metadata != b.Metadata { + return false + } + + if !stringSlicesEquivalentNormalized(a.Modalities.Input, b.Modalities.Input) { + return false + } + if !stringSlicesEquivalentNormalized(a.Modalities.Output, b.Modalities.Output) { + return false + } + + if len(a.Aliases) != len(b.Aliases) { + return false + } + // Aliases are stored lowercased already. + aCopy := append([]string(nil), a.Aliases...) + bCopy := append([]string(nil), b.Aliases...) + sort.Strings(aCopy) + sort.Strings(bCopy) + for i := range aCopy { + if aCopy[i] != bCopy[i] { + return false + } + } + return true +} + +func stringSlicesEquivalentNormalized(a, b []string) bool { + aNorm := normalizeStringSlice(a) + bNorm := normalizeStringSlice(b) + if len(aNorm) != len(bNorm) { + return false + } + for i := range aNorm { + if aNorm[i] != bNorm[i] { + return false + } + } + return true +} + +func normalizeStringSlice(in []string) []string { + out := make([]string, 0, len(in)) + for _, v := range in { + v = strings.ToLower(strings.TrimSpace(v)) + if v == "" { + continue + } + out = append(out, v) + } + sort.Strings(out) + return out +} diff --git a/internal/modelcatalog/catalog_test.go b/internal/modelcatalog/catalog_test.go new file mode 100644 index 0000000..372ded7 --- /dev/null +++ b/internal/modelcatalog/catalog_test.go @@ -0,0 +1,343 @@ +package modelcatalog + +import ( + "fmt" + "sync" + "testing" +) + +func TestResolve_Defaults(t *testing.T) { + t.Parallel() + + canonical, ok := Resolve("gpt-4o-mini") + if !ok { + t.Fatal("expected alias to resolve") + } + if canonical != "openai/gpt-4o-mini" { + t.Fatalf("canonical = %q, want %q", canonical, "openai/gpt-4o-mini") + } + + if !IsValidCanonical("openai/gpt-4o") { + t.Fatal("expected openai/gpt-4o to be valid") + } + + if IsValidCanonical("openai/does-not-exist") { + t.Fatal("expected unknown model to be invalid") + } +} + +func TestOpenAIModelFields_Populated(t *testing.T) { + t.Parallel() + + m, ok := GetModel("openai/gpt-4o-mini") + if !ok { + t.Fatal("expected openai/gpt-4o-mini to exist") + } + + if m.Metadata.Name == "" { + t.Error("expected Metadata.Name to be populated") + } + if m.Limits.ContextTokens <= 0 { + t.Errorf("expected Limits.ContextTokens > 0, got %d", m.Limits.ContextTokens) + } + if m.Limits.OutputTokens <= 0 { + t.Errorf("expected Limits.OutputTokens > 0, got %d", m.Limits.OutputTokens) + } + if m.Pricing.PromptPrice <= 0 { + t.Errorf("expected Pricing.PromptPrice > 0, got %v", m.Pricing.PromptPrice) + } + if m.Pricing.CompletionPrice <= 0 { + t.Errorf("expected Pricing.CompletionPrice > 0, got %v", m.Pricing.CompletionPrice) + } + if len(m.Modalities.Input) == 0 { + t.Error("expected Modalities.Input to be populated") + } + if len(m.Modalities.Output) == 0 { + t.Error("expected Modalities.Output to be populated") + } +} + +func TestNormalizeStringSlice(t *testing.T) { + t.Parallel() + + got := normalizeStringSlice([]string{" Image ", "text", "TEXT", ""}) + want := []string{"image", "text", "text"} + if len(got) != len(want) { + t.Fatalf("len = %d, want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("[%d] = %q, want %q (got=%v)", i, got[i], want[i], got) + } + } +} + +func TestRegisterModel_Idempotent(t *testing.T) { + t.Parallel() + + m := Model{ + ID: "testprovider/test-model", + Aliases: []string{"test-model"}, + Limits: Limits{ContextTokens: 1, OutputTokens: 1}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Test Model", Family: "test"}, + } + if err := RegisterModel(m); err != nil { + t.Fatalf("RegisterModel err = %v", err) + } + if err := RegisterModel(m); err != nil { + t.Fatalf("RegisterModel (idempotent) err = %v", err) + } +} + +func TestRegisterModel_EmptyID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + model Model + wantErr bool + }{ + {"empty ID", Model{ID: ""}, true}, + {"whitespace ID", Model{ID: " "}, true}, + {"missing provider", Model{ID: "model-only"}, true}, + {"missing model", Model{ID: "provider/"}, true}, + {"slash only", Model{ID: "/"}, true}, + {"valid model", Model{ID: "provider/model", Limits: Limits{ContextTokens: 1, OutputTokens: 1}, Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, Metadata: Metadata{Name: "Valid Model", Family: "test"}}, false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := RegisterModel(tt.model) + if (err != nil) != tt.wantErr { + t.Errorf("RegisterModel() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestRegisterModel_MissingRequiredFields(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + model Model + wantErr bool + }{ + { + name: "missing context tokens", + model: Model{ID: "req/context", Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, Metadata: Metadata{Name: "X"}}, + wantErr: true, + }, + { + name: "missing modalities", + model: Model{ID: "req/modalities", Limits: Limits{ContextTokens: 1, OutputTokens: 1}, Metadata: Metadata{Name: "X"}}, + wantErr: true, + }, + { + name: "missing metadata name", + model: Model{ID: "req/metadata", Limits: Limits{ContextTokens: 1, OutputTokens: 1}, Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}}, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := RegisterModel(tt.model) + if (err != nil) != tt.wantErr { + t.Errorf("RegisterModel() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestRegisterModel_ConflictingAliases(t *testing.T) { + t.Parallel() + + m1 := Model{ID: "provider1/model1", Aliases: []string{"shared-alias"}, Limits: Limits{ContextTokens: 1, OutputTokens: 1}, Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, Metadata: Metadata{Name: "Model 1", Family: "test"}} + if err := RegisterModel(m1); err != nil { + t.Fatalf("RegisterModel(m1) err = %v", err) + } + + m2 := Model{ID: "provider2/model2", Aliases: []string{"shared-alias"}, Limits: Limits{ContextTokens: 1, OutputTokens: 1}, Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, Metadata: Metadata{Name: "Model 2", Family: "test"}} + err := RegisterModel(m2) + if err == nil { + t.Fatal("expected error registering conflicting alias") + } +} + +func TestRegisterAlias(t *testing.T) { + t.Parallel() + + m := Model{ID: "provider/alias-test-model", Limits: Limits{ContextTokens: 1, OutputTokens: 1}, Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, Metadata: Metadata{Name: "Alias Test Model", Family: "test"}} + if err := RegisterModel(m); err != nil { + t.Fatalf("RegisterModel err = %v", err) + } + + tests := []struct { + name string + alias string + canonical string + wantErr bool + }{ + {"valid alias", "my-alias", "provider/alias-test-model", false}, + {"idempotent alias", "my-alias", "provider/alias-test-model", false}, + {"empty alias", "", "provider/alias-test-model", true}, + {"whitespace alias", " ", "provider/alias-test-model", true}, + {"unknown canonical model", "new-alias", "unknown/model", true}, + {"invalid canonical format", "new-alias2", "invalid-format", true}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := RegisterAlias(tt.alias, tt.canonical) + if (err != nil) != tt.wantErr { + t.Errorf("RegisterAlias() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestResolve_EdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantOk bool + }{ + {"empty string", "", false}, + {"whitespace only", " ", false}, + {"unknown model", "unknown/model", false}, + {"invalid format", "invalid-format", false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, ok := Resolve(tt.input) + if ok != tt.wantOk { + t.Errorf("Resolve(%q) ok = %v, want %v", tt.input, ok, tt.wantOk) + } + }) + } +} + +func TestListModels(t *testing.T) { + t.Parallel() + + models := ListModels() + if len(models) == 0 { + t.Fatal("expected at least one model") + } + + // Check sorting + for i := 1; i < len(models); i++ { + if models[i-1].ID >= models[i].ID { + t.Errorf("models not sorted: %q >= %q", models[i-1].ID, models[i].ID) + } + } +} + +func TestListModelsByProvider(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + provider string + wantMinCount int + }{ + {"openai provider", "openai", 3}, + {"openrouter provider", "openrouter", 5}, + {"case insensitive", "OpenAI", 3}, + {"unknown provider", "unknown", 0}, + {"empty provider", "", 0}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + models := ListModelsByProvider(tt.provider) + if len(models) < tt.wantMinCount { + t.Errorf("ListModelsByProvider(%q) returned %d models, want at least %d", tt.provider, len(models), tt.wantMinCount) + } + + // Check sorting + for i := 1; i < len(models); i++ { + if models[i-1].ID >= models[i].ID { + t.Errorf("models not sorted: %q >= %q", models[i-1].ID, models[i].ID) + } + } + }) + } +} + +func TestConcurrentRegistration(t *testing.T) { + t.Parallel() + + const goroutines = 10 + const modelsPerGoroutine = 5 + + var wg sync.WaitGroup + wg.Add(goroutines) + + for i := 0; i < goroutines; i++ { + i := i + go func() { + defer wg.Done() + for j := 0; j < modelsPerGoroutine; j++ { + m := Model{ + ID: fmt.Sprintf("concurrent-test/model-%d-%d", i, j), + Aliases: []string{fmt.Sprintf("concurrent-alias-%d-%d", i, j)}, + Limits: Limits{ContextTokens: 1, OutputTokens: 1}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Concurrent Test", Family: "test"}, + } + _ = RegisterModel(m) + } + }() + } + + wg.Wait() + + models := ListModels() + if len(models) == 0 { + t.Error("expected models after concurrent registration") + } +} + +func TestConcurrentResolve(t *testing.T) { + t.Parallel() + + m := Model{ID: "concurrent-read/model", Aliases: []string{"concurrent-read-alias"}, Limits: Limits{ContextTokens: 1, OutputTokens: 1}, Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, Metadata: Metadata{Name: "Concurrent Read", Family: "test"}} + if err := RegisterModel(m); err != nil { + t.Fatalf("RegisterModel err = %v", err) + } + + const goroutines = 20 + var wg sync.WaitGroup + wg.Add(goroutines) + + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + _, _ = Resolve("concurrent-read-alias") + _ = IsValidCanonical("concurrent-read/model") + _ = IsValid("concurrent-read-alias") + _ = ListModels() + _ = ListModelsByProvider("concurrent-read") + } + }() + } + + wg.Wait() +} diff --git a/internal/modelcatalog/default_models.go b/internal/modelcatalog/default_models.go new file mode 100644 index 0000000..d13ba9c --- /dev/null +++ b/internal/modelcatalog/default_models.go @@ -0,0 +1,1460 @@ +package modelcatalog + +// Code generated by go generate; DO NOT EDIT. +// +// Default supported model list. +// +// This catalog is authoritative for core.NewLM/dsgo.NewLM. +// +// Source of truth for OpenAI/OpenRouter models: https://models.dev/api.json +// Note: Modalities are forced to text-only for now. +//go:generate go run ./generate_default_models.go + +func init() { + defaults := []Model{ + { + ID: "mock/gpt-4o", + Pricing: Pricing{PromptPrice: 2.5, CompletionPrice: 10, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 16384}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: false, StructuredOutput: false, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Mock GPT-4o", Family: "mock", Knowledge: "", ReleaseDate: "", LastUpdated: "", OpenWeights: false}, + }, + { + ID: "mock/gpt-4o-mini", + Pricing: Pricing{PromptPrice: 0.15, CompletionPrice: 0.6, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 16384}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: false, StructuredOutput: false, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Mock GPT-4o mini", Family: "mock", Knowledge: "", ReleaseDate: "", LastUpdated: "", OpenWeights: false}, + }, + { + ID: "openai/codex-mini-latest", + Aliases: []string{"codex-mini-latest"}, + Pricing: Pricing{PromptPrice: 1.5, CompletionPrice: 6, CacheReadPrice: 0.375, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 100000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Codex Mini", Family: "codex", Knowledge: "2024-04", ReleaseDate: "2025-05-16", LastUpdated: "2025-05-16", OpenWeights: false}, + }, + { + ID: "openai/gpt-3.5-turbo", + Aliases: []string{"gpt-3.5-turbo"}, + Pricing: Pricing{PromptPrice: 0.5, CompletionPrice: 1.5, CacheReadPrice: 1.25, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 16385, OutputTokens: 4096}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: false, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-3.5-turbo", Family: "gpt-3.5-turbo", Knowledge: "2021-09-01", ReleaseDate: "2023-03-01", LastUpdated: "2023-11-06", OpenWeights: false}, + }, + { + ID: "openai/gpt-4", + Aliases: []string{"gpt-4"}, + Pricing: Pricing{PromptPrice: 30, CompletionPrice: 60, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 8192, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-4", Family: "gpt-4", Knowledge: "2023-11", ReleaseDate: "2023-11-06", LastUpdated: "2024-04-09", OpenWeights: false}, + }, + { + ID: "openai/gpt-4-turbo", + Aliases: []string{"gpt-4-turbo"}, + Pricing: Pricing{PromptPrice: 10, CompletionPrice: 30, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 4096}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-4 Turbo", Family: "gpt-4-turbo", Knowledge: "2023-12", ReleaseDate: "2023-11-06", LastUpdated: "2024-04-09", OpenWeights: false}, + }, + { + ID: "openai/gpt-4.1", + Aliases: []string{"gpt-4.1"}, + Pricing: Pricing{PromptPrice: 2, CompletionPrice: 8, CacheReadPrice: 0.5, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1047576, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: true, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-4.1", Family: "gpt-4.1", Knowledge: "2024-04", ReleaseDate: "2025-04-14", LastUpdated: "2025-04-14", OpenWeights: false}, + }, + { + ID: "openai/gpt-4.1-mini", + Aliases: []string{"gpt-4.1-mini"}, + Pricing: Pricing{PromptPrice: 0.4, CompletionPrice: 1.6, CacheReadPrice: 0.1, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1047576, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: true, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-4.1 mini", Family: "gpt-4.1-mini", Knowledge: "2024-04", ReleaseDate: "2025-04-14", LastUpdated: "2025-04-14", OpenWeights: false}, + }, + { + ID: "openai/gpt-4.1-nano", + Aliases: []string{"gpt-4.1-nano"}, + Pricing: Pricing{PromptPrice: 0.1, CompletionPrice: 0.4, CacheReadPrice: 0.03, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1047576, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: true, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-4.1 nano", Family: "gpt-4.1-nano", Knowledge: "2024-04", ReleaseDate: "2025-04-14", LastUpdated: "2025-04-14", OpenWeights: false}, + }, + { + ID: "openai/gpt-4o", + Aliases: []string{"gpt-4o"}, + Pricing: Pricing{PromptPrice: 2.5, CompletionPrice: 10, CacheReadPrice: 1.25, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 16384}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: true, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-4o", Family: "gpt-4o", Knowledge: "2023-09", ReleaseDate: "2024-05-13", LastUpdated: "2024-08-06", OpenWeights: false}, + }, + { + ID: "openai/gpt-4o-2024-05-13", + Aliases: []string{"gpt-4o-2024-05-13"}, + Pricing: Pricing{PromptPrice: 5, CompletionPrice: 15, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 4096}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: true, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-4o (2024-05-13)", Family: "gpt-4o", Knowledge: "2023-09", ReleaseDate: "2024-05-13", LastUpdated: "2024-05-13", OpenWeights: false}, + }, + { + ID: "openai/gpt-4o-2024-08-06", + Aliases: []string{"gpt-4o-2024-08-06"}, + Pricing: Pricing{PromptPrice: 2.5, CompletionPrice: 10, CacheReadPrice: 1.25, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 16384}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: true, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-4o (2024-08-06)", Family: "gpt-4o", Knowledge: "2023-09", ReleaseDate: "2024-08-06", LastUpdated: "2024-08-06", OpenWeights: false}, + }, + { + ID: "openai/gpt-4o-2024-11-20", + Aliases: []string{"gpt-4o-2024-11-20"}, + Pricing: Pricing{PromptPrice: 2.5, CompletionPrice: 10, CacheReadPrice: 1.25, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 16384}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: true, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-4o (2024-11-20)", Family: "gpt-4o", Knowledge: "2023-09", ReleaseDate: "2024-11-20", LastUpdated: "2024-11-20", OpenWeights: false}, + }, + { + ID: "openai/gpt-4o-mini", + Aliases: []string{"gpt-4o-mini"}, + Pricing: Pricing{PromptPrice: 0.15, CompletionPrice: 0.6, CacheReadPrice: 0.08, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 16384}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: true, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-4o mini", Family: "gpt-4o-mini", Knowledge: "2023-09", ReleaseDate: "2024-07-18", LastUpdated: "2024-07-18", OpenWeights: false}, + }, + { + ID: "openai/gpt-5", + Aliases: []string{"gpt-5"}, + Pricing: Pricing{PromptPrice: 1.25, CompletionPrice: 10, CacheReadPrice: 0.13, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5", Family: "gpt-5", Knowledge: "2024-09-30", ReleaseDate: "2025-08-07", LastUpdated: "2025-08-07", OpenWeights: false}, + }, + { + ID: "openai/gpt-5-chat-latest", + Aliases: []string{"gpt-5-chat-latest"}, + Pricing: Pricing{PromptPrice: 1.25, CompletionPrice: 10, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: false, StructuredOutput: true, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5 Chat (latest)", Family: "gpt-5-chat", Knowledge: "2024-09-30", ReleaseDate: "2025-08-07", LastUpdated: "2025-08-07", OpenWeights: false}, + }, + { + ID: "openai/gpt-5-codex", + Aliases: []string{"gpt-5-codex"}, + Pricing: Pricing{PromptPrice: 1.25, CompletionPrice: 10, CacheReadPrice: 0.125, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5-Codex", Family: "gpt-5-codex", Knowledge: "2024-09-30", ReleaseDate: "2025-09-15", LastUpdated: "2025-09-15", OpenWeights: false}, + }, + { + ID: "openai/gpt-5-mini", + Aliases: []string{"gpt-5-mini"}, + Pricing: Pricing{PromptPrice: 0.25, CompletionPrice: 2, CacheReadPrice: 0.03, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5 Mini", Family: "gpt-5-mini", Knowledge: "2024-05-30", ReleaseDate: "2025-08-07", LastUpdated: "2025-08-07", OpenWeights: false}, + }, + { + ID: "openai/gpt-5-nano", + Aliases: []string{"gpt-5-nano"}, + Pricing: Pricing{PromptPrice: 0.05, CompletionPrice: 0.4, CacheReadPrice: 0.01, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5 Nano", Family: "gpt-5-nano", Knowledge: "2024-05-30", ReleaseDate: "2025-08-07", LastUpdated: "2025-08-07", OpenWeights: false}, + }, + { + ID: "openai/gpt-5-pro", + Aliases: []string{"gpt-5-pro"}, + Pricing: Pricing{PromptPrice: 15, CompletionPrice: 120, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 272000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5 Pro", Family: "gpt-5-pro", Knowledge: "2024-09-30", ReleaseDate: "2025-10-06", LastUpdated: "2025-10-06", OpenWeights: false}, + }, + { + ID: "openai/gpt-5.1", + Aliases: []string{"gpt-5.1"}, + Pricing: Pricing{PromptPrice: 1.25, CompletionPrice: 10, CacheReadPrice: 0.13, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5.1", Family: "gpt-5", Knowledge: "2024-09-30", ReleaseDate: "2025-11-13", LastUpdated: "2025-11-13", OpenWeights: false}, + }, + { + ID: "openai/gpt-5.1-chat-latest", + Aliases: []string{"gpt-5.1-chat-latest"}, + Pricing: Pricing{PromptPrice: 1.25, CompletionPrice: 10, CacheReadPrice: 0.125, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 16384}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5.1 Chat", Family: "gpt-5-chat", Knowledge: "2024-09-30", ReleaseDate: "2025-11-13", LastUpdated: "2025-11-13", OpenWeights: false}, + }, + { + ID: "openai/gpt-5.1-codex", + Aliases: []string{"gpt-5.1-codex"}, + Pricing: Pricing{PromptPrice: 1.25, CompletionPrice: 10, CacheReadPrice: 0.125, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5.1 Codex", Family: "gpt-5-codex", Knowledge: "2024-09-30", ReleaseDate: "2025-11-13", LastUpdated: "2025-11-13", OpenWeights: false}, + }, + { + ID: "openai/gpt-5.1-codex-max", + Aliases: []string{"gpt-5.1-codex-max"}, + Pricing: Pricing{PromptPrice: 1.25, CompletionPrice: 10, CacheReadPrice: 0.125, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5.1 Codex Max", Family: "gpt-5-codex", Knowledge: "2024-09-30", ReleaseDate: "2025-11-13", LastUpdated: "2025-11-13", OpenWeights: false}, + }, + { + ID: "openai/gpt-5.1-codex-mini", + Aliases: []string{"gpt-5.1-codex-mini"}, + Pricing: Pricing{PromptPrice: 0.25, CompletionPrice: 2, CacheReadPrice: 0.025, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5.1 Codex mini", Family: "gpt-5-codex-mini", Knowledge: "2024-09-30", ReleaseDate: "2025-11-13", LastUpdated: "2025-11-13", OpenWeights: false}, + }, + { + ID: "openai/gpt-5.2", + Aliases: []string{"gpt-5.2"}, + Pricing: Pricing{PromptPrice: 1.75, CompletionPrice: 14, CacheReadPrice: 0.175, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5.2", Family: "gpt-5", Knowledge: "2025-08-31", ReleaseDate: "2025-12-11", LastUpdated: "2025-12-11", OpenWeights: false}, + }, + { + ID: "openai/gpt-5.2-chat-latest", + Aliases: []string{"gpt-5.2-chat-latest"}, + Pricing: Pricing{PromptPrice: 1.75, CompletionPrice: 14, CacheReadPrice: 0.175, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 16384}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5.2 Chat", Family: "gpt-5-chat", Knowledge: "2025-08-31", ReleaseDate: "2025-12-11", LastUpdated: "2025-12-11", OpenWeights: false}, + }, + { + ID: "openai/gpt-5.2-pro", + Aliases: []string{"gpt-5.2-pro"}, + Pricing: Pricing{PromptPrice: 21, CompletionPrice: 168, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5.2 Pro", Family: "gpt-5-pro", Knowledge: "2025-08-31", ReleaseDate: "2025-12-11", LastUpdated: "2025-12-11", OpenWeights: false}, + }, + { + ID: "openai/o1", + Aliases: []string{"o1"}, + Pricing: Pricing{PromptPrice: 15, CompletionPrice: 60, CacheReadPrice: 7.5, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 100000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "o1", Family: "o1", Knowledge: "2023-09", ReleaseDate: "2024-12-05", LastUpdated: "2024-12-05", OpenWeights: false}, + }, + { + ID: "openai/o1-mini", + Aliases: []string{"o1-mini"}, + Pricing: Pricing{PromptPrice: 1.1, CompletionPrice: 4.4, CacheReadPrice: 0.55, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 65536}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: false, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "o1-mini", Family: "o1-mini", Knowledge: "2023-09", ReleaseDate: "2024-09-12", LastUpdated: "2024-09-12", OpenWeights: false}, + }, + { + ID: "openai/o1-preview", + Aliases: []string{"o1-preview"}, + Pricing: Pricing{PromptPrice: 15, CompletionPrice: 60, CacheReadPrice: 7.5, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: false, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "o1-preview", Family: "o1-preview", Knowledge: "2023-09", ReleaseDate: "2024-09-12", LastUpdated: "2024-09-12", OpenWeights: false}, + }, + { + ID: "openai/o1-pro", + Aliases: []string{"o1-pro"}, + Pricing: Pricing{PromptPrice: 150, CompletionPrice: 600, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 100000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "o1-pro", Family: "o1-pro", Knowledge: "2023-09", ReleaseDate: "2025-03-19", LastUpdated: "2025-03-19", OpenWeights: false}, + }, + { + ID: "openai/o3", + Aliases: []string{"o3"}, + Pricing: Pricing{PromptPrice: 2, CompletionPrice: 8, CacheReadPrice: 0.5, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 100000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "o3", Family: "o3", Knowledge: "2024-05", ReleaseDate: "2025-04-16", LastUpdated: "2025-04-16", OpenWeights: false}, + }, + { + ID: "openai/o3-deep-research", + Aliases: []string{"o3-deep-research"}, + Pricing: Pricing{PromptPrice: 10, CompletionPrice: 40, CacheReadPrice: 2.5, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 100000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "o3-deep-research", Family: "o3", Knowledge: "2024-05", ReleaseDate: "2024-06-26", LastUpdated: "2024-06-26", OpenWeights: false}, + }, + { + ID: "openai/o3-mini", + Aliases: []string{"o3-mini"}, + Pricing: Pricing{PromptPrice: 1.1, CompletionPrice: 4.4, CacheReadPrice: 0.55, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 100000}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "o3-mini", Family: "o3-mini", Knowledge: "2024-05", ReleaseDate: "2024-12-20", LastUpdated: "2025-01-29", OpenWeights: false}, + }, + { + ID: "openai/o3-pro", + Aliases: []string{"o3-pro"}, + Pricing: Pricing{PromptPrice: 20, CompletionPrice: 80, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 100000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "o3-pro", Family: "o3-pro", Knowledge: "2024-05", ReleaseDate: "2025-06-10", LastUpdated: "2025-06-10", OpenWeights: false}, + }, + { + ID: "openai/o4-mini", + Aliases: []string{"o4-mini"}, + Pricing: Pricing{PromptPrice: 1.1, CompletionPrice: 4.4, CacheReadPrice: 0.28, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 100000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "o4-mini", Family: "o4-mini", Knowledge: "2024-05", ReleaseDate: "2025-04-16", LastUpdated: "2025-04-16", OpenWeights: false}, + }, + { + ID: "openai/o4-mini-deep-research", + Aliases: []string{"o4-mini-deep-research"}, + Pricing: Pricing{PromptPrice: 2, CompletionPrice: 8, CacheReadPrice: 0.5, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 100000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "o4-mini-deep-research", Family: "o4-mini", Knowledge: "2024-05", ReleaseDate: "2024-06-26", LastUpdated: "2024-06-26", OpenWeights: false}, + }, + { + ID: "openai/text-embedding-3-large", + Aliases: []string{"text-embedding-3-large"}, + Pricing: Pricing{PromptPrice: 0.13, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 8191, OutputTokens: 3072}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: false, StructuredOutput: false, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "text-embedding-3-large", Family: "text-embedding-3-large", Knowledge: "2024-01", ReleaseDate: "2024-01-25", LastUpdated: "2024-01-25", OpenWeights: false}, + }, + { + ID: "openai/text-embedding-3-small", + Aliases: []string{"text-embedding-3-small"}, + Pricing: Pricing{PromptPrice: 0.02, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 8191, OutputTokens: 1536}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: false, StructuredOutput: false, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "text-embedding-3-small", Family: "text-embedding-3-small", Knowledge: "2024-01", ReleaseDate: "2024-01-25", LastUpdated: "2024-01-25", OpenWeights: false}, + }, + { + ID: "openai/text-embedding-ada-002", + Aliases: []string{"text-embedding-ada-002"}, + Pricing: Pricing{PromptPrice: 0.1, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 8192, OutputTokens: 1536}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: false, StructuredOutput: false, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "text-embedding-ada-002", Family: "text-embedding-ada", Knowledge: "2022-12", ReleaseDate: "2022-12-15", LastUpdated: "2022-12-15", OpenWeights: false}, + }, + { + ID: "openrouter/anthropic/claude-3.5-haiku", + Pricing: Pricing{PromptPrice: 0.8, CompletionPrice: 4, CacheReadPrice: 0.08, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Claude Haiku 3.5", Family: "claude-haiku", Knowledge: "2024-07-31", ReleaseDate: "2024-10-22", LastUpdated: "2024-10-22", OpenWeights: false}, + }, + { + ID: "openrouter/anthropic/claude-3.7-sonnet", + Pricing: Pricing{PromptPrice: 15, CompletionPrice: 75, CacheReadPrice: 1.5, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Claude Sonnet 3.7", Family: "claude-sonnet", Knowledge: "2024-01", ReleaseDate: "2025-02-19", LastUpdated: "2025-02-19", OpenWeights: false}, + }, + { + ID: "openrouter/anthropic/claude-haiku-4.5", + Pricing: Pricing{PromptPrice: 1, CompletionPrice: 5, CacheReadPrice: 0.1, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 64000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Claude Haiku 4.5", Family: "claude-haiku", Knowledge: "2025-02-28", ReleaseDate: "2025-10-15", LastUpdated: "2025-10-15", OpenWeights: false}, + }, + { + ID: "openrouter/anthropic/claude-opus-4", + Pricing: Pricing{PromptPrice: 15, CompletionPrice: 75, CacheReadPrice: 1.5, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 32000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Claude Opus 4", Family: "claude-opus", Knowledge: "2025-03-31", ReleaseDate: "2025-05-22", LastUpdated: "2025-05-22", OpenWeights: false}, + }, + { + ID: "openrouter/anthropic/claude-opus-4.1", + Pricing: Pricing{PromptPrice: 15, CompletionPrice: 75, CacheReadPrice: 1.5, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 32000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Claude Opus 4.1", Family: "claude-opus", Knowledge: "2025-03-31", ReleaseDate: "2025-08-05", LastUpdated: "2025-08-05", OpenWeights: false}, + }, + { + ID: "openrouter/anthropic/claude-opus-4.5", + Pricing: Pricing{PromptPrice: 5, CompletionPrice: 25, CacheReadPrice: 0.5, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 32000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Claude Opus 4.5", Family: "claude-opus", Knowledge: "2025-05-30", ReleaseDate: "2025-11-24", LastUpdated: "2025-11-24", OpenWeights: false}, + }, + { + ID: "openrouter/anthropic/claude-sonnet-4", + Pricing: Pricing{PromptPrice: 3, CompletionPrice: 15, CacheReadPrice: 0.3, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 64000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Claude Sonnet 4", Family: "claude-sonnet", Knowledge: "2025-03-31", ReleaseDate: "2025-05-22", LastUpdated: "2025-05-22", OpenWeights: false}, + }, + { + ID: "openrouter/anthropic/claude-sonnet-4.5", + Pricing: Pricing{PromptPrice: 3, CompletionPrice: 15, CacheReadPrice: 0.3, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1000000, OutputTokens: 64000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Claude Sonnet 4.5", Family: "claude-sonnet", Knowledge: "2025-07-31", ReleaseDate: "2025-09-29", LastUpdated: "2025-09-29", OpenWeights: false}, + }, + { + ID: "openrouter/cognitivecomputations/dolphin3.0-mistral-24b", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 32768, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Dolphin3.0 Mistral 24B", Family: "mistral", Knowledge: "2024-10", ReleaseDate: "2025-02-13", LastUpdated: "2025-02-13", OpenWeights: true}, + }, + { + ID: "openrouter/cognitivecomputations/dolphin3.0-r1-mistral-24b", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 32768, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Dolphin3.0 R1 Mistral 24B", Family: "mistral", Knowledge: "2024-10", ReleaseDate: "2025-02-13", LastUpdated: "2025-02-13", OpenWeights: true}, + }, + { + ID: "openrouter/deepseek/deepseek-chat-v3-0324", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 16384, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: false, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "DeepSeek V3 0324", Family: "deepseek-v3", Knowledge: "2024-10", ReleaseDate: "2025-03-24", LastUpdated: "2025-03-24", OpenWeights: true}, + }, + { + ID: "openrouter/deepseek/deepseek-chat-v3.1", + Pricing: Pricing{PromptPrice: 0.2, CompletionPrice: 0.8, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 163840, OutputTokens: 163840}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "DeepSeek-V3.1", Family: "deepseek-v3", Knowledge: "2025-07", ReleaseDate: "2025-08-21", LastUpdated: "2025-08-21", OpenWeights: true}, + }, + { + ID: "openrouter/deepseek/deepseek-r1-0528-qwen3-8b:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 131072}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Deepseek R1 0528 Qwen3 8B (free)", Family: "qwen3", Knowledge: "2025-05", ReleaseDate: "2025-05-29", LastUpdated: "2025-05-29", OpenWeights: true}, + }, + { + ID: "openrouter/deepseek/deepseek-r1-0528:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 163840, OutputTokens: 163840}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "R1 0528 (free)", Family: "deepseek-r1", Knowledge: "2025-05", ReleaseDate: "2025-05-28", LastUpdated: "2025-05-28", OpenWeights: true}, + }, + { + ID: "openrouter/deepseek/deepseek-r1-distill-llama-70b", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 8192, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: false, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "DeepSeek R1 Distill Llama 70B", Family: "deepseek-r1-distill-llama", Knowledge: "2024-10", ReleaseDate: "2025-01-23", LastUpdated: "2025-01-23", OpenWeights: true}, + }, + { + ID: "openrouter/deepseek/deepseek-r1-distill-qwen-14b", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 64000, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: false, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "DeepSeek R1 Distill Qwen 14B", Family: "qwen", Knowledge: "2024-10", ReleaseDate: "2025-01-29", LastUpdated: "2025-01-29", OpenWeights: true}, + }, + { + ID: "openrouter/deepseek/deepseek-r1:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 163840, OutputTokens: 163840}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "R1 (free)", Family: "deepseek-r1", Knowledge: "2025-01", ReleaseDate: "2025-01-20", LastUpdated: "2025-01-20", OpenWeights: true}, + }, + { + ID: "openrouter/deepseek/deepseek-v3-base:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 163840, OutputTokens: 163840}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: false, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "DeepSeek V3 Base (free)", Family: "deepseek-v3", Knowledge: "2025-03", ReleaseDate: "2025-03-29", LastUpdated: "2025-03-29", OpenWeights: true}, + }, + { + ID: "openrouter/deepseek/deepseek-v3.1-terminus", + Pricing: Pricing{PromptPrice: 0.27, CompletionPrice: 1, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 65536}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "DeepSeek V3.1 Terminus", Family: "deepseek-v3", Knowledge: "2025-07", ReleaseDate: "2025-09-22", LastUpdated: "2025-09-22", OpenWeights: true}, + }, + { + ID: "openrouter/deepseek/deepseek-v3.1-terminus:exacto", + Pricing: Pricing{PromptPrice: 0.27, CompletionPrice: 1, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 65536}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "DeepSeek V3.1 Terminus (exacto)", Family: "deepseek-v3", Knowledge: "2025-07", ReleaseDate: "2025-09-22", LastUpdated: "2025-09-22", OpenWeights: true}, + }, + { + ID: "openrouter/deepseek/deepseek-v3.2", + Pricing: Pricing{PromptPrice: 0.28, CompletionPrice: 0.4, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 163840, OutputTokens: 65536}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "DeepSeek V3.2", Family: "deepseek-v3", Knowledge: "2024-07", ReleaseDate: "2025-12-01", LastUpdated: "2025-12-01", OpenWeights: true}, + }, + { + ID: "openrouter/deepseek/deepseek-v3.2-speciale", + Pricing: Pricing{PromptPrice: 0.27, CompletionPrice: 0.41, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 163840, OutputTokens: 65536}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "DeepSeek V3.2 Speciale", Family: "deepseek-v3", Knowledge: "2024-07", ReleaseDate: "2025-12-01", LastUpdated: "2025-12-01", OpenWeights: true}, + }, + { + ID: "openrouter/featherless/qwerky-72b", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 32768, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: false, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwerky 72B", Family: "qwerky", Knowledge: "2024-10", ReleaseDate: "2025-03-20", LastUpdated: "2025-03-20", OpenWeights: true}, + }, + { + ID: "openrouter/google/gemini-2.0-flash-001", + Pricing: Pricing{PromptPrice: 0.1, CompletionPrice: 0.4, CacheReadPrice: 0.025, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1048576, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Gemini 2.0 Flash", Family: "gemini-flash", Knowledge: "2024-06", ReleaseDate: "2024-12-11", LastUpdated: "2024-12-11", OpenWeights: false}, + }, + { + ID: "openrouter/google/gemini-2.0-flash-exp:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1048576, OutputTokens: 1048576}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Gemini 2.0 Flash Experimental (free)", Family: "gemini-flash", Knowledge: "2024-12", ReleaseDate: "2024-12-11", LastUpdated: "2024-12-11", OpenWeights: false}, + }, + { + ID: "openrouter/google/gemini-2.5-flash", + Pricing: Pricing{PromptPrice: 0.3, CompletionPrice: 2.5, CacheReadPrice: 0.0375, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1048576, OutputTokens: 65536}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Gemini 2.5 Flash", Family: "gemini-flash", Knowledge: "2025-01", ReleaseDate: "2025-07-17", LastUpdated: "2025-07-17", OpenWeights: false}, + }, + { + ID: "openrouter/google/gemini-2.5-flash-lite", + Pricing: Pricing{PromptPrice: 0.1, CompletionPrice: 0.4, CacheReadPrice: 0.025, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1048576, OutputTokens: 65536}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Gemini 2.5 Flash Lite", Family: "gemini-flash-lite", Knowledge: "2025-01", ReleaseDate: "2025-06-17", LastUpdated: "2025-06-17", OpenWeights: false}, + }, + { + ID: "openrouter/google/gemini-2.5-flash-lite-preview-09-2025", + Pricing: Pricing{PromptPrice: 0.1, CompletionPrice: 0.4, CacheReadPrice: 0.025, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1048576, OutputTokens: 65536}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Gemini 2.5 Flash Lite Preview 09-25", Family: "gemini-flash-lite", Knowledge: "2025-01", ReleaseDate: "2025-09-25", LastUpdated: "2025-09-25", OpenWeights: false}, + }, + { + ID: "openrouter/google/gemini-2.5-flash-preview-09-2025", + Pricing: Pricing{PromptPrice: 0.3, CompletionPrice: 2.5, CacheReadPrice: 0.031, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1048576, OutputTokens: 65536}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Gemini 2.5 Flash Preview 09-25", Family: "gemini-flash", Knowledge: "2025-01", ReleaseDate: "2025-09-25", LastUpdated: "2025-09-25", OpenWeights: false}, + }, + { + ID: "openrouter/google/gemini-2.5-pro", + Pricing: Pricing{PromptPrice: 1.25, CompletionPrice: 10, CacheReadPrice: 0.31, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1048576, OutputTokens: 65536}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Gemini 2.5 Pro", Family: "gemini-pro", Knowledge: "2025-01", ReleaseDate: "2025-03-20", LastUpdated: "2025-06-05", OpenWeights: false}, + }, + { + ID: "openrouter/google/gemini-2.5-pro-preview-05-06", + Pricing: Pricing{PromptPrice: 1.25, CompletionPrice: 10, CacheReadPrice: 0.31, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1048576, OutputTokens: 65536}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Gemini 2.5 Pro Preview 05-06", Family: "gemini-pro", Knowledge: "2025-01", ReleaseDate: "2025-05-06", LastUpdated: "2025-05-06", OpenWeights: false}, + }, + { + ID: "openrouter/google/gemini-2.5-pro-preview-06-05", + Pricing: Pricing{PromptPrice: 1.25, CompletionPrice: 10, CacheReadPrice: 0.31, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1048576, OutputTokens: 65536}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Gemini 2.5 Pro Preview 06-05", Family: "gemini-pro", Knowledge: "2025-01", ReleaseDate: "2025-06-05", LastUpdated: "2025-06-05", OpenWeights: false}, + }, + { + ID: "openrouter/google/gemini-3-pro-preview", + Pricing: Pricing{PromptPrice: 2, CompletionPrice: 12, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1050000, OutputTokens: 66000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Gemini 3 Pro Preview", Family: "gemini-pro", Knowledge: "2025-01", ReleaseDate: "2025-11-18", LastUpdated: "2025-11", OpenWeights: false}, + }, + { + ID: "openrouter/google/gemma-2-9b-it:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 8192, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Gemma 2 9B (free)", Family: "gemma-2", Knowledge: "2024-06", ReleaseDate: "2024-06-28", LastUpdated: "2024-06-28", OpenWeights: true}, + }, + { + ID: "openrouter/google/gemma-3-12b-it", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 96000, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Gemma 3 12B IT", Family: "gemma-3", Knowledge: "2024-10", ReleaseDate: "2025-03-13", LastUpdated: "2025-03-13", OpenWeights: true}, + }, + { + ID: "openrouter/google/gemma-3-27b-it", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 96000, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Gemma 3 27B IT", Family: "gemma-3", Knowledge: "2024-10", ReleaseDate: "2025-03-12", LastUpdated: "2025-03-12", OpenWeights: true}, + }, + { + ID: "openrouter/google/gemma-3n-e4b-it", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 8192, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: false, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Gemma 3n E4B IT", Family: "gemma-3", Knowledge: "2024-10", ReleaseDate: "2025-05-20", LastUpdated: "2025-05-20", OpenWeights: true}, + }, + { + ID: "openrouter/google/gemma-3n-e4b-it:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 8192, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Gemma 3n 4B (free)", Family: "gemma-3", Knowledge: "2025-05", ReleaseDate: "2025-05-20", LastUpdated: "2025-05-20", OpenWeights: true}, + }, + { + ID: "openrouter/kwaipilot/kat-coder-pro:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 256000, OutputTokens: 65536}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Kat Coder Pro (free)", Family: "kat-coder-pro", Knowledge: "2025-11", ReleaseDate: "2025-11-10", LastUpdated: "2025-11-10", OpenWeights: false}, + }, + { + ID: "openrouter/meta-llama/llama-3.2-11b-vision-instruct", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: false, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Llama 3.2 11B Vision Instruct", Family: "llama-3.2", Knowledge: "2023-12", ReleaseDate: "2024-09-25", LastUpdated: "2024-09-25", OpenWeights: true}, + }, + { + ID: "openrouter/meta-llama/llama-3.3-70b-instruct:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 65536, OutputTokens: 65536}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Llama 3.3 70B Instruct (free)", Family: "llama-3.3", Knowledge: "2024-12", ReleaseDate: "2024-12-06", LastUpdated: "2024-12-06", OpenWeights: true}, + }, + { + ID: "openrouter/meta-llama/llama-4-scout:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 64000, OutputTokens: 64000}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Llama 4 Scout (free)", Family: "llama-4-scout", Knowledge: "2024-08", ReleaseDate: "2025-04-05", LastUpdated: "2025-04-05", OpenWeights: true}, + }, + { + ID: "openrouter/microsoft/mai-ds-r1:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 163840, OutputTokens: 163840}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "MAI DS R1 (free)", Family: "mai-ds-r1", Knowledge: "2025-04", ReleaseDate: "2025-04-21", LastUpdated: "2025-04-21", OpenWeights: true}, + }, + { + ID: "openrouter/minimax/minimax-01", + Pricing: Pricing{PromptPrice: 0.2, CompletionPrice: 1.1, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1000000, OutputTokens: 1000000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "MiniMax-01", Family: "minimax", Knowledge: "", ReleaseDate: "2025-01-15", LastUpdated: "2025-01-15", OpenWeights: true}, + }, + { + ID: "openrouter/minimax/minimax-m1", + Pricing: Pricing{PromptPrice: 0.4, CompletionPrice: 2.2, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1000000, OutputTokens: 40000}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "MiniMax M1", Family: "minimax", Knowledge: "", ReleaseDate: "2025-06-17", LastUpdated: "2025-06-17", OpenWeights: true}, + }, + { + ID: "openrouter/minimax/minimax-m2", + Pricing: Pricing{PromptPrice: 0.28, CompletionPrice: 1.15, CacheReadPrice: 0.28, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 196600, OutputTokens: 118000}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "MiniMax M2", Family: "minimax", Knowledge: "", ReleaseDate: "2025-10-23", LastUpdated: "2025-10-23", OpenWeights: true}, + }, + { + ID: "openrouter/mistralai/codestral-2508", + Pricing: Pricing{PromptPrice: 0.3, CompletionPrice: 0.9, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 256000, OutputTokens: 256000}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Codestral 2508", Family: "codestral", Knowledge: "2025-05", ReleaseDate: "2025-08-01", LastUpdated: "2025-08-01", OpenWeights: true}, + }, + { + ID: "openrouter/mistralai/devstral-2512", + Pricing: Pricing{PromptPrice: 0.15, CompletionPrice: 0.6, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 262144, OutputTokens: 262144}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Devstral 2 2512", Family: "devstral", Knowledge: "2025-12", ReleaseDate: "2025-09-12", LastUpdated: "2025-09-12", OpenWeights: true}, + }, + { + ID: "openrouter/mistralai/devstral-2512:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 262144, OutputTokens: 262144}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Devstral 2 2512 (free)", Family: "devstral", Knowledge: "2025-12", ReleaseDate: "2025-09-12", LastUpdated: "2025-09-12", OpenWeights: true}, + }, + { + ID: "openrouter/mistralai/devstral-medium-2507", + Pricing: Pricing{PromptPrice: 0.4, CompletionPrice: 2, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 131072}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Devstral Medium", Family: "devstral-medium", Knowledge: "2025-05", ReleaseDate: "2025-07-10", LastUpdated: "2025-07-10", OpenWeights: true}, + }, + { + ID: "openrouter/mistralai/devstral-small-2505", + Pricing: Pricing{PromptPrice: 0.06, CompletionPrice: 0.12, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Devstral Small", Family: "devstral-small", Knowledge: "2025-05", ReleaseDate: "2025-05-07", LastUpdated: "2025-05-07", OpenWeights: true}, + }, + { + ID: "openrouter/mistralai/devstral-small-2505:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 32768, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Devstral Small 2505 (free)", Family: "devstral-small", Knowledge: "2025-05", ReleaseDate: "2025-05-21", LastUpdated: "2025-05-21", OpenWeights: true}, + }, + { + ID: "openrouter/mistralai/devstral-small-2507", + Pricing: Pricing{PromptPrice: 0.1, CompletionPrice: 0.3, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 131072}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Devstral Small 1.1", Family: "devstral-small", Knowledge: "2025-05", ReleaseDate: "2025-07-10", LastUpdated: "2025-07-10", OpenWeights: true}, + }, + { + ID: "openrouter/mistralai/mistral-7b-instruct:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 32768, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Mistral 7B Instruct (free)", Family: "mistral-7b", Knowledge: "2024-05", ReleaseDate: "2024-05-27", LastUpdated: "2024-05-27", OpenWeights: true}, + }, + { + ID: "openrouter/mistralai/mistral-medium-3", + Pricing: Pricing{PromptPrice: 0.4, CompletionPrice: 2, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 131072}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Mistral Medium 3", Family: "mistral-medium", Knowledge: "2025-05", ReleaseDate: "2025-05-07", LastUpdated: "2025-05-07", OpenWeights: false}, + }, + { + ID: "openrouter/mistralai/mistral-medium-3.1", + Pricing: Pricing{PromptPrice: 0.4, CompletionPrice: 2, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 262144, OutputTokens: 262144}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Mistral Medium 3.1", Family: "mistral-medium", Knowledge: "2025-05", ReleaseDate: "2025-08-12", LastUpdated: "2025-08-12", OpenWeights: false}, + }, + { + ID: "openrouter/mistralai/mistral-nemo:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 131072}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Mistral Nemo (free)", Family: "mistral-nemo", Knowledge: "2024-07", ReleaseDate: "2024-07-19", LastUpdated: "2024-07-19", OpenWeights: true}, + }, + { + ID: "openrouter/mistralai/mistral-small-3.1-24b-instruct", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Mistral Small 3.1 24B Instruct", Family: "mistral-small", Knowledge: "2024-10", ReleaseDate: "2025-03-17", LastUpdated: "2025-03-17", OpenWeights: true}, + }, + { + ID: "openrouter/mistralai/mistral-small-3.2-24b-instruct", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 96000, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Mistral Small 3.2 24B Instruct", Family: "mistral-small", Knowledge: "2024-10", ReleaseDate: "2025-06-20", LastUpdated: "2025-06-20", OpenWeights: true}, + }, + { + ID: "openrouter/mistralai/mistral-small-3.2-24b-instruct:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 96000, OutputTokens: 96000}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Mistral Small 3.2 24B (free)", Family: "mistral-small", Knowledge: "2025-06", ReleaseDate: "2025-06-20", LastUpdated: "2025-06-20", OpenWeights: true}, + }, + { + ID: "openrouter/moonshotai/kimi-dev-72b:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 131072}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Kimi Dev 72b (free)", Family: "kimi", Knowledge: "2025-06", ReleaseDate: "2025-06-16", LastUpdated: "2025-06-16", OpenWeights: true}, + }, + { + ID: "openrouter/moonshotai/kimi-k2", + Pricing: Pricing{PromptPrice: 0.55, CompletionPrice: 2.2, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Kimi K2", Family: "kimi-k2", Knowledge: "2024-10", ReleaseDate: "2025-07-11", LastUpdated: "2025-07-11", OpenWeights: true}, + }, + { + ID: "openrouter/moonshotai/kimi-k2-0905", + Pricing: Pricing{PromptPrice: 0.6, CompletionPrice: 2.5, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 262144, OutputTokens: 16384}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Kimi K2 Instruct 0905", Family: "kimi-k2", Knowledge: "2024-10", ReleaseDate: "2025-09-05", LastUpdated: "2025-09-05", OpenWeights: true}, + }, + { + ID: "openrouter/moonshotai/kimi-k2-0905:exacto", + Pricing: Pricing{PromptPrice: 0.6, CompletionPrice: 2.5, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 262144, OutputTokens: 16384}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Kimi K2 Instruct 0905 (exacto)", Family: "kimi-k2", Knowledge: "2024-10", ReleaseDate: "2025-09-05", LastUpdated: "2025-09-05", OpenWeights: true}, + }, + { + ID: "openrouter/moonshotai/kimi-k2-thinking", + Pricing: Pricing{PromptPrice: 0.6, CompletionPrice: 2.5, CacheReadPrice: 0.15, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 262144, OutputTokens: 262144}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Kimi K2 Thinking", Family: "kimi-k2", Knowledge: "2024-08", ReleaseDate: "2025-11-06", LastUpdated: "2025-11-06", OpenWeights: true}, + }, + { + ID: "openrouter/moonshotai/kimi-k2:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 32800, OutputTokens: 32800}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Kimi K2 (free)", Family: "kimi-k2", Knowledge: "2025-04", ReleaseDate: "2025-07-11", LastUpdated: "2025-07-11", OpenWeights: true}, + }, + { + ID: "openrouter/nousresearch/deephermes-3-llama-3-8b-preview", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "DeepHermes 3 Llama 3 8B Preview", Family: "llama-3", Knowledge: "2024-04", ReleaseDate: "2025-02-28", LastUpdated: "2025-02-28", OpenWeights: true}, + }, + { + ID: "openrouter/nousresearch/hermes-4-405b", + Pricing: Pricing{PromptPrice: 1, CompletionPrice: 3, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 131072}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Hermes 4 405B", Family: "hermes", Knowledge: "2023-12", ReleaseDate: "2025-08-25", LastUpdated: "2025-08-25", OpenWeights: true}, + }, + { + ID: "openrouter/nousresearch/hermes-4-70b", + Pricing: Pricing{PromptPrice: 0.13, CompletionPrice: 0.4, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 131072}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Hermes 4 70B", Family: "hermes", Knowledge: "2023-12", ReleaseDate: "2025-08-25", LastUpdated: "2025-08-25", OpenWeights: true}, + }, + { + ID: "openrouter/nvidia/nemotron-nano-9b-v2", + Pricing: Pricing{PromptPrice: 0.04, CompletionPrice: 0.16, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 131072}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "nvidia-nemotron-nano-9b-v2", Family: "nemotron", Knowledge: "2024-09", ReleaseDate: "2025-08-18", LastUpdated: "2025-08-18", OpenWeights: true}, + }, + { + ID: "openrouter/openai/gpt-4.1", + Pricing: Pricing{PromptPrice: 2, CompletionPrice: 8, CacheReadPrice: 0.5, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1047576, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-4.1", Family: "gpt-4.1", Knowledge: "2024-04", ReleaseDate: "2025-04-14", LastUpdated: "2025-04-14", OpenWeights: false}, + }, + { + ID: "openrouter/openai/gpt-4.1-mini", + Pricing: Pricing{PromptPrice: 0.4, CompletionPrice: 1.6, CacheReadPrice: 0.1, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1047576, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-4.1 Mini", Family: "gpt-4.1-mini", Knowledge: "2024-04", ReleaseDate: "2025-04-14", LastUpdated: "2025-04-14", OpenWeights: false}, + }, + { + ID: "openrouter/openai/gpt-4o-mini", + Pricing: Pricing{PromptPrice: 0.15, CompletionPrice: 0.6, CacheReadPrice: 0.08, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 16384}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-4o-mini", Family: "gpt-4o-mini", Knowledge: "2024-10", ReleaseDate: "2024-07-18", LastUpdated: "2024-07-18", OpenWeights: false}, + }, + { + ID: "openrouter/openai/gpt-5", + Pricing: Pricing{PromptPrice: 1.25, CompletionPrice: 10, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5", Family: "gpt-5", Knowledge: "2024-10-01", ReleaseDate: "2025-08-07", LastUpdated: "2025-08-07", OpenWeights: false}, + }, + { + ID: "openrouter/openai/gpt-5-chat", + Pricing: Pricing{PromptPrice: 1.25, CompletionPrice: 10, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: false, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5 Chat (latest)", Family: "gpt-5-chat", Knowledge: "2024-09-30", ReleaseDate: "2025-08-07", LastUpdated: "2025-08-07", OpenWeights: false}, + }, + { + ID: "openrouter/openai/gpt-5-codex", + Pricing: Pricing{PromptPrice: 1.25, CompletionPrice: 10, CacheReadPrice: 0.125, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5 Codex", Family: "gpt-5-codex", Knowledge: "2024-10-01", ReleaseDate: "2025-09-15", LastUpdated: "2025-09-15", OpenWeights: false}, + }, + { + ID: "openrouter/openai/gpt-5-image", + Pricing: Pricing{PromptPrice: 5, CompletionPrice: 10, CacheReadPrice: 1.25, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5 Image", Family: "gpt-5", Knowledge: "2024-10-01", ReleaseDate: "2025-10-14", LastUpdated: "2025-10-14", OpenWeights: false}, + }, + { + ID: "openrouter/openai/gpt-5-mini", + Pricing: Pricing{PromptPrice: 0.25, CompletionPrice: 2, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5 Mini", Family: "gpt-5-mini", Knowledge: "2024-10-01", ReleaseDate: "2025-08-07", LastUpdated: "2025-08-07", OpenWeights: false}, + }, + { + ID: "openrouter/openai/gpt-5-nano", + Pricing: Pricing{PromptPrice: 0.05, CompletionPrice: 0.4, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5 Nano", Family: "gpt-5-nano", Knowledge: "2024-10-01", ReleaseDate: "2025-08-07", LastUpdated: "2025-08-07", OpenWeights: false}, + }, + { + ID: "openrouter/openai/gpt-5-pro", + Pricing: Pricing{PromptPrice: 15, CompletionPrice: 120, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 272000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5 Pro", Family: "gpt-5-pro", Knowledge: "2024-09-30", ReleaseDate: "2025-10-06", LastUpdated: "2025-10-06", OpenWeights: false}, + }, + { + ID: "openrouter/openai/gpt-5.1", + Pricing: Pricing{PromptPrice: 1.25, CompletionPrice: 10, CacheReadPrice: 0.125, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5.1", Family: "gpt-5", Knowledge: "2024-09-30", ReleaseDate: "2025-11-13", LastUpdated: "2025-11-13", OpenWeights: false}, + }, + { + ID: "openrouter/openai/gpt-5.1-chat", + Pricing: Pricing{PromptPrice: 1.25, CompletionPrice: 10, CacheReadPrice: 0.125, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 16384}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5.1 Chat", Family: "gpt-5-chat", Knowledge: "2024-09-30", ReleaseDate: "2025-11-13", LastUpdated: "2025-11-13", OpenWeights: false}, + }, + { + ID: "openrouter/openai/gpt-5.1-codex", + Pricing: Pricing{PromptPrice: 1.25, CompletionPrice: 10, CacheReadPrice: 0.125, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5.1-Codex", Family: "gpt-5-codex", Knowledge: "2024-09-30", ReleaseDate: "2025-11-13", LastUpdated: "2025-11-13", OpenWeights: false}, + }, + { + ID: "openrouter/openai/gpt-5.1-codex-mini", + Pricing: Pricing{PromptPrice: 0.25, CompletionPrice: 2, CacheReadPrice: 0.025, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 100000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5.1-Codex-Mini", Family: "gpt-5-codex-mini", Knowledge: "2024-09-30", ReleaseDate: "2025-11-13", LastUpdated: "2025-11-13", OpenWeights: false}, + }, + { + ID: "openrouter/openai/gpt-5.2", + Pricing: Pricing{PromptPrice: 1.75, CompletionPrice: 14, CacheReadPrice: 0.175, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5.2", Family: "gpt-5", Knowledge: "2025-08-31", ReleaseDate: "2025-12-11", LastUpdated: "2025-12-11", OpenWeights: false}, + }, + { + ID: "openrouter/openai/gpt-5.2-chat-latest", + Pricing: Pricing{PromptPrice: 1.75, CompletionPrice: 14, CacheReadPrice: 0.175, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 16384}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5.2 Chat", Family: "gpt-5-chat", Knowledge: "2025-08-31", ReleaseDate: "2025-12-11", LastUpdated: "2025-12-11", OpenWeights: false}, + }, + { + ID: "openrouter/openai/gpt-5.2-pro", + Pricing: Pricing{PromptPrice: 21, CompletionPrice: 168, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 400000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: true, Temperature: false}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT-5.2 Pro", Family: "gpt-5-pro", Knowledge: "2025-08-31", ReleaseDate: "2025-12-11", LastUpdated: "2025-12-11", OpenWeights: false}, + }, + { + ID: "openrouter/openai/gpt-oss-120b", + Pricing: Pricing{PromptPrice: 0.072, CompletionPrice: 0.28, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT OSS 120B", Family: "gpt-oss", Knowledge: "", ReleaseDate: "2025-08-05", LastUpdated: "2025-08-05", OpenWeights: true}, + }, + { + ID: "openrouter/openai/gpt-oss-120b:exacto", + Pricing: Pricing{PromptPrice: 0.05, CompletionPrice: 0.24, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT OSS 120B (exacto)", Family: "gpt-oss", Knowledge: "", ReleaseDate: "2025-08-05", LastUpdated: "2025-08-05", OpenWeights: true}, + }, + { + ID: "openrouter/openai/gpt-oss-20b", + Pricing: Pricing{PromptPrice: 0.05, CompletionPrice: 0.2, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT OSS 20B", Family: "gpt-oss", Knowledge: "", ReleaseDate: "2025-08-05", LastUpdated: "2025-08-05", OpenWeights: true}, + }, + { + ID: "openrouter/openai/gpt-oss-safeguard-20b", + Pricing: Pricing{PromptPrice: 0.075, CompletionPrice: 0.3, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 65536}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GPT OSS Safeguard 20B", Family: "gpt-oss", Knowledge: "", ReleaseDate: "2025-10-29", LastUpdated: "2025-10-29", OpenWeights: false}, + }, + { + ID: "openrouter/openai/o4-mini", + Pricing: Pricing{PromptPrice: 1.1, CompletionPrice: 4.4, CacheReadPrice: 0.28, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 100000}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "o4 Mini", Family: "o4-mini", Knowledge: "2024-06", ReleaseDate: "2025-04-16", LastUpdated: "2025-04-16", OpenWeights: false}, + }, + { + ID: "openrouter/openrouter/sherlock-dash-alpha", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1840000, OutputTokens: 0}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Sherlock Dash Alpha", Family: "sherlock", Knowledge: "2025-11", ReleaseDate: "2025-11-15", LastUpdated: "2025-12-14", OpenWeights: false}, + }, + { + ID: "openrouter/openrouter/sherlock-think-alpha", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 1840000, OutputTokens: 0}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Sherlock Think Alpha", Family: "sherlock", Knowledge: "2025-11", ReleaseDate: "2025-11-15", LastUpdated: "2025-12-14", OpenWeights: false}, + }, + { + ID: "openrouter/qwen/qwen-2.5-coder-32b-instruct", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 32768, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: false, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen2.5 Coder 32B Instruct", Family: "qwen", Knowledge: "2024-10", ReleaseDate: "2024-11-11", LastUpdated: "2024-11-11", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen2.5-vl-32b-instruct:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 8192, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen2.5 VL 32B Instruct (free)", Family: "qwen2.5-vl", Knowledge: "2025-03", ReleaseDate: "2025-03-24", LastUpdated: "2025-03-24", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen2.5-vl-72b-instruct", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 32768, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: false, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen2.5 VL 72B Instruct", Family: "qwen2.5-vl", Knowledge: "2024-10", ReleaseDate: "2025-02-01", LastUpdated: "2025-02-01", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen2.5-vl-72b-instruct:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 32768, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: true, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen2.5 VL 72B Instruct (free)", Family: "qwen2.5-vl", Knowledge: "2025-02", ReleaseDate: "2025-02-01", LastUpdated: "2025-02-01", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen3-14b:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 40960, OutputTokens: 40960}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen3 14B (free)", Family: "qwen3", Knowledge: "2025-04", ReleaseDate: "2025-04-28", LastUpdated: "2025-04-28", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen3-235b-a22b-07-25", + Pricing: Pricing{PromptPrice: 0.15, CompletionPrice: 0.85, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 262144, OutputTokens: 131072}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen3 235B A22B Instruct 2507", Family: "qwen3", Knowledge: "2025-04", ReleaseDate: "2025-04-28", LastUpdated: "2025-07-21", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen3-235b-a22b-07-25:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 262144, OutputTokens: 131072}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen3 235B A22B Instruct 2507 (free)", Family: "qwen3", Knowledge: "2025-04", ReleaseDate: "2025-04-28", LastUpdated: "2025-07-21", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen3-235b-a22b-thinking-2507", + Pricing: Pricing{PromptPrice: 0.078, CompletionPrice: 0.312, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 262144, OutputTokens: 81920}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen3 235B A22B Thinking 2507", Family: "qwen3", Knowledge: "2025-04", ReleaseDate: "2025-07-25", LastUpdated: "2025-07-25", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen3-235b-a22b:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 131072}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen3 235B A22B (free)", Family: "qwen3", Knowledge: "2025-04", ReleaseDate: "2025-04-28", LastUpdated: "2025-04-28", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen3-30b-a3b-instruct-2507", + Pricing: Pricing{PromptPrice: 0.2, CompletionPrice: 0.8, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 262000, OutputTokens: 262000}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen3 30B A3B Instruct 2507", Family: "qwen3", Knowledge: "2025-04", ReleaseDate: "2025-07-29", LastUpdated: "2025-07-29", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen3-30b-a3b-thinking-2507", + Pricing: Pricing{PromptPrice: 0.2, CompletionPrice: 0.8, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 262000, OutputTokens: 262000}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen3 30B A3B Thinking 2507", Family: "qwen3", Knowledge: "2025-04", ReleaseDate: "2025-07-29", LastUpdated: "2025-07-29", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen3-30b-a3b:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 40960, OutputTokens: 40960}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen3 30B A3B (free)", Family: "qwen3", Knowledge: "2025-04", ReleaseDate: "2025-04-28", LastUpdated: "2025-04-28", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen3-32b:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 40960, OutputTokens: 40960}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen3 32B (free)", Family: "qwen3", Knowledge: "2025-04", ReleaseDate: "2025-04-28", LastUpdated: "2025-04-28", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen3-8b:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 40960, OutputTokens: 40960}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen3 8B (free)", Family: "qwen3", Knowledge: "2025-04", ReleaseDate: "2025-04-28", LastUpdated: "2025-04-28", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen3-coder", + Pricing: Pricing{PromptPrice: 0.3, CompletionPrice: 1.2, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 262144, OutputTokens: 66536}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen3 Coder", Family: "qwen3-coder", Knowledge: "2025-04", ReleaseDate: "2025-07-23", LastUpdated: "2025-07-23", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen3-coder-flash", + Pricing: Pricing{PromptPrice: 0.3, CompletionPrice: 1.5, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 66536}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen3 Coder Flash", Family: "qwen3-coder", Knowledge: "2025-04", ReleaseDate: "2025-07-23", LastUpdated: "2025-07-23", OpenWeights: false}, + }, + { + ID: "openrouter/qwen/qwen3-coder:exacto", + Pricing: Pricing{PromptPrice: 0.38, CompletionPrice: 1.53, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen3 Coder (exacto)", Family: "qwen3-coder", Knowledge: "2025-04", ReleaseDate: "2025-07-23", LastUpdated: "2025-07-23", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen3-coder:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 262144, OutputTokens: 66536}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen3 Coder 480B A35B Instruct (free)", Family: "qwen3-coder", Knowledge: "2025-04", ReleaseDate: "2025-07-23", LastUpdated: "2025-07-23", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen3-max", + Pricing: Pricing{PromptPrice: 1.2, CompletionPrice: 6, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 262144, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen3 Max", Family: "qwen3", Knowledge: "", ReleaseDate: "2025-09-05", LastUpdated: "2025-09-05", OpenWeights: false}, + }, + { + ID: "openrouter/qwen/qwen3-next-80b-a3b-instruct", + Pricing: Pricing{PromptPrice: 0.14, CompletionPrice: 1.4, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 262144, OutputTokens: 262144}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen3 Next 80B A3B Instruct", Family: "qwen3", Knowledge: "2025-04", ReleaseDate: "2025-09-11", LastUpdated: "2025-09-11", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwen3-next-80b-a3b-thinking", + Pricing: Pricing{PromptPrice: 0.14, CompletionPrice: 1.4, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 262144, OutputTokens: 262144}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Qwen3 Next 80B A3B Thinking", Family: "qwen3", Knowledge: "2025-04", ReleaseDate: "2025-09-11", LastUpdated: "2025-09-11", OpenWeights: true}, + }, + { + ID: "openrouter/qwen/qwq-32b:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 32768, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "QwQ 32B (free)", Family: "qwq", Knowledge: "2025-03", ReleaseDate: "2025-03-05", LastUpdated: "2025-03-05", OpenWeights: true}, + }, + { + ID: "openrouter/rekaai/reka-flash-3", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 32768, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Reka Flash 3", Family: "reka-flash", Knowledge: "2024-10", ReleaseDate: "2025-03-12", LastUpdated: "2025-03-12", OpenWeights: true}, + }, + { + ID: "openrouter/sarvamai/sarvam-m:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 32768, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Sarvam-M (free)", Family: "sarvam-m", Knowledge: "2025-05", ReleaseDate: "2025-05-25", LastUpdated: "2025-05-25", OpenWeights: true}, + }, + { + ID: "openrouter/thudm/glm-z1-32b:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 32768, OutputTokens: 32768}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GLM Z1 32B (free)", Family: "glm-z1", Knowledge: "2025-04", ReleaseDate: "2025-04-17", LastUpdated: "2025-04-17", OpenWeights: true}, + }, + { + ID: "openrouter/tngtech/deepseek-r1t2-chimera:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 163840, OutputTokens: 163840}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: false, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "DeepSeek R1T2 Chimera (free)", Family: "deepseek-r1", Knowledge: "2025-07", ReleaseDate: "2025-07-08", LastUpdated: "2025-07-08", OpenWeights: true}, + }, + { + ID: "openrouter/x-ai/grok-3", + Pricing: Pricing{PromptPrice: 3, CompletionPrice: 15, CacheReadPrice: 0.75, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Grok 3", Family: "grok-3", Knowledge: "2024-11", ReleaseDate: "2025-02-17", LastUpdated: "2025-02-17", OpenWeights: false}, + }, + { + ID: "openrouter/x-ai/grok-3-beta", + Pricing: Pricing{PromptPrice: 3, CompletionPrice: 15, CacheReadPrice: 0.75, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: false, Reasoning: false, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Grok 3 Beta", Family: "grok-3", Knowledge: "2024-11", ReleaseDate: "2025-02-17", LastUpdated: "2025-02-17", OpenWeights: false}, + }, + { + ID: "openrouter/x-ai/grok-3-mini", + Pricing: Pricing{PromptPrice: 0.3, CompletionPrice: 0.5, CacheReadPrice: 0.075, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Grok 3 Mini", Family: "grok-3", Knowledge: "2024-11", ReleaseDate: "2025-02-17", LastUpdated: "2025-02-17", OpenWeights: false}, + }, + { + ID: "openrouter/x-ai/grok-3-mini-beta", + Pricing: Pricing{PromptPrice: 0.3, CompletionPrice: 0.5, CacheReadPrice: 0.075, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 131072, OutputTokens: 8192}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Grok 3 Mini Beta", Family: "grok-3", Knowledge: "2024-11", ReleaseDate: "2025-02-17", LastUpdated: "2025-02-17", OpenWeights: false}, + }, + { + ID: "openrouter/x-ai/grok-4", + Pricing: Pricing{PromptPrice: 3, CompletionPrice: 15, CacheReadPrice: 0.75, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 256000, OutputTokens: 64000}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Grok 4", Family: "grok-4", Knowledge: "2025-07", ReleaseDate: "2025-07-09", LastUpdated: "2025-07-09", OpenWeights: false}, + }, + { + ID: "openrouter/x-ai/grok-4-fast", + Pricing: Pricing{PromptPrice: 0.2, CompletionPrice: 0.5, CacheReadPrice: 0.05, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 2000000, OutputTokens: 30000}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Grok 4 Fast", Family: "grok-4", Knowledge: "2024-11", ReleaseDate: "2025-08-19", LastUpdated: "2025-08-19", OpenWeights: false}, + }, + { + ID: "openrouter/x-ai/grok-4.1-fast", + Pricing: Pricing{PromptPrice: 0.2, CompletionPrice: 0.5, CacheReadPrice: 0.05, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 2000000, OutputTokens: 30000}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Grok 4.1 Fast", Family: "grok-4", Knowledge: "2024-11", ReleaseDate: "2025-11-19", LastUpdated: "2025-11-19", OpenWeights: false}, + }, + { + ID: "openrouter/x-ai/grok-code-fast-1", + Pricing: Pricing{PromptPrice: 0.2, CompletionPrice: 1.5, CacheReadPrice: 0.02, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 256000, OutputTokens: 10000}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "Grok Code Fast 1", Family: "grok", Knowledge: "2025-08", ReleaseDate: "2025-08-26", LastUpdated: "2025-08-26", OpenWeights: false}, + }, + { + ID: "openrouter/z-ai/glm-4.5", + Pricing: Pricing{PromptPrice: 0.6, CompletionPrice: 2.2, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 96000}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GLM 4.5", Family: "glm-4.5", Knowledge: "2025-04", ReleaseDate: "2025-07-28", LastUpdated: "2025-07-28", OpenWeights: true}, + }, + { + ID: "openrouter/z-ai/glm-4.5-air", + Pricing: Pricing{PromptPrice: 0.2, CompletionPrice: 1.1, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 96000}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GLM 4.5 Air", Family: "glm-4.5-air", Knowledge: "2025-04", ReleaseDate: "2025-07-28", LastUpdated: "2025-07-28", OpenWeights: true}, + }, + { + ID: "openrouter/z-ai/glm-4.5-air:free", + Pricing: Pricing{PromptPrice: 0, CompletionPrice: 0, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 128000, OutputTokens: 96000}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: false, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GLM 4.5 Air (free)", Family: "glm-4.5-air", Knowledge: "2025-04", ReleaseDate: "2025-07-28", LastUpdated: "2025-07-28", OpenWeights: true}, + }, + { + ID: "openrouter/z-ai/glm-4.5v", + Pricing: Pricing{PromptPrice: 0.6, CompletionPrice: 1.8, CacheReadPrice: 0, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 64000, OutputTokens: 16384}, + Capabilities: Capabilities{Attachment: true, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GLM 4.5V", Family: "glm-4.5v", Knowledge: "2025-04", ReleaseDate: "2025-08-11", LastUpdated: "2025-08-11", OpenWeights: true}, + }, + { + ID: "openrouter/z-ai/glm-4.6", + Pricing: Pricing{PromptPrice: 0.6, CompletionPrice: 2.2, CacheReadPrice: 0.11, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GLM 4.6", Family: "glm-4.6", Knowledge: "2025-09", ReleaseDate: "2025-09-30", LastUpdated: "2025-09-30", OpenWeights: true}, + }, + { + ID: "openrouter/z-ai/glm-4.6:exacto", + Pricing: Pricing{PromptPrice: 0.6, CompletionPrice: 1.9, CacheReadPrice: 0.11, CacheWritePrice: 0}, + Limits: Limits{ContextTokens: 200000, OutputTokens: 128000}, + Capabilities: Capabilities{Attachment: false, Reasoning: true, ToolCall: true, StructuredOutput: false, Temperature: true}, + Modalities: Modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: Metadata{Name: "GLM 4.6 (exacto)", Family: "glm-4.6", Knowledge: "2025-09", ReleaseDate: "2025-09-30", LastUpdated: "2025-09-30", OpenWeights: true}, + }, + } + + for _, m := range defaults { + // Ignore errors in init: duplicates are a programmer error. + _ = RegisterModel(m) + } +} diff --git a/internal/modelcatalog/generate_default_models.go b/internal/modelcatalog/generate_default_models.go new file mode 100644 index 0000000..5b7d74b --- /dev/null +++ b/internal/modelcatalog/generate_default_models.go @@ -0,0 +1,272 @@ +//go:build ignore + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "go/format" + "io" + "net/http" + "os" + "sort" + "strings" + "time" +) + +const modelsDevAPIURL = "https://models.dev/api.json" + +type modelsDevProvider struct { + Models map[string]modelsDevModel `json:"models"` +} + +type modelsDevModel struct { + ID string `json:"id"` + Name string `json:"name"` + Family string `json:"family"` + Attachment bool `json:"attachment"` + Reasoning bool `json:"reasoning"` + ToolCall bool `json:"tool_call"` + StructuredOutput bool `json:"structured_output"` + Temperature bool `json:"temperature"` + Knowledge string `json:"knowledge"` + ReleaseDate string `json:"release_date"` + LastUpdated string `json:"last_updated"` + OpenWeights bool `json:"open_weights"` + Cost struct { + Input float64 `json:"input"` + Output float64 `json:"output"` + CacheRead float64 `json:"cache_read"` + } `json:"cost"` + Limit struct { + Context int `json:"context"` + Output int `json:"output"` + } `json:"limit"` +} + +type model struct { + ID string + Aliases []string + Pricing pricing + Limits limits + Capabilities capabilities + Modalities modalities + Metadata metadata +} + +type pricing struct { + PromptPrice float64 + CompletionPrice float64 + CacheReadPrice float64 + CacheWritePrice float64 +} + +type limits struct { + ContextTokens int + OutputTokens int +} + +type capabilities struct { + Attachment bool + Reasoning bool + ToolCall bool + StructuredOutput bool + Temperature bool +} + +type modalities struct { + Input []string + Output []string +} + +type metadata struct { + Name string + Family string + Knowledge string + ReleaseDate string + LastUpdated string + OpenWeights bool +} + +func main() { + root, err := fetchModelsDev(modelsDevAPIURL) + if err != nil { + fatal(err) + } + + models := make([]model, 0, 512) + models = append(models, modelsFromProvider(root, "openai", true)...) + models = append(models, modelsFromProvider(root, "openrouter", false)...) + + // Keep stable ordering. + sort.Slice(models, func(i, j int) bool { return models[i].ID < models[j].ID }) + + // Append mock models (not part of models.dev). + models = append(models, model{ + ID: "mock/gpt-4o-mini", + Pricing: pricing{PromptPrice: 0.15, CompletionPrice: 0.6}, + Limits: limits{ContextTokens: 128000, OutputTokens: 16384}, + Modalities: modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: metadata{Name: "Mock GPT-4o mini", Family: "mock"}, + }) + models = append(models, model{ + ID: "mock/gpt-4o", + Pricing: pricing{PromptPrice: 2.5, CompletionPrice: 10}, + Limits: limits{ContextTokens: 128000, OutputTokens: 16384}, + Modalities: modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: metadata{Name: "Mock GPT-4o", Family: "mock"}, + }) + + sort.Slice(models, func(i, j int) bool { return models[i].ID < models[j].ID }) + + src, err := render(models) + if err != nil { + fatal(err) + } + + if err := os.WriteFile("default_models.go", src, 0o644); err != nil { + fatal(err) + } +} + +func fetchModelsDev(url string) (map[string]modelsDevProvider, error) { + client := &http.Client{Timeout: 45 * time.Second} + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("http.NewRequest: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch %s: %w", url, err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 8*1024)) + return nil, fmt.Errorf("fetch %s: unexpected status %s (%s)", url, resp.Status, strings.TrimSpace(string(b))) + } + + var root map[string]modelsDevProvider + if err := json.NewDecoder(resp.Body).Decode(&root); err != nil { + return nil, fmt.Errorf("decode models.dev api: %w", err) + } + return root, nil +} + +func modelsFromProvider(root map[string]modelsDevProvider, provider string, addAlias bool) []model { + p, ok := root[provider] + if !ok { + fatal(fmt.Errorf("models.dev response missing provider %q", provider)) + } + + out := make([]model, 0, len(p.Models)) + for key, entry := range p.Models { + id := entry.ID + if id == "" { + id = key + } + canonical := provider + "/" + id + + aliases := []string(nil) + if addAlias { + aliases = []string{id} + } + + out = append(out, model{ + ID: canonical, + Aliases: aliases, + Pricing: pricing{ + PromptPrice: entry.Cost.Input, + CompletionPrice: entry.Cost.Output, + CacheReadPrice: entry.Cost.CacheRead, + }, + Limits: limits{ + ContextTokens: entry.Limit.Context, + OutputTokens: entry.Limit.Output, + }, + Capabilities: capabilities{ + Attachment: entry.Attachment, + Reasoning: entry.Reasoning, + ToolCall: entry.ToolCall, + StructuredOutput: entry.StructuredOutput, + Temperature: entry.Temperature, + }, + // Note: modalities are intentionally forced to text-only for now. + Modalities: modalities{Input: []string{"text"}, Output: []string{"text"}}, + Metadata: metadata{ + Name: entry.Name, + Family: entry.Family, + Knowledge: entry.Knowledge, + ReleaseDate: entry.ReleaseDate, + LastUpdated: entry.LastUpdated, + OpenWeights: entry.OpenWeights, + }, + }) + } + + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +func render(models []model) ([]byte, error) { + var b bytes.Buffer + b.WriteString("package modelcatalog\n\n") + b.WriteString("// Code generated by go generate; DO NOT EDIT.\n") + b.WriteString("//\n") + b.WriteString("// Default supported model list.\n") + b.WriteString("//\n") + b.WriteString("// This catalog is authoritative for core.NewLM/dsgo.NewLM.\n") + b.WriteString("//\n") + b.WriteString("// Source of truth for OpenAI/OpenRouter models: https://models.dev/api.json\n") + b.WriteString("// Note: Modalities are forced to text-only for now.\n") + b.WriteString("//go:generate go run ./generate_default_models.go\n\n") + b.WriteString("func init() {\n") + b.WriteString("\tdefaults := []Model{\n") + + for _, m := range models { + fmt.Fprintf(&b, "\t\t{\n") + fmt.Fprintf(&b, "\t\t\tID: %q,\n", m.ID) + if len(m.Aliases) > 0 { + fmt.Fprintf(&b, "\t\t\tAliases: []string{%s},\n", quoteList(m.Aliases)) + } + fmt.Fprintf(&b, "\t\t\tPricing: Pricing{PromptPrice: %v, CompletionPrice: %v, CacheReadPrice: %v, CacheWritePrice: %v},\n", + m.Pricing.PromptPrice, m.Pricing.CompletionPrice, m.Pricing.CacheReadPrice, m.Pricing.CacheWritePrice) + fmt.Fprintf(&b, "\t\t\tLimits: Limits{ContextTokens: %d, OutputTokens: %d},\n", m.Limits.ContextTokens, m.Limits.OutputTokens) + fmt.Fprintf(&b, "\t\t\tCapabilities: Capabilities{Attachment: %v, Reasoning: %v, ToolCall: %v, StructuredOutput: %v, Temperature: %v},\n", + m.Capabilities.Attachment, m.Capabilities.Reasoning, m.Capabilities.ToolCall, m.Capabilities.StructuredOutput, m.Capabilities.Temperature) + fmt.Fprintf(&b, "\t\t\tModalities: Modalities{Input: []string{%s}, Output: []string{%s}},\n", + quoteList(m.Modalities.Input), quoteList(m.Modalities.Output)) + fmt.Fprintf(&b, "\t\t\tMetadata: Metadata{Name: %q, Family: %q, Knowledge: %q, ReleaseDate: %q, LastUpdated: %q, OpenWeights: %v},\n", + m.Metadata.Name, m.Metadata.Family, m.Metadata.Knowledge, m.Metadata.ReleaseDate, m.Metadata.LastUpdated, m.Metadata.OpenWeights) + fmt.Fprintf(&b, "\t\t},\n") + } + + b.WriteString("\t}\n\n") + b.WriteString("\tfor _, m := range defaults {\n") + b.WriteString("\t\t// Ignore errors in init: duplicates are a programmer error.\n") + b.WriteString("\t\t_ = RegisterModel(m)\n") + b.WriteString("\t}\n") + b.WriteString("}\n") + + return format.Source(b.Bytes()) +} + +func quoteList(in []string) string { + if len(in) == 0 { + return "" + } + out := make([]string, 0, len(in)) + for _, v := range in { + out = append(out, fmt.Sprintf("%q", v)) + } + return strings.Join(out, ", ") +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} diff --git a/internal/providers/mock/cost_integration_test.go b/internal/providers/mock/cost_integration_test.go index 28a8c0e..baf207e 100644 --- a/internal/providers/mock/cost_integration_test.go +++ b/internal/providers/mock/cost_integration_test.go @@ -57,7 +57,7 @@ func TestMockHTTP_CostTracking_Generate_WithCollector(t *testing.T) { t.Fatalf("usage.total=%d, want %d", result.Usage.TotalTokens, totalTokens) } - expectedCost := cost.Calculate("mock", "gpt-4o-mini", promptTokens, completionTokens) + expectedCost := cost.Calculate("mock/gpt-4o-mini", promptTokens, completionTokens) if expectedCost <= 0 { t.Fatalf("expected positive pricing for mock/gpt-4o-mini; got %.12f", expectedCost) } @@ -124,7 +124,7 @@ func TestMockHTTP_CostTracking_Stream_WithCollector(t *testing.T) { t.Fatalf("entry.usage.total=%d, want %d", entry.Usage.TotalTokens, totalTokens) } - expectedCost := cost.Calculate("mock", "gpt-4o-mini", promptTokens, completionTokens) + expectedCost := cost.Calculate("mock/gpt-4o-mini", promptTokens, completionTokens) if expectedCost <= 0 { t.Fatalf("expected positive pricing for mock/gpt-4o-mini; got %.12f", expectedCost) } diff --git a/internal/providers/mock/lm.go b/internal/providers/mock/lm.go index 29bff54..498de40 100644 --- a/internal/providers/mock/lm.go +++ b/internal/providers/mock/lm.go @@ -14,12 +14,23 @@ import ( "github.com/assagman/dsgo/internal/core" "github.com/assagman/dsgo/internal/jsonutil" + "github.com/assagman/dsgo/internal/modelcatalog" ) func init() { core.RegisterLM("mock", func(model string) core.LM { return newMockHTTP(model) }) + + // Register mock provider models for testing + mockModels := []modelcatalog.Model{ + {ID: "mock/gpt-4o"}, + {ID: "mock/gpt-4o-mini"}, + {ID: "mock/test-model"}, + } + for _, m := range mockModels { + _ = modelcatalog.RegisterModel(m) + } } const ( diff --git a/internal/providers/openai/lm_test.go b/internal/providers/openai/lm_test.go index 23d5166..55ae694 100644 --- a/internal/providers/openai/lm_test.go +++ b/internal/providers/openai/lm_test.go @@ -498,7 +498,8 @@ func TestOpenAI_Stream_Error(t *testing.T) { func TestOpenAI_InitRegistration(t *testing.T) { t.Setenv("OPENAI_API_KEY", "test-registration-key") - lm, err := core.NewLM(context.Background(), "openai/gpt-4-registration") + // Use a model that's registered in the catalog + lm, err := core.NewLM(context.Background(), "openai/gpt-4o-mini") if err != nil { t.Fatalf("NewLM failed: %v", err) } @@ -506,8 +507,8 @@ func TestOpenAI_InitRegistration(t *testing.T) { t.Fatal("NewLM returned nil for openai provider") } - if lm.Name() != "gpt-4-registration" { - t.Errorf("expected model name gpt-4-registration, got %s", lm.Name()) + if lm.Name() != "gpt-4o-mini" { + t.Errorf("expected model name gpt-4o-mini, got %s", lm.Name()) } } diff --git a/internal/providers/openrouter/lm.go b/internal/providers/openrouter/lm.go index f1aa6f8..087a00a 100644 --- a/internal/providers/openrouter/lm.go +++ b/internal/providers/openrouter/lm.go @@ -24,7 +24,6 @@ import ( // Tool call ID constraints across providers: // - OpenAI: max 40 characters // - Azure: max 64 characters -// - Amazon Bedrock: max 64 characters, pattern [a-zA-Z0-9_-]+ // - Anthropic: pattern ^[a-zA-Z0-9_-]+$ // We use the most restrictive: max 40 chars, alphanumeric + underscore + hyphen only const maxToolCallIDLength = 40 @@ -263,7 +262,6 @@ func (o *openRouter) buildParams(messages []core.Message, options *core.Generate } // Detect provider-specific models that don't support tool_choice: "none" - isAmazonModel := strings.Contains(o.Model, "amazon/") || strings.Contains(o.Model, "bedrock") isZAIModel := strings.Contains(o.Model, "z-ai/") if len(options.Tools) > 0 { @@ -276,7 +274,7 @@ func (o *openRouter) buildParams(messages []core.Message, options *core.Generate if options.ToolChoice != "" && options.ToolChoice != "auto" { if options.ToolChoice == "none" { - if !isAmazonModel && !isZAIModel { + if !isZAIModel { params.ToolChoice = openai.ChatCompletionToolChoiceOptionUnionParam{ OfAuto: openai.String("none"), } @@ -291,9 +289,9 @@ func (o *openRouter) buildParams(messages []core.Message, options *core.Generate } } - // Handle Amazon Bedrock and Z.AI specific requirements + // Handle Z.AI specific requirements hasToolContent := false - if isAmazonModel || isZAIModel { + if isZAIModel { for _, msg := range messages { if msg.Role == "tool" || (msg.Role == "assistant" && len(msg.ToolCalls) > 0) { hasToolContent = true @@ -302,7 +300,7 @@ func (o *openRouter) buildParams(messages []core.Message, options *core.Generate } } - if (isAmazonModel && hasToolContent) || (isZAIModel && len(options.Tools) > 0) { + if isZAIModel && (len(options.Tools) > 0 || hasToolContent) { params.ToolChoice = openai.ChatCompletionToolChoiceOptionUnionParam{ OfAuto: openai.String("auto"), } @@ -348,7 +346,7 @@ func (o *openRouter) convertMessages(messages []core.Message) []openai.ChatCompl assistantMsg := &openai.ChatCompletionAssistantMessageParam{ ToolCalls: toolCalls, } - // Only set content if non-empty - Amazon Bedrock rejects empty text fields + // Only set content if non-empty to avoid empty text field issues if msg.Content != "" { assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{OfString: openai.String(msg.Content)} } diff --git a/internal/providers/openrouter/lm_test.go b/internal/providers/openrouter/lm_test.go index 44ea3b6..e829554 100644 --- a/internal/providers/openrouter/lm_test.go +++ b/internal/providers/openrouter/lm_test.go @@ -400,49 +400,10 @@ func TestOpenRouter_BuildParams(t *testing.T) { }) } -func TestOpenRouter_BuildParams_AmazonToolChoice(t *testing.T) { +func TestOpenRouter_BuildParams_ToolChoice(t *testing.T) { t.Parallel() - t.Run("amazon model does not send tool_choice none", func(t *testing.T) { - lm := &openRouter{Model: "amazon/nova-pro-v1"} - messages := []core.Message{{Role: "user", Content: "test"}} - options := &core.GenerateOptions{ - Tools: []core.Tool{*core.NewTool("test_tool", "A test tool", nil)}, - ToolChoice: "none", - } - params := lm.buildParams(messages, options) - - data, _ := json.Marshal(params) - var m map[string]any - _ = json.Unmarshal(data, &m) - - if _, ok := m["tool_choice"]; ok { - t.Errorf("expected tool_choice to be omitted for Amazon model with 'none', got %v", m["tool_choice"]) - } - }) - - t.Run("amazon model with tool content forces auto", func(t *testing.T) { - lm := &openRouter{Model: "amazon/bedrock-claude"} - messages := []core.Message{ - {Role: "user", Content: "test"}, - {Role: "assistant", ToolCalls: []core.ToolCall{{ID: "call_1", Name: "test_tool", Arguments: map[string]any{}}}}, - {Role: "tool", Content: "result", ToolID: "call_1"}, - } - options := &core.GenerateOptions{ - Tools: []core.Tool{*core.NewTool("test_tool", "A test tool", nil)}, - } - params := lm.buildParams(messages, options) - - data, _ := json.Marshal(params) - var m map[string]any - _ = json.Unmarshal(data, &m) - - if m["tool_choice"] != "auto" { - t.Errorf("expected tool_choice 'auto' for Amazon model with tool content, got %v", m["tool_choice"]) - } - }) - - t.Run("non-amazon model sends tool_choice none", func(t *testing.T) { + t.Run("non-zai model sends tool_choice none", func(t *testing.T) { lm := &openRouter{Model: "openai/gpt-4"} messages := []core.Message{{Role: "user", Content: "test"}} options := &core.GenerateOptions{ @@ -456,7 +417,7 @@ func TestOpenRouter_BuildParams_AmazonToolChoice(t *testing.T) { _ = json.Unmarshal(data, &m) if m["tool_choice"] != "none" { - t.Errorf("expected tool_choice 'none' for non-Amazon model, got %v", m["tool_choice"]) + t.Errorf("expected tool_choice 'none' for non-ZAI model, got %v", m["tool_choice"]) } }) } @@ -745,18 +706,19 @@ func TestInit_RegistersLM(t *testing.T) { t.Parallel() ctx := context.Background() + // Use a model that's registered in the catalog core.Configure( core.WithProvider("openrouter"), - core.WithModel("test-model"), + core.WithModel("google/gemini-2.5-flash"), ) - lm, err := core.NewLM(ctx, "openrouter/test-model") + lm, err := core.NewLM(ctx, "openrouter/google/gemini-2.5-flash") if err != nil { t.Fatalf("expected LM to be created, got error: %v", err) } - if lm.Name() != "test-model" { - t.Errorf("expected model name test-model, got %s", lm.Name()) + if lm.Name() != "google/gemini-2.5-flash" { + t.Errorf("expected model name google/gemini-2.5-flash, got %s", lm.Name()) } }