Skip to content

[Feature] 模型价格设置:长上下文档位不支持用户覆盖,保存后被 normalization 回写为派生值 #2665

Description

@hcy0317

问题描述

Codex API 服务 →「模型价格设置」中,「标准(长上下文)」档位(输入 / 缓存输入 / 输出)具有可编辑控件外观,但不具备可写语义:提交的值在保存阶段被后端归一化逻辑丢弃,并由基础单价按固定乘数因子重新派生,界面上表现为显示自定义但价格回退至默认值。

环境信息

  • 应用版本:Cockpit Tools v1.3.62(Windows)
  • 对照提交:4ea6a34df6aa3b2d494c3cca5983bd09a84a9e3a
  • 相关配置项:Codex API 服务模型价格设置

复现步骤

  1. 打开 Codex API 服务 → 模型页 →「价格设置」。
  2. 选择任一会话级长上下文模型(如 gpt-6-astra)。
  3. 仅修改「标准(长上下文)」三项单价。
  4. 点击「保存」,界面提示「价格设置已保存」。
  5. 关闭弹窗后重新打开。

预期行为

三项单价作为修改后的值持久化,参与后续费用估算与历史重算。

实际行为

  1. 重新打开后三项恢复为基础单价 ×2 / ×2 / ×1.5 的派生值(gpt-6-astra:标准 10 / 1 / 50 → 长上下文 20 / 2 / 75)。
  2. 若仅修改标准档单价,界面残留的旧长上下文值会一并提交,后端再按新基础单价重新派生,结果仍为派生值覆盖。

定位分析

结论:长上下文档位在当前实现中为只读派生字段,缺少用户覆盖语义;后端与前端各有一处导致入参被回写。

1. 后端归一化丢弃绝对值入参

文件:src-tauri/src/modules/codex_local_access_routing_pricing.rs
位置:normalize_model_pricings(L1672-L1674)

// standard_long absolute fields are display-only / legacy; billing uses
// multipliers. Persist derived display values for session-long models.
let standard_long = session_long.then(|| derived_standard_long_price(standard));

该分支不读取入参中的 standard_long_* 字段,直接由标准档单价与缩放因子重新派生,代码注释已声明其为 display-only / legacy。派生函数:

fn derived_standard_long_price(standard: CodexLocalAccessPrice) -> CodexLocalAccessPrice {
// OpenAI above-272k: input x2, cache x2, output x1.5.
codex_price(
standard.input_usd_per_million * CODEX_LOCAL_ACCESS_LONG_CONTEXT_INPUT_MULTIPLIER,
standard.cached_input_usd_per_million * CODEX_LOCAL_ACCESS_LONG_CONTEXT_CACHE_MULTIPLIER,
standard.output_usd_per_million * CODEX_LOCAL_ACCESS_LONG_CONTEXT_OUTPUT_MULTIPLIER,
)
}
fn price_book_entry_to_model_pricing(
entry: &CodexLocalAccessPriceBookEntry,
) -> CodexLocalAccessModelPricing {

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(保存成功后由服务端返回行重建,输入被覆盖)

if (!pricingModalOpen) return;
setPricingDrafts(modelPricingRows.map(modelPricingDraftFromRow));
setPricingError("");
}, [modelPricingRows, pricingModalOpen]);

保存入口与入参构造:L2726-L2916

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", "价格设置已保存"),
);
};

4. 控件未标注只读语义

文件:src/pages/CodexApiServiceView.tsx
位置:L3690-L3754

<div className="codex-api-service-pricing-inputs">
<input
type="number"
min={0}
step="0.000001"
value={draft.standardLongInputUsdPerMillion}
placeholder={t(
"codex.apiService.models.pricingOfficialMissing",
"-",
)}
aria-label={t(
"codex.apiService.models.pricingStandardLongInput",
"标准(长上下文)输入",
)}
onChange={(event) =>
updatePricingDraft(
draft.modelId,
"standardLongInputUsdPerMillion",
event.target.value,
)
}
/>
<input
type="number"
min={0}
step="0.000001"
value={draft.standardLongCachedInputUsdPerMillion}
placeholder={t(
"codex.apiService.models.pricingOfficialMissing",
"-",
)}
aria-label={t(
"codex.apiService.models.pricingStandardLongCache",
"标准(长上下文)缓存输入",
)}
onChange={(event) =>
updatePricingDraft(
draft.modelId,
"standardLongCachedInputUsdPerMillion",
event.target.value,
)
}
/>
<input
type="number"
min={0}
step="0.000001"
value={draft.standardLongOutputUsdPerMillion}
placeholder={t(
"codex.apiService.models.pricingOfficialMissing",
"-",
)}
aria-label={t(
"codex.apiService.models.pricingStandardLongOutput",
"标准(长上下文)输出",
)}
onChange={(event) =>
updatePricingDraft(
draft.modelId,
"standardLongOutputUsdPerMillion",
event.target.value,
)
}
/>
</div>

业务影响

Codex API 服务支持接入自建或第三方中转上游,该场景下模型名相同但阶梯计价口径不同(如超过阈值后整体改价、长上下文不改价等)。当前实现仅支持官方单一倍率口径,导致本地估算价值与上游实际账单存在系统性偏差,且无法通过配置修正。

改进建议

  1. 后端:为长上下文档位增加用户覆盖语义。仅当三项为空、或等于基础价派生值、或来源于未修改的内置预设时,回退至倍率派生。
  2. 配置模型:增加字段标识覆盖来源(用户覆盖 / 派生),按仓库约定补充 serde(default),保证已有配置升级兼容。
  3. 前端:保存成功后不得无条件以服务端状态覆盖草稿;若产品不接受自定义,将该三项置为只读控件并标注计算规则。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions