Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions src/main/providers/ClaudeModelCapabilities.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// ABOUTME: Per-generation Anthropic API capabilities (thinking mode, sampling-parameter support)
// ABOUTME: Single place encoding which Claude models take extended thinking vs adaptive thinking + effort
package org.nlogo.extensions.llm.providers

/**
* Which thinking request shape a Claude model accepts.
*
* Anthropic changed the thinking API across generations, and the two shapes are
* mutually exclusive -- sending the wrong one is an HTTP 400:
*
* - Extended: `thinking: {type: "enabled", budget_tokens: N}`.
* Claude 4.5 and earlier. Rejected by 4.7 and later.
* - Adaptive: `thinking: {type: "adaptive"}` with depth steered by
* `output_config.effort`. Claude 4.6 and later; the only mode on 4.7+.
*
* Claude 4.6 accepts both; adaptive is preferred there because extended
* thinking is deprecated on that generation.
*/
enum ClaudeThinkingMode:
case Extended
case Adaptive

/**
* Capability lookup for Anthropic model identifiers.
*
* Kept separate from ReasoningModelDetector on purpose: that object answers the
* cross-provider question "should thinking be on at all", while these are
* Anthropic request-shape details that only ClaudeProvider needs. Folding them
* in would grow the shared cross-provider string-matching surface.
*
* Matching is on model-name substrings because Anthropic model IDs are
* versioned strings and the extension accepts user-supplied and override-config
* model names that are not in the bundled registry.
*/
object ClaudeModelCapabilities {

/**
* Generations that predate adaptive thinking, and so must use the legacy
* `{type: "enabled", budget_tokens: N}` shape.
*
* Claude 3.x is included for completeness: those models are retired, but a
* user pinning one via override config should still get the shape their
* model expects rather than a guaranteed 400 from the adaptive shape.
*/
private val ExtendedThinkingMarkers = Seq(
"claude-3-5", "claude-3-7", "claude-3-opus", "claude-3-haiku", "claude-3-sonnet",
"claude-opus-4-0", "claude-opus-4-1", "claude-opus-4-5",
"claude-sonnet-4-0", "claude-sonnet-4-5",
"claude-haiku-4-5",
"claude-opus-4-20", "claude-sonnet-4-20"
)

/**
* Generations that still ACCEPT `temperature`/`top_p`/`top_k`.
*
* Claude 4.7 and later, plus the Fable/Mythos line, reject a non-default
* `temperature` with a 400 on EVERY request, thinking or not — which is why
* the non-thinking path has to honour this too.
*
* This is deliberately an allowlist of older generations rather than a denylist
* of newer ones. A denylist has to enumerate every future model ID, so
* `claude-sonnet-4-7` or `claude-opus-4-9` would match nothing, fall through as
* permitted, and be sent a temperature they reject. Listing what is known to
* accept sampling params instead makes the unknown case default FORWARD — the
* same direction `thinkingMode` already defaults, so the two stay consistent.
*/
private val SamplingParamsMarkers = Seq(
"claude-3-5", "claude-3-7", "claude-3-opus", "claude-3-haiku", "claude-3-sonnet",
"claude-opus-4-0", "claude-opus-4-1", "claude-opus-4-5", "claude-opus-4-6",
"claude-sonnet-4-0", "claude-sonnet-4-5", "claude-sonnet-4-6",
"claude-haiku-4-5",
"claude-opus-4-20", "claude-sonnet-4-20"
)

private def matches(model: String, markers: Seq[String]): Boolean = {
val m = model.toLowerCase
markers.exists(m.contains)
}

/**
* Thinking request shape for a model.
*
* Defaults to Adaptive for unrecognized names: new Anthropic models move
* forward, not back, so an unknown identifier is far more likely to be a
* newer adaptive-only model than a pre-4.6 one.
*/
def thinkingMode(model: String): ClaudeThinkingMode =
if (matches(model, ExtendedThinkingMarkers)) ClaudeThinkingMode.Extended
else ClaudeThinkingMode.Adaptive

/**
* Whether a `temperature` value may be sent for this model at all.
*
* False for 4.7+ regardless of thinking state.
*/
def supportsSamplingParams(model: String): Boolean =
matches(model, SamplingParamsMarkers)

/**
* Map the extension's reasoning_effort config onto Anthropic's
* `output_config.effort` value.
*
* The extension accepts none|low|medium|high|xhigh. Anthropic accepts
* low|medium|high|xhigh|max -- there is no "none", so it is treated as
* "no explicit effort" and the API default (high) applies.
*/
def effortValue(reasoningEffort: Option[String]): Option[String] =
reasoningEffort.map(_.toLowerCase.trim).collect {
case e @ ("low" | "medium" | "high" | "xhigh" | "max") => e
}
}
72 changes: 48 additions & 24 deletions src/main/providers/ClaudeProvider.scala
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,13 @@ class ClaudeProvider(implicit ec: ExecutionContext) extends BaseHttpProvider {
}

override protected def buildHeaders(apiKey: Option[String]): Map[String, String] = {
// Note: This reads ENABLE_THINKING from the provider's configStore, which stays in sync
// because LLMExtension invalidates the provider (currentProvider = None) on thinking config changes.
val thinkingEnabled = configStore.get(ConfigStore.ENABLE_THINKING).exists(_.toLowerCase == "true")
val version = if (thinkingEnabled) "2025-04-15" else "2023-06-01"
// 2023-06-01 is the only current Anthropic API version; thinking does not
// require a different one. (A previous version bumped this to "2025-04-15"
// when thinking was on, which is not a version Anthropic publishes.)
Map(
"x-api-key" -> apiKey.getOrElse(throw new IllegalStateException("API key required for Claude")),
"content-type" -> "application/json",
"anthropic-version" -> version
"anthropic-version" -> ClaudeProvider.ApiVersion
)
}

Expand Down Expand Up @@ -78,28 +77,48 @@ class ClaudeProvider(implicit ec: ExecutionContext) extends BaseHttpProvider {
baseRequest("system") = sysMsg.content
}

if (isThinking) {
// Anthropic requires budget >= 1024 AND budget < max_tokens, so max_tokens must be > 1024
if (maxTokens <= 1024) {
throw new RuntimeException(
s"Claude thinking requires max_tokens > 1024 (current: $maxTokens). " +
"The thinking budget must be at least 1024 and less than max_tokens."
)
}
// Newer Claude generations (4.7+) reject non-default temperature/top_p/top_k
// with a 400 on EVERY request, thinking or not -- so this gate also applies
// to the non-thinking path below.
val allowsSampling = ClaudeModelCapabilities.supportsSamplingParams(request.model)

// Anthropic requires temperature=1.0 when thinking is enabled
baseRequest("temperature") = 1.0
if (isThinking) {
ClaudeModelCapabilities.thinkingMode(request.model) match {
case ClaudeThinkingMode.Extended =>
// Legacy shape: budget >= 1024 AND budget < max_tokens, so max_tokens must be > 1024
if (maxTokens <= 1024) {
throw new RuntimeException(
s"Claude thinking requires max_tokens > 1024 (current: $maxTokens). " +
"The thinking budget must be at least 1024 and less than max_tokens."
)
}

// Budget must be >= 1024 and < max_tokens
val budget = request.thinkingConfig.flatMap(_.budgetTokens)
.map(b => math.max(1024, math.min(b, maxTokens - 1)))
.getOrElse(math.max(1024, math.min(4096, maxTokens - 1)))
// These models require temperature=1.0 when thinking is enabled
if (allowsSampling) {
baseRequest("temperature") = 1.0
}

baseRequest("thinking") = ujson.Obj(
"type" -> "enabled",
"budget_tokens" -> budget
)
} else {
val budget = request.thinkingConfig.flatMap(_.budgetTokens)
.map(b => math.max(1024, math.min(b, maxTokens - 1)))
.getOrElse(math.max(1024, math.min(4096, maxTokens - 1)))

baseRequest("thinking") = ujson.Obj(
"type" -> "enabled",
"budget_tokens" -> budget
)

case ClaudeThinkingMode.Adaptive =>
// Adaptive models take no budget_tokens and no temperature; depth is
// steered by output_config.effort instead.
baseRequest("thinking") = ujson.Obj("type" -> "adaptive")

ClaudeModelCapabilities
.effortValue(request.thinkingConfig.flatMap(_.reasoningEffort))
.foreach { effort =>
baseRequest("output_config") = ujson.Obj("effort" -> effort)
}
}
} else if (allowsSampling) {
request.temperature.foreach { temp =>
baseRequest("temperature") = temp
}
Expand Down Expand Up @@ -166,3 +185,8 @@ class ClaudeProvider(implicit ec: ExecutionContext) extends BaseHttpProvider {
}
}
}

object ClaudeProvider {
/** The only Anthropic API version currently published. */
val ApiVersion: String = "2023-06-01"
}
17 changes: 11 additions & 6 deletions src/main/providers/ModelRegistry.scala
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,20 @@ object ModelRegistry {
private var modelDirLoaded: Option[String] = None
private var overrideLoadMessage: Option[String] = None

// Fallback config in case YAML loading fails (minimal set for stability)
private val FALLBACK_CONFIG: Map[String, ProviderModels] = Map(
// Fallback config in case YAML loading fails (minimal set for stability).
//
// Every provider's defaultModel must appear here as well as in models.yaml.
// The drift guard in ProviderDefaultsSpec checks descriptors against the
// LOADED registry, so a stale entry here survives it — which is how the
// retired models #62 removed elsewhere lingered in this map.
private[llm] val FALLBACK_CONFIG: Map[String, ProviderModels] = Map(
"openai" -> ProviderModels(Set("gpt-4o", "gpt-4o-mini", "gpt-4", "gpt-3.5-turbo"), isCustom = false),
"anthropic" -> ProviderModels(Set(
"claude-3-5-sonnet-20241022", "claude-3-5-sonnet-latest",
"claude-3-5-haiku-20241022", "claude-3-5-haiku-latest"
"claude-opus-5", "claude-sonnet-5",
"claude-haiku-4-5-20251001", "claude-opus-4-5-20251101"
), isCustom = false),
"gemini" -> ProviderModels(Set("gemini-1.5-pro", "gemini-1.5-flash", "gemini-2.0-flash-exp"), isCustom = false),
"ollama" -> ProviderModels(Set("llama3.2", "llama3.1", "mistral", "phi4"), isCustom = false),
"gemini" -> ProviderModels(Set("gemini-2.5-pro", "gemini-2.5-flash", "gemini-3-pro-preview"), isCustom = false),
"ollama" -> ProviderModels(Set("llama3.2:3b", "llama3.2:1b", "mistral", "phi4"), isCustom = false),
"openrouter" -> ProviderModels(Set("openai/gpt-4o", "openai/gpt-4o-mini", "anthropic/claude-3.5-sonnet", "deepseek/deepseek-r1"), isCustom = false),
"together" -> ProviderModels(Set("meta-llama/Llama-3.3-70B-Instruct-Turbo", "deepseek-ai/DeepSeek-R1", "Qwen/Qwen2.5-72B-Instruct-Turbo"), isCustom = false)
)
Expand Down
8 changes: 4 additions & 4 deletions src/main/providers/ProviderRegistrations.scala
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ object ProviderRegistrations {
apiKeyConfigKey = "anthropic_api_key",
baseUrlConfigKey = "anthropic_base_url",
defaultBaseUrl = "https://api.anthropic.com/v1",
defaultModel = "claude-3-5-haiku-latest",
defaultModel = "claude-haiku-4-5-20251001",
defaultMaxTokens = "4000",
requiresApiKey = true,
apiKeyPrefix = None,
Expand Down Expand Up @@ -76,7 +76,7 @@ object ProviderRegistrations {
apiKeyConfigKey = "gemini_api_key",
baseUrlConfigKey = "gemini_base_url",
defaultBaseUrl = "https://generativelanguage.googleapis.com/v1beta",
defaultModel = "gemini-1.5-flash",
defaultModel = "gemini-2.5-flash",
defaultMaxTokens = "2048",
requiresApiKey = true,
apiKeyPrefix = None,
Expand Down Expand Up @@ -104,7 +104,7 @@ object ProviderRegistrations {
apiKeyConfigKey = "ollama_api_key",
baseUrlConfigKey = "ollama_base_url",
defaultBaseUrl = "http://localhost:11434",
defaultModel = "llama3.2",
defaultModel = "llama3.2:3b",
defaultMaxTokens = "2048",
requiresApiKey = false,
apiKeyPrefix = None,
Expand All @@ -122,7 +122,7 @@ object ProviderRegistrations {
| - Or start Ollama app (it runs in background)
|
|3. Pull a model:
| - Run: ollama pull llama3.2
| - Run: ollama pull llama3.2:3b
| - Or try: ollama pull deepseek-r1:1.5b (smaller)
|
|4. Verify installation:
Expand Down
25 changes: 17 additions & 8 deletions src/main/resources/config/models.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,29 @@ openai:
- gpt-3.5-turbo

anthropic:
# Claude 4.5 (latest production)
# Adaptive thinking (thinking.type "adaptive" + output_config.effort).
# These reject thinking.type "enabled" and any non-default temperature.
# IDs are dateless from the 4.6 generation on, and still pinned snapshots.
- claude-fable-5
- claude-opus-5
- claude-sonnet-5
- claude-opus-4-8
- claude-opus-4-7

# Adaptive thinking, extended thinking deprecated but still accepted
- claude-opus-4-6
- claude-sonnet-4-6

# Extended thinking only (thinking.type "enabled" + budget_tokens).
# These reject "adaptive".
- claude-opus-4-5-20251101
- claude-sonnet-4-5-20250929
- claude-haiku-4-5-20251001
# Claude 4.1 (current)

# Claude 4.1 / 4 (extended thinking)
- claude-opus-4-1-20250805

# Claude 4 (stable)
- claude-sonnet-4-20250514
- claude-opus-4-20250514

# Claude 3.7 (deprecated, retiring Feb 19, 2026)
- claude-3-7-sonnet-20250219 # deprecated

gemini:
# Gemini 3 (latest preview)
Expand Down
Loading
Loading