Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
25 changes: 25 additions & 0 deletions Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,23 @@ public enum ZaiAPIRegion: String, CaseIterable, Sendable {
URL(string: "https://bigmodel.cn/coding-plan/team/usage-stats")!
}
}

/// BigModel CN pay-as-you-go account balance. The endpoint lives on the
/// `www.bigmodel.cn` console host (not the `open.` API host) and accepts both
/// `Bearer <key>` and raw-key Authorization (verified 2026-08). z.ai global has
/// no documented equivalent.
public var balanceURL: URL? {
switch self {
case .global: nil
case .bigmodelCN: URL(string: "https://www.bigmodel.cn/api/biz/account/query-customer-account-report")!
}
}
}

public enum ZaiEndpointRouter {
private static let quotaPath = "api/monitor/usage/quota/limit"
private static let modelUsagePath = "api/monitor/usage/model-usage"
public static let balancePath = "api/biz/account/query-customer-account-report"

public static func resolveQuotaURL(
region: ZaiAPIRegion,
Expand Down Expand Up @@ -83,6 +95,19 @@ public enum ZaiEndpointRouter {
return region.modelUsageURL
}

/// Balance is a BigModel CN-only feature; returns nil for the global region so the
/// plugin skips the extra request entirely.
public static func resolveBalanceURL(
region: ZaiAPIRegion,
environment: [String: String] = ProcessInfo.processInfo.environment) -> URL?
{
if let override = ZaiSettingsReader.balanceURL(environment: environment) {
return override
}
guard region == .bigmodelCN else { return nil }
return region.balanceURL
}

public static func resolveDashboardURL(
region: ZaiAPIRegion,
environment: [String: String] = ProcessInfo.processInfo.environment,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ public enum ZaiProviderDescriptor {
region: region,
environment: context.env).absoluteString,
]
if let balanceURL = ZaiEndpointRouter.resolveBalanceURL(
region: region,
environment: context.env)
{
plainValues["Z_AI_BALANCE_ENDPOINT"] = balanceURL.absoluteString
}
if let team = settings?.teamContext ?? ZaiBigModelTeamContext(environment: context.env) {
plainValues["Z_AI_ORGANIZATION"] = team.organizationID
plainValues["Z_AI_PROJECT"] = team.projectID
Expand Down
13 changes: 13 additions & 0 deletions Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public struct ZaiSettingsReader: Sendable {
]
public static let apiHostKey = "Z_AI_API_HOST"
public static let quotaURLKey = "Z_AI_QUOTA_URL"
public static let balanceURLKey = "Z_AI_BALANCE_URL"
public static let bigModelOrganizationKey = "Z_AI_BIGMODEL_ORGANIZATION"
public static let bigModelProjectKey = "Z_AI_BIGMODEL_PROJECT"

Expand Down Expand Up @@ -64,11 +65,23 @@ public struct ZaiSettingsReader: Sendable {
return ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw)
}

public static func balanceURL(
environment: [String: String] = ProcessInfo.processInfo.environment) -> URL?
{
guard let raw = self.cleaned(environment[balanceURLKey]) else { return nil }
return ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw)
}

public static func validateEndpointOverrides(
environment: [String: String] = ProcessInfo.processInfo.environment) throws
{
try self.validateQuotaEndpointOverride(environment: environment)
try self.validateAPIHostEndpointOverride(environment: environment)
if let raw = self.cleaned(environment[balanceURLKey]),
self.balanceURL(environment: environment) == nil
{
throw ZaiSettingsError.invalidEndpointOverride(self.balanceURLKey)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate balance overrides on the region-aware path

When CodexBar performs a provider fetch, ZaiProviderDescriptor calls validateEndpointOverrides(region:environment:), but the new balance check exists only in the overload without a region. Consequently, a malformed or non-HTTPS Z_AI_BALANCE_URL is silently ignored and a CN fetch falls back to the production balance endpoint instead of rejecting the invalid override as it does for the other endpoint settings; move or share this check with the region-aware overload.

Useful? React with 👍 / 👎.

}
}

public static func validateEndpointOverrides(
Expand Down
37 changes: 37 additions & 0 deletions Sources/CodexBarCore/Resources/Plugins/zai.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ defineProvider({
endpoints: [
"https://api.z.ai",
"https://open.bigmodel.cn",
"https://www.bigmodel.cn",
{ setting: "Z_AI_QUOTA_ENDPOINT", policy: "https" },
{ setting: "Z_AI_MODEL_USAGE_ENDPOINT", policy: "https" },
{ setting: "Z_AI_BALANCE_ENDPOINT", policy: "https" },
],
auth: { type: "bearer", secret: "Z_AI_API_KEY" },
settings: [
Expand All @@ -16,6 +18,7 @@ defineProvider({
{ key: "Z_AI_PROJECT", title: "Project", type: "plain" },
{ key: "Z_AI_QUOTA_ENDPOINT", title: "Quota endpoint", type: "plain" },
{ key: "Z_AI_MODEL_USAGE_ENDPOINT", title: "Model usage endpoint", type: "plain" },
{ key: "Z_AI_BALANCE_ENDPOINT", title: "Balance endpoint", type: "plain" },
],

async fetchUsage(ctx) {
Expand Down Expand Up @@ -201,6 +204,40 @@ defineProvider({
);
if (plan) result.identity.loginMethod = plan.trim();

// BigModel CN pay-as-you-go account balance (www.bigmodel.cn console endpoint,
// verified 2026-08: accepts both "Bearer <key>" and raw-key Authorization).
// z.ai global has no documented equivalent, so the row is CN-only. Best-effort —
// a failed balance lookup must never break quota display.
if (region === "bigmodel-cn") {
try {
const balanceEndpoint =
ctx.settings.get("Z_AI_BALANCE_ENDPOINT") ||
"https://www.bigmodel.cn/api/biz/account/query-customer-account-report";
const response = await ctx.http.getJSON(balanceEndpoint, {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound the best-effort balance request timeout

When the new console endpoint stalls, this call inherits the 15-second request timeout while the entire plugin fetch has a 20-second deadline; because it runs after the required quota request and before two model-usage requests, it can exhaust the global deadline and discard the otherwise valid quota snapshot despite the surrounding try/catch. Pass a short explicit timeoutSeconds for this optional request so an unavailable balance service cannot take down BigModel CN usage.

Useful? React with 👍 / 👎.

const body = response.json;
if (response.status === 200 && body && typeof body === "object" && body.success === true) {
const data = body.data && typeof body.data === "object" ? body.data : {};
const available = Number(data.availableBalance);
const current = Number(data.balance);
const value = Number.isFinite(available) ? available : current;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the balance fallback for null values

When the API returns availableBalance: null alongside a valid balance, Number(null) becomes 0, which is finite, so the advertised fallback is skipped and the UI incorrectly reports ¥0.00; nullable secondary amount fields similarly become misleading zero-valued entries. Check for null/undefined before numeric conversion so only actual numeric values participate in the fallback and detail rows.

Useful? React with 👍 / 👎.

if (Number.isFinite(value)) {
const secondary = [];
if (Number.isFinite(Number(data.rechargeAmount)))
secondary.push(`recharged ¥${Number(data.rechargeAmount).toFixed(2)}`);
if (Number.isFinite(Number(data.giveAmount)) && Number(data.giveAmount) > 0)
secondary.push(`granted ¥${Number(data.giveAmount).toFixed(2)}`);
if (Number.isFinite(Number(data.totalSpendAmount)))
secondary.push(`spent ¥${Number(data.totalSpendAmount).toFixed(2)}`);
result.details[0].rows.push({
label: "Account balance",
value: `¥${Number(value).toFixed(2)}`,
secondaryValue: secondary.join(" · ") || undefined,
});
}
}
} catch {}
}

async function modelUsage(daysBack) {
const end = ctx.date.now();
const start = new Date(end);
Expand Down
131 changes: 131 additions & 0 deletions TestsPlugin/ZaiPluginBalanceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import CodexBarCore
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import Testing

/// BigModel CN pay-as-you-go account balance, surfaced by the bundled zai plugin
/// as a best-effort detail row (endpoint verified against the live console API:
/// `GET www.bigmodel.cn/api/biz/account/query-customer-account-report`, which
/// accepts both `Bearer <key>` and raw-key Authorization).
struct ZaiPluginBalanceTests {
@Test
func `bigmodel CN snapshot renders account balance row`() async throws {
let recorder = BalanceRequestRecorder()
let snapshot = try await Self.fetch(
region: "bigmodel-cn",
balanceBody: """
{
"code": 200,
"msg": "操作成功",
"success": true,
"data": {
"balance": 42.5,
"availableBalance": 40.0,
"rechargeAmount": 100.0,
"giveAmount": 20.0,
"totalSpendAmount": 77.5,
"frozenBalance": 2.5
}
}
""",
recorder: recorder)

// availableBalance wins over balance; the secondary line summarizes spend provenance
#expect(snapshot.detailRow(label: "Account balance")?.value == "¥40.00")
#expect(
snapshot.detailRow(label: "Account balance")?.secondaryValue
== "recharged ¥100.00 · granted ¥20.00 · spent ¥77.50")
let balanceRequest = try #require(await recorder.requests.first { $0.url?.host == "www.bigmodel.cn" })
#expect(balanceRequest.url?.path == "/api/biz/account/query-customer-account-report")
}

@Test
func `balance endpoint failure keeps quota snapshot intact`() async throws {
let snapshot = try await Self.fetch(region: "bigmodel-cn", balanceBody: "{}", balanceStatus: 500)

#expect(snapshot.primary?.usedPercent == 42)
#expect(snapshot.detailRow(label: "Account balance") == nil)
}

@Test
func `global region skips the balance request entirely`() async throws {
let recorder = BalanceRequestRecorder()
let snapshot = try await Self.fetch(region: "global", balanceBody: "{}", recorder: recorder)

#expect(snapshot.primary?.usedPercent == 42)
let balanceHostRequests = await recorder.requests.filter { $0.url?.host == "www.bigmodel.cn" }
#expect(balanceHostRequests.isEmpty)
}

@Test
func `router resolves CN balance default, explicit override, and nil for global`() {
#expect(
ZaiEndpointRouter.resolveBalanceURL(region: .bigmodelCN, environment: [:])
== ZaiAPIRegion.bigmodelCN.balanceURL)
#expect(ZaiEndpointRouter.resolveBalanceURL(region: .global, environment: [:]) == nil)
let overridden = ZaiEndpointRouter.resolveBalanceURL(
region: .bigmodelCN,
environment: [ZaiSettingsReader.balanceURLKey: "https://balance-proxy.test/report"])
#expect(overridden?.absoluteString == "https://balance-proxy.test/report")
}

// MARK: - Fixtures

private static func fetch(
region: String,
balanceBody: String,
balanceStatus: Int = 200,
recorder: BalanceRequestRecorder? = nil) async throws -> UsageSnapshot
{
let runtime = try ProviderPluginRuntime(
bundledPlugin: "zai",
transport: ProviderHTTPTransportHandler { request in
if let recorder {
await recorder.append(request)
}
let body = request.url?.host == "www.bigmodel.cn" ? balanceBody : Self.quotaFixture
let status = request.url?.host == "www.bigmodel.cn" ? balanceStatus : 200
return try Self.response(request: request, body: body, status: status)
})
return try await runtime.fetchUsage(
settings: [
"Z_AI_REGION": region,
"Z_AI_USAGE_SCOPE": "personal",
"Z_AI_QUOTA_ENDPOINT": "https://open.bigmodel.cn/api/monitor/usage/quota/limit",
"Z_AI_MODEL_USAGE_ENDPOINT": "https://open.bigmodel.cn/api/monitor/usage/model-usage",
],
secrets: [ZaiSettingsReader.apiTokenKey: "fixture-key"])
}

private static let quotaFixture = """
{
"code": 200,
"success": true,
"data": {
"limits": [
{ "type": "TOKENS_LIMIT", "unit": 5, "number": 300, "percentage": 42,
"usage": 1000, "remaining": 580, "nextResetTime": 1756000000 }
]
}
}
"""

private static func response(request: URLRequest, body: String, status: Int) throws -> (Data, HTTPURLResponse) {
let response = try #require(HTTPURLResponse(
url: request.url!,
statusCode: status,
httpVersion: nil,
headerFields: ["Content-Type": "application/json"]))
return (Data(body.utf8), response)
}
}

private actor BalanceRequestRecorder {
private(set) var requests: [URLRequest] = []

func append(_ request: URLRequest) {
self.requests.append(request)
}
}