feat(grok): 支持 Web SSO 批量导入并转换为 Build OAuth

新增 Grok Web SSO → xAI Device Flow → Grok Build OAuth 导入链路,
支持管理员批量粘贴 SSO key 创建 OAuth 账号。

- 后端:ConvertSSOToBuild、ConvertFromSSO、POST /admin/grok/sso-to-oauth
- 批量:3 worker 并发,失败跳过并汇总 created/failed,worker panic recover
- 无 refresh_token 时写入 expires_at 并强制 auto_pause_on_expired
- 前端:SSO Cookie 导入入口、动态超时、中英文案、部分成功不关弹窗
- 测试:pkg/service/handler/前端超时单测;本地 Docker 真实 SSO e2e 通过
This commit is contained in:
jinfeijie bot
2026-07-14 01:09:07 +08:00
parent 7d239d62e8
commit ad4bf5c60d
17 changed files with 1316 additions and 35 deletions
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { post } = vi.hoisted(() => ({
post: vi.fn(),
}))
vi.mock('@/api/client', () => ({
apiClient: { post },
}))
import { createFromSSO, getGrokSSOImportTimeout } from '@/api/admin/grok'
describe('admin Grok SSO import API', () => {
beforeEach(() => {
post.mockReset()
post.mockResolvedValue({ data: { created: [], failed: [] } })
})
it.each([
[1, 180_000],
[3, 180_000],
[4, 270_000],
[7, 360_000],
])('uses a timeout sized for %i keys', async (keyCount, expectedTimeout) => {
expect(getGrokSSOImportTimeout(keyCount)).toBe(expectedTimeout)
await createFromSSO({
sso_tokens: Array.from({ length: keyCount }, (_, index) => `sso-${index + 1}`),
})
expect(post).toHaveBeenCalledWith(
'/admin/grok/sso-to-oauth',
expect.objectContaining({ sso_tokens: expect.any(Array) }),
{ timeout: expectedTimeout },
)
})
})
+50 -1
View File
@@ -34,11 +34,51 @@ export interface GrokTokenInfo {
scope?: string
client_id?: string
email?: string
sub?: string
team_id?: string
subscription_tier?: string
entitlement_status?: string
[key: string]: unknown
}
export interface GrokSSOToOAuthRequest {
sso_tokens: string[]
name?: string
notes?: string | null
proxy_id?: number | null
group_ids?: number[]
credentials?: Record<string, unknown>
extra?: Record<string, unknown>
concurrency?: number
load_factor?: number
priority?: number
rate_multiplier?: number
expires_at?: number | null
auto_pause_on_expired?: boolean
}
export interface GrokSSOToOAuthItemResult {
index: number
name?: string
email?: string
account?: unknown
error?: string
}
export interface GrokSSOToOAuthResponse {
created: GrokSSOToOAuthItemResult[]
failed: GrokSSOToOAuthItemResult[]
}
const GROK_SSO_IMPORT_CONCURRENCY = 3
const GROK_SSO_IMPORT_TIMEOUT_PER_BATCH_MS = 90_000
const GROK_SSO_IMPORT_TIMEOUT_BUFFER_MS = 90_000
export function getGrokSSOImportTimeout(keyCount: number): number {
const batches = Math.ceil(Math.max(1, keyCount) / GROK_SSO_IMPORT_CONCURRENCY)
return batches * GROK_SSO_IMPORT_TIMEOUT_PER_BATCH_MS + GROK_SSO_IMPORT_TIMEOUT_BUFFER_MS
}
export interface GrokQuotaWindow {
limit?: number | null
remaining?: number | null
@@ -119,4 +159,13 @@ export async function resetQuota(id: number): Promise<GrokQuotaResetResult> {
return data
}
export default { generateAuthUrl, exchangeCode, refreshGrokToken, queryQuota, resetQuota }
export async function createFromSSO(payload: GrokSSOToOAuthRequest): Promise<GrokSSOToOAuthResponse> {
const { data } = await apiClient.post<GrokSSOToOAuthResponse>(
'/admin/grok/sso-to-oauth',
payload,
{ timeout: getGrokSSOImportTimeout(payload.sso_tokens.length) }
)
return data
}
export default { generateAuthUrl, exchangeCode, refreshGrokToken, queryQuota, resetQuota, createFromSSO }
@@ -50,7 +50,7 @@
<input
v-model="form.name"
type="text"
required
:required="!isGrokSSOImport"
class="input"
:placeholder="t('admin.accounts.enterAccountName')"
data-tour="account-form-name"
@@ -355,7 +355,7 @@
<!-- Account Type Selection (Grok) -->
<div v-if="form.platform === 'grok'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2" data-tour="account-form-type">
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-3" data-tour="account-form-type">
<button
type="button"
@click="accountCategory = 'oauth-based'"
@@ -382,6 +382,36 @@
</div>
</button>
<button
type="button"
@click="accountCategory = 'sso_cookie'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'sso_cookie'
? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20'
: 'border-gray-200 hover:border-teal-300 dark:border-dark-600 dark:hover:border-teal-700'
]"
>
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'sso_cookie'
? 'bg-teal-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon name="link" size="sm" />
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.oauth.grok.ssoCookieAuth') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.oauth.grok.ssoCookieHint') }}
</span>
</div>
</button>
<button
type="button"
data-testid="grok-account-type-api-key"
@@ -1966,7 +1996,7 @@
<!-- OpenAI OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) -->
<div
v-if="(form.platform === 'openai' || form.platform === 'grok') && accountCategory === 'oauth-based'"
v-if="(form.platform === 'openai' || form.platform === 'grok') && isOAuthFlow"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
@@ -3080,12 +3110,15 @@
:show-proxy-warning="form.platform !== 'openai' && form.platform !== 'grok' && !!form.proxy_id"
:allow-multiple="form.platform === 'anthropic'"
:show-cookie-option="form.platform === 'anthropic'"
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'antigravity' || form.platform === 'grok'"
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'antigravity' || (form.platform === 'grok' && !isGrokSSOImport)"
:show-mobile-refresh-token-option="form.platform === 'openai'"
:show-session-token-option="false"
:show-access-token-option="false"
:show-codex-session-import-option="form.platform === 'openai'"
:show-codex-pat-option="form.platform === 'openai'"
:show-sso-option="form.platform === 'grok'"
:show-manual-option="!isGrokSSOImport"
:initial-input-method="initialOAuthInputMethod"
:platform="form.platform"
:show-project-id="geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@@ -3095,6 +3128,7 @@
@validate-session-token="handleValidateSessionToken"
@import-codex-session="handleOpenAIImportCodexSession"
@import-codex-pat="handleOpenAIImportCodexPAT"
@import-sso="handleGrokImportSSO"
/>
</div>
@@ -3492,6 +3526,7 @@ interface OAuthFlowExposed {
sessionToken: string
codexSession: string
codexPAT: string
ssoCookie: string
inputMethod: AuthInputMethod
reset: () => void
}
@@ -3595,7 +3630,7 @@ interface TempUnschedRuleForm {
// State
const step = ref(1)
const submitting = ref(false)
const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock' | 'service_account'>('oauth-based') // UI selection for account category
const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock' | 'service_account' | 'sso_cookie'>('oauth-based') // UI selection for account category
const addMethod = ref<AddMethod>('oauth') // For oauth-based: 'oauth' or 'setup-token'
const apiKeyBaseUrl = ref('https://api.anthropic.com')
const apiKeyValue = ref('')
@@ -3973,9 +4008,13 @@ const isOAuthFlow = computed(() => {
if (form.platform === 'anthropic' && accountCategory.value === 'bedrock') {
return false
}
return accountCategory.value === 'oauth-based'
return accountCategory.value === 'oauth-based' || (form.platform === 'grok' && accountCategory.value === 'sso_cookie')
})
const isGrokSSOImport = computed(() => form.platform === 'grok' && accountCategory.value === 'sso_cookie')
const initialOAuthInputMethod = computed<AuthInputMethod>(() => isGrokSSOImport.value ? 'sso_cookie' : 'manual')
const isManualInputMethod = computed(() => {
return oauthFlowRef.value?.inputMethod === 'manual'
})
@@ -4049,7 +4088,7 @@ watch(
}
if ((form.platform === 'gemini' || form.platform === 'anthropic') && category === 'service_account') {
form.type = 'service_account' as AccountType
} else if (category === 'oauth-based') {
} else if (category === 'oauth-based' || (form.platform === 'grok' && category === 'sso_cookie')) {
form.type = form.platform === 'anthropic' ? method as AccountType : 'oauth'
} else {
form.type = 'apikey'
@@ -4103,6 +4142,9 @@ watch(
if (newPlatform !== 'anthropic' && accountCategory.value === 'bedrock') {
accountCategory.value = 'oauth-based'
}
if (newPlatform !== 'grok' && accountCategory.value === 'sso_cookie') {
accountCategory.value = 'oauth-based'
}
// Reset Bedrock fields when switching platforms
bedrockAccessKeyId.value = ''
bedrockSecretAccessKey.value = ''
@@ -4766,7 +4808,7 @@ const handleVertexServiceAccountDrop = async (event: DragEvent) => {
const handleSubmit = async () => {
// For OAuth-based type, handle OAuth flow (goes to step 2)
if (isOAuthFlow.value) {
if (!form.name.trim()) {
if (!isGrokSSOImport.value && !form.name.trim()) {
appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
return
}
@@ -5204,6 +5246,76 @@ const handleGrokValidateRT = async (refreshTokenInput: string) => {
}
}
const handleGrokImportSSO = async (ssoInput: string) => {
// Align with OpenAI/Grok RT batch import: one token per line, no client-side dedupe.
const ssoTokens = ssoInput
.split('\n')
.map((token) => token.trim())
.filter((token) => token)
if (ssoTokens.length === 0) return
grokOAuth.loading.value = true
grokOAuth.error.value = ''
const credentials: Record<string, unknown> = {}
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
if (modelMapping) {
credentials.model_mapping = modelMapping
}
if (!applyTempUnschedConfig(credentials)) {
grokOAuth.loading.value = false
return
}
try {
const result = await adminAPI.grok.createFromSSO({
sso_tokens: ssoTokens,
name: form.name || undefined,
notes: form.notes || undefined,
proxy_id: form.proxy_id,
group_ids: form.group_ids,
credentials,
concurrency: form.concurrency,
load_factor: form.load_factor ?? undefined,
priority: form.priority,
rate_multiplier: form.rate_multiplier,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
})
const successCount = result.created?.length || 0
const failedCount = result.failed?.length || 0
if (successCount > 0 && failedCount === 0) {
appStore.showSuccess(
ssoTokens.length > 1
? t('admin.accounts.oauth.batchSuccess', { count: successCount })
: t('admin.accounts.accountCreated')
)
emit('created')
handleClose()
} else if (successCount > 0 && failedCount > 0) {
// Same as OpenAI/Grok RT: keep input, show failures, refresh list.
appStore.showWarning(
t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount })
)
grokOAuth.error.value = (result.failed || [])
.map((item) => `#${item.index}: ${item.error || 'Unknown error'}`)
.join('\n')
emit('created')
} else {
grokOAuth.error.value = (result.failed || [])
.map((item) => `#${item.index}: ${item.error || 'Unknown error'}`)
.join('\n') || t('admin.accounts.oauth.grok.failedToConvertSSO')
appStore.showError(t('admin.accounts.oauth.batchFailed'))
}
} catch (error: any) {
grokOAuth.error.value = error.response?.data?.detail || error.message || t('admin.accounts.oauth.grok.failedToConvertSSO')
appStore.showError(grokOAuth.error.value)
} finally {
grokOAuth.loading.value = false
}
}
// OpenAI OAuth 授权码兑换
const handleOpenAIExchange = async (authCode: string) => {
const oauthClient = openaiOAuth
@@ -15,7 +15,7 @@
{{ methodLabel }}
</label>
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center gap-2">
<label v-if="showManualOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
@@ -48,6 +48,17 @@
t(getOAuthKey('refreshTokenAuth'))
}}</span>
</label>
<label v-if="showSsoOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
value="sso_cookie"
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t(getOAuthKey('ssoCookieAuth'))
}}</span>
</label>
<label v-if="showMobileRefreshTokenOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
@@ -190,6 +201,81 @@
</div>
</div>
<!-- SSO Cookie Input (Grok Web -> Grok Build) -->
<div v-if="inputMethod === 'sso_cookie'" class="space-y-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
{{ t(getOAuthKey('ssoCookieDesc')) }}
</p>
<div class="mb-4">
<label
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<Icon name="key" size="sm" class="text-blue-500" />
{{ t(getOAuthKey('ssoCookieLabel')) }}
<span
v-if="parsedSSOCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{ t('admin.accounts.oauth.keysCount', { count: parsedSSOCount }) }}
</span>
</label>
<textarea
v-model="ssoCookieInput"
rows="5"
class="input w-full resize-y font-mono text-sm"
:placeholder="t(getOAuthKey('ssoCookiePlaceholder'))"
spellcheck="false"
></textarea>
<p class="mt-1 text-xs text-blue-600 dark:text-blue-400">
{{ t(getOAuthKey('ssoCookieHint')) }}
</p>
</div>
<div
v-if="error"
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
{{ error }}
</p>
</div>
<button
type="button"
class="btn btn-primary w-full"
:disabled="loading || !ssoCookieInput.trim()"
@click="handleImportSSO"
>
<svg
v-if="loading"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="sparkles" size="sm" class="mr-2" />
{{ loading ? t(getOAuthKey('convertingSSO')) : t(getOAuthKey('convertSSOAndCreate')) }}
</button>
</div>
</div>
<!-- Codex OAuth/session JSON batch import -->
<div v-if="inputMethod === 'codex_session'" class="space-y-4">
<div
@@ -737,6 +823,9 @@ interface Props {
showAccessTokenOption?: boolean
showCodexSessionImportOption?: boolean
showCodexPatOption?: boolean
showSsoOption?: boolean
showManualOption?: boolean
initialInputMethod?: AuthInputMethod
platform?: AccountPlatform // Platform type for different UI/text
showProjectId?: boolean // New prop to control project ID visibility
}
@@ -757,6 +846,9 @@ const props = withDefaults(defineProps<Props>(), {
showAccessTokenOption: false,
showCodexSessionImportOption: false,
showCodexPatOption: false,
showSsoOption: false,
showManualOption: true,
initialInputMethod: 'manual',
platform: 'anthropic',
showProjectId: true
})
@@ -771,6 +863,7 @@ const emit = defineEmits<{
'import-access-token': [accessToken: string]
'import-codex-session': [content: string]
'import-codex-pat': [accessToken: string]
'import-sso': [content: string]
'update:inputMethod': [method: AuthInputMethod]
}>()
@@ -807,19 +900,31 @@ const oauthImportantNotice = computed(() => {
})
// Local state
const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'manual')
const inputMethod = ref<AuthInputMethod>(props.initialInputMethod)
const authCodeInput = ref('')
const sessionKeyInput = ref('')
const refreshTokenInput = ref('')
const sessionTokenInput = ref('')
const codexSessionInput = ref('')
const codexPATInput = ref('')
const ssoCookieInput = ref('')
const showHelpDialog = ref(false)
const oauthState = ref('')
const projectId = ref('')
// Computed: show method selection when either cookie or refresh token option is enabled
const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showMobileRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption || props.showCodexSessionImportOption || props.showCodexPatOption)
// Computed: show method selection only when there is something to choose.
const methodOptionCount = computed(() => [
props.showManualOption,
props.showCookieOption,
props.showRefreshTokenOption,
props.showMobileRefreshTokenOption,
props.showSessionTokenOption,
props.showAccessTokenOption,
props.showCodexSessionImportOption,
props.showCodexPatOption,
props.showSsoOption
].filter(Boolean).length)
const showMethodSelection = computed(() => methodOptionCount.value > 1)
// Clipboard
const { copied, copyToClipboard } = useClipboard()
@@ -850,7 +955,18 @@ const parsedCodexSessionCount = computed(() => {
.filter((item) => item).length
})
const parsedSSOCount = computed(() => {
return ssoCookieInput.value
.split('\n')
.map((item) => item.trim())
.filter((item) => item).length
})
// Watchers
watch(() => props.initialInputMethod, (newVal) => {
inputMethod.value = newVal
})
watch(inputMethod, (newVal) => {
emit('update:inputMethod', newVal)
})
@@ -933,6 +1049,12 @@ const handleImportCodexPAT = () => {
}
}
const handleImportSSO = () => {
if (ssoCookieInput.value.trim()) {
emit('import-sso', ssoCookieInput.value.trim())
}
}
// Expose methods and state
defineExpose({
authCode: authCodeInput,
@@ -943,6 +1065,7 @@ defineExpose({
sessionToken: sessionTokenInput,
codexSession: codexSessionInput,
codexPAT: codexPATInput,
ssoCookie: ssoCookieInput,
inputMethod,
reset: () => {
authCodeInput.value = ''
@@ -953,7 +1076,8 @@ defineExpose({
sessionTokenInput.value = ''
codexSessionInput.value = ''
codexPATInput.value = ''
inputMethod.value = 'manual'
ssoCookieInput.value = ''
inputMethod.value = props.initialInputMethod
showHelpDialog.value = false
}
})
+1 -1
View File
@@ -3,7 +3,7 @@ import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
export type AddMethod = 'oauth' | 'setup-token'
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'mobile_refresh_token' | 'session_token' | 'access_token' | 'codex_session' | 'codex_pat'
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'mobile_refresh_token' | 'session_token' | 'access_token' | 'codex_session' | 'codex_pat' | 'sso_cookie'
export interface OAuthState {
authUrl: string
+2
View File
@@ -121,6 +121,8 @@ export function useGrokOAuth() {
client_id: tokenInfo.client_id,
scope: tokenInfo.scope,
email: tokenInfo.email,
sub: tokenInfo.sub,
team_id: tokenInfo.team_id,
subscription_tier: tokenInfo.subscription_tier,
entitlement_status: tokenInfo.entitlement_status,
base_url: 'https://cli-chat-proxy.grok.com/v1'
@@ -870,6 +870,13 @@ export default {
refreshTokenAuth: 'Manual RT Input',
refreshTokenDesc: 'Enter existing xAI refresh token(s). Supports batch input, one per line.',
refreshTokenPlaceholder: 'Paste your xAI refresh token...\nSupports multiple, one per line',
ssoCookieAuth: 'SSO Cookie Import',
ssoCookieDesc: 'Paste one Grok Web SSO key per line. The server will complete the xAI Device Flow and convert them into Grok Build OAuth credentials.',
ssoCookieLabel: 'Grok Web SSO Key',
ssoCookiePlaceholder: 'One SSO key per line\nSupports multiple, one per line',
ssoCookieHint: 'One SSO key per line. Multiple keys are imported with 3-way concurrency; expect about 90 seconds per batch. Use a matching-region proxy if needed.',
convertingSSO: 'Converting...',
convertSSOAndCreate: 'Convert & Create Account',
validating: 'Validating...',
validateAndCreate: 'Validate & Create Account',
pleaseEnterRefreshToken: 'Please enter Refresh Token',
@@ -877,6 +884,7 @@ export default {
missingExchangeParams: 'Missing authorization code, state, or OAuth session',
failedToExchangeCode: 'Failed to exchange Grok authorization code',
failedToValidateRT: 'Failed to validate Grok refresh token',
failedToConvertSSO: 'Failed to convert Grok SSO cookie',
errors: {
GROK_OAUTH_SESSION_NOT_FOUND:
'Grok OAuth session was not found or has expired. Generate a new auth URL and paste the newest callback URL.',
@@ -956,6 +956,13 @@ export default {
refreshTokenAuth: '手动输入 RT',
refreshTokenDesc: '输入已有的 xAI refresh token,支持批量输入(每行一个)。',
refreshTokenPlaceholder: '粘贴您的 xAI refresh token...\n支持多个,每行一个',
ssoCookieAuth: 'SSO Cookie 导入',
ssoCookieDesc: '每行粘贴一个 Grok Web SSO key,系统会自动走 xAI Device Flow 并转换为 Grok Build OAuth 凭据。',
ssoCookieLabel: 'Grok Web SSO Key',
ssoCookiePlaceholder: '每行一个 SSO key\n支持多个,每行一个',
ssoCookieHint: '每行一个 SSO key;多个 key 会 3 路并发导入,耗时约 90 秒 × 批次数,建议使用对应地区代理。',
convertingSSO: '转换中...',
convertSSOAndCreate: '转换并创建账号',
validating: '验证中...',
validateAndCreate: '验证并创建账号',
pleaseEnterRefreshToken: '请输入 Refresh Token',
@@ -963,6 +970,7 @@ export default {
missingExchangeParams: '缺少授权码、state 或 OAuth 会话',
failedToExchangeCode: 'Grok 授权码兑换失败',
failedToValidateRT: '验证 Grok refresh token 失败',
failedToConvertSSO: 'Grok SSO 转换失败',
errors: {
GROK_OAUTH_SESSION_NOT_FOUND:
'Grok OAuth 会话不存在或已过期。请重新生成授权链接,并粘贴最新的回调链接。',