Skip to content

Commit 549ace5

Browse files
authored
feat(provider): Fix provider_id conflicts and remove connection test in custom provider (agentscope-ai#1234)
1 parent 9d315ce commit 549ace5

4 files changed

Lines changed: 75 additions & 45 deletions

File tree

console/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -387,15 +387,15 @@ export function ProviderConfigModal({
387387

388388
// Validate connection before saving
389389
// For local providers, we might skip this or just check if models exist (which the backend does)
390-
const result = await api.testProviderConnection(provider.id, {
391-
api_key: values.api_key,
392-
base_url: values.base_url,
393-
chat_model: values.chat_model,
394-
});
390+
if (!provider.is_custom) {
391+
const result = await api.testProviderConnection(provider.id, {
392+
api_key: values.api_key,
393+
base_url: values.base_url,
394+
chat_model: values.chat_model,
395+
});
395396

396-
if (!result.success) {
397-
message.error(result.message || t("models.testConnectionFailed"));
398-
if (!provider.is_custom) {
397+
if (!result.success) {
398+
message.error(result.message || t("models.testConnectionFailed"));
399399
// For built-in providers, we want to enforce valid config before saving
400400
return;
401401
}
@@ -502,14 +502,16 @@ export function ProviderConfigModal({
502502
{t("models.revokeAuthorization")}
503503
</Button>
504504
)}
505-
<Button
506-
size="small"
507-
icon={<ApiOutlined />}
508-
onClick={handleTest}
509-
loading={testing}
510-
>
511-
{t("models.testConnection")}
512-
</Button>
505+
{!provider.is_custom && (
506+
<Button
507+
size="small"
508+
icon={<ApiOutlined />}
509+
onClick={handleTest}
510+
loading={testing}
511+
>
512+
{t("models.testConnection")}
513+
</Button>
514+
)}
513515
</div>
514516
<div className={styles.modalFooterRight}>
515517
<Button onClick={onClose}>{t("models.cancel")}</Button>

src/copaw/cli/providers_cmd.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,7 @@ def add_provider_cmd(
503503
"""Add a new custom provider."""
504504
manager = _manager()
505505
try:
506-
asyncio.run(
506+
provider_info = asyncio.run(
507507
manager.add_custom_provider(
508508
ProviderInfo(
509509
id=provider_id,
@@ -518,7 +518,12 @@ def add_provider_cmd(
518518
except ValueError as exc:
519519
click.echo(click.style(f"Error: {exc}", fg="red"))
520520
raise SystemExit(1) from exc
521-
click.echo(f"✓ Custom provider '{name}' ({provider_id}) created.")
521+
click.echo(
522+
"✓ Custom provider "
523+
f"'{provider_info.name}' ({provider_info.id}) created.",
524+
)
525+
if provider_info.id != provider_id:
526+
click.echo(f" requested id: {provider_id}")
522527
if base_url:
523528
click.echo(f" base_url: {base_url}")
524529
click.echo(

src/copaw/providers/provider_manager.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -292,20 +292,31 @@ async def fetch_provider_models(
292292
)
293293
return []
294294

295+
def _resolve_custom_provider_id(self, provider_id: str) -> str:
296+
"""Resolve provider ID conflicts for a custom provider."""
297+
base_id = provider_id
298+
if base_id in self.builtin_providers:
299+
base_id = f"{base_id}-custom"
300+
301+
resolved_id = base_id
302+
while (
303+
resolved_id in self.builtin_providers
304+
or resolved_id in self.custom_providers
305+
):
306+
resolved_id = f"{resolved_id}-new"
307+
308+
return resolved_id
309+
295310
async def add_custom_provider(self, provider_data: ProviderInfo):
296311
# Add a new custom provider with the given data. This will update the
297312
# providers.json file and make the new provider available in the UI.
298-
if provider_data.id in self.builtin_providers:
299-
raise ValueError(
300-
f"'{provider_data.id}' conflicts with a built-in provider.",
301-
)
302-
if provider_data.id in self.custom_providers:
303-
raise ValueError(
304-
f"Custom provider '{provider_data.id}' already exists.",
305-
)
306-
provider_data.is_custom = True
313+
provider_payload = provider_data.model_dump()
314+
provider_payload["id"] = self._resolve_custom_provider_id(
315+
provider_data.id,
316+
)
317+
provider_payload["is_custom"] = True
307318
provider = self._provider_from_data(
308-
provider_data.model_dump(),
319+
provider_payload,
309320
) # Validate provider data
310321
self.custom_providers[provider.id] = provider
311322
self._save_provider(provider, is_builtin=False)

tests/unit/providers/test_provider_manager.py

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -95,30 +95,33 @@ async def test_add_custom_provider_and_reload_from_storage(
9595
models=[ModelInfo(id="custom-model", name="Custom Model")],
9696
)
9797

98-
await manager.add_custom_provider(custom)
99-
with pytest.raises(ValueError, match="conflicts with a built-in provider"):
100-
await manager.add_custom_provider(
101-
OpenAIProvider(
102-
id="openai",
103-
name="Conflict OpenAI",
104-
),
105-
)
106-
107-
with pytest.raises(
108-
ValueError,
109-
match="Custom provider 'custom-openai' already exists",
110-
):
111-
await manager.add_custom_provider(custom)
98+
created = await manager.add_custom_provider(custom)
99+
builtin_conflict = await manager.add_custom_provider(
100+
OpenAIProvider(
101+
id="openai",
102+
name="Conflict OpenAI",
103+
),
104+
)
105+
duplicate = await manager.add_custom_provider(custom)
112106

113107
reloaded = ProviderManager()
114108
loaded = reloaded.get_provider("custom-openai")
109+
loaded_builtin_conflict = reloaded.get_provider("openai-custom")
110+
loaded_duplicate = reloaded.get_provider("custom-openai-new")
115111

112+
assert created.id == "custom-openai"
113+
assert builtin_conflict.id == "openai-custom"
114+
assert duplicate.id == "custom-openai-new"
116115
assert loaded is not None
117116
assert isinstance(loaded, OpenAIProvider)
118117
assert loaded.is_custom is True
119118
assert loaded.base_url == "https://custom.example/v1"
120119
assert loaded.api_key == "sk-custom"
121120
assert [m.id for m in loaded.models] == ["custom-model"]
121+
assert loaded_builtin_conflict is not None
122+
assert isinstance(loaded_builtin_conflict, OpenAIProvider)
123+
assert loaded_duplicate is not None
124+
assert isinstance(loaded_duplicate, OpenAIProvider)
122125

123126

124127
async def test_activate_provider_persists_active_model(
@@ -222,7 +225,7 @@ def test_migrate_legacy_file_and_persist_active_model(
222225
assert active_model_file.exists()
223226

224227

225-
async def test_add_custom_provider_conflict_with_builtin_raises(
228+
async def test_add_custom_provider_conflict_resolution_loops_until_unique(
226229
isolated_secret_dir,
227230
) -> None:
228231
manager = ProviderManager()
@@ -231,8 +234,17 @@ async def test_add_custom_provider_conflict_with_builtin_raises(
231234
name="Conflict OpenAI",
232235
)
233236

234-
with pytest.raises(ValueError, match="conflicts with a built-in provider"):
235-
await manager.add_custom_provider(conflict)
237+
first = await manager.add_custom_provider(conflict)
238+
second = await manager.add_custom_provider(conflict)
239+
third = await manager.add_custom_provider(conflict)
240+
241+
assert first.id == "openai-custom"
242+
assert second.id == "openai-custom-new"
243+
assert third.id == "openai-custom-new-new"
244+
245+
assert manager.get_provider("openai-custom") is not None
246+
assert manager.get_provider("openai-custom-new") is not None
247+
assert manager.get_provider("openai-custom-new-new") is not None
236248

237249

238250
def test_update_provider_for_builtin_persists_to_builtin_path(

0 commit comments

Comments
 (0)