From b5044084085ac19a5118da003375599e68a8fbf1 Mon Sep 17 00:00:00 2001 From: Dean Eckert Date: Tue, 24 Mar 2026 09:32:29 +0100 Subject: [PATCH 1/3] feat(pricing): added staged pricing depending on context length Signed-off-by: Dean Eckert --- .gitignore | 2 + api/entry.go | 1 - api/graph.go | 185 ++++---- api/graph_fix_test.go | 20 +- api/graph_test.go | 19 +- apiproxy/proxy.go | 35 +- apiproxy/response_model_snapshot_test.go | 8 +- app/drizzle/schema.ts | 26 +- app/src/app/admin/costs/costs-client.tsx | 98 ++-- app/src/app/admin/costs/costs-table.tsx | 197 ++++---- app/src/server/api/routers/admin.ts | 82 +++- app/src/server/api/routers/api-key.ts | 214 ++++----- app/src/server/api/routers/reporting.ts | 437 ++++++++---------- app/src/server/backendName.js | 9 + app/src/server/backendName.test.js | 15 + app/src/server/costAggregation.js | 185 ++++++++ app/src/server/costAggregation.test.js | 88 ++++ app/src/server/costStageResolver.ts | 355 ++++++++++++++ app/src/server/db/schema.ts | 38 +- costs/azureCostCollector.go | 4 - db/cost_stage_resolver.go | 321 +++++++++++++ db/cost_stage_resolver_test.go | 258 +++++++++++ db/database.go | 191 +++++++- db/database_write_costs_test.go | 146 ++++++ ...ation_drop_backend_pricing_columns_test.go | 328 +++++++++++++ ...100000_costs_add_context_length_stages.sql | 60 +++ ...0_costs_restore_natural_key_uniqueness.sql | 37 ++ ...324120000_drop_backend_pricing_columns.sql | 55 +++ db/migrations/atlas.sum | 5 +- db/schema.sql | 25 +- go.mod | 1 + go.sum | 3 + local-dev/README.md | 22 +- local-dev/app.env.example | 9 + local-dev/docker-compose.yml | 55 ++- 35 files changed, 2798 insertions(+), 736 deletions(-) create mode 100644 app/src/server/backendName.js create mode 100644 app/src/server/backendName.test.js create mode 100644 app/src/server/costAggregation.js create mode 100644 app/src/server/costAggregation.test.js create mode 100644 app/src/server/costStageResolver.ts create mode 100644 db/cost_stage_resolver.go create mode 100644 db/cost_stage_resolver_test.go create mode 100644 db/database_write_costs_test.go create mode 100644 db/migration_drop_backend_pricing_columns_test.go create mode 100644 db/migrations/20260323100000_costs_add_context_length_stages.sql create mode 100644 db/migrations/20260324110000_costs_restore_natural_key_uniqueness.sql create mode 100644 db/migrations/20260324120000_drop_backend_pricing_columns.sql diff --git a/.gitignore b/.gitignore index 9348aed..709af06 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ vendor/ .direnv .idea + +local-dev/postgres-data-fresh diff --git a/api/entry.go b/api/entry.go index 22fc95e..477fb31 100644 --- a/api/entry.go +++ b/api/entry.go @@ -49,7 +49,6 @@ func (a *ApiHandler) CreateEntry(w http.ResponseWriter, r *http.Request) { UUID: uuid, ApiKey: a.hashToken(apikey), Owner: claims.Sub, - AiApi: r.Form.Get("apitype"), Description: r.Form.Get("beschreibung"), } a.db.WriteEntry(&h) diff --git a/api/graph.go b/api/graph.go index be3da25..d46835e 100644 --- a/api/graph.go +++ b/api/graph.go @@ -9,6 +9,7 @@ import ( "net/url" co "openai-api-proxy/costs" db "openai-api-proxy/db" + "sort" "strings" "time" @@ -19,6 +20,8 @@ import ( type GraphHandler struct { a *ApiHandler cache []Cache + // Per-model cost rows cache to avoid repeated DB lookups while resolving per-request costs. + costsCache map[string][]db.Costs } // if basecount, filter and DB Rows missmatch, Cache will be updated @@ -32,7 +35,7 @@ type Cache struct { func NewGraphHandler(a *ApiHandler) *GraphHandler { var cache []Cache - return &GraphHandler{a, cache} + return &GraphHandler{a, cache, make(map[string][]db.Costs)} } // Generate Token Graphs for API Key and Admin overview and allow to filter by timeframes @@ -188,10 +191,10 @@ func (g *GraphHandler) RenderGraph(gr *Graph) { Estimated: td.isEstimated, Unit: getUnits()[gr.unit], } - if err := t.Execute(gr.w, snippetData); err != nil { - log.Println("Error Templating Chart", err) - return - } + if err := t.Execute(gr.w, snippetData); err != nil { + log.Println("Error Templating Chart", err) + return + } } // This handles the request and the formatting of the data @@ -252,56 +255,77 @@ func (g *GraphHandler) GetTableGraphData(gr *Graph) []db.RequestSummary { // lookup Data in DB func (g *GraphHandler) LookupTableGraphData(gr *Graph) []db.RequestSummary { - data, err := g.a.db.LookupApiKeyUserStats(gr.key, gr.kind, gr.filter, gr.overwriteDateTrunc) - if err != nil && data != nil { + // Tokens graphs are allowed to use aggregated token totals. + if !gr.overwriteDateTrunc { + data, err := g.a.db.LookupApiKeyUserStats(gr.key, gr.kind, gr.filter, gr.overwriteDateTrunc) + if err != nil && data != nil { + log.Println(err) + http.Error(gr.w, "Could not get Data from DB for User "+string(gr.key), 500) + return nil + } + return data + } + + // Cost graphs must be resolved per request so stage selection can use request.input_token_count. + requestRows, err := g.a.db.LookupApiKeyUserRequests(gr.key, gr.kind, gr.filter) + if err != nil { log.Println(err) http.Error(gr.w, "Could not get Data from DB for User "+string(gr.key), 500) return nil } - // group by original dateTrunc after calculating costs - if gr.overwriteDateTrunc { - tm := db.GetFilterTruncMap() + tm := db.GetFilterTruncMap() + granularity := tm[gr.filter] + if granularity == "" { + // Default to day buckets for filters that are not explicitly listed. + granularity = "day" + } - dates := make(map[time.Time]db.RequestSummary) - var timeRange time.Time - for i, entry := range data { - entry.Cost, entry.IsEstimated = g.GetCost(entry) - log.Printf("Entry %v", i) - y, m, d := entry.RequestTime.Date() - switch tm[gr.filter] { - case "hour": - timeRange = time.Date(y, m, d, entry.RequestTime.Hour(), 0, 0, 0, time.UTC) - log.Println("matched hour") - case "day": - timeRange = time.Date(y, m, d, 0, 0, 0, 0, time.UTC) - log.Println("matched day") - case "month": - timeRange = time.Date(y, m, 0, 0, 0, 0, 0, time.UTC) - log.Println("matched month") - } - if dates[timeRange].ID == "" { - dates[timeRange] = entry - continue - } + dates := make(map[time.Time]db.RequestSummary) - if trunced, ok := dates[timeRange]; ok { - log.Printf("Merge Costs with %v + %v", trunced.Cost, entry.Cost) - bigT := trunced.Cost * co.MoneyUnit - bigE := entry.Cost * co.MoneyUnit - bigMoney := bigE + bigT - trunced.Cost = bigMoney / co.MoneyUnit - dates[timeRange] = trunced - } + for i, entry := range requestRows { + entry.Cost, entry.IsEstimated = g.GetCost(entry) + + y, m, d := entry.RequestTime.Date() + var timeRange time.Time + switch granularity { + case "hour": + timeRange = time.Date(y, m, d, entry.RequestTime.Hour(), 0, 0, 0, time.UTC) + case "day": + timeRange = time.Date(y, m, d, 0, 0, 0, 0, time.UTC) + case "month": + timeRange = time.Date(y, m, 0, 0, 0, 0, 0, time.UTC) + default: + timeRange = time.Date(y, m, d, 0, 0, 0, 0, time.UTC) } - var costData []db.RequestSummary - for _, trunced := range dates { - costData = append(costData, trunced) + + // Ensure the chart axis uses the bucket boundary, not the raw request timestamp. + entry.RequestTime = timeRange + + if existing, ok := dates[timeRange]; !ok || existing.ID == "" { + dates[timeRange] = entry + continue } - data = costData + + trunced := dates[timeRange] + bigT := trunced.Cost * co.MoneyUnit + bigE := entry.Cost * co.MoneyUnit + bigMoney := bigE + bigT + trunced.Cost = bigMoney / co.MoneyUnit + trunced.IsEstimated = trunced.IsEstimated || entry.IsEstimated + dates[timeRange] = trunced + + log.Printf("Entry %v", i) } - return data + costData := make([]db.RequestSummary, 0, len(dates)) + for _, trunced := range dates { + costData = append(costData, trunced) + } + sort.Slice(costData, func(i, j int) bool { + return costData[i].RequestTime.Before(costData[j].RequestTime) + }) + return costData } func (g *GraphHandler) GetCost(row db.RequestSummary) (float64, bool) { @@ -379,57 +403,40 @@ func (g *GraphHandler) SetTableGraphData(gr *Graph, d []db.RequestSummary) (*Tab func (g *GraphHandler) getCostData(dbs db.RequestSummary) (totalCosts int, estimated bool) { // Delegate to helper so tests can invoke the same logic without DB access. - costs := g.a.db.LookupCosts(dbs.Model) + costs, ok := g.costsCache[dbs.Model] + if !ok { + costs = g.a.db.LookupCosts(dbs.Model) + g.costsCache[dbs.Model] = costs + } return computeCosts(costs, dbs) } // computeCosts computes the total cost (in smallest money unit) for a request -// given a list of recorded costs. This mirrors the logic that was previously -// embedded in getCostData and is kept here so unit tests can exercise it -// without needing a database. +// given a list of recorded costs. func computeCosts(costs []db.Costs, dbs db.RequestSummary) (totalCosts int, estimated bool) { - log.Println(costs, dbs.Model) - var in, out int - - y, m, d := dbs.RequestTime.Date() - requestDay := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) - - dayToNextEntry := 0 - - // if no record on the same day is found, check if there is one in the range of 10 days - for dayToNextEntry < 10 { - for _, daybetween := range []int{dayToNextEntry, dayToNextEntry * -1} { - for _, c := range costs { - y, m, d := c.RequestTime.Date() - d = d + daybetween - costDay := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) - - if costDay == requestDay && c.UnitOfMeasure == "1K" { - if c.TokenType == "Outp" { - out = dbs.TokenCountComplete * c.RetailPrice / 1000 - } - if c.TokenType == "Inp" { - // Input costs must be calculated from the prompt token count. - in = dbs.TokenCountPrompt * c.RetailPrice / 1000 - } - } - } - log.Println("Before Condition db: ", daybetween, " out:", out) - if dayToNextEntry == 0 && out != 0 { - return in + out, false - } - } - dayToNextEntry++ + inputTokenCount := dbs.InputTokenCount + cachedInputTokenCount := dbs.CachedInputTokenCount + outputTokenCount := dbs.OutputTokenCount + + // Legacy fallback: graph code historically used TokenCountPrompt/TokenCountComplete. + // When per-request input/cached/output counts aren't populated, treat prompt as full input and cached as 0. + if inputTokenCount == 0 && cachedInputTokenCount == 0 && outputTokenCount == 0 { + inputTokenCount = dbs.TokenCountPrompt + cachedInputTokenCount = 0 + outputTokenCount = dbs.TokenCountComplete } - // Handle no Cost of Tokens in DB to give Assumption to the User even if there is no record in 10 days range - if out == 0 { - var tmp int - tmp = co.MoneyUnit * 0.0026 - in := dbs.TokenCountPrompt * tmp / 1000 - tmp = co.MoneyUnit * 0.0105 - out = dbs.TokenCountComplete * tmp / 1000 - log.Println("set Out and in to", out, in) + res := db.ResolveRequestCostStage(costs, db.RequestCostStageResolutionRequest{ + Model: dbs.Model, + RequestTime: dbs.RequestTime, + InputTokenCount: inputTokenCount, + CachedInputTokenCount: cachedInputTokenCount, + OutputTokenCount: outputTokenCount, + StageType: db.ContextLengthStageType, + }) + + if res.Missing { + return 0, true } - return in + out, true + return int(res.TotalCost * float64(co.MoneyUnit)), false } diff --git a/api/graph_fix_test.go b/api/graph_fix_test.go index afcfded..5964dfc 100644 --- a/api/graph_fix_test.go +++ b/api/graph_fix_test.go @@ -4,12 +4,11 @@ import ( "testing" "time" + co "openai-api-proxy/costs" db "openai-api-proxy/db" ) -// This test asserts the correct behavior: input (Inp) costs should be -// calculated from TokenCountPrompt, not TokenCountComplete. With the -// current buggy implementation, this test will fail and reproduce the bug. +// Regression test: input ("input"/"Inp") costs must be computed from prompt tokens, not completion tokens. func TestComputeCosts_ShouldUsePromptForInp(t *testing.T) { now := time.Now().UTC() rq := db.RequestSummary{ @@ -21,23 +20,21 @@ func TestComputeCosts_ShouldUsePromptForInp(t *testing.T) { inp := db.Costs{ ModelName: "test-model", - RetailPrice: 1000, + RetailPrice: 1000, // cents per 1K tokens => 10 EUR / 1K TokenType: "Inp", UnitOfMeasure: "1K", - IsRegional: true, - BackendName: "azure", Currency: "EUR", RequestTime: now, + StageType: db.ContextLengthStageType, } out := db.Costs{ ModelName: "test-model", RetailPrice: 2000, TokenType: "Outp", UnitOfMeasure: "1K", - IsRegional: true, - BackendName: "azure", Currency: "EUR", RequestTime: now, + StageType: db.ContextLengthStageType, } costs := []db.Costs{inp, out} @@ -47,8 +44,11 @@ func TestComputeCosts_ShouldUsePromptForInp(t *testing.T) { t.Fatalf("expected estimated to be false when same-day costs are present") } - // Correct calculation should use prompt for Inp and complete for Outp - expectedCorrect := rq.TokenCountPrompt*inp.RetailPrice/1000 + rq.TokenCountComplete*out.RetailPrice/1000 + // Expected: + // input: 1000 prompt tokens / 1K = 1 * (1000 cents / 100) = 10 EUR + // output: 2000 completion tokens / 1K = 2 * (2000 cents / 100) = 40 EUR + // total: 50 EUR + expectedCorrect := 50 * co.MoneyUnit if total != expectedCorrect { t.Fatalf("computeCosts returned %d, want %d (correct)", total, expectedCorrect) diff --git a/api/graph_test.go b/api/graph_test.go index b32e9e4..1d3b031 100644 --- a/api/graph_test.go +++ b/api/graph_test.go @@ -4,11 +4,11 @@ import ( "testing" "time" + co "openai-api-proxy/costs" db "openai-api-proxy/db" ) -// Test that computeCosts uses TokenCountPrompt for Inp and -// TokenCountComplete for Outp (correct behavior). +// Test that computeCosts uses prompt tokens for "input" billing and completion tokens for "output" billing. func TestComputeCosts_CorrectBehavior(t *testing.T) { // prepare a request summary with different prompt and completion counts now := time.Now().UTC() @@ -22,23 +22,21 @@ func TestComputeCosts_CorrectBehavior(t *testing.T) { // prepare costs: 1K-unit prices (simple values so arithmetic is easy) inp := db.Costs{ ModelName: "test-model", - RetailPrice: 1000, // price unit: 1000 (so price per 1K tokens = 1000) + RetailPrice: 1000, // cents per 1K tokens => 10 EUR / 1K TokenType: "Inp", UnitOfMeasure: "1K", - IsRegional: true, - BackendName: "azure", Currency: "EUR", RequestTime: now, + StageType: db.ContextLengthStageType, } out := db.Costs{ ModelName: "test-model", RetailPrice: 2000, TokenType: "Outp", UnitOfMeasure: "1K", - IsRegional: true, - BackendName: "azure", Currency: "EUR", RequestTime: now, + StageType: db.ContextLengthStageType, } costs := []db.Costs{inp, out} @@ -49,8 +47,11 @@ func TestComputeCosts_CorrectBehavior(t *testing.T) { t.Fatalf("expected estimated to be false when same-day costs are present") } - // Correct calculation should use prompt for Inp and complete for Outp - expectedCorrect := rq.TokenCountPrompt*inp.RetailPrice/1000 + rq.TokenCountComplete*out.RetailPrice/1000 + // Expected: + // input: 1000 prompt tokens / 1K = 1 * (1000 cents / 100) = 10 EUR + // output: 2000 completion tokens / 1K = 2 * (2000 cents / 100) = 40 EUR + // total: 50 EUR + expectedCorrect := 50 * co.MoneyUnit if total != expectedCorrect { t.Fatalf("computeCosts returned %d, want %d (correct)", total, expectedCorrect) diff --git a/apiproxy/proxy.go b/apiproxy/proxy.go index 76a61a2..bbeec3f 100644 --- a/apiproxy/proxy.go +++ b/apiproxy/proxy.go @@ -63,43 +63,14 @@ type baseHandle struct { } func (h *baseHandle) ServeHTTP(w http.ResponseWriter, r *http.Request) { - backend := r.Header.Get("Backend") - r.Header.Del("Backend") - // Intercept OpenAI-compatible models endpoints and serve locally if strings.HasPrefix(r.URL.Path, "/api/models") || strings.HasPrefix(r.URL.Path, "/api/v1/models") { h.handleModels(w, r) return } - - if fn, ok := backendProxy[backend]; ok { - fn.ServeHTTP(w, r) - return - } - - // See if backend is OpenAI compatible - _, ok := OpenAIbackendService[backend] - - if ok { - //h.HandleOpenAI(w, r, backend) - return - } - if backend == "azure" { - h.HandleAzure(w, r, backend) - return - } - - if backend == "" { - if defaultBackend == "azure" { - h.HandleAzure(w, r, defaultBackend) - return - } else { - //h.HandleOpenAI(w, r, defaultBackend) - return - } - } - - w.Write([]byte("404: Backend not found ")) + // The only fully supported runtime path today is the Azure OpenAI proxy. + // Routing by the request `Backend` header is intentionally removed. + h.HandleAzure(w, r, "azure") } // Since OpenAI and Azure API are not really compatible, we need 2 different handler functions diff --git a/apiproxy/response_model_snapshot_test.go b/apiproxy/response_model_snapshot_test.go index 203b166..91ba5c1 100644 --- a/apiproxy/response_model_snapshot_test.go +++ b/apiproxy/response_model_snapshot_test.go @@ -4,10 +4,10 @@ import "testing" func TestSplitModelSnapshot(t *testing.T) { tests := []struct { - name string - model string - wantModel string - wantSnapshot string + name string + model string + wantModel string + wantSnapshot string }{ { name: "model with snapshot suffix", diff --git a/app/drizzle/schema.ts b/app/drizzle/schema.ts index 1e5c5f0..f65e107 100644 --- a/app/drizzle/schema.ts +++ b/app/drizzle/schema.ts @@ -201,6 +201,15 @@ export const costUnit = pgEnum("cost_unit", ["1M", "1K"]); export const costs = pgTable( "costs", { + id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity({ + name: "costs_id_seq", + startWith: 1, + increment: 1, + minValue: 1, + // biome-ignore lint/correctness/noPrecisionLoss: matches Postgres BIGINT max. + maxValue: 9223372036854775807, + cache: 1, + }), model: varchar({ length: 255 }).notNull(), price: integer().notNull(), validFrom: date("valid_from").defaultNow().notNull(), @@ -209,18 +218,9 @@ export const costs = pgTable( isRegional: boolean("is_regional").notNull(), backendName: varchar("backend_name", { length: 255 }).notNull(), currency: char({ length: 3 }), + stageType: text("stage_type").notNull().default("context_length"), + stageMinTokens: integer("stage_min_tokens").notNull().default(0), + stageMaxTokens: integer("stage_max_tokens"), }, - (table) => [ - primaryKey({ - columns: [ - table.model, - table.price, - table.validFrom, - table.tokenType, - table.isRegional, - table.backendName, - ], - name: "costs_pkey", - }), - ], + (_table) => [], ); diff --git a/app/src/app/admin/costs/costs-client.tsx b/app/src/app/admin/costs/costs-client.tsx index 9f90d8b..a8add8d 100644 --- a/app/src/app/admin/costs/costs-client.tsx +++ b/app/src/app/admin/costs/costs-client.tsx @@ -13,7 +13,6 @@ import { api } from "~/trpc/react"; import { CostsTable } from "./costs-table"; const defaultUnit = costUnitOptions[0]; -const defaultBackend = "openai"; const defaultCurrency = "EUR"; const defaultTokenType = "input"; @@ -31,9 +30,10 @@ export function CostsClient() { const [price, setPrice] = useState(""); const [validFrom, setValidFrom] = useState(todayString()); const [unitOfMessure, setUnitOfMessure] = useState(defaultUnit); - const [isRegional, setIsRegional] = useState(false); - const [backendName, setBackendName] = useState(defaultBackend); const [currency, setCurrency] = useState(defaultCurrency); + const [stageType, setStageType] = useState("context_length"); + const [stageMinTokens, setStageMinTokens] = useState(0); + const [stageMaxTokens, setStageMaxTokens] = useState(""); const createCost = api.admin.createCost.useMutation({ onSuccess: async () => { @@ -42,9 +42,10 @@ export function CostsClient() { setPrice(""); setValidFrom(todayString()); setUnitOfMessure(defaultUnit); - setIsRegional(false); - setBackendName(defaultBackend); setCurrency(defaultCurrency); + setStageType("context_length"); + setStageMinTokens(0); + setStageMaxTokens(""); await utils.admin.listCosts.invalidate(); toast.success("Eintrag erstellt."); }, @@ -74,11 +75,15 @@ export function CostsClient() { }); const priceValue = useMemo(() => Number.parseInt(price, 10), [price]); + const stageMaxTokensValue = + stageMaxTokens.trim() === "" ? null : Number.parseInt(stageMaxTokens, 10); + const stageMaxTokensValid = + stageMaxTokensValue === null || !Number.isNaN(stageMaxTokensValue); const canSubmit = model.trim().length > 0 && - backendName.trim().length > 0 && tokenType.trim().length > 0 && - !Number.isNaN(priceValue); + !Number.isNaN(priceValue) && + stageMaxTokensValid; return (
@@ -95,8 +100,7 @@ export function CostsClient() {

Neuen Eintrag anlegen

- Standardwerte: Backend {defaultBackend}, Einheit {defaultUnit}, - Währung {defaultCurrency}. + Standardwerte: Einheit {defaultUnit}, Währung {defaultCurrency}.

@@ -181,20 +185,6 @@ export function CostsClient() { ))}
-
- - setBackendName(event.target.value)} - placeholder="openai" - value={backendName} - /> -
-
- setIsRegional(event.target.checked)} - type="checkbox" +
+ +
+
+ + setStageType(event.target.value)} + value={stageType} /> -
+
+ + + setStageMinTokens( + Number.parseInt(event.target.value || "0", 10), + ) + } + type="number" + value={String(stageMinTokens)} + /> +
+
+ + setStageMaxTokens(event.target.value)} + placeholder="(optional)" + type="number" + value={stageMaxTokens} + />
@@ -235,9 +264,10 @@ export function CostsClient() { validFrom: validFrom || undefined, tokenType: tokenType.trim(), unitOfMessure: unitOfMessure ?? null, - isRegional, - backendName: backendName.trim(), currency: currency.trim() || null, + stageType: stageType.trim() || "context_length", + stageMinTokens, + stageMaxTokens: stageMaxTokensValue, }) } > diff --git a/app/src/app/admin/costs/costs-table.tsx b/app/src/app/admin/costs/costs-table.tsx index 0d465c6..b9ac8c8 100644 --- a/app/src/app/admin/costs/costs-table.tsx +++ b/app/src/app/admin/costs/costs-table.tsx @@ -38,25 +38,29 @@ import { import { type CostUnit, costUnitOptions } from "~/lib/costs"; export type CostRow = { + id: number; model: string; price: number; validFrom: string | null; tokenType: string; unitOfMessure: CostUnit | null; - isRegional: boolean; - backendName: string; currency: string | null; + stageType: string | null; + stageMinTokens: number; + stageMaxTokens: number | null; }; type CostPayload = { + id?: number; model: string; price: number; validFrom?: string; tokenType: string; unitOfMessure?: CostUnit | null; - isRegional: boolean; - backendName: string; currency?: string | null; + stageType?: string | null; + stageMinTokens?: number; + stageMaxTokens?: number | null; }; type CostUpdatePayload = { @@ -108,36 +112,43 @@ function CostEditDialog({ const [tokenType, setTokenType] = useState(row.tokenType); const [price, setPrice] = useState(String(row.price)); const [validFrom, setValidFrom] = useState(row.validFrom ?? todayString()); + const [stageType, setStageType] = useState(row.stageType ?? "context_length"); + const [stageMinTokens, setStageMinTokens] = useState(row.stageMinTokens); + const [stageMaxTokens, setStageMaxTokens] = useState( + row.stageMaxTokens === null ? "" : String(row.stageMaxTokens), + ); const [unitOfMessure, setUnitOfMessure] = useState( costUnitOptions.includes((row.unitOfMessure ?? "") as CostUnit) ? ((row.unitOfMessure ?? defaultUnit) as CostUnit) : defaultUnit, ); - const [isRegional, setIsRegional] = useState(row.isRegional); - const [backendName, setBackendName] = useState(row.backendName); const [currency, setCurrency] = useState(row.currency ?? ""); - const fieldKey = `${row.model}-${row.tokenType}-${row.price}`.replace( - /[^a-zA-Z0-9-_]/g, - "-", - ); + const fieldKey = String(row.id); const priceValue = useMemo(() => Number.parseInt(price, 10), [price]); + const stageMaxTokensValue = + stageMaxTokens.trim() === "" ? null : Number.parseInt(stageMaxTokens, 10); + const stageMaxTokensValid = + stageMaxTokensValue === null || !Number.isNaN(stageMaxTokensValue); const canSubmit = model.trim().length > 0 && - backendName.trim().length > 0 && tokenType.trim().length > 0 && - !Number.isNaN(priceValue); + !Number.isNaN(priceValue) && + stageMaxTokensValid && + stageMinTokens >= 0; const originalPayload = useMemo( () => ({ + id: row.id, model: row.model, price: row.price, validFrom: row.validFrom ?? undefined, tokenType: row.tokenType, unitOfMessure: row.unitOfMessure ?? null, - isRegional: row.isRegional, - backendName: row.backendName, currency: row.currency ?? null, + stageType: row.stageType ?? "context_length", + stageMinTokens: row.stageMinTokens, + stageMaxTokens: row.stageMaxTokens ?? null, }), [row], ); @@ -155,9 +166,12 @@ function CostEditDialog({ setTokenType(row.tokenType); setPrice(String(row.price)); setValidFrom(row.validFrom ?? todayString()); + setStageType(row.stageType ?? "context_length"); + setStageMinTokens(row.stageMinTokens); + setStageMaxTokens( + row.stageMaxTokens === null ? "" : String(row.stageMaxTokens), + ); setUnitOfMessure(row.unitOfMessure ?? defaultUnit); - setIsRegional(row.isRegional); - setBackendName(row.backendName); setCurrency(row.currency ?? ""); } }} @@ -257,6 +271,54 @@ function CostEditDialog({ ) : null}
+
+ + setStageType(event.target.value)} + value={stageType} + /> +
+
+ + + setStageMinTokens( + Number.parseInt(event.target.value || "0", 10), + ) + } + type="number" + value={String(stageMinTokens)} + /> +
+
+ + setStageMaxTokens(event.target.value)} + placeholder="(optional)" + type="number" + value={stageMaxTokens} + /> +
-
- - setBackendName(event.target.value)} - placeholder="openai" - value={backendName} - /> -
-
- setIsRegional(event.target.checked)} - type="checkbox" - /> - -
@@ -338,9 +371,10 @@ function CostEditDialog({ validFrom: effectiveValidFrom, tokenType: tokenType.trim(), unitOfMessure: unitOfMessure ?? null, - isRegional, - backendName: backendName.trim(), currency: currency.trim() || null, + stageType: stageType.trim() || "context_length", + stageMinTokens, + stageMaxTokens: stageMaxTokensValue, }; if (mode === "update") { @@ -381,7 +415,6 @@ export function CostsTable({ const [sorting, setSorting] = useState([]); const [globalFilter, setGlobalFilter] = useState(""); const [modelFilter, setModelFilter] = useState(""); - const [backendFilter, setBackendFilter] = useState(""); const [tokenFilter, setTokenFilter] = useState(""); const [currencyFilter, setCurrencyFilter] = useState(""); const [pagination, setPagination] = useState({ @@ -398,12 +431,6 @@ export function CostsTable({ ) { return false; } - if ( - backendFilter && - !row.backendName.toLowerCase().includes(backendFilter.toLowerCase()) - ) { - return false; - } if ( tokenFilter && !row.tokenType.toLowerCase().includes(tokenFilter.toLowerCase()) @@ -422,23 +449,17 @@ export function CostsTable({ row.tokenType, row.price, row.unitOfMessure ?? "", - row.backendName, row.currency ?? "", - row.isRegional ? "regional" : "standard", + row.stageType ?? "", + row.stageMinTokens, + row.stageMaxTokens ?? "", formatDate(row.validFrom), ]; return values .map((value) => String(value).toLowerCase()) .some((value) => value.includes(search)); }); - }, [ - backendFilter, - currencyFilter, - data, - globalFilter, - modelFilter, - tokenFilter, - ]); + }, [currencyFilter, data, globalFilter, modelFilter, tokenFilter]); const columns = useMemo[]>( () => [ @@ -450,6 +471,24 @@ export function CostsTable({ accessorKey: "tokenType", header: "Token-Typ", }, + { + accessorKey: "stageType", + header: "Stage Typ", + cell: ({ getValue }) => getValue() ?? "—", + }, + { + accessorKey: "stageMinTokens", + header: "Stage Min", + cell: ({ getValue }) => formatNumber(getValue()), + }, + { + accessorKey: "stageMaxTokens", + header: "Stage Max", + cell: ({ getValue }) => { + const v = getValue(); + return v === null ? "∞" : formatNumber(v); + }, + }, { accessorKey: "price", header: "Preis", @@ -466,15 +505,6 @@ export function CostsTable({ header: "Währung", cell: ({ getValue }) => getValue() ?? "—", }, - { - accessorKey: "backendName", - header: "Backend", - }, - { - accessorKey: "isRegional", - header: "Region", - cell: ({ getValue }) => (getValue() ? "Regional" : "Standard"), - }, { accessorKey: "validFrom", header: "Gültig ab", @@ -520,14 +550,7 @@ export function CostsTable({ // biome-ignore lint/correctness/useExhaustiveDependencies: reset pagination when filters change. useEffect(() => { setPagination((prev) => ({ ...prev, pageIndex: 0 })); - }, [ - globalFilter, - modelFilter, - backendFilter, - tokenFilter, - currencyFilter, - data.length, - ]); + }, [globalFilter, modelFilter, tokenFilter, currencyFilter, data.length]); const totalRows = table.getFilteredRowModel().rows.length; const startRow = @@ -562,11 +585,6 @@ export function CostsTable({ placeholder="Modell" value={modelFilter} /> - setBackendFilter(event.target.value)} - placeholder="Backend" - value={backendFilter} - /> setTokenFilter(event.target.value)} placeholder="Token-Typ" @@ -578,17 +596,12 @@ export function CostsTable({ value={currencyFilter} /> - {globalFilter || - modelFilter || - backendFilter || - tokenFilter || - currencyFilter ? ( + {globalFilter || modelFilter || tokenFilter || currencyFilter ? (
- setStageType(event.target.value)} + onChange={(event) => + setStageType(event.target.value as CostStageType) + } value={stageType} - /> + > + {costStageTypeOptions.map((stage) => ( + + {stage} + + ))} +