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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user