Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ oh-myusage 把官方订阅额度、模型使用窗口、第三方中转余额、

[下载最新版本](https://github.com/Four-JJJJ/oh-myusage/releases/latest) · [安装说明](docs/DOWNLOAD.md) · [支持的服务](docs/PROVIDERS.md) · [扩展指南](docs/EXTENDING.md) · [发布清单](docs/RELEASE_CHECKLIST.md) · [English](docs/README.en.md)

## V2.4.4 更新

V2.4.4 为 Xiaomi MIMO 新增按量付费余额查询,在 Token Plan 不可用时自动切换,无需另行配置。

| 方向 | 改进 |
| --- | --- |
| MIMO 按量付费 | Token Plan 无订阅、套餐为空或接口返回 404 时,自动查询 `/api/v1/userProfile` 与 `/api/v1/balance`,显示 CNY 余额、已用额度和总额度 |

## V2.4.3 更新

V2.4.3 修复 Xiaomi MIMO 凭证读取与设置窗口显示问题,并优化 MIMO Token Plan 的鉴权诊断。
Expand Down
80 changes: 80 additions & 0 deletions Sources/OhMyUsage/Providers/RelayBalanceChannelExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ struct RelayBalanceChannelExecutor {
let credentialResolver: RelayCredentialResolver
let recoveryPolicy: RelayRecoveryPolicy
let httpClient: RelayHTTPClient
let registry: RelayAdapterRegistry

func fetch(
baseURL: URL,
Expand Down Expand Up @@ -420,6 +421,20 @@ struct RelayBalanceChannelExecutor {

return extracted
} catch let error as ProviderError {
if shouldFallbackToXiaomimimoPayAsYouGo(error) {
do {
return try await attemptXiaomimimoPayAsYouGoFetch(
candidates: [candidate],
baseURL: baseURL,
relayConfig: relayConfig
)
} catch let fallbackError as ProviderError {
// Keep probing the next credential if the pay-as-you-go
// endpoints reject this one too. The subscription error
// remains the most useful failure when all candidates fail.
lastError = fallbackError
}
}
switch error {
case .invalidResponse:
lastError = error
Expand All @@ -435,4 +450,69 @@ struct RelayBalanceChannelExecutor {

throw lastError
}

private func shouldFallbackToXiaomimimoPayAsYouGo(_ error: ProviderError) -> Bool {
guard case .invalidResponse(let detail) = error else { return false }
return detail.localizedCaseInsensitiveContains("no active subscription")
|| detail.localizedCaseInsensitiveContains("token plan usage payload missing")
|| detail.localizedCaseInsensitiveContains("http 404")
}

private func attemptXiaomimimoPayAsYouGoFetch(
candidates: [RelayCredentialCandidate],
baseURL: URL,
relayConfig: RelayProviderConfig
) async throws -> AccountChannelResult {
let manifest = registry.manifest(id: "xiaomimimo")
?? RelayAdapterRegistry.genericManifest
let requests = RelayRequestResolver.resolveBalanceRequests(
manifest: manifest,
relayConfig: relayConfig
)
var lastError: ProviderError = .invalidResponse("xiaomimimo pay-as-you-go balance unavailable")

for candidate in candidates {
for request in requests {
do {
let root = try await httpClient.requestJSON(
url: RelayRequestResolver.relayURL(baseURL: baseURL, rawPath: request.path),
headers: candidate.headers.merging(request.staticHeaders, uniquingKeysWith: { _, rhs in rhs }),
method: request.method,
bodyJSON: request.bodyJSON
)
var extracted = try await RelayResponseInterpreter.extractAccountValues(
root: root,
baseURL: baseURL,
request: request,
manifest: manifest,
headers: candidate.headers,
candidate: candidate,
requestJSON: httpClient.requestJSON
)
if let persisted = candidate.persistedCredential {
_ = credentialResolver.persistTokenCandidate(persisted, auth: relayConfig.balanceAuth)
}
extracted.rawMeta["billingMode"] = "payAsYouGo"
extracted.note = extracted.note.replacingOccurrences(
of: "Account remaining",
with: "Pay-as-you-go balance"
)
extracted.rawMeta["savedCredentialSource"] = candidate.source
return extracted
} catch let error as ProviderError {
switch error {
case .invalidResponse:
lastError = error
continue
case .unauthorized, .unauthorizedDetail:
lastError = error
break
default:
throw error
}
}
}
}
throw lastError
}
}
3 changes: 2 additions & 1 deletion Sources/OhMyUsage/Providers/RelayProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ final class RelayProvider: UsageProvider, @unchecked Sendable {
descriptor: descriptor,
credentialResolver: credentialResolver,
recoveryPolicy: recoveryPolicy,
httpClient: httpClient
httpClient: httpClient,
registry: registry
)
}

Expand Down
25 changes: 21 additions & 4 deletions Sources/OhMyUsage/Providers/RelayResponseInterpreter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ struct AccountChannelResult {
let accountLabel: String?
let planType: String?
let quotaWindows: [UsageQuotaWindow]
let note: String
var note: String
var rawMeta: [String: String]
var recoveryMeta: [String: String] = [:]
}
Expand Down Expand Up @@ -340,9 +340,9 @@ enum RelayResponseInterpreter {
/// XiaomiMIMO's platform returns a business envelope with `code: 0` on success.
/// When the browser Cookie expires it typically still answers HTTP 200 with a
/// non-zero `code` and no `data`, which the usage-shape check below would
/// otherwise misread as "missing usage item". Surface that as an auth error so
/// the executor can fall through to browser recovery instead of a confusing
/// "invalid response".
/// otherwise misread as "missing usage item". Surface auth failures as auth
/// errors, while keeping explicit no-subscription responses distinguishable
/// so the executor can try the pay-as-you-go balance endpoints.
private static func xiaomimimoTokenPlanBusinessError(detailRoot: Any, usageRoot: Any) -> ProviderError? {
for root in [detailRoot, usageRoot] {
guard let dict = root as? [String: Any] else { continue }
Expand All @@ -353,13 +353,30 @@ enum RelayResponseInterpreter {
let suffix = message?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
? message!.trimmingCharacters(in: .whitespacesAndNewlines)
: "code \(code)"
if let message,
isXiaomimimoNoSubscriptionMessage(message) {
return .invalidResponse(
"xiaomimimo token plan has no active subscription; \(message)"
)
}
return .unauthorizedDetail(
"XiaomiMIMO Token Plan login missing or expired (\(suffix)). Log in again in platform.xiaomimimo.com and test the connection again."
)
}
return nil
}

private static func isXiaomimimoNoSubscriptionMessage(_ message: String) -> Bool {
let normalized = message.lowercased()
return normalized.contains("not subscribed")
|| normalized.contains("no subscription")
|| normalized.contains("subscription not found")
|| normalized.contains("no active plan")
|| normalized.contains("未订阅")
|| normalized.contains("无套餐")
|| normalized.contains("套餐不存在")
}

private static func xiaomimimoBusinessCode(_ dict: [String: Any]) -> Int? {
if let number = dict["code"] as? NSNumber {
return number.intValue
Expand Down
99 changes: 99 additions & 0 deletions Tests/OhMyUsageTests/RelayProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1285,6 +1285,83 @@ final class RelayProviderTests: XCTestCase {
XCTAssertEqual(snapshot.quotaWindows.first?.remainingPercent ?? -1, expectedRemainingPercent, accuracy: 0.000001)
}

func testXiaomimimoTokenPlanFallsBackToPayAsYouGoBalanceWhenNoSubscription() async throws {
final class RequestPathRecorder: @unchecked Sendable {
private let lock = NSLock()
private var paths: [String] = []

func append(_ path: String) {
lock.lock()
paths.append(path)
lock.unlock()
}

func snapshot() -> [String] {
lock.lock()
defer { lock.unlock() }
return paths
}
}

let requestedPaths = RequestPathRecorder()
RelayMockURLProtocol.requestHandler = { request in
requestedPaths.append(request.url?.path ?? "")
XCTAssertEqual(request.value(forHTTPHeaderField: "Cookie"), "api-platform_serviceToken=abc123; userId=10001")
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
switch request.url?.path {
case "/api/v1/tokenPlan/detail":
return (response, Data(#"{"code":0,"data":null}"#.utf8))
case "/api/v1/tokenPlan/usage":
return (response, Data(#"{"code":0,"data":{"usage":{"items":[]}}}"#.utf8))
case "/api/v1/userProfile":
return (response, Data(#"{"data":{"nickname":"mimo-payg"}}"#.utf8))
case "/api/v1/balance":
return (response, Data(#"{"data":{"availableBalance":"12.34","monthlyUsage":"2.00","totalLimit":"50.00"}}"#.utf8))
default:
XCTFail("Unexpected path \(request.url?.path ?? "nil")")
return (response, Data(#"{}"#.utf8))
}
}
defer { RelayMockURLProtocol.requestHandler = nil }

let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [RelayMockURLProtocol.self]
let session = URLSession(configuration: config)
let service = "OhMyUsageTests-\(UUID().uuidString)"
let keychain = makeTestKeychain()
XCTAssertTrue(keychain.saveToken(
"api-platform_serviceToken=abc123; userId=10001",
service: service,
account: "platform.xiaomimimo.com/session-cookie"
))

let provider = RelayProvider(
descriptor: makeRelayDescriptor(
service: service,
adapterID: "xiaomimimo-token-plan",
baseURL: "https://platform.xiaomimimo.com",
balanceAccount: "platform.xiaomimimo.com/session-cookie"
),
session: session,
keychain: keychain,
browserCredentialService: BrowserCredentialService()
)

let snapshot = try await provider.fetch()
XCTAssertEqual(snapshot.remaining ?? -1, 12.34, accuracy: 0.001)
XCTAssertEqual(snapshot.used ?? -1, 2.00, accuracy: 0.001)
XCTAssertEqual(snapshot.limit ?? -1, 50.00, accuracy: 0.001)
XCTAssertEqual(snapshot.unit, "CNY")
XCTAssertEqual(snapshot.rawMeta["account.billingMode"], "payAsYouGo")
XCTAssertEqual(snapshot.rawMeta["account.endpointPath"], "/api/v1/balance")
XCTAssertEqual(requestedPaths.snapshot(), [
"/api/v1/tokenPlan/detail",
"/api/v1/tokenPlan/usage",
"/api/v1/userProfile",
"/api/v1/balance"
])
}

func testXiaomimimoTokenPlanAcceptsBareServiceTokenValue() async throws {
RelayMockURLProtocol.requestHandler = { request in
XCTAssertEqual(request.value(forHTTPHeaderField: "Cookie"), "api-platform_serviceToken=abc123")
Expand Down Expand Up @@ -1418,6 +1495,28 @@ final class RelayProviderTests: XCTestCase {
}
}

func testXiaomimimoTokenPlanBusinessNoSubscriptionIsFallbackError() throws {
let detailRoot: Any = ["code": 40001, "message": "not subscribed", "data": NSNull()]
let usageRoot: Any = ["code": 40001, "message": "not subscribed", "data": NSNull()]
let candidate = RelayCredentialCandidate(
headers: ["Cookie": "api-platform_serviceToken=abc123"],
source: "savedCookieHeader",
persistedCredential: "api-platform_serviceToken=abc123"
)
XCTAssertThrowsError(
try RelayResponseInterpreter.extractXiaomimimoTokenPlanValues(
detailRoot: detailRoot,
usageRoot: usageRoot,
candidate: candidate
)
) { error in
guard case .invalidResponse(let detail) = error as? ProviderError else {
return XCTFail("Expected invalidResponse, got \(error)")
}
XCTAssertTrue(detail.localizedCaseInsensitiveContains("no active subscription"))
}
}

func testXiaomimimoTokenPlanAcceptsPayloadWrappedUsage() throws {
let detailRoot: Any = ["code": 0, "data": ["planCode": "standard", "planName": "Standard"]]
let usageRoot: Any = ["code": 0, "data": ["payload": ["usage": ["percent": 10.0, "items": [
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.4.3
2.4.4
Loading