feat(pricing): resolve provider-aware billing for multi-provider models#873
feat(pricing): resolve provider-aware billing for multi-provider models#873
Conversation
📝 WalkthroughWalkthrough引入多供应商定价解析与固化功能:新增 pricing-resolution 模块、扩展定价数据模型与相关仓库/动作、在代理/响应路径中使用解析结果并更新成本计算,同时添加多语言字符串与前端比较/固化 UI 及相应测试覆盖。 Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 分钟 Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 3❌ Failed checks (3 warnings)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request significantly upgrades the model pricing and billing system to handle complex scenarios involving multi-provider models and tiered long-context pricing. It introduces a robust pricing resolution pipeline, enhances the data structure for model prices, and provides new user interface features for better visibility and control over how costs are determined and applied. Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
This is a substantial and well-executed pull request that introduces a sophisticated provider-aware billing mechanism. The new pricing resolution logic is well-structured, and the changes are consistently applied across the backend, UI, and tests. The addition of provider price pinning and visibility in logs are excellent features that will greatly improve usability. I've identified a couple of issues, one in the UI and a more subtle one in the backend stream handling logic, which I've detailed in the comments.
| const getPricingSourceLabel = (source: string) => t(`logs.billingDetails.pricingSource.`); | ||
| const tChain = useTranslations("provider-chain"); |
There was a problem hiding this comment.
The implementation of getPricingSourceLabel is incorrect. The source parameter is not being used within the template literal, which will cause translation lookups to fail. It should be included in the key path.
| const getPricingSourceLabel = (source: string) => t(`logs.billingDetails.pricingSource.`); | |
| const tChain = useTranslations("provider-chain"); | |
| t(`logs.billingDetails.pricingSource.${source}`); |
|
|
||
| pushChunk(headPart, remainingHeadBytes); | ||
|
|
||
| pushToTail(); | ||
| pushChunk(tailPart, bytes - remainingHeadBytes); |
There was a problem hiding this comment.
There's a logical issue in how a chunk that spans the head/tail boundary is processed. The current implementation calls pushToTail() which uses the original, full-sized chunk from its closure scope, not the tailPart. Additionally, it then recursively calls pushChunk(tailPart, ...) which will process the tail part again. This will lead to duplicated and incorrect data in the buffered response for statistics.
A simpler and more correct approach would be to recursively call pushChunk for the head and tail parts, and set inTailMode to true after the head is filled.
| pushChunk(headPart, remainingHeadBytes); | |
| pushToTail(); | |
| pushChunk(tailPart, bytes - remainingHeadBytes); | |
| pushChunk(headPart, remainingHeadBytes); | |
| inTailMode = true; | |
| pushChunk(tailPart, bytes - remainingHeadBytes); |
| const providerIdForPersistence = providerIdOverride ?? session.provider?.id; | ||
|
|
||
| // 1. 结束请求状态追踪 | ||
| ProxyStatusTracker.getInstance().endRequest(messageContext.user.id, messageContext.id); | ||
|
|
||
| // 2. 更新请求时长 | ||
| await updateMessageRequestDuration(messageContext.id, duration); | ||
|
|
||
| // 3. 解析 usage metrics | ||
| const { usageMetrics } = parseUsageFromResponseText(responseText, provider.providerType); | ||
|
|
||
| if (!usageMetrics) { | ||
| // 即使没有 usageMetrics,也需要更新状态码和 provider chain | ||
| await updateMessageRequestDetails(messageContext.id, { | ||
| statusCode: statusCode, | ||
| ...(errorMessage ? { errorMessage } : {}), | ||
| ttfbMs: session.ttfbMs ?? duration, | ||
| providerChain: session.getProviderChain(), | ||
| model: session.getCurrentModel() ?? undefined, | ||
| providerId: providerIdForPersistence, // 更新最终供应商ID(重试切换后) | ||
| context1mApplied: session.getContext1mApplied(), | ||
| swapCacheTtlApplied: provider.swapCacheTtlBilling ?? false, | ||
| }); | ||
| return null; |
There was a problem hiding this comment.
Missing request-details update when usageMetrics is null
The old finalizeRequestStats called updateMessageRequestDetails before the early return when usageMetrics was null, ensuring that the status code, error message, provider chain, model, and providerId were always written to the database for every request. That call was removed — now the function simply returns null without persisting anything.
For all code paths that delegate to finalizeRequestStats (Gemini non-stream passthrough, streaming passthrough error branches, etc.), requests whose response body cannot be parsed for usage metrics — including many error responses — will never have their status code, error details, or redirected model written to the message record. This means the log entry will remain in an incomplete state.
| const providerIdForPersistence = providerIdOverride ?? session.provider?.id; | |
| // 1. 结束请求状态追踪 | |
| ProxyStatusTracker.getInstance().endRequest(messageContext.user.id, messageContext.id); | |
| // 2. 更新请求时长 | |
| await updateMessageRequestDuration(messageContext.id, duration); | |
| // 3. 解析 usage metrics | |
| const { usageMetrics } = parseUsageFromResponseText(responseText, provider.providerType); | |
| if (!usageMetrics) { | |
| // 即使没有 usageMetrics,也需要更新状态码和 provider chain | |
| await updateMessageRequestDetails(messageContext.id, { | |
| statusCode: statusCode, | |
| ...(errorMessage ? { errorMessage } : {}), | |
| ttfbMs: session.ttfbMs ?? duration, | |
| providerChain: session.getProviderChain(), | |
| model: session.getCurrentModel() ?? undefined, | |
| providerId: providerIdForPersistence, // 更新最终供应商ID(重试切换后) | |
| context1mApplied: session.getContext1mApplied(), | |
| swapCacheTtlApplied: provider.swapCacheTtlBilling ?? false, | |
| }); | |
| return null; | |
| if (!usageMetrics) { | |
| await updateMessageRequestDetails(messageContext.id, { | |
| statusCode: statusCode, | |
| ...(errorMessage ? { errorMessage } : {}), | |
| ttfbMs: session.ttfbMs ?? duration, | |
| providerChain: session.getProviderChain(), | |
| model: session.getCurrentModel() ?? undefined, | |
| providerId: providerIdForPersistence, | |
| context1mApplied: session.getContext1mApplied(), | |
| swapCacheTtlApplied: session.provider?.swapCacheTtlBilling ?? false, | |
| specialSettings: session.getSpecialSettings() ?? undefined, | |
| }); | |
| return null; | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/response-handler.ts
Line: 2898-2901
Comment:
**Missing request-details update when usageMetrics is null**
The old `finalizeRequestStats` called `updateMessageRequestDetails` before the early return when `usageMetrics` was null, ensuring that the status code, error message, provider chain, model, and `providerId` were always written to the database for every request. That call was removed — now the function simply returns `null` without persisting anything.
For all code paths that delegate to `finalizeRequestStats` (Gemini non-stream passthrough, streaming passthrough error branches, etc.), requests whose response body cannot be parsed for usage metrics — including many error responses — will never have their status code, error details, or redirected model written to the message record. This means the log entry will remain in an incomplete state.
```suggestion
if (!usageMetrics) {
await updateMessageRequestDetails(messageContext.id, {
statusCode: statusCode,
...(errorMessage ? { errorMessage } : {}),
ttfbMs: session.ttfbMs ?? duration,
providerChain: session.getProviderChain(),
model: session.getCurrentModel() ?? undefined,
providerId: providerIdForPersistence,
context1mApplied: session.getContext1mApplied(),
swapCacheTtlApplied: session.provider?.swapCacheTtlBilling ?? false,
specialSettings: session.getSpecialSettings() ?? undefined,
});
return null;
}
```
How can I resolve this? If you propose a fix, please make it concise.| if (longContextThresholdExceeded && inputAboveThreshold != null && usage.input_tokens != null) { | ||
| inputBucket = inputBucket.add(multiplyCost(usage.input_tokens, inputAboveThreshold)); | ||
| } else if ( | ||
| longContextThresholdExceeded && | ||
| context1mApplied && | ||
| inputCostPerToken != null && | ||
| usage.input_tokens != null | ||
| ) { | ||
| inputBucket = inputBucket.add( | ||
| calculateTieredCostWithSeparatePrices(usage.input_tokens, inputCostPerToken, inputAbove200k) | ||
| multiplyCost(usage.input_tokens, inputCostPerToken * CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER) | ||
| ); | ||
| } else { | ||
| inputBucket = inputBucket.add(multiplyCost(usage.input_tokens, inputCostPerToken)); | ||
| } | ||
|
|
||
| // Output tokens -> output bucket |
There was a problem hiding this comment.
All tokens billed at premium rate when threshold is exceeded
The previous implementation applied a tiered calculation: tokens up to the threshold were billed at the base rate; only the excess above the threshold was billed at the premium/above-threshold rate. The new implementation applies the inputAboveThreshold price to all input tokens the moment the threshold is crossed.
For example, with a Gemini model using 300K input tokens, a base rate of $1/M, and an above-200K rate of $2/M:
- Old behavior: 200K × $1 + 100K × $2 = $0.40
- New behavior: 300K × $2 = $0.60 (50% higher)
Similarly, for Claude 1M-context requests (context1mApplied), the CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER is now applied to the full token count rather than only to tokens above the threshold.
If the intent is to match a provider's "all tokens at the high tier" billing model, that should be documented explicitly. If this is unintentional, the tiered logic needs to be restored. The same pattern is repeated for outputBucket, all cache-creation buckets, and cache-read buckets in both calculateRequestCostBreakdown and calculateRequestCost.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/utils/cost-calculation.ts
Line: 221-236
Comment:
**All tokens billed at premium rate when threshold is exceeded**
The previous implementation applied a tiered calculation: tokens up to the threshold were billed at the base rate; only the excess above the threshold was billed at the premium/above-threshold rate. The new implementation applies the `inputAboveThreshold` price to **all** input tokens the moment the threshold is crossed.
For example, with a Gemini model using 300K input tokens, a base rate of $1/M, and an above-200K rate of $2/M:
- **Old behavior:** 200K × $1 + 100K × $2 = $0.40
- **New behavior:** 300K × $2 = $0.60 (50% higher)
Similarly, for Claude 1M-context requests (`context1mApplied`), the `CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER` is now applied to the full token count rather than only to tokens above the threshold.
If the intent is to match a provider's "all tokens at the high tier" billing model, that should be documented explicitly. If this is unintentional, the tiered logic needs to be restored. The same pattern is repeated for `outputBucket`, all cache-creation buckets, and cache-read buckets in both `calculateRequestCostBreakdown` and `calculateRequestCost`.
How can I resolve this? If you propose a fix, please make it concise.| @@ -755,26 +740,37 @@ export class ProxySession { | |||
| const primaryModel = useOriginal ? originalModel : redirectedModel; | |||
| const fallbackModel = useOriginal ? redirectedModel : originalModel; | |||
|
|
|||
| const findValidPriceDataByModel = async (modelName: string): Promise<ModelPriceData | null> => { | |||
| const result = await findLatestPriceByModel(modelName); | |||
| const data = result?.priceData; | |||
| if (!data || !hasValidPriceData(data)) { | |||
| return null; | |||
| } | |||
| return data; | |||
| }; | |||
|
|
|||
| let priceData: ModelPriceData | null = null; | |||
| if (primaryModel) { | |||
| priceData = await findValidPriceDataByModel(primaryModel); | |||
| } | |||
|
|
|||
| if (!priceData && fallbackModel && fallbackModel !== primaryModel) { | |||
| priceData = await findValidPriceDataByModel(fallbackModel); | |||
| } | |||
| const primaryRecord = primaryModel ? await findLatestPriceByModel(primaryModel) : null; | |||
| const fallbackRecord = | |||
| fallbackModel && fallbackModel !== primaryModel | |||
| ? await findLatestPriceByModel(fallbackModel) | |||
| : null; | |||
|
|
|||
| return resolvePricingForModelRecords({ | |||
| provider: provider ?? this.provider, | |||
| primaryModelName: primaryModel, | |||
| fallbackModelName: fallbackModel, | |||
| primaryRecord, | |||
| fallbackRecord, | |||
| }); | |||
| } | |||
|
|
|||
| this.cachedBillingPriceData = priceData; | |||
| return this.cachedBillingPriceData; | |||
| /** | |||
| * 根据系统配置的计费模型来源获取价格数据(带缓存) | |||
| * | |||
| * billingModelSource: | |||
| * - "original": 优先使用重定向前模型(getOriginalModel) | |||
| * - "redirected": 优先使用重定向后模型(request.model) | |||
| * | |||
| * Fallback:主模型无价格时尝试备选模型。 | |||
| * | |||
| * @returns 价格数据;无模型或无价格时返回 null | |||
| */ | |||
There was a problem hiding this comment.
getResolvedPricingByBillingSource has no per-request cache
The old getCachedPriceDataByBillingSource stored its result in this.cachedBillingPriceData and returned the cached value on subsequent calls. The new getResolvedPricingByBillingSource always performs fresh DB queries (up to two: one for the primary model, one for the fallback).
In response-handler.ts this method is now called in multiple independent places during a single request lifecycle:
- Inside
updateRequestCostFromUsage - In the streaming response handler (
ensurePricingResolutionSpecialSettingpath) - In
trackCostToRedis - In
finalizeRequestStats
This can result in 6–8 findLatestPriceByModel DB queries per request where there was previously 1. Consider adding a request-level cache (e.g. private resolvedPricingCache?: ResolvedPricing | null) similar to the removed cachedBillingPriceData.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/session.ts
Line: 704-768
Comment:
**`getResolvedPricingByBillingSource` has no per-request cache**
The old `getCachedPriceDataByBillingSource` stored its result in `this.cachedBillingPriceData` and returned the cached value on subsequent calls. The new `getResolvedPricingByBillingSource` always performs fresh DB queries (up to two: one for the primary model, one for the fallback).
In `response-handler.ts` this method is now called in multiple independent places during a single request lifecycle:
1. Inside `updateRequestCostFromUsage`
2. In the streaming response handler (`ensurePricingResolutionSpecialSetting` path)
3. In `trackCostToRedis`
4. In `finalizeRequestStats`
This can result in 6–8 `findLatestPriceByModel` DB queries per request where there was previously 1. Consider adding a request-level cache (e.g. `private resolvedPricingCache?: ResolvedPricing | null`) similar to the removed `cachedBillingPriceData`.
How can I resolve this? If you propose a fix, please make it concise.| modelName: string; | ||
| resolvedModelName: string; | ||
| resolvedPricingProviderKey: string; | ||
| source: | ||
| | "local_manual" | ||
| | "cloud_exact" | ||
| | "cloud_model_fallback" | ||
| | "priority_fallback" | ||
| | "single_provider_top_level" | ||
| | "official_fallback"; | ||
| }; |
There was a problem hiding this comment.
"official_fallback" source value is defined but never produced
PricingResolutionSpecialSetting.source includes "official_fallback", but ResolvedPricingSource (in pricing-resolution.ts) does not — the resolution logic never returns this value. The i18n messages also include a translation key for "official_fallback".
Since ensurePricingResolutionSpecialSetting always derives source from a ResolvedPricing (which is typed as ResolvedPricingSource), "official_fallback" can never appear in a stored PricingResolutionSpecialSetting. Consider either:
- Removing
"official_fallback"fromPricingResolutionSpecialSetting.source(and the i18n files) until it is implemented, or - Adding
"official_fallback"toResolvedPricingSourceif it is intentionally reserved for future use and document accordingly.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/types/special-settings.ts
Line: 207-217
Comment:
**`"official_fallback"` source value is defined but never produced**
`PricingResolutionSpecialSetting.source` includes `"official_fallback"`, but `ResolvedPricingSource` (in `pricing-resolution.ts`) does not — the resolution logic never returns this value. The i18n messages also include a translation key for `"official_fallback"`.
Since `ensurePricingResolutionSpecialSetting` always derives `source` from a `ResolvedPricing` (which is typed as `ResolvedPricingSource`), `"official_fallback"` can never appear in a stored `PricingResolutionSpecialSetting`. Consider either:
- Removing `"official_fallback"` from `PricingResolutionSpecialSetting.source` (and the i18n files) until it is implemented, or
- Adding `"official_fallback"` to `ResolvedPricingSource` if it is intentionally reserved for future use and document accordingly.
How can I resolve this? If you propose a fix, please make it concise.| pricing: base.pricing, | ||
| selected_pricing_provider: pricingProviderKey, | ||
| }; | ||
| } | ||
|
|
||
| function getDetailScore(pricingNode: Record<string, unknown>): number { | ||
| return DETAIL_FIELDS.reduce((score, field) => { | ||
| const value = pricingNode[field]; | ||
| return typeof value === "number" && Number.isFinite(value) ? score + 1 : score; | ||
| }, 0); | ||
| } | ||
|
|
||
| function compareDetailKeys( | ||
| a: string, | ||
| b: string, | ||
| pricingMap: Record<string, Record<string, unknown>> | ||
| ): number { |
There was a problem hiding this comment.
mergePriceData third argument may be undefined in resolveManualPricing
In resolveManualPricing, mergePriceData is called with record.priceData.selected_pricing_provider as string as the pricingProviderKey parameter. If selected_pricing_provider is undefined (which is the common case for legacy records), the cast silences TypeScript but the runtime value of pricingProviderKey inside mergePriceData will be undefined. When base.selected_pricing_provider is also not a string, mergePriceData will then set selected_pricing_provider: undefined in the returned priceData, which is benign but inconsistent with the resolvedPricingProviderKey that was computed with a "manual" fallback.
Consider passing the already-computed resolvedPricingProviderKey as the third argument instead:
| pricing: base.pricing, | |
| selected_pricing_provider: pricingProviderKey, | |
| }; | |
| } | |
| function getDetailScore(pricingNode: Record<string, unknown>): number { | |
| return DETAIL_FIELDS.reduce((score, field) => { | |
| const value = pricingNode[field]; | |
| return typeof value === "number" && Number.isFinite(value) ? score + 1 : score; | |
| }, 0); | |
| } | |
| function compareDetailKeys( | |
| a: string, | |
| b: string, | |
| pricingMap: Record<string, Record<string, unknown>> | |
| ): number { | |
| return { | |
| resolvedModelName: modelName ?? record.modelName, | |
| resolvedPricingProviderKey: | |
| (typeof record.priceData.selected_pricing_provider === "string" && | |
| record.priceData.selected_pricing_provider.trim()) || | |
| (typeof record.priceData.litellm_provider === "string" && record.priceData.litellm_provider.trim()) || | |
| "manual", | |
| source: "local_manual", | |
| priceData: mergePriceData( | |
| record.priceData, | |
| null, | |
| (typeof record.priceData.selected_pricing_provider === "string" && | |
| record.priceData.selected_pricing_provider.trim()) || | |
| (typeof record.priceData.litellm_provider === "string" && record.priceData.litellm_provider.trim()) || | |
| "manual" | |
| ), | |
| pricingNode: null, | |
| }; |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/utils/pricing-resolution.ts
Line: 198-214
Comment:
**`mergePriceData` third argument may be `undefined` in `resolveManualPricing`**
In `resolveManualPricing`, `mergePriceData` is called with `record.priceData.selected_pricing_provider as string` as the `pricingProviderKey` parameter. If `selected_pricing_provider` is `undefined` (which is the common case for legacy records), the cast silences TypeScript but the runtime value of `pricingProviderKey` inside `mergePriceData` will be `undefined`. When `base.selected_pricing_provider` is also not a string, `mergePriceData` will then set `selected_pricing_provider: undefined` in the returned `priceData`, which is benign but inconsistent with the `resolvedPricingProviderKey` that was computed with a `"manual"` fallback.
Consider passing the already-computed `resolvedPricingProviderKey` as the third argument instead:
```suggestion
return {
resolvedModelName: modelName ?? record.modelName,
resolvedPricingProviderKey:
(typeof record.priceData.selected_pricing_provider === "string" &&
record.priceData.selected_pricing_provider.trim()) ||
(typeof record.priceData.litellm_provider === "string" && record.priceData.litellm_provider.trim()) ||
"manual",
source: "local_manual",
priceData: mergePriceData(
record.priceData,
null,
(typeof record.priceData.selected_pricing_provider === "string" &&
record.priceData.selected_pricing_provider.trim()) ||
(typeof record.priceData.litellm_provider === "string" && record.priceData.litellm_provider.trim()) ||
"manual"
),
pricingNode: null,
};
```
How can I resolve this? If you propose a fix, please make it concise.Fixed: - Import ordering (alphabetical) in MetadataTab.tsx, SummaryTab.tsx, price-list.tsx, response-handler.ts, session.ts - Changed 'let' to 'const' for never-reassigned variable in response-handler.ts CI Run: https://github.com/ding113/claude-code-hub/actions/runs/22762234077 Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review Summary
This PR implements provider-aware billing resolution for multi-provider models with a clean pricing fallback chain architecture. The core pricing resolution logic is well-structured with comprehensive test coverage.
PR Size: XL
- Lines changed: 2,618 (2061 additions + 557 deletions)
- Files changed: 34
The PR is appropriately sized for its scope (new pricing resolution system + UI enhancements + i18n + tests). Consider splitting future enhancements into smaller PRs for faster review cycles.
Issues Found
| Category | Critical | High | Medium | Low |
|---|---|---|---|---|
| Logic/Bugs | 0 | 0 | 1 | 0 |
| Security | 0 | 0 | 0 | 0 |
| Error Handling | 0 | 0 | 0 | 0 |
| Types | 0 | 0 | 1 | 0 |
| Comments/Docs | 0 | 0 | 0 | 0 |
| Tests | 0 | 0 | 0 | 0 |
| Simplification | 0 | 0 | 0 | 1 |
Medium Priority Issues (Should Fix)
1. [TYPE-INCONSISTENCY] official_fallback defined in i18n but not in ResolvedPricingSource type
Location: src/lib/utils/pricing-resolution.ts:19-26, messages/en/dashboard.json:296
Why this is a problem: All 5 i18n language files define pricingSource.official_fallback label, but the ResolvedPricingSource type only includes:
export type ResolvedPricingSource =
| "local_manual"
| "cloud_exact"
| "cloud_model_fallback"
| "priority_fallback"
| "single_provider_top_level";The official_fallback value is never produced by the resolution logic. This creates dead translation strings that will never be displayed.
Suggested fix: Either add official_fallback to the type and update the resolution logic to produce it when appropriate, or remove the unused translation keys from all i18n files.
2. [LOGIC-BUG] pinModelPricingProviderAsManual hardcodes litellm source
Location: src/actions/model-prices.ts:649
Why this is a problem: The function queries cloud prices with hardcoded source "litellm":
const latestCloudPrice = await findLatestPriceByModelAndSource(modelName, "litellm");If a model's cloud price was ingested from a different source (e.g., openrouter, manual), the pinning feature will fail with the error "未找到云端模型价格" (cloud model price not found), even though the price exists in the database.
Suggested fix: Query for the most recent cloud price regardless of source, or try multiple sources in fallback order:
// Option 1: Query latest cloud price from any non-manual source
const latestCloudPrice = await findLatestPriceByModel(modelName);
// Option 2: Try litellm first, then fall back to other sources
const latestCloudPrice =
(await findLatestPriceByModelAndSource(modelName, "litellm")) ??
(await findLatestPriceByModelAndSource(modelName, "openrouter"));Low Priority Issues
3. [DEAD-CODE] findLatestPricesByModels defined but unused
Location: src/repository/model-price.ts:93-124
This batch query function is added but never called in this PR. Consider removing it if not planned for immediate use, or document its intended purpose.
Review Coverage
- Logic and correctness - Reviewed
- Security (OWASP Top 10) - No security concerns
- Error handling - Adequate error handling with user feedback
- Type safety - Minor inconsistency noted above
- Documentation accuracy - PR description accurately reflects changes
- Test coverage - Good coverage for new pricing resolution logic
- Code clarity - Clean implementation with clear separation of concerns
Positive Observations
- Well-structured pricing resolution with clear fallback chain (manual -> exact provider -> official -> detailed -> top-level)
- Comprehensive i18n coverage for all 5 supported languages
- New tests adequately cover pricing resolution logic and cost calculation changes
- Clean separation between
ResolvedPricingandModelPriceDatatypes
Automated review by Claude AI
| @@ -55,13 +57,7 @@ function calculateTieredCost( | |||
| return new Decimal(tokens).mul(baseCostDecimal); | |||
| } | |||
|
|
|||
| // 阈值内的token按基础费率计算 | |||
| const baseCost = new Decimal(threshold).mul(baseCostDecimal); | |||
| // 超出阈值的token按溢价费率计算 | |||
| const premiumTokens = tokens - threshold; | |||
| const premiumCost = new Decimal(premiumTokens).mul(baseCostDecimal).mul(premiumMultiplier); | |||
|
|
|||
| return baseCost.add(premiumCost); | |||
| return new Decimal(tokens).mul(baseCostDecimal).mul(premiumMultiplier); | |||
| } | |||
|
|
|||
| /** | |||
| @@ -72,7 +68,7 @@ function calculateTieredCost( | |||
| * @param threshold - 阈值(默认 200K) | |||
| * @returns 费用 | |||
| */ | |||
| function calculateTieredCostWithSeparatePrices( | |||
| function __calculateTieredCostWithSeparatePrices( | |||
| tokens: number, | |||
| baseCostPerToken: number, | |||
| premiumCostPerToken: number, | |||
| @@ -89,13 +85,41 @@ function calculateTieredCostWithSeparatePrices( | |||
| return new Decimal(tokens).mul(baseCostDecimal); | |||
| } | |||
|
|
|||
| // 阈值内的 token 按基础费率计算 | |||
| const baseCost = new Decimal(threshold).mul(baseCostDecimal); | |||
| // 超出阈值的 token 按溢价费率计算 | |||
| const premiumTokens = tokens - threshold; | |||
| const premiumCost = new Decimal(premiumTokens).mul(premiumCostDecimal); | |||
| return new Decimal(tokens).mul(premiumCostDecimal); | |||
| } | |||
There was a problem hiding this comment.
Dead code: unused functions should be removed
_calculateTieredCost (lines 44–61) and __calculateTieredCostWithSeparatePrices (lines 71–89) were refactored away and are never called anywhere in the codebase. The underscore prefix does not denote private functions in TypeScript convention. These should be deleted entirely to avoid confusion and potential noUnusedLocals type-check failures.
| // Remove both _calculateTieredCost and __calculateTieredCostWithSeparatePrices functions |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/utils/cost-calculation.ts
Line: 44-89
Comment:
## Dead code: unused functions should be removed
`_calculateTieredCost` (lines 44–61) and `__calculateTieredCostWithSeparatePrices` (lines 71–89) were refactored away and are never called anywhere in the codebase. The underscore prefix does not denote private functions in TypeScript convention. These should be deleted entirely to avoid confusion and potential `noUnusedLocals` type-check failures.
```suggestion
// Remove both _calculateTieredCost and __calculateTieredCostWithSeparatePrices functions
```
How can I resolve this? If you propose a fix, please make it concise.| if ( | ||
| longContextThresholdExceeded && | ||
| cacheCreation1hAboveThreshold != null && | ||
| cache1hTokens != null | ||
| ) { | ||
| cacheCreationBucket = cacheCreationBucket.add( | ||
| calculateTieredCost(cache1hTokens, cacheCreation1hCost, CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER) | ||
| multiplyCost(cache1hTokens, cacheCreation1hAboveThreshold) | ||
| ); |
There was a problem hiding this comment.
Missing hasRealCacheCreationBase guard for 1-hour cache long-context billing in calculateRequestCostBreakdown
The 5-minute TTL long-context cache branch (lines 255–263) correctly gates on hasRealCacheCreationBase before applying the above-threshold rate. The analogous 1-hour TTL branch here does not:
- 5m path (line 255–260):
longContextThresholdExceeded && hasRealCacheCreationBase && cacheCreationAboveThreshold != null - 1h path (line 279–282):
longContextThresholdExceeded && cacheCreation1hAboveThreshold != null← missing guard
If a model record has cache_creation_input_token_cost_above_1hr_above_272k_tokens set but no base cache_creation_input_token_cost, the 1-hour premium will incorrectly apply even when the model should not use tiered pricing.
| if ( | |
| longContextThresholdExceeded && | |
| cacheCreation1hAboveThreshold != null && | |
| cache1hTokens != null | |
| ) { | |
| cacheCreationBucket = cacheCreationBucket.add( | |
| calculateTieredCost(cache1hTokens, cacheCreation1hCost, CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER) | |
| multiplyCost(cache1hTokens, cacheCreation1hAboveThreshold) | |
| ); | |
| if ( | |
| longContextThresholdExceeded && | |
| hasRealCacheCreationBase && | |
| cacheCreation1hAboveThreshold != null && | |
| cache1hTokens != null | |
| ) { | |
| cacheCreationBucket = cacheCreationBucket.add( | |
| multiplyCost(cache1hTokens, cacheCreation1hAboveThreshold) | |
| ); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/utils/cost-calculation.ts
Line: 278-285
Comment:
## Missing `hasRealCacheCreationBase` guard for 1-hour cache long-context billing in `calculateRequestCostBreakdown`
The 5-minute TTL long-context cache branch (lines 255–263) correctly gates on `hasRealCacheCreationBase` before applying the above-threshold rate. The analogous 1-hour TTL branch here does not:
- **5m path** (line 255–260): `longContextThresholdExceeded && hasRealCacheCreationBase && cacheCreationAboveThreshold != null`
- **1h path** (line 279–282): `longContextThresholdExceeded && cacheCreation1hAboveThreshold != null` ← missing guard
If a model record has `cache_creation_input_token_cost_above_1hr_above_272k_tokens` set but no base `cache_creation_input_token_cost`, the 1-hour premium will incorrectly apply even when the model should not use tiered pricing.
```suggestion
if (
longContextThresholdExceeded &&
hasRealCacheCreationBase &&
cacheCreation1hAboveThreshold != null &&
cache1hTokens != null
) {
cacheCreationBucket = cacheCreationBucket.add(
multiplyCost(cache1hTokens, cacheCreation1hAboveThreshold)
);
```
How can I resolve this? If you propose a fix, please make it concise.| if ( | ||
| longContextThresholdExceeded && | ||
| cacheCreation1hAboveThreshold != null && | ||
| cache1hTokens != null | ||
| ) { | ||
| segments.push(multiplyCost(cache1hTokens, cacheCreation1hAboveThreshold)); |
There was a problem hiding this comment.
Missing hasRealCacheCreationBase guard in calculateRequestCost 1-hour cache branch
Same issue as in calculateRequestCostBreakdown: the 5-minute cache branch (lines 462–468) correctly checks hasRealCacheCreationBase, but the 1-hour branch here does not. This creates an asymmetry where models without a base cache creation price can still be charged above-threshold rates for 1-hour TTL caching.
| if ( | |
| longContextThresholdExceeded && | |
| cacheCreation1hAboveThreshold != null && | |
| cache1hTokens != null | |
| ) { | |
| segments.push(multiplyCost(cache1hTokens, cacheCreation1hAboveThreshold)); | |
| if ( | |
| longContextThresholdExceeded && | |
| hasRealCacheCreationBase && | |
| cacheCreation1hAboveThreshold != null && | |
| cache1hTokens != null | |
| ) { | |
| segments.push(multiplyCost(cache1hTokens, cacheCreation1hAboveThreshold)); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/utils/cost-calculation.ts
Line: 483-488
Comment:
## Missing `hasRealCacheCreationBase` guard in `calculateRequestCost` 1-hour cache branch
Same issue as in `calculateRequestCostBreakdown`: the 5-minute cache branch (lines 462–468) correctly checks `hasRealCacheCreationBase`, but the 1-hour branch here does not. This creates an asymmetry where models without a base cache creation price can still be charged above-threshold rates for 1-hour TTL caching.
```suggestion
if (
longContextThresholdExceeded &&
hasRealCacheCreationBase &&
cacheCreation1hAboveThreshold != null &&
cache1hTokens != null
) {
segments.push(multiplyCost(cache1hTokens, cacheCreation1hAboveThreshold));
```
How can I resolve this? If you propose a fix, please make it concise.| source: | ||
| | "local_manual" | ||
| | "cloud_exact" | ||
| | "cloud_model_fallback" | ||
| | "priority_fallback" | ||
| | "single_provider_top_level" | ||
| | "official_fallback"; |
There was a problem hiding this comment.
"official_fallback" source value is defined but never produced
PricingResolutionSpecialSetting.source includes "official_fallback" at line 216, but ResolvedPricingSource in src/lib/utils/pricing-resolution.ts does not include this value. The pricing resolution logic never returns "official_fallback" — it only returns "local_manual", "cloud_exact", "cloud_model_fallback", "priority_fallback", or "single_provider_top_level".
Since ensurePricingResolutionSpecialSetting always derives source from a ResolvedPricing, the "official_fallback" value can never appear in a stored PricingResolutionSpecialSetting.
Either remove "official_fallback" from the union (lines 210–216) and from i18n files, or add it to ResolvedPricingSource if it is reserved for future use and document accordingly.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/types/special-settings.ts
Line: 210-216
Comment:
## `"official_fallback"` source value is defined but never produced
`PricingResolutionSpecialSetting.source` includes `"official_fallback"` at line 216, but `ResolvedPricingSource` in `src/lib/utils/pricing-resolution.ts` does not include this value. The pricing resolution logic never returns `"official_fallback"` — it only returns `"local_manual"`, `"cloud_exact"`, `"cloud_model_fallback"`, `"priority_fallback"`, or `"single_provider_top_level"`.
Since `ensurePricingResolutionSpecialSetting` always derives `source` from a `ResolvedPricing`, the `"official_fallback"` value can never appear in a stored `PricingResolutionSpecialSetting`.
Either remove `"official_fallback"` from the union (lines 210–216) and from i18n files, or add it to `ResolvedPricingSource` if it is reserved for future use and document accordingly.
How can I resolve this? If you propose a fix, please make it concise.
src/lib/utils/pricing-resolution.ts
Outdated
| priceData: mergePriceData( | ||
| record.priceData, | ||
| null, | ||
| record.priceData.selected_pricing_provider as string | ||
| ), |
There was a problem hiding this comment.
mergePriceData may receive undefined as third argument
In resolveManualPricing, mergePriceData is called with record.priceData.selected_pricing_provider as string as the pricingProviderKey parameter (line 250). If selected_pricing_provider is undefined (common for legacy records), the type assertion silences TypeScript but the runtime value becomes undefined.
When mergePriceData receives undefined as pricingProviderKey and base.selected_pricing_provider is also not a string, the function will set selected_pricing_provider: undefined in the returned priceData, which is inconsistent with the resolvedPricingProviderKey computed above (line 245) with a "manual" fallback.
Consider passing the already-computed resolvedPricingProviderKey as the third argument instead:
| priceData: mergePriceData( | |
| record.priceData, | |
| null, | |
| record.priceData.selected_pricing_provider as string | |
| ), | |
| priceData: mergePriceData( | |
| record.priceData, | |
| null, | |
| resolvedPricingProviderKey | |
| ), |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/utils/pricing-resolution.ts
Line: 247-251
Comment:
## `mergePriceData` may receive `undefined` as third argument
In `resolveManualPricing`, `mergePriceData` is called with `record.priceData.selected_pricing_provider as string` as the `pricingProviderKey` parameter (line 250). If `selected_pricing_provider` is `undefined` (common for legacy records), the type assertion silences TypeScript but the runtime value becomes `undefined`.
When `mergePriceData` receives `undefined` as `pricingProviderKey` and `base.selected_pricing_provider` is also not a string, the function will set `selected_pricing_provider: undefined` in the returned `priceData`, which is inconsistent with the `resolvedPricingProviderKey` computed above (line 245) with a `"manual"` fallback.
Consider passing the already-computed `resolvedPricingProviderKey` as the third argument instead:
```suggestion
priceData: mergePriceData(
record.priceData,
null,
resolvedPricingProviderKey
),
```
How can I resolve this? If you propose a fix, please make it concise.| const { usageMetrics } = parseUsageFromResponseText(responseText, provider.providerType); | ||
|
|
||
| if (!usageMetrics) { | ||
| // 即使没有 usageMetrics,也需要更新状态码和 provider chain | ||
| await updateMessageRequestDetails(messageContext.id, { | ||
| statusCode: statusCode, | ||
| ...(errorMessage ? { errorMessage } : {}), | ||
| ttfbMs: session.ttfbMs ?? duration, | ||
| providerChain: session.getProviderChain(), | ||
| model: session.getCurrentModel() ?? undefined, | ||
| providerId: providerIdForPersistence, // 更新最终供应商ID(重试切换后) | ||
| context1mApplied: session.getContext1mApplied(), | ||
| swapCacheTtlApplied: provider.swapCacheTtlBilling ?? false, | ||
| }); | ||
| return null; | ||
| } |
There was a problem hiding this comment.
Request details never persisted when usage metrics cannot be parsed
When parseUsageFromResponseText returns null for usageMetrics (line 2900–2901), the function returns immediately without calling updateMessageRequestDetails. This means requests whose response body cannot be parsed for usage metrics — including error responses and malformed JSON — will never have their status code, error message, provider chain, model, or provider ID written to the database.
The resulting log entry will be incomplete and missing critical metadata needed for debugging. The request status, error reason, and model information should be recorded even when token usage cannot be extracted.
Move the updateMessageRequestDetails call (currently at line 2966) earlier in the function, before the early return, to ensure request metadata is always persisted:
| const { usageMetrics } = parseUsageFromResponseText(responseText, provider.providerType); | |
| if (!usageMetrics) { | |
| // 即使没有 usageMetrics,也需要更新状态码和 provider chain | |
| await updateMessageRequestDetails(messageContext.id, { | |
| statusCode: statusCode, | |
| ...(errorMessage ? { errorMessage } : {}), | |
| ttfbMs: session.ttfbMs ?? duration, | |
| providerChain: session.getProviderChain(), | |
| model: session.getCurrentModel() ?? undefined, | |
| providerId: providerIdForPersistence, // 更新最终供应商ID(重试切换后) | |
| context1mApplied: session.getContext1mApplied(), | |
| swapCacheTtlApplied: provider.swapCacheTtlBilling ?? false, | |
| }); | |
| return null; | |
| } | |
| const { usageMetrics } = parseUsageFromResponseText(responseText, provider.providerType); | |
| // Always record request metadata before early return | |
| await updateMessageRequestDetails(messageContext.id, { | |
| statusCode: statusCode, | |
| ...(errorMessage ? { errorMessage } : {}), | |
| model: session.getCurrentModel() ?? undefined, | |
| providerId: providerIdForPersistence, | |
| providerChain: session.getProviderChain(), | |
| }); | |
| if (!usageMetrics) { | |
| return null; | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/response-handler.ts
Line: 2899-2902
Comment:
## Request details never persisted when usage metrics cannot be parsed
When `parseUsageFromResponseText` returns null for `usageMetrics` (line 2900–2901), the function returns immediately without calling `updateMessageRequestDetails`. This means requests whose response body cannot be parsed for usage metrics — including error responses and malformed JSON — will never have their status code, error message, provider chain, model, or provider ID written to the database.
The resulting log entry will be incomplete and missing critical metadata needed for debugging. The request status, error reason, and model information should be recorded even when token usage cannot be extracted.
Move the `updateMessageRequestDetails` call (currently at line 2966) earlier in the function, before the early return, to ensure request metadata is always persisted:
```suggestion
const { usageMetrics } = parseUsageFromResponseText(responseText, provider.providerType);
// Always record request metadata before early return
await updateMessageRequestDetails(messageContext.id, {
statusCode: statusCode,
...(errorMessage ? { errorMessage } : {}),
model: session.getCurrentModel() ?? undefined,
providerId: providerIdForPersistence,
providerChain: session.getProviderChain(),
});
if (!usageMetrics) {
return null;
}
```
How can I resolve this? If you propose a fix, please make it concise.| interface ModelRecordCandidate { | ||
| modelName: string | null; | ||
| record: ModelPrice | null; | ||
| isPrimary: boolean; |
There was a problem hiding this comment.
**[Medium] [TYPE-INCONSISTENCY] official_fallback defined in i18n but not in ResolvedPricingSource type
Why this is a problem: All 5 i18n language files define pricingSource.official_fallback label, but this type only includes 5 values and never produces official_fallback. This creates dead translation strings that will never be displayed to users.
Suggested fix:
export type ResolvedPricingSource =
| "local_manual"
| "cloud_exact"
| "cloud_model_fallback"
| "priority_fallback"
| "single_provider_top_level"
| "official_fallback"; // Add this if planned for future use, or remove from i18n| return { ok: false, error: "价格提供商不能为空" }; | ||
| } | ||
|
|
||
| const latestCloudPrice = await findLatestPriceByModelAndSource(modelName, "litellm"); |
There was a problem hiding this comment.
**[Medium] [LOGIC-BUG] Hardcoded litellm source limits pin functionality
Why this is a problem: This query hardcodes source = \"litellm\". If a model's cloud price was ingested from a different source (e.g., openrouter, manual), the pin feature will fail with "未找到云端模型价格" even though the price exists in the database.
Suggested fix:
// Option 1: Try litellm first, then fall back to other sources
const latestCloudPrice =
(await findLatestPriceByModelAndSource(modelName, \"litellm\")) ??
(await findLatestPriceByModelAndSource(modelName, \"openrouter\"));
// Option 2: Query latest non-manual price
const latestCloudPrice = await findLatestPriceByModelExcludingSource(modelName, \"manual\");There was a problem hiding this comment.
Actionable comments posted: 15
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/app/v1/_lib/proxy/response-handler.ts (1)
2791-2843:⚠️ Potential issue | 🟠 Major这里再次独立解析价表,会让同一请求的账单、限流和日志落到不同价格版本。
updateRequestCostFromUsage()现在自己走一遍getSystemSettings()/findLatestPriceByModel()/resolvePricingForModelRecords(),而本文件后面的trackCostToRedis()、session usage 和 Langfuse 成本路径又会通过session.getResolvedPricingByBillingSource(provider)再解析一遍。长流期间如果价表同步落在这两个 await 之间,同一请求就可能把 DB 账单、限流成本和日志成本写成不同数值。建议把ResolvedPricing在请求级解析一次并复用到这些路径。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/v1/_lib/proxy/response-handler.ts` around lines 2791 - 2843, 当前实现中 updateRequestCostFromUsage() 会再次调用 getSystemSettings()/findLatestPriceByModel()/resolvePricingForModelRecords() 导致同一请求在不同位置(DB 账单、限流、日志)可能使用不同价表版本;请改为在请求级只解析一次 ResolvedPricing 并复用:在处理请求的入口(调用 updateRequestCostFromUsage 的上游)调用 getSystemSettings(), findLatestPriceByModel(), resolvePricingForModelRecords() 得到 ResolvedPricing(引用 resolvedPricing/priceData),并把它作为参数传入 updateRequestCostFromUsage(provider, usage, costMultiplier, context1mApplied, resolvedPricing) 或存入请求上下文;随后确保 trackCostToRedis(), session.getResolvedPricingByBillingSource(provider) 的使用路径改为直接读取传入或上下文中的 ResolvedPricing,移除 updateRequestCostFromUsage 内部重复的系统设置/价表查找逻辑(相关标识符:updateRequestCostFromUsage, getSystemSettings, findLatestPriceByModel, resolvePricingForModelRecords, trackCostToRedis, session.getResolvedPricingByBillingSource, ResolvedPricing)。
🧹 Nitpick comments (3)
src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx (1)
71-74: 把 pricing resolution 展示抽成共享片段。
getPricingResolutionSpecialSetting、source 文案解析和这两行 JSX 在SummaryTab.tsx与MetadataTab.tsx已经重复了一遍,后面新增 source 类型或调整显示格式时很容易只改一处。抽成一个小 helper/子组件会更稳。Also applies to: 361-378
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/`[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx around lines 71 - 74, 将重复的 pricing resolution 逻辑抽成共享的 helper 或子组件:把 getPricingResolutionSpecialSetting 调用、pricingResolution 的判空、基于 pricingResolution.source 的多语言文本解析(目前在 SummaryTab.tsx 与 MetadataTab.tsx 重复)抽到一个单一导出的函数或小组件(例如 usePricingSourceLabel 或 PricingSourceLabel 组件),然后在 SummaryTab.tsx 和 MetadataTab.tsx 中替换掉原有的 pricingResolution/pricingSourceLabel 两行逻辑并统一使用该共享符号,从而后续新增 source 类型或修改显示格式只需改一处。src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx (1)
341-342: 存在未使用的变量。
_isWarmupSkipped被计算但未使用。如果是预留变量,可以保留;否则建议移除以保持代码整洁。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/`[locale]/dashboard/logs/_components/virtualized-logs-table.tsx around lines 341 - 342, The variable _isWarmupSkipped is computed from log.blockedBy but never used; remove the unused declaration (the const _isWarmupSkipped = log.blockedBy === "warmup";) to clean up the component, or if it was intended to affect rendering or logic, wire it into the relevant conditional or prop handling where virtualized-logs-table uses pricingResolution/getPricingResolutionSpecialSetting so the value is actually consumed; update references accordingly and ensure no linter warnings remain.tests/unit/actions/model-prices.test.ts (1)
497-551: 测试覆盖可进一步完善。当前测试仅覆盖了成功路径。建议补充以下边界情况的测试:
- 空
modelName或pricingProviderKey应返回错误- 非管理员用户应被拒绝
- 未找到云端价格时的错误处理
- 指定的
pricingProviderKey在pricing映射中不存在时的错误处理💡 建议补充的测试用例
it("should reject empty modelName", async () => { const { pinModelPricingProviderAsManual } = await import("@/actions/model-prices"); const result = await pinModelPricingProviderAsManual({ modelName: "", pricingProviderKey: "openrouter", }); expect(result.ok).toBe(false); expect(result.error).toContain("模型名称"); }); it("should reject non-admin users", async () => { getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } }); const { pinModelPricingProviderAsManual } = await import("@/actions/model-prices"); const result = await pinModelPricingProviderAsManual({ modelName: "gpt-5.4", pricingProviderKey: "openrouter", }); expect(result.ok).toBe(false); expect(result.error).toContain("无权限"); }); it("should return error when cloud price not found", async () => { findLatestPriceByModelAndSourceMock.mockResolvedValue(null); const { pinModelPricingProviderAsManual } = await import("@/actions/model-prices"); const result = await pinModelPricingProviderAsManual({ modelName: "gpt-5.4", pricingProviderKey: "openrouter", }); expect(result.ok).toBe(false); expect(result.error).toContain("云端"); });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/unit/actions/model-prices.test.ts` around lines 497 - 551, Add unit tests for pinModelPricingProviderAsManual to cover failure/edge cases: validate empty inputs (call pinModelPricingProviderAsManual with modelName: "" and expect ok=false and an error message about model name), test authorization by mocking getSessionMock to return a non-admin user and expect rejection, test cloud-price-not-found by mocking findLatestPriceByModelAndSourceMock to resolve null and expect an error, and test missing pricing provider key by returning a price whose pricing map does not include the requested pricingProviderKey and expect an error; use the existing mocks (getSessionMock, findLatestPriceByModelAndSourceMock, upsertModelPriceMock) and import pinModelPricingProviderAsManual from "@/actions/model-prices" as in the existing test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@messages/en/dashboard.json`:
- Around line 376-377: 更新 "not200" 文案以覆盖所有非 200/非成功响应而不是仅指“errors/blocked”: 在
messages/en/dashboard.json 中找到键名 "not200" 并将其值从 "Non-200 (errors/blocked)"
修改为更广义的表述(例如 "Non-200 (non-success responses)" 或 "Non-200 (non-successful
responses)"),确保语义覆盖所有非 200 状态码。
In `@messages/ja/dashboard.json`:
- Around line 376-377: The Japanese copy for the "not200" key is too
narrow—update the value for the "not200" key in messages/ja/dashboard.json (the
entry with key "not200") to a broader phrase that covers all non-200 /
non-success responses (e.g., replace "非 200(エラー/ブロック)" with something like
"200以外(非成功ステータス)" or "200以外(失敗/非成功リクエスト)") so it matches the key semantics and
other language versions.
In `@messages/ja/settings/prices.json`:
- Around line 196-218: Add the missing JSON key "badges.multi" (e.g., "badges":
{ "multi": "複数プロバイダー" }) to the same translations object (near
"actions"/"toast"/"providerPricing") so the multi-vendor badge has a Japanese
label; ensure the key name is exactly "badges.multi" and the value is the
Japanese string (for example "複数プロバイダー") consistent with other badge entries.
In `@messages/ru/dashboard.json`:
- Around line 289-299: The Russian translation for the key context1mPricing is
inconsistent between its two occurrences (one reads "Вход 2x >200k, Выход 1.5x
>200k" and the other "Вход >200k 2x..."); make both entries identical by
choosing the correct phrasing and updating the other occurrence so both
context1mPricing values match exactly; locate the key context1mPricing and the
duplicate occurrence referenced in the diff (also check the block around the
pricingSource object) and apply the same string to both places to ensure UI
consistency.
In `@messages/ru/settings/prices.json`:
- Around line 196-218: Add the missing localization key badges.multi to this
locale: under the same top-level object that contains providerPricing, add
"badges": { "multi": "Несколько провайдеров" } (or appropriate Russian phrasing)
so the multi-badge used for multi-provider models is present and prevents
missing-string errors when rendering the multi badge.
In `@messages/zh-CN/dashboard.json`:
- Around line 290-298: The JSON keys pricingProvider and the pricingSource
values (local_manual, cloud_exact, cloud_model_fallback, priority_fallback,
single_provider_top_level, official_fallback) use the term "提供商" while the rest
of the file uses "供应商"; update these strings to use "供应商" to ensure consistent
terminology across the page (replace "提供商" with "供应商" in the value for
pricingProvider and in each affected pricingSource child value).
In `@messages/zh-TW/settings/prices.json`:
- Around line 196-218: The zh-TW locale JSON is missing the badges.multi key
used by the price list; add a badges object with the multi key (e.g., "badges":
{ "multi": "多供應商" }) alongside the existing providerPricing block so the UI can
find the translation for badges.multi when rendering the multi badge.
In `@src/app/`[locale]/dashboard/logs/_components/virtualized-logs-table.tsx:
- Line 78: getPricingSourceLabel currently calls t with the incomplete key
"logs.billingDetails.pricingSource." which will cause a runtime lookup failure;
update getPricingSourceLabel so it interpolates the source variable into the
translation key (e.g., produce "logs.billingDetails.pricingSource.<source>")
when calling the t function, ensuring the correct translation key is requested
for each source.
In `@src/app/v1/_lib/proxy/forwarder.ts`:
- Around line 2894-2904: The added anthropic 1M audit setting is only added to
session via session.addSpecialSetting({... type:
"anthropic_context_1m_header_override", ...}) but not persisted, so it can be
lost; modify the same code path to call persistSpecialSettings(session)
immediately after adding the special setting (or ensure doForward() calls
persistSpecialSettings(session) unconditionally before returning) and keep
buildUnifiedSpecialSettings() unchanged; locate the session.addSpecialSetting
call in forwarder.ts and add the persistSpecialSettings(session) invocation (or
a single unified persist in doForward) so the 1M audit entry is guaranteed to be
written to storage.
In `@src/app/v1/_lib/proxy/response-handler.ts`:
- Around line 1216-1232: The branch that exceeds MAX_STATS_HEAD_BYTES never sets
inTailMode so tail content is never considered by joinChunks(); modify the logic
in the block using inTailMode, headBufferedBytes, MAX_STATS_HEAD_BYTES,
pushChunk, pushToTail and headChunks so that when you transition from head to
tail you set inTailMode = true before calling pushToTail (and avoid calling
pushToTail twice causing duplication), push only the correct headPart and
tailPart once (use pushChunk for each), and ensure joinChunks() combines
headChunks and the tail buffer so final output includes both head and tail for
correct usageMetadata and error detection.
In `@src/lib/utils/pricing-resolution.ts`:
- Around line 5-10: The code currently classifies hits from the official
provider as "priority_fallback" but the UI/types expect a distinct
"official_fallback"; update the ResolvedPricingSource union to include
"official_fallback" and change resolveFromPricingMap (and any branch/return that
checks for provider === "official" or maps official provider hits) to return
"official_fallback" instead of "priority_fallback" (ensure any places
referencing "priority_fallback" for other flows remain unchanged).
In `@src/lib/utils/special-settings.ts`:
- Around line 25-27: The PR removed the fallback derivation for context1mApplied
which causes historical or non-explicit requests to lose 1M context audit data;
restore the existing fallback behavior in the special settings logic so that
when specialSettings.anthropic_context_1m_header_override is absent you still
derive its value from context1mApplied (the property used by
src/repository/usage-logs.ts when passing row.context1mApplied), ensuring
backward compatibility until all write paths persist explicit specialSettings;
update the code paths around specialSettings and the logic referencing
context1mApplied/anthropic_context_1m_header_override to read the fallback if
the explicit override is missing.
- Around line 110-117: The deduplication key for "pricing_resolution" omits the
boolean hit flag, causing entries with hit: true and hit: false to collide;
update the dedupe key generation in the switch case for "pricing_resolution"
inside buildUnifiedSpecialSettings (or the function handling the switch in
special-settings.ts) to include setting.hit in the JSON.stringify array
alongside setting.type, setting.modelName, setting.resolvedModelName,
setting.resolvedPricingProviderKey, and setting.source so each distinct hit
value produces a distinct key.
In `@tests/integration/billing-model-source.test.ts`:
- Around line 377-436: The new nested-pricing test only covers non-stream and
omits asserting the rate-limit billing path; add a parallel stream-case and
include assertions for all three billing chains. Duplicate the setup used in
this test (mocks for findLatestPriceByModel, updateMessageRequestCost,
SessionManager.updateSessionUsage, and RateLimitService.trackUserDailyCost) but
send a streaming response via the streaming helper (e.g., createStreamResponse
or the project’s stream-response factory) and call ProxyResponseHandler.dispatch
with that session, then await drainAsyncTasks; assert the DB cost captured by
updateMessageRequestCost, the session cost captured by
SessionManager.updateSessionUsage, and that RateLimitService.trackUserDailyCost
was called with the expected cost (use the same expected value "50" as in the
non-stream case). Ensure you reference and reuse the same symbols:
ProxyResponseHandler.dispatch, createSession,
createNonStreamResponse/createStreamResponse, updateMessageRequestCost,
SessionManager.updateSessionUsage, and RateLimitService.trackUserDailyCost.
In `@tests/unit/lib/cost-calculation-long-context.test.ts`:
- Around line 5-23: 当前测试只覆盖了 calculateRequestCost 在 claude-sonnet 且 input_tokens
>= 200k 时触发长上下文分支;请补两个用例:一是对同样的 model_family "claude-sonnet" 增加一个反例(input_tokens
< 200000 但 input_tokens+output_tokens > 200000),断言应使用短上下文定价(即不触发
input_cost_per_token_above_200k_tokens /
output_cost_per_token_above_200k_tokens);二是新增一个 model_family "gpt" 的场景,使用靠近 GPT
长上下文阈值 272k 的 token 组合(一个命中 272k 分支的正例和/或一个不命中的反例),分别断言使用/不使用对应的 272k
分支费率以确保阈值逻辑被覆盖;都在 tests/unit/lib/cost-calculation-long-context.test.ts 中与现有
calculateRequestCost 调用保持相同参数结构并显式断言期望的费率分支。
---
Outside diff comments:
In `@src/app/v1/_lib/proxy/response-handler.ts`:
- Around line 2791-2843: 当前实现中 updateRequestCostFromUsage() 会再次调用
getSystemSettings()/findLatestPriceByModel()/resolvePricingForModelRecords()
导致同一请求在不同位置(DB 账单、限流、日志)可能使用不同价表版本;请改为在请求级只解析一次 ResolvedPricing 并复用:在处理请求的入口(调用
updateRequestCostFromUsage 的上游)调用 getSystemSettings(), findLatestPriceByModel(),
resolvePricingForModelRecords() 得到 ResolvedPricing(引用
resolvedPricing/priceData),并把它作为参数传入 updateRequestCostFromUsage(provider, usage,
costMultiplier, context1mApplied, resolvedPricing) 或存入请求上下文;随后确保
trackCostToRedis(), session.getResolvedPricingByBillingSource(provider)
的使用路径改为直接读取传入或上下文中的 ResolvedPricing,移除 updateRequestCostFromUsage
内部重复的系统设置/价表查找逻辑(相关标识符:updateRequestCostFromUsage, getSystemSettings,
findLatestPriceByModel, resolvePricingForModelRecords, trackCostToRedis,
session.getResolvedPricingByBillingSource, ResolvedPricing)。
---
Nitpick comments:
In
`@src/app/`[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx:
- Around line 71-74: 将重复的 pricing resolution 逻辑抽成共享的 helper 或子组件:把
getPricingResolutionSpecialSetting 调用、pricingResolution 的判空、基于
pricingResolution.source 的多语言文本解析(目前在 SummaryTab.tsx 与 MetadataTab.tsx
重复)抽到一个单一导出的函数或小组件(例如 usePricingSourceLabel 或 PricingSourceLabel 组件),然后在
SummaryTab.tsx 和 MetadataTab.tsx 中替换掉原有的 pricingResolution/pricingSourceLabel
两行逻辑并统一使用该共享符号,从而后续新增 source 类型或修改显示格式只需改一处。
In `@src/app/`[locale]/dashboard/logs/_components/virtualized-logs-table.tsx:
- Around line 341-342: The variable _isWarmupSkipped is computed from
log.blockedBy but never used; remove the unused declaration (the const
_isWarmupSkipped = log.blockedBy === "warmup";) to clean up the component, or if
it was intended to affect rendering or logic, wire it into the relevant
conditional or prop handling where virtualized-logs-table uses
pricingResolution/getPricingResolutionSpecialSetting so the value is actually
consumed; update references accordingly and ensure no linter warnings remain.
In `@tests/unit/actions/model-prices.test.ts`:
- Around line 497-551: Add unit tests for pinModelPricingProviderAsManual to
cover failure/edge cases: validate empty inputs (call
pinModelPricingProviderAsManual with modelName: "" and expect ok=false and an
error message about model name), test authorization by mocking getSessionMock to
return a non-admin user and expect rejection, test cloud-price-not-found by
mocking findLatestPriceByModelAndSourceMock to resolve null and expect an error,
and test missing pricing provider key by returning a price whose pricing map
does not include the requested pricingProviderKey and expect an error; use the
existing mocks (getSessionMock, findLatestPriceByModelAndSourceMock,
upsertModelPriceMock) and import pinModelPricingProviderAsManual from
"@/actions/model-prices" as in the existing test.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: e6441f36-7056-45a8-b27a-eaf7a32b278c
📒 Files selected for processing (34)
messages/en/dashboard.jsonmessages/en/settings/prices.jsonmessages/ja/dashboard.jsonmessages/ja/settings/prices.jsonmessages/ru/dashboard.jsonmessages/ru/settings/prices.jsonmessages/zh-CN/dashboard.jsonmessages/zh-CN/settings/prices.jsonmessages/zh-TW/dashboard.jsonmessages/zh-TW/settings/prices.jsonsrc/actions/model-prices.tssrc/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsxsrc/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsxsrc/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsxsrc/app/[locale]/dashboard/logs/_components/usage-logs-table.tsxsrc/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsxsrc/app/[locale]/settings/prices/_components/price-list.tsxsrc/app/[locale]/settings/prices/_components/provider-pricing-dialog.tsxsrc/app/v1/_lib/proxy/forwarder.tssrc/app/v1/_lib/proxy/response-handler.tssrc/app/v1/_lib/proxy/session.tssrc/lib/utils/cost-calculation.tssrc/lib/utils/price-data.tssrc/lib/utils/pricing-resolution.tssrc/lib/utils/special-settings.tssrc/repository/model-price.tssrc/types/model-price.tssrc/types/special-settings.tstests/integration/billing-model-source.test.tstests/unit/actions/model-prices.test.tstests/unit/lib/cost-calculation-long-context.test.tstests/unit/lib/utils/pricing-resolution.test.tstests/unit/lib/utils/special-settings.test.tstests/unit/settings/prices/price-list-multi-provider-ui.test.tsx
| "500": "500 (Server Error)", | ||
| "not200": "Non-200 (errors/blocked)" |
There was a problem hiding this comment.
not200 文案把范围写窄了。
not200 这个键名表达的是“所有非 200 状态”。现在的 Non-200 (errors/blocked) 会让用户理解成只包含错误和拦截,不包含其他非 200 结果。建议改成覆盖所有非 200 / 非成功请求的表述。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@messages/en/dashboard.json` around lines 376 - 377, 更新 "not200" 文案以覆盖所有非
200/非成功响应而不是仅指“errors/blocked”: 在 messages/en/dashboard.json 中找到键名 "not200"
并将其值从 "Non-200 (errors/blocked)" 修改为更广义的表述(例如 "Non-200 (non-success responses)"
或 "Non-200 (non-successful responses)"),确保语义覆盖所有非 200 状态码。
| "500": "500 (サーバーエラー)", | ||
| "not200": "非 200(エラー/ブロック)" |
There was a problem hiding this comment.
not200 的日文文案也把范围写窄了。
非 200(エラー/ブロック) 会让用户理解成只包含错误和拦截;但这个键名表达的是所有非 200 状态。建议改成覆盖全部非 200 / 非成功请求的表述,避免和其他语言版本的语义漂移。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@messages/ja/dashboard.json` around lines 376 - 377, The Japanese copy for the
"not200" key is too narrow—update the value for the "not200" key in
messages/ja/dashboard.json (the entry with key "not200") to a broader phrase
that covers all non-200 / non-success responses (e.g., replace "非 200(エラー/ブロック)"
with something like "200以外(非成功ステータス)" or "200以外(失敗/非成功リクエスト)") so it matches the
key semantics and other language versions.
messages/ja/settings/prices.json
Outdated
| "actions": { | ||
| "edit": "編集", | ||
| "more": "その他の操作", | ||
| "delete": "削除" | ||
| "delete": "削除", | ||
| "comparePricing": "価格を比較" | ||
| }, | ||
| "toast": { | ||
| "createSuccess": "モデルを追加しました", | ||
| "updateSuccess": "モデルを更新しました", | ||
| "deleteSuccess": "モデルを削除しました", | ||
| "saveFailed": "保存に失敗しました", | ||
| "deleteFailed": "削除に失敗しました" | ||
| }, | ||
| "providerPricing": { | ||
| "title": "プロバイダー価格 · {model}", | ||
| "description": "プロバイダー別のクラウド価格を比較し、そのうち一つをローカルモデル価格として固定します。", | ||
| "input": "入力", | ||
| "output": "出力", | ||
| "cacheRead": "キャッシュ読取", | ||
| "pinAction": "ローカル価格として固定", | ||
| "pinSuccess": "{provider} の価格をローカルモデル価格として固定しました", | ||
| "pinFailed": "プロバイダー価格の固定に失敗しました", | ||
| "pinned": "固定済み" |
There was a problem hiding this comment.
日语资源同样漏了 badges.multi。
这次已经补了多供应商价格相关文案,但多供应商徽标对应的 key 只在英文里新增了,ja 这里还没有;如果列表展示 multi badge,这个语言会出现缺失翻译。请同步补上。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@messages/ja/settings/prices.json` around lines 196 - 218, Add the missing
JSON key "badges.multi" (e.g., "badges": { "multi": "複数プロバイダー" }) to the same
translations object (near "actions"/"toast"/"providerPricing") so the
multi-vendor badge has a Japanese label; ensure the key name is exactly
"badges.multi" and the value is the Japanese string (for example "複数プロバイダー")
consistent with other badge entries.
| "context1mPricing": "Вход 2x >200k, Выход 1.5x >200k", | ||
| "pricingProvider": "Провайдер тарифа", | ||
| "pricingSourceLabel": "Источник тарифа", | ||
| "pricingSource": { | ||
| "local_manual": "Локальная ручная цена", | ||
| "cloud_exact": "Точная облачная цена провайдера", | ||
| "cloud_model_fallback": "Цена провайдера из резервной модели", | ||
| "priority_fallback": "Приоритетная резервная цена", | ||
| "single_provider_top_level": "Цена верхнего уровня для одного провайдера", | ||
| "official_fallback": "Официальная резервная цена" | ||
| } |
There was a problem hiding this comment.
统一两处 context1mPricing 的俄文文案。
Line 289 和 Line 392 现在是两种顺序(Вход 2x >200k... vs Вход >200k 2x...)。这两个 key 会在不同日志视图里展示同一含义,建议统一成同一说法,避免前后 UI 不一致。
Also applies to: 392-402
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@messages/ru/dashboard.json` around lines 289 - 299, The Russian translation
for the key context1mPricing is inconsistent between its two occurrences (one
reads "Вход 2x >200k, Выход 1.5x >200k" and the other "Вход >200k 2x..."); make
both entries identical by choosing the correct phrasing and updating the other
occurrence so both context1mPricing values match exactly; locate the key
context1mPricing and the duplicate occurrence referenced in the diff (also check
the block around the pricingSource object) and apply the same string to both
places to ensure UI consistency.
messages/ru/settings/prices.json
Outdated
| "actions": { | ||
| "edit": "Редактировать", | ||
| "more": "Больше действий", | ||
| "delete": "Удалить" | ||
| "delete": "Удалить", | ||
| "comparePricing": "Сравнить цены" | ||
| }, | ||
| "toast": { | ||
| "createSuccess": "Модель добавлена", | ||
| "updateSuccess": "Модель обновлена", | ||
| "deleteSuccess": "Модель удалена", | ||
| "saveFailed": "Ошибка сохранения", | ||
| "deleteFailed": "Ошибка удаления" | ||
| }, | ||
| "providerPricing": { | ||
| "title": "Цены провайдеров · {model}", | ||
| "description": "Сравните облачные цены по провайдерам и закрепите одну из них как локальную цену модели.", | ||
| "input": "Вход", | ||
| "output": "Выход", | ||
| "cacheRead": "Чтение кэша", | ||
| "pinAction": "Закрепить как локальную цену", | ||
| "pinSuccess": "Цена {provider} закреплена как локальная цена модели", | ||
| "pinFailed": "Не удалось закрепить цену провайдера", | ||
| "pinned": "Закреплено" |
There was a problem hiding this comment.
俄语资源也漏了 badges.multi。
本次功能除了“比较价格/供应商价格”外,还引入了 multi badge;英文已经有对应 key,但这个 locale 还没同步,运行到多供应商模型时会缺少文案。请把 badges.multi 补齐。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@messages/ru/settings/prices.json` around lines 196 - 218, Add the missing
localization key badges.multi to this locale: under the same top-level object
that contains providerPricing, add "badges": { "multi": "Несколько провайдеров"
} (or appropriate Russian phrasing) so the multi-badge used for multi-provider
models is present and prevents missing-string errors when rendering the multi
badge.
| if ( | ||
| session.getContext1mApplied?.() && | ||
| (provider.providerType === "claude" || provider.providerType === "claude-auth") | ||
| ) { | ||
| session.addSpecialSetting({ | ||
| type: "anthropic_context_1m_header_override", | ||
| scope: "request_header", | ||
| hit: true, | ||
| header: "anthropic-beta", | ||
| flag: CONTEXT_1M_BETA_HEADER, | ||
| }); |
There was a problem hiding this comment.
这里新增的 1M 审计没有保证落库。
现在 anthropic_context_1m_header_override 只是在这里写进 session,但这条路径本身没有持久化;而 buildUnifiedSpecialSettings() 又已经不再从 context1mApplied 自动补派生。这样一来,只要后续没命中其他 persistSpecialSettings(session) 分支(例如关闭 enableClaudeMetadataUserIdInjection),日志/详情页就会直接丢掉这条 1M 审计记录。建议把持久化放到与这次写入同一条路径,或者在 doForward() 里统一持久化一次。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/v1/_lib/proxy/forwarder.ts` around lines 2894 - 2904, The added
anthropic 1M audit setting is only added to session via
session.addSpecialSetting({... type: "anthropic_context_1m_header_override",
...}) but not persisted, so it can be lost; modify the same code path to call
persistSpecialSettings(session) immediately after adding the special setting (or
ensure doForward() calls persistSpecialSettings(session) unconditionally before
returning) and keep buildUnifiedSpecialSettings() unchanged; locate the
session.addSpecialSetting call in forwarder.ts and add the
persistSpecialSettings(session) invocation (or a single unified persist in
doForward) so the 1M audit entry is guaranteed to be written to storage.
| it("nested pricing: gpt-5.4 alias model should bill from pricing.openai when provider is chatgpt", async () => { | ||
| vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); | ||
| vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined); | ||
| vi.mocked(updateMessageRequestDuration).mockResolvedValue(undefined); | ||
| vi.mocked(SessionManager.storeSessionResponse).mockResolvedValue(undefined); | ||
| vi.mocked(RateLimitService.trackUserDailyCost).mockResolvedValue(undefined); | ||
| vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); | ||
|
|
||
| vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { | ||
| if (modelName === "gpt-5.4") { | ||
| return makePriceRecord(modelName, { | ||
| mode: "responses", | ||
| model_family: "gpt", | ||
| litellm_provider: "chatgpt", | ||
| pricing: { | ||
| openai: { | ||
| input_cost_per_token: 2.5, | ||
| output_cost_per_token: 15, | ||
| }, | ||
| }, | ||
| }); | ||
| } | ||
| return null; | ||
| }); | ||
|
|
||
| const dbCosts: string[] = []; | ||
| vi.mocked(updateMessageRequestCost).mockImplementation( | ||
| async (_id: number, costUsd: unknown) => { | ||
| dbCosts.push(String(costUsd)); | ||
| } | ||
| ); | ||
|
|
||
| const sessionCosts: string[] = []; | ||
| vi.mocked(SessionManager.updateSessionUsage).mockImplementation( | ||
| async (_sessionId: string, payload: Record<string, unknown>) => { | ||
| if (typeof payload.costUsd === "string") { | ||
| sessionCosts.push(payload.costUsd); | ||
| } | ||
| } | ||
| ); | ||
|
|
||
| const session = createSession({ | ||
| originalModel: "gpt-5.4", | ||
| redirectedModel: "gpt-5.4", | ||
| sessionId: "sess-gpt54-chatgpt", | ||
| messageId: 3100, | ||
| providerOverrides: { | ||
| name: "ChatGPT", | ||
| url: "https://chatgpt.com/backend-api/codex", | ||
| providerType: "codex", | ||
| }, | ||
| }); | ||
|
|
||
| const response = createNonStreamResponse({ input_tokens: 2, output_tokens: 3 }); | ||
| await ProxyResponseHandler.dispatch(session, response); | ||
| await drainAsyncTasks(); | ||
|
|
||
| expect(dbCosts[0]).toBe("50"); | ||
| expect(sessionCosts[0]).toBe("50"); | ||
| }); |
There was a problem hiding this comment.
把这个 nested pricing 场景补成和前面一致的覆盖矩阵。
前面的 billing source 用例都分别覆盖了 stream/non-stream,并且校验了 DB、Session、RateLimitService.trackCost 三条计费链路;这个新场景目前只测了非流式,且没断言 trackCost。如果 provider-aware resolution 只在某一条链路接上,这里会漏报。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/integration/billing-model-source.test.ts` around lines 377 - 436, The
new nested-pricing test only covers non-stream and omits asserting the
rate-limit billing path; add a parallel stream-case and include assertions for
all three billing chains. Duplicate the setup used in this test (mocks for
findLatestPriceByModel, updateMessageRequestCost,
SessionManager.updateSessionUsage, and RateLimitService.trackUserDailyCost) but
send a streaming response via the streaming helper (e.g., createStreamResponse
or the project’s stream-response factory) and call ProxyResponseHandler.dispatch
with that session, then await drainAsyncTasks; assert the DB cost captured by
updateMessageRequestCost, the session cost captured by
SessionManager.updateSessionUsage, and that RateLimitService.trackUserDailyCost
was called with the expected cost (use the same expected value "50" as in the
non-stream case). Ensure you reference and reuse the same symbols:
ProxyResponseHandler.dispatch, createSession,
createNonStreamResponse/createStreamResponse, updateMessageRequestCost,
SessionManager.updateSessionUsage, and RateLimitService.trackUserDailyCost.
| test("uses long-context output pricing when total input context exceeds threshold", () => { | ||
| const cost = calculateRequestCost( | ||
| { | ||
| input_tokens: 250000, | ||
| output_tokens: 100000, | ||
| }, | ||
| { | ||
| mode: "chat", | ||
| model_family: "claude-sonnet", | ||
| input_cost_per_token: 0.000003, | ||
| input_cost_per_token_above_200k_tokens: 0.000006, | ||
| output_cost_per_token: 0.000015, | ||
| output_cost_per_token_above_200k_tokens: 0.0000225, | ||
| }, | ||
| 1, | ||
| false | ||
| ); | ||
|
|
||
| expect(Number(cost.toString())).toBe(3.75); |
There was a problem hiding this comment.
这个用例还没把这次阈值回归真正钉住。
现在这组数据只覆盖了“200k 分支正向命中”。如果实现误把总 tokens 当阈值输入,或者 GPT 的 272k 分支回归,这个测试也会继续通过。建议至少再补一个 input_tokens < threshold 但总量更高的反例,以及一个 model_family: "gpt" 的 272k 场景。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/unit/lib/cost-calculation-long-context.test.ts` around lines 5 - 23,
当前测试只覆盖了 calculateRequestCost 在 claude-sonnet 且 input_tokens >= 200k
时触发长上下文分支;请补两个用例:一是对同样的 model_family "claude-sonnet" 增加一个反例(input_tokens <
200000 但 input_tokens+output_tokens > 200000),断言应使用短上下文定价(即不触发
input_cost_per_token_above_200k_tokens /
output_cost_per_token_above_200k_tokens);二是新增一个 model_family "gpt" 的场景,使用靠近 GPT
长上下文阈值 272k 的 token 组合(一个命中 272k 分支的正例和/或一个不命中的反例),分别断言使用/不使用对应的 272k
分支费率以确保阈值逻辑被覆盖;都在 tests/unit/lib/cost-calculation-long-context.test.ts 中与现有
calculateRequestCost 调用保持相同参数结构并显式断言期望的费率分支。
| // 3. 解析 usage metrics | ||
| const { usageMetrics } = parseUsageFromResponseText(responseText, provider.providerType); | ||
|
|
||
| if (!usageMetrics) { |
There was a problem hiding this comment.
[CRITICAL] [LOGIC-BUG] Missing-usage path in finalizeRequestStats no longer persists status/providerChain for Gemini passthrough
Evidence (src/app/v1/_lib/proxy/response-handler.ts:2899-2902):
const { usageMetrics } = parseUsageFromResponseText(responseText, provider.providerType);
if (!usageMetrics) {
return null;
}Why this is a problem: For Gemini passthrough (stream + non-stream), finalizeRequestStats(...) is the stats task that persists the final request snapshot. When usage is missing (common on upstream error payloads), this now returns before writing statusCode / errorMessage / providerChain (and also no longer ends the ProxyStatusTracker / writes duration in this function).
Suggested fix: Always persist the final request snapshot (and end tracking) even if usage is missing; only skip billing calculations.
const { usageMetrics } = parseUsageFromResponseText(responseText, provider.providerType);
ProxyStatusTracker.getInstance().endRequest(messageContext.user.id, messageContext.id);
await updateMessageRequestDuration(messageContext.id, duration);
if (!usageMetrics) {
await updateMessageRequestDetails(messageContext.id, {
statusCode,
...(errorMessage ? { errorMessage } : {}),
ttfbMs: session.ttfbMs ?? duration,
providerChain: session.getProviderChain(),
model: session.getCurrentModel() ?? undefined,
providerId: providerIdForPersistence,
context1mApplied: session.getContext1mApplied(),
swapCacheTtlApplied: provider.swapCacheTtlBilling ?? false,
specialSettings: session.getSpecialSettings() ?? undefined,
});
return null;
}
// existing normalizedUsage/cost path continues...| serverTimeZone: _serverTimeZone, | ||
| }: VirtualizedLogsTableProps) { | ||
| const t = useTranslations("dashboard"); | ||
| const getPricingSourceLabel = (source: string) => t(`logs.billingDetails.pricingSource.`); |
There was a problem hiding this comment.
[HIGH] [LOGIC-BUG] getPricingSourceLabel uses an incomplete i18n key (missing ${source})
Evidence (src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx:78):
const getPricingSourceLabel = (source: string) => t(`logs.billingDetails.pricingSource.`);Why this is a problem: source is never interpolated, so the UI always looks up logs.billingDetails.pricingSource. instead of logs.billingDetails.pricingSource.{source}. That renders the wrong label (or can throw a missing-message error depending on next-intl config) whenever pricing_resolution is present.
Suggested fix:
const getPricingSourceLabel = (source: string) =>
t(`logs.billingDetails.pricingSource.${source}`);| ) : null} | ||
| {price.priceData.pricing && | ||
| Object.keys(price.priceData.pricing).length > 1 ? ( | ||
| <Badge variant="outline">{t("badges.multi")}</Badge> |
There was a problem hiding this comment.
[HIGH] [STANDARD-VIOLATION] badges.multi is referenced but missing in 3 locale message files
Evidence (src/app/[locale]/settings/prices/_components/price-list.tsx:489):
<Badge variant="outline">{t("badges.multi")}</Badge>Guideline (CLAUDE.md):
3. **i18n Required** - All user-facing strings must use i18n (5 languages supported). Never hardcode display textWhy this is a problem: badges.multi exists in messages/en/settings/prices.json and messages/zh-CN/settings/prices.json, but is missing from:
messages/ja/settings/prices.jsonmessages/ru/settings/prices.jsonmessages/zh-TW/settings/prices.json
This can trigger a next-intl missing-message error when the Multi badge renders in those locales.
Suggested fix: Add badges.multi to the missing locale files.
// messages/ja/settings/prices.json
"badges": {
"local": "ローカル",
"multi": "複数"
}
// messages/ru/settings/prices.json
"badges": {
"local": "Локальная",
"multi": "Мульти"
}
// messages/zh-TW/settings/prices.json
"badges": {
"local": "本機",
"multi": "多供應商"
}| pricingProviderKey, | ||
| }); | ||
| if (!result.ok) { | ||
| toast.error(result.error); |
There was a problem hiding this comment.
[MEDIUM] [STANDARD-VIOLATION] Pin-provider errors are not localized (toast.error(result.error))
Evidence (src/app/[locale]/settings/prices/_components/provider-pricing-dialog.tsx:68):
toast.error(result.error);Guideline (CLAUDE.md):
3. **i18n Required** - All user-facing strings must use i18n (5 languages supported). Never hardcode display textWhy this is a problem: pinModelPricingProviderAsManual returns hardcoded Chinese error strings (e.g. src/actions/model-prices.ts:636-661), and the dialog shows them verbatim. This breaks localization for ja/en/ru/zh-TW users.
Suggested fix: Return errorCode/errorParams from the action and translate on the client via useTranslations("errors") + getErrorMessage.
// src/app/[locale]/settings/prices/_components/provider-pricing-dialog.tsx
import { getErrorMessage } from "@/lib/utils/error-messages";
const tErrors = useTranslations("errors");
if (!result.ok) {
const message = result.errorCode
? getErrorMessage(tErrors, result.errorCode, result.errorParams)
: result.error;
toast.error(message);
return;
}// src/actions/model-prices.ts
import { ERROR_CODES } from "@/lib/utils/error-messages";
if (!session || session.user.role !== "admin") {
return { ok: false, error: ERROR_CODES.PERMISSION_DENIED, errorCode: ERROR_CODES.PERMISSION_DENIED };
}
if (!modelName) {
return {
ok: false,
error: ERROR_CODES.REQUIRED_FIELD,
errorCode: ERROR_CODES.REQUIRED_FIELD,
errorParams: { field: "model" },
};
}There was a problem hiding this comment.
Code Review Summary
This PR introduces provider-aware pricing resolution across billing + UI, but it currently has a critical regression in the Gemini passthrough stats finalization path and multiple i18n issues that can break non-en locales. These should be addressed before merge to avoid missing request records and runtime translation failures.
PR Size: XL
- Lines changed: 2618
- Files changed: 34
- Split suggestion: (1) core pricing-resolution + types + cost-calculation, (2) proxy pipeline integration (session/response-handler/forwarder), (3) dashboard/settings UI + i18n/messages
Issues Found
| Category | Critical | High | Medium | Low |
|---|---|---|---|---|
| Logic/Bugs | 1 | 2 | 0 | 0 |
| Security | 0 | 0 | 0 | 0 |
| Error Handling | 0 | 0 | 0 | 0 |
| Types | 0 | 0 | 0 | 0 |
| Comments/Docs | 0 | 0 | 1 | 0 |
| Tests | 0 | 0 | 0 | 0 |
| Simplification | 0 | 0 | 0 | 0 |
Critical Issues (Must Fix)
src/app/v1/_lib/proxy/response-handler.ts:2900-finalizeRequestStatsreturns on missingusageMetricswithout persistingstatusCode/providerChainfor Gemini passthrough (Confidence: 98)
High Priority Issues (Should Fix)
src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx:78- pricing source label uses incomplete i18n key (missing${source}) (Confidence: 92)src/app/[locale]/settings/prices/_components/price-list.tsx:489-badges.multimissing inja/ru/zh-TWmessages can trigger next-intl missing-message errors (Confidence: 90)src/app/[locale]/settings/prices/_components/provider-pricing-dialog.tsx:68- pin-provider errors are shown via rawresult.error(action returns hardcoded Chinese strings); useerrorCode+errorsi18n (Confidence: 85)
Review Coverage
- Logic and correctness
- Security (OWASP Top 10)
- Error handling
- Type safety
- Documentation accuracy
- Test coverage
- Code clarity
Automated review by Codex AI
There was a problem hiding this comment.
Applied PR size label size/XL, left 4 inline review comments, and submitted a PR review summary.
Inline comments posted:
- [CRITICAL]
src/app/v1/_lib/proxy/response-handler.ts:2900— Gemini passthrough stats can skip persistence whenusageMetricsis missing. - [HIGH]
src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx:78— pricing source label i18n key missing${source}. - [HIGH]
src/app/[locale]/settings/prices/_components/price-list.tsx:489—badges.multiused but missing inja/ru/zh-TWmessages. - [MEDIUM]
src/app/[locale]/settings/prices/_components/provider-pricing-dialog.tsx:68— pin-provider errors shown via rawresult.error(not localized); recommenderrorCode+errorsi18n.
| serverTimeZone: _serverTimeZone, | ||
| }: VirtualizedLogsTableProps) { | ||
| const t = useTranslations("dashboard"); | ||
| const getPricingSourceLabel = (source: string) => t(`logs.billingDetails.pricingSource.`); |
There was a problem hiding this comment.
Missing ${source} interpolation in getPricingSourceLabel
The source parameter is never inserted into the translation key — the string ends with a trailing dot, so t(...) always receives the key "logs.billingDetails.pricingSource." (empty suffix). This means every pricing-source label in the virtualized table will be a raw/broken translation key rather than the correct human-readable string (e.g. "Local manual pricing").
Compare with the correct implementation in usage-logs-table.tsx (line 65-66):
const getPricingSourceLabel = (source: string) =>
t(`logs.billingDetails.pricingSource.${source}`);| const getPricingSourceLabel = (source: string) => t(`logs.billingDetails.pricingSource.`); | |
| const getPricingSourceLabel = (source: string) => t(`logs.billingDetails.pricingSource.${source}`); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx
Line: 78
Comment:
**Missing `${source}` interpolation in `getPricingSourceLabel`**
The `source` parameter is never inserted into the translation key — the string ends with a trailing dot, so `t(...)` always receives the key `"logs.billingDetails.pricingSource."` (empty suffix). This means every pricing-source label in the virtualized table will be a raw/broken translation key rather than the correct human-readable string (e.g. `"Local manual pricing"`).
Compare with the correct implementation in `usage-logs-table.tsx` (line 65-66):
```ts
const getPricingSourceLabel = (source: string) =>
t(`logs.billingDetails.pricingSource.${source}`);
```
```suggestion
const getPricingSourceLabel = (source: string) => t(`logs.billingDetails.pricingSource.${source}`);
```
How can I resolve this? If you propose a fix, please make it concise.| ); | ||
| } else if ( | ||
| hasRealCacheCreationBase && | ||
| cacheCreationAbove200k != null && | ||
| longContextThresholdExceeded && | ||
| context1mApplied && | ||
| cacheCreation1hCost != null && | ||
| cache1hTokens != null | ||
| ) { | ||
| cacheCreationBucket = cacheCreationBucket.add( | ||
| calculateTieredCostWithSeparatePrices( | ||
| cache1hTokens, | ||
| cacheCreation1hCost, | ||
| cacheCreationAbove200k | ||
| ) | ||
| multiplyCost(cache1hTokens, cacheCreation1hCost * CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER) |
There was a problem hiding this comment.
Missing hasRealCacheCreationBase guard for 1-hour cache long-context billing
The 5-minute cache branch (lines 266–273) correctly gates on hasRealCacheCreationBase before applying the above-threshold rate:
if (
longContextThresholdExceeded &&
hasRealCacheCreationBase &&
cacheCreationAboveThreshold != null &&
cache5mTokens != null
)However, the 1-hour cache branch here does not include this guard:
if (
longContextThresholdExceeded &&
cacheCreation1hAboveThreshold != null &&
cache1hTokens != null
)If a model record has cache_creation_input_token_cost_above_1hr_above_272k_tokens set but no base cache_creation_input_token_cost, the 1-hour premium will incorrectly apply even when the model should not use tiered pricing. The same issue exists in calculateRequestCost around line 505–510.
Add the guard:
| ); | |
| } else if ( | |
| hasRealCacheCreationBase && | |
| cacheCreationAbove200k != null && | |
| longContextThresholdExceeded && | |
| context1mApplied && | |
| cacheCreation1hCost != null && | |
| cache1hTokens != null | |
| ) { | |
| cacheCreationBucket = cacheCreationBucket.add( | |
| calculateTieredCostWithSeparatePrices( | |
| cache1hTokens, | |
| cacheCreation1hCost, | |
| cacheCreationAbove200k | |
| ) | |
| multiplyCost(cache1hTokens, cacheCreation1hCost * CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER) | |
| // Cache creation 1h -> cache_creation bucket | |
| if ( | |
| longContextThresholdExceeded && | |
| hasRealCacheCreationBase && | |
| cacheCreation1hAboveThreshold != null && | |
| cache1hTokens != null | |
| ) { |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/utils/cost-calculation.ts
Line: 288-296
Comment:
**Missing `hasRealCacheCreationBase` guard for 1-hour cache long-context billing**
The 5-minute cache branch (lines 266–273) correctly gates on `hasRealCacheCreationBase` before applying the above-threshold rate:
```ts
if (
longContextThresholdExceeded &&
hasRealCacheCreationBase &&
cacheCreationAboveThreshold != null &&
cache5mTokens != null
)
```
However, the 1-hour cache branch here does not include this guard:
```ts
if (
longContextThresholdExceeded &&
cacheCreation1hAboveThreshold != null &&
cache1hTokens != null
)
```
If a model record has `cache_creation_input_token_cost_above_1hr_above_272k_tokens` set but no base `cache_creation_input_token_cost`, the 1-hour premium will incorrectly apply even when the model should not use tiered pricing. The same issue exists in `calculateRequestCost` around line 505–510.
Add the guard:
```suggestion
// Cache creation 1h -> cache_creation bucket
if (
longContextThresholdExceeded &&
hasRealCacheCreationBase &&
cacheCreation1hAboveThreshold != null &&
cache1hTokens != null
) {
```
How can I resolve this? If you propose a fix, please make it concise.| segments.push(multiplyCost(cache1hTokens, cacheCreation1hCost)); | ||
| } | ||
|
|
||
| // 缓存读取费用 | ||
| if ( | ||
| longContextThresholdExceeded && |
There was a problem hiding this comment.
Missing hasRealCacheCreationBase guard in calculateRequestCost 1-hour cache branch
Same issue as in calculateRequestCostBreakdown: the 5-minute cache branch (lines 484–490) correctly checks hasRealCacheCreationBase, but the 1-hour branch here does not. This creates an asymmetry where models without a base cache creation price can still be charged above-threshold rates for 1-hour TTL caching.
Add the guard:
| segments.push(multiplyCost(cache1hTokens, cacheCreation1hCost)); | |
| } | |
| // 缓存读取费用 | |
| if ( | |
| longContextThresholdExceeded && | |
| // 缓存创建费用(1小时 TTL):优先级 explicit long-context > context1m fallback > 普通 | |
| if ( | |
| longContextThresholdExceeded && | |
| hasRealCacheCreationBase && | |
| cacheCreation1hAboveThreshold != null && | |
| cache1hTokens != null | |
| ) { |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/utils/cost-calculation.ts
Line: 505-510
Comment:
**Missing `hasRealCacheCreationBase` guard in `calculateRequestCost` 1-hour cache branch**
Same issue as in `calculateRequestCostBreakdown`: the 5-minute cache branch (lines 484–490) correctly checks `hasRealCacheCreationBase`, but the 1-hour branch here does not. This creates an asymmetry where models without a base cache creation price can still be charged above-threshold rates for 1-hour TTL caching.
Add the guard:
```suggestion
// 缓存创建费用(1小时 TTL):优先级 explicit long-context > context1m fallback > 普通
if (
longContextThresholdExceeded &&
hasRealCacheCreationBase &&
cacheCreation1hAboveThreshold != null &&
cache1hTokens != null
) {
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (4)
src/lib/utils/special-settings.ts (1)
25-27:⚠️ Potential issue | 🟠 Major不要现在就去掉
context1mApplied的兼容派生。
buildUnifiedSpecialSettings()现在完全不再使用这个参数了,但src/repository/usage-logs.ts仍会把历史记录里的context1mApplied传进来。这样旧日志或未显式写入anthropic_context_1m_header_override的请求,会直接丢掉 1M 上下文审计。至少应保留从context1mApplied派生 special setting 的回退,直到所有写路径都稳定持久化显式 special setting。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/utils/special-settings.ts` around lines 25 - 27, buildUnifiedSpecialSettings currently ignores the legacy context1mApplied flag but usage-logs still passes it in, so restore a fallback: in buildUnifiedSpecialSettings (or the function that normalizes special settings) detect a missing anthropic_context_1m_header_override and, if context1mApplied is true (or truthy), set the equivalent special setting to preserve 1M audit behavior; ensure you reference and handle the context1mApplied input from the call site in src/repository/usage-logs.ts so old logs without explicit anthropic_context_1m_header_override still honor the legacy value until all writers persist the explicit special setting.tests/integration/billing-model-source.test.ts (1)
383-442:⚠️ Potential issue | 🟡 Minor把这个 nested pricing 场景补成和前面一致的覆盖矩阵。
这里现在仍然只测了非流式,而且没有断言
RateLimitService.trackCost。如果 provider-aware pricing 只接上了 DB / Session 其中一条链路,或者只在 stream 分支里有问题,这个用例会漏报。建议至少补一个 stream case,并把 DB、Session、RateLimit 三条计费链路都断言出来。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/integration/billing-model-source.test.ts` around lines 383 - 442, The test only covers non-stream pricing and asserts DB and Session updates but misses the stream path and RateLimitService; update the "nested pricing: gpt-5.4..." test to also exercise the streaming branch (use or add a stream response factory, e.g., createStreamResponse or equivalent) and repeat the dispatch+drainAsyncTasks flow, and add assertions that RateLimitService.trackUserDailyCost was called with the expected cost in both non-stream and stream cases; ensure you explicitly assert the three billing chains by checking the mocked updateMessageRequestCost (dbCosts), SessionManager.updateSessionUsage (sessionCosts), and vi.mocked(RateLimitService.trackUserDailyCost) call arguments for the expected "50" value so the test fails if any one chain is missing.messages/ja/settings/prices.json (1)
196-220:⚠️ Potential issue | 🟡 Minor日语资源仍缺少
badges.multi。这次已经补了多供应商价格相关文案,但
badges里仍只有local。如果价格列表继续渲染 multi 徽标,ja locale 会直接出现缺失翻译。请把badges.multi一并补上。As per coding guidelines, "All user-facing strings must use i18n (5 languages supported: zh-CN, zh-TW, en, ja, ru). Never hardcode display text".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@messages/ja/settings/prices.json` around lines 196 - 220, The ja locale is missing the badges.multi translation causing missing-text at runtime; add a "badges.multi" key with an appropriate Japanese string (matching style of existing badge translations, e.g., "マルチ" or the agreed term) alongside the existing badges.local entry in messages/ja/settings/prices.json so the multi-provider badge renders correctly (ensure the key name is exactly "multi" under the badges object to mirror other locales).src/app/v1/_lib/proxy/response-handler.ts (1)
3009-3021:⚠️ Potential issue | 🟡 Minor
usageMetrics为空时,请求持续时间未被持久化。当
usageMetrics为null时,函数在第 3021 行直接返回,但updateMessageRequestDuration从未被调用。虽然updateMessageRequestDetails中设置了ttfbMs,但实际的请求总时长(duration字段)未被保存。此外,
ProxyStatusTracker.endRequest也未在此路径中调用,可能导致追踪状态残留。修复建议
if (!usageMetrics) { + await updateMessageRequestDuration(messageContext.id, duration); + ProxyStatusTracker.getInstance().endRequest(messageContext.user.id, messageContext.id); + await updateMessageRequestDetails(messageContext.id, { statusCode: statusCode, // ... existing fields }); return null; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/v1/_lib/proxy/response-handler.ts` around lines 3009 - 3021, When usageMetrics is null the handler returns after calling updateMessageRequestDetails and never persists the total request duration nor ends the proxy tracking; modify the branch where usageMetrics is falsy (around the check for usageMetrics and the updateMessageRequestDetails call) to also call updateMessageRequestDuration(messageContext.id, { duration }) and ensure ProxyStatusTracker.endRequest(...) is invoked (using the same session/context and providerIdForPersistence as in the other path) before returning so both duration and request tracking are properly finalized.
🧹 Nitpick comments (2)
src/app/v1/_lib/proxy/session.ts (1)
126-127: 这个缓存目前只覆盖串行调用,并发命中同一 key 时仍会重复查库。
resolvedPricingCache存的是最终值,不存 in-flight promise。两个并发的getResolvedPricingByBillingSource()都会在has(cacheKey)之前 miss,然后各自执行一次findLatestPriceByModel()。如果这条路径会在同一请求内被多个异步阶段读取,建议像billingModelSourcePromise一样缓存 promise。建议改法
- private resolvedPricingCache = new Map<string, ResolvedPricing | null>(); + private resolvedPricingCache = new Map<string, Promise<ResolvedPricing | null>>(); async getResolvedPricingByBillingSource( provider?: Provider | null ): Promise<ResolvedPricing | null> { ... - if (this.resolvedPricingCache.has(cacheKey)) { - return this.resolvedPricingCache.get(cacheKey) ?? null; - } + const cached = this.resolvedPricingCache.get(cacheKey); + if (cached) { + return cached; + } - const primaryRecord = primaryModel ? await findLatestPriceByModel(primaryModel) : null; - let resolved = resolvePricingForModelRecords(...); - ... - this.resolvedPricingCache.set(cacheKey, resolved ?? null); - return resolved ?? null; + const pending = (async () => { + const primaryRecord = primaryModel ? await findLatestPriceByModel(primaryModel) : null; + let resolved = resolvePricingForModelRecords(...); + ... + return resolved ?? null; + })().catch((error) => { + this.resolvedPricingCache.delete(cacheKey); + throw error; + }); + + this.resolvedPricingCache.set(cacheKey, pending); + return pending; }Also applies to: 752-780
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/v1/_lib/proxy/session.ts` around lines 126 - 127, 当前 resolvedPricingCache 存的是最终 ResolvedPricing 值,导致并发调用 getResolvedPricingByBillingSource() 在 has(cacheKey) 前都会 miss 并各自触发 findLatestPriceByModel();请改为像 billingModelSourcePromise 那样在 resolvedPricingCache 或新增 resolvedPricingPromiseCache 中缓存正在进行的 Promise(而不是最终值),在首次请求时将查库的 Promise 存入缓存并返回该 Promise,查库成功后用结果替换或清理缓存项/存最终值以保持语义一致,从而避免并发重复查库;涉及标识符:resolvedPricingCache、getResolvedPricingByBillingSource、findLatestPriceByModel、billingModelSourcePromise(同时在文件中 752-780 相同逻辑处一并修复)。src/lib/utils/pricing-resolution.ts (1)
96-123: 在ModelPriceData类型定义中显式声明model_family字段可以改进类型安全性。
model_family字段来自云端价格同步数据(通过 TOML 解析自https://claude-code-hub.app/config/prices-base.toml),并在getOfficialProviderKeys中被访问。虽然当前代码通过typeof检查进行了防御性处理,但该字段未在ModelPriceData类型中显式声明,只能通过索引签名[key: string]: unknown允许。建议在
ModelPriceData接口中添加:model_family?: string;这样可以提高类型安全性,改善 IDE 自动补全,并使代码意图更加清晰。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/utils/pricing-resolution.ts` around lines 96 - 123, Add an explicit optional model_family?: string; property to the ModelPriceData interface so accesses in getOfficialProviderKeys and other callers are type-safe; update the type definition for ModelPriceData to include model_family?: string (keep it optional) and leave the existing defensive checks in getOfficialProviderKeys (typeof priceData?.model_family === "string") as-is to preserve runtime safety.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/app/v1/_lib/proxy/response-handler.ts`:
- Around line 1311-1327: The else branch in the pushChunk logic causes infinite
recursion because it calls pushChunk(text, bytes) when inTailMode is true or
headBufferedBytes >= MAX_STATS_HEAD_BYTES; change that call to invoke the
captured pushToTail() closure instead (use pushToTail() where pushChunk(text,
bytes) is currently called) so the already-captured text/bytes are pushed to the
tail buffer without re-entering pushChunk; verify pushToTail, inTailMode,
headChunks, headBufferedBytes and MAX_STATS_HEAD_BYTES are used consistently.
In `@src/lib/utils/cost-calculation.ts`:
- Around line 219-230: The code currently uses a single guard
(hasRealCacheCreationBase / hasRealCacheReadBase) that only checks
cache_creation_input_token_cost and cache_read_input_token_cost, causing
1h-specific base rates (cache_creation_input_token_cost_above_1hr* /
cache_read_input_token_cost_above_1hr*) and their long-context branches to be
skipped; update the logic to split these into separate guards for 5m vs 1h
(e.g., hasRealCacheCreationBase5m, hasRealCacheCreationBase1h,
hasRealCacheReadBase5m, hasRealCacheReadBase1h) and use the appropriate guard
when deciding to fall back to base/derived prices inside
resolveLongContextThreshold/getRequestInputContextTokens handling and pricing
branches; apply the same split-fix in both calculateRequestCostBreakdown() and
calculateRequestCost() (and the analogous blocks at the other mentioned ranges:
~296-304, ~446-457, ~521-528) so 1h long-context rates are honored when only 1h
base prices are present.
In `@tests/integration/billing-model-source.test.ts`:
- Around line 444-620: The three new "codex fast" tests only exercise non-stream
responses and assert DB/Session costs but miss stream coverage and rate-limit
assertions; add parallel stream test cases using createStreamResponse (or the
project's streaming helper) and dispatch via ProxyResponseHandler.dispatch,
mock/spy RateLimitService.trackUserDailyCost (and/or RateLimitService.trackCost
if used) to assert it was called with the expected cost, and also assert
updateMessageRequestCost and SessionManager.updateSessionUsage were invoked with
the same computed priority/non-priority amounts so the DB, Session and RateLimit
chains are all covered for service_tier handling.
---
Duplicate comments:
In `@messages/ja/settings/prices.json`:
- Around line 196-220: The ja locale is missing the badges.multi translation
causing missing-text at runtime; add a "badges.multi" key with an appropriate
Japanese string (matching style of existing badge translations, e.g., "マルチ" or
the agreed term) alongside the existing badges.local entry in
messages/ja/settings/prices.json so the multi-provider badge renders correctly
(ensure the key name is exactly "multi" under the badges object to mirror other
locales).
In `@src/app/v1/_lib/proxy/response-handler.ts`:
- Around line 3009-3021: When usageMetrics is null the handler returns after
calling updateMessageRequestDetails and never persists the total request
duration nor ends the proxy tracking; modify the branch where usageMetrics is
falsy (around the check for usageMetrics and the updateMessageRequestDetails
call) to also call updateMessageRequestDuration(messageContext.id, { duration })
and ensure ProxyStatusTracker.endRequest(...) is invoked (using the same
session/context and providerIdForPersistence as in the other path) before
returning so both duration and request tracking are properly finalized.
In `@src/lib/utils/special-settings.ts`:
- Around line 25-27: buildUnifiedSpecialSettings currently ignores the legacy
context1mApplied flag but usage-logs still passes it in, so restore a fallback:
in buildUnifiedSpecialSettings (or the function that normalizes special
settings) detect a missing anthropic_context_1m_header_override and, if
context1mApplied is true (or truthy), set the equivalent special setting to
preserve 1M audit behavior; ensure you reference and handle the context1mApplied
input from the call site in src/repository/usage-logs.ts so old logs without
explicit anthropic_context_1m_header_override still honor the legacy value until
all writers persist the explicit special setting.
In `@tests/integration/billing-model-source.test.ts`:
- Around line 383-442: The test only covers non-stream pricing and asserts DB
and Session updates but misses the stream path and RateLimitService; update the
"nested pricing: gpt-5.4..." test to also exercise the streaming branch (use or
add a stream response factory, e.g., createStreamResponse or equivalent) and
repeat the dispatch+drainAsyncTasks flow, and add assertions that
RateLimitService.trackUserDailyCost was called with the expected cost in both
non-stream and stream cases; ensure you explicitly assert the three billing
chains by checking the mocked updateMessageRequestCost (dbCosts),
SessionManager.updateSessionUsage (sessionCosts), and
vi.mocked(RateLimitService.trackUserDailyCost) call arguments for the expected
"50" value so the test fails if any one chain is missing.
---
Nitpick comments:
In `@src/app/v1/_lib/proxy/session.ts`:
- Around line 126-127: 当前 resolvedPricingCache 存的是最终 ResolvedPricing 值,导致并发调用
getResolvedPricingByBillingSource() 在 has(cacheKey) 前都会 miss 并各自触发
findLatestPriceByModel();请改为像 billingModelSourcePromise 那样在 resolvedPricingCache
或新增 resolvedPricingPromiseCache 中缓存正在进行的 Promise(而不是最终值),在首次请求时将查库的 Promise
存入缓存并返回该
Promise,查库成功后用结果替换或清理缓存项/存最终值以保持语义一致,从而避免并发重复查库;涉及标识符:resolvedPricingCache、getResolvedPricingByBillingSource、findLatestPriceByModel、billingModelSourcePromise(同时在文件中
752-780 相同逻辑处一并修复)。
In `@src/lib/utils/pricing-resolution.ts`:
- Around line 96-123: Add an explicit optional model_family?: string; property
to the ModelPriceData interface so accesses in getOfficialProviderKeys and other
callers are type-safe; update the type definition for ModelPriceData to include
model_family?: string (keep it optional) and leave the existing defensive checks
in getOfficialProviderKeys (typeof priceData?.model_family === "string") as-is
to preserve runtime safety.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 97131ec7-6136-41d0-945d-d4745b31751c
📒 Files selected for processing (22)
messages/en/settings/prices.jsonmessages/ja/settings/prices.jsonmessages/ru/settings/prices.jsonmessages/zh-CN/settings/prices.jsonmessages/zh-TW/settings/prices.jsonsrc/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsxsrc/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsxsrc/app/[locale]/settings/prices/_components/price-list.tsxsrc/app/[locale]/settings/prices/_components/provider-pricing-dialog.tsxsrc/app/v1/_lib/proxy/response-handler.tssrc/app/v1/_lib/proxy/session.tssrc/lib/utils/cost-calculation.tssrc/lib/utils/pricing-resolution.tssrc/lib/utils/special-settings.tssrc/types/special-settings.tstests/integration/billing-model-source.test.tstests/unit/lib/cost-calculation-long-context.test.tstests/unit/lib/cost-calculation-priority.test.tstests/unit/lib/utils/pricing-resolution.test.tstests/unit/lib/utils/special-settings.test.tstests/unit/proxy/pricing-no-price.test.tstests/unit/proxy/session.test.ts
🚧 Files skipped from review as they are similar to previous changes (5)
- messages/en/settings/prices.json
- messages/zh-CN/settings/prices.json
- messages/ru/settings/prices.json
- messages/zh-TW/settings/prices.json
- tests/unit/lib/cost-calculation-long-context.test.ts
| if (!inTailMode && headBufferedBytes < MAX_STATS_HEAD_BYTES) { | ||
| const remainingHeadBytes = MAX_STATS_HEAD_BYTES - headBufferedBytes; | ||
| if (remainingHeadBytes > 0 && bytes > remainingHeadBytes) { | ||
| const headPart = text.substring(0, remainingHeadBytes); | ||
| const tailPart = text.substring(remainingHeadBytes); | ||
|
|
||
| pushChunk(headPart, remainingHeadBytes); | ||
|
|
||
| inTailMode = true; | ||
| pushChunk(tailPart, bytes - remainingHeadBytes); | ||
| } else { | ||
| headChunks.push(text); | ||
| headBufferedBytes += bytes; | ||
| return; | ||
| } | ||
|
|
||
| inTailMode = true; | ||
| } else { | ||
| pushChunk(text, bytes); | ||
| } |
There was a problem hiding this comment.
递归调用导致无限循环(栈溢出)。
当 inTailMode 为 true 或 headBufferedBytes >= MAX_STATS_HEAD_BYTES 时,第 1326 行的 pushChunk(text, bytes) 会无限递归调用自身:
- 进入
pushChunk("data", 100) - 检查
!inTailMode && headBufferedBytes < MAX_STATS_HEAD_BYTES→false - 进入
else分支,再次调用pushChunk("data", 100) - 重复步骤 2-3,直到栈溢出
pushToTail() 闭包已捕获 text 和 bytes,应直接调用它。
修复建议
} else {
- pushChunk(text, bytes);
+ inTailMode = true;
+ pushToTail();
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/v1/_lib/proxy/response-handler.ts` around lines 1311 - 1327, The else
branch in the pushChunk logic causes infinite recursion because it calls
pushChunk(text, bytes) when inTailMode is true or headBufferedBytes >=
MAX_STATS_HEAD_BYTES; change that call to invoke the captured pushToTail()
closure instead (use pushToTail() where pushChunk(text, bytes) is currently
called) so the already-captured text/bytes are pushed to the tail buffer without
re-entering pushChunk; verify pushToTail, inTailMode, headChunks,
headBufferedBytes and MAX_STATS_HEAD_BYTES are used consistently.
| const cacheCreation1hAboveThreshold = | ||
| priceData.cache_creation_input_token_cost_above_1hr_above_272k_tokens ?? | ||
| priceData.cache_creation_input_token_cost_above_1hr_above_200k_tokens ?? | ||
| cacheCreationAboveThreshold; | ||
| const cacheReadAboveThreshold = | ||
| priceData.cache_read_input_token_cost_above_272k_tokens ?? | ||
| priceData.cache_read_input_token_cost_above_200k_tokens; | ||
| const longContextThreshold = resolveLongContextThreshold(priceData); | ||
| const longContextThresholdExceeded = | ||
| getRequestInputContextTokens(usage, cache5mTokens, cache1hTokens) > longContextThreshold; | ||
| const hasRealCacheCreationBase = priceData.cache_creation_input_token_cost != null; | ||
| const hasRealCacheReadBase = priceData.cache_read_input_token_cost != null; |
There was a problem hiding this comment.
1h cache 的 long-context 显式费率仍可能被跳过。
hasRealCacheCreationBase 现在只检查 cache_creation_input_token_cost。如果某个模型只配置了 1h 基础价(cache_creation_input_token_cost_above_1hr)和对应的 long-context 1h 费率(cache_creation_input_token_cost_above_1hr_above_*),这两个 1h 分支都会落回普通/派生价格,导致超阈值的 1h cache creation 少计费。这里需要把 5m/1h 的“是否有真实基础价”拆成两个 guard。
建议修改
- const hasRealCacheCreationBase = priceData.cache_creation_input_token_cost != null;
+ const hasRealCacheCreation5mBase = priceData.cache_creation_input_token_cost != null;
+ const hasRealCacheCreation1hBase =
+ priceData.cache_creation_input_token_cost_above_1hr != null ||
+ priceData.cache_creation_input_token_cost != null;
// Cache creation 5m
if (
longContextThresholdExceeded &&
- hasRealCacheCreationBase &&
+ hasRealCacheCreation5mBase &&
cacheCreationAboveThreshold != null &&
cache5mTokens != null
) {
...
}
// Cache creation 1h
if (
longContextThresholdExceeded &&
- hasRealCacheCreationBase &&
+ hasRealCacheCreation1hBase &&
cacheCreation1hAboveThreshold != null &&
cache1hTokens != null
) {
...
}同样的修正需要在 calculateRequestCostBreakdown() 和 calculateRequestCost() 两处同时应用。
Also applies to: 296-304, 446-457, 521-528
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/lib/utils/cost-calculation.ts` around lines 219 - 230, The code currently
uses a single guard (hasRealCacheCreationBase / hasRealCacheReadBase) that only
checks cache_creation_input_token_cost and cache_read_input_token_cost, causing
1h-specific base rates (cache_creation_input_token_cost_above_1hr* /
cache_read_input_token_cost_above_1hr*) and their long-context branches to be
skipped; update the logic to split these into separate guards for 5m vs 1h
(e.g., hasRealCacheCreationBase5m, hasRealCacheCreationBase1h,
hasRealCacheReadBase5m, hasRealCacheReadBase1h) and use the appropriate guard
when deciding to fall back to base/derived prices inside
resolveLongContextThreshold/getRequestInputContextTokens handling and pricing
branches; apply the same split-fix in both calculateRequestCostBreakdown() and
calculateRequestCost() (and the analogous blocks at the other mentioned ranges:
~296-304, ~446-457, ~521-528) so 1h long-context rates are honored when only 1h
base prices are present.
| it("codex fast: uses priority pricing when response reports service_tier=priority", async () => { | ||
| vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); | ||
| vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined); | ||
| vi.mocked(updateMessageRequestDuration).mockResolvedValue(undefined); | ||
| vi.mocked(SessionManager.storeSessionResponse).mockResolvedValue(undefined); | ||
| vi.mocked(RateLimitService.trackUserDailyCost).mockResolvedValue(undefined); | ||
| vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); | ||
|
|
||
| vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { | ||
| if (modelName === "gpt-5.4") { | ||
| return makePriceRecord(modelName, { | ||
| mode: "responses", | ||
| model_family: "gpt", | ||
| litellm_provider: "chatgpt", | ||
| pricing: { | ||
| openai: { | ||
| input_cost_per_token: 1, | ||
| output_cost_per_token: 10, | ||
| input_cost_per_token_priority: 2, | ||
| output_cost_per_token_priority: 20, | ||
| }, | ||
| }, | ||
| }); | ||
| } | ||
| return null; | ||
| }); | ||
|
|
||
| const dbCosts: string[] = []; | ||
| vi.mocked(updateMessageRequestCost).mockImplementation( | ||
| async (_id: number, costUsd: unknown) => { | ||
| dbCosts.push(String(costUsd)); | ||
| } | ||
| ); | ||
|
|
||
| const sessionCosts: string[] = []; | ||
| vi.mocked(SessionManager.updateSessionUsage).mockImplementation( | ||
| async (_sessionId: string, payload: Record<string, unknown>) => { | ||
| if (typeof payload.costUsd === "string") { | ||
| sessionCosts.push(payload.costUsd); | ||
| } | ||
| } | ||
| ); | ||
|
|
||
| const session = createSession({ | ||
| originalModel: "gpt-5.4", | ||
| redirectedModel: "gpt-5.4", | ||
| sessionId: "sess-gpt54-priority-actual", | ||
| messageId: 3200, | ||
| providerOverrides: { | ||
| name: "ChatGPT", | ||
| url: "https://chatgpt.com/backend-api/codex", | ||
| providerType: "codex", | ||
| }, | ||
| requestMessage: { service_tier: "default" }, | ||
| }); | ||
|
|
||
| const response = createNonStreamResponse( | ||
| { input_tokens: 2, output_tokens: 3 }, | ||
| { service_tier: "priority" } | ||
| ); | ||
| await ProxyResponseHandler.dispatch(session, response); | ||
| await drainAsyncTasks(); | ||
|
|
||
| expect(dbCosts[0]).toBe("64"); | ||
| expect(sessionCosts[0]).toBe("64"); | ||
| }); | ||
|
|
||
| it("codex fast: falls back to requested priority pricing when response omits service_tier", async () => { | ||
| vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); | ||
| vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined); | ||
| vi.mocked(updateMessageRequestDuration).mockResolvedValue(undefined); | ||
| vi.mocked(SessionManager.storeSessionResponse).mockResolvedValue(undefined); | ||
| vi.mocked(RateLimitService.trackUserDailyCost).mockResolvedValue(undefined); | ||
| vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); | ||
|
|
||
| vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { | ||
| if (modelName === "gpt-5.4") { | ||
| return makePriceRecord(modelName, { | ||
| mode: "responses", | ||
| model_family: "gpt", | ||
| litellm_provider: "chatgpt", | ||
| pricing: { | ||
| openai: { | ||
| input_cost_per_token: 1, | ||
| output_cost_per_token: 10, | ||
| input_cost_per_token_priority: 2, | ||
| output_cost_per_token_priority: 20, | ||
| }, | ||
| }, | ||
| }); | ||
| } | ||
| return null; | ||
| }); | ||
|
|
||
| const dbCosts: string[] = []; | ||
| vi.mocked(updateMessageRequestCost).mockImplementation( | ||
| async (_id: number, costUsd: unknown) => { | ||
| dbCosts.push(String(costUsd)); | ||
| } | ||
| ); | ||
|
|
||
| const session = createSession({ | ||
| originalModel: "gpt-5.4", | ||
| redirectedModel: "gpt-5.4", | ||
| sessionId: "sess-gpt54-priority-requested", | ||
| messageId: 3201, | ||
| providerOverrides: { | ||
| name: "ChatGPT", | ||
| url: "https://chatgpt.com/backend-api/codex", | ||
| providerType: "codex", | ||
| }, | ||
| requestMessage: { service_tier: "priority" }, | ||
| }); | ||
|
|
||
| const response = createNonStreamResponse({ input_tokens: 2, output_tokens: 3 }); | ||
| await ProxyResponseHandler.dispatch(session, response); | ||
| await drainAsyncTasks(); | ||
|
|
||
| expect(dbCosts[0]).toBe("64"); | ||
| }); | ||
|
|
||
| it("codex fast: does not use priority pricing when response explicitly reports non-priority tier", async () => { | ||
| vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); | ||
| vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined); | ||
| vi.mocked(updateMessageRequestDuration).mockResolvedValue(undefined); | ||
| vi.mocked(SessionManager.storeSessionResponse).mockResolvedValue(undefined); | ||
| vi.mocked(RateLimitService.trackUserDailyCost).mockResolvedValue(undefined); | ||
| vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); | ||
|
|
||
| vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { | ||
| if (modelName === "gpt-5.4") { | ||
| return makePriceRecord(modelName, { | ||
| mode: "responses", | ||
| model_family: "gpt", | ||
| litellm_provider: "chatgpt", | ||
| pricing: { | ||
| openai: { | ||
| input_cost_per_token: 1, | ||
| output_cost_per_token: 10, | ||
| input_cost_per_token_priority: 2, | ||
| output_cost_per_token_priority: 20, | ||
| }, | ||
| }, | ||
| }); | ||
| } | ||
| return null; | ||
| }); | ||
|
|
||
| const dbCosts: string[] = []; | ||
| vi.mocked(updateMessageRequestCost).mockImplementation( | ||
| async (_id: number, costUsd: unknown) => { | ||
| dbCosts.push(String(costUsd)); | ||
| } | ||
| ); | ||
|
|
||
| const session = createSession({ | ||
| originalModel: "gpt-5.4", | ||
| redirectedModel: "gpt-5.4", | ||
| sessionId: "sess-gpt54-priority-downgraded", | ||
| messageId: 3202, | ||
| providerOverrides: { | ||
| name: "ChatGPT", | ||
| url: "https://chatgpt.com/backend-api/codex", | ||
| providerType: "codex", | ||
| }, | ||
| requestMessage: { service_tier: "priority" }, | ||
| }); | ||
|
|
||
| const response = createNonStreamResponse( | ||
| { input_tokens: 2, output_tokens: 3 }, | ||
| { service_tier: "default" } | ||
| ); | ||
| await ProxyResponseHandler.dispatch(session, response); | ||
| await drainAsyncTasks(); | ||
|
|
||
| expect(dbCosts[0]).toBe("32"); | ||
| }); |
There was a problem hiding this comment.
codex fast 新计费路径也缺少流式与限流链路覆盖。
这三条新用例都只走了 non-stream,且大多只校验了 DB / Session 成本,没有把 RateLimitService.trackCost 纳入断言。service_tier 解析和 priority 定价是在新逻辑里新增的,最好至少补一组 stream 用例,并和非流式一样覆盖 DB、Session、RateLimit 三条链路,否则这条计费路径的回归很容易漏掉。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/integration/billing-model-source.test.ts` around lines 444 - 620, The
three new "codex fast" tests only exercise non-stream responses and assert
DB/Session costs but miss stream coverage and rate-limit assertions; add
parallel stream test cases using createStreamResponse (or the project's
streaming helper) and dispatch via ProxyResponseHandler.dispatch, mock/spy
RateLimitService.trackUserDailyCost (and/or RateLimitService.trackCost if used)
to assert it was called with the expected cost, and also assert
updateMessageRequestCost and SessionManager.updateSessionUsage were invoked with
the same computed priority/non-priority amounts so the DB, Session and RateLimit
chains are all covered for service_tier handling.
| function resolveLongContextThreshold(priceData: ModelPriceData): number { | ||
| const has272kFields = | ||
| typeof priceData.input_cost_per_token_above_272k_tokens === "number" || | ||
| typeof priceData.output_cost_per_token_above_272k_tokens === "number" || | ||
| typeof priceData.cache_creation_input_token_cost_above_272k_tokens === "number" || | ||
| typeof priceData.cache_read_input_token_cost_above_272k_tokens === "number" || | ||
| typeof priceData.cache_creation_input_token_cost_above_1hr_above_272k_tokens === "number"; | ||
|
|
||
| const modelFamily = typeof priceData.model_family === "string" ? priceData.model_family : ""; | ||
| if (has272kFields || modelFamily === "gpt" || modelFamily === "gpt-pro") { | ||
| return OPENAI_LONG_CONTEXT_TOKEN_THRESHOLD; | ||
| } | ||
|
|
||
| return CONTEXT_1M_TOKEN_THRESHOLD; | ||
| } |
There was a problem hiding this comment.
Threshold/rate mismatch for GPT models with only 200k pricing fields
resolveLongContextThreshold returns OPENAI_LONG_CONTEXT_TOKEN_THRESHOLD (272k) for any model whose model_family is "gpt" or "gpt-pro", even when the price record has no 272k fields at all (only input_cost_per_token_above_200k_tokens).
In calculateRequestCostBreakdown / calculateRequestCost, inputAboveThreshold is then computed as:
const inputAboveThreshold =
priceData.input_cost_per_token_above_272k_tokens ??
priceData.input_cost_per_token_above_200k_tokens;So for such a model:
- The premium activates at 272k total tokens (272k threshold)
- But the rate used is the 200k pricing tier
This means a GPT model with 220k tokens would not be billed at premium (because 220k < 272k), even though it crossed the 200k pricing tier. Conversely, a model with 280k tokens would be billed at the 200k-tier rate with the 272k threshold, which may or may not match the provider's actual billing.
If the 272k threshold is intentional for all GPT-family models regardless of whether 272k pricing fields exist, this should be documented with a comment. If not, the model_family === "gpt" fallback should only apply when has272kFields is already true.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/utils/cost-calculation.ts
Line: 91-105
Comment:
**Threshold/rate mismatch for GPT models with only 200k pricing fields**
`resolveLongContextThreshold` returns `OPENAI_LONG_CONTEXT_TOKEN_THRESHOLD` (272k) for any model whose `model_family` is `"gpt"` or `"gpt-pro"`, even when the price record has **no** 272k fields at all (only `input_cost_per_token_above_200k_tokens`).
In `calculateRequestCostBreakdown` / `calculateRequestCost`, `inputAboveThreshold` is then computed as:
```ts
const inputAboveThreshold =
priceData.input_cost_per_token_above_272k_tokens ??
priceData.input_cost_per_token_above_200k_tokens;
```
So for such a model:
- The premium activates at **272k** total tokens (272k threshold)
- But the rate used is the **200k** pricing tier
This means a GPT model with 220k tokens would **not** be billed at premium (because 220k < 272k), even though it crossed the 200k pricing tier. Conversely, a model with 280k tokens would be billed at the 200k-tier rate with the 272k threshold, which may or may not match the provider's actual billing.
If the 272k threshold is intentional for all GPT-family models regardless of whether 272k pricing fields exist, this should be documented with a comment. If not, the `model_family === "gpt"` fallback should only apply when `has272kFields` is already true.
How can I resolve this? If you propose a fix, please make it concise.| export function resolvePricingKeyCandidates( | ||
| provider: Provider | null | undefined, | ||
| modelName: string | null | undefined, | ||
| priceData?: ModelPriceData | ||
| ): PricingKeyCandidate[] { | ||
| const candidates: PricingKeyCandidate[] = []; | ||
| const name = normalizeText(provider?.name); | ||
| const url = normalizeText(provider?.url); | ||
| const host = extractHost(provider?.url); | ||
|
|
||
| if (name.includes("openrouter") || host.includes("openrouter")) { | ||
| pushUnique(candidates, "openrouter", "exact"); | ||
| } | ||
| if (name.includes("opencode") || host.includes("opencode")) { | ||
| pushUnique(candidates, "opencode", "exact"); | ||
| } | ||
| if ( | ||
| name.includes("cloudflare") || | ||
| host.includes("cloudflare") || | ||
| url.includes("cloudflare-ai-gateway") | ||
| ) { | ||
| pushUnique(candidates, "cloudflare-ai-gateway", "exact"); | ||
| } | ||
| if (name.includes("github") || name.includes("copilot") || host.includes("githubcopilot")) { | ||
| pushUnique(candidates, "github-copilot", "exact"); | ||
| } | ||
| if (name.includes("chatgpt") || host.includes("chatgpt.com")) { | ||
| pushUnique(candidates, "chatgpt", "exact"); | ||
| } | ||
| if (name.includes("openai") || host.includes("openai.com") || host.includes("api.openai.com")) { | ||
| pushUnique(candidates, "openai", "exact"); | ||
| } | ||
| if (name.includes("anthropic") || host.includes("anthropic.com")) { | ||
| pushUnique(candidates, "anthropic", "exact"); | ||
| } | ||
| if (name.includes("vertex") || host.includes("googleapis.com") || name.includes("google")) { | ||
| pushUnique(candidates, "vertex_ai", "exact"); | ||
| pushUnique(candidates, "vertex", "exact"); | ||
| pushUnique(candidates, "google", "exact"); | ||
| } | ||
|
|
||
| for (const officialKey of getOfficialProviderKeys(modelName, priceData)) { | ||
| pushUnique(candidates, officialKey, "official"); | ||
| } | ||
|
|
||
| return candidates; |
There was a problem hiding this comment.
Azure OpenAI misidentified as direct OpenAI for pricing
resolvePricingKeyCandidates uses host.includes("openai.com") to identify OpenAI providers:
if (name.includes("openai") || host.includes("openai.com") || host.includes("api.openai.com")) {
pushUnique(candidates, "openai", "exact");
}Azure OpenAI endpoint URLs are formatted as https://{resource-name}.openai.azure.com/.... Since the host myresource.openai.azure.com contains openai.com, Azure providers will be classified as "openai" key candidates and will resolve against the OpenAI pricing node in the model's pricing map.
Azure OpenAI pricing may differ from OpenAI direct pricing (e.g. for GPT-5.4, Azure may have different rates). If a model record contains both "openai" and "azure" (or "azure_openai") pricing nodes, Azure-hosted deployments would incorrectly resolve against the OpenAI node rather than the Azure-specific node.
Consider adding an explicit Azure check before the generic OpenAI check:
if (host.includes("openai.azure.com") || name.includes("azure")) {
pushUnique(candidates, "azure_openai", "exact");
}Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/utils/pricing-resolution.ts
Line: 125-170
Comment:
**Azure OpenAI misidentified as direct OpenAI for pricing**
`resolvePricingKeyCandidates` uses `host.includes("openai.com")` to identify OpenAI providers:
```ts
if (name.includes("openai") || host.includes("openai.com") || host.includes("api.openai.com")) {
pushUnique(candidates, "openai", "exact");
}
```
Azure OpenAI endpoint URLs are formatted as `https://{resource-name}.openai.azure.com/...`. Since the host `myresource.openai.azure.com` contains `openai.com`, Azure providers will be classified as `"openai"` key candidates and will resolve against the OpenAI pricing node in the model's pricing map.
Azure OpenAI pricing may differ from OpenAI direct pricing (e.g. for GPT-5.4, Azure may have different rates). If a model record contains both `"openai"` and `"azure"` (or `"azure_openai"`) pricing nodes, Azure-hosted deployments would incorrectly resolve against the OpenAI node rather than the Azure-specific node.
Consider adding an explicit Azure check before the generic OpenAI check:
```ts
if (host.includes("openai.azure.com") || name.includes("azure")) {
pushUnique(candidates, "azure_openai", "exact");
}
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
src/lib/utils/special-settings.ts (1)
180-185: 共享常量不要在兼容回退里写死。
src/lib/special-attributes/index.ts已经定义了CONTEXT_1M_BETA_HEADER,src/app/v1/_lib/proxy/forwarder.ts也在用同一个常量。这里单独写"context-1m-2025-08-07",后续 flag 轮换时很容易只改一处,结果把回退审计写成过期值。可选修正
+import { CONTEXT_1M_BETA_HEADER } from "@/lib/special-attributes"; import type { SpecialSetting } from "@/types/special-settings"; @@ type: "anthropic_context_1m_header_override", scope: "request_header", hit: true, header: "anthropic-beta", - flag: "context-1m-2025-08-07", + flag: CONTEXT_1M_BETA_HEADER, });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/utils/special-settings.ts` around lines 180 - 185, The fallback uses a hardcoded flag string ("context-1m-2025-08-07"); replace this literal with the shared constant CONTEXT_1M_BETA_HEADER (the constant defined in src/lib/special-attributes/index.ts and used by forwarder.ts) by importing CONTEXT_1M_BETA_HEADER into special-settings.ts and using it for the derived.flag (and header if appropriate) so future rotations only require updating the single shared constant.tests/unit/lib/utils/special-settings.test.ts (1)
196-236: 补一条pricing_resolution命中优先级的回归测试。当前新增用例只覆盖了
codex_service_tier_result,没有覆盖getPricingResolutionSpecialSetting()在hit: false和hit: true并存时应优先返回命中记录的场景。这个分支一旦回归,日志页展示的 provider/source 会直接错而测试拦不住。As per coding guidelines,
**/*.test.{ts,tsx,js,jsx}: All new features must have unit test coverage of at least 80%🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/unit/lib/utils/special-settings.test.ts` around lines 196 - 236, Add a regression unit test that verifies getPricingResolutionSpecialSetting() prefers and returns the entry with hit: true when both hit: false and hit: true pricing_resolution records are present; create a new test case (in the same suite as hasPriorityServiceTierSpecialSetting tests) that passes an array containing two pricing_resolution special-setting objects (one with hit: false and one with hit: true, differing provider/source metadata) and assert that getPricingResolutionSpecialSetting(...) returns the hit:true record (or the expected provider/source) to cover the hit-preference branch.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/lib/utils/special-settings.ts`:
- Around line 25-27: 注释与实现不一致:变量 context1mApplied 的注释说“不再自动派生”,但代码仍在兼容路径回退生成
anthropic_context_1m_header_override;请在 special-settings.ts 中二选一:要保留兼容回退则更新
context1mApplied 的注释以明确说明“保留用于兼容,会在必要时回退生成
anthropic_context_1m_header_override”;要移除回退则删除/禁用生成
anthropic_context_1m_header_override 的兼容分支(并清理相关测试/调用点);在修改时参考 context1mApplied
和 anthropic_context_1m_header_override 的使用处以确保行为与注释一致。
- Around line 248-260: getPricingResolutionSpecialSetting currently returns the
first pricing_resolution entry which can expose a miss to the UI when a later
hit exists; change getPricingResolutionSpecialSetting to search specialSettings
for an item with type === "pricing_resolution" and hit === true first (return
that), and only if none found return the first pricing_resolution entry (or null
if none); reference the specialSettings parameter, the type:
"pricing_resolution" discriminator and the hit boolean so the function prefers
hit:true over hit:false to keep buildSettingKey()/UI logs correct.
---
Nitpick comments:
In `@src/lib/utils/special-settings.ts`:
- Around line 180-185: The fallback uses a hardcoded flag string
("context-1m-2025-08-07"); replace this literal with the shared constant
CONTEXT_1M_BETA_HEADER (the constant defined in
src/lib/special-attributes/index.ts and used by forwarder.ts) by importing
CONTEXT_1M_BETA_HEADER into special-settings.ts and using it for the
derived.flag (and header if appropriate) so future rotations only require
updating the single shared constant.
In `@tests/unit/lib/utils/special-settings.test.ts`:
- Around line 196-236: Add a regression unit test that verifies
getPricingResolutionSpecialSetting() prefers and returns the entry with hit:
true when both hit: false and hit: true pricing_resolution records are present;
create a new test case (in the same suite as
hasPriorityServiceTierSpecialSetting tests) that passes an array containing two
pricing_resolution special-setting objects (one with hit: false and one with
hit: true, differing provider/source metadata) and assert that
getPricingResolutionSpecialSetting(...) returns the hit:true record (or the
expected provider/source) to cover the hit-preference branch.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 291a5224-ba67-4759-a094-4bfd507130e1
📒 Files selected for processing (2)
src/lib/utils/special-settings.tstests/unit/lib/utils/special-settings.test.ts
| * 1M 上下文是否应用(保留参数用于兼容调用方;不再自动派生 header 覆写审计) | ||
| */ | ||
| context1mApplied?: boolean | null; |
There was a problem hiding this comment.
参数注释与实际行为不一致。
这里写的是“不再自动派生”,但 Line 175-185 仍会在兼容场景下回退生成 anthropic_context_1m_header_override。这会误导后续维护者,并让这段兼容逻辑看起来像死代码。
可选修正
- * 1M 上下文是否应用(保留参数用于兼容调用方;不再自动派生 header 覆写审计)
+ * 1M 上下文是否应用(保留参数用于兼容调用方;当缺少显式记录且不存在 codex service tier result 时,回退派生 header 覆写审计)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| * 1M 上下文是否应用(保留参数用于兼容调用方;不再自动派生 header 覆写审计) | |
| */ | |
| context1mApplied?: boolean | null; | |
| * 1M 上下文是否应用(保留参数用于兼容调用方;当缺少显式记录且不存在 codex service tier result 时,回退派生 header 覆写审计) | |
| */ | |
| context1mApplied?: boolean | null; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/lib/utils/special-settings.ts` around lines 25 - 27, 注释与实现不一致:变量
context1mApplied 的注释说“不再自动派生”,但代码仍在兼容路径回退生成
anthropic_context_1m_header_override;请在 special-settings.ts 中二选一:要保留兼容回退则更新
context1mApplied 的注释以明确说明“保留用于兼容,会在必要时回退生成
anthropic_context_1m_header_override”;要移除回退则删除/禁用生成
anthropic_context_1m_header_override 的兼容分支(并清理相关测试/调用点);在修改时参考 context1mApplied
和 anthropic_context_1m_header_override 的使用处以确保行为与注释一致。
| export function getPricingResolutionSpecialSetting( | ||
| specialSettings?: SpecialSetting[] | null | ||
| ): Extract<SpecialSetting, { type: "pricing_resolution" }> | null { | ||
| if (!Array.isArray(specialSettings) || specialSettings.length === 0) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| specialSettings.find( | ||
| (setting): setting is Extract<SpecialSetting, { type: "pricing_resolution" }> => | ||
| setting.type === "pricing_resolution" | ||
| ) ?? null | ||
| ); |
There was a problem hiding this comment.
这里会把 pricing_resolution 的 miss 记录错误地暴露给 UI。
buildSettingKey() 现在允许 hit: false 和 hit: true 并存,但这里直接拿第一个 pricing_resolution。如果数组顺序是 miss 在前、hit 在后,日志详情/汇总展示的 provider/source 就会错。
建议修正
export function getPricingResolutionSpecialSetting(
specialSettings?: SpecialSetting[] | null
): Extract<SpecialSetting, { type: "pricing_resolution" }> | null {
if (!Array.isArray(specialSettings) || specialSettings.length === 0) {
return null;
}
return (
specialSettings.find(
+ (setting): setting is Extract<SpecialSetting, { type: "pricing_resolution" }> =>
+ setting.type === "pricing_resolution" && setting.hit
+ ) ??
+ specialSettings.find(
(setting): setting is Extract<SpecialSetting, { type: "pricing_resolution" }> =>
setting.type === "pricing_resolution"
) ?? null
);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function getPricingResolutionSpecialSetting( | |
| specialSettings?: SpecialSetting[] | null | |
| ): Extract<SpecialSetting, { type: "pricing_resolution" }> | null { | |
| if (!Array.isArray(specialSettings) || specialSettings.length === 0) { | |
| return null; | |
| } | |
| return ( | |
| specialSettings.find( | |
| (setting): setting is Extract<SpecialSetting, { type: "pricing_resolution" }> => | |
| setting.type === "pricing_resolution" | |
| ) ?? null | |
| ); | |
| export function getPricingResolutionSpecialSetting( | |
| specialSettings?: SpecialSetting[] | null | |
| ): Extract<SpecialSetting, { type: "pricing_resolution" }> | null { | |
| if (!Array.isArray(specialSettings) || specialSettings.length === 0) { | |
| return null; | |
| } | |
| return ( | |
| specialSettings.find( | |
| (setting): setting is Extract<SpecialSetting, { type: "pricing_resolution" }> => | |
| setting.type === "pricing_resolution" && setting.hit | |
| ) ?? | |
| specialSettings.find( | |
| (setting): setting is Extract<SpecialSetting, { type: "pricing_resolution" }> => | |
| setting.type === "pricing_resolution" | |
| ) ?? null | |
| ); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/lib/utils/special-settings.ts` around lines 248 - 260,
getPricingResolutionSpecialSetting currently returns the first
pricing_resolution entry which can expose a miss to the UI when a later hit
exists; change getPricingResolutionSpecialSetting to search specialSettings for
an item with type === "pricing_resolution" and hit === true first (return that),
and only if none found return the first pricing_resolution entry (or null if
none); reference the specialSettings parameter, the type: "pricing_resolution"
discriminator and the hit boolean so the function prefers hit:true over
hit:false to keep buildSettingKey()/UI logs correct.
All caused by commit 2e663cd which changed billing model and session API without updating tests/translations: - i18n: add missing `prices.badges.multi` key to ja/ru/zh-TW locales - tests: update cost-calculation expectations to match full-request pricing (all tokens at premium rate when context > 200K threshold) - tests: fix lease-decrement session mock to use getResolvedPricingByBillingSource instead of removed method Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…#876) * feat(providers): add sub-navigation and Options tab to provider form sidebar Add Scheduling, Circuit Breaker, Timeout sub-items under their parent tabs in the desktop sidebar for quick scroll access. Promote Options to a top-level tab. Includes scroll tracking, i18n (5 langs), and 13 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(providers): add Active Time sub-item under Options tab Add activeTime sub-navigation for quick scroll access to Scheduled Active Time section. Also add variant="highlight" to Options SectionCard for consistent visual styling with other main sections. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(providers): show green ShieldCheck icon when proxy is configured Separate icon with tooltip next to endpoint count on provider list. Visible only when proxyUrl is set. i18n for 5 languages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(providers): fix 7 review issues in sub-nav and form architecture - Derive TAB_CONFIG, TAB_ORDER, NAV_ORDER, PARENT_MAP from NAV_CONFIG (DRY) - Add sub-nav to tablet and mobile breakpoints - Move activeSubTab from useState into form reducer - Compute Options tab status from routing state instead of hardcoding - Lift TooltipProvider from per-item to list container level - Fix RU i18n: singular form for timeout label - Add 8 new tests covering derived constants and responsive sub-nav Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(providers): extract OptionsSection, perf fixes, batch dialog fix - Extract OptionsSection from RoutingSection (963 -> 432 lines) - Throttle scroll handler via requestAnimationFrame - Merge double dispatch into single SET_ACTIVE_NAV action - Derive sectionRefs from NAV_ORDER instead of manual record - Add NAV_BY_ID lookup map for O(1) tablet/mobile nav access - Add excludeTabs prop to FormTabNav, hide Options in batch dialog - Clean up setTimeout/rAF refs on unmount Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(providers): add Options tab to batch edit dialog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(providers): show proxy badge for providers without resolved vendor Move ShieldCheck proxy indicator out of the vendor-specific branch so it renders for all providers with a configured proxyUrl. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(providers): remove dead SET_ACTIVE_SUB_TAB, add status grouping comments Address Gemini Code Assist review findings: - Remove unused SET_ACTIVE_SUB_TAB action type and reducer case (superseded by SET_ACTIVE_NAV) - Add grouping comments to options tab status conditional for readability Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(providers): address CodeRabbit review findings - Fix zh-CN/zh-TW i18n terminology consistency (供应商/供應商 instead of 提供者) - Add activeTimeEnd check to options tab status indicator - Add focus-visible ring to tablet/mobile sub-nav buttons for accessibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(providers): use i18n fallback for unknown tag errors and scrollend event for scroll lock - Replace raw reason string fallback with tUI("unknownError") i18n key in tag validation callbacks (routing-section.tsx) - Add "unknownError" key to tagInput namespace in all 5 locales - Use scrollend event with { once: true } + 1000ms fallback timer instead of fixed 500ms setTimeout for scroll lock release (index.tsx) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(providers): add unit tests for OptionsSection component (25 tests) Covers rendering, conditional display by provider type (claude/codex/gemini), batch mode, dispatch actions, active time UI, disabled state, edit mode IDs, and batch-only badges. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(providers): remove stale scrollend listener on rapid tab clicks Store previous scrollend listener in a ref and remove it at the start of each scrollToSection call, preventing premature unlock when multiple smooth scrolls overlap during fast sequential tab clicks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(providers): fix 3 style-level review findings - Remove dead subSectionRefs.options property from OptionsSection (parent div in index.tsx already tracks this ref) - Use filteredNav.find() instead of NAV_BY_ID for tablet/mobile sub-row lookup so excludeTabs is respected; remove unused NAV_BY_ID - Replace non-null assertion with guarded clearTimeout in scrollend handler Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ci): resolve 3 pre-existing test failures from PR #873 All caused by commit 2e663cd which changed billing model and session API without updating tests/translations: - i18n: add missing `prices.badges.multi` key to ja/ru/zh-TW locales - tests: update cost-calculation expectations to match full-request pricing (all tokens at premium rate when context > 200K threshold) - tests: fix lease-decrement session mock to use getResolvedPricingByBillingSource instead of removed method Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(types): narrow Recharts v3 dataKey type for React Key/ReactNode compat Recharts v3 widened `dataKey` to `string | number | ((obj: any) => any) | undefined`, which is incompatible with React `Key` and `ReactNode`. Wrap with `String()` in 2 files to satisfy tsgo in CI. Pre-existing issue from main, not introduced by this branch. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: format code (feat-providers-list-3-0732ba6) * ci: retrigger CI after auto-format fix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(types): narrow Recharts ValueType for formatter call in chart.tsx Removing the explicit .map() parameter type exposed ValueType (string | number | readonly (string|number)[]) which is too wide for the formatter's (string | number) parameter. Cast item.value. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(types): restore narrowing annotation for Recharts v3 tooltip map Bring back the explicit type annotation on .map() callback but extend dataKey to include ((obj: any) => any) to match Recharts v3 DataKey<any>. This keeps value/name narrowed for formatter compatibility while making the annotation assignable from TooltipPayloadEntry. Replaces the previous approach of removing the annotation entirely, which exposed ValueType and NameType width issues one by one. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(types): use inline casts instead of annotation for Recharts v3 compat Remove the .map() parameter annotation entirely (it causes TS2345 due to contravariance — our narrower type is not assignable from TooltipPayloadEntry). Instead, let TS infer the full type and cast only at the two call sites where formatter needs narrower types: item.value as string|number, item.name as string. All other usages of item.dataKey, item.name, item.value are compatible with the wider Recharts v3 types (String() wraps, template literals, ReactNode). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: John Doe <johndoe@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Summary
This PR implements provider-aware billing resolution for multi-provider models (gpt-5.4/gpt-5.4-pro), fixes the long-context tiered pricing calculation bug, and adds comprehensive pricing source visibility throughout the system.
Problem
Related Issues:
Issues Addressed
Multi-provider model billing ambiguity - Models available through multiple providers (gpt-5.4 via OpenAI, Azure, or other aggregators) had unclear pricing attribution.
Incorrect long-context tiered pricing - The
calculateTieredCost()function used a split calculation (base for first 200K, premium for excess) when providers require all tokens at premium rate when threshold is exceeded.Lack of pricing transparency - Users couldn't see which provider's pricing was used or the source of price data in logs or settings.
Provider price override limitations - No way to pin specific provider prices to local manual prices.
Solution
Core Changes
New Pricing Resolution System (
src/lib/utils/pricing-resolution.ts)local_manual- User-defined manual pricingcloud_exact- Exact match from cloud provider pricingcloud_model_fallback- Fallback to model-level pricingpriority_fallback- Priority provider fallbacksingle_provider_top_level- Single provider top-level pricingFixed Long-Context Pricing (
src/lib/utils/cost-calculation.ts)calculateTieredCost()to charge ALL tokens at premium rate when threshold exceededcalculateTieredCostWithSeparatePrices()Pricing Source Visibility in Logs (
src/app/[locale]/dashboard/logs/)Settings Price Management Enhancement (
src/app/[locale]/settings/prices/)Model Price Repository Updates (
src/repository/model-price.ts)Supporting Changes
ResolvedPricingSourcetype and updatedModelPricetypespricing-resolution.test.ts- Unit tests for pricing resolution logiccost-calculation-long-context.test.ts- Tests for fixed tiered pricingbilling-model-source.test.ts- Integration tests for billing with model sourcemodel-prices.test.ts- Tests for model price actionsprice-list-multi-provider-ui.test.tsx- UI tests for multi-provider price listChanges Summary
src/lib/utils/pricing-resolution.ts(408 lines)Breaking Changes
None. This PR maintains backward compatibility:
Testing
Automated Tests
pricing-resolution.test.ts)cost-calculation-long-context.test.ts)billing-model-source.test.ts)model-prices.test.ts)price-list-multi-provider-ui.test.tsx)Validation Commands
Manual Testing Areas
Checklist
Description enhanced by Claude AI
Greptile Summary
This PR implements provider-aware billing resolution for multi-provider models, fixes long-context tiered pricing (charging all tokens at the premium rate when the threshold is exceeded rather than only the excess), and adds pricing source visibility in logs and settings UI across 37 files.
Key changes:
pricing-resolution.tsmodule with a 5-step fallback chain (local_manual→cloud_exact→cloud_model_fallback→official_fallback→priority_fallback→single_provider_top_level) for resolving which provider's pricing to usecalculateTieredCostandcalculateTieredCostWithSeparatePricesrenamed (prefixed with_/__) and effectively replaced by inlinemultiplyCostlogic that applies the premium rate to all tokens when the threshold is exceeded — a ~33%+ cost increase for long-context requests that were previously using split-tier billingresolvedPricingCacheMap to avoid redundant DB queries per requestIssues found:
usage-logs-table.tsxstill displays(2x >200k)/(1.5x >200k)annotations that describe the old tiered behavior. With the new all-tokens-at-premium approach, these labels are misleading and the guard condition only checksinputTokensrather than total input context (which includes cache tokens)resolveLongContextThreshold, GPT-family models (model_family === "gpt") are assigned the 272k threshold even when the price record only contains 200k pricing fields — this creates an asymmetry between when the premium activates (272k) and what rate is used (200k-tier rate), potentially under-billing requests in the 200k–272k rangeresolvePricingKeyCandidatesuseshost.includes("openai.com")which also matches Azure OpenAI endpoints (*.openai.azure.com), causing Azure-hosted GPT deployments to resolve against the"openai"pricing node rather than an Azure-specific oneConfidence Score: 2/5
virtualized-logs-table.tsxgetPricingSourceLabelbug (missing${source}interpolation) is still present and unaddressed from prior review threads. The GPT-family threshold mismatch inresolveLongContextThresholdcould cause under-billing in the 200k–272k token range for models that only have 200k pricing data. The Azure/OpenAI provider URL overlap is a potential pricing correctness issue for a provider type the PR explicitly aims to support. Combined, these issues warrant careful attention before merging.src/lib/utils/cost-calculation.ts(threshold/rate mismatch for GPT models),src/lib/utils/pricing-resolution.ts(Azure provider URL matching),src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx(broken translation key interpolation), andsrc/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx(stale billing annotations).Important Files Changed
hasRealCacheCreationBaseguard is correctly applied to the 1h cache branch. However, a threshold/rate mismatch exists for GPT-family models that only have 200k pricing fields but are now evaluated against the 272k threshold. The two renamed-but-unused helper functions (_calculateTieredCost,__calculateTieredCostWithSeparatePrices) are dead code (previously flagged).official_fallbacksource is now correctly included inResolvedPricingSourceand is produced byresolveFromPricingMap. TheresolveManualPricingfunction correctly passesresolvedPricingProviderKeytomergePriceData. Azure OpenAI endpoints may be incorrectly matched against the OpenAI pricing node due to thehost.includes("openai.com")check.inputTokens, missing cache tokens that contribute to the threshold check.getCachedPriceDataByBillingSourcewithgetResolvedPricingByBillingSource, now backed by aresolvedPricingCacheMap to avoid redundant DB queries per request. Cache key correctly includes provider identity. Logic correctly implements 2-stage resolution (primary then fallback model).resolvePricingForModelRecordsfor billing resolution. TheupdateRequestCostFromUsagefunction performs its own DB queries independent of the session cache, but this is acceptable as it runs in a standalone context. The early return whenusageMetricsis null (missingupdateMessageRequestDetails) was previously flagged.getPricingSourceLabelfunction on line 78 has a bug where thesourceparameter is not interpolated into the translation key (previously flagged). Every pricing-source label will render as a broken key string.PricingResolutionSpecialSettingandAnthropicContext1mHeaderOverrideSpecialSettingtypes. The"official_fallback"source is now correctly present in bothPricingResolutionSpecialSetting.sourceandResolvedPricingSource(previously flagged as mismatch). NewCodexServiceTierResultSpecialSettingtype added cleanly.findPricesByModelFamilyandfindProviderPricesByModelfunctions for provider-aware pricing queries. Functions are well-structured and include proper null handling.Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Request with model + provider] --> B[getResolvedPricingByBillingSource] B --> C{Check billingModelSource setting} C -->|original| D[primaryModel = originalModel] C -->|redirected| E[primaryModel = redirectedModel] D --> F[findLatestPriceByModel primary] E --> F F --> G[resolvePricingForModelRecords] G --> H{Manual source?} H -->|yes| I[resolveManualPricing → local_manual] H -->|no| J{Exact provider key match?} J -->|yes| K[resolveFromPricingMap exact → cloud_exact / cloud_model_fallback] J -->|no| L{Official provider key match?} L -->|yes| M[resolveFromPricingMap official → official_fallback] L -->|no| N{Detailed score fallback?} N -->|yes| O[resolveDetailedFallback → priority_fallback] N -->|no| P[resolveTopLevel → single_provider_top_level / cloud_model_fallback] I --> Q[ResolvedPricing priceData + source] K --> Q M --> Q O --> Q P --> Q Q --> R{longContextThresholdExceeded?} R -->|yes| S[ALL tokens × premium rate] R -->|no| T[tokens × base rate] S --> U[Final cost] T --> UComments Outside Diff (1)
src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx, line 435-449 (link)Stale tooltip annotation — describes old tiered behavior
The
(2x >200k)/(1.5x >200k)tooltip annotations imply that only tokens above the threshold are billed at the premium rate. However, this PR changedcalculateRequestCostandcalculateRequestCostBreakdownto bill all tokens at the premium rate the moment the threshold is exceeded (not just the excess).For example, a 300K-token request is now billed at 2× on all 300K tokens, not
200K×1x + 100K×2x. The annotation says ">200k" which is now misleading for users trying to reconcile costs from the tooltip.Additionally, the guard condition
(log.inputTokens ?? 0) > 200000only checksinputTokens, while the actual long-context premium is triggered by total input context (including cache creation and cache read tokens). A request with 150K prompt tokens + 100K cache tokens would exceed the 200K threshold and be billed at premium rates, yet this annotation would not appear.Consider updating the annotation to reflect the new behavior:
and similarly for the output annotation:
Prompt To Fix With AI
Last reviewed commit: cffef3b