diff --git a/README.md b/README.md index 6b622a3..7ad79d5 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,7 @@ Most providers work automatically. If a provider has a “Needs setup” link, o | Zhipu Coding Plan | Automatic | Remote API | | NanoGPT | Usually automatic | Remote API | | DeepSeek | Usually automatic | Remote API balance | +| LiteLLM | Manual env/config/auth | Remote API | | OpenCode Go | [Needs setup](#opencode-go) | Dashboard scraping | ## Common configuration @@ -657,6 +658,7 @@ These providers use trusted env vars, trusted user/global OpenCode config, or na | Zhipu Coding Plan | Use `ZHIPU_API_KEY` or `ZHIPU_CODING_PLAN_API_KEY`; malformed fallback auth is surfaced as an auth error. | | NanoGPT | Use `NANOGPT_API_KEY`, `NANO_GPT_API_KEY`, trusted user/global config, or OpenCode auth. | | DeepSeek | Use `DEEPSEEK_API_KEY`, trusted user/global config under `provider.deepseek.options.apiKey`, or OpenCode auth. This provider shows balance only because DeepSeek does not expose a quota reset window. | +| LiteLLM | Use `LITELLM_API_KEY`, `LITELLM_KEY`, trusted user/global config under `provider.litellm.options.apiKey`, or OpenCode auth. Also set `provider.litellm.options.baseURL` if not using the default `http://localhost:4000`. | For security, repo-local `opencode.json` / `opencode.jsonc` is ignored for provider secrets in these integrations. Put secrets in environment variables or trusted user/global config. OpenCode auth fallbacks for API-key providers require `{ "type": "api", "key": "..." }` entries. diff --git a/src/lib/litellm-auth.ts b/src/lib/litellm-auth.ts new file mode 100644 index 0000000..78c236b --- /dev/null +++ b/src/lib/litellm-auth.ts @@ -0,0 +1,58 @@ +import { getAuthPaths, readAuthFile } from "./opencode-auth.js"; +import { + createProviderApiKeyResolver, + getGlobalOpencodeConfigCandidatePaths, +} from "./api-key-resolver.js"; + +export interface LiteLLMApiKeyResult { + key: string; + source: LiteLLMKeySource; +} + +const ALLOWED_LITELLM_ENV_VARS = ["LITELLM_API_KEY", "LITELLM_KEY"] as const; +const LITELLM_PROVIDER_KEYS = ["litellm"] as const; + +export type LiteLLMKeySource = + | "env:LITELLM_API_KEY" + | "env:LITELLM_KEY" + | "opencode.json" + | "opencode.jsonc" + | "auth.json"; + +export { getGlobalOpencodeConfigCandidatePaths as getOpencodeConfigCandidatePaths } from "./api-key-resolver.js"; + +const litellmApiKeyResolver = createProviderApiKeyResolver({ + envVars: [ + { name: "LITELLM_API_KEY", source: "env:LITELLM_API_KEY" }, + { name: "LITELLM_KEY", source: "env:LITELLM_KEY" }, + ], + providerKeys: LITELLM_PROVIDER_KEYS, + allowedEnvVars: ALLOWED_LITELLM_ENV_VARS, + configJsonSource: "opencode.json", + configJsoncSource: "opencode.jsonc", + getConfigCandidates: getGlobalOpencodeConfigCandidatePaths, + auth: { + readAuth: readAuthFile, + authSource: "auth.json", + }, +}); + +export async function resolveLiteLLMApiKey(): Promise { + return litellmApiKeyResolver.resolve(); +} + +export async function hasLiteLLMApiKey(): Promise { + return litellmApiKeyResolver.has(); +} + +export async function getLiteLLMKeyDiagnostics(): Promise<{ + configured: boolean; + source: LiteLLMKeySource | null; + checkedPaths: string[]; + authPaths: string[]; +}> { + return { + ...(await litellmApiKeyResolver.diagnostics()), + authPaths: getAuthPaths(), + }; +} diff --git a/src/lib/litellm.ts b/src/lib/litellm.ts new file mode 100644 index 0000000..454004e --- /dev/null +++ b/src/lib/litellm.ts @@ -0,0 +1,293 @@ +import { loadConfiguredOpenCodeConfig } from "./opencode-config-providers.js"; +import { readAuthFileCached } from "./opencode-auth.js"; +import type { AuthData } from "./types.js"; + +export { + getLiteLLMKeyDiagnostics, + hasLiteLLMApiKey as hasLiteLLMApiKeyConfigured, + type LiteLLMKeySource, +} from "./litellm-auth.js"; + +const LITELLM_ENV_VARS = [ + "LITELLM_API_KEY", + "LITELLM_KEY", +] as const; + +export function resolveStaticApiKey(): string | null { + for (const envVar of LITELLM_ENV_VARS) { + const value = process.env[envVar]?.trim(); + if (value) return value; + } + return null; +} + +export function resolveToken( + auth: Record | null | undefined, + staticKey: string | null, +): string | null { + // OAuth access token (from device flow) + const access = typeof auth?.access === "string" ? auth.access.trim() : ""; + if (access) return access; + // API key stored directly in auth.json + const key = typeof auth?.key === "string" ? auth.key.trim() : ""; + if (key) return key; + // Env var fallback + return staticKey; +} + +export interface LiteLLMUserInfoV2 { + user_id?: string; + user_email?: string; + spend?: number; + max_budget?: number | null; + budget_reset_at?: string | null; +} + +export interface LiteLLMDailyMetrics { + spend?: number; + successful_requests?: number; + failed_requests?: number; + api_requests?: number; + total_tokens?: number; +} + +export interface LiteLLMDailyModelEntry { + metrics?: LiteLLMDailyMetrics; +} + +export interface LiteLLMDailyResult { + date?: string; + metrics?: LiteLLMDailyMetrics; + breakdown?: { + models?: Record; + }; +} + +export interface LiteLLMDailyActivityResponse { + results?: LiteLLMDailyResult[]; +} + +const DEFAULT_BASE_URL = "http://localhost:4000"; + +export async function resolveBaseURL(): Promise { + try { + const config = await loadConfiguredOpenCodeConfig({ configRootDir: process.cwd() }); + const baseURL = (((config.provider as Record)?.litellm as Record)?.options as Record)?.baseURL; + if (typeof baseURL === "string" && baseURL.trim()) { + return baseURL.trim(); + } + } catch { + // fall through + } + + try { + const authData = await readAuthFileCached({ maxAgeMs: 5_000 }); + const baseURL = ((authData?.litellm as Record)?.metadata as Record)?.baseURL; + if (typeof baseURL === "string" && baseURL.trim()) { + return baseURL.trim(); + } + } catch { + // fall through to default + } + + return DEFAULT_BASE_URL; +} + +export function buildURL( + baseURL: string, + path: string, + params?: Record, +): string { + const normalized = baseURL.replace(/\/+$/, ""); + const url = new URL(path, normalized + "/"); + if (params) { + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v); + } + } + return url.toString(); +} + +export function todayDateString(): string { + return new Date().toISOString().slice(0, 10); +} + +export async function fetchUserInfo( + token: string, + baseURL: string, + requestTimeoutMs?: number, +): Promise { + try { + const url = buildURL(baseURL, "/v2/user/info"); + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "X-Proxy-Id": "litellm", + }, + signal: requestTimeoutMs ? AbortSignal.timeout(requestTimeoutMs) : undefined, + }); + if (!response.ok) return null; + return (await response.json()) as LiteLLMUserInfoV2; + } catch { + return null; + } +} + +export async function fetchTodayActivity( + token: string, + baseURL: string, + requestTimeoutMs?: number, +): Promise { + try { + const today = todayDateString(); + const url = buildURL(baseURL, "/user/daily/activity", { + start_date: today, + end_date: today, + page_size: "1000", + page: "1", + }); + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "X-Proxy-Id": "litellm", + }, + signal: requestTimeoutMs ? AbortSignal.timeout(requestTimeoutMs) : undefined, + }); + if (!response.ok) return null; + const data = (await response.json()) as LiteLLMDailyActivityResponse; + return data.results?.[0] ?? null; + } catch { + return null; + } +} + +export function topModelBySpend( + models: Record | undefined, +): string | null { + if (!models) return null; + let topModel: string | null = null; + let topSpend = -Infinity; + for (const [modelId, entry] of Object.entries(models)) { + const spend = entry.metrics?.spend ?? 0; + if (spend > topSpend) { + topSpend = spend; + topModel = modelId; + } + } + return topModel; +} + +export interface LiteLLMQueryResult { + success: true; + spend: number; + budget?: number; + budgetResetAt?: string; + today?: LiteLLMDailyResult; +} + +export async function queryLiteLLM( + token: string, + baseURL: string, + requestTimeoutMs?: number, +): Promise { + const [userInfo, todayActivity] = await Promise.all([ + fetchUserInfo(token, baseURL, requestTimeoutMs), + fetchTodayActivity(token, baseURL, requestTimeoutMs), + ]); + + if (!userInfo) return null; + + return { + success: true, + spend: userInfo.spend ?? 0, + budget: typeof userInfo.max_budget === "number" ? userInfo.max_budget : undefined, + budgetResetAt: userInfo.budget_reset_at ?? undefined, + today: todayActivity ?? undefined, + }; +} + +export async function hasLiteLLMAuthAvailable(): Promise { + const authData = await readAuthFileCached({ maxAgeMs: 5_000 }); + const litellmAuth = authData?.litellm; + + // allow oauth access keys if available for those using oauth + if (litellmAuth?.access) return true; + const key = (litellmAuth as Record | undefined)?.key; + // use default key if one is avaialble + if (typeof key === "string" && key.trim()) return true; + + // check for static API key from env + return resolveStaticApiKey() !== null; +} + +import type { QuotaToastEntry } from "./entries.js"; + +export function modelsTodayEntries(today: LiteLLMDailyResult): QuotaToastEntry[] { + const models = today.breakdown?.models; + if (!models || Object.keys(models).length === 0) { + // No per-model breakdown — fall back to aggregate line + const spend = today.metrics?.spend ?? 0; + const requests = today.metrics?.successful_requests ?? 0; + const reqLabel = requests === 1 ? "1 req" : `${requests} reqs`; + return [{ + kind: "value", + name: "LiteLLM", + group: "LiteLLM", + label: "Today:", + value: [`$${spend.toFixed(4)}`, reqLabel].join(" | "), + }]; + } + + // Sort models by spend descending, emit one entry each + const sortedEntries = Object.entries(models) + .filter(([, entry]) => (entry.metrics?.spend ?? 0) > 0 || (entry.metrics?.successful_requests ?? 0) > 0) + .sort(([, a], [, b]) => (b.metrics?.spend ?? 0) - (a.metrics?.spend ?? 0)); + + return sortedEntries.map(([modelId, entry], index) => { + const spend = entry.metrics?.spend ?? 0; + const requests = entry.metrics?.successful_requests ?? 0; + const reqLabel = requests === 1 ? "1 req" : `${requests} reqs`; + return { + kind: "value" as const, + name: "LiteLLM", + group: "LiteLLM", + label: index === 0 ? "Today:" : "", + value: [`$${spend.toFixed(4)}`, reqLabel, modelId].join(" | "), + }; + }); +} + +export function buildLiteLLMEntries(data: LiteLLMQueryResult): QuotaToastEntry[] { + const entries: QuotaToastEntry[] = []; + + if (data.budget && data.budget > 0) { + const remaining = Math.max(0, data.budget - data.spend); + const percentRemaining = Math.round((remaining / data.budget) * 100); + entries.push({ + name: "LiteLLM", + group: "LiteLLM", + label: "Budget:", + right: `$${data.spend.toFixed(2)}/$${data.budget.toFixed(2)}`, + percentRemaining, + resetTimeIso: data.budgetResetAt, + }); + } else { + entries.push({ + kind: "value", + name: "LiteLLM", + group: "LiteLLM", + label: "Spend:", + value: data.today?.metrics?.spend != null + ? `$${data.spend.toFixed(2)} (today: $${data.today.metrics.spend.toFixed(4)})` + : `$${data.spend.toFixed(2)}`, + }); + } + + if (data.today) { + entries.push(...modelsTodayEntries(data.today)); + } + + return entries; +} diff --git a/src/lib/provider-metadata.ts b/src/lib/provider-metadata.ts index df66128..20a13d9 100644 --- a/src/lib/provider-metadata.ts +++ b/src/lib/provider-metadata.ts @@ -17,7 +17,8 @@ export type CanonicalQuotaProviderId = | "minimax-china-coding-plan" | "kimi-for-coding" | "deepseek" - | "opencode-go"; + | "opencode-go" + | "litellm"; export type QuotaProviderAutoSetup = "yes" | "usually" | "manual_env_config" | "needs_quick_setup"; @@ -72,6 +73,7 @@ export const QUOTA_PROVIDER_LABELS: Readonly> = { "kimi-code": "Kimi Code", deepseek: "DeepSeek", "opencode-go": "OpenCode Go", + litellm: "LiteLLM", }; export const QUOTA_PROVIDER_ID_SYNONYMS: Readonly> = { @@ -135,6 +137,7 @@ export const QUOTA_PROVIDER_RUNTIME_IDS: QuotaProviderRuntimeIds = { "kimi-for-coding": ["kimi-for-coding", "kimi", "kimi-code"], deepseek: ["deepseek"], "opencode-go": ["opencode-go"], + litellm: ["litellm"], }; const LIVE_LOCAL_USAGE_PROVIDER_ID_SET = new Set([ @@ -280,6 +283,13 @@ export const QUOTA_PROVIDER_SHAPES: readonly QuotaProviderShape[] = [ quickSetupAnchor: "opencode-go-quick-setup", notes: "Scrapes the OpenCode Go dashboard; requires workspaceId and authCookie", }, + { + id: "litellm", + autoSetup: "needs_quick_setup", + authentication: "companion_auth_oauth_token", + authFallbacks: ["env_api_key", "global_opencode_config"], + quota: "remote_api", + }, ]; const QUOTA_PROVIDER_SHAPES_BY_ID: Readonly< diff --git a/src/lib/quota-status.ts b/src/lib/quota-status.ts index ddee91a..dca40e1 100644 --- a/src/lib/quota-status.ts +++ b/src/lib/quota-status.ts @@ -12,6 +12,7 @@ import { getChutesKeyDiagnostics } from "./chutes.js"; import { getCrofKeyDiagnostics } from "./crof.js"; import { getNanoGptKeyDiagnostics, queryNanoGptQuota } from "./nanogpt.js"; import { getDeepSeekKeyDiagnostics } from "./deepseek.js"; +import { getLiteLLMKeyDiagnostics } from "./litellm.js"; import { getSyntheticKeyDiagnostics } from "./synthetic.js"; import { getCopilotQuotaAuthDiagnostics } from "./copilot.js"; import { @@ -1360,6 +1361,17 @@ export async function buildQuotaStatusReport(params: { appendProviderCompactLiveProbeRows(deepSeekRows, "deepseek", params.providerLiveProbes); sections.push(createKvSection("deepseek", "deepseek:", deepSeekRows)); + // === litellm === + const litellmDiag = await readApiKeyDiagnosticsWithAuthPaths(getLiteLLMKeyDiagnostics); + const litellmRows: ReportKvRow[] = [ + { key: "api_key_configured", value: litellmDiag.configured ? "true" : "false" }, + { key: "api_key_source", value: litellmDiag.source ?? "(none)" }, + { key: "api_key_checked_paths", value: joinOrNone(litellmDiag.checkedPaths) }, + { key: "api_key_auth_paths", value: joinOrNone(litellmDiag.authPaths) }, + ]; + appendProviderCompactLiveProbeRows(litellmRows, "litellm", params.providerLiveProbes); + sections.push(createKvSection("litellm", "litellm:", litellmRows)); + // === nanogpt === const nanoGptDiag = await readApiKeyDiagnosticsWithAuthPaths(getNanoGptKeyDiagnostics); const nanoGptRows: ReportKvRow[] = [ diff --git a/src/lib/types.ts b/src/lib/types.ts index 97a7f7e..71ebfd2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -279,6 +279,13 @@ export interface SyntheticAuthData { key: string; } +export interface LiteLLMAuthData { + type: string; + access?: string; + key?: string; + [key: string]: unknown; +} + export interface MiniMaxAuthData { type: string; key?: string; @@ -376,6 +383,7 @@ export interface AuthData { "minimax-cn-coding-plan"?: MiniMaxAuthData; "kimi-code"?: KimiAuthData; kimi?: KimiAuthData; + litellm?: LiteLLMAuthData; } // ============================================================================= diff --git a/src/providers/litellm.ts b/src/providers/litellm.ts new file mode 100644 index 0000000..4d409ac --- /dev/null +++ b/src/providers/litellm.ts @@ -0,0 +1,59 @@ +import type { + QuotaProvider, + QuotaProviderContext, + QuotaProviderResult, +} from "../lib/entries.js"; +import { isCanonicalProviderAvailable } from "../lib/provider-availability.js"; +import { readAuthFileCached } from "../lib/opencode-auth.js"; +import { + buildLiteLLMEntries, + modelsTodayEntries, + queryLiteLLM, + resolveStaticApiKey, + resolveBaseURL, + resolveToken, + hasLiteLLMAuthAvailable, +} from "../lib/litellm.js"; +import { + attemptedResult, + mapNullableProviderResult, +} from "./result-helpers.js"; + +export { modelsTodayEntries, buildLiteLLMEntries }; + +export const litellmProvider: QuotaProvider = { + id: "litellm", + + async isAvailable(ctx: QuotaProviderContext): Promise { + const providerAvailable = await isCanonicalProviderAvailable({ + ctx, + providerId: "litellm", + fallbackOnError: false, + }); + if (providerAvailable) return true; + + return hasLiteLLMAuthAvailable(); + }, + + async fetch(ctx: QuotaProviderContext): Promise { + const authData = await readAuthFileCached({ maxAgeMs: 5_000 }); + const auth = authData?.litellm; + const token = resolveToken(auth, resolveStaticApiKey()); + + if (!token) { + return { + attempted: false, + entries: [], + errors: [], + }; + } + + const baseURL = await resolveBaseURL(); + const result = await queryLiteLLM(token, baseURL, ctx.config?.requestTimeoutMs); + + return mapNullableProviderResult(result, { + errorLabel: "LiteLLM", + onSuccess: (data) => attemptedResult(buildLiteLLMEntries(data)), + }); + }, +}; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index a086297..fe26c18 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -26,6 +26,7 @@ import { import { opencodeGoProvider } from "./opencode-go.js"; import { kimiCodeProvider } from "./kimi-code.js"; import { deepseekProvider } from "./deepseek.js"; +import { litellmProvider } from "./litellm.js"; export function getProviders(): QuotaProvider[] { // Order here defines display ordering in the toast. @@ -49,5 +50,6 @@ export function getProviders(): QuotaProvider[] { kimiCodeProvider, deepseekProvider, opencodeGoProvider, + litellmProvider, ]; } diff --git a/tests/lib.litellm.test.ts b/tests/lib.litellm.test.ts new file mode 100644 index 0000000..b567c49 --- /dev/null +++ b/tests/lib.litellm.test.ts @@ -0,0 +1,470 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const authMocks = vi.hoisted(() => ({ + readAuthFileCached: vi.fn(), +})); + +const configMocks = vi.hoisted(() => ({ + loadConfiguredOpenCodeConfig: vi.fn(), +})); + +vi.mock("../src/lib/opencode-auth.js", () => ({ + readAuthFileCached: authMocks.readAuthFileCached, + readAuthFile: authMocks.readAuthFileCached, + getAuthPaths: vi.fn(() => ["/tmp/auth.json"]), +})); + +vi.mock("../src/lib/opencode-config-providers.js", () => ({ + loadConfiguredOpenCodeConfig: configMocks.loadConfiguredOpenCodeConfig, +})); + +const realEnv = process.env; + +describe("litellm lib", () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T12:00:00.000Z")); + process.env = { ...realEnv }; + delete process.env.LITELLM_API_KEY; + delete process.env.LITELLM_KEY; + authMocks.readAuthFileCached.mockReset(); + authMocks.readAuthFileCached.mockResolvedValue({}); + configMocks.loadConfiguredOpenCodeConfig.mockReset(); + configMocks.loadConfiguredOpenCodeConfig.mockResolvedValue({}); + vi.stubGlobal("fetch", vi.fn(async () => new Response("not found", { status: 404 })) as any); + }); + + afterEach(() => { + process.env = realEnv; + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + describe("resolveStaticApiKey", () => { + it("returns LITELLM_API_KEY when set", async () => { + process.env.LITELLM_API_KEY = "test-api-key"; + const { resolveStaticApiKey } = await import("../src/lib/litellm.js"); + expect(resolveStaticApiKey()).toBe("test-api-key"); + }); + + it("returns LITELLM_KEY when set and LITELLM_API_KEY is not", async () => { + process.env.LITELLM_KEY = "test-key"; + const { resolveStaticApiKey } = await import("../src/lib/litellm.js"); + expect(resolveStaticApiKey()).toBe("test-key"); + }); + + it("prefers LITELLM_API_KEY over LITELLM_KEY", async () => { + process.env.LITELLM_API_KEY = "api-key"; + process.env.LITELLM_KEY = "fallback-key"; + const { resolveStaticApiKey } = await import("../src/lib/litellm.js"); + expect(resolveStaticApiKey()).toBe("api-key"); + }); + + it("returns null when neither env var is set", async () => { + const { resolveStaticApiKey } = await import("../src/lib/litellm.js"); + expect(resolveStaticApiKey()).toBeNull(); + }); + + it("ignores empty/whitespace values", async () => { + process.env.LITELLM_API_KEY = " "; + const { resolveStaticApiKey } = await import("../src/lib/litellm.js"); + expect(resolveStaticApiKey()).toBeNull(); + }); + }); + + describe("resolveToken", () => { + it("prefers OAuth access token", async () => { + const { resolveToken } = await import("../src/lib/litellm.js"); + const auth = { type: "oauth", access: "oauth-token", key: "api-key" }; + expect(resolveToken(auth, "env-key")).toBe("oauth-token"); + }); + + it("falls back to API key from auth", async () => { + const { resolveToken } = await import("../src/lib/litellm.js"); + const auth = { type: "oauth", key: "api-key" }; + expect(resolveToken(auth, "env-key")).toBe("api-key"); + }); + + it("falls back to env var key when no auth tokens", async () => { + const { resolveToken } = await import("../src/lib/litellm.js"); + const auth = { type: "oauth" }; + expect(resolveToken(auth, "env-key")).toBe("env-key"); + }); + + it("returns null when no tokens available", async () => { + const { resolveToken } = await import("../src/lib/litellm.js"); + expect(resolveToken({}, null)).toBeNull(); + }); + + it("trims whitespace from tokens", async () => { + const { resolveToken } = await import("../src/lib/litellm.js"); + const auth = { key: " api-key " }; + expect(resolveToken(auth, null)).toBe("api-key"); + }); + }); + + describe("resolveBaseURL", () => { + it("returns baseURL from config when available", async () => { + configMocks.loadConfiguredOpenCodeConfig.mockResolvedValueOnce({ + provider: { + litellm: { + options: { + baseURL: "https://ai-gateway.example.com/v1", + }, + }, + }, + }); + + const { resolveBaseURL } = await import("../src/lib/litellm.js"); + const url = await resolveBaseURL(); + expect(url).toBe("https://ai-gateway.example.com/v1"); + }); + + it("returns default baseURL when config not available", async () => { + configMocks.loadConfiguredOpenCodeConfig.mockResolvedValueOnce({}); + + const { resolveBaseURL } = await import("../src/lib/litellm.js"); + const url = await resolveBaseURL(); + expect(url).toBe("http://localhost:4000"); + }); + + it("returns default baseURL when config throws", async () => { + configMocks.loadConfiguredOpenCodeConfig.mockRejectedValueOnce(new Error("Config not found")); + + const { resolveBaseURL } = await import("../src/lib/litellm.js"); + const url = await resolveBaseURL(); + expect(url).toBe("http://localhost:4000"); + }); + + it("handles missing provider config gracefully", async () => { + configMocks.loadConfiguredOpenCodeConfig.mockResolvedValueOnce({ provider: {} }); + + const { resolveBaseURL } = await import("../src/lib/litellm.js"); + const url = await resolveBaseURL(); + expect(url).toBe("http://localhost:4000"); + }); + + it("falls back to auth.json metadata.baseURL when config has no baseURL", async () => { + configMocks.loadConfiguredOpenCodeConfig.mockResolvedValueOnce({}); + authMocks.readAuthFileCached.mockResolvedValueOnce({ + litellm: { type: "api", key: "test-key", metadata: { baseURL: "https://ai-gateway.example.com/v1" } }, + }); + + const { resolveBaseURL } = await import("../src/lib/litellm.js"); + const url = await resolveBaseURL(); + expect(url).toBe("https://ai-gateway.example.com/v1"); + }); + + it("prefers config baseURL over auth.json metadata.baseURL", async () => { + configMocks.loadConfiguredOpenCodeConfig.mockResolvedValueOnce({ + provider: { litellm: { options: { baseURL: "https://config-gateway.example.com" } } }, + }); + authMocks.readAuthFileCached.mockResolvedValueOnce({ + litellm: { type: "api", key: "test-key", metadata: { baseURL: "https://auth-gateway.example.com" } }, + }); + + const { resolveBaseURL } = await import("../src/lib/litellm.js"); + const url = await resolveBaseURL(); + expect(url).toBe("https://config-gateway.example.com"); + }); + + it("returns default baseURL when neither config nor auth has baseURL", async () => { + configMocks.loadConfiguredOpenCodeConfig.mockResolvedValueOnce({}); + authMocks.readAuthFileCached.mockResolvedValueOnce({ + litellm: { type: "api", key: "test-key" }, + }); + + const { resolveBaseURL } = await import("../src/lib/litellm.js"); + const url = await resolveBaseURL(); + expect(url).toBe("http://localhost:4000"); + }); + }); + + describe("buildURL", () => { + it("builds URL without params", async () => { + const { buildURL } = await import("../src/lib/litellm.js"); + const url = buildURL("http://localhost:4000", "/v2/user/info"); + expect(url).toBe("http://localhost:4000/v2/user/info"); + }); + + it("builds URL with params", async () => { + const { buildURL } = await import("../src/lib/litellm.js"); + const url = buildURL("http://localhost:4000", "/user/daily/activity", { + start_date: "2026-01-15", + page_size: "100", + }); + expect(url).toContain("/user/daily/activity?"); + expect(url).toContain("start_date=2026-01-15"); + expect(url).toContain("page_size=100"); + }); + + it("handles trailing slashes in baseURL", async () => { + const { buildURL } = await import("../src/lib/litellm.js"); + const url = buildURL("http://localhost:4000///", "/path"); + expect(url).toBe("http://localhost:4000/path"); + }); + }); + + describe("todayDateString", () => { + it("returns ISO date string", async () => { + const { todayDateString } = await import("../src/lib/litellm.js"); + expect(todayDateString()).toBe("2026-01-15"); + }); + }); + + describe("fetchUserInfo", () => { + it("fetches user info with proper headers", async () => { + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + user_id: "user-123", + spend: 100.0, + max_budget: 500.0, + }), + { status: 200 }, + ), + ); + vi.stubGlobal("fetch", fetchMock as any); + + const { fetchUserInfo } = await import("../src/lib/litellm.js"); + const result = await fetchUserInfo("test-token", "http://localhost:4000"); + + expect(result).toEqual({ + user_id: "user-123", + spend: 100.0, + max_budget: 500.0, + }); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/v2/user/info"), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }), + ); + }); + + it("returns null on API error", async () => { + const fetchMock = vi.fn(async () => + new Response("Internal Server Error", { status: 500 }), + ); + vi.stubGlobal("fetch", fetchMock as any); + + const { fetchUserInfo } = await import("../src/lib/litellm.js"); + const result = await fetchUserInfo("test-token", "http://localhost:4000"); + + expect(result).toBeNull(); + }); + + it("returns null on network error", async () => { + const fetchMock = vi.fn(async () => { + throw new Error("Network error"); + }); + vi.stubGlobal("fetch", fetchMock as any); + + const { fetchUserInfo } = await import("../src/lib/litellm.js"); + const result = await fetchUserInfo("test-token", "http://localhost:4000"); + + expect(result).toBeNull(); + }); + }); + + describe("fetchTodayActivity", () => { + it("fetches daily activity for today", async () => { + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + results: [ + { + date: "2026-01-15", + metrics: { + spend: 5.0, + successful_requests: 10, + }, + }, + ], + }), + { status: 200 }, + ), + ); + vi.stubGlobal("fetch", fetchMock as any); + + const { fetchTodayActivity } = await import("../src/lib/litellm.js"); + const result = await fetchTodayActivity("test-token", "http://localhost:4000"); + + expect(result).toEqual({ + date: "2026-01-15", + metrics: { + spend: 5.0, + successful_requests: 10, + }, + }); + + const firstCall = fetchMock.mock.calls.at(0); + expect(firstCall).toBeDefined(); + const url = new URL(String((firstCall as unknown[])[0])); + expect(url.searchParams.get("start_date")).toBe("2026-01-15"); + expect(url.searchParams.get("end_date")).toBe("2026-01-15"); + }); + + it("returns null when no results", async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ results: [] }), { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock as any); + + const { fetchTodayActivity } = await import("../src/lib/litellm.js"); + const result = await fetchTodayActivity("test-token", "http://localhost:4000"); + + expect(result).toBeNull(); + }); + }); + + describe("topModelBySpend", () => { + it("returns model with highest spend", async () => { + const { topModelBySpend } = await import("../src/lib/litellm.js"); + const models = { + "model-a": { metrics: { spend: 1.0 } }, + "model-b": { metrics: { spend: 5.0 } }, + "model-c": { metrics: { spend: 2.0 } }, + }; + expect(topModelBySpend(models)).toBe("model-b"); + }); + + it("returns null for empty models", async () => { + const { topModelBySpend } = await import("../src/lib/litellm.js"); + expect(topModelBySpend({})).toBeNull(); + }); + + it("returns null for undefined", async () => { + const { topModelBySpend } = await import("../src/lib/litellm.js"); + expect(topModelBySpend(undefined)).toBeNull(); + }); + + it("handles zero spend models", async () => { + const { topModelBySpend } = await import("../src/lib/litellm.js"); + const models = { + "model-a": { metrics: { spend: 0 } }, + "model-b": { metrics: { spend: 0 } }, + }; + const result = topModelBySpend(models); + expect(["model-a", "model-b"]).toContain(result); + }); + }); + + describe("queryLiteLLM", () => { + it("returns user info and daily activity", async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.includes("/v2/user/info")) { + return new Response( + JSON.stringify({ user_id: "user-123", spend: 100.0 }), + { status: 200 }, + ); + } + if (url.includes("/user/daily/activity")) { + return new Response( + JSON.stringify({ + results: [{ date: "2026-01-15", metrics: { spend: 5.0 } }], + }), + { status: 200 }, + ); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchMock as any); + + const { queryLiteLLM } = await import("../src/lib/litellm.js"); + const result = await queryLiteLLM("test-token", "http://localhost:4000"); + + expect(result).toEqual({ + success: true, + spend: 100.0, + today: { + date: "2026-01-15", + metrics: { spend: 5.0 }, + }, + }); + }); + + it("returns null when user info fails", async () => { + const fetchMock = vi.fn(async () => + new Response("Error", { status: 500 }), + ); + vi.stubGlobal("fetch", fetchMock as any); + + const { queryLiteLLM } = await import("../src/lib/litellm.js"); + const result = await queryLiteLLM("test-token", "http://localhost:4000"); + + expect(result).toBeNull(); + }); + + it("works without daily activity", async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.includes("/v2/user/info")) { + return new Response( + JSON.stringify({ user_id: "user-123", spend: 100.0 }), + { status: 200 }, + ); + } + if (url.includes("/user/daily/activity")) { + return new Response("not found", { status: 404 }); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchMock as any); + + const { queryLiteLLM } = await import("../src/lib/litellm.js"); + const result = await queryLiteLLM("test-token", "http://localhost:4000"); + + expect(result).toEqual({ + success: true, + spend: 100.0, + }); + }); + }); + + describe("hasLiteLLMAuthAvailable", () => { + it("returns true when OAuth token exists", async () => { + authMocks.readAuthFileCached.mockResolvedValueOnce({ + litellm: { type: "oauth", access: "oauth-123" }, + }); + + const { hasLiteLLMAuthAvailable } = await import("../src/lib/litellm.js"); + const result = await hasLiteLLMAuthAvailable(); + + expect(result).toBe(true); + }); + + it("returns true when API key exists in auth", async () => { + authMocks.readAuthFileCached.mockResolvedValueOnce({ + litellm: { key: "api-key" }, + }); + + const { hasLiteLLMAuthAvailable } = await import("../src/lib/litellm.js"); + const result = await hasLiteLLMAuthAvailable(); + + expect(result).toBe(true); + }); + + it("returns true when env var is set", async () => { + process.env.LITELLM_API_KEY = "env-key"; + authMocks.readAuthFileCached.mockResolvedValueOnce({}); + + const { hasLiteLLMAuthAvailable } = await import("../src/lib/litellm.js"); + const result = await hasLiteLLMAuthAvailable(); + + expect(result).toBe(true); + }); + + it("returns false when no auth available", async () => { + authMocks.readAuthFileCached.mockResolvedValueOnce({}); + + const { hasLiteLLMAuthAvailable } = await import("../src/lib/litellm.js"); + const result = await hasLiteLLMAuthAvailable(); + + expect(result).toBe(false); + }); + }); +}); diff --git a/tests/lib.provider-metadata.test.ts b/tests/lib.provider-metadata.test.ts index 57689d4..0444d30 100644 --- a/tests/lib.provider-metadata.test.ts +++ b/tests/lib.provider-metadata.test.ts @@ -149,6 +149,13 @@ describe("provider-metadata", () => { quickSetupAnchor: "opencode-go-quick-setup", notes: "Scrapes the OpenCode Go dashboard; requires workspaceId and authCookie", }, + { + id: "litellm", + autoSetup: "needs_quick_setup", + authentication: "companion_auth_oauth_token", + authFallbacks: ["env_api_key", "global_opencode_config"], + quota: "remote_api", + }, ]); }); diff --git a/tests/lib.quota-status.test.ts b/tests/lib.quota-status.test.ts index 90c039e..e9361f4 100644 --- a/tests/lib.quota-status.test.ts +++ b/tests/lib.quota-status.test.ts @@ -197,6 +197,7 @@ vi.mock("../src/lib/opencode-auth.js", () => ({ getAuthPath: () => "/tmp/auth.json", getAuthPaths: () => ["/tmp/auth.json"], readAuthFileCached: vi.fn(async () => ({})), + readAuthFile: vi.fn(async () => ({})), })); vi.mock("../src/lib/opencode-runtime-paths.js", () => ({ @@ -1681,6 +1682,7 @@ synthetic: chutes: crof: deepseek: +litellm: nanogpt: copilot_quota_auth: google_antigravity: diff --git a/tests/providers.litellm.test.ts b/tests/providers.litellm.test.ts new file mode 100644 index 0000000..4bdf901 --- /dev/null +++ b/tests/providers.litellm.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; +import { + expectAttemptedWithErrorLabel, + expectAttemptedWithNoErrors, + expectNotAttempted, +} from "./helpers/provider-assertions.js"; +import { createProviderAvailabilityContext } from "./helpers/provider-test-harness.js"; +import { litellmProvider } from "../src/providers/litellm.js"; + +const libMocks = vi.hoisted(() => ({ + resolveStaticApiKey: vi.fn(), + resolveToken: vi.fn(), + resolveBaseURL: vi.fn().mockResolvedValue("http://localhost:4000"), + hasLiteLLMAuthAvailable: vi.fn(), + queryLiteLLM: vi.fn(), + buildLiteLLMEntries: vi.fn((data: any) => data.entries || []), + readAuthFileCached: vi.fn().mockResolvedValue({}), +})); + +vi.mock("../src/lib/litellm.js", () => libMocks); + +describe("litellm provider", () => { + describe("isAvailable", () => { + it("returns false when no auth configured", async () => { + const { hasLiteLLMAuthAvailable } = await import("../src/lib/litellm.js"); + (hasLiteLLMAuthAvailable as any).mockResolvedValueOnce(false); + + const out = await litellmProvider.isAvailable({} as any); + expect(out).toBe(false); + }); + + it("returns true when auth is available", async () => { + const { hasLiteLLMAuthAvailable } = await import("../src/lib/litellm.js"); + (hasLiteLLMAuthAvailable as any).mockResolvedValueOnce(true); + + const out = await litellmProvider.isAvailable({} as any); + expect(out).toBe(true); + }); + + it("returns true for metadata-backed litellm runtime", async () => { + const out = await litellmProvider.isAvailable( + createProviderAvailabilityContext({ providerIds: ["litellm"] }) + ); + expect(out).toBe(true); + }); + }); + + describe("fetch", () => { + it("returns attempted:false when no token available", async () => { + const { resolveStaticApiKey, resolveToken, readAuthFileCached } = await import("../src/lib/litellm.js"); + (readAuthFileCached as any).mockResolvedValueOnce({}); + (resolveStaticApiKey as any).mockReturnValueOnce(null); + (resolveToken as any).mockReturnValueOnce(null); + + const out = await litellmProvider.fetch({} as any); + expectNotAttempted(out); + }); + + it("returns attempted:false when query returns null", async () => { + const { resolveStaticApiKey, resolveToken, readAuthFileCached, queryLiteLLM, resolveBaseURL } = await import("../src/lib/litellm.js"); + (readAuthFileCached as any).mockResolvedValueOnce({ litellm: { access: "token" } }); + (resolveStaticApiKey as any).mockReturnValueOnce(null); + (resolveToken as any).mockReturnValueOnce("token"); + (resolveBaseURL as any).mockResolvedValueOnce("http://localhost:4000"); + (queryLiteLLM as any).mockResolvedValueOnce(null); + + const out = await litellmProvider.fetch({ config: {} } as any); + expectNotAttempted(out); + }); + + it("maps quota result into entries", async () => { + const { resolveStaticApiKey, resolveToken, readAuthFileCached, queryLiteLLM, buildLiteLLMEntries } = await import("../src/lib/litellm.js"); + (readAuthFileCached as any).mockResolvedValueOnce({ litellm: { access: "token" } }); + (resolveStaticApiKey as any).mockReturnValueOnce(null); + (resolveToken as any).mockReturnValueOnce("token"); + const { resolveBaseURL } = await import("../src/lib/litellm.js"); + (resolveBaseURL as any).mockResolvedValueOnce("http://localhost:4000"); + (queryLiteLLM as any).mockResolvedValueOnce({ + success: true, + spend: 100.0, + budget: 200.0, + today: { metrics: { spend: 5.0 } }, + }); + (buildLiteLLMEntries as any).mockReturnValueOnce([ + { name: "LiteLLM", label: "Spend:", value: "$100.00 (today: $5.0000)" }, + ]); + + const out = await litellmProvider.fetch({ config: {} } as any); + expectAttemptedWithNoErrors(out); + expect(out.entries).toHaveLength(1); + expect(out.entries[0]).toMatchObject({ name: "LiteLLM", label: "Spend:" }); + }); + + it("passes requestTimeoutMs to queryLiteLLM", async () => { + const { resolveToken, readAuthFileCached, queryLiteLLM, resolveBaseURL } = await import("../src/lib/litellm.js"); + (readAuthFileCached as any).mockResolvedValueOnce({ litellm: { access: "token" } }); + (resolveToken as any).mockReturnValueOnce("token"); + (resolveBaseURL as any).mockResolvedValueOnce("http://localhost:4000"); + (queryLiteLLM as any).mockResolvedValueOnce({ success: true, spend: 0 }); + + await litellmProvider.fetch({ config: { requestTimeoutMs: 5000 } } as any); + + expect(queryLiteLLM).toHaveBeenCalledWith("token", "http://localhost:4000", 5000); + }); + }); +});