149 lines
6.6 KiB
Swift
149 lines
6.6 KiB
Swift
// Copyright (C) 2026 ysicing
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import Foundation
|
|
import CoreFoundation
|
|
|
|
/// 公开 API 的基础美元单价,单位为每百万 Token;不是订阅账单。
|
|
public struct AIModelPrice: Codable, Equatable, Sendable {
|
|
public let input: Double
|
|
public let output: Double
|
|
public let cacheRead: Double?
|
|
public let cacheWrite: Double?
|
|
|
|
public init(input: Double, output: Double, cacheRead: Double? = nil, cacheWrite: Double? = nil) {
|
|
self.input = input; self.output = output
|
|
self.cacheRead = cacheRead; self.cacheWrite = cacheWrite
|
|
}
|
|
|
|
var isValid: Bool {
|
|
[input, output].allSatisfy { $0.isFinite && $0 >= 0 }
|
|
&& [cacheRead, cacheWrite].compactMap { $0 }.allSatisfy { $0.isFinite && $0 >= 0 }
|
|
}
|
|
}
|
|
|
|
public struct AICostEstimate: Equatable, Sendable {
|
|
public let usd: Double?
|
|
public let unpricedModels: [String]
|
|
|
|
public init(usd: Double?, unpricedModels: [String]) {
|
|
self.usd = usd; self.unpricedModels = unpricedModels
|
|
}
|
|
}
|
|
|
|
public struct AIPriceCatalog: Codable, Equatable, Sendable {
|
|
public let prices: [String: AIModelPrice]
|
|
public let fetchedAt: Date
|
|
|
|
public init(prices: [String: AIModelPrice], fetchedAt: Date) {
|
|
self.prices = prices; self.fetchedAt = fetchedAt
|
|
}
|
|
|
|
/// 本地输入包含缓存读取和创建;输出已包含推理 Token,不能再次叠加。
|
|
/// 仅采用目录中的基础费率,不推测逐请求上下文阶梯、缓存时长或订阅折扣。
|
|
public func estimate(_ rows: [ModelTokenUsage]) -> AICostEstimate {
|
|
var total = 0.0
|
|
var priced = rows.isEmpty
|
|
var unknown = Set<String>()
|
|
for row in rows {
|
|
guard row.input >= 0, row.cached >= 0, row.cacheCreated >= 0, row.output >= 0,
|
|
row.cached <= row.input, row.cacheCreated <= row.input - row.cached else {
|
|
unknown.insert(row.model); continue
|
|
}
|
|
if row.input == 0 && row.output == 0 { priced = true; continue }
|
|
var model = row.model
|
|
// 只去掉明确的厂商命名空间;不剥离日期、版本或任意后缀。
|
|
if model.hasPrefix("openai/"), !model.dropFirst(7).hasPrefix("claude-") {
|
|
model = String(model.dropFirst(7))
|
|
} else if model.hasPrefix("anthropic/"), model.dropFirst(10).hasPrefix("claude-") {
|
|
model = String(model.dropFirst(10))
|
|
}
|
|
guard let price = prices[row.model] ?? prices[model], price.isValid else {
|
|
unknown.insert(row.model); continue
|
|
}
|
|
let categories: [(Int, Double?)] = [
|
|
(row.input - row.cached - row.cacheCreated, price.input),
|
|
(row.cached, price.cacheRead), (row.cacheCreated, price.cacheWrite), (row.output, price.output)
|
|
]
|
|
var amount = 0.0
|
|
var complete = true
|
|
for (tokens, rate) in categories where tokens > 0 {
|
|
guard let rate else { complete = false; break }
|
|
// 先缩放,避免合法大 Token 数与单价的中间乘积溢出。
|
|
amount += (Double(tokens) / 1_000_000) * rate
|
|
}
|
|
guard complete, amount.isFinite, (total + amount).isFinite else {
|
|
unknown.insert(row.model); continue
|
|
}
|
|
total += amount; priced = true
|
|
}
|
|
return AICostEstimate(usd: priced ? total : nil, unpricedModels: unknown.sorted())
|
|
}
|
|
|
|
/// models.dev 的 cost 为美元/百万 Token。忽略第三方转售商和缺失必需单价的模型。
|
|
static func parse(_ data: Data, fetchedAt: Date) throws -> Self {
|
|
guard data.count <= AICostReferenceStore.catalogLimit,
|
|
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw AICostReferenceError.invalidResponse
|
|
}
|
|
var prices: [String: AIModelPrice] = [:]
|
|
for provider in ["openai", "anthropic"] {
|
|
guard let entry = root[provider] as? [String: Any],
|
|
let models = entry["models"] as? [String: Any] else { continue }
|
|
for (model, value) in models {
|
|
try Task.checkCancellation()
|
|
guard !model.isEmpty, model.utf8.count <= 256, !model.contains("/"),
|
|
let object = value as? [String: Any],
|
|
let cost = object["cost"] as? [String: Any],
|
|
let input = AICostReferenceNumber.nonnegative(cost["input"]),
|
|
let output = AICostReferenceNumber.nonnegative(cost["output"]) else { continue }
|
|
// 非法可选价格作为缺失处理,只有实际使用该类别时才无法估算。
|
|
prices[model] = AIModelPrice(input: input, output: output,
|
|
cacheRead: AICostReferenceNumber.nonnegative(cost["cache_read"]),
|
|
cacheWrite: AICostReferenceNumber.nonnegative(cost["cache_write"]))
|
|
}
|
|
}
|
|
guard !prices.isEmpty else { throw AICostReferenceError.invalidResponse }
|
|
return Self(prices: prices, fetchedAt: fetchedAt)
|
|
}
|
|
}
|
|
|
|
public struct AIExchangeRate: Codable, Equatable, Sendable {
|
|
public let cnyPerUSD: Double
|
|
public let fetchedAt: Date
|
|
|
|
public init(cnyPerUSD: Double, fetchedAt: Date) {
|
|
self.cnyPerUSD = cnyPerUSD; self.fetchedAt = fetchedAt
|
|
}
|
|
|
|
static func parse(_ data: Data, fetchedAt: Date) throws -> Self {
|
|
guard data.count <= AICostReferenceStore.exchangeLimit,
|
|
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
root["result"] as? String == "success", root["base_code"] as? String == "USD",
|
|
let rates = root["rates"] as? [String: Any],
|
|
let rate = AICostReferenceNumber.nonnegative(rates["CNY"]), rate > 0 else {
|
|
throw AICostReferenceError.invalidResponse
|
|
}
|
|
return Self(cnyPerUSD: rate, fetchedAt: fetchedAt)
|
|
}
|
|
}
|
|
|
|
public struct AICostReferenceSnapshot: Equatable, Sendable {
|
|
public let catalog: AIPriceCatalog?
|
|
public let exchangeRate: AIExchangeRate?
|
|
|
|
public init(catalog: AIPriceCatalog?, exchangeRate: AIExchangeRate?) {
|
|
self.catalog = catalog; self.exchangeRate = exchangeRate
|
|
}
|
|
}
|
|
|
|
enum AICostReferenceError: Error, Equatable { case invalidResponse, responseTooLarge }
|
|
|
|
private enum AICostReferenceNumber {
|
|
static func nonnegative(_ value: Any?) -> Double? {
|
|
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(),
|
|
number.doubleValue.isFinite, number.doubleValue >= 0 else { return nil }
|
|
return number.doubleValue
|
|
}
|
|
}
|