Skip to content
24 changes: 24 additions & 0 deletions apps/src-tauri/src/commands/apikey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
) -> Result<serde_json::Value, String> {
rpc_call_in_background("quota/modelPriceRules/list", addr, None).await
}

#[tauri::command]
pub async fn service_model_price_rule_read(
addr: Option<String>,
model_pattern: String,
) -> Result<serde_json::Value, String> {
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<String>,
payload: serde_json::Value,
) -> Result<serde_json::Value, String> {
rpc_call_in_background("quota/modelPriceRule/upsert", addr, Some(payload)).await
}
3 changes: 3 additions & 0 deletions apps/src-tauri/src/commands/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 28 additions & 1 deletion apps/src/app/models/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -133,6 +133,8 @@ export default function ModelsPage() {
isServiceReady,
refreshRemote,
saveModel,
saveModelPriceRule,
readModelPriceRule,
deleteModel,
deleteModels,
exportCodexCache,
Expand All @@ -159,6 +161,7 @@ export default function ModelsPage() {
const [editingSlug, setEditingSlug] = useState<string | null>(null);
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([]);
const [deleteSlugs, setDeleteSlugs] = useState<string[]>([]);
const [editingPriceRule, setEditingPriceRule] = useState<ModelPriceRuleEntry | null>(null);
const [activeModelSlug, setActiveModelSlug] = useState<string>("");
const [routingDialogOpen, setRoutingDialogOpen] = useState(false);
const [sourceDraft, setSourceDraft] = useState({
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -1410,6 +1435,8 @@ export default function ModelsPage() {
nextSortIndex={nextSortIndex}
isSaving={isSaving}
onSave={saveModel}
onSavePriceRule={saveModelPriceRule}
priceRule={editingPriceRule}
/>
) : null}

Expand Down
142 changes: 137 additions & 5 deletions apps/src/components/modals/model-catalog-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -35,6 +36,8 @@ interface ModelCatalogModalProps {
nextSortIndex: number;
isSaving?: boolean;
onSave: (payload: ManagedModelPayload) => Promise<ManagedModelInfo | null>;
onSavePriceRule?: (payload: ModelPriceRuleUpsertPayload) => Promise<void>;
priceRule?: ModelPriceRuleEntry | null;
}

interface ModelCatalogDraft {
Expand All @@ -49,6 +52,9 @@ interface ModelCatalogDraft {
visibility: string;
defaultReasoningLevel: string;
advancedJson: string;
inputPricePer1m: string;
cachedInputPricePer1m: string;
outputPricePer1m: string;
}

const EDITABLE_ADVANCED_KEYS = [
Expand Down Expand Up @@ -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 || "",
Expand All @@ -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) : "",
};
}

Expand Down Expand Up @@ -261,22 +271,48 @@ export function ModelCatalogModal({
nextSortIndex,
isSaving = false,
onSave,
onSavePriceRule,
priceRule,
}: ModelCatalogModalProps) {
const { t } = useI18n();
const [draft, setDraft] = useState<ModelCatalogDraft>(() =>
buildDraft(model, nextSortIndex),
buildDraft(model, nextSortIndex, priceRule),
);
const [priceError, setPriceError] = useState<string | null>(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],
Expand All @@ -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");
Expand All @@ -312,6 +349,29 @@ 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 !== "";

if (hasUserInput) {
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,
Expand All @@ -320,6 +380,24 @@ export function ModelCatalogModal({
model: nextModel,
});
if (saved) {
if (onSavePriceRule && slug && hasUserInput) {
try {
setSavingPrice(true);
await onSavePriceRule({
modelPattern: slug,
inputPricePer1m: ip !== "" ? Number(ip) : (priceRule?.inputPricePer1m ?? null),
cachedInputPricePer1m: cp !== "" ? Number(cp) : (priceRule?.cachedInputPricePer1m ?? null),
outputPricePer1m: op !== "" ? Number(op) : (priceRule?.outputPricePer1m ?? null),
});
} catch (error) {
setPriceError(
`模型已保存,但价格保存失败: ${error instanceof Error ? error.message : String(error)}`,
);
setSavingPrice(false);
return;
}
setSavingPrice(false);
}
onOpenChange(false);
}
};
Expand Down Expand Up @@ -500,6 +578,60 @@ export function ModelCatalogModal({
</Card>
</div>

<div className="space-y-2">
<Label className="text-sm font-medium">{t("Token 价格 (USD / 1M tokens)")}</Label>
<p className="text-xs text-muted-foreground">
{t("零表示不计费,价格将用于请求成本估算。")}
</p>
{priceError ? (
<p className="text-xs text-destructive">{priceError}</p>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="price-input">{t("输入价格")}</Label>
<Input
id="price-input"
type="number"
step="0.0001"
min="0"
value={draft.inputPricePer1m}
onChange={(event) =>
updateDraft("inputPricePer1m", event.target.value)
}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="price-cached">{t("缓存输入价格")}</Label>
<Input
id="price-cached"
type="number"
step="0.0001"
min="0"
value={draft.cachedInputPricePer1m}
onChange={(event) =>
updateDraft("cachedInputPricePer1m", event.target.value)
}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="price-output">{t("输出价格")}</Label>
<Input
id="price-output"
type="number"
step="0.0001"
min="0"
value={draft.outputPricePer1m}
onChange={(event) =>
updateDraft("outputPricePer1m", event.target.value)
}
placeholder="0"
/>
</div>
</div>

<div className="space-y-2">
<Label htmlFor="model-advanced-json">{t("高级 JSON")}</Label>
<Textarea
Expand Down Expand Up @@ -532,7 +664,7 @@ export function ModelCatalogModal({
onClick={() => {
void handleSave();
}}
disabled={isSaving}
disabled={isSaving || savingPrice}
>
{isSaving ? t("保存中...") : t("保存模型")}
</Button>
Expand Down
9 changes: 9 additions & 0 deletions apps/src/hooks/useManagedModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ManagedModelSourceMappingPayload,
ManagedModelSourceModelPayload,
ManagedModelSourceSyncPayload,
ModelPriceRuleUpsertPayload,
} from "@/lib/api/account-client";
import { serviceClient } from "@/lib/api/service-client";
import {
Expand Down Expand Up @@ -405,6 +406,14 @@ export function useManagedModels() {
if (!ensureServiceReady("保存模型")) return null;
return saveMutation.mutateAsync(params);
},
saveModelPriceRule: async (params: ModelPriceRuleUpsertPayload) => {
if (!ensureServiceReady("保存模型价格")) throw new Error("服务未就绪,无法保存模型价格");
await accountClient.upsertModelPriceRule(params);
},
readModelPriceRule: async (modelPattern: string) => {
if (!ensureServiceReady("读取模型价格")) return null;
return accountClient.readModelPriceRule(modelPattern);
},
deleteModel: async (slug: string) => {
if (!ensureServiceReady("删除模型")) return false;
await deleteMutation.mutateAsync(slug);
Expand Down
Loading