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..65f44a6 100644 --- a/app/src/app/admin/costs/costs-client.tsx +++ b/app/src/app/admin/costs/costs-client.tsx @@ -8,12 +8,16 @@ import { NativeSelect, NativeSelectOption, } from "~/components/ui/native-select"; -import { type CostUnit, costUnitOptions } from "~/lib/costs"; +import { + CostStageType, + type CostUnit, + costStageTypeOptions, + costUnitOptions, +} from "~/lib/costs"; 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 +35,12 @@ 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( + CostStageType.ContextLength, + ); + const [stageMinTokens, setStageMinTokens] = useState(0); + const [stageMaxTokens, setStageMaxTokens] = useState(""); const createCost = api.admin.createCost.useMutation({ onSuccess: async () => { @@ -42,9 +49,10 @@ export function CostsClient() { setPrice(""); setValidFrom(todayString()); setUnitOfMessure(defaultUnit); - setIsRegional(false); - setBackendName(defaultBackend); setCurrency(defaultCurrency); + setStageType(CostStageType.ContextLength); + setStageMinTokens(0); + setStageMaxTokens(""); await utils.admin.listCosts.invalidate(); toast.success("Eintrag erstellt."); }, @@ -74,11 +82,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 +107,7 @@ export function CostsClient() {

Neuen Eintrag anlegen

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

@@ -181,20 +192,6 @@ export function CostsClient() { ))}
-
- - setBackendName(event.target.value)} - placeholder="openai" - value={backendName} - /> -
-
- setIsRegional(event.target.checked)} - type="checkbox" +
+ +
+
+ + + setStageType(event.target.value as CostStageType) + } + value={stageType} + > + {costStageTypeOptions.map((stage) => ( + + {stage} + + ))} + +
+
+ + + 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 +280,10 @@ export function CostsClient() { validFrom: validFrom || undefined, tokenType: tokenType.trim(), unitOfMessure: unitOfMessure ?? null, - isRegional, - backendName: backendName.trim(), currency: currency.trim() || null, + stageType, + 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..dcdcee7 100644 --- a/app/src/app/admin/costs/costs-table.tsx +++ b/app/src/app/admin/costs/costs-table.tsx @@ -35,28 +35,37 @@ import { TableHeader, TableRow, } from "~/components/ui/table"; -import { type CostUnit, costUnitOptions } from "~/lib/costs"; +import { + CostStageType, + type CostUnit, + costStageTypeOptions, + 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: CostStageType | 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?: CostStageType | null; + stageMinTokens?: number; + stageMaxTokens?: number | null; }; type CostUpdatePayload = { @@ -108,36 +117,45 @@ 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 ?? CostStageType.ContextLength, + ); + 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 ?? CostStageType.ContextLength, + stageMinTokens: row.stageMinTokens, + stageMaxTokens: row.stageMaxTokens ?? null, }), [row], ); @@ -155,9 +173,12 @@ function CostEditDialog({ setTokenType(row.tokenType); setPrice(String(row.price)); setValidFrom(row.validFrom ?? todayString()); + setStageType(row.stageType ?? CostStageType.ContextLength); + setStageMinTokens(row.stageMinTokens); + setStageMaxTokens( + row.stageMaxTokens === null ? "" : String(row.stageMaxTokens), + ); setUnitOfMessure(row.unitOfMessure ?? defaultUnit); - setIsRegional(row.isRegional); - setBackendName(row.backendName); setCurrency(row.currency ?? ""); } }} @@ -260,21 +281,21 @@ function CostEditDialog({
- setUnitOfMessure(event.target.value as CostUnit) + setStageType(event.target.value as CostStageType) } - value={unitOfMessure} + value={stageType} > - {costUnitOptions.map((unit) => ( - - {unit} + {costStageTypeOptions.map((stage) => ( + + {stage} ))} @@ -282,17 +303,60 @@ function CostEditDialog({
+ + setStageMinTokens( + Number.parseInt(event.target.value || "0", 10), + ) + } + type="number" + value={String(stageMinTokens)} + /> +
+
+ setBackendName(event.target.value)} - placeholder="openai" - value={backendName} + id={`costs-stage-max-${fieldKey}`} + min={0} + onChange={(event) => setStageMaxTokens(event.target.value)} + placeholder="(optional)" + type="number" + value={stageMaxTokens} />
+
+ + + setUnitOfMessure(event.target.value as CostUnit) + } + value={unitOfMessure} + > + {costUnitOptions.map((unit) => ( + + {unit} + + ))} + +
-
- setIsRegional(event.target.checked)} - type="checkbox" - /> - -
@@ -338,9 +387,10 @@ function CostEditDialog({ validFrom: effectiveValidFrom, tokenType: tokenType.trim(), unitOfMessure: unitOfMessure ?? null, - isRegional, - backendName: backendName.trim(), currency: currency.trim() || null, + stageType, + stageMinTokens, + stageMaxTokens: stageMaxTokensValue, }; if (mode === "update") { @@ -381,7 +431,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 +447,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 +465,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 +487,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 +521,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 +566,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 +601,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 +612,12 @@ export function CostsTable({ value={currencyFilter} />
- {globalFilter || - modelFilter || - backendFilter || - tokenFilter || - currencyFilter ? ( + {globalFilter || modelFilter || tokenFilter || currencyFilter ? (
+ + + + + + + Konfigurierte Modelle + + {models.length} Einträge in der Liste. + + + + + {isLoading ? ( +

Lade Modelle...

+ ) : models.length === 0 ? ( +

+ Keine Modelle konfiguriert. +

+ ) : ( +
+ {models.map((model) => ( + + {model} + + + ))} +
+ )} +
+
+
+ + ); +} diff --git a/app/src/app/admin/models/page.tsx b/app/src/app/admin/models/page.tsx new file mode 100644 index 0000000..d0b6080 --- /dev/null +++ b/app/src/app/admin/models/page.tsx @@ -0,0 +1,13 @@ +import { redirect } from "next/navigation"; + +import { auth } from "~/server/auth"; +import { ModelsClient } from "./models-client"; + +export default async function AdminModelsPage() { + const session = await auth(); + if (!session?.user?.isAdmin) { + redirect("/"); + } + + return ; +} diff --git a/app/src/app/my-api-keys/api-keys-client.tsx b/app/src/app/my-api-keys/api-keys-client.tsx index 58a499d..25feda0 100644 --- a/app/src/app/my-api-keys/api-keys-client.tsx +++ b/app/src/app/my-api-keys/api-keys-client.tsx @@ -158,18 +158,20 @@ export function ApiKeysClient() {
- + {token ? null : ( + + )} {createApiKey.error ? ( Schlüssel konnte nicht erstellt werden. Bitte erneut diff --git a/app/src/components/sidebar-nav.tsx b/app/src/components/sidebar-nav.tsx index e769de9..f5b039c 100644 --- a/app/src/components/sidebar-nav.tsx +++ b/app/src/components/sidebar-nav.tsx @@ -1,6 +1,13 @@ "use client"; -import { BarChart3, Coins, FileText, KeyRound, Users } from "lucide-react"; +import { + BarChart3, + Boxes, + Coins, + FileText, + KeyRound, + Users, +} from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -32,6 +39,11 @@ const adminItems = [ label: "Kosten", icon: Coins, }, + { + href: "/admin/models", + label: "Modelle", + icon: Boxes, + }, ]; const reportingItems = [ diff --git a/app/src/lib/costs.ts b/app/src/lib/costs.ts index 4fb862f..70c70de 100644 --- a/app/src/lib/costs.ts +++ b/app/src/lib/costs.ts @@ -1,3 +1,9 @@ export const costUnitOptions = ["1M", "1K"] as const; export type CostUnit = (typeof costUnitOptions)[number]; + +export enum CostStageType { + ContextLength = "context_length", +} + +export const costStageTypeOptions = [CostStageType.ContextLength] as const; diff --git a/app/src/server/api/routers/admin.ts b/app/src/server/api/routers/admin.ts index 66f5c36..00d9c33 100644 --- a/app/src/server/api/routers/admin.ts +++ b/app/src/server/api/routers/admin.ts @@ -1,7 +1,7 @@ import { TRPCError } from "@trpc/server"; import { and, eq, sql } from "drizzle-orm"; import { z } from "zod"; -import { costUnitOptions } from "~/lib/costs"; +import { CostStageType, costUnitOptions } from "~/lib/costs"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { apikeys, costs, models, requests, users } from "~/server/db/schema"; @@ -17,15 +17,20 @@ const adminProcedure = protectedProcedure.use(({ ctx, next }) => { }); const rangeInput = z.enum(["24h", "7d", "30d", "all"]); +const modelInput = z.object({ + modelId: z.string().trim().min(1).max(255), +}); const costInput = z.object({ + id: z.number().int().positive().optional(), model: z.string().trim().min(1).max(255), price: z.number().int().nonnegative(), validFrom: z.string().trim().min(1).max(32).optional(), tokenType: z.string().trim().min(1).max(255), unitOfMessure: z.enum(costUnitOptions).optional().nullable(), - isRegional: z.boolean(), - backendName: z.string().trim().min(1).max(255), currency: z.string().trim().length(3).optional().nullable(), + stageType: z.nativeEnum(CostStageType).optional().nullable(), + stageMinTokens: z.number().int().min(0).optional(), + stageMaxTokens: z.number().int().min(0).optional().nullable(), }); export const adminRouter = createTRPCRouter({ @@ -140,26 +145,52 @@ export const adminRouter = createTRPCRouter({ .orderBy(models.id); return rows.map((row) => row.id); }), + addModel: adminProcedure + .input(modelInput) + .mutation(async ({ ctx, input }) => { + await ctx.db + .insert(models) + .values({ id: input.modelId }) + .onConflictDoNothing(); + }), + deleteModel: adminProcedure + .input(modelInput) + .mutation(async ({ ctx, input }) => { + await ctx.db.delete(models).where(eq(models.id, input.modelId)); + }), listCosts: adminProcedure.query(async ({ ctx }) => { const rows = await ctx.db .select({ + id: costs.id, model: costs.model, price: costs.price, validFrom: costs.validFrom, tokenType: costs.tokenType, unitOfMessure: costs.unitOfMessure, - isRegional: costs.isRegional, - backendName: costs.backendName, currency: costs.currency, + stageType: costs.stageType, + stageMinTokens: costs.stageMinTokens, + stageMaxTokens: costs.stageMaxTokens, }) .from(costs) - .orderBy(costs.model, costs.tokenType, costs.validFrom); + .orderBy( + costs.model, + costs.tokenType, + costs.validFrom, + costs.stageMinTokens, + ); return rows.map((row) => ({ ...row, price: Number(row.price ?? 0), validFrom: row.validFrom ?? null, currency: row.currency ? row.currency.trim() : null, + stageType: + row.stageType === CostStageType.ContextLength + ? CostStageType.ContextLength + : null, + stageMinTokens: Number(row.stageMinTokens ?? 0), + stageMaxTokens: row.stageMaxTokens ?? null, })); }), createCost: adminProcedure @@ -167,6 +198,9 @@ export const adminRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const validFrom = input.validFrom?.trim() || new Date().toISOString().slice(0, 10); + const stageType = input.stageType ?? CostStageType.ContextLength; + const stageMinTokens = input.stageMinTokens ?? 0; + const stageMaxTokens = input.stageMaxTokens ?? null; await ctx.db.insert(costs).values({ model: input.model, @@ -174,9 +208,10 @@ export const adminRouter = createTRPCRouter({ validFrom, tokenType: input.tokenType, unitOfMessure: input.unitOfMessure ?? null, - isRegional: input.isRegional, - backendName: input.backendName, currency: input.currency ?? null, + stageType, + stageMinTokens, + stageMaxTokens, }); }), updatePricing: adminProcedure @@ -188,6 +223,9 @@ export const adminRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const validFrom = new Date().toISOString().slice(0, 10); const update = input.update; + const stageType = update.stageType ?? CostStageType.ContextLength; + const stageMinTokens = update.stageMinTokens ?? 0; + const stageMaxTokens = update.stageMaxTokens ?? null; await ctx.db.insert(costs).values({ model: update.model, @@ -195,9 +233,10 @@ export const adminRouter = createTRPCRouter({ validFrom, tokenType: update.tokenType, unitOfMessure: update.unitOfMessure ?? null, - isRegional: update.isRegional, - backendName: update.backendName, currency: update.currency ?? null, + stageType, + stageMinTokens, + stageMaxTokens, }); }), updateCost: adminProcedure @@ -216,6 +255,13 @@ export const adminRouter = createTRPCRouter({ throw new TRPCError({ code: "BAD_REQUEST" }); } + const stageType = + update.stageType ?? original.stageType ?? CostStageType.ContextLength; + const stageMinTokens = + update.stageMinTokens ?? original.stageMinTokens ?? 0; + const stageMaxTokens = + update.stageMaxTokens ?? original.stageMaxTokens ?? null; + await ctx.db .update(costs) .set({ @@ -224,19 +270,34 @@ export const adminRouter = createTRPCRouter({ validFrom, tokenType: update.tokenType, unitOfMessure: update.unitOfMessure ?? null, - isRegional: update.isRegional, - backendName: update.backendName, currency: update.currency ?? null, + stageType, + stageMinTokens, + stageMaxTokens, }) .where( - and( - eq(costs.model, original.model), - eq(costs.price, original.price), - eq(costs.validFrom, original.validFrom), - eq(costs.tokenType, original.tokenType), - eq(costs.isRegional, original.isRegional), - eq(costs.backendName, original.backendName), - ), + // Drizzle typed columns don't narrow correctly through complex inline expressions, + // so keep this as a local variable to get proper TypeScript null handling. + (() => { + const originalStageMaxTokens = original.stageMaxTokens ?? null; + + return original.id + ? eq(costs.id, original.id) + : and( + eq(costs.model, original.model), + eq(costs.price, original.price), + eq(costs.validFrom, original.validFrom), + eq(costs.tokenType, original.tokenType), + eq( + costs.stageType, + original.stageType ?? CostStageType.ContextLength, + ), + eq(costs.stageMinTokens, original.stageMinTokens ?? 0), + originalStageMaxTokens === null + ? sql`${costs.stageMaxTokens} IS NULL` + : eq(costs.stageMaxTokens, originalStageMaxTokens), + ); + })(), ); }), }); diff --git a/app/src/server/api/routers/api-key.ts b/app/src/server/api/routers/api-key.ts index 9f69f4e..307e86c 100644 --- a/app/src/server/api/routers/api-key.ts +++ b/app/src/server/api/routers/api-key.ts @@ -5,10 +5,21 @@ import { and, eq, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; +import { + addResolvedTotalCost, + createTotalCostAggregate, + mergeCurrency, + presentTotalCost, + scaledCostToNumber, +} from "~/server/costAggregation"; +import { + buildCostStageIndex, + type CostStageRow, + resolveRequestCostStage, +} from "~/server/costStageResolver"; import { apikeys, costs, requests, users } from "~/server/db/schema"; const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; -const DEFAULT_AIAPI = "openai"; function base32Encode(bytes: Uint8Array) { let output = ""; @@ -73,6 +84,7 @@ export const apiKeyRouter = createTRPCRouter({ requests.model, ); + // Fetch and index all pricing rows once; resolve cost per request afterwards. const costRows = await ctx.db .select({ model: costs.model, @@ -81,95 +93,71 @@ export const apiKeyRouter = createTRPCRouter({ tokenType: costs.tokenType, unitOfMessure: costs.unitOfMessure, currency: costs.currency, + stageType: costs.stageType, + stageMinTokens: costs.stageMinTokens, + stageMaxTokens: costs.stageMaxTokens, }) .from(costs); - const normalize = (value: string | null | undefined) => - (value ?? "").trim().toLowerCase(); + const costStageRows: CostStageRow[] = costRows.map((row) => ({ + model: row.model ?? "", + tokenType: row.tokenType ?? "", + price: Number(row.price ?? 0), + validFrom: row.validFrom ?? new Date(0), + unitOfMessure: (row.unitOfMessure ?? + null) as CostStageRow["unitOfMessure"], + currency: row.currency ? row.currency.trim().toUpperCase() : null, + stageType: row.stageType ?? "context_length", + stageMinTokens: Number(row.stageMinTokens ?? 0), + stageMaxTokens: row.stageMaxTokens ?? null, + })); + + const costStageIndex = buildCostStageIndex(costStageRows); - const costIndex = new Map< + const costAggByKeyModel = new Map< string, - Map< - string, - { - price: number; - validFrom: string | null; - unit: "1M" | "1K" | null; - currency: string | null; - } - > + ReturnType >(); - for (const row of costRows) { - const modelKey = normalize(row.model); - const tokenKey = normalize(row.tokenType); - if (!modelKey || !tokenKey) continue; - const modelMap = costIndex.get(modelKey) ?? new Map(); - const existing = modelMap.get(tokenKey); - const currentValidFrom = row.validFrom - ? new Date(row.validFrom).getTime() - : 0; - const existingValidFrom = existing?.validFrom - ? new Date(existing.validFrom).getTime() - : 0; - if (!existing || currentValidFrom >= existingValidFrom) { - modelMap.set(tokenKey, { - price: Number(row.price ?? 0), - validFrom: row.validFrom ?? null, - unit: (row.unitOfMessure ?? null) as "1M" | "1K" | null, - currency: row.currency ? row.currency.trim().toUpperCase() : null, - }); - } - costIndex.set(modelKey, modelMap); - } + const requestRowsForCost = await ctx.db + .select({ + apiKeyId: apikeys.uuid, + model: requests.model, + requestTime: requests.requestTime, + inputTokenCount: requests.inputTokenCount, + cachedInputTokenCount: requests.cachedInputTokenCount, + outputTokenCount: requests.outputTokenCount, + }) + .from(requests) + .innerJoin(apikeys, eq(apikeys.uuid, requests.apiKeyId)) + .innerJoin(users, eq(apikeys.owner, users.id)) + .where(eq(users.id, userId)); - const tokenAliases = { - input: ["input", "prompt", "input_tokens", "prompt_tokens"], - cached: [ - "cached_input", - "cached", - "cache", - "input_cached", - "cached_input_tokens", - ], - output: ["output", "completion", "output_tokens", "completion_tokens"], - }; - - const unitDivisor = (unit: "1M" | "1K" | null) => { - if (unit === "1K") return 1_000; - return 1_000_000; - }; - - const priceToCurrency = (price: number) => price / 100; - - const calcTokenCost = ( - model: string, - tokens: number, - aliases: string[], - ) => { - if (tokens <= 0) return { cost: 0, missing: false }; - const modelKey = normalize(model); - const modelMap = costIndex.get(modelKey); - if (!modelMap) return { cost: 0, missing: true }; - let entry: - | { - price: number; - validFrom: string | null; - unit: "1M" | "1K" | null; - currency: string | null; - } - | undefined; - for (const alias of aliases) { - entry = modelMap.get(alias); - if (entry) break; - } - if (!entry) return { cost: 0, missing: true }; - return { - cost: (tokens / unitDivisor(entry.unit)) * priceToCurrency(entry.price), - missing: false, - currency: entry.currency, - }; - }; + for (const row of requestRowsForCost) { + const model = row.model ?? null; + if (!model) continue; + if (!row.requestTime) continue; + + const inputTokenCount = Number(row.inputTokenCount ?? 0); + const cachedInputTokenCount = Number(row.cachedInputTokenCount ?? 0); + const outputTokenCount = Number(row.outputTokenCount ?? 0); + + if (inputTokenCount + outputTokenCount <= 0) continue; + + const resolved = resolveRequestCostStage(costStageIndex, { + model, + requestTime: new Date(row.requestTime), + inputTokenCount, + cachedInputTokenCount, + outputTokenCount, + }); + + const aggKey = `${row.apiKeyId}::${model}`; + const agg = costAggByKeyModel.get(aggKey) ?? createTotalCostAggregate(); + addResolvedTotalCost(agg, resolved); + + costAggByKeyModel.set(aggKey, agg); + } const byKey = new Map< string, @@ -189,7 +177,8 @@ export const apiKeyRouter = createTRPCRouter({ cost: number | null; currency: string | null; }>; - cost: number | null; + costScaled: bigint; + costMissing: boolean; currency: string | null; } >(); @@ -205,7 +194,8 @@ export const apiKeyRouter = createTRPCRouter({ outputTokens: 0, createdAt: row.createdAt ?? null, models: [], - cost: 0, + costScaled: 0n, + costMissing: false, currency: null, }; @@ -220,51 +210,30 @@ export const apiKeyRouter = createTRPCRouter({ const model = row.model ?? "Unknown"; if (inputTokens + cachedInputTokens + outputTokens > 0) { - const inputCost = calcTokenCost(model, inputTokens, tokenAliases.input); - const cachedCost = calcTokenCost( - model, - cachedInputTokens, - tokenAliases.cached, - ); - const outputCost = calcTokenCost( - model, - outputTokens, - tokenAliases.output, + const aggKey = `${id}::${model}`; + const modelCostPresentation = presentTotalCost( + costAggByKeyModel.get(aggKey), ); - const modelMissing = - inputCost.missing || cachedCost.missing || outputCost.missing; - const currencies = [ - inputCost.currency, - cachedCost.currency, - outputCost.currency, - ].filter((value): value is string => Boolean(value)); - const modelCurrency = - currencies.length > 0 && - currencies.every((value) => value === currencies[0]) - ? (currencies[0] ?? null) - : null; - const modelCost = modelMissing - ? null - : inputCost.cost + cachedCost.cost + outputCost.cost; entry.models.push({ model, inputTokens, cachedInputTokens, outputTokens, - cost: modelCost, - currency: modelCost === null ? null : modelCurrency, + cost: modelCostPresentation.cost, + currency: modelCostPresentation.currency, }); - if (entry.cost !== null) { - entry.cost = modelCost === null ? null : entry.cost + modelCost; - if (entry.cost === null) { - entry.currency = null; - } else if (entry.currency === null) { - entry.currency = modelCurrency; - } else if (modelCurrency && entry.currency !== modelCurrency) { - entry.currency = null; - } + const modelAgg = costAggByKeyModel.get(aggKey); + if (!modelAgg || modelCostPresentation.missing) { + entry.costMissing = true; + entry.currency = null; + } else { + entry.costScaled += modelAgg.totalCostScaled; + entry.currency = mergeCurrency( + entry.currency, + modelCostPresentation.currency, + ); } } @@ -279,8 +248,8 @@ export const apiKeyRouter = createTRPCRouter({ cachedInputTokens: row.cachedInputTokens, outputTokens: row.outputTokens, createdAt: row.createdAt ?? null, - cost: row.cost, - currency: row.currency ?? null, + cost: row.costMissing ? null : scaledCostToNumber(row.costScaled), + currency: row.costMissing ? null : (row.currency ?? null), models: row.models.sort((a, b) => a.model.localeCompare(b.model)), })); }), @@ -323,7 +292,6 @@ export const apiKeyRouter = createTRPCRouter({ uuid: id, apikey: hashed, owner, - aiapi: DEFAULT_AIAPI, description: input.description?.trim() || null, }); diff --git a/app/src/server/api/routers/reporting.ts b/app/src/server/api/routers/reporting.ts index 23f34b7..2445531 100644 --- a/app/src/server/api/routers/reporting.ts +++ b/app/src/server/api/routers/reporting.ts @@ -3,6 +3,21 @@ import { and, eq, inArray, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; +import { + addResolvedBreakdownCost, + addResolvedTotalCost, + createBreakdownCostAggregate, + createTotalCostAggregate, + mergeCurrency, + presentBreakdownCost, + scaledCostToNumber, +} from "~/server/costAggregation"; +import { + buildCostStageIndex, + type CostStageRow, + resolveRequestCostStage, + tokenTypeLabelWithStage, +} from "~/server/costStageResolver"; import { apikeys, costs, @@ -53,25 +68,6 @@ const reportRangeInput = z.discriminatedUnion("type", [ const normalize = (value: string | null | undefined) => (value ?? "").trim().toLowerCase(); -const tokenAliases = { - input: ["input", "prompt", "input_tokens", "prompt_tokens"], - cached: [ - "cached_input", - "cached", - "cache", - "input_cached", - "cached_input_tokens", - ], - output: ["output", "completion", "output_tokens", "completion_tokens"], -}; - -const unitDivisor = (unit: "1M" | "1K" | null) => { - if (unit === "1K") return 1_000; - return 1_000_000; -}; - -const priceToCurrency = (price: number) => price / 100; - function getDateRange(input: z.infer) { switch (input.type) { case "daily": { @@ -461,6 +457,18 @@ export const reportingRouter = createTRPCRouter({ : usersBase ).groupBy(users.id, users.name, requests.model); + const scopedConditions = [timeFilter]; + if (scopedUserIds) { + scopedConditions.push(inArray(apikeys.owner, scopedUserIds)); + } + const scopedFilter = and(...scopedConditions); + + const costBucket = + input.range.type === "daily" + ? sql`date_trunc('hour', ${requests.requestTime})` + : sql`date_trunc('day', ${requests.requestTime})`; + + // Fetch and index all pricing rows once; resolve cost per request afterwards. const costRows = await ctx.db .select({ model: costs.model, @@ -469,46 +477,26 @@ export const reportingRouter = createTRPCRouter({ tokenType: costs.tokenType, unitOfMessure: costs.unitOfMessure, currency: costs.currency, + stageType: costs.stageType, + stageMinTokens: costs.stageMinTokens, + stageMaxTokens: costs.stageMaxTokens, }) .from(costs); - const costIndex = new Map< - string, - Map< - string, - { - price: number; - validFrom: string | null; - unit: "1M" | "1K" | null; - currency: string | null; - tokenType: string | null; - } - > - >(); + const costStageRows: CostStageRow[] = costRows.map((row) => ({ + model: row.model ?? "", + tokenType: row.tokenType ?? "", + price: Number(row.price ?? 0), + validFrom: row.validFrom ?? new Date(0), + unitOfMessure: (row.unitOfMessure ?? + null) as CostStageRow["unitOfMessure"], + currency: row.currency ? row.currency.trim().toUpperCase() : null, + stageType: row.stageType ?? "context_length", + stageMinTokens: Number(row.stageMinTokens ?? 0), + stageMaxTokens: row.stageMaxTokens ?? null, + })); - for (const row of costRows) { - const modelKey = normalize(row.model); - const tokenKey = normalize(row.tokenType); - if (!modelKey || !tokenKey) continue; - const modelMap = costIndex.get(modelKey) ?? new Map(); - const existing = modelMap.get(tokenKey); - const currentValidFrom = row.validFrom - ? new Date(row.validFrom).getTime() - : 0; - const existingValidFrom = existing?.validFrom - ? new Date(existing.validFrom).getTime() - : 0; - if (!existing || currentValidFrom >= existingValidFrom) { - modelMap.set(tokenKey, { - price: Number(row.price ?? 0), - validFrom: row.validFrom ?? null, - unit: (row.unitOfMessure ?? null) as "1M" | "1K" | null, - currency: row.currency ? row.currency.trim().toUpperCase() : null, - tokenType: row.tokenType ?? null, - }); - } - costIndex.set(modelKey, modelMap); - } + const costStageIndex = buildCostStageIndex(costStageRows); const usedCosts = new Map< string, @@ -523,178 +511,122 @@ export const reportingRouter = createTRPCRouter({ >(); const registerUsedCost = ( - model: string, - tokenKey: string, - entry: { - price: number; - validFrom: string | null; - unit: "1M" | "1K" | null; - currency: string | null; - tokenType: string | null; - }, + tokenType: "input" | "cached" | "output", + usedCost: CostStageRow, ) => { - const key = `${normalize(model)}::${tokenKey}`; + const stageMin = usedCost.stageMinTokens; + const stageMax = usedCost.stageMaxTokens; + const tokenTypeLabel = tokenTypeLabelWithStage( + tokenType, + stageMin, + stageMax, + ); + const validFrom = usedCost.validFrom + ? new Date(usedCost.validFrom).toISOString().slice(0, 10) + : null; + const key = `${normalize(usedCost.model)}::${tokenTypeLabel}::${validFrom}`; if (usedCosts.has(key)) return; + usedCosts.set(key, { - model, - tokenType: entry.tokenType ?? tokenKey, - price: entry.price, - unit: entry.unit, - currency: entry.currency, - validFrom: entry.validFrom, + model: usedCost.model, + tokenType: tokenTypeLabel, + price: usedCost.price, + unit: usedCost.unitOfMessure as "1M" | "1K" | null, + currency: usedCost.currency + ? usedCost.currency.trim().toUpperCase() + : null, + validFrom, }); }; - const calcTokenCost = ( - model: string, - tokens: number, - aliases: string[], - ) => { - if (tokens <= 0) return { cost: 0, missing: false, currency: null }; - const modelKey = normalize(model); - const modelMap = costIndex.get(modelKey); - if (!modelMap) return { cost: 0, missing: true, currency: null }; - let entry: - | { - price: number; - validFrom: string | null; - unit: "1M" | "1K" | null; - currency: string | null; - tokenType: string | null; - } - | undefined; - let matchedKey: string | null = null; - for (const alias of aliases) { - entry = modelMap.get(alias); - if (entry) { - matchedKey = alias; - break; - } - } - if (!entry) return { cost: 0, missing: true, currency: null }; - if (matchedKey) { - registerUsedCost(model, matchedKey, entry); - } - return { - cost: - (tokens / unitDivisor(entry.unit)) * priceToCurrency(entry.price), - missing: false, - currency: entry.currency, - }; - }; - - const scopedConditions = [timeFilter]; - if (scopedUserIds) { - scopedConditions.push(inArray(apikeys.owner, scopedUserIds)); - } - const scopedFilter = and(...scopedConditions); + const costBuckets = new Map< + string, + ReturnType & { date: Date } + >(); - const costBucket = - input.range.type === "daily" - ? sql`date_trunc('hour', ${requests.requestTime})` - : sql`date_trunc('day', ${requests.requestTime})`; + const costAggByUserModel = new Map< + string, + ReturnType + >(); - const costRowsByBucket = await ctx.db + const requestRowsForCost = await ctx.db .select({ - bucket: costBucket.as("bucket"), + userId: users.id, model: requests.model, - inputTokens: - sql`coalesce(sum(${requests.inputTokenCount} - ${requests.cachedInputTokenCount}), 0)`.as( - "inputTokens", - ), - cachedInputTokens: - sql`coalesce(sum(${requests.cachedInputTokenCount}), 0)`.as( - "cachedInputTokens", - ), - outputTokens: - sql`coalesce(sum(${requests.outputTokenCount}), 0)`.as( - "outputTokens", - ), + requestTime: requests.requestTime, + inputTokenCount: requests.inputTokenCount, + cachedInputTokenCount: requests.cachedInputTokenCount, + outputTokenCount: requests.outputTokenCount, + bucket: costBucket.as("bucket"), }) .from(requests) - .leftJoin(apikeys, eq(apikeys.uuid, requests.apiKeyId)) + .innerJoin(apikeys, eq(apikeys.uuid, requests.apiKeyId)) + .innerJoin(users, eq(apikeys.owner, users.id)) .where(scopedFilter) - .groupBy(costBucket, requests.model); + .orderBy(requests.requestTime); - const costBuckets = new Map< - string, - { - date: Date; - cost: number; - currency: string | null; - missing: boolean; - } - >(); - - for (const row of costRowsByBucket) { + for (const row of requestRowsForCost) { const model = row.model ?? null; - const inputTokens = Number(row.inputTokens ?? 0); - const cachedTokens = Number(row.cachedInputTokens ?? 0); - const outputTokens = Number(row.outputTokens ?? 0); + if (!model) continue; + if (!row.requestTime) continue; - if (!model || inputTokens + cachedTokens + outputTokens <= 0) { - continue; - } + const inputTokenCount = Number(row.inputTokenCount ?? 0); + const cachedInputTokenCount = Number(row.cachedInputTokenCount ?? 0); + const outputTokenCount = Number(row.outputTokenCount ?? 0); + + // Keep output stable: ignore requests that contain no billed tokens. + if (inputTokenCount + outputTokenCount <= 0) continue; const bucketValue = row.bucket; if (!bucketValue) continue; const bucketDate = new Date(bucketValue); const bucketKey = bucketDate.toISOString(); - const inputCost = calcTokenCost(model, inputTokens, tokenAliases.input); - const cachedCost = calcTokenCost( - model, - cachedTokens, - tokenAliases.cached, - ); - const outputCost = calcTokenCost( + const resolved = resolveRequestCostStage(costStageIndex, { model, - outputTokens, - tokenAliases.output, - ); - - const modelMissing = - inputCost.missing || cachedCost.missing || outputCost.missing; - const currencies = [ - inputCost.currency, - cachedCost.currency, - outputCost.currency, - ].filter((value): value is string => Boolean(value)); - const modelCurrency = - currencies.length > 0 && - currencies.every((value) => value === currencies[0]) - ? (currencies[0] ?? null) - : null; - const modelCost = modelMissing - ? null - : inputCost.cost + cachedCost.cost + outputCost.cost; + requestTime: new Date(row.requestTime), + inputTokenCount, + cachedInputTokenCount, + outputTokenCount, + }); const bucket = costBuckets.get(bucketKey) ?? { date: bucketDate, - cost: 0, - currency: null, - missing: false, + ...createTotalCostAggregate(), }; - if (modelCost === null) { - bucket.missing = true; - } else { - bucket.cost += modelCost; - if (bucket.currency === null) { - bucket.currency = modelCurrency; - } else if (modelCurrency && bucket.currency !== modelCurrency) { - bucket.currency = null; + addResolvedTotalCost(bucket, resolved); + if (!resolved.missing) { + if (resolved.inputCost.usedCost) { + registerUsedCost("input", resolved.inputCost.usedCost); + } + if (resolved.cachedCost.usedCost) { + registerUsedCost("cached", resolved.cachedCost.usedCost); + } + if (resolved.outputCost.usedCost) { + registerUsedCost("output", resolved.outputCost.usedCost); } } costBuckets.set(bucketKey, bucket); + + const userId = row.userId; + const userModelKey = `${userId}::${model}`; + const agg = + costAggByUserModel.get(userModelKey) ?? + createBreakdownCostAggregate(); + addResolvedBreakdownCost(agg, resolved); + + costAggByUserModel.set(userModelKey, agg); } const cumulativeCosts = Array.from(costBuckets.values()) .sort((a, b) => a.date.getTime() - b.date.getTime()) .map((bucket) => ({ date: bucket.date.toISOString(), - cost: bucket.missing ? null : bucket.cost, + cost: bucket.missing + ? null + : scaledCostToNumber(bucket.totalCostScaled), })); let runningCost = 0; @@ -779,7 +711,8 @@ export const reportingRouter = createTRPCRouter({ inputTokens: number; cachedInputTokens: number; outputTokens: number; - totalCost: number | null; + totalCostScaled: bigint; + totalCostMissing: boolean; currency: string | null; models: Array<{ model: string; @@ -799,7 +732,8 @@ export const reportingRouter = createTRPCRouter({ let totalInputTokens = 0; let totalCachedTokens = 0; let totalOutputTokens = 0; - let totalCost = 0; + let totalCostScaled = 0n; + let totalCostMissing = false; let totalCurrency: string | null = null; for (const row of usageRows) { @@ -810,7 +744,8 @@ export const reportingRouter = createTRPCRouter({ inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, - totalCost: 0, + totalCostScaled: 0n, + totalCostMissing: false, currency: null, models: [], }; @@ -829,72 +764,50 @@ export const reportingRouter = createTRPCRouter({ const model = row.model ?? null; if (model && inputTokens + cachedTokens + outputTokens > 0) { - const inputCost = calcTokenCost( - model, - inputTokens, - tokenAliases.input, - ); - const cachedCost = calcTokenCost( - model, - cachedTokens, - tokenAliases.cached, - ); - const outputCost = calcTokenCost( - model, - outputTokens, - tokenAliases.output, - ); - - const modelMissing = - inputCost.missing || cachedCost.missing || outputCost.missing; - const currencies = [ - inputCost.currency, - cachedCost.currency, - outputCost.currency, - ].filter((value): value is string => Boolean(value)); - const modelCurrency = - currencies.length > 0 && - currencies.every((value) => value === currencies[0]) - ? (currencies[0] ?? null) - : null; - const modelCost = modelMissing - ? null - : inputCost.cost + cachedCost.cost + outputCost.cost; - - entry.models.push({ - model, - inputTokens, - cachedInputTokens: cachedTokens, - outputTokens, - inputCost: modelMissing ? null : inputCost.cost, - cachedCost: modelMissing ? null : cachedCost.cost, - outputCost: modelMissing ? null : outputCost.cost, - totalCost: modelCost, - currency: modelCost === null ? null : modelCurrency, - }); - - if (modelCost !== null) { - entry.totalCost = (entry.totalCost ?? 0) + modelCost; - if (entry.currency === null) { - entry.currency = modelCurrency; - } else if (modelCurrency && entry.currency !== modelCurrency) { - entry.currency = null; - } - } else { - entry.currency = null; - } + const userModelKey = `${id}::${model}`; + const agg = costAggByUserModel.get(userModelKey); + const modelCostPresentation = presentBreakdownCost(agg); modelTotals.set(model, (modelTotals.get(model) ?? 0) + outputTokens); - if (modelCost !== null) { - totalCost += modelCost; - if (totalCurrency === null) { - totalCurrency = modelCurrency; - } else if (modelCurrency && totalCurrency !== modelCurrency) { - totalCurrency = null; - } - } else { + if (!agg || modelCostPresentation.missing) { + entry.models.push({ + model, + inputTokens, + cachedInputTokens: cachedTokens, + outputTokens, + inputCost: null, + cachedCost: null, + outputCost: null, + totalCost: null, + currency: null, + }); + entry.totalCostMissing = true; + entry.currency = null; + totalCostMissing = true; totalCurrency = null; + } else { + const modelCostScaled = + agg.inputCostScaled + agg.cachedCostScaled + agg.outputCostScaled; + const modelCurrency = modelCostPresentation.currency; + + entry.models.push({ + model, + inputTokens, + cachedInputTokens: cachedTokens, + outputTokens, + inputCost: modelCostPresentation.inputCost, + cachedCost: modelCostPresentation.cachedCost, + outputCost: modelCostPresentation.outputCost, + totalCost: modelCostPresentation.totalCost, + currency: modelCurrency, + }); + + entry.totalCostScaled += modelCostScaled; + entry.currency = mergeCurrency(entry.currency, modelCurrency); + + totalCostScaled += modelCostScaled; + totalCurrency = mergeCurrency(totalCurrency, modelCurrency); } } @@ -902,7 +815,15 @@ export const reportingRouter = createTRPCRouter({ } const usersData = Array.from(usersMap.values()).map((user) => ({ - ...user, + id: user.id, + name: user.name, + inputTokens: user.inputTokens, + cachedInputTokens: user.cachedInputTokens, + outputTokens: user.outputTokens, + totalCost: user.totalCostMissing + ? null + : scaledCostToNumber(user.totalCostScaled), + currency: user.totalCostMissing ? null : user.currency, models: user.models.sort((a, b) => a.model.localeCompare(b.model)), })); @@ -917,8 +838,10 @@ export const reportingRouter = createTRPCRouter({ inputTokens: totalInputTokens, cachedInputTokens: totalCachedTokens, outputTokens: totalOutputTokens, - totalCost: totalCost ?? null, - currency: totalCurrency ?? null, + totalCost: totalCostMissing + ? null + : scaledCostToNumber(totalCostScaled), + currency: totalCostMissing ? null : totalCurrency, }, modelUsage: Array.from(modelTotals.entries()) .map(([model, outputTokens]) => ({ diff --git a/app/src/server/backendName.js b/app/src/server/backendName.js new file mode 100644 index 0000000..3418a1c --- /dev/null +++ b/app/src/server/backendName.js @@ -0,0 +1,9 @@ +export const DEFAULT_BACKEND_NAME = "openai"; + +/** + * @param {string | null | undefined} backendName + */ +export function normalizeBackendName(backendName) { + const normalized = backendName?.trim() ?? ""; + return normalized.length > 0 ? normalized : DEFAULT_BACKEND_NAME; +} diff --git a/app/src/server/backendName.test.js b/app/src/server/backendName.test.js new file mode 100644 index 0000000..b8b54e8 --- /dev/null +++ b/app/src/server/backendName.test.js @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DEFAULT_BACKEND_NAME, normalizeBackendName } from "./backendName.js"; + +test("empty backend name normalizes the same as openai", () => { + assert.equal(normalizeBackendName(null), DEFAULT_BACKEND_NAME); + assert.equal(normalizeBackendName(undefined), DEFAULT_BACKEND_NAME); + assert.equal(normalizeBackendName(""), DEFAULT_BACKEND_NAME); + assert.equal(normalizeBackendName(" "), DEFAULT_BACKEND_NAME); + assert.equal( + normalizeBackendName(" openai "), + normalizeBackendName(DEFAULT_BACKEND_NAME), + ); +}); diff --git a/app/src/server/costAggregation.js b/app/src/server/costAggregation.js new file mode 100644 index 0000000..640dbff --- /dev/null +++ b/app/src/server/costAggregation.js @@ -0,0 +1,185 @@ +const COST_SCALE = 100000000n; +const COST_SCALE_NUMBER = Number(COST_SCALE); + +/** + * @typedef {{ + * missing: boolean; + * totalCost: number; + * currency: string | null; + * }} TotalCostResolution + */ + +/** + * @typedef {{ + * cost: number; + * }} CostPartResolution + */ + +/** + * @typedef {{ + * missing: boolean; + * currency: string | null; + * inputCost: CostPartResolution; + * cachedCost: CostPartResolution; + * outputCost: CostPartResolution; + * }} BreakdownCostResolution + */ + +/** + * @typedef {{ + * missing: boolean; + * currency: string | null; + * totalCostScaled: bigint; + * }} TotalCostAggregate + */ + +/** + * @typedef {{ + * missing: boolean; + * currency: string | null; + * inputCostScaled: bigint; + * cachedCostScaled: bigint; + * outputCostScaled: bigint; + * }} BreakdownCostAggregate + */ + +/** + * @param {number | null | undefined} value + */ +function scaleCost(value) { + return BigInt(Math.round((value ?? 0) * COST_SCALE_NUMBER)); +} + +/** + * @param {bigint} value + */ +export function scaledCostToNumber(value) { + return Number(value) / COST_SCALE_NUMBER; +} + +/** + * @param {string | null | undefined} value + */ +function normalizeCurrency(value) { + const currency = value?.trim().toUpperCase() ?? null; + return currency && currency.length > 0 ? currency : null; +} + +/** + * @param {string | null} current + * @param {string | null | undefined} next + */ +export function mergeCurrency(current, next) { + const normalizedNext = normalizeCurrency(next); + if (normalizedNext === null) { + return current; + } + if (current === null) { + return normalizedNext; + } + return current === normalizedNext ? current : null; +} + +/** + * @returns {TotalCostAggregate} + */ +export function createTotalCostAggregate() { + return { + missing: false, + currency: null, + totalCostScaled: 0n, + }; +} + +/** + * @param {TotalCostAggregate} aggregate + * @param {TotalCostResolution} resolved + */ +export function addResolvedTotalCost(aggregate, resolved) { + if (resolved.missing) { + aggregate.missing = true; + return aggregate; + } + + aggregate.totalCostScaled += scaleCost(resolved.totalCost); + aggregate.currency = mergeCurrency(aggregate.currency, resolved.currency); + return aggregate; +} + +/** + * @param {TotalCostAggregate | undefined} aggregate + */ +export function presentTotalCost(aggregate) { + if (!aggregate || aggregate.missing) { + return { + missing: true, + cost: null, + currency: null, + }; + } + + return { + missing: false, + cost: scaledCostToNumber(aggregate.totalCostScaled), + currency: aggregate.currency, + }; +} + +/** + * @returns {BreakdownCostAggregate} + */ +export function createBreakdownCostAggregate() { + return { + missing: false, + currency: null, + inputCostScaled: 0n, + cachedCostScaled: 0n, + outputCostScaled: 0n, + }; +} + +/** + * @param {BreakdownCostAggregate} aggregate + * @param {BreakdownCostResolution} resolved + */ +export function addResolvedBreakdownCost(aggregate, resolved) { + if (resolved.missing) { + aggregate.missing = true; + return aggregate; + } + + aggregate.inputCostScaled += scaleCost(resolved.inputCost.cost); + aggregate.cachedCostScaled += scaleCost(resolved.cachedCost.cost); + aggregate.outputCostScaled += scaleCost(resolved.outputCost.cost); + aggregate.currency = mergeCurrency(aggregate.currency, resolved.currency); + return aggregate; +} + +/** + * @param {BreakdownCostAggregate | undefined} aggregate + */ +export function presentBreakdownCost(aggregate) { + if (!aggregate || aggregate.missing) { + return { + missing: true, + inputCost: null, + cachedCost: null, + outputCost: null, + totalCost: null, + currency: null, + }; + } + + const inputCost = scaledCostToNumber(aggregate.inputCostScaled); + const cachedCost = scaledCostToNumber(aggregate.cachedCostScaled); + const outputCost = scaledCostToNumber(aggregate.outputCostScaled); + + return { + missing: false, + inputCost, + cachedCost, + outputCost, + totalCost: inputCost + cachedCost + outputCost, + currency: aggregate.currency, + }; +} diff --git a/app/src/server/costAggregation.test.js b/app/src/server/costAggregation.test.js new file mode 100644 index 0000000..f8fe34d --- /dev/null +++ b/app/src/server/costAggregation.test.js @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + addResolvedBreakdownCost, + addResolvedTotalCost, + createBreakdownCostAggregate, + createTotalCostAggregate, + presentBreakdownCost, + presentTotalCost, +} from "./costAggregation.js"; + +test("total cost aggregation keeps resolved rows after missing rows", () => { + const firstOrder = createTotalCostAggregate(); + addResolvedTotalCost(firstOrder, { + missing: true, + totalCost: 0, + currency: null, + }); + addResolvedTotalCost(firstOrder, { + missing: false, + totalCost: 1.25, + currency: "eur", + }); + addResolvedTotalCost(firstOrder, { + missing: false, + totalCost: 2.5, + currency: "EUR", + }); + + const secondOrder = createTotalCostAggregate(); + addResolvedTotalCost(secondOrder, { + missing: false, + totalCost: 2.5, + currency: "EUR", + }); + addResolvedTotalCost(secondOrder, { + missing: true, + totalCost: 0, + currency: null, + }); + addResolvedTotalCost(secondOrder, { + missing: false, + totalCost: 1.25, + currency: "EUR", + }); + + assert.equal(firstOrder.missing, true); + assert.equal(secondOrder.missing, true); + assert.equal(firstOrder.totalCostScaled, 375000000n); + assert.equal(secondOrder.totalCostScaled, 375000000n); + assert.deepEqual(presentTotalCost(firstOrder), { + missing: true, + cost: null, + currency: null, + }); +}); + +test("breakdown aggregation keeps resolved parts after missing rows", () => { + const aggregate = createBreakdownCostAggregate(); + addResolvedBreakdownCost(aggregate, { + missing: true, + currency: null, + inputCost: { cost: 0 }, + cachedCost: { cost: 0 }, + outputCost: { cost: 0 }, + }); + addResolvedBreakdownCost(aggregate, { + missing: false, + currency: "EUR", + inputCost: { cost: 0.5 }, + cachedCost: { cost: 0.25 }, + outputCost: { cost: 0.75 }, + }); + + assert.equal(aggregate.missing, true); + assert.equal(aggregate.inputCostScaled, 50000000n); + assert.equal(aggregate.cachedCostScaled, 25000000n); + assert.equal(aggregate.outputCostScaled, 75000000n); + assert.deepEqual(presentBreakdownCost(aggregate), { + missing: true, + inputCost: null, + cachedCost: null, + outputCost: null, + totalCost: null, + currency: null, + }); +}); diff --git a/app/src/server/costStageResolver.ts b/app/src/server/costStageResolver.ts new file mode 100644 index 0000000..d8e0b16 --- /dev/null +++ b/app/src/server/costStageResolver.ts @@ -0,0 +1,355 @@ +import type { CostUnit } from "~/lib/costs"; + +const ContextLengthStageType = "context_length" as const; + +type CanonicalTokenType = "input" | "cached" | "output"; + +export type CostStageRow = { + model: string; + tokenType: string; + price: number; + validFrom: string | Date; + unitOfMessure: CostUnit | null; + currency: string | null; + stageType: string; + stageMinTokens: number; + stageMaxTokens: number | null; +}; + +export type TokenCostStageResolutionResult = { + cost: number; + currency: string | null; // empty when missing (handled via `missing`) + unit: CostUnit | null; + usedCost: CostStageRow | null; + missing: boolean; + ambiguous: boolean; +}; + +export type RequestCostStageResolutionResult = { + totalCost: number; + currency: string | null; + missing: boolean; + inputCost: TokenCostStageResolutionResult; + cachedCost: TokenCostStageResolutionResult; + outputCost: TokenCostStageResolutionResult; +}; + +function normalizeKey(s: string) { + return s.trim().toLowerCase(); +} + +function canonicalTokenType(tokenType: string): CanonicalTokenType | string { + const t = normalizeKey(tokenType); + switch (t) { + // input-ish + case "input": + case "prompt": + case "input_tokens": + case "prompt_tokens": + case "inp": + return "input"; + // cached-ish + case "cached": + case "cache": + case "cached_input": + case "input_cached": + case "cached_input_tokens": + case "cached_input_token": + return "cached"; + // output-ish + case "output": + case "completion": + case "output_tokens": + case "completion_tokens": + case "outp": + return "output"; + default: + return tokenType; + } +} + +function toMs(value: string | Date | null | undefined) { + if (!value) return 0; + return new Date(value).getTime(); +} + +function unitDivisor(unit: CostUnit | null) { + if (unit === "1K") return 1_000; + // Default: treat unknown/null as "1M" to avoid exploding on partial data. + return 1_000_000; +} + +function priceToCurrency(priceCents: number) { + return priceCents / 100; +} + +function stageContains( + stageMin: number, + stageMax: number | null, + inputTokens: number, +) { + if (inputTokens < stageMin) return false; + if (stageMax === null) return true; + return inputTokens <= stageMax; +} + +function tokenTypeLabelWithStage( + tokenType: CanonicalTokenType, + stageMin: number, + stageMax: number | null, +) { + return stageMax === null + ? `${tokenType} (${stageMin}..∞)` + : `${tokenType} (${stageMin}..${stageMax})`; +} + +export function buildCostStageIndex(costRows: CostStageRow[]) { + const index = new Map(); + + for (const row of costRows) { + const modelKey = normalizeKey(row.model); + const tokenKey = canonicalTokenType(row.tokenType); + const stageTypeKey = normalizeKey(row.stageType || ContextLengthStageType); + + // tokenKey is canonicalized to input/cached/output when possible. + const key = `${modelKey}|${tokenKey}|${stageTypeKey}`; + const existing = index.get(key) ?? []; + existing.push(row); + index.set(key, existing); + } + + return index; +} + +export function resolveTokenCostStage( + costRowsForKey: CostStageRow[], + opts: { + requestTime: Date; + stageType?: string; + inputTokensForStage: number; + tokensToBill: number; + }, +): TokenCostStageResolutionResult { + if (opts.tokensToBill <= 0) { + return { + cost: 0, + currency: null, + unit: null, + usedCost: null, + missing: false, + ambiguous: false, + }; + } + + if (!costRowsForKey.length) { + return { + cost: 0, + currency: null, + unit: null, + usedCost: null, + missing: true, + ambiguous: false, + }; + } + + const requestTimeMs = opts.requestTime.getTime(); + + // 1) Select newest valid_from <= request_time + let latestValidFromMs = -Infinity; + for (const c of costRowsForKey) { + const validFromMs = toMs(c.validFrom); + if (validFromMs <= requestTimeMs && validFromMs > latestValidFromMs) { + latestValidFromMs = validFromMs; + } + } + + if (!Number.isFinite(latestValidFromMs)) { + return { + cost: 0, + currency: null, + unit: null, + usedCost: null, + missing: true, + ambiguous: false, + }; + } + + const candidatesAtLatest = costRowsForKey.filter( + (c) => toMs(c.validFrom) === latestValidFromMs, + ); + + // 2) Select stage whose range contains request input_token_count + const matchingStages = candidatesAtLatest.filter((c) => + stageContains(c.stageMinTokens, c.stageMaxTokens, opts.inputTokensForStage), + ); + + if (!matchingStages.length) { + return { + cost: 0, + currency: null, + unit: null, + usedCost: null, + missing: true, + ambiguous: false, + }; + } + + // Deterministic tie-break: + // - highest stage_min_tokens wins + // - then lowest stage_max_tokens wins (treat NULL as +Inf) + let best = matchingStages[0]; + if (!best) { + return { + cost: 0, + currency: null, + unit: null, + usedCost: null, + missing: true, + ambiguous: false, + }; + } + let bestMax = best.stageMaxTokens ?? Number.POSITIVE_INFINITY; + + for (const c of matchingStages.slice(1)) { + const cMax = c.stageMaxTokens ?? Number.POSITIVE_INFINITY; + + if (c.stageMinTokens > best.stageMinTokens) { + best = c; + bestMax = cMax; + continue; + } + if (c.stageMinTokens === best.stageMinTokens && cMax < bestMax) { + best = c; + bestMax = cMax; + } + } + + // If multiple rows have the exact same stage bounds, ensure they agree on price/unit/currency. + const bestUnit = best.unitOfMessure ?? null; + const bestCurrency = best.currency?.trim() ?? null; + + let conflicts = 0; + for (const c of matchingStages) { + const sameMax = + (best.stageMaxTokens === null && c.stageMaxTokens === null) || + (best.stageMaxTokens !== null && + c.stageMaxTokens !== null && + best.stageMaxTokens === c.stageMaxTokens); + + if (c.stageMinTokens === best.stageMinTokens && sameMax) { + const cUnit = c.unitOfMessure ?? null; + const cCurrency = c.currency?.trim() ?? null; + + if ( + c.price !== best.price || + cUnit !== bestUnit || + cCurrency !== bestCurrency + ) { + conflicts += 1; + } + } + } + + if (conflicts > 0) { + return { + cost: 0, + currency: null, + unit: null, + usedCost: null, + missing: true, + ambiguous: true, + }; + } + + const divisor = unitDivisor(best.unitOfMessure); + const cost = (opts.tokensToBill / divisor) * priceToCurrency(best.price); + + return { + cost, + currency: best.currency?.trim() ?? null, + unit: best.unitOfMessure ?? null, + usedCost: best, + missing: false, + ambiguous: false, + }; +} + +export function resolveRequestCostStage( + costStageIndex: Map, + req: { + model: string; + requestTime: Date; + inputTokenCount: number; + cachedInputTokenCount: number; + outputTokenCount: number; + stageType?: string; + }, +): RequestCostStageResolutionResult { + const stageType = req.stageType?.trim() || ContextLengthStageType; + + const promptTokens = Math.max( + 0, + req.inputTokenCount - req.cachedInputTokenCount, + ); + const cachedTokens = Math.max(0, req.cachedInputTokenCount); + const outputTokens = Math.max(0, req.outputTokenCount); + + const modelKey = normalizeKey(req.model); + const stageTypeKey = normalizeKey(stageType); + + const resolveForTokenType = ( + tokenType: CanonicalTokenType, + tokensToBill: number, + ) => { + const key = `${modelKey}|${tokenType}|${stageTypeKey}`; + const costRowsForKey = costStageIndex.get(key) ?? []; + return resolveTokenCostStage(costRowsForKey, { + requestTime: req.requestTime, + inputTokensForStage: req.inputTokenCount, + tokensToBill, + }); + }; + + const inputCost = resolveForTokenType("input", promptTokens); + const cachedCost = resolveForTokenType("cached", cachedTokens); + const outputCost = resolveForTokenType("output", outputTokens); + + const missing = inputCost.missing || cachedCost.missing || outputCost.missing; + if (missing) { + return { + totalCost: 0, + currency: null, + missing: true, + inputCost, + cachedCost, + outputCost, + }; + } + + const currencies = [ + inputCost.usedCost?.currency, + cachedCost.usedCost?.currency, + outputCost.usedCost?.currency, + ] + .filter((c): c is string => Boolean(c)) + .map((c) => c.trim()); + + let currency: string | null = null; + if (currencies.length > 0) { + const first = currencies[0]; + if (first !== undefined && currencies.every((c) => c === first)) { + currency = first; + } + } + + return { + totalCost: inputCost.cost + cachedCost.cost + outputCost.cost, + currency, + missing: false, + inputCost, + cachedCost, + outputCost, + }; +} + +export { tokenTypeLabelWithStage }; diff --git a/app/src/server/db/schema.ts b/app/src/server/db/schema.ts index 1e5c5f0..dc20169 100644 --- a/app/src/server/db/schema.ts +++ b/app/src/server/db/schema.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { bigint, boolean, @@ -11,6 +12,7 @@ import { primaryKey, text, timestamp, + uniqueIndex, varchar, } from "drizzle-orm/pg-core"; @@ -83,7 +85,6 @@ export const apikeys = pgTable( uuid: varchar({ length: 255 }).primaryKey().notNull(), apikey: varchar({ length: 255 }).notNull(), owner: varchar({ length: 255 }).notNull(), - aiapi: varchar({ length: 255 }), description: varchar({ length: 255 }), deactivated: boolean("deactivated").default(false).notNull(), }, @@ -201,26 +202,35 @@ 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(), tokenType: varchar("token_type", { length: 255 }).notNull(), unitOfMessure: costUnit("unit_of_messure"), - 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", - }), + uniqueIndex("costs_natural_key_idx").on( + table.model, + table.validFrom, + table.tokenType, + table.unitOfMessure, + table.currency, + table.stageType, + table.stageMinTokens, + sql`COALESCE(${table.stageMaxTokens}, -1)`, + ), ], ); diff --git a/costs/azureCostCollector.go b/costs/azureCostCollector.go index 0fe7b89..d70fa3e 100644 --- a/costs/azureCostCollector.go +++ b/costs/azureCostCollector.go @@ -24,7 +24,6 @@ type Costs struct { UnitOfMeasure string `json:"unitOfMeasure"` TokenType string Currency string - IsRegional bool } const MoneyUnit = 10000000 @@ -65,7 +64,6 @@ func GetAllCosts(d *db.Database, g <-chan time.Time) { c := GetCosts(skuName, tokenType) if c.SKUName != "" { c.TokenType = tokenType - c.IsRegional = regional c.ModelName = model totalCosts = append(totalCosts, c) } @@ -79,8 +77,6 @@ func GetAllCosts(d *db.Database, g <-chan time.Time) { RetailPrice: int(costs.RetailPrice * float32(MoneyUnit)), TokenType: costs.TokenType, UnitOfMeasure: costs.UnitOfMeasure, - IsRegional: costs.IsRegional, - BackendName: "azure", } dbcosts = append(dbcosts, dbc) } diff --git a/db/cost_stage_resolver.go b/db/cost_stage_resolver.go new file mode 100644 index 0000000..8fa4573 --- /dev/null +++ b/db/cost_stage_resolver.go @@ -0,0 +1,321 @@ +package database + +import ( + "strings" + "time" +) + +const ContextLengthStageType = "context_length" + +type TokenCostStageResolutionRequest struct { + Model string + TokenType string + StageType string + RequestTime time.Time + InputTokensForStage int // stored request input_token_count + TokensToBill int // prompt/cached/output tokens to price +} + +type TokenCostStageResolutionResult struct { + Cost float64 + Currency string // empty when missing + Unit string // "1M"/"1K" (or empty if missing) + UsedCost *Costs + Missing bool + Ambiguous bool +} + +type RequestCostStageResolutionRequest struct { + Model string + RequestTime time.Time + InputTokenCount int + CachedInputTokenCount int + OutputTokenCount int + StageType string +} + +type RequestCostStageResolutionResult struct { + TotalCost float64 + Currency string // empty when missing or currency mismatch + Missing bool + + InputCost TokenCostStageResolutionResult + CachedCost TokenCostStageResolutionResult + OutputCost TokenCostStageResolutionResult +} + +func normalizeKey(s string) string { + return strings.TrimSpace(strings.ToLower(s)) +} + +func canonicalTokenTypeKey(tokenType string) string { + t := normalizeKey(tokenType) + switch t { + case "input", "prompt", "input_tokens", "prompt_tokens", "inp": + return "input" + case "cached", "cache", "cached_input", "input_cached", "cached_input_tokens", "cached_input_token": + return "cached" + case "output", "completion", "output_tokens", "completion_tokens", "outp": + return "output" + default: + return t + } +} + +func unitDivisor(unit string) float64 { + if strings.TrimSpace(unit) == "1K" { + return 1_000 + } + // Default: treat unknown/null as "1M" to avoid exploding on partial data. + return 1_000_000 +} + +func priceToCurrency(priceCents int) float64 { + return float64(priceCents) / 100 +} + +func stageContains(stageMin int, stageMax *int, inputTokens int) bool { + if inputTokens < stageMin { + return false + } + if stageMax == nil { + return true + } + return inputTokens <= *stageMax +} + +// ResolveTokenCostStage resolves the single best cost stage row for a token type +// within the newest valid_from <= requestTime, then the stage whose range contains +// InputTokensForStage. +// +// Missing: +// - true when TokensToBill > 0 but no stage row matches. +// - false when TokensToBill <= 0 (cost is 0, stage selection is irrelevant). +// Ambiguous: +// - true when multiple stage rows match with identical stage bounds but conflicting prices/currency. +func ResolveTokenCostStage(costRows []Costs, req TokenCostStageResolutionRequest) TokenCostStageResolutionResult { + if req.StageType == "" { + req.StageType = ContextLengthStageType + } + if req.TokensToBill <= 0 { + return TokenCostStageResolutionResult{ + Cost: 0, + Currency: "", + Unit: "", + UsedCost: nil, + Missing: false, + Ambiguous: false, + } + } + + modelKey := normalizeKey(req.Model) + tokenKey := canonicalTokenTypeKey(req.TokenType) + stageTypeKey := normalizeKey(req.StageType) + + candidates := make([]Costs, 0, len(costRows)) + for _, c := range costRows { + if normalizeKey(c.ModelName) != modelKey { + continue + } + if canonicalTokenTypeKey(c.TokenType) != tokenKey { + continue + } + if normalizeKey(c.StageType) != stageTypeKey { + continue + } + if c.RequestTime.After(req.RequestTime) { + continue + } + candidates = append(candidates, c) + } + + if len(candidates) == 0 { + return TokenCostStageResolutionResult{ + Missing: true, + Ambiguous: false, + } + } + + // 1) Select newest valid_from <= request_time + latestValidFrom := candidates[0].RequestTime + for _, c := range candidates[1:] { + if c.RequestTime.After(latestValidFrom) { + latestValidFrom = c.RequestTime + } + } + + candidatesAtLatest := make([]Costs, 0, len(candidates)) + for _, c := range candidates { + if c.RequestTime.Equal(latestValidFrom) { + candidatesAtLatest = append(candidatesAtLatest, c) + } + } + + // 2) Select stage whose range contains input token count + matchingStages := make([]Costs, 0, len(candidatesAtLatest)) + for _, c := range candidatesAtLatest { + if stageContains(c.StageMinTokens, c.StageMaxTokens, req.InputTokensForStage) { + matchingStages = append(matchingStages, c) + } + } + if len(matchingStages) == 0 { + return TokenCostStageResolutionResult{ + Missing: true, + Ambiguous: false, + } + } + + // Deterministic tie-break: + // - highest stage_min_tokens wins + // - then lowest stage_max_tokens wins (treat NULL as +Inf) + maxInt := int(^uint(0) >> 1) + best := matchingStages[0] + for _, c := range matchingStages[1:] { + bestMax := maxInt + if best.StageMaxTokens != nil { + bestMax = *best.StageMaxTokens + } + cMax := maxInt + if c.StageMaxTokens != nil { + cMax = *c.StageMaxTokens + } + + if c.StageMinTokens > best.StageMinTokens { + best = c + continue + } + if c.StageMinTokens == best.StageMinTokens && cMax < bestMax { + best = c + continue + } + } + + // If multiple rows have the exact same stage bounds, ensure they agree on the price. + var ( + conflicts = 0 + bestUnit = strings.TrimSpace(best.UnitOfMeasure) + bestCurrency = strings.TrimSpace(best.Currency) + ) + for _, c := range matchingStages { + cStageMax := c.StageMaxTokens + sameMax := (best.StageMaxTokens == nil && cStageMax == nil) || + (best.StageMaxTokens != nil && cStageMax != nil && *best.StageMaxTokens == *cStageMax) + if c.StageMinTokens == best.StageMinTokens && sameMax { + if c.RetailPrice != best.RetailPrice || + strings.TrimSpace(c.UnitOfMeasure) != bestUnit || + strings.TrimSpace(c.Currency) != bestCurrency { + conflicts++ + } + } + } + if conflicts > 0 { + return TokenCostStageResolutionResult{ + Missing: true, + Ambiguous: true, + } + } + + divisor := unitDivisor(best.UnitOfMeasure) + cost := (float64(req.TokensToBill) / divisor) * priceToCurrency(best.RetailPrice) + return TokenCostStageResolutionResult{ + Cost: cost, + Currency: strings.TrimSpace(best.Currency), + Unit: strings.TrimSpace(best.UnitOfMeasure), + UsedCost: &best, + Missing: false, + Ambiguous: false, + } +} + +func ResolveRequestCostStage(costRows []Costs, req RequestCostStageResolutionRequest) RequestCostStageResolutionResult { + if req.StageType == "" { + req.StageType = ContextLengthStageType + } + + promptTokens := req.InputTokenCount - req.CachedInputTokenCount + if promptTokens < 0 { + promptTokens = 0 + } + cachedTokens := req.CachedInputTokenCount + if cachedTokens < 0 { + cachedTokens = 0 + } + outputTokens := req.OutputTokenCount + if outputTokens < 0 { + outputTokens = 0 + } + + inputRes := ResolveTokenCostStage(costRows, TokenCostStageResolutionRequest{ + Model: req.Model, + TokenType: "input", + StageType: req.StageType, + RequestTime: req.RequestTime, + InputTokensForStage: req.InputTokenCount, + TokensToBill: promptTokens, + }) + cachedRes := ResolveTokenCostStage(costRows, TokenCostStageResolutionRequest{ + Model: req.Model, + TokenType: "cached", + StageType: req.StageType, + RequestTime: req.RequestTime, + InputTokensForStage: req.InputTokenCount, + TokensToBill: cachedTokens, + }) + outputRes := ResolveTokenCostStage(costRows, TokenCostStageResolutionRequest{ + Model: req.Model, + TokenType: "output", + StageType: req.StageType, + RequestTime: req.RequestTime, + InputTokensForStage: req.InputTokenCount, + TokensToBill: outputTokens, + }) + + // Missing if any token type that has >0 tokens can't be resolved. + missing := inputRes.Missing || cachedRes.Missing || outputRes.Missing + if missing { + return RequestCostStageResolutionResult{ + TotalCost: 0, + Currency: "", + Missing: true, + InputCost: inputRes, + CachedCost: cachedRes, + OutputCost: outputRes, + } + } + + // Currency selection: all resolved token parts must agree on currency. + currencies := make([]string, 0, 3) + if inputRes.UsedCost != nil && inputRes.Currency != "" { + currencies = append(currencies, inputRes.Currency) + } + if cachedRes.UsedCost != nil && cachedRes.Currency != "" { + currencies = append(currencies, cachedRes.Currency) + } + if outputRes.UsedCost != nil && outputRes.Currency != "" { + currencies = append(currencies, outputRes.Currency) + } + + var currency string + if len(currencies) > 0 { + allSame := true + for i := 1; i < len(currencies); i++ { + if currencies[i] != currencies[0] { + allSame = false + break + } + } + if allSame { + currency = currencies[0] + } + } + + total := inputRes.Cost + cachedRes.Cost + outputRes.Cost + return RequestCostStageResolutionResult{ + TotalCost: total, + Currency: currency, + Missing: false, + InputCost: inputRes, + CachedCost: cachedRes, + OutputCost: outputRes, + } +} diff --git a/db/cost_stage_resolver_test.go b/db/cost_stage_resolver_test.go new file mode 100644 index 0000000..0939dad --- /dev/null +++ b/db/cost_stage_resolver_test.go @@ -0,0 +1,258 @@ +package database + +import ( + "testing" + "time" +) + +func ptrInt(v int) *int { return &v } + +func TestResolveRequestCostStage_GPT54ShortContextUsesShortStage(t *testing.T) { + validFrom := time.Date(2026, 3, 23, 0, 0, 0, 0, time.UTC) + requestTime := time.Date(2026, 3, 23, 15, 0, 0, 0, time.UTC) + + // Short tier: input_tokens <= 272000 + // Long tier: input_tokens >= 272001 + shortMax := 272000 + + costRows := []Costs{ + // Existing single-stage row (converted to open-ended). + {ModelName: "gpt-5.4-mini", RetailPrice: 65, RequestTime: validFrom, TokenType: "input", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: nil}, + {ModelName: "gpt-5.4-mini", RetailPrice: 7, RequestTime: validFrom, TokenType: "cached", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: nil}, + {ModelName: "gpt-5.4-mini", RetailPrice: 392, RequestTime: validFrom, TokenType: "output", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: nil}, + + // Explicit short stage. + {ModelName: "gpt-5.4-mini", RetailPrice: 65, RequestTime: validFrom, TokenType: "input", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: ptrInt(shortMax)}, + {ModelName: "gpt-5.4-mini", RetailPrice: 7, RequestTime: validFrom, TokenType: "cached", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: ptrInt(shortMax)}, + {ModelName: "gpt-5.4-mini", RetailPrice: 392, RequestTime: validFrom, TokenType: "output", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: ptrInt(shortMax)}, + + // Explicit long stage. + {ModelName: "gpt-5.4-mini", RetailPrice: 130, RequestTime: validFrom, TokenType: "input", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 272001, StageMaxTokens: nil}, + {ModelName: "gpt-5.4-mini", RetailPrice: 14, RequestTime: validFrom, TokenType: "cached", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 272001, StageMaxTokens: nil}, + {ModelName: "gpt-5.4-mini", RetailPrice: 588, RequestTime: validFrom, TokenType: "output", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 272001, StageMaxTokens: nil}, + } + + req := RequestCostStageResolutionRequest{ + Model: "gpt-5.4-mini", + RequestTime: requestTime, + InputTokenCount: 200_000, + CachedInputTokenCount: 50_000, + OutputTokenCount: 100_000, + StageType: ContextLengthStageType, + } + + res := ResolveRequestCostStage(costRows, req) + if res.Missing { + t.Fatalf("expected Missing=false, got true") + } + + if res.InputCost.UsedCost == nil || res.InputCost.UsedCost.StageMaxTokens == nil { + t.Fatalf("expected short stage match for input") + } + if *res.InputCost.UsedCost.StageMaxTokens != shortMax { + t.Fatalf("expected short stage max=%d, got %v", shortMax, res.InputCost.UsedCost.StageMaxTokens) + } + if res.InputCost.UsedCost.RetailPrice != 65 { + t.Fatalf("expected short input price=65, got %d", res.InputCost.UsedCost.RetailPrice) + } + + // Cached tokens must still be priced in the same short context stage (stage selector uses input_token_count). + if res.CachedCost.UsedCost == nil || res.CachedCost.UsedCost.StageMaxTokens == nil { + t.Fatalf("expected short stage match for cached") + } + if *res.CachedCost.UsedCost.StageMaxTokens != shortMax { + t.Fatalf("expected short stage max=%d for cached, got %v", shortMax, res.CachedCost.UsedCost.StageMaxTokens) + } + if res.CachedCost.UsedCost.RetailPrice != 7 { + t.Fatalf("expected short cached price=7, got %d", res.CachedCost.UsedCost.RetailPrice) + } + + if res.OutputCost.UsedCost == nil || res.OutputCost.UsedCost.StageMaxTokens == nil { + t.Fatalf("expected short stage match for output") + } + if *res.OutputCost.UsedCost.StageMaxTokens != shortMax { + t.Fatalf("expected short stage max=%d for output, got %v", shortMax, res.OutputCost.UsedCost.StageMaxTokens) + } + if res.OutputCost.UsedCost.RetailPrice != 392 { + t.Fatalf("expected short output price=392, got %d", res.OutputCost.UsedCost.RetailPrice) + } +} + +func TestResolveRequestCostStage_GPT54LongContextUsesLongStage(t *testing.T) { + validFrom := time.Date(2026, 3, 23, 0, 0, 0, 0, time.UTC) + requestTime := time.Date(2026, 3, 23, 15, 0, 0, 0, time.UTC) + + shortMax := 272000 + + costRows := []Costs{ + // Open-ended converted row. + {ModelName: "gpt-5.4-mini", RetailPrice: 65, RequestTime: validFrom, TokenType: "input", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: nil}, + {ModelName: "gpt-5.4-mini", RetailPrice: 7, RequestTime: validFrom, TokenType: "cached", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: nil}, + {ModelName: "gpt-5.4-mini", RetailPrice: 392, RequestTime: validFrom, TokenType: "output", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: nil}, + + // Short stage. + {ModelName: "gpt-5.4-mini", RetailPrice: 65, RequestTime: validFrom, TokenType: "input", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: ptrInt(shortMax)}, + {ModelName: "gpt-5.4-mini", RetailPrice: 7, RequestTime: validFrom, TokenType: "cached", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: ptrInt(shortMax)}, + {ModelName: "gpt-5.4-mini", RetailPrice: 392, RequestTime: validFrom, TokenType: "output", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: ptrInt(shortMax)}, + + // Long stage. + {ModelName: "gpt-5.4-mini", RetailPrice: 130, RequestTime: validFrom, TokenType: "input", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 272001, StageMaxTokens: nil}, + {ModelName: "gpt-5.4-mini", RetailPrice: 14, RequestTime: validFrom, TokenType: "cached", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 272001, StageMaxTokens: nil}, + {ModelName: "gpt-5.4-mini", RetailPrice: 588, RequestTime: validFrom, TokenType: "output", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 272001, StageMaxTokens: nil}, + } + + req := RequestCostStageResolutionRequest{ + Model: "gpt-5.4-mini", + RequestTime: requestTime, + InputTokenCount: 300_000, // >272k + CachedInputTokenCount: 100_000, + OutputTokenCount: 50_000, + StageType: ContextLengthStageType, + } + + res := ResolveRequestCostStage(costRows, req) + if res.Missing { + t.Fatalf("expected Missing=false, got true") + } + + if res.InputCost.UsedCost == nil || res.InputCost.UsedCost.StageMinTokens != 272001 { + t.Fatalf("expected long stage match for input") + } + if res.InputCost.UsedCost.RetailPrice != 130 { + t.Fatalf("expected long input price=130, got %d", res.InputCost.UsedCost.RetailPrice) + } + + if res.CachedCost.UsedCost == nil || res.CachedCost.UsedCost.StageMinTokens != 272001 { + t.Fatalf("expected long stage match for cached") + } + if res.CachedCost.UsedCost.RetailPrice != 14 { + t.Fatalf("expected long cached price=14, got %d", res.CachedCost.UsedCost.RetailPrice) + } + + if res.OutputCost.UsedCost == nil || res.OutputCost.UsedCost.StageMinTokens != 272001 { + t.Fatalf("expected long stage match for output") + } + if res.OutputCost.UsedCost.RetailPrice != 588 { + t.Fatalf("expected long output price=588, got %d", res.OutputCost.UsedCost.RetailPrice) + } +} + +func TestResolveRequestCostStage_SingleStageModelResolvesAsOpenEnded(t *testing.T) { + validFrom := time.Date(2026, 3, 23, 0, 0, 0, 0, time.UTC) + requestTime := time.Date(2026, 3, 23, 15, 0, 0, 0, time.UTC) + + costRows := []Costs{ + {ModelName: "gpt-4o", RetailPrice: 212, RequestTime: validFrom, TokenType: "input", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: nil}, + {ModelName: "gpt-4o", RetailPrice: 106, RequestTime: validFrom, TokenType: "cached", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: nil}, + {ModelName: "gpt-4o", RetailPrice: 847, RequestTime: validFrom, TokenType: "output", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: nil}, + } + + req := RequestCostStageResolutionRequest{ + Model: "gpt-4o", + RequestTime: requestTime, + InputTokenCount: 999_999, + CachedInputTokenCount: 10_000, + OutputTokenCount: 20_000, + StageType: ContextLengthStageType, + } + + res := ResolveRequestCostStage(costRows, req) + if res.Missing { + t.Fatalf("expected Missing=false, got true") + } + + if res.InputCost.UsedCost == nil || res.InputCost.UsedCost.StageMaxTokens != nil { + t.Fatalf("expected open-ended stage match for input") + } + if res.InputCost.UsedCost.RetailPrice != 212 { + t.Fatalf("expected input price=212, got %d", res.InputCost.UsedCost.RetailPrice) + } +} + +func TestResolveRequestCostStage_MissingModelOrStage(t *testing.T) { + validFrom := time.Date(2026, 3, 23, 0, 0, 0, 0, time.UTC) + requestTime := time.Date(2026, 3, 23, 15, 0, 0, 0, time.UTC) + + t.Run("missing model", func(t *testing.T) { + costRows := []Costs{ + {ModelName: "some-other-model", RetailPrice: 212, RequestTime: validFrom, TokenType: "input", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: nil}, + } + + req := RequestCostStageResolutionRequest{ + Model: "missing-model", + RequestTime: requestTime, + InputTokenCount: 200_000, + CachedInputTokenCount: 0, + OutputTokenCount: 0, + StageType: ContextLengthStageType, + } + + res := ResolveRequestCostStage(costRows, req) + if !res.Missing { + t.Fatalf("expected Missing=true for missing model") + } + }) + + t.Run("missing stage range", func(t *testing.T) { + max := 1000 + costRows := []Costs{ + {ModelName: "gpt-test", RetailPrice: 212, RequestTime: validFrom, TokenType: "input", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: ptrInt(max)}, + // No open-ended stage row; inputTokens=5000 won't match. + {ModelName: "gpt-test", RetailPrice: 106, RequestTime: validFrom, TokenType: "cached", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: ptrInt(max)}, + {ModelName: "gpt-test", RetailPrice: 847, RequestTime: validFrom, TokenType: "output", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: ptrInt(max)}, + } + + req := RequestCostStageResolutionRequest{ + Model: "gpt-test", + RequestTime: requestTime, + InputTokenCount: 5000, + CachedInputTokenCount: 0, + OutputTokenCount: 0, + StageType: ContextLengthStageType, + } + + res := ResolveRequestCostStage(costRows, req) + if !res.Missing { + t.Fatalf("expected Missing=true for missing stage range") + } + }) +} + +func TestResolveRequestCostStage_AmbiguousOverlappingStagesResolvedDeterministically(t *testing.T) { + validFrom := time.Date(2026, 3, 23, 0, 0, 0, 0, time.UTC) + requestTime := time.Date(2026, 3, 23, 15, 0, 0, 0, time.UTC) + + // Overlapping tiers: + // - stage A: [0..300000] price 10 + // - stage B: [100000..500000] price 20 (more specific; should win) + aMax := 300000 + bMax := 500000 + + costRows := []Costs{ + {ModelName: "overlap-model", RetailPrice: 10, RequestTime: validFrom, TokenType: "input", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 0, StageMaxTokens: ptrInt(aMax)}, + {ModelName: "overlap-model", RetailPrice: 20, RequestTime: validFrom, TokenType: "input", UnitOfMeasure: "1M", Currency: "EUR", StageType: ContextLengthStageType, StageMinTokens: 100000, StageMaxTokens: ptrInt(bMax)}, + + // Keep cached/output at 0 tokens to focus the test. + } + + req := RequestCostStageResolutionRequest{ + Model: "overlap-model", + RequestTime: requestTime, + InputTokenCount: 200_000, + CachedInputTokenCount: 0, + OutputTokenCount: 0, + StageType: ContextLengthStageType, + } + + res := ResolveRequestCostStage(costRows, req) + if res.Missing { + t.Fatalf("expected Missing=false, got true") + } + + if res.InputCost.UsedCost == nil { + t.Fatalf("expected input stage match") + } + if res.InputCost.UsedCost.RetailPrice != 20 { + t.Fatalf("expected overlapping stage B price=20, got %d", res.InputCost.UsedCost.RetailPrice) + } +} diff --git a/db/database.go b/db/database.go index f3a86e2..dda2a53 100644 --- a/db/database.go +++ b/db/database.go @@ -19,7 +19,6 @@ type ApiKey struct { UUID string // ID that will be displayed in UI ApiKey string // Backend, not implemented yet Owner string // sub from oidc claims or name string on return - AiApi string // can be openai or azure Description string // optional, user can describe his key Deactivated bool TokenCountPrompt *int @@ -97,8 +96,8 @@ func (d *Database) LookupDatabasePath() string { func (d *Database) WriteEntry(a *ApiKey) error { _, err := d.db.Exec( - "INSERT INTO apiKeys (UUID, ApiKey, Owner, AiApi, Description, Deactivated) VALUES ($1, $2, $3, $4, $5, $6)", - a.UUID, a.ApiKey, a.Owner, a.AiApi, a.Description, a.Deactivated, + "INSERT INTO apiKeys (UUID, ApiKey, Owner, Description, Deactivated) VALUES ($1, $2, $3, $4, $5)", + a.UUID, a.ApiKey, a.Owner, a.Description, a.Deactivated, ) if err != nil { log.Printf("Api-Key Insert Failed: %v", err) @@ -149,7 +148,7 @@ func (d *Database) LookupApiKeyInfos(uid string) ([]ApiKey, error) { var apikeys []ApiKey rows, err := d.db.Query(` SELECT - a.UUID, a.Owner, a.AiApi, a.Description, + a.UUID, a.Owner, a.Description, COALESCE(SUM(r.input_token_count), 0), COALESCE(SUM(r.cached_input_token_count), 0), COALESCE(SUM(r.output_token_count), 0) @@ -165,7 +164,7 @@ func (d *Database) LookupApiKeyInfos(uid string) ([]ApiKey, error) { var a ApiKey var inputTotal, cachedTotal, outputTotal int if err := rows.Scan( - &a.UUID, &a.Owner, &a.AiApi, &a.Description, + &a.UUID, &a.Owner, &a.Description, &inputTotal, &cachedTotal, &outputTotal, ); err != nil { return apikeys, err @@ -190,28 +189,68 @@ func (d *Database) LookupApiKeyInfos(uid string) ([]ApiKey, error) { } type Costs struct { + ID int64 ModelName string RetailPrice int TokenType string UnitOfMeasure string - IsRegional bool - BackendName string // currently only azure Currency string RequestTime time.Time + + // Stage pricing (e.g. context length). + StageType string + StageMinTokens int + StageMaxTokens *int // NULL means open-ended stage } func (d *Database) WriteCosts(carray []*Costs) error { for _, c := range carray { - _, err := d.db.Exec(` + stageType := c.StageType + if strings.TrimSpace(stageType) == "" { + stageType = ContextLengthStageType + } + validFrom := c.RequestTime + if validFrom.IsZero() { + now := time.Now().UTC() + validFrom = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + } + + result, err := d.db.Exec(` INSERT INTO costs - (model,price,token_type,unit_of_messure,is_regional,backend_name,currency) + ( + model, price, valid_from, token_type, unit_of_messure, + currency, stage_type, stage_min_tokens, stage_max_tokens + ) VALUES - ($1, $2, $3, $4, $5, $6, $7)`, c.ModelName, c.RetailPrice, c.TokenType, c.UnitOfMeasure, c.IsRegional, c.BackendName, c.Currency) - if !strings.Contains(fmt.Sprintf("%s", err), "SQLSTATE 23505") { + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT ( + model, + valid_from, + token_type, + unit_of_messure, + currency, + stage_type, + stage_min_tokens, + (COALESCE(stage_max_tokens, -1)) + ) DO NOTHING`, + c.ModelName, + c.RetailPrice, + validFrom, + c.TokenType, + c.UnitOfMeasure, + c.Currency, + stageType, + c.StageMinTokens, + c.StageMaxTokens, + ) + if err != nil { log.Println(err) } if err == nil { - log.Printf("%s-costs: Wrote %s-Costs (%v) for Model %s to db. Unit: %s", c.BackendName, c.TokenType, c.RetailPrice, c.ModelName, c.UnitOfMeasure) + rowsAffected, rowsErr := result.RowsAffected() + if rowsErr == nil && rowsAffected > 0 { + log.Printf("costs: Wrote %s-Costs (%v) for Model %s to db. Unit: %s", c.TokenType, c.RetailPrice, c.ModelName, c.UnitOfMeasure) + } } } log.Println("Azure: Collecting Prices Done!") @@ -282,17 +321,54 @@ type RequestSummary struct { } func (d *Database) LookupCosts(model string) (carray []Costs) { - rows, err := d.db.Query("select * from costs where model = $1", model) + rows, err := d.db.Query(` + SELECT + id, + model, + price, + valid_from, + token_type, + unit_of_messure, + currency, + stage_type, + stage_min_tokens, + stage_max_tokens + FROM costs + WHERE model = $1`, model) if err != nil { return nil } var c Costs for rows.Next() { - if err := rows.Scan(&c.ModelName, &c.RetailPrice, &c.RequestTime, &c.TokenType, &c.UnitOfMeasure, &c.IsRegional, &c.BackendName, &c.Currency); err != nil { + var currency sql.NullString + var stageMax sql.NullInt64 + if err := rows.Scan( + &c.ID, + &c.ModelName, + &c.RetailPrice, + &c.RequestTime, + &c.TokenType, + &c.UnitOfMeasure, + ¤cy, + &c.StageType, + &c.StageMinTokens, + &stageMax, + ); err != nil { log.Println("DB Error for looking up costs: ", err) return carray } + if currency.Valid { + c.Currency = currency.String + } else { + c.Currency = "" + } + if stageMax.Valid { + v := int(stageMax.Int64) + c.StageMaxTokens = &v + } else { + c.StageMaxTokens = nil + } carray = append(carray, c) } return carray @@ -400,6 +476,93 @@ func (d *Database) LookupApiKeyUserStats(uid string, kind string, filter string, } return summary, nil } + +func (d *Database) LookupApiKeyUserRequests(uid string, kind string, filter string) ([]RequestSummary, error) { + // build sql condition based on filter (same rules as LookupApiKeyUserStats) + var condition string + switch filter { + case "24 Hours": + condition = "r.request_time >= NOW() - INTERVAL '1 day'" + case "30 days", "7 days": + condition = "r.request_time >= NOW() - INTERVAL '1 month'" + case "This Month": + condition = ` + r.request_time >= date_trunc('month', current_timestamp) + AND r.request_time < date_trunc('month', current_timestamp) + interval '1 month'` + case "Last Month": + condition = ` + r.request_time >= date_trunc('month', current_timestamp) - interval '1 month' + AND r.request_time < date_trunc('month', current_timestamp)` + case "This Year": + condition = ` + r.request_time >= date_trunc('year', current_timestamp) + AND r.request_time < date_trunc('year', current_timestamp) + interval '1 year'` + case "Last Year": + condition = ` + r.request_time >= date_trunc('year', current_timestamp) - interval '1 year' + AND r.request_time < date_trunc('year', current_timestamp)` + default: + log.Println("Filter did not match", filter) + } + + // handle "user" view for admin table and "apiKey" view for user table + if kind == "user" { + kind = "u.id" + } else { + kind = "a.UUID" + } + + query := fmt.Sprintf(` + SELECT + u.name, + r.id, + r.model, + COALESCE(r.input_token_count, 0), + COALESCE(r.cached_input_token_count, 0), + COALESCE(r.output_token_count, 0), + r.request_time + FROM requests r + INNER JOIN apikeys a ON a.UUID = r.api_key_id + INNER JOIN users u on a.Owner = u.id + WHERE + %[1]s = $1 + AND %[2]s + ORDER BY r.request_time;`, + kind, condition) + + rows, err := d.db.Query(query, uid) + if err != nil { + return nil, err + } + + var summary []RequestSummary + for rows.Next() { + var rq RequestSummary + if err := rows.Scan( + &rq.Name, + &rq.ID, + &rq.Model, + &rq.InputTokenCount, + &rq.CachedInputTokenCount, + &rq.OutputTokenCount, + &rq.RequestTime, + ); err != nil { + return summary, err + } + + // Keep legacy fields populated for any UI paths that still use prompt/output. + rq.TokenCountPrompt = rq.InputTokenCount - rq.CachedInputTokenCount + if rq.TokenCountPrompt < 0 { + rq.TokenCountPrompt = 0 + } + rq.TokenCountComplete = rq.OutputTokenCount + if rq.InputTokenCount > 0 { + rq.CacheRatioPercent = (float64(rq.CachedInputTokenCount) / float64(rq.InputTokenCount)) * 100 + } + summary = append(summary, rq) + } + return summary, nil +} func (d *Database) LookupApiKeyUserOverview() ([]RequestSummary, error) { var summary []RequestSummary rows, err := d.db.Query(` diff --git a/db/database_write_costs_test.go b/db/database_write_costs_test.go new file mode 100644 index 0000000..efcfeb1 --- /dev/null +++ b/db/database_write_costs_test.go @@ -0,0 +1,146 @@ +package database + +import ( + "regexp" + "testing" + "time" + + sqlmock "github.com/DATA-DOG/go-sqlmock" +) + +func TestWriteCosts_IgnoresDuplicateNaturalKeys(t *testing.T) { + sqlDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer sqlDB.Close() + + database := &Database{db: sqlDB} + requestTime := time.Date(2026, 3, 24, 0, 0, 0, 0, time.UTC) + cost := &Costs{ + ModelName: "gpt-5.4-mini", + RetailPrice: 130, + TokenType: "input", + UnitOfMeasure: "1M", + Currency: "EUR", + RequestTime: requestTime, + StageType: ContextLengthStageType, + StageMinTokens: 272001, + StageMaxTokens: nil, + } + + insertQuery := regexp.QuoteMeta(` + INSERT INTO costs + ( + model, price, valid_from, token_type, unit_of_messure, + currency, stage_type, stage_min_tokens, stage_max_tokens + ) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT ( + model, + valid_from, + token_type, + unit_of_messure, + currency, + stage_type, + stage_min_tokens, + (COALESCE(stage_max_tokens, -1)) + ) DO NOTHING`) + + mock.ExpectExec(insertQuery). + WithArgs( + cost.ModelName, + cost.RetailPrice, + cost.RequestTime, + cost.TokenType, + cost.UnitOfMeasure, + cost.Currency, + cost.StageType, + cost.StageMinTokens, + cost.StageMaxTokens, + ). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(insertQuery). + WithArgs( + cost.ModelName, + cost.RetailPrice, + cost.RequestTime, + cost.TokenType, + cost.UnitOfMeasure, + cost.Currency, + cost.StageType, + cost.StageMinTokens, + cost.StageMaxTokens, + ). + WillReturnResult(sqlmock.NewResult(1, 0)) + + if err := database.WriteCosts([]*Costs{cost}); err != nil { + t.Fatalf("first WriteCosts: %v", err) + } + if err := database.WriteCosts([]*Costs{cost}); err != nil { + t.Fatalf("second WriteCosts: %v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("ExpectationsWereMet: %v", err) + } +} + +func TestWriteCosts_DefaultsMissingCollectorFields(t *testing.T) { + sqlDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer sqlDB.Close() + + database := &Database{db: sqlDB} + cost := &Costs{ + ModelName: "azure-test-model", + RetailPrice: 123, + TokenType: "Inp", + UnitOfMeasure: "1M", + Currency: "EUR", + } + + insertQuery := regexp.QuoteMeta(` + INSERT INTO costs + ( + model, price, valid_from, token_type, unit_of_messure, + currency, stage_type, stage_min_tokens, stage_max_tokens + ) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT ( + model, + valid_from, + token_type, + unit_of_messure, + currency, + stage_type, + stage_min_tokens, + (COALESCE(stage_max_tokens, -1)) + ) DO NOTHING`) + + mock.ExpectExec(insertQuery). + WithArgs( + cost.ModelName, + cost.RetailPrice, + sqlmock.AnyArg(), + cost.TokenType, + cost.UnitOfMeasure, + cost.Currency, + ContextLengthStageType, + 0, + cost.StageMaxTokens, + ). + WillReturnResult(sqlmock.NewResult(1, 1)) + + if err := database.WriteCosts([]*Costs{cost}); err != nil { + t.Fatalf("WriteCosts: %v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("ExpectationsWereMet: %v", err) + } +} diff --git a/db/migration_drop_backend_pricing_columns_test.go b/db/migration_drop_backend_pricing_columns_test.go new file mode 100644 index 0000000..7fdc62b --- /dev/null +++ b/db/migration_drop_backend_pricing_columns_test.go @@ -0,0 +1,328 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + atlasmigrate "ariga.io/atlas/sql/migrate" +) + +func migrationFilePath(t *testing.T, filename string) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatalf("runtime.Caller failed") + } + return filepath.Join(filepath.Dir(thisFile), "migrations", filename) +} + +func readMigrationFile(t *testing.T, filename string) string { + t.Helper() + b, err := os.ReadFile(migrationFilePath(t, filename)) + if err != nil { + t.Fatalf("read migration file %s: %v", filename, err) + } + return string(b) +} + +func TestDropBackendPricingColumns_DoesNotContainAzureSpecialCase(t *testing.T) { + sqlText := readMigrationFile(t, "20260324120000_drop_backend_pricing_columns.sql") + if strings.Contains(strings.ToLower(sqlText), "azure") { + t.Fatalf("migration unexpectedly references 'azure'; backend-based deletion would be unsafe") + } +} + +func TestAtlasMigrationChecksum_IsInSync(t *testing.T) { + // This mirrors the same integrity check Atlas runs before applying migrations, + // but uses the Atlas Go library instead of requiring the `atlas` CLI binary. + migrationsDir := filepath.Join(filepath.Dir(migrationFilePath(t, "atlas.sum")), "") + // migrationFilePath(t, "atlas.sum") points at db/migrations/atlas.sum, so its parent is db/migrations. + // The extra "" keeps the join explicit and gofmt-stable. + dir, err := atlasmigrate.NewLocalDir(migrationsDir) + if err != nil { + t.Fatalf("atlas migrate new local dir: %v", err) + } + if err := atlasmigrate.Validate(dir); err != nil { + t.Fatalf("atlas migration dir checksum validation failed: %v", err) + } +} + +func TestDropBackendPricingColumns_DedupesByBackendFreeKey(t *testing.T) { + connStr := os.Getenv("DATABASE_PATH") + if connStr == "" { + // Mirrors local-dev/docker-compose default credentials/port. + connStr = "postgresql://openai:openai_pass@localhost:54329/openai_proxy?sslmode=disable" + } + + db, err := sql.Open("pgx", connStr) + if err != nil { + t.Skipf("cannot open DB connection: %v", err) + } + defer db.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if err := db.PingContext(ctx); err != nil { + t.Skipf("cannot connect to Postgres; skipping migration integration test: %v", err) + } + + // Use a dedicated schema so we can run DDL without touching dev/prod tables. + schema := "test_mig_drop_backend_pricing_columns_" + strconv.FormatInt(time.Now().UnixNano(), 10) + if _, err := db.ExecContext(ctx, "CREATE SCHEMA "+schema); err != nil { + t.Fatalf("create schema: %v", err) + } + t.Cleanup(func() { + // Best-effort cleanup. + _, _ = db.ExecContext(context.Background(), "DROP SCHEMA IF EXISTS "+schema+" CASCADE") + }) + + conn, err := db.Conn(ctx) + if err != nil { + t.Fatalf("db.Conn: %v", err) + } + defer conn.Close() + + if _, err := conn.ExecContext(ctx, "SET search_path TO "+schema); err != nil { + t.Fatalf("set search_path: %v", err) + } + + // Pre-migration shape: costs contains backend dimensions and the old natural key includes backend_name/is_regional. + if _, err := conn.ExecContext(ctx, ` + CREATE TABLE apikeys ( + uuid VARCHAR(255) NOT NULL PRIMARY KEY, + aiapi VARCHAR(255) + )`, + ); err != nil { + t.Fatalf("create apikeys table: %v", err) + } + + if _, err := conn.ExecContext(ctx, ` + CREATE TABLE costs ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + model VARCHAR(255) NOT NULL, + price integer NOT NULL, + valid_from date NOT NULL, + token_type VARCHAR(255) NOT NULL, + unit_of_messure VARCHAR(255), + is_regional boolean NOT NULL, + backend_name VARCHAR(255) NOT NULL, + currency CHAR(3), + stage_type text NOT NULL DEFAULT 'context_length', + stage_min_tokens integer NOT NULL DEFAULT 0, + stage_max_tokens integer NULL + )`); err != nil { + t.Fatalf("create costs table: %v", err) + } + + if _, err := conn.ExecContext(ctx, ` + CREATE UNIQUE INDEX costs_natural_key_idx + ON costs ( + model, + valid_from, + token_type, + unit_of_messure, + is_regional, + backend_name, + currency, + stage_type, + stage_min_tokens, + COALESCE(stage_max_tokens, -1) + )`); err != nil { + t.Fatalf("create old unique index: %v", err) + } + + validFrom := "2026-03-23" + model := "gpt-5.4-mini" + tokenType := "input" + unitOfMeasure := "1M" + currency := "EUR" + stageType := "context_length" + + // Remaining backend-free natural keys: + // K1: stage_min_tokens=0, stage_max_tokens=NULL (has both azure and non-azure rows; dedupe should keep newest by id) + // K3: stage_min_tokens=272001, stage_max_tokens=NULL (non-azure only; must not be deleted just because azure exists somewhere) + const ( + k1StageMin = 0 + k3StageMin = 272001 + priceAzureK1 = 100 + priceNonK1 = 200 + priceNonK3 = 300 + ) + + var idAzureK1 int64 + var idNonK1 int64 + var idNonK3 int64 + + if err := conn.QueryRowContext(ctx, ` + INSERT INTO costs ( + model, price, valid_from, token_type, unit_of_messure, + is_regional, backend_name, currency, stage_type, stage_min_tokens, stage_max_tokens + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id`, + model, priceAzureK1, validFrom, tokenType, unitOfMeasure, + false, "azure", currency, stageType, k1StageMin, nil, + ).Scan(&idAzureK1); err != nil { + t.Fatalf("insert azure K1: %v", err) + } + + if err := conn.QueryRowContext(ctx, ` + INSERT INTO costs ( + model, price, valid_from, token_type, unit_of_messure, + is_regional, backend_name, currency, stage_type, stage_min_tokens, stage_max_tokens + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id`, + model, priceNonK1, validFrom, tokenType, unitOfMeasure, + false, "openai", currency, stageType, k1StageMin, nil, + ).Scan(&idNonK1); err != nil { + t.Fatalf("insert non-azure K1: %v", err) + } + + if err := conn.QueryRowContext(ctx, ` + INSERT INTO costs ( + model, price, valid_from, token_type, unit_of_messure, + is_regional, backend_name, currency, stage_type, stage_min_tokens, stage_max_tokens + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id`, + model, priceNonK3, validFrom, tokenType, unitOfMeasure, + false, "openai", currency, stageType, k3StageMin, nil, + ).Scan(&idNonK3); err != nil { + t.Fatalf("insert non-azure K3: %v", err) + } + + // Execute the migration SQL directly (avoid Atlas CLI dependency in unit tests). + migrationSQL := readMigrationFile(t, "20260324120000_drop_backend_pricing_columns.sql") + // Note: this file is intentionally plain SQL (no PL/pgSQL blocks), so splitting on ';' is safe. + for _, stmt := range strings.Split(migrationSQL, ";") { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + upper := strings.ToUpper(stmt) + if !(strings.Contains(upper, "BEGIN") || + strings.Contains(upper, "COMMIT") || + strings.Contains(upper, "DELETE") || + strings.Contains(upper, "DROP") || + strings.Contains(upper, "ALTER") || + strings.Contains(upper, "CREATE")) { + continue + } + + if _, err := conn.ExecContext(ctx, stmt); err != nil { + t.Fatalf("exec migration statement failed: %v\nstmt:\n%s", err, stmt) + } + } + + var total int + if err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM costs`).Scan(&total); err != nil { + t.Fatalf("count costs: %v", err) + } + + // Distinct backend-free keys expected after dedupe: + // - K1 (kept newest by id across azure/non-azure) + // - K3 (non-azure only, must survive) + if total != 2 { + t.Fatalf("expected 2 backend-free pricing rows after migration, got %d", total) + } + + type costKeyRow struct { + id int64 + price int + } + + var k1 costKeyRow + if err := conn.QueryRowContext(ctx, ` + SELECT id, price + FROM costs + WHERE model = $1 + AND valid_from = $2 + AND token_type = $3 + AND unit_of_messure = $4 + AND currency = $5 + AND stage_type = $6 + AND stage_min_tokens = $7 + AND stage_max_tokens IS NULL + ORDER BY id DESC + LIMIT 1`, + model, validFrom, tokenType, unitOfMeasure, currency, stageType, k1StageMin, + ).Scan(&k1.id, &k1.price); err != nil { + t.Fatalf("lookup K1: %v", err) + } + + if k1.id != idNonK1 { + t.Fatalf("expected K1 kept row id=%d (newest by id), got %d", idNonK1, k1.id) + } + if k1.price != priceNonK1 { + t.Fatalf("expected K1 price=%d (from newest row), got %d", priceNonK1, k1.price) + } + + var k3 costKeyRow + if err := conn.QueryRowContext(ctx, ` + SELECT id, price + FROM costs + WHERE model = $1 + AND valid_from = $2 + AND token_type = $3 + AND unit_of_messure = $4 + AND currency = $5 + AND stage_type = $6 + AND stage_min_tokens = $7 + AND stage_max_tokens IS NULL + ORDER BY id DESC + LIMIT 1`, + model, validFrom, tokenType, unitOfMeasure, currency, stageType, k3StageMin, + ).Scan(&k3.id, &k3.price); err != nil { + t.Fatalf("lookup K3: %v", err) + } + + if k3.id != idNonK3 { + t.Fatalf("expected K3 kept row id=%d (only row in that backend-free key), got %d", idNonK3, k3.id) + } + if k3.price != priceNonK3 { + t.Fatalf("expected K3 price=%d, got %d", priceNonK3, k3.price) + } + + // Ensure backend columns were actually dropped. + var backendCols int + if err := conn.QueryRowContext(ctx, fmt.Sprintf(` + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = %s + AND table_name = 'costs' + AND column_name IN ('backend_name', 'is_regional')`, + // Avoid SQL injection by using a fixed, controlled identifier for schema. + // (schema is generated locally and should only include [a-z0-9_].) + quoteLiteral(schema), + )).Scan(&backendCols); err != nil { + t.Fatalf("check dropped columns: %v", err) + } + if backendCols != 0 { + t.Fatalf("expected backend dimension columns to be dropped, found %d remaining columns", backendCols) + } + + var aiapiCols int + if err := conn.QueryRowContext(ctx, fmt.Sprintf(` + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = %s + AND table_name = 'apikeys' + AND column_name = 'aiapi'`, + quoteLiteral(schema), + )).Scan(&aiapiCols); err != nil { + t.Fatalf("check dropped aiapi column: %v", err) + } + if aiapiCols != 0 { + t.Fatalf("expected apikeys.aiapi to be dropped, found %d remaining", aiapiCols) + } +} + +func quoteLiteral(s string) string { + // Minimal SQL literal quoting for test-only identifiers. + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} diff --git a/db/migrations/20260323100000_costs_add_context_length_stages.sql b/db/migrations/20260323100000_costs_add_context_length_stages.sql new file mode 100644 index 0000000..2295511 --- /dev/null +++ b/db/migrations/20260323100000_costs_add_context_length_stages.sql @@ -0,0 +1,17 @@ +-- Add staged pricing (context length) support to the "costs" table. +-- +-- Design: +-- - Keep existing single-stage rows working by backfilling defaults: +-- stage_type='context_length', stage_min_tokens=0, stage_max_tokens=NULL (open-ended). +-- - Insert GPT-5.4 short/long context rows (input/cached/output) for the same stage_type. +-- - Change the primary key to a generated `id` so stage_max_tokens can remain NULL. + +-- 1) Primary key change: composite PK -> generated `id`. +ALTER TABLE "costs" ADD COLUMN "id" bigint GENERATED ALWAYS AS IDENTITY; +ALTER TABLE "costs" DROP CONSTRAINT "costs_pkey"; +ALTER TABLE "costs" ADD PRIMARY KEY ("id"); + +-- 2) Stage columns. +ALTER TABLE "costs" ADD COLUMN "stage_type" text NOT NULL DEFAULT 'context_length'; +ALTER TABLE "costs" ADD COLUMN "stage_min_tokens" integer NOT NULL DEFAULT 0; +ALTER TABLE "costs" ADD COLUMN "stage_max_tokens" integer NULL; \ No newline at end of file diff --git a/db/migrations/20260324110000_costs_restore_natural_key_uniqueness.sql b/db/migrations/20260324110000_costs_restore_natural_key_uniqueness.sql new file mode 100644 index 0000000..db33f5a --- /dev/null +++ b/db/migrations/20260324110000_costs_restore_natural_key_uniqueness.sql @@ -0,0 +1,37 @@ +WITH ranked_costs AS ( + SELECT + id, + row_number() OVER ( + PARTITION BY + model, + valid_from, + token_type, + unit_of_messure, + is_regional, + backend_name, + currency, + stage_type, + stage_min_tokens, + COALESCE(stage_max_tokens, -1) + ORDER BY id + ) AS row_num + FROM costs +) +DELETE FROM costs +USING ranked_costs +WHERE costs.id = ranked_costs.id + AND ranked_costs.row_num > 1; + +CREATE UNIQUE INDEX IF NOT EXISTS costs_natural_key_idx +ON costs ( + model, + valid_from, + token_type, + unit_of_messure, + is_regional, + backend_name, + currency, + stage_type, + stage_min_tokens, + COALESCE(stage_max_tokens, -1) +); diff --git a/db/migrations/20260324120000_drop_backend_pricing_columns.sql b/db/migrations/20260324120000_drop_backend_pricing_columns.sql new file mode 100644 index 0000000..7d95c9b --- /dev/null +++ b/db/migrations/20260324120000_drop_backend_pricing_columns.sql @@ -0,0 +1,55 @@ +-- Remove backend/regional pricing dimensions. +-- This migration drops pricing columns that are no longer used for stage selection. + +BEGIN; + +-- Deduplicate using the new backend-free natural key only. +-- +-- This migration used to delete rows based on backend_name alone, but backend is +-- no longer part of the model. When multiple backend variants exist for the same +-- remaining key, keep exactly one deterministically: +-- - keep the newest row by `id` (largest `id`) +-- - collapse only rows that share all remaining key columns +WITH ranked_costs AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY + model, + valid_from, + token_type, + unit_of_messure, + currency, + stage_type, + stage_min_tokens, + COALESCE(stage_max_tokens, -1) + ORDER BY id DESC + ) AS row_num + FROM costs +) +DELETE FROM costs +USING ranked_costs +WHERE costs.id = ranked_costs.id + AND ranked_costs.row_num > 1; + +DROP INDEX IF EXISTS costs_natural_key_idx; + +ALTER TABLE costs DROP COLUMN IF EXISTS is_regional; +ALTER TABLE costs DROP COLUMN IF EXISTS backend_name; + +ALTER TABLE apikeys DROP COLUMN IF EXISTS aiapi; + +-- Recreate the unique index without backend/regional columns. +CREATE UNIQUE INDEX IF NOT EXISTS costs_natural_key_idx +ON costs ( + model, + valid_from, + token_type, + unit_of_messure, + currency, + stage_type, + stage_min_tokens, + COALESCE(stage_max_tokens, -1) +); + +COMMIT; diff --git a/db/migrations/atlas.sum b/db/migrations/atlas.sum index 9d86495..9ca622e 100644 --- a/db/migrations/atlas.sum +++ b/db/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:uD0kHBSeCJvUQblwBr8LJ6b9vrICujr7oYOZrGzxwTA= +h1:u1R4K2H9FjXH64VLRCaO6xiMLKNIJJ0LnnlSM6QDndI= 20240924151008_init.sql h1:rTjsfTqruGXjIUITaR6Pqt0n4lm+4EcdvESuYXgmyP4= 20240924154252_request_monitoring.sql h1:VWH6kcc2DWS7L9tBQ8lgS858/9f5HP7PMYZo0fZlCX8= 20241001124048_id_datatype.sql h1:Nb1OpAzcVshPzgEqQ3w/sCFjorO4h/GTOBeuISanu4k= @@ -20,3 +20,6 @@ h1:uD0kHBSeCJvUQblwBr8LJ6b9vrICujr7oYOZrGzxwTA= 20260305130000_reporting_groups.sql h1:HS3rs7B/55oLaP6s4WWVuzS4MxhyCJfQPndXN2p3uEw= 20260318100000_add_gpt_5_4_mini_nano_costs.sql h1:nSIJLpAB+9fo98DX8xo/g1xu+Mw6Dv4h6yYJ7AonhgY= 20260318101000_add_gpt_5_4_models.sql h1:1aKL2Qjrt7bwO5fDoBjH94gDT5q/doFLneVcu7W0ThQ= +20260323100000_costs_add_context_length_stages.sql h1:DFx//bwOQ6co8nTYBkoRNuyNKjt/UGGkaTK/zb/4vyA= +20260324110000_costs_restore_natural_key_uniqueness.sql h1:h2X1ZXYqri2VBqpw+jf93XF0kXSlK4deOUZ1tlvuV7A= +20260324120000_drop_backend_pricing_columns.sql h1:3XrUmuQwlbKs4cmXb9Fb8uAmz9EJhNQBaHddWy/UbcQ= diff --git a/db/schema.sql b/db/schema.sql index 3cdce53..450fb8e 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -16,19 +16,31 @@ CREATE TABLE IF NOT EXISTS apiKeys ( UUID VARCHAR(255) NOT NULL PRIMARY KEY, ApiKey VARCHAR(255) NOT NULL, Owner VARCHAR(255) NOT NULL REFERENCES users(id), - AiApi VARCHAR(255), Description VARCHAR(255) ); CREATE TABLE IF NOT EXISTS costs ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, model VARCHAR(255) NOT NULL, price integer NOT NULL, - request_day date DEFAULT now(), - token_type VARCHAR(255), + valid_from date DEFAULT now() NOT NULL, + token_type VARCHAR(255) NOT NULL, unit_of_messure VARCHAR(255), - is_regional BOOLEAN, - backend_name VARCHAR(255), currency CHAR(3), - PRIMARY KEY(model,request_day,token_type,is_regional,price,backend_name) + stage_type text NOT NULL DEFAULT 'context_length', + stage_min_tokens integer NOT NULL DEFAULT 0, + stage_max_tokens integer NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS costs_natural_key_idx +ON costs ( + model, + valid_from, + token_type, + unit_of_messure, + currency, + stage_type, + stage_min_tokens, + COALESCE(stage_max_tokens, -1) ); CREATE TABLE IF NOT EXISTS requests ( @@ -39,4 +51,3 @@ CREATE TABLE IF NOT EXISTS requests ( token_count_complete integer NOT NULL, model VARCHAR(255) ); - diff --git a/go.mod b/go.mod index 92fc229..f12f8a8 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( require ( ariga.io/atlas v0.19.1-0.20240218093714-1a4929bdea1f // indirect dario.cat/mergo v1.0.1 // indirect + github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect diff --git a/go.sum b/go.sum index e644ab2..163af12 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= @@ -53,6 +55,7 @@ github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= diff --git a/local-dev/README.md b/local-dev/README.md index 83722d7..506e9ad 100644 --- a/local-dev/README.md +++ b/local-dev/README.md @@ -3,27 +3,25 @@ This folder contains a Docker Compose setup for running the app and a Postgres database locally. Files: -- `docker-compose.yml` — starts `db` (Postgres) and `app` (built from repository Dockerfile). +- `docker-compose.yml` — starts `db` (Postgres), the Go backend (`app`), and the Next.js frontend (`next`) in dev mode. - `app.env` — example environment file. Copy and fill values before `docker compose up`. Persistence mapping (created next to this folder): - `local-dev/postgres-data` → Postgres data directory (persistent DB storage). Quick start: -1. Copy env: `cp local-dev/app.env local-dev/app.env` and edit values. +1. Copy env: `cp local-dev/app.env.example local-dev/app.env` and edit values. 2. From `local-dev/` run: `docker compose up --build`. -3. App will be available at `http://localhost:8082`. +3. Backend will be available at `http://localhost:8082`. +4. Frontend will be available at `http://localhost:3000`. -Dev iteration (no image rebuild): -- Use the dev override to run the Go runtime directly (faster iteration): - - `docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build` - - This starts `app-dev` (based on the `golang` image) and mounts the repository at `/app` so code edits take effect without rebuilding the image. - - To stop: `docker compose down`. - -Notes: -- `app-dev` runs `go run ./cmd/main.go`. You still must restart the container to pick up changes, but you do not need to rebuild the Docker image. -- For true hot reload, consider adding a file-watcher (e.g., `air` or `reflex`) into the dev container. +Dev iteration: +- Next.js: source is bind-mounted and `npm run dev` runs inside the container, so changes should pick up automatically. +- Go backend: after Go/db changes, rebuild and restart with `docker compose up --build`. Notes: - The compose `app` service mounts `../db` and `../public` so you can edit migrations and static files locally. - After changing Go code, rebuild with `docker compose up --build`. +- The `app` service waits for the Postgres healthcheck before startup. +- Migration retries are configurable via `MIGRATE_MAX_ATTEMPTS` (default `30`) and `MIGRATE_RETRY_DELAY` (default `2s`). +- For verbose app/proxy logs, enable `DEV_LOG_REQUEST`, `DEV_LOG_RAW_RESPONSE`, `DEV_LOG_TOKEN_COUNT`, `DEV_LOG_TOKEN_DEBUG` in `local-dev/app.env`. diff --git a/local-dev/app.env.example b/local-dev/app.env.example index c885364..67bbe53 100644 --- a/local-dev/app.env.example +++ b/local-dev/app.env.example @@ -22,3 +22,12 @@ DEFAULT_BACKEND=azure # Optional: override the generated DATABASE_PATH # DATABASE_PATH=postgresql://openai:openai_pass@db/openai_proxy +# Optional: migration startup retry behavior +# MIGRATE_MAX_ATTEMPTS=30 +# MIGRATE_RETRY_DELAY=2s + +# Optional: verbose local logging (set to 1 to enable) +# DEV_LOG_REQUEST=1 +# DEV_LOG_RAW_RESPONSE=1 +# DEV_LOG_TOKEN_COUNT=1 +# DEV_LOG_TOKEN_DEBUG=1 diff --git a/local-dev/docker-compose.yml b/local-dev/docker-compose.yml index 82e8330..9e9b77e 100644 --- a/local-dev/docker-compose.yml +++ b/local-dev/docker-compose.yml @@ -10,6 +10,12 @@ services: POSTGRES_DB: ${POSTGRES_DB:-openai_proxy} volumes: - ./postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-openai} -d ${POSTGRES_DB:-openai_proxy}"] + interval: 2s + timeout: 3s + retries: 20 + start_period: 3s app: build: @@ -17,7 +23,8 @@ services: dockerfile: Dockerfile restart: unless-stopped depends_on: - - db + db: + condition: service_healthy ports: - "8082:8082" env_file: @@ -30,8 +37,54 @@ services: DATABASE_NAME: ${POSTGRES_DB:-openai_proxy} # Provide a full connection string for tools that prefer it. DATABASE_PATH: "postgresql://${POSTGRES_USER:-openai}:${POSTGRES_PASSWORD:-openai_pass}@db/${POSTGRES_DB:-openai_proxy}?sslmode=disable" + RUST_LOG: "DEBUG" volumes: # Keep DB migrations and editable DB schema available to the container - ../db:/app/db:ro # Expose the public static files so you can edit them locally - ../public:/app/public:rw + + next: + image: node:20 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + ports: + - "3000:3000" + env_file: + - ./app.env + environment: + # Ensure Next binds publicly so Docker port mapping works. + NODE_ENV: development + PORT: "3000" + # Auth.js needs this to correctly scope CSRF cookies in local Docker setups. + AUTH_URL: http://localhost:3000 + NEXTAUTH_URL: http://localhost:3000 + # Auth.js (NextAuth v5) requires a secret even in development. + # This is a dev-only value; if you want a stable/custom secret, set `AUTH_SECRET` in `local-dev/app.env`. + AUTH_SECRET: ${AUTH_SECRET:-dev_auth_secret} + working_dir: /app + volumes: + # Bind-mount source for day-to-day dev (HMR). + - ../app:/app:rw + # Keep dependencies stable across container restarts. + - next_node_modules:/app/node_modules + command: > + sh -c ' + set -e + + export DATABASE_URL="postgresql://$$POSTGRES_USER:$$POSTGRES_PASSWORD@db/$$POSTGRES_DB?sslmode=disable" + export ZITADEL_ISSUER="$$ISSUER" + export ZITADEL_CLIENT_ID="$$CLIENT_ID" + export ZITADEL_CLIENT_SECRET="$$CLIENT_SECRET" + + if [ ! -f node_modules/next/package.json ]; then + npm ci + fi + + npm run dev -- --hostname 0.0.0.0 --port 3000 + ' + +volumes: + next_node_modules: