diff --git a/apps/src-tauri/src/commands/apikey.rs b/apps/src-tauri/src/commands/apikey.rs index 94ce312d6..aadd3bb75 100644 --- a/apps/src-tauri/src/commands/apikey.rs +++ b/apps/src-tauri/src/commands/apikey.rs @@ -347,3 +347,27 @@ pub async fn service_apikey_enable( let params = serde_json::json!({ "id": key_id }); rpc_call_in_background("apikey/enable", addr, Some(params)).await } + +#[tauri::command] +pub async fn service_model_price_rules_list( + addr: Option, +) -> Result { + rpc_call_in_background("quota/modelPriceRules/list", addr, None).await +} + +#[tauri::command] +pub async fn service_model_price_rule_read( + addr: Option, + model_pattern: String, +) -> Result { + let params = serde_json::json!({ "modelPattern": model_pattern }); + rpc_call_in_background("quota/modelPriceRule/read", addr, Some(params)).await +} + +#[tauri::command] +pub async fn service_model_price_rule_upsert( + addr: Option, + payload: serde_json::Value, +) -> Result { + rpc_call_in_background("quota/modelPriceRule/upsert", addr, Some(payload)).await +} diff --git a/apps/src-tauri/src/commands/registry.rs b/apps/src-tauri/src/commands/registry.rs index a3bbeefc9..dda6367b0 100644 --- a/apps/src-tauri/src/commands/registry.rs +++ b/apps/src-tauri/src/commands/registry.rs @@ -130,6 +130,9 @@ macro_rules! invoke_handler { crate::commands::apikey::service_model_source_model_save, crate::commands::apikey::service_model_source_mapping_save, crate::commands::apikey::service_model_source_mapping_delete, + crate::commands::apikey::service_model_price_rules_list, + crate::commands::apikey::service_model_price_rule_read, + crate::commands::apikey::service_model_price_rule_upsert, crate::commands::apikey::service_apikey_usage_stats, crate::commands::apikey::service_apikey_update_model, crate::commands::apikey::service_apikey_delete, diff --git a/apps/src/app/models/page.tsx b/apps/src/app/models/page.tsx index 7e173a983..c2482dfd6 100644 --- a/apps/src/app/models/page.tsx +++ b/apps/src/app/models/page.tsx @@ -68,7 +68,7 @@ import { import { useManagedModels } from "@/hooks/useManagedModels"; import { usePageTransitionReady } from "@/hooks/usePageTransitionReady"; import { useRuntimeCapabilities } from "@/hooks/useRuntimeCapabilities"; -import { accountClient } from "@/lib/api/account-client"; +import { accountClient, ModelPriceRuleEntry } from "@/lib/api/account-client"; import { findBestMatchingModel } from "@/lib/api/model-catalog"; import { useI18n } from "@/lib/i18n/provider"; import { formatTsFromSeconds } from "@/lib/utils/usage"; @@ -133,6 +133,8 @@ export default function ModelsPage() { isServiceReady, refreshRemote, saveModel, + saveModelPriceRule, + readModelPriceRule, deleteModel, deleteModels, exportCodexCache, @@ -159,6 +161,7 @@ export default function ModelsPage() { const [editingSlug, setEditingSlug] = useState(null); const [selectedSlugs, setSelectedSlugs] = useState([]); const [deleteSlugs, setDeleteSlugs] = useState([]); + const [editingPriceRule, setEditingPriceRule] = useState(null); const [activeModelSlug, setActiveModelSlug] = useState(""); const [routingDialogOpen, setRoutingDialogOpen] = useState(false); const [sourceDraft, setSourceDraft] = useState({ @@ -244,6 +247,28 @@ export default function ModelsPage() { [editingSlug, models] ); + useEffect(() => { + let cancelled = false; + const slug = editingModel?.slug; + if (!slug) { + setEditingPriceRule(null); + return; + } + setEditingPriceRule(null); + readModelPriceRule(slug) + .then((result) => { + if (!cancelled) setEditingPriceRule(result); + }) + .catch((err) => { + console.warn("读取模型价格失败", err); + if (!cancelled) setEditingPriceRule(null); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editingModel]); + const nextSortIndex = useMemo( () => models.reduce((maxValue, item) => Math.max(maxValue, item.sortIndex), -1) + 1, [models] @@ -1410,6 +1435,8 @@ export default function ModelsPage() { nextSortIndex={nextSortIndex} isSaving={isSaving} onSave={saveModel} + onSavePriceRule={saveModelPriceRule} + priceRule={editingPriceRule} /> ) : null} diff --git a/apps/src/components/modals/model-catalog-modal.tsx b/apps/src/components/modals/model-catalog-modal.tsx index 7172ca169..09716434a 100644 --- a/apps/src/components/modals/model-catalog-modal.tsx +++ b/apps/src/components/modals/model-catalog-modal.tsx @@ -24,7 +24,8 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { ManagedModelPayload } from "@/lib/api/account-client"; +import { ManagedModelPayload, ModelPriceRuleUpsertPayload } from "@/lib/api/account-client"; +import type { ModelPriceRuleEntry } from "@/lib/api/account-client"; import { useI18n } from "@/lib/i18n/provider"; import { ManagedModelInfo } from "@/types"; @@ -35,6 +36,8 @@ interface ModelCatalogModalProps { nextSortIndex: number; isSaving?: boolean; onSave: (payload: ManagedModelPayload) => Promise; + onSavePriceRule?: (payload: ModelPriceRuleUpsertPayload) => Promise; + priceRule?: ModelPriceRuleEntry | null; } interface ModelCatalogDraft { @@ -49,6 +52,9 @@ interface ModelCatalogDraft { visibility: string; defaultReasoningLevel: string; advancedJson: string; + inputPricePer1m: string; + cachedInputPricePer1m: string; + outputPricePer1m: string; } const EDITABLE_ADVANCED_KEYS = [ @@ -195,6 +201,7 @@ function buildAdvancedJson(model: ManagedModelInfo | null | undefined): string { function buildDraft( model: ManagedModelInfo | null | undefined, nextSortIndex: number, + priceRule?: ModelPriceRuleEntry | null, ): ModelCatalogDraft { return { slug: model?.slug || "", @@ -208,6 +215,9 @@ function buildDraft( visibility: normalizeVisibilityValue(model?.visibility), defaultReasoningLevel: model?.defaultReasoningLevel || "", advancedJson: buildAdvancedJson(model), + inputPricePer1m: priceRule?.inputPricePer1m != null ? String(priceRule.inputPricePer1m) : "", + cachedInputPricePer1m: priceRule?.cachedInputPricePer1m != null ? String(priceRule.cachedInputPricePer1m) : "", + outputPricePer1m: priceRule?.outputPricePer1m != null ? String(priceRule.outputPricePer1m) : "", }; } @@ -261,22 +271,48 @@ export function ModelCatalogModal({ nextSortIndex, isSaving = false, onSave, + onSavePriceRule, + priceRule, }: ModelCatalogModalProps) { const { t } = useI18n(); const [draft, setDraft] = useState(() => - buildDraft(model, nextSortIndex), + buildDraft(model, nextSortIndex, priceRule), ); + const [priceError, setPriceError] = useState(null); + const [savingPrice, setSavingPrice] = useState(false); useEffect(() => { if (!open) return; const frameId = window.requestAnimationFrame(() => { - setDraft(buildDraft(model, nextSortIndex)); + setDraft(buildDraft(model, nextSortIndex, priceRule)); + setPriceError(null); + setSavingPrice(false); }); return () => { window.cancelAnimationFrame(frameId); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [model, nextSortIndex, open]); + useEffect(() => { + if (!open) return; + setDraft((prev) => ({ + ...prev, + inputPricePer1m: + priceRule?.inputPricePer1m != null + ? String(priceRule.inputPricePer1m) + : "", + cachedInputPricePer1m: + priceRule?.cachedInputPricePer1m != null + ? String(priceRule.cachedInputPricePer1m) + : "", + outputPricePer1m: + priceRule?.outputPricePer1m != null + ? String(priceRule.outputPricePer1m) + : "", + })); + }, [priceRule, open]); + const title = useMemo( () => (model ? t("编辑模型") : t("新增模型")), [model, t], @@ -292,7 +328,8 @@ export function ModelCatalogModal({ const handleSave = async () => { const slug = draft.slug.trim(); if (!slug) { - throw new Error("模型 slug 不能为空"); + setPriceError("模型 slug 不能为空"); + return; } const advancedFields = parseJsonObject(draft.advancedJson, "高级 JSON"); @@ -312,6 +349,30 @@ export function ModelCatalogModal({ updatedAt: model?.updatedAt ?? 0, }; + const ip = draft.inputPricePer1m.trim(); + const cp = draft.cachedInputPricePer1m.trim(); + const op = draft.outputPricePer1m.trim(); + const hasUserInput = ip !== "" || cp !== "" || op !== ""; + const hasExisting = priceRule != null; + + if (hasUserInput || hasExisting) { + const inputNum = ip !== "" ? Number(ip) : (priceRule?.inputPricePer1m ?? null); + const cachedNum = cp !== "" ? Number(cp) : (priceRule?.cachedInputPricePer1m ?? null); + const outputNum = op !== "" ? Number(op) : (priceRule?.outputPricePer1m ?? null); + if ( + (inputNum !== null && (!Number.isFinite(inputNum) || inputNum < 0)) || + (cachedNum !== null && (!Number.isFinite(cachedNum) || cachedNum < 0)) || + (outputNum !== null && (!Number.isFinite(outputNum) || outputNum < 0)) + ) { + setPriceError("价格必须为非负有效数字"); + return; + } + if (inputNum == null || outputNum == null) { + setPriceError("输入价格和输出价格必须同时填写"); + return; + } + } + const saved = await onSave({ previousSlug: model?.slug || null, sourceKind: nextModel.sourceKind, @@ -320,6 +381,24 @@ export function ModelCatalogModal({ model: nextModel, }); if (saved) { + if (onSavePriceRule && slug && (hasUserInput || hasExisting)) { + try { + setSavingPrice(true); + await onSavePriceRule({ + modelPattern: slug, + inputPricePer1m: hasUserInput ? (ip !== "" ? Number(ip) : (priceRule?.inputPricePer1m ?? null)) : null, + cachedInputPricePer1m: hasUserInput ? (cp !== "" ? Number(cp) : (priceRule?.cachedInputPricePer1m ?? null)) : null, + outputPricePer1m: hasUserInput ? (op !== "" ? Number(op) : (priceRule?.outputPricePer1m ?? null)) : null, + }); + } catch (error) { + setPriceError( + `模型已保存,但价格保存失败: ${error instanceof Error ? error.message : String(error)}`, + ); + setSavingPrice(false); + return; + } + setSavingPrice(false); + } onOpenChange(false); } }; @@ -500,6 +579,60 @@ export function ModelCatalogModal({ +
+ +

+ {t("零表示不计费,价格将用于请求成本估算。")} +

+ {priceError ? ( +

{priceError}

+ ) : null} +
+
+
+ + + updateDraft("inputPricePer1m", event.target.value) + } + placeholder="0" + /> +
+
+ + + updateDraft("cachedInputPricePer1m", event.target.value) + } + placeholder="0" + /> +
+
+ + + updateDraft("outputPricePer1m", event.target.value) + } + placeholder="0" + /> +
+
+