feat(providers): add xAI Grok OAuth with capability-gated X Search (#5035)

This commit is contained in:
chengyongru
2026-07-23 11:55:16 +08:00
committed by GitHub
parent c22efb5f7a
commit c7393c785e
38 changed files with 3881 additions and 104 deletions
+367 -52
View File
@@ -98,6 +98,7 @@ import { Textarea } from "@/components/ui/textarea";
import { isLoopbackHost } from "@/lib/network";
import {
checkVersion,
completeProviderOAuth,
createModelConfiguration,
disableNanobotFeature,
enableNanobotFeature,
@@ -166,6 +167,10 @@ import type {
NanobotFeaturesPayload,
NetworkSafetySettingsUpdate,
ProviderModelsPayload,
ProviderOAuthAuthorizationRequired,
ProviderOAuthCompletionResult,
ProviderOAuthLoginResult,
ProviderOAuthPending,
SessionAutomationJob,
SettingsPayload,
SkillSummary,
@@ -188,6 +193,18 @@ export type SettingsSectionKey =
| "runtime"
| "advanced";
function isProviderOAuthAuthorizationRequired(
payload: ProviderOAuthLoginResult,
): payload is ProviderOAuthAuthorizationRequired {
return (payload as ProviderOAuthAuthorizationRequired).status === "authorization_required";
}
function isProviderOAuthPending(
payload: ProviderOAuthCompletionResult,
): payload is ProviderOAuthPending {
return (payload as ProviderOAuthPending).status === "pending";
}
type AppsKindFilter = "ready" | "cli" | "mcp";
type AutomationFilter = "all" | "active" | "paused" | "failed" | "system";
type AutomationSort = "next" | "last" | "updated" | "name";
@@ -223,10 +240,16 @@ type RestartAwarePayload = {
runtime_capabilities?: SettingsPayload["runtime_capabilities"];
};
type ProviderApiType = "auto" | "chat_completions" | "responses";
type ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType };
type ProviderForm = {
apiKey: string;
apiBase: string;
apiType: ProviderApiType;
proxy: string;
};
type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144, 1_048_576] as const;
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144, 500_000, 1_048_576] as const;
const OAUTH_PROXY_PROVIDERS = new Set(["openai_codex", "xai_grok"]);
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
"aihubmix",
"atomic_chat",
@@ -541,6 +564,8 @@ export function SettingsView({
const { t } = useTranslation();
const { token } = useClient();
const pageVisible = usePageVisibility();
const remoteBrowserAccess =
typeof window !== "undefined" && !isLoopbackHost(window.location.hostname);
const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
const [nanobotFeatures, setNanobotFeatures] = useState<NanobotFeaturesPayload | null>(null);
@@ -565,6 +590,11 @@ export function SettingsView({
const [nanobotFeatureConfirm, setNanobotFeatureConfirm] = useState<NanobotFeatureInfo | null>(null);
const [mcpPresetAction, setMcpPresetAction] = useState<string | null>(null);
const [providerSaving, setProviderSaving] = useState<string | null>(null);
const [xaiOAuthFlow, setXaiOAuthFlow] =
useState<ProviderOAuthAuthorizationRequired | null>(null);
const xaiOAuthFlowRef = useRef<ProviderOAuthAuthorizationRequired | null>(null);
const [xaiOAuthCode, setXaiOAuthCode] = useState("");
const [xaiOAuthCompleting, setXaiOAuthCompleting] = useState(false);
const [webSearchSaving, setWebSearchSaving] = useState(false);
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
const [transcriptionSaving, setTranscriptionSaving] = useState(false);
@@ -658,6 +688,46 @@ export function SettingsView({
onSettingsChange?.(payload);
}, [onSettingsChange]);
const closeXaiOAuthFlow = useCallback(() => {
xaiOAuthFlowRef.current = null;
setXaiOAuthFlow(null);
setXaiOAuthCode("");
setXaiOAuthCompleting(false);
}, []);
useEffect(() => {
if (!xaiOAuthFlow) return;
let cancelled = false;
let timer: number | null = null;
const poll = async () => {
try {
const payload = await completeProviderOAuth(
token,
xaiOAuthFlow.provider,
xaiOAuthFlow.flow_id,
);
if (cancelled || xaiOAuthFlowRef.current?.flow_id !== xaiOAuthFlow.flow_id) return;
if (isProviderOAuthPending(payload)) {
timer = window.setTimeout(() => void poll(), 1000);
return;
}
applyPayload(payload);
setExpandedProvider(xaiOAuthFlow.provider);
setError(null);
closeXaiOAuthFlow();
} catch (err) {
if (cancelled || xaiOAuthFlowRef.current?.flow_id !== xaiOAuthFlow.flow_id) return;
setError((err as Error).message);
closeXaiOAuthFlow();
}
};
timer = window.setTimeout(() => void poll(), 1000);
return () => {
cancelled = true;
if (timer !== null) window.clearTimeout(timer);
};
}, [applyPayload, closeXaiOAuthFlow, token, xaiOAuthFlow]);
useEffect(() => {
if (!initialSettings || settings !== null) return;
applyPayload(initialSettings);
@@ -882,6 +952,7 @@ export function SettingsView({
apiKey: next[provider.name]?.apiKey ?? "",
apiBase: next[provider.name]?.apiBase ?? provider.api_base ?? provider.default_api_base ?? "",
apiType: next[provider.name]?.apiType ?? provider.api_type ?? "auto",
proxy: next[provider.name]?.proxy ?? provider.proxy ?? "",
};
}
return next;
@@ -1229,11 +1300,16 @@ export function SettingsView({
if (providerSaving) return;
const provider = settings?.providers.find((item) => item.name === providerName);
if (!provider) return;
if (provider.auth_type === "oauth") return;
const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "", apiType: "auto" };
const isOauthProvider = provider.auth_type === "oauth";
const providerForm = providerForms[providerName] ?? {
apiKey: "",
apiBase: "",
apiType: "auto",
proxy: provider.proxy ?? "",
};
const apiKey = providerForm.apiKey.trim();
const apiKeyRequired = provider.api_key_required ?? true;
if (!provider.configured && apiKeyRequired && !apiKey) {
if (!isOauthProvider && !provider.configured && apiKeyRequired && !apiKey) {
setError(t("settings.byok.apiKeyRequired"));
return;
}
@@ -1245,12 +1321,20 @@ export function SettingsView({
? "azure"
: null;
if (supportName && !(await installCapabilities([supportName]))) return;
const payload = await updateProviderSettings(token, {
provider: providerName,
apiKey: apiKey || undefined,
apiBase: providerForm.apiBase.trim(),
apiType: providerForm.apiType,
});
const payload = await updateProviderSettings(
token,
isOauthProvider
? {
provider: providerName,
proxy: providerForm.proxy.trim(),
}
: {
provider: providerName,
apiKey: apiKey || undefined,
apiBase: providerForm.apiBase.trim(),
apiType: providerForm.apiType,
},
);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, image: true }));
@@ -1262,6 +1346,7 @@ export function SettingsView({
apiKey: "",
apiBase: providerForm.apiBase.trim(),
apiType: providerForm.apiType,
proxy: providerForm.proxy.trim(),
},
}));
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
@@ -1276,22 +1361,75 @@ export function SettingsView({
const runProviderOAuth = async (providerName: string, action: "login" | "logout") => {
if (providerSaving) return;
let popup: Window | null = null;
if (action === "login" && providerName === "xai_grok" && !remoteBrowserAccess) {
try {
popup = window.open("about:blank", "_blank");
if (popup) popup.opener = null;
} catch {
popup = null;
}
}
setProviderSaving(providerName);
try {
const payload =
action === "login"
? await loginProviderOAuth(token, providerName)
: await logoutProviderOAuth(token, providerName);
if (isProviderOAuthAuthorizationRequired(payload)) {
try {
if (popup && !popup.closed) popup.location.href = payload.authorization_url;
} catch {
// The dialog keeps the authorization link available when the popup was closed.
}
xaiOAuthFlowRef.current = payload;
setXaiOAuthFlow(payload);
setXaiOAuthCode("");
setExpandedProvider(providerName);
setError(null);
return;
}
popup?.close();
closeXaiOAuthFlow();
applyPayload(payload);
setExpandedProvider(providerName);
setError(null);
} catch (err) {
popup?.close();
setError((err as Error).message);
} finally {
setProviderSaving(null);
}
};
const completeXaiOAuth = async () => {
const flow = xaiOAuthFlowRef.current;
const authorizationCode = xaiOAuthCode.trim();
if (!flow || !authorizationCode || xaiOAuthCompleting) return;
setXaiOAuthCompleting(true);
try {
const payload = await completeProviderOAuth(
token,
flow.provider,
flow.flow_id,
authorizationCode,
);
if (xaiOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
if (isProviderOAuthPending(payload)) return;
applyPayload(payload);
setExpandedProvider(flow.provider);
setError(null);
closeXaiOAuthFlow();
} catch (err) {
if (xaiOAuthFlowRef.current?.flow_id === flow.flow_id) {
setError((err as Error).message);
closeXaiOAuthFlow();
}
} finally {
setXaiOAuthCompleting(false);
}
};
const saveWebSearch = async () => {
if (!settings || webSearchSaving) return;
const provider = settings.web_search.providers.find((item) => item.name === webSearchForm.provider);
@@ -1364,6 +1502,7 @@ export function SettingsView({
apiKey: "",
apiBase: provider.api_base ?? provider.default_api_base ?? "",
apiType: provider.api_type ?? "auto",
proxy: provider.proxy ?? "",
},
}));
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
@@ -1418,6 +1557,7 @@ export function SettingsView({
apiKey: "",
apiBase: forms[providerName]?.apiBase ?? "",
apiType: forms[providerName]?.apiType ?? "auto",
proxy: forms[providerName]?.proxy ?? "",
},
}));
setVisibleProviderKeys((visible) => ({ ...visible, [providerName]: false }));
@@ -1671,6 +1811,7 @@ export function SettingsView({
providerSaving={providerSaving}
query={providerQuery}
showBrandLogos={localPrefs.brandLogos}
remoteBrowserAccess={remoteBrowserAccess}
onQueryChange={setProviderQuery}
onToggleProvider={handleToggleProvider}
onToggleProviderKey={toggleProviderKeyVisibility}
@@ -1682,6 +1823,7 @@ export function SettingsView({
apiKey: prev[provider]?.apiKey ?? "",
apiBase: prev[provider]?.apiBase ?? "",
apiType: prev[provider]?.apiType ?? "auto",
proxy: prev[provider]?.proxy ?? "",
...value,
},
}))
@@ -1917,6 +2059,21 @@ export function SettingsView({
onSave={handleCreateModelConfiguration}
/>
<XaiOAuthLoginDialog
flow={xaiOAuthFlow}
authorizationCode={xaiOAuthCode}
completing={xaiOAuthCompleting}
remoteBrowserAccess={remoteBrowserAccess}
onAuthorizationCodeChange={setXaiOAuthCode}
onOpenAuthorization={() => {
if (!xaiOAuthFlow) return;
const opened = window.open(xaiOAuthFlow.authorization_url, "_blank", "noopener,noreferrer");
if (opened) opened.opener = null;
}}
onComplete={() => void completeXaiOAuth()}
onClose={closeXaiOAuthFlow}
/>
<NanobotFeatureInstallDialog
feature={nanobotFeatureConfirm}
installing={nanobotFeatureAction === `enable:${nanobotFeatureConfirm?.name ?? ""}`}
@@ -2523,6 +2680,82 @@ function AppearanceSettings({
);
}
function XaiOAuthLoginDialog({
flow,
authorizationCode,
completing,
remoteBrowserAccess,
onAuthorizationCodeChange,
onOpenAuthorization,
onComplete,
onClose,
}: {
flow: ProviderOAuthAuthorizationRequired | null;
authorizationCode: string;
completing: boolean;
remoteBrowserAccess: boolean;
onAuthorizationCodeChange: (value: string) => void;
onOpenAuthorization: () => void;
onComplete: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
return (
<Dialog
open={Boolean(flow)}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="w-[min(calc(100vw-2rem),28rem)] rounded-[24px]">
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
onComplete();
}}
>
<DialogHeader>
<DialogTitle>xAI Grok</DialogTitle>
<DialogDescription>
{remoteBrowserAccess
? t("settings.oauth.remoteCodeHelp")
: t("settings.oauth.localCodeHelp")}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label
htmlFor="xai-oauth-code"
className="block text-xs font-medium text-foreground"
>
{t("settings.oauth.authorizationCode")}
</label>
<Input
id="xai-oauth-code"
value={authorizationCode}
onChange={(event) => onAuthorizationCodeChange(event.target.value)}
placeholder={t("settings.oauth.authorizationCode")}
aria-label={t("settings.oauth.authorizationCode")}
autoComplete="off"
spellCheck={false}
/>
</div>
<DialogFooter className="gap-2 sm:space-x-0">
<Button type="button" variant="outline" onClick={onOpenAuthorization}>
<ExternalLink className="mr-2 h-4 w-4" aria-hidden />
{t("settings.oauth.signIn")}
</Button>
<Button type="submit" disabled={!authorizationCode.trim() || completing}>
{completing ? t("settings.oauth.signingIn") : t("settings.oauth.finishSignIn")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function NewModelConfigurationDialog({
open,
draft,
@@ -2827,11 +3060,13 @@ function ModelsSettings({
label:
tokens === 1_048_576
? "1M"
: tokens === 262_144
? "256K"
: tokens === 200_000
? "200K"
: "64K",
: tokens === 500_000
? "500K"
: tokens === 262_144
? "256K"
: tokens === 200_000
? "200K"
: "64K",
}))}
onChange={(value) =>
setForm((prev) => ({
@@ -2871,6 +3106,7 @@ function ProvidersSettings({
providerSaving,
query,
showBrandLogos,
remoteBrowserAccess,
onQueryChange,
onToggleProvider,
onToggleProviderKey,
@@ -2895,6 +3131,7 @@ function ProvidersSettings({
providerSaving: string | null;
query: string;
showBrandLogos: boolean;
remoteBrowserAccess: boolean;
onQueryChange: (query: string) => void;
onToggleProvider: (provider: string) => void;
onToggleProviderKey: (provider: string) => void;
@@ -2923,14 +3160,20 @@ function ProvidersSettings({
apiKey: "",
apiBase: provider.api_base ?? provider.default_api_base ?? "",
apiType: provider.api_type ?? "auto",
proxy: provider.proxy ?? "",
};
const saving = providerSaving === provider.name;
const isOauthProvider = provider.auth_type === "oauth";
const supportsOauthProxy = isOauthProvider && OAUTH_PROXY_PROVIDERS.has(provider.name);
const keyVisible = !!visibleProviderKeys[provider.name];
const editingKey = !provider.configured || !!editingProviderKeys[provider.name];
const apiKeyRequired = provider.api_key_required ?? true;
const apiKey = form.apiKey.trim();
const apiBase = form.apiBase.trim();
const proxy = form.proxy.trim();
const oauthProxyDirty = supportsOauthProxy && proxy !== (provider.proxy ?? "").trim();
const oauthProxySaving = saving && oauthProxyDirty;
const oauthActionBusy = saving && !oauthProxySaving;
const missingRequiredApiKey = !isOauthProvider && apiKeyRequired && !provider.configured && !apiKey;
const missingOptionalCredential =
!isOauthProvider && !apiKeyRequired && !provider.configured && !apiKey && !apiBase;
@@ -2990,48 +3233,120 @@ function ProvidersSettings({
<p className="text-[12px] text-destructive">{capabilityError}</p>
) : null}
{isOauthProvider ? (
<div className="flex flex-col gap-3 rounded-[18px] border border-border/45 bg-background/75 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-[13px] font-semibold text-foreground">
{tx("settings.oauth.authentication", "OAuth authentication")}
</p>
<p className="mt-1 truncate text-[12px] text-muted-foreground">
{provider.configured
? t("settings.oauth.signedInAs", {
account: provider.oauth_account || provider.label,
defaultValue: "Signed in as {{account}}",
})
: tx("settings.oauth.signInHelp", "Sign in from this device; no API key is stored in config.")}
</p>
</div>
<div className="flex shrink-0 justify-end gap-2">
{provider.configured ? (
<>
<div className="flex flex-col gap-3 rounded-[18px] border border-border/45 bg-background/75 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-[13px] font-semibold text-foreground">
{tx("settings.oauth.authentication", "OAuth authentication")}
</p>
<p className="mt-1 text-[12px] text-muted-foreground">
{provider.configured
? t("settings.oauth.signedInAs", {
account: provider.oauth_account || provider.label,
defaultValue: "Signed in as {{account}}",
})
: provider.name === "xai_grok" && remoteBrowserAccess
? tx(
"settings.oauth.remoteSignInHelp",
"Select Sign in to open xAI on your computer, then paste the authorization code shown after login.",
)
: tx("settings.oauth.signInHelp", "Sign in from this device; no API key is stored in config.")}
</p>
</div>
<div className="flex shrink-0 justify-end gap-2">
{provider.configured ? (
<Button
size="sm"
variant="ghost"
onClick={() => onProviderOAuthLogout(provider.name)}
disabled={saving}
className="rounded-full"
>
{tx("settings.oauth.signOut", "Sign out")}
</Button>
) : null}
<Button
size="sm"
variant="ghost"
onClick={() => onProviderOAuthLogout(provider.name)}
disabled={saving}
variant="outline"
onClick={() => onProviderOAuthLogin(provider.name)}
disabled={saving || oauthProxyDirty || !provider.oauth_login_supported}
title={
oauthProxyDirty
? tx(
"settings.oauth.proxySaveBeforeSignIn",
"Save proxy changes before signing in.",
)
: undefined
}
className="rounded-full"
>
{tx("settings.oauth.signOut", "Sign out")}
{oauthActionBusy ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{oauthActionBusy
? tx("settings.oauth.signingIn", "Signing in...")
: provider.configured
? tx("settings.oauth.signInAgain", "Sign in again")
: tx("settings.oauth.signIn", "Sign in")}
</Button>
) : null}
<Button
size="sm"
variant="outline"
onClick={() => onProviderOAuthLogin(provider.name)}
disabled={saving || !provider.oauth_login_supported}
className="rounded-full"
>
{saving ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
{saving
? tx("settings.oauth.signingIn", "Signing in...")
: provider.configured
? tx("settings.oauth.signInAgain", "Sign in again")
: tx("settings.oauth.signIn", "Sign in")}
</Button>
</div>
</div>
</div>
{supportsOauthProxy ? (
<div className="rounded-[18px] border border-border/45 bg-background/75 px-4 py-3.5">
<div className="flex items-center gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Globe2 className="h-4 w-4" aria-hidden />
</span>
<label
htmlFor={`provider-${provider.name}-proxy`}
className="text-[13px] font-semibold text-foreground"
>
{tx("settings.oauth.proxyLabel", "Network proxy")}
</label>
</div>
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
id={`provider-${provider.name}-proxy`}
value={form.proxy}
onChange={(event) =>
onChangeProviderForm(provider.name, { proxy: event.target.value })
}
placeholder="http://127.0.0.1:7890"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
className="h-9 min-w-0 flex-1 rounded-full font-mono text-[12px]"
/>
<div className="flex shrink-0 justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => onResetProviderDraft(provider.name)}
disabled={saving || !oauthProxyDirty}
className="rounded-full"
>
{t("settings.actions.cancel")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onSaveProvider(provider.name)}
disabled={saving || !oauthProxyDirty}
className="rounded-full"
>
{oauthProxySaving ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{oauthProxySaving
? t("settings.actions.saving")
: tx("settings.oauth.saveProxy", "Save proxy")}
</Button>
</div>
</div>
</div>
) : null}
</>
) : (
<>
<label className="block space-y-1.5">
+9 -1
View File
@@ -719,10 +719,18 @@
"signOut": "Sign out",
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"remoteSignInHelp": "Select Sign in to open xAI on your computer, then paste the authorization code shown after login.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
"notSignedIn": "Not signed in",
"proxyLabel": "Network proxy",
"proxySaveBeforeSignIn": "Save proxy changes before signing in.",
"saveProxy": "Save proxy",
"localCodeHelp": "Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, paste the authorization code below.",
"remoteCodeHelp": "Select Sign in to open xAI on your computer. After signing in, paste the authorization code shown by xAI below.",
"authorizationCode": "Authorization code",
"finishSignIn": "Finish sign-in"
},
"skills": {
"description": "Review the instruction skills this agent can load during a conversation.",
+9 -1
View File
@@ -706,10 +706,18 @@
"signOut": "Cerrar sesión",
"signedInAs": "Sesión iniciada como {{account}}",
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
"remoteSignInHelp": "Selecciona Iniciar sesión para abrir xAI en tu computadora y luego pega el código de autorización que se muestra tras iniciar sesión.",
"signInRequired": "Inicio de sesión requerido",
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Sesión iniciada",
"notSignedIn": "Sin sesión"
"notSignedIn": "Sin sesión",
"proxyLabel": "Proxy de red",
"proxySaveBeforeSignIn": "Guarda los cambios del proxy antes de iniciar sesión.",
"saveProxy": "Guardar proxy",
"localCodeHelp": "Completa el inicio de sesión en el navegador. nanobot suele finalizar automáticamente; si no lo hace, pega el código de autorización abajo.",
"remoteCodeHelp": "Selecciona Iniciar sesión para abrir xAI en tu computadora. Después de iniciar sesión, pega abajo el código de autorización que muestra xAI.",
"authorizationCode": "Código de autorización",
"finishSignIn": "Completar inicio de sesión"
},
"skills": {
"description": "Revisa las habilidades de instrucciones que este agente puede cargar durante una conversación.",
+9 -1
View File
@@ -705,10 +705,18 @@
"signOut": "Se déconnecter",
"signedInAs": "Connecté en tant que {{account}}",
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
"remoteSignInHelp": "Sélectionnez Se connecter pour ouvrir xAI sur votre ordinateur, puis collez le code dautorisation affiché après la connexion.",
"signInRequired": "Connexion requise",
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Connecté",
"notSignedIn": "Non connecté"
"notSignedIn": "Non connecté",
"proxyLabel": "Proxy réseau",
"proxySaveBeforeSignIn": "Enregistrez les modifications du proxy avant de vous connecter.",
"saveProxy": "Enregistrer le proxy",
"localCodeHelp": "Terminez la connexion dans votre navigateur. nanobot termine généralement automatiquement ; sinon, collez le code dautorisation ci-dessous.",
"remoteCodeHelp": "Sélectionnez Se connecter pour ouvrir xAI sur votre ordinateur. Après la connexion, collez ci-dessous le code dautorisation affiché par xAI.",
"authorizationCode": "Code dautorisation",
"finishSignIn": "Terminer la connexion"
},
"skills": {
"description": "Consultez les compétences dinstruction que cet agent peut charger pendant une conversation.",
+9 -1
View File
@@ -705,10 +705,18 @@
"signOut": "Keluar",
"signedInAs": "Masuk sebagai {{account}}",
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
"remoteSignInHelp": "Pilih Masuk untuk membuka xAI di komputer Anda, lalu tempel kode otorisasi yang ditampilkan setelah masuk.",
"signInRequired": "Perlu masuk",
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Sudah masuk",
"notSignedIn": "Belum masuk"
"notSignedIn": "Belum masuk",
"proxyLabel": "Proksi jaringan",
"proxySaveBeforeSignIn": "Simpan perubahan proksi sebelum login.",
"saveProxy": "Simpan proksi",
"localCodeHelp": "Selesaikan proses masuk di browser. nanobot biasanya menyelesaikannya secara otomatis; jika tidak, tempel kode otorisasi di bawah.",
"remoteCodeHelp": "Pilih Masuk untuk membuka xAI di komputer Anda. Setelah masuk, tempel kode otorisasi yang ditampilkan xAI di bawah.",
"authorizationCode": "Kode otorisasi",
"finishSignIn": "Selesaikan masuk"
},
"skills": {
"description": "Tinjau skill instruksi yang dapat dimuat agent ini selama percakapan.",
+9 -1
View File
@@ -705,10 +705,18 @@
"signOut": "サインアウト",
"signedInAs": "{{account}} としてサインイン済み",
"signInHelp": "このデバイスからサインインします。API key は config に保存されません。",
"remoteSignInHelp": "「サインイン」を選択して自分のコンピューターで xAI を開き、サインイン後に表示される認証コードを貼り付けてください。",
"signInRequired": "サインインが必要です",
"signInBeforeSaving": "この OAuth プロバイダーをアクティブなモデルプロバイダーとして保存する前にサインインしてください。",
"signedIn": "サインイン済み",
"notSignedIn": "未サインイン"
"notSignedIn": "未サインイン",
"proxyLabel": "ネットワークプロキシ",
"proxySaveBeforeSignIn": "サインイン前にプロキシの変更を保存してください。",
"saveProxy": "プロキシを保存",
"localCodeHelp": "ブラウザーでサインインを完了してください。通常は nanobot が自動で完了します。完了しない場合は、認証コードを下に貼り付けてください。",
"remoteCodeHelp": "「サインイン」を選択して自分のコンピューターで xAI を開いてください。サインイン後、xAI に表示された認証コードを下に貼り付けてください。",
"authorizationCode": "認証コード",
"finishSignIn": "サインインを完了"
},
"skills": {
"description": "このエージェントが会話中に読み込める指示スキルを確認します。",
+9 -1
View File
@@ -705,10 +705,18 @@
"signOut": "로그아웃",
"signedInAs": "{{account}}로 로그인됨",
"signInHelp": "이 기기에서 로그인합니다. API key는 config에 저장되지 않습니다.",
"remoteSignInHelp": "로그인을 선택하여 사용자 컴퓨터에서 xAI를 연 다음, 로그인 후 표시되는 인증 코드를 붙여 넣으세요.",
"signInRequired": "로그인이 필요합니다",
"signInBeforeSaving": "이 OAuth 제공자를 활성 모델 제공자로 저장하기 전에 로그인하세요.",
"signedIn": "로그인됨",
"notSignedIn": "로그인 안 됨"
"notSignedIn": "로그인 안 됨",
"proxyLabel": "네트워크 프록시",
"proxySaveBeforeSignIn": "로그인하기 전에 프록시 변경 사항을 저장하세요.",
"saveProxy": "프록시 저장",
"localCodeHelp": "브라우저에서 로그인을 완료하세요. 일반적으로 nanobot이 자동으로 완료합니다. 완료되지 않으면 인증 코드를 아래에 붙여 넣으세요.",
"remoteCodeHelp": "로그인을 선택하여 사용자 컴퓨터에서 xAI를 여세요. 로그인 후 xAI에 표시된 인증 코드를 아래에 붙여 넣으세요.",
"authorizationCode": "인증 코드",
"finishSignIn": "로그인 완료"
},
"skills": {
"description": "이 에이전트가 대화 중에 불러올 수 있는 지시 스킬을 확인합니다.",
+9 -1
View File
@@ -719,10 +719,18 @@
"signOut": "Sair",
"signedInAs": "Conectado como {{account}}",
"signInHelp": "Entre por este dispositivo; nenhuma chave de API é armazenada em config.",
"remoteSignInHelp": "Selecione Entrar para abrir a xAI no seu computador e depois cole o código de autorização exibido após o login.",
"signInRequired": "Login necessário",
"signInBeforeSaving": "Entre antes de salvar este provedor OAuth como provedor de modelo ativo.",
"signedIn": "Conectado",
"notSignedIn": "Desconectado"
"notSignedIn": "Desconectado",
"proxyLabel": "Proxy de rede",
"proxySaveBeforeSignIn": "Salve as alterações do proxy antes de entrar.",
"saveProxy": "Salvar proxy",
"localCodeHelp": "Conclua o login no navegador. O nanobot geralmente termina automaticamente; caso contrário, cole o código de autorização abaixo.",
"remoteCodeHelp": "Selecione Entrar para abrir a xAI no seu computador. Após o login, cole abaixo o código de autorização exibido pela xAI.",
"authorizationCode": "Código de autorização",
"finishSignIn": "Concluir login"
},
"skills": {
"description": "Revise as skills de instrução que este agente pode carregar durante uma conversa.",
+9 -1
View File
@@ -705,10 +705,18 @@
"signOut": "Đăng xuất",
"signedInAs": "Đã đăng nhập bằng {{account}}",
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
"remoteSignInHelp": "Chọn Đăng nhập để mở xAI trên máy tính của bạn, sau đó dán mã ủy quyền được hiển thị sau khi đăng nhập.",
"signInRequired": "Cần đăng nhập",
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Đã đăng nhập",
"notSignedIn": "Chưa đăng nhập"
"notSignedIn": "Chưa đăng nhập",
"proxyLabel": "Proxy mạng",
"proxySaveBeforeSignIn": "Hãy lưu thay đổi proxy trước khi đăng nhập.",
"saveProxy": "Lưu proxy",
"localCodeHelp": "Hoàn tất đăng nhập trong trình duyệt. nanobot thường tự động hoàn tất; nếu không, hãy dán mã ủy quyền bên dưới.",
"remoteCodeHelp": "Chọn Đăng nhập để mở xAI trên máy tính của bạn. Sau khi đăng nhập, hãy dán mã ủy quyền do xAI hiển thị bên dưới.",
"authorizationCode": "Mã ủy quyền",
"finishSignIn": "Hoàn tất đăng nhập"
},
"skills": {
"description": "Xem các kỹ năng chỉ dẫn mà agent này có thể tải trong cuộc trò chuyện.",
+9 -1
View File
@@ -719,10 +719,18 @@
"signOut": "退出登录",
"signedInAs": "已登录为 {{account}}",
"signInHelp": "从这台设备登录;不会在配置中保存 API key。",
"remoteSignInHelp": "点击“登录”在你的电脑上打开 xAI,完成登录后粘贴页面显示的授权码。",
"signInRequired": "需要登录",
"signInBeforeSaving": "将此 OAuth 提供商设为当前模型提供商前,请先登录。",
"signedIn": "已登录",
"notSignedIn": "未登录"
"notSignedIn": "未登录",
"proxyLabel": "网络代理",
"proxySaveBeforeSignIn": "请先保存代理更改再登录。",
"saveProxy": "保存代理",
"localCodeHelp": "请在浏览器中完成登录。nanobot 通常会自动完成;若未自动完成,请将授权码粘贴到下方。",
"remoteCodeHelp": "点击“登录”在你的电脑上打开 xAI。完成登录后,请将 xAI 显示的授权码粘贴到下方。",
"authorizationCode": "授权码",
"finishSignIn": "完成登录"
},
"skills": {
"description": "查看此 agent 在对话中可以加载的指令技能。",
+9 -1
View File
@@ -705,10 +705,18 @@
"signOut": "登出",
"signedInAs": "已使用 {{account}} 登入",
"signInHelp": "請從這臺裝置登入;系統不會將 API 金鑰儲存在設定中。",
"remoteSignInHelp": "點擊「登入」在你的電腦上開啟 xAI,完成登入後貼上頁面顯示的授權碼。",
"signInRequired": "需要登入",
"signInBeforeSaving": "將此 OAuth 供應商設為目前模型供應商前,請先登入。",
"signedIn": "已登入",
"notSignedIn": "未登入"
"notSignedIn": "未登入",
"proxyLabel": "網路代理",
"proxySaveBeforeSignIn": "請先儲存代理變更再登入。",
"saveProxy": "儲存代理",
"localCodeHelp": "請在瀏覽器中完成登入。nanobot 通常會自動完成;若未自動完成,請將授權碼貼到下方。",
"remoteCodeHelp": "點擊「登入」在你的電腦上開啟 xAI。完成登入後,請將 xAI 顯示的授權碼貼到下方。",
"authorizationCode": "授權碼",
"finishSignIn": "完成登入"
},
"skills": {
"description": "檢閱此 Agent 可在對話期間載入的指令技能。",
+25 -2
View File
@@ -16,6 +16,8 @@ import type {
NetworkSafetySettingsUpdate,
PairingPayload,
ProviderModelsPayload,
ProviderOAuthCompletionResult,
ProviderOAuthLoginResult,
ProviderSettingsUpdate,
SessionDeleteResult,
SessionAutomationsPayload,
@@ -51,6 +53,7 @@ function isSlashCommandLifecycle(value: unknown): value is SlashCommandLifecycle
}
const CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values";
const API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values";
const OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code";
export class ApiError extends Error {
status: number;
@@ -809,6 +812,7 @@ export async function updateProviderSettings(
if (update.apiKey !== undefined) query.set("api_key", update.apiKey);
if (update.apiBase !== undefined) query.set("api_base", update.apiBase);
if (update.apiType !== undefined) query.set("api_type", update.apiType);
if (update.proxy !== undefined) query.set("proxy", update.proxy);
return request<SettingsPayload>(
`${base}/api/settings/provider/update?${query}`,
token,
@@ -819,12 +823,31 @@ export async function loginProviderOAuth(
token: string,
provider: string,
base: string = "",
): Promise<SettingsPayload> {
): Promise<ProviderOAuthLoginResult> {
const query = new URLSearchParams();
query.set("provider", provider);
return request<SettingsPayload>(
return request<ProviderOAuthLoginResult>(
`${base}/api/settings/provider/oauth-login?${query}`,
token,
{ cache: "no-store" },
);
}
export async function completeProviderOAuth(
token: string,
provider: string,
flowId: string,
authorizationCode?: string,
base: string = "",
): Promise<ProviderOAuthCompletionResult> {
const query = new URLSearchParams();
query.set("provider", provider);
query.set("flow_id", flowId);
const headers = authorizationCode ? { [OAUTH_CODE_HEADER]: authorizationCode } : undefined;
return request<ProviderOAuthCompletionResult>(
`${base}/api/settings/provider/oauth-login/complete?${query}`,
token,
{ cache: "no-store", ...(headers ? { headers } : {}) },
);
}
+5
View File
@@ -131,6 +131,8 @@ export const PROVIDER_BRAND_ALIASES: Record<string, string> = {
minimaxAnthropic: "minimax",
minimax_anthropic: "minimax",
openai_codex: "openai",
"xai-grok": "xai",
xai_grok: "xai",
xiaomi: "xiaomi_mimo",
volcengine_coding_plan: "volcengine",
};
@@ -141,6 +143,8 @@ export const PROVIDER_LABEL_ALIASES: Record<string, string> = {
minimaxAnthropic: "MiniMax",
minimax_anthropic: "MiniMax",
openai_codex: "OpenAI",
"xai-grok": "xAI",
xai_grok: "xAI",
volcengine_coding_plan: "Volcengine",
};
@@ -194,6 +198,7 @@ const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
xiaomi_mimo: brand("mimo.xiaomi.com", "#FF6900", "MI", [
"https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg",
]),
xai: brand("x.ai", "#111827", "xAI"),
zhipu: brand("z.ai", "#155EEF", "Z", [
"https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
"https://www.google.com/s2/favicons?domain=z.ai&sz=64",
+19
View File
@@ -377,6 +377,23 @@ export interface ProviderModelsPayload {
fetched_at?: number;
}
export interface ProviderOAuthAuthorizationRequired {
status: "authorization_required";
provider: string;
flow_id: string;
authorization_url: string;
expires_in: number;
}
export interface ProviderOAuthPending {
status: "pending";
provider: string;
flow_id: string;
}
export type ProviderOAuthLoginResult = SettingsPayload | ProviderOAuthAuthorizationRequired;
export type ProviderOAuthCompletionResult = SettingsPayload | ProviderOAuthPending;
export interface SettingsPayload {
surface?: RuntimeSurface;
runtime_surface?: RuntimeSurface;
@@ -430,6 +447,7 @@ export interface SettingsPayload {
oauth_account?: string | null;
oauth_expires_at?: number | null;
oauth_login_supported?: boolean;
proxy?: string | null;
}>;
web_search: {
provider: string;
@@ -936,6 +954,7 @@ export interface ProviderSettingsUpdate {
apiKey?: string;
apiBase?: string;
apiType?: "auto" | "chat_completions" | "responses";
proxy?: string;
}
export interface WebSearchSettingsUpdate {
+39
View File
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
configureChannel,
completeProviderOAuth,
createModelConfiguration,
deleteSession,
fetchFilePreview,
@@ -431,6 +432,20 @@ describe("webui API helpers", () => {
);
});
it("serializes OAuth provider proxy updates", async () => {
await updateProviderSettings("tok", {
provider: "xai_grok",
proxy: "http://127.0.0.1:7890",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/update?provider=xai_grok&proxy=http%3A%2F%2F127.0.0.1%3A7890",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("fetches provider model lists", async () => {
await fetchProviderModels("tok", "deepseek");
@@ -451,6 +466,30 @@ describe("webui API helpers", () => {
}),
);
await completeProviderOAuth("tok", "xai_grok", "flow-123");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await completeProviderOAuth(
"tok",
"xai_grok",
"flow-123",
"secret",
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-OAuth-Code": "secret",
},
}),
);
await logoutProviderOAuth("tok", "openai_codex");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-logout?provider=openai_codex",
+5
View File
@@ -74,6 +74,11 @@ describe("provider brand logos", () => {
expect(providerBrand("openrouter")?.initials).toBe("OR");
});
it("maps both xAI Grok spellings to the xAI brand", () => {
expect(providerBrand("xai_grok")?.logoUrls).toContain("https://x.ai/favicon.ico");
expect(providerBrand("xai-grok")?.initials).toBe("xAI");
});
it("keeps AssemblyAI voice settings on the first-party brand domain", () => {
expect(providerBrand("assemblyai")?.logoUrls).toContain("https://assemblyai.com/favicon.ico");
expect(providerBrand("assemblyai")?.initials).toBe("AA");
+301
View File
@@ -1973,9 +1973,310 @@ describe("SettingsView Apps catalog", () => {
expect(screen.getByRole("button", { name: "64K" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "200K" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "500K" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "1M" })).toBeInTheDocument();
});
it("signs in to the xAI Grok provider", async () => {
const base = settingsPayload();
const xaiProvider = {
name: "xai_grok",
label: "xAI Grok",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://cli-chat-proxy.grok.com/v1",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [xaiProvider] };
const signedIn: SettingsPayload = {
...payload,
providers: [{ ...xaiProvider, configured: true, oauth_account: "user@example.com" }],
};
const authorization = {
status: "authorization_required",
provider: "xai_grok",
flow_id: "flow-123",
authorization_url: "https://auth.x.ai/oauth2/authorize?state=test",
expires_in: 600,
};
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/provider/oauth-login?provider=xai_grok") {
return jsonResponse(authorization);
}
if (
url ===
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123"
) {
expect(init?.headers).toMatchObject({
"X-Nanobot-OAuth-Code": "secret",
});
return jsonResponse(signedIn);
}
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
const popup = {
opener: window,
location: { href: "about:blank" },
close: vi.fn(),
};
vi.stubGlobal("open", vi.fn(() => popup));
renderSettingsView({ initialSection: "models", initialSettings: payload });
const providerLabel = await screen.findByText("xAI Grok");
fireEvent.click(providerLabel.closest("button")!);
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login?provider=xai_grok",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
),
);
expect(popup.opener).toBeNull();
expect(popup.location.href).toBe(authorization.authorization_url);
expect(
screen.getByText(
"Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, paste the authorization code below.",
),
).toBeInTheDocument();
const callbackInput = await screen.findByRole("textbox", {
name: "Authorization code",
});
fireEvent.change(callbackInput, {
target: { value: "secret" },
});
fireEvent.click(screen.getByRole("button", { name: "Finish sign-in" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
expect.objectContaining({
headers: expect.objectContaining({
"X-Nanobot-OAuth-Code": "secret",
}),
}),
),
);
expect(await screen.findByText("Signed in as user@example.com")).toBeInTheDocument();
});
it("recognizes remote access before starting xAI Grok sign-in", async () => {
const happyWindow = window as typeof window & {
happyDOM: { setURL: (url: string) => void };
};
const originalUrl = window.location.href;
happyWindow.happyDOM.setURL("http://203.0.113.10:18887/#/settings?section=models");
try {
const base = settingsPayload();
const xaiProvider = {
name: "xai_grok",
label: "xAI Grok",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://cli-chat-proxy.grok.com/v1",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [xaiProvider] };
const authorization = {
status: "authorization_required",
provider: "xai_grok",
flow_id: "flow-remote",
authorization_url: "https://auth.x.ai/oauth2/authorize?state=remote",
expires_in: 600,
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/provider/oauth-login?provider=xai_grok") {
return jsonResponse(authorization);
}
if (
url ===
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-remote"
) {
return jsonResponse({
status: "pending",
provider: "xai_grok",
flow_id: "flow-remote",
});
}
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
const popup = {
opener: window,
location: { href: "about:blank" },
close: vi.fn(),
};
const openMock = vi.fn(() => popup);
vi.stubGlobal("open", openMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
fireEvent.click((await screen.findByText("xAI Grok")).closest("button")!);
expect(
screen.getByText(
"Select Sign in to open xAI on your computer, then paste the authorization code shown after login.",
),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
const dialog = await screen.findByRole("dialog");
expect(openMock).not.toHaveBeenCalled();
expect(
within(dialog).getByText(
"Select Sign in to open xAI on your computer. After signing in, paste the authorization code shown by xAI below.",
),
).toBeInTheDocument();
expect(
within(dialog).queryByRole("textbox", { name: "xAI sign-in URL" }),
).not.toBeInTheDocument();
expect(
within(dialog).queryByRole("button", { name: "Copy" }),
).not.toBeInTheDocument();
expect(
within(dialog).getByRole("textbox", { name: "Authorization code" }),
).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Sign in" }));
expect(openMock).toHaveBeenCalledWith(
authorization.authorization_url,
"_blank",
"noopener,noreferrer",
);
expect(popup.opener).toBeNull();
} finally {
happyWindow.happyDOM.setURL(originalUrl);
}
});
it("saves scoped proxies for xAI and OpenAI Codex OAuth providers", async () => {
const base = settingsPayload();
const providers: SettingsPayload["providers"] = [
{
name: "xai_grok",
label: "xAI Grok",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://cli-chat-proxy.grok.com/v1",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
proxy: "http://127.0.0.1:7000",
},
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://chatgpt.com/backend-api",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
proxy: null,
},
];
let payload: SettingsPayload = { ...base, providers };
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url.startsWith("/api/settings/provider/update?")) {
const query = new URLSearchParams(url.split("?")[1]);
const providerName = query.get("provider");
const proxy = query.get("proxy");
payload = {
...payload,
providers: payload.providers.map((provider) =>
provider.name === providerName ? { ...provider, proxy } : provider,
),
};
return jsonResponse(payload);
}
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
fireEvent.click((await screen.findByText("xAI Grok")).closest("button")!);
const xaiProxy = screen.getByLabelText("Network proxy");
expect(xaiProxy).toHaveValue("http://127.0.0.1:7000");
fireEvent.change(xaiProxy, { target: { value: "http://127.0.0.1:7890" } });
expect(screen.getByRole("button", { name: "Sign in" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Sign in" })).toHaveAttribute(
"title",
"Save proxy changes before signing in.",
);
fireEvent.click(screen.getByRole("button", { name: "Save proxy" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider/update?provider=xai_grok&proxy=http%3A%2F%2F127.0.0.1%3A7890",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
),
);
await waitFor(() => expect(screen.getByRole("button", { name: "Sign in" })).toBeEnabled());
fireEvent.click(screen.getByText("OpenAI Codex").closest("button")!);
const codexProxy = screen.getByLabelText("Network proxy");
expect(codexProxy).toHaveValue("");
fireEvent.change(codexProxy, { target: { value: "http://proxy.example:8080" } });
fireEvent.click(screen.getByRole("button", { name: "Save proxy" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider/update?provider=openai_codex&proxy=http%3A%2F%2Fproxy.example%3A8080",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
),
);
});
it("keeps the default model distinct from the active named configuration", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {