File size: 3,882 Bytes
63522a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
import { AgentServerClient } from "@openhands/typescript-client/clients";
import { getAgentServerClientOptions } from "./agent-server-client-options";
import {
  LLM_BALANCE_PATH,
  LLM_BALANCE_TIMEOUT_MS,
} from "#/constants/llm-balance";

/**
 * Credit balance reported by the agent server for the active LLM provider
 * (currently only OpenRouter). Amounts are in USD credits.
 */
export interface LLMBalance {
  provider: string;
  /** Credit cap configured for the key, or null when uncapped. */
  limit: number | null;
  /** Credits remaining under `limit`, or null when uncapped. */
  limitRemaining: number | null;
  /** Total credits consumed by the key. */
  usage: number;
  usageDaily: number | null;
  usageWeekly: number | null;
  usageMonthly: number | null;
  isFreeTier: boolean;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function numberOrNull(value: unknown): number | null {
  return typeof value === "number" && Number.isFinite(value) ? value : null;
}

/** `AbortSignal.timeout` rejects with a `TimeoutError`; a manual abort uses `AbortError`. */
function isAbortLike(error: unknown): boolean {
  return (
    isRecord(error) &&
    (error.name === "TimeoutError" || error.name === "AbortError")
  );
}

function normalizeBalance(raw: unknown): LLMBalance | null {
  if (!isRecord(raw) || typeof raw.provider !== "string") return null;

  return {
    provider: raw.provider,
    limit: numberOrNull(raw.limit),
    limitRemaining: numberOrNull(raw.limit_remaining),
    usage: numberOrNull(raw.usage) ?? 0,
    usageDaily: numberOrNull(raw.usage_daily),
    usageWeekly: numberOrNull(raw.usage_weekly),
    usageMonthly: numberOrNull(raw.usage_monthly),
    isFreeTier: raw.is_free_tier === true,
  };
}

class LLMBalanceService {
  /**
   * Fetch the provider balance from the agent server.
   *
   * Returns `null` when the server answers 404 (older servers without the
   * endpoint, or a provider that doesn't support balance reporting) or when
   * the response doesn't match the expected shape — callers hide the balance
   * UI in that case. Other HTTP failures throw, as does a request that outlives
   * `LLM_BALANCE_TIMEOUT_MS`. A timeout stays an error rather than collapsing
   * into `null`: `null` means "this server has no balance to report" and is
   * cached under `staleTime: Infinity`, so treating a transient stall as an
   * absent endpoint would hide the card for the rest of the conversation.
   */
  static async getBalance(): Promise<LLMBalance | null> {
    const { host, apiKey } = getAgentServerClientOptions();
    const client = new AgentServerClient({
      host,
      apiKey,
      timeout: LLM_BALANCE_TIMEOUT_MS,
    });
    try {
      const data = await client.get<unknown>(LLM_BALANCE_PATH, {
        acceptableStatusCodes: new Set([200]),
        responseType: "json",
        timeoutSeconds: Math.floor(LLM_BALANCE_TIMEOUT_MS / 1000),
      });
      return normalizeBalance(data);
    } catch (error) {
      // Older servers without balance support answer 404 — hide the UI.
      if (
        error instanceof Error &&
        "status" in error &&
        (error as { status: number }).status === 404
      ) {
        return null;
      }
      if (isAbortLike(error)) {
        throw new Error(
          `Balance request timed out after ${LLM_BALANCE_TIMEOUT_MS}ms`,
        );
      }
      const status =
        error instanceof Error &&
        "status" in error &&
        (error as { status: number }).status;
      throw new Error(
        status
          ? `Balance request failed with ${status}`
          : `Balance request failed: ${error instanceof Error ? error.message : String(error)}`,
      );
    } finally {
      client.close();
    }
  }
}

export default LLMBalanceService;