diff --git a/README.md b/README.md index 836c899..8e52e4e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/main/java/de/extio/lmlib/client/anthropic/AnthropicCapabilitySupport.java b/src/main/java/de/extio/lmlib/client/anthropic/AnthropicCapabilitySupport.java new file mode 100644 index 0000000..c3e01f5 --- /dev/null +++ b/src/main/java/de/extio/lmlib/client/anthropic/AnthropicCapabilitySupport.java @@ -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(); + } +} diff --git a/src/main/java/de/extio/lmlib/client/anthropic/AnthropicClient.java b/src/main/java/de/extio/lmlib/client/anthropic/AnthropicClient.java index 36bef0c..21566fa 100644 --- a/src/main/java/de/extio/lmlib/client/anthropic/AnthropicClient.java +++ b/src/main/java/de/extio/lmlib/client/anthropic/AnthropicClient.java @@ -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> resolvedModels = new ConcurrentHashMap<>(); + private static final AnthropicDialect DEFAULT_ANTHROPIC_DIALECT = new AnthropicDialect() { }; @@ -157,8 +161,74 @@ public class AnthropicClient implements Client { @Override public List 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 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 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 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; } diff --git a/src/main/java/de/extio/lmlib/client/anthropic/AnthropicModel.java b/src/main/java/de/extio/lmlib/client/anthropic/AnthropicModel.java new file mode 100644 index 0000000..7f624be --- /dev/null +++ b/src/main/java/de/extio/lmlib/client/anthropic/AnthropicModel.java @@ -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) { +} diff --git a/src/main/java/de/extio/lmlib/client/anthropic/AnthropicModelCapabilities.java b/src/main/java/de/extio/lmlib/client/anthropic/AnthropicModelCapabilities.java new file mode 100644 index 0000000..fd53a24 --- /dev/null +++ b/src/main/java/de/extio/lmlib/client/anthropic/AnthropicModelCapabilities.java @@ -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. + *

+ * {@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(); + } +} diff --git a/src/main/java/de/extio/lmlib/client/anthropic/AnthropicModelListResponse.java b/src/main/java/de/extio/lmlib/client/anthropic/AnthropicModelListResponse.java new file mode 100644 index 0000000..bd73e8d --- /dev/null +++ b/src/main/java/de/extio/lmlib/client/anthropic/AnthropicModelListResponse.java @@ -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 data) { +} diff --git a/src/main/java/de/extio/lmlib/client/anthropic/AnthropicThinkingCapability.java b/src/main/java/de/extio/lmlib/client/anthropic/AnthropicThinkingCapability.java new file mode 100644 index 0000000..5bae825 --- /dev/null +++ b/src/main/java/de/extio/lmlib/client/anthropic/AnthropicThinkingCapability.java @@ -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) { + } +} diff --git a/src/test/java/de/extio/lmlib/client/anthropic/AnthropicClientRequestTest.java b/src/test/java/de/extio/lmlib/client/anthropic/AnthropicClientRequestTest.java index 12c2663..37c3ab9 100644 --- a/src/test/java/de/extio/lmlib/client/anthropic/AnthropicClientRequestTest.java +++ b/src/test/java/de/extio/lmlib/client/anthropic/AnthropicClientRequestTest.java @@ -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 blocks(final AnthropicMessage message) { return (List) message.content(); diff --git a/src/test/java/de/extio/lmlib/client/anthropic/AnthropicClientTest.java b/src/test/java/de/extio/lmlib/client/anthropic/AnthropicClientTest.java index 623b539..a78de33 100644 --- a/src/test/java/de/extio/lmlib/client/anthropic/AnthropicClientTest.java +++ b/src/test/java/de/extio/lmlib/client/anthropic/AnthropicClientTest.java @@ -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();