fix custom provider credentials, edit path, and failure UX

Require an API key or {env:VAR} on client and server, add edit/prefill for
existing custom providers, save auth before config, and surface incomplete
auth plus disconnect after partial failures. Add VS Code parity tests and
drop the unused allProvidersConnected locale key.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-02 11:40:02 +00:00
co-authored by Serhii Dziupin
parent d84e4e0312
commit d40bb9e5a0
23 changed files with 815 additions and 104 deletions
+4 -1
View File
@@ -22,7 +22,10 @@ For gateways, campus LLMs, Ollama, LiteLLM, and similar OpenAI-compatible APIs:
1. Choose **Other / Custom** in the provider list. 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. 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. 3. Optionally add request headers.
4. Save — OpenChamber writes the provider block to OpenCode config and stores the key in OpenCode auth. 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. When a provider shows as connected, its models become available in chat.
@@ -29,28 +29,48 @@ type CustomProviderFormProps = {
existingProviderIDs: ReadonlySet<string>; existingProviderIDs: ReadonlySet<string>;
disabledProviders?: readonly string[]; disabledProviders?: readonly string[];
busy?: boolean; busy?: boolean;
mode?: 'create' | 'edit';
initialValues?: CustomProviderFormState;
allowExistingAuth?: boolean;
authFailureHint?: string | null;
onSubmit: (plan: CustomProviderPersistPlan) => void | Promise<void>; onSubmit: (plan: CustomProviderPersistPlan) => void | Promise<void>;
onCancel?: () => void; onCancel?: () => void;
onDisconnect?: () => void | Promise<void>;
}; };
export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({ export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
existingProviderIDs, existingProviderIDs,
disabledProviders = [], disabledProviders = [],
busy = false, busy = false,
mode = 'create',
initialValues,
allowExistingAuth = false,
authFailureHint = null,
onSubmit, onSubmit,
onCancel, onCancel,
onDisconnect,
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const [form, setForm] = React.useState<CustomProviderFormState>(() => createEmptyCustomProviderForm()); const isEdit = mode === 'edit';
const [form, setForm] = React.useState<CustomProviderFormState>(
() => initialValues ?? createEmptyCustomProviderForm(),
);
const [err, setErr] = React.useState<FieldErrors>({}); const [err, setErr] = React.useState<FieldErrors>({});
const [modelErrors, setModelErrors] = React.useState<ModelFieldErrors[]>([]); const [modelErrors, setModelErrors] = React.useState<ModelFieldErrors[]>([]);
const [headerErrors, setHeaderErrors] = React.useState<HeaderFieldErrors[]>([]); const [headerErrors, setHeaderErrors] = React.useState<HeaderFieldErrors[]>([]);
React.useEffect(() => {
if (initialValues) {
setForm(initialValues);
setErr({});
setModelErrors([]);
setHeaderErrors([]);
}
}, [initialValues]);
const setField = (key: keyof Pick<CustomProviderFormState, 'providerID' | 'name' | 'baseURL' | 'apiKey'>, value: string) => { const setField = (key: keyof Pick<CustomProviderFormState, 'providerID' | 'name' | 'baseURL' | 'apiKey'>, value: string) => {
setForm((prev) => ({ ...prev, [key]: value })); setForm((prev) => ({ ...prev, [key]: value }));
if (key !== 'apiKey') { setErr((prev) => ({ ...prev, [key]: undefined }));
setErr((prev) => ({ ...prev, [key]: undefined }));
}
}; };
const setModel = (index: number, key: 'id' | 'name', value: string) => { const setModel = (index: number, key: 'id' | 'name', value: string) => {
@@ -88,6 +108,8 @@ export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
t: ((key, vars) => t(key as Parameters<typeof t>[0], vars)) as CustomProviderTranslator, t: ((key, vars) => t(key as Parameters<typeof t>[0], vars)) as CustomProviderTranslator,
existingProviderIDs, existingProviderIDs,
disabledProviders, disabledProviders,
editingProviderID: isEdit ? form.providerID : undefined,
allowExistingAuth: isEdit && allowExistingAuth,
}); });
setErr(output.err); setErr(output.err);
setModelErrors(output.models); setModelErrors(output.models);
@@ -101,13 +123,19 @@ export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
return ( return (
<form onSubmit={handleSubmit} className="space-y-0"> <form onSubmit={handleSubmit} className="space-y-0">
<SettingsSection <SettingsSection
title={t('settings.providers.page.custom.title')} title={isEdit ? t('settings.providers.page.custom.editTitle') : t('settings.providers.page.custom.title')}
divider={false} divider={false}
settingsItem="providers.custom" settingsItem="providers.custom"
contentClassName={SETTINGS_FIELDS_STACK_CLASS} contentClassName={SETTINGS_FIELDS_STACK_CLASS}
> >
<p className={SETTINGS_HELPER_CLASS}>{t('settings.providers.page.custom.description')}</p> <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 <SettingsStackedField
label={t('settings.providers.page.custom.field.providerID.label')} label={t('settings.providers.page.custom.field.providerID.label')}
info={t('settings.providers.page.custom.field.providerID.info')} info={t('settings.providers.page.custom.field.providerID.info')}
@@ -117,7 +145,8 @@ export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
onChange={(event) => setField('providerID', event.target.value)} onChange={(event) => setField('providerID', event.target.value)}
placeholder={t('settings.providers.page.custom.field.providerID.placeholder')} placeholder={t('settings.providers.page.custom.field.providerID.placeholder')}
className="h-8 rounded-md px-3 font-mono text-xs" className="h-8 rounded-md px-3 font-mono text-xs"
autoFocus autoFocus={!isEdit}
disabled={isEdit || busy}
aria-invalid={Boolean(err.providerID)} aria-invalid={Boolean(err.providerID)}
aria-label={t('settings.providers.page.custom.field.providerID.label')} aria-label={t('settings.providers.page.custom.field.providerID.label')}
/> />
@@ -156,16 +185,26 @@ export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
<SettingsStackedField <SettingsStackedField
label={t('settings.providers.page.custom.field.apiKey.label')} label={t('settings.providers.page.custom.field.apiKey.label')}
info={t('settings.providers.page.custom.field.apiKey.info')} info={
isEdit && allowExistingAuth
? t('settings.providers.page.custom.field.apiKey.editInfo')
: t('settings.providers.page.custom.field.apiKey.info')
}
> >
<Input <Input
type="password" type="password"
value={form.apiKey} value={form.apiKey}
onChange={(event) => setField('apiKey', event.target.value)} onChange={(event) => setField('apiKey', event.target.value)}
placeholder={t('settings.providers.page.custom.field.apiKey.placeholder')} 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" 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')} 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> </SettingsStackedField>
</SettingsSection> </SettingsSection>
@@ -324,10 +363,24 @@ export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
{t('settings.providers.page.custom.actions.back')} {t('settings.providers.page.custom.actions.back')}
</Button> </Button>
) : null} ) : 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}> <Button type="submit" size="xs" className="!font-normal" disabled={busy}>
{busy {busy
? t('settings.providers.page.actions.saving') ? t('settings.providers.page.actions.saving')
: t('settings.providers.page.custom.actions.save')} : isEdit
? t('settings.providers.page.custom.actions.update')
: t('settings.providers.page.custom.actions.save')}
</Button> </Button>
</div> </div>
</form> </form>
@@ -31,6 +31,8 @@ import {
buildAuthSetRequest, buildAuthSetRequest,
buildProviderUpsertRequest, buildProviderUpsertRequest,
CUSTOM_PROVIDER_ID, CUSTOM_PROVIDER_ID,
isCustomOpenAICompatibleProvider,
providerToCustomFormState,
type CustomProviderPersistPlan, type CustomProviderPersistPlan,
} from './custom-provider-form'; } from './custom-provider-form';
@@ -179,8 +181,17 @@ export const ProvidersPage: React.FC = () => {
const [providerDropdownOpen, setProviderDropdownOpen] = React.useState(false); const [providerDropdownOpen, setProviderDropdownOpen] = React.useState(false);
const [providerSources, setProviderSources] = React.useState<Record<string, ProviderSources>>({}); const [providerSources, setProviderSources] = React.useState<Record<string, ProviderSources>>({});
const [showAuthPanel, setShowAuthPanel] = React.useState(false); const [showAuthPanel, setShowAuthPanel] = React.useState(false);
const [editingCustomProviderId, setEditingCustomProviderId] = React.useState<string | 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 isAddMode = selectedProviderId === ADD_PROVIDER_ID;
const isCustomMode = isAddMode && candidateProviderId === CUSTOM_PROVIDER_ID; const isCustomCreateMode = isAddMode && candidateProviderId === CUSTOM_PROVIDER_ID;
const isCustomEditMode = Boolean(
editingCustomProviderId
&& selectedProviderId
&& editingCustomProviderId === selectedProviderId
&& !isAddMode,
);
React.useEffect(() => { React.useEffect(() => {
if (!selectedProviderId && providers.length > 0) { if (!selectedProviderId && providers.length > 0) {
@@ -291,11 +302,17 @@ export const ProvidersPage: React.FC = () => {
React.useEffect(() => { React.useEffect(() => {
if (selectedProviderId === ADD_PROVIDER_ID) { if (selectedProviderId === ADD_PROVIDER_ID) {
setShowAuthPanel(true); setShowAuthPanel(true);
setEditingCustomProviderId(null);
setCustomAuthFailureHint(null);
return; return;
} }
setShowAuthPanel(false); setShowAuthPanel(false);
}, [selectedProviderId, t]); if (editingCustomProviderId && editingCustomProviderId !== selectedProviderId) {
setEditingCustomProviderId(null);
setCustomAuthFailureHint(null);
}
}, [selectedProviderId, editingCustomProviderId, t]);
React.useEffect(() => { React.useEffect(() => {
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) { if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
@@ -376,8 +393,20 @@ export const ProvidersPage: React.FC = () => {
const handleSaveCustomProvider = async (plan: CustomProviderPersistPlan) => { const handleSaveCustomProvider = async (plan: CustomProviderPersistPlan) => {
const busyKey = `custom:${plan.providerID}`; const busyKey = `custom:${plan.providerID}`;
setAuthBusyKey(busyKey); setAuthBusyKey(busyKey);
setLastCustomPersistId(plan.providerID);
setCustomAuthFailureHint(null);
try { 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); const upsertBody = buildProviderUpsertRequest(plan);
const response = await runtimeFetch('/api/provider', { const response = await runtimeFetch('/api/provider', {
method: 'PUT', method: 'PUT',
@@ -389,19 +418,17 @@ export const ProvidersPage: React.FC = () => {
}); });
const payload = await response.json().catch(() => null); const payload = await response.json().catch(() => null);
if (!response.ok) { if (!response.ok) {
throw new Error(payload?.error || t('settings.providers.page.toast.customProviderSaveFailed')); if (authRequest) {
} setCustomAuthFailureHint(t('settings.providers.page.custom.authFailure.configAfterAuth'));
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'));
} }
throw new Error(payload?.error || t('settings.providers.page.toast.customProviderSaveFailed'));
} }
toast.success(t('settings.providers.page.toast.customProviderSaved', { provider: plan.name })); toast.success(t('settings.providers.page.toast.customProviderSaved', { provider: plan.name }));
setCandidateProviderId(''); setCandidateProviderId('');
setEditingCustomProviderId(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
await reloadOpenCodeConfiguration({ scopes: ['providers'], mode: 'active' }); await reloadOpenCodeConfiguration({ scopes: ['providers'], mode: 'active' });
setSelectedProvider(plan.providerID); setSelectedProvider(plan.providerID);
} catch (error) { } catch (error) {
@@ -554,6 +581,17 @@ export const ProvidersPage: React.FC = () => {
} }
}; };
const handleDisconnectCustomProvider = async (providerId: string) => {
if (!providerId) {
return;
}
await handleDisconnectProvider(providerId);
setEditingCustomProviderId(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
setCandidateProviderId('');
};
if (!isAddMode && providers.length === 0) { if (!isAddMode && providers.length === 0) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
@@ -692,11 +730,22 @@ export const ProvidersPage: React.FC = () => {
</div> </div>
</SettingsSection> </SettingsSection>
{isCustomMode ? ( {isCustomCreateMode ? (
<CustomProviderForm <CustomProviderForm
mode="create"
existingProviderIDs={connectedProviderIds} existingProviderIDs={connectedProviderIds}
busy={authBusyKey?.startsWith('custom:') ?? false} busy={authBusyKey?.startsWith('custom:') ?? false}
onCancel={() => setCandidateProviderId('')} authFailureHint={customAuthFailureHint}
onCancel={() => {
setCandidateProviderId('');
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
}}
onDisconnect={
customAuthFailureHint && lastCustomPersistId
? () => void handleDisconnectCustomProvider(lastCustomPersistId)
: undefined
}
onSubmit={handleSaveCustomProvider} onSubmit={handleSaveCustomProvider}
/> />
) : candidateProviderId ? ( ) : candidateProviderId ? (
@@ -853,6 +902,15 @@ export const ProvidersPage: React.FC = () => {
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : []; const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? []; const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
const oauthAuthMethods = providerAuthMethods.filter((method) => normalizeAuthType(method) === 'oauth'); const oauthAuthMethods = providerAuthMethods.filter((method) => normalizeAuthType(method) === 'oauth');
const isCustomProvider = isCustomOpenAICompatibleProvider(selectedProvider);
const providerEnv = Array.isArray(selectedProvider.env)
? selectedProvider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
: [];
const sourcesLoaded = Boolean(selectedSources);
const hasStoredAuth = Boolean(selectedSources?.auth.exists);
const hasEnvCredentials = providerEnv.length > 0;
const hasCredentials = hasStoredAuth || hasEnvCredentials;
const authStatusIncomplete = isCustomProvider && sourcesLoaded && !hasCredentials;
const filteredModels = providerModels.filter((model) => { const filteredModels = providerModels.filter((model) => {
const name = typeof model?.name === 'string' ? model.name : ''; const name = typeof model?.name === 'string' ? model.name : '';
@@ -862,6 +920,34 @@ export const ProvidersPage: React.FC = () => {
return name.toLowerCase().includes(query) || id.toLowerCase().includes(query); return name.toLowerCase().includes(query) || id.toLowerCase().includes(query);
}); });
if (isCustomEditMode && isCustomProvider) {
const initialValues = providerToCustomFormState(selectedProvider);
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={initialValues}
allowExistingAuth={hasCredentials}
busy={authBusyKey?.startsWith('custom:') ?? false}
authFailureHint={customAuthFailureHint}
onCancel={() => {
setEditingCustomProviderId(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
}}
onDisconnect={() => void handleDisconnectCustomProvider(selectedProvider.id)}
onSubmit={handleSaveCustomProvider}
/>
</SettingsPageLayout>
);
}
return ( return (
<SettingsPageLayout <SettingsPageLayout
title={selectedProvider.name || selectedProvider.id} title={selectedProvider.name || selectedProvider.id}
@@ -873,23 +959,46 @@ export const ProvidersPage: React.FC = () => {
title={t('settings.providers.page.auth.title')} title={t('settings.providers.page.auth.title')}
divider={false} divider={false}
headerAction={( headerAction={(
<Button <div className="flex items-center gap-1">
variant="outline" {isCustomProvider ? (
size="xs" <Button
className="!font-normal" variant="outline"
onClick={() => setShowAuthPanel((prev) => !prev)} size="xs"
> className="!font-normal"
{showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')} onClick={() => {
</Button> setCustomAuthFailureHint(null);
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" settingsItem="providers.auth"
> >
{!showAuthPanel ? ( {!showAuthPanel ? (
<div className="flex items-center gap-1.5 py-1.5"> authStatusIncomplete ? (
<Icon name="check" className="w-4 h-4 text-[var(--status-success)] shrink-0" /> <div className="flex items-center gap-1.5 py-1.5">
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.connected')}</span> <Icon name="alert" className="w-4 h-4 text-[var(--status-warning)] shrink-0" />
<SettingsInfoHint>{t('settings.providers.page.auth.useReconnectHint')}</SettingsInfoHint> <span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.incomplete')}</span>
</div> <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 ? ( ) : authLoading ? (
<div className="py-1.5 typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</div> <div className="py-1.5 typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</div>
) : ( ) : (
@@ -2,8 +2,10 @@ import { describe, expect, test } from 'bun:test';
import { import {
buildAuthSetRequest, buildAuthSetRequest,
buildProviderUpsertRequest, buildProviderUpsertRequest,
mergeProviderConfig, isCustomOpenAICompatibleProvider,
providerToCustomFormState,
validateCustomProvider, validateCustomProvider,
type CustomProviderConfig,
type CustomProviderFormState, type CustomProviderFormState,
} from './custom-provider-form'; } from './custom-provider-form';
@@ -19,6 +21,28 @@ const baseForm = (overrides: Partial<CustomProviderFormState> = {}): CustomProvi
...overrides, ...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', () => { describe('validateCustomProvider', () => {
test('builds trimmed config and auth payloads', () => { test('builds trimmed config and auth payloads', () => {
const result = validateCustomProvider({ const result = validateCustomProvider({
@@ -70,6 +94,31 @@ describe('validateCustomProvider', () => {
expect(result.result?.config.env).toEqual(['CUSTOM_PROVIDER_KEY']); 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', () => { test('rejects invalid provider id, base URL, and duplicate rows', () => {
const result = validateCustomProvider({ const result = validateCustomProvider({
form: baseForm({ form: baseForm({
@@ -113,7 +162,7 @@ describe('validateCustomProvider', () => {
expect(result.err.providerID).toEqual(undefined); expect(result.err.providerID).toEqual(undefined);
}); });
test('rejects an already-connected provider id', () => { test('rejects an already-connected provider id on create', () => {
const result = validateCustomProvider({ const result = validateCustomProvider({
form: baseForm(), form: baseForm(),
t, t,
@@ -123,6 +172,18 @@ describe('validateCustomProvider', () => {
expect(result.result).toEqual(undefined); expect(result.result).toEqual(undefined);
expect(result.err.providerID).toBe('settings.providers.page.custom.error.providerID.exists'); 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', () => { describe('request construction', () => {
@@ -200,3 +261,31 @@ describe('mergeProviderConfig persistence shape', () => {
}); });
}); });
}); });
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' });
});
});
@@ -40,6 +40,7 @@ export type FieldErrors = {
providerID?: string; providerID?: string;
name?: string; name?: string;
baseURL?: string; baseURL?: string;
apiKey?: string;
}; };
export type ModelFieldErrors = { export type ModelFieldErrors = {
@@ -76,6 +77,13 @@ export type ValidateCustomProviderInput = {
t: CustomProviderTranslator; t: CustomProviderTranslator;
existingProviderIDs: ReadonlySet<string>; existingProviderIDs: ReadonlySet<string>;
disabledProviders?: readonly 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 = { export type ValidateCustomProviderResult = {
@@ -85,6 +93,14 @@ export type ValidateCustomProviderResult = {
result?: CustomProviderPersistPlan; 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; let rowCounter = 0;
const nextRow = (): string => `row-${rowCounter++}`; const nextRow = (): string => `row-${rowCounter++}`;
@@ -123,6 +139,73 @@ export function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
return { key: trimmed }; 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 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. * Validates form input and builds the auth + OpenCode provider config payloads.
*/ */
@@ -132,6 +215,7 @@ export function validateCustomProvider(input: ValidateCustomProviderInput): Vali
const baseURL = input.form.baseURL.trim(); const baseURL = input.form.baseURL.trim();
const { env, key } = parseEnvApiKey(input.form.apiKey); const { env, key } = parseEnvApiKey(input.form.apiKey);
const disabledProviders = input.disabledProviders ?? []; const disabledProviders = input.disabledProviders ?? [];
const editingProviderID = input.editingProviderID?.trim();
const idError = !providerID const idError = !providerID
? input.t('settings.providers.page.custom.error.providerID.required') ? input.t('settings.providers.page.custom.error.providerID.required')
@@ -149,8 +233,14 @@ export function validateCustomProvider(input: ValidateCustomProviderInput): Vali
? input.t('settings.providers.page.custom.error.baseURL.format') ? input.t('settings.providers.page.custom.error.baseURL.format')
: undefined; : 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 disabled = disabledProviders.includes(providerID);
const existsError = idError const isSelfEdit = Boolean(editingProviderID && editingProviderID === providerID);
const existsError = idError || isSelfEdit
? undefined ? undefined
: input.existingProviderIDs.has(providerID) && !disabled : input.existingProviderIDs.has(providerID) && !disabled
? input.t('settings.providers.page.custom.error.providerID.exists') ? input.t('settings.providers.page.custom.error.providerID.exists')
@@ -211,9 +301,10 @@ export function validateCustomProvider(input: ValidateCustomProviderInput): Vali
providerID: idError ?? existsError, providerID: idError ?? existsError,
name: nameError, name: nameError,
baseURL: urlError, baseURL: urlError,
apiKey: apiKeyError,
}; };
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid; const ok = !idError && !existsError && !nameError && !urlError && !apiKeyError && modelsValid && headersValid;
if (!ok) { if (!ok) {
return { err, models: modelErrors, headers: headerErrors }; return { err, models: modelErrors, headers: headerErrors };
} }
@@ -268,34 +359,3 @@ export function buildProviderUpsertRequest(plan: CustomProviderPersistPlan): {
config: plan.config, config: plan.config,
}; };
} }
/**
* Merges a custom provider block into an existing OpenCode config object.
* Used by persistence tests and mirrors server upsert semantics.
*/
export function mergeProviderConfig(
existing: Record<string, unknown>,
providerID: string,
config: CustomProviderConfig,
options?: { removeFromDisabled?: boolean },
): 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 (options?.removeFromDisabled !== false && Array.isArray(existing.disabled_providers)) {
next.disabled_providers = existing.disabled_providers.filter(
(entry) => entry !== providerID,
);
}
return next;
}
@@ -1333,9 +1333,9 @@ export const settingsDict = {
'settings.providers.page.connect.selectProviderPlaceholder': 'Select provider', 'settings.providers.page.connect.selectProviderPlaceholder': 'Select provider',
'settings.providers.page.connect.searchProvidersPlaceholder': 'Search...', 'settings.providers.page.connect.searchProvidersPlaceholder': 'Search...',
'settings.providers.page.connect.noProvidersFound': 'No providers found', '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.optionLabel': 'Other / Custom',
'settings.providers.page.custom.title': 'Custom provider', '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.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.label': 'Provider ID',
'settings.providers.page.custom.field.providerID.placeholder': 'my-provider', 'settings.providers.page.custom.field.providerID.placeholder': 'my-provider',
@@ -1349,6 +1349,8 @@ export const settingsDict = {
'settings.providers.page.custom.field.apiKey.label': 'API key', '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.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.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.title': 'Models',
'settings.providers.page.custom.models.idLabel': 'Model ID', 'settings.providers.page.custom.models.idLabel': 'Model ID',
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o', 'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
@@ -1366,6 +1368,7 @@ export const settingsDict = {
'settings.providers.page.custom.headers.remove': 'Remove header', 'settings.providers.page.custom.headers.remove': 'Remove header',
'settings.providers.page.custom.actions.back': 'Back', 'settings.providers.page.custom.actions.back': 'Back',
'settings.providers.page.custom.actions.save': 'Save provider', '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.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.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.providerID.exists': 'A provider with this ID is already connected',
@@ -1374,6 +1377,8 @@ export const settingsDict = {
'settings.providers.page.custom.error.baseURL.format': 'Base URL must start with http:// or https://', '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.required': 'Required',
'settings.providers.page.custom.error.duplicate': 'Duplicate', '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.title': 'Authentication',
'settings.providers.page.auth.loadingMethods': 'Loading authentication methods...', 'settings.providers.page.auth.loadingMethods': 'Loading authentication methods...',
'settings.providers.page.auth.apiKeyLabel': 'API Key', 'settings.providers.page.auth.apiKeyLabel': 'API Key',
@@ -1382,6 +1387,8 @@ export const settingsDict = {
'settings.providers.page.auth.oauthMethodFallback': 'OAuth method {index}', 'settings.providers.page.auth.oauthMethodFallback': 'OAuth method {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Paste authorization code', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Paste authorization code',
'settings.providers.page.auth.connected': 'Connected', '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.auth.useReconnectHint': '· Use Reconnect to update credentials',
'settings.providers.page.connectionDetails.title': 'Connection Details', 'settings.providers.page.connectionDetails.title': 'Connection Details',
'settings.providers.page.connectionDetails.configuredIn': 'Configured in:', 'settings.providers.page.connectionDetails.configuredIn': 'Configured in:',
@@ -1411,6 +1418,7 @@ export const settingsDict = {
'settings.providers.page.actions.complete': 'Complete', 'settings.providers.page.actions.complete': 'Complete',
'settings.providers.page.actions.hide': 'Hide', 'settings.providers.page.actions.hide': 'Hide',
'settings.providers.page.actions.reconnect': 'Reconnect', 'settings.providers.page.actions.reconnect': 'Reconnect',
'settings.providers.page.actions.edit': 'Edit',
'settings.providers.page.actions.disconnecting': 'Disconnecting...', 'settings.providers.page.actions.disconnecting': 'Disconnecting...',
'settings.providers.page.actions.disconnect': 'Disconnect', 'settings.providers.page.actions.disconnect': 'Disconnect',
'settings.providers.page.actions.hideAll': 'Hide all', 'settings.providers.page.actions.hideAll': 'Hide all',
@@ -1300,9 +1300,10 @@ export const settingsDict = {
"settings.providers.page.connect.selectProviderPlaceholder": "Seleccionar proveedor", "settings.providers.page.connect.selectProviderPlaceholder": "Seleccionar proveedor",
"settings.providers.page.connect.searchProvidersPlaceholder": "Buscar...", "settings.providers.page.connect.searchProvidersPlaceholder": "Buscar...",
"settings.providers.page.connect.noProvidersFound": "No se encontraron proveedores", "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.optionLabel": "Otro / Personalizado",
"settings.providers.page.custom.title": "Proveedor 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.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.label": "ID del proveedor",
"settings.providers.page.custom.field.providerID.placeholder": "mi-proveedor", "settings.providers.page.custom.field.providerID.placeholder": "mi-proveedor",
@@ -1316,6 +1317,10 @@ export const settingsDict = {
"settings.providers.page.custom.field.apiKey.label": "Clave API", "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.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.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.title": "Modelos",
"settings.providers.page.custom.models.idLabel": "ID del modelo", "settings.providers.page.custom.models.idLabel": "ID del modelo",
"settings.providers.page.custom.models.idPlaceholder": "gpt-4o", "settings.providers.page.custom.models.idPlaceholder": "gpt-4o",
@@ -1333,6 +1338,8 @@ export const settingsDict = {
"settings.providers.page.custom.headers.remove": "Quitar encabezado", "settings.providers.page.custom.headers.remove": "Quitar encabezado",
"settings.providers.page.custom.actions.back": "Atrás", "settings.providers.page.custom.actions.back": "Atrás",
"settings.providers.page.custom.actions.save": "Guardar proveedor", "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.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.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.providerID.exists": "Ya hay un proveedor conectado con este ID",
@@ -1341,6 +1348,10 @@ export const settingsDict = {
"settings.providers.page.custom.error.baseURL.format": "La URL base debe empezar por http:// o https://", "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.required": "Obligatorio",
"settings.providers.page.custom.error.duplicate": "Duplicado", "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.title": "Autenticación",
"settings.providers.page.auth.loadingMethods": "Cargando métodos de autenticación...", "settings.providers.page.auth.loadingMethods": "Cargando métodos de autenticación...",
"settings.providers.page.auth.apiKeyLabel": "Clave API", "settings.providers.page.auth.apiKeyLabel": "Clave API",
@@ -1349,6 +1360,10 @@ export const settingsDict = {
"settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}", "settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}",
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Pegar código de autorización", "settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Pegar código de autorización",
"settings.providers.page.auth.connected": "Conectado", "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.auth.useReconnectHint": "· Usar Reconnect para actualizar credenciales",
"settings.providers.page.connectionDetails.title": "Detalles de conexión", "settings.providers.page.connectionDetails.title": "Detalles de conexión",
"settings.providers.page.connectionDetails.configuredIn": "Configurado en:", "settings.providers.page.connectionDetails.configuredIn": "Configurado en:",
@@ -1378,6 +1393,8 @@ export const settingsDict = {
"settings.providers.page.actions.complete": "Completar", "settings.providers.page.actions.complete": "Completar",
"settings.providers.page.actions.hide": "Ocultar", "settings.providers.page.actions.hide": "Ocultar",
"settings.providers.page.actions.reconnect": "Reconectar", "settings.providers.page.actions.reconnect": "Reconectar",
"settings.providers.page.actions.edit": "Editar",
"settings.providers.page.actions.disconnecting": "Desconectando...", "settings.providers.page.actions.disconnecting": "Desconectando...",
"settings.providers.page.actions.disconnect": "Desconectar", "settings.providers.page.actions.disconnect": "Desconectar",
"settings.providers.page.actions.hideAll": "Ocultar todo", "settings.providers.page.actions.hideAll": "Ocultar todo",
@@ -1221,9 +1221,10 @@ export const settingsDict = {
'settings.providers.page.connect.selectProviderPlaceholder': 'Sélectionnez le fournisseur', 'settings.providers.page.connect.selectProviderPlaceholder': 'Sélectionnez le fournisseur',
'settings.providers.page.connect.searchProvidersPlaceholder': 'Recherche...', 'settings.providers.page.connect.searchProvidersPlaceholder': 'Recherche...',
'settings.providers.page.connect.noProvidersFound': 'Aucun fournisseur trouvé', '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.optionLabel': 'Autre / Personnalisé',
'settings.providers.page.custom.title': 'Fournisseur 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.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.label': 'ID du fournisseur',
'settings.providers.page.custom.field.providerID.placeholder': 'mon-fournisseur', 'settings.providers.page.custom.field.providerID.placeholder': 'mon-fournisseur',
@@ -1237,6 +1238,10 @@ export const settingsDict = {
'settings.providers.page.custom.field.apiKey.label': 'Clé API', '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.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.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.title': 'Modèles',
'settings.providers.page.custom.models.idLabel': 'ID du modèle', 'settings.providers.page.custom.models.idLabel': 'ID du modèle',
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o', 'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
@@ -1254,6 +1259,8 @@ export const settingsDict = {
'settings.providers.page.custom.headers.remove': 'Supprimer l’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.back': 'Retour',
'settings.providers.page.custom.actions.save': 'Enregistrer le fournisseur', '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.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.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.providerID.exists': 'Un fournisseur avec cet ID est déjà connecté',
@@ -1262,6 +1269,10 @@ export const settingsDict = {
'settings.providers.page.custom.error.baseURL.format': 'L’URL de base doit commencer par http:// ou https://', '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.required': 'Obligatoire',
'settings.providers.page.custom.error.duplicate': 'Doublon', '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.title': 'Authentification',
'settings.providers.page.auth.loadingMethods': 'Chargement des méthodes d\'authentification...', 'settings.providers.page.auth.loadingMethods': 'Chargement des méthodes d\'authentification...',
'settings.providers.page.auth.apiKeyLabel': 'Clé API', 'settings.providers.page.auth.apiKeyLabel': 'Clé API',
@@ -1270,6 +1281,10 @@ export const settingsDict = {
'settings.providers.page.auth.oauthMethodFallback': 'Méthode OAuth {index}', 'settings.providers.page.auth.oauthMethodFallback': 'Méthode OAuth {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Coller le code d\'autorisation', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Coller le code d\'autorisation',
'settings.providers.page.auth.connected': 'Connecté', '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.auth.useReconnectHint': '· Utilisez Reconnect pour mettre à jour les informations d\'identification',
'settings.providers.page.connectionDetails.title': 'Détails de connexion', 'settings.providers.page.connectionDetails.title': 'Détails de connexion',
'settings.providers.page.connectionDetails.configuredIn': 'Configuré dans :', 'settings.providers.page.connectionDetails.configuredIn': 'Configuré dans :',
@@ -1299,6 +1314,8 @@ export const settingsDict = {
'settings.providers.page.actions.complete': 'Complet', 'settings.providers.page.actions.complete': 'Complet',
'settings.providers.page.actions.hide': 'Cacher', 'settings.providers.page.actions.hide': 'Cacher',
'settings.providers.page.actions.reconnect': 'Reconnecter', 'settings.providers.page.actions.reconnect': 'Reconnecter',
'settings.providers.page.actions.edit': 'Modifier',
'settings.providers.page.actions.disconnecting': 'Déconnexion...', 'settings.providers.page.actions.disconnecting': 'Déconnexion...',
'settings.providers.page.actions.disconnect': 'Déconnecter', 'settings.providers.page.actions.disconnect': 'Déconnecter',
'settings.providers.page.actions.hideAll': 'Tout cacher', 'settings.providers.page.actions.hideAll': 'Tout cacher',
@@ -1333,9 +1333,10 @@ export const settingsDict = {
'settings.providers.page.connect.selectProviderPlaceholder': 'Provider を選択', 'settings.providers.page.connect.selectProviderPlaceholder': 'Provider を選択',
'settings.providers.page.connect.searchProvidersPlaceholder': '検索...', 'settings.providers.page.connect.searchProvidersPlaceholder': '検索...',
'settings.providers.page.connect.noProvidersFound': 'Provider が見つかりません', 'settings.providers.page.connect.noProvidersFound': 'Provider が見つかりません',
'settings.providers.page.connect.allProvidersConnected': 'すべての Provider が接続されています。',
'settings.providers.page.custom.optionLabel': 'その他 / カスタム', 'settings.providers.page.custom.optionLabel': 'その他 / カスタム',
'settings.providers.page.custom.title': 'カスタムプロバイダー', 'settings.providers.page.custom.title': 'カスタムプロバイダー',
'settings.providers.page.custom.editTitle': 'カスタムプロバイダーを編集',
'settings.providers.page.custom.description': 'ベース URL・認証情報・モデル一覧を指定して、OpenAI 互換プロバイダーを追加します。OpenCode 設定に保存され、他のプロバイダーと同様にチャットで使えます。', 'settings.providers.page.custom.description': 'ベース URL・認証情報・モデル一覧を指定して、OpenAI 互換プロバイダーを追加します。OpenCode 設定に保存され、他のプロバイダーと同様にチャットで使えます。',
'settings.providers.page.custom.field.providerID.label': 'プロバイダー ID', 'settings.providers.page.custom.field.providerID.label': 'プロバイダー ID',
'settings.providers.page.custom.field.providerID.placeholder': 'my-provider', 'settings.providers.page.custom.field.providerID.placeholder': 'my-provider',
@@ -1349,6 +1350,10 @@ export const settingsDict = {
'settings.providers.page.custom.field.apiKey.label': 'API キー', '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.placeholder': 'sk-... または {env:VAR_NAME}',
'settings.providers.page.custom.field.apiKey.info': 'OpenChamber ではなく OpenCode の認証に保存されます。環境変数から読む場合は {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.title': 'モデル',
'settings.providers.page.custom.models.idLabel': 'モデル ID', 'settings.providers.page.custom.models.idLabel': 'モデル ID',
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o', 'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
@@ -1366,6 +1371,8 @@ export const settingsDict = {
'settings.providers.page.custom.headers.remove': 'ヘッダーを削除', 'settings.providers.page.custom.headers.remove': 'ヘッダーを削除',
'settings.providers.page.custom.actions.back': '戻る', 'settings.providers.page.custom.actions.back': '戻る',
'settings.providers.page.custom.actions.save': 'プロバイダーを保存', '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.required': 'プロバイダー ID は必須です',
'settings.providers.page.custom.error.providerID.format': '小文字・数字・ハイフン・アンダースコアを使ってください', 'settings.providers.page.custom.error.providerID.format': '小文字・数字・ハイフン・アンダースコアを使ってください',
'settings.providers.page.custom.error.providerID.exists': 'この ID のプロバイダーは既に接続されています', 'settings.providers.page.custom.error.providerID.exists': 'この ID のプロバイダーは既に接続されています',
@@ -1374,6 +1381,10 @@ export const settingsDict = {
'settings.providers.page.custom.error.baseURL.format': 'ベース URL は http:// または https:// で始めてください', 'settings.providers.page.custom.error.baseURL.format': 'ベース URL は http:// または https:// で始めてください',
'settings.providers.page.custom.error.required': '必須', 'settings.providers.page.custom.error.required': '必須',
'settings.providers.page.custom.error.duplicate': '重複', '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.title': '認証',
'settings.providers.page.auth.loadingMethods': '認証方法を読み込み中...', 'settings.providers.page.auth.loadingMethods': '認証方法を読み込み中...',
'settings.providers.page.auth.apiKeyLabel': 'API キー', 'settings.providers.page.auth.apiKeyLabel': 'API キー',
@@ -1382,6 +1393,10 @@ export const settingsDict = {
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方法 {index}', 'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方法 {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '認証コードを貼り付け', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '認証コードを貼り付け',
'settings.providers.page.auth.connected': '接続済み', '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.auth.useReconnectHint': '· 認証情報を更新するには再接続を使用',
'settings.providers.page.connectionDetails.title': '接続詳細', 'settings.providers.page.connectionDetails.title': '接続詳細',
'settings.providers.page.connectionDetails.configuredIn': '設定場所:', 'settings.providers.page.connectionDetails.configuredIn': '設定場所:',
@@ -1411,6 +1426,8 @@ export const settingsDict = {
'settings.providers.page.actions.complete': '完了', 'settings.providers.page.actions.complete': '完了',
'settings.providers.page.actions.hide': '非表示', 'settings.providers.page.actions.hide': '非表示',
'settings.providers.page.actions.reconnect': '再接続', 'settings.providers.page.actions.reconnect': '再接続',
'settings.providers.page.actions.edit': '編集',
'settings.providers.page.actions.disconnecting': '切断中...', 'settings.providers.page.actions.disconnecting': '切断中...',
'settings.providers.page.actions.disconnect': '切断', 'settings.providers.page.actions.disconnect': '切断',
'settings.providers.page.actions.hideAll': 'すべて非表示', 'settings.providers.page.actions.hideAll': 'すべて非表示',
@@ -1300,9 +1300,10 @@ export const settingsDict = {
'settings.providers.page.connect.selectProviderPlaceholder': '프로바이더 선택', 'settings.providers.page.connect.selectProviderPlaceholder': '프로바이더 선택',
'settings.providers.page.connect.searchProvidersPlaceholder': '검색...', 'settings.providers.page.connect.searchProvidersPlaceholder': '검색...',
'settings.providers.page.connect.noProvidersFound': '프로바이더를 찾을 수 없습니다', 'settings.providers.page.connect.noProvidersFound': '프로바이더를 찾을 수 없습니다',
'settings.providers.page.connect.allProvidersConnected': '모든 프로바이더가 연결되었습니다.',
'settings.providers.page.custom.optionLabel': '기타 / 사용자 정의', 'settings.providers.page.custom.optionLabel': '기타 / 사용자 정의',
'settings.providers.page.custom.title': '사용자 정의 제공자', 'settings.providers.page.custom.title': '사용자 정의 제공자',
'settings.providers.page.custom.editTitle': '사용자 지정 공급자 편집',
'settings.providers.page.custom.description': '기본 URL, 자격 증명, 모델 목록으로 OpenAI 호환 제공자를 추가합니다. OpenCode 설정에 저장되어 다른 제공자와 같이 채팅에서 사용할 수 있습니다.', 'settings.providers.page.custom.description': '기본 URL, 자격 증명, 모델 목록으로 OpenAI 호환 제공자를 추가합니다. OpenCode 설정에 저장되어 다른 제공자와 같이 채팅에서 사용할 수 있습니다.',
'settings.providers.page.custom.field.providerID.label': '제공자 ID', 'settings.providers.page.custom.field.providerID.label': '제공자 ID',
'settings.providers.page.custom.field.providerID.placeholder': 'my-provider', 'settings.providers.page.custom.field.providerID.placeholder': 'my-provider',
@@ -1316,6 +1317,10 @@ export const settingsDict = {
'settings.providers.page.custom.field.apiKey.label': 'API 키', '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.placeholder': 'sk-... 또는 {env:VAR_NAME}',
'settings.providers.page.custom.field.apiKey.info': 'OpenChamber가 아니라 OpenCode 인증에 저장됩니다. 환경 변수에서 읽으려면 {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.title': '모델',
'settings.providers.page.custom.models.idLabel': '모델 ID', 'settings.providers.page.custom.models.idLabel': '모델 ID',
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o', 'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
@@ -1333,6 +1338,8 @@ export const settingsDict = {
'settings.providers.page.custom.headers.remove': '헤더 제거', 'settings.providers.page.custom.headers.remove': '헤더 제거',
'settings.providers.page.custom.actions.back': '뒤로', 'settings.providers.page.custom.actions.back': '뒤로',
'settings.providers.page.custom.actions.save': '제공자 저장', '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.required': '제공자 ID는 필수입니다',
'settings.providers.page.custom.error.providerID.format': '소문자, 숫자, 하이픈, 밑줄을 사용하세요', 'settings.providers.page.custom.error.providerID.format': '소문자, 숫자, 하이픈, 밑줄을 사용하세요',
'settings.providers.page.custom.error.providerID.exists': '이 ID의 제공자가 이미 연결되어 있습니다', 'settings.providers.page.custom.error.providerID.exists': '이 ID의 제공자가 이미 연결되어 있습니다',
@@ -1341,6 +1348,10 @@ export const settingsDict = {
'settings.providers.page.custom.error.baseURL.format': '기본 URL은 http:// 또는 https://로 시작해야 합니다', 'settings.providers.page.custom.error.baseURL.format': '기본 URL은 http:// 또는 https://로 시작해야 합니다',
'settings.providers.page.custom.error.required': '필수', 'settings.providers.page.custom.error.required': '필수',
'settings.providers.page.custom.error.duplicate': '중복', '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.title': '인증',
'settings.providers.page.auth.loadingMethods': '인증 방식 로딩 중...', 'settings.providers.page.auth.loadingMethods': '인증 방식 로딩 중...',
'settings.providers.page.auth.apiKeyLabel': 'API Key', 'settings.providers.page.auth.apiKeyLabel': 'API Key',
@@ -1349,6 +1360,10 @@ export const settingsDict = {
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 방식 {index}', 'settings.providers.page.auth.oauthMethodFallback': 'OAuth 방식 {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'authorization code 붙여넣기', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'authorization code 붙여넣기',
'settings.providers.page.auth.connected': '연결됨', '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.auth.useReconnectHint': '· 인증 정보를 업데이트하려면 Reconnect를 사용하세요',
'settings.providers.page.connectionDetails.title': '연결 세부 정보', 'settings.providers.page.connectionDetails.title': '연결 세부 정보',
'settings.providers.page.connectionDetails.configuredIn': '설정 위치:', 'settings.providers.page.connectionDetails.configuredIn': '설정 위치:',
@@ -1378,6 +1393,8 @@ export const settingsDict = {
'settings.providers.page.actions.complete': '완료', 'settings.providers.page.actions.complete': '완료',
'settings.providers.page.actions.hide': '숨기기', 'settings.providers.page.actions.hide': '숨기기',
'settings.providers.page.actions.reconnect': '재연결', 'settings.providers.page.actions.reconnect': '재연결',
'settings.providers.page.actions.edit': '편집',
'settings.providers.page.actions.disconnecting': '연결 해제 중...', 'settings.providers.page.actions.disconnecting': '연결 해제 중...',
'settings.providers.page.actions.disconnect': '연결 해제', 'settings.providers.page.actions.disconnect': '연결 해제',
'settings.providers.page.actions.hideAll': '모두 숨기기', 'settings.providers.page.actions.hideAll': '모두 숨기기',
@@ -1364,6 +1364,8 @@ export const settingsDict = {
'settings.providers.page.actions.hideAll': 'Ukryj wszystko', 'settings.providers.page.actions.hideAll': 'Ukryj wszystko',
'settings.providers.page.actions.open': 'Otwórz', 'settings.providers.page.actions.open': 'Otwórz',
'settings.providers.page.actions.reconnect': 'Połącz ponownie', '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.saveKey': 'Zapisz klucz',
'settings.providers.page.actions.saving': 'Zapisywanie...', 'settings.providers.page.actions.saving': 'Zapisywanie...',
'settings.providers.page.actions.showAll': 'Pokaż wszystko', 'settings.providers.page.actions.showAll': 'Pokaż wszystko',
@@ -1371,14 +1373,19 @@ export const settingsDict = {
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', '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.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.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.loadingMethods': 'Ładowanie metod uwierzytelniania...',
'settings.providers.page.auth.oauthMethodFallback': 'Metoda OAuth {index}', 'settings.providers.page.auth.oauthMethodFallback': 'Metoda OAuth {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Wklej kod autoryzacyjny', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Wklej kod autoryzacyjny',
'settings.providers.page.auth.title': 'Uwierzytelnianie', 'settings.providers.page.auth.title': 'Uwierzytelnianie',
'settings.providers.page.auth.useReconnectHint': '· Użyj Połącz ponownie, aby zaktualizować dane logowania', '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.optionLabel': 'Inny / Niestandardowy',
'settings.providers.page.custom.title': 'Niestandardowy dostawca', '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.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.label': 'ID dostawcy',
'settings.providers.page.custom.field.providerID.placeholder': 'moj-dostawca', 'settings.providers.page.custom.field.providerID.placeholder': 'moj-dostawca',
@@ -1392,6 +1399,10 @@ export const settingsDict = {
'settings.providers.page.custom.field.apiKey.label': 'Klucz API', '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.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.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.title': 'Modele',
'settings.providers.page.custom.models.idLabel': 'ID modelu', 'settings.providers.page.custom.models.idLabel': 'ID modelu',
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o', 'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
@@ -1409,6 +1420,8 @@ export const settingsDict = {
'settings.providers.page.custom.headers.remove': 'Usuń nagłówek', 'settings.providers.page.custom.headers.remove': 'Usuń nagłówek',
'settings.providers.page.custom.actions.back': 'Wstecz', 'settings.providers.page.custom.actions.back': 'Wstecz',
'settings.providers.page.custom.actions.save': 'Zapisz dostawcę', '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.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.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.providerID.exists': 'Dostawca o tym ID jest już połączony',
@@ -1417,6 +1430,10 @@ export const settingsDict = {
'settings.providers.page.custom.error.baseURL.format': 'Adres bazowy musi zaczynać się od http:// lub https://', '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.required': 'Wymagane',
'settings.providers.page.custom.error.duplicate': 'Duplikat', '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.noProvidersFound': 'Nie znaleziono dostawców',
'settings.providers.page.connect.providerField': 'Dostawca', 'settings.providers.page.connect.providerField': 'Dostawca',
'settings.providers.page.connect.searchProvidersPlaceholder': 'Szukaj...', 'settings.providers.page.connect.searchProvidersPlaceholder': 'Szukaj...',
@@ -1300,9 +1300,10 @@ export const settingsDict = {
"settings.providers.page.connect.selectProviderPlaceholder": "Selecionar provedor", "settings.providers.page.connect.selectProviderPlaceholder": "Selecionar provedor",
"settings.providers.page.connect.searchProvidersPlaceholder": "Buscar...", "settings.providers.page.connect.searchProvidersPlaceholder": "Buscar...",
"settings.providers.page.connect.noProvidersFound": "Nenhum provedores", "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.optionLabel": "Outro / Personalizado",
"settings.providers.page.custom.title": "Provedor 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.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.label": "ID do provedor",
"settings.providers.page.custom.field.providerID.placeholder": "meu-provedor", "settings.providers.page.custom.field.providerID.placeholder": "meu-provedor",
@@ -1316,6 +1317,10 @@ export const settingsDict = {
"settings.providers.page.custom.field.apiKey.label": "Chave de API", "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.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.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.title": "Modelos",
"settings.providers.page.custom.models.idLabel": "ID do modelo", "settings.providers.page.custom.models.idLabel": "ID do modelo",
"settings.providers.page.custom.models.idPlaceholder": "gpt-4o", "settings.providers.page.custom.models.idPlaceholder": "gpt-4o",
@@ -1333,6 +1338,8 @@ export const settingsDict = {
"settings.providers.page.custom.headers.remove": "Remover cabeçalho", "settings.providers.page.custom.headers.remove": "Remover cabeçalho",
"settings.providers.page.custom.actions.back": "Voltar", "settings.providers.page.custom.actions.back": "Voltar",
"settings.providers.page.custom.actions.save": "Salvar provedor", "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.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.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.providerID.exists": "Já existe um provedor conectado com este ID",
@@ -1341,6 +1348,10 @@ export const settingsDict = {
"settings.providers.page.custom.error.baseURL.format": "A URL base deve começar com http:// ou https://", "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.required": "Obrigatório",
"settings.providers.page.custom.error.duplicate": "Duplicado", "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.title": "Autenticação",
"settings.providers.page.auth.loadingMethods": "Carregando métodos de autenticação...", "settings.providers.page.auth.loadingMethods": "Carregando métodos de autenticação...",
"settings.providers.page.auth.apiKeyLabel": "Chave API", "settings.providers.page.auth.apiKeyLabel": "Chave API",
@@ -1349,6 +1360,10 @@ export const settingsDict = {
"settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}", "settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}",
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Colar código de autorização", "settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Colar código de autorização",
"settings.providers.page.auth.connected": "Conectado", "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.auth.useReconnectHint": "· Usar Reconnect para atualizar credenciais",
"settings.providers.page.connectionDetails.title": "Detalhes de conexão", "settings.providers.page.connectionDetails.title": "Detalhes de conexão",
"settings.providers.page.connectionDetails.configuredIn": "Configuredo en:", "settings.providers.page.connectionDetails.configuredIn": "Configuredo en:",
@@ -1378,6 +1393,8 @@ export const settingsDict = {
"settings.providers.page.actions.complete": "Completar", "settings.providers.page.actions.complete": "Completar",
"settings.providers.page.actions.hide": "Ocultar", "settings.providers.page.actions.hide": "Ocultar",
"settings.providers.page.actions.reconnect": "Reconectar", "settings.providers.page.actions.reconnect": "Reconectar",
"settings.providers.page.actions.edit": "Editar",
"settings.providers.page.actions.disconnecting": "Desconectando...", "settings.providers.page.actions.disconnecting": "Desconectando...",
"settings.providers.page.actions.disconnect": "Desconectar", "settings.providers.page.actions.disconnect": "Desconectar",
"settings.providers.page.actions.hideAll": "Ocultar todo", "settings.providers.page.actions.hideAll": "Ocultar todo",
@@ -1300,9 +1300,10 @@ export const settingsDict = {
"settings.providers.page.connect.selectProviderPlaceholder": "Виберіть провайдера", "settings.providers.page.connect.selectProviderPlaceholder": "Виберіть провайдера",
"settings.providers.page.connect.searchProvidersPlaceholder": "Пошук...", "settings.providers.page.connect.searchProvidersPlaceholder": "Пошук...",
"settings.providers.page.connect.noProvidersFound": "Немає провайдерів", "settings.providers.page.connect.noProvidersFound": "Немає провайдерів",
"settings.providers.page.connect.allProvidersConnected": "Усі провайдери підключені.",
"settings.providers.page.custom.optionLabel": "Інший / Власний", "settings.providers.page.custom.optionLabel": "Інший / Власний",
"settings.providers.page.custom.title": "Власний провайдер", "settings.providers.page.custom.title": "Власний провайдер",
"settings.providers.page.custom.editTitle": "Редагувати власного провайдера",
"settings.providers.page.custom.description": "Додайте OpenAI-сумісного провайдера з базовою URL-адресою, обліковими даними та списком моделей. Зберігається в конфігурації OpenCode й працює в чаті як будь-який інший провайдер.", "settings.providers.page.custom.description": "Додайте OpenAI-сумісного провайдера з базовою URL-адресою, обліковими даними та списком моделей. Зберігається в конфігурації OpenCode й працює в чаті як будь-який інший провайдер.",
"settings.providers.page.custom.field.providerID.label": "ID провайдера", "settings.providers.page.custom.field.providerID.label": "ID провайдера",
"settings.providers.page.custom.field.providerID.placeholder": "mij-provider", "settings.providers.page.custom.field.providerID.placeholder": "mij-provider",
@@ -1316,6 +1317,10 @@ export const settingsDict = {
"settings.providers.page.custom.field.apiKey.label": "API-ключ", "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.placeholder": "sk-... або {env:VAR_NAME}",
"settings.providers.page.custom.field.apiKey.info": "Зберігається в автентифікації OpenCode, не OpenChamber. Використовуйте {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.title": "Моделі",
"settings.providers.page.custom.models.idLabel": "ID моделі", "settings.providers.page.custom.models.idLabel": "ID моделі",
"settings.providers.page.custom.models.idPlaceholder": "gpt-4o", "settings.providers.page.custom.models.idPlaceholder": "gpt-4o",
@@ -1333,6 +1338,8 @@ export const settingsDict = {
"settings.providers.page.custom.headers.remove": "Видалити заголовок", "settings.providers.page.custom.headers.remove": "Видалити заголовок",
"settings.providers.page.custom.actions.back": "Назад", "settings.providers.page.custom.actions.back": "Назад",
"settings.providers.page.custom.actions.save": "Зберегти провайдера", "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.required": "ID провайдера обов’язковий",
"settings.providers.page.custom.error.providerID.format": "Використовуйте малі літери, цифри, дефіси або підкреслення", "settings.providers.page.custom.error.providerID.format": "Використовуйте малі літери, цифри, дефіси або підкреслення",
"settings.providers.page.custom.error.providerID.exists": "Провайдер із цим ID уже підключено", "settings.providers.page.custom.error.providerID.exists": "Провайдер із цим ID уже підключено",
@@ -1341,6 +1348,10 @@ export const settingsDict = {
"settings.providers.page.custom.error.baseURL.format": "Базова URL-адреса має починатися з http:// або https://", "settings.providers.page.custom.error.baseURL.format": "Базова URL-адреса має починатися з http:// або https://",
"settings.providers.page.custom.error.required": "Обов’язково", "settings.providers.page.custom.error.required": "Обов’язково",
"settings.providers.page.custom.error.duplicate": "Дублікат", "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.title": "Аутентифікація",
"settings.providers.page.auth.loadingMethods": "Завантаження методів автентифікації...", "settings.providers.page.auth.loadingMethods": "Завантаження методів автентифікації...",
"settings.providers.page.auth.apiKeyLabel": "API ключ", "settings.providers.page.auth.apiKeyLabel": "API ключ",
@@ -1349,6 +1360,10 @@ export const settingsDict = {
"settings.providers.page.auth.oauthMethodFallback": "OAuth метод {index}", "settings.providers.page.auth.oauthMethodFallback": "OAuth метод {index}",
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Вставити код авторизації", "settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Вставити код авторизації",
"settings.providers.page.auth.connected": "Підключено", "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.auth.useReconnectHint": "· Скористайтеся повторним підключенням, щоб оновити облікові дані",
"settings.providers.page.connectionDetails.title": "Деталі підключення", "settings.providers.page.connectionDetails.title": "Деталі підключення",
"settings.providers.page.connectionDetails.configuredIn": "Налаштовано в:", "settings.providers.page.connectionDetails.configuredIn": "Налаштовано в:",
@@ -1378,6 +1393,8 @@ export const settingsDict = {
"settings.providers.page.actions.complete": "Завершити", "settings.providers.page.actions.complete": "Завершити",
"settings.providers.page.actions.hide": "Сховати", "settings.providers.page.actions.hide": "Сховати",
"settings.providers.page.actions.reconnect": "Перепідключити", "settings.providers.page.actions.reconnect": "Перепідключити",
"settings.providers.page.actions.edit": "Редагувати",
"settings.providers.page.actions.disconnecting": "Відключення...", "settings.providers.page.actions.disconnecting": "Відключення...",
"settings.providers.page.actions.disconnect": "Відключити", "settings.providers.page.actions.disconnect": "Відключити",
"settings.providers.page.actions.hideAll": "Сховати все", "settings.providers.page.actions.hideAll": "Сховати все",
@@ -1300,9 +1300,10 @@ export const settingsDict = {
'settings.providers.page.connect.selectProviderPlaceholder': '选择提供商', 'settings.providers.page.connect.selectProviderPlaceholder': '选择提供商',
'settings.providers.page.connect.searchProvidersPlaceholder': '搜索...', 'settings.providers.page.connect.searchProvidersPlaceholder': '搜索...',
'settings.providers.page.connect.noProvidersFound': '未找到提供商', 'settings.providers.page.connect.noProvidersFound': '未找到提供商',
'settings.providers.page.connect.allProvidersConnected': '所有提供商均已连接。',
'settings.providers.page.custom.optionLabel': '其他 / 自定义', 'settings.providers.page.custom.optionLabel': '其他 / 自定义',
'settings.providers.page.custom.title': '自定义提供商', 'settings.providers.page.custom.title': '自定义提供商',
'settings.providers.page.custom.editTitle': '编辑自定义提供商',
'settings.providers.page.custom.description': '通过指定基础 URL、凭据和模型列表,添加兼容 OpenAI 的提供商。会写入 OpenCode 配置,可像其他提供商一样在聊天中使用。', 'settings.providers.page.custom.description': '通过指定基础 URL、凭据和模型列表,添加兼容 OpenAI 的提供商。会写入 OpenCode 配置,可像其他提供商一样在聊天中使用。',
'settings.providers.page.custom.field.providerID.label': '提供商 ID', 'settings.providers.page.custom.field.providerID.label': '提供商 ID',
'settings.providers.page.custom.field.providerID.placeholder': 'my-provider', 'settings.providers.page.custom.field.providerID.placeholder': 'my-provider',
@@ -1316,6 +1317,10 @@ export const settingsDict = {
'settings.providers.page.custom.field.apiKey.label': 'API 密钥', '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.placeholder': 'sk-... 或 {env:VAR_NAME}',
'settings.providers.page.custom.field.apiKey.info': '保存在 OpenCode 认证中,而非 OpenChamber。使用 {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.title': '模型',
'settings.providers.page.custom.models.idLabel': '模型 ID', 'settings.providers.page.custom.models.idLabel': '模型 ID',
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o', 'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
@@ -1333,6 +1338,8 @@ export const settingsDict = {
'settings.providers.page.custom.headers.remove': '移除请求头', 'settings.providers.page.custom.headers.remove': '移除请求头',
'settings.providers.page.custom.actions.back': '返回', 'settings.providers.page.custom.actions.back': '返回',
'settings.providers.page.custom.actions.save': '保存提供商', '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.required': '提供商 ID 为必填项',
'settings.providers.page.custom.error.providerID.format': '请使用小写字母、数字、连字符或下划线', 'settings.providers.page.custom.error.providerID.format': '请使用小写字母、数字、连字符或下划线',
'settings.providers.page.custom.error.providerID.exists': '已连接具有此 ID 的提供商', 'settings.providers.page.custom.error.providerID.exists': '已连接具有此 ID 的提供商',
@@ -1341,6 +1348,10 @@ export const settingsDict = {
'settings.providers.page.custom.error.baseURL.format': '基础 URL 必须以 http:// 或 https:// 开头', 'settings.providers.page.custom.error.baseURL.format': '基础 URL 必须以 http:// 或 https:// 开头',
'settings.providers.page.custom.error.required': '必填', 'settings.providers.page.custom.error.required': '必填',
'settings.providers.page.custom.error.duplicate': '重复', '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.title': '认证',
'settings.providers.page.auth.loadingMethods': '正在加载认证方式...', 'settings.providers.page.auth.loadingMethods': '正在加载认证方式...',
'settings.providers.page.auth.apiKeyLabel': 'API Key', 'settings.providers.page.auth.apiKeyLabel': 'API Key',
@@ -1349,6 +1360,10 @@ export const settingsDict = {
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方式 {index}', 'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方式 {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '粘贴授权码', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '粘贴授权码',
'settings.providers.page.auth.connected': '已连接', '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.auth.useReconnectHint': '· 使用“重新连接”以更新凭据',
'settings.providers.page.connectionDetails.title': '连接详情', 'settings.providers.page.connectionDetails.title': '连接详情',
'settings.providers.page.connectionDetails.configuredIn': '配置来源:', 'settings.providers.page.connectionDetails.configuredIn': '配置来源:',
@@ -1378,6 +1393,8 @@ export const settingsDict = {
'settings.providers.page.actions.complete': '完成', 'settings.providers.page.actions.complete': '完成',
'settings.providers.page.actions.hide': '隐藏', 'settings.providers.page.actions.hide': '隐藏',
'settings.providers.page.actions.reconnect': '重新连接', 'settings.providers.page.actions.reconnect': '重新连接',
'settings.providers.page.actions.edit': '编辑',
'settings.providers.page.actions.disconnecting': '断开连接中...', 'settings.providers.page.actions.disconnecting': '断开连接中...',
'settings.providers.page.actions.disconnect': '断开连接', 'settings.providers.page.actions.disconnect': '断开连接',
'settings.providers.page.actions.hideAll': '全部隐藏', 'settings.providers.page.actions.hideAll': '全部隐藏',
@@ -1206,9 +1206,10 @@
'settings.providers.page.connect.selectProviderPlaceholder': '選擇供應商', 'settings.providers.page.connect.selectProviderPlaceholder': '選擇供應商',
'settings.providers.page.connect.searchProvidersPlaceholder': '搜尋...', 'settings.providers.page.connect.searchProvidersPlaceholder': '搜尋...',
'settings.providers.page.connect.noProvidersFound': '找不到供應商', 'settings.providers.page.connect.noProvidersFound': '找不到供應商',
'settings.providers.page.connect.allProvidersConnected': '所有供應商均已連線。',
'settings.providers.page.custom.optionLabel': '其他 / 自訂', 'settings.providers.page.custom.optionLabel': '其他 / 自訂',
'settings.providers.page.custom.title': '自訂供應商', 'settings.providers.page.custom.title': '自訂供應商',
'settings.providers.page.custom.editTitle': '編輯自訂提供者',
'settings.providers.page.custom.description': '透過指定基礎 URL、憑證與模型清單,新增相容 OpenAI 的供應商。會寫入 OpenCode 設定,可像其他供應商一樣在聊天中使用。', 'settings.providers.page.custom.description': '透過指定基礎 URL、憑證與模型清單,新增相容 OpenAI 的供應商。會寫入 OpenCode 設定,可像其他供應商一樣在聊天中使用。',
'settings.providers.page.custom.field.providerID.label': '供應商 ID', 'settings.providers.page.custom.field.providerID.label': '供應商 ID',
'settings.providers.page.custom.field.providerID.placeholder': 'my-provider', 'settings.providers.page.custom.field.providerID.placeholder': 'my-provider',
@@ -1222,6 +1223,10 @@
'settings.providers.page.custom.field.apiKey.label': 'API 金鑰', '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.placeholder': 'sk-... 或 {env:VAR_NAME}',
'settings.providers.page.custom.field.apiKey.info': '儲存在 OpenCode 驗證中,而非 OpenChamber。使用 {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.title': '模型',
'settings.providers.page.custom.models.idLabel': '模型 ID', 'settings.providers.page.custom.models.idLabel': '模型 ID',
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o', 'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
@@ -1239,6 +1244,8 @@
'settings.providers.page.custom.headers.remove': '移除標頭', 'settings.providers.page.custom.headers.remove': '移除標頭',
'settings.providers.page.custom.actions.back': '返回', 'settings.providers.page.custom.actions.back': '返回',
'settings.providers.page.custom.actions.save': '儲存供應商', '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.required': '供應商 ID 為必填',
'settings.providers.page.custom.error.providerID.format': '請使用小寫字母、數字、連字號或底線', 'settings.providers.page.custom.error.providerID.format': '請使用小寫字母、數字、連字號或底線',
'settings.providers.page.custom.error.providerID.exists': '已連線具有此 ID 的供應商', 'settings.providers.page.custom.error.providerID.exists': '已連線具有此 ID 的供應商',
@@ -1247,6 +1254,10 @@
'settings.providers.page.custom.error.baseURL.format': '基礎 URL 必須以 http:// 或 https:// 開頭', 'settings.providers.page.custom.error.baseURL.format': '基礎 URL 必須以 http:// 或 https:// 開頭',
'settings.providers.page.custom.error.required': '必填', 'settings.providers.page.custom.error.required': '必填',
'settings.providers.page.custom.error.duplicate': '重複', '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.title': '驗證',
'settings.providers.page.auth.loadingMethods': '正在載入驗證方式...', 'settings.providers.page.auth.loadingMethods': '正在載入驗證方式...',
'settings.providers.page.auth.apiKeyLabel': 'API Key', 'settings.providers.page.auth.apiKeyLabel': 'API Key',
@@ -1255,6 +1266,10 @@
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方式 {index}', 'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方式 {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '貼上授權碼', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '貼上授權碼',
'settings.providers.page.auth.connected': '已連線', '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.auth.useReconnectHint': '· 使用「重新連線」以更新憑證',
'settings.providers.page.connectionDetails.title': '連線詳情', 'settings.providers.page.connectionDetails.title': '連線詳情',
'settings.providers.page.connectionDetails.configuredIn': '設定來源:', 'settings.providers.page.connectionDetails.configuredIn': '設定來源:',
@@ -1284,6 +1299,8 @@
'settings.providers.page.actions.complete': '完成', 'settings.providers.page.actions.complete': '完成',
'settings.providers.page.actions.hide': '隱藏', 'settings.providers.page.actions.hide': '隱藏',
'settings.providers.page.actions.reconnect': '重新連線', 'settings.providers.page.actions.reconnect': '重新連線',
'settings.providers.page.actions.edit': '編輯',
'settings.providers.page.actions.disconnecting': '中斷連線中...', 'settings.providers.page.actions.disconnecting': '中斷連線中...',
'settings.providers.page.actions.disconnect': '中斷連線', 'settings.providers.page.actions.disconnect': '中斷連線',
'settings.providers.page.actions.hideAll': '全部隱藏', 'settings.providers.page.actions.hideAll': '全部隱藏',
+1 -1
View File
@@ -61,7 +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 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`). - 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. - 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`). - Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI-compatible config; requires `env` or stored auth; secrets via OpenCode auth API).
- `opencode-upgrade-runtime.ts` - `opencode-upgrade-runtime.ts`
- Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior. - Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior.
@@ -521,6 +521,7 @@ export async function handleSystemBridgeMessage(
config, config,
workingDirectory, workingDirectory,
normalizedScope, normalizedScope,
{ hasStoredAuth: Boolean(getProviderAuth(providerId)) },
); );
await ctx?.manager?.restart(); await ctx?.manager?.restart();
return { return {
@@ -0,0 +1,176 @@
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);
});
});
+18 -8
View File
@@ -2172,7 +2172,11 @@ const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
const BASE_URL_PATTERN = /^https?:\/\//; const BASE_URL_PATTERN = /^https?:\/\//;
const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible'; const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible';
export const validateCustomProviderConfig = (providerId: string, config: unknown) => { export const validateCustomProviderConfig = (
providerId: string,
config: unknown,
options: { hasStoredAuth?: boolean } = {},
) => {
if (!providerId || typeof providerId !== 'string' || !PROVIDER_ID_PATTERN.test(providerId)) { 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-_]*$/' }; return { ok: false as const, error: 'Provider ID must match /^[a-z0-9][a-z0-9-_]*$/' };
} }
@@ -2191,12 +2195,12 @@ export const validateCustomProviderConfig = (providerId: string, config: unknown
return { ok: false as const, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` }; return { ok: false as const, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` };
} }
const options = isPlainObject(config.options) ? config.options : null; const optionsBlock = isPlainObject(config.options) ? config.options : null;
if (!options) { if (!optionsBlock) {
return { ok: false as const, error: 'Provider options are required' }; return { ok: false as const, error: 'Provider options are required' };
} }
const baseURL = typeof options.baseURL === 'string' ? options.baseURL.trim() : ''; const baseURL = typeof optionsBlock.baseURL === 'string' ? optionsBlock.baseURL.trim() : '';
if (!baseURL) { if (!baseURL) {
return { ok: false as const, error: 'Base URL is required' }; return { ok: false as const, error: 'Base URL is required' };
} }
@@ -2234,8 +2238,9 @@ export const validateCustomProviderConfig = (providerId: string, config: unknown
models: normalizedModels, models: normalizedModels,
}; };
let env: string[] = [];
if (Array.isArray(config.env)) { if (Array.isArray(config.env)) {
const env = config.env env = config.env
.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) .filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
.map((entry) => entry.trim()); .map((entry) => entry.trim());
if (env.length > 0) { if (env.length > 0) {
@@ -2243,9 +2248,13 @@ export const validateCustomProviderConfig = (providerId: string, config: unknown
} }
} }
if (isPlainObject(options.headers)) { 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> = {}; const headers: Record<string, string> = {};
for (const [headerKey, headerValue] of Object.entries(options.headers)) { for (const [headerKey, headerValue] of Object.entries(optionsBlock.headers)) {
if (typeof headerKey !== 'string' || !headerKey.trim()) { if (typeof headerKey !== 'string' || !headerKey.trim()) {
continue; continue;
} }
@@ -2267,8 +2276,9 @@ export const upsertProviderConfig = (
config: unknown, config: unknown,
workingDirectory?: string, workingDirectory?: string,
scope: 'user' | 'project' | 'custom' = 'user', scope: 'user' | 'project' | 'custom' = 'user',
options: { hasStoredAuth?: boolean } = {},
) => { ) => {
const validated = validateCustomProviderConfig(providerId, config); const validated = validateCustomProviderConfig(providerId, config, options);
if (!validated.ok) { if (!validated.ok) {
const error = new Error(validated.error) as Error & { statusCode?: number }; const error = new Error(validated.error) as Error & { statusCode?: number };
error.statusCode = 400; error.statusCode = 400;
@@ -59,8 +59,8 @@ This module provides OpenCode server integration utilities for the web server ru
## Public exports (providers.js) ## Public exports (providers.js)
- `getProviderSources(providerId, workingDirectory)`: Resolves which OpenCode config layers define a provider. - `getProviderSources(providerId, workingDirectory)`: Resolves which OpenCode config layers define a provider.
- `upsertProviderConfig(providerId, config, workingDirectory, scope?)`: 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. - `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`).
- `validateCustomProviderConfig(providerId, config)`: Structural validation for custom provider payloads (id format, http(s) base URL, models). - `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. - `removeProviderConfig(providerId, workingDirectory, scope?)`: Removes a provider block from the selected config layer.
## Public exports (shared.js) ## Public exports (shared.js)
+21 -9
View File
@@ -44,8 +44,11 @@ function getProviderSources(providerId, workingDirectory) {
/** /**
* Validate a custom OpenAI-compatible provider config payload before persistence. * Validate a custom OpenAI-compatible provider config payload before persistence.
* Returns { ok: true, value } or { ok: false, error }. * 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) { function validateCustomProviderConfig(providerId, config, options = {}) {
if (!providerId || typeof providerId !== 'string' || !PROVIDER_ID_PATTERN.test(providerId)) { if (!providerId || typeof providerId !== 'string' || !PROVIDER_ID_PATTERN.test(providerId)) {
return { ok: false, error: 'Provider ID must match /^[a-z0-9][a-z0-9-_]*$/' }; return { ok: false, error: 'Provider ID must match /^[a-z0-9][a-z0-9-_]*$/' };
} }
@@ -64,12 +67,12 @@ function validateCustomProviderConfig(providerId, config) {
return { ok: false, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` }; return { ok: false, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` };
} }
const options = isPlainObject(config.options) ? config.options : null; const optionsBlock = isPlainObject(config.options) ? config.options : null;
if (!options) { if (!optionsBlock) {
return { ok: false, error: 'Provider options are required' }; return { ok: false, error: 'Provider options are required' };
} }
const baseURL = typeof options.baseURL === 'string' ? options.baseURL.trim() : ''; const baseURL = typeof optionsBlock.baseURL === 'string' ? optionsBlock.baseURL.trim() : '';
if (!baseURL) { if (!baseURL) {
return { ok: false, error: 'Base URL is required' }; return { ok: false, error: 'Base URL is required' };
} }
@@ -107,8 +110,9 @@ function validateCustomProviderConfig(providerId, config) {
models: normalizedModels, models: normalizedModels,
}; };
let env = [];
if (Array.isArray(config.env)) { if (Array.isArray(config.env)) {
const env = config.env env = config.env
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0) .filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
.map((entry) => entry.trim()); .map((entry) => entry.trim());
if (env.length > 0) { if (env.length > 0) {
@@ -116,9 +120,17 @@ function validateCustomProviderConfig(providerId, config) {
} }
} }
if (isPlainObject(options.headers)) { 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 = {}; const headers = {};
for (const [headerKey, headerValue] of Object.entries(options.headers)) { for (const [headerKey, headerValue] of Object.entries(optionsBlock.headers)) {
if (typeof headerKey !== 'string' || !headerKey.trim()) { if (typeof headerKey !== 'string' || !headerKey.trim()) {
continue; continue;
} }
@@ -139,8 +151,8 @@ function validateCustomProviderConfig(providerId, config) {
* Persist (create or update) a custom provider block in OpenCode user/project/custom config. * 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. * Does not write secrets — API keys remain in auth.json via the OpenCode auth API.
*/ */
function upsertProviderConfig(providerId, config, workingDirectory, scope = 'user') { function upsertProviderConfig(providerId, config, workingDirectory, scope = 'user', options = {}) {
const validated = validateCustomProviderConfig(providerId, config); const validated = validateCustomProviderConfig(providerId, config, options);
if (!validated.ok) { if (!validated.ok) {
const error = new Error(validated.error); const error = new Error(validated.error);
error.statusCode = 400; error.statusCode = 400;
@@ -50,6 +50,27 @@ describe('custom provider config persistence', () => {
}).ok).toBe(false); }).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', () => { test('upsertProviderConfig writes and round-trips project config', () => {
const result = upsertProviderConfig('campus-llm', { const result = upsertProviderConfig('campus-llm', {
name: 'Campus LLM', name: 'Campus LLM',
@@ -105,6 +126,7 @@ describe('custom provider config persistence', () => {
name: 'Campus LLM', name: 'Campus LLM',
options: { baseURL: 'https://llm.example.edu/v1' }, options: { baseURL: 'https://llm.example.edu/v1' },
models: { b: { name: 'B' } }, models: { b: { name: 'B' } },
env: ['CAMPUS_KEY'],
}, projectDir, 'project'); }, projectDir, 'project');
const written = readJson(configPath); const written = readJson(configPath);
@@ -118,6 +140,7 @@ describe('custom provider config persistence', () => {
name: 'Temp', name: 'Temp',
options: { baseURL: 'https://api.example.com/v1' }, options: { baseURL: 'https://api.example.com/v1' },
models: { m: { name: 'M' } }, models: { m: { name: 'M' } },
env: ['TEMP_KEY'],
}, projectDir, 'project'); }, projectDir, 'project');
expect(getProviderSources('temp-provider', projectDir).sources.project.exists).toBe(true); expect(getProviderSources('temp-provider', projectDir).sources.project.exists).toBe(true);
@@ -131,7 +154,19 @@ describe('custom provider config persistence', () => {
name: 'X', name: 'X',
options: { baseURL: 'not-a-url' }, options: { baseURL: 'not-a-url' },
models: { m: { name: 'M' } }, models: { m: { name: 'M' } },
env: ['X'],
}, projectDir, 'project')).toThrow(/Base URL/); }, projectDir, 'project')).toThrow(/Base URL/);
expect(fs.existsSync(configPath)).toBe(false); 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);
});
}); });
+6 -4
View File
@@ -482,14 +482,16 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
} }
} }
const result = upsertProviderConfig(providerID, config, directory, scope); const { getProviderAuth } = await getAuthLibrary();
const hasStoredAuth = Boolean(getProviderAuth(providerID));
const upsertResult = upsertProviderConfig(providerID, config, directory, scope, { hasStoredAuth });
await refreshOpenCodeAfterConfigChange(`provider ${providerID} upserted (${scope})`); await refreshOpenCodeAfterConfigChange(`provider ${providerID} upserted (${scope})`);
return res.json({ return res.json({
success: true, success: true,
providerId: result.providerId, providerId: upsertResult.providerId,
path: result.path, path: upsertResult.path,
config: result.config, config: upsertResult.config,
requiresReload: true, requiresReload: true,
reloadDelayMs: clientReloadDelayMs, reloadDelayMs: clientReloadDelayMs,
}); });