mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-09-01 14:52:19 +08:00
Add Grok CLI local provider support
This commit is contained in:
@@ -0,0 +1,764 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
GatewayProviderConfig,
|
||||
LocalAgentProviderCandidate,
|
||||
LocalAgentProviderImportResult,
|
||||
ProviderAccountConfig,
|
||||
ProviderAccountConnectorConfig,
|
||||
ProviderAccountMappingConfig,
|
||||
ProviderModelMetadata
|
||||
} from "@ccr/core/contracts/app";
|
||||
import {
|
||||
bearerAuthPlugin,
|
||||
firstString,
|
||||
isRecord,
|
||||
localAgentProviderApiKey,
|
||||
missingCandidate,
|
||||
modelDisplayNamesForModels,
|
||||
modelMetadataForModels,
|
||||
providerInternalNamePlaceholder,
|
||||
providerPayload,
|
||||
readBoolean,
|
||||
readJsonRecord,
|
||||
readString,
|
||||
uniqueProviderName,
|
||||
uniqueStrings,
|
||||
type OAuthTokenSet
|
||||
} from "@ccr/core/agents/local-providers/shared";
|
||||
import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch";
|
||||
import { normalizeProviderBaseUrl } from "@ccr/core/providers/url";
|
||||
|
||||
export const grokDefaultBaseUrl = "https://cli-chat-proxy.grok.com/v1";
|
||||
export const grokDefaultBillingEndpoint = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
|
||||
export const grokDefaultSubscriptionEndpoint = "https://cli-chat-proxy.grok.com/v1/user?include=subscription";
|
||||
|
||||
const grokDefaultModels = ["grok-4.5"];
|
||||
const grokProviderId = "grok-cli-api";
|
||||
const grokProviderName = "Grok CLI API";
|
||||
const grokDefaultOidcIssuer = "https://auth.x.ai";
|
||||
const grokOauthDefaultTimeoutMs = 8_000;
|
||||
|
||||
const grokBillingResetPaths = [
|
||||
"$.billingPeriodEnd",
|
||||
"$.currentPeriod.end",
|
||||
"$.currentPeriod.billingPeriodEnd",
|
||||
"$.config.billingPeriodEnd",
|
||||
"$.config.currentPeriod.end",
|
||||
"$.end"
|
||||
];
|
||||
|
||||
const grokBillingMapping: ProviderAccountMappingConfig = {
|
||||
meters: [
|
||||
{
|
||||
id: "grok_credit_usage_percent",
|
||||
kind: "quota",
|
||||
label: "Credit usage",
|
||||
limit: 100,
|
||||
remaining: [
|
||||
"100 - $.creditUsagePercent",
|
||||
"100 - $.config.creditUsagePercent",
|
||||
"100 - $.config.creditUsagePercent.val"
|
||||
],
|
||||
resetAt: grokBillingResetPaths,
|
||||
unit: "%",
|
||||
used: [
|
||||
"$.creditUsagePercent",
|
||||
"$.config.creditUsagePercent",
|
||||
"$.config.creditUsagePercent.val"
|
||||
],
|
||||
window: "monthly"
|
||||
},
|
||||
{
|
||||
id: "grok_included_credits",
|
||||
kind: "quota",
|
||||
label: "Included credits",
|
||||
limit: [
|
||||
"$.monthlyLimit",
|
||||
"$.monthlyLimit.val",
|
||||
"$.currentPeriod.monthlyLimit",
|
||||
"$.currentPeriod.monthlyLimit.val",
|
||||
"$.config.monthlyLimit",
|
||||
"$.config.monthlyLimit.val",
|
||||
"$.config.currentPeriod.monthlyLimit",
|
||||
"$.config.currentPeriod.monthlyLimit.val"
|
||||
],
|
||||
resetAt: grokBillingResetPaths,
|
||||
unit: "credits",
|
||||
used: [
|
||||
"$.includedUsed",
|
||||
"$.includedUsed.val",
|
||||
"$.currentPeriod.includedUsed",
|
||||
"$.currentPeriod.includedUsed.val",
|
||||
"$.config.includedUsed",
|
||||
"$.config.includedUsed.val",
|
||||
"$.config.currentPeriod.includedUsed",
|
||||
"$.config.currentPeriod.includedUsed.val"
|
||||
],
|
||||
window: "monthly"
|
||||
},
|
||||
{
|
||||
id: "grok_total_credits",
|
||||
kind: "quota",
|
||||
label: "Total credits",
|
||||
limit: [
|
||||
"$.monthlyLimit",
|
||||
"$.monthlyLimit.val",
|
||||
"$.currentPeriod.monthlyLimit",
|
||||
"$.currentPeriod.monthlyLimit.val",
|
||||
"$.config.monthlyLimit",
|
||||
"$.config.monthlyLimit.val",
|
||||
"$.config.currentPeriod.monthlyLimit",
|
||||
"$.config.currentPeriod.monthlyLimit.val"
|
||||
],
|
||||
resetAt: grokBillingResetPaths,
|
||||
unit: "credits",
|
||||
used: [
|
||||
"$.totalUsed",
|
||||
"$.totalUsed.val",
|
||||
"$.currentPeriod.totalUsed",
|
||||
"$.currentPeriod.totalUsed.val",
|
||||
"$.config.totalUsed",
|
||||
"$.config.totalUsed.val",
|
||||
"$.config.currentPeriod.totalUsed",
|
||||
"$.config.currentPeriod.totalUsed.val"
|
||||
],
|
||||
window: "monthly"
|
||||
},
|
||||
{
|
||||
id: "grok_pay_as_you_go_cap",
|
||||
kind: "quota",
|
||||
label: "Pay-as-you-go cap",
|
||||
limit: [
|
||||
"$.onDemandCap",
|
||||
"$.onDemandCap.val",
|
||||
"$.currentPeriod.onDemandCap",
|
||||
"$.currentPeriod.onDemandCap.val",
|
||||
"$.config.onDemandCap",
|
||||
"$.config.onDemandCap.val",
|
||||
"$.config.currentPeriod.onDemandCap",
|
||||
"$.config.currentPeriod.onDemandCap.val"
|
||||
],
|
||||
resetAt: grokBillingResetPaths,
|
||||
unit: "credits",
|
||||
used: [
|
||||
"$.onDemandUsed",
|
||||
"$.onDemandUsed.val",
|
||||
"$.currentPeriod.onDemandUsed",
|
||||
"$.currentPeriod.onDemandUsed.val",
|
||||
"$.config.onDemandUsed",
|
||||
"$.config.onDemandUsed.val",
|
||||
"$.config.currentPeriod.onDemandUsed",
|
||||
"$.config.currentPeriod.onDemandUsed.val"
|
||||
],
|
||||
window: "monthly"
|
||||
},
|
||||
{
|
||||
id: "grok_prepaid_balance",
|
||||
kind: "balance",
|
||||
label: "Prepaid balance",
|
||||
remaining: [
|
||||
"$.prepaidBalance",
|
||||
"$.prepaidBalance.val",
|
||||
"$.currentPeriod.prepaidBalance",
|
||||
"$.currentPeriod.prepaidBalance.val",
|
||||
"$.config.prepaidBalance",
|
||||
"$.config.prepaidBalance.val",
|
||||
"$.config.currentPeriod.prepaidBalance",
|
||||
"$.config.currentPeriod.prepaidBalance.val"
|
||||
],
|
||||
resetAt: grokBillingResetPaths,
|
||||
unit: "credits",
|
||||
window: "monthly"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export type GrokTokenSet = OAuthTokenSet & {
|
||||
authRecordKey?: string;
|
||||
oidcClientId?: string;
|
||||
oidcIssuer?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
type GrokModelCatalog = {
|
||||
baseUrl: string;
|
||||
modelDisplayNames?: Record<string, string>;
|
||||
modelMetadata?: Record<string, ProviderModelMetadata>;
|
||||
models: string[];
|
||||
};
|
||||
|
||||
export function grokCandidate(): LocalAgentProviderCandidate {
|
||||
const auth = readGrokAuth();
|
||||
const catalog = readGrokLocalModelCatalog();
|
||||
if ((auth?.accessToken && !grokAccessTokenExpired(auth)) || auth?.refreshToken) {
|
||||
return {
|
||||
detail: "Grok CLI login detected. Click Import to add it as a gateway provider.",
|
||||
id: grokProviderId,
|
||||
importable: true,
|
||||
kind: "grok",
|
||||
modelDisplayNames: catalog.modelDisplayNames,
|
||||
modelMetadata: catalog.modelMetadata,
|
||||
models: catalog.models,
|
||||
name: grokProviderName,
|
||||
protocol: "openai_responses",
|
||||
sourceFile: auth.sourceFile,
|
||||
status: "available"
|
||||
};
|
||||
}
|
||||
if (auth?.accessToken || auth?.refreshToken) {
|
||||
return {
|
||||
detail: auth.accessToken && grokAccessTokenExpired(auth)
|
||||
? "Grok CLI login was detected, but the access token is expired. Run grok login again, then rescan."
|
||||
: "Grok CLI login was detected, but no usable access token was found.",
|
||||
id: grokProviderId,
|
||||
importable: false,
|
||||
kind: "grok",
|
||||
modelDisplayNames: catalog.modelDisplayNames,
|
||||
modelMetadata: catalog.modelMetadata,
|
||||
models: catalog.models,
|
||||
name: grokProviderName,
|
||||
protocol: "openai_responses",
|
||||
sourceFile: auth.sourceFile,
|
||||
status: "locked"
|
||||
};
|
||||
}
|
||||
return missingCandidate("grok", grokProviderId, grokProviderName, "openai_responses", catalog.models, catalog.modelDisplayNames);
|
||||
}
|
||||
|
||||
export async function importGrokProvider(candidate: LocalAgentProviderCandidate, providerNames: string[]): Promise<LocalAgentProviderImportResult> {
|
||||
const auth = await resolveGrokAuth();
|
||||
if (!auth?.accessToken || grokAccessTokenExpired(auth)) {
|
||||
throw new Error("Grok CLI access token was not found or is expired.");
|
||||
}
|
||||
return importGrokProviderWithAuth(candidate, providerNames, auth);
|
||||
}
|
||||
|
||||
export function readGrokAuth(): GrokTokenSet | undefined {
|
||||
const candidates = grokCredentialFiles()
|
||||
.flatMap((sourceFile) => readGrokAuthRecords(sourceFile));
|
||||
return candidates.find((item) => item.accessToken && !grokAccessTokenExpired(item)) ??
|
||||
candidates.find((item) => item.refreshToken) ??
|
||||
candidates.find((item) => item.accessToken);
|
||||
}
|
||||
|
||||
export async function resolveGrokAuth(): Promise<GrokTokenSet | undefined> {
|
||||
const auth = readGrokAuth();
|
||||
if (!auth?.refreshToken || (auth.accessToken && !grokAccessTokenExpired(auth))) {
|
||||
return auth;
|
||||
}
|
||||
return refreshGrokAuth(auth);
|
||||
}
|
||||
|
||||
export function readGrokLocalModelCatalog(): GrokModelCatalog {
|
||||
const preferredModel = readGrokDefaultModel();
|
||||
const catalog = grokModelCatalogFromPayload(readJsonRecord(grokModelsCacheFile()), preferredModel);
|
||||
const models = uniqueStrings([
|
||||
preferredModel,
|
||||
...catalog.models,
|
||||
...grokDefaultModels
|
||||
]);
|
||||
return {
|
||||
baseUrl: catalog.baseUrl || grokRuntimeDefaultBaseUrl(),
|
||||
modelDisplayNames: modelDisplayNamesForModels(catalog.modelDisplayNames, models),
|
||||
modelMetadata: modelMetadataForModels(catalog.modelMetadata, models),
|
||||
models
|
||||
};
|
||||
}
|
||||
|
||||
function readGrokAuthRecords(sourceFile: string): GrokTokenSet[] {
|
||||
const record = readJsonRecord(sourceFile);
|
||||
if (!record) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
record,
|
||||
...Object.entries(record)
|
||||
.filter((entry): entry is [string, Record<string, unknown>] => isRecord(entry[1]))
|
||||
.map(([key, value]) => ({ ...value, __ccr_auth_record_key: key }))
|
||||
]
|
||||
.map((item) => grokAuthFromRecord(item, sourceFile))
|
||||
.filter((item): item is GrokTokenSet => Boolean(item));
|
||||
}
|
||||
|
||||
function grokAuthFromRecord(record: Record<string, unknown>, sourceFile: string): GrokTokenSet | undefined {
|
||||
const accessToken =
|
||||
readString(record.key) ||
|
||||
readString(record.access_token) ||
|
||||
readString(record.accessToken) ||
|
||||
readString(record.token) ||
|
||||
readString(record.id_token) ||
|
||||
readString(record.idToken);
|
||||
const refreshToken =
|
||||
readString(record.refresh_token) ||
|
||||
readString(record.refreshToken);
|
||||
if (!accessToken && !refreshToken) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
accessToken,
|
||||
authRecordKey: readString(record.__ccr_auth_record_key),
|
||||
expiresAt: readString(record.expires_at) || readString(record.expiresAt),
|
||||
oidcClientId: readString(record.oidc_client_id) || readString(record.oidcClientId) || readString(process.env.GROK_OIDC_CLIENT_ID),
|
||||
oidcIssuer: readString(record.oidc_issuer) || readString(record.oidcIssuer) || readString(process.env.GROK_OIDC_ISSUER),
|
||||
refreshToken,
|
||||
sourceFile
|
||||
};
|
||||
}
|
||||
|
||||
export function grokAccessTokenExpired(auth: GrokTokenSet): boolean {
|
||||
const expiresAtMs = dateMs(auth.expiresAt) ?? jwtExpiresAtMs(auth.accessToken);
|
||||
return expiresAtMs !== undefined && expiresAtMs <= Date.now() + 60_000;
|
||||
}
|
||||
|
||||
function grokModelCatalogFromPayload(payload: unknown, preferredModel?: string): GrokModelCatalog {
|
||||
const models: string[] = [];
|
||||
const modelDisplayNames: Record<string, string> = {};
|
||||
const modelMetadata: Record<string, ProviderModelMetadata> = {};
|
||||
const baseUrlsByModel: Record<string, string> = {};
|
||||
|
||||
for (const item of grokModelCatalogItems(payload)) {
|
||||
const info = isRecord(item.value) && isRecord(item.value.info) ? item.value.info : isRecord(item.value) ? item.value : {};
|
||||
if (readBoolean(info.hidden) || readBoolean(info.supported_in_api) === false || readBoolean(info.supportedInApi) === false) {
|
||||
continue;
|
||||
}
|
||||
const apiBackend = readString(info.api_backend) || readString(info.apiBackend);
|
||||
if (apiBackend && !apiBackend.toLowerCase().includes("responses")) {
|
||||
continue;
|
||||
}
|
||||
const model = readString(info.model) || readString(info.id) || readString(info.name) || item.key;
|
||||
if (!model) {
|
||||
continue;
|
||||
}
|
||||
models.push(model);
|
||||
const displayName = readString(info.display_name) || readString(info.displayName) || readString(info.label) || readString(info.title) || readString(info.name);
|
||||
if (displayName && displayName !== model) {
|
||||
modelDisplayNames[model] = displayName;
|
||||
}
|
||||
const baseUrl = readString(info.base_url) || readString(info.baseUrl);
|
||||
if (baseUrl) {
|
||||
baseUrlsByModel[model] = baseUrl;
|
||||
}
|
||||
const metadata = grokModelMetadataFromInfo(info);
|
||||
if (metadata) {
|
||||
modelMetadata[model] = metadata;
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueModels = uniqueStrings(models);
|
||||
const preferredBaseUrl = preferredModel ? baseUrlsByModel[preferredModel] : undefined;
|
||||
const baseUrl = preferredBaseUrl || firstString(uniqueModels.map((model) => baseUrlsByModel[model])) || grokRuntimeDefaultBaseUrl();
|
||||
const filteredModels = uniqueModels.filter((model) => !baseUrlsByModel[model] || baseUrlsByModel[model] === baseUrl);
|
||||
return {
|
||||
baseUrl,
|
||||
modelDisplayNames: modelDisplayNamesForModels(modelDisplayNames, filteredModels),
|
||||
modelMetadata: modelMetadataForModels(modelMetadata, filteredModels),
|
||||
models: filteredModels
|
||||
};
|
||||
}
|
||||
|
||||
function grokModelMetadataFromInfo(info: Record<string, unknown>): ProviderModelMetadata | undefined {
|
||||
const defaultReasoningLevel = readNullableString(info.reasoning_effort) ?? readNullableString(info.reasoningEffort);
|
||||
const metadata: ProviderModelMetadata = {
|
||||
...(defaultReasoningLevel !== undefined ? { defaultReasoningLevel } : {})
|
||||
};
|
||||
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||
}
|
||||
|
||||
function grokModelCatalogItems(payload: unknown): Array<{ key?: string; value: unknown }> {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.map((value) => ({ value }));
|
||||
}
|
||||
if (!isRecord(payload)) {
|
||||
return [];
|
||||
}
|
||||
const models = payload.models;
|
||||
if (Array.isArray(models)) {
|
||||
return models.map((value) => ({ value }));
|
||||
}
|
||||
if (isRecord(models)) {
|
||||
return Object.entries(models).map(([key, value]) => ({ key, value }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function readGrokDefaultModel(): string | undefined {
|
||||
for (const sourceFile of grokConfigFiles()) {
|
||||
if (!existsSync(sourceFile)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const text = readFileSync(sourceFile, "utf8");
|
||||
const match = text.match(/^\s*default\s*=\s*"([^"]+)"\s*$/m) ?? text.match(/^\s*default\s*=\s*'([^']+)'\s*$/m);
|
||||
const model = match?.[1]?.trim();
|
||||
if (model) {
|
||||
return model;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function importGrokProviderWithAuth(
|
||||
candidate: LocalAgentProviderCandidate,
|
||||
providerNames: string[],
|
||||
auth: GrokTokenSet
|
||||
): LocalAgentProviderImportResult {
|
||||
const catalog = readGrokLocalModelCatalog();
|
||||
const provider = providerPayload(
|
||||
{
|
||||
...candidate,
|
||||
modelDisplayNames: catalog.modelDisplayNames,
|
||||
modelMetadata: catalog.modelMetadata,
|
||||
models: catalog.models
|
||||
},
|
||||
uniqueProviderName(providerNames, grokProviderName),
|
||||
catalog.baseUrl,
|
||||
grokProviderAccountConfig()
|
||||
);
|
||||
return {
|
||||
candidate: {
|
||||
...candidate,
|
||||
modelDisplayNames: catalog.modelDisplayNames,
|
||||
modelMetadata: catalog.modelMetadata,
|
||||
models: catalog.models
|
||||
},
|
||||
provider,
|
||||
providerPlugins: [
|
||||
grokOauthPlugin("grok-cli-oauth", auth.accessToken ?? ""),
|
||||
grokOauthPlugin("grok-cli-oauth-internal", auth.accessToken ?? "", providerInternalNamePlaceholder)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function grokProviderAccountConfig(): ProviderAccountConfig {
|
||||
return {
|
||||
connectors: [
|
||||
{
|
||||
auth: "provider-api-key",
|
||||
endpoint: grokBillingEndpoint(),
|
||||
headers: {
|
||||
"x-grok-client-identifier": "xai-grok-cli",
|
||||
"x-grok-client-version": "0.2.93"
|
||||
},
|
||||
mapping: grokBillingMapping,
|
||||
type: "http-json"
|
||||
},
|
||||
{
|
||||
auth: "provider-api-key",
|
||||
endpoint: grokSubscriptionEndpoint(),
|
||||
headers: {
|
||||
"x-grok-client-identifier": "xai-grok-cli",
|
||||
"x-grok-client-version": "0.2.93"
|
||||
},
|
||||
mapping: { meters: [] },
|
||||
parser: "grok-subscription",
|
||||
type: "http-json"
|
||||
}
|
||||
],
|
||||
enabled: true
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeGrokProviderAccountConfig(provider: GatewayProviderConfig): GatewayProviderConfig {
|
||||
if (!isLocalGrokProvider(provider) || !shouldUseCurrentGrokAccountConfig(provider.account)) {
|
||||
return provider;
|
||||
}
|
||||
const account = grokProviderAccountConfig();
|
||||
return {
|
||||
...provider,
|
||||
account: {
|
||||
...account,
|
||||
refreshIntervalMs: provider.account?.refreshIntervalMs ?? account.refreshIntervalMs
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function isLocalGrokProvider(provider: GatewayProviderConfig): boolean {
|
||||
if (providerApiKey(provider) !== localAgentProviderApiKey) {
|
||||
return false;
|
||||
}
|
||||
const baseUrl = normalizeProviderBaseUrl(providerBaseUrl(provider)).toLowerCase();
|
||||
const name = provider.name?.toLowerCase() ?? "";
|
||||
return baseUrl.includes("cli-chat-proxy.grok.com") || name.includes("grok");
|
||||
}
|
||||
|
||||
function shouldUseCurrentGrokAccountConfig(account: ProviderAccountConfig | undefined): boolean {
|
||||
if (account?.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
const connectors = account?.connectors ?? [];
|
||||
if (connectors.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return connectors.every(isGrokAccountConnector);
|
||||
}
|
||||
|
||||
function isGrokAccountConnector(connector: ProviderAccountConnectorConfig): boolean {
|
||||
if (connector.type === "standard") {
|
||||
return !connector.endpoint?.trim() && !connector.endpoints?.length && !connector.headers && !connector.id;
|
||||
}
|
||||
if (connector.type !== "http-json") {
|
||||
return false;
|
||||
}
|
||||
return /^https:\/\/grok\.com\/(?:billing|user)(?:$|[?#/])/i.test(connector.endpoint.trim()) ||
|
||||
/^https:\/\/cli-chat-proxy\.grok\.com\/v1\/(?:billing|user)(?:$|[?#/])/i.test(connector.endpoint.trim());
|
||||
}
|
||||
|
||||
function providerBaseUrl(provider: GatewayProviderConfig): string {
|
||||
return provider.api_base_url || provider.baseurl || provider.baseUrl || "";
|
||||
}
|
||||
|
||||
function providerApiKey(provider: GatewayProviderConfig): string {
|
||||
return provider.api_key || provider.apiKey || provider.apikey || "";
|
||||
}
|
||||
|
||||
function grokOauthPlugin(suffix: string, token: string, providerName?: string): Record<string, unknown> {
|
||||
return {
|
||||
...bearerAuthPlugin(suffix, token, {}, providerName),
|
||||
request: {
|
||||
headers: {
|
||||
"x-grok-model-override": "{{ model }}"
|
||||
},
|
||||
strict: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshGrokAuth(auth: GrokTokenSet): Promise<GrokTokenSet> {
|
||||
const refreshToken = auth.refreshToken;
|
||||
if (!refreshToken) {
|
||||
throw new Error("Grok CLI refresh token was not found.");
|
||||
}
|
||||
const clientId = auth.oidcClientId || readString(process.env.GROK_OIDC_CLIENT_ID);
|
||||
if (!clientId) {
|
||||
throw new Error("Grok CLI OAuth client id was not found.");
|
||||
}
|
||||
|
||||
const tokenEndpoint = await grokTokenEndpoint(auth);
|
||||
const timeoutMs = normalizeGrokOauthTimeout(process.env.GROK_OIDC_REFRESH_TIMEOUT_MS);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetchWithSystemProxy(tokenEndpoint, {
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken
|
||||
}).toString(),
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
method: "POST",
|
||||
signal: controller.signal
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = parseJsonRecord(text);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Grok CLI OAuth token refresh returned HTTP ${response.status}${tokenRefreshErrorMessage(payload, text)}`);
|
||||
}
|
||||
const accessToken = readString(payload?.access_token) || readString(payload?.accessToken);
|
||||
if (!accessToken) {
|
||||
throw new Error("Grok CLI OAuth token refresh did not return an access token.");
|
||||
}
|
||||
const refreshed: GrokTokenSet = {
|
||||
...auth,
|
||||
accessToken,
|
||||
expiresAt: refreshedGrokExpiresAt(accessToken, payload),
|
||||
refreshToken: readString(payload?.refresh_token) || readString(payload?.refreshToken) || refreshToken
|
||||
};
|
||||
persistRefreshedGrokAuth(refreshed);
|
||||
return refreshed;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error(`Grok CLI OAuth token refresh timed out after ${timeoutMs}ms.`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function grokTokenEndpoint(auth: GrokTokenSet): Promise<string> {
|
||||
const configured = readString(process.env.GROK_OIDC_TOKEN_ENDPOINT);
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
const issuer = (auth.oidcIssuer || readString(process.env.GROK_OIDC_ISSUER) || grokDefaultOidcIssuer).replace(/\/+$/, "");
|
||||
const metadataUrl = `${issuer}/.well-known/openid-configuration`;
|
||||
const timeoutMs = normalizeGrokOauthTimeout(process.env.GROK_OIDC_REFRESH_TIMEOUT_MS);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetchWithSystemProxy(metadataUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
signal: controller.signal
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = parseJsonRecord(text);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Grok CLI OIDC discovery returned HTTP ${response.status}${tokenRefreshErrorMessage(payload, text)}`);
|
||||
}
|
||||
const tokenEndpoint = readString(payload?.token_endpoint) || readString(payload?.tokenEndpoint);
|
||||
if (!tokenEndpoint) {
|
||||
throw new Error("Grok CLI OIDC discovery did not return a token endpoint.");
|
||||
}
|
||||
return tokenEndpoint;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error(`Grok CLI OIDC discovery timed out after ${timeoutMs}ms.`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function grokCredentialFiles(): string[] {
|
||||
const explicitFile = process.env.GROK_AUTH_FILE?.trim();
|
||||
return uniqueStrings([
|
||||
explicitFile,
|
||||
path.join(grokStorageRoot(), "auth.json"),
|
||||
path.join(grokStorageRoot(), "credentials.json")
|
||||
]);
|
||||
}
|
||||
|
||||
function grokConfigFiles(): string[] {
|
||||
const explicitFile = process.env.GROK_CONFIG_FILE?.trim();
|
||||
return uniqueStrings([
|
||||
explicitFile,
|
||||
path.join(grokStorageRoot(), "config.toml")
|
||||
]);
|
||||
}
|
||||
|
||||
function grokModelsCacheFile(): string {
|
||||
return process.env.GROK_MODELS_CACHE_FILE?.trim() || path.join(grokStorageRoot(), "models_cache.json");
|
||||
}
|
||||
|
||||
function grokStorageRoot(): string {
|
||||
const explicitRoot = process.env.GROK_HOME?.trim() || process.env.GROK_STORAGE_DIR?.trim() || process.env.GROK_CONFIG_DIR?.trim();
|
||||
if (explicitRoot) {
|
||||
return explicitRoot;
|
||||
}
|
||||
const homeDir = process.env.CCR_INTERNAL_HOME_DIR?.trim() || process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || os.homedir();
|
||||
return path.join(homeDir, ".grok");
|
||||
}
|
||||
|
||||
function grokRuntimeDefaultBaseUrl(): string {
|
||||
return process.env.GROK_CLI_CHAT_PROXY_BASE_URL?.trim() || grokDefaultBaseUrl;
|
||||
}
|
||||
|
||||
function grokBillingEndpoint(): string {
|
||||
return process.env.GROK_BILLING_ENDPOINT?.trim() || grokDefaultBillingEndpoint;
|
||||
}
|
||||
|
||||
function grokSubscriptionEndpoint(): string {
|
||||
return process.env.GROK_SUBSCRIPTION_ENDPOINT?.trim() || grokDefaultSubscriptionEndpoint;
|
||||
}
|
||||
|
||||
function readNullableString(value: unknown): string | null | undefined {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
return readString(value) || undefined;
|
||||
}
|
||||
|
||||
function dateMs(value: string | undefined): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
}
|
||||
|
||||
function jwtExpiresAtMs(token: string | undefined): number | undefined {
|
||||
const encoded = token?.split(".")[1];
|
||||
if (!encoded) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const padded = encoded.padEnd(encoded.length + ((4 - encoded.length % 4) % 4), "=");
|
||||
const payload = JSON.parse(Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")) as unknown;
|
||||
const exp = isRecord(payload) && typeof payload.exp === "number" ? payload.exp : undefined;
|
||||
return exp ? exp * 1000 : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshedGrokExpiresAt(accessToken: string, payload: Record<string, unknown> | undefined): string | undefined {
|
||||
const expiresAtMs = jwtExpiresAtMs(accessToken) ?? expiresInMs(payload?.expires_in) ?? expiresInMs(payload?.expiresIn);
|
||||
return expiresAtMs ? new Date(expiresAtMs).toISOString() : undefined;
|
||||
}
|
||||
|
||||
function expiresInMs(value: unknown): number | undefined {
|
||||
const seconds = typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string" && value.trim()
|
||||
? Number(value)
|
||||
: undefined;
|
||||
return seconds && Number.isFinite(seconds) ? Date.now() + seconds * 1000 : undefined;
|
||||
}
|
||||
|
||||
function persistRefreshedGrokAuth(auth: GrokTokenSet): void {
|
||||
if (!auth.sourceFile || !auth.accessToken) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(auth.sourceFile, "utf8")) as unknown;
|
||||
if (!isRecord(parsed)) {
|
||||
return;
|
||||
}
|
||||
let target: Record<string, unknown> = parsed;
|
||||
if (auth.authRecordKey) {
|
||||
const authRecord = parsed[auth.authRecordKey];
|
||||
if (isRecord(authRecord)) {
|
||||
target = authRecord;
|
||||
}
|
||||
}
|
||||
target.key = auth.accessToken;
|
||||
if (auth.refreshToken) {
|
||||
target.refresh_token = auth.refreshToken;
|
||||
}
|
||||
if (auth.expiresAt) {
|
||||
target.expires_at = auth.expiresAt;
|
||||
}
|
||||
writeFileSync(auth.sourceFile, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
|
||||
} catch {
|
||||
// Best effort. The refreshed token is still used for this CCR run.
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonRecord(text: string): Record<string, unknown> | undefined {
|
||||
try {
|
||||
const payload = JSON.parse(text) as unknown;
|
||||
return isRecord(payload) ? payload : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function tokenRefreshErrorMessage(payload: Record<string, unknown> | undefined, text: string): string {
|
||||
const message =
|
||||
readString(payload?.error_description) ||
|
||||
readString(payload?.error) ||
|
||||
readString(payload?.message) ||
|
||||
readableResponseSnippet(text);
|
||||
return message ? `: ${message}` : "";
|
||||
}
|
||||
|
||||
function readableResponseSnippet(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim().slice(0, 200);
|
||||
}
|
||||
|
||||
function normalizeGrokOauthTimeout(value: unknown): number {
|
||||
const numeric = Number(value);
|
||||
return Math.max(1, Number.isFinite(numeric) ? numeric : grokOauthDefaultTimeoutMs);
|
||||
}
|
||||
|
||||
export function grokModelCatalogFromPayloadForTest(payload: unknown, preferredModel?: string): GrokModelCatalog {
|
||||
return grokModelCatalogFromPayload(payload, preferredModel);
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import type {
|
||||
} from "@ccr/core/contracts/app";
|
||||
import { claudeCodeCandidate, importClaudeCodeProvider } from "@ccr/core/agents/local-providers/claude-code";
|
||||
import { codexCandidate, importCodexProvider, probeCodexProvider } from "@ccr/core/agents/local-providers/codex";
|
||||
import { grokCandidate, importGrokProvider } from "@ccr/core/agents/local-providers/grok";
|
||||
import { importZcodeProvider, zcodeCandidate } from "@ccr/core/agents/local-providers/zcode";
|
||||
|
||||
export { codexDefaultBaseUrl, readCodexAuth } from "@ccr/core/agents/local-providers/codex";
|
||||
export { grokDefaultBaseUrl, readGrokAuth, resolveGrokAuth } from "@ccr/core/agents/local-providers/grok";
|
||||
export { readZcodeLocalProviderCredential, zcodeDefaultBaseUrl } from "@ccr/core/agents/local-providers/zcode";
|
||||
export { localAgentProviderApiKey, type OAuthTokenSet } from "@ccr/core/agents/local-providers/shared";
|
||||
|
||||
@@ -17,6 +19,7 @@ export function getLocalAgentProviderCandidates(): LocalAgentProviderCandidate[]
|
||||
return [
|
||||
codexCandidate(),
|
||||
claudeCodeCandidate(),
|
||||
grokCandidate(),
|
||||
zcodeCandidate()
|
||||
].filter((candidate) => candidate.status !== "missing");
|
||||
}
|
||||
@@ -36,6 +39,9 @@ export async function importLocalAgentProvider(request: LocalAgentProviderImport
|
||||
if (candidate.kind === "claude-code") {
|
||||
return importClaudeCodeProvider(candidate, request.providerNames ?? []);
|
||||
}
|
||||
if (candidate.kind === "grok") {
|
||||
return importGrokProvider(candidate, request.providerNames ?? []);
|
||||
}
|
||||
return importZcodeProvider(candidate, request.providerNames ?? []);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { loadPersistedAppConfig, replacePersistedAppConfig } from "@ccr/core/con
|
||||
import { loadPersistedApiKeys, replacePersistedApiKeys } from "@ccr/core/config/api-key-store";
|
||||
import { CONFIG_FILE, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE, LEGACY_WINDOWS_CONFIG_FILE } from "@ccr/core/config/constants";
|
||||
import { normalizeCodexProviderAccountConfig } from "@ccr/core/agents/local-providers/codex";
|
||||
import { normalizeGrokProviderAccountConfig } from "@ccr/core/agents/local-providers/grok";
|
||||
import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent } from "@ccr/core/contracts/app";
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config";
|
||||
import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "@ccr/core/providers/presets/index";
|
||||
@@ -1067,7 +1068,7 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
|
||||
transformer: item.transformer,
|
||||
type: readString(item.type)
|
||||
};
|
||||
return normalizeCodexProviderAccountConfig(provider);
|
||||
return normalizeGrokProviderAccountConfig(normalizeCodexProviderAccountConfig(provider));
|
||||
})
|
||||
.filter((item): item is GatewayProviderConfig => Boolean(item));
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ export type ProviderAccountStatus = "ok" | "warning" | "critical" | "error" | "u
|
||||
export type ProviderAccountMeterKind = "balance" | "subscription" | "quota" | "time_window" | "tokens" | "requests";
|
||||
export type ProviderAccountMeterUnit = "USD" | "CNY" | "hours" | "minutes" | "tokens" | "requests" | string;
|
||||
export type ProviderAccountMeterWindow = "5h" | "daily" | "weekly" | "monthly" | string;
|
||||
export type ProviderAccountHttpJsonParser = "kimi-code-usages" | "new-api-key-usage" | "new-api-user-self";
|
||||
export type ProviderAccountHttpJsonParser = "grok-subscription" | "kimi-code-usages" | "new-api-key-usage" | "new-api-user-self";
|
||||
|
||||
export type ProviderAccountConfig = {
|
||||
connectors?: ProviderAccountConnectorConfig[];
|
||||
@@ -329,7 +329,7 @@ export type ProviderManifestFetchResult = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type LocalAgentProviderKind = "claude-code" | "codex" | "zcode";
|
||||
export type LocalAgentProviderKind = "claude-code" | "codex" | "grok" | "zcode";
|
||||
|
||||
export type LocalAgentProviderStatus = "available" | "locked" | "missing";
|
||||
|
||||
|
||||
@@ -42,7 +42,8 @@ import { normalizeProviderBaseUrl as normalizeProviderBaseUrlInput } from "@ccr/
|
||||
import { backendService } from "@ccr/core/plugins/backend-service";
|
||||
import { RAW_TRACE_SPOOL_DIR } from "@ccr/core/config/constants";
|
||||
import { loadPersistedApiKeys } from "@ccr/core/config/api-key-store";
|
||||
import { codexDefaultBaseUrl, readCodexAuth } from "@ccr/core/agents/local-providers/service";
|
||||
import { codexDefaultBaseUrl, readCodexAuth, readGrokAuth, resolveGrokAuth } from "@ccr/core/agents/local-providers/service";
|
||||
import { grokAccessTokenExpired } from "@ccr/core/agents/local-providers/grok";
|
||||
import { fetchWithSystemProxy, getSystemProxyUrlForProtocol } from "@ccr/core/proxy/system-proxy-fetch";
|
||||
import { handleNetworkCaptureMcpRequest, isNetworkCaptureMcpPath } from "@ccr/core/mcp/network-capture-mcp";
|
||||
import { BROWSER_AUTOMATION_MCP_PATH, TOOL_HUB_MCP_SERVER_NAME, browserAutomationMcpEnabled, toolHubBuiltInBackendServers, toolHubMcpRuntimeConfig, toolHubRequestTimeoutMs } from "@ccr/core/mcp/toolhub-config";
|
||||
@@ -1176,10 +1177,10 @@ async function writeCoreGatewayConfig(
|
||||
assertLoopbackCoreHost(config.gateway.coreHost);
|
||||
mkdirSync(dirname(config.gateway.generatedConfigFile), { mode: privateDirMode, recursive: true });
|
||||
const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig();
|
||||
const providerPlugins = withCodexOauthRuntimeDefaults([
|
||||
const providerPlugins = await withGrokOauthRuntimeDefaults(withCodexOauthRuntimeDefaults([
|
||||
...(config.providerPlugins ?? []).filter(providerPluginEnabled),
|
||||
...pluginService.getCoreProviderPlugins().filter(providerPluginEnabled)
|
||||
]);
|
||||
]));
|
||||
const codexOauthProviderNames = codexOauthLocalProviderNames(providerPlugins);
|
||||
const virtualModelProfiles = normalizeCoreGatewayVirtualModelProfiles(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([
|
||||
...(config.virtualModelProfiles ?? []),
|
||||
@@ -1398,6 +1399,31 @@ function withCodexOauthRuntimeDefaults(providerPlugins: unknown[]): unknown[] {
|
||||
});
|
||||
}
|
||||
|
||||
async function withGrokOauthRuntimeDefaults(providerPlugins: unknown[]): Promise<unknown[]> {
|
||||
const grokAuth = await resolveGrokAuth().catch(() => readGrokAuth());
|
||||
if (!grokAuth?.accessToken || grokAccessTokenExpired(grokAuth)) {
|
||||
return providerPlugins;
|
||||
}
|
||||
|
||||
return providerPlugins.map((plugin) => {
|
||||
if (!isLocalGrokOauthProviderPlugin(plugin)) {
|
||||
return plugin;
|
||||
}
|
||||
const currentAuth = isRecord(plugin.auth) ? plugin.auth : {};
|
||||
const currentHeaders = isRecord(currentAuth.headers) ? currentAuth.headers : {};
|
||||
return {
|
||||
...plugin,
|
||||
auth: {
|
||||
...currentAuth,
|
||||
headers: {
|
||||
...currentHeaders,
|
||||
authorization: `Bearer ${grokAuth.accessToken}`
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function codexOauthLocalProviderNames(providerPlugins: unknown[]): Set<string> {
|
||||
const names = new Set<string>();
|
||||
for (const plugin of providerPlugins) {
|
||||
@@ -1455,6 +1481,14 @@ function isLocalCodexOauthProviderPlugin(value: unknown): value is Record<string
|
||||
return key.startsWith("ccr-local-agent-") && key.includes("codex-oauth");
|
||||
}
|
||||
|
||||
function isLocalGrokOauthProviderPlugin(value: unknown): value is Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const key = stringValue(value.key)?.toLowerCase() ?? "";
|
||||
return key.startsWith("ccr-local-agent-") && key.includes("grok-cli-oauth");
|
||||
}
|
||||
|
||||
function withCodexBackendRequestTransform(request: unknown): Record<string, unknown> {
|
||||
const currentRequest = isRecord(request) ? request : {};
|
||||
const bodyRemove = Array.isArray(currentRequest.bodyRemove)
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
codexDefaultBaseUrl,
|
||||
localAgentProviderApiKey,
|
||||
readCodexAuth,
|
||||
readGrokAuth,
|
||||
resolveGrokAuth,
|
||||
readZcodeLocalProviderCredential,
|
||||
zcodeDefaultBaseUrl
|
||||
} from "@ccr/core/agents/local-providers/service";
|
||||
import { grokAccessTokenExpired } from "@ccr/core/agents/local-providers/grok";
|
||||
import { pluginService } from "@ccr/core/plugins/service";
|
||||
import { getUsageTotalsSince } from "@ccr/core/usage/store";
|
||||
import { findProviderPresetByBaseUrl, providerEndpointCanReceiveProviderApiKey } from "@ccr/core/providers/presets/index";
|
||||
@@ -163,6 +166,16 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR
|
||||
type: "http-json"
|
||||
};
|
||||
const payload = await fetchJson(connector.endpoint, provider, connector.auth, connector.headers, connector.method, connector.body);
|
||||
if (connector.parser === "grok-subscription") {
|
||||
const meters = grokSubscriptionMeters(payload);
|
||||
return {
|
||||
meters,
|
||||
message: grokSubscriptionMessage(payload),
|
||||
paths: flattenJsonPaths(payload),
|
||||
payload,
|
||||
status: grokSubscriptionStatus(payload) ?? statusFromMeters(meters, [], 1)
|
||||
};
|
||||
}
|
||||
if (connector.parser === "kimi-code-usages") {
|
||||
const meters = kimiCodeUsageMeters(payload);
|
||||
return {
|
||||
@@ -647,6 +660,15 @@ async function resolveHttpJsonConnector(
|
||||
...(connector.headers ?? {}),
|
||||
...(request.headers ?? {})
|
||||
}, connector.method, connector.body);
|
||||
if (connector.parser === "grok-subscription") {
|
||||
return {
|
||||
errors: [],
|
||||
message: grokSubscriptionMessage(payload),
|
||||
meters: grokSubscriptionMeters(payload),
|
||||
source: "http-json",
|
||||
status: grokSubscriptionStatus(payload)
|
||||
};
|
||||
}
|
||||
if (connector.parser === "kimi-code-usages") {
|
||||
const meters = kimiCodeUsageMeters(payload);
|
||||
return {
|
||||
@@ -846,6 +868,110 @@ function normalizeRemoteSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
function grokSubscriptionMeters(payload: unknown): ProviderAccountMeter[] {
|
||||
const allowAccess = grokSubscriptionBoolean(payload, [
|
||||
"allow_access",
|
||||
"allowAccess",
|
||||
"has_grok_code_access",
|
||||
"hasGrokCodeAccess"
|
||||
]);
|
||||
if (allowAccess === undefined) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: "grok_subscription_access",
|
||||
kind: "subscription",
|
||||
label: "Subscription access",
|
||||
limit: 100,
|
||||
remaining: allowAccess ? 100 : 0,
|
||||
source: "http-json",
|
||||
unit: "%",
|
||||
used: allowAccess ? 0 : 100,
|
||||
window: "subscription"
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function grokSubscriptionMessage(payload: unknown): string | undefined {
|
||||
return grokSubscriptionString(payload, [
|
||||
"gate_message",
|
||||
"gateMessage",
|
||||
"subscription_tier_display",
|
||||
"subscriptionTierDisplay",
|
||||
"subscription_tier",
|
||||
"subscriptionTier",
|
||||
"tier_display",
|
||||
"tierDisplay",
|
||||
"tier",
|
||||
"user_blocked_reason",
|
||||
"userBlockedReason",
|
||||
"team_blocked_reason",
|
||||
"teamBlockedReason"
|
||||
]);
|
||||
}
|
||||
|
||||
function grokSubscriptionStatus(payload: unknown): ProviderAccountStatus | undefined {
|
||||
const allowAccess = grokSubscriptionBoolean(payload, [
|
||||
"allow_access",
|
||||
"allowAccess",
|
||||
"has_grok_code_access",
|
||||
"hasGrokCodeAccess"
|
||||
]);
|
||||
if (allowAccess === false) {
|
||||
return "critical";
|
||||
}
|
||||
if (grokSubscriptionString(payload, ["gate_message", "gateMessage", "gate_label", "gateLabel"])) {
|
||||
return "warning";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function grokSubscriptionBoolean(payload: unknown, keys: string[]): boolean | undefined {
|
||||
for (const record of grokSubscriptionRecords(payload)) {
|
||||
for (const key of keys) {
|
||||
const value = readBoolean(readJsonRecordValue(record, key));
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function grokSubscriptionString(payload: unknown, keys: string[]): string | undefined {
|
||||
for (const record of grokSubscriptionRecords(payload)) {
|
||||
for (const key of keys) {
|
||||
const value = readString(readJsonRecordValue(record, key));
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function grokSubscriptionRecords(payload: unknown): Record<string, unknown>[] {
|
||||
if (!isRecord(payload)) {
|
||||
return [];
|
||||
}
|
||||
const records: Record<string, unknown>[] = [];
|
||||
const queue = [payload];
|
||||
for (const record of queue) {
|
||||
if (records.includes(record)) {
|
||||
continue;
|
||||
}
|
||||
records.push(record);
|
||||
for (const key of ["account", "data", "meta", "subscription", "user", "viewer_context", "viewerContext"]) {
|
||||
const nested = readJsonRecordValue(record, key);
|
||||
if (isRecord(nested)) {
|
||||
queue.push(nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function newApiKeyUsageMeters(payload: unknown): ProviderAccountMeter[] {
|
||||
const meter = newApiKeyUsageMeter(payload);
|
||||
return meter ? [meter] : [];
|
||||
@@ -1252,6 +1378,9 @@ async function localAgentProviderAccountCredential(
|
||||
if (key.includes("claude-code-oauth")) {
|
||||
return localBearerAccountCredential(plugin);
|
||||
}
|
||||
if (key.includes("grok-cli-oauth")) {
|
||||
return await localGrokAccountCredential(plugin);
|
||||
}
|
||||
if (key.includes("zcode-api-key")) {
|
||||
return localApiKeyHeaderAccountCredential(plugin);
|
||||
}
|
||||
@@ -1573,6 +1702,18 @@ function localBearerAccountCredential(plugin: Record<string, unknown>): { apiKey
|
||||
};
|
||||
}
|
||||
|
||||
async function localGrokAccountCredential(plugin: Record<string, unknown>): Promise<{ apiKey?: string; headers?: Record<string, string> }> {
|
||||
const headers = localProviderPluginAuthHeaders(plugin);
|
||||
const auth = await resolveGrokAuth().catch(() => readGrokAuth());
|
||||
const apiKey = auth?.accessToken && !grokAccessTokenExpired(auth)
|
||||
? auth.accessToken
|
||||
: readBearerToken(headers.authorization || headers.Authorization);
|
||||
return {
|
||||
apiKey,
|
||||
headers: withoutHeader(headers, "authorization")
|
||||
};
|
||||
}
|
||||
|
||||
function localApiKeyHeaderAccountCredential(plugin: Record<string, unknown>): { apiKey?: string; headers?: Record<string, string> } {
|
||||
const headers = localProviderPluginAuthHeaders(plugin);
|
||||
const apiKey = headers["x-api-key"] || headers["X-API-Key"];
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1189,7 +1189,7 @@ function LocalAgentProviderImportPanel({
|
||||
apiKey: result.provider.apiKey ?? "",
|
||||
baseUrl: result.provider.baseUrl,
|
||||
credentials: [],
|
||||
icon: result.provider.icon ?? "",
|
||||
icon: result.provider.icon?.trim() || localAgentProviderIconUrls[candidate.kind] || "",
|
||||
modelDescriptions: result.provider.modelDescriptions,
|
||||
modelDisplayNames: result.provider.modelDisplayNames,
|
||||
modelMetadata: result.provider.modelMetadata,
|
||||
@@ -1214,7 +1214,7 @@ function LocalAgentProviderImportPanel({
|
||||
<div className="mb-2 flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-semibold text-foreground">{t("Import local agent login")}</div>
|
||||
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground">{t("CCR scanned this computer for Claude Code, Codex, and ZCode login states. Click Import to add one as a gateway provider.")}</div>
|
||||
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground">{t("CCR scanned this computer for Claude Code, Codex, Grok CLI, and ZCode login states. Click Import to add one as a gateway provider.")}</div>
|
||||
</div>
|
||||
{loading ? <LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" /> : null}
|
||||
</div>
|
||||
@@ -1281,6 +1281,7 @@ const localAgentProviderApiKey = "ccr-local-agent-login";
|
||||
const localAgentProviderPluginSuffixes: Record<LocalAgentProviderCandidate["kind"], string[]> = {
|
||||
"claude-code": ["-claude-code-oauth", "-claude-code-oauth-internal"],
|
||||
codex: ["-codex-oauth", "-codex-oauth-internal"],
|
||||
grok: ["-grok-cli-oauth", "-grok-cli-oauth-internal"],
|
||||
zcode: ["-zcode-api-key", "-zcode-api-key-internal"]
|
||||
};
|
||||
|
||||
|
||||
@@ -729,6 +729,7 @@ export function providerPluginCapability(item: Record<string, unknown>): string
|
||||
const capabilities: string[] = ["Provider middleware"];
|
||||
if (item.deepseekThinking || item.deepSeekThinking) capabilities.push("DeepSeek thinking");
|
||||
if (item.codexOauth) capabilities.push("Codex OAuth");
|
||||
if (typeof item.key === "string" && item.key.includes("grok-cli-oauth")) capabilities.push("Grok OAuth");
|
||||
if (item.auth) capabilities.push("Auth mutation");
|
||||
if (item.request) capabilities.push("Request mutation");
|
||||
if (item.response) capabilities.push("Response mutation");
|
||||
|
||||
@@ -259,7 +259,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Checking connection": "Checking connection",
|
||||
"Click Check Connection to verify connectivity with a real model request.": "Click Check Connection to verify connectivity with a real model request.",
|
||||
"Connection verified": "Connection verified",
|
||||
"CCR scanned this computer for Claude Code, Codex, and ZCode login states. Click Import to add one as a gateway provider.": "CCR scanned this computer for Claude Code, Codex, and ZCode login states. Click Import to add one as a gateway provider.",
|
||||
"CCR scanned this computer for Claude Code, Codex, Grok CLI, and ZCode login states. Click Import to add one as a gateway provider.": "CCR scanned this computer for Claude Code, Codex, Grok CLI, and ZCode login states. Click Import to add one as a gateway provider.",
|
||||
"Detected": "Detected",
|
||||
"Detecting protocols": "Detecting protocols",
|
||||
"Enter API endpoint, API key, and at least one model to enable connectivity check.": "Enter API endpoint, API key, and at least one model to enable connectivity check.",
|
||||
@@ -270,6 +270,9 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Claude Code login was detected, but no usable access token was found.": "Claude Code login was detected, but no usable access token was found.",
|
||||
"Codex auth file was found, but no usable token was detected.": "Codex auth file was found, but no usable token was detected.",
|
||||
"Claude Code credential file was found, but no usable OAuth token was detected.": "Claude Code credential file was found, but no usable OAuth token was detected.",
|
||||
"Grok CLI login detected. Click Import to add it as a gateway provider.": "Grok CLI login detected. Click Import to add it as a gateway provider.",
|
||||
"Grok CLI login was detected, but no usable access token was found.": "Grok CLI login was detected, but no usable access token was found.",
|
||||
"Grok CLI login was detected, but the access token is expired. Run grok login again, then rescan.": "Grok CLI login was detected, but the access token is expired. Run grok login again, then rescan.",
|
||||
"Locked": "Locked",
|
||||
"Local agent login will be connected after saving this provider.": "Local agent login will be connected after saving this provider.",
|
||||
"Models to check": "Models to check",
|
||||
@@ -770,7 +773,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Default failure handling": "默认故障处理",
|
||||
"Default on failure": "默认失败处理",
|
||||
"Description": "描述",
|
||||
"CCR scanned this computer for Claude Code, Codex, and ZCode login states. Click Import to add one as a gateway provider.": "CCR 已扫描本机的 Claude Code、Codex 和 ZCode 登录态。点击导入即可添加为网关供应商。",
|
||||
"CCR scanned this computer for Claude Code, Codex, Grok CLI, and ZCode login states. Click Import to add one as a gateway provider.": "CCR 已扫描本机的 Claude Code、Codex、Grok CLI 和 ZCode 登录态。点击导入即可添加为网关供应商。",
|
||||
"Detected": "已检测",
|
||||
"Detecting protocols": "正在探测协议",
|
||||
"Enter API endpoint, API key, and at least one model to enable connectivity check.": "填写 API 地址、API Key 和至少一个模型后,才可检测连通性。",
|
||||
@@ -780,6 +783,9 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Claude Code login was detected, but no usable access token was found.": "已检测到 Claude Code 登录态,但没有找到可用的 access token。",
|
||||
"Codex auth file was found, but no usable token was detected.": "已找到 Codex 认证文件,但没有检测到可用 token。",
|
||||
"Claude Code credential file was found, but no usable OAuth token was detected.": "已找到 Claude Code 凭据文件,但没有检测到可用 OAuth token。",
|
||||
"Grok CLI login detected. Click Import to add it as a gateway provider.": "已检测到 Grok CLI 登录态。点击导入即可添加为网关供应商。",
|
||||
"Grok CLI login was detected, but no usable access token was found.": "已检测到 Grok CLI 登录态,但没有找到可用的 access token。",
|
||||
"Grok CLI login was detected, but the access token is expired. Run grok login again, then rescan.": "已检测到 Grok CLI 登录态,但 access token 已过期。请重新运行 grok login,然后重新扫描。",
|
||||
"Locked": "已加密",
|
||||
"Local agent login will be connected after saving this provider.": "保存这个供应商后会接入本机 Agent 登录态。",
|
||||
"Display name": "显示名称",
|
||||
|
||||
@@ -102,6 +102,7 @@ import { cn } from "@/lib/utils";
|
||||
import appLogoUrl from "@/assets/logo.png";
|
||||
import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png";
|
||||
import codexLogoUrl from "@/assets/agent-logos/codex.png";
|
||||
import grokLogoUrl from "@/assets/agent-logos/grok.ico";
|
||||
import zcodeLogoUrl from "@/assets/agent-logos/zcode.png";
|
||||
import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg";
|
||||
import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png";
|
||||
@@ -388,9 +389,12 @@ import type { AddProviderDraft, AddRoutingRuleDraft, ModelCatalogItem, ProviderC
|
||||
export const localAgentProviderIconUrls: Record<LocalAgentProviderKind, string> = {
|
||||
"claude-code": claudeCodeLogoUrl,
|
||||
codex: codexLogoUrl,
|
||||
grok: grokLogoUrl,
|
||||
zcode: zcodeLogoUrl
|
||||
};
|
||||
|
||||
const localAgentProviderApiKeyValue = "ccr-local-agent-login";
|
||||
|
||||
export function createModelCatalogItems(config: AppConfig): ModelCatalogItem[] {
|
||||
const rows: ModelCatalogItem[] = [];
|
||||
config.Providers.forEach((provider, providerIndex) => {
|
||||
@@ -774,6 +778,9 @@ export function providerDeepLinkDisplayIcon(payload: ProviderDeepLinkPayload): s
|
||||
}
|
||||
|
||||
export function providerDisplayIcon(provider: GatewayProviderConfig): string {
|
||||
if (isLocalGrokProvider(provider)) {
|
||||
return grokLogoUrl;
|
||||
}
|
||||
const icon = provider.icon?.trim();
|
||||
if (icon) {
|
||||
return icon;
|
||||
@@ -783,6 +790,15 @@ export function providerDisplayIcon(provider: GatewayProviderConfig): string {
|
||||
return preset ? providerPresetIconUrls[preset.id] ?? "" : "";
|
||||
}
|
||||
|
||||
function isLocalGrokProvider(provider: GatewayProviderConfig): boolean {
|
||||
if (providerApiKey(provider) !== localAgentProviderApiKeyValue) {
|
||||
return false;
|
||||
}
|
||||
const baseUrl = normalizeProviderBaseUrl(providerBaseUrl(provider)).toLowerCase();
|
||||
const name = provider.name?.toLowerCase() ?? "";
|
||||
return baseUrl.includes("cli-chat-proxy.grok.com") || name.includes("grok");
|
||||
}
|
||||
|
||||
export type ProviderDeepLinkCatalogModelsResolution = {
|
||||
modelDisplayNames?: Record<string, string>;
|
||||
modelMetadata?: Record<string, ProviderModelMetadata>;
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
grokCandidate,
|
||||
grokDefaultBillingEndpoint,
|
||||
grokDefaultBaseUrl,
|
||||
grokDefaultSubscriptionEndpoint,
|
||||
grokModelCatalogFromPayloadForTest,
|
||||
importGrokProvider,
|
||||
normalizeGrokProviderAccountConfig
|
||||
} from "../../packages/core/src/agents/local-providers/grok.ts";
|
||||
import { localAgentProviderApiKey } from "../../packages/core/src/agents/local-providers/shared.ts";
|
||||
|
||||
test("Grok local provider imports bearer token and model override plugin", async () => {
|
||||
await withGrokHome(async (grokHome) => {
|
||||
writeGrokAuth(grokHome, {
|
||||
key: "grok-access-token",
|
||||
refresh_token: "grok-refresh-token",
|
||||
expires_at: "2999-01-01T00:00:00Z"
|
||||
});
|
||||
writeFileSync(path.join(grokHome, "config.toml"), "[models]\ndefault = \"grok-4.5\"\n");
|
||||
writeGrokModels(grokHome);
|
||||
|
||||
const candidate = grokCandidate();
|
||||
assert.equal(candidate.kind, "grok");
|
||||
assert.equal(candidate.importable, true);
|
||||
assert.equal(candidate.protocol, "openai_responses");
|
||||
assert.deepEqual(candidate.models, ["grok-4.5", "grok-composer-2.5-fast"]);
|
||||
assert.deepEqual(candidate.modelDisplayNames, {
|
||||
"grok-4.5": "Grok 4.5",
|
||||
"grok-composer-2.5-fast": "Composer 2.5"
|
||||
});
|
||||
|
||||
const result = await importGrokProvider(candidate, []);
|
||||
assert.equal(result.provider.name, "Grok CLI API");
|
||||
assert.equal(result.provider.baseUrl, grokDefaultBaseUrl);
|
||||
assert.equal(result.provider.protocol, "openai_responses");
|
||||
assert.equal(result.provider.apiKey, "ccr-local-agent-login");
|
||||
assert.equal(result.provider.account?.enabled, true);
|
||||
assert.equal(result.provider.account?.connectors?.length, 2);
|
||||
assert.equal(result.provider.account?.connectors?.[0]?.type, "http-json");
|
||||
assert.equal(result.provider.account?.connectors?.[0]?.auth, "provider-api-key");
|
||||
assert.equal(result.provider.account?.connectors?.[0]?.endpoint, grokDefaultBillingEndpoint);
|
||||
assert.equal(result.provider.account?.connectors?.[0]?.headers?.["x-grok-client-identifier"], "xai-grok-cli");
|
||||
assert.equal(result.provider.account?.connectors?.[0]?.headers?.["x-grok-client-version"], "0.2.93");
|
||||
assert.deepEqual(
|
||||
result.provider.account?.connectors?.[0]?.mapping.meters.map((meter) => meter.id),
|
||||
[
|
||||
"grok_credit_usage_percent",
|
||||
"grok_included_credits",
|
||||
"grok_total_credits",
|
||||
"grok_pay_as_you_go_cap",
|
||||
"grok_prepaid_balance"
|
||||
]
|
||||
);
|
||||
assert.equal(result.provider.account?.connectors?.[1]?.type, "http-json");
|
||||
assert.equal(result.provider.account?.connectors?.[1]?.auth, "provider-api-key");
|
||||
assert.equal(result.provider.account?.connectors?.[1]?.endpoint, grokDefaultSubscriptionEndpoint);
|
||||
assert.equal(result.provider.account?.connectors?.[1]?.headers?.["x-grok-client-identifier"], "xai-grok-cli");
|
||||
assert.equal(result.provider.account?.connectors?.[1]?.parser, "grok-subscription");
|
||||
assert.equal(result.providerPlugins.length, 2);
|
||||
assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer grok-access-token");
|
||||
assert.equal(result.providerPlugins[0].request.headers["x-grok-model-override"], "{{ model }}");
|
||||
assert.equal(result.providerPlugins[1].providerName, "__CCR_PROVIDER_INTERNAL_NAME__");
|
||||
});
|
||||
});
|
||||
|
||||
test("Grok local provider refreshes expired token during import", async (t) => {
|
||||
await withGrokHome(async (grokHome) => {
|
||||
writeGrokAuth(grokHome, {
|
||||
key: "expired-token",
|
||||
refresh_token: "grok-refresh-token",
|
||||
expires_at: "2000-01-01T00:00:00Z",
|
||||
oidc_client_id: "grok-client-id",
|
||||
oidc_issuer: "https://auth.x.ai"
|
||||
});
|
||||
writeGrokModels(grokHome);
|
||||
|
||||
const previousFetch = globalThis.fetch;
|
||||
const previousTokenEndpoint = process.env.GROK_OIDC_TOKEN_ENDPOINT;
|
||||
process.env.GROK_OIDC_TOKEN_ENDPOINT = "http://127.0.0.1/grok/oauth/token";
|
||||
let requestBody = "";
|
||||
globalThis.fetch = async (input, init) => {
|
||||
assert.equal(String(input), "http://127.0.0.1/grok/oauth/token");
|
||||
requestBody = String(init?.body ?? "");
|
||||
return new Response(JSON.stringify({
|
||||
access_token: "refreshed-grok-access-token",
|
||||
expires_in: 3600,
|
||||
refresh_token: "refreshed-grok-refresh-token"
|
||||
}), { headers: { "content-type": "application/json" }, status: 200 });
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = previousFetch;
|
||||
restoreEnv("GROK_OIDC_TOKEN_ENDPOINT", previousTokenEndpoint);
|
||||
});
|
||||
|
||||
const candidate = grokCandidate();
|
||||
assert.equal(candidate.kind, "grok");
|
||||
assert.equal(candidate.importable, true);
|
||||
assert.equal(candidate.status, "available");
|
||||
|
||||
const result = await importGrokProvider(candidate, []);
|
||||
assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer refreshed-grok-access-token");
|
||||
assert.equal(requestBody, "client_id=grok-client-id&grant_type=refresh_token&refresh_token=grok-refresh-token");
|
||||
|
||||
const persisted = JSON.parse(readFileSync(path.join(grokHome, "auth.json"), "utf8"));
|
||||
assert.equal(persisted["https://auth.x.ai::test-account"].key, "refreshed-grok-access-token");
|
||||
assert.equal(persisted["https://auth.x.ai::test-account"].refresh_token, "refreshed-grok-refresh-token");
|
||||
});
|
||||
});
|
||||
|
||||
test("Grok model catalog parser keeps responses models from the selected base URL", () => {
|
||||
const catalog = grokModelCatalogFromPayloadForTest({
|
||||
models: {
|
||||
"grok-4.5": {
|
||||
info: {
|
||||
api_backend: "responses",
|
||||
base_url: "https://cli-chat-proxy.grok.com/v1",
|
||||
context_window: 500000,
|
||||
model: "grok-4.5",
|
||||
name: "Grok 4.5",
|
||||
reasoning_effort: "high",
|
||||
supported_in_api: true
|
||||
}
|
||||
},
|
||||
"grok-hidden": {
|
||||
info: {
|
||||
api_backend: "responses",
|
||||
base_url: "https://cli-chat-proxy.grok.com/v1",
|
||||
hidden: true,
|
||||
model: "grok-hidden",
|
||||
name: "Hidden"
|
||||
}
|
||||
},
|
||||
"grok-chat": {
|
||||
info: {
|
||||
api_backend: "chat_completions",
|
||||
base_url: "https://cli-chat-proxy.grok.com/v1",
|
||||
model: "grok-chat",
|
||||
name: "Chat"
|
||||
}
|
||||
},
|
||||
"other-responses": {
|
||||
info: {
|
||||
api_backend: "responses",
|
||||
base_url: "https://example.com/v1",
|
||||
model: "other-responses",
|
||||
name: "Other"
|
||||
}
|
||||
}
|
||||
}
|
||||
}, "grok-4.5");
|
||||
|
||||
assert.deepEqual(catalog.models, ["grok-4.5"]);
|
||||
assert.deepEqual(catalog.modelDisplayNames, { "grok-4.5": "Grok 4.5" });
|
||||
assert.deepEqual(catalog.modelMetadata, { "grok-4.5": { defaultReasoningLevel: "high" } });
|
||||
assert.equal(catalog.baseUrl, grokDefaultBaseUrl);
|
||||
});
|
||||
|
||||
test("Grok local provider account config upgrades persisted usage mapping", () => {
|
||||
const provider = normalizeGrokProviderAccountConfig({
|
||||
account: {
|
||||
connectors: [],
|
||||
refreshIntervalMs: 45000
|
||||
},
|
||||
api_base_url: grokDefaultBaseUrl,
|
||||
api_key: localAgentProviderApiKey,
|
||||
models: ["grok-4.5"],
|
||||
name: "Grok CLI API",
|
||||
protocol: "openai_responses"
|
||||
});
|
||||
|
||||
const connector = provider.account?.connectors?.[0];
|
||||
const subscriptionConnector = provider.account?.connectors?.[1];
|
||||
assert.equal(provider.account?.refreshIntervalMs, 45000);
|
||||
assert.equal(connector?.type, "http-json");
|
||||
assert.equal(connector?.endpoint, grokDefaultBillingEndpoint);
|
||||
assert.equal(connector?.mapping.meters.find((meter) => meter.id === "grok_credit_usage_percent")?.unit, "%");
|
||||
assert.equal(subscriptionConnector?.type, "http-json");
|
||||
assert.equal(subscriptionConnector?.endpoint, grokDefaultSubscriptionEndpoint);
|
||||
assert.equal(subscriptionConnector?.parser, "grok-subscription");
|
||||
});
|
||||
|
||||
test("Grok local provider account config upgrades old web usage endpoints", () => {
|
||||
const provider = normalizeGrokProviderAccountConfig({
|
||||
account: {
|
||||
connectors: [
|
||||
{
|
||||
endpoint: "https://grok.com/billing?format=credits",
|
||||
mapping: { meters: [] },
|
||||
type: "http-json"
|
||||
},
|
||||
{
|
||||
endpoint: "https://grok.com/user?include=subscription",
|
||||
mapping: { meters: [] },
|
||||
parser: "grok-subscription",
|
||||
type: "http-json"
|
||||
}
|
||||
]
|
||||
},
|
||||
api_base_url: grokDefaultBaseUrl,
|
||||
api_key: localAgentProviderApiKey,
|
||||
models: ["grok-4.5"],
|
||||
name: "Grok CLI API",
|
||||
protocol: "openai_responses"
|
||||
});
|
||||
|
||||
assert.equal(provider.account?.connectors?.[0]?.type, "http-json");
|
||||
assert.equal(provider.account?.connectors?.[0]?.endpoint, grokDefaultBillingEndpoint);
|
||||
assert.equal(provider.account?.connectors?.[1]?.type, "http-json");
|
||||
assert.equal(provider.account?.connectors?.[1]?.endpoint, grokDefaultSubscriptionEndpoint);
|
||||
});
|
||||
|
||||
test("Grok local provider account config keeps custom connectors", () => {
|
||||
const account = {
|
||||
connectors: [
|
||||
{
|
||||
endpoint: "https://example.com/usage",
|
||||
mapping: {
|
||||
meters: [
|
||||
{
|
||||
id: "custom",
|
||||
kind: "balance",
|
||||
label: "Custom",
|
||||
remaining: "$.balance",
|
||||
unit: "credits"
|
||||
}
|
||||
]
|
||||
},
|
||||
type: "http-json"
|
||||
}
|
||||
],
|
||||
enabled: true
|
||||
};
|
||||
|
||||
const provider = normalizeGrokProviderAccountConfig({
|
||||
account,
|
||||
api_base_url: grokDefaultBaseUrl,
|
||||
api_key: localAgentProviderApiKey,
|
||||
models: ["grok-4.5"],
|
||||
name: "Grok CLI API",
|
||||
protocol: "openai_responses"
|
||||
});
|
||||
|
||||
assert.equal(provider.account, account);
|
||||
});
|
||||
|
||||
async function withGrokHome(run) {
|
||||
const previousGrokHome = process.env.GROK_HOME;
|
||||
const previousGrokAuthFile = process.env.GROK_AUTH_FILE;
|
||||
const previousGrokConfigFile = process.env.GROK_CONFIG_FILE;
|
||||
const previousGrokModelsCacheFile = process.env.GROK_MODELS_CACHE_FILE;
|
||||
const previousGrokTokenEndpoint = process.env.GROK_OIDC_TOKEN_ENDPOINT;
|
||||
const grokHome = mkdtempSync(path.join(os.tmpdir(), "ccr-grok-test-"));
|
||||
process.env.GROK_HOME = grokHome;
|
||||
delete process.env.GROK_AUTH_FILE;
|
||||
delete process.env.GROK_CONFIG_FILE;
|
||||
delete process.env.GROK_MODELS_CACHE_FILE;
|
||||
delete process.env.GROK_OIDC_TOKEN_ENDPOINT;
|
||||
try {
|
||||
await run(grokHome);
|
||||
} finally {
|
||||
restoreEnv("GROK_HOME", previousGrokHome);
|
||||
restoreEnv("GROK_AUTH_FILE", previousGrokAuthFile);
|
||||
restoreEnv("GROK_CONFIG_FILE", previousGrokConfigFile);
|
||||
restoreEnv("GROK_MODELS_CACHE_FILE", previousGrokModelsCacheFile);
|
||||
restoreEnv("GROK_OIDC_TOKEN_ENDPOINT", previousGrokTokenEndpoint);
|
||||
rmSync(grokHome, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function restoreEnv(name, value) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function writeGrokAuth(grokHome, auth) {
|
||||
writeFileSync(path.join(grokHome, "auth.json"), JSON.stringify({
|
||||
"https://auth.x.ai::test-account": auth
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function writeGrokModels(grokHome) {
|
||||
writeFileSync(path.join(grokHome, "models_cache.json"), JSON.stringify({
|
||||
models: {
|
||||
"grok-4.5": {
|
||||
info: {
|
||||
api_backend: "responses",
|
||||
auth_scheme: "bearer",
|
||||
base_url: grokDefaultBaseUrl,
|
||||
context_window: 500000,
|
||||
hidden: false,
|
||||
model: "grok-4.5",
|
||||
name: "Grok 4.5",
|
||||
reasoning_effort: "high",
|
||||
supported_in_api: true
|
||||
}
|
||||
},
|
||||
"grok-composer-2.5-fast": {
|
||||
info: {
|
||||
api_backend: "responses",
|
||||
auth_scheme: "bearer",
|
||||
base_url: grokDefaultBaseUrl,
|
||||
context_window: 200000,
|
||||
hidden: false,
|
||||
model: "grok-composer-2.5-fast",
|
||||
name: "Composer 2.5",
|
||||
reasoning_effort: null,
|
||||
supported_in_api: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}, null, 2));
|
||||
}
|
||||
@@ -5,13 +5,102 @@ import path from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
localAgentProviderAccountCredentialForTest,
|
||||
localCodexAccountCredentialForTest
|
||||
localCodexAccountCredentialForTest,
|
||||
testProviderAccountConnector
|
||||
} from "../../packages/core/src/providers/account-service.ts";
|
||||
import {
|
||||
grokDefaultBillingEndpoint,
|
||||
grokDefaultBaseUrl,
|
||||
grokDefaultSubscriptionEndpoint,
|
||||
grokProviderAccountConfig
|
||||
} from "../../packages/core/src/agents/local-providers/grok.ts";
|
||||
|
||||
const localAgentProviderApiKey = "ccr-local-agent-login";
|
||||
const codexDefaultBaseUrl = "https://chatgpt.com/backend-api/codex";
|
||||
const zcodeDefaultBaseUrl = "https://zcode.z.ai/api/v1/zcode-plan/anthropic";
|
||||
|
||||
test("Grok billing connector maps credit usage payload", async (t) => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
let authorization = "";
|
||||
let clientIdentifier = "";
|
||||
let clientVersion = "";
|
||||
globalThis.fetch = async (input, init) => {
|
||||
assert.equal(String(input), grokDefaultBillingEndpoint);
|
||||
authorization = init?.headers?.authorization ?? "";
|
||||
clientIdentifier = init?.headers?.["x-grok-client-identifier"] ?? "";
|
||||
clientVersion = init?.headers?.["x-grok-client-version"] ?? "";
|
||||
return new Response(JSON.stringify({
|
||||
config: {
|
||||
billingPeriodEnd: "2026-08-01T00:00:00Z",
|
||||
creditUsagePercent: { val: 25 },
|
||||
includedUsed: { val: 10 },
|
||||
monthlyLimit: { val: 40 },
|
||||
onDemandCap: { val: 100 },
|
||||
onDemandUsed: { val: 5 },
|
||||
prepaidBalance: { val: 12 },
|
||||
totalUsed: { val: 15 }
|
||||
}
|
||||
}), { headers: { "content-type": "application/json" }, status: 200 });
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = previousFetch;
|
||||
});
|
||||
|
||||
const connector = grokProviderAccountConfig().connectors?.[0];
|
||||
assert.equal(connector?.type, "http-json");
|
||||
const result = await testProviderAccountConnector({
|
||||
apiKey: "grok-access-token",
|
||||
baseUrl: grokDefaultBaseUrl,
|
||||
connector,
|
||||
providerName: "Grok CLI API"
|
||||
});
|
||||
|
||||
assert.equal(authorization, "Bearer grok-access-token");
|
||||
assert.equal(clientIdentifier, "xai-grok-cli");
|
||||
assert.equal(clientVersion, "0.2.93");
|
||||
assert.equal(result.meters.find((meter) => meter.id === "grok_credit_usage_percent")?.remaining, 75);
|
||||
assert.equal(result.meters.find((meter) => meter.id === "grok_included_credits")?.remaining, 30);
|
||||
assert.equal(result.meters.find((meter) => meter.id === "grok_total_credits")?.used, 15);
|
||||
assert.equal(result.meters.find((meter) => meter.id === "grok_pay_as_you_go_cap")?.remaining, 95);
|
||||
assert.equal(result.meters.find((meter) => meter.id === "grok_prepaid_balance")?.remaining, 12);
|
||||
});
|
||||
|
||||
test("Grok subscription connector maps access status payload", async (t) => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
let authorization = "";
|
||||
let clientIdentifier = "";
|
||||
let clientVersion = "";
|
||||
globalThis.fetch = async (input, init) => {
|
||||
assert.equal(String(input), grokDefaultSubscriptionEndpoint);
|
||||
authorization = init?.headers?.authorization ?? "";
|
||||
clientIdentifier = init?.headers?.["x-grok-client-identifier"] ?? "";
|
||||
clientVersion = init?.headers?.["x-grok-client-version"] ?? "";
|
||||
return new Response(JSON.stringify({
|
||||
hasGrokCodeAccess: true,
|
||||
subscriptionTier: "SuperGrok Heavy"
|
||||
}), { headers: { "content-type": "application/json" }, status: 200 });
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = previousFetch;
|
||||
});
|
||||
|
||||
const connector = grokProviderAccountConfig().connectors?.[1];
|
||||
assert.equal(connector?.type, "http-json");
|
||||
const result = await testProviderAccountConnector({
|
||||
apiKey: "grok-access-token",
|
||||
baseUrl: grokDefaultBaseUrl,
|
||||
connector,
|
||||
providerName: "Grok CLI API"
|
||||
});
|
||||
|
||||
assert.equal(authorization, "Bearer grok-access-token");
|
||||
assert.equal(clientIdentifier, "xai-grok-cli");
|
||||
assert.equal(clientVersion, "0.2.93");
|
||||
assert.equal(result.status, "ok");
|
||||
assert.equal(result.message, "SuperGrok Heavy");
|
||||
assert.equal(result.meters.find((meter) => meter.id === "grok_subscription_access")?.remaining, 100);
|
||||
});
|
||||
|
||||
test("Codex local account credential refreshes when only a refresh token is available", async (t) => {
|
||||
const previousHome = process.env.CCR_INTERNAL_HOME_DIR;
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-codex-account-refresh-"));
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createProviderConfigFromDeepLink,
|
||||
createProviderDraft,
|
||||
createProviderInstallLinkFromDraft,
|
||||
localAgentProviderIconUrls,
|
||||
providerCapabilitiesForProtocols,
|
||||
providerCapabilityBaseUrlForProtocol,
|
||||
providerDisplayIcon,
|
||||
@@ -467,6 +468,17 @@ test("provider display icon prefers custom icons and falls back to preset icons"
|
||||
}),
|
||||
providerPresetIconUrls.gemini
|
||||
);
|
||||
assert.equal(
|
||||
providerDisplayIcon({
|
||||
api_base_url: "https://cli-chat-proxy.grok.com/v1",
|
||||
api_key: "ccr-local-agent-login",
|
||||
icon: "/assets/grok-old.svg",
|
||||
models: [],
|
||||
name: "Grok CLI API",
|
||||
type: "openai_responses"
|
||||
}),
|
||||
localAgentProviderIconUrls.grok
|
||||
);
|
||||
});
|
||||
|
||||
test("ProvidersView renders configured provider icons in the list", () => {
|
||||
|
||||
Reference in New Issue
Block a user