feat: add Anthropic model discovery and capability probing

Uses GET /v1/models to list models and read advertised capabilities.
supportsToolCalling and extended-thinking requests honor the listed
capabilities (including tool_use/tool_calling on compatible servers).
Model listing is cached per profile; unreachable endpoints or missing
capabilities skip the checks rather than failing requests.
This commit is contained in:
extio-de
2026-10-17 20:39:04 +02:00
parent bb7ae456a8
commit cd99dfa360
9 changed files with 303 additions and 4 deletions

View File

@@ -514,7 +514,7 @@ lm-lib caches `/api/show` model details per resolved model. Thinking and tool de
`ANTHROPIC` targets the Anthropic Claude Messages API (`POST /v1/messages`), including Anthropic-compatible servers that expose the same endpoint shape.
The client sends system turns as the top-level `system` parameter, uses `x-api-key` and `anthropic-version` headers, and requires a positive `maxTokens` in the profile because the Messages API mandates `max_tokens`. Tool calling uses native `tool_use`/`tool_result` content blocks, and streamed responses are consumed through the standard server-sent event protocol (`message_start`, `content_block_delta`, `message_delta`, `message_stop`). Extended thinking is supported: thinking blocks are captured as reasoning and replayed on follow-up tool rounds. There is no model discovery endpoint, so `getModelNames(...)` returns an empty list and `modelName` must be configured.
The client sends system turns as the top-level `system` parameter, uses `x-api-key` and `anthropic-version` headers, and requires a positive `maxTokens` in the profile because the Messages API mandates `max_tokens`. Tool calling uses native `tool_use`/`tool_result` content blocks, and streamed responses are consumed through the standard server-sent event protocol (`message_start`, `content_block_delta`, `message_delta`, `message_stop`). Extended thinking is supported: thinking blocks are captured as reasoning and replayed on follow-up tool rounds. Model discovery uses `GET /v1/models` (cached per profile, refreshed with `forceReload`); `modelName` may be left empty to use the first listed model. Advertised model `capabilities` are used like Ollama's `/api/show` probing: `supportsToolCalling(...)` and extended-thinking requests honor the listed capabilities (including `tool_use`/`tool_calling` on compatible servers), while models or servers without capabilities or with an unreachable models endpoint simply skip these checks.
To enable extended thinking, register an `AnthropicDialect` bean and return a budget (at least 1024 tokens) from `thinkingBudgetTokens(...)`; the budget is clamped below the profile's `maxTokens`. Omitting the bean or returning `null` keeps thinking disabled.

View File

@@ -0,0 +1,13 @@
package de.extio.lmlib.client.anthropic;
/**
* A single capability entry from the Anthropic {@code GET /v1/models} listing.
* A missing {@code supported} flag is treated as supported so unknown or
* partial capability information never fails a request.
*/
public record AnthropicCapabilitySupport(Boolean supported) {
public boolean isSupported() {
return this.supported == null || this.supported.booleanValue();
}
}

View File

@@ -9,9 +9,11 @@ import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import org.slf4j.Logger;
@@ -62,6 +64,8 @@ public class AnthropicClient implements Client {
private static final String MESSAGES_PATH = "/v1/messages";
private final Map<String, Map<String, AnthropicModel>> resolvedModels = new ConcurrentHashMap<>();
private static final AnthropicDialect DEFAULT_ANTHROPIC_DIALECT = new AnthropicDialect() {
};
@@ -157,8 +161,74 @@ public class AnthropicClient implements Client {
@Override
public List<String> getModelNames(final ModelProfile modelProfile, final boolean forceReload) {
// The Anthropic API does not offer a model discovery endpoint.
return List.of();
return this.resolveModels(modelProfile, forceReload).values().stream()
.map(AnthropicModel::id)
.filter(id -> id != null && !id.isBlank())
.toList();
}
/**
* Loads and caches the model listing (including capabilities) per profile.
* Never fails: an unreachable or non-listing endpoint yields an empty map so
* capability checks are skipped instead of rejecting requests.
*/
private Map<String, AnthropicModel> resolveModels(final ModelProfile modelProfile, final boolean forceReload) {
if (modelProfile == null || modelProfile.modelProvider() != ModelProvider.ANTHROPIC) {
return Map.of();
}
if (modelProfile.url() == null || modelProfile.url().isBlank()) {
return Map.of();
}
final var cacheKey = modelProfile.category() + "|" + modelProfile.url() + "|" + modelProfile.apiKey();
if (forceReload) {
final var models = this.loadModels(modelProfile);
this.resolvedModels.put(cacheKey, models);
return models;
}
return this.resolvedModels.computeIfAbsent(cacheKey, key -> this.loadModels(modelProfile));
}
private Map<String, AnthropicModel> loadModels(final ModelProfile modelProfile) {
final var base = stripMessagesSuffix(modelProfile.url());
final var path = modelsPath(modelProfile.url());
try {
var requestSpec = this.restClientBuilder.baseUrl(base).build()
.method(HttpMethod.GET)
.uri(path)
.header("anthropic-version", this.getAnthropicDialect().anthropicVersion(modelProfile));
if (modelProfile.apiKey() != null && !modelProfile.apiKey().isBlank()) {
requestSpec = requestSpec.header("x-api-key", modelProfile.apiKey());
}
requestSpec = this.proxyAuthorizationSupport.apply(requestSpec);
final var response = requestSpec.exchange((clientRequest, clientResponse) -> {
if (clientResponse.getStatusCode().isError()) {
LOGGER.warn("Error response from model listing: {} {}", clientResponse.getStatusCode(), new String(clientResponse.getBody().readAllBytes(), StandardCharsets.UTF_8));
return null;
}
return this.objectMapper.readValue(clientResponse.getBody(), AnthropicModelListResponse.class);
});
if (response == null || response.data() == null) {
return Map.of();
}
final Map<String, AnthropicModel> models = new LinkedHashMap<>();
for (final var model : response.data()) {
if (model != null && model.id() != null && !model.id().isBlank()) {
models.put(model.id(), model);
}
}
return Collections.unmodifiableMap(models);
}
catch (final RuntimeException e) {
LOGGER.warn("Failed to load Anthropic model listing from {}", modelProfile.url(), e);
return Map.of();
}
}
private AnthropicModel findModel(final ModelProfile modelProfile, final String modelName) {
if (modelName == null || modelName.isBlank()) {
return null;
}
return this.resolveModels(modelProfile, false).get(modelName);
}
@Override
@@ -174,7 +244,42 @@ public class AnthropicClient implements Client {
@Override
public boolean supportsToolCalling(final ModelProfile modelProfile) {
return modelProfile != null && modelProfile.modelProvider() == ModelProvider.ANTHROPIC;
if (modelProfile == null || modelProfile.modelProvider() != ModelProvider.ANTHROPIC) {
return false;
}
return supportsToolCalling(this.findModel(modelProfile, this.getPrimaryModelName(modelProfile)));
}
/**
* Determines tool calling support from model capabilities. Unknown models or
* missing capability information skip the check and are assumed to support
* tool calling.
*/
static boolean supportsToolCalling(final AnthropicModel model) {
if (model == null) {
return true;
}
final var capabilities = model.capabilities();
if (capabilities == null) {
return true;
}
return capabilities.isToolCallingSupported();
}
/**
* Determines extended thinking support from model capabilities. Unknown
* models or missing capability information skip the check and are assumed to
* support thinking.
*/
static boolean supportsExtendedThinking(final AnthropicModel model) {
if (model == null) {
return true;
}
final var capabilities = model.capabilities();
if (capabilities == null) {
return true;
}
return capabilities.isExtendedThinkingSupported();
}
AnthropicMessageRequest createRequest(final Conversation conversation, final ModelProfile modelProfile, final boolean streaming, final ToolCallData toolCallData) {
@@ -298,6 +403,10 @@ public class AnthropicClient implements Client {
if (budgetTokens == null) {
return null;
}
if (!supportsExtendedThinking(this.findModel(modelProfile, this.getPrimaryModelName(modelProfile)))) {
LOGGER.debug("Model {} does not advertise extended thinking support - thinking will be omitted.", this.getPrimaryModelName(modelProfile));
return null;
}
final var maxTokens = modelProfile.maxTokens();
final var clamped = Math.min(budgetTokens.intValue(), maxTokens - 1);
if (clamped < AnthropicDialect.MIN_THINKING_BUDGET_TOKENS) {
@@ -664,6 +773,20 @@ public class AnthropicClient implements Client {
return MESSAGES_PATH;
}
/**
* Resolves the models listing path relative to the (suffix-normalized)
* configured base URL.
*/
static String modelsPath(final String url) {
if (url == null) {
return "/v1/models";
}
if (url.endsWith("/v1/") || url.endsWith("/v1")) {
return "/models";
}
return "/v1/models";
}
private AnthropicDialect getAnthropicDialect() {
return this.anthropicDialect == null ? DEFAULT_ANTHROPIC_DIALECT : this.anthropicDialect;
}

View File

@@ -0,0 +1,9 @@
package de.extio.lmlib.client.anthropic;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* A model entry from the Anthropic {@code GET /v1/models} listing.
*/
public record AnthropicModel(String id, @JsonProperty("display_name") String displayName, String type, String object, AnthropicModelCapabilities capabilities) {
}

View File

@@ -0,0 +1,41 @@
package de.extio.lmlib.client.anthropic;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Capabilities of an Anthropic model as advertised by the
* {@code GET /v1/models} endpoint. All fields are optional: providers and
* compatible servers may omit some or all capabilities, in which case lm-lib
* assumes the capability is available.
* <p>
* {@code toolUse} and {@code toolCalling} are not part of the official
* capability set but are accepted for Anthropic-compatible servers that
* advertise tool support through those keys.
*/
public record AnthropicModelCapabilities(
AnthropicCapabilitySupport batch,
AnthropicCapabilitySupport citations,
@JsonProperty("code_execution") AnthropicCapabilitySupport codeExecution,
@JsonProperty("image_input") AnthropicCapabilitySupport imageInput,
@JsonProperty("pdf_input") AnthropicCapabilitySupport pdfInput,
@JsonProperty("structured_outputs") AnthropicCapabilitySupport structuredOutputs,
@JsonProperty("tool_use") AnthropicCapabilitySupport toolUse,
@JsonProperty("tool_calling") AnthropicCapabilitySupport toolCalling,
AnthropicThinkingCapability thinking) {
public boolean isToolCallingSupported() {
if (this.toolUse != null) {
return this.toolUse.isSupported();
}
if (this.toolCalling != null) {
return this.toolCalling.isSupported();
}
// The official capability set has no tool entry, so tool support cannot be
// determined and the check is skipped.
return true;
}
public boolean isExtendedThinkingSupported() {
return this.thinking == null || this.thinking.isExtendedThinkingSupported();
}
}

View File

@@ -0,0 +1,9 @@
package de.extio.lmlib.client.anthropic;
import java.util.List;
/**
* Response of the Anthropic {@code GET /v1/models} endpoint.
*/
public record AnthropicModelListResponse(List<AnthropicModel> data) {
}

View File

@@ -0,0 +1,22 @@
package de.extio.lmlib.client.anthropic;
/**
* Thinking capability of an Anthropic model, including the supported thinking
* type configurations.
*/
public record AnthropicThinkingCapability(Boolean supported, ThinkingTypes types) {
public boolean isSupported() {
return this.supported == null || this.supported.booleanValue();
}
public boolean isExtendedThinkingSupported() {
if (!this.isSupported()) {
return false;
}
return this.types == null || this.types.enabled() == null || this.types.enabled().isSupported();
}
public record ThinkingTypes(AnthropicCapabilitySupport enabled, AnthropicCapabilitySupport disabled, AnthropicCapabilitySupport adaptive) {
}
}

View File

@@ -1,6 +1,7 @@
package de.extio.lmlib.client.anthropic;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -314,6 +315,78 @@ class AnthropicClientRequestTest {
assertEquals("http://127.0.0.1:5001", AnthropicClient.stripMessagesSuffix("http://127.0.0.1:5001"));
}
@Test
void resolvesModelsPathVariants() {
assertEquals("/v1/models", AnthropicClient.modelsPath("http://127.0.0.1:5001"));
assertEquals("/models", AnthropicClient.modelsPath("http://127.0.0.1:5001/v1"));
assertEquals("/models", AnthropicClient.modelsPath("http://127.0.0.1:5001/v1/"));
}
@Test
void parsesModelListingResponse() {
final var mapper = JsonMapper.builder().build();
final var official = mapper.readValue("""
{"data": [{"type": "model", "id": "claude-sonnet-4-5", "display_name": "Claude Sonnet 4.5", "created_at": "2025-09-29T00:00:00Z"}], "has_more": false}
""", AnthropicModelListResponse.class);
assertEquals("claude-sonnet-4-5", official.data().get(0).id());
assertEquals("Claude Sonnet 4.5", official.data().get(0).displayName());
final var openAiShaped = mapper.readValue("""
{"object": "list", "data": [{"id": "local-model", "object": "model", "status": {"value": "loaded"}}]}
""", AnthropicModelListResponse.class);
assertEquals("local-model", openAiShaped.data().get(0).id());
}
@Test
void parsesModelCapabilities() {
final var mapper = JsonMapper.builder().build();
final var response = mapper.readValue("""
{"data": [{
"type": "model",
"id": "claude-opus-5-5",
"capabilities": {
"image_input": {"supported": true},
"thinking": {"supported": true, "types": {"adaptive": {"supported": true}, "disabled": {"supported": true}, "enabled": {"supported": false}}}
}
}, {
"type": "model",
"id": "claude-haiku-no-thinking",
"capabilities": {"thinking": {"supported": false}}
}, {
"type": "model",
"id": "tool-less-model",
"capabilities": {"tool_use": {"supported": false}}
}, {
"type": "model",
"id": "no-capabilities-model"
}]}
""", AnthropicModelListResponse.class);
final var models = response.data().stream().collect(java.util.stream.Collectors.toMap(AnthropicModel::id, model -> model));
final var opus = models.get("claude-opus-5-5");
assertTrue(opus.capabilities().imageInput().isSupported());
assertTrue(AnthropicClient.supportsToolCalling(opus));
// thinking.types.enabled explicitly false -> extended thinking unsupported
assertFalse(AnthropicClient.supportsExtendedThinking(opus));
assertFalse(AnthropicClient.supportsExtendedThinking(models.get("claude-haiku-no-thinking")));
assertFalse(AnthropicClient.supportsToolCalling(models.get("tool-less-model")));
// missing capabilities skip the checks
assertTrue(AnthropicClient.supportsToolCalling(models.get("no-capabilities-model")));
assertTrue(AnthropicClient.supportsExtendedThinking(models.get("no-capabilities-model")));
assertTrue(AnthropicClient.supportsToolCalling((AnthropicModel) null));
assertTrue(AnthropicClient.supportsExtendedThinking(null));
}
@Test
void modelDiscoveryNeverFails() {
final var client = new TestAnthropicClient();
// no rest client configured - discovery must degrade to an empty listing
assertEquals(List.of(), client.getModelNames(this.createProfile(2048), true));
assertTrue(client.supportsToolCalling(this.createProfile(2048)));
}
@SuppressWarnings("unchecked")
private List<Object> blocks(final AnthropicMessage message) {
return (List<Object>) message.content();

View File

@@ -54,6 +54,15 @@ public class AnthropicClientTest {
assertTrue(this.anthropicClient.supportsToolCalling(modelProfile));
}
@Test
void listModels() throws Exception {
final var modelProfile = this.modelProfile();
final var modelNames = this.anthropicClient.getModelNames(modelProfile, true);
AnthropicClientTest.LOGGER.info("Models: {}", modelNames);
assertFalse(modelNames.isEmpty());
assertFalse(modelNames.getFirst().isBlank());
}
@Test
void chatCompletion() throws Exception {
final var modelProfile = this.modelProfile();