Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
143 changes: 138 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,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,
Expand All @@ -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);
}
};
Expand Down Expand Up @@ -500,6 +579,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 +665,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