This repository was archived by the owner on Jul 30, 2026. It is now read-only.
forked from junhoyeo/tokscale
-
Notifications
You must be signed in to change notification settings - Fork 1
fix(pricing): require source-backed model prices #77
Merged
makoMakoGo
merged 3 commits into
personal/local-clients
from
fix/pricing-source-authority
Jun 24, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -376,13 +376,11 @@ tokscale pricing "claude-3-5-sonnet" --provider litellm | |
|
|
||
| 1. **自定义价格覆盖** - `~/.config/tokscale/custom-pricing.json` 中大小写不敏感的完整 key 精确匹配 | ||
| 2. **精确匹配** - 在 LiteLLM/OpenRouter 数据库中直接查找 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win 查询策略中的“精确匹配”来源缺少 models.dev。 Line 378 仅提到 LiteLLM/OpenRouter,但 Line 1325 已声明三源(含 models.dev)。建议保持一致,避免中文读者误解查询覆盖范围。 🤖 Prompt for AI Agents |
||
| 3. **别名解析** - 解析可信显式别名(例如:`big-pickle` → `glm-4.7`) | ||
| 4. **版本标准化** - 处理版本格式(`claude-3-5-sonnet` ↔ `claude-3.5-sonnet`) | ||
| 5. **提供商前缀匹配** - 尝试常见前缀(`anthropic/`、`openai/` 等) | ||
| 6. **Cursor 模型定价** - LiteLLM/OpenRouter 中尚未收录的模型的硬编码定价(例如:`gpt-5.3-codex`) | ||
| 7. **模糊匹配** - 部分模型名称的词边界匹配 | ||
| 3. **版本标准化** - 处理确定性的目录版本格式(`claude-3-5-sonnet` ↔ `claude-3.5-sonnet`) | ||
| 4. **提供商前缀匹配** - 尝试目录中的提供商前缀(`anthropic/`、`openai/` 等) | ||
| 5. **模糊匹配** - 部分模型名称的词边界匹配 | ||
|
|
||
| 独立价格查询不会推断任意 route 前缀、source 前缀或 reasoning-tier 后缀。来源特定的模型解码应在对应 parser 中完成;`tokscale pricing <model>` 是目录查询,不是原始 route 清洗工具。自定义价格覆盖也只匹配完整 key;如果要为 `accounts/fireworks/models/...` 这类 gateway 路径定价,需要在 custom pricing 文件中写入完整路径。 | ||
| 独立价格查询不会推断任意 route 前缀、source 前缀或 reasoning-tier 后缀。来源特定的模型解码应在对应 parser 中完成;`tokscale pricing <model>` 是目录查询,不是原始 route 清洗工具。对本地报告而言,自定义价格覆盖匹配的是 parser 输出的 canonical model ID;独立价格查询则按命令参数本身匹配。如果要为 `accounts/fireworks/models/...` 这类 gateway 路径定价,需要在 custom pricing 文件中写入完整路径。 | ||
|
|
||
| **提供商优先级:** | ||
|
|
||
|
|
@@ -1324,15 +1322,14 @@ Codebuff(前身 Manicode)按聊天写入 JSON 文件。Tokscale 从 `metadat | |
|
|
||
| ## 定价 | ||
|
|
||
| Tokscale 从 [LiteLLM 的价格数据库](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)获取实时价格。 | ||
| Tokscale 从 [LiteLLM 的价格数据库](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)、OpenRouter 和 models.dev 获取实时价格。 | ||
|
|
||
| **动态回退**:对于 LiteLLM 中尚未收录的模型(例如最近发布的模型),Tokscale 会自动从 [OpenRouter 的端点 API](https://openrouter.ai/docs/api/api-reference/endpoints/list-endpoints) 获取定价。 | ||
|
|
||
| **Cursor 模型定价**:对于 LiteLLM 和 OpenRouter 中都尚未收录的最新模型(例如 `gpt-5.3-codex`),Tokscale 使用从 [Cursor 模型文档](https://cursor.com/en-US/docs/models)获取的硬编码定价。这些覆盖在所有上游来源之后、模糊匹配之前检查,因此当真正的上游定价可用时会自动让步。 | ||
| 如果模型没有命中自定义价格或上游价格来源,Tokscale 会保留 `$0.00` 派生成本,不再使用内置价格。 | ||
|
|
||
| **缓存**:价格数据以 1 小时 TTL 缓存到磁盘,确保快速启动: | ||
| - LiteLLM 缓存:`~/.config/tokscale/cache/pricing-litellm.json` | ||
| - OpenRouter 缓存:`~/.config/tokscale/cache/pricing-openrouter.json`(缓存支持提供商的模型作者定价信息) | ||
| - models.dev 缓存:`~/.config/tokscale/cache/pricing-models-dev.json` | ||
|
|
||
| 定价包括: | ||
| - 输入 Token | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,8 @@ pub(crate) const DEEPSEEK_V4_PRO_BETA_ALIAS: &str = "model1"; | |
| pub(crate) const DEEPSEEK_V4_FLASH_BETA_ALIAS: &str = "model2"; | ||
|
|
||
| const CLAUDE_FAMILIES: &[&str] = &["opus", "sonnet", "haiku", "fable"]; | ||
| const OPENAI_REASONING_TIERS: &[&str] = | ||
| &["minimal", "low", "medium", "high", "xhigh", "auto", "none"]; | ||
|
|
||
| pub(crate) fn is_deepseek_v4_beta_alias(model: &str) -> bool { | ||
| let lower = model.trim().to_lowercase(); | ||
|
|
@@ -24,7 +26,7 @@ pub(crate) fn canonicalize_source_model_id(model: &str) -> Option<String> { | |
| } | ||
|
|
||
| canonicalize_modern_claude_source_model(&lower) | ||
| .or_else(|| canonicalize_openai_source_model(&lower).map(str::to_string)) | ||
| .or_else(|| canonicalize_openai_source_model(&lower)) | ||
| .or_else(|| canonicalize_glm_source_model(&lower).map(str::to_string)) | ||
| .or_else(|| canonicalize_qwen_source_model(&lower)) | ||
| .or_else(|| canonicalize_kimi_source_model(&lower).map(str::to_string)) | ||
|
|
@@ -40,13 +42,71 @@ fn canonical_model_segment(model: &str) -> &str { | |
| .unwrap_or(model) | ||
| } | ||
|
|
||
| fn canonicalize_openai_source_model(model: &str) -> Option<&'static str> { | ||
| match canonical_model_segment(model) { | ||
| "gpt-5.5-fast" => Some("gpt-5.5"), | ||
| _ => None, | ||
| fn canonicalize_openai_source_model(model: &str) -> Option<String> { | ||
| let model = canonical_model_segment(model); | ||
|
|
||
| if let Some(base) = strip_parenthesized_openai_reasoning_tier(model) { | ||
| return Some(base.to_string()); | ||
| } | ||
|
|
||
| if let Some((base, tier)) = model.rsplit_once('-') { | ||
| if (tier == "fast" || OPENAI_REASONING_TIERS.contains(&tier)) | ||
| && is_openai_gpt_source_base_model(base) | ||
| { | ||
| return Some(base.to_string()); | ||
| } | ||
| } | ||
|
Comment on lines
+52
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 补上 当前只按 建议修复- if let Some((base, tier)) = model.rsplit_once('-') {
- if (tier == "fast" || OPENAI_REASONING_TIERS.contains(&tier))
- && is_openai_gpt_source_base_model(base)
- {
- return Some(base.to_string());
- }
+ for separator in ['-', '_'] {
+ if let Some((base, tier)) = model.rsplit_once(separator) {
+ if (tier == "fast" || OPENAI_REASONING_TIERS.contains(&tier))
+ && is_openai_gpt_source_base_model(base)
+ {
+ return Some(base.to_string());
+ }
+ }
}Also applies to: 316-330 🤖 Prompt for AI Agents |
||
|
|
||
| None | ||
| } | ||
|
|
||
| fn strip_parenthesized_openai_reasoning_tier(model: &str) -> Option<&str> { | ||
| let (base, tier) = model.rsplit_once('(')?; | ||
| let tier = tier.strip_suffix(')')?; | ||
| let base = | ||
| base.trim_end_matches(|ch: char| ch.is_ascii_whitespace() || matches!(ch, '-' | '_')); | ||
| if OPENAI_REASONING_TIERS.contains(&tier) && is_openai_gpt_source_base_model(base) { | ||
| Some(base) | ||
| } else { | ||
| None | ||
| } | ||
| } | ||
|
|
||
| fn is_openai_gpt_source_base_model(model: &str) -> bool { | ||
| let rest = match model.strip_prefix("gpt-") { | ||
| Some(rest) => rest, | ||
| None => return false, | ||
| }; | ||
|
|
||
| let (version, suffix) = match rest.split_once('-') { | ||
| Some((version, suffix)) => (version, Some(suffix)), | ||
| None => (rest, None), | ||
| }; | ||
| if !is_openai_gpt_version(version) { | ||
| return false; | ||
| } | ||
|
|
||
| match suffix { | ||
| None => true, | ||
| Some("nano" | "mini" | "pro" | "codex" | "codex-max" | "codex-spark") => true, | ||
| Some(_) => false, | ||
| } | ||
| } | ||
|
|
||
| fn is_openai_gpt_version(value: &str) -> bool { | ||
| if value == "5" { | ||
| return true; | ||
| } | ||
|
|
||
| matches!( | ||
| value.split_once('.'), | ||
| Some((major, minor)) | ||
| if major == "5" | ||
| && !minor.is_empty() | ||
| && minor.bytes().all(|byte| byte.is_ascii_digit()) | ||
| ) | ||
| } | ||
|
|
||
| fn canonicalize_glm_source_model(model: &str) -> Option<&'static str> { | ||
| let model = canonical_model_segment(model); | ||
| if matches!(model, "glm-4.7-free" | "glm-4.7:free" | "glm-4.7 (free)") { | ||
|
|
@@ -253,6 +313,21 @@ mod tests { | |
| let cases = [ | ||
| ("gpt-5.5-fast", "gpt-5.5"), | ||
| ("openai/gpt-5.5-fast", "gpt-5.5"), | ||
| ("gpt-5.5(high)", "gpt-5.5"), | ||
| ("gpt-5.5 (high)", "gpt-5.5"), | ||
| ("gpt-5.5-(high)", "gpt-5.5"), | ||
| ("gpt-5.5_(high)", "gpt-5.5"), | ||
| ("openai/gpt-5.5(xhigh)", "gpt-5.5"), | ||
| ("openai/gpt-5.5 (xhigh)", "gpt-5.5"), | ||
| ("gpt-5.5-high", "gpt-5.5"), | ||
| ("gpt-5.5-xhigh", "gpt-5.5"), | ||
| ("gpt-5.4-mini-xhigh", "gpt-5.4-mini"), | ||
| ("gpt-5.4-mini(high)", "gpt-5.4-mini"), | ||
| ("gpt-5.4-nano-xhigh", "gpt-5.4-nano"), | ||
| ("gpt-5.4-pro(high)", "gpt-5.4-pro"), | ||
| ("gpt-5.3-codex-xhigh", "gpt-5.3-codex"), | ||
| ("gpt-5.3-codex-spark-high", "gpt-5.3-codex-spark"), | ||
| ("gpt-5.1-codex-max-xhigh", "gpt-5.1-codex-max"), | ||
| ("glm-4.7-free", "glm-4.7"), | ||
| ("glm-4.7:free-fast", "glm-4.7"), | ||
| ("glm-4.7 (free)-medium", "glm-4.7"), | ||
|
|
@@ -267,8 +342,10 @@ mod tests { | |
|
|
||
| assert_eq!(canonicalize_source_model_id("qwen3.7-max-2605"), None); | ||
| assert_eq!(canonicalize_source_model_id("qwen3.7-max-05-20"), None); | ||
| assert_eq!(canonicalize_source_model_id("gpt-5.3-codex-spark"), None); | ||
| assert_eq!(canonicalize_source_model_id("gpt-4o-high"), None); | ||
| assert_eq!( | ||
| canonicalize_source_model_id("gpt-5.1-codex-max-xhigh"), | ||
| canonicalize_source_model_id("gpt-5.3-codex-spark-lite"), | ||
| None | ||
| ); | ||
| } | ||
|
|
||
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.