From 5a7487a60a3e21d1527dfbcc98612804a865fcb5 Mon Sep 17 00:00:00 2001 From: backryun Date: Sun, 16 Aug 2026 12:13:46 +0900 Subject: [PATCH] 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. --- open-sse/config/providers/index.ts | 3 +- .../providers/registry/xai-oauth/index.ts | 33 -------------- .../config/providers/registry/xai/index.ts | 38 ++++++++++++++++ .../[id]/ProviderDetailPageClient.tsx | 11 ++++- .../[id]/components/ProviderModalsPanel.tsx | 6 ++- .../__tests__/dual-auth-actions.test.tsx | 9 ++++ .../dashboard/providers/providerPageUtils.ts | 20 +++++---- src/app/api/providers/test-batch/route.ts | 4 +- src/lib/providers/catalog.ts | 2 + src/shared/constants/providers.ts | 27 ++++++++++-- .../providers/apikey/frontier-labs.ts | 7 +++ src/shared/constants/providers/oauth.ts | 3 ++ src/shared/validation/providerSchema.ts | 1 + ...rovider-connections-fetch-url-2998.test.ts | 4 ++ tests/unit/providers-page-utils.test.ts | 44 +++++++++++++++++++ tests/unit/xai-oauth-provider.test.ts | 2 +- 16 files changed, 162 insertions(+), 52 deletions(-) delete mode 100644 open-sse/config/providers/registry/xai-oauth/index.ts diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index ff09ad02b6..91584babc9 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -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"; diff --git a/open-sse/config/providers/registry/xai-oauth/index.ts b/open-sse/config/providers/registry/xai-oauth/index.ts deleted file mode 100644 index cd644a707e..0000000000 --- a/open-sse/config/providers/registry/xai-oauth/index.ts +++ /dev/null @@ -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 || []), - ], -}; diff --git a/open-sse/config/providers/registry/xai/index.ts b/open-sse/config/providers/registry/xai/index.ts index 6d2cc9621a..efd0a72e3a 100644 --- a/open-sse/config/providers/registry/xai/index.ts +++ b/open-sse/config/providers/registry/xai/index.ts @@ -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 || []), + ], +}; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index f08d62d870..4f9fb8388e 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -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 ─────────────── diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx index 92a2c40169..45aa63ec63 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx @@ -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({ setShowOAuthModal(false)} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/dual-auth-actions.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/dual-auth-actions.test.tsx index dff34c534f..3dfb48e616 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/dual-auth-actions.test.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/dual-auth-actions.test.tsx @@ -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); diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 1405ce875c..185be8e945 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -8,6 +8,7 @@ import { type StaticProviderCatalogCategory, } from "@/lib/providers/catalog"; import { + getProviderConnectionFamilyIds, isClaudeCodeCompatibleProvider, supportsApiKeyOnFreeProvider, supportsDualAuthProvider, @@ -204,13 +205,8 @@ type ProviderRecord> = Record = { - 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; } /** diff --git a/src/app/api/providers/test-batch/route.ts b/src/app/api/providers/test-batch/route.ts index f8acb869d6..8daab38afd 100644 --- a/src/app/api/providers/test-batch/route.ts +++ b/src/app/api/providers/test-batch/route.ts @@ -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); diff --git a/src/lib/providers/catalog.ts b/src/lib/providers/catalog.ts index 47cae957d7..1f53542c6b 100644 --- a/src/lib/providers/catalog.ts +++ b/src/lib/providers/catalog.ts @@ -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; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 05957989f0..526ec3ed9a 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -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> = { + 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 diff --git a/src/shared/constants/providers/apikey/frontier-labs.ts b/src/shared/constants/providers/apikey/frontier-labs.ts index cd1f98975a..45faf24359 100644 --- a/src/shared/constants/providers/apikey/frontier-labs.ts +++ b/src/shared/constants/providers/apikey/frontier-labs.ts @@ -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", diff --git a/src/shared/constants/providers/oauth.ts b/src/shared/constants/providers/oauth.ts index c9237f0711..ec2faa8a8c 100644 --- a/src/shared/constants/providers/oauth.ts +++ b/src/shared/constants/providers/oauth.ts @@ -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.", }, diff --git a/src/shared/validation/providerSchema.ts b/src/shared/validation/providerSchema.ts index 5fb2bc920f..929108e555 100644 --- a/src/shared/validation/providerSchema.ts +++ b/src/shared/validation/providerSchema.ts @@ -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(), diff --git a/tests/unit/provider-connections-fetch-url-2998.test.ts b/tests/unit/provider-connections-fetch-url-2998.test.ts index 3e20261216..c8c99a8c46 100644 --- a/tests/unit/provider-connections-fetch-url-2998.test.ts +++ b/tests/unit/provider-connections-fetch-url-2998.test.ts @@ -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"); +}); diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index 03811be80f..9e2081539a 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -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" + ) + ); +}); diff --git a/tests/unit/xai-oauth-provider.test.ts b/tests/unit/xai-oauth-provider.test.ts index 6fdba9a1ec..6d58db0bc6 100644 --- a/tests/unit/xai-oauth-provider.test.ts +++ b/tests/unit/xai-oauth-provider.test.ts @@ -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;