|
const handleSaveModelPricings = async () => { |
|
if (pricingRepriceActive) { |
|
setPricingError( |
|
t( |
|
"codex.apiService.models.pricingRepriceSaveBlocked", |
|
"历史估算价值更新中,完成后再保存", |
|
), |
|
); |
|
return; |
|
} |
|
const presetMap = new Map( |
|
(state?.modelPricingPresets ?? []).map((item) => [ |
|
item.modelId.toLowerCase(), |
|
item, |
|
]), |
|
); |
|
const sameAsPreset = ( |
|
draft: ModelPricingDraft, |
|
preset: CodexLocalAccessModelPricing, |
|
) => |
|
sameOptionalPrice( |
|
parseOptionalPositiveIntegerDraft(draft.longContextThresholdTokens), |
|
preset.longContextThresholdTokens ?? null, |
|
) && |
|
sameOptionalPrice( |
|
parsePriceDraftValue(draft.inputUsdPerMillion, false), |
|
preset.inputUsdPerMillion, |
|
) && |
|
sameOptionalPrice( |
|
parsePriceDraftValue(draft.outputUsdPerMillion, false), |
|
preset.outputUsdPerMillion, |
|
) && |
|
sameOptionalPrice( |
|
parsePriceDraftValue(draft.cachedInputUsdPerMillion, true), |
|
preset.cachedInputUsdPerMillion ?? null, |
|
) && |
|
sameOptionalPrice( |
|
parsePriceDraftValue(draft.standardLongInputUsdPerMillion, true), |
|
preset.standardLongInputUsdPerMillion ?? null, |
|
) && |
|
sameOptionalPrice( |
|
parsePriceDraftValue(draft.standardLongOutputUsdPerMillion, true), |
|
preset.standardLongOutputUsdPerMillion ?? null, |
|
) && |
|
sameOptionalPrice( |
|
parsePriceDraftValue(draft.standardLongCachedInputUsdPerMillion, true), |
|
preset.standardLongCachedInputUsdPerMillion ?? null, |
|
) && |
|
sameOptionalPrice( |
|
parsePriceDraftValue(draft.priorityInputUsdPerMillion, true), |
|
preset.priorityInputUsdPerMillion ?? null, |
|
) && |
|
sameOptionalPrice( |
|
parsePriceDraftValue(draft.priorityOutputUsdPerMillion, true), |
|
preset.priorityOutputUsdPerMillion ?? null, |
|
) && |
|
sameOptionalPrice( |
|
parsePriceDraftValue(draft.priorityCachedInputUsdPerMillion, true), |
|
preset.priorityCachedInputUsdPerMillion ?? null, |
|
); |
|
const nextPricings: CodexLocalAccessModelPricing[] = []; |
|
for (const draft of pricingDrafts) { |
|
const longContextThresholdTokens = parseOptionalPositiveIntegerDraft( |
|
draft.longContextThresholdTokens, |
|
); |
|
const input = parsePriceDraftValue(draft.inputUsdPerMillion, false); |
|
const cached = parsePriceDraftValue(draft.cachedInputUsdPerMillion, true); |
|
const output = parsePriceDraftValue(draft.outputUsdPerMillion, false); |
|
const standardLongInput = parsePriceDraftValue( |
|
draft.standardLongInputUsdPerMillion, |
|
true, |
|
); |
|
const standardLongCached = parsePriceDraftValue( |
|
draft.standardLongCachedInputUsdPerMillion, |
|
true, |
|
); |
|
const standardLongOutput = parsePriceDraftValue( |
|
draft.standardLongOutputUsdPerMillion, |
|
true, |
|
); |
|
const priorityInput = parsePriceDraftValue( |
|
draft.priorityInputUsdPerMillion, |
|
true, |
|
); |
|
const priorityCached = parsePriceDraftValue( |
|
draft.priorityCachedInputUsdPerMillion, |
|
true, |
|
); |
|
const priorityOutput = parsePriceDraftValue( |
|
draft.priorityOutputUsdPerMillion, |
|
true, |
|
); |
|
const preset = presetMap.get(draft.modelId.toLowerCase()); |
|
const unsetUnknown = |
|
!preset && |
|
draft.longContextThresholdTokens.trim() === "" && |
|
draft.inputUsdPerMillion.trim() === "" && |
|
draft.cachedInputUsdPerMillion.trim() === "" && |
|
draft.outputUsdPerMillion.trim() === "" && |
|
draft.standardLongInputUsdPerMillion.trim() === "" && |
|
draft.standardLongCachedInputUsdPerMillion.trim() === "" && |
|
draft.standardLongOutputUsdPerMillion.trim() === "" && |
|
draft.priorityInputUsdPerMillion.trim() === "" && |
|
draft.priorityCachedInputUsdPerMillion.trim() === "" && |
|
draft.priorityOutputUsdPerMillion.trim() === ""; |
|
if (unsetUnknown) { |
|
continue; |
|
} |
|
// 阈值可空:非长上下文模型(如 gpt-5.4-mini)不填是合法的。 |
|
// 仅当用户填写了内容但不是正整数(解析为 NaN)时才拦截。 |
|
// 若填写了任一长上下文价格档,则必须同时提供合法阈值。 |
|
const hasLongContextTier = |
|
(standardLongInput != null && Number.isFinite(standardLongInput)) || |
|
(standardLongOutput != null && Number.isFinite(standardLongOutput)) || |
|
(standardLongCached != null && Number.isFinite(standardLongCached)); |
|
const tokenInvalid = hasLongContextTier |
|
? longContextThresholdTokens === null || |
|
!Number.isFinite(longContextThresholdTokens) |
|
: longContextThresholdTokens != null && |
|
!Number.isFinite(longContextThresholdTokens); |
|
const inputInvalid = input === null || !Number.isFinite(input); |
|
const cachedInvalid = cached !== null && !Number.isFinite(cached); |
|
const outputInvalid = output === null || !Number.isFinite(output); |
|
const tierInvalid = |
|
(standardLongInput !== null && !Number.isFinite(standardLongInput)) || |
|
(standardLongOutput !== null && |
|
!Number.isFinite(standardLongOutput)) || |
|
(standardLongCached !== null && !Number.isFinite(standardLongCached)) || |
|
(priorityInput !== null && !Number.isFinite(priorityInput)) || |
|
(priorityOutput !== null && !Number.isFinite(priorityOutput)) || |
|
(priorityCached !== null && !Number.isFinite(priorityCached)); |
|
if ( |
|
tokenInvalid || |
|
inputInvalid || |
|
cachedInvalid || |
|
outputInvalid || |
|
tierInvalid |
|
) { |
|
setPricingError( |
|
t( |
|
"codex.apiService.models.pricingInvalid", |
|
"价格必须是大于或等于 0 的数字,Token 阈值必须是正整数", |
|
), |
|
); |
|
return; |
|
} |
|
const allZero = |
|
!preset && |
|
input === 0 && |
|
output === 0 && |
|
(cached == null || cached === 0) && |
|
standardLongInput === 0 && |
|
standardLongOutput === 0 && |
|
(standardLongCached == null || standardLongCached === 0) && |
|
priorityInput === 0 && |
|
priorityOutput === 0 && |
|
(priorityCached == null || priorityCached === 0); |
|
if ((preset && sameAsPreset(draft, preset)) || allZero) { |
|
continue; |
|
} |
|
nextPricings.push({ |
|
modelId: draft.modelId, |
|
longContextThresholdTokens, |
|
inputUsdPerMillion: input, |
|
outputUsdPerMillion: output, |
|
cachedInputUsdPerMillion: cached, |
|
standardLongInputUsdPerMillion: standardLongInput, |
|
standardLongOutputUsdPerMillion: standardLongOutput, |
|
standardLongCachedInputUsdPerMillion: standardLongCached, |
|
priorityInputUsdPerMillion: priorityInput, |
|
priorityOutputUsdPerMillion: priorityOutput, |
|
priorityCachedInputUsdPerMillion: priorityCached, |
|
// priority_long_* is not a product tier; keep wire fields cleared. |
|
priorityLongInputUsdPerMillion: null, |
|
priorityLongOutputUsdPerMillion: null, |
|
priorityLongCachedInputUsdPerMillion: null, |
|
}); |
|
} |
|
setPricingError(""); |
|
setPricingRepriceProgress(null); |
|
await runAction( |
|
async () => { |
|
const next = |
|
await codexLocalAccessService.updateCodexLocalAccessModelPricings( |
|
nextPricings, |
|
); |
|
setState(next); |
|
}, |
|
t("codex.apiService.models.pricingSaved", "价格设置已保存"), |
|
); |
|
}; |
问题描述
Codex API 服务 →「模型价格设置」中,「标准(长上下文)」档位(输入 / 缓存输入 / 输出)具有可编辑控件外观,但不具备可写语义:提交的值在保存阶段被后端归一化逻辑丢弃,并由基础单价按固定乘数因子重新派生,界面上表现为显示自定义但价格回退至默认值。
环境信息
复现步骤
预期行为
三项单价作为修改后的值持久化,参与后续费用估算与历史重算。
实际行为
定位分析
结论:长上下文档位在当前实现中为只读派生字段,缺少用户覆盖语义;后端与前端各有一处导致入参被回写。
1. 后端归一化丢弃绝对值入参
文件:src-tauri/src/modules/codex_local_access_routing_pricing.rs
位置:normalize_model_pricings(L1672-L1674)
cockpit-tools/src-tauri/src/modules/codex_local_access_routing_pricing.rs
Lines 1672 to 1674 in 4ea6a34
该分支不读取入参中的 standard_long_* 字段,直接由标准档单价与缩放因子重新派生,代码注释已声明其为 display-only / legacy。派生函数:
cockpit-tools/src-tauri/src/modules/codex_local_access_routing_pricing.rs
Lines 1283 to 1294 in 4ea6a34
2. 缩放因子与阈值不可配置
文件:src-tauri/src/modules/codex_local_access_foundation.rs
常量:CODEX_LOCAL_ACCESS_LONG_CONTEXT_THRESHOLD_TOKENS = 272000;输入 / 缓存 ×2.0;输出 ×1.5
3. 前端保存后以服务端状态覆盖草稿
文件:src/pages/CodexApiServicePage.tsx
位置:L1591-L1594(保存成功后由服务端返回行重建,输入被覆盖)
cockpit-tools/src/pages/CodexApiServicePage.tsx
Lines 1591 to 1594 in 4ea6a34
保存入口与入参构造:L2726-L2916
cockpit-tools/src/pages/CodexApiServicePage.tsx
Lines 2726 to 2916 in 4ea6a34
4. 控件未标注只读语义
文件:src/pages/CodexApiServiceView.tsx
位置:L3690-L3754
cockpit-tools/src/pages/CodexApiServiceView.tsx
Lines 3690 to 3754 in 4ea6a34
业务影响
Codex API 服务支持接入自建或第三方中转上游,该场景下模型名相同但阶梯计价口径不同(如超过阈值后整体改价、长上下文不改价等)。当前实现仅支持官方单一倍率口径,导致本地估算价值与上游实际账单存在系统性偏差,且无法通过配置修正。
改进建议