diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilder.java index fc436b84..3abe7ca0 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilder.java @@ -137,11 +137,27 @@ public class AnthropicChatModelBuilder implements ChatModelBuilder { } /** - * True for any Claude 4.7+ model — the family that drops temperature / - * top_p / top_k and exposes the "xhigh" thinking tier between high and max. + * Detect the Claude Fable model line (e.g. {@code claude-fable-5}). + * Fable is a reasoning-first family that follows the same strict API + * contract as Claude 4.7+: temperature / top_p / top_k must be unset + * (any non-default value returns HTTP 400) and the "xhigh" adaptive + * thinking tier is available. Matching on the {@code claude-fable} token + * covers the direct-API id, the OpenRouter-prefixed form + * ({@code anthropic/claude-fable-5}), and future {@code claude-fable-N} + * revisions without a per-version code change. + */ + static boolean isClaudeFable(String modelName) { + if (modelName == null) return false; + return modelName.toLowerCase().contains("claude-fable"); + } + + /** + * True for any modern Claude model that drops temperature / top_p / top_k + * and exposes the "xhigh" thinking tier between high and max — the Claude + * 4.7 / 4.8 generations and the Fable reasoning line. */ static boolean isClaude47OrLater(String modelName) { - return isClaude47(modelName) || isClaude48(modelName); + return isClaude47(modelName) || isClaude48(modelName) || isClaudeFable(modelName); } AnthropicChatOptions buildAnthropicOptions(ModelConfigEntity runtimeModel) { diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelCapabilityService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelCapabilityService.java index 23f87841..3eaba40e 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelCapabilityService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelCapabilityService.java @@ -86,6 +86,7 @@ public class ModelCapabilityService { // ===== Anthropic Claude ===== // Vision yes (image), native video no — Anthropic's API only accepts images. + m.put("claude-fable", EnumSet.of(Modality.VISION)); m.put("claude-4.7", EnumSet.of(Modality.VISION)); m.put("claude-4.5", EnumSet.of(Modality.VISION)); m.put("claude-4", EnumSet.of(Modality.VISION)); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V145__claude_fable_5_models.sql b/mateclaw-server/src/main/resources/db/migration/h2/V145__claude_fable_5_models.sql new file mode 100644 index 00000000..7058a230 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V145__claude_fable_5_models.sql @@ -0,0 +1,25 @@ +-- Add Claude Fable 5 model entries to mate_model_config. Unlike earlier Claude +-- families, the Fable rows live ONLY here, not in the data-{en,zh}.sql seed: +-- Flyway runs every version (V1..) on a fresh database, so the migration seeds +-- new installs and upgrades existing deployments alike. The trade-off is that +-- the description below is English-only (seed files carry localized copy). +-- +-- Fable 5 is a reasoning-first model with a 1M-token context window and native +-- vision input. It follows the same strict API contract as Claude 4.7+: +-- temperature / top_p / top_k must be NULL (otherwise HTTP 400), and the +-- "xhigh" adaptive thinking tier is available. Both are handled in +-- AnthropicChatModelBuilder via the isClaudeFable() / isClaude47OrLater() +-- detectors. Vision capability is resolved in ModelCapabilityService. +-- +-- MERGE INTO is the H2 idempotent upsert; running this twice is a no-op. +-- Same V number is used in mysql/ for cross-dialect parity. + +MERGE INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +KEY (id) +VALUES +-- Direct Anthropic +(1000000300, 'Claude Fable 5', 'anthropic', 'claude-fable-5', 'Anthropic Claude Fable 5 (1M context, vision, xhigh adaptive thinking)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- OpenRouter passthrough +(1000000301, 'Claude Fable 5', 'openrouter', 'anthropic/claude-fable-5', 'Claude Fable 5 via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Claude Code OAuth (Pro/Max subscription) +(1000000302, 'Claude Fable 5', 'anthropic-claude-code', 'claude-fable-5', 'Claude Fable 5 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V145__claude_fable_5_models.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V145__claude_fable_5_models.sql new file mode 100644 index 00000000..15fef7c3 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V145__claude_fable_5_models.sql @@ -0,0 +1,37 @@ +-- Add Claude Fable 5 model entries to mate_model_config. Unlike earlier Claude +-- families, the Fable rows live ONLY here, not in the data-mysql-{en,zh}.sql +-- seed: Flyway runs every version (V1..) on a fresh database, so the migration +-- seeds new installs and upgrades existing deployments alike. The trade-off is +-- that the description below is English-only (seed files carry localized copy). +-- +-- Fable 5 is a reasoning-first model with a 1M-token context window and native +-- vision input. It follows the same strict API contract as Claude 4.7+: +-- temperature / top_p / top_k must be NULL (otherwise HTTP 400), and the +-- "xhigh" adaptive thinking tier is available. Both are handled in +-- AnthropicChatModelBuilder via the isClaudeFable() / isClaude47OrLater() +-- detectors. Vision capability is resolved in ModelCapabilityService. +-- +-- INSERT ... ON DUPLICATE KEY UPDATE is the MySQL idempotent upsert. +-- Same V number is used in h2/ for cross-dialect parity. + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES +-- Direct Anthropic +(1000000300, 'Claude Fable 5', 'anthropic', 'claude-fable-5', 'Anthropic Claude Fable 5 (1M context, vision, xhigh adaptive thinking)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- OpenRouter passthrough +(1000000301, 'Claude Fable 5', 'openrouter', 'anthropic/claude-fable-5', 'Claude Fable 5 via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Claude Code OAuth (Pro/Max subscription) +(1000000302, 'Claude Fable 5', 'anthropic-claude-code', 'claude-fable-5', 'Claude Fable 5 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + provider = VALUES(provider), + model_name = VALUES(model_name), + description = VALUES(description), + temperature = VALUES(temperature), + max_tokens = VALUES(max_tokens), + top_p = VALUES(top_p), + builtin = VALUES(builtin), + enabled = VALUES(enabled), + is_default = VALUES(is_default), + update_time = VALUES(update_time), + deleted = VALUES(deleted); diff --git a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilderFableTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilderFableTest.java new file mode 100644 index 00000000..e1861dd8 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilderFableTest.java @@ -0,0 +1,60 @@ +package vip.mate.llm.chatmodel; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link AnthropicChatModelBuilder#isClaudeFable} must classify the Claude + * Fable reasoning line as a modern model so it inherits the strict 4.7+ API + * contract: temperature / top_p / top_k must be unset (any non-default value + * returns HTTP 400) and the "xhigh" adaptive thinking tier is available. + * + *
{@link AnthropicChatModelBuilder#isClaude47OrLater} unifies the gating + * logic across the 4.7 / 4.8 generations and the Fable family.
+ */ +class AnthropicChatModelBuilderFableTest { + + @Test + @DisplayName("isClaudeFable detects the direct-API and OpenRouter ids") + void detect_directAndOpenRouter() { + assertTrue(AnthropicChatModelBuilder.isClaudeFable("claude-fable-5")); + assertTrue(AnthropicChatModelBuilder.isClaudeFable("anthropic/claude-fable-5")); + // Case-insensitive + assertTrue(AnthropicChatModelBuilder.isClaudeFable("Claude-Fable-5")); + } + + @Test + @DisplayName("isClaudeFable tolerates date-stamped and future revisions") + void detect_futureRevisions() { + assertTrue(AnthropicChatModelBuilder.isClaudeFable("claude-fable-5-20260609")); + assertTrue(AnthropicChatModelBuilder.isClaudeFable("claude-fable-6")); + } + + @Test + @DisplayName("isClaudeFable ignores other Claude families and unrelated names") + void detect_negatives() { + assertFalse(AnthropicChatModelBuilder.isClaudeFable("claude-opus-4-8")); + assertFalse(AnthropicChatModelBuilder.isClaudeFable("claude-sonnet-4-6")); + // The "claude-fable" token guard prevents a stray "fable" elsewhere from matching. + assertFalse(AnthropicChatModelBuilder.isClaudeFable("some-fable-model")); + } + + @Test + @DisplayName("isClaudeFable null-safe") + void detect_nullSafe() { + assertFalse(AnthropicChatModelBuilder.isClaudeFable(null)); + assertFalse(AnthropicChatModelBuilder.isClaudeFable("")); + } + + @Test + @DisplayName("isClaude47OrLater routes Fable onto the sampling-forbidden contract") + void claude47OrLater_includesFable() { + assertTrue(AnthropicChatModelBuilder.isClaude47OrLater("claude-fable-5")); + assertTrue(AnthropicChatModelBuilder.isClaude47OrLater("anthropic/claude-fable-5")); + // Sanity: existing generations still classify modern, legacy still falls through. + assertTrue(AnthropicChatModelBuilder.isClaude47OrLater("claude-opus-4-8")); + assertFalse(AnthropicChatModelBuilder.isClaude47OrLater("claude-opus-4-6")); + } +}