+
+
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.
+
+
+
+
+
+ | Status |
+ Meter |
+ Produkt |
+ Azure ab |
+ Gematchte Regeln |
+ Mapping |
+ Konflikt |
+ Entscheidung |
+ Rohdaten |
+
+
+
+ {visiblePreview.map((row) => (
+
+ |
+ {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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {selectedRuleIds.size} Regel(n) ausgewählt
+
+
+
+
+ {(data?.rules ?? []).map((rule) => (
+
+
+
+
+
+
+
+
+ ))}
+
+
+ Import-Audits
+ {audits.map((audit) => (
+
+
+ {new Date(audit.createdAt).toLocaleString()} · {audit.operation} ·{" "}
+ {audit.outcome}
+
+
+
+ ))}
+ {selectedAudit && (
+
+ {JSON.stringify(
+ {
+ configuration: selectedAudit.configuration,
+ rawResponse: selectedAudit.rawResponse,
+ rows: selectedAudit.rows,
+ error: selectedAudit.error,
+ },
+ null,
+ 2,
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/app/src/components/sidebar-nav.tsx b/app/src/components/sidebar-nav.tsx
index f5b039c..fbc46c2 100644
--- a/app/src/components/sidebar-nav.tsx
+++ b/app/src/components/sidebar-nav.tsx
@@ -39,6 +39,11 @@ const adminItems = [
label: "Kosten",
icon: Coins,
},
+ {
+ href: "/admin/costs/azure",
+ label: "Azure Preise",
+ icon: Coins,
+ },
{
href: "/admin/models",
label: "Modelle",
diff --git a/app/src/server/api/routers/admin.ts b/app/src/server/api/routers/admin.ts
index 3c4233e..648f8e9 100644
--- a/app/src/server/api/routers/admin.ts
+++ b/app/src/server/api/routers/admin.ts
@@ -13,7 +13,24 @@ import {
requestTypeOptions,
} from "~/lib/costs";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
-import { apikeys, costs, models, requests, users } from "~/server/db/schema";
+import {
+ type AzureRule,
+ buildCostIdentity,
+ classifyConflict,
+ costIdentity,
+ evaluateRules,
+ normalizeAzurePrice,
+} from "~/server/azurePricing";
+import {
+ apikeys,
+ azurePricingAudits,
+ azurePricingConfig,
+ azurePricingRules,
+ costs,
+ models,
+ requests,
+ users,
+} from "~/server/db/schema";
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (!ctx.session.user.isAdmin) {
@@ -74,6 +91,128 @@ const costInput = z
}
});
+const azureConfigInput = z.object({
+ serviceName: z.string().trim().min(1).max(255).default("Azure OpenAI"),
+ armRegionName: z.string().trim().min(1).max(255),
+ currencyCode: z
+ .string()
+ .trim()
+ .regex(/^[A-Za-z]{3}$/)
+ .transform((value) => value.toUpperCase()),
+ productName: z.string().trim().max(255).nullable().optional(),
+ armSkuName: z.string().trim().max(255).nullable().optional(),
+ meterName: z.string().trim().max(255).nullable().optional(),
+});
+const azureRuleInput = z
+ .object({
+ id: z.number().int().positive().optional(),
+ name: z.string().trim().min(1).max(255),
+ enabled: z.boolean().default(true),
+ priority: z.number().int(),
+ action: z.enum(["map", "ignore"]),
+ conditions: z
+ .array(
+ z.object({
+ field: z.string().trim().min(1).max(255),
+ operator: z.enum(["equals", "contains", "regex"]),
+ value: z.string().max(2000),
+ }),
+ )
+ .max(32),
+ assignments: z
+ .object({
+ model: z.string().max(255).optional(),
+ requestType: z.enum(requestTypeOptions).optional(),
+ billingUnit: z.enum(billingUnitOptions).optional(),
+ tokenType: z.string().max(64).optional(),
+ stageMinTokens: z.union([z.string(), z.number()]).optional(),
+ stageMaxTokens: z.union([z.string(), z.number()]).nullable().optional(),
+ })
+ .default({}),
+ })
+ .superRefine((value, ctx) => {
+ for (const [index, condition] of value.conditions.entries()) {
+ if (condition.operator !== "regex") continue;
+ try {
+ new RegExp(condition.value);
+ } catch {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["conditions", index, "value"],
+ message: "Invalid regular expression",
+ });
+ }
+ }
+ });
+
+async function fetchAzurePrices(config: z.infer