Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ Most providers work automatically. If a provider has a “Needs setup” link, o
| Zhipu Coding Plan | OpenCode config | Remote API | Quota |
| NanoGPT | API key/config | Remote API | Quota and balance |
| DeepSeek | API key/config | Remote API | Balance and status |
| xAI SuperGrok | OpenCode OAuth (`/connect` xAI) | Remote API | Weekly quota |
| Ollama Cloud | [Needs setup](docs/readme/providers.md#ollama-cloud) | Dashboard scraping | Quota |
| OpenCode Go | [Needs setup](docs/readme/providers.md#opencode-go) | Dashboard scraping | Quota |

Expand Down
1 change: 1 addition & 0 deletions docs/readme/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Most providers work automatically. If a provider has a “Needs setup” link, o
| Zhipu Coding Plan | OpenCode config | Remote API | Quota |
| NanoGPT | API key/config | Remote API | Quota and balance |
| DeepSeek | API key/config | Remote API | Balance and status |
| xAI SuperGrok | OpenCode OAuth (`/connect` xAI) | Remote API | Weekly quota |
| Ollama Cloud | [Needs setup](#ollama-cloud) | Dashboard scraping | Quota |
| OpenCode Go | [Needs setup](#opencode-go) | Dashboard scraping | Quota |

Expand Down
12 changes: 12 additions & 0 deletions src/lib/provider-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type CanonicalQuotaProviderId =
| "minimax-china-coding-plan"
| "kimi-for-coding"
| "deepseek"
| "xai"
| "opencode-go"
| "ollama-cloud"
| "quota-providers";
Expand Down Expand Up @@ -73,6 +74,7 @@ export const QUOTA_PROVIDER_LABELS: Readonly<Record<string, string>> = {
"kimi-for-coding": "Kimi Code",
"kimi-code": "Kimi Code",
deepseek: "DeepSeek",
xai: "xAI",
"opencode-go": "OpenCode Go",
"ollama-cloud": "Ollama Cloud",
"quota-providers": "Quota providers",
Expand All @@ -98,6 +100,8 @@ export const QUOTA_PROVIDER_ID_SYNONYMS: Readonly<Record<string, string>> = {
"kimi-for-code": "kimi-for-coding",
"kimi-code": "kimi-for-coding",
"deep-seek": "deepseek",
grok: "xai",
"x-ai": "xai",
"opencode-go-subscription": "opencode-go",
"gemini-cli": "google-gemini-cli",
"google-gemini": "google-gemini-cli",
Expand Down Expand Up @@ -140,6 +144,7 @@ export const QUOTA_PROVIDER_RUNTIME_IDS: QuotaProviderRuntimeIds = {
],
"kimi-for-coding": ["kimi-for-coding", "kimi", "kimi-code"],
deepseek: ["deepseek"],
xai: ["xai", "grok"],
"opencode-go": ["opencode-go"],
"ollama-cloud": ["ollama-cloud"],
"quota-providers": [],
Expand Down Expand Up @@ -278,6 +283,13 @@ export const QUOTA_PROVIDER_SHAPES: readonly QuotaProviderShape[] = [
authFallbacks: ["env_api_key", "global_opencode_config"],
quota: "remote_api",
},
{
id: "xai",
autoSetup: "yes",
authentication: "opencode_auth_oauth_token",
quota: "remote_api",
notes: "SuperGrok OAuth via OpenCode /connect; shared weekly credit meter",
},
{
id: "opencode-go",
autoSetup: "needs_quick_setup",
Expand Down
10 changes: 10 additions & 0 deletions src/lib/quota-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1536,6 +1536,16 @@ export async function buildQuotaStatusReport(params: {
appendProviderCompactLiveProbeRows(deepSeekRows, "deepseek", params.providerLiveProbes);
sections.push(createKvSection("deepseek", "deepseek:", deepSeekRows));

const xaiLiveProbeSection = createCompactLiveProbeOnlySection({
id: "xai",
title: "xai:",
providerId: "xai",
probes: params.providerLiveProbes,
});
if (xaiLiveProbeSection) {
sections.push(xaiLiveProbeSection);
}

// === nanogpt ===
const nanoGptDiag = await readApiKeyDiagnosticsWithAuthPaths(getNanoGptKeyDiagnostics);
const nanoGptRows: ReportKvRow[] = [
Expand Down
9 changes: 9 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,14 @@ export interface OpenAIOAuthData {
[key: string]: unknown;
}

export interface XaiOAuthData {
type: string;
access?: string;
refresh?: string;
expires?: number;
[key: string]: unknown;
}

export interface GeminiCliOAuthAuthData {
type: string;
access?: string;
Expand Down Expand Up @@ -418,6 +426,7 @@ export interface AuthData {
"minimax-cn-coding-plan"?: MiniMaxAuthData;
"kimi-code"?: KimiAuthData;
kimi?: KimiAuthData;
xai?: XaiOAuthData;
}

// =============================================================================
Expand Down
190 changes: 190 additions & 0 deletions src/lib/xai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* xAI SuperGrok subscription quota fetcher.
*
* Uses OpenCode's read-only `xai` OAuth entry and queries the same shared
* period meter exposed by Grok Build:
* GET https://cli-chat-proxy.grok.com/v1/billing?format=credits
*
* OpenCode remains the sole owner of OAuth refresh and auth.json persistence.
*/

import type { AuthData, QuotaError } from "./types.js";
import { sanitizeDisplaySnippet, sanitizeDisplayText } from "./display-sanitize.js";
import { clampPercent } from "./format-utils.js";
import { fetchWithTimeout } from "./http.js";
import { readAuthFileCached } from "./opencode-auth.js";

export const DEFAULT_XAI_AUTH_CACHE_MAX_AGE_MS = 5_000;

const CREDITS_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
const USER_AGENT = "OpenCode-Quota-Toast/1.0";

export type XaiPeriodKind = "weekly" | "monthly" | "daily" | "period";

export interface XaiWindowValue {
percentRemaining: number;
resetTimeIso?: string;
kind: XaiPeriodKind;
}

export type XaiResult =
| {
success: true;
label: "xAI SuperGrok";
window: XaiWindowValue;
}
| QuotaError
| null;

export type ResolvedXaiOAuth =
| { state: "none" }
| {
state: "configured";
accessToken: string;
expiresAt?: number;
};

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

function getNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}

function isoOrUndefined(value: unknown): string | undefined {
const raw = getNonEmptyString(value);
if (!raw) return undefined;
const date = new Date(raw);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}

function periodKindFromType(value: unknown): XaiPeriodKind {
const raw = getNonEmptyString(value)?.toUpperCase() ?? "";
if (raw.includes("WEEK")) return "weekly";
if (raw.includes("MONTH")) return "monthly";
if (raw.includes("DAY")) return "daily";
return "period";
}

export function periodKindLabel(kind: XaiPeriodKind): string {
switch (kind) {
case "weekly":
return "Weekly";
case "monthly":
return "Monthly";
case "daily":
return "Daily";
default:
return "Period";
}
}

export function resolveXaiOAuth(auth: AuthData | null | undefined): ResolvedXaiOAuth {
const entry = auth?.xai;
if (!entry || entry.type !== "oauth") return { state: "none" };

const accessToken = typeof entry.access === "string" ? entry.access.trim() : "";
if (!accessToken) return { state: "none" };

return {
state: "configured",
accessToken,
expiresAt: typeof entry.expires === "number" ? entry.expires : undefined,
};
}

export function hasXaiOAuth(auth: AuthData | null | undefined): boolean {
return resolveXaiOAuth(auth).state === "configured";
}

export async function hasXaiOAuthCached(params?: { maxAgeMs?: number }): Promise<boolean> {
const auth = await readAuthFileCached({
maxAgeMs: Math.max(0, params?.maxAgeMs ?? DEFAULT_XAI_AUTH_CACHE_MAX_AGE_MS),
});
return hasXaiOAuth(auth);
}

function parseCreditsWindow(payload: unknown): XaiWindowValue | null {
if (!isRecord(payload) || !isRecord(payload.config)) {
throw new Error("xAI credits response returned an unexpected response shape");
}

const config = payload.config;
const period = isRecord(config.currentPeriod) ? config.currentPeriod : null;
const hasUsage = Object.prototype.hasOwnProperty.call(config, "creditUsagePercent");
const hasPeriod = Boolean(
getNonEmptyString(period?.type) ||
getNonEmptyString(period?.start) ||
getNonEmptyString(period?.end),
);
if (!hasPeriod && !hasUsage) return null;

if (
hasUsage &&
(typeof config.creditUsagePercent !== "number" || !Number.isFinite(config.creditUsagePercent))
) {
throw new Error("xAI credits response returned an invalid usage percentage");
}

// Protobuf JSON omits zero-valued fields, so an absent percentage with a
// current period means 0% used rather than missing quota.
const usedPercent = hasUsage ? (config.creditUsagePercent as number) : 0;
return {
percentRemaining: clampPercent(100 - usedPercent),
resetTimeIso: isoOrUndefined(period?.end) ?? isoOrUndefined(config.billingPeriodEnd),
kind: periodKindFromType(period?.type),
};
}

export async function queryXaiQuota(
options: { requestTimeoutMs?: number } = {},
): Promise<XaiResult> {
const auth = await readAuthFileCached({
maxAgeMs: DEFAULT_XAI_AUTH_CACHE_MAX_AGE_MS,
});
const resolvedAuth = resolveXaiOAuth(auth);
if (resolvedAuth.state !== "configured") return null;

if (resolvedAuth.expiresAt && resolvedAuth.expiresAt < Date.now()) {
return {
success: false,
error: "xAI OAuth token expired; use xAI in OpenCode to refresh it or reconnect xAI",
};
}

try {
const resp = await fetchWithTimeout(
CREDITS_URL,
{
method: "GET",
headers: {
Authorization: `Bearer ${resolvedAuth.accessToken}`,
Accept: "application/json",
"User-Agent": USER_AGENT,
"x-grok-client-surface": "grok-build",
"x-grok-client-version": "1.0.0",
},
},
options.requestTimeoutMs,
);

if (!resp.ok) {
const text = await resp.text();
return {
success: false,
error: `xAI API error ${resp.status}: ${sanitizeDisplaySnippet(text, 120)}`,
};
}

const window = parseCreditsWindow(await resp.json());
if (!window) return { success: false, error: "No weekly quota data" };

return { success: true, label: "xAI SuperGrok", window };
} catch (err) {
return {
success: false,
error: sanitizeDisplayText(err instanceof Error ? err.message : String(err)),
};
}
}
2 changes: 2 additions & 0 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { opencodeGoProvider } from "./opencode-go.js";
import { kimiCodeProvider } from "./kimi-code.js";
import { deepseekProvider } from "./deepseek.js";
import { xaiProvider } from "./xai.js";
import { ollamaCloudProvider } from "./ollama-cloud.js";
import { quotaProvidersProvider } from "./quota-providers.js";

Expand All @@ -50,6 +51,7 @@ export function getProviders(): QuotaProvider[] {
minimaxChinaCodingPlanProvider,
kimiCodeProvider,
deepseekProvider,
xaiProvider,
opencodeGoProvider,
ollamaCloudProvider,
quotaProvidersProvider,
Expand Down
65 changes: 65 additions & 0 deletions src/providers/xai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* xAI SuperGrok provider wrapper.
*/

import type {
QuotaProvider,
QuotaProviderContext,
QuotaProviderMatchContext,
QuotaProviderResult,
} from "../lib/entries.js";
import {
DEFAULT_XAI_AUTH_CACHE_MAX_AGE_MS,
hasXaiOAuthCached,
periodKindLabel,
queryXaiQuota,
} from "../lib/xai.js";
import { isCanonicalProviderAvailable } from "../lib/provider-availability.js";
import { modelProviderMatchesRuntimeId } from "../lib/provider-model-matching.js";
import { normalizeQuotaProviderId } from "../lib/provider-metadata.js";
import { attemptedResult, mapNullableProviderResult } from "./result-helpers.js";

export const xaiProvider: QuotaProvider = {
id: "xai",

async isAvailable(ctx: QuotaProviderContext): Promise<boolean> {
const providerAvailable = await isCanonicalProviderAvailable({
ctx,
providerId: "xai",
fallbackOnError: false,
});
if (providerAvailable) return hasXaiOAuthCached({ maxAgeMs: 0 });
return hasXaiOAuthCached({ maxAgeMs: DEFAULT_XAI_AUTH_CACHE_MAX_AGE_MS });
},

matchesCurrentModel(model: string, context?: QuotaProviderMatchContext): boolean {
if (context?.currentProviderID) {
return normalizeQuotaProviderId(context.currentProviderID) === "xai";
}
return modelProviderMatchesRuntimeId(model, "xai");
},

async fetch(ctx: QuotaProviderContext): Promise<QuotaProviderResult> {
const result = await queryXaiQuota({ requestTimeoutMs: ctx.config?.requestTimeoutMs });

return mapNullableProviderResult(result, {
errorLabel: "xAI",
onSuccess: (result) => {
const period = periodKindLabel(result.window.kind);
return attemptedResult(
[
{
name: `${result.label} ${period}`,
group: result.label,
label: `${period}:`,
percentRemaining: result.window.percentRemaining,
resetTimeIso: result.window.resetTimeIso,
},
],
[],
{ singleWindowDisplayName: result.label },
);
},
});
},
};
Loading