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
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
23 changes: 23 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,19 @@ 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)
try self.validateBalanceEndpointOverride(environment: environment)
}

public static func validateEndpointOverrides(
Expand All @@ -77,6 +86,20 @@ public struct ZaiSettingsReader: Sendable {
{
try self.validateQuotaEndpointOverride(region: region, environment: environment)
try self.validateAPIHostEndpointOverride(region: region, environment: environment)
try self.validateBalanceEndpointOverride(environment: environment)
}

/// A malformed or non-HTTPS `Z_AI_BALANCE_URL` must reject the fetch (mirroring the
/// quota/host overrides) instead of silently falling back to the production endpoint.
/// Shared by both `validateEndpointOverrides` overloads so the region-aware path used
/// by the provider fetch pipeline validates it too.
static func validateBalanceEndpointOverride(
environment: [String: String] = ProcessInfo.processInfo.environment) throws
{
guard self.cleaned(environment[self.balanceURLKey]) != nil else { return }
guard self.balanceURL(environment: environment) != nil else {
throw ZaiSettingsError.invalidEndpointOverride(self.balanceURLKey)
}
}

public static func validateQuotaEndpointOverride(
Expand Down
43 changes: 43 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,46 @@ 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";
// Optional lookup: bound it well below the fetch deadline so a stalling balance
// service can neither delay the later model-usage requests nor discard the
// already-fetched quota snapshot.
const response = await ctx.http.getJSON(balanceEndpoint, { timeoutSeconds: 5 });
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 : {};
// Number(null) is 0, which would silently defeat the fallback below and
// render misleading ¥0.00 rows — only actual numeric values participate.
const numeric = (value) => (value === null || value === undefined ? undefined : Number(value));
const available = numeric(data.availableBalance);
const current = numeric(data.balance);
const value = Number.isFinite(available) ? available : current;
if (Number.isFinite(value)) {
const recharged = numeric(data.rechargeAmount);
const granted = numeric(data.giveAmount);
const spent = numeric(data.totalSpendAmount);
const secondary = [];
if (Number.isFinite(recharged)) secondary.push(`recharged ¥${recharged.toFixed(2)}`);
if (Number.isFinite(granted) && granted > 0) secondary.push(`granted ¥${granted.toFixed(2)}`);
if (Number.isFinite(spent)) secondary.push(`spent ¥${spent.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
169 changes: 169 additions & 0 deletions TestsPlugin/ZaiPluginBalanceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
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")
// Optional lookup must be bounded well below the fetch deadline (review P1).
#expect(balanceRequest.timeoutInterval == 5)
}

@Test
func `null availableBalance falls back to balance and hides null secondary fields`() async throws {
// Number(null) is 0 — without an explicit null guard the row would read ¥0.00.
let snapshot = try await Self.fetch(
region: "bigmodel-cn",
balanceBody: """
{
"code": 200,
"success": true,
"data": {
"balance": 42.5,
"availableBalance": null,
"rechargeAmount": null,
"giveAmount": 5.0,
"totalSpendAmount": null
}
}
""")

#expect(snapshot.detailRow(label: "Account balance")?.value == "¥42.50")
#expect(snapshot.detailRow(label: "Account balance")?.secondaryValue == "granted ¥5.00")
}

@Test
func `region-aware validation rejects invalid balance override`() {
#expect(throws: ZaiSettingsError.self) {
try ZaiSettingsReader.validateEndpointOverrides(
region: .bigmodelCN,
environment: [ZaiSettingsReader.balanceURLKey: "http://insecure.test/report"])
}
#expect(throws: ZaiSettingsError.self) {
try ZaiSettingsReader.validateEndpointOverrides(
environment: [ZaiSettingsReader.balanceURLKey: "http://insecure.test/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)
}
}