File size: 3,910 Bytes
4e23b01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
import { APIProviderQuotaExhaustedError, parseRetryAfterMs, parseTraceId } from '#/errors';

// Structured error `type`/`code` value that means the Moonshot account's
// quota or balance is exhausted (as opposed to a transient rate limit): the
// backend sets `exceeded_current_quota_error` as the body `error.type`.
const KIMI_QUOTA_EXHAUSTED_ERROR_CODES = new Set(['exceeded_current_quota_error']);

// Message fallback for gateways that flatten the body to text, matched
// against the lowercased message of a 429. Every pattern is anchored to
// billing wording — deliberately no bare /quota/ or /balance/, which would
// also match transient throttle messages like "token quota per minute".
// Grounded in observed Moonshot bodies: "You exceeded your current token
// quota: ... please check your account balance" and "Your account ... is
// suspended due to insufficient balance, please recharge your account or
// check your plan and billing details".
const KIMI_QUOTA_EXHAUSTED_MESSAGE_PATTERNS = [
  /exceeded your current (?:token )?quota/,
  /check your account balance/,
  /insufficient balance/,
  /recharge your account|please recharge/,
  /account (?:is )?in arrears/,
] as const;

function readStringProp(value: object, key: string): string | undefined {
  const raw = (value as Record<string, unknown>)[key];
  return typeof raw === 'string' ? raw : undefined;
}

function readErrorObjectProp(value: object): object | undefined {
  const raw = (value as Record<string, unknown>)['error'];
  return typeof raw === 'object' && raw !== null ? raw : undefined;
}

// Collect every candidate `code`/`type` string the SDK error may carry. The
// OpenAI SDK hoists the body's `error.code`/`error.type` to the top level and
// keeps the inner error object on `.error`; the Anthropic SDK keeps the FULL
// body on `.error` (`{type: 'error', error: {type, message}}`), so the quota
// type sits two levels deep. Walking `error` → `.error` → `.error.error`
// covers both shapes without SDK imports.
function collectErrorCodes(error: object): string[] {
  const codes: string[] = [];
  let current: object | undefined = error;
  for (let depth = 0; current !== undefined && depth < 3; depth += 1) {
    const code = readStringProp(current, 'code');
    if (code !== undefined) codes.push(code);
    const type = readStringProp(current, 'type');
    if (type !== undefined) codes.push(type);
    current = readErrorObjectProp(current);
  }
  return codes;
}

/**
 * Classify a raw provider failure as Moonshot's quota/balance-exhausted 429,
 * or answer `undefined` to keep the base classification. This is the Kimi
 * vendor's error knowledge, kept out of the shared OpenAI conversion: the
 * Kimi provider (and the Kimi files client) passes it to
 * `convertOpenAIError` as the vendor hook, consulted after the abort guard
 * with the raw SDK error — the base conversion would otherwise drop the
 * SDK-parsed body `error.type`/`error.code` this reads.
 */
export function classifyKimiQuotaError(
  error: unknown,
): APIProviderQuotaExhaustedError | undefined {
  if (typeof error !== 'object' || error === null) return undefined;
  const status = (error as Record<string, unknown>)['status'];
  if (status !== 429) return undefined;

  const message = readStringProp(error, 'message') ?? '';
  const structuredHit = collectErrorCodes(error).some((code) =>
    KIMI_QUOTA_EXHAUSTED_ERROR_CODES.has(code),
  );
  const lowerMessage = message.toLowerCase();
  const wordingHit = KIMI_QUOTA_EXHAUSTED_MESSAGE_PATTERNS.some((pattern) =>
    pattern.test(lowerMessage),
  );
  if (!structuredHit && !wordingHit) return undefined;

  const requestId = readStringProp(error, 'requestID') ?? null;
  const headers = (error as Record<string, unknown>)['headers'];
  return new APIProviderQuotaExhaustedError(
    message,
    requestId,
    parseRetryAfterMs(headers),
    parseTraceId(headers),
  );
}