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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
### Highlights
- Claude OAuth/keychain flows were reworked across a series of follow-up PRs to reduce prompt storms, stabilize background behavior, and make failure modes deterministic (#245, #305, #308, #309). Thanks @manikv12!
- Claude: harden Claude Code PTY capture for `/usage` and `/status` (prompt automation, safer command palette confirmation, partial UTF-8 handling, and parsing guards against status-bar context meters) (#320).
- Provider correctness fixes landed for Cursor plan parsing and MiniMax region routing (#240, #234). Thanks @robinebers
- Provider correctness fixes landed for Cursor plan parsing and MiniMax region routing (#240, #234, #344). Thanks @robinebers
and @theglove44!
- Menu bar animation behavior was hardened in merged mode and fallback mode (#283, #291). Thanks @vignesh07 and @Ilakiancs!
- CI/tooling reliability improved via pinned lint tools, deterministic macOS test execution, and PTY timing test stabilization plus Node 24-ready GitHub Actions upgrades (#292, #312, #290).
Expand All @@ -20,6 +20,8 @@
### Provider & Usage Fixes
- Cursor: compute usage against `plan.limit` rather than `breakdown.total` to avoid incorrect limit interpretation (#240). Thanks @robinebers!
- MiniMax: correct API region URL selection to route requests to the expected regional endpoint (#234). Thanks @theglove44!
- MiniMax: always show the API region picker and retry the China endpoint when the global host rejects the token to
avoid upgrade regressions for users without a persisted region (#344). Thanks @apoorvdarshan!
- Amp: detect login redirects during usage fetch and fail fast when the session is invalid (#339). Thanks @JosephDoUrden!
- Resource loading: fix app bundle lookup path to avoid "could not load resource bundle" startup failures (#223). Thanks @validatedev!
- OpenAI Web dashboard: keep WebView instances cached for reuse to reduce repeated network fetch overhead; tests were updated to avoid network-dependent flakes (#284). Thanks @vignesh07!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation {
subtitle: "Choose the MiniMax host (global .io or China mainland .com).",
binding: regionBinding,
options: regionOptions,
isVisible: { authMode().allowsCookies },
isVisible: nil,
onChange: nil),
]
}
Expand Down
29 changes: 28 additions & 1 deletion Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,36 @@ public struct MiniMaxUsageFetcher: Sendable {
throw MiniMaxUsageError.invalidCredentials
}

// Historically, MiniMax API token fetching used a China endpoint by default in some configurations. If the
// user has no persisted region and we default to `.global`, retry the China endpoint when the global host
// rejects the token so upgrades don't regress existing setups.
if region != .global {
return try await self.fetchUsageOnce(apiToken: cleaned, region: region, now: now)
}

do {
return try await self.fetchUsageOnce(apiToken: cleaned, region: .global, now: now)
} catch let error as MiniMaxUsageError {
guard case .invalidCredentials = error else { throw error }
Self.log.debug("MiniMax API token rejected for global host, retrying China mainland host")
do {
return try await self.fetchUsageOnce(apiToken: cleaned, region: .chinaMainland, now: now)
} catch {
// Preserve the original invalid-credentials error so the fetch pipeline can fall back to web.
Self.log.debug("MiniMax China mainland retry failed, preserving global invalidCredentials")
throw MiniMaxUsageError.invalidCredentials
}
}
}

private static func fetchUsageOnce(
apiToken: String,
region: MiniMaxAPIRegion,
now: Date) async throws -> MiniMaxUsageSnapshot
{
var request = URLRequest(url: region.apiRemainsURL)
request.httpMethod = "GET"
request.setValue("Bearer \(cleaned)", forHTTPHeaderField: "Authorization")
request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "accept")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("CodexBar", forHTTPHeaderField: "MM-API-Source")
Expand Down
177 changes: 177 additions & 0 deletions Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import CodexBarCore
import Foundation
import Testing

@Suite(.serialized)
struct MiniMaxAPITokenFetchTests {
@Test
func retriesChinaHostWhenGlobalRejectsToken() async throws {
let registered = URLProtocol.registerClass(MiniMaxAPITokenStubURLProtocol.self)
defer {
if registered {
URLProtocol.unregisterClass(MiniMaxAPITokenStubURLProtocol.self)
}
MiniMaxAPITokenStubURLProtocol.handler = nil
MiniMaxAPITokenStubURLProtocol.requests = []
}

MiniMaxAPITokenStubURLProtocol.handler = { request in
guard let url = request.url else { throw URLError(.badURL) }
let host = url.host ?? ""
if host == "api.minimax.io" {
return Self.makeResponse(url: url, body: "{}", statusCode: 401)
}
if host == "api.minimaxi.com" {
let start = 1_700_000_000_000
let end = start + 5 * 60 * 60 * 1000
let body = """
{
"base_resp": { "status_code": 0 },
"current_subscribe_title": "Max",
"model_remains": [
{
"current_interval_total_count": 1000,
"current_interval_usage_count": 250,
"start_time": \(start),
"end_time": \(end),
"remains_time": 240000
}
]
}
"""
return Self.makeResponse(url: url, body: body)
}
return Self.makeResponse(url: url, body: "{}", statusCode: 404)
}

let now = Date(timeIntervalSince1970: 1_700_000_000)
let snapshot = try await MiniMaxUsageFetcher.fetchUsage(apiToken: "sk-cp-test", region: .global, now: now)

#expect(snapshot.planName == "Max")
#expect(MiniMaxAPITokenStubURLProtocol.requests.count == 2)
#expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimax.io")
#expect(MiniMaxAPITokenStubURLProtocol.requests.last?.url?.host == "api.minimaxi.com")
}

@Test
func preservesInvalidCredentialsWhenChinaRetryFailsTransport() async throws {
let registered = URLProtocol.registerClass(MiniMaxAPITokenStubURLProtocol.self)
defer {
if registered {
URLProtocol.unregisterClass(MiniMaxAPITokenStubURLProtocol.self)
}
MiniMaxAPITokenStubURLProtocol.handler = nil
MiniMaxAPITokenStubURLProtocol.requests = []
}

MiniMaxAPITokenStubURLProtocol.handler = { request in
guard let url = request.url else { throw URLError(.badURL) }
let host = url.host ?? ""
if host == "api.minimax.io" {
return Self.makeResponse(url: url, body: "{}", statusCode: 401)
}
if host == "api.minimaxi.com" {
throw URLError(.cannotFindHost)
}
return Self.makeResponse(url: url, body: "{}", statusCode: 404)
}

let now = Date(timeIntervalSince1970: 1_700_000_000)
await #expect(throws: MiniMaxUsageError.invalidCredentials) {
_ = try await MiniMaxUsageFetcher.fetchUsage(apiToken: "sk-cp-test", region: .global, now: now)
}

#expect(MiniMaxAPITokenStubURLProtocol.requests.count == 2)
#expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimax.io")
#expect(MiniMaxAPITokenStubURLProtocol.requests.last?.url?.host == "api.minimaxi.com")
}

@Test
func doesNotRetryWhenRegionIsChinaMainland() async throws {
let registered = URLProtocol.registerClass(MiniMaxAPITokenStubURLProtocol.self)
defer {
if registered {
URLProtocol.unregisterClass(MiniMaxAPITokenStubURLProtocol.self)
}
MiniMaxAPITokenStubURLProtocol.handler = nil
MiniMaxAPITokenStubURLProtocol.requests = []
}

MiniMaxAPITokenStubURLProtocol.handler = { request in
guard let url = request.url else { throw URLError(.badURL) }
let host = url.host ?? ""
if host == "api.minimaxi.com" {
let start = 1_700_000_000_000
let end = start + 5 * 60 * 60 * 1000
let body = """
{
"base_resp": { "status_code": 0 },
"current_subscribe_title": "Max",
"model_remains": [
{
"current_interval_total_count": 1000,
"current_interval_usage_count": 250,
"start_time": \(start),
"end_time": \(end),
"remains_time": 240000
}
]
}
"""
return Self.makeResponse(url: url, body: body)
}
return Self.makeResponse(url: url, body: "{}", statusCode: 401)
}

let now = Date(timeIntervalSince1970: 1_700_000_000)
_ = try await MiniMaxUsageFetcher.fetchUsage(apiToken: "sk-cp-test", region: .chinaMainland, now: now)

#expect(MiniMaxAPITokenStubURLProtocol.requests.count == 1)
#expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimaxi.com")
}

private static func makeResponse(
url: URL,
body: String,
statusCode: Int = 200) -> (HTTPURLResponse, Data)
{
let response = HTTPURLResponse(
url: url,
statusCode: statusCode,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"])!
return (response, Data(body.utf8))
}
}

final class MiniMaxAPITokenStubURLProtocol: URLProtocol {
nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
nonisolated(unsafe) static var requests: [URLRequest] = []

override static func canInit(with request: URLRequest) -> Bool {
guard let host = request.url?.host else { return false }
return host == "api.minimax.io" || host == "api.minimaxi.com"
}

override static func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}

override func startLoading() {
Self.requests.append(self.request)
guard let handler = Self.handler else {
self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
return
}
do {
let (response, data) = try handler(self.request)
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
self.client?.urlProtocol(self, didLoad: data)
self.client?.urlProtocolDidFinishLoading(self)
} catch {
self.client?.urlProtocol(self, didFailWithError: error)
}
}

override func stopLoading() {}
}