From ec598584b284b59f241050a08b4a6fa70492324f Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Wed, 2 Sep 2026 14:00:14 -0600 Subject: [PATCH] feat: expose model prices on GET /v1/models Clients that show spend (OpenCode and the like) had no way to read what the proxy bills a model at without an admin key. `include_pricing=1` adds `pricing` (per million units) and `pricing_inherited` to every entry, resolved the way the data plane resolves them, so a client's arithmetic matches the proxy's accounting. Off by default, so the public list keeps its OpenAI shape. The parameter needs an API key or session, like `include_hidden`: prices are operator data, and anonymous callers see none. Price lookup moves out of modelView into modelPricing, shared by the admin view and the list so the two cannot drift. --- docs/guides/pricing.md | 26 ++++++++++ docs/reference/api.md | 2 +- docs/reference/openapi.yaml | 23 +++++++++ internal/server/admin.go | 19 ++++--- internal/server/modelpricing_test.go | 76 ++++++++++++++++++++++++++++ internal/server/openai.go | 23 ++++++--- 6 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 internal/server/modelpricing_test.go diff --git a/docs/guides/pricing.md b/docs/guides/pricing.md index 7bb2ce7..b1e493e 100644 --- a/docs/guides/pricing.md +++ b/docs/guides/pricing.md @@ -8,6 +8,7 @@ Examples assume: ```bash P=http://127.0.0.1:4000 ADMIN=lp_... # an admin key +KEY=lp_... # any key ``` ## Price one model @@ -98,6 +99,31 @@ Read the active feed with `GET /admin/v1/pricing`; it returns the version `LLMPROXY_PRICING_FILE` loads a feed at startup, and stores it only when its `version` differs from the active one. +## Let clients read the prices + +`GET /v1/models?include_pricing=1` returns the prices with the model list, so +a client that shows spend (OpenCode, or any tool that prices its own requests) +reads them from the proxy instead of carrying its own table. It needs an API +key or a session; the plain list stays public and unpriced. + +```bash +curl -s "$P/v1/models?include_pricing=1" -H "authorization: Bearer $KEY" +``` + +```json +{ + "id": "qwen-72b", + "object": "model", + "owned_by": "vllm-1", + "pricing": {"input_tokens": 0.4, "output_tokens": 1.2}, + "pricing_inherited": false +} +``` + +Prices are per million units and resolved the way the data plane resolves +them, so what a client multiplies by matches what the proxy bills. A model +with no price for a unit simply has no entry for it. + ## Unpriced is not free A `(model, unit)` pair with no entry is recorded as unpriced, never as zero: diff --git a/docs/reference/api.md b/docs/reference/api.md index 3c8d683..784774e 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -11,7 +11,7 @@ The full machine-readable spec is in [openapi.yaml](openapi.yaml). |---|---|---| | `POST /v1/chat/completions` | **Supported** | Unary JSON and SSE streaming. Vision content parts, tool/function calls, response_format, logprobs etc. pass through untouched. Capability `chat` (+ `chat_stream` for streaming). | | `POST /v1/completions` | **Supported** | Legacy text completions, unary and streamed. Capability `completions`. | -| `GET /v1/models` | **Supported** | Aliases on enabled providers, minus the hidden ones; `?endpoint=chat\|embeddings\|...` filters by capability. `?include_hidden=1` adds hidden models and needs an API key or session (401 without one). Each entry carries the OpenAI fields plus `capabilities` (resolved), `alias_of` (the model this name points at, or `null`) and `hidden`. Public, no API key required. | +| `GET /v1/models` | **Supported** | Aliases on enabled providers, minus the hidden ones; `?endpoint=chat\|embeddings\|...` filters by capability. `?include_hidden=1` adds hidden models and needs an API key or session (401 without one). `?include_pricing=1` adds `pricing` (per million units, the prices in force) and `pricing_inherited`, and needs an API key or session too. Each entry carries the OpenAI fields plus `capabilities` (resolved), `alias_of` (the model this name points at, or `null`) and `hidden`. Public, no API key required. | | `GET /v1/models/{id}` | Not yet | Trivial to add; nothing has needed it. | | `POST /v1/embeddings` | **Supported** | Unary passthrough. Capability `embeddings`. Array `input` is capped at `LLMPROXY_MAX_EMBEDDING_BATCH` items (default 2048); larger batches get 400 `embedding_batch_too_large`. | | `POST /v1/audio/transcriptions` | Planned | Streamed multipart, no disk spill; `audio_seconds` unit reserved. | diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 6d10aed..b69ede8 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -273,6 +273,19 @@ components: Whether the model is kept off this list. Only ever true when the list was fetched with `include_hidden=1`. An extension to the OpenAI shape. + pricing: + $ref: '#/components/schemas/ModelPricing' + description: > + Prices in force for this model, per million units, present only + when the list was fetched with `include_pricing=1`. Units with no + price are left out: unpriced is not zero. An extension to the + OpenAI shape. + pricing_inherited: + type: boolean + description: > + True when a price in force is keyed on another name (the model + this one points at, or the upstream name) rather than this one. + Present only with `include_pricing=1`. ModelList: type: object @@ -1167,6 +1180,16 @@ paths: schema: type: string enum: ['1'] + - name: include_pricing + in: query + description: > + Set to `1` to add `pricing` and `pricing_inherited` to every + entry: the prices the proxy bills this model at, per million + units, resolved the way the data plane resolves them. Requires + authentication (API key or session); 401 without one. + schema: + type: string + enum: ['1'] responses: '200': description: Model list. diff --git a/internal/server/admin.go b/internal/server/admin.go index eeb442a..ee2bb8d 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -364,12 +364,11 @@ func perMillion(price float64) float64 { return math.Round(price*1_000_000*1e6) / 1e6 } -// modelView renders a binding with the prices that apply to it. Prices live in -// the versioned feed keyed on (model, unit); the model can be priced under its -// alias or, inherited, under the upstream name it points at. -func modelView(b *store.ModelBinding, idx *pricing.Index) map[string]any { - caps := strings.Split(b.CapabilitySet, ",") - sort.Strings(caps) +// modelPricing returns the prices that apply to a binding, per million units, +// and whether any of them is inherited. Prices live in the versioned feed keyed +// on (model, unit); the model can be priced under its alias or, inherited, +// under the name it points at or the upstream name. +func modelPricing(b *store.ModelBinding, idx *pricing.Index) (map[string]float64, bool) { prices := make(map[string]float64) inherited := false for unit := range pricing.ValidUnits { @@ -382,6 +381,14 @@ func modelView(b *store.ModelBinding, idx *pricing.Index) map[string]any { inherited = true } } + return prices, inherited +} + +// modelView renders a binding with the prices that apply to it. +func modelView(b *store.ModelBinding, idx *pricing.Index) map[string]any { + caps := strings.Split(b.CapabilitySet, ",") + sort.Strings(caps) + prices, inherited := modelPricing(b, idx) view := map[string]any{ "alias": b.Alias, "provider": b.ProviderName, diff --git a/internal/server/modelpricing_test.go b/internal/server/modelpricing_test.go new file mode 100644 index 0000000..0d01203 --- /dev/null +++ b/internal/server/modelpricing_test.go @@ -0,0 +1,76 @@ +package server_test + +import ( + "testing" +) + +// include_pricing puts the prices the proxy bills at on the caller-facing +// list, so a client can show what a request cost without an admin key. +func TestModelListIncludesPricing(t *testing.T) { + e := newEnv(t) + + resp, body := e.request(t, "PATCH", "/admin/v1/models/alpha", e.adminKey, map[string]any{ + "pricing": map[string]any{"input_tokens": 0.4, "output_tokens": 1.2}, + }) + if resp.StatusCode != 200 { + t.Fatalf("price the model: %d %s", resp.StatusCode, body) + } + + // Off by default: the list keeps its OpenAI shape unless asked. + if _, ok := modelEntries(t, mustList(t, e, "/v1/models", ""))["alpha"]["pricing"]; ok { + t.Fatalf("pricing must not appear without include_pricing") + } + + entries := modelEntries(t, mustList(t, e, "/v1/models?include_pricing=1", e.memberKey)) + prices, ok := entries["alpha"]["pricing"].(map[string]any) + if !ok { + t.Fatalf("no pricing on the entry: %v", entries["alpha"]) + } + if prices["input_tokens"] != 0.4 || prices["output_tokens"] != 1.2 { + t.Fatalf("wrong prices: %v", prices) + } + if entries["alpha"]["pricing_inherited"] != false { + t.Fatalf("prices set on the alias are not inherited: %v", entries["alpha"]) + } + + // An unpriced model carries an empty set, never a zero price. + unpriced, ok := entries["slow"]["pricing"].(map[string]any) + if !ok || len(unpriced) != 0 { + t.Fatalf("unpriced model must list no prices: %v", entries["slow"]) + } +} + +// Prices are operator data, so the parameter costs an identity, like +// include_hidden. +func TestIncludePricingNeedsAuth(t *testing.T) { + e := newEnv(t) + + resp, body := e.request(t, "GET", "/v1/models?include_pricing=1", "", nil) + if resp.StatusCode != 401 { + t.Fatalf("include_pricing must require auth: %d %s", resp.StatusCode, body) + } +} + +// A name for another model lists the prices in force for it, flagged as +// inherited, the same resolution the data plane bills with. +func TestModelListPricingFollowsInheritance(t *testing.T) { + e := newEnv(t) + + resp, body := e.request(t, "PATCH", "/admin/v1/models/alpha", e.adminKey, map[string]any{ + "pricing": map[string]any{"input_tokens": 0.4}, + }) + if resp.StatusCode != 200 { + t.Fatalf("price the target: %d %s", resp.StatusCode, body) + } + resp, body = e.request(t, "POST", "/admin/v1/models", e.adminKey, + map[string]any{"alias": "quick", "target": "alpha"}) + if resp.StatusCode != 201 { + t.Fatalf("create alias: %d %s", resp.StatusCode, body) + } + + entry := modelEntries(t, mustList(t, e, "/v1/models?include_pricing=1", e.memberKey))["quick"] + if entry["pricing"].(map[string]any)["input_tokens"] != 0.4 || + entry["pricing_inherited"] != true { + t.Fatalf("alias did not inherit the target's price: %v", entry) + } +} diff --git a/internal/server/openai.go b/internal/server/openai.go index 94fe289..6ea9736 100644 --- a/internal/server/openai.go +++ b/internal/server/openai.go @@ -27,16 +27,18 @@ func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { "'endpoint' must be one of %v", catalog.Capabilities)) return } - // The list is public, so hidden models are only ever named to a caller who - // asked for them and proved who they are. Hiding is decluttering, not - // access control: hidden models stay callable either way. + // The list is public, so hidden models and prices are only ever shown to a + // caller who asked for them and proved who they are. Hiding is + // decluttering, not access control: hidden models stay callable either way. includeHidden := r.URL.Query().Get("include_hidden") == "1" - if includeHidden { + includePricing := r.URL.Query().Get("include_pricing") == "1" + if includeHidden || includePricing { if _, perr := s.authenticate(r); perr != nil { writeProxyError(w, perr) return } } + idx := s.pricing.Load() bindings, err := s.store.ListServableBindings(r.Context(), includeHidden) if err != nil { writeProxyError(w, apierr.New(500, "internal_error", "failed to list models")) @@ -58,7 +60,7 @@ func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { if b.TargetAlias != "" { aliasOf = b.TargetAlias } - data = append(data, map[string]any{ + entry := map[string]any{ "id": b.Alias, "object": "model", "created": created, @@ -66,7 +68,16 @@ func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { "capabilities": splitCapabilitySet(b.CapabilitySet), "alias_of": aliasOf, "hidden": b.Hidden, - }) + } + // Prices are what the proxy bills this model at, per million units, so + // a client can show a request's cost without a second call. Units with + // no price are left out: unpriced is not zero. + if includePricing { + prices, inherited := modelPricing(&b, idx) + entry["pricing"] = prices + entry["pricing_inherited"] = inherited + } + data = append(data, entry) } writeJSON(w, 200, map[string]any{"object": "list", "data": data}) }