Skip to content

Commit 66a4475

Browse files
arefbehboudiclaude
andcommitted
Add multi-model provider support and an agents page
- Named LLM providers under `agent.llm.providers` (LlmProviderProperties) with a per-provider ChatModelFactory SPI (OpenAI, Anthropic incl. Claude Code OAuth, Ollama, Google Gen AI) and a ChatClientRegistry that builds/caches a ChatClient per provider and rebuilds on configuration refresh. - Hot-reload of configuration without a full restart (ConfigurationRebinder + ConfigurationRefreshedEvent; ConfigurationManager now tracks changed keys and supports removeProperty). - Main agent ChatClient delegates to the default provider and rebuilds on refresh (MainChatClientProvider + DelegatingChatClient); subagent routing wired via the agent-utils TaskTool. - Agents page + REST API (/api/agents): create/edit/delete agents; structured config (provider type, model, API key) is saved to application.yaml under agent.llm.providers.<name>, and description + instructions to workspace/agents/<name>.md. - ProviderConnectionTester + /api/providers REST; onboarding now saves the selected provider as providers.default. - Tests for registry fallback, connection tester, and the agent/provider controllers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ce93f7b commit 66a4475

34 files changed

Lines changed: 2725 additions & 101 deletions

‎app/src/main/java/ai/javaclaw/JavaClawApplication.java‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,18 @@ public void run(ApplicationArguments args) throws Exception {
4444

4545
@EventListener
4646
public void on(ConfigurationChangedEvent configurationChangedEvent) {
47+
// Model-provider changes are applied at runtime via ConfigurationRebinder +
48+
// ChatClientRegistry (hot reload), so they must NOT trigger a full restart.
49+
boolean providerChangeOnly = !configurationChangedEvent.changedKeys().isEmpty()
50+
&& configurationChangedEvent.changedKeys().stream()
51+
.allMatch(key -> key.equals("agent.llm.providers") || key.startsWith("agent.llm.providers."));
52+
if (providerChangeOnly) {
53+
log.info("Configuration change limited to model providers; applying without restart.");
54+
return;
55+
}
56+
4757
ApplicationArguments args = applicationContext.getBean(ApplicationArguments.class);
48-
58+
4959
Thread thread = new Thread(() -> {
5060
try {
5161
Thread.sleep(2000);

‎app/src/main/java/ai/javaclaw/onboarding/steps/S2_ProviderStep.java‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package ai.javaclaw.onboarding.steps;
22

33
import ai.javaclaw.configuration.ConfigurationManager;
4+
import ai.javaclaw.llm.LlmProviderProperties;
45
import ai.javaclaw.onboarding.AgentOnboardingProvider;
56
import ai.javaclaw.onboarding.AgentOnboardingProviders;
67
import ai.javaclaw.onboarding.OnboardingProvider;
@@ -63,14 +64,24 @@ public String processStep(Map<String, String> formParams, Map<String, Object> se
6364
@Override
6465
public void saveConfiguration(Map<String, Object> session, ConfigurationManager configurationManager) throws IOException {
6566
String providerId = (String) session.get(SESSION_PROVIDER);
67+
if (providerId == null || providerId.isBlank()) {
68+
return;
69+
}
6670
String model = (String) session.get(SESSION_MODEL);
6771
String apiKey = (String) session.getOrDefault(SESSION_API_KEY, "");
6872

6973
AgentOnboardingProvider agentOnboardingProvider = agentOnboardingProviders.getById(providerId);
74+
75+
// The single provider configured during onboarding becomes the "default" named provider.
76+
String base = "agent.llm.providers." + LlmProviderProperties.DEFAULT_PROVIDER_NAME;
7077
Map<String, Object> props = new LinkedHashMap<>();
71-
agentOnboardingProvider.saveProperty(props, "chat.options.model", model);
72-
agentOnboardingProvider.saveProperty(props, "api-key", apiKey);
73-
props.put("spring.ai.model.chat", agentOnboardingProvider.getId().replace(".", "-"));
78+
props.put(base + ".provider", agentOnboardingProvider.getId());
79+
if (model != null && !model.isBlank()) {
80+
props.put(base + ".model", model);
81+
}
82+
if (apiKey != null && !apiKey.isBlank()) {
83+
props.put(base + ".api-key", apiKey);
84+
}
7485
configurationManager.updateProperties(props);
7586
}
7687
}
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
package ai.javaclaw.providers.api;
2+
3+
import ai.javaclaw.configuration.ConfigurationManager;
4+
import ai.javaclaw.llm.ChatModelFactory;
5+
import ai.javaclaw.llm.LlmProviderProperties;
6+
import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig;
7+
import ai.javaclaw.llm.ProviderConnectionTester;
8+
import ai.javaclaw.llm.SubagentReferenceScanner;
9+
import ai.javaclaw.llm.TestResult;
10+
import org.springframework.http.HttpStatus;
11+
import org.springframework.http.ResponseEntity;
12+
import org.springframework.web.bind.annotation.DeleteMapping;
13+
import org.springframework.web.bind.annotation.GetMapping;
14+
import org.springframework.web.bind.annotation.PathVariable;
15+
import org.springframework.web.bind.annotation.PostMapping;
16+
import org.springframework.web.bind.annotation.PutMapping;
17+
import org.springframework.web.bind.annotation.RequestBody;
18+
import org.springframework.web.bind.annotation.RequestMapping;
19+
import org.springframework.web.bind.annotation.RequestParam;
20+
import org.springframework.web.bind.annotation.RestController;
21+
22+
import java.io.IOException;
23+
import java.util.ArrayList;
24+
import java.util.LinkedHashMap;
25+
import java.util.List;
26+
import java.util.Map;
27+
import java.util.Set;
28+
import java.util.concurrent.ConcurrentHashMap;
29+
import java.util.regex.Pattern;
30+
31+
/**
32+
* REST API for managing the named LLM providers under {@code agent.llm.providers}. Saving fires a
33+
* {@code ConfigurationChangedEvent}, which the {@code ConfigurationRebinder} turns into a
34+
* {@code ConfigurationRefreshedEvent} so the {@code ChatClientRegistry} rebuilds affected clients.
35+
*/
36+
@RestController
37+
@RequestMapping("/api/providers")
38+
public class ProviderController {
39+
40+
private static final Pattern NAME_PATTERN = Pattern.compile("[a-z0-9-]+");
41+
private static final String CONFIG_PREFIX = "agent.llm.providers";
42+
private static final long STATUS_CACHE_TTL_MS = 30_000L;
43+
44+
private static final String STATUS_OK = "OK";
45+
private static final String STATUS_UNREACHABLE = "UNREACHABLE";
46+
47+
private final LlmProviderProperties properties;
48+
private final ProviderConnectionTester connectionTester;
49+
private final ConfigurationManager configurationManager;
50+
private final SubagentReferenceScanner subagentScanner;
51+
private final List<ChatModelFactory> chatModelFactories;
52+
53+
private final Map<String, CachedStatus> statusCache = new ConcurrentHashMap<>();
54+
55+
public ProviderController(LlmProviderProperties properties,
56+
ProviderConnectionTester connectionTester,
57+
ConfigurationManager configurationManager,
58+
SubagentReferenceScanner subagentScanner,
59+
List<ChatModelFactory> chatModelFactories) {
60+
this.properties = properties;
61+
this.connectionTester = connectionTester;
62+
this.configurationManager = configurationManager;
63+
this.subagentScanner = subagentScanner;
64+
this.chatModelFactories = chatModelFactories;
65+
}
66+
67+
private Map<String, ProviderConfig> currentProviders() {
68+
return properties.getProviders();
69+
}
70+
71+
@GetMapping
72+
public List<ProviderDto> list() {
73+
List<ProviderDto> result = new ArrayList<>();
74+
currentProviders().forEach((name, config) ->
75+
result.add(toDto(name, config, cachedStatus(name, config))));
76+
return result;
77+
}
78+
79+
@PostMapping("/test")
80+
public TestResult test(@RequestBody ProviderForm form) {
81+
return connectionTester.test(form.toConfig());
82+
}
83+
84+
@PostMapping
85+
public ResponseEntity<?> create(@RequestBody ProviderForm form) {
86+
String name = form.name() == null ? "" : form.name().trim();
87+
if (name.isBlank() || !NAME_PATTERN.matcher(name).matches()) {
88+
return badRequest("Provider name must match [a-z0-9-]+");
89+
}
90+
if (LlmProviderProperties.DEFAULT_PROVIDER_NAME.equals(name)) {
91+
return badRequest("The 'default' provider is created during onboarding and can only be edited");
92+
}
93+
if (currentProviders().containsKey(name)) {
94+
return badRequest("A provider named '" + name + "' already exists");
95+
}
96+
if (!isKnownProviderType(form.provider())) {
97+
return badRequest("Unknown provider type: " + form.provider());
98+
}
99+
100+
TestResult testResult = connectionTester.test(form.toConfig());
101+
if (!testResult.success()) {
102+
return badRequest("Connection test failed: " + testResult.message());
103+
}
104+
105+
save(name, form);
106+
return ResponseEntity.status(HttpStatus.CREATED)
107+
.body(toDto(name, form.toConfig(), STATUS_OK));
108+
}
109+
110+
@PutMapping("/{name}")
111+
public ResponseEntity<?> update(@PathVariable String name, @RequestBody ProviderForm form) {
112+
ProviderConfig existing = currentProviders().get(name);
113+
if (existing == null) {
114+
return ResponseEntity.notFound().build();
115+
}
116+
117+
// Name and provider type are immutable on edit.
118+
String provider = existing.getProvider();
119+
String newApiKey = form.apiKey();
120+
String newBaseUrl = form.baseUrl();
121+
String newModel = form.model();
122+
123+
boolean credentialsChanged =
124+
!equalsNullSafe(newApiKey, existing.getApiKey()) || !equalsNullSafe(newBaseUrl, existing.getBaseUrl());
125+
if (credentialsChanged) {
126+
ProviderConfig candidate = new ProviderConfig(provider, newApiKey, newBaseUrl, newModel);
127+
TestResult testResult = connectionTester.test(candidate);
128+
if (!testResult.success()) {
129+
return badRequest("Connection test failed: " + testResult.message());
130+
}
131+
}
132+
133+
ProviderForm effective = new ProviderForm(name, provider, newModel, newApiKey, newBaseUrl);
134+
save(name, effective);
135+
statusCache.remove(name);
136+
return ResponseEntity.ok(toDto(name, effective.toConfig(), STATUS_OK));
137+
}
138+
139+
@DeleteMapping("/{name}")
140+
public ResponseEntity<?> delete(@PathVariable String name,
141+
@RequestParam(defaultValue = "false") boolean force) {
142+
if (!currentProviders().containsKey(name)) {
143+
return ResponseEntity.notFound().build();
144+
}
145+
if (LlmProviderProperties.DEFAULT_PROVIDER_NAME.equals(name)) {
146+
return badRequest("The default provider cannot be removed");
147+
}
148+
149+
Set<String> referencedBy = subagentScanner.namesReferencing(name);
150+
if (!referencedBy.isEmpty() && !force) {
151+
Map<String, Object> body = new LinkedHashMap<>();
152+
body.put("message", "'" + name + "' is referenced by " + referencedBy.size() + " subagent(s)");
153+
body.put("affected", new ArrayList<>(referencedBy));
154+
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
155+
}
156+
157+
try {
158+
configurationManager.removeProperty(CONFIG_PREFIX + "." + name);
159+
} catch (IOException e) {
160+
return ResponseEntity.internalServerError().body(error("Failed to update configuration: " + e.getMessage()));
161+
}
162+
statusCache.remove(name);
163+
return ResponseEntity.noContent().build();
164+
}
165+
166+
@GetMapping("/{name}/status")
167+
public ResponseEntity<?> status(@PathVariable String name) {
168+
ProviderConfig config = currentProviders().get(name);
169+
if (config == null) {
170+
return ResponseEntity.notFound().build();
171+
}
172+
TestResult result = connectionTester.test(config);
173+
String status = result.success() ? STATUS_OK : STATUS_UNREACHABLE;
174+
statusCache.put(name, new CachedStatus(status, now()));
175+
return ResponseEntity.ok(Map.of("name", name, "status", status, "message", result.message()));
176+
}
177+
178+
// --- helpers ---
179+
180+
private void save(String name, ProviderForm form) {
181+
String base = CONFIG_PREFIX + "." + name;
182+
Map<String, Object> props = new LinkedHashMap<>();
183+
props.put(base + ".provider", form.provider());
184+
if (notBlank(form.model())) props.put(base + ".model", form.model());
185+
if (notBlank(form.apiKey())) props.put(base + ".api-key", form.apiKey());
186+
if (notBlank(form.baseUrl())) props.put(base + ".base-url", form.baseUrl());
187+
try {
188+
configurationManager.updateProperties(props);
189+
} catch (IOException e) {
190+
throw new IllegalStateException("Failed to write provider configuration", e);
191+
}
192+
}
193+
194+
private boolean isKnownProviderType(String type) {
195+
if (type == null || type.isBlank()) {
196+
return false;
197+
}
198+
String normalized = type.trim().toLowerCase();
199+
return chatModelFactories.stream().anyMatch(f -> f.supports(normalized));
200+
}
201+
202+
private String cachedStatus(String name, ProviderConfig config) {
203+
CachedStatus cached = statusCache.get(name);
204+
if (cached != null && (now() - cached.timestamp()) < STATUS_CACHE_TTL_MS) {
205+
return cached.status();
206+
}
207+
TestResult result = connectionTester.test(config);
208+
String status = result.success() ? STATUS_OK : STATUS_UNREACHABLE;
209+
statusCache.put(name, new CachedStatus(status, now()));
210+
return status;
211+
}
212+
213+
private ProviderDto toDto(String name, ProviderConfig config, String status) {
214+
boolean isDefault = LlmProviderProperties.DEFAULT_PROVIDER_NAME.equals(name);
215+
return new ProviderDto(name, config.getProvider(), config.getModel(),
216+
config.getBaseUrl(), maskApiKey(config.getApiKey()), isDefault, status);
217+
}
218+
219+
private static String maskApiKey(String apiKey) {
220+
if (apiKey == null || apiKey.isBlank()) {
221+
return "";
222+
}
223+
String key = apiKey.trim();
224+
if (key.length() <= 7) {
225+
return "••••";
226+
}
227+
return key.substring(0, 3) + "..." + key.substring(key.length() - 4);
228+
}
229+
230+
private static boolean notBlank(String value) {
231+
return value != null && !value.isBlank();
232+
}
233+
234+
private static boolean equalsNullSafe(String a, String b) {
235+
String na = a == null ? "" : a;
236+
String nb = b == null ? "" : b;
237+
return na.equals(nb);
238+
}
239+
240+
private static long now() {
241+
return System.currentTimeMillis();
242+
}
243+
244+
private static ResponseEntity<Map<String, Object>> badRequest(String message) {
245+
return ResponseEntity.badRequest().body(error(message));
246+
}
247+
248+
private static Map<String, Object> error(String message) {
249+
Map<String, Object> body = new LinkedHashMap<>();
250+
body.put("message", message);
251+
return body;
252+
}
253+
254+
private record CachedStatus(String status, long timestamp) {
255+
}
256+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package ai.javaclaw.providers.api;
2+
3+
/**
4+
* Read model for a configured provider as returned by {@code GET /api/providers}. Never carries the
5+
* raw API key — only a masked form for display.
6+
*
7+
* @param name provider name (the key under {@code agent.llm.providers})
8+
* @param provider provider type (openai | anthropic | ollama | google.genai | ...)
9+
* @param model configured model id
10+
* @param baseUrl base URL (relevant for ollama / self-hosted)
11+
* @param apiKeyMasked masked API key for cloud providers, e.g. {@code sk-...a1b2}; empty otherwise
12+
* @param isDefault whether this is the reserved {@code default} provider
13+
* @param status live reachability: {@code OK}, {@code UNREACHABLE}, or {@code CHECKING}
14+
*/
15+
public record ProviderDto(
16+
String name,
17+
String provider,
18+
String model,
19+
String baseUrl,
20+
String apiKeyMasked,
21+
boolean isDefault,
22+
String status) {
23+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package ai.javaclaw.providers.api;
2+
3+
import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig;
4+
5+
/**
6+
* Write model for creating/updating a provider and for the standalone connection test. Carries the
7+
* raw API key (request bodies only — never serialized back to the client).
8+
*/
9+
public record ProviderForm(
10+
String name,
11+
String provider,
12+
String model,
13+
String apiKey,
14+
String baseUrl) {
15+
16+
public ProviderConfig toConfig() {
17+
return new ProviderConfig(provider, apiKey, baseUrl, model);
18+
}
19+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package ai.javaclaw.providers.api;
2+
3+
import org.springframework.stereotype.Controller;
4+
import org.springframework.web.bind.annotation.GetMapping;
5+
6+
/**
7+
* Serves the post-onboarding settings pages. The Subagents page is a thin shell that talks to
8+
* {@link SubagentController} (and reads provider names from {@link ProviderController}) via fetch.
9+
*/
10+
@Controller
11+
public class SettingsController {
12+
13+
@GetMapping("/settings/agents")
14+
public String agents() {
15+
return "settings/agents";
16+
}
17+
}

0 commit comments

Comments
 (0)