Skip to content

Commit 12a42b3

Browse files
suibianwanwancz-cli
andcommitted
fix: handle quota and billing errors
Co-Authored-By: cz-cli <noreply@clickzetta.com>
1 parent a2ca54c commit 12a42b3

14 files changed

Lines changed: 414 additions & 55 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { accountLoginUrlForService } from "./account-login.js"
2+
import { getDefaultProfileName, loadProfiles } from "../connection/profile-store.js"
3+
4+
const INSUFFICIENT_BALANCE_CODE = "CZLH-60029"
5+
const OVERDUE_PAYMENTS_RE = /Account\s+([a-z0-9_-]+)\s+has overdue payments\./i
6+
const INSUFFICIENT_BALANCE_RE = /insufficient account balance|overdue payments|job submission is currently restricted/i
7+
8+
function activeProfileEntry(profileName?: string) {
9+
const profiles = loadProfiles()
10+
const explicitName = profileName ?? process.env.CZ_PROFILE
11+
if (explicitName) return profiles[explicitName]
12+
return profiles[getDefaultProfileName() ?? ""] ?? Object.values(profiles)[0]
13+
}
14+
15+
function normalizeAccountsUrl(value: string) {
16+
return value.trim().replace(/\/+$/, "")
17+
}
18+
19+
function extractAccountName(message: string) {
20+
return OVERDUE_PAYMENTS_RE.exec(message)?.[1]
21+
}
22+
23+
function resolveAccountsUrl(input: {
24+
accountName?: string
25+
profileName?: string
26+
service?: string
27+
}) {
28+
const profile = activeProfileEntry(input.profileName)
29+
if (typeof profile?.accounts_url === "string" && profile.accounts_url.trim()) {
30+
return normalizeAccountsUrl(profile.accounts_url)
31+
}
32+
if (!input.accountName || !input.service) return undefined
33+
return accountLoginUrlForService(input.service, input.accountName)
34+
}
35+
36+
export function formatBillingError(input: {
37+
code?: string
38+
message?: string
39+
profileName?: string
40+
service?: string
41+
}) {
42+
const message = input.message ?? "Query failed"
43+
if (input.code !== INSUFFICIENT_BALANCE_CODE && !INSUFFICIENT_BALANCE_RE.test(message)) {
44+
return message
45+
}
46+
47+
const accountsUrl = resolveAccountsUrl({
48+
accountName: extractAccountName(message),
49+
profileName: input.profileName,
50+
service: input.service,
51+
})
52+
if (!accountsUrl) return message
53+
54+
return `Insufficient account balance. Please visit ${accountsUrl} to add funds.`
55+
}

packages/cz-cli/src/commands/sql.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { success, successRows, error, parseOutputArgs, renderOutput } from "../o
66
import { maskRows } from "../output/masking.js"
77
import { logOperation } from "../logger.js"
88
import { getExecContext, execSql, execSqlWithRetry, isQueryResult, validateIdentifier, classifyExecError, type ExecContext } from "./exec.js"
9+
import { formatBillingError } from "./billing-error.js"
910

1011
const WRITE_RE = /^\s*(INSERT|UPDATE|DELETE|REPLACE|ALTER|CREATE|DROP|TRUNCATE|RENAME|FORK)\b/i
1112
const SELECT_RE = /^\s*(SELECT\b|WITH\b[\s\S]*?\bSELECT\b|SHOW\b)/i
@@ -131,6 +132,7 @@ async function applyUseStatement(
131132
hints?: Record<string, string>,
132133
configStatements?: string[],
133134
timeoutMs?: number,
135+
profileName?: string,
134136
): Promise<boolean> {
135137
if (use.kind === "workspace") {
136138
ctx.config.workspace = use.target
@@ -145,7 +147,7 @@ async function applyUseStatement(
145147
}
146148
if (result.status === JobStatus.FAILED) {
147149
logOperation("sql", { sql: use.normalized, ok: false, errorCode: result.errorCode })
148-
error(result.errorCode ?? "SCHEMA_NOT_FOUND", result.errorMessage ?? `Schema '${use.target}' does not exist.`, { format })
150+
error(result.errorCode ?? "SCHEMA_NOT_FOUND", formatQueryError(result, ctx, profileName, `Schema '${use.target}' does not exist.`), { format })
149151
return false
150152
}
151153
ctx.config.schema = extractSchema(use.raw)
@@ -159,7 +161,7 @@ async function applyUseStatement(
159161
}
160162
if (result.status === JobStatus.FAILED) {
161163
logOperation("sql", { sql: use.normalized, ok: false, errorCode: result.errorCode })
162-
error(result.errorCode ?? "SQL_ERROR", result.errorMessage ?? "Query failed", { format })
164+
error(result.errorCode ?? "SQL_ERROR", formatQueryError(result, ctx, profileName), { format })
163165
return false
164166
}
165167
if (!useTargetExists(result.rows, use.target)) {
@@ -241,7 +243,7 @@ async function executeSingle(
241243
// Intercept USE statements — client-side context switch
242244
const use = parseUseStatement(sql)
243245
if (use) {
244-
if (!await applyUseStatement(ctx, use, format, hints, configStatements, argv.timeout * 1000)) return
246+
if (!await applyUseStatement(ctx, use, format, hints, configStatements, argv.timeout * 1000, argv.profile)) return
245247
success({ use: use.normalized }, { format, timeMs: 0 })
246248
return
247249
}
@@ -296,7 +298,7 @@ async function executeSingle(
296298
r = await execSqlWithRetry(ctx, sql, { hints: probeHints, timeoutMs: argv.timeout * 1000, configStatements, onJobId })
297299
if (!isQueryResult(r)) { error("UNEXPECTED_RESULT", "Expected query result but got async marker.", { format }); return }
298300
if (r.status === JobStatus.FAILED) {
299-
await handleFailure(r, sql, ctx, format, t0)
301+
await handleFailure(r, sql, ctx, format, t0, argv.profile)
300302
return
301303
}
302304
await emitResult(r, sql, argv, ctx, t0)
@@ -306,7 +308,7 @@ async function executeSingle(
306308
if (r.status === JobStatus.FAILED) {
307309
const hint = await fetchSchemaHint(ctx, sql, r.errorMessage ?? "")
308310
logOperation("sql", { sql, ok: false, errorCode: r.errorCode, timeMs: Date.now() - t0 })
309-
error(r.errorCode ?? "SQL_ERROR", r.errorMessage ?? "Query failed", { format, extra: hint ? { schema: hint } : undefined })
311+
error(r.errorCode ?? "SQL_ERROR", formatQueryError(r, ctx, argv.profile), { format, extra: hint ? { schema: hint } : undefined })
310312
return
311313
}
312314
if (r.rowCount > rowLimit) {
@@ -342,7 +344,7 @@ async function executeSingle(
342344
const r = await execSqlWithRetry(ctx, probeSql, { hints, timeoutMs: argv.timeout * 1000, configStatements, onJobId })
343345
if (!isQueryResult(r)) { error("UNEXPECTED_RESULT", "Expected query result but got async marker.", { format }); return }
344346
if (r.status === JobStatus.FAILED) {
345-
await handleFailure(r, sql, ctx, format, t0)
347+
await handleFailure(r, sql, ctx, format, t0, argv.profile)
346348
return
347349
}
348350
let aiMessage: string | undefined
@@ -360,20 +362,29 @@ async function executeSingle(
360362
const r = await execSqlWithRetry(ctx, sql, { hints, timeoutMs: argv.timeout * 1000, configStatements, onJobId })
361363
if (!isQueryResult(r)) { error("UNEXPECTED_RESULT", "Expected query result but got async marker.", { format }); return }
362364
if (r.status === JobStatus.FAILED) {
363-
await handleFailure(r, sql, ctx, format, t0)
365+
await handleFailure(r, sql, ctx, format, t0, argv.profile)
364366
return
365367
}
366368

367369
await emitResult(r, sql, argv, ctx, t0)
368370
}
369371

370-
async function handleFailure(r: QueryResult, sql: string, ctx: ExecContext, format: string, t0: number): Promise<void> {
372+
function formatQueryError(r: QueryResult, ctx: ExecContext, profileName?: string, fallback = "Query failed") {
373+
return formatBillingError({
374+
code: r.errorCode,
375+
message: r.errorMessage ?? fallback,
376+
profileName,
377+
service: ctx.config.service,
378+
})
379+
}
380+
381+
async function handleFailure(r: QueryResult, sql: string, ctx: ExecContext, format: string, t0: number, profileName?: string): Promise<void> {
371382
const hint = await fetchSchemaHint(ctx, sql, r.errorMessage ?? "")
372383
logOperation("sql", { sql, ok: false, errorCode: r.errorCode, timeMs: Date.now() - t0 })
373384
const aiMessage = hint
374385
? `SQL failed. Available schema info attached in the 'schema' field — check table/column names and retry.`
375386
: undefined
376-
error(r.errorCode ?? "SQL_ERROR", r.errorMessage ?? "Query failed", {
387+
error(r.errorCode ?? "SQL_ERROR", formatQueryError(r, ctx, profileName), {
377388
format,
378389
extra: hint ? { schema: hint } : undefined,
379390
...(aiMessage && { aiMessage }),
@@ -498,7 +509,7 @@ async function handler(argv: SqlArgs): Promise<void> {
498509
// Extract USE statements to update session context client-side
499510
const use = parseUseStatement(stmt)
500511
if (use) {
501-
if (!await applyUseStatement(ctx, use, format, accumulatedHints, configStatements, argv.timeout * 1000)) return
512+
if (!await applyUseStatement(ctx, use, format, accumulatedHints, configStatements, argv.timeout * 1000, argv.profile)) return
502513
configStatements.push(stmt)
503514
continue
504515
}
@@ -508,7 +519,7 @@ async function handler(argv: SqlArgs): Promise<void> {
508519
try {
509520
const r = await execSqlWithRetry(ctx, stmt, { hints: accumulatedHints, timeoutMs: argv.timeout * 1000, configStatements })
510521
if (isQueryResult(r) && r.status === JobStatus.FAILED) {
511-
const line = { index: i, sql: stmt, error: { code: r.errorCode ?? "SQL_ERROR", message: r.errorMessage ?? "Query failed" }, time_ms: Date.now() - t0, ...(r.jobId ? { job_id: r.jobId } : {}) }
522+
const line = { index: i, sql: stmt, error: { code: r.errorCode ?? "SQL_ERROR", message: formatQueryError(r, ctx, argv.profile) }, time_ms: Date.now() - t0, ...(r.jobId ? { job_id: r.jobId } : {}) }
512523
process.stdout.write((format === "pretty" ? renderOutput(line, format) : JSON.stringify(line)) + "\n")
513524
logOperation("sql", { sql: stmt, ok: false, errorCode: r.errorCode })
514525
} else if (isQueryResult(r)) {
@@ -529,7 +540,7 @@ async function handler(argv: SqlArgs): Promise<void> {
529540
const r = await execSqlWithRetry(ctx, stmt, { hints: accumulatedHints, timeoutMs: argv.timeout * 1000, configStatements })
530541
if (isQueryResult(r) && r.status === JobStatus.FAILED) {
531542
logOperation("sql", { sql: stmt, ok: false, errorCode: r.errorCode })
532-
error(r.errorCode ?? "SQL_ERROR", r.errorMessage ?? "Query failed", { format })
543+
error(r.errorCode ?? "SQL_ERROR", formatQueryError(r, ctx, argv.profile), { format })
533544
return
534545
}
535546
} else {

packages/cz-cli/src/llm/clickzetta-rotation.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ const API = {
1212
SAVE: "/llm-gateway-admin/v2/virtual-key/save",
1313
GET: "/llm-gateway-admin/v2/virtual-key/getApiKey",
1414
}
15-
const QUOTA_EXHAUSTED_PATTERN = /virtual key total quota exceeded/i
16-
const ALIAS_PREFIX = "cz-code_auto_"
17-
const PROFILE_ALIAS_PREFIX = "cz-cli_auto_"
15+
const FREE_ALIAS_PREFIX = "cz-code_auto_"
16+
const AUTO_ALIAS_PREFIX = "cz-cli_auto_"
17+
const PROFILE_ALIAS_PREFIX = "cz-cli_user_"
1818
const ALIAS_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"
19-
export const CLICKZETTA_ROTATION_PROMPT = "Free quota exhausted. Create a new virtual key with the current profile and switch?"
19+
const VIRTUAL_KEY_QUOTA_EXHAUSTED_PATTERN = /virtual key\s+\w+\s+quota exceeded/i
20+
const VIRTUAL_KEY_NAME_PATTERN = /virtual key\s+['"]([^'"]+)['"]/i
21+
export const CLICKZETTA_ROTATION_PROMPT = "Your complimentary token quota has been exhausted.\nWe also offer competitively priced paid token plans, and I'd be happy to help you create and configure a paid API key."
2022
export const CLICKZETTA_ROTATION_HEADER = "Quota"
2123
export const CLICKZETTA_ROTATION_CONFIRM_LABEL = "Create & switch"
2224
export const CLICKZETTA_ROTATION_CANCEL_LABEL = "Keep current"
@@ -85,11 +87,20 @@ export function inferAiGatewayUrl(profile: { service?: string; instance?: string
8587

8688
function randomAlias() {
8789
const bytes = crypto.getRandomValues(new Uint8Array(8))
88-
return ALIAS_PREFIX + Array.from(bytes, (byte) => ALIAS_ALPHABET[byte % ALIAS_ALPHABET.length]).join("")
90+
return AUTO_ALIAS_PREFIX + Array.from(bytes, (byte) => ALIAS_ALPHABET[byte % ALIAS_ALPHABET.length]).join("")
8991
}
9092

91-
function quotaExhausted(detail?: string | null) {
92-
return typeof detail === "string" && QUOTA_EXHAUSTED_PATTERN.test(detail)
93+
export function clickzettaQuotaVirtualKeyName(detail?: string | null) {
94+
if (typeof detail !== "string") return undefined
95+
return VIRTUAL_KEY_NAME_PATTERN.exec(detail)?.[1]
96+
}
97+
98+
export function isClickzettaVirtualKeyQuotaErrorDetail(detail?: string | null) {
99+
return typeof detail === "string" && VIRTUAL_KEY_QUOTA_EXHAUSTED_PATTERN.test(detail) && !!clickzettaQuotaVirtualKeyName(detail)
100+
}
101+
102+
export function isClickzettaFreeQuotaErrorDetail(detail?: string | null) {
103+
return isClickzettaVirtualKeyQuotaErrorDetail(detail) && clickzettaQuotaVirtualKeyName(detail)?.startsWith(FREE_ALIAS_PREFIX) === true
93104
}
94105

95106
function promptAllowed(approval: RotateOptions["approval"]) {
@@ -215,7 +226,7 @@ export function isClickzettaQuotaExhausted(input: {
215226
status?: number
216227
detail?: string | null
217228
}) {
218-
return input.provider === "clickzetta" && input.status === 429 && quotaExhausted(input.detail)
229+
return input.provider === "clickzetta" && input.status === 429 && isClickzettaFreeQuotaErrorDetail(input.detail)
219230
}
220231

221232
export async function rotateClickzettaLlm(input: {

packages/cz-cli/test/clickzetta-rotation.test.ts

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -113,20 +113,21 @@ afterAll(() => {
113113
})
114114

115115
describe("clickzetta key rotation", () => {
116-
test("recognizes the clickzetta quota-exhausted 429 pattern", () => {
116+
test("recognizes the clickzetta free key quota-exhausted 429 pattern", () => {
117117
expect(
118118
isClickzettaQuotaExhausted({
119119
provider: "clickzetta",
120120
status: 429,
121121
detail:
122-
"{\"code\":429,\"message\":\"Virtual key total quota exceeded: limit is 10000000 tokens for virtual key 'cz-code_auto_old', current usage: 10075371 tokens\"}",
122+
"{\"code\":429,\"message\":\"Virtual key total quota exceeded: limit is 10000000 tokens for virtual key 'cz-code_auto_pdiaxzjq', current usage: 10082801 tokens\"}",
123123
}),
124124
).toBe(true)
125125
expect(
126126
isClickzettaQuotaExhausted({
127127
provider: "clickzetta",
128-
status: 401,
129-
detail: "Invalid virtual key",
128+
status: 429,
129+
detail:
130+
"{\"code\":429,\"message\":\"Virtual key total quota exceeded: limit is 10000000 tokens for virtual key 'cz-cli_auto_UAT_TEST', current usage: 10075371 tokens\"}",
130131
}),
131132
).toBe(false)
132133
})
@@ -147,11 +148,11 @@ describe("clickzetta key rotation", () => {
147148
expect(studioCalls[0]?.body).toEqual({
148149
pageIndex: 1,
149150
pageSize: 200,
150-
vApiKeyAlias: "cz-cli_auto_UAT_TEST",
151+
vApiKeyAlias: "cz-cli_user_UAT_TEST",
151152
})
152153
expect(studioCalls[1]?.path).toBe("/llm-gateway-admin/v2/virtual-key/save")
153154
expect(studioCalls[1]?.body).toEqual({
154-
vApiKeyAlias: "cz-cli_auto_UAT_TEST",
155+
vApiKeyAlias: "cz-cli_user_UAT_TEST",
155156
rateLimitConfigs: { quota_total: 10000000 },
156157
})
157158
const profiles = readFileSync(profileFile, "utf-8")
@@ -216,31 +217,60 @@ describe("clickzetta key rotation", () => {
216217
expect(studioCalls[0]?.path).toBe("/llm-gateway-admin/v2/virtual-key/listWithAuth")
217218
expect(studioCalls[1]?.path).toBe("/llm-gateway-admin/v2/virtual-key/save")
218219
expect(studioCalls[1]?.body).toEqual({
219-
vApiKeyAlias: expect.stringMatching(/^cz-code_auto_/),
220+
vApiKeyAlias: expect.stringMatching(/^cz-cli_auto_/),
220221
rateLimitConfigs: { quota_total: 10000000 },
221222
})
222223
const profiles = readFileSync(profileFile, "utf-8")
223224
expect(profiles).toContain('api_key = "ck-new"')
224225
})
225226

226227
test("reuses an existing key with the derived alias before creating a new one", async () => {
227-
gatewayState.listData = [{ id: 77, vApiKeyAlias: "cz-cli_auto_UAT_TEST" }]
228+
writeFileSync(
229+
profileFile,
230+
[
231+
'default_profile = "uat"',
232+
"",
233+
"[profiles.uat]",
234+
'pat = "pat-uat"',
235+
'instance = "inst-uat"',
236+
'workspace = "ws-uat"',
237+
'username = "EXISTING_TEST"',
238+
'service = "uat-service.example"',
239+
'protocol = "https"',
240+
'ai_gateway_url = "https://mock-aimesh.example/gateway/v1"',
241+
"",
242+
].join("\n"),
243+
)
244+
gatewayState.listData = [{ id: 77, vApiKeyAlias: "cz-cli_user_EXISTING_TEST" }]
228245
gatewayState.getById[77] = "ck-existing"
229246

230247
const result = await maybeRotateExhaustedClickzettaLlm({
231248
provider: "clickzetta",
232249
status: 429,
233250
detail:
234-
"{\"code\":429,\"message\":\"Virtual key total quota exceeded: limit is 10000000 tokens for virtual key 'cz-cli_auto_UAT_TEST', current usage: 10075371 tokens\"}",
251+
"{\"code\":429,\"message\":\"Virtual key total quota exceeded: limit is 10000000 tokens for virtual key 'cz-code_auto_old', current usage: 10075371 tokens\"}",
235252
approval: "auto",
236253
})
237254

238255
expect(result?.rotated).toBe(true)
239-
expect(result?.alias).toBe("cz-cli_auto_UAT_TEST")
256+
expect(result?.alias).toBe("cz-cli_user_EXISTING_TEST")
240257
expect(studioCalls.map((call) => call.path)).toEqual([
241258
"/llm-gateway-admin/v2/virtual-key/listWithAuth",
242259
"/llm-gateway-admin/v2/virtual-key/getApiKey?id=77",
243260
])
244261
expect(readFileSync(profileFile, "utf-8")).toContain('api_key = "ck-existing"')
245262
})
263+
264+
test("does not rotate when quota is exhausted for a non-free key", async () => {
265+
const result = await maybeRotateExhaustedClickzettaLlm({
266+
provider: "clickzetta",
267+
status: 429,
268+
detail:
269+
"{\"code\":429,\"message\":\"Virtual key total quota exceeded: limit is 10000000 tokens for virtual key 'cz-cli_auto_UAT_TEST', current usage: 10075371 tokens\"}",
270+
approval: "auto",
271+
})
272+
273+
expect(result).toBeUndefined()
274+
expect(studioCalls).toEqual([])
275+
})
246276
})

0 commit comments

Comments
 (0)