cc-haha/src/utils/contextBudget.ts
程序员阿江(Relakkes) eede5568d2 fix: preserve chat flow after unsupported media
Unsupported image rejections from text-only compatible providers should not
poison the session, and low-trust multimodal usage spikes should not make
context indicators report a full window.

Constraint: Third-party Anthropic-compatible providers may report encoded media bytes as usage tokens.
Rejected: Trust all provider usage uniformly | third-party media responses can pin context to 100% incorrectly.
Confidence: high
Scope-risk: moderate
Directive: Do not remove the media-aware fallback without checking text-only provider recovery and desktop context indicators.
Tested: bun run check:server
Tested: focused media/context regression suite
2026-05-31 17:42:30 +08:00

179 lines
4.5 KiB
TypeScript

import { getAPIProvider } from './model/providers.js'
export type ProviderUsageTrust = 'high' | 'low'
export type ContextUsageLike = {
input_tokens: number
output_tokens?: number
cache_creation_input_tokens: number
cache_read_input_tokens: number
}
export type ContextBudgetSource = 'estimate' | 'provider_usage'
export type ContextBudget = {
usedTokens: number
source: ContextBudgetSource
providerUsageTokens: number | null
estimatedTokens: number
ignoredUsageReason?: 'low_trust_media_usage'
}
export function getProviderUsageTrust(args: {
isFirstPartyAnthropic: boolean
}): ProviderUsageTrust {
return getAPIProvider() === 'firstParty' && args.isFirstPartyAnthropic
? 'high'
: 'low'
}
function isMediaAttachment(attachment: unknown): boolean {
if (typeof attachment !== 'object' || attachment === null) return false
const candidate = attachment as {
type?: unknown
content?: { type?: unknown } | null
}
if (candidate.type !== 'file') return false
return (
candidate.content?.type === 'image' || candidate.content?.type === 'pdf'
)
}
export function hasMediaInput(
messages: readonly {
type: string
message?: { content?: unknown }
attachment?: unknown
}[],
): boolean {
return messages.some(message => {
if (message.type === 'attachment') {
return isMediaAttachment(message.attachment)
}
if (message.type !== 'user' && message.type !== 'assistant') return false
if (!Array.isArray(message.message?.content)) return false
return message.message.content.some(
block =>
typeof block === 'object' &&
block !== null &&
'type' in block &&
(block.type === 'image' || block.type === 'document'),
)
})
}
type ShouldIgnoreLowTrustUsageArgs = {
usageTrust: ProviderUsageTrust
hasMediaInput: boolean
usageTokens: number
estimatedTokens: number
contextWindow: number
}
type CalculateContextBudgetArgs = {
estimatedTokens: number
contextWindow: number
currentUsage: ContextUsageLike | null
usageTrust: ProviderUsageTrust
hasMediaInput: boolean
}
export function getUsageTokenTotal(
usage: ContextUsageLike,
options?: { includeOutput?: boolean },
): number {
const includeOutput = options?.includeOutput ?? true
return (
usage.input_tokens +
usage.cache_creation_input_tokens +
usage.cache_read_input_tokens +
(includeOutput ? usage.output_tokens ?? 0 : 0)
)
}
export function shouldIgnoreLowTrustUsage(
args: ShouldIgnoreLowTrustUsageArgs,
): boolean {
if (args.usageTrust === 'high') return false
if (!args.hasMediaInput) return false
if (args.estimatedTokens <= 0) return false
if (args.usageTokens < args.contextWindow) return false
if (args.estimatedTokens >= args.contextWindow) return false
const suspiciousFloor = Math.max(
args.estimatedTokens * 4,
args.estimatedTokens + 50_000,
)
return args.usageTokens > suspiciousFloor
}
export function calculateContextBudget(
args: CalculateContextBudgetArgs,
): ContextBudget {
const estimatedTokens = Math.min(args.estimatedTokens, args.contextWindow)
if (!args.currentUsage) {
return {
usedTokens: estimatedTokens,
source: 'estimate',
providerUsageTokens: null,
estimatedTokens: args.estimatedTokens,
}
}
const providerUsageTokens = getUsageTokenTotal(args.currentUsage)
if (
shouldIgnoreLowTrustUsage({
usageTrust: args.usageTrust,
hasMediaInput: args.hasMediaInput,
usageTokens: providerUsageTokens,
estimatedTokens: args.estimatedTokens,
contextWindow: args.contextWindow,
})
) {
return {
usedTokens: estimatedTokens,
source: 'estimate',
providerUsageTokens,
estimatedTokens: args.estimatedTokens,
ignoredUsageReason: 'low_trust_media_usage',
}
}
const usedTokens = Math.min(
Math.max(args.estimatedTokens, providerUsageTokens),
args.contextWindow,
)
return {
usedTokens,
source:
providerUsageTokens >= args.estimatedTokens ? 'provider_usage' : 'estimate',
providerUsageTokens,
estimatedTokens: args.estimatedTokens,
}
}
export function calculateContextPercentagesFromTokens(
usedTokens: number | null,
contextWindowSize: number,
): { used: number | null; remaining: number | null } {
if (usedTokens === null || contextWindowSize <= 0) {
return { used: null, remaining: null }
}
const usedPercentage = Math.round((usedTokens / contextWindowSize) * 100)
const used = Math.min(100, Math.max(0, usedPercentage))
return {
used,
remaining: 100 - used,
}
}