fix custom provider edit to preserve config scope

Derive the effective OpenCode config layer (custom > project > user) from
provider sources and send it on PUT /api/provider so project/custom edits
update that layer instead of creating a global user override. Resolve
OPENCODE_CONFIG at call time and add UI/web/VS Code coverage for scoped
upserts.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-03 08:22:30 +00:00
co-authored by Serhii Dziupin
parent b7c09ee137
commit 66c5f0cdd4
9 changed files with 271 additions and 14 deletions
@@ -33,8 +33,10 @@ import {
CUSTOM_PROVIDER_ID,
isConfigDefinedCustomProvider,
providerToCustomFormState,
resolveProviderConfigScope,
type CustomProviderFormState,
type CustomProviderPersistPlan,
type ProviderConfigScope,
} from './custom-provider-form';
const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
@@ -184,6 +186,7 @@ export const ProvidersPage: React.FC = () => {
const [showAuthPanel, setShowAuthPanel] = React.useState(false);
const [editingCustomProviderId, setEditingCustomProviderId] = React.useState<string | null>(null);
const [editingCustomFormInitial, setEditingCustomFormInitial] = React.useState<CustomProviderFormState | null>(null);
const [editingCustomScope, setEditingCustomScope] = React.useState<ProviderConfigScope | null>(null);
const [customAuthFailureHint, setCustomAuthFailureHint] = React.useState<string | null>(null);
const [lastCustomPersistId, setLastCustomPersistId] = React.useState<string | null>(null);
const isAddMode = selectedProviderId === ADD_PROVIDER_ID;
@@ -306,6 +309,7 @@ export const ProvidersPage: React.FC = () => {
setShowAuthPanel(true);
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
return;
}
@@ -314,6 +318,7 @@ export const ProvidersPage: React.FC = () => {
if (editingCustomProviderId && editingCustomProviderId !== selectedProviderId) {
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
}
}, [selectedProviderId, editingCustomProviderId]);
@@ -411,7 +416,14 @@ export const ProvidersPage: React.FC = () => {
}
}
const upsertBody = buildProviderUpsertRequest(plan);
const upsertBody = buildProviderUpsertRequest(plan, {
// Create defaults to user. Edit must rewrite the winning config layer
// (custom > project > user) so project/custom providers are not copied
// into a global user override.
scope: editingCustomProviderId
? (editingCustomScope ?? resolveProviderConfigScope(providerSources[editingCustomProviderId]))
: 'user',
});
const response = await runtimeFetch('/api/provider', {
method: 'PUT',
headers: {
@@ -432,6 +444,7 @@ export const ProvidersPage: React.FC = () => {
setCandidateProviderId('');
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
await reloadOpenCodeConfiguration({ scopes: ['providers'], mode: 'active' });
@@ -593,6 +606,7 @@ export const ProvidersPage: React.FC = () => {
await handleDisconnectProvider(providerId);
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
setCandidateProviderId('');
@@ -945,6 +959,7 @@ export const ProvidersPage: React.FC = () => {
onCancel={() => {
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
}}
@@ -975,6 +990,7 @@ export const ProvidersPage: React.FC = () => {
onClick={() => {
setCustomAuthFailureHint(null);
setEditingCustomFormInitial(providerToCustomFormState(selectedProvider));
setEditingCustomScope(resolveProviderConfigScope(selectedSources));
setEditingCustomProviderId(selectedProvider.id);
}}
>
@@ -5,6 +5,7 @@ import {
isConfigDefinedCustomProvider,
isCustomOpenAICompatibleProvider,
providerToCustomFormState,
resolveProviderConfigScope,
validateCustomProvider,
type CustomProviderConfig,
type CustomProviderFormState,
@@ -203,9 +204,22 @@ describe('request construction', () => {
expect(buildProviderUpsertRequest(plan)).toEqual({
providerID: 'custom-provider',
config: plan.config,
scope: 'user',
});
});
test('includes explicit project/custom scope on upsert requests', () => {
const validated = validateCustomProvider({
form: baseForm(),
t,
existingProviderIDs: new Set(),
});
const plan = validated.result!;
expect(buildProviderUpsertRequest(plan, { scope: 'project' }).scope).toBe('project');
expect(buildProviderUpsertRequest(plan, { scope: 'custom' }).scope).toBe('custom');
});
test('omits auth.set when using env credentials', () => {
const validated = validateCustomProvider({
form: baseForm({ apiKey: '{env:MY_KEY}' }),
@@ -309,4 +323,28 @@ describe('provider edit helpers', () => {
project: { exists: false },
})).toBe(true);
});
test('resolveProviderConfigScope follows custom > project > user precedence', () => {
expect(resolveProviderConfigScope(undefined)).toBe('user');
expect(resolveProviderConfigScope({
user: { exists: true },
project: { exists: false },
custom: { exists: false },
})).toBe('user');
expect(resolveProviderConfigScope({
user: { exists: true },
project: { exists: true },
custom: { exists: false },
})).toBe('project');
expect(resolveProviderConfigScope({
user: { exists: true },
project: { exists: true },
custom: { exists: true },
})).toBe('custom');
expect(resolveProviderConfigScope({
user: { exists: false },
project: { exists: false },
custom: { exists: true },
})).toBe('custom');
});
});
@@ -169,6 +169,8 @@ export type ProviderConfigSourcesLike = {
custom?: { exists?: boolean };
};
export type ProviderConfigScope = 'user' | 'project' | 'custom';
/**
* True when a provider both looks OpenAI-compatible-custom and is defined in a
* user/project/custom OpenCode config layer. Catalog-only providers often share
@@ -187,6 +189,22 @@ export function isConfigDefinedCustomProvider(
return inConfigLayer && isCustomOpenAICompatibleProvider(provider);
}
/**
* Effective writable config layer for a provider, matching OpenCode merge
* precedence: custom > project > user.
*/
export function resolveProviderConfigScope(
sources: ProviderConfigSourcesLike | null | undefined,
): ProviderConfigScope {
if (sources?.custom?.exists) {
return 'custom';
}
if (sources?.project?.exists) {
return 'project';
}
return 'user';
}
export function providerToCustomFormState(provider: ProviderLikeForCustomForm): CustomProviderFormState {
const options = provider.options && typeof provider.options === 'object' ? provider.options : {};
const baseURL = typeof options.baseURL === 'string' ? options.baseURL : '';
@@ -373,13 +391,20 @@ export function buildAuthSetRequest(plan: CustomProviderPersistPlan): {
/**
* Builds the OpenChamber provider upsert request body (config persistence).
* `scope` selects the OpenCode config layer (user/project/custom). Create
* defaults to user; edit must pass the provider's effective existing layer.
*/
export function buildProviderUpsertRequest(plan: CustomProviderPersistPlan): {
export function buildProviderUpsertRequest(
plan: CustomProviderPersistPlan,
options?: { scope?: ProviderConfigScope },
): {
providerID: string;
config: CustomProviderConfig;
scope: ProviderConfigScope;
} {
return {
providerID: plan.providerID,
config: plan.config,
scope: options?.scope ?? 'user',
};
}