feat(providers): add xAI Grok OAuth with capability-gated X Search (#5035)
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 d’autorisation 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 d’autorisation ci-dessous.",
|
||||
"remoteCodeHelp": "Sélectionnez Se connecter pour ouvrir xAI sur votre ordinateur. Après la connexion, collez ci-dessous le code d’autorisation affiché par xAI.",
|
||||
"authorizationCode": "Code d’autorisation",
|
||||
"finishSignIn": "Terminer la connexion"
|
||||
},
|
||||
"skills": {
|
||||
"description": "Consultez les compétences d’instruction que cet agent peut charger pendant une conversation.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "このエージェントが会話中に読み込める指示スキルを確認します。",
|
||||
|
||||
@@ -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": "이 에이전트가 대화 중에 불러올 수 있는 지시 스킬을 확인합니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 在对话中可以加载的指令技能。",
|
||||
|
||||
@@ -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
@@ -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 } : {}) },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user