Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/guides/pricing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
23 changes: 23 additions & 0 deletions docs/reference/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 13 additions & 6 deletions internal/server/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
76 changes: 76 additions & 0 deletions internal/server/modelpricing_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
23 changes: 17 additions & 6 deletions internal/server/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -58,15 +60,24 @@ 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,
"owned_by": b.ProviderName,
"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})
}
Expand Down
Loading