mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
88660caec8
commit
bfd84fd5c3
@ -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
|
||||
|
||||
@ -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) {
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user