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 @@ -132,6 +132,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
2 changes: 2 additions & 0 deletions apps/src/app/models/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export default function ModelsPage() {
isServiceReady,
refreshRemote,
saveModel,
saveModelPriceRule,
deleteModel,
deleteModels,
exportCodexCache,
Expand Down Expand Up @@ -1410,6 +1411,7 @@ export default function ModelsPage() {
nextSortIndex={nextSortIndex}
isSaving={isSaving}
onSave={saveModel}
onSavePriceRule={saveModelPriceRule}
/>
) : null}

Expand Down
78 changes: 77 additions & 1 deletion apps/src/components/modals/model-catalog-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ManagedModelPayload } from "@/lib/api/account-client";
import { ManagedModelPayload, ModelPriceRuleUpsertPayload } from "@/lib/api/account-client";
import { useI18n } from "@/lib/i18n/provider";
import { ManagedModelInfo } from "@/types";

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

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

const EDITABLE_ADVANCED_KEYS = [
Expand Down Expand Up @@ -208,6 +212,9 @@ function buildDraft(
visibility: normalizeVisibilityValue(model?.visibility),
defaultReasoningLevel: model?.defaultReasoningLevel || "",
advancedJson: buildAdvancedJson(model),
inputPricePer1m: "",
cachedInputPricePer1m: "",
outputPricePer1m: "",
};
}

Expand Down Expand Up @@ -261,6 +268,7 @@ export function ModelCatalogModal({
nextSortIndex,
isSaving = false,
onSave,
onSavePriceRule,
}: ModelCatalogModalProps) {
const { t } = useI18n();
const [draft, setDraft] = useState<ModelCatalogDraft>(() =>
Expand Down Expand Up @@ -320,6 +328,23 @@ export function ModelCatalogModal({
model: nextModel,
});
if (saved) {
if (onSavePriceRule && slug) {
const ip = draft.inputPricePer1m.trim();
const cp = draft.cachedInputPricePer1m.trim();
const op = draft.outputPricePer1m.trim();
if (ip !== "" || cp !== "" || op !== "") {
try {
await onSavePriceRule({
modelPattern: slug,
inputPricePer1m: ip !== "" ? Number(ip) : null,
cachedInputPricePer1m: cp !== "" ? Number(cp) : null,
outputPricePer1m: op !== "" ? Number(op) : null,
});
} catch {
// price save is non-fatal
}
}
}
onOpenChange(false);
}
};
Expand Down Expand Up @@ -500,6 +525,57 @@ 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>
</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
5 changes: 5 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,10 @@ export function useManagedModels() {
if (!ensureServiceReady("保存模型")) return null;
return saveMutation.mutateAsync(params);
},
saveModelPriceRule: async (params: ModelPriceRuleUpsertPayload) => {
if (!ensureServiceReady("保存模型价格")) return;
await accountClient.upsertModelPriceRule(params);
},
deleteModel: async (slug: string) => {
if (!ensureServiceReady("删除模型")) return false;
await deleteMutation.mutateAsync(slug);
Expand Down
48 changes: 48 additions & 0 deletions apps/src/lib/api/account-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,33 @@ export interface ManagedModelSourceMappingPayload {
billingModelSlug?: string | null;
}

export interface ModelPriceRuleEntry {
id: string;
provider: string;
modelPattern: string;
matchType: string;
inputPricePer1m: number | null;
cachedInputPricePer1m: number | null;
outputPricePer1m: number | null;
enabled: boolean;
priority: number;
source: string;
createdAt: number;
updatedAt: number;
}

export interface ModelPriceRuleUpsertPayload {
id?: string | null;
provider?: string | null;
modelPattern: string;
matchType?: string | null;
inputPricePer1m?: number | null;
cachedInputPricePer1m?: number | null;
outputPricePer1m?: number | null;
enabled?: boolean | null;
priority?: number | null;
}

export interface AggregateApiSupplierModelPayload {
supplierKey: string;
providerType: string;
Expand Down Expand Up @@ -861,6 +888,27 @@ export const accountClient = {
},
deleteManagedModel: (slug: string) =>
invoke("service_model_catalog_delete", withAddr({ slug })),
listModelPriceRules: async () => {
const result = await invoke<{ items: ModelPriceRuleEntry[] }>(
"service_model_price_rules_list",
withAddr(),
);
return result.items;
},
readModelPriceRule: async (modelPattern: string) => {
const result = await invoke<ModelPriceRuleEntry | null>(
"service_model_price_rule_read",
withAddr({ modelPattern }),
);
return result;
},
upsertModelPriceRule: async (payload: ModelPriceRuleUpsertPayload) => {
const result = await invoke<ModelPriceRuleEntry>(
"service_model_price_rule_upsert",
withAddr({ payload }),
);
return result;
},
async readApiKeySecret(keyId: string): Promise<string> {
const result = await invoke<unknown>(
"service_apikey_read_secret",
Expand Down
10 changes: 10 additions & 0 deletions apps/src/lib/api/transport-web-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,16 @@ export function createWebCommandMap(
service_model_source_mapping_delete: {
rpcMethod: "apikey/modelSourceMappingDelete",
},
service_model_price_rules_list: {
rpcMethod: "quota/modelPriceRules/list",
},
service_model_price_rule_read: {
rpcMethod: "quota/modelPriceRule/read",
},
service_model_price_rule_upsert: {
rpcMethod: "quota/modelPriceRule/upsert",
mapParams: (params) => asRecord(asRecord(params)?.payload) ?? {},
},
service_apikey_read_secret: {
rpcMethod: "apikey/readSecret",
mapParams: mapKeyIdToId,
Expand Down
54 changes: 54 additions & 0 deletions crates/core/src/rpc/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1656,6 +1656,60 @@ pub struct MemberDashboardSummaryResult {
pub alerts: Vec<MemberDashboardAlert>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelPriceRuleEntry {
pub id: String,
pub provider: String,
pub model_pattern: String,
pub match_type: String,
#[serde(default)]
pub input_price_per_1m: Option<f64>,
#[serde(default)]
pub cached_input_price_per_1m: Option<f64>,
#[serde(default)]
pub output_price_per_1m: Option<f64>,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub priority: i64,
#[serde(default)]
pub source: String,
#[serde(default)]
pub created_at: i64,
#[serde(default)]
pub updated_at: i64,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelPriceRuleListResult {
#[serde(default)]
pub items: Vec<ModelPriceRuleEntry>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelPriceRuleUpsertInput {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub provider: Option<String>,
pub model_pattern: String,
#[serde(default)]
pub match_type: Option<String>,
#[serde(default)]
pub input_price_per_1m: Option<f64>,
#[serde(default)]
pub cached_input_price_per_1m: Option<f64>,
#[serde(default)]
pub output_price_per_1m: Option<f64>,
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub priority: Option<i64>,
}

#[cfg(test)]
#[path = "tests/types_tests.rs"]
mod tests;
Loading