mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 06:02:14 +03:00
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:
@@ -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";
|
||||
|
||||
@@ -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 || []),
|
||||
],
|
||||
};
|
||||
@@ -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 || []),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 ───────────────
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user