diff --git a/cmd/olliesrv/internal/backend/openai.go b/cmd/olliesrv/internal/backend/openai.go index e0214d7..8b366d3 100644 --- a/cmd/olliesrv/internal/backend/openai.go +++ b/cmd/olliesrv/internal/backend/openai.go @@ -29,8 +29,10 @@ type openAIModelInfo struct { ContextLength int `json:"context_length"` // OpenRouter pricing fields (per-token, as string) Pricing *struct { - Prompt string `json:"prompt"` - Completion string `json:"completion"` + Prompt string `json:"prompt"` + Completion string `json:"completion"` + InputCacheRead string `json:"input_cache_read"` + InputCacheWrite string `json:"input_cache_write"` } `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 { 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 } } diff --git a/cmd/olliesrv/internal/backend/openai_test.go b/cmd/olliesrv/internal/backend/openai_test.go index 56ea3d2..202d29d 100644 --- a/cmd/olliesrv/internal/backend/openai_test.go +++ b/cmd/olliesrv/internal/backend/openai_test.go @@ -505,8 +505,10 @@ func TestModelsInfo_ParsesPricing(t *testing.T) { "id": "test-model", "context_length": 128000, "pricing": map[string]string{ - "prompt": "0.000003", - "completion": "0.000015", + "prompt": "0.000003", + "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 { 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 if info[1].ID != "no-pricing-model" { diff --git a/cmd/olliesrv/internal/fs/cache.go b/cmd/olliesrv/internal/fs/cache.go index 17bc237..3e8e8eb 100644 --- a/cmd/olliesrv/internal/fs/cache.go +++ b/cmd/olliesrv/internal/fs/cache.go @@ -89,6 +89,39 @@ func writeModelLine(sb *strings.Builder, backendName, modelID string, pricing *b 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. // Returns "-" if the value is zero. Appends "(e)" if estimated is true and value is non-zero. func formatPricingWithEstimate(perToken float64, estimated bool) string { diff --git a/cmd/olliesrv/internal/fs/cache_test.go b/cmd/olliesrv/internal/fs/cache_test.go index c688a64..b86157e 100644 --- a/cmd/olliesrv/internal/fs/cache_test.go +++ b/cmd/olliesrv/internal/fs/cache_test.go @@ -1,6 +1,90 @@ 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) { tests := []struct { diff --git a/cmd/olliesrv/internal/fs/spec.go b/cmd/olliesrv/internal/fs/spec.go index 7e6f720..2dc1d87 100644 --- a/cmd/olliesrv/internal/fs/spec.go +++ b/cmd/olliesrv/internal/fs/spec.go @@ -1000,10 +1000,12 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl } return nil, nil } + name := strings.Join(args, " ") 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) { return []byte(s.CachedListModels() + "\n"), nil