Skip to content

Commit 9c181fd

Browse files
Introduce PreparationProvider SPI and retire provider switches (closes #143) (#147)
Provider selection was hardcoded across an enum and two parallel switch statements (router + health checker), and "no-AI / deterministic" was a boolean threaded through the synthesis layer rather than a selectable mode. Adding a provider meant a multi-file edit. Replace this with a PreparationProvider SPI (id + build + check), mirroring the existing RuleChecker pattern: beans are auto-collected into a pure ProviderRegistry and resolved by id. The router and health checker no longer switch over the enum. - Add PreparationProvider SPI and a shared ProviderProbe HTTP helper. - Reimplement Ollama and OpenAI-compatible factories as provider beans; add a first-class deterministic (no-AI) provider that reports ready with no network call. - ProviderRegistry is a pure lookup table; all resolution policy and AUTO probing live in ProviderHealthChecker. Only the AUTO path probes. - Deterministic active yields aiAvailable=false, so synthesis short-circuits before the inert model is invoked; synthesis-disabled resolves to deterministic regardless of the configured provider. - SettingsView renders the provider list from the registry instead of the enum. launchpad.ai.synthesis.enabled remains the config-time kill switch. Scope: LlmProvider kept as the built-in vocabulary; arbitrary external string-id providers deferred to a later migration.
1 parent 72df900 commit 9c181fd

17 files changed

Lines changed: 588 additions & 190 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424
- **Standards-pack schema version**: `standards-pack.yml` requires an integer `schemaVersion`; missing or unsupported versions are rejected on load and surfaced by the MCP standards tools as `incompatible_pack_schema` (closes #84).
2525

2626
### Changed
27+
- **Pluggable preparation-provider SPI**: Providers are now `PreparationProvider` beans (`id()` + `build()` + `check()`) auto-collected into a registry, replacing the hardcoded provider switches in the router and health checker; adding a built-in provider is a new bean rather than a multi-file edit. A first-class `deterministic` (no-AI) provider is selectable and produces deterministic-only output with no network calls; when synthesis is disabled globally the runtime resolves to it regardless of the configured provider. `launchpad.ai.synthesis.enabled` remains the config-time kill switch (closes #143).
2728
- **Preparation-first, provider-pluggable docs**: Rewrote README and docs to frame Launchpad as agent-agnostic and provider-pluggable. "Local-first" is no longer the product identity -- local AI is described as the default option, not the defining trait. Added a Philosophy section to README (agent-agnostic, preparation-first, standards-aware, local-capable not local-limited, complementary to context-aware tools). Renamed "Local-first" architecture principle to "Provider-pluggable", relabeled the pipeline's "Local AI synthesis" stage to "AI synthesis", softened the getting-started AI model prerequisite, and updated the docs index lead sentence (closes #142, supersedes #72).
2829
- **Uniform MCP argument validation**: Every MCP tool now rejects a missing or blank required argument with a single `missing_argument` error (type `INVALID_ARGUMENT`, `details.field` naming the argument) instead of scattered per-field codes or a misleading default-project fallback. Replaces the prior `missing_query`, `missing_path`, and `missing_task` codes (closes #75).
2930
- **Repositioned product headline**: README and positioning now lead with the Preparation Engine and standards-first story instead of contested token-savings claims. Launchpad prepares a repository for AI-assisted development by scanning locally, resolving standards, and emitting grounded context artifacts (closes #71).
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.acltabontabon.launchpad.ai;
2+
3+
import com.acltabontabon.launchpad.config.LaunchpadSettings.Snapshot;
4+
import java.util.List;
5+
import org.springframework.ai.chat.model.ChatModel;
6+
import org.springframework.ai.chat.model.ChatResponse;
7+
import org.springframework.ai.chat.prompt.Prompt;
8+
import org.springframework.core.annotation.Order;
9+
import org.springframework.stereotype.Component;
10+
import reactor.core.publisher.Flux;
11+
12+
/**
13+
* First-class no-AI provider. Selecting it (or disabling synthesis globally)
14+
* produces deterministic-only output with no network calls: {@link #check}
15+
* always reports ready without probing anything.
16+
*
17+
* <p>{@link #build} returns a no-op {@link ChatModel} purely as a safety net.
18+
* The runtime contract is {@code aiAvailable == false} whenever this provider is
19+
* active (see {@code LaunchpadRunner}), so the synthesis layer short-circuits to
20+
* its deterministic fallback before this model is ever invoked.
21+
*/
22+
@Component
23+
@Order(100)
24+
public class DeterministicProvider implements PreparationProvider {
25+
26+
@Override
27+
public String id() {
28+
return LlmProvider.DETERMINISTIC.slug();
29+
}
30+
31+
@Override
32+
public ChatModel build(Snapshot snap) {
33+
return new NoOpChatModel();
34+
}
35+
36+
@Override
37+
public LlmProviderStatus check(Snapshot snap) {
38+
return LlmProviderStatus.deterministic();
39+
}
40+
41+
/** Excluded from AUTO probing - AUTO should never silently land on no-AI mode. */
42+
@Override
43+
public boolean autoDetectable() {
44+
return false;
45+
}
46+
47+
/**
48+
* Inert {@link ChatModel} that yields no content. Never reached on the normal
49+
* path; exists so a stray call cannot blow up an assembling document.
50+
*/
51+
private static final class NoOpChatModel implements ChatModel {
52+
@Override
53+
public ChatResponse call(Prompt prompt) {
54+
return new ChatResponse(List.of());
55+
}
56+
57+
@Override
58+
public Flux<ChatResponse> stream(Prompt prompt) {
59+
return Flux.empty();
60+
}
61+
}
62+
}

src/main/java/com/acltabontabon/launchpad/ai/LlmProvider.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
package com.acltabontabon.launchpad.ai;
22

33
/**
4-
* Local-AI backend Launchpad talks to. {@link #AUTO} defers the choice to
5-
* {@link ProviderHealthChecker#resolveAuto(String, String)} at health-check
6-
* time; the resolved value is surfaced on {@link LlmProviderStatus} so the
7-
* Welcome badge can show what was actually picked.
4+
* Built-in vocabulary of preparation backends Launchpad talks to. Each value's
5+
* {@link #slug()} matches the {@code id()} of a {@link PreparationProvider} bean.
6+
* {@link #AUTO} defers the choice to {@link ProviderHealthChecker} at
7+
* health-check time; the resolved value is surfaced on {@link LlmProviderStatus}
8+
* so the Welcome badge can show what was actually picked. {@link #DETERMINISTIC}
9+
* selects deterministic, no-AI output.
810
*/
911
public enum LlmProvider {
1012
OLLAMA("ollama"),
1113
OPENAI_COMPATIBLE("openai-compatible"),
14+
DETERMINISTIC("deterministic"),
1215
AUTO("auto");
1316

1417
private final String slug;
@@ -36,6 +39,7 @@ public String displayName() {
3639
return switch (this) {
3740
case OLLAMA -> "Ollama";
3841
case OPENAI_COMPATIBLE -> "OpenAI-compatible";
42+
case DETERMINISTIC -> "Deterministic (no AI)";
3943
case AUTO -> "Auto-detect";
4044
};
4145
}

src/main/java/com/acltabontabon/launchpad/ai/LlmProviderRouter.java

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.acltabontabon.launchpad.ai;
22

3-
import com.acltabontabon.launchpad.config.LaunchpadAiProperties;
43
import com.acltabontabon.launchpad.config.LaunchpadSettings;
54
import com.acltabontabon.launchpad.config.LaunchpadSettings.LlmProviderSettingsChanged;
65
import com.acltabontabon.launchpad.config.LaunchpadSettings.Snapshot;
@@ -15,10 +14,10 @@
1514

1615
/**
1716
* Single {@link ChatModel} the rest of Launchpad sees. Holds a volatile delegate
18-
* that points at either the Ollama- or OpenAI-compatible-backed model, depending
19-
* on the current {@link LaunchpadSettings} snapshot. Settings changes published
20-
* through {@link LlmProviderSettingsChanged} swap the delegate in place so the
21-
* user can switch providers without restarting the JVM.
17+
* built by the {@link PreparationProvider} that {@link ProviderHealthChecker}
18+
* resolves for the current {@link LaunchpadSettings} snapshot. Settings changes
19+
* published through {@link LlmProviderSettingsChanged} swap the delegate in place
20+
* so the user can switch providers without restarting the JVM.
2221
* <p>
2322
* The Spring AI autoconfigured {@code ChatClient.Builder} picks up this bean
2423
* (via {@link Primary}) and wraps it, so every {@code chatClient.prompt()...call()}
@@ -28,16 +27,11 @@
2827
@Primary
2928
public class LlmProviderRouter implements ChatModel {
3029

31-
private final OllamaChatModelFactory ollamaFactory;
32-
private final OpenAiCompatibleChatModelFactory openAiFactory;
3330
private final ProviderHealthChecker healthChecker;
3431
private volatile ChatModel delegate;
3532

3633
public LlmProviderRouter(LaunchpadSettings settings,
37-
LaunchpadAiProperties properties,
3834
ProviderHealthChecker healthChecker) {
39-
this.ollamaFactory = new OllamaChatModelFactory(properties);
40-
this.openAiFactory = new OpenAiCompatibleChatModelFactory(properties);
4135
this.healthChecker = healthChecker;
4236
this.delegate = build(settings.snapshot());
4337
}
@@ -63,15 +57,8 @@ public ChatOptions getDefaultOptions() {
6357
}
6458

6559
private ChatModel build(Snapshot snap) {
66-
var concrete = snap.provider() == LlmProvider.AUTO
67-
? healthChecker.resolveAuto(snap.baseUrl(), snap.apiKey())
68-
: snap.provider();
69-
return switch (concrete) {
70-
case OLLAMA -> ollamaFactory.build(snap);
71-
case OPENAI_COMPATIBLE -> openAiFactory.build(snap);
72-
// resolveAuto() never returns AUTO; the fallthrough keeps the
73-
// switch exhaustive and routes to Ollama as a last-resort default.
74-
case AUTO -> ollamaFactory.build(snap);
75-
};
60+
// A concrete provider builds with no network; only the AUTO path probes
61+
// (inside resolve), exactly as before.
62+
return healthChecker.resolve(snap).build(snap);
7663
}
7764
}

src/main/java/com/acltabontabon/launchpad/ai/LlmProviderStatus.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,24 @@ public static LlmProviderStatus ready(LlmProvider provider, String model) {
1818
State.READY, provider, provider.displayName() + " ready - " + model, null);
1919
}
2020

21+
/**
22+
* Ready status for the deterministic (no-AI) provider. Always reachable - it
23+
* makes no network call - but yields deterministic-only output, so the
24+
* runtime treats it as {@code aiAvailable == false}.
25+
*/
26+
public static LlmProviderStatus deterministic() {
27+
return new LlmProviderStatus(
28+
State.READY, LlmProvider.DETERMINISTIC, "Deterministic mode - no AI synthesis", null);
29+
}
30+
2131
public static LlmProviderStatus daemonDown(LlmProvider attempted, String baseUrl) {
2232
String hint = switch (attempted) {
2333
case OLLAMA -> "Run: ollama serve";
2434
case OPENAI_COMPATIBLE -> "Start your OpenAI-compatible server (LM Studio / llama.cpp / vLLM)";
2535
case AUTO -> "Start Ollama (ollama serve) or an OpenAI-compatible server";
36+
// The deterministic provider is always reachable, so it never reaches
37+
// this path; the arm keeps the switch exhaustive.
38+
case DETERMINISTIC -> null;
2639
};
2740
String label = attempted == LlmProvider.AUTO
2841
? "Local AI not reachable at " + baseUrl
@@ -35,6 +48,9 @@ public static LlmProviderStatus modelMissing(LlmProvider provider, String model)
3548
case OLLAMA -> "Run: ollama pull " + model;
3649
case OPENAI_COMPATIBLE -> "Load model '" + model + "' in your local server";
3750
case AUTO -> "Load model '" + model + "' in your local server";
51+
// The deterministic provider has no model to load; the arm keeps the
52+
// switch exhaustive.
53+
case DETERMINISTIC -> null;
3854
};
3955
return new LlmProviderStatus(
4056
State.MODEL_MISSING, provider, "Model '" + model + "' is not loaded", hint);

src/main/java/com/acltabontabon/launchpad/ai/OllamaChatModelFactory.java

Lines changed: 0 additions & 39 deletions
This file was deleted.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package com.acltabontabon.launchpad.ai;
2+
3+
import com.acltabontabon.launchpad.config.LaunchpadAiProperties;
4+
import com.acltabontabon.launchpad.config.LaunchpadSettings.Snapshot;
5+
import org.springframework.ai.chat.model.ChatModel;
6+
import org.springframework.ai.ollama.OllamaChatModel;
7+
import org.springframework.ai.ollama.api.OllamaApi;
8+
import org.springframework.ai.ollama.api.OllamaChatOptions;
9+
import org.springframework.core.annotation.Order;
10+
import org.springframework.stereotype.Component;
11+
12+
/**
13+
* Ollama-backed {@link PreparationProvider}. Builds an {@link OllamaChatModel}
14+
* bound to the configured base URL and model - wrapping every HTTP call with the
15+
* configured connect+read timeouts so a hung daemon cannot freeze the calling
16+
* thread - and probes {@code /api/tags} for health.
17+
*/
18+
@Component
19+
@Order(10)
20+
public class OllamaProvider implements PreparationProvider {
21+
22+
private final HttpTimeouts http;
23+
private final int numCtx;
24+
private final ProviderProbe probe;
25+
26+
public OllamaProvider(LaunchpadAiProperties properties, ProviderProbe probe) {
27+
this.http = new HttpTimeouts(properties);
28+
this.numCtx = properties.ollama().numCtx();
29+
this.probe = probe;
30+
}
31+
32+
@Override
33+
public String id() {
34+
return LlmProvider.OLLAMA.slug();
35+
}
36+
37+
@Override
38+
public ChatModel build(Snapshot snap) {
39+
var api = OllamaApi.builder()
40+
.baseUrl(snap.baseUrl())
41+
.restClientBuilder(http.restClient())
42+
.webClientBuilder(http.webClient())
43+
.build();
44+
var opts = OllamaChatOptions.builder()
45+
.model(snap.model())
46+
.numCtx(numCtx)
47+
.build();
48+
return OllamaChatModel.builder()
49+
.ollamaApi(api)
50+
.defaultOptions(opts)
51+
.build();
52+
}
53+
54+
@Override
55+
public LlmProviderStatus check(Snapshot snap) {
56+
var body = probe.fetch(snap.baseUrl() + "/api/tags", null);
57+
if (body == null) return LlmProviderStatus.daemonDown(LlmProvider.OLLAMA, snap.baseUrl());
58+
return probe.matchModel(body, snap.model(), OllamaProvider::ollamaMatch)
59+
? LlmProviderStatus.ready(LlmProvider.OLLAMA, snap.model())
60+
: LlmProviderStatus.modelMissing(LlmProvider.OLLAMA, snap.model());
61+
}
62+
63+
/** Ollama returns names like "llama3.2:latest" for a "llama3.2" pull. */
64+
private static boolean ollamaMatch(String installed, String configured) {
65+
if (installed.equals(configured)) return true;
66+
int colon = installed.indexOf(':');
67+
return colon > 0 && installed.substring(0, colon).equals(configured);
68+
}
69+
}

src/main/java/com/acltabontabon/launchpad/ai/OpenAiCompatibleChatModelFactory.java renamed to src/main/java/com/acltabontabon/launchpad/ai/OpenAiCompatibleProvider.java

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,22 @@
44
import com.acltabontabon.launchpad.config.LaunchpadSettings.Snapshot;
55
import com.openai.client.okhttp.OpenAIOkHttpClient;
66
import com.openai.client.okhttp.OpenAIOkHttpClientAsync;
7+
import org.springframework.ai.chat.model.ChatModel;
78
import org.springframework.ai.openai.OpenAiChatModel;
89
import org.springframework.ai.openai.OpenAiChatOptions;
10+
import org.springframework.core.annotation.Order;
11+
import org.springframework.stereotype.Component;
912

1013
/**
11-
* Builds an {@link OpenAiChatModel} pointed at any OpenAI-compatible local
12-
* endpoint (LM Studio, llama.cpp server, vLLM, hosted gateways). The api key
13-
* is optional - local servers typically ignore it but the SDK requires a
14-
* non-null string, so we substitute a placeholder when the user leaves it blank.
14+
* OpenAI-compatible {@link PreparationProvider} for any local endpoint (LM
15+
* Studio, llama.cpp server, vLLM, hosted gateways). The api key is optional -
16+
* local servers typically ignore it but the SDK requires a non-null string, so a
17+
* placeholder is substituted when the user leaves it blank. Probes
18+
* {@code /v1/models} for health.
1519
*/
16-
final class OpenAiCompatibleChatModelFactory {
20+
@Component
21+
@Order(20)
22+
public class OpenAiCompatibleProvider implements PreparationProvider {
1723

1824
/**
1925
* Placeholder the OpenAI SDK accepts when the local server does not enforce
@@ -22,12 +28,20 @@ final class OpenAiCompatibleChatModelFactory {
2228
private static final String NO_AUTH_PLACEHOLDER = "not-needed";
2329

2430
private final LaunchpadAiProperties properties;
31+
private final ProviderProbe probe;
2532

26-
OpenAiCompatibleChatModelFactory(LaunchpadAiProperties properties) {
33+
public OpenAiCompatibleProvider(LaunchpadAiProperties properties, ProviderProbe probe) {
2734
this.properties = properties;
35+
this.probe = probe;
2836
}
2937

30-
OpenAiChatModel build(Snapshot snap) {
38+
@Override
39+
public String id() {
40+
return LlmProvider.OPENAI_COMPATIBLE.slug();
41+
}
42+
43+
@Override
44+
public ChatModel build(Snapshot snap) {
3145
var apiKey = snap.hasApiKey() ? snap.apiKey() : NO_AUTH_PLACEHOLDER;
3246
var sync = OpenAIOkHttpClient.builder()
3347
.baseUrl(snap.baseUrl())
@@ -49,4 +63,13 @@ OpenAiChatModel build(Snapshot snap) {
4963
.options(opts)
5064
.build();
5165
}
66+
67+
@Override
68+
public LlmProviderStatus check(Snapshot snap) {
69+
var body = probe.fetch(snap.baseUrl() + "/v1/models", snap.apiKey());
70+
if (body == null) return LlmProviderStatus.daemonDown(LlmProvider.OPENAI_COMPATIBLE, snap.baseUrl());
71+
return probe.matchModel(body, snap.model(), String::equals)
72+
? LlmProviderStatus.ready(LlmProvider.OPENAI_COMPATIBLE, snap.model())
73+
: LlmProviderStatus.modelMissing(LlmProvider.OPENAI_COMPATIBLE, snap.model());
74+
}
5275
}

0 commit comments

Comments
 (0)