refactor(providers): unify xAI authentication entry point (#10201)

Present xAI API-key and OAuth connections through one dashboard card while preserving the distinct backend IDs required for refresh and quota handling.

Co-locate both registry entries and include canonical and legacy connection IDs in provider fetch and batch-test flows.
This commit is contained in:
backryun
2026-08-16 12:13:46 +09:00
committed by GitHub
parent 5239728d6f
commit 5a7487a60a
16 changed files with 162 additions and 52 deletions

View File

@@ -145,8 +145,7 @@ import { vertex_partnerProvider } from "./registry/vertex/partner/index.ts";
import { vertexProvider } from "./registry/vertex/index.ts";
import { duckduckgo_webProvider } from "./registry/duckduckgo-web/index.ts";
import { felo_webProvider } from "./registry/felo-web/index.ts";
import { xaiProvider } from "./registry/xai/index.ts";
import { xai_oauthProvider } from "./registry/xai-oauth/index.ts";
import { xaiProvider, xai_oauthProvider } from "./registry/xai/index.ts";
import { morphProvider } from "./registry/morph/index.ts";
import { siliconflowProvider } from "./registry/siliconflow/index.ts";
import { gitlab_duoProvider } from "./registry/gitlab-duo/index.ts";

View File

@@ -1,33 +0,0 @@
import type { RegistryEntry } from "../../shared.ts";
import { resolvePublicCred } from "../../shared.ts";
import { xaiProvider } from "../xai/index.ts";
export const xai_oauthProvider: RegistryEntry = {
id: "xai-oauth",
alias: "xao",
format: "openai",
executor: "xai-oauth",
baseUrl: xaiProvider.baseUrl,
responsesBaseUrl: xaiProvider.responsesBaseUrl,
authType: "oauth",
authHeader: "bearer",
passthroughModels: true,
oauth: {
clientIdEnv: "GROK_OAUTH_CLIENT_ID",
clientIdDefault: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"),
tokenUrl: "https://auth.x.ai/oauth2/token",
},
models: [
// SuperGrok / xAI OAuth serves grok-4.5 on native /v1/responses. Tag so
// chatCore translates OpenAI Chat Completions → Responses (messages→input,
// max_tokens→max_output_tokens). Without the tag, some 3.8.50 paths hit
// /v1/responses with a chat-shaped body → 422 missing `input` (#10165).
{
id: "grok-4.5",
name: "Grok 4.5",
contextLength: 500000,
targetFormat: "openai-responses",
},
...(xaiProvider.models || []),
],
};

View File

@@ -1,4 +1,5 @@
import type { RegistryEntry } from "../../shared.ts";
import { resolvePublicCred } from "../../shared.ts";
export const xaiProvider: RegistryEntry = {
id: "xai",
@@ -38,3 +39,40 @@ export const xaiProvider: RegistryEntry = {
{ id: "grok-4.20-0309-non-reasoning", name: "Grok 4.20" },
],
};
/**
* OAuth authentication variant for the unified xAI provider.
*
* Keep the backend ID distinct because refresh and quota handling key off
* `xai-oauth`, while co-locating both variants prevents their shared endpoint
* and model catalog from drifting apart.
*/
export const xai_oauthProvider: RegistryEntry = {
id: "xai-oauth",
alias: "xao",
format: xaiProvider.format,
executor: "xai-oauth",
baseUrl: xaiProvider.baseUrl,
responsesBaseUrl: xaiProvider.responsesBaseUrl,
authType: "oauth",
authHeader: xaiProvider.authHeader,
passthroughModels: true,
oauth: {
clientIdEnv: "GROK_OAUTH_CLIENT_ID",
clientIdDefault: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"),
tokenUrl: "https://auth.x.ai/oauth2/token",
},
models: [
// SuperGrok / xAI OAuth serves grok-4.5 on native /v1/responses. Tag so
// chatCore translates OpenAI Chat Completions → Responses (messages→input,
// max_tokens→max_output_tokens). Without the tag, some 3.8.50 paths hit
// /v1/responses with a chat-shaped body → 422 missing `input` (#10165).
{
id: "grok-4.5",
name: "Grok 4.5",
contextLength: 500000,
targetFormat: "openai-responses",
},
...(xaiProvider.models || []),
],
};

View File

@@ -31,7 +31,11 @@ import { normalizeModelCatalogSource } from "@/shared/utils/modelCatalogSearch";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { useNotificationStore } from "@/store/notificationStore";
import { resolveDashboardProviderInfo, resolveProviderHeaderLink } from "../providerPageUtils";
import {
resolveDashboardProviderInfo,
resolveProviderHeaderLink,
resolveProviderOAuthBackendId,
} from "../providerPageUtils";
import { findDefaultReferral } from "@/lib/radar/referrals";
import { type ConnectionRowConnection } from "./components/ConnectionRow";
import { useProviderConnections } from "./hooks/useProviderConnections";
@@ -254,8 +258,11 @@ export default function ProviderDetailPageClient() {
providerInfo?.website,
referralUrl
);
const oauthProviderId = resolveProviderOAuthBackendId(providerId, providerInfo);
const providerSupportsOAuth =
providerInfo?.toggleAuthType === "oauth" || providerInfo?.toggleAuthType === "free";
providerInfo?.toggleAuthType === "oauth" ||
providerInfo?.toggleAuthType === "free" ||
oauthProviderId !== providerId;
const subscriptionRisk = providerInfo?.subscriptionRisk === true;
// ── Phase 1t.3: connection gate + risk-notice modal state ───────────────

View File

@@ -30,9 +30,11 @@ import { type BatchTestResults } from "../hooks/useProviderConnections";
import { type ConnectionDeleteConfirmState } from "../hooks/useConnectionDeleteConfirm";
import { type ImportProgress } from "../hooks/useModelImportHandlers";
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
import { resolveProviderOAuthBackendId } from "../../providerPageUtils";
interface ProviderInfo {
name: string;
oauthProviderId?: string;
riskNoticeVariant?: string;
website?: string;
[key: string]: unknown;
@@ -228,6 +230,8 @@ export default function ProviderModalsPanel({
setShowTutorialModal,
t,
}: ProviderModalsPanelProps) {
const oauthProviderId = resolveProviderOAuthBackendId(providerId, providerInfo);
return (
<>
{showRiskNoticeModal && subscriptionRisk && (
@@ -288,7 +292,7 @@ export default function ProviderModalsPanel({
<OAuthModal
isOpen={showOAuthModal}
reauthConnection={reauthConnection}
provider={providerId}
provider={oauthProviderId}
providerInfo={providerInfo}
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}

View File

@@ -167,6 +167,15 @@ describe("dual-auth provider actions (#8882)", () => {
expectDualAuthActions(rendered.container, rendered);
});
it("renders OAuth Connect and Manual API key for empty xAI", () => {
const rendered = renderEmptyProvider({
providerId: "xai",
supportsDualAuth: true,
providerSupportsPat: false,
});
expectDualAuthActions(rendered.container, rendered);
});
it("renders OAuth Connect and Manual API key for populated CodeBuddy CN", () => {
const rendered = renderPopulatedCodeBuddy();
expectDualAuthActions(rendered.container, rendered);

View File

@@ -8,6 +8,7 @@ import {
type StaticProviderCatalogCategory,
} from "@/lib/providers/catalog";
import {
getProviderConnectionFamilyIds,
isClaudeCodeCompatibleProvider,
supportsApiKeyOnFreeProvider,
supportsDualAuthProvider,
@@ -204,13 +205,8 @@ type ProviderRecord<TProvider = Record<string, unknown>> = Record<string, TProvi
const OAUTH_CARD_API_KEY_CONNECTION_PROVIDER_IDS = new Set(["kiro", "amazon-q", "kimi-coding"]);
const PROVIDER_CONNECTION_ALIASES: Record<string, readonly string[]> = {
alibaba: ["alibaba-cn"],
"kimi-coding": ["kimi-coding-apikey"],
};
export function getProviderConnectionsRequestUrl(providerId: string): string {
const hasAliases = (PROVIDER_CONNECTION_ALIASES[providerId]?.length ?? 0) > 0;
const hasAliases = getProviderConnectionFamilyIds(providerId).length > 1;
return hasAliases
? "/api/providers"
: `/api/providers?provider=${encodeURIComponent(providerId)}`;
@@ -221,8 +217,16 @@ export function connectionBelongsToProviderPage(
providerId: string
): boolean {
if (!connectionProvider) return false;
if (connectionProvider === providerId) return true;
return PROVIDER_CONNECTION_ALIASES[providerId]?.includes(connectionProvider) === true;
return getProviderConnectionFamilyIds(providerId).includes(connectionProvider);
}
export function resolveProviderOAuthBackendId(
providerId: string,
provider: { oauthProviderId?: unknown } | null | undefined
): string {
return typeof provider?.oauthProviderId === "string" && provider.oauthProviderId.length > 0
? provider.oauthProviderId
: providerId;
}
/**

View File

@@ -12,6 +12,7 @@ import {
AUDIO_ONLY_PROVIDERS,
CLOUD_AGENT_PROVIDERS,
IDE_PROVIDER_IDS,
getProviderConnectionFamilyIds,
OPENAI_COMPATIBLE_PREFIX,
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
@@ -104,7 +105,8 @@ export async function POST(request) {
const idSet = new Set(connectionIds || []);
connectionsToTest = allConnections.filter((c) => idSet.has(c.id));
} else if (mode === "provider" && providerId) {
connectionsToTest = allConnections.filter((c) => c.provider === providerId);
const familyProviderIds = new Set(getProviderConnectionFamilyIds(providerId));
connectionsToTest = allConnections.filter((c) => familyProviderIds.has(c.provider));
} else if (mode === "oauth") {
connectionsToTest = allConnections.filter((c) => {
const authGroup = getAuthGroup(c.provider);

View File

@@ -51,6 +51,8 @@ export interface ProviderCatalogMetadata {
riskNoticeVariant?: RiskNoticeVariant;
apiType?: string;
baseUrl?: string;
/** Backend OAuth provider ID when one dashboard card fronts both auth modes. */
oauthProviderId?: string;
hiddenFromDashboard?: boolean;
/** Optional operator-supplied remote icon URL (#2166) for compatible provider nodes. */
iconUrl?: string;

View File

@@ -46,16 +46,35 @@ export function supportsApiKeyOnFreeProvider(providerId: unknown): boolean {
return typeof providerId === "string" && FREE_APIKEY_PROVIDER_IDS.has(providerId);
}
// OAuth-primary providers that also accept a direct API key. Keep these out of
// FREE_APIKEY_PROVIDER_IDS so the dashboard's primary action remains OAuth.
const DUAL_AUTH_PROVIDER_IDS = new Set(["clinepass", "codebuddy-cn"]);
// Providers presented as one dashboard card with OAuth as the primary action
// and a direct API-key alternative. Keep these out of FREE_APIKEY_PROVIDER_IDS.
const DUAL_AUTH_PROVIDER_IDS = new Set(["clinepass", "codebuddy-cn", "xai"]);
export function supportsDualAuthProvider(providerId: unknown): boolean {
return typeof providerId === "string" && DUAL_AUTH_PROVIDER_IDS.has(providerId);
}
// Web / Cookie Providers
/**
* Backend provider IDs that are managed from one dashboard provider family.
*
* Family members intentionally remain distinct in the registry and database:
* the xAI OAuth ID has different token-refresh and quota semantics from the
* API-key ID. Consumers that need to list or test every connection for a
* family should use getProviderConnectionFamilyIds() rather than duplicating
* this compatibility map.
*/
export const PROVIDER_CONNECTION_FAMILY_ALIASES: Readonly<Record<string, readonly string[]>> = {
alibaba: ["alibaba-cn"],
"kimi-coding": ["kimi-coding-apikey"],
xai: ["xai-oauth", "xao"],
};
export function getProviderConnectionFamilyIds(providerId: unknown): readonly string[] {
if (typeof providerId !== "string" || providerId.length === 0) return [];
return [providerId, ...(PROVIDER_CONNECTION_FAMILY_ALIASES[providerId] || [])];
}
// Web / Cookie Providers
// API Key Providers

View File

@@ -101,6 +101,13 @@ export const APIKEY_PROVIDERS_FRONTIER = {
textIcon: "XA",
website: "https://x.ai",
serviceKinds: ["llm", "imageToText"],
subscriptionRisk: true,
riskNoticeVariant: "oauth",
authHint:
"Use an official xAI API key, or sign in with xAI OAuth. Grok Build JWT sessions remain a separate provider.",
// The dashboard presents xAI as one dual-auth provider while preserving
// the separate backend OAuth provider ID for token refresh and quota flow.
oauthProviderId: "xai-oauth",
},
mistral: {
id: "mistral",

View File

@@ -26,6 +26,9 @@ export const OAUTH_PROVIDERS = {
website: "https://x.ai",
subscriptionRisk: true,
riskNoticeVariant: "oauth",
// Render xAI OAuth through the unified xAI dashboard card. Keep this
// catalog entry addressable for existing routes and stored connections.
hiddenFromDashboard: true,
authHint:
"Sign in with xAI to use api.x.ai models such as Grok 4.5. This is separate from Grok Build JWT sessions, which use cli-chat-proxy.grok.com and grok-build model aliases.",
},

View File

@@ -30,6 +30,7 @@ export const ProviderSchema = z.object({
freeNote: z.string().optional(),
authHint: z.string().optional(),
apiHint: z.string().optional(),
oauthProviderId: z.string().min(1).optional(),
serviceKinds: z.array(z.enum(SERVICE_KIND_VALUES)).optional(),
noAuth: z.boolean().optional(),
anonymousFallback: z.boolean().optional(),

View File

@@ -11,3 +11,7 @@ test("provider detail keeps alias-backed pages on the unfiltered request", () =>
assert.equal(getProviderConnectionsRequestUrl("alibaba"), "/api/providers");
assert.equal(getProviderConnectionsRequestUrl("kimi-coding"), "/api/providers");
});
test("unified xAI detail fetches all auth variants through the unfiltered request", () => {
assert.equal(getProviderConnectionsRequestUrl("xai"), "/api/providers");
});

View File

@@ -1102,3 +1102,47 @@ test("connectionMatchesProviderCard counts a dual-auth provider's PAT (apikey) c
assert.equal(connectionMatchesProviderCard(null, "qoder", "oauth"), false);
assert.equal(connectionMatchesProviderCard(undefined, "qoder", "oauth"), false);
});
test("unified xAI OAuth card includes canonical and legacy connection provider IDs", () => {
const {
buildStaticProviderEntries,
connectionBelongsToProviderPage,
connectionMatchesProviderCard,
resolveProviderOAuthBackendId,
} = providerPageUtils;
const connections = [
{ provider: "xai", authType: "apikey" },
{ provider: "xai-oauth", authType: "oauth" },
{ provider: "xao", authType: "oauth" },
];
assert.deepEqual(
connections
.filter((connection) => connectionBelongsToProviderPage(connection.provider, "xai"))
.map((connection) => connection.provider),
["xai", "xai-oauth", "xao"]
);
assert.deepEqual(
connections
.filter((connection) => connectionMatchesProviderCard(connection, "xai", "oauth"))
.map((connection) => connection.provider),
["xai", "xai-oauth", "xao"]
);
assert.equal(resolveProviderOAuthBackendId("xai", providers.APIKEY_PROVIDERS.xai), "xai-oauth");
assert.equal(
resolveProviderOAuthBackendId("openai", providers.APIKEY_PROVIDERS.openai),
"openai"
);
assert.equal(providers.OAUTH_PROVIDERS["xai-oauth"].hiddenFromDashboard, true);
assert.equal(providers.supportsDualAuthProvider("xai"), true);
const emptyStats = () => ({ total: 0 });
assert.ok(
buildStaticProviderEntries("apikey", emptyStats).some((entry) => entry.providerId === "xai")
);
assert.ok(
!buildStaticProviderEntries("oauth", emptyStats).some(
(entry) => entry.providerId === "xai-oauth"
)
);
});

View File

@@ -6,7 +6,7 @@ import { xaiOauth, decodeXaiIdTokenIdentity } from "../../src/lib/oauth/provider
import { XAI_OAUTH_CONFIG } from "../../src/lib/oauth/constants/oauth.ts";
import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts";
import { XaiExecutor } from "../../open-sse/executors/xai.ts";
import { xai_oauthProvider } from "../../open-sse/config/providers/registry/xai-oauth/index.ts";
import { xai_oauthProvider } from "../../open-sse/config/providers/registry/xai/index.ts";
const originalFetch = globalThis.fetch;