Files
OmniRoute/src/shared/components/PricingModal.tsx
diegosouzapw a3d1dc6cf9 fix(api): support image-only models and authless search providers
Allow image generation requests to omit prompts for models that only
accept image input, and validate required inputs from model metadata
instead of enforcing a text prompt for every request.

Treat authless search providers as executable with built-in defaults so
SearXNG can run without stored credentials, including during provider
auto-selection.

Also align runtime support with Node.js 24 LTS, harden thinking tag
compression and proxy wildcard matching, and update tests for the new
route and runtime behavior.
2026-04-17 18:45:32 -03:00

212 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { getDefaultPricing, formatCost } from "@/shared/constants/pricing";
export default function PricingModal({ isOpen, onClose, onSave }) {
const t = useTranslations("pricingModal");
const [pricingData, setPricingData] = useState({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isOpen) {
loadPricing();
}
}, [isOpen]);
const loadPricing = async () => {
setLoading(true);
try {
const response = await fetch("/api/pricing");
if (response.ok) {
const data = await response.json();
setPricingData(data);
} else {
// Fallback to defaults
const defaults = getDefaultPricing();
setPricingData(defaults);
}
} catch (error) {
console.error("Failed to load pricing:", error);
const defaults = getDefaultPricing();
setPricingData(defaults);
} finally {
setLoading(false);
}
};
const handlePricingChange = (provider, model, field, value) => {
const numValue = parseFloat(value);
if (isNaN(numValue) || numValue < 0) return;
setPricingData((prev) => {
const newData = { ...prev };
if (!newData[provider]) newData[provider] = {};
if (!newData[provider][model]) newData[provider][model] = {};
newData[provider][model][field] = numValue;
return newData;
});
};
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch("/api/pricing", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(pricingData),
});
if (response.ok) {
onSave?.();
onClose();
} else {
const error = await response.json();
alert(`${t("errorSaveFailed")}: ${error.error}`);
}
} catch (error) {
console.error("Failed to save pricing:", error);
alert(t("errorSaveFailed"));
} finally {
setSaving(false);
}
};
const handleReset = async () => {
if (!confirm(t("resetConfirm"))) return;
try {
const response = await fetch("/api/pricing", { method: "DELETE" });
if (response.ok) {
const defaults = getDefaultPricing();
setPricingData(defaults);
}
} catch (error) {
console.error("Failed to reset pricing:", error);
alert(t("errorResetFailed"));
}
};
if (!isOpen) return null;
// Get all unique providers and models for display
const allProviders = Object.keys(pricingData).sort();
const pricingFields = ["input", "output", "cached", "reasoning", "cache_creation"];
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-bg-base border border-border rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="text-xl font-semibold">{t("title")}</h2>
<button
onClick={onClose}
className="text-text-muted hover:text-text text-2xl leading-none"
>
×
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-4">
{loading ? (
<div className="text-center py-8 text-text-muted">{t("loading")}</div>
) : (
<div className="space-y-6">
{/* Instructions */}
<div className="bg-bg-subtle border border-border rounded-lg p-3 text-sm">
<p className="font-medium mb-1">{t("pricingRatesFormat")}</p>
<p className="text-text-muted">
{t.rich("ratesDescription", {
strong: (c) => <strong className="font-semibold">{c}</strong>,
})}
</p>
</div>
{/* Pricing Tables */}
{allProviders.map((provider) => {
const models = Object.keys(pricingData[provider]).sort();
return (
<div key={provider} className="border border-border rounded-lg overflow-hidden">
<div className="bg-bg-subtle px-4 py-2 font-semibold text-sm">
{provider.toUpperCase()}
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-bg-hover text-text-muted uppercase text-xs">
<tr>
<th className="px-3 py-2 text-left">{t("model")}</th>
<th className="px-3 py-2 text-right">{t("input")}</th>
<th className="px-3 py-2 text-right">{t("output")}</th>
<th className="px-3 py-2 text-right">{t("cached")}</th>
<th className="px-3 py-2 text-right">{t("reasoning")}</th>
<th className="px-3 py-2 text-right">{t("cacheCreation")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{models.map((model) => (
<tr key={model} className="hover:bg-bg-subtle/50">
<td className="px-3 py-2 font-medium">{model}</td>
{pricingFields.map((field) => (
<td key={field} className="px-3 py-2">
<input
type="number"
step="0.01"
min="0"
value={pricingData[provider][model][field] || 0}
onChange={(e) =>
handlePricingChange(provider, model, field, e.target.value)
}
className="w-20 px-2 py-1 text-right bg-bg-base border border-border rounded focus:outline-none focus:border-primary"
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
})}
{allProviders.length === 0 && (
<div className="text-center py-8 text-text-muted">{t("noPricingData")}</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-border flex items-center justify-between gap-2">
<button
onClick={handleReset}
className="px-4 py-2 text-sm text-red-500 hover:bg-red-500/10 rounded border border-red-500/20 transition-colors"
disabled={saving}
>
{t("resetToDefaults")}
</button>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-text-muted hover:text-text border border-border rounded transition-colors"
disabled={saving}
>
{t("cancel")}
</button>
<button
onClick={handleSave}
className="px-4 py-2 text-sm bg-primary text-white rounded hover:bg-primary/90 transition-colors disabled:opacity-50"
disabled={saving}
>
{saving ? t("saving") : t("saveChanges")}
</button>
</div>
</div>
</div>
</div>
);
}