feat: add custom/other OpenAI-compatible LLM providers #2571
This commit is contained in:
@@ -10,11 +10,23 @@ Before OpenChamber can do anything, it needs at least one AI provider connected.
|
||||
## Connect a provider
|
||||
|
||||
1. Open **Settings → Providers**.
|
||||
2. Open the **Add provider** menu and pick a provider that isn't connected yet.
|
||||
2. Open the **Add provider** menu and pick a provider that isn't connected yet, or choose **Other / Custom** for an OpenAI-compatible endpoint.
|
||||
3. Sign in one of two ways, depending on the provider:
|
||||
- **API key** — paste your key and save.
|
||||
- **Sign-in (device flow)** — OpenChamber shows a link and a short code. Open the link, enter the code, and approve. OpenChamber finishes connecting on its own.
|
||||
|
||||
### Custom / Other providers
|
||||
|
||||
For gateways, campus LLMs, Ollama, LiteLLM, and similar OpenAI-compatible APIs:
|
||||
|
||||
1. Choose **Other / Custom** in the provider list.
|
||||
2. Enter a provider ID, display name, base URL (`http://` or `https://`), API key (or `{env:VAR_NAME}`), and at least one model id/name.
|
||||
3. Optionally add request headers.
|
||||
4. Save — OpenChamber writes the provider block to OpenCode config and stores the key in OpenCode auth (literal keys) or records an `{env:VAR}` reference.
|
||||
5. To change an existing custom provider, open it and choose **Edit**.
|
||||
|
||||
Custom providers require an API key or `{env:VAR_NAME}` before they show as fully connected. Without credentials, models may appear but chat calls will fail.
|
||||
|
||||
When a provider shows as connected, its models become available in chat.
|
||||
|
||||
To disconnect, open the provider and choose to remove its sign-in.
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsStackedField,
|
||||
SETTINGS_FIELDS_STACK_CLASS,
|
||||
SETTINGS_FIELD_LABEL_CLASS,
|
||||
SETTINGS_HELPER_CLASS,
|
||||
SETTINGS_ICON_BUTTON_CLASS,
|
||||
SETTINGS_CONTROL_CLUSTER_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
createEmptyCustomProviderForm,
|
||||
createHeaderRow,
|
||||
createModelRow,
|
||||
validateCustomProvider,
|
||||
type CustomProviderFormState,
|
||||
type CustomProviderPersistPlan,
|
||||
type CustomProviderTranslator,
|
||||
type FieldErrors,
|
||||
type HeaderFieldErrors,
|
||||
type ModelFieldErrors,
|
||||
} from './custom-provider-form';
|
||||
|
||||
type CustomProviderFormProps = {
|
||||
existingProviderIDs: ReadonlySet<string>;
|
||||
disabledProviders?: readonly string[];
|
||||
busy?: boolean;
|
||||
mode?: 'create' | 'edit';
|
||||
initialValues?: CustomProviderFormState;
|
||||
allowExistingAuth?: boolean;
|
||||
authFailureHint?: string | null;
|
||||
onSubmit: (plan: CustomProviderPersistPlan) => void | Promise<void>;
|
||||
onCancel?: () => void;
|
||||
onDisconnect?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
|
||||
existingProviderIDs,
|
||||
disabledProviders = [],
|
||||
busy = false,
|
||||
mode = 'create',
|
||||
initialValues,
|
||||
allowExistingAuth = false,
|
||||
authFailureHint = null,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onDisconnect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isEdit = mode === 'edit';
|
||||
const [form, setForm] = React.useState<CustomProviderFormState>(
|
||||
() => initialValues ?? createEmptyCustomProviderForm(),
|
||||
);
|
||||
const [err, setErr] = React.useState<FieldErrors>({});
|
||||
const [modelErrors, setModelErrors] = React.useState<ModelFieldErrors[]>([]);
|
||||
const [headerErrors, setHeaderErrors] = React.useState<HeaderFieldErrors[]>([]);
|
||||
const seededEditProviderIdRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!initialValues) {
|
||||
return;
|
||||
}
|
||||
// Edit mode: seed once per provider id so parent re-renders (new object
|
||||
// identity for the same snapshot) do not wipe in-progress edits.
|
||||
if (isEdit && seededEditProviderIdRef.current === initialValues.providerID) {
|
||||
return;
|
||||
}
|
||||
seededEditProviderIdRef.current = isEdit ? initialValues.providerID : null;
|
||||
setForm(initialValues);
|
||||
setErr({});
|
||||
setModelErrors([]);
|
||||
setHeaderErrors([]);
|
||||
}, [initialValues, isEdit]);
|
||||
|
||||
const setField = (key: keyof Pick<CustomProviderFormState, 'providerID' | 'name' | 'baseURL' | 'apiKey'>, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
setErr((prev) => ({ ...prev, [key]: undefined }));
|
||||
};
|
||||
|
||||
const setModel = (index: number, key: 'id' | 'name', value: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
models: prev.models.map((row, rowIndex) => (rowIndex === index ? { ...row, [key]: value } : row)),
|
||||
}));
|
||||
setModelErrors((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...(next[index] ?? {}), [key]: undefined };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const setHeader = (index: number, key: 'key' | 'value', value: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
headers: prev.headers.map((row, rowIndex) => (rowIndex === index ? { ...row, [key]: value } : row)),
|
||||
}));
|
||||
setHeaderErrors((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...(next[index] ?? {}), [key]: undefined };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const output = validateCustomProvider({
|
||||
form,
|
||||
t: ((key, vars) => t(key as Parameters<typeof t>[0], vars)) as CustomProviderTranslator,
|
||||
existingProviderIDs,
|
||||
disabledProviders,
|
||||
editingProviderID: isEdit ? form.providerID : undefined,
|
||||
allowExistingAuth: isEdit && allowExistingAuth,
|
||||
});
|
||||
setErr(output.err);
|
||||
setModelErrors(output.models);
|
||||
setHeaderErrors(output.headers);
|
||||
if (!output.result) {
|
||||
return;
|
||||
}
|
||||
await onSubmit(output.result);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-0">
|
||||
<SettingsSection
|
||||
title={isEdit ? t('settings.providers.page.custom.editTitle') : t('settings.providers.page.custom.title')}
|
||||
divider={false}
|
||||
settingsItem="providers.custom"
|
||||
contentClassName={SETTINGS_FIELDS_STACK_CLASS}
|
||||
>
|
||||
<p className={SETTINGS_HELPER_CLASS}>{t('settings.providers.page.custom.description')}</p>
|
||||
|
||||
{authFailureHint ? (
|
||||
<p className="typography-meta text-[var(--status-warning)]" role="status">
|
||||
{authFailureHint}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SettingsStackedField
|
||||
label={t('settings.providers.page.custom.field.providerID.label')}
|
||||
info={t('settings.providers.page.custom.field.providerID.info')}
|
||||
>
|
||||
<Input
|
||||
value={form.providerID}
|
||||
onChange={(event) => setField('providerID', event.target.value)}
|
||||
placeholder={t('settings.providers.page.custom.field.providerID.placeholder')}
|
||||
className="h-8 rounded-md px-3 font-mono text-xs"
|
||||
autoFocus={!isEdit}
|
||||
disabled={isEdit || busy}
|
||||
aria-invalid={Boolean(err.providerID)}
|
||||
aria-label={t('settings.providers.page.custom.field.providerID.label')}
|
||||
/>
|
||||
{err.providerID ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.providerID}</p> : null}
|
||||
</SettingsStackedField>
|
||||
|
||||
<SettingsStackedField
|
||||
label={t('settings.providers.page.custom.field.name.label')}
|
||||
info={t('settings.providers.page.custom.field.name.info')}
|
||||
>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(event) => setField('name', event.target.value)}
|
||||
placeholder={t('settings.providers.page.custom.field.name.placeholder')}
|
||||
className="h-8 rounded-md px-3"
|
||||
aria-invalid={Boolean(err.name)}
|
||||
aria-label={t('settings.providers.page.custom.field.name.label')}
|
||||
/>
|
||||
{err.name ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.name}</p> : null}
|
||||
</SettingsStackedField>
|
||||
|
||||
<SettingsStackedField
|
||||
label={t('settings.providers.page.custom.field.baseURL.label')}
|
||||
info={t('settings.providers.page.custom.field.baseURL.info')}
|
||||
>
|
||||
<Input
|
||||
value={form.baseURL}
|
||||
onChange={(event) => setField('baseURL', event.target.value)}
|
||||
placeholder={t('settings.providers.page.custom.field.baseURL.placeholder')}
|
||||
className="h-8 rounded-md px-3 font-mono text-xs"
|
||||
aria-invalid={Boolean(err.baseURL)}
|
||||
aria-label={t('settings.providers.page.custom.field.baseURL.label')}
|
||||
/>
|
||||
{err.baseURL ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.baseURL}</p> : null}
|
||||
</SettingsStackedField>
|
||||
|
||||
<SettingsStackedField
|
||||
label={t('settings.providers.page.custom.field.apiKey.label')}
|
||||
info={
|
||||
isEdit && allowExistingAuth
|
||||
? t('settings.providers.page.custom.field.apiKey.editInfo')
|
||||
: t('settings.providers.page.custom.field.apiKey.info')
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.apiKey}
|
||||
onChange={(event) => setField('apiKey', event.target.value)}
|
||||
placeholder={
|
||||
isEdit && allowExistingAuth
|
||||
? t('settings.providers.page.custom.field.apiKey.editPlaceholder')
|
||||
: t('settings.providers.page.custom.field.apiKey.placeholder')
|
||||
}
|
||||
className="h-8 rounded-md px-3 font-mono text-xs"
|
||||
aria-invalid={Boolean(err.apiKey)}
|
||||
aria-label={t('settings.providers.page.custom.field.apiKey.label')}
|
||||
/>
|
||||
{err.apiKey ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.apiKey}</p> : null}
|
||||
</SettingsStackedField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.providers.page.custom.models.title')}
|
||||
contentClassName={SETTINGS_FIELDS_STACK_CLASS}
|
||||
>
|
||||
{form.models.map((model, index) => (
|
||||
<div key={model.row} className={`${SETTINGS_CONTROL_CLUSTER_CLASS} space-y-2`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div>
|
||||
<label className={SETTINGS_FIELD_LABEL_CLASS}>
|
||||
{t('settings.providers.page.custom.models.idLabel')}
|
||||
</label>
|
||||
<Input
|
||||
value={model.id}
|
||||
onChange={(event) => setModel(index, 'id', event.target.value)}
|
||||
placeholder={t('settings.providers.page.custom.models.idPlaceholder')}
|
||||
className="mt-1 h-8 rounded-md px-3 font-mono text-xs"
|
||||
aria-label={t('settings.providers.page.custom.models.idLabel')}
|
||||
/>
|
||||
{modelErrors[index]?.id ? (
|
||||
<p className="mt-1 typography-meta text-[var(--status-error)]">{modelErrors[index]?.id}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<label className={SETTINGS_FIELD_LABEL_CLASS}>
|
||||
{t('settings.providers.page.custom.models.nameLabel')}
|
||||
</label>
|
||||
<Input
|
||||
value={model.name}
|
||||
onChange={(event) => setModel(index, 'name', event.target.value)}
|
||||
placeholder={t('settings.providers.page.custom.models.namePlaceholder')}
|
||||
className="mt-1 h-8 rounded-md px-3"
|
||||
aria-label={t('settings.providers.page.custom.models.nameLabel')}
|
||||
/>
|
||||
{modelErrors[index]?.name ? (
|
||||
<p className="mt-1 typography-meta text-[var(--status-error)]">{modelErrors[index]?.name}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={SETTINGS_ICON_BUTTON_CLASS}
|
||||
disabled={form.models.length <= 1}
|
||||
onClick={() => {
|
||||
if (form.models.length <= 1) return;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
models: prev.models.filter((_, rowIndex) => rowIndex !== index),
|
||||
}));
|
||||
setModelErrors((prev) => prev.filter((_, rowIndex) => rowIndex !== index));
|
||||
}}
|
||||
aria-label={t('settings.providers.page.custom.models.remove')}
|
||||
>
|
||||
<Icon name="delete-bin" className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => {
|
||||
setForm((prev) => ({ ...prev, models: [...prev.models, createModelRow()] }));
|
||||
setModelErrors((prev) => [...prev, {}]);
|
||||
}}
|
||||
>
|
||||
{t('settings.providers.page.custom.models.add')}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.providers.page.custom.headers.title')}
|
||||
contentClassName={SETTINGS_FIELDS_STACK_CLASS}
|
||||
>
|
||||
<p className={SETTINGS_HELPER_CLASS}>{t('settings.providers.page.custom.headers.description')}</p>
|
||||
{form.headers.map((header, index) => (
|
||||
<div key={header.row} className={`${SETTINGS_CONTROL_CLUSTER_CLASS} space-y-2`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div>
|
||||
<label className={SETTINGS_FIELD_LABEL_CLASS}>
|
||||
{t('settings.providers.page.custom.headers.keyLabel')}
|
||||
</label>
|
||||
<Input
|
||||
value={header.key}
|
||||
onChange={(event) => setHeader(index, 'key', event.target.value)}
|
||||
placeholder={t('settings.providers.page.custom.headers.keyPlaceholder')}
|
||||
className="mt-1 h-8 rounded-md px-3 font-mono text-xs"
|
||||
aria-label={t('settings.providers.page.custom.headers.keyLabel')}
|
||||
/>
|
||||
{headerErrors[index]?.key ? (
|
||||
<p className="mt-1 typography-meta text-[var(--status-error)]">{headerErrors[index]?.key}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<label className={SETTINGS_FIELD_LABEL_CLASS}>
|
||||
{t('settings.providers.page.custom.headers.valueLabel')}
|
||||
</label>
|
||||
<Input
|
||||
value={header.value}
|
||||
onChange={(event) => setHeader(index, 'value', event.target.value)}
|
||||
placeholder={t('settings.providers.page.custom.headers.valuePlaceholder')}
|
||||
className="mt-1 h-8 rounded-md px-3 font-mono text-xs"
|
||||
aria-label={t('settings.providers.page.custom.headers.valueLabel')}
|
||||
/>
|
||||
{headerErrors[index]?.value ? (
|
||||
<p className="mt-1 typography-meta text-[var(--status-error)]">{headerErrors[index]?.value}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={SETTINGS_ICON_BUTTON_CLASS}
|
||||
disabled={form.headers.length <= 1}
|
||||
onClick={() => {
|
||||
if (form.headers.length <= 1) return;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
headers: prev.headers.filter((_, rowIndex) => rowIndex !== index),
|
||||
}));
|
||||
setHeaderErrors((prev) => prev.filter((_, rowIndex) => rowIndex !== index));
|
||||
}}
|
||||
aria-label={t('settings.providers.page.custom.headers.remove')}
|
||||
>
|
||||
<Icon name="delete-bin" className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => {
|
||||
setForm((prev) => ({ ...prev, headers: [...prev.headers, createHeaderRow()] }));
|
||||
setHeaderErrors((prev) => [...prev, {}]);
|
||||
}}
|
||||
>
|
||||
{t('settings.providers.page.custom.headers.add')}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 py-4">
|
||||
{onCancel ? (
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={onCancel} disabled={busy}>
|
||||
{t('settings.providers.page.custom.actions.back')}
|
||||
</Button>
|
||||
) : null}
|
||||
{onDisconnect ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => void onDisconnect()}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('settings.providers.page.actions.disconnect')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="submit" size="xs" className="!font-normal" disabled={busy}>
|
||||
{busy
|
||||
? t('settings.providers.page.actions.saving')
|
||||
: isEdit
|
||||
? t('settings.providers.page.custom.actions.update')
|
||||
: t('settings.providers.page.custom.actions.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -26,6 +26,18 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { shouldLoadAvailableProviders } from './providerAvailability';
|
||||
import { CustomProviderForm } from './CustomProviderForm';
|
||||
import {
|
||||
buildAuthSetRequest,
|
||||
buildProviderUpsertRequest,
|
||||
CUSTOM_PROVIDER_ID,
|
||||
isConfigDefinedCustomProvider,
|
||||
providerToCustomFormState,
|
||||
resolveProviderConfigScope,
|
||||
type CustomProviderFormState,
|
||||
type CustomProviderPersistPlan,
|
||||
type ProviderConfigScope,
|
||||
} from './custom-provider-form';
|
||||
|
||||
const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
notation: 'compact',
|
||||
@@ -172,7 +184,19 @@ export const ProvidersPage: React.FC = () => {
|
||||
const [providerDropdownOpen, setProviderDropdownOpen] = React.useState(false);
|
||||
const [providerSources, setProviderSources] = React.useState<Record<string, ProviderSources>>({});
|
||||
const [showAuthPanel, setShowAuthPanel] = React.useState(false);
|
||||
const [editingCustomProviderId, setEditingCustomProviderId] = React.useState<string | null>(null);
|
||||
const [editingCustomFormInitial, setEditingCustomFormInitial] = React.useState<CustomProviderFormState | null>(null);
|
||||
const [editingCustomScope, setEditingCustomScope] = React.useState<ProviderConfigScope | null>(null);
|
||||
const [customAuthFailureHint, setCustomAuthFailureHint] = React.useState<string | null>(null);
|
||||
const [lastCustomPersistId, setLastCustomPersistId] = React.useState<string | null>(null);
|
||||
const isAddMode = selectedProviderId === ADD_PROVIDER_ID;
|
||||
const isCustomCreateMode = isAddMode && candidateProviderId === CUSTOM_PROVIDER_ID;
|
||||
const isCustomEditMode = Boolean(
|
||||
editingCustomProviderId
|
||||
&& selectedProviderId
|
||||
&& editingCustomProviderId === selectedProviderId
|
||||
&& !isAddMode,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedProviderId && providers.length > 0) {
|
||||
@@ -271,7 +295,11 @@ export const ProvidersPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (candidateProviderId && !unconnectedProviders.some((provider) => provider.id === candidateProviderId)) {
|
||||
if (
|
||||
candidateProviderId
|
||||
&& candidateProviderId !== CUSTOM_PROVIDER_ID
|
||||
&& !unconnectedProviders.some((provider) => provider.id === candidateProviderId)
|
||||
) {
|
||||
setCandidateProviderId('');
|
||||
}
|
||||
}, [selectedProviderId, candidateProviderId, unconnectedProviders]);
|
||||
@@ -279,11 +307,21 @@ export const ProvidersPage: React.FC = () => {
|
||||
React.useEffect(() => {
|
||||
if (selectedProviderId === ADD_PROVIDER_ID) {
|
||||
setShowAuthPanel(true);
|
||||
setEditingCustomProviderId(null);
|
||||
setEditingCustomFormInitial(null);
|
||||
setEditingCustomScope(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowAuthPanel(false);
|
||||
}, [selectedProviderId, t]);
|
||||
if (editingCustomProviderId && editingCustomProviderId !== selectedProviderId) {
|
||||
setEditingCustomProviderId(null);
|
||||
setEditingCustomFormInitial(null);
|
||||
setEditingCustomScope(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
}
|
||||
}, [selectedProviderId, editingCustomProviderId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
|
||||
@@ -361,6 +399,68 @@ export const ProvidersPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCustomProvider = async (plan: CustomProviderPersistPlan) => {
|
||||
const busyKey = `custom:${plan.providerID}`;
|
||||
setAuthBusyKey(busyKey);
|
||||
setLastCustomPersistId(plan.providerID);
|
||||
setCustomAuthFailureHint(null);
|
||||
|
||||
try {
|
||||
// Auth first so a failed key write cannot leave an orphan config that
|
||||
// blocks create validation, and so PUT can pass hasStoredAuth for literal keys.
|
||||
const authRequest = buildAuthSetRequest(plan);
|
||||
if (authRequest) {
|
||||
const authResult = await opencodeClient.getSdkClient().auth.set(authRequest);
|
||||
if (authResult.error) {
|
||||
throw new Error(t('settings.providers.page.toast.apiKeySaveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
const upsertBody = buildProviderUpsertRequest(plan, {
|
||||
// Create defaults to user. Edit must rewrite the winning config layer
|
||||
// (custom > project > user) so project/custom providers are not copied
|
||||
// into a global user override.
|
||||
scope: editingCustomProviderId
|
||||
? (editingCustomScope ?? resolveProviderConfigScope(providerSources[editingCustomProviderId]))
|
||||
: 'user',
|
||||
});
|
||||
const response = await runtimeFetch('/api/provider', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(upsertBody),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
if (authRequest) {
|
||||
setCustomAuthFailureHint(t('settings.providers.page.custom.authFailure.configAfterAuth'));
|
||||
}
|
||||
throw new Error(payload?.error || t('settings.providers.page.toast.customProviderSaveFailed'));
|
||||
}
|
||||
|
||||
toast.success(t('settings.providers.page.toast.customProviderSaved', { provider: plan.name }));
|
||||
setCandidateProviderId('');
|
||||
setEditingCustomProviderId(null);
|
||||
setEditingCustomFormInitial(null);
|
||||
setEditingCustomScope(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
setLastCustomPersistId(null);
|
||||
await reloadOpenCodeConfiguration({ scopes: ['providers'], mode: 'active' });
|
||||
setSelectedProvider(plan.providerID);
|
||||
} catch (error) {
|
||||
console.error('Failed to save custom provider:', error);
|
||||
toast.error(
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: t('settings.providers.page.toast.customProviderSaveFailed'),
|
||||
);
|
||||
} finally {
|
||||
setAuthBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuthStart = async (providerId: string, methodIndex: number) => {
|
||||
const busyKey = `oauth:${providerId}:${methodIndex}`;
|
||||
setAuthBusyKey(busyKey);
|
||||
@@ -499,6 +599,19 @@ export const ProvidersPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnectCustomProvider = async (providerId: string) => {
|
||||
if (!providerId) {
|
||||
return;
|
||||
}
|
||||
await handleDisconnectProvider(providerId);
|
||||
setEditingCustomProviderId(null);
|
||||
setEditingCustomFormInitial(null);
|
||||
setEditingCustomScope(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
setLastCustomPersistId(null);
|
||||
setCandidateProviderId('');
|
||||
};
|
||||
|
||||
if (!isAddMode && providers.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
@@ -528,8 +641,6 @@ export const ProvidersPage: React.FC = () => {
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.state.loading')}</p>
|
||||
) : availableError ? (
|
||||
<p className="typography-meta text-muted-foreground">{availableError}</p>
|
||||
) : unconnectedProviders.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.connect.allProvidersConnected')}</p>
|
||||
) : (
|
||||
<DropdownMenu open={providerDropdownOpen} onOpenChange={(open) => {
|
||||
setProviderDropdownOpen(open);
|
||||
@@ -541,11 +652,15 @@ export const ProvidersPage: React.FC = () => {
|
||||
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{candidateProviderId ? <ProviderLogo providerId={candidateProviderId} className="h-3.5 w-3.5 flex-shrink-0" /> : null}
|
||||
{candidateProviderId && candidateProviderId !== CUSTOM_PROVIDER_ID ? (
|
||||
<ProviderLogo providerId={candidateProviderId} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
) : null}
|
||||
<span className={cn("truncate typography-ui-label font-normal", candidateProviderId ? "text-foreground" : "text-muted-foreground")}>
|
||||
{candidateProviderId
|
||||
? (unconnectedProviders.find(p => p.id === candidateProviderId)?.name || candidateProviderId)
|
||||
: t('settings.providers.page.connect.selectProviderPlaceholder')}
|
||||
{candidateProviderId === CUSTOM_PROVIDER_ID
|
||||
? t('settings.providers.page.custom.optionLabel')
|
||||
: candidateProviderId
|
||||
? (unconnectedProviders.find(p => p.id === candidateProviderId)?.name || candidateProviderId)
|
||||
: t('settings.providers.page.connect.selectProviderPlaceholder')}
|
||||
</span>
|
||||
</span>
|
||||
<Icon name="arrow-down-s" className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
|
||||
@@ -573,32 +688,60 @@ export const ProvidersPage: React.FC = () => {
|
||||
</div>
|
||||
<ScrollableOverlay outerClassName="max-h-[240px]" className="p-1">
|
||||
{(() => {
|
||||
const query = providerSearchQuery.toLowerCase();
|
||||
const customLabel = t('settings.providers.page.custom.optionLabel');
|
||||
const customMatches = !query
|
||||
|| customLabel.toLowerCase().includes(query)
|
||||
|| 'other'.includes(query)
|
||||
|| 'custom'.includes(query);
|
||||
const filtered = unconnectedProviders.filter(p => {
|
||||
const query = providerSearchQuery.toLowerCase();
|
||||
return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query);
|
||||
});
|
||||
if (filtered.length === 0) {
|
||||
if (filtered.length === 0 && !customMatches) {
|
||||
return <p className="py-4 text-center typography-meta text-muted-foreground">{t('settings.providers.page.connect.noProvidersFound')}</p>;
|
||||
}
|
||||
return filtered.map((provider) => (
|
||||
<DropdownMenuItem
|
||||
key={provider.id}
|
||||
onSelect={() => {
|
||||
setCandidateProviderId(provider.id);
|
||||
setProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{provider.name || provider.id}</span>
|
||||
</span>
|
||||
{candidateProviderId === provider.id && (
|
||||
<Icon name="check" className="h-4 w-4 text-[var(--primary-base)]" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
));
|
||||
return (
|
||||
<>
|
||||
{filtered.map((provider) => (
|
||||
<DropdownMenuItem
|
||||
key={provider.id}
|
||||
onSelect={() => {
|
||||
setCandidateProviderId(provider.id);
|
||||
setProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{provider.name || provider.id}</span>
|
||||
</span>
|
||||
{candidateProviderId === provider.id && (
|
||||
<Icon name="check" className="h-4 w-4 text-[var(--primary-base)]" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{customMatches ? (
|
||||
<DropdownMenuItem
|
||||
key={CUSTOM_PROVIDER_ID}
|
||||
onSelect={() => {
|
||||
setCandidateProviderId(CUSTOM_PROVIDER_ID);
|
||||
setProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<Icon name="add" className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{customLabel}</span>
|
||||
</span>
|
||||
{candidateProviderId === CUSTOM_PROVIDER_ID && (
|
||||
<Icon name="check" className="h-4 w-4 text-[var(--primary-base)]" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</ScrollableOverlay>
|
||||
</DropdownMenuContent>
|
||||
@@ -607,7 +750,25 @@ export const ProvidersPage: React.FC = () => {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{candidateProviderId && (
|
||||
{isCustomCreateMode ? (
|
||||
<CustomProviderForm
|
||||
mode="create"
|
||||
existingProviderIDs={connectedProviderIds}
|
||||
busy={authBusyKey?.startsWith('custom:') ?? false}
|
||||
authFailureHint={customAuthFailureHint}
|
||||
onCancel={() => {
|
||||
setCandidateProviderId('');
|
||||
setCustomAuthFailureHint(null);
|
||||
setLastCustomPersistId(null);
|
||||
}}
|
||||
onDisconnect={
|
||||
customAuthFailureHint && lastCustomPersistId
|
||||
? () => void handleDisconnectCustomProvider(lastCustomPersistId)
|
||||
: undefined
|
||||
}
|
||||
onSubmit={handleSaveCustomProvider}
|
||||
/>
|
||||
) : candidateProviderId ? (
|
||||
<SettingsSection
|
||||
title={t('settings.providers.page.auth.title')}
|
||||
settingsItem="providers.auth"
|
||||
@@ -741,7 +902,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
) : null}
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -761,6 +922,16 @@ export const ProvidersPage: React.FC = () => {
|
||||
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
|
||||
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
|
||||
const oauthAuthMethods = providerAuthMethods.filter((method) => normalizeAuthType(method) === 'oauth');
|
||||
const sourcesLoaded = Boolean(selectedSources);
|
||||
const isEditableCustomProvider = sourcesLoaded
|
||||
&& isConfigDefinedCustomProvider(selectedProvider, selectedSources);
|
||||
const providerEnv = Array.isArray(selectedProvider.env)
|
||||
? selectedProvider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
||||
: [];
|
||||
const hasStoredAuth = Boolean(selectedSources?.auth.exists);
|
||||
const hasEnvCredentials = providerEnv.length > 0;
|
||||
const hasCredentials = hasStoredAuth || hasEnvCredentials;
|
||||
const authStatusIncomplete = isEditableCustomProvider && !hasCredentials;
|
||||
|
||||
const filteredModels = providerModels.filter((model) => {
|
||||
const name = typeof model?.name === 'string' ? model.name : '';
|
||||
@@ -770,6 +941,35 @@ export const ProvidersPage: React.FC = () => {
|
||||
return name.toLowerCase().includes(query) || id.toLowerCase().includes(query);
|
||||
});
|
||||
|
||||
if (isCustomEditMode && isEditableCustomProvider && editingCustomFormInitial) {
|
||||
return (
|
||||
<SettingsPageLayout
|
||||
title={selectedProvider.name || selectedProvider.id}
|
||||
titleLeading={<ProviderLogo providerId={selectedProvider.id} className="h-5 w-5 shrink-0" />}
|
||||
description={<span className="font-mono typography-settings-description text-muted-foreground">{selectedProvider.id}</span>}
|
||||
showSaveStatus={false}
|
||||
>
|
||||
<CustomProviderForm
|
||||
mode="edit"
|
||||
existingProviderIDs={connectedProviderIds}
|
||||
initialValues={editingCustomFormInitial}
|
||||
allowExistingAuth={hasCredentials || !sourcesLoaded}
|
||||
busy={authBusyKey?.startsWith('custom:') ?? false}
|
||||
authFailureHint={customAuthFailureHint}
|
||||
onCancel={() => {
|
||||
setEditingCustomProviderId(null);
|
||||
setEditingCustomFormInitial(null);
|
||||
setEditingCustomScope(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
setLastCustomPersistId(null);
|
||||
}}
|
||||
onDisconnect={() => void handleDisconnectCustomProvider(selectedProvider.id)}
|
||||
onSubmit={handleSaveCustomProvider}
|
||||
/>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsPageLayout
|
||||
title={selectedProvider.name || selectedProvider.id}
|
||||
@@ -781,23 +981,48 @@ export const ProvidersPage: React.FC = () => {
|
||||
title={t('settings.providers.page.auth.title')}
|
||||
divider={false}
|
||||
headerAction={(
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => setShowAuthPanel((prev) => !prev)}
|
||||
>
|
||||
{showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{isEditableCustomProvider ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => {
|
||||
setCustomAuthFailureHint(null);
|
||||
setEditingCustomFormInitial(providerToCustomFormState(selectedProvider));
|
||||
setEditingCustomScope(resolveProviderConfigScope(selectedSources));
|
||||
setEditingCustomProviderId(selectedProvider.id);
|
||||
}}
|
||||
>
|
||||
{t('settings.providers.page.actions.edit')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => setShowAuthPanel((prev) => !prev)}
|
||||
>
|
||||
{showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
settingsItem="providers.auth"
|
||||
>
|
||||
{!showAuthPanel ? (
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
<Icon name="check" className="w-4 h-4 text-[var(--status-success)] shrink-0" />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.connected')}</span>
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.useReconnectHint')}</SettingsInfoHint>
|
||||
</div>
|
||||
authStatusIncomplete ? (
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
<Icon name="alert" className="w-4 h-4 text-[var(--status-warning)] shrink-0" />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.incomplete')}</span>
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.incompleteHint')}</SettingsInfoHint>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
<Icon name="check" className="w-4 h-4 text-[var(--status-success)] shrink-0" />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.connected')}</span>
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.useReconnectHint')}</SettingsInfoHint>
|
||||
</div>
|
||||
)
|
||||
) : authLoading ? (
|
||||
<div className="py-1.5 typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
buildAuthSetRequest,
|
||||
buildProviderUpsertRequest,
|
||||
isConfigDefinedCustomProvider,
|
||||
isCustomOpenAICompatibleProvider,
|
||||
providerToCustomFormState,
|
||||
resolveProviderConfigScope,
|
||||
validateCustomProvider,
|
||||
type CustomProviderConfig,
|
||||
type CustomProviderFormState,
|
||||
} from './custom-provider-form';
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
const baseForm = (overrides: Partial<CustomProviderFormState> = {}): CustomProviderFormState => ({
|
||||
providerID: 'custom-provider',
|
||||
name: 'Custom Provider',
|
||||
baseURL: 'https://api.example.com/v1',
|
||||
apiKey: 'sk-test',
|
||||
models: [{ row: 'm0', id: 'model-a', name: 'Model A' }],
|
||||
headers: [{ row: 'h0', key: '', value: '' }],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Mirrors server upsert semantics for request-construction tests. */
|
||||
function mergeProviderConfig(
|
||||
existing: Record<string, unknown>,
|
||||
providerID: string,
|
||||
config: CustomProviderConfig,
|
||||
): Record<string, unknown> {
|
||||
const providerSection = (
|
||||
typeof existing.provider === 'object' && existing.provider !== null && !Array.isArray(existing.provider)
|
||||
? { ...(existing.provider as Record<string, unknown>) }
|
||||
: {}
|
||||
);
|
||||
providerSection[providerID] = config;
|
||||
const next: Record<string, unknown> = {
|
||||
...existing,
|
||||
provider: providerSection,
|
||||
};
|
||||
if (Array.isArray(existing.disabled_providers)) {
|
||||
next.disabled_providers = existing.disabled_providers.filter((entry) => entry !== providerID);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
describe('validateCustomProvider', () => {
|
||||
test('builds trimmed config and auth payloads', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm({
|
||||
providerID: ' custom-provider ',
|
||||
name: ' Custom Provider ',
|
||||
baseURL: ' https://api.example.com/v1 ',
|
||||
apiKey: ' sk-secret ',
|
||||
models: [{ row: 'm0', id: ' model-a ', name: ' Model A ' }],
|
||||
headers: [
|
||||
{ row: 'h0', key: ' X-Test ', value: ' enabled ' },
|
||||
{ row: 'h1', key: '', value: '' },
|
||||
],
|
||||
}),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
|
||||
expect(result.result).toEqual({
|
||||
providerID: 'custom-provider',
|
||||
name: 'Custom Provider',
|
||||
apiKey: 'sk-secret',
|
||||
config: {
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
name: 'Custom Provider',
|
||||
options: {
|
||||
baseURL: 'https://api.example.com/v1',
|
||||
headers: {
|
||||
'X-Test': 'enabled',
|
||||
},
|
||||
},
|
||||
models: {
|
||||
'model-a': { name: 'Model A' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('supports {env:VAR} credentials without writing an auth key', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm({
|
||||
apiKey: '{env: CUSTOM_PROVIDER_KEY}',
|
||||
}),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
|
||||
expect(result.result?.apiKey).toEqual(undefined);
|
||||
expect(result.result?.config.env).toEqual(['CUSTOM_PROVIDER_KEY']);
|
||||
});
|
||||
|
||||
test('rejects missing credentials', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm({ apiKey: ' ' }),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
|
||||
expect(result.result).toEqual(undefined);
|
||||
expect(result.err.apiKey).toBe('settings.providers.page.custom.error.apiKey.required');
|
||||
});
|
||||
|
||||
test('allows empty api key when editing with existing auth', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm({ apiKey: '' }),
|
||||
t,
|
||||
existingProviderIDs: new Set(['custom-provider']),
|
||||
editingProviderID: 'custom-provider',
|
||||
allowExistingAuth: true,
|
||||
});
|
||||
|
||||
expect(result.result?.providerID).toBe('custom-provider');
|
||||
expect(result.err.apiKey).toEqual(undefined);
|
||||
expect(result.result?.apiKey).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('rejects invalid provider id, base URL, and duplicate rows', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm({
|
||||
providerID: 'Bad ID',
|
||||
baseURL: 'ftp://example.com',
|
||||
models: [
|
||||
{ row: 'm0', id: 'model-a', name: 'Model A' },
|
||||
{ row: 'm1', id: 'model-a', name: 'Model A 2' },
|
||||
],
|
||||
headers: [
|
||||
{ row: 'h0', key: 'Authorization', value: 'one' },
|
||||
{ row: 'h1', key: 'authorization', value: 'two' },
|
||||
],
|
||||
}),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
|
||||
expect(result.result).toEqual(undefined);
|
||||
expect(result.err.providerID).toBe('settings.providers.page.custom.error.providerID.format');
|
||||
expect(result.err.baseURL).toBe('settings.providers.page.custom.error.baseURL.format');
|
||||
expect(result.models[1]).toEqual({
|
||||
id: 'settings.providers.page.custom.error.duplicate',
|
||||
name: undefined,
|
||||
});
|
||||
expect(result.headers[1]).toEqual({
|
||||
key: 'settings.providers.page.custom.error.duplicate',
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('allows reconnecting a disabled provider id', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm(),
|
||||
t,
|
||||
existingProviderIDs: new Set(['custom-provider']),
|
||||
disabledProviders: ['custom-provider'],
|
||||
});
|
||||
|
||||
expect(result.result?.providerID).toBe('custom-provider');
|
||||
expect(result.err.providerID).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('rejects an already-connected provider id on create', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm(),
|
||||
t,
|
||||
existingProviderIDs: new Set(['custom-provider']),
|
||||
});
|
||||
|
||||
expect(result.result).toEqual(undefined);
|
||||
expect(result.err.providerID).toBe('settings.providers.page.custom.error.providerID.exists');
|
||||
});
|
||||
|
||||
test('allows updating the same provider id while editing', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm({ apiKey: 'sk-updated' }),
|
||||
t,
|
||||
existingProviderIDs: new Set(['custom-provider']),
|
||||
editingProviderID: 'custom-provider',
|
||||
});
|
||||
|
||||
expect(result.result?.providerID).toBe('custom-provider');
|
||||
expect(result.err.providerID).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('request construction', () => {
|
||||
test('builds auth.set and provider upsert requests', () => {
|
||||
const validated = validateCustomProvider({
|
||||
form: baseForm(),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
const plan = validated.result!;
|
||||
|
||||
expect(buildAuthSetRequest(plan)).toEqual({
|
||||
providerID: 'custom-provider',
|
||||
auth: { type: 'api', key: 'sk-test' },
|
||||
});
|
||||
expect(buildProviderUpsertRequest(plan)).toEqual({
|
||||
providerID: 'custom-provider',
|
||||
config: plan.config,
|
||||
scope: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
test('includes explicit project/custom scope on upsert requests', () => {
|
||||
const validated = validateCustomProvider({
|
||||
form: baseForm(),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
const plan = validated.result!;
|
||||
|
||||
expect(buildProviderUpsertRequest(plan, { scope: 'project' }).scope).toBe('project');
|
||||
expect(buildProviderUpsertRequest(plan, { scope: 'custom' }).scope).toBe('custom');
|
||||
});
|
||||
|
||||
test('omits auth.set when using env credentials', () => {
|
||||
const validated = validateCustomProvider({
|
||||
form: baseForm({ apiKey: '{env:MY_KEY}' }),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
|
||||
expect(buildAuthSetRequest(validated.result!)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeProviderConfig persistence shape', () => {
|
||||
test('merges provider block and clears disabled_providers entry', () => {
|
||||
const validated = validateCustomProvider({
|
||||
form: baseForm(),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
const plan = validated.result!;
|
||||
|
||||
const next = mergeProviderConfig(
|
||||
{
|
||||
model: 'openai/gpt-4o',
|
||||
provider: {
|
||||
openai: { name: 'OpenAI' },
|
||||
},
|
||||
disabled_providers: ['custom-provider', 'other'],
|
||||
},
|
||||
plan.providerID,
|
||||
plan.config,
|
||||
);
|
||||
|
||||
expect(next).toEqual({
|
||||
model: 'openai/gpt-4o',
|
||||
provider: {
|
||||
openai: { name: 'OpenAI' },
|
||||
'custom-provider': plan.config,
|
||||
},
|
||||
disabled_providers: ['other'],
|
||||
});
|
||||
});
|
||||
|
||||
test('creates provider section when missing', () => {
|
||||
const validated = validateCustomProvider({
|
||||
form: baseForm(),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
const plan = validated.result!;
|
||||
|
||||
const next = mergeProviderConfig({}, plan.providerID, plan.config);
|
||||
expect(next.provider).toEqual({
|
||||
'custom-provider': plan.config,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider edit helpers', () => {
|
||||
test('detects openai-compatible custom providers and prefills form state', () => {
|
||||
expect(isCustomOpenAICompatibleProvider({
|
||||
id: 'campus-llm',
|
||||
options: { baseURL: 'https://llm.example.edu/v1' },
|
||||
models: [],
|
||||
})).toBe(true);
|
||||
|
||||
const state = providerToCustomFormState({
|
||||
id: 'campus-llm',
|
||||
name: 'Campus LLM',
|
||||
env: ['CAMPUS_KEY'],
|
||||
options: {
|
||||
baseURL: 'https://llm.example.edu/v1',
|
||||
headers: { 'X-Campus': '1' },
|
||||
},
|
||||
models: [{ id: 'fast', name: 'Fast' }],
|
||||
});
|
||||
|
||||
expect(state.providerID).toBe('campus-llm');
|
||||
expect(state.name).toBe('Campus LLM');
|
||||
expect(state.baseURL).toBe('https://llm.example.edu/v1');
|
||||
expect(state.apiKey).toBe('{env:CAMPUS_KEY}');
|
||||
expect(state.models[0]).toEqual({ row: state.models[0].row, id: 'fast', name: 'Fast' });
|
||||
expect(state.headers[0]).toEqual({ row: state.headers[0].row, key: 'X-Campus', value: '1' });
|
||||
});
|
||||
|
||||
test('requires a config-layer source before treating a provider as editable custom', () => {
|
||||
const catalogLike = {
|
||||
id: 'openai',
|
||||
options: { baseURL: 'https://api.openai.com/v1' },
|
||||
models: [{ id: 'gpt-4o', name: 'GPT-4o', api: { npm: '@ai-sdk/openai-compatible' } }],
|
||||
};
|
||||
|
||||
expect(isCustomOpenAICompatibleProvider(catalogLike)).toBe(true);
|
||||
expect(isConfigDefinedCustomProvider(catalogLike, undefined)).toBe(false);
|
||||
expect(isConfigDefinedCustomProvider(catalogLike, {
|
||||
user: { exists: false },
|
||||
project: { exists: false },
|
||||
custom: { exists: false },
|
||||
})).toBe(false);
|
||||
expect(isConfigDefinedCustomProvider(catalogLike, {
|
||||
user: { exists: true },
|
||||
project: { exists: false },
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
test('resolveProviderConfigScope follows custom > project > user precedence', () => {
|
||||
expect(resolveProviderConfigScope(undefined)).toBe('user');
|
||||
expect(resolveProviderConfigScope({
|
||||
user: { exists: true },
|
||||
project: { exists: false },
|
||||
custom: { exists: false },
|
||||
})).toBe('user');
|
||||
expect(resolveProviderConfigScope({
|
||||
user: { exists: true },
|
||||
project: { exists: true },
|
||||
custom: { exists: false },
|
||||
})).toBe('project');
|
||||
expect(resolveProviderConfigScope({
|
||||
user: { exists: true },
|
||||
project: { exists: true },
|
||||
custom: { exists: true },
|
||||
})).toBe('custom');
|
||||
expect(resolveProviderConfigScope({
|
||||
user: { exists: false },
|
||||
project: { exists: false },
|
||||
custom: { exists: true },
|
||||
})).toBe('custom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Custom / Other OpenAI-compatible provider form helpers.
|
||||
* Mirrors OpenCode web UI validation and request construction so a provider
|
||||
* can be defined from Settings without code changes.
|
||||
*/
|
||||
|
||||
export const CUSTOM_PROVIDER_NPM = '@ai-sdk/openai-compatible';
|
||||
export const CUSTOM_PROVIDER_ID = '__custom_provider__';
|
||||
export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
|
||||
export const BASE_URL_PATTERN = /^https?:\/\//;
|
||||
export const ENV_KEY_PATTERN = /^\{env:([^}]+)\}$/;
|
||||
|
||||
export type CustomProviderTranslator = (
|
||||
key: string,
|
||||
vars?: Record<string, string | number | boolean>,
|
||||
) => string;
|
||||
|
||||
export type ModelRow = {
|
||||
row: string;
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type HeaderRow = {
|
||||
row: string;
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type CustomProviderFormState = {
|
||||
providerID: string;
|
||||
name: string;
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
models: ModelRow[];
|
||||
headers: HeaderRow[];
|
||||
};
|
||||
|
||||
export type FieldErrors = {
|
||||
providerID?: string;
|
||||
name?: string;
|
||||
baseURL?: string;
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
export type ModelFieldErrors = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type HeaderFieldErrors = {
|
||||
key?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export type CustomProviderConfig = {
|
||||
npm: typeof CUSTOM_PROVIDER_NPM;
|
||||
name: string;
|
||||
env?: string[];
|
||||
options: {
|
||||
baseURL: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
models: Record<string, { name: string }>;
|
||||
};
|
||||
|
||||
export type CustomProviderPersistPlan = {
|
||||
providerID: string;
|
||||
name: string;
|
||||
/** Literal API key to send via auth.set; omitted when using {env:VAR} or empty. */
|
||||
apiKey?: string;
|
||||
config: CustomProviderConfig;
|
||||
};
|
||||
|
||||
export type ValidateCustomProviderInput = {
|
||||
form: CustomProviderFormState;
|
||||
t: CustomProviderTranslator;
|
||||
existingProviderIDs: ReadonlySet<string>;
|
||||
disabledProviders?: readonly string[];
|
||||
/** When editing this provider id, treat it as an allowed update target. */
|
||||
editingProviderID?: string;
|
||||
/**
|
||||
* When true, empty apiKey is allowed because auth.json already has a credential
|
||||
* (edit path). Still requires env or key when false.
|
||||
*/
|
||||
allowExistingAuth?: boolean;
|
||||
};
|
||||
|
||||
export type ValidateCustomProviderResult = {
|
||||
err: FieldErrors;
|
||||
models: ModelFieldErrors[];
|
||||
headers: HeaderFieldErrors[];
|
||||
result?: CustomProviderPersistPlan;
|
||||
};
|
||||
|
||||
export type ProviderLikeForCustomForm = {
|
||||
id: string;
|
||||
name?: string;
|
||||
env?: string[];
|
||||
options?: Record<string, unknown> | null;
|
||||
models?: Array<{ id?: string; name?: string; api?: { npm?: string } }> | Record<string, unknown>;
|
||||
};
|
||||
|
||||
let rowCounter = 0;
|
||||
|
||||
const nextRow = (): string => `row-${rowCounter++}`;
|
||||
|
||||
export const createModelRow = (): ModelRow => ({
|
||||
row: nextRow(),
|
||||
id: '',
|
||||
name: '',
|
||||
});
|
||||
|
||||
export const createHeaderRow = (): HeaderRow => ({
|
||||
row: nextRow(),
|
||||
key: '',
|
||||
value: '',
|
||||
});
|
||||
|
||||
export const createEmptyCustomProviderForm = (): CustomProviderFormState => ({
|
||||
providerID: '',
|
||||
name: '',
|
||||
baseURL: '',
|
||||
apiKey: '',
|
||||
models: [createModelRow()],
|
||||
headers: [createHeaderRow()],
|
||||
});
|
||||
|
||||
export function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
|
||||
const trimmed = apiKey.trim();
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
}
|
||||
const envMatch = trimmed.match(ENV_KEY_PATTERN);
|
||||
const env = envMatch?.[1]?.trim();
|
||||
if (env) {
|
||||
return { env };
|
||||
}
|
||||
return { key: trimmed };
|
||||
}
|
||||
|
||||
export function isCustomOpenAICompatibleProvider(provider: ProviderLikeForCustomForm): boolean {
|
||||
const options = provider.options && typeof provider.options === 'object' ? provider.options : null;
|
||||
const baseURL = typeof options?.baseURL === 'string' ? options.baseURL.trim() : '';
|
||||
if (baseURL && BASE_URL_PATTERN.test(baseURL)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const models = Array.isArray(provider.models)
|
||||
? provider.models
|
||||
: (provider.models && typeof provider.models === 'object'
|
||||
? Object.values(provider.models)
|
||||
: []);
|
||||
|
||||
return models.some((model) => {
|
||||
if (!model || typeof model !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const api = 'api' in model && model.api && typeof model.api === 'object'
|
||||
? model.api as { npm?: unknown }
|
||||
: null;
|
||||
return typeof api?.npm === 'string' && api.npm === CUSTOM_PROVIDER_NPM;
|
||||
});
|
||||
}
|
||||
|
||||
export type ProviderConfigSourcesLike = {
|
||||
user?: { exists?: boolean };
|
||||
project?: { exists?: boolean };
|
||||
custom?: { exists?: boolean };
|
||||
};
|
||||
|
||||
export type ProviderConfigScope = 'user' | 'project' | 'custom';
|
||||
|
||||
/**
|
||||
* True when a provider both looks OpenAI-compatible-custom and is defined in a
|
||||
* user/project/custom OpenCode config layer. Catalog-only providers often share
|
||||
* the same npm/baseURL signals and must not get Edit / config overrides.
|
||||
*/
|
||||
export function isConfigDefinedCustomProvider(
|
||||
provider: ProviderLikeForCustomForm,
|
||||
sources: ProviderConfigSourcesLike | null | undefined,
|
||||
): boolean {
|
||||
if (!sources) {
|
||||
return false;
|
||||
}
|
||||
const inConfigLayer = Boolean(
|
||||
sources.user?.exists || sources.project?.exists || sources.custom?.exists,
|
||||
);
|
||||
return inConfigLayer && isCustomOpenAICompatibleProvider(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective writable config layer for a provider, matching OpenCode merge
|
||||
* precedence: custom > project > user.
|
||||
*/
|
||||
export function resolveProviderConfigScope(
|
||||
sources: ProviderConfigSourcesLike | null | undefined,
|
||||
): ProviderConfigScope {
|
||||
if (sources?.custom?.exists) {
|
||||
return 'custom';
|
||||
}
|
||||
if (sources?.project?.exists) {
|
||||
return 'project';
|
||||
}
|
||||
return 'user';
|
||||
}
|
||||
|
||||
export function providerToCustomFormState(provider: ProviderLikeForCustomForm): CustomProviderFormState {
|
||||
const options = provider.options && typeof provider.options === 'object' ? provider.options : {};
|
||||
const baseURL = typeof options.baseURL === 'string' ? options.baseURL : '';
|
||||
const headersRaw = options.headers && typeof options.headers === 'object' && !Array.isArray(options.headers)
|
||||
? options.headers as Record<string, unknown>
|
||||
: {};
|
||||
const headerRows = Object.entries(headersRaw)
|
||||
.filter((entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string')
|
||||
.map(([key, value]) => ({ row: nextRow(), key, value }));
|
||||
|
||||
const modelEntries = Array.isArray(provider.models)
|
||||
? provider.models
|
||||
: (provider.models && typeof provider.models === 'object'
|
||||
? Object.entries(provider.models).map(([id, value]) => ({
|
||||
id,
|
||||
name: value && typeof value === 'object' && 'name' in value && typeof (value as { name?: unknown }).name === 'string'
|
||||
? (value as { name: string }).name
|
||||
: id,
|
||||
}))
|
||||
: []);
|
||||
|
||||
const models = modelEntries.length > 0
|
||||
? modelEntries.map((model) => ({
|
||||
row: nextRow(),
|
||||
id: typeof model?.id === 'string' ? model.id : '',
|
||||
name: typeof model?.name === 'string' ? model.name : (typeof model?.id === 'string' ? model.id : ''),
|
||||
}))
|
||||
: [createModelRow()];
|
||||
|
||||
const envName = Array.isArray(provider.env)
|
||||
? provider.env.find((entry) => typeof entry === 'string' && entry.trim().length > 0)?.trim()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
providerID: provider.id,
|
||||
name: typeof provider.name === 'string' && provider.name.trim() ? provider.name : provider.id,
|
||||
baseURL,
|
||||
apiKey: envName ? `{env:${envName}}` : '',
|
||||
models,
|
||||
headers: headerRows.length > 0 ? headerRows : [createHeaderRow()],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates form input and builds the auth + OpenCode provider config payloads.
|
||||
*/
|
||||
export function validateCustomProvider(input: ValidateCustomProviderInput): ValidateCustomProviderResult {
|
||||
const providerID = input.form.providerID.trim();
|
||||
const name = input.form.name.trim();
|
||||
const baseURL = input.form.baseURL.trim();
|
||||
const { env, key } = parseEnvApiKey(input.form.apiKey);
|
||||
const disabledProviders = input.disabledProviders ?? [];
|
||||
const editingProviderID = input.editingProviderID?.trim();
|
||||
|
||||
const idError = !providerID
|
||||
? input.t('settings.providers.page.custom.error.providerID.required')
|
||||
: !PROVIDER_ID_PATTERN.test(providerID)
|
||||
? input.t('settings.providers.page.custom.error.providerID.format')
|
||||
: undefined;
|
||||
|
||||
const nameError = !name
|
||||
? input.t('settings.providers.page.custom.error.name.required')
|
||||
: undefined;
|
||||
|
||||
const urlError = !baseURL
|
||||
? input.t('settings.providers.page.custom.error.baseURL.required')
|
||||
: !BASE_URL_PATTERN.test(baseURL)
|
||||
? input.t('settings.providers.page.custom.error.baseURL.format')
|
||||
: undefined;
|
||||
|
||||
const credentialsSatisfied = Boolean(env || key || (editingProviderID && input.allowExistingAuth && editingProviderID === providerID));
|
||||
const apiKeyError = credentialsSatisfied
|
||||
? undefined
|
||||
: input.t('settings.providers.page.custom.error.apiKey.required');
|
||||
|
||||
const disabled = disabledProviders.includes(providerID);
|
||||
const isSelfEdit = Boolean(editingProviderID && editingProviderID === providerID);
|
||||
const existsError = idError || isSelfEdit
|
||||
? undefined
|
||||
: input.existingProviderIDs.has(providerID) && !disabled
|
||||
? input.t('settings.providers.page.custom.error.providerID.exists')
|
||||
: undefined;
|
||||
|
||||
const seenModels = new Set<string>();
|
||||
const modelErrors = input.form.models.map((model) => {
|
||||
const id = model.id.trim();
|
||||
const modelIdError = !id
|
||||
? input.t('settings.providers.page.custom.error.required')
|
||||
: seenModels.has(id)
|
||||
? input.t('settings.providers.page.custom.error.duplicate')
|
||||
: (() => {
|
||||
seenModels.add(id);
|
||||
return undefined;
|
||||
})();
|
||||
const modelNameError = !model.name.trim()
|
||||
? input.t('settings.providers.page.custom.error.required')
|
||||
: undefined;
|
||||
return { id: modelIdError, name: modelNameError };
|
||||
});
|
||||
|
||||
const modelsValid = modelErrors.every((entry) => !entry.id && !entry.name);
|
||||
const modelConfig = Object.fromEntries(
|
||||
input.form.models.map((model) => [model.id.trim(), { name: model.name.trim() }]),
|
||||
);
|
||||
|
||||
const seenHeaders = new Set<string>();
|
||||
const headerErrors = input.form.headers.map((header) => {
|
||||
const headerKey = header.key.trim();
|
||||
const headerValue = header.value.trim();
|
||||
if (!headerKey && !headerValue) {
|
||||
return {};
|
||||
}
|
||||
const keyError = !headerKey
|
||||
? input.t('settings.providers.page.custom.error.required')
|
||||
: seenHeaders.has(headerKey.toLowerCase())
|
||||
? input.t('settings.providers.page.custom.error.duplicate')
|
||||
: (() => {
|
||||
seenHeaders.add(headerKey.toLowerCase());
|
||||
return undefined;
|
||||
})();
|
||||
const valueError = !headerValue
|
||||
? input.t('settings.providers.page.custom.error.required')
|
||||
: undefined;
|
||||
return { key: keyError, value: valueError };
|
||||
});
|
||||
|
||||
const headersValid = headerErrors.every((entry) => !entry.key && !entry.value);
|
||||
const headerConfig = Object.fromEntries(
|
||||
input.form.headers
|
||||
.map((header) => ({ key: header.key.trim(), value: header.value.trim() }))
|
||||
.filter((header) => header.key && header.value)
|
||||
.map((header) => [header.key, header.value]),
|
||||
);
|
||||
|
||||
const err: FieldErrors = {
|
||||
providerID: idError ?? existsError,
|
||||
name: nameError,
|
||||
baseURL: urlError,
|
||||
apiKey: apiKeyError,
|
||||
};
|
||||
|
||||
const ok = !idError && !existsError && !nameError && !urlError && !apiKeyError && modelsValid && headersValid;
|
||||
if (!ok) {
|
||||
return { err, models: modelErrors, headers: headerErrors };
|
||||
}
|
||||
|
||||
return {
|
||||
err,
|
||||
models: modelErrors,
|
||||
headers: headerErrors,
|
||||
result: {
|
||||
providerID,
|
||||
name,
|
||||
apiKey: key,
|
||||
config: {
|
||||
npm: CUSTOM_PROVIDER_NPM,
|
||||
name,
|
||||
...(env ? { env: [env] } : {}),
|
||||
options: {
|
||||
baseURL,
|
||||
...(Object.keys(headerConfig).length > 0 ? { headers: headerConfig } : {}),
|
||||
},
|
||||
models: modelConfig,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the OpenCode auth.set request body when a literal API key is present.
|
||||
*/
|
||||
export function buildAuthSetRequest(plan: CustomProviderPersistPlan): {
|
||||
providerID: string;
|
||||
auth: { type: 'api'; key: string };
|
||||
} | null {
|
||||
if (!plan.apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
providerID: plan.providerID,
|
||||
auth: { type: 'api', key: plan.apiKey },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the OpenChamber provider upsert request body (config persistence).
|
||||
* `scope` selects the OpenCode config layer (user/project/custom). Create
|
||||
* defaults to user; edit must pass the provider's effective existing layer.
|
||||
*/
|
||||
export function buildProviderUpsertRequest(
|
||||
plan: CustomProviderPersistPlan,
|
||||
options?: { scope?: ProviderConfigScope },
|
||||
): {
|
||||
providerID: string;
|
||||
config: CustomProviderConfig;
|
||||
scope: ProviderConfigScope;
|
||||
} {
|
||||
return {
|
||||
providerID: plan.providerID,
|
||||
config: plan.config,
|
||||
scope: options?.scope ?? 'user',
|
||||
};
|
||||
}
|
||||
@@ -1268,7 +1268,52 @@ export const settingsDict = {
|
||||
'settings.providers.page.connect.selectProviderPlaceholder': 'Anbieter auswählen',
|
||||
'settings.providers.page.connect.searchProvidersPlaceholder': 'Suche...',
|
||||
'settings.providers.page.connect.noProvidersFound': 'Keine Anbieter gefunden',
|
||||
'settings.providers.page.connect.allProvidersConnected': 'Alle Anbieter verbunden.',
|
||||
'settings.providers.page.custom.optionLabel': 'Andere / Benutzerdefiniert',
|
||||
'settings.providers.page.custom.title': 'Benutzerdefinierter Anbieter',
|
||||
'settings.providers.page.custom.editTitle': 'Benutzerdefinierten Anbieter bearbeiten',
|
||||
'settings.providers.page.custom.description': 'Fügen Sie einen OpenAI-kompatiblen Anbieter mit Basis-URL, Anmeldedaten und Modellliste hinzu. Wird in der OpenCode-Konfiguration gespeichert und steht im Chat wie jeder andere Anbieter zur Verfügung.',
|
||||
'settings.providers.page.custom.field.providerID.label': 'Anbieter-ID',
|
||||
'settings.providers.page.custom.field.providerID.placeholder': 'mein-anbieter',
|
||||
'settings.providers.page.custom.field.providerID.info': 'Kleinbuchstaben, Zahlen, Bindestriche und Unterstriche. Wird als OpenCode-Anbieter-ID verwendet.',
|
||||
'settings.providers.page.custom.field.name.label': 'Anzeigename',
|
||||
'settings.providers.page.custom.field.name.placeholder': 'Mein Anbieter',
|
||||
'settings.providers.page.custom.field.name.info': 'Wird in den Anbieter- und Modellauswahlen angezeigt.',
|
||||
'settings.providers.page.custom.field.baseURL.label': 'Basis-URL',
|
||||
'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1',
|
||||
'settings.providers.page.custom.field.baseURL.info': 'OpenAI-kompatible API-Basis-URL. Muss mit http:// oder https:// beginnen.',
|
||||
'settings.providers.page.custom.field.apiKey.label': 'API-Schlüssel',
|
||||
'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... oder {env:VAR_NAME}',
|
||||
'settings.providers.page.custom.field.apiKey.info': 'Wird in der OpenCode-Authentifizierung gespeichert, nicht von OpenChamber. Verwenden Sie {env:VAR_NAME}, um einen Schlüssel aus der Umgebung zu lesen.',
|
||||
'settings.providers.page.custom.field.apiKey.editInfo': 'Leer lassen, um die vorhandenen Anmeldedaten zu behalten, oder einen neuen Schlüssel / {env:VAR_NAME} eingeben.',
|
||||
'settings.providers.page.custom.field.apiKey.editPlaceholder': 'Leer lassen, um den vorhandenen Schlüssel zu behalten',
|
||||
'settings.providers.page.custom.models.title': 'Modelle',
|
||||
'settings.providers.page.custom.models.idLabel': 'Modell-ID',
|
||||
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
|
||||
'settings.providers.page.custom.models.nameLabel': 'Modellname',
|
||||
'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o',
|
||||
'settings.providers.page.custom.models.add': 'Modell hinzufügen',
|
||||
'settings.providers.page.custom.models.remove': 'Modell entfernen',
|
||||
'settings.providers.page.custom.headers.title': 'Header',
|
||||
'settings.providers.page.custom.headers.description': 'Optionale Anfrage-Header, die bei jedem Aufruf gesendet werden.',
|
||||
'settings.providers.page.custom.headers.keyLabel': 'Header-Name',
|
||||
'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header',
|
||||
'settings.providers.page.custom.headers.valueLabel': 'Header-Wert',
|
||||
'settings.providers.page.custom.headers.valuePlaceholder': 'Wert',
|
||||
'settings.providers.page.custom.headers.add': 'Header hinzufügen',
|
||||
'settings.providers.page.custom.headers.remove': 'Header entfernen',
|
||||
'settings.providers.page.custom.actions.back': 'Zurück',
|
||||
'settings.providers.page.custom.actions.save': 'Anbieter speichern',
|
||||
'settings.providers.page.custom.actions.update': 'Anbieter aktualisieren',
|
||||
'settings.providers.page.custom.error.providerID.required': 'Anbieter-ID ist erforderlich',
|
||||
'settings.providers.page.custom.error.providerID.format': 'Verwenden Sie Kleinbuchstaben, Zahlen, Bindestriche oder Unterstriche',
|
||||
'settings.providers.page.custom.error.providerID.exists': 'Ein Anbieter mit dieser ID ist bereits verbunden',
|
||||
'settings.providers.page.custom.error.name.required': 'Anzeigename ist erforderlich',
|
||||
'settings.providers.page.custom.error.baseURL.required': 'Basis-URL ist erforderlich',
|
||||
'settings.providers.page.custom.error.baseURL.format': 'Basis-URL muss mit http:// oder https:// beginnen',
|
||||
'settings.providers.page.custom.error.required': 'Erforderlich',
|
||||
'settings.providers.page.custom.error.duplicate': 'Duplikat',
|
||||
'settings.providers.page.custom.error.apiKey.required': 'API-Schlüssel oder {env:VAR_NAME} ist erforderlich',
|
||||
'settings.providers.page.custom.authFailure.configAfterAuth': 'Anmeldedaten wurden gespeichert, aber die Anbieterkonfiguration nicht. Beheben Sie den Fehler und versuchen Sie es erneut, oder trennen Sie die Verbindung, um den teilweisen Speichervorgang zu löschen.',
|
||||
'settings.providers.page.auth.title': 'Authentifizierung',
|
||||
'settings.providers.page.auth.loadingMethods': 'Lade Authentifizierungsmethoden...',
|
||||
'settings.providers.page.auth.apiKeyLabel': 'API-Schlüssel',
|
||||
@@ -1277,6 +1322,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.auth.oauthMethodFallback': 'OAuth-Methode {index}',
|
||||
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Autorisierungscode einfügen',
|
||||
'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',
|
||||
'settings.providers.page.auth.useReconnectHint': '· Verwenden Sie „Erneut verbinden“, um Anmeldedaten zu aktualisieren',
|
||||
'settings.providers.page.connectionDetails.title': 'Verbindungsdetails',
|
||||
'settings.providers.page.connectionDetails.configuredIn': 'Konfiguriert in:',
|
||||
@@ -1306,6 +1353,7 @@ export const settingsDict = {
|
||||
'settings.providers.page.actions.complete': 'Vervollständigen',
|
||||
'settings.providers.page.actions.hide': 'Ausblenden',
|
||||
'settings.providers.page.actions.reconnect': 'Erneut verbinden',
|
||||
'settings.providers.page.actions.edit': 'Bearbeiten',
|
||||
'settings.providers.page.actions.disconnecting': 'Trennen...',
|
||||
'settings.providers.page.actions.disconnect': 'Trennen',
|
||||
'settings.providers.page.actions.hideAll': 'Alle ausblenden',
|
||||
@@ -1326,6 +1374,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.toast.deviceCodeCopyFailed': 'Fehler beim Kopieren des Gerätecodes',
|
||||
'settings.providers.page.toast.providerDisconnected': 'Anbieter getrennt',
|
||||
'settings.providers.page.toast.providerDisconnectFailed': 'Fehler beim Trennen des Anbieters',
|
||||
'settings.providers.page.toast.customProviderSaved': '{provider} verbunden',
|
||||
'settings.providers.page.toast.customProviderSaveFailed': 'Benutzerdefinierter Anbieter konnte nicht gespeichert werden',
|
||||
'settings.mcp.page.empty.selectServer': 'Wählen Sie einen MCP-Server aus der Seitenleiste',
|
||||
'settings.mcp.page.empty.addNewOne': 'oder fügen Sie einen neuen hinzu',
|
||||
'settings.mcp.page.header.newServer': 'Neuer MCP-Server',
|
||||
|
||||
@@ -1333,7 +1333,52 @@ export const settingsDict = {
|
||||
'settings.providers.page.connect.selectProviderPlaceholder': 'Select provider',
|
||||
'settings.providers.page.connect.searchProvidersPlaceholder': 'Search...',
|
||||
'settings.providers.page.connect.noProvidersFound': 'No providers found',
|
||||
'settings.providers.page.connect.allProvidersConnected': 'All providers connected.',
|
||||
'settings.providers.page.custom.optionLabel': 'Other / Custom',
|
||||
'settings.providers.page.custom.title': 'Custom provider',
|
||||
'settings.providers.page.custom.editTitle': 'Edit custom provider',
|
||||
'settings.providers.page.custom.description': 'Add an OpenAI-compatible provider with a base URL, credentials, and model list. Saved to OpenCode config so it works in chat like any other provider.',
|
||||
'settings.providers.page.custom.field.providerID.label': 'Provider ID',
|
||||
'settings.providers.page.custom.field.providerID.placeholder': 'my-provider',
|
||||
'settings.providers.page.custom.field.providerID.info': 'Lowercase letters, numbers, hyphens, and underscores. Used as the OpenCode provider id.',
|
||||
'settings.providers.page.custom.field.name.label': 'Display name',
|
||||
'settings.providers.page.custom.field.name.placeholder': 'My Provider',
|
||||
'settings.providers.page.custom.field.name.info': 'Shown in the provider and model pickers.',
|
||||
'settings.providers.page.custom.field.baseURL.label': 'Base URL',
|
||||
'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1',
|
||||
'settings.providers.page.custom.field.baseURL.info': 'OpenAI-compatible API base URL. Must start with http:// or https://.',
|
||||
'settings.providers.page.custom.field.apiKey.label': 'API key',
|
||||
'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... or {env:VAR_NAME}',
|
||||
'settings.providers.page.custom.field.apiKey.info': 'Stored in OpenCode auth, not by OpenChamber. Use {env:VAR_NAME} to read a key from the environment instead.',
|
||||
'settings.providers.page.custom.field.apiKey.editInfo': 'Leave blank to keep the existing credential, or enter a new key / {env:VAR_NAME}.',
|
||||
'settings.providers.page.custom.field.apiKey.editPlaceholder': 'Leave blank to keep existing key',
|
||||
'settings.providers.page.custom.models.title': 'Models',
|
||||
'settings.providers.page.custom.models.idLabel': 'Model ID',
|
||||
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
|
||||
'settings.providers.page.custom.models.nameLabel': 'Model name',
|
||||
'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o',
|
||||
'settings.providers.page.custom.models.add': 'Add model',
|
||||
'settings.providers.page.custom.models.remove': 'Remove model',
|
||||
'settings.providers.page.custom.headers.title': 'Headers',
|
||||
'settings.providers.page.custom.headers.description': 'Optional request headers sent with every call.',
|
||||
'settings.providers.page.custom.headers.keyLabel': 'Header name',
|
||||
'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header',
|
||||
'settings.providers.page.custom.headers.valueLabel': 'Header value',
|
||||
'settings.providers.page.custom.headers.valuePlaceholder': 'value',
|
||||
'settings.providers.page.custom.headers.add': 'Add header',
|
||||
'settings.providers.page.custom.headers.remove': 'Remove header',
|
||||
'settings.providers.page.custom.actions.back': 'Back',
|
||||
'settings.providers.page.custom.actions.save': 'Save provider',
|
||||
'settings.providers.page.custom.actions.update': 'Update provider',
|
||||
'settings.providers.page.custom.error.providerID.required': 'Provider ID is required',
|
||||
'settings.providers.page.custom.error.providerID.format': 'Use lowercase letters, numbers, hyphens, or underscores',
|
||||
'settings.providers.page.custom.error.providerID.exists': 'A provider with this ID is already connected',
|
||||
'settings.providers.page.custom.error.name.required': 'Display name is required',
|
||||
'settings.providers.page.custom.error.baseURL.required': 'Base URL is required',
|
||||
'settings.providers.page.custom.error.baseURL.format': 'Base URL must start with http:// or https://',
|
||||
'settings.providers.page.custom.error.required': 'Required',
|
||||
'settings.providers.page.custom.error.duplicate': 'Duplicate',
|
||||
'settings.providers.page.custom.error.apiKey.required': 'API key or {env:VAR_NAME} is required',
|
||||
'settings.providers.page.custom.authFailure.configAfterAuth': 'Credentials were saved, but the provider config was not. Fix the error and try again, or disconnect to clear the partial save.',
|
||||
'settings.providers.page.auth.title': 'Authentication',
|
||||
'settings.providers.page.auth.loadingMethods': 'Loading authentication methods...',
|
||||
'settings.providers.page.auth.apiKeyLabel': 'API Key',
|
||||
@@ -1342,6 +1387,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.auth.oauthMethodFallback': 'OAuth method {index}',
|
||||
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Paste authorization code',
|
||||
'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',
|
||||
'settings.providers.page.auth.useReconnectHint': '· Use Reconnect to update credentials',
|
||||
'settings.providers.page.connectionDetails.title': 'Connection Details',
|
||||
'settings.providers.page.connectionDetails.configuredIn': 'Configured in:',
|
||||
@@ -1371,6 +1418,7 @@ export const settingsDict = {
|
||||
'settings.providers.page.actions.complete': 'Complete',
|
||||
'settings.providers.page.actions.hide': 'Hide',
|
||||
'settings.providers.page.actions.reconnect': 'Reconnect',
|
||||
'settings.providers.page.actions.edit': 'Edit',
|
||||
'settings.providers.page.actions.disconnecting': 'Disconnecting...',
|
||||
'settings.providers.page.actions.disconnect': 'Disconnect',
|
||||
'settings.providers.page.actions.hideAll': 'Hide all',
|
||||
@@ -1391,6 +1439,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.toast.deviceCodeCopyFailed': 'Failed to copy device code',
|
||||
'settings.providers.page.toast.providerDisconnected': 'Provider disconnected',
|
||||
'settings.providers.page.toast.providerDisconnectFailed': 'Failed to disconnect provider',
|
||||
'settings.providers.page.toast.customProviderSaved': '{provider} connected',
|
||||
'settings.providers.page.toast.customProviderSaveFailed': 'Failed to save custom provider',
|
||||
'settings.mcp.page.empty.selectServer': 'Select an MCP server from the sidebar',
|
||||
'settings.mcp.page.empty.addNewOne': 'or add a new one',
|
||||
'settings.mcp.page.header.newServer': 'New MCP Server',
|
||||
|
||||
@@ -1300,7 +1300,58 @@ export const settingsDict = {
|
||||
"settings.providers.page.connect.selectProviderPlaceholder": "Seleccionar proveedor",
|
||||
"settings.providers.page.connect.searchProvidersPlaceholder": "Buscar...",
|
||||
"settings.providers.page.connect.noProvidersFound": "No se encontraron proveedores",
|
||||
"settings.providers.page.connect.allProvidersConnected": "Todos los proveedores están conectados.",
|
||||
"settings.providers.page.custom.optionLabel": "Otro / Personalizado",
|
||||
"settings.providers.page.custom.title": "Proveedor personalizado",
|
||||
"settings.providers.page.custom.editTitle": "Editar proveedor personalizado",
|
||||
|
||||
"settings.providers.page.custom.description": "Añade un proveedor compatible con OpenAI con URL base, credenciales y lista de modelos. Se guarda en la configuración de OpenCode para usarlo en el chat como cualquier otro proveedor.",
|
||||
"settings.providers.page.custom.field.providerID.label": "ID del proveedor",
|
||||
"settings.providers.page.custom.field.providerID.placeholder": "mi-proveedor",
|
||||
"settings.providers.page.custom.field.providerID.info": "Minúsculas, números, guiones y guiones bajos. Se usa como ID de proveedor de OpenCode.",
|
||||
"settings.providers.page.custom.field.name.label": "Nombre visible",
|
||||
"settings.providers.page.custom.field.name.placeholder": "Mi proveedor",
|
||||
"settings.providers.page.custom.field.name.info": "Se muestra en los selectores de proveedor y modelo.",
|
||||
"settings.providers.page.custom.field.baseURL.label": "URL base",
|
||||
"settings.providers.page.custom.field.baseURL.placeholder": "https://api.example.com/v1",
|
||||
"settings.providers.page.custom.field.baseURL.info": "URL base de la API compatible con OpenAI. Debe empezar por http:// o https://.",
|
||||
"settings.providers.page.custom.field.apiKey.label": "Clave API",
|
||||
"settings.providers.page.custom.field.apiKey.placeholder": "sk-... o {env:VAR_NAME}",
|
||||
"settings.providers.page.custom.field.apiKey.info": "Se guarda en la autenticación de OpenCode, no en OpenChamber. Usa {env:VAR_NAME} para leer una clave del entorno.",
|
||||
"settings.providers.page.custom.field.apiKey.editInfo": "Déjalo en blanco para conservar la credencial existente, o introduce una clave nueva / {env:VAR_NAME}.",
|
||||
"settings.providers.page.custom.field.apiKey.editPlaceholder": "Déjalo en blanco para conservar la clave existente",
|
||||
|
||||
|
||||
"settings.providers.page.custom.models.title": "Modelos",
|
||||
"settings.providers.page.custom.models.idLabel": "ID del modelo",
|
||||
"settings.providers.page.custom.models.idPlaceholder": "gpt-4o",
|
||||
"settings.providers.page.custom.models.nameLabel": "Nombre del modelo",
|
||||
"settings.providers.page.custom.models.namePlaceholder": "GPT-4o",
|
||||
"settings.providers.page.custom.models.add": "Añadir modelo",
|
||||
"settings.providers.page.custom.models.remove": "Quitar modelo",
|
||||
"settings.providers.page.custom.headers.title": "Encabezados",
|
||||
"settings.providers.page.custom.headers.description": "Encabezados de solicitud opcionales enviados en cada llamada.",
|
||||
"settings.providers.page.custom.headers.keyLabel": "Nombre del encabezado",
|
||||
"settings.providers.page.custom.headers.keyPlaceholder": "X-Custom-Header",
|
||||
"settings.providers.page.custom.headers.valueLabel": "Valor del encabezado",
|
||||
"settings.providers.page.custom.headers.valuePlaceholder": "valor",
|
||||
"settings.providers.page.custom.headers.add": "Añadir encabezado",
|
||||
"settings.providers.page.custom.headers.remove": "Quitar encabezado",
|
||||
"settings.providers.page.custom.actions.back": "Atrás",
|
||||
"settings.providers.page.custom.actions.save": "Guardar proveedor",
|
||||
"settings.providers.page.custom.actions.update": "Actualizar proveedor",
|
||||
|
||||
"settings.providers.page.custom.error.providerID.required": "El ID del proveedor es obligatorio",
|
||||
"settings.providers.page.custom.error.providerID.format": "Usa minúsculas, números, guiones o guiones bajos",
|
||||
"settings.providers.page.custom.error.providerID.exists": "Ya hay un proveedor conectado con este ID",
|
||||
"settings.providers.page.custom.error.name.required": "El nombre visible es obligatorio",
|
||||
"settings.providers.page.custom.error.baseURL.required": "La URL base es obligatoria",
|
||||
"settings.providers.page.custom.error.baseURL.format": "La URL base debe empezar por http:// o https://",
|
||||
"settings.providers.page.custom.error.required": "Obligatorio",
|
||||
"settings.providers.page.custom.error.duplicate": "Duplicado",
|
||||
"settings.providers.page.custom.error.apiKey.required": "Se requiere una clave API o {env:VAR_NAME}",
|
||||
"settings.providers.page.custom.authFailure.configAfterAuth": "Las credenciales se guardaron, pero no la configuración del proveedor. Corrige el error e inténtalo de nuevo, o desconéctalo para eliminar el guardado parcial.",
|
||||
|
||||
|
||||
"settings.providers.page.auth.title": "Autenticación",
|
||||
"settings.providers.page.auth.loadingMethods": "Cargando métodos de autenticación...",
|
||||
"settings.providers.page.auth.apiKeyLabel": "Clave API",
|
||||
@@ -1309,6 +1360,10 @@ export const settingsDict = {
|
||||
"settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}",
|
||||
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Pegar código de autorización",
|
||||
"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",
|
||||
|
||||
|
||||
"settings.providers.page.auth.useReconnectHint": "· Usar Reconnect para actualizar credenciales",
|
||||
"settings.providers.page.connectionDetails.title": "Detalles de conexión",
|
||||
"settings.providers.page.connectionDetails.configuredIn": "Configurado en:",
|
||||
@@ -1338,6 +1393,8 @@ export const settingsDict = {
|
||||
"settings.providers.page.actions.complete": "Completar",
|
||||
"settings.providers.page.actions.hide": "Ocultar",
|
||||
"settings.providers.page.actions.reconnect": "Reconectar",
|
||||
"settings.providers.page.actions.edit": "Editar",
|
||||
|
||||
"settings.providers.page.actions.disconnecting": "Desconectando...",
|
||||
"settings.providers.page.actions.disconnect": "Desconectar",
|
||||
"settings.providers.page.actions.hideAll": "Ocultar todo",
|
||||
@@ -1358,6 +1415,8 @@ export const settingsDict = {
|
||||
"settings.providers.page.toast.deviceCodeCopyFailed": "No se pudo copiar el código de dispositivo",
|
||||
"settings.providers.page.toast.providerDisconnected": "Proveedor desconectado",
|
||||
"settings.providers.page.toast.providerDisconnectFailed": "No se pudo desconectar el proveedor",
|
||||
"settings.providers.page.toast.customProviderSaved": "{provider} conectado",
|
||||
"settings.providers.page.toast.customProviderSaveFailed": "No se pudo guardar el proveedor personalizado",
|
||||
"settings.mcp.page.empty.selectServer": "Selecciona un servidor MCP desde el panel lateral",
|
||||
"settings.mcp.page.empty.addNewOne": "o añade uno nuevo",
|
||||
"settings.mcp.page.header.newServer": "Nuevo servidor MCP",
|
||||
|
||||
@@ -1221,7 +1221,58 @@ export const settingsDict = {
|
||||
'settings.providers.page.connect.selectProviderPlaceholder': 'Sélectionnez le fournisseur',
|
||||
'settings.providers.page.connect.searchProvidersPlaceholder': 'Recherche...',
|
||||
'settings.providers.page.connect.noProvidersFound': 'Aucun fournisseur trouvé',
|
||||
'settings.providers.page.connect.allProvidersConnected': 'Tous les fournisseurs connectés.',
|
||||
'settings.providers.page.custom.optionLabel': 'Autre / Personnalisé',
|
||||
'settings.providers.page.custom.title': 'Fournisseur personnalisé',
|
||||
'settings.providers.page.custom.editTitle': 'Modifier le fournisseur personnalisé',
|
||||
|
||||
'settings.providers.page.custom.description': 'Ajoutez un fournisseur compatible OpenAI avec une URL de base, des identifiants et une liste de modèles. Enregistré dans la configuration OpenCode pour l’utiliser dans le chat comme les autres fournisseurs.',
|
||||
'settings.providers.page.custom.field.providerID.label': 'ID du fournisseur',
|
||||
'settings.providers.page.custom.field.providerID.placeholder': 'mon-fournisseur',
|
||||
'settings.providers.page.custom.field.providerID.info': 'Minuscules, chiffres, tirets et underscores. Utilisé comme ID de fournisseur OpenCode.',
|
||||
'settings.providers.page.custom.field.name.label': 'Nom affiché',
|
||||
'settings.providers.page.custom.field.name.placeholder': 'Mon fournisseur',
|
||||
'settings.providers.page.custom.field.name.info': 'Affiché dans les sélecteurs de fournisseur et de modèle.',
|
||||
'settings.providers.page.custom.field.baseURL.label': 'URL de base',
|
||||
'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1',
|
||||
'settings.providers.page.custom.field.baseURL.info': 'URL de base de l’API compatible OpenAI. Doit commencer par http:// ou https://.',
|
||||
'settings.providers.page.custom.field.apiKey.label': 'Clé API',
|
||||
'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... ou {env:VAR_NAME}',
|
||||
'settings.providers.page.custom.field.apiKey.info': 'Stockée dans l’auth OpenCode, pas par OpenChamber. Utilisez {env:VAR_NAME} pour lire une clé depuis l’environnement.',
|
||||
'settings.providers.page.custom.field.apiKey.editInfo': 'Laissez vide pour conserver l\'identifiant existant, ou saisissez une nouvelle clé / {env:VAR_NAME}.',
|
||||
'settings.providers.page.custom.field.apiKey.editPlaceholder': 'Laissez vide pour conserver la clé existante',
|
||||
|
||||
|
||||
'settings.providers.page.custom.models.title': 'Modèles',
|
||||
'settings.providers.page.custom.models.idLabel': 'ID du modèle',
|
||||
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
|
||||
'settings.providers.page.custom.models.nameLabel': 'Nom du modèle',
|
||||
'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o',
|
||||
'settings.providers.page.custom.models.add': 'Ajouter un modèle',
|
||||
'settings.providers.page.custom.models.remove': 'Supprimer le modèle',
|
||||
'settings.providers.page.custom.headers.title': 'En-têtes',
|
||||
'settings.providers.page.custom.headers.description': 'En-têtes de requête optionnels envoyés à chaque appel.',
|
||||
'settings.providers.page.custom.headers.keyLabel': 'Nom de l’en-tête',
|
||||
'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header',
|
||||
'settings.providers.page.custom.headers.valueLabel': 'Valeur de l’en-tête',
|
||||
'settings.providers.page.custom.headers.valuePlaceholder': 'valeur',
|
||||
'settings.providers.page.custom.headers.add': 'Ajouter un en-tête',
|
||||
'settings.providers.page.custom.headers.remove': 'Supprimer l’en-tête',
|
||||
'settings.providers.page.custom.actions.back': 'Retour',
|
||||
'settings.providers.page.custom.actions.save': 'Enregistrer le fournisseur',
|
||||
'settings.providers.page.custom.actions.update': 'Mettre à jour le fournisseur',
|
||||
|
||||
'settings.providers.page.custom.error.providerID.required': 'L’ID du fournisseur est obligatoire',
|
||||
'settings.providers.page.custom.error.providerID.format': 'Utilisez des minuscules, chiffres, tirets ou underscores',
|
||||
'settings.providers.page.custom.error.providerID.exists': 'Un fournisseur avec cet ID est déjà connecté',
|
||||
'settings.providers.page.custom.error.name.required': 'Le nom affiché est obligatoire',
|
||||
'settings.providers.page.custom.error.baseURL.required': 'L’URL de base est obligatoire',
|
||||
'settings.providers.page.custom.error.baseURL.format': 'L’URL de base doit commencer par http:// ou https://',
|
||||
'settings.providers.page.custom.error.required': 'Obligatoire',
|
||||
'settings.providers.page.custom.error.duplicate': 'Doublon',
|
||||
'settings.providers.page.custom.error.apiKey.required': 'Une clé API ou {env:VAR_NAME} est requise',
|
||||
'settings.providers.page.custom.authFailure.configAfterAuth': 'Les identifiants ont été enregistrés, mais pas la configuration du fournisseur. Corrigez l\'erreur et réessayez, ou déconnectez pour effacer l\'enregistrement partiel.',
|
||||
|
||||
|
||||
'settings.providers.page.auth.title': 'Authentification',
|
||||
'settings.providers.page.auth.loadingMethods': 'Chargement des méthodes d\'authentification...',
|
||||
'settings.providers.page.auth.apiKeyLabel': 'Clé API',
|
||||
@@ -1230,6 +1281,10 @@ export const settingsDict = {
|
||||
'settings.providers.page.auth.oauthMethodFallback': 'Méthode OAuth {index}',
|
||||
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Coller le code d\'autorisation',
|
||||
'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 d’utiliser ce fournisseur dans le chat',
|
||||
|
||||
|
||||
'settings.providers.page.auth.useReconnectHint': '· Utilisez Reconnect pour mettre à jour les informations d\'identification',
|
||||
'settings.providers.page.connectionDetails.title': 'Détails de connexion',
|
||||
'settings.providers.page.connectionDetails.configuredIn': 'Configuré dans :',
|
||||
@@ -1259,6 +1314,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.actions.complete': 'Complet',
|
||||
'settings.providers.page.actions.hide': 'Cacher',
|
||||
'settings.providers.page.actions.reconnect': 'Reconnecter',
|
||||
'settings.providers.page.actions.edit': 'Modifier',
|
||||
|
||||
'settings.providers.page.actions.disconnecting': 'Déconnexion...',
|
||||
'settings.providers.page.actions.disconnect': 'Déconnecter',
|
||||
'settings.providers.page.actions.hideAll': 'Tout cacher',
|
||||
@@ -1279,6 +1336,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.toast.deviceCodeCopyFailed': 'Échec de la copie du code de l\'appareil',
|
||||
'settings.providers.page.toast.providerDisconnected': 'Fournisseur déconnecté',
|
||||
'settings.providers.page.toast.providerDisconnectFailed': 'Échec de la déconnexion du fournisseur',
|
||||
'settings.providers.page.toast.customProviderSaved': '{provider} connecté',
|
||||
'settings.providers.page.toast.customProviderSaveFailed': 'Échec de l’enregistrement du fournisseur personnalisé',
|
||||
'settings.mcp.page.empty.selectServer': 'Sélectionnez un serveur MCP dans la barre latérale',
|
||||
'settings.mcp.page.empty.addNewOne': 'ou ajoutez-en un nouveau',
|
||||
'settings.mcp.page.header.newServer': 'Nouveau serveur MCP',
|
||||
|
||||
@@ -1333,7 +1333,58 @@ export const settingsDict = {
|
||||
'settings.providers.page.connect.selectProviderPlaceholder': 'Provider を選択',
|
||||
'settings.providers.page.connect.searchProvidersPlaceholder': '検索...',
|
||||
'settings.providers.page.connect.noProvidersFound': 'Provider が見つかりません',
|
||||
'settings.providers.page.connect.allProvidersConnected': 'すべての Provider が接続されています。',
|
||||
'settings.providers.page.custom.optionLabel': 'その他 / カスタム',
|
||||
'settings.providers.page.custom.title': 'カスタムプロバイダー',
|
||||
'settings.providers.page.custom.editTitle': 'カスタムプロバイダーを編集',
|
||||
|
||||
'settings.providers.page.custom.description': 'ベース URL・認証情報・モデル一覧を指定して、OpenAI 互換プロバイダーを追加します。OpenCode 設定に保存され、他のプロバイダーと同様にチャットで使えます。',
|
||||
'settings.providers.page.custom.field.providerID.label': 'プロバイダー ID',
|
||||
'settings.providers.page.custom.field.providerID.placeholder': 'my-provider',
|
||||
'settings.providers.page.custom.field.providerID.info': '小文字・数字・ハイフン・アンダースコア。OpenCode のプロバイダー ID として使われます。',
|
||||
'settings.providers.page.custom.field.name.label': '表示名',
|
||||
'settings.providers.page.custom.field.name.placeholder': 'My Provider',
|
||||
'settings.providers.page.custom.field.name.info': 'プロバイダーおよびモデル選択に表示されます。',
|
||||
'settings.providers.page.custom.field.baseURL.label': 'ベース URL',
|
||||
'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1',
|
||||
'settings.providers.page.custom.field.baseURL.info': 'OpenAI 互換 API のベース URL。http:// または https:// で始めてください。',
|
||||
'settings.providers.page.custom.field.apiKey.label': 'API キー',
|
||||
'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... または {env:VAR_NAME}',
|
||||
'settings.providers.page.custom.field.apiKey.info': 'OpenChamber ではなく OpenCode の認証に保存されます。環境変数から読む場合は {env:VAR_NAME} を使います。',
|
||||
'settings.providers.page.custom.field.apiKey.editInfo': '空のままにすると既存の認証情報を保持します。新しいキーまたは {env:VAR_NAME} を入力することもできます。',
|
||||
'settings.providers.page.custom.field.apiKey.editPlaceholder': '空のままにすると既存のキーを保持',
|
||||
|
||||
|
||||
'settings.providers.page.custom.models.title': 'モデル',
|
||||
'settings.providers.page.custom.models.idLabel': 'モデル ID',
|
||||
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
|
||||
'settings.providers.page.custom.models.nameLabel': 'モデル名',
|
||||
'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o',
|
||||
'settings.providers.page.custom.models.add': 'モデルを追加',
|
||||
'settings.providers.page.custom.models.remove': 'モデルを削除',
|
||||
'settings.providers.page.custom.headers.title': 'ヘッダー',
|
||||
'settings.providers.page.custom.headers.description': '各リクエストに付ける任意のヘッダーです。',
|
||||
'settings.providers.page.custom.headers.keyLabel': 'ヘッダー名',
|
||||
'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header',
|
||||
'settings.providers.page.custom.headers.valueLabel': 'ヘッダー値',
|
||||
'settings.providers.page.custom.headers.valuePlaceholder': 'value',
|
||||
'settings.providers.page.custom.headers.add': 'ヘッダーを追加',
|
||||
'settings.providers.page.custom.headers.remove': 'ヘッダーを削除',
|
||||
'settings.providers.page.custom.actions.back': '戻る',
|
||||
'settings.providers.page.custom.actions.save': 'プロバイダーを保存',
|
||||
'settings.providers.page.custom.actions.update': 'プロバイダーを更新',
|
||||
|
||||
'settings.providers.page.custom.error.providerID.required': 'プロバイダー ID は必須です',
|
||||
'settings.providers.page.custom.error.providerID.format': '小文字・数字・ハイフン・アンダースコアを使ってください',
|
||||
'settings.providers.page.custom.error.providerID.exists': 'この ID のプロバイダーは既に接続されています',
|
||||
'settings.providers.page.custom.error.name.required': '表示名は必須です',
|
||||
'settings.providers.page.custom.error.baseURL.required': 'ベース URL は必須です',
|
||||
'settings.providers.page.custom.error.baseURL.format': 'ベース URL は http:// または https:// で始めてください',
|
||||
'settings.providers.page.custom.error.required': '必須',
|
||||
'settings.providers.page.custom.error.duplicate': '重複',
|
||||
'settings.providers.page.custom.error.apiKey.required': 'API キーまたは {env:VAR_NAME} が必要です',
|
||||
'settings.providers.page.custom.authFailure.configAfterAuth': '認証情報は保存されましたが、プロバイダー設定は保存されませんでした。エラーを修正して再試行するか、切断して不完全な保存を削除してください。',
|
||||
|
||||
|
||||
'settings.providers.page.auth.title': '認証',
|
||||
'settings.providers.page.auth.loadingMethods': '認証方法を読み込み中...',
|
||||
'settings.providers.page.auth.apiKeyLabel': 'API キー',
|
||||
@@ -1342,6 +1393,10 @@ export const settingsDict = {
|
||||
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方法 {index}',
|
||||
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '認証コードを貼り付け',
|
||||
'settings.providers.page.auth.connected': '接続済み',
|
||||
'settings.providers.page.auth.incomplete': '認証情報が不足しています',
|
||||
'settings.providers.page.auth.incompleteHint': '· チャットでこのプロバイダーを使う前に API キーまたは {env:VAR} を追加してください',
|
||||
|
||||
|
||||
'settings.providers.page.auth.useReconnectHint': '· 認証情報を更新するには再接続を使用',
|
||||
'settings.providers.page.connectionDetails.title': '接続詳細',
|
||||
'settings.providers.page.connectionDetails.configuredIn': '設定場所:',
|
||||
@@ -1371,6 +1426,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.actions.complete': '完了',
|
||||
'settings.providers.page.actions.hide': '非表示',
|
||||
'settings.providers.page.actions.reconnect': '再接続',
|
||||
'settings.providers.page.actions.edit': '編集',
|
||||
|
||||
'settings.providers.page.actions.disconnecting': '切断中...',
|
||||
'settings.providers.page.actions.disconnect': '切断',
|
||||
'settings.providers.page.actions.hideAll': 'すべて非表示',
|
||||
@@ -1391,6 +1448,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.toast.deviceCodeCopyFailed': 'デバイスコードのコピーに失敗しました',
|
||||
'settings.providers.page.toast.providerDisconnected': 'Provider を切断しました',
|
||||
'settings.providers.page.toast.providerDisconnectFailed': 'Provider の切断に失敗しました',
|
||||
'settings.providers.page.toast.customProviderSaved': '{provider} を接続しました',
|
||||
'settings.providers.page.toast.customProviderSaveFailed': 'カスタムプロバイダーの保存に失敗しました',
|
||||
'settings.mcp.page.empty.selectServer': 'サイドバーから MCP サーバーを選択してください',
|
||||
'settings.mcp.page.empty.addNewOne': 'または新しいものを追加',
|
||||
'settings.mcp.page.header.newServer': '新しい MCP サーバー',
|
||||
|
||||
@@ -1300,7 +1300,58 @@ export const settingsDict = {
|
||||
'settings.providers.page.connect.selectProviderPlaceholder': '프로바이더 선택',
|
||||
'settings.providers.page.connect.searchProvidersPlaceholder': '검색...',
|
||||
'settings.providers.page.connect.noProvidersFound': '프로바이더를 찾을 수 없습니다',
|
||||
'settings.providers.page.connect.allProvidersConnected': '모든 프로바이더가 연결되었습니다.',
|
||||
'settings.providers.page.custom.optionLabel': '기타 / 사용자 정의',
|
||||
'settings.providers.page.custom.title': '사용자 정의 제공자',
|
||||
'settings.providers.page.custom.editTitle': '사용자 지정 공급자 편집',
|
||||
|
||||
'settings.providers.page.custom.description': '기본 URL, 자격 증명, 모델 목록으로 OpenAI 호환 제공자를 추가합니다. OpenCode 설정에 저장되어 다른 제공자와 같이 채팅에서 사용할 수 있습니다.',
|
||||
'settings.providers.page.custom.field.providerID.label': '제공자 ID',
|
||||
'settings.providers.page.custom.field.providerID.placeholder': 'my-provider',
|
||||
'settings.providers.page.custom.field.providerID.info': '소문자, 숫자, 하이픈, 밑줄. OpenCode 제공자 ID로 사용됩니다.',
|
||||
'settings.providers.page.custom.field.name.label': '표시 이름',
|
||||
'settings.providers.page.custom.field.name.placeholder': '내 제공자',
|
||||
'settings.providers.page.custom.field.name.info': '제공자 및 모델 선택기에 표시됩니다.',
|
||||
'settings.providers.page.custom.field.baseURL.label': '기본 URL',
|
||||
'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1',
|
||||
'settings.providers.page.custom.field.baseURL.info': 'OpenAI 호환 API 기본 URL. http:// 또는 https://로 시작해야 합니다.',
|
||||
'settings.providers.page.custom.field.apiKey.label': 'API 키',
|
||||
'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... 또는 {env:VAR_NAME}',
|
||||
'settings.providers.page.custom.field.apiKey.info': 'OpenChamber가 아니라 OpenCode 인증에 저장됩니다. 환경 변수에서 읽으려면 {env:VAR_NAME}을 사용하세요.',
|
||||
'settings.providers.page.custom.field.apiKey.editInfo': '비워 두면 기존 자격 증명을 유지합니다. 새 키 또는 {env:VAR_NAME}을(를) 입력할 수도 있습니다.',
|
||||
'settings.providers.page.custom.field.apiKey.editPlaceholder': '비워 두면 기존 키 유지',
|
||||
|
||||
|
||||
'settings.providers.page.custom.models.title': '모델',
|
||||
'settings.providers.page.custom.models.idLabel': '모델 ID',
|
||||
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
|
||||
'settings.providers.page.custom.models.nameLabel': '모델 이름',
|
||||
'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o',
|
||||
'settings.providers.page.custom.models.add': '모델 추가',
|
||||
'settings.providers.page.custom.models.remove': '모델 제거',
|
||||
'settings.providers.page.custom.headers.title': '헤더',
|
||||
'settings.providers.page.custom.headers.description': '매 호출에 전송되는 선택적 요청 헤더입니다.',
|
||||
'settings.providers.page.custom.headers.keyLabel': '헤더 이름',
|
||||
'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header',
|
||||
'settings.providers.page.custom.headers.valueLabel': '헤더 값',
|
||||
'settings.providers.page.custom.headers.valuePlaceholder': 'value',
|
||||
'settings.providers.page.custom.headers.add': '헤더 추가',
|
||||
'settings.providers.page.custom.headers.remove': '헤더 제거',
|
||||
'settings.providers.page.custom.actions.back': '뒤로',
|
||||
'settings.providers.page.custom.actions.save': '제공자 저장',
|
||||
'settings.providers.page.custom.actions.update': '공급자 업데이트',
|
||||
|
||||
'settings.providers.page.custom.error.providerID.required': '제공자 ID는 필수입니다',
|
||||
'settings.providers.page.custom.error.providerID.format': '소문자, 숫자, 하이픈, 밑줄을 사용하세요',
|
||||
'settings.providers.page.custom.error.providerID.exists': '이 ID의 제공자가 이미 연결되어 있습니다',
|
||||
'settings.providers.page.custom.error.name.required': '표시 이름은 필수입니다',
|
||||
'settings.providers.page.custom.error.baseURL.required': '기본 URL은 필수입니다',
|
||||
'settings.providers.page.custom.error.baseURL.format': '기본 URL은 http:// 또는 https://로 시작해야 합니다',
|
||||
'settings.providers.page.custom.error.required': '필수',
|
||||
'settings.providers.page.custom.error.duplicate': '중복',
|
||||
'settings.providers.page.custom.error.apiKey.required': 'API 키 또는 {env:VAR_NAME}이(가) 필요합니다',
|
||||
'settings.providers.page.custom.authFailure.configAfterAuth': '자격 증명은 저장되었지만 공급자 구성은 저장되지 않았습니다. 오류를 수정한 뒤 다시 시도하거나, 연결을 해제하여 부분 저장을 지우세요.',
|
||||
|
||||
|
||||
'settings.providers.page.auth.title': '인증',
|
||||
'settings.providers.page.auth.loadingMethods': '인증 방식 로딩 중...',
|
||||
'settings.providers.page.auth.apiKeyLabel': 'API Key',
|
||||
@@ -1309,6 +1360,10 @@ export const settingsDict = {
|
||||
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 방식 {index}',
|
||||
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'authorization code 붙여넣기',
|
||||
'settings.providers.page.auth.connected': '연결됨',
|
||||
'settings.providers.page.auth.incomplete': '자격 증명 없음',
|
||||
'settings.providers.page.auth.incompleteHint': '· 채팅에서 이 공급자를 사용하기 전에 API 키 또는 {env:VAR}을(를) 추가하세요',
|
||||
|
||||
|
||||
'settings.providers.page.auth.useReconnectHint': '· 인증 정보를 업데이트하려면 Reconnect를 사용하세요',
|
||||
'settings.providers.page.connectionDetails.title': '연결 세부 정보',
|
||||
'settings.providers.page.connectionDetails.configuredIn': '설정 위치:',
|
||||
@@ -1338,6 +1393,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.actions.complete': '완료',
|
||||
'settings.providers.page.actions.hide': '숨기기',
|
||||
'settings.providers.page.actions.reconnect': '재연결',
|
||||
'settings.providers.page.actions.edit': '편집',
|
||||
|
||||
'settings.providers.page.actions.disconnecting': '연결 해제 중...',
|
||||
'settings.providers.page.actions.disconnect': '연결 해제',
|
||||
'settings.providers.page.actions.hideAll': '모두 숨기기',
|
||||
@@ -1358,6 +1415,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.toast.deviceCodeCopyFailed': '기기 코드를 복사하지 못했습니다',
|
||||
'settings.providers.page.toast.providerDisconnected': '프로바이더 연결이 해제되었습니다',
|
||||
'settings.providers.page.toast.providerDisconnectFailed': '프로바이더 연결을 해제하지 못했습니다',
|
||||
'settings.providers.page.toast.customProviderSaved': '{provider} 연결됨',
|
||||
'settings.providers.page.toast.customProviderSaveFailed': '사용자 정의 제공자를 저장하지 못했습니다',
|
||||
'settings.mcp.page.empty.selectServer': '사이드바에서 MCP 서버를 선택하세요',
|
||||
'settings.mcp.page.empty.addNewOne': '또는 새로 추가하세요',
|
||||
'settings.mcp.page.header.newServer': '새 MCP 서버',
|
||||
|
||||
@@ -1368,6 +1368,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.actions.hideAll': 'Ukryj wszystko',
|
||||
'settings.providers.page.actions.open': 'Otwórz',
|
||||
'settings.providers.page.actions.reconnect': 'Połącz ponownie',
|
||||
'settings.providers.page.actions.edit': 'Edytuj',
|
||||
|
||||
'settings.providers.page.actions.saveKey': 'Zapisz klucz',
|
||||
'settings.providers.page.actions.saving': 'Zapisywanie...',
|
||||
'settings.providers.page.actions.showAll': 'Pokaż wszystko',
|
||||
@@ -1375,12 +1377,67 @@ export const settingsDict = {
|
||||
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
|
||||
'settings.providers.page.auth.apiKeyTooltip': 'Klucze są wysyłane bezpośrednio do OpenCode i nigdy nie są przechowywane przez OpenChamber.',
|
||||
'settings.providers.page.auth.connected': 'Połączono',
|
||||
'settings.providers.page.auth.incomplete': 'Brak poświadczeń',
|
||||
'settings.providers.page.auth.incompleteHint': '· Dodaj klucz API lub {env:VAR} przed użyciem tego dostawcy w czacie',
|
||||
|
||||
|
||||
'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.title': 'Uwierzytelnianie',
|
||||
'settings.providers.page.auth.useReconnectHint': '· Użyj Połącz ponownie, aby zaktualizować dane logowania',
|
||||
'settings.providers.page.connect.allProvidersConnected': 'Wszyscy dostawcy są połączeni.',
|
||||
'settings.providers.page.custom.optionLabel': 'Inny / Niestandardowy',
|
||||
'settings.providers.page.custom.title': 'Niestandardowy dostawca',
|
||||
'settings.providers.page.custom.editTitle': 'Edytuj niestandardowego dostawcę',
|
||||
|
||||
'settings.providers.page.custom.description': 'Dodaj dostawcę zgodnego z OpenAI, podając adres bazowy, poświadczenia i listę modeli. Zapisuje się w konfiguracji OpenCode i działa w czacie jak każdy inny dostawca.',
|
||||
'settings.providers.page.custom.field.providerID.label': 'ID dostawcy',
|
||||
'settings.providers.page.custom.field.providerID.placeholder': 'moj-dostawca',
|
||||
'settings.providers.page.custom.field.providerID.info': 'Małe litery, cyfry, myślniki i podkreślenia. Używane jako ID dostawcy OpenCode.',
|
||||
'settings.providers.page.custom.field.name.label': 'Nazwa wyświetlana',
|
||||
'settings.providers.page.custom.field.name.placeholder': 'Mój dostawca',
|
||||
'settings.providers.page.custom.field.name.info': 'Widoczna w selektorach dostawcy i modelu.',
|
||||
'settings.providers.page.custom.field.baseURL.label': 'Adres bazowy',
|
||||
'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1',
|
||||
'settings.providers.page.custom.field.baseURL.info': 'Bazowy URL API zgodnego z OpenAI. Musi zaczynać się od http:// lub https://.',
|
||||
'settings.providers.page.custom.field.apiKey.label': 'Klucz API',
|
||||
'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... lub {env:VAR_NAME}',
|
||||
'settings.providers.page.custom.field.apiKey.info': 'Przechowywany w auth OpenCode, nie przez OpenChamber. Użyj {env:VAR_NAME}, aby odczytać klucz ze zmiennej środowiskowej.',
|
||||
'settings.providers.page.custom.field.apiKey.editInfo': 'Pozostaw puste, aby zachować istniejące poświadczenie, albo wpisz nowy klucz / {env:VAR_NAME}.',
|
||||
'settings.providers.page.custom.field.apiKey.editPlaceholder': 'Pozostaw puste, aby zachować istniejący klucz',
|
||||
|
||||
|
||||
'settings.providers.page.custom.models.title': 'Modele',
|
||||
'settings.providers.page.custom.models.idLabel': 'ID modelu',
|
||||
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
|
||||
'settings.providers.page.custom.models.nameLabel': 'Nazwa modelu',
|
||||
'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o',
|
||||
'settings.providers.page.custom.models.add': 'Dodaj model',
|
||||
'settings.providers.page.custom.models.remove': 'Usuń model',
|
||||
'settings.providers.page.custom.headers.title': 'Nagłówki',
|
||||
'settings.providers.page.custom.headers.description': 'Opcjonalne nagłówki żądania wysyłane przy każdym wywołaniu.',
|
||||
'settings.providers.page.custom.headers.keyLabel': 'Nazwa nagłówka',
|
||||
'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header',
|
||||
'settings.providers.page.custom.headers.valueLabel': 'Wartość nagłówka',
|
||||
'settings.providers.page.custom.headers.valuePlaceholder': 'wartość',
|
||||
'settings.providers.page.custom.headers.add': 'Dodaj nagłówek',
|
||||
'settings.providers.page.custom.headers.remove': 'Usuń nagłówek',
|
||||
'settings.providers.page.custom.actions.back': 'Wstecz',
|
||||
'settings.providers.page.custom.actions.save': 'Zapisz dostawcę',
|
||||
'settings.providers.page.custom.actions.update': 'Zaktualizuj dostawcę',
|
||||
|
||||
'settings.providers.page.custom.error.providerID.required': 'ID dostawcy jest wymagane',
|
||||
'settings.providers.page.custom.error.providerID.format': 'Użyj małych liter, cyfr, myślników lub podkreśleń',
|
||||
'settings.providers.page.custom.error.providerID.exists': 'Dostawca o tym ID jest już połączony',
|
||||
'settings.providers.page.custom.error.name.required': 'Nazwa wyświetlana jest wymagana',
|
||||
'settings.providers.page.custom.error.baseURL.required': 'Adres bazowy jest wymagany',
|
||||
'settings.providers.page.custom.error.baseURL.format': 'Adres bazowy musi zaczynać się od http:// lub https://',
|
||||
'settings.providers.page.custom.error.required': 'Wymagane',
|
||||
'settings.providers.page.custom.error.duplicate': 'Duplikat',
|
||||
'settings.providers.page.custom.error.apiKey.required': 'Wymagany jest klucz API lub {env:VAR_NAME}',
|
||||
'settings.providers.page.custom.authFailure.configAfterAuth': 'Poświadczenia zostały zapisane, ale konfiguracja dostawcy nie. Napraw błąd i spróbuj ponownie albo rozłącz, aby usunąć częściowy zapis.',
|
||||
|
||||
|
||||
'settings.providers.page.connect.noProvidersFound': 'Nie znaleziono dostawców',
|
||||
'settings.providers.page.connect.providerField': 'Dostawca',
|
||||
'settings.providers.page.connect.searchProvidersPlaceholder': 'Szukaj...',
|
||||
@@ -1426,6 +1483,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.toast.oauthLinkCopyFailed': 'Nie udało się skopiować linku OAuth',
|
||||
'settings.providers.page.toast.oauthStartFailed': 'Nie udało się rozpocząć procesu OAuth',
|
||||
'settings.providers.page.toast.providerDisconnectFailed': 'Nie udało się odłączyć dostawcy',
|
||||
'settings.providers.page.toast.customProviderSaved': 'Połączono {provider}',
|
||||
'settings.providers.page.toast.customProviderSaveFailed': 'Nie udało się zapisać niestandardowego dostawcy',
|
||||
'settings.providers.page.toast.providerDisconnected': 'Dostawca został odłączony',
|
||||
'settings.providers.page.toast.providerSourcesLoadFailed': 'Nie udało się załadować źródeł dostawcy',
|
||||
'settings.providers.sidebar.actions.connectProviderAria': 'Połącz dostawcę',
|
||||
|
||||
@@ -1300,7 +1300,58 @@ export const settingsDict = {
|
||||
"settings.providers.page.connect.selectProviderPlaceholder": "Selecionar provedor",
|
||||
"settings.providers.page.connect.searchProvidersPlaceholder": "Buscar...",
|
||||
"settings.providers.page.connect.noProvidersFound": "Nenhum provedores",
|
||||
"settings.providers.page.connect.allProvidersConnected": "Todos os provedores estão conectados.",
|
||||
"settings.providers.page.custom.optionLabel": "Outro / Personalizado",
|
||||
"settings.providers.page.custom.title": "Provedor personalizado",
|
||||
"settings.providers.page.custom.editTitle": "Editar provedor personalizado",
|
||||
|
||||
"settings.providers.page.custom.description": "Adicione um provedor compatível com OpenAI com URL base, credenciais e lista de modelos. Salvo na configuração do OpenCode para uso no chat como qualquer outro provedor.",
|
||||
"settings.providers.page.custom.field.providerID.label": "ID do provedor",
|
||||
"settings.providers.page.custom.field.providerID.placeholder": "meu-provedor",
|
||||
"settings.providers.page.custom.field.providerID.info": "Letras minúsculas, números, hífens e sublinhados. Usado como ID de provedor do OpenCode.",
|
||||
"settings.providers.page.custom.field.name.label": "Nome de exibição",
|
||||
"settings.providers.page.custom.field.name.placeholder": "Meu provedor",
|
||||
"settings.providers.page.custom.field.name.info": "Mostrado nos seletores de provedor e modelo.",
|
||||
"settings.providers.page.custom.field.baseURL.label": "URL base",
|
||||
"settings.providers.page.custom.field.baseURL.placeholder": "https://api.example.com/v1",
|
||||
"settings.providers.page.custom.field.baseURL.info": "URL base da API compatível com OpenAI. Deve começar com http:// ou https://.",
|
||||
"settings.providers.page.custom.field.apiKey.label": "Chave de API",
|
||||
"settings.providers.page.custom.field.apiKey.placeholder": "sk-... ou {env:VAR_NAME}",
|
||||
"settings.providers.page.custom.field.apiKey.info": "Armazenada na autenticação do OpenCode, não pelo OpenChamber. Use {env:VAR_NAME} para ler uma chave do ambiente.",
|
||||
"settings.providers.page.custom.field.apiKey.editInfo": "Deixe em branco para manter a credencial existente, ou informe uma nova chave / {env:VAR_NAME}.",
|
||||
"settings.providers.page.custom.field.apiKey.editPlaceholder": "Deixe em branco para manter a chave existente",
|
||||
|
||||
|
||||
"settings.providers.page.custom.models.title": "Modelos",
|
||||
"settings.providers.page.custom.models.idLabel": "ID do modelo",
|
||||
"settings.providers.page.custom.models.idPlaceholder": "gpt-4o",
|
||||
"settings.providers.page.custom.models.nameLabel": "Nome do modelo",
|
||||
"settings.providers.page.custom.models.namePlaceholder": "GPT-4o",
|
||||
"settings.providers.page.custom.models.add": "Adicionar modelo",
|
||||
"settings.providers.page.custom.models.remove": "Remover modelo",
|
||||
"settings.providers.page.custom.headers.title": "Cabeçalhos",
|
||||
"settings.providers.page.custom.headers.description": "Cabeçalhos de solicitação opcionais enviados em cada chamada.",
|
||||
"settings.providers.page.custom.headers.keyLabel": "Nome do cabeçalho",
|
||||
"settings.providers.page.custom.headers.keyPlaceholder": "X-Custom-Header",
|
||||
"settings.providers.page.custom.headers.valueLabel": "Valor do cabeçalho",
|
||||
"settings.providers.page.custom.headers.valuePlaceholder": "valor",
|
||||
"settings.providers.page.custom.headers.add": "Adicionar cabeçalho",
|
||||
"settings.providers.page.custom.headers.remove": "Remover cabeçalho",
|
||||
"settings.providers.page.custom.actions.back": "Voltar",
|
||||
"settings.providers.page.custom.actions.save": "Salvar provedor",
|
||||
"settings.providers.page.custom.actions.update": "Atualizar provedor",
|
||||
|
||||
"settings.providers.page.custom.error.providerID.required": "O ID do provedor é obrigatório",
|
||||
"settings.providers.page.custom.error.providerID.format": "Use letras minúsculas, números, hífens ou sublinhados",
|
||||
"settings.providers.page.custom.error.providerID.exists": "Já existe um provedor conectado com este ID",
|
||||
"settings.providers.page.custom.error.name.required": "O nome de exibição é obrigatório",
|
||||
"settings.providers.page.custom.error.baseURL.required": "A URL base é obrigatória",
|
||||
"settings.providers.page.custom.error.baseURL.format": "A URL base deve começar com http:// ou https://",
|
||||
"settings.providers.page.custom.error.required": "Obrigatório",
|
||||
"settings.providers.page.custom.error.duplicate": "Duplicado",
|
||||
"settings.providers.page.custom.error.apiKey.required": "É necessária uma chave de API ou {env:VAR_NAME}",
|
||||
"settings.providers.page.custom.authFailure.configAfterAuth": "As credenciais foram salvas, mas a configuração do provedor não. Corrija o erro e tente novamente, ou desconecte para limpar o salvamento parcial.",
|
||||
|
||||
|
||||
"settings.providers.page.auth.title": "Autenticação",
|
||||
"settings.providers.page.auth.loadingMethods": "Carregando métodos de autenticação...",
|
||||
"settings.providers.page.auth.apiKeyLabel": "Chave API",
|
||||
@@ -1309,6 +1360,10 @@ export const settingsDict = {
|
||||
"settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}",
|
||||
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Colar código de autorização",
|
||||
"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",
|
||||
|
||||
|
||||
"settings.providers.page.auth.useReconnectHint": "· Usar Reconnect para atualizar credenciais",
|
||||
"settings.providers.page.connectionDetails.title": "Detalhes de conexão",
|
||||
"settings.providers.page.connectionDetails.configuredIn": "Configuredo en:",
|
||||
@@ -1338,6 +1393,8 @@ export const settingsDict = {
|
||||
"settings.providers.page.actions.complete": "Completar",
|
||||
"settings.providers.page.actions.hide": "Ocultar",
|
||||
"settings.providers.page.actions.reconnect": "Reconectar",
|
||||
"settings.providers.page.actions.edit": "Editar",
|
||||
|
||||
"settings.providers.page.actions.disconnecting": "Desconectando...",
|
||||
"settings.providers.page.actions.disconnect": "Desconectar",
|
||||
"settings.providers.page.actions.hideAll": "Ocultar todo",
|
||||
@@ -1358,6 +1415,8 @@ export const settingsDict = {
|
||||
"settings.providers.page.toast.deviceCodeCopyFailed": "Não foi possível copiar o código de dispositivo",
|
||||
"settings.providers.page.toast.providerDisconnected": "Provedor desconectado",
|
||||
"settings.providers.page.toast.providerDisconnectFailed": "Não foi possível desconectar o provedor",
|
||||
"settings.providers.page.toast.customProviderSaved": "{provider} conectado",
|
||||
"settings.providers.page.toast.customProviderSaveFailed": "Falha ao salvar o provedor personalizado",
|
||||
"settings.mcp.page.empty.selectServer": "Selecione um servidor MCP de o painel lateral",
|
||||
"settings.mcp.page.empty.addNewOne": "o añade um novo",
|
||||
"settings.mcp.page.header.newServer": "Novo servidor MCP",
|
||||
|
||||
@@ -1300,7 +1300,58 @@ export const settingsDict = {
|
||||
"settings.providers.page.connect.selectProviderPlaceholder": "Виберіть провайдера",
|
||||
"settings.providers.page.connect.searchProvidersPlaceholder": "Пошук...",
|
||||
"settings.providers.page.connect.noProvidersFound": "Немає провайдерів",
|
||||
"settings.providers.page.connect.allProvidersConnected": "Усі провайдери підключені.",
|
||||
"settings.providers.page.custom.optionLabel": "Інший / Власний",
|
||||
"settings.providers.page.custom.title": "Власний провайдер",
|
||||
"settings.providers.page.custom.editTitle": "Редагувати власного провайдера",
|
||||
|
||||
"settings.providers.page.custom.description": "Додайте OpenAI-сумісного провайдера з базовою URL-адресою, обліковими даними та списком моделей. Зберігається в конфігурації OpenCode й працює в чаті як будь-який інший провайдер.",
|
||||
"settings.providers.page.custom.field.providerID.label": "ID провайдера",
|
||||
"settings.providers.page.custom.field.providerID.placeholder": "mij-provider",
|
||||
"settings.providers.page.custom.field.providerID.info": "Малі літери, цифри, дефіси та підкреслення. Використовується як ID провайдера OpenCode.",
|
||||
"settings.providers.page.custom.field.name.label": "Відображувана назва",
|
||||
"settings.providers.page.custom.field.name.placeholder": "Мій провайдер",
|
||||
"settings.providers.page.custom.field.name.info": "Показується у виборі провайдера та моделі.",
|
||||
"settings.providers.page.custom.field.baseURL.label": "Базова URL-адреса",
|
||||
"settings.providers.page.custom.field.baseURL.placeholder": "https://api.example.com/v1",
|
||||
"settings.providers.page.custom.field.baseURL.info": "Базова URL-адреса OpenAI-сумісного API. Має починатися з http:// або https://.",
|
||||
"settings.providers.page.custom.field.apiKey.label": "API-ключ",
|
||||
"settings.providers.page.custom.field.apiKey.placeholder": "sk-... або {env:VAR_NAME}",
|
||||
"settings.providers.page.custom.field.apiKey.info": "Зберігається в автентифікації OpenCode, не OpenChamber. Використовуйте {env:VAR_NAME}, щоб читати ключ зі змінної середовища.",
|
||||
"settings.providers.page.custom.field.apiKey.editInfo": "Залиште порожнім, щоб зберегти наявні облікові дані, або введіть новий ключ / {env:VAR_NAME}.",
|
||||
"settings.providers.page.custom.field.apiKey.editPlaceholder": "Залиште порожнім, щоб зберегти наявний ключ",
|
||||
|
||||
|
||||
"settings.providers.page.custom.models.title": "Моделі",
|
||||
"settings.providers.page.custom.models.idLabel": "ID моделі",
|
||||
"settings.providers.page.custom.models.idPlaceholder": "gpt-4o",
|
||||
"settings.providers.page.custom.models.nameLabel": "Назва моделі",
|
||||
"settings.providers.page.custom.models.namePlaceholder": "GPT-4o",
|
||||
"settings.providers.page.custom.models.add": "Додати модель",
|
||||
"settings.providers.page.custom.models.remove": "Видалити модель",
|
||||
"settings.providers.page.custom.headers.title": "Заголовки",
|
||||
"settings.providers.page.custom.headers.description": "Необов’язкові заголовки запиту, що надсилаються з кожним викликом.",
|
||||
"settings.providers.page.custom.headers.keyLabel": "Назва заголовка",
|
||||
"settings.providers.page.custom.headers.keyPlaceholder": "X-Custom-Header",
|
||||
"settings.providers.page.custom.headers.valueLabel": "Значення заголовка",
|
||||
"settings.providers.page.custom.headers.valuePlaceholder": "значення",
|
||||
"settings.providers.page.custom.headers.add": "Додати заголовок",
|
||||
"settings.providers.page.custom.headers.remove": "Видалити заголовок",
|
||||
"settings.providers.page.custom.actions.back": "Назад",
|
||||
"settings.providers.page.custom.actions.save": "Зберегти провайдера",
|
||||
"settings.providers.page.custom.actions.update": "Оновити провайдера",
|
||||
|
||||
"settings.providers.page.custom.error.providerID.required": "ID провайдера обов’язковий",
|
||||
"settings.providers.page.custom.error.providerID.format": "Використовуйте малі літери, цифри, дефіси або підкреслення",
|
||||
"settings.providers.page.custom.error.providerID.exists": "Провайдер із цим ID уже підключено",
|
||||
"settings.providers.page.custom.error.name.required": "Відображувана назва обов’язкова",
|
||||
"settings.providers.page.custom.error.baseURL.required": "Базова URL-адреса обов’язкова",
|
||||
"settings.providers.page.custom.error.baseURL.format": "Базова URL-адреса має починатися з http:// або https://",
|
||||
"settings.providers.page.custom.error.required": "Обов’язково",
|
||||
"settings.providers.page.custom.error.duplicate": "Дублікат",
|
||||
"settings.providers.page.custom.error.apiKey.required": "Потрібен API-ключ або {env:VAR_NAME}",
|
||||
"settings.providers.page.custom.authFailure.configAfterAuth": "Облікові дані збережено, але конфігурацію провайдера — ні. Виправте помилку й спробуйте знову або від’єднайте, щоб очистити часткове збереження.",
|
||||
|
||||
|
||||
"settings.providers.page.auth.title": "Аутентифікація",
|
||||
"settings.providers.page.auth.loadingMethods": "Завантаження методів автентифікації...",
|
||||
"settings.providers.page.auth.apiKeyLabel": "API ключ",
|
||||
@@ -1309,6 +1360,10 @@ export const settingsDict = {
|
||||
"settings.providers.page.auth.oauthMethodFallback": "OAuth метод {index}",
|
||||
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Вставити код авторизації",
|
||||
"settings.providers.page.auth.connected": "Підключено",
|
||||
"settings.providers.page.auth.incomplete": "Облікові дані відсутні",
|
||||
"settings.providers.page.auth.incompleteHint": "· Додайте API-ключ або {env:VAR} перед використанням цього провайдера в чаті",
|
||||
|
||||
|
||||
"settings.providers.page.auth.useReconnectHint": "· Скористайтеся повторним підключенням, щоб оновити облікові дані",
|
||||
"settings.providers.page.connectionDetails.title": "Деталі підключення",
|
||||
"settings.providers.page.connectionDetails.configuredIn": "Налаштовано в:",
|
||||
@@ -1338,6 +1393,8 @@ export const settingsDict = {
|
||||
"settings.providers.page.actions.complete": "Завершити",
|
||||
"settings.providers.page.actions.hide": "Сховати",
|
||||
"settings.providers.page.actions.reconnect": "Перепідключити",
|
||||
"settings.providers.page.actions.edit": "Редагувати",
|
||||
|
||||
"settings.providers.page.actions.disconnecting": "Відключення...",
|
||||
"settings.providers.page.actions.disconnect": "Відключити",
|
||||
"settings.providers.page.actions.hideAll": "Сховати все",
|
||||
@@ -1358,6 +1415,8 @@ export const settingsDict = {
|
||||
"settings.providers.page.toast.deviceCodeCopyFailed": "Не вдалося скопіювати код пристрою",
|
||||
"settings.providers.page.toast.providerDisconnected": "Провайдера відключено",
|
||||
"settings.providers.page.toast.providerDisconnectFailed": "Не вдалося відключити провайдера",
|
||||
"settings.providers.page.toast.customProviderSaved": "{provider} підключено",
|
||||
"settings.providers.page.toast.customProviderSaveFailed": "Не вдалося зберегти власного провайдера",
|
||||
"settings.mcp.page.empty.selectServer": "Виберіть MCP сервер на бічній панелі",
|
||||
"settings.mcp.page.empty.addNewOne": "або додати новий",
|
||||
"settings.mcp.page.header.newServer": "Новий сервер MCP",
|
||||
|
||||
@@ -1300,7 +1300,58 @@ export const settingsDict = {
|
||||
'settings.providers.page.connect.selectProviderPlaceholder': '选择提供商',
|
||||
'settings.providers.page.connect.searchProvidersPlaceholder': '搜索...',
|
||||
'settings.providers.page.connect.noProvidersFound': '未找到提供商',
|
||||
'settings.providers.page.connect.allProvidersConnected': '所有提供商均已连接。',
|
||||
'settings.providers.page.custom.optionLabel': '其他 / 自定义',
|
||||
'settings.providers.page.custom.title': '自定义提供商',
|
||||
'settings.providers.page.custom.editTitle': '编辑自定义提供商',
|
||||
|
||||
'settings.providers.page.custom.description': '通过指定基础 URL、凭据和模型列表,添加兼容 OpenAI 的提供商。会写入 OpenCode 配置,可像其他提供商一样在聊天中使用。',
|
||||
'settings.providers.page.custom.field.providerID.label': '提供商 ID',
|
||||
'settings.providers.page.custom.field.providerID.placeholder': 'my-provider',
|
||||
'settings.providers.page.custom.field.providerID.info': '小写字母、数字、连字符和下划线。用作 OpenCode 提供商 ID。',
|
||||
'settings.providers.page.custom.field.name.label': '显示名称',
|
||||
'settings.providers.page.custom.field.name.placeholder': '我的提供商',
|
||||
'settings.providers.page.custom.field.name.info': '显示在提供商和模型选择器中。',
|
||||
'settings.providers.page.custom.field.baseURL.label': '基础 URL',
|
||||
'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1',
|
||||
'settings.providers.page.custom.field.baseURL.info': '兼容 OpenAI 的 API 基础 URL。必须以 http:// 或 https:// 开头。',
|
||||
'settings.providers.page.custom.field.apiKey.label': 'API 密钥',
|
||||
'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... 或 {env:VAR_NAME}',
|
||||
'settings.providers.page.custom.field.apiKey.info': '保存在 OpenCode 认证中,而非 OpenChamber。使用 {env:VAR_NAME} 可从环境变量读取密钥。',
|
||||
'settings.providers.page.custom.field.apiKey.editInfo': '留空以保留现有凭据,或输入新密钥 / {env:VAR_NAME}。',
|
||||
'settings.providers.page.custom.field.apiKey.editPlaceholder': '留空以保留现有密钥',
|
||||
|
||||
|
||||
'settings.providers.page.custom.models.title': '模型',
|
||||
'settings.providers.page.custom.models.idLabel': '模型 ID',
|
||||
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
|
||||
'settings.providers.page.custom.models.nameLabel': '模型名称',
|
||||
'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o',
|
||||
'settings.providers.page.custom.models.add': '添加模型',
|
||||
'settings.providers.page.custom.models.remove': '移除模型',
|
||||
'settings.providers.page.custom.headers.title': '请求头',
|
||||
'settings.providers.page.custom.headers.description': '每次调用可选发送的请求头。',
|
||||
'settings.providers.page.custom.headers.keyLabel': '请求头名称',
|
||||
'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header',
|
||||
'settings.providers.page.custom.headers.valueLabel': '请求头值',
|
||||
'settings.providers.page.custom.headers.valuePlaceholder': 'value',
|
||||
'settings.providers.page.custom.headers.add': '添加请求头',
|
||||
'settings.providers.page.custom.headers.remove': '移除请求头',
|
||||
'settings.providers.page.custom.actions.back': '返回',
|
||||
'settings.providers.page.custom.actions.save': '保存提供商',
|
||||
'settings.providers.page.custom.actions.update': '更新提供商',
|
||||
|
||||
'settings.providers.page.custom.error.providerID.required': '提供商 ID 为必填项',
|
||||
'settings.providers.page.custom.error.providerID.format': '请使用小写字母、数字、连字符或下划线',
|
||||
'settings.providers.page.custom.error.providerID.exists': '已连接具有此 ID 的提供商',
|
||||
'settings.providers.page.custom.error.name.required': '显示名称为必填项',
|
||||
'settings.providers.page.custom.error.baseURL.required': '基础 URL 为必填项',
|
||||
'settings.providers.page.custom.error.baseURL.format': '基础 URL 必须以 http:// 或 https:// 开头',
|
||||
'settings.providers.page.custom.error.required': '必填',
|
||||
'settings.providers.page.custom.error.duplicate': '重复',
|
||||
'settings.providers.page.custom.error.apiKey.required': '需要 API 密钥或 {env:VAR_NAME}',
|
||||
'settings.providers.page.custom.authFailure.configAfterAuth': '凭据已保存,但提供商配置未保存。请修复错误后重试,或断开连接以清除部分保存。',
|
||||
|
||||
|
||||
'settings.providers.page.auth.title': '认证',
|
||||
'settings.providers.page.auth.loadingMethods': '正在加载认证方式...',
|
||||
'settings.providers.page.auth.apiKeyLabel': 'API Key',
|
||||
@@ -1309,6 +1360,10 @@ export const settingsDict = {
|
||||
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方式 {index}',
|
||||
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '粘贴授权码',
|
||||
'settings.providers.page.auth.connected': '已连接',
|
||||
'settings.providers.page.auth.incomplete': '缺少凭据',
|
||||
'settings.providers.page.auth.incompleteHint': '· 在聊天中使用此提供商之前,请添加 API 密钥或 {env:VAR}',
|
||||
|
||||
|
||||
'settings.providers.page.auth.useReconnectHint': '· 使用“重新连接”以更新凭据',
|
||||
'settings.providers.page.connectionDetails.title': '连接详情',
|
||||
'settings.providers.page.connectionDetails.configuredIn': '配置来源:',
|
||||
@@ -1338,6 +1393,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.actions.complete': '完成',
|
||||
'settings.providers.page.actions.hide': '隐藏',
|
||||
'settings.providers.page.actions.reconnect': '重新连接',
|
||||
'settings.providers.page.actions.edit': '编辑',
|
||||
|
||||
'settings.providers.page.actions.disconnecting': '断开连接中...',
|
||||
'settings.providers.page.actions.disconnect': '断开连接',
|
||||
'settings.providers.page.actions.hideAll': '全部隐藏',
|
||||
@@ -1358,6 +1415,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.toast.deviceCodeCopyFailed': '复制设备代码失败',
|
||||
'settings.providers.page.toast.providerDisconnected': '提供商已断开连接',
|
||||
'settings.providers.page.toast.providerDisconnectFailed': '断开提供商连接失败',
|
||||
'settings.providers.page.toast.customProviderSaved': '已连接 {provider}',
|
||||
'settings.providers.page.toast.customProviderSaveFailed': '保存自定义提供商失败',
|
||||
'settings.mcp.page.empty.selectServer': '请从侧边栏选择一个 MCP 服务器',
|
||||
'settings.mcp.page.empty.addNewOne': '或添加一个新的',
|
||||
'settings.mcp.page.header.newServer': '新建 MCP 服务器',
|
||||
|
||||
@@ -1206,7 +1206,58 @@
|
||||
'settings.providers.page.connect.selectProviderPlaceholder': '選擇供應商',
|
||||
'settings.providers.page.connect.searchProvidersPlaceholder': '搜尋...',
|
||||
'settings.providers.page.connect.noProvidersFound': '找不到供應商',
|
||||
'settings.providers.page.connect.allProvidersConnected': '所有供應商均已連線。',
|
||||
'settings.providers.page.custom.optionLabel': '其他 / 自訂',
|
||||
'settings.providers.page.custom.title': '自訂供應商',
|
||||
'settings.providers.page.custom.editTitle': '編輯自訂提供者',
|
||||
|
||||
'settings.providers.page.custom.description': '透過指定基礎 URL、憑證與模型清單,新增相容 OpenAI 的供應商。會寫入 OpenCode 設定,可像其他供應商一樣在聊天中使用。',
|
||||
'settings.providers.page.custom.field.providerID.label': '供應商 ID',
|
||||
'settings.providers.page.custom.field.providerID.placeholder': 'my-provider',
|
||||
'settings.providers.page.custom.field.providerID.info': '小寫字母、數字、連字號與底線。用作 OpenCode 供應商 ID。',
|
||||
'settings.providers.page.custom.field.name.label': '顯示名稱',
|
||||
'settings.providers.page.custom.field.name.placeholder': '我的供應商',
|
||||
'settings.providers.page.custom.field.name.info': '顯示於供應商與模型選擇器。',
|
||||
'settings.providers.page.custom.field.baseURL.label': '基礎 URL',
|
||||
'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1',
|
||||
'settings.providers.page.custom.field.baseURL.info': '相容 OpenAI 的 API 基礎 URL。必須以 http:// 或 https:// 開頭。',
|
||||
'settings.providers.page.custom.field.apiKey.label': 'API 金鑰',
|
||||
'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... 或 {env:VAR_NAME}',
|
||||
'settings.providers.page.custom.field.apiKey.info': '儲存在 OpenCode 驗證中,而非 OpenChamber。使用 {env:VAR_NAME} 可從環境變數讀取金鑰。',
|
||||
'settings.providers.page.custom.field.apiKey.editInfo': '留空以保留現有憑證,或輸入新金鑰 / {env:VAR_NAME}。',
|
||||
'settings.providers.page.custom.field.apiKey.editPlaceholder': '留空以保留現有金鑰',
|
||||
|
||||
|
||||
'settings.providers.page.custom.models.title': '模型',
|
||||
'settings.providers.page.custom.models.idLabel': '模型 ID',
|
||||
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
|
||||
'settings.providers.page.custom.models.nameLabel': '模型名稱',
|
||||
'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o',
|
||||
'settings.providers.page.custom.models.add': '新增模型',
|
||||
'settings.providers.page.custom.models.remove': '移除模型',
|
||||
'settings.providers.page.custom.headers.title': '標頭',
|
||||
'settings.providers.page.custom.headers.description': '每次呼叫可選擇傳送的請求標頭。',
|
||||
'settings.providers.page.custom.headers.keyLabel': '標頭名稱',
|
||||
'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header',
|
||||
'settings.providers.page.custom.headers.valueLabel': '標頭值',
|
||||
'settings.providers.page.custom.headers.valuePlaceholder': 'value',
|
||||
'settings.providers.page.custom.headers.add': '新增標頭',
|
||||
'settings.providers.page.custom.headers.remove': '移除標頭',
|
||||
'settings.providers.page.custom.actions.back': '返回',
|
||||
'settings.providers.page.custom.actions.save': '儲存供應商',
|
||||
'settings.providers.page.custom.actions.update': '更新提供者',
|
||||
|
||||
'settings.providers.page.custom.error.providerID.required': '供應商 ID 為必填',
|
||||
'settings.providers.page.custom.error.providerID.format': '請使用小寫字母、數字、連字號或底線',
|
||||
'settings.providers.page.custom.error.providerID.exists': '已連線具有此 ID 的供應商',
|
||||
'settings.providers.page.custom.error.name.required': '顯示名稱為必填',
|
||||
'settings.providers.page.custom.error.baseURL.required': '基礎 URL 為必填',
|
||||
'settings.providers.page.custom.error.baseURL.format': '基礎 URL 必須以 http:// 或 https:// 開頭',
|
||||
'settings.providers.page.custom.error.required': '必填',
|
||||
'settings.providers.page.custom.error.duplicate': '重複',
|
||||
'settings.providers.page.custom.error.apiKey.required': '需要 API 金鑰或 {env:VAR_NAME}',
|
||||
'settings.providers.page.custom.authFailure.configAfterAuth': '憑證已儲存,但提供者設定未儲存。請修正錯誤後再試,或中斷連線以清除部分儲存。',
|
||||
|
||||
|
||||
'settings.providers.page.auth.title': '驗證',
|
||||
'settings.providers.page.auth.loadingMethods': '正在載入驗證方式...',
|
||||
'settings.providers.page.auth.apiKeyLabel': 'API Key',
|
||||
@@ -1215,6 +1266,10 @@
|
||||
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方式 {index}',
|
||||
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '貼上授權碼',
|
||||
'settings.providers.page.auth.connected': '已連線',
|
||||
'settings.providers.page.auth.incomplete': '缺少憑證',
|
||||
'settings.providers.page.auth.incompleteHint': '· 在聊天中使用此提供者之前,請新增 API 金鑰或 {env:VAR}',
|
||||
|
||||
|
||||
'settings.providers.page.auth.useReconnectHint': '· 使用「重新連線」以更新憑證',
|
||||
'settings.providers.page.connectionDetails.title': '連線詳情',
|
||||
'settings.providers.page.connectionDetails.configuredIn': '設定來源:',
|
||||
@@ -1244,6 +1299,8 @@
|
||||
'settings.providers.page.actions.complete': '完成',
|
||||
'settings.providers.page.actions.hide': '隱藏',
|
||||
'settings.providers.page.actions.reconnect': '重新連線',
|
||||
'settings.providers.page.actions.edit': '編輯',
|
||||
|
||||
'settings.providers.page.actions.disconnecting': '中斷連線中...',
|
||||
'settings.providers.page.actions.disconnect': '中斷連線',
|
||||
'settings.providers.page.actions.hideAll': '全部隱藏',
|
||||
@@ -1264,6 +1321,8 @@
|
||||
'settings.providers.page.toast.deviceCodeCopyFailed': '複製裝置程式碼失敗',
|
||||
'settings.providers.page.toast.providerDisconnected': '供應商已中斷連線',
|
||||
'settings.providers.page.toast.providerDisconnectFailed': '中斷供應商連線失敗',
|
||||
'settings.providers.page.toast.customProviderSaved': '已連線 {provider}',
|
||||
'settings.providers.page.toast.customProviderSaveFailed': '無法儲存自訂供應商',
|
||||
'settings.mcp.page.empty.selectServer': '請從側邊欄選擇一個 MCP 伺服器',
|
||||
'settings.mcp.page.empty.addNewOne': '或新增一個新的',
|
||||
'settings.mcp.page.header.newServer': '新建 MCP 伺服器',
|
||||
|
||||
@@ -761,6 +761,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
titleKey: 'settings.providers.page.connect.title',
|
||||
keywords: ['add provider', 'connect provider', 'credentials'],
|
||||
},
|
||||
{
|
||||
id: 'providers.custom',
|
||||
page: 'providers',
|
||||
titleKey: 'settings.providers.page.custom.title',
|
||||
descriptionKey: 'settings.providers.page.custom.description',
|
||||
keywords: ['other', 'custom', 'openai-compatible', 'base url', 'api key'],
|
||||
},
|
||||
{
|
||||
id: 'providers.auth',
|
||||
page: 'providers',
|
||||
|
||||
@@ -61,6 +61,7 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r
|
||||
- Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`).
|
||||
- Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`).
|
||||
- Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade.
|
||||
- Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI-compatible config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API).
|
||||
|
||||
- `opencode-upgrade-runtime.ts`
|
||||
- Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior.
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { removeProviderConfig, getProviderSources } from './opencodeConfig';
|
||||
import { removeProviderConfig, getProviderSources, upsertProviderConfig } from './opencodeConfig';
|
||||
import { getProviderAuth, removeProviderAuth } from './opencodeAuth';
|
||||
import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders';
|
||||
import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
|
||||
@@ -485,6 +485,64 @@ export async function handleSystemBridgeMessage(
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:provider:upsert': {
|
||||
const {
|
||||
providerID,
|
||||
providerId: providerIdAlias,
|
||||
config,
|
||||
scope,
|
||||
directory,
|
||||
} = (payload || {}) as {
|
||||
providerID?: string;
|
||||
providerId?: string;
|
||||
config?: unknown;
|
||||
scope?: string;
|
||||
directory?: string;
|
||||
};
|
||||
const providerId = (typeof providerID === 'string' && providerID.trim())
|
||||
|| (typeof providerIdAlias === 'string' && providerIdAlias.trim())
|
||||
|| '';
|
||||
if (!providerId) {
|
||||
return { id, type, success: false, error: 'Provider ID is required' };
|
||||
}
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
||||
return { id, type, success: false, error: 'Provider config is required' };
|
||||
}
|
||||
const normalizedScope = typeof scope === 'string' ? scope : 'user';
|
||||
if (normalizedScope !== 'user' && normalizedScope !== 'project' && normalizedScope !== 'custom') {
|
||||
return { id, type, success: false, error: 'Invalid scope' };
|
||||
}
|
||||
try {
|
||||
const workingDirectory = typeof directory === 'string' && directory.trim().length > 0
|
||||
? directory.trim()
|
||||
: ctx?.manager?.getWorkingDirectory();
|
||||
const result = upsertProviderConfig(
|
||||
providerId,
|
||||
config,
|
||||
workingDirectory,
|
||||
normalizedScope,
|
||||
{ hasStoredAuth: Boolean(getProviderAuth(providerId)) },
|
||||
);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
providerId: result.providerId,
|
||||
path: result.path,
|
||||
config: result.config,
|
||||
requiresReload: true,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:quota:providers': {
|
||||
try {
|
||||
const providers = listConfiguredQuotaProviders();
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { afterEach, beforeEach, describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
upsertProviderConfig,
|
||||
validateCustomProviderConfig,
|
||||
} from './opencodeConfig';
|
||||
|
||||
let projectDir: string;
|
||||
|
||||
const writeJson = (filePath: string, value: unknown) => {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
||||
};
|
||||
|
||||
const readJson = (filePath: string) => JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
|
||||
describe('custom provider config persistence (VS Code parity)', () => {
|
||||
beforeEach(() => {
|
||||
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-provider-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('validateCustomProviderConfig rejects invalid endpoint and credentials shape', () => {
|
||||
assert.equal(validateCustomProviderConfig('Bad Id', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).ok, false);
|
||||
|
||||
const ftp = validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'ftp://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
});
|
||||
assert.equal(ftp.ok, false);
|
||||
assert.match(ftp.error ?? '', /http:\/\//);
|
||||
|
||||
assert.equal(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: {},
|
||||
}).ok, false);
|
||||
});
|
||||
|
||||
test('validateCustomProviderConfig rejects missing credentials', () => {
|
||||
assert.equal(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).ok, false);
|
||||
|
||||
assert.equal(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, { hasStoredAuth: true }).ok, true);
|
||||
|
||||
assert.equal(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
env: ['MY_KEY'],
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).ok, true);
|
||||
});
|
||||
|
||||
test('upsertProviderConfig writes and round-trips project config', () => {
|
||||
const result = upsertProviderConfig('campus-llm', {
|
||||
name: 'Campus LLM',
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
options: {
|
||||
baseURL: 'https://llm.example.edu/v1',
|
||||
headers: { 'X-Campus': '1' },
|
||||
},
|
||||
models: {
|
||||
'fast-model': { name: 'Fast' },
|
||||
},
|
||||
env: ['CAMPUS_KEY'],
|
||||
}, projectDir, 'project');
|
||||
|
||||
assert.equal(result.providerId, 'campus-llm');
|
||||
assert.equal(fs.existsSync(result.path), true);
|
||||
assert.equal(result.path.startsWith(projectDir), true);
|
||||
|
||||
const written = readJson(result.path);
|
||||
assert.deepEqual(written.provider['campus-llm'], {
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
name: 'Campus LLM',
|
||||
env: ['CAMPUS_KEY'],
|
||||
options: {
|
||||
baseURL: 'https://llm.example.edu/v1',
|
||||
headers: { 'X-Campus': '1' },
|
||||
},
|
||||
models: {
|
||||
'fast-model': { name: 'Fast' },
|
||||
},
|
||||
});
|
||||
|
||||
const sources = getProviderSources('campus-llm', projectDir);
|
||||
assert.equal(sources.project.exists, true);
|
||||
assert.equal(sources.project.path, result.path);
|
||||
});
|
||||
|
||||
test('upsertProviderConfig updates existing entry and clears disabled_providers', () => {
|
||||
const configPath = path.join(projectDir, 'opencode.json');
|
||||
writeJson(configPath, {
|
||||
provider: {
|
||||
'campus-llm': {
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
name: 'Old',
|
||||
options: { baseURL: 'https://old.example.edu/v1' },
|
||||
models: { a: { name: 'A' } },
|
||||
},
|
||||
},
|
||||
disabled_providers: ['campus-llm', 'other'],
|
||||
});
|
||||
|
||||
upsertProviderConfig('campus-llm', {
|
||||
name: 'Campus LLM',
|
||||
options: { baseURL: 'https://llm.example.edu/v1' },
|
||||
models: { b: { name: 'B' } },
|
||||
env: ['CAMPUS_KEY'],
|
||||
}, projectDir, 'project');
|
||||
|
||||
const written = readJson(configPath);
|
||||
assert.equal(written.provider['campus-llm'].name, 'Campus LLM');
|
||||
assert.deepEqual(written.provider['campus-llm'].models, { b: { name: 'B' } });
|
||||
assert.deepEqual(written.disabled_providers, ['other']);
|
||||
});
|
||||
|
||||
test('upsert then remove restores absence', () => {
|
||||
upsertProviderConfig('temp-provider', {
|
||||
name: 'Temp',
|
||||
options: { baseURL: 'https://api.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
env: ['TEMP_KEY'],
|
||||
}, projectDir, 'project');
|
||||
|
||||
assert.equal(getProviderSources('temp-provider', projectDir).project.exists, true);
|
||||
assert.equal(removeProviderConfig('temp-provider', projectDir, 'project'), true);
|
||||
assert.equal(getProviderSources('temp-provider', projectDir).project.exists, false);
|
||||
});
|
||||
|
||||
test('failed validation does not write config', () => {
|
||||
const configPath = path.join(projectDir, 'opencode.json');
|
||||
assert.throws(
|
||||
() => upsertProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'not-a-url' },
|
||||
models: { m: { name: 'M' } },
|
||||
env: ['X'],
|
||||
}, projectDir, 'project'),
|
||||
/Base URL/,
|
||||
);
|
||||
assert.equal(fs.existsSync(configPath), false);
|
||||
});
|
||||
|
||||
test('upsert with hasStoredAuth allows config without env', () => {
|
||||
const result = upsertProviderConfig('keyed-provider', {
|
||||
name: 'Keyed',
|
||||
options: { baseURL: 'https://api.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, projectDir, 'project', { hasStoredAuth: true });
|
||||
|
||||
assert.equal(result.providerId, 'keyed-provider');
|
||||
assert.equal(result.config.env, undefined);
|
||||
});
|
||||
|
||||
test('project-scope edit updates project layer without creating a user entry', () => {
|
||||
const providerId = `proj-scope-${Date.now()}`;
|
||||
const configPath = path.join(projectDir, 'opencode.json');
|
||||
|
||||
upsertProviderConfig(providerId, {
|
||||
name: 'Project Scoped',
|
||||
options: { baseURL: 'https://project.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, projectDir, 'project', { hasStoredAuth: true });
|
||||
|
||||
upsertProviderConfig(providerId, {
|
||||
name: 'Project Scoped Updated',
|
||||
options: { baseURL: 'https://project.example.com/v2', headers: { 'X-Project': '1' } },
|
||||
models: { m: { name: 'M2' } },
|
||||
}, projectDir, 'project', { hasStoredAuth: true });
|
||||
|
||||
const written = readJson(configPath);
|
||||
assert.deepEqual(written.provider[providerId], {
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
name: 'Project Scoped Updated',
|
||||
options: {
|
||||
baseURL: 'https://project.example.com/v2',
|
||||
headers: { 'X-Project': '1' },
|
||||
},
|
||||
models: { m: { name: 'M2' } },
|
||||
});
|
||||
|
||||
const sources = getProviderSources(providerId, projectDir);
|
||||
assert.equal(sources.project.exists, true);
|
||||
assert.equal(sources.user.exists, false);
|
||||
assert.equal(sources.custom.exists, false);
|
||||
|
||||
for (const userPath of [
|
||||
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
|
||||
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
|
||||
]) {
|
||||
if (!fs.existsSync(userPath)) continue;
|
||||
const userConfig = readJson(userPath);
|
||||
assert.equal(userConfig.provider?.[providerId], undefined);
|
||||
assert.equal(userConfig.providers?.[providerId], undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('custom-scope edit updates custom layer without creating a user entry', () => {
|
||||
const providerId = `custom-scope-${Date.now()}`;
|
||||
const customPath = path.join(projectDir, 'custom-opencode.json');
|
||||
const previousEnv = process.env.OPENCODE_CONFIG;
|
||||
process.env.OPENCODE_CONFIG = customPath;
|
||||
|
||||
try {
|
||||
upsertProviderConfig(providerId, {
|
||||
name: 'Custom Scoped',
|
||||
options: { baseURL: 'https://custom.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, projectDir, 'custom', { hasStoredAuth: true });
|
||||
|
||||
upsertProviderConfig(providerId, {
|
||||
name: 'Custom Scoped Updated',
|
||||
options: { baseURL: 'https://custom.example.com/v2' },
|
||||
models: { n: { name: 'N' } },
|
||||
}, projectDir, 'custom', { hasStoredAuth: true });
|
||||
|
||||
const written = readJson(customPath);
|
||||
assert.equal(written.provider[providerId].name, 'Custom Scoped Updated');
|
||||
assert.equal(written.provider[providerId].options.baseURL, 'https://custom.example.com/v2');
|
||||
|
||||
const sources = getProviderSources(providerId, projectDir);
|
||||
assert.equal(sources.custom.exists, true);
|
||||
assert.equal(sources.user.exists, false);
|
||||
assert.equal(sources.project.exists, false);
|
||||
|
||||
for (const userPath of [
|
||||
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
|
||||
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
|
||||
]) {
|
||||
if (!fs.existsSync(userPath)) continue;
|
||||
const userConfig = readJson(userPath);
|
||||
assert.equal(userConfig.provider?.[providerId], undefined);
|
||||
assert.equal(userConfig.providers?.[providerId], undefined);
|
||||
}
|
||||
} finally {
|
||||
if (previousEnv === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG;
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG = previousEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,9 +10,6 @@ const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
|
||||
const GLOBAL_SNIPPET_DIR = path.join(OPENCODE_CONFIG_DIR, 'snippet');
|
||||
const GLOBAL_SNIPPET_DIR_ALT = path.join(OPENCODE_CONFIG_DIR, 'snippets');
|
||||
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json');
|
||||
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
|
||||
? path.resolve(process.env.OPENCODE_CONFIG)
|
||||
: null;
|
||||
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
|
||||
const SNIPPET_EXTENSION = '.md';
|
||||
const SNIPPET_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,79}$/i;
|
||||
@@ -541,7 +538,10 @@ const getConfigPaths = (workingDirectory?: string) => ({
|
||||
path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'),
|
||||
],
|
||||
projectPath: getProjectConfigPath(workingDirectory),
|
||||
customPath: CUSTOM_CONFIG_FILE
|
||||
// Resolve at call time so OPENCODE_CONFIG changes (and tests) take effect.
|
||||
customPath: process.env.OPENCODE_CONFIG
|
||||
? path.resolve(process.env.OPENCODE_CONFIG)
|
||||
: null,
|
||||
});
|
||||
|
||||
const getPrimaryUserConfigPath = (userPaths: string[]): string => {
|
||||
@@ -2168,6 +2168,163 @@ export const removeProviderConfig = (providerId: string, workingDirectory?: stri
|
||||
return true;
|
||||
};
|
||||
|
||||
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
|
||||
const BASE_URL_PATTERN = /^https?:\/\//;
|
||||
const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible';
|
||||
|
||||
export const validateCustomProviderConfig = (
|
||||
providerId: string,
|
||||
config: unknown,
|
||||
options: { hasStoredAuth?: boolean } = {},
|
||||
) => {
|
||||
if (!providerId || typeof providerId !== 'string' || !PROVIDER_ID_PATTERN.test(providerId)) {
|
||||
return { ok: false as const, error: 'Provider ID must match /^[a-z0-9][a-z0-9-_]*$/' };
|
||||
}
|
||||
|
||||
if (!isPlainObject(config)) {
|
||||
return { ok: false as const, error: 'Provider config must be an object' };
|
||||
}
|
||||
|
||||
const name = typeof config.name === 'string' ? config.name.trim() : '';
|
||||
if (!name) {
|
||||
return { ok: false as const, error: 'Provider name is required' };
|
||||
}
|
||||
|
||||
const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM;
|
||||
if (npm !== OPENAI_COMPATIBLE_NPM) {
|
||||
return { ok: false as const, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` };
|
||||
}
|
||||
|
||||
const optionsBlock = isPlainObject(config.options) ? config.options : null;
|
||||
if (!optionsBlock) {
|
||||
return { ok: false as const, error: 'Provider options are required' };
|
||||
}
|
||||
|
||||
const baseURL = typeof optionsBlock.baseURL === 'string' ? optionsBlock.baseURL.trim() : '';
|
||||
if (!baseURL) {
|
||||
return { ok: false as const, error: 'Base URL is required' };
|
||||
}
|
||||
if (!BASE_URL_PATTERN.test(baseURL)) {
|
||||
return { ok: false as const, error: 'Base URL must start with http:// or https://' };
|
||||
}
|
||||
|
||||
const models = isPlainObject(config.models) ? config.models : null;
|
||||
if (!models || Object.keys(models).length === 0) {
|
||||
return { ok: false as const, error: 'At least one model is required' };
|
||||
}
|
||||
|
||||
const normalizedModels: Record<string, { name: string }> = {};
|
||||
for (const [modelId, modelValue] of Object.entries(models)) {
|
||||
const trimmedId = typeof modelId === 'string' ? modelId.trim() : '';
|
||||
if (!trimmedId) {
|
||||
return { ok: false as const, error: 'Model id is required' };
|
||||
}
|
||||
if (!isPlainObject(modelValue)) {
|
||||
return { ok: false as const, error: `Model "${trimmedId}" must be an object` };
|
||||
}
|
||||
const modelName = typeof modelValue.name === 'string' ? modelValue.name.trim() : '';
|
||||
if (!modelName) {
|
||||
return { ok: false as const, error: `Model "${trimmedId}" requires a name` };
|
||||
}
|
||||
normalizedModels[trimmedId] = { name: modelName };
|
||||
}
|
||||
|
||||
const normalized: Record<string, unknown> = {
|
||||
npm: OPENAI_COMPATIBLE_NPM,
|
||||
name,
|
||||
options: {
|
||||
baseURL,
|
||||
},
|
||||
models: normalizedModels,
|
||||
};
|
||||
|
||||
let env: string[] = [];
|
||||
if (Array.isArray(config.env)) {
|
||||
env = config.env
|
||||
.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
||||
.map((entry) => entry.trim());
|
||||
if (env.length > 0) {
|
||||
normalized.env = env;
|
||||
}
|
||||
}
|
||||
|
||||
if (env.length === 0 && !options.hasStoredAuth) {
|
||||
return { ok: false as const, error: 'API key or {env:VAR} credentials are required' };
|
||||
}
|
||||
|
||||
if (isPlainObject(optionsBlock.headers)) {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [headerKey, headerValue] of Object.entries(optionsBlock.headers)) {
|
||||
if (typeof headerKey !== 'string' || !headerKey.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (typeof headerValue !== 'string' || !headerValue.trim()) {
|
||||
return { ok: false as const, error: `Header "${headerKey}" requires a non-empty value` };
|
||||
}
|
||||
headers[headerKey.trim()] = headerValue.trim();
|
||||
}
|
||||
if (Object.keys(headers).length > 0) {
|
||||
(normalized.options as Record<string, unknown>).headers = headers;
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true as const, value: { providerId, config: normalized } };
|
||||
};
|
||||
|
||||
export const upsertProviderConfig = (
|
||||
providerId: string,
|
||||
config: unknown,
|
||||
workingDirectory?: string,
|
||||
scope: 'user' | 'project' | 'custom' = 'user',
|
||||
options: { hasStoredAuth?: boolean } = {},
|
||||
) => {
|
||||
const validated = validateCustomProviderConfig(providerId, config, options);
|
||||
if (!validated.ok) {
|
||||
const error = new Error(validated.error) as Error & { statusCode?: number };
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
let targetPath: string | null | undefined = layers.paths.userPath;
|
||||
|
||||
if (scope === 'project') {
|
||||
if (!workingDirectory) {
|
||||
throw new Error('Working directory is required for project scope');
|
||||
}
|
||||
targetPath = layers.paths.projectPath ?? targetPath;
|
||||
} else if (scope === 'custom') {
|
||||
if (!layers.paths.customPath) {
|
||||
throw new Error('Custom config path (OPENCODE_CONFIG) is not set');
|
||||
}
|
||||
targetPath = layers.paths.customPath;
|
||||
} else if (scope !== 'user') {
|
||||
throw new Error('Invalid scope');
|
||||
}
|
||||
|
||||
const targetConfig = getConfigForPath(layers, targetPath) as Record<string, unknown>;
|
||||
const providerConfig = isPlainObject(targetConfig.provider)
|
||||
? { ...(targetConfig.provider as Record<string, unknown>) }
|
||||
: {};
|
||||
providerConfig[validated.value.providerId] = validated.value.config;
|
||||
targetConfig.provider = providerConfig;
|
||||
|
||||
if (Array.isArray(targetConfig.disabled_providers)) {
|
||||
targetConfig.disabled_providers = targetConfig.disabled_providers.filter(
|
||||
(entry) => entry !== validated.value.providerId,
|
||||
);
|
||||
}
|
||||
|
||||
const writePath = targetPath || CONFIG_FILE;
|
||||
writeConfig(targetConfig, writePath);
|
||||
|
||||
return {
|
||||
providerId: validated.value.providerId,
|
||||
path: writePath,
|
||||
config: validated.value.config,
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteCommand = (commandName: string, workingDirectory?: string) => {
|
||||
let deleted = false;
|
||||
|
||||
|
||||
@@ -1118,6 +1118,30 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
|
||||
}
|
||||
}
|
||||
|
||||
// Handle custom provider upsert: PUT /api/provider
|
||||
if (pathname === '/api/provider' && method === 'PUT') {
|
||||
try {
|
||||
const body = await extractJsonBody(input, init, method);
|
||||
const queryDirectory = url.searchParams.get('directory') || undefined;
|
||||
const data = await sendBridgeMessage('api:provider:upsert', {
|
||||
...(body && typeof body === 'object' ? body : {}),
|
||||
directory: queryDirectory
|
||||
?? (body && typeof body === 'object' && typeof body.directory === 'string' ? body.directory : undefined),
|
||||
});
|
||||
if (data && typeof data === 'object' && 'success' in data && (data as { success?: boolean }).success === false) {
|
||||
const message = (data as { error?: string }).error || 'Failed to save provider config';
|
||||
return new Response(JSON.stringify({ error: message }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
return new Response(JSON.stringify((data as { data?: unknown })?.data ?? data), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -57,8 +57,14 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `AUTH_FILE`: Auth file path constant.
|
||||
- `OPENCODE_DATA_DIR`: OpenCode data directory path constant.
|
||||
|
||||
## Public exports (providers.js)
|
||||
- `getProviderSources(providerId, workingDirectory)`: Resolves which OpenCode config layers define a provider.
|
||||
- `upsertProviderConfig(providerId, config, workingDirectory, scope?, options?)`: Validates and writes a custom OpenAI-compatible provider block (`npm`, `name`, `options.baseURL`, `models`, optional `env`/`headers`) into the user/project/custom config layer. Does not store API keys. Requires `config.env` or `options.hasStoredAuth` (auth already written via OpenCode `auth.set`). Edit flows must pass the provider's effective existing layer (`custom` > `project` > `user`) so updates do not create a global user override.
|
||||
- `validateCustomProviderConfig(providerId, config, options?)`: Structural validation for custom provider payloads (id format, http(s) base URL, models, credentials via `env` or `hasStoredAuth`).
|
||||
- `removeProviderConfig(providerId, workingDirectory, scope?)`: Removes a provider block from the selected config layer.
|
||||
|
||||
## Public exports (shared.js)
|
||||
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`, `CUSTOM_CONFIG_FILE`: Path constants.
|
||||
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`: Path constants. `OPENCODE_CONFIG` is resolved at call time for the custom config layer path.
|
||||
- `AGENT_SCOPE`, `COMMAND_SCOPE`, `SKILL_SCOPE`: Scope constants with USER and PROJECT values.
|
||||
- `ensureDirs()`: Creates required OpenCode directories.
|
||||
- `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter.
|
||||
@@ -82,6 +88,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `GET /api/opencode/upgrade-status` (returns version availability plus the authoritative `upgrade.supported`, `upgrade.manager`, and `upgrade.reason` capability)
|
||||
- `POST /api/opencode/directory`
|
||||
- `GET /api/provider/:providerId/source`
|
||||
- `PUT /api/provider` (create/update custom OpenAI-compatible provider config in OpenCode user/project/custom layers via `scope`; secrets stay in auth via the OpenCode auth API)
|
||||
- `DELETE /api/provider/:providerId/auth`
|
||||
- Owns lazy auth library loading for provider auth checks/removal.
|
||||
- Keeps route behavior independent from composition root; `index.js` now supplies dependencies only.
|
||||
|
||||
@@ -1070,6 +1070,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
|
||||
req.path.startsWith('/api/push') ||
|
||||
req.path.startsWith('/api/notifications') ||
|
||||
req.path.startsWith('/api/permission-auto-accept') ||
|
||||
req.path.startsWith('/api/provider') ||
|
||||
req.path.startsWith('/api/session-folders') ||
|
||||
req.path.startsWith('/api/small-model') ||
|
||||
req.path.startsWith('/api/walkthrough') ||
|
||||
|
||||
@@ -127,6 +127,37 @@ describe('core-routes', () => {
|
||||
expect(response.body).toEqual({ body: { content: 'Snippet body' } });
|
||||
});
|
||||
|
||||
it('should parse JSON bodies for custom provider upsert routes', async () => {
|
||||
const app = express();
|
||||
registerCommonRequestMiddleware(app, { express });
|
||||
app.put('/api/provider', (req, res) => {
|
||||
res.json({ body: req.body });
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put('/api/provider')
|
||||
.send({
|
||||
providerID: 'campus-llm',
|
||||
config: {
|
||||
name: 'Campus LLM',
|
||||
options: { baseURL: 'https://llm.example.edu/v1' },
|
||||
models: { fast: { name: 'Fast' } },
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
body: {
|
||||
providerID: 'campus-llm',
|
||||
config: {
|
||||
name: 'Campus LLM',
|
||||
options: { baseURL: 'https://llm.example.edu/v1' },
|
||||
models: { fast: { name: 'Fast' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should require API auth before probing loopback preview URLs', async () => {
|
||||
const app = express();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -19,7 +19,7 @@ import { registerPluginRoutes } from './plugin-routes.js';
|
||||
import { getNpmInfo, clearCache as clearNpmCache } from './npm-registry.js';
|
||||
import { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js';
|
||||
import { registerOpenCodeRoutes } from './routes.js';
|
||||
import { getProviderSources, removeProviderConfig } from './providers.js';
|
||||
import { getProviderSources, removeProviderConfig, upsertProviderConfig } from './providers.js';
|
||||
import { getAgentSources, getAgentConfig, createAgent, updateAgent, deleteAgent } from './agents.js';
|
||||
import { getCommandSources, createCommand, updateCommand, deleteCommand } from './commands.js';
|
||||
import { listMcpConfigs, getMcpConfig, createMcpConfig, updateMcpConfig, deleteMcpConfig } from './mcp.js';
|
||||
@@ -145,6 +145,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
resolveProjectDirectory,
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
upsertProviderConfig,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
writeConfig,
|
||||
} from './shared.js';
|
||||
|
||||
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
|
||||
const BASE_URL_PATTERN = /^https?:\/\//;
|
||||
const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible';
|
||||
|
||||
function getProviderSources(providerId, workingDirectory) {
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
const { userConfig, projectConfig, customConfig, paths } = layers;
|
||||
@@ -37,6 +41,162 @@ function getProviderSources(providerId, workingDirectory) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a custom OpenAI-compatible provider config payload before persistence.
|
||||
* Returns { ok: true, value } or { ok: false, error }.
|
||||
*
|
||||
* Credentials: either config.env contains a variable name, or hasStoredAuth is true
|
||||
* (auth.json already has a key — typically after auth.set, or when editing).
|
||||
*/
|
||||
function validateCustomProviderConfig(providerId, config, options = {}) {
|
||||
if (!providerId || typeof providerId !== 'string' || !PROVIDER_ID_PATTERN.test(providerId)) {
|
||||
return { ok: false, error: 'Provider ID must match /^[a-z0-9][a-z0-9-_]*$/' };
|
||||
}
|
||||
|
||||
if (!isPlainObject(config)) {
|
||||
return { ok: false, error: 'Provider config must be an object' };
|
||||
}
|
||||
|
||||
const name = typeof config.name === 'string' ? config.name.trim() : '';
|
||||
if (!name) {
|
||||
return { ok: false, error: 'Provider name is required' };
|
||||
}
|
||||
|
||||
const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM;
|
||||
if (npm !== OPENAI_COMPATIBLE_NPM) {
|
||||
return { ok: false, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` };
|
||||
}
|
||||
|
||||
const optionsBlock = isPlainObject(config.options) ? config.options : null;
|
||||
if (!optionsBlock) {
|
||||
return { ok: false, error: 'Provider options are required' };
|
||||
}
|
||||
|
||||
const baseURL = typeof optionsBlock.baseURL === 'string' ? optionsBlock.baseURL.trim() : '';
|
||||
if (!baseURL) {
|
||||
return { ok: false, error: 'Base URL is required' };
|
||||
}
|
||||
if (!BASE_URL_PATTERN.test(baseURL)) {
|
||||
return { ok: false, error: 'Base URL must start with http:// or https://' };
|
||||
}
|
||||
|
||||
const models = isPlainObject(config.models) ? config.models : null;
|
||||
if (!models || Object.keys(models).length === 0) {
|
||||
return { ok: false, error: 'At least one model is required' };
|
||||
}
|
||||
|
||||
const normalizedModels = {};
|
||||
for (const [modelId, modelValue] of Object.entries(models)) {
|
||||
const trimmedId = typeof modelId === 'string' ? modelId.trim() : '';
|
||||
if (!trimmedId) {
|
||||
return { ok: false, error: 'Model id is required' };
|
||||
}
|
||||
if (!isPlainObject(modelValue)) {
|
||||
return { ok: false, error: `Model "${trimmedId}" must be an object` };
|
||||
}
|
||||
const modelName = typeof modelValue.name === 'string' ? modelValue.name.trim() : '';
|
||||
if (!modelName) {
|
||||
return { ok: false, error: `Model "${trimmedId}" requires a name` };
|
||||
}
|
||||
normalizedModels[trimmedId] = { name: modelName };
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
npm: OPENAI_COMPATIBLE_NPM,
|
||||
name,
|
||||
options: {
|
||||
baseURL,
|
||||
},
|
||||
models: normalizedModels,
|
||||
};
|
||||
|
||||
let env = [];
|
||||
if (Array.isArray(config.env)) {
|
||||
env = config.env
|
||||
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
|
||||
.map((entry) => entry.trim());
|
||||
if (env.length > 0) {
|
||||
normalized.env = env;
|
||||
}
|
||||
}
|
||||
|
||||
const hasStoredAuth = Boolean(options.hasStoredAuth);
|
||||
if (env.length === 0 && !hasStoredAuth) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'API key or {env:VAR} credentials are required',
|
||||
};
|
||||
}
|
||||
|
||||
if (isPlainObject(optionsBlock.headers)) {
|
||||
const headers = {};
|
||||
for (const [headerKey, headerValue] of Object.entries(optionsBlock.headers)) {
|
||||
if (typeof headerKey !== 'string' || !headerKey.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (typeof headerValue !== 'string' || !headerValue.trim()) {
|
||||
return { ok: false, error: `Header "${headerKey}" requires a non-empty value` };
|
||||
}
|
||||
headers[headerKey.trim()] = headerValue.trim();
|
||||
}
|
||||
if (Object.keys(headers).length > 0) {
|
||||
normalized.options.headers = headers;
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, value: { providerId, config: normalized } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist (create or update) a custom provider block in OpenCode user/project/custom config.
|
||||
* Does not write secrets — API keys remain in auth.json via the OpenCode auth API.
|
||||
*/
|
||||
function upsertProviderConfig(providerId, config, workingDirectory, scope = 'user', options = {}) {
|
||||
const validated = validateCustomProviderConfig(providerId, config, options);
|
||||
if (!validated.ok) {
|
||||
const error = new Error(validated.error);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
let targetPath = layers.paths.userPath;
|
||||
|
||||
if (scope === 'project') {
|
||||
if (!workingDirectory) {
|
||||
throw new Error('Working directory is required for project scope');
|
||||
}
|
||||
targetPath = layers.paths.projectPath || targetPath;
|
||||
} else if (scope === 'custom') {
|
||||
if (!layers.paths.customPath) {
|
||||
throw new Error('Custom config path (OPENCODE_CONFIG) is not set');
|
||||
}
|
||||
targetPath = layers.paths.customPath;
|
||||
} else if (scope !== 'user') {
|
||||
throw new Error('Invalid scope');
|
||||
}
|
||||
|
||||
const targetConfig = getConfigForPath(layers, targetPath);
|
||||
const providerConfig = isPlainObject(targetConfig.provider) ? { ...targetConfig.provider } : {};
|
||||
providerConfig[validated.value.providerId] = validated.value.config;
|
||||
targetConfig.provider = providerConfig;
|
||||
|
||||
if (Array.isArray(targetConfig.disabled_providers)) {
|
||||
targetConfig.disabled_providers = targetConfig.disabled_providers.filter(
|
||||
(entry) => entry !== validated.value.providerId,
|
||||
);
|
||||
}
|
||||
|
||||
const writePath = targetPath || CONFIG_FILE;
|
||||
writeConfig(targetConfig, writePath);
|
||||
|
||||
return {
|
||||
providerId: validated.value.providerId,
|
||||
path: writePath,
|
||||
config: validated.value.config,
|
||||
};
|
||||
}
|
||||
|
||||
function removeProviderConfig(providerId, workingDirectory, scope = 'user') {
|
||||
if (!providerId || typeof providerId !== 'string') {
|
||||
throw new Error('Provider ID is required');
|
||||
@@ -93,4 +253,6 @@ function removeProviderConfig(providerId, workingDirectory, scope = 'user') {
|
||||
export {
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
upsertProviderConfig,
|
||||
validateCustomProviderConfig,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
upsertProviderConfig,
|
||||
validateCustomProviderConfig,
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
} from './providers.js';
|
||||
|
||||
let projectDir;
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
describe('custom provider config persistence', () => {
|
||||
beforeEach(() => {
|
||||
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-provider-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('validateCustomProviderConfig rejects invalid endpoint and credentials shape', () => {
|
||||
expect(validateCustomProviderConfig('Bad Id', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).ok).toBe(false);
|
||||
|
||||
expect(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'ftp://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).error).toContain('http://');
|
||||
|
||||
expect(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: {},
|
||||
}).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('validateCustomProviderConfig rejects missing credentials', () => {
|
||||
expect(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).ok).toBe(false);
|
||||
|
||||
expect(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, { hasStoredAuth: true }).ok).toBe(true);
|
||||
|
||||
expect(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
env: ['MY_KEY'],
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('upsertProviderConfig writes and round-trips project config', () => {
|
||||
const result = upsertProviderConfig('campus-llm', {
|
||||
name: 'Campus LLM',
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
options: {
|
||||
baseURL: 'https://llm.example.edu/v1',
|
||||
headers: { 'X-Campus': '1' },
|
||||
},
|
||||
models: {
|
||||
'fast-model': { name: 'Fast' },
|
||||
},
|
||||
env: ['CAMPUS_KEY'],
|
||||
}, projectDir, 'project');
|
||||
|
||||
expect(result.providerId).toBe('campus-llm');
|
||||
expect(fs.existsSync(result.path)).toBe(true);
|
||||
expect(result.path.startsWith(projectDir)).toBe(true);
|
||||
|
||||
const written = readJson(result.path);
|
||||
expect(written.provider['campus-llm']).toEqual({
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
name: 'Campus LLM',
|
||||
env: ['CAMPUS_KEY'],
|
||||
options: {
|
||||
baseURL: 'https://llm.example.edu/v1',
|
||||
headers: { 'X-Campus': '1' },
|
||||
},
|
||||
models: {
|
||||
'fast-model': { name: 'Fast' },
|
||||
},
|
||||
});
|
||||
|
||||
const sources = getProviderSources('campus-llm', projectDir);
|
||||
expect(sources.sources.project.exists).toBe(true);
|
||||
expect(sources.sources.project.path).toBe(result.path);
|
||||
});
|
||||
|
||||
test('upsertProviderConfig updates existing entry and clears disabled_providers', () => {
|
||||
const configPath = path.join(projectDir, 'opencode.json');
|
||||
writeJson(configPath, {
|
||||
provider: {
|
||||
'campus-llm': {
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
name: 'Old',
|
||||
options: { baseURL: 'https://old.example.edu/v1' },
|
||||
models: { a: { name: 'A' } },
|
||||
},
|
||||
},
|
||||
disabled_providers: ['campus-llm', 'other'],
|
||||
});
|
||||
|
||||
upsertProviderConfig('campus-llm', {
|
||||
name: 'Campus LLM',
|
||||
options: { baseURL: 'https://llm.example.edu/v1' },
|
||||
models: { b: { name: 'B' } },
|
||||
env: ['CAMPUS_KEY'],
|
||||
}, projectDir, 'project');
|
||||
|
||||
const written = readJson(configPath);
|
||||
expect(written.provider['campus-llm'].name).toBe('Campus LLM');
|
||||
expect(written.provider['campus-llm'].models).toEqual({ b: { name: 'B' } });
|
||||
expect(written.disabled_providers).toEqual(['other']);
|
||||
});
|
||||
|
||||
test('upsert then remove restores absence', () => {
|
||||
upsertProviderConfig('temp-provider', {
|
||||
name: 'Temp',
|
||||
options: { baseURL: 'https://api.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
env: ['TEMP_KEY'],
|
||||
}, projectDir, 'project');
|
||||
|
||||
expect(getProviderSources('temp-provider', projectDir).sources.project.exists).toBe(true);
|
||||
expect(removeProviderConfig('temp-provider', projectDir, 'project')).toBe(true);
|
||||
expect(getProviderSources('temp-provider', projectDir).sources.project.exists).toBe(false);
|
||||
});
|
||||
|
||||
test('failed validation does not write config', () => {
|
||||
const configPath = path.join(projectDir, 'opencode.json');
|
||||
expect(() => upsertProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'not-a-url' },
|
||||
models: { m: { name: 'M' } },
|
||||
env: ['X'],
|
||||
}, projectDir, 'project')).toThrow(/Base URL/);
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
});
|
||||
|
||||
test('upsert with hasStoredAuth allows config without env', () => {
|
||||
const result = upsertProviderConfig('keyed-provider', {
|
||||
name: 'Keyed',
|
||||
options: { baseURL: 'https://api.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, projectDir, 'project', { hasStoredAuth: true });
|
||||
|
||||
expect(result.providerId).toBe('keyed-provider');
|
||||
expect(result.config.env).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('project-scope edit updates project layer without creating a user entry', () => {
|
||||
const providerId = `proj-scope-${Date.now()}`;
|
||||
const configPath = path.join(projectDir, 'opencode.json');
|
||||
|
||||
upsertProviderConfig(providerId, {
|
||||
name: 'Project Scoped',
|
||||
options: { baseURL: 'https://project.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, projectDir, 'project', { hasStoredAuth: true });
|
||||
|
||||
upsertProviderConfig(providerId, {
|
||||
name: 'Project Scoped Updated',
|
||||
options: { baseURL: 'https://project.example.com/v2', headers: { 'X-Project': '1' } },
|
||||
models: { m: { name: 'M2' } },
|
||||
}, projectDir, 'project', { hasStoredAuth: true });
|
||||
|
||||
const written = readJson(configPath);
|
||||
expect(written.provider[providerId]).toEqual({
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
name: 'Project Scoped Updated',
|
||||
options: {
|
||||
baseURL: 'https://project.example.com/v2',
|
||||
headers: { 'X-Project': '1' },
|
||||
},
|
||||
models: { m: { name: 'M2' } },
|
||||
});
|
||||
|
||||
const sources = getProviderSources(providerId, projectDir);
|
||||
expect(sources.sources.project.exists).toBe(true);
|
||||
expect(sources.sources.user.exists).toBe(false);
|
||||
expect(sources.sources.custom.exists).toBe(false);
|
||||
|
||||
for (const userPath of [
|
||||
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
|
||||
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
|
||||
]) {
|
||||
if (!fs.existsSync(userPath)) continue;
|
||||
const userConfig = readJson(userPath);
|
||||
expect(userConfig.provider?.[providerId]).toBeUndefined();
|
||||
expect(userConfig.providers?.[providerId]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('custom-scope edit updates custom layer without creating a user entry', () => {
|
||||
const providerId = `custom-scope-${Date.now()}`;
|
||||
const customPath = path.join(projectDir, 'custom-opencode.json');
|
||||
const previousEnv = process.env.OPENCODE_CONFIG;
|
||||
process.env.OPENCODE_CONFIG = customPath;
|
||||
|
||||
try {
|
||||
upsertProviderConfig(providerId, {
|
||||
name: 'Custom Scoped',
|
||||
options: { baseURL: 'https://custom.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, projectDir, 'custom', { hasStoredAuth: true });
|
||||
|
||||
upsertProviderConfig(providerId, {
|
||||
name: 'Custom Scoped Updated',
|
||||
options: { baseURL: 'https://custom.example.com/v2' },
|
||||
models: { n: { name: 'N' } },
|
||||
}, projectDir, 'custom', { hasStoredAuth: true });
|
||||
|
||||
const written = readJson(customPath);
|
||||
expect(written.provider[providerId].name).toBe('Custom Scoped Updated');
|
||||
expect(written.provider[providerId].options.baseURL).toBe('https://custom.example.com/v2');
|
||||
|
||||
const sources = getProviderSources(providerId, projectDir);
|
||||
expect(sources.sources.custom.exists).toBe(true);
|
||||
expect(sources.sources.user.exists).toBe(false);
|
||||
expect(sources.sources.project.exists).toBe(false);
|
||||
|
||||
for (const userPath of [
|
||||
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
|
||||
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
|
||||
]) {
|
||||
if (!fs.existsSync(userPath)) continue;
|
||||
const userConfig = readJson(userPath);
|
||||
expect(userConfig.provider?.[providerId]).toBeUndefined();
|
||||
expect(userConfig.providers?.[providerId]).toBeUndefined();
|
||||
}
|
||||
} finally {
|
||||
if (previousEnv === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG;
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG = previousEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
resolveProjectDirectory,
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
upsertProviderConfig,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
@@ -443,6 +444,64 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/provider', async (req, res) => {
|
||||
try {
|
||||
const providerID = typeof req.body?.providerID === 'string'
|
||||
? req.body.providerID.trim()
|
||||
: (typeof req.body?.providerId === 'string' ? req.body.providerId.trim() : '');
|
||||
const config = req.body?.config;
|
||||
const scope = typeof req.body?.scope === 'string' ? req.body.scope : 'user';
|
||||
|
||||
if (!providerID) {
|
||||
return res.status(400).json({ error: 'Provider ID is required' });
|
||||
}
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
||||
return res.status(400).json({ error: 'Provider config is required' });
|
||||
}
|
||||
if (scope !== 'user' && scope !== 'project' && scope !== 'custom') {
|
||||
return res.status(400).json({ error: 'Invalid scope' });
|
||||
}
|
||||
|
||||
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
||||
const queryDirectory = Array.isArray(req.query?.directory)
|
||||
? req.query.directory[0]
|
||||
: req.query?.directory;
|
||||
const requestedDirectory = headerDirectory || queryDirectory || null;
|
||||
|
||||
let directory = null;
|
||||
if (scope === 'project' || requestedDirectory) {
|
||||
const resolved = await resolveProjectDirectory(req);
|
||||
if (!resolved.directory) {
|
||||
return res.status(400).json({ error: resolved.error || 'Working directory is required' });
|
||||
}
|
||||
directory = resolved.directory;
|
||||
} else {
|
||||
const resolved = await resolveProjectDirectory(req);
|
||||
if (resolved.directory) {
|
||||
directory = resolved.directory;
|
||||
}
|
||||
}
|
||||
|
||||
const { getProviderAuth } = await getAuthLibrary();
|
||||
const hasStoredAuth = Boolean(getProviderAuth(providerID));
|
||||
const upsertResult = upsertProviderConfig(providerID, config, directory, scope, { hasStoredAuth });
|
||||
await refreshOpenCodeAfterConfigChange(`provider ${providerID} upserted (${scope})`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
providerId: upsertResult.providerId,
|
||||
path: upsertResult.path,
|
||||
config: upsertResult.config,
|
||||
requiresReload: true,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = typeof error?.statusCode === 'number' ? error.statusCode : 500;
|
||||
console.error('Failed to upsert provider config:', error);
|
||||
return res.status(status).json({ error: error.message || 'Failed to save provider config' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/provider/:providerId/auth', async (req, res) => {
|
||||
try {
|
||||
const { providerId } = req.params;
|
||||
|
||||
@@ -11,9 +11,6 @@ const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
|
||||
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
|
||||
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
|
||||
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json');
|
||||
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
|
||||
? path.resolve(process.env.OPENCODE_CONFIG)
|
||||
: null;
|
||||
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
|
||||
|
||||
// ============== SCOPE TYPE CONSTANTS ==============
|
||||
@@ -121,7 +118,10 @@ function getConfigPaths(workingDirectory) {
|
||||
path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'),
|
||||
],
|
||||
projectPath: getProjectConfigPath(workingDirectory),
|
||||
customPath: CUSTOM_CONFIG_FILE
|
||||
// Resolve at call time so OPENCODE_CONFIG changes (and tests) take effect.
|
||||
customPath: process.env.OPENCODE_CONFIG
|
||||
? path.resolve(process.env.OPENCODE_CONFIG)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user