Repository navigation
Add BigModel CN account balance to z.ai provider #3109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 <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, {}); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the API returns 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); | ||
|
|
||
| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When CodexBar performs a provider fetch,
ZaiProviderDescriptorcallsvalidateEndpointOverrides(region:environment:), but the new balance check exists only in the overload without a region. Consequently, a malformed or non-HTTPSZ_AI_BALANCE_URLis 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 👍 / 👎.