fs: show OpenRouter cache pricing for selected models

This commit is contained in:
Ollie Agent 2026-09-05 11:56:04 +02:00
parent 3ca84487c5
commit 2e30b0ddab
5 changed files with 143 additions and 8 deletions

View File

@ -29,8 +29,10 @@ type openAIModelInfo struct {
ContextLength int `json:"context_length"` ContextLength int `json:"context_length"`
// OpenRouter pricing fields (per-token, as string) // OpenRouter pricing fields (per-token, as string)
Pricing *struct { Pricing *struct {
Prompt string `json:"prompt"` Prompt string `json:"prompt"`
Completion string `json:"completion"` Completion string `json:"completion"`
InputCacheRead string `json:"input_cache_read"`
InputCacheWrite string `json:"input_cache_write"`
} `json:"pricing,omitempty"` } `json:"pricing,omitempty"`
} }
@ -124,7 +126,13 @@ func (b *OpenAIBackend) ModelsInfo(ctx context.Context) []ModelInfo {
if v, err := strconv.ParseFloat(m.Pricing.Completion, 64); err == nil { if v, err := strconv.ParseFloat(m.Pricing.Completion, 64); err == nil {
pricing.Output = v pricing.Output = v
} }
if pricing.Input > 0 || pricing.Output > 0 { if v, err := strconv.ParseFloat(m.Pricing.InputCacheRead, 64); err == nil {
pricing.CacheRead = v
}
if v, err := strconv.ParseFloat(m.Pricing.InputCacheWrite, 64); err == nil {
pricing.CacheWrite = v
}
if pricing.Input > 0 || pricing.Output > 0 || pricing.CacheRead > 0 || pricing.CacheWrite > 0 {
info[i].Pricing = &pricing info[i].Pricing = &pricing
} }
} }

View File

@ -505,8 +505,10 @@ func TestModelsInfo_ParsesPricing(t *testing.T) {
"id": "test-model", "id": "test-model",
"context_length": 128000, "context_length": 128000,
"pricing": map[string]string{ "pricing": map[string]string{
"prompt": "0.000003", "prompt": "0.000003",
"completion": "0.000015", "completion": "0.000015",
"input_cache_read": "0.0000003",
"input_cache_write": "0.00000375",
}, },
}, },
{ {
@ -541,6 +543,12 @@ func TestModelsInfo_ParsesPricing(t *testing.T) {
if info[0].Pricing.Output != 0.000015 { if info[0].Pricing.Output != 0.000015 {
t.Errorf("Output = %v, want 0.000015", info[0].Pricing.Output) t.Errorf("Output = %v, want 0.000015", info[0].Pricing.Output)
} }
if info[0].Pricing.CacheRead != 0.0000003 {
t.Errorf("CacheRead = %v, want 0.0000003", info[0].Pricing.CacheRead)
}
if info[0].Pricing.CacheWrite != 0.00000375 {
t.Errorf("CacheWrite = %v, want 0.00000375", info[0].Pricing.CacheWrite)
}
// Second model has no pricing // Second model has no pricing
if info[1].ID != "no-pricing-model" { if info[1].ID != "no-pricing-model" {

View File

@ -89,6 +89,39 @@ func writeModelLine(sb *strings.Builder, backendName, modelID string, pricing *b
sb.WriteByte('\n') sb.WriteByte('\n')
} }
// modelPricingBlock renders a model name followed by its per-million-token
// USD pricing, in the format returned by the agent ctl "model" command.
// Resolution mirrors ModelCache.refresh: API-provided pricing (ModelLister)
// first, then the static estimate table. Returns name-only when no pricing
// is known.
func modelPricingBlock(be backend.Backend, modelID string) string {
var sb strings.Builder
sb.WriteString(modelID)
sb.WriteByte('\n')
var pricing *backend.ModelPricing
if lister, ok := be.(backend.ModelLister); ok {
for _, m := range lister.ModelsInfo(context.Background()) {
if m.ID == modelID {
pricing = m.Pricing
break
}
}
}
if pricing == nil {
pricing = backend.LookupStaticPricing(modelID)
}
if pricing == nil {
return sb.String()
}
fmt.Fprintf(&sb, "In/Mtok: %s\n", formatPricingWithEstimate(pricing.Input, pricing.Estimated))
fmt.Fprintf(&sb, "Out/Mtok: %s\n", formatPricingWithEstimate(pricing.Output, pricing.Estimated))
fmt.Fprintf(&sb, "CacheRead/Mtok: %s\n", formatPricingWithEstimate(pricing.CacheRead, pricing.Estimated))
fmt.Fprintf(&sb, "CacheWrite/Mtok: %s\n", formatPricingWithEstimate(pricing.CacheWrite, pricing.Estimated))
return sb.String()
}
// formatPricingWithEstimate formats per-token cost as per-million-tokens USD string. // formatPricingWithEstimate formats per-token cost as per-million-tokens USD string.
// Returns "-" if the value is zero. Appends "(e)" if estimated is true and value is non-zero. // Returns "-" if the value is zero. Appends "(e)" if estimated is true and value is non-zero.
func formatPricingWithEstimate(perToken float64, estimated bool) string { func formatPricingWithEstimate(perToken float64, estimated bool) string {

View File

@ -1,6 +1,90 @@
package fs package fs
import "testing" import (
"context"
"fmt"
"testing"
"ollie/cmd/olliesrv/internal/backend"
)
// stubCtlBackend satisfies backend.Backend for pricing-block tests.
type stubCtlBackend struct{}
func (stubCtlBackend) ChatStream(context.Context, []backend.Message, []backend.Tool, backend.GenerationParams) (<-chan backend.StreamEvent, error) {
return nil, fmt.Errorf("not implemented")
}
func (stubCtlBackend) Name() string { return "stub" }
func (stubCtlBackend) DefaultModel() string { return "stub-model" }
func (stubCtlBackend) Model() string { return "stub-model" }
func (stubCtlBackend) SetModel(string) {}
func (stubCtlBackend) ContextLength(context.Context) int { return 0 }
func (stubCtlBackend) Models(context.Context) []string { return nil }
// stubCtlLister adds API-provided pricing for one exact model ID.
type stubCtlLister struct{ stubCtlBackend }
func (stubCtlLister) ModelsInfo(context.Context) []backend.ModelInfo {
return []backend.ModelInfo{
{ID: "exact-model", Pricing: &backend.ModelPricing{
Input: 2.5 / 1_000_000, Output: 10.0 / 1_000_000, CacheRead: 1.25 / 1_000_000,
}},
}
}
func TestModelPricingBlock(t *testing.T) {
tests := []struct {
name string
be backend.Backend
id string
want string
}{
{
name: "api_pricing_exact_match",
be: stubCtlLister{},
id: "exact-model",
want: "exact-model\n" +
"In/Mtok: $2.50\n" +
"Out/Mtok: $10.00\n" +
"CacheRead/Mtok: $1.25\n" +
"CacheWrite/Mtok: -\n",
},
{
name: "api_pricing_miss_falls_back_to_static",
be: stubCtlLister{},
id: "claude-sonnet-4-5",
want: "claude-sonnet-4-5\n" +
"In/Mtok: $3.00(e)\n" +
"Out/Mtok: $15.00(e)\n" +
"CacheRead/Mtok: $0.3000(e)\n" +
"CacheWrite/Mtok: $3.75(e)\n",
},
{
name: "static_pricing_no_lister",
be: stubCtlBackend{},
id: "gpt-4o",
want: "gpt-4o\n" +
"In/Mtok: $2.50(e)\n" +
"Out/Mtok: $10.00(e)\n" +
"CacheRead/Mtok: $1.25(e)\n" +
"CacheWrite/Mtok: -\n",
},
{
name: "no_pricing_known",
be: stubCtlBackend{},
id: "totally-unknown-model",
want: "totally-unknown-model\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := modelPricingBlock(tt.be, tt.id)
if got != tt.want {
t.Errorf("modelPricingBlock(%q) =\n%q\nwant\n%q", tt.id, got, tt.want)
}
})
}
}
func TestFormatPricingWithEstimate(t *testing.T) { func TestFormatPricingWithEstimate(t *testing.T) {
tests := []struct { tests := []struct {

View File

@ -1000,10 +1000,12 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
} }
return nil, nil return nil, nil
} }
name := strings.Join(args, " ")
if be := a.Backend(); be != nil { if be := a.Backend(); be != nil {
be.SetModel(strings.Join(args, " ")) be.SetModel(name)
return []byte(modelPricingBlock(be, name)), nil
} }
return []byte(strings.Join(args, " ") + "\n"), nil return []byte(name + "\n"), nil
}, },
"models": func(_ []string) ([]byte, error) { "models": func(_ []string) ([]byte, error) {
return []byte(s.CachedListModels() + "\n"), nil return []byte(s.CachedListModels() + "\n"), nil