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:
co-authored by
Serhii Dziupin
parent
b7c09ee137
commit
66c5f0cdd4
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user