fix(models): 成员角色可读取自己能绑定的 provider 选项 (#570)

绑定 Agent 首选 provider 是 member 权限的操作,但唯一能列出 provider 的
GET /api/v1/models 要求全局 admin(它带 base URL、请求参数、liveness 诊断等
连接配置)—— 成员写得了绑定,却读不到可选项。

员工编辑弹窗把这个 admin-only 请求放在没有单独兜底的 Promise.all 里,成员
打开时 403 会让整批请求失败:技能、工具、已有绑定、首选模型链全部加载不出来,
弹出通用加载错误。

新增 GET /api/v1/models/options —— 只返回 provider 的 id 和显示名,viewer
权限,过滤掉未配置项 —— 并让员工编辑弹窗改用它。/api/v1/models 保持全局
admin 限制不变。
This commit is contained in:
mateaix 2026-08-01 22:12:47 +08:00
parent 88660caec8
commit bfd84fd5c3
6 changed files with 182 additions and 3 deletions

View File

@ -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<List<ProviderOptionDTO>> options() {
return R.ok(modelProviderService.listProviderOptions());
}
@Operation(summary = "RFC-074: 获取 Provider 全量目录(含未启用),供 Add Provider 抽屉使用")
@GetMapping("/catalog")
@RequireGlobalAdmin

View File

@ -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.
* <p>
* 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) {
}

View File

@ -101,6 +101,21 @@ public class ModelProviderService {
return listProvidersInternal(false);
}
/**
* Enabled providers that are actually usable, reduced to id + display name.
* <p>
* 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<ProviderOptionDTO> listProviderOptions() {
return listProviders().stream()
.filter(p -> Boolean.TRUE.equals(p.getConfigured()))
.map(p -> new ProviderOptionDTO(p.getId(), p.getName()))
.toList();
}
private List<ProviderInfoDTO> listProvidersInternal(boolean enabledOnly) {
LambdaQueryWrapper<ModelProviderEntity> qw = new LambdaQueryWrapper<>();
if (enabledOnly) {

View File

@ -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<ClaudeCodeOAuthService> 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<ProviderInitProbe> 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<ProviderOptionDTO> 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<String> 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<ModelConfigEntity> 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;
}
}

View File

@ -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'),

View File

@ -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)