diff --git a/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java b/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java index b1859809..dfed1400 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java @@ -46,6 +46,18 @@ public class ModelConfigController { return R.ok(modelProviderService.listProviders()); } + /** + * Provider id + display name only. {@link #list()} stays admin-only because + * it carries connection settings; binding an agent to a preferred provider + * is a member action, so members need to read the choices from here. + */ + @Operation(summary = "获取 Provider 选项(仅 id/名称,不含连接配置)") + @GetMapping("/options") + @RequireWorkspaceRole("viewer") + public R> options() { + return R.ok(modelProviderService.listProviderOptions()); + } + @Operation(summary = "RFC-074: 获取 Provider 全量目录(含未启用),供 Add Provider 抽屉使用") @GetMapping("/catalog") @RequireGlobalAdmin diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderOptionDTO.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderOptionDTO.java new file mode 100644 index 00000000..a9ecfb20 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderOptionDTO.java @@ -0,0 +1,17 @@ +package vip.mate.llm.model; + +/** + * Credential-free projection of a configured provider: just enough to render a + * picker and store the chosen id. + *

+ * The full {@link ProviderInfoDTO} carries connection settings (base URL, the + * masked key, request kwargs, liveness diagnostics) and is therefore only + * served to global admins. Binding an agent to a preferred provider is a + * workspace-member action, so the member has to be able to read the list of + * choices — this DTO is what that read returns. + * + * @param id provider id, the value persisted on the agent binding + * @param name display name shown in the picker + */ +public record ProviderOptionDTO(String id, String name) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java index 8b05972b..7cb8e142 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java @@ -101,6 +101,21 @@ public class ModelProviderService { return listProvidersInternal(false); } + /** + * Enabled providers that are actually usable, reduced to id + display name. + *

+ * Feeds the agent's preferred-provider picker, which workspace members may + * edit. They cannot read the full provider list (it carries connection + * settings), so this projection is what makes the choices visible without + * widening that exposure. + */ + public List listProviderOptions() { + return listProviders().stream() + .filter(p -> Boolean.TRUE.equals(p.getConfigured())) + .map(p -> new ProviderOptionDTO(p.getId(), p.getName())) + .toList(); + } + private List listProvidersInternal(boolean enabledOnly) { LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); if (enabledOnly) { diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceOptionsTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceOptionsTest.java new file mode 100644 index 00000000..1d5531c5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceOptionsTest.java @@ -0,0 +1,129 @@ +package vip.mate.llm.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService; +import vip.mate.llm.failover.AvailableProviderPool; +import vip.mate.llm.failover.ProviderHealthProperties; +import vip.mate.llm.failover.ProviderHealthTracker; +import vip.mate.llm.failover.ProviderInitProbe; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.model.ProviderOptionDTO; +import vip.mate.llm.repository.ModelProviderMapper; + +import java.lang.reflect.RecordComponent; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * The provider-options projection that workspace members read when they bind an + * agent to a preferred provider. The full provider list stays admin-only, so + * these tests pin the two properties that make the narrower endpoint safe and + * useful: it carries no connection settings, and it lists only providers that + * are actually usable. + */ +class ModelProviderServiceOptionsTest { + + private ModelProviderMapper providerMapper; + private ModelConfigService modelConfigService; + private AvailableProviderPool pool; + + private ModelProviderService service; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + providerMapper = mock(ModelProviderMapper.class); + modelConfigService = mock(ModelConfigService.class); + ApplicationEventPublisher eventPublisher = mock(ApplicationEventPublisher.class); + ObjectProvider claudeCodeOAuthProvider = mock(ObjectProvider.class); + when(claudeCodeOAuthProvider.getIfAvailable()).thenReturn(null); + pool = new AvailableProviderPool(); + ProviderHealthProperties props = new ProviderHealthProperties(); + props.setFailureThreshold(1); + ProviderHealthTracker healthTracker = new ProviderHealthTracker(props); + ProviderInitProbe initProbe = mock(ProviderInitProbe.class); + ObjectProvider initProbeProvider = mock(ObjectProvider.class); + when(initProbeProvider.getIfAvailable()).thenReturn(initProbe); + when(initProbe.hasBeenProbed(any())).thenReturn(true); + + service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher, + claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider); + } + + @Test + @DisplayName("configured providers surface as id + display name") + void configuredProvidersBecomeOptions() { + ModelProviderEntity openai = cloud("openai", "OpenAI"); + openai.setApiKey("sk-test-1234567890"); + seedProviders(openai); + pool.add("openai"); + + List options = service.listProviderOptions(); + + assertThat(options).containsExactly(new ProviderOptionDTO("openai", "OpenAI")); + } + + @Test + @DisplayName("a provider without credentials is not offered as a choice") + void unconfiguredProviderIsFilteredOut() { + ModelProviderEntity kimi = cloud("kimi", "Kimi"); + kimi.setApiKey(""); + seedProviders(kimi); + + assertThat(service.listProviderOptions()).isEmpty(); + } + + @Test + @DisplayName("the option carries no credential or connection field") + void optionExposesNoConnectionSettings() { + // Members reach this projection; the guarantee is structural, so assert + // on the record's shape rather than on one serialized instance. + List fields = Arrays.stream(ProviderOptionDTO.class.getRecordComponents()) + .map(RecordComponent::getName) + .toList(); + + assertThat(fields).containsExactly("id", "name"); + assertThat(fields).noneSatisfy(f -> { + String lower = f.toLowerCase(Locale.ROOT); + assertThat(lower).containsAnyOf("key", "url", "token", "secret"); + }); + } + + private void seedProviders(ModelProviderEntity... rows) { + for (ModelProviderEntity p : rows) { + if (p.getEnabled() == null) p.setEnabled(true); + } + when(providerMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(rows)); + List models = Arrays.stream(rows).map(p -> { + ModelConfigEntity m = new ModelConfigEntity(); + m.setProvider(p.getProviderId()); + m.setModelName(p.getProviderId() + "-model"); + m.setName(p.getProviderId() + "-model"); + m.setBuiltin(true); + return m; + }).toList(); + when(modelConfigService.listModels()).thenReturn(models); + } + + private static ModelProviderEntity cloud(String id, String name) { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId(id); + p.setName(name); + p.setIsLocal(false); + p.setIsCustom(false); + p.setRequireApiKey(true); + return p; + } +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 6e08100e..ae7d0991 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -571,6 +571,10 @@ export const planApi = { // ==================== Model ==================== export const modelApi = { listProviders: () => http.get('/models'), + // Provider id + name only. /models carries connection settings and is + // admin-only, so anything a workspace member can reach (the agent's + // preferred-provider picker) has to read the choices from here. + listProviderOptions: () => http.get('/models/options'), listEnabled: () => http.get('/models/enabled'), get: (id: string | number) => http.get(`/models/${id}`), getDefault: () => http.get('/models/default'), diff --git a/mateclaw-ui/src/views/Agents.vue b/mateclaw-ui/src/views/Agents.vue index 242ed5b6..9a0dd843 100644 --- a/mateclaw-ui/src/views/Agents.vue +++ b/mateclaw-ui/src/views/Agents.vue @@ -1365,7 +1365,9 @@ async function openEditModal(agent: Agent) { // tool grouped by server, with stale/available flags so the picker // matches the runtime callback set exactly. toolApi.listAvailable(), - modelApi.listProviders(), + // Options, not the full provider list: /models is admin-only, and a + // workspace member editing an agent would 403 and fail this whole batch. + modelApi.listProviderOptions(), agentBindingApi.listSkills(agent.id), agentBindingApi.listTools(agent.id), agentBindingApi.listProviderPreferences(agent.id), @@ -1373,9 +1375,9 @@ async function openEditModal(agent: Agent) { availableSkills.value = (skillsRes as any).data || [] availableTools.value = (toolsRes as any).data || [] // Pool of providers the user has actually configured — no point letting an - // agent prefer a provider that doesn't exist on this deployment. + // agent prefer a provider that doesn't exist on this deployment. The + // options endpoint already drops unconfigured rows. availableProviders.value = ((providersRes as any).data || []) - .filter((p: any) => p.configured) .map((p: any) => ({ id: p.id, name: p.name })) selectedSkillIds.value = ((boundSkillsRes as any).data || []) .filter((b: any) => b.enabled)