diff --git a/.gitignore b/.gitignore index 709af06..5daf11b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ apiproxy.sqlite apgiproxy.sqlite vendor/ +.gocache/ .pre-commit-config.yaml .direnv @@ -9,3 +10,4 @@ vendor/ .idea local-dev/postgres-data-fresh +tmp/ diff --git a/app/RELEASE_NOTES.md b/app/RELEASE_NOTES.md index e4692a5..680d966 100644 --- a/app/RELEASE_NOTES.md +++ b/app/RELEASE_NOTES.md @@ -3,6 +3,8 @@ ## Unreleased - Added separate chat-token and rerank-search reporting and dashboard charts. - Added request-type-aware rerank pricing management and filters. +- Added administrator-only Azure Retail Pricing preview, rule mapping, auditing, and transactional import. +- Added JSON export and transactional import for reusable Azure pricing mapping rules. ## 1.0.1 - 2026-07-15 - Added provider-reported GPT-5.6 cache-write accounting across pricing, reporting, and usage views. diff --git a/app/src/app/admin/costs/azure/page.tsx b/app/src/app/admin/costs/azure/page.tsx new file mode 100644 index 0000000..37bae2a --- /dev/null +++ b/app/src/app/admin/costs/azure/page.tsx @@ -0,0 +1,9 @@ +import { redirect } from "next/navigation"; +import { auth } from "~/server/auth"; +import { AzurePricingClient } from "./pricing-client"; + +export default async function AzurePricingPage() { + const session = await auth(); + if (!session?.user?.isAdmin) redirect("/"); + return ; +} diff --git a/app/src/app/admin/costs/azure/pricing-client.tsx b/app/src/app/admin/costs/azure/pricing-client.tsx new file mode 100644 index 0000000..945f3f0 --- /dev/null +++ b/app/src/app/admin/costs/azure/pricing-client.tsx @@ -0,0 +1,800 @@ +"use client"; +import { CircleHelp } from "lucide-react"; +import { type ChangeEvent, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { Textarea } from "~/components/ui/textarea"; +import { api } from "~/trpc/react"; + +const assignmentLabels: Record = { + model: "Modell", + requestType: "Anfrageart", + billingUnit: "Abrechnungseinheit", + tokenType: "Token-Typ", + stageMinTokens: "Stufe ab Tokens", + stageMaxTokens: "Stufe bis Tokens", +}; +const classificationLabels: Record = { + insert: "Automatisch einfügen", + older: "Automatisch überspringen (älter)", + unchanged: "Unverändert lassen", + conflict: "Konflikt – Entscheidung erforderlich", +}; + +export function AzurePricingClient() { + const utils = api.useUtils(); + const rulesFileInput = useRef(null); + const { data, isLoading } = api.admin.azurePricing.useQuery(); + const [config, setConfig] = useState({ + serviceName: "Azure OpenAI", + armRegionName: "", + currencyCode: "EUR", + productName: "", + armSkuName: "", + meterName: "", + }); + const [auditId, setAuditId] = useState(null); + useEffect(() => { + if (data?.config) + setConfig({ + ...data.config, + productName: data.config.productName ?? "", + armSkuName: data.config.armSkuName ?? "", + meterName: data.config.meterName ?? "", + }); + }, [data?.config]); + const [preview, setPreview] = useState< + Array<{ + raw: Record; + status: string; + errors?: string[]; + decision: "skip" | "replace" | "ignore"; + assignments?: Record; + matchingRuleNames?: string[]; + normalized?: + | { + price: number; + validFrom: string; + currency: string; + unitOfMessure: string; + } + | { error: string }; + classification?: "insert" | "older" | "unchanged" | "conflict"; + index: number; + conflict?: string; + baseStatus?: string; + }> + >([]); + const save = api.admin.saveAzurePricingConfig.useMutation({ + onSuccess: () => { + toast.success("Konfiguration gespeichert."); + void utils.admin.azurePricing.invalidate(); + }, + onError: (error) => toast.error(error.message), + }); + const fetchPrices = api.admin.fetchAzurePricing.useMutation({ + onSuccess: (result) => { + setAuditId(result.auditId ?? null); + setPreview(result.rows); + toast.success(`${result.rows.length} Azure-Zeilen geladen.`); + }, + onError: (error) => toast.error(error.message), + }); + const createRule = api.admin.createAzurePricingRule.useMutation({ + onSuccess: () => { + toast.success("Regel gespeichert."); + void utils.admin.azurePricing.invalidate(); + }, + }); + const updateRule = api.admin.updateAzurePricingRule.useMutation({ + onSuccess: () => void utils.admin.azurePricing.invalidate(), + }); + const deleteRule = api.admin.deleteAzurePricingRule.useMutation({ + onSuccess: () => void utils.admin.azurePricing.invalidate(), + }); + const importRules = api.admin.importAzurePricingRules.useMutation({ + onSuccess: (result) => { + toast.success(`${result.count} Regeln importiert.`); + void utils.admin.azurePricing.invalidate(); + }, + onError: (error) => toast.error(error.message), + }); + const { data: audits = [] } = api.admin.listAzurePricingAudits.useQuery(); + const [ruleName, setRuleName] = useState(""); + const [rulePriority, setRulePriority] = useState("0"); + const [ruleAction, setRuleAction] = useState<"map" | "ignore">("map"); + const [ruleConditions, setRuleConditions] = useState("[]"); + const [ruleAssignments, setRuleAssignments] = useState("{}"); + const [editingRuleId, setEditingRuleId] = useState(null); + const [selectedRuleIds, setSelectedRuleIds] = useState>( + new Set(), + ); + const [helpOpen, setHelpOpen] = useState(false); + const [showIgnoredRows, setShowIgnoredRows] = useState(false); + const [selectedAuditId, setSelectedAuditId] = useState(null); + const { data: selectedAudit } = api.admin.getAzurePricingAudit.useQuery( + { id: selectedAuditId ?? 0 }, + { enabled: selectedAuditId !== null }, + ); + const importer = api.admin.importAzurePricing.useMutation({ + onSuccess: (result) => { + setPreview((currentRows) => + currentRows.map((row) => { + const outcome = result.find((item) => item.index === row.index); + if (!outcome) return row; + const status = + outcome.status === "insert" + ? "inserted" + : outcome.status === "replace" + ? "replaced" + : outcome.status; + return { ...row, status, baseStatus: status }; + }), + ); + toast.success("Import abgeschlossen."); + }, + onError: (error) => toast.error(error.message), + }); + const exportRules = () => { + const rules = (data?.rules ?? []) + .filter((rule) => selectedRuleIds.has(rule.id)) + .map((rule) => ({ + name: rule.name, + enabled: rule.enabled, + priority: rule.priority, + action: rule.action, + conditions: rule.conditions, + assignments: rule.assignments, + })); + if (!rules.length) { + toast.error("Bitte mindestens eine Regel auswählen."); + return; + } + const blob = new Blob( + [ + JSON.stringify( + { + formatVersion: 1, + rules, + }, + null, + 2, + ), + ], + { type: "application/json" }, + ); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `azure-pricing-rules-${new Date() + .toISOString() + .slice(0, 10)}.json`; + link.click(); + URL.revokeObjectURL(url); + }; + const importRuleFile = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + try { + const parsed: unknown = JSON.parse(await file.text()); + const rules = Array.isArray(parsed) + ? parsed + : parsed && + typeof parsed === "object" && + "rules" in parsed && + Array.isArray(parsed.rules) + ? parsed.rules + : undefined; + if (!rules?.length) throw new Error("Die Datei enthält keine Regeln."); + importRules.mutate({ rules: rules as never }); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Die Regeldatei konnte nicht gelesen werden.", + ); + } + }; + if (isLoading) + return
Lade Azure-Preiskonfiguration …
; + const current = config; + const visiblePreview = showIgnoredRows + ? preview + : preview.filter((row) => row.status !== "ignored"); + return ( +
+
+

Azure Retail Pricing Import

+

+ Manueller, administrativer Import von Azure OpenAI Preisen. +

+
+
+

Globale Abfragekonfiguration

+
+ {( + [ + "serviceName", + "armRegionName", + "currencyCode", + "productName", + "armSkuName", + "meterName", + ] as const + ).map((field) => ( + + ))} +
+
+ + +
+
+
+
+

Vorschau

+ +
+

+ Nicht gemappte oder ungültige Zeilen müssen ausdrücklich ignoriert + werden. +

+
+ + + + + + + + + + + + + + + + {visiblePreview.map((row) => ( + + + + + + + + + + + + ))} + +
StatusMeterProduktAzure abGematchte RegelnMappingKonfliktEntscheidungRohdaten
+ {row.status} + {row.classification + ? ` · ${classificationLabels[row.classification]}` + : ""} + {row.errors?.length ? `: ${row.errors.join(", ")}` : ""} + {String(row.raw.meterName ?? "")}{String(row.raw.productName ?? "")} + {row.normalized && "validFrom" in row.normalized + ? row.normalized.validFrom + : String(row.raw.effectiveStartDate ?? "—")} + + {row.matchingRuleNames?.length + ? row.matchingRuleNames.join(", ") + : "—"} + + {(row.assignments && + Object.keys(row.assignments).length > 0) || + (row.normalized && "validFrom" in row.normalized) ? ( +
+ {row.assignments && + Object.entries(row.assignments).map( + ([field, value]) => ( +
+ + {assignmentLabels[field] ?? field}: + + + {value === null ? "—" : String(value)} + +
+ ), + )} + {row.normalized && "validFrom" in row.normalized && ( + <> +
+ Preis: + {row.normalized.price} Cent +
+
+ Einheit: + {row.normalized.unitOfMessure} +
+
+ Währung: + {row.normalized.currency} +
+
+ Gültig ab: + {row.normalized.validFrom} +
+
+ Stufe: + context_length +
+ + )} +
+ ) : ( + "—" + )} +
{row.conflict === "conflict" ? "Konflikt" : "—"} + + +
+ + Anzeigen + +
+												{JSON.stringify(row.raw, null, 2)}
+											
+
+
+
+ {preview.length > 0 && ( + + )} +
+
+
+

Mapping-Regeln

+ + + + + + } + > + + + + + Mapping-Regel anlegen + + Eine Regel verbindet Azure-Meter mit einem vorhandenen Modell + und der passenden Abrechnungsart. + + +
+

+ Beispiel für den Input-Meter von gpt-5.6-luna: +

+
+									{`Bedingungen:
+[
+  {
+    "field": "meterName",
+    "operator": "contains",
+    "value": "5.6 luna ShortCo Cd Inp"
+  }
+]
+
+Zuweisungen:
+{
+  "model": "gpt-5.6-luna",
+  "requestType": "CHAT_COMPLETION",
+  "billingUnit": "TOKENS",
+  "tokenType": "input"
+}`}
+								
+
    +
  1. + Make sure gpt-5.6-luna already exists under + Modelle. +
  2. +
  3. + Enter a rule name such as + 5.6 luna input and choose action + map. +
  4. +
  5. + Set the conditions JSON and assignments JSON as shown above, + then click Regel anlegen. +
  6. +
  7. + Create equivalent rules for output and cache-write meters, + using + output or cache_write as the token + type. +
  8. +
  9. + Save the Azure configuration, click{" "} + Azure laden, and review every fetched row + before importing. +
  10. +
+

+ For dynamic model names, use a regex condition with a named + capture, for example (?<model>gpt-[\\w.-]+) + , and set the assignment to ${"{model}"}. The + captured value must match an existing model exactly. +

+
+
+
+
+
+ + + + +
+
+