From a1588c18bed8d71a3377dc2fc05b007b1ee3dfc6 Mon Sep 17 00:00:00 2001 From: RunhuaHuang Date: Thu, 20 Aug 2026 21:04:34 +0800 Subject: [PATCH 1/2] Add BigModel CN account balance to z.ai provider --- .../Providers/Zai/ZaiAPIRegion.swift | 25 ++++ .../Providers/Zai/ZaiProviderDescriptor.swift | 6 + .../Providers/Zai/ZaiSettingsReader.swift | 13 ++ Sources/CodexBarCore/Resources/Plugins/zai.js | 37 +++++ TestsPlugin/ZaiPluginBalanceTests.swift | 131 ++++++++++++++++++ 5 files changed, 212 insertions(+) create mode 100644 TestsPlugin/ZaiPluginBalanceTests.swift diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift b/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift index b6ff36a23f..abe89fd724 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift @@ -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 ` 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, @@ -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, diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift index cd50e09bc5..f497ba7eb0 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift @@ -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 diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift b/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift index f807c30252..f6c880bfaa 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift @@ -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" @@ -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) + } } public static func validateEndpointOverrides( diff --git a/Sources/CodexBarCore/Resources/Plugins/zai.js b/Sources/CodexBarCore/Resources/Plugins/zai.js index c9a0e27d3d..42be345d5d 100644 --- a/Sources/CodexBarCore/Resources/Plugins/zai.js +++ b/Sources/CodexBarCore/Resources/Plugins/zai.js @@ -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: [ @@ -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) { @@ -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 " 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, {}); + 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; + 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); diff --git a/TestsPlugin/ZaiPluginBalanceTests.swift b/TestsPlugin/ZaiPluginBalanceTests.swift new file mode 100644 index 0000000000..1a5684adf8 --- /dev/null +++ b/TestsPlugin/ZaiPluginBalanceTests.swift @@ -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 ` 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) + } +} From 72d762ac24d1d3c1ff20d2609a2fc0958142f15c Mon Sep 17 00:00:00 2001 From: RunhuaHuang Date: Thu, 20 Aug 2026 21:12:53 +0800 Subject: [PATCH 2/2] Bound balance timeout, region-aware override validation, null-safe amounts --- .../Providers/Zai/ZaiSettingsReader.swift | 20 +++++++--- Sources/CodexBarCore/Resources/Plugins/zai.js | 24 +++++++----- TestsPlugin/ZaiPluginBalanceTests.swift | 38 +++++++++++++++++++ 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift b/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift index f6c880bfaa..97bd282ed9 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift @@ -77,11 +77,7 @@ public struct ZaiSettingsReader: Sendable { { 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) - } + try self.validateBalanceEndpointOverride(environment: environment) } public static func validateEndpointOverrides( @@ -90,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( diff --git a/Sources/CodexBarCore/Resources/Plugins/zai.js b/Sources/CodexBarCore/Resources/Plugins/zai.js index 42be345d5d..20c38888f2 100644 --- a/Sources/CodexBarCore/Resources/Plugins/zai.js +++ b/Sources/CodexBarCore/Resources/Plugins/zai.js @@ -213,21 +213,27 @@ defineProvider({ 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, {}); + // 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 : {}; - const available = Number(data.availableBalance); - const current = Number(data.balance); + // 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(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)}`); + 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)}`, diff --git a/TestsPlugin/ZaiPluginBalanceTests.swift b/TestsPlugin/ZaiPluginBalanceTests.swift index 1a5684adf8..de7c8312ce 100644 --- a/TestsPlugin/ZaiPluginBalanceTests.swift +++ b/TestsPlugin/ZaiPluginBalanceTests.swift @@ -39,6 +39,44 @@ struct ZaiPluginBalanceTests { == "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