mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 05:12:11 +03:00
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.
212 lines
7.7 KiB
TypeScript
212 lines
7.7 KiB
TypeScript
"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>
|
||
);
|
||
}
|