From 200358545034c323c481e9eda491f90edb2eb8b4 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Wed, 15 Jul 2026 06:52:18 +0000 Subject: [PATCH 1/3] fix(providers): accept Grok auth record imports --- .../components/grok-token-import.test.ts | 71 +++++++++ .../components/grok-token-import.tsx | 147 ++++++++++++++---- web/src/pages/providers/types.test.ts | 1 + 3 files changed, 193 insertions(+), 26 deletions(-) create mode 100644 web/src/pages/providers/components/grok-token-import.test.ts diff --git a/web/src/pages/providers/components/grok-token-import.test.ts b/web/src/pages/providers/components/grok-token-import.test.ts new file mode 100644 index 00000000..a198b634 --- /dev/null +++ b/web/src/pages/providers/components/grok-token-import.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeGrokConfig } from './grok-token-import'; + +describe('normalizeGrokConfig', () => { + it('accepts direct CPA xAI credential JSON', () => { + expect( + normalizeGrokConfig({ + type: 'xai', + auth_kind: 'oauth', + email: 'user@example.com', + access_token: 'access-token', + refresh_token: 'refresh-token', + base_url: 'https://cli-chat-proxy.grok.com/v1', + }), + ).toMatchObject({ + type: 'xai', + authKind: 'oauth', + email: 'user@example.com', + accessToken: 'access-token', + refreshToken: 'refresh-token', + baseURL: 'https://cli-chat-proxy.grok.com/v1', + }); + }); + + it('accepts CLIProxyAPI auth records with credentials in metadata', () => { + expect( + normalizeGrokConfig({ + provider: 'xai', + disabled: true, + attributes: { auth_kind: 'oauth' }, + metadata: { + type: 'xai', + email: 'record@example.com', + access_token: 'record-access', + refresh_token: 'record-refresh', + }, + }), + ).toMatchObject({ + type: 'xai', + authKind: 'oauth', + email: 'record@example.com', + accessToken: 'record-access', + refreshToken: 'record-refresh', + disabled: true, + }); + }); + + it('accepts Maxx exported Grok providers with camelCase config', () => { + expect( + normalizeGrokConfig({ + type: 'grok', + name: 'Grok export', + config: { + grok: { + type: 'xai', + authKind: 'oauth', + email: 'maxx@example.com', + accessToken: 'maxx-access', + refreshToken: 'maxx-refresh', + }, + }, + }), + ).toMatchObject({ + type: 'xai', + authKind: 'oauth', + email: 'maxx@example.com', + accessToken: 'maxx-access', + refreshToken: 'maxx-refresh', + }); + }); +}); diff --git a/web/src/pages/providers/components/grok-token-import.tsx b/web/src/pages/providers/components/grok-token-import.tsx index 26f7200f..b111a365 100644 --- a/web/src/pages/providers/components/grok-token-import.tsx +++ b/web/src/pages/providers/components/grok-token-import.tsx @@ -12,7 +12,7 @@ import { useCreateModelMapping, useCreateProvider } from '@/hooks/queries'; import type { ClientType, CreateProviderData } from '@/lib/transport'; import type { ProviderConfigGrok } from '@/lib/transport/types'; -interface CPAxAIExportJSON { +export interface CPAxAIExportJSON { type?: string; auth_kind?: string; email?: string; @@ -31,9 +31,24 @@ interface CPAxAIExportJSON { headers?: Record; } +interface MaxxGrokProviderExport { + type?: string; + name?: string; + config?: { + grok?: ProviderConfigGrok; + }; +} + +interface CPAAuthRecordExport { + provider?: string; + disabled?: boolean; + attributes?: Record; + metadata?: CPAxAIExportJSON; +} + interface GrokImportItem { source: string; - raw: CPAxAIExportJSON; + raw: CPAxAIExportJSON | MaxxGrokProviderExport | CPAAuthRecordExport; } const GROK_CLIENT_TYPES = ['openai'] as const satisfies readonly ClientType[]; @@ -147,36 +162,111 @@ function ModelMappingsEditor({ mappings, onChange }: ModelMappingsEditorProps) { ); } -function normalizeGrokConfig(raw: CPAxAIExportJSON): ProviderConfigGrok { - if (raw.type !== 'xai') { - throw new Error(`Expected CPA xai credential JSON, got type=${raw.type || '(empty)'}`); +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === 'number' ? value : undefined; +} + +function booleanValue(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +function stringRecord(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + const out: Record = {}; + for (const [key, val] of Object.entries(value)) { + if (typeof val === 'string') out[key] = val; } - if ((raw.auth_kind || 'oauth') !== 'oauth') { - throw new Error(`Expected oauth credential JSON, got auth_kind=${raw.auth_kind || '(empty)'}`); + return Object.keys(out).length > 0 ? out : undefined; +} + +function normalizeSnakeCaseCredential(raw: Record): ProviderConfigGrok { + const type = stringValue(raw.type); + if (type !== 'xai') { + throw new Error(`Expected CPA xai credential JSON, got type=${type || '(empty)'}`); } - if (!raw.access_token && !raw.refresh_token) { + const authKind = stringValue(raw.auth_kind) || 'oauth'; + if (authKind !== 'oauth') { + throw new Error(`Expected oauth credential JSON, got auth_kind=${authKind || '(empty)'}`); + } + const accessToken = stringValue(raw.access_token); + const refreshToken = stringValue(raw.refresh_token); + if (!accessToken && !refreshToken) { throw new Error('access_token or refresh_token is required'); } return { type: 'xai', authKind: 'oauth', - email: raw.email, - sub: raw.sub, - accessToken: raw.access_token, - refreshToken: raw.refresh_token, - idToken: raw.id_token, - tokenType: raw.token_type, - expiresIn: raw.expires_in, - expired: raw.expired, - lastRefresh: raw.last_refresh, - redirectURI: raw.redirect_uri, - tokenEndpoint: raw.token_endpoint, - baseURL: raw.base_url, - disabled: raw.disabled, - headers: raw.headers, + email: stringValue(raw.email), + sub: stringValue(raw.sub), + accessToken, + refreshToken, + idToken: stringValue(raw.id_token), + tokenType: stringValue(raw.token_type), + expiresIn: numberValue(raw.expires_in), + expired: stringValue(raw.expired), + lastRefresh: stringValue(raw.last_refresh), + redirectURI: stringValue(raw.redirect_uri), + tokenEndpoint: stringValue(raw.token_endpoint), + baseURL: stringValue(raw.base_url), + disabled: booleanValue(raw.disabled), + headers: stringRecord(raw.headers), }; } +function normalizeMaxxGrokConfig(raw: ProviderConfigGrok): ProviderConfigGrok { + if ((raw.type || 'xai') !== 'xai') { + throw new Error(`Expected Maxx Grok xai config, got type=${raw.type || '(empty)'}`); + } + if ((raw.authKind || 'oauth') !== 'oauth') { + throw new Error(`Expected Maxx Grok oauth config, got authKind=${raw.authKind || '(empty)'}`); + } + if (!raw.accessToken && !raw.refreshToken) { + throw new Error('accessToken or refreshToken is required'); + } + return { + ...raw, + type: 'xai', + authKind: 'oauth', + }; +} + +export function normalizeGrokConfig( + raw: CPAxAIExportJSON | MaxxGrokProviderExport | CPAAuthRecordExport, +): ProviderConfigGrok { + if (!isRecord(raw)) { + throw new Error('Expected CPA xai credential JSON object'); + } + + // Maxx provider export/import payload: { type: 'grok', config: { grok: { ...camelCase } } }. + const config = raw.config; + if (isRecord(config) && isRecord(config.grok)) { + return normalizeMaxxGrokConfig(config.grok as ProviderConfigGrok); + } + + // CLIProxyAPI auth record export: { provider: 'xai', attributes: {...}, metadata: { type: 'xai', ... } }. + if (isRecord(raw.metadata)) { + const provider = stringValue(raw.provider); + if (provider && provider !== 'xai' && provider !== 'grok') { + throw new Error(`Expected CPA xai auth record, got provider=${provider}`); + } + const merged: Record = { ...raw.metadata }; + if (!merged.auth_kind && isRecord(raw.attributes)) merged.auth_kind = raw.attributes.auth_kind; + if (merged.disabled === undefined && typeof raw.disabled === 'boolean') + merged.disabled = raw.disabled; + return normalizeSnakeCaseCredential(merged); + } + + return normalizeSnakeCaseCredential(raw); +} + function providerName(grok: ProviderConfigGrok, source: string): string { if (grok.email) return `Grok (${grok.email})`; const stem = source.replace(/\.json$/i, '').trim(); @@ -186,7 +276,7 @@ function providerName(grok: ProviderConfigGrok, source: string): string { function parseImportItemsFromText(jsonText: string): GrokImportItem[] { const trimmed = jsonText.trim(); if (!trimmed) return []; - const parsed = JSON.parse(trimmed) as CPAxAIExportJSON | CPAxAIExportJSON[]; + const parsed = JSON.parse(trimmed) as GrokImportItem['raw'] | GrokImportItem['raw'][]; if (Array.isArray(parsed)) { return parsed.map((raw, index) => ({ source: `pasted item ${index + 1}`, raw })); } @@ -197,7 +287,7 @@ async function parseImportItemsFromFiles(files: FileList): Promise items.push({ source: `${file.name}#${index + 1}`, raw })); } else { @@ -286,7 +376,9 @@ export function GrokTokenImport() { return (
navigate('/providers/create')} />} + icon={ + navigate('/providers/create')} /> + } title={t('addProvider.grok.name')} description={t('addProvider.grok.description')} > @@ -315,7 +407,10 @@ export function GrokTokenImport() { {t('provider.basicInfo')}
-