Skip to content

Commit fc2226c

Browse files
Add opt-in Anthropic (Claude) cloud preparation provider (closes #144) (#148)
Ship the first paid/cloud synthesis backend on the PreparationProvider SPI introduced in #143. Until now every provider was local/free (Ollama, OpenAI-compatible, deterministic), so teams wanting higher-quality synthesis from a preferred paid provider could not select one. AnthropicProvider is a single new SPI bean - no router or health-checker changes. Per the philosophy guardrails, local stays the default and the cloud provider is explicit-selection only: autoDetectable() returns false, so auto-detection never routes preparation to a paid API even when a key is set. - Vendor-specific auth: launchpad.ai.anthropic.api-key (bound from LAUNCHPAD_ANTHROPIC_API_KEY), falling back to the shared key; its own cloud base-url and anthropic-version, decoupled from the local-oriented settings. - Graceful degradation: a missing key short-circuits with a clear "API key is not configured" status (no network call), and an unreachable or unauthorized endpoint reports not-ready - both degrade to deterministic-only output before generation runs, never crashing. - Health probe reuses ProviderProbe via a new header-map fetch overload (x-api-key + anthropic-version); model matching is exact with a narrow -latest alias tolerance. Spring AI 2.0.0-M6 wraps the official Anthropic Java SDK (AnthropicClient), so the provider mirrors the OpenAI provider's explicit sync+async client wiring. Adds the Spring AI Anthropic starter, the ANTHROPIC provider vocabulary, a nested Anthropic config record, provider-text status factories with generic default switch arms, docs notes, a CHANGELOG entry, and tests (dedicated provider tests plus registry/health-checker guardrails proving Anthropic is listed/selectable but never chosen by auto-detection).
1 parent 9c181fd commit fc2226c

15 files changed

Lines changed: 427 additions & 14 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99
### Added
10+
- **Anthropic (Claude) cloud preparation provider**: An opt-in, paid synthesis backend on the `PreparationProvider` SPI (`provider=anthropic`), shipped via the Spring AI Anthropic starter. It is explicit-selection only - `autoDetectable()` is `false`, so auto-detection never routes preparation to a paid API even when a key is present; local AI stays the default. Authentication is vendor-specific (`launchpad.ai.anthropic.api-key`, bound from `LAUNCHPAD_ANTHROPIC_API_KEY`, falling back to the shared key), with its own cloud base URL and `anthropic-version`. A missing key or an unreachable endpoint degrades cleanly to deterministic-only output instead of crashing. Adding it was a single new bean plus its config - no router or health-checker changes (toward #144).
1011
- **Project readiness evaluator**: `ReadinessEvaluator.evaluate(Path)` returns a read-only `ReadinessResult` (status, structured reason lines, recommended action, last-prepared timestamp) derived from a project's prepared artifacts (`AGENTS.md` and the `.launchpad/*.json` sidecars) and its provenance stamp - the deterministic verdict the readiness dashboard renders. `ProvenanceHeader` gains a `parse(...)` inverse of `render()` (closes #122).
1112
- **Output contract docs**: `docs/output-contract.adoc` documents every file a scan writes - layout, section structure, provenance stamp, write/merge actions, stable-vs-experimental tiers, and versioning - so downstream tools integrate against a contract instead of snapshot-testing prose. Adds a `docs/index.adoc` landing page (closes #91).
1213
- **MCP task-interview tools**: `ask_task_question`, `finalize_task`, and `regenerate_section` expose the local `/new-task` interview over MCP so a cloud agent can de-risk a task first. Stateless (the caller carries the `history` transcript), reuses `TaskAdvisorService`, caps at 8 rounds with a critic after round 2 (closes #19).

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ Launchpad is:
7777

7878
## 🔌 AI providers
7979

80-
Providers are pluggable. Local AI is the default -- Ollama, LM Studio, llama.cpp, vLLM, or anything with an OpenAI-compatible endpoint -- and keeps your code on your machine, which makes it a first-class option for privacy-sensitive, offline, air-gapped, or cost-sensitive workflows. Paid and cloud providers are also supported when they produce better results.
80+
Providers are pluggable. Local AI is the default -- Ollama, LM Studio, llama.cpp, vLLM, or anything with an OpenAI-compatible endpoint -- and keeps your code on your machine, which makes it a first-class option for privacy-sensitive, offline, air-gapped, or cost-sensitive workflows. Paid and cloud providers are also supported when they produce better results: Claude (Anthropic) is the first cloud provider, opt-in and explicitly selected. Local stays the default, and auto-detection never selects a paid provider -- choosing one is always a deliberate choice.
81+
82+
Selecting Anthropic is explicit (`launchpad.ai.provider=anthropic`); its key reads from `LAUNCHPAD_ANTHROPIC_API_KEY` (falling back to the shared `LAUNCHPAD_LLM_API_KEY`). If the key is missing or Anthropic is unreachable, preparation degrades cleanly to deterministic-only output.
8183

8284
Default:
8385

docs/architecture.adoc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ image::resources/launchpad-overview.drawio.png[Launchpad architecture overview]
1212

1313
Launchpad works with any AI provider that exposes an OpenAI-compatible endpoint. Local providers (Ollama, LM Studio, llama.cpp) are the default: source code, scan results, and generated context stay on your machine, and no cloud API calls are made during analysis or synthesis. Paid or remote providers are also supported; the choice belongs to the operator, not the tool.
1414

15+
The provider is a pluggable bean, so a paid cloud provider is just another selectable backend - Anthropic (Claude) ships as the first one, chosen for higher-quality synthesis when the operator wants it. It is opt-in and explicitly selected; auto-detection only ever resolves to a local backend, so preparation is never silently routed to a paid API. No vendor is privileged - cloud providers are added on the same provider seam, not as a blessed default.
16+
1517
=== Deterministic-first
1618

1719
Repository structure, dependencies, endpoints, and documentation are extracted through structured, deterministic analysis. The local AI model is only used for bounded synthesis -- writing short narrative sections from data the deterministic phase already produced.

pom.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@
6565
<artifactId>spring-ai-starter-model-openai</artifactId>
6666
</dependency>
6767

68+
<!-- Spring AI - opt-in cloud provider. Anthropic (Claude) is a paid,
69+
explicit-selection synthesis backend on the PreparationProvider SPI;
70+
AUTO never selects it. Like the local starters, its autoconfigured
71+
bean is shadowed by the router's @Primary ChatModel. -->
72+
<dependency>
73+
<groupId>org.springframework.ai</groupId>
74+
<artifactId>spring-ai-starter-model-anthropic</artifactId>
75+
</dependency>
76+
6877
<!-- Spring AI MCP server (stdio transport) - exposes Launchpad's scan / standards /
6978
audit results as MCP tools and resources so any MCP client (Claude Code, Cursor,
7079
Cline, Continue, Zed) can query them. -->
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package com.acltabontabon.launchpad.ai;
2+
3+
import com.acltabontabon.launchpad.config.LaunchpadAiProperties;
4+
import com.acltabontabon.launchpad.config.LaunchpadSettings.Snapshot;
5+
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
6+
import com.anthropic.client.okhttp.AnthropicOkHttpClientAsync;
7+
import java.util.Map;
8+
import org.springframework.ai.anthropic.AnthropicChatModel;
9+
import org.springframework.ai.anthropic.AnthropicChatOptions;
10+
import org.springframework.ai.chat.model.ChatModel;
11+
import org.springframework.core.annotation.Order;
12+
import org.springframework.stereotype.Component;
13+
14+
/**
15+
* Anthropic (Claude) {@link PreparationProvider} - the first paid/cloud synthesis
16+
* backend on the SPI. Opt-in only: {@link #autoDetectable()} is {@code false}, so
17+
* {@link LlmProvider#AUTO} never routes preparation to a paid API even when a key
18+
* is present; the user must pin {@code launchpad.ai.provider=anthropic} explicitly.
19+
*
20+
* <p>Authentication is vendor-specific: it prefers the {@code launchpad.ai.anthropic.api-key}
21+
* key (bound from {@code LAUNCHPAD_ANTHROPIC_API_KEY}) and falls back to the shared
22+
* snapshot key. A missing key or an unreachable endpoint degrades cleanly to
23+
* deterministic-only output - {@link #check(Snapshot)} reports not-ready before any
24+
* generation runs, and {@link #build(Snapshot)} stays safe to construct regardless.
25+
* Probes {@code /v1/models} with Anthropic's {@code x-api-key} + {@code anthropic-version}
26+
* headers for health.
27+
*/
28+
@Component
29+
@Order(30)
30+
public class AnthropicProvider implements PreparationProvider {
31+
32+
/**
33+
* Substituted when no key is configured so the Anthropic client construction
34+
* never fails. The router builds its delegate eagerly, but an unauthenticated
35+
* provider reports not-ready, so this placeholder model is never actually called.
36+
*/
37+
private static final String NOT_CONFIGURED_PLACEHOLDER = "not-configured";
38+
39+
/**
40+
* Upper bound on synthesised tokens. Anthropic requires {@code max_tokens}; the
41+
* synthesis layer caps each fragment well under this, so it is a guard rail, not
42+
* a target.
43+
*/
44+
private static final int MAX_TOKENS = 1024;
45+
46+
private final LaunchpadAiProperties properties;
47+
private final ProviderProbe probe;
48+
49+
public AnthropicProvider(LaunchpadAiProperties properties, ProviderProbe probe) {
50+
this.properties = properties;
51+
this.probe = probe;
52+
}
53+
54+
@Override
55+
public String id() {
56+
return LlmProvider.ANTHROPIC.slug();
57+
}
58+
59+
/** Paid/cloud: never auto-selected. The user must pin it explicitly. */
60+
@Override
61+
public boolean autoDetectable() {
62+
return false;
63+
}
64+
65+
@Override
66+
public ChatModel build(Snapshot snap) {
67+
var apiKey = resolveApiKey(snap);
68+
if (apiKey.isBlank()) apiKey = NOT_CONFIGURED_PLACEHOLDER;
69+
var baseUrl = properties.anthropic().baseUrl();
70+
var timeout = properties.readTimeout();
71+
// The official Anthropic SDK rebuilds an async client from the
72+
// environment when one is not supplied, and that rebuild ignores our
73+
// base URL / api key. Wire both clients explicitly, mirroring the
74+
// OpenAI-compatible provider.
75+
var sync = AnthropicOkHttpClient.builder()
76+
.baseUrl(baseUrl)
77+
.apiKey(apiKey)
78+
.timeout(timeout)
79+
.build();
80+
var async = AnthropicOkHttpClientAsync.builder()
81+
.baseUrl(baseUrl)
82+
.apiKey(apiKey)
83+
.timeout(timeout)
84+
.build();
85+
var opts = AnthropicChatOptions.builder()
86+
.model(snap.model())
87+
.maxTokens(MAX_TOKENS)
88+
.build();
89+
return AnthropicChatModel.builder()
90+
.anthropicClient(sync)
91+
.anthropicClientAsync(async)
92+
.options(opts)
93+
.build();
94+
}
95+
96+
@Override
97+
public LlmProviderStatus check(Snapshot snap) {
98+
var apiKey = resolveApiKey(snap);
99+
if (apiKey.isBlank()) {
100+
return LlmProviderStatus.unavailable(LlmProvider.ANTHROPIC,
101+
"Anthropic API key is not configured",
102+
"Set LAUNCHPAD_ANTHROPIC_API_KEY (or launchpad.ai.api-key) and select a Claude model");
103+
}
104+
var body = probe.fetch(properties.anthropic().baseUrl() + "/v1/models", Map.of(
105+
"x-api-key", apiKey,
106+
"anthropic-version", properties.anthropic().version()));
107+
if (body == null) {
108+
return LlmProviderStatus.unavailable(LlmProvider.ANTHROPIC,
109+
"Anthropic not reachable - check the API key and network",
110+
"Verify the Anthropic API key is valid and api.anthropic.com is reachable");
111+
}
112+
return probe.matchModel(body, snap.model(), AnthropicProvider::anthropicMatch)
113+
? LlmProviderStatus.ready(LlmProvider.ANTHROPIC, snap.model())
114+
: LlmProviderStatus.modelMissing(LlmProvider.ANTHROPIC, snap.model(),
115+
"Set an available Claude model id (e.g. claude-sonnet-4-5)");
116+
}
117+
118+
/** Vendor key first, then the shared snapshot key; blank means unconfigured. */
119+
private String resolveApiKey(Snapshot snap) {
120+
var vendor = properties.anthropic().apiKey();
121+
if (vendor != null && !vendor.isBlank()) return vendor;
122+
return snap.hasApiKey() ? snap.apiKey() : "";
123+
}
124+
125+
/**
126+
* Exact match first. Then a narrow alias tolerance: a configured {@code -latest}
127+
* alias matches a listed dated id sharing the same family stem (e.g.
128+
* {@code claude-sonnet-4-5-latest} matches {@code claude-sonnet-4-5-20250930}).
129+
* Deliberately not a broad substring match.
130+
*/
131+
private static boolean anthropicMatch(String listed, String configured) {
132+
if (listed.equals(configured)) return true;
133+
if (configured.endsWith("-latest")) {
134+
var stem = configured.substring(0, configured.length() - "-latest".length());
135+
return listed.startsWith(stem + "-");
136+
}
137+
return false;
138+
}
139+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
public enum LlmProvider {
1212
OLLAMA("ollama"),
1313
OPENAI_COMPATIBLE("openai-compatible"),
14+
ANTHROPIC("anthropic"),
1415
DETERMINISTIC("deterministic"),
1516
AUTO("auto");
1617

@@ -39,6 +40,7 @@ public String displayName() {
3940
return switch (this) {
4041
case OLLAMA -> "Ollama";
4142
case OPENAI_COMPATIBLE -> "OpenAI-compatible";
43+
case ANTHROPIC -> "Claude (Anthropic)";
4244
case DETERMINISTIC -> "Deterministic (no AI)";
4345
case AUTO -> "Auto-detect";
4446
};

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

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,24 +34,42 @@ public static LlmProviderStatus daemonDown(LlmProvider attempted, String baseUrl
3434
case OPENAI_COMPATIBLE -> "Start your OpenAI-compatible server (LM Studio / llama.cpp / vLLM)";
3535
case AUTO -> "Start Ollama (ollama serve) or an OpenAI-compatible server";
3636
// The deterministic provider is always reachable, so it never reaches
37-
// this path; the arm keeps the switch exhaustive.
38-
case DETERMINISTIC -> null;
37+
// this path. Other providers (e.g. cloud) carry their own message via
38+
// unavailable(); the default arm keeps this switch generic.
39+
default -> null;
3940
};
4041
String label = attempted == LlmProvider.AUTO
4142
? "Local AI not reachable at " + baseUrl
4243
: attempted.displayName() + " not reachable at " + baseUrl;
4344
return new LlmProviderStatus(State.DAEMON_DOWN, attempted, label, hint);
4445
}
4546

47+
/**
48+
* Not-ready status carrying a provider-supplied message and hint, mapped to
49+
* {@link State#DAEMON_DOWN} so {@link #isReady()} is false and the runtime
50+
* degrades to deterministic output. Lets a provider phrase its own failure
51+
* (e.g. "Anthropic API key is not configured") instead of routing through
52+
* the generic daemon-down wording.
53+
*/
54+
public static LlmProviderStatus unavailable(LlmProvider provider, String message, String hint) {
55+
return new LlmProviderStatus(State.DAEMON_DOWN, provider, message, hint);
56+
}
57+
4658
public static LlmProviderStatus modelMissing(LlmProvider provider, String model) {
4759
String hint = switch (provider) {
4860
case OLLAMA -> "Run: ollama pull " + model;
4961
case OPENAI_COMPATIBLE -> "Load model '" + model + "' in your local server";
5062
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;
63+
// The deterministic provider has no model to load; other providers
64+
// pass an explicit hint via the three-arg overload. The default arm
65+
// keeps this switch generic.
66+
default -> "Select a model available to this provider";
5467
};
68+
return modelMissing(provider, model, hint);
69+
}
70+
71+
/** Model-missing status with a provider-supplied hint. */
72+
public static LlmProviderStatus modelMissing(LlmProvider provider, String model, String hint) {
5573
return new LlmProviderStatus(
5674
State.MODEL_MISSING, provider, "Model '" + model + "' is not loaded", hint);
5775
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public ChatModel build(Snapshot snap) {
5353

5454
@Override
5555
public LlmProviderStatus check(Snapshot snap) {
56-
var body = probe.fetch(snap.baseUrl() + "/api/tags", null);
56+
var body = probe.fetch(snap.baseUrl() + "/api/tags", (String) null);
5757
if (body == null) return LlmProviderStatus.daemonDown(LlmProvider.OLLAMA, snap.baseUrl());
5858
return probe.matchModel(body, snap.model(), OllamaProvider::ollamaMatch)
5959
? LlmProviderStatus.ready(LlmProvider.OLLAMA, snap.model())

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import java.net.http.HttpRequest;
66
import java.net.http.HttpResponse;
77
import java.time.Duration;
8+
import java.util.Map;
89
import java.util.regex.Matcher;
910
import java.util.regex.Pattern;
1011
import org.springframework.stereotype.Component;
@@ -32,14 +33,25 @@ public ProviderProbe() {
3233

3334
/** GET the url (optionally bearer-authed); returns the body on HTTP 200, else null. */
3435
public String fetch(String url, String apiKey) {
36+
Map<String, String> headers = (apiKey != null && !apiKey.isBlank())
37+
? Map.of("Authorization", "Bearer " + apiKey)
38+
: Map.of();
39+
return fetch(url, headers);
40+
}
41+
42+
/**
43+
* GET the url with arbitrary headers; returns the body on HTTP 200, else
44+
* null. Lets vendors that authenticate outside the {@code Authorization:
45+
* Bearer} convention (Anthropic's {@code x-api-key} + {@code anthropic-version})
46+
* reuse the same bounded-timeout probe.
47+
*/
48+
public String fetch(String url, Map<String, String> headers) {
3549
try {
3650
var builder = HttpRequest.newBuilder()
3751
.uri(URI.create(url))
3852
.timeout(TIMEOUT)
3953
.GET();
40-
if (apiKey != null && !apiKey.isBlank()) {
41-
builder.header("Authorization", "Bearer " + apiKey);
42-
}
54+
headers.forEach(builder::header);
4355
var response = http.send(builder.build(), HttpResponse.BodyHandlers.ofString());
4456
return response.statusCode() == 200 ? response.body() : null;
4557
} catch (Exception e) {

src/main/java/com/acltabontabon/launchpad/config/LaunchpadAiProperties.java

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,25 +20,27 @@ public record LaunchpadAiProperties(
2020
Duration connectTimeout,
2121
Duration readTimeout,
2222
Ollama ollama,
23+
Anthropic anthropic,
2324
Synthesis synthesis
2425
) {
2526

2627
public LaunchpadAiProperties {
2728
if (connectTimeout == null) connectTimeout = Duration.ofSeconds(10);
2829
if (readTimeout == null) readTimeout = Duration.ofMinutes(2);
2930
if (ollama == null) ollama = new Ollama(null);
31+
if (anthropic == null) anthropic = new Anthropic(null, null, null);
3032
if (synthesis == null) synthesis = new Synthesis(null, null, null);
3133
}
3234

3335
/**
34-
* Builds a properties instance with default Ollama / synthesis settings.
35-
* Used by call-sites that only care about HTTP timeouts (tests, code
36-
* paths that don't synthesise). Distinct from the canonical 4-arg
36+
* Builds a properties instance with default Ollama / Anthropic / synthesis
37+
* settings. Used by call-sites that only care about HTTP timeouts (tests,
38+
* code paths that don't synthesise). Distinct from the canonical
3739
* constructor so Spring's @ConfigurationProperties binding stays
3840
* unambiguous (Spring picks the canonical constructor, not this factory).
3941
*/
4042
public static LaunchpadAiProperties ofTimeouts(Duration connectTimeout, Duration readTimeout) {
41-
return new LaunchpadAiProperties(connectTimeout, readTimeout, null, null);
43+
return new LaunchpadAiProperties(connectTimeout, readTimeout, null, null, null);
4244
}
4345

4446
/**
@@ -53,6 +55,28 @@ public record Ollama(Integer numCtx) {
5355
}
5456
}
5557

58+
/**
59+
* Anthropic (Claude) cloud-provider knobs. Decoupled from the shared
60+
* {@code launchpad.ai.base-url} / {@code api-key} (which are local-oriented)
61+
* so selecting Anthropic does not require re-pointing the local settings.
62+
* <ul>
63+
* <li>{@code baseUrl} - Anthropic API host. Defaults to the public cloud
64+
* endpoint.</li>
65+
* <li>{@code version} - the {@code anthropic-version} header sent on every
66+
* request and on the health probe.</li>
67+
* <li>{@code apiKey} - the vendor-specific key. Bound in
68+
* {@code application.properties} from the {@code LAUNCHPAD_ANTHROPIC_API_KEY}
69+
* env var; when blank the provider falls back to the shared snapshot key.</li>
70+
* </ul>
71+
*/
72+
public record Anthropic(String baseUrl, String version, String apiKey) {
73+
public Anthropic {
74+
if (baseUrl == null || baseUrl.isBlank()) baseUrl = "https://api.anthropic.com";
75+
if (version == null || version.isBlank()) version = "2023-06-01";
76+
if (apiKey == null) apiKey = "";
77+
}
78+
}
79+
5680
/**
5781
* Bounded local-AI synthesis. Each fragment job has a strict input and
5882
* output budget so a runaway prompt can never re-introduce the

0 commit comments

Comments
 (0)