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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions open-sse/config/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
33 changes: 0 additions & 33 deletions open-sse/config/providers/registry/xai-oauth/index.ts

This file was deleted.

38 changes: 38 additions & 0 deletions open-sse/config/providers/registry/xai/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RegistryEntry } from "../../shared.ts";
import { resolvePublicCred } from "../../shared.ts";

export const xaiProvider: RegistryEntry = {
id: "xai",
Expand Down Expand Up @@ -27,3 +28,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 || []),
],
};
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ───────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -228,6 +230,8 @@ export default function ProviderModalsPanel({
setShowTutorialModal,
t,
}: ProviderModalsPanelProps) {
const oauthProviderId = resolveProviderOAuthBackendId(providerId, providerInfo);

return (
<>
{showRiskNoticeModal && subscriptionRisk && (
Expand Down Expand Up @@ -288,7 +292,7 @@ export default function ProviderModalsPanel({
<OAuthModal
isOpen={showOAuthModal}
reauthConnection={reauthConnection}
provider={providerId}
provider={oauthProviderId}
providerInfo={providerInfo}
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 12 additions & 8 deletions src/app/(dashboard)/dashboard/providers/providerPageUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type StaticProviderCatalogCategory,
} from "@/lib/providers/catalog";
import {
getProviderConnectionFamilyIds,
isClaudeCodeCompatibleProvider,
supportsApiKeyOnFreeProvider,
supportsDualAuthProvider,
Expand Down Expand Up @@ -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)}`;
Expand All @@ -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;
}

/**
Expand Down
4 changes: 3 additions & 1 deletion src/app/api/providers/test-batch/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/lib/providers/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 23 additions & 4 deletions src/shared/constants/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,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

Expand Down
7 changes: 7 additions & 0 deletions src/shared/constants/providers/apikey/frontier-labs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/shared/constants/providers/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
Expand Down
1 change: 1 addition & 0 deletions src/shared/validation/providerSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/provider-connections-fetch-url-2998.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
44 changes: 44 additions & 0 deletions tests/unit/providers-page-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
);
});
2 changes: 1 addition & 1 deletion tests/unit/xai-oauth-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading