fix(providers): complete OAuth logins that finish in the browser

OpenCode's authorize response reports how the client must finish: `code`
expects a pasted code, while `auto` requires the client to call
oauth/callback immediately and hold it open — upstream blocks in there
polling for the device code or waiting on its loopback redirect, and only
that call persists the credential. Every auth plugin OpenCode ships uses
`auto`; none use `code`.

The page implemented only `code`. It opened the browser, showed a paste
field no provider can fill, and never called back, so a successful sign-in
stored nothing and the app sat unchanged. Authorization now drives the UI:
`auto` chains straight into the callback behind a waiting state with a
cancel, and the paste field appears only when a provider actually asks
for a code.

Two smaller failures shared that surface. Prompts were never collected,
which put GitHub Copilot Enterprise out of reach entirely, so a method
that declares them now asks first and passes the answers to authorize.
Device codes are also recovered from the instructions text, where they
actually live — the old code read fields the API does not return, so the
copy button never appeared.

The callback is exempt from the ordinary proxy deadline and gets a
15-minute budget, bounded by the shortest upstream expiry we know of.
A human sign-in with 2FA does not fit in four minutes, and expiring it
turned a completed login into a 504.
This commit is contained in:
Bohdan Triapitsyn
2026-08-04 19:14:58 +03:00
parent 8c37061886
commit 687681c83b
19 changed files with 1239 additions and 290 deletions
@@ -0,0 +1,455 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import {
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
SETTINGS_SELECT_SIZE,
} from '@/components/sections/shared/SettingsSection';
import { useI18n, type I18nKey } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import { opencodeClient } from '@/lib/opencode/client';
import {
collectPromptInputs,
defaultPromptValues,
describeOAuthError,
firstUnansweredPrompt,
parseAuthPrompts,
parseAuthorization,
visiblePrompts,
type AuthPrompt,
type OAuthAuthorization,
} from './provider-oauth';
export interface ProviderOAuthMethod {
/** Index into the provider's full auth-method list, which is what OpenCode's `method` parameter addresses. */
index: number;
label: string;
prompts?: unknown;
}
interface ProviderOAuthMethodsProps {
providerId: string;
methods: ProviderOAuthMethod[];
/** Called once a credential has been stored, so the caller can reload providers. */
onConnected: () => void | Promise<void>;
/** Layout only — the caller owns separation from whatever sits above. */
className?: string;
}
type Flow =
| { phase: 'idle' }
| { phase: 'prompting'; methodIndex: number; prompts: AuthPrompt[]; error: string | null }
| { phase: 'authorizing'; methodIndex: number }
/** `auto`: the callback request is in flight and blocks until the browser sign-in finishes. */
| { phase: 'waiting'; methodIndex: number; authorization: OAuthAuthorization }
/** `code`: waiting for the user to paste a code out of the browser. */
| { phase: 'awaitingCode'; methodIndex: number; authorization: OAuthAuthorization; submitting: boolean }
| { phase: 'failed'; methodIndex: number; message: string };
const IDLE: Flow = { phase: 'idle' };
/**
* OAuth sign-in for a provider's auth methods.
*
* The completion method reported by `authorize` drives everything: `auto`
* chains straight into `callback` and holds it open until the user finishes in
* the browser, `code` collects a pasted code first. See `provider-oauth.ts`.
*
* Only one method can run at a time, and the in-flight callback is aborted when
* this component unmounts. Mount it with `key={providerId}` so switching
* providers starts from a clean flow.
*/
export const ProviderOAuthMethods: React.FC<ProviderOAuthMethodsProps> = ({
providerId,
methods,
onConnected,
className,
}) => {
const { t } = useI18n();
const [flow, setFlow] = React.useState<Flow>(IDLE);
const [promptValues, setPromptValues] = React.useState<Record<string, string>>({});
const [codeInput, setCodeInput] = React.useState('');
const callbackAbortRef = React.useRef<AbortController | null>(null);
React.useEffect(() => () => callbackAbortRef.current?.abort(), []);
const activeIndex = flow.phase === 'idle' ? null : flow.methodIndex;
const busy = flow.phase === 'authorizing'
|| flow.phase === 'waiting'
|| (flow.phase === 'awaitingCode' && flow.submitting);
const copy = async (value: string, successKey: I18nKey, failureKey: I18nKey) => {
const result = await copyTextToClipboard(value);
if (result.ok) {
toast.success(t(successKey));
return;
}
console.error('Failed to copy OAuth value:', result.error);
toast.error(t(failureKey));
};
/**
* Runs the blocking half of the flow. Never throws: the caller has already
* handed control to the user, so a failure here is a flow state, not an
* exception to unwind.
*/
const runCallback = async (methodIndex: number, code?: string) => {
const controller = new AbortController();
callbackAbortRef.current?.abort();
callbackAbortRef.current = controller;
try {
const result = await opencodeClient.getSdkClient().provider.oauth.callback(
{
providerID: providerId,
method: methodIndex,
...(code ? { code } : {}),
},
{ signal: controller.signal },
);
if (controller.signal.aborted) {
return;
}
if (result.error) {
throw result.error;
}
setFlow(IDLE);
toast.success(t('settings.providers.page.toast.oauthCompleted'));
await onConnected();
} catch (error) {
if (controller.signal.aborted) {
return;
}
console.error('Failed to complete OAuth flow:', error);
setFlow({
phase: 'failed',
methodIndex,
message: describeOAuthError(error, t, 'settings.providers.page.toast.oauthCompleteFailed'),
});
} finally {
if (callbackAbortRef.current === controller) {
callbackAbortRef.current = null;
}
}
};
const runAuthorize = async (methodIndex: number, inputs: Record<string, string>) => {
setFlow({ phase: 'authorizing', methodIndex });
let authorization: OAuthAuthorization;
try {
const result = await opencodeClient.getSdkClient().provider.oauth.authorize({
providerID: providerId,
method: methodIndex,
...(Object.keys(inputs).length > 0 ? { inputs } : {}),
});
if (result.error) {
throw result.error;
}
const parsed = parseAuthorization(result.data);
if (!parsed) {
setFlow({
phase: 'failed',
methodIndex,
message: t('settings.providers.page.toast.oauthDetailsMissing'),
});
return;
}
authorization = parsed;
} catch (error) {
console.error('Failed to start OAuth flow:', error);
setFlow({
phase: 'failed',
methodIndex,
message: describeOAuthError(error, t, 'settings.providers.page.toast.oauthStartFailed'),
});
return;
}
if (authorization.url) {
void openExternalUrl(authorization.url);
}
if (authorization.method === 'code') {
setCodeInput('');
setFlow({ phase: 'awaitingCode', methodIndex, authorization, submitting: false });
return;
}
setFlow({ phase: 'waiting', methodIndex, authorization });
await runCallback(methodIndex);
};
const beginConnect = (method: ProviderOAuthMethod) => {
const prompts = parseAuthPrompts(method.prompts);
if (prompts.length === 0) {
void runAuthorize(method.index, {});
return;
}
setPromptValues(defaultPromptValues(prompts));
setFlow({ phase: 'prompting', methodIndex: method.index, prompts, error: null });
};
const submitPrompts = () => {
if (flow.phase !== 'prompting') {
return;
}
const unanswered = firstUnansweredPrompt(flow.prompts, promptValues);
if (unanswered) {
setFlow({
...flow,
error: t('settings.providers.page.auth.oauth.promptRequired', { field: unanswered.message }),
});
return;
}
void runAuthorize(flow.methodIndex, collectPromptInputs(flow.prompts, promptValues));
};
const submitCode = () => {
if (flow.phase !== 'awaitingCode') {
return;
}
const code = codeInput.trim();
if (!code) {
return;
}
setFlow({ ...flow, submitting: true });
void runCallback(flow.methodIndex, code);
};
/**
* Stops tracking the attempt. Upstream keeps its pending authorization until
* a new `authorize` replaces it, so reconnecting is always safe.
*/
const cancel = () => {
callbackAbortRef.current?.abort();
callbackAbortRef.current = null;
setFlow(IDLE);
};
const renderPrompt = (prompt: AuthPrompt) => {
const value = promptValues[prompt.key] ?? '';
const setValue = (next: string) =>
setPromptValues((prev) => ({ ...prev, [prompt.key]: next }));
return (
<div key={prompt.key} className="space-y-1.5">
<label className="typography-ui-label text-foreground">{prompt.message}</label>
{prompt.type === 'select' ? (
<Select value={value} onValueChange={setValue}>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
<SelectValue>
{(current) => prompt.options.find((option) => option.value === current)?.label ?? null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{prompt.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.hint ? `${option.label} · ${option.hint}` : option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={prompt.placeholder}
className="max-w-[24rem] text-xs"
/>
)}
</div>
);
};
const renderAuthorizationDetails = (authorization: OAuthAuthorization) => (
<>
{authorization.instructions && (
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
{authorization.instructions}
</p>
)}
{authorization.userCode && (
<div className="flex items-center gap-2">
<Input
value={authorization.userCode}
readOnly
aria-label={t('settings.providers.page.auth.oauth.deviceCodeLabel')}
className="font-mono text-center tracking-widest"
/>
<Button
variant="outline"
size="xs"
className="!font-normal shrink-0"
onClick={() => void copy(
authorization.userCode ?? '',
'settings.providers.page.toast.deviceCodeCopied',
'settings.providers.page.toast.deviceCodeCopyFailed',
)}
>
{t('settings.providers.page.actions.copyCode')}
</Button>
</div>
)}
{authorization.url && (
<div className="flex items-center gap-2">
<Input
value={authorization.url}
readOnly
aria-label={t('settings.providers.page.auth.oauth.linkLabel')}
className="text-xs text-muted-foreground"
/>
<div className="flex gap-1 shrink-0">
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void openExternalUrl(authorization.url ?? '')}
>
{t('settings.providers.page.actions.open')}
</Button>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void copy(
authorization.url ?? '',
'settings.providers.page.toast.oauthLinkCopied',
'settings.providers.page.toast.oauthLinkCopyFailed',
)}
>
{t('settings.providers.page.actions.copy')}
</Button>
</div>
</div>
)}
</>
);
return (
<div className={cn('space-y-4', className)}>
{methods.map((method) => {
const isActive = activeIndex === method.index;
return (
<div key={method.index} className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="typography-ui-label text-foreground">{method.label}</div>
<Button
variant="outline"
size="xs"
className="!font-normal shrink-0"
onClick={() => beginConnect(method)}
disabled={busy}
>
{t('settings.providers.page.actions.connect')}
</Button>
</div>
{isActive && flow.phase === 'prompting' && (
<div className="space-y-3">
{visiblePrompts(flow.prompts, promptValues).map(renderPrompt)}
{flow.error && (
<p className="typography-meta text-[var(--status-error)]">{flow.error}</p>
)}
<div className="flex items-center gap-2">
<Button size="xs" className="!font-normal" onClick={submitPrompts}>
{t('settings.providers.page.actions.continue')}
</Button>
<Button variant="ghost" size="xs" className="!font-normal" onClick={cancel}>
{t('settings.providers.page.actions.cancel')}
</Button>
</div>
</div>
)}
{isActive && flow.phase === 'authorizing' && (
<p className="typography-meta text-muted-foreground flex items-center gap-2">
<Icon name="loader" className="h-3.5 w-3.5 animate-spin" />
{t('settings.providers.page.auth.oauth.starting')}
</p>
)}
{isActive && flow.phase === 'waiting' && (
<div className="space-y-3">
{renderAuthorizationDetails(flow.authorization)}
<div className="flex items-center justify-between gap-2">
<p className="typography-meta text-muted-foreground flex items-center gap-2">
<Icon name="loader" className="h-3.5 w-3.5 animate-spin" />
{t('settings.providers.page.auth.oauth.waiting')}
</p>
<Button variant="ghost" size="xs" className="!font-normal shrink-0" onClick={cancel}>
{t('settings.providers.page.actions.cancel')}
</Button>
</div>
<p className="typography-meta text-muted-foreground">
{t('settings.providers.page.auth.oauth.waitingHint')}
</p>
</div>
)}
{isActive && flow.phase === 'awaitingCode' && (
<div className="space-y-3">
{renderAuthorizationDetails(flow.authorization)}
<p className="typography-meta text-muted-foreground">
{t('settings.providers.page.auth.oauth.codeHint')}
</p>
<div className="flex items-center gap-2">
<Input
value={codeInput}
onChange={(event) => setCodeInput(event.target.value)}
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
className="font-mono text-xs"
disabled={flow.submitting}
/>
<Button
size="xs"
className="!font-normal shrink-0"
onClick={submitCode}
disabled={flow.submitting || codeInput.trim().length === 0}
>
{flow.submitting
? t('settings.providers.page.actions.saving')
: t('settings.providers.page.actions.complete')}
</Button>
<Button
variant="ghost"
size="xs"
className="!font-normal shrink-0"
onClick={cancel}
disabled={flow.submitting}
>
{t('settings.providers.page.actions.cancel')}
</Button>
</div>
</div>
)}
{isActive && flow.phase === 'failed' && (
<div className="space-y-2">
<p className="typography-meta text-[var(--status-error)]">{flow.message}</p>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => beginConnect(method)}
>
{t('settings.providers.page.actions.tryAgain')}
</Button>
</div>
)}
</div>
);
})}
</div>
);
};
@@ -19,8 +19,6 @@ import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import type { ModelMetadata } from '@/types';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -31,8 +29,10 @@ import {
parseAuthPayload,
shouldShowApiKeyAuth,
type AuthMethod,
type OAuthAuthMethodEntry,
} from './providerAuth';
import { CustomProviderForm } from './CustomProviderForm';
import { ProviderOAuthMethods, type ProviderOAuthMethod } from './ProviderOAuthMethods';
import {
buildAuthSetRequest,
buildProviderUpsertRequest,
@@ -85,6 +85,16 @@ interface ProviderSources {
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const toOAuthMethods = (
entries: OAuthAuthMethodEntry[],
fallbackLabel: (index: number) => string,
): ProviderOAuthMethod[] =>
entries.map(({ method, methodIndex }) => ({
index: methodIndex,
label: method.label || method.name || fallbackLabel(methodIndex),
prompts: method.prompts,
}));
const normalizeProviderEntry = (entry: unknown): ProviderOption | null => {
if (typeof entry === 'string') {
return { id: entry };
@@ -147,9 +157,6 @@ export const ProvidersPage: React.FC = () => {
const [apiKeyInputs, setApiKeyInputs] = React.useState<Record<string, string>>({});
const [authBusyKey, setAuthBusyKey] = React.useState<string | null>(null);
const [modelQuery, setModelQuery] = React.useState('');
const [pendingOAuth, setPendingOAuth] = React.useState<{ providerId: string; methodIndex: number } | null>(null);
const [oauthCodes, setOauthCodes] = React.useState<Record<string, string>>({});
const [oauthDetails, setOauthDetails] = React.useState<Record<string, { url?: string; instructions?: string; userCode?: string }>>({});
const [availableProviders, setAvailableProviders] = React.useState<ProviderOption[]>([]);
const [availableLoading, setAvailableLoading] = React.useState(false);
const [availableError, setAvailableError] = React.useState<string | null>(null);
@@ -181,7 +188,8 @@ export const ProvidersPage: React.FC = () => {
React.useEffect(() => {
// Auth methods drive which credential UI to show (API key vs OAuth). Keep
// them loaded for the active provider view so OAuth-only plugins never fall
// back to an API key form merely because methods were never fetched.
// back to an API key form merely because methods were never fetched, and so
// an already-listed provider can still offer re-authentication.
if (!selectedProviderId) {
return;
}
@@ -458,117 +466,13 @@ export const ProvidersPage: React.FC = () => {
}
};
const handleOAuthStart = async (providerId: string, methodIndex: number) => {
const busyKey = `oauth:${providerId}:${methodIndex}`;
setAuthBusyKey(busyKey);
const oauthMethodFallbackLabel = (index: number) =>
t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) });
try {
const result = await opencodeClient.getSdkClient().provider.oauth.authorize({
providerID: providerId,
method: methodIndex,
});
if (result.error) {
throw new Error(t('settings.providers.page.toast.oauthStartFailed'));
}
const payloadRecord: Record<string, unknown> = isRecord(result.data) ? result.data : {};
const nestedData = payloadRecord.data;
const dataRecord: Record<string, unknown> = isRecord(nestedData) ? nestedData : payloadRecord;
const urlCandidate =
(typeof dataRecord.url === 'string' && dataRecord.url) ||
(typeof dataRecord.verification_uri_complete === 'string' && dataRecord.verification_uri_complete) ||
(typeof dataRecord.verification_uri === 'string' && dataRecord.verification_uri) ||
undefined;
const instructions =
(typeof dataRecord.instructions === 'string' && dataRecord.instructions) ||
(typeof dataRecord.message === 'string' && dataRecord.message) ||
undefined;
const userCode =
(typeof dataRecord.user_code === 'string' && dataRecord.user_code) ||
(typeof dataRecord.code === 'string' && dataRecord.code) ||
(typeof dataRecord.userCode === 'string' && dataRecord.userCode) ||
undefined;
if (!urlCandidate && !instructions && !userCode) {
throw new Error(t('settings.providers.page.toast.oauthDetailsMissing'));
}
const detailsKey = `${providerId}:${methodIndex}`;
setOauthDetails((prev) => ({
...prev,
[detailsKey]: {
url: urlCandidate,
instructions,
userCode,
},
}));
if (urlCandidate) {
void openExternalUrl(urlCandidate);
}
setPendingOAuth({ providerId, methodIndex });
toast.message(t('settings.providers.page.toast.completeOAuthInBrowser'));
} catch (error) {
console.error('Failed to start OAuth flow:', error);
toast.error(t('settings.providers.page.toast.oauthStartFailed'));
} finally {
setAuthBusyKey(null);
}
};
const handleOAuthComplete = async (providerId: string, methodIndex: number) => {
const codeKey = `${providerId}:${methodIndex}`;
const code = oauthCodes[codeKey]?.trim();
const busyKey = `oauth-complete:${providerId}:${methodIndex}`;
setAuthBusyKey(busyKey);
try {
const requestBody: { method: number; code?: string } = { method: methodIndex };
if (code) {
requestBody.code = code;
}
const result = await opencodeClient.getSdkClient().provider.oauth.callback({
providerID: providerId,
method: requestBody.method,
code: requestBody.code,
});
if (result.error) {
throw new Error(t('settings.providers.page.toast.oauthCompleteFailed'));
}
toast.success(t('settings.providers.page.toast.oauthCompleted'));
setOauthCodes((prev) => ({ ...prev, [codeKey]: '' }));
setPendingOAuth(null);
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
setSelectedProvider(providerId);
} catch (error) {
console.error('Failed to complete OAuth flow:', error);
toast.error(t('settings.providers.page.toast.oauthCompleteFailed'));
} finally {
setAuthBusyKey(null);
}
};
const handleCopyOAuthLink = async (url: string) => {
const result = await copyTextToClipboard(url);
if (result.ok) {
toast.success(t('settings.providers.page.toast.oauthLinkCopied'));
return;
}
console.error('Failed to copy OAuth link:', result.error);
toast.error(t('settings.providers.page.toast.oauthLinkCopyFailed'));
};
const handleCopyOAuthCode = async (code: string) => {
const result = await copyTextToClipboard(code);
if (result.ok) {
toast.success(t('settings.providers.page.toast.deviceCodeCopied'));
return;
}
console.error('Failed to copy device code:', result.error);
toast.error(t('settings.providers.page.toast.deviceCodeCopyFailed'));
const handleOAuthConnected = async (providerId: string) => {
setShowAuthPanel(false);
await reloadOpenCodeConfiguration({ scopes: ['providers'], mode: 'active' });
setSelectedProvider(providerId);
};
const handleDisconnectProvider = async (providerId: string) => {
@@ -777,7 +681,10 @@ export const ProvidersPage: React.FC = () => {
<>
{(() => {
const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? [];
const candidateOAuthMethods = getOAuthAuthMethods(candidateAuthMethods);
const candidateOAuthMethods = toOAuthMethods(
getOAuthAuthMethods(candidateAuthMethods),
oauthMethodFallbackLabel,
);
const showApiKey = shouldShowApiKeyAuth(candidateAuthMethods);
return (
@@ -814,85 +721,13 @@ export const ProvidersPage: React.FC = () => {
) : null}
{candidateOAuthMethods.length > 0 ? (
<div className={cn('space-y-4', showApiKey && 'border-t border-[var(--surface-subtle)] pt-2')}>
{candidateOAuthMethods.map(({ method, methodIndex }) => {
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) });
const codeKey = `${candidateProviderId}:${methodIndex}`;
const isPending =
pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === methodIndex;
return (
<div key={`${candidateProviderId}-${methodIndex}-${methodLabel}`} className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div>
<div className="typography-ui-label text-foreground">{methodLabel}</div>
{(method.description || method.help) && (
<div className="typography-meta text-muted-foreground">
{String(method.description || method.help)}
</div>
)}
</div>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => handleOAuthStart(candidateProviderId, methodIndex)}
disabled={authBusyKey === `oauth:${candidateProviderId}:${methodIndex}`}
>
{t('settings.providers.page.actions.connect')}
</Button>
</div>
{oauthDetails[codeKey]?.instructions && (
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
{oauthDetails[codeKey]?.instructions}
</p>
)}
{oauthDetails[codeKey]?.userCode && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
</div>
)}
{oauthDetails[codeKey]?.url && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
<div className="flex gap-1 shrink-0">
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
</div>
</div>
)}
{isPending && (
<div className="flex items-center gap-2 mt-2">
<Input
value={oauthCodes[codeKey] ?? ''}
onChange={(event) =>
setOauthCodes((prev) => ({
...prev,
[codeKey]: event.target.value,
}))
}
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
className="font-mono text-xs"
/>
<Button
size="xs"
className="!font-normal"
onClick={() => handleOAuthComplete(candidateProviderId, methodIndex)}
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${methodIndex}`}
>
{authBusyKey === `oauth-complete:${candidateProviderId}:${methodIndex}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
</Button>
</div>
)}
</div>
);
})}
</div>
<ProviderOAuthMethods
key={candidateProviderId}
providerId={candidateProviderId}
methods={candidateOAuthMethods}
onConnected={() => handleOAuthConnected(candidateProviderId)}
className={cn(showApiKey && 'border-t border-[var(--surface-subtle)] pt-2')}
/>
) : null}
</>
);
@@ -919,7 +754,10 @@ export const ProvidersPage: React.FC = () => {
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
const oauthAuthMethods = getOAuthAuthMethods(providerAuthMethods);
const oauthAuthMethods = toOAuthMethods(
getOAuthAuthMethods(providerAuthMethods),
oauthMethodFallbackLabel,
);
const showApiKeyAuth = shouldShowApiKeyAuth(providerAuthMethods);
const sourcesLoaded = Boolean(selectedSources);
const isEditableCustomProvider = sourcesLoaded
@@ -1062,85 +900,13 @@ export const ProvidersPage: React.FC = () => {
) : null}
{oauthAuthMethods.length > 0 && (
<div className={cn('space-y-4', showApiKeyAuth && 'border-t border-[var(--surface-subtle)] pt-2')}>
{oauthAuthMethods.map(({ method, methodIndex }) => {
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) });
const codeKey = `${selectedProvider.id}:${methodIndex}`;
const isPending =
pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === methodIndex;
return (
<div key={`${selectedProvider.id}-${methodIndex}-${methodLabel}`} className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div>
<div className="typography-ui-label text-foreground">{methodLabel}</div>
{(method.description || method.help) && (
<div className="typography-meta text-muted-foreground">
{String(method.description || method.help)}
</div>
)}
</div>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => handleOAuthStart(selectedProvider.id, methodIndex)}
disabled={authBusyKey === `oauth:${selectedProvider.id}:${methodIndex}`}
>
{t('settings.providers.page.actions.connect')}
</Button>
</div>
{oauthDetails[codeKey]?.instructions && (
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
{oauthDetails[codeKey]?.instructions}
</p>
)}
{oauthDetails[codeKey]?.userCode && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
</div>
)}
{oauthDetails[codeKey]?.url && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
<div className="flex gap-1 shrink-0">
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
</div>
</div>
)}
{isPending && (
<div className="flex items-center gap-2 mt-2">
<Input
value={oauthCodes[codeKey] ?? ''}
onChange={(event) =>
setOauthCodes((prev) => ({
...prev,
[codeKey]: event.target.value,
}))
}
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
className="font-mono text-xs"
/>
<Button
size="xs"
className="!font-normal"
onClick={() => handleOAuthComplete(selectedProvider.id, methodIndex)}
disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${methodIndex}`}
>
{authBusyKey === `oauth-complete:${selectedProvider.id}:${methodIndex}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
</Button>
</div>
)}
</div>
);
})}
</div>
<ProviderOAuthMethods
key={selectedProvider.id}
providerId={selectedProvider.id}
methods={oauthAuthMethods}
onConnected={() => handleOAuthConnected(selectedProvider.id)}
className={cn(showApiKeyAuth && 'border-t border-[var(--surface-subtle)] pt-2')}
/>
)}
</div>
)}
@@ -0,0 +1,236 @@
import { describe, expect, test } from 'bun:test';
import {
collectPromptInputs,
defaultPromptValues,
describeOAuthError,
firstUnansweredPrompt,
isPromptVisible,
parseAuthPrompts,
parseAuthorization,
visiblePrompts,
type AuthPrompt,
type ProviderOAuthTranslator,
} from './provider-oauth';
/** Mirrors the github-copilot auth method shipped by OpenCode. */
const copilotPrompts = [
{
type: 'select',
key: 'deploymentType',
message: 'Select GitHub deployment type',
options: [
{ label: 'GitHub.com', value: 'github.com', hint: 'Public' },
{ label: 'GitHub Enterprise', value: 'enterprise' },
],
},
{
type: 'text',
key: 'enterpriseUrl',
message: 'Enter your GitHub Enterprise URL or domain',
placeholder: 'company.ghe.com',
when: { key: 'deploymentType', op: 'eq', value: 'enterprise' },
},
];
describe('parseAuthPrompts', () => {
test('parses select and conditional text prompts', () => {
const prompts = parseAuthPrompts(copilotPrompts);
expect(prompts).toHaveLength(2);
expect(prompts[0]).toEqual({
type: 'select',
key: 'deploymentType',
message: 'Select GitHub deployment type',
options: [
{ value: 'github.com', label: 'GitHub.com', hint: 'Public' },
{ value: 'enterprise', label: 'GitHub Enterprise' },
],
});
expect(prompts[1]).toEqual({
type: 'text',
key: 'enterpriseUrl',
message: 'Enter your GitHub Enterprise URL or domain',
options: [],
placeholder: 'company.ghe.com',
when: { key: 'deploymentType', op: 'eq', value: 'enterprise' },
});
});
test('returns an empty list for a method without prompts', () => {
expect(parseAuthPrompts(undefined)).toEqual([]);
expect(parseAuthPrompts(null)).toEqual([]);
expect(parseAuthPrompts({})).toEqual([]);
});
test('drops entries that could never be answered', () => {
const prompts = parseAuthPrompts([
{ type: 'text', message: 'no key' },
{ type: 'select', key: 'empty', message: 'no options', options: [] },
{ type: 'text', key: 'keep', message: 'keep me' },
]);
expect(prompts.map((prompt) => prompt.key)).toEqual(['keep']);
});
test('falls back to the key when a message is missing', () => {
expect(parseAuthPrompts([{ type: 'text', key: 'token' }])[0]?.message).toBe('token');
});
test('ignores a malformed when condition instead of hiding the prompt', () => {
const [prompt] = parseAuthPrompts([
{ type: 'text', key: 'url', message: 'URL', when: { key: 'other', op: 'contains', value: 'x' } },
]);
expect(prompt.when).toBe(undefined);
expect(isPromptVisible(prompt, {})).toBe(true);
});
});
describe('prompt visibility', () => {
const prompts = parseAuthPrompts(copilotPrompts);
test('hides a conditional prompt until its branch is selected', () => {
expect(visiblePrompts(prompts, { deploymentType: 'github.com' }).map((p) => p.key))
.toEqual(['deploymentType']);
expect(visiblePrompts(prompts, { deploymentType: 'enterprise' }).map((p) => p.key))
.toEqual(['deploymentType', 'enterpriseUrl']);
});
test('supports neq conditions', () => {
const prompt: AuthPrompt = {
type: 'text',
key: 'custom',
message: 'Custom',
options: [],
when: { key: 'mode', op: 'neq', value: 'default' },
};
expect(isPromptVisible(prompt, { mode: 'default' })).toBe(false);
expect(isPromptVisible(prompt, { mode: 'other' })).toBe(true);
expect(isPromptVisible(prompt, {})).toBe(true);
});
});
describe('prompt answers', () => {
const prompts = parseAuthPrompts(copilotPrompts);
test('preselects the first select option so the form starts answerable', () => {
expect(defaultPromptValues(prompts)).toEqual({ deploymentType: 'github.com', enterpriseUrl: '' });
expect(firstUnansweredPrompt(prompts, defaultPromptValues(prompts))).toBeNull();
});
test('reports the hidden-then-revealed field as unanswered', () => {
const values = { deploymentType: 'enterprise', enterpriseUrl: ' ' };
expect(firstUnansweredPrompt(prompts, values)?.key).toBe('enterpriseUrl');
});
test('omits answers whose prompt is no longer visible', () => {
const values = { deploymentType: 'github.com', enterpriseUrl: 'left-over.ghe.com' };
expect(collectPromptInputs(prompts, values)).toEqual({ deploymentType: 'github.com' });
});
test('trims submitted answers', () => {
const values = { deploymentType: 'enterprise', enterpriseUrl: ' company.ghe.com ' };
expect(collectPromptInputs(prompts, values)).toEqual({
deploymentType: 'enterprise',
enterpriseUrl: 'company.ghe.com',
});
});
});
describe('parseAuthorization', () => {
test('reads a device-code authorization and recovers the code from instructions', () => {
const authorization = parseAuthorization({
url: 'https://github.com/login/device',
instructions: 'Enter code: 1A2B-3C4D',
method: 'auto',
});
expect(authorization).toEqual({
method: 'auto',
url: 'https://github.com/login/device',
instructions: 'Enter code: 1A2B-3C4D',
userCode: '1A2B-3C4D',
});
});
test('keeps an explicitly reported code over the instructions match', () => {
expect(parseAuthorization({
url: 'https://example.com',
instructions: 'Enter code: AAAA-BBBB',
user_code: 'ZZZZ-9999',
method: 'auto',
})?.userCode).toBe('ZZZZ-9999');
});
test('preserves the code method', () => {
expect(parseAuthorization({ url: 'https://example.com', method: 'code' })?.method).toBe('code');
});
test('treats a missing or unknown method as auto', () => {
expect(parseAuthorization({ url: 'https://example.com' })?.method).toBe('auto');
expect(parseAuthorization({ url: 'https://example.com', method: 'device' })?.method).toBe('auto');
});
test('unwraps a nested data envelope', () => {
expect(parseAuthorization({ data: { url: 'https://example.com', method: 'code' } })).toEqual({
method: 'code',
url: 'https://example.com',
});
});
test('accepts device-authorization field names', () => {
expect(parseAuthorization({
verification_uri_complete: 'https://example.com/activate?code=1',
message: 'Open the link',
})).toEqual({
method: 'auto',
url: 'https://example.com/activate?code=1',
instructions: 'Open the link',
});
});
test('returns null when nothing is actionable', () => {
expect(parseAuthorization(null)).toBeNull();
expect(parseAuthorization({})).toBeNull();
expect(parseAuthorization({ method: 'auto' })).toBeNull();
});
});
describe('describeOAuthError', () => {
const t: ProviderOAuthTranslator = (key) => key;
const fallback = 'settings.providers.page.toast.oauthCompleteFailed';
/** Names come from OpenCode's ProviderAuthApiError schema. */
test('maps each provider auth error name to its own message', () => {
expect(describeOAuthError({ name: 'ProviderAuthOauthMissing', data: {} }, t, fallback))
.toBe('settings.providers.page.auth.oauth.error.sessionExpired');
expect(describeOAuthError({ name: 'ProviderAuthOauthCodeMissing', data: {} }, t, fallback))
.toBe('settings.providers.page.auth.oauth.error.codeRequired');
expect(describeOAuthError({ name: 'ProviderAuthOauthCallbackFailed', data: {} }, t, fallback))
.toBe('settings.providers.page.auth.oauth.error.declined');
});
test('surfaces the plugin-authored validation message verbatim', () => {
const error = {
name: 'ProviderAuthValidationFailed',
data: { field: 'enterpriseUrl', message: 'URL or domain is required' },
};
expect(describeOAuthError(error, t, fallback)).toBe('URL or domain is required');
});
test('falls back when a validation failure carries no message', () => {
expect(describeOAuthError({ name: 'ProviderAuthValidationFailed', data: {} }, t, fallback))
.toBe('settings.providers.page.auth.oauth.error.invalidInput');
});
test('falls back for unknown, empty, and non-object errors', () => {
expect(describeOAuthError({ name: 'BadRequest', data: {} }, t, fallback)).toBe(fallback);
expect(describeOAuthError(new Error('network down'), t, fallback)).toBe(fallback);
expect(describeOAuthError(undefined, t, fallback)).toBe(fallback);
});
});
@@ -0,0 +1,244 @@
/**
* Provider OAuth flow helpers.
*
* `POST /provider/{id}/oauth/authorize` answers with the completion method that
* decides what the client has to do next:
*
* - `auto` — the client must call `oauth/callback` right away and hold that
* request open. Upstream blocks inside it (device-code polling, or waiting on
* a loopback redirect) until the user finishes signing in, and only then
* persists the credential. Nothing is stored if the client never calls it.
* - `code` — the user copies a code out of the browser and hands it to
* `oauth/callback`.
*
* Every auth plugin shipped with OpenCode uses `auto`; `code` stays supported
* for third-party auth plugins that still return it.
*/
import type { I18nKey, I18nParams } from '@/lib/i18n';
export type OAuthCompletionMethod = 'auto' | 'code';
export type ProviderOAuthTranslator = (key: I18nKey, params?: I18nParams) => string;
export interface OAuthAuthorization {
method: OAuthCompletionMethod;
url?: string;
instructions?: string;
/** Device code surfaced separately so it can be copied on its own. */
userCode?: string;
}
export interface AuthPromptOption {
label: string;
value: string;
hint?: string;
}
export interface AuthPromptCondition {
key: string;
op: 'eq' | 'neq';
value: string;
}
export interface AuthPrompt {
type: 'text' | 'select';
key: string;
message: string;
placeholder?: string;
options: AuthPromptOption[];
when?: AuthPromptCondition;
}
/**
* Device codes are only carried inside the human-readable instructions
* (`Enter code: ABCD-1234`), so they are recovered by shape.
*/
const DEVICE_CODE_PATTERN = /[A-Z0-9]{4}-[A-Z0-9]{4,5}/;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const asText = (value: unknown): string | undefined =>
typeof value === 'string' && value.length > 0 ? value : undefined;
const parsePromptOptions = (value: unknown): AuthPromptOption[] => {
if (!Array.isArray(value)) {
return [];
}
const options: AuthPromptOption[] = [];
for (const entry of value) {
if (!isRecord(entry)) {
continue;
}
const optionValue = asText(entry.value);
if (optionValue === undefined) {
continue;
}
options.push({
value: optionValue,
label: asText(entry.label) ?? optionValue,
...(asText(entry.hint) ? { hint: asText(entry.hint)! } : {}),
});
}
return options;
};
const parsePromptCondition = (value: unknown): AuthPromptCondition | undefined => {
if (!isRecord(value)) {
return undefined;
}
const key = asText(value.key);
const op = value.op === 'eq' || value.op === 'neq' ? value.op : undefined;
if (!key || !op || typeof value.value !== 'string') {
return undefined;
}
return { key, op, value: value.value };
};
/** Parses the `prompts` an auth method wants answered before `authorize`. */
export const parseAuthPrompts = (value: unknown): AuthPrompt[] => {
if (!Array.isArray(value)) {
return [];
}
const prompts: AuthPrompt[] = [];
for (const entry of value) {
if (!isRecord(entry)) {
continue;
}
const key = asText(entry.key);
if (!key) {
continue;
}
const type = entry.type === 'select' ? 'select' : 'text';
const options = type === 'select' ? parsePromptOptions(entry.options) : [];
// A select with no usable option can never be answered; skipping it would
// silently drop a required input, so treat the whole method as unusable.
if (type === 'select' && options.length === 0) {
continue;
}
const when = parsePromptCondition(entry.when);
prompts.push({
type,
key,
message: asText(entry.message) ?? key,
options,
...(asText(entry.placeholder) ? { placeholder: asText(entry.placeholder)! } : {}),
...(when ? { when } : {}),
});
}
return prompts;
};
/** True when a prompt's `when` condition is satisfied by the answers so far. */
export const isPromptVisible = (prompt: AuthPrompt, values: Record<string, string>): boolean => {
if (!prompt.when) {
return true;
}
const current = values[prompt.when.key] ?? '';
return prompt.when.op === 'eq'
? current === prompt.when.value
: current !== prompt.when.value;
};
export const visiblePrompts = (
prompts: AuthPrompt[],
values: Record<string, string>,
): AuthPrompt[] => prompts.filter((prompt) => isPromptVisible(prompt, values));
/** Selects preselect their first option so the form always starts answerable. */
export const defaultPromptValues = (prompts: AuthPrompt[]): Record<string, string> => {
const values: Record<string, string> = {};
for (const prompt of prompts) {
values[prompt.key] = prompt.type === 'select' ? (prompt.options[0]?.value ?? '') : '';
}
return values;
};
/** First visible prompt still left blank, or `null` when the form is complete. */
export const firstUnansweredPrompt = (
prompts: AuthPrompt[],
values: Record<string, string>,
): AuthPrompt | null =>
visiblePrompts(prompts, values).find((prompt) => (values[prompt.key] ?? '').trim().length === 0) ?? null;
/**
* Builds the `inputs` payload for `authorize`. Hidden prompts are dropped so a
* stale answer from a since-changed branch is never sent upstream.
*/
export const collectPromptInputs = (
prompts: AuthPrompt[],
values: Record<string, string>,
): Record<string, string> => {
const inputs: Record<string, string> = {};
for (const prompt of visiblePrompts(prompts, values)) {
inputs[prompt.key] = (values[prompt.key] ?? '').trim();
}
return inputs;
};
/**
* Normalizes an `authorize` response.
*
* Anything that is not explicitly `code` is treated as `auto`: `auto` only
* means "call back and wait", which is also the safe reading of an unknown
* method, whereas guessing `code` would strand the user at a paste field no
* provider can fill.
*
* Returns `null` when the response carries nothing the user can act on.
*/
export const parseAuthorization = (payload: unknown): OAuthAuthorization | null => {
const outer: Record<string, unknown> = isRecord(payload) ? payload : {};
const record: Record<string, unknown> = isRecord(outer.data) ? outer.data : outer;
const url =
asText(record.url)
?? asText(record.verification_uri_complete)
?? asText(record.verification_uri);
const instructions = asText(record.instructions) ?? asText(record.message);
if (!url && !instructions) {
return null;
}
const userCode =
asText(record.user_code)
?? asText(record.userCode)
?? (instructions ? DEVICE_CODE_PATTERN.exec(instructions)?.[0] : undefined);
return {
method: record.method === 'code' ? 'code' : 'auto',
...(url ? { url } : {}),
...(instructions ? { instructions } : {}),
...(userCode ? { userCode } : {}),
};
};
/**
* Renders a `ProviderAuthApiError` as user-facing copy.
*
* Validation failures carry a message authored by the auth plugin (a field
* rule such as "URL or domain is required"); it is shown verbatim because only
* the plugin knows which input was rejected.
*/
export const describeOAuthError = (
error: unknown,
t: ProviderOAuthTranslator,
fallbackKey: I18nKey,
): string => {
const record: Record<string, unknown> = isRecord(error) ? error : {};
const data: Record<string, unknown> = isRecord(record.data) ? record.data : {};
switch (record.name) {
case 'ProviderAuthOauthMissing':
return t('settings.providers.page.auth.oauth.error.sessionExpired');
case 'ProviderAuthOauthCodeMissing':
return t('settings.providers.page.auth.oauth.error.codeRequired');
case 'ProviderAuthOauthCallbackFailed':
return t('settings.providers.page.auth.oauth.error.declined');
case 'ProviderAuthValidationFailed':
return asText(data.message) ?? t('settings.providers.page.auth.oauth.error.invalidInput');
default:
return t(fallbackKey);
}
};
@@ -5,6 +5,8 @@ export interface AuthMethod {
description?: string;
help?: string;
method?: number;
/** Inputs an OAuth method wants answered before authorize; see `provider-oauth.ts`. */
prompts?: unknown;
[key: string]: unknown;
}
@@ -1321,6 +1321,17 @@ export const settingsDict = {
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
'settings.providers.page.auth.oauthMethodFallback': 'OAuth-Methode {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Autorisierungscode einfügen',
'settings.providers.page.auth.oauth.starting': 'Autorisierung wird gestartet …',
'settings.providers.page.auth.oauth.waiting': 'Warten auf Autorisierung …',
'settings.providers.page.auth.oauth.waitingHint': 'Schließen Sie die Anmeldung im Browser ab. Lassen Sie diese Seite geöffnet die Verbindung wird von selbst hergestellt.',
'settings.providers.page.auth.oauth.codeHint': 'Kopieren Sie den Autorisierungscode aus dem Browser und fügen Sie ihn hier ein.',
'settings.providers.page.auth.oauth.deviceCodeLabel': 'Gerätecode',
'settings.providers.page.auth.oauth.linkLabel': 'Autorisierungslink',
'settings.providers.page.auth.oauth.promptRequired': 'Füllen Sie „{field}“ aus, um fortzufahren',
'settings.providers.page.auth.oauth.error.sessionExpired': 'Die Autorisierungsanfrage ist abgelaufen. Verbinden Sie erneut, um sie neu zu starten.',
'settings.providers.page.auth.oauth.error.codeRequired': 'Dieser Anbieter benötigt den Autorisierungscode aus Ihrem Browser.',
'settings.providers.page.auth.oauth.error.declined': 'Die Autorisierung wurde abgelehnt oder nicht abgeschlossen.',
'settings.providers.page.auth.oauth.error.invalidInput': 'Die eingegebenen Angaben wurden abgelehnt.',
'settings.providers.page.auth.connected': 'Verbunden',
'settings.providers.page.auth.incomplete': 'Anmeldedaten fehlen',
'settings.providers.page.auth.incompleteHint': '· Fügen Sie einen API-Schlüssel oder {env:VAR} hinzu, bevor Sie diesen Anbieter im Chat verwenden',
@@ -1351,6 +1362,9 @@ export const settingsDict = {
'settings.providers.page.actions.open': 'Öffnen',
'settings.providers.page.actions.copy': 'Kopieren',
'settings.providers.page.actions.complete': 'Vervollständigen',
'settings.providers.page.actions.continue': 'Weiter',
'settings.providers.page.actions.cancel': 'Abbrechen',
'settings.providers.page.actions.tryAgain': 'Wiederholen',
'settings.providers.page.actions.hide': 'Ausblenden',
'settings.providers.page.actions.reconnect': 'Erneut verbinden',
'settings.providers.page.actions.edit': 'Bearbeiten',
@@ -1365,7 +1379,6 @@ export const settingsDict = {
'settings.providers.page.toast.apiKeySaved': 'API-Schlüssel gespeichert',
'settings.providers.page.toast.oauthStartFailed': 'Fehler beim Starten des OAuth-Flows',
'settings.providers.page.toast.oauthDetailsMissing': 'Keine OAuth-Details zurückgegeben',
'settings.providers.page.toast.completeOAuthInBrowser': 'Schließen Sie den OAuth-Flow in Ihrem Browser ab',
'settings.providers.page.toast.oauthCompleteFailed': 'Fehler beim Abschließen des OAuth-Flows',
'settings.providers.page.toast.oauthCompleted': 'OAuth-Verbindung abgeschlossen',
'settings.providers.page.toast.oauthLinkCopied': 'OAuth-Link kopiert',
@@ -1386,6 +1386,17 @@ export const settingsDict = {
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
'settings.providers.page.auth.oauthMethodFallback': 'OAuth method {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Paste authorization code',
'settings.providers.page.auth.oauth.starting': 'Starting authorization…',
'settings.providers.page.auth.oauth.waiting': 'Waiting for authorization…',
'settings.providers.page.auth.oauth.waitingHint': 'Finish signing in in your browser. Keep this page open — the connection completes on its own.',
'settings.providers.page.auth.oauth.codeHint': 'Copy the authorization code from your browser and paste it here.',
'settings.providers.page.auth.oauth.deviceCodeLabel': 'Device code',
'settings.providers.page.auth.oauth.linkLabel': 'Authorization link',
'settings.providers.page.auth.oauth.promptRequired': 'Fill in “{field}” to continue',
'settings.providers.page.auth.oauth.error.sessionExpired': 'The authorization request expired. Connect again to restart it.',
'settings.providers.page.auth.oauth.error.codeRequired': 'This provider needs the authorization code from your browser.',
'settings.providers.page.auth.oauth.error.declined': 'Authorization was declined or did not complete.',
'settings.providers.page.auth.oauth.error.invalidInput': 'The details you entered were rejected.',
'settings.providers.page.auth.connected': 'Connected',
'settings.providers.page.auth.incomplete': 'Credentials missing',
'settings.providers.page.auth.incompleteHint': '· Add an API key or {env:VAR} before using this provider in chat',
@@ -1416,6 +1427,9 @@ export const settingsDict = {
'settings.providers.page.actions.open': 'Open',
'settings.providers.page.actions.copy': 'Copy',
'settings.providers.page.actions.complete': 'Complete',
'settings.providers.page.actions.continue': 'Continue',
'settings.providers.page.actions.cancel': 'Cancel',
'settings.providers.page.actions.tryAgain': 'Try again',
'settings.providers.page.actions.hide': 'Hide',
'settings.providers.page.actions.reconnect': 'Reconnect',
'settings.providers.page.actions.edit': 'Edit',
@@ -1430,7 +1444,6 @@ export const settingsDict = {
'settings.providers.page.toast.apiKeySaved': 'API key saved',
'settings.providers.page.toast.oauthStartFailed': 'Failed to start OAuth flow',
'settings.providers.page.toast.oauthDetailsMissing': 'No OAuth details returned',
'settings.providers.page.toast.completeOAuthInBrowser': 'Complete the OAuth flow in your browser',
'settings.providers.page.toast.oauthCompleteFailed': 'Failed to complete OAuth flow',
'settings.providers.page.toast.oauthCompleted': 'OAuth connection completed',
'settings.providers.page.toast.oauthLinkCopied': 'OAuth link copied',
@@ -1359,6 +1359,17 @@ export const settingsDict = {
"settings.providers.page.auth.apiKeyPlaceholder": "sk-...",
"settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}",
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Pegar código de autorización",
"settings.providers.page.auth.oauth.starting": "Iniciando la autorización…",
"settings.providers.page.auth.oauth.waiting": "Esperando la autorización…",
"settings.providers.page.auth.oauth.waitingHint": "Termina de iniciar sesión en el navegador. Mantén esta página abierta: la conexión se completará sola.",
"settings.providers.page.auth.oauth.codeHint": "Copia el código de autorización del navegador y pégalo aquí.",
"settings.providers.page.auth.oauth.deviceCodeLabel": "Código del dispositivo",
"settings.providers.page.auth.oauth.linkLabel": "Enlace de autorización",
"settings.providers.page.auth.oauth.promptRequired": "Completa «{field}» para continuar",
"settings.providers.page.auth.oauth.error.sessionExpired": "La solicitud de autorización caducó. Vuelve a conectar para reiniciarla.",
"settings.providers.page.auth.oauth.error.codeRequired": "Este proveedor necesita el código de autorización de tu navegador.",
"settings.providers.page.auth.oauth.error.declined": "La autorización se rechazó o no se completó.",
"settings.providers.page.auth.oauth.error.invalidInput": "Se rechazaron los datos introducidos.",
"settings.providers.page.auth.connected": "Conectado",
"settings.providers.page.auth.incomplete": "Faltan credenciales",
"settings.providers.page.auth.incompleteHint": "· Añade una clave API o {env:VAR} antes de usar este proveedor en el chat",
@@ -1391,6 +1402,9 @@ export const settingsDict = {
"settings.providers.page.actions.open": "Abrir",
"settings.providers.page.actions.copy": "Copiar",
"settings.providers.page.actions.complete": "Completar",
"settings.providers.page.actions.continue": "Continuar",
"settings.providers.page.actions.cancel": "Cancelar",
"settings.providers.page.actions.tryAgain": "Reintentar",
"settings.providers.page.actions.hide": "Ocultar",
"settings.providers.page.actions.reconnect": "Reconectar",
"settings.providers.page.actions.edit": "Editar",
@@ -1406,7 +1420,6 @@ export const settingsDict = {
"settings.providers.page.toast.apiKeySaved": "Clave API guardada",
"settings.providers.page.toast.oauthStartFailed": "No se pudo iniciar el flujo OAuth",
"settings.providers.page.toast.oauthDetailsMissing": "No se devolvieron detalles de OAuth",
"settings.providers.page.toast.completeOAuthInBrowser": "Completa el flujo OAuth en tu navegador",
"settings.providers.page.toast.oauthCompleteFailed": "No se pudo completar el flujo OAuth",
"settings.providers.page.toast.oauthCompleted": "Conexión OAuth completada",
"settings.providers.page.toast.oauthLinkCopied": "Enlace de OAuth copiado",
@@ -1280,6 +1280,17 @@ export const settingsDict = {
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
'settings.providers.page.auth.oauthMethodFallback': 'Méthode OAuth {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Coller le code d\'autorisation',
'settings.providers.page.auth.oauth.starting': 'Démarrage de lautorisation…',
'settings.providers.page.auth.oauth.waiting': 'En attente de lautorisation…',
'settings.providers.page.auth.oauth.waitingHint': 'Terminez la connexion dans votre navigateur. Laissez cette page ouverte : la connexion se finalisera delle-même.',
'settings.providers.page.auth.oauth.codeHint': 'Copiez le code dautorisation depuis votre navigateur et collez-le ici.',
'settings.providers.page.auth.oauth.deviceCodeLabel': 'Code de lappareil',
'settings.providers.page.auth.oauth.linkLabel': 'Lien dautorisation',
'settings.providers.page.auth.oauth.promptRequired': 'Renseignez « {field} » pour continuer',
'settings.providers.page.auth.oauth.error.sessionExpired': 'La demande dautorisation a expiré. Reconnectez-vous pour la relancer.',
'settings.providers.page.auth.oauth.error.codeRequired': 'Ce fournisseur a besoin du code dautorisation de votre navigateur.',
'settings.providers.page.auth.oauth.error.declined': 'Lautorisation a été refusée ou na pas abouti.',
'settings.providers.page.auth.oauth.error.invalidInput': 'Les informations saisies ont été refusées.',
'settings.providers.page.auth.connected': 'Connecté',
'settings.providers.page.auth.incomplete': 'Identifiants manquants',
'settings.providers.page.auth.incompleteHint': '· Ajoutez une clé API ou {env:VAR} avant dutiliser ce fournisseur dans le chat',
@@ -1312,6 +1323,9 @@ export const settingsDict = {
'settings.providers.page.actions.open': 'Ouvrir',
'settings.providers.page.actions.copy': 'Copie',
'settings.providers.page.actions.complete': 'Complet',
'settings.providers.page.actions.continue': 'Continuer',
'settings.providers.page.actions.cancel': 'Annuler',
'settings.providers.page.actions.tryAgain': 'Réessayer',
'settings.providers.page.actions.hide': 'Cacher',
'settings.providers.page.actions.reconnect': 'Reconnecter',
'settings.providers.page.actions.edit': 'Modifier',
@@ -1327,7 +1341,6 @@ export const settingsDict = {
'settings.providers.page.toast.apiKeySaved': 'Clé API enregistrée',
'settings.providers.page.toast.oauthStartFailed': 'Échec du démarrage du flux OAuth',
'settings.providers.page.toast.oauthDetailsMissing': 'Aucun détail OAuth renvoyé',
'settings.providers.page.toast.completeOAuthInBrowser': 'Complétez le flux OAuth dans votre navigateur',
'settings.providers.page.toast.oauthCompleteFailed': 'Échec de la réalisation du flux OAuth',
'settings.providers.page.toast.oauthCompleted': 'Connexion OAuth terminée',
'settings.providers.page.toast.oauthLinkCopied': 'Lien OAuth copié',
@@ -1392,6 +1392,17 @@ export const settingsDict = {
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方法 {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '認証コードを貼り付け',
'settings.providers.page.auth.oauth.starting': '認証を開始しています…',
'settings.providers.page.auth.oauth.waiting': '認証を待っています…',
'settings.providers.page.auth.oauth.waitingHint': 'ブラウザーでサインインを完了してください。このページは開いたままにしてください。接続は自動的に完了します。',
'settings.providers.page.auth.oauth.codeHint': 'ブラウザーから認証コードをコピーして、ここに貼り付けてください。',
'settings.providers.page.auth.oauth.deviceCodeLabel': 'デバイスコード',
'settings.providers.page.auth.oauth.linkLabel': '認証リンク',
'settings.providers.page.auth.oauth.promptRequired': '続行するには「{field}」を入力してください',
'settings.providers.page.auth.oauth.error.sessionExpired': '認証リクエストの有効期限が切れました。もう一度接続してやり直してください。',
'settings.providers.page.auth.oauth.error.codeRequired': 'このプロバイダーにはブラウザーの認証コードが必要です。',
'settings.providers.page.auth.oauth.error.declined': '認証が拒否されたか、完了しませんでした。',
'settings.providers.page.auth.oauth.error.invalidInput': '入力された内容は拒否されました。',
'settings.providers.page.auth.connected': '接続済み',
'settings.providers.page.auth.incomplete': '認証情報が不足しています',
'settings.providers.page.auth.incompleteHint': '· チャットでこのプロバイダーを使う前に API キーまたは {env:VAR} を追加してください',
@@ -1424,6 +1435,9 @@ export const settingsDict = {
'settings.providers.page.actions.open': '開く',
'settings.providers.page.actions.copy': 'コピー',
'settings.providers.page.actions.complete': '完了',
'settings.providers.page.actions.continue': '続行',
'settings.providers.page.actions.cancel': 'キャンセル',
'settings.providers.page.actions.tryAgain': '再試行',
'settings.providers.page.actions.hide': '非表示',
'settings.providers.page.actions.reconnect': '再接続',
'settings.providers.page.actions.edit': '編集',
@@ -1439,7 +1453,6 @@ export const settingsDict = {
'settings.providers.page.toast.apiKeySaved': 'API キーを保存しました',
'settings.providers.page.toast.oauthStartFailed': 'OAuth フローの開始に失敗しました',
'settings.providers.page.toast.oauthDetailsMissing': 'OAuth の詳細が返されませんでした',
'settings.providers.page.toast.completeOAuthInBrowser': 'ブラウザで OAuth フローを完了してください',
'settings.providers.page.toast.oauthCompleteFailed': 'OAuth フローの完了に失敗しました',
'settings.providers.page.toast.oauthCompleted': 'OAuth 接続が完了しました',
'settings.providers.page.toast.oauthLinkCopied': 'OAuth リンクをコピーしました',
@@ -1359,6 +1359,17 @@ export const settingsDict = {
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 방식 {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'authorization code 붙여넣기',
'settings.providers.page.auth.oauth.starting': '인증을 시작하는 중…',
'settings.providers.page.auth.oauth.waiting': '인증을 기다리는 중…',
'settings.providers.page.auth.oauth.waitingHint': '브라우저에서 로그인을 완료하세요. 이 페이지를 열어 두면 연결이 자동으로 완료됩니다.',
'settings.providers.page.auth.oauth.codeHint': '브라우저에서 인증 코드를 복사해 여기에 붙여넣으세요.',
'settings.providers.page.auth.oauth.deviceCodeLabel': '기기 코드',
'settings.providers.page.auth.oauth.linkLabel': '인증 링크',
'settings.providers.page.auth.oauth.promptRequired': '계속하려면 “{field}”을(를) 입력하세요',
'settings.providers.page.auth.oauth.error.sessionExpired': '인증 요청이 만료되었습니다. 다시 연결해 처음부터 시작하세요.',
'settings.providers.page.auth.oauth.error.codeRequired': '이 제공자에는 브라우저의 인증 코드가 필요합니다.',
'settings.providers.page.auth.oauth.error.declined': '인증이 거부되었거나 완료되지 않았습니다.',
'settings.providers.page.auth.oauth.error.invalidInput': '입력한 정보가 거부되었습니다.',
'settings.providers.page.auth.connected': '연결됨',
'settings.providers.page.auth.incomplete': '자격 증명 없음',
'settings.providers.page.auth.incompleteHint': '· 채팅에서 이 공급자를 사용하기 전에 API 키 또는 {env:VAR}을(를) 추가하세요',
@@ -1391,6 +1402,9 @@ export const settingsDict = {
'settings.providers.page.actions.open': '열기',
'settings.providers.page.actions.copy': '복사',
'settings.providers.page.actions.complete': '완료',
'settings.providers.page.actions.continue': '계속',
'settings.providers.page.actions.cancel': '취소',
'settings.providers.page.actions.tryAgain': '다시 시도',
'settings.providers.page.actions.hide': '숨기기',
'settings.providers.page.actions.reconnect': '재연결',
'settings.providers.page.actions.edit': '편집',
@@ -1406,7 +1420,6 @@ export const settingsDict = {
'settings.providers.page.toast.apiKeySaved': 'API key가 저장되었습니다',
'settings.providers.page.toast.oauthStartFailed': 'OAuth flow를 시작하지 못했습니다',
'settings.providers.page.toast.oauthDetailsMissing': '반환된 OAuth 세부 정보가 없습니다',
'settings.providers.page.toast.completeOAuthInBrowser': '브라우저에서 OAuth flow를 완료하세요',
'settings.providers.page.toast.oauthCompleteFailed': 'OAuth flow를 완료하지 못했습니다',
'settings.providers.page.toast.oauthCompleted': 'OAuth 연결이 완료되었습니다',
'settings.providers.page.toast.oauthLinkCopied': 'OAuth 링크가 복사되었습니다',
@@ -1360,6 +1360,9 @@ export const settingsDict = {
'settings.projects.sidebar.actions.addProject': 'Dodaj projekt',
'settings.projects.sidebar.total': 'Suma: {count}',
'settings.providers.page.actions.complete': 'Zakończ',
'settings.providers.page.actions.continue': 'Kontynuuj',
'settings.providers.page.actions.cancel': 'Anuluj',
'settings.providers.page.actions.tryAgain': 'Spróbuj ponownie',
'settings.providers.page.actions.connect': 'Połącz',
'settings.providers.page.actions.copy': 'Kopiuj',
'settings.providers.page.actions.copyCode': 'Kopiuj kod',
@@ -1385,6 +1388,17 @@ export const settingsDict = {
'settings.providers.page.auth.loadingMethods': 'Ładowanie metod uwierzytelniania...',
'settings.providers.page.auth.oauthMethodFallback': 'Metoda OAuth {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Wklej kod autoryzacyjny',
'settings.providers.page.auth.oauth.starting': 'Rozpoczynanie autoryzacji…',
'settings.providers.page.auth.oauth.waiting': 'Oczekiwanie na autoryzację…',
'settings.providers.page.auth.oauth.waitingHint': 'Dokończ logowanie w przeglądarce. Zostaw tę stronę otwartą — połączenie zakończy się samo.',
'settings.providers.page.auth.oauth.codeHint': 'Skopiuj kod autoryzacji z przeglądarki i wklej go tutaj.',
'settings.providers.page.auth.oauth.deviceCodeLabel': 'Kod urządzenia',
'settings.providers.page.auth.oauth.linkLabel': 'Link autoryzacyjny',
'settings.providers.page.auth.oauth.promptRequired': 'Wypełnij pole „{field}”, aby kontynuować',
'settings.providers.page.auth.oauth.error.sessionExpired': 'Żądanie autoryzacji wygasło. Połącz ponownie, aby zacząć od nowa.',
'settings.providers.page.auth.oauth.error.codeRequired': 'Ten dostawca wymaga kodu autoryzacji z przeglądarki.',
'settings.providers.page.auth.oauth.error.declined': 'Autoryzacja została odrzucona lub nie została ukończona.',
'settings.providers.page.auth.oauth.error.invalidInput': 'Wprowadzone dane zostały odrzucone.',
'settings.providers.page.auth.title': 'Uwierzytelnianie',
'settings.providers.page.auth.useReconnectHint': '· Użyj Połącz ponownie, aby zaktualizować dane logowania',
'settings.providers.page.custom.optionLabel': 'Inny / Niestandardowy',
@@ -1474,7 +1488,6 @@ export const settingsDict = {
'settings.providers.page.toast.apiKeySaveFailed': 'Nie udało się zapisać klucza API',
'settings.providers.page.toast.apiKeySaved': 'Klucz API został zapisany',
'settings.providers.page.toast.authMethodsLoadFailed': 'Nie udało się załadować metod uwierzytelniania dostawcy',
'settings.providers.page.toast.completeOAuthInBrowser': 'Dokończ proces OAuth w przeglądarce',
'settings.providers.page.toast.deviceCodeCopied': 'Kod urządzenia został skopiowany',
'settings.providers.page.toast.deviceCodeCopyFailed': 'Nie udało się skopiować kodu urządzenia',
'settings.providers.page.toast.oauthCompleteFailed': 'Nie udało się dokończyć procesu OAuth',
@@ -1359,6 +1359,17 @@ export const settingsDict = {
"settings.providers.page.auth.apiKeyPlaceholder": "sk-...",
"settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}",
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Colar código de autorização",
"settings.providers.page.auth.oauth.starting": "Iniciando a autorização…",
"settings.providers.page.auth.oauth.waiting": "Aguardando a autorização…",
"settings.providers.page.auth.oauth.waitingHint": "Conclua o login no navegador. Mantenha esta página aberta — a conexão será concluída sozinha.",
"settings.providers.page.auth.oauth.codeHint": "Copie o código de autorização do navegador e cole aqui.",
"settings.providers.page.auth.oauth.deviceCodeLabel": "Código do dispositivo",
"settings.providers.page.auth.oauth.linkLabel": "Link de autorização",
"settings.providers.page.auth.oauth.promptRequired": "Preencha “{field}” para continuar",
"settings.providers.page.auth.oauth.error.sessionExpired": "A solicitação de autorização expirou. Conecte novamente para reiniciá-la.",
"settings.providers.page.auth.oauth.error.codeRequired": "Este provedor precisa do código de autorização do seu navegador.",
"settings.providers.page.auth.oauth.error.declined": "A autorização foi recusada ou não foi concluída.",
"settings.providers.page.auth.oauth.error.invalidInput": "Os dados informados foram recusados.",
"settings.providers.page.auth.connected": "Conectado",
"settings.providers.page.auth.incomplete": "Credenciais ausentes",
"settings.providers.page.auth.incompleteHint": "· Adicione uma chave de API ou {env:VAR} antes de usar este provedor no chat",
@@ -1391,6 +1402,9 @@ export const settingsDict = {
"settings.providers.page.actions.open": "Abrir",
"settings.providers.page.actions.copy": "Copiar",
"settings.providers.page.actions.complete": "Completar",
"settings.providers.page.actions.continue": "Continuar",
"settings.providers.page.actions.cancel": "Cancelar",
"settings.providers.page.actions.tryAgain": "Tentar novamente",
"settings.providers.page.actions.hide": "Ocultar",
"settings.providers.page.actions.reconnect": "Reconectar",
"settings.providers.page.actions.edit": "Editar",
@@ -1406,7 +1420,6 @@ export const settingsDict = {
"settings.providers.page.toast.apiKeySaved": "Chave API salva",
"settings.providers.page.toast.oauthStartFailed": "Não foi possível iniciar o fluxo OAuth",
"settings.providers.page.toast.oauthDetailsMissing": "Não se devolvieron detalhes de OAuth",
"settings.providers.page.toast.completeOAuthInBrowser": "Complete o fluxo OAuth no navegador",
"settings.providers.page.toast.oauthCompleteFailed": "Não foi possível concluir o fluxo OAuth",
"settings.providers.page.toast.oauthCompleted": "Conexão OAuth concluída",
"settings.providers.page.toast.oauthLinkCopied": "Link de OAuth copiado",
@@ -1359,6 +1359,17 @@ export const settingsDict = {
"settings.providers.page.auth.apiKeyPlaceholder": "sk-...",
"settings.providers.page.auth.oauthMethodFallback": "OAuth метод {index}",
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Вставити код авторизації",
"settings.providers.page.auth.oauth.starting": "Запускаємо авторизацію…",
"settings.providers.page.auth.oauth.waiting": "Очікуємо на авторизацію…",
"settings.providers.page.auth.oauth.waitingHint": "Завершіть вхід у браузері. Не закривайте цю сторінку — підключення завершиться саме.",
"settings.providers.page.auth.oauth.codeHint": "Скопіюйте код авторизації з браузера і вставте його сюди.",
"settings.providers.page.auth.oauth.deviceCodeLabel": "Код пристрою",
"settings.providers.page.auth.oauth.linkLabel": "Посилання для авторизації",
"settings.providers.page.auth.oauth.promptRequired": "Заповніть «{field}», щоб продовжити",
"settings.providers.page.auth.oauth.error.sessionExpired": "Термін дії запиту на авторизацію минув. Підключіться ще раз, щоб почати заново.",
"settings.providers.page.auth.oauth.error.codeRequired": "Цьому провайдеру потрібен код авторизації з браузера.",
"settings.providers.page.auth.oauth.error.declined": "Авторизацію відхилено або не завершено.",
"settings.providers.page.auth.oauth.error.invalidInput": "Введені дані відхилено.",
"settings.providers.page.auth.connected": "Підключено",
"settings.providers.page.auth.incomplete": "Облікові дані відсутні",
"settings.providers.page.auth.incompleteHint": "· Додайте API-ключ або {env:VAR} перед використанням цього провайдера в чаті",
@@ -1391,6 +1402,9 @@ export const settingsDict = {
"settings.providers.page.actions.open": "Відкрити",
"settings.providers.page.actions.copy": "Копіювати",
"settings.providers.page.actions.complete": "Завершити",
"settings.providers.page.actions.continue": "Продовжити",
"settings.providers.page.actions.cancel": "Скасувати",
"settings.providers.page.actions.tryAgain": "Повторити спробу",
"settings.providers.page.actions.hide": "Сховати",
"settings.providers.page.actions.reconnect": "Перепідключити",
"settings.providers.page.actions.edit": "Редагувати",
@@ -1406,7 +1420,6 @@ export const settingsDict = {
"settings.providers.page.toast.apiKeySaved": "Ключ API збережено",
"settings.providers.page.toast.oauthStartFailed": "Не вдалося запустити потік OAuth",
"settings.providers.page.toast.oauthDetailsMissing": "Деталі OAuth не повернуто",
"settings.providers.page.toast.completeOAuthInBrowser": "Завершіть процес OAuth у вашому браузері",
"settings.providers.page.toast.oauthCompleteFailed": "Не вдалося завершити потік OAuth",
"settings.providers.page.toast.oauthCompleted": "Підключення OAuth завершено",
"settings.providers.page.toast.oauthLinkCopied": "Посилання OAuth скопійовано",
@@ -1359,6 +1359,17 @@ export const settingsDict = {
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方式 {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '粘贴授权码',
'settings.providers.page.auth.oauth.starting': '正在启动授权…',
'settings.providers.page.auth.oauth.waiting': '正在等待授权…',
'settings.providers.page.auth.oauth.waitingHint': '请在浏览器中完成登录。保持此页面打开,连接会自动完成。',
'settings.providers.page.auth.oauth.codeHint': '从浏览器复制授权码并粘贴到此处。',
'settings.providers.page.auth.oauth.deviceCodeLabel': '设备码',
'settings.providers.page.auth.oauth.linkLabel': '授权链接',
'settings.providers.page.auth.oauth.promptRequired': '请填写“{field}”后继续',
'settings.providers.page.auth.oauth.error.sessionExpired': '授权请求已过期。请重新连接以重新开始。',
'settings.providers.page.auth.oauth.error.codeRequired': '此提供方需要浏览器中的授权码。',
'settings.providers.page.auth.oauth.error.declined': '授权被拒绝或未完成。',
'settings.providers.page.auth.oauth.error.invalidInput': '输入的信息被拒绝。',
'settings.providers.page.auth.connected': '已连接',
'settings.providers.page.auth.incomplete': '缺少凭据',
'settings.providers.page.auth.incompleteHint': '· 在聊天中使用此提供商之前,请添加 API 密钥或 {env:VAR}',
@@ -1391,6 +1402,9 @@ export const settingsDict = {
'settings.providers.page.actions.open': '打开',
'settings.providers.page.actions.copy': '复制',
'settings.providers.page.actions.complete': '完成',
'settings.providers.page.actions.continue': '继续',
'settings.providers.page.actions.cancel': '取消',
'settings.providers.page.actions.tryAgain': '重试',
'settings.providers.page.actions.hide': '隐藏',
'settings.providers.page.actions.reconnect': '重新连接',
'settings.providers.page.actions.edit': '编辑',
@@ -1406,7 +1420,6 @@ export const settingsDict = {
'settings.providers.page.toast.apiKeySaved': 'API Key 已保存',
'settings.providers.page.toast.oauthStartFailed': '启动 OAuth 流程失败',
'settings.providers.page.toast.oauthDetailsMissing': '未返回 OAuth 详情',
'settings.providers.page.toast.completeOAuthInBrowser': '请在浏览器中完成 OAuth 流程',
'settings.providers.page.toast.oauthCompleteFailed': '完成 OAuth 流程失败',
'settings.providers.page.toast.oauthCompleted': 'OAuth 连接已完成',
'settings.providers.page.toast.oauthLinkCopied': 'OAuth 链接已复制',
@@ -1265,6 +1265,17 @@
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方式 {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '貼上授權碼',
'settings.providers.page.auth.oauth.starting': '正在啟動授權…',
'settings.providers.page.auth.oauth.waiting': '正在等待授權…',
'settings.providers.page.auth.oauth.waitingHint': '請在瀏覽器中完成登入。保持此頁面開啟,連線會自動完成。',
'settings.providers.page.auth.oauth.codeHint': '從瀏覽器複製授權碼並貼上到這裡。',
'settings.providers.page.auth.oauth.deviceCodeLabel': '裝置碼',
'settings.providers.page.auth.oauth.linkLabel': '授權連結',
'settings.providers.page.auth.oauth.promptRequired': '請填寫「{field}」後繼續',
'settings.providers.page.auth.oauth.error.sessionExpired': '授權請求已過期。請重新連線以重新開始。',
'settings.providers.page.auth.oauth.error.codeRequired': '此提供者需要瀏覽器中的授權碼。',
'settings.providers.page.auth.oauth.error.declined': '授權遭拒或未完成。',
'settings.providers.page.auth.oauth.error.invalidInput': '輸入的資訊遭拒。',
'settings.providers.page.auth.connected': '已連線',
'settings.providers.page.auth.incomplete': '缺少憑證',
'settings.providers.page.auth.incompleteHint': '· 在聊天中使用此提供者之前,請新增 API 金鑰或 {env:VAR}',
@@ -1297,6 +1308,9 @@
'settings.providers.page.actions.open': '開啟',
'settings.providers.page.actions.copy': '複製',
'settings.providers.page.actions.complete': '完成',
'settings.providers.page.actions.continue': '繼續',
'settings.providers.page.actions.cancel': '取消',
'settings.providers.page.actions.tryAgain': '重試',
'settings.providers.page.actions.hide': '隱藏',
'settings.providers.page.actions.reconnect': '重新連線',
'settings.providers.page.actions.edit': '編輯',
@@ -1312,7 +1326,6 @@
'settings.providers.page.toast.apiKeySaved': 'API Key 已儲存',
'settings.providers.page.toast.oauthStartFailed': '啟動 OAuth 流程失敗',
'settings.providers.page.toast.oauthDetailsMissing': '未回傳 OAuth 詳情',
'settings.providers.page.toast.completeOAuthInBrowser': '請在瀏覽器中完成 OAuth 流程',
'settings.providers.page.toast.oauthCompleteFailed': '完成 OAuth 流程失敗',
'settings.providers.page.toast.oauthCompleted': 'OAuth 連線已完成',
'settings.providers.page.toast.oauthLinkCopied': 'OAuth 連結已複製',
@@ -375,6 +375,8 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
- SSE forwarders: `GET /api/global/event`, `GET /api/event`
- Downstream heartbeats keep clients and intermediaries alive, while a separate upstream-only stall watchdog closes the downstream response when OpenCode stops producing bytes so clients reconnect instead of trusting synthetic heartbeats indefinitely. Each watchdog reset uses the current load-aware timeout, matching the shared event transport.
- Session message forwarder: `POST /api/session/:sessionId/message`
- Interactive OAuth forwarder: `POST /api/provider/:providerID/oauth/callback`
- Upstream blocks inside this call for the whole browser sign-in (device-code polling or a loopback redirect), so it is exempt from the ordinary request deadline and uses a 15-minute proxy timeout instead of `LONG_REQUEST_TIMEOUT_MS`. All other `/api/provider/*` routes, including `oauth/authorize`, keep the ordinary deadline.
- Generic `/api/*` forwarding with hop-by-hop header filtering
- Windows `/session` merge fallback path behavior
- OpenCode readiness gate for proxied `/api` requests
+21 -3
View File
@@ -309,6 +309,16 @@ export const registerOpenCodeProxy = (app, deps) => {
const PROXY_REQUEST_TIMEOUT_MS = normalizeProxyTimeout(LONG_REQUEST_TIMEOUT_MS);
const PROXY_TIMEOUT_MARKER = Symbol('openchamberProxyTimedOut');
// A provider OAuth callback blocks upstream for as long as the user takes to
// sign in in their browser (device-code polling, or a loopback redirect), so
// it cannot share the ordinary request deadline. Bounded by the shortest
// upstream expiry we know of — GitHub device codes last ~15 minutes.
const INTERACTIVE_OAUTH_TIMEOUT_MS = 15 * 60 * 1000;
const INTERACTIVE_OAUTH_PATH = /^\/provider\/[^/]+\/oauth\/callback\/?$/;
const isInteractiveOAuthCallback = (req) =>
req.method === 'POST' && INTERACTIVE_OAUTH_PATH.test(req.path);
const isProxyTimeoutError = (error) => {
const code = typeof error?.code === 'string' ? error.code : '';
const message = typeof error?.message === 'string' ? error.message.toLowerCase() : '';
@@ -327,6 +337,10 @@ export const registerOpenCodeProxy = (app, deps) => {
};
const applyProxyResponseDeadline = (req, res, next) => {
if (isInteractiveOAuthCallback(req)) {
return next();
}
const timeout = setTimeout(() => {
req[PROXY_TIMEOUT_MARKER] = true;
if (sendProxyErrorResponse(res, 504)) {
@@ -753,12 +767,12 @@ export const registerOpenCodeProxy = (app, deps) => {
});
// Generic proxy for non-SSE OpenCode API routes.
const apiProxy = createProxyMiddleware({
const createApiProxy = (timeoutMs) => createProxyMiddleware({
target: resolveProxyTarget(),
changeOrigin: true,
pathRewrite: { '^/api': '' },
timeout: PROXY_REQUEST_TIMEOUT_MS,
proxyTimeout: PROXY_REQUEST_TIMEOUT_MS,
timeout: timeoutMs,
proxyTimeout: timeoutMs,
// Dynamic target — port can change after restart
router: () => resolveProxyTarget(),
on: {
@@ -805,6 +819,9 @@ export const registerOpenCodeProxy = (app, deps) => {
},
});
const apiProxy = createApiProxy(PROXY_REQUEST_TIMEOUT_MS);
const interactiveOAuthProxy = createApiProxy(INTERACTIVE_OAUTH_TIMEOUT_MS);
// Best-effort fallback for stale clients still sending symlink paths.
// Settings and project selection normalize at source; this cached async path
// avoids blocking the proxy hot path on every directory-scoped request.
@@ -821,5 +838,6 @@ export const registerOpenCodeProxy = (app, deps) => {
});
app.use('/api', applyProxyResponseDeadline);
app.post('/api/provider/:providerID/oauth/callback', interactiveOAuthProxy);
app.use('/api', apiProxy);
};
@@ -623,4 +623,87 @@ describe('OpenCode proxy SSE forwarding', () => {
expect(response.status).toBe(504);
await expect(response.json()).resolves.toMatchObject({ error: 'OpenCode upstream timed out' });
});
it('exempts interactive provider OAuth callbacks from the request deadline', async () => {
const upstream = express();
// Stands in for upstream blocking until the user finishes signing in.
upstream.post('/provider/:providerID/oauth/callback', async (_req, res) => {
await new Promise((resolve) => setTimeout(resolve, 250));
res.json(true);
});
upstreamServer = await listen(upstream);
const upstreamPort = upstreamServer.address().port;
const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`;
const app = express();
registerOpenCodeProxy(app, {
fs: {},
os: {},
path,
OPEN_CODE_READY_GRACE_MS: 0,
LONG_REQUEST_TIMEOUT_MS: 50,
getRuntime: () => ({
openCodePort: upstreamPort,
openCodeBaseUrl: externalBaseUrl,
isOpenCodeReady: true,
openCodeNotReadySince: 0,
isRestartingOpenCode: false,
}),
getOpenCodeAuthHeaders: () => ({}),
buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`,
ensureOpenCodeApiPrefix: () => {},
});
proxyServer = await listen(app);
const proxyPort = proxyServer.address().port;
const response = await fetch(`http://127.0.0.1:${proxyPort}/api/provider/github-copilot/oauth/callback`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ method: 0 }),
signal: AbortSignal.timeout(5000),
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toBe(true);
});
it('still applies the request deadline to the OAuth authorize call', async () => {
const upstream = express();
upstream.post('/provider/:providerID/oauth/authorize', (_req, _res) => {
// Leave the response open so the proxy timeout path is exercised.
});
upstreamServer = await listen(upstream);
const upstreamPort = upstreamServer.address().port;
const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`;
const app = express();
registerOpenCodeProxy(app, {
fs: {},
os: {},
path,
OPEN_CODE_READY_GRACE_MS: 0,
LONG_REQUEST_TIMEOUT_MS: 50,
getRuntime: () => ({
openCodePort: upstreamPort,
openCodeBaseUrl: externalBaseUrl,
isOpenCodeReady: true,
openCodeNotReadySince: 0,
isRestartingOpenCode: false,
}),
getOpenCodeAuthHeaders: () => ({}),
buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`,
ensureOpenCodeApiPrefix: () => {},
});
proxyServer = await listen(app);
const proxyPort = proxyServer.address().port;
const response = await fetch(`http://127.0.0.1:${proxyPort}/api/provider/github-copilot/oauth/authorize`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ method: 0 }),
signal: AbortSignal.timeout(2000),
});
expect(response.status).toBe(504);
});
});