diff --git a/ClaudeRevit.PaneTests/ClaudeRevit.PaneTests.csproj b/ClaudeRevit.PaneTests/ClaudeRevit.PaneTests.csproj index 2ed0eba..6b3de4d 100644 --- a/ClaudeRevit.PaneTests/ClaudeRevit.PaneTests.csproj +++ b/ClaudeRevit.PaneTests/ClaudeRevit.PaneTests.csproj @@ -31,6 +31,7 @@ + diff --git a/ClaudeRevit.PaneTests/Program.cs b/ClaudeRevit.PaneTests/Program.cs index 4af8346..633450d 100644 --- a/ClaudeRevit.PaneTests/Program.cs +++ b/ClaudeRevit.PaneTests/Program.cs @@ -46,7 +46,7 @@ private static void Main(string[] args) effort.SelectedValue = "high"; Check(SettingsStore.GetAgentSelection("codex").Effort == "high", "Codex effort was not saved"); agent.SelectedIndex = 1; - model.SelectedValue = "opus"; + model.SelectedValue = "claude-opus-5-5"; effort.SelectedValue = "medium"; agent.SelectedIndex = 2; Check((string)model.SelectedValue == "gpt-test" && (string)effort.SelectedValue == "high", "Codex choices were not restored"); @@ -58,7 +58,7 @@ private static void Main(string[] args) await (Task)send.Invoke(pane, null)!; Check(ChatService.SentSelection?.Model == "custom-model-id", "Typed model was ignored on send"); Check(ChatService.SentSelection?.Agent == "codex", "Wrong agent on send"); - Check(SettingsStore.GetAgentSelection("claudecode").Model == "opus", "Claude model was overwritten by Codex"); + Check(SettingsStore.GetAgentSelection("claudecode").Model == "claude-opus-5-5", "Claude model was overwritten by Codex"); model.SelectedValue = "gpt-test"; effort.SelectedValue = "high"; ChatService.Pending = new(); @@ -117,7 +117,7 @@ private static void Main(string[] args) Check(((ItemsControl)pane.FindName("AttachmentList")).Items.Count == 0, "A's attachments leaked into B"); Check(pane.Messages.Count == 0, "Another project's transcript was shown in B"); agent.SelectedIndex=1; - model.SelectedValue="sonnet"; + model.SelectedValue="claude-sonnet-5-5"; effort.SelectedValue="low"; pane.Messages.Add(new ChatMessage { Role = "user", Text = "Project B" }); DocumentHarness.Change("a", false); @@ -125,7 +125,7 @@ private static void Main(string[] args) Check(pane.Messages.Single().Text == "Project A", "Returning to A lost its transcript"); Check((string)model.SelectedValue=="gpt-test" && (string)effort.SelectedValue=="high","A's selected model/effort leaked from B"); agent.SelectedIndex=1; - Check((string)model.SelectedValue=="opus" && (string)effort.SelectedValue=="medium","A's other provider choices leaked from B"); + Check((string)model.SelectedValue=="claude-opus-5-5" && (string)effort.SelectedValue=="medium","A's other provider choices leaked from B"); agent.SelectedIndex=2; ChatService.Pending = new(); ((TextBox)pane.FindName("InputBox")).Text = "Pending A turn"; diff --git a/ClaudeRevit.PaneTests/Stubs.cs b/ClaudeRevit.PaneTests/Stubs.cs index 72688d0..9c9ef4b 100644 --- a/ClaudeRevit.PaneTests/Stubs.cs +++ b/ClaudeRevit.PaneTests/Stubs.cs @@ -46,6 +46,12 @@ public void ClearHistory() { } public void RecreateClient() { } public void Activate() { } } + public static class ClaudeModelCatalog + { + public static bool LastWasLive { get; private set; } + public static Task> ReadAsync(CancellationToken ct, bool forceRefresh = false) => + Task.FromResult(ClaudeModels.Current); + } public static class CodexModelCatalog { public static Task> ReadAsync(string exe, string workDir, CancellationToken ct, bool forceRefresh = false) => diff --git a/ClaudeRevit.Tests/ClaudeModelsTests.cs b/ClaudeRevit.Tests/ClaudeModelsTests.cs index de9bd91..32a4695 100644 --- a/ClaudeRevit.Tests/ClaudeModelsTests.cs +++ b/ClaudeRevit.Tests/ClaudeModelsTests.cs @@ -10,7 +10,9 @@ public class ClaudeModelsTests [InlineData("sonnet-5-5", "claude-sonnet-5-5")] [InlineData("haiku-5-5", "claude-haiku-5-5")] [InlineData("fable-5-1", "claude-fable-5-1")] - [InlineData("auto", "claude-sonnet-5")] + [InlineData("fable-5-2", "claude-fable-5-2")] + [InlineData("claude-opus-5-5", "claude-opus-5-5")] + [InlineData("auto", "claude-sonnet-5-5")] public void TagsMapToApiIds(string tag, string id) => Assert.Equal(id, ClaudeModels.ApiId(tag)); // Served ids come back dashed or in the SDK's enum spelling; the newer ids must not fall @@ -27,12 +29,40 @@ public class ClaudeModelsTests [InlineData("claude-fable-5", "fable-5")] [InlineData("claude-opus-4-8", "opus-4-8")] [InlineData("claude-sonnet-5", "sonnet-5")] + [InlineData("claude-fable-5-2", "fable-5-2")] + [InlineData("claude-opus-4-5-20251101", "opus-4-5")] + [InlineData("claude-sonnet-5-20260101", "sonnet-5")] public void ServedModelsMapToRateTags(string id, string tag) => Assert.Equal(tag, ClaudeModels.TagFromModel(id)); + // The pickers follow the Models API: Claude ids only, in the API's order, without repeats, + // and a missing display name is derived from the id. + [Fact] + public void ServedCatalogBecomesPickerEntries() + { + var list = ClaudeModels.FromServed(new (string, string?)[] + { + ("claude-fable-5-2", "Claude Fable 5.2"), ("claude-opus-5-5", null), ("gpt-5", "GPT"), + ("claude-opus-5-5", "dup"), ("claude-haiku-4-5", "Claude Haiku 4.5"), + }); + Assert.Equal(new[] { "fable-5-2", "opus-5-5", "haiku-4-5" }, list.Select(m => m.Tag)); + Assert.Equal("Claude Opus 5.5", list[1].Name); + Assert.Equal(new[] { "fable-5-2", "opus-5-5", "sonnet-5-5", "haiku-5-5" }, ClaudeModels.Current.Select(m => m.Tag)); + } + + [Theory] + [InlineData("opus-5-5", true)] + [InlineData("claude-sonnet-4-6", true)] + [InlineData("fable-5-2", true)] + [InlineData("haiku-4-5", false)] + [InlineData("opus-4-5-20251101", false)] + [InlineData("sonnet-4-5", false)] + public void AdaptiveThinkingFromThe46GenerationOn(string tag, bool expected) => + Assert.Equal(expected, ClaudeModels.SupportsAdaptiveThinking(tag)); + [Fact] public void PreservedThinkingModelsKeepAppendOnlyHistory() { - foreach (var tag in new[] { "fable-5-1", "opus-5-5", "sonnet-5-5", "haiku-5-5" }) + foreach (var tag in new[] { "fable-5-2", "fable-5-1", "opus-5-5", "sonnet-5-5", "haiku-5-5", "claude-opus-5-5" }) Assert.True(ClaudeModels.AppendOnlyHistory(tag), tag); foreach (var tag in new[] { "sonnet-5", "opus-5", "opus-4-8", "fable-5", "haiku-4-5", "auto" }) Assert.False(ClaudeModels.AppendOnlyHistory(tag), tag); @@ -57,6 +87,9 @@ public void PricesMatchTheCurrentListAndUnknownTagsOverestimate() // Fable 5.1 reads its cache at $0.25 per million, Opus 5.5 at $0.20. Assert.True(ClaudeModels.TryPrice("fable-5-1", 1000, out var fable51)); Assert.Equal(0.25m, ClaudeModels.Cost(fable51, 0, 0, 0, 1_000_000)); + Assert.True(ClaudeModels.TryPrice("fable-5-2", 1000, out var fable52)); + Assert.Equal(40m, ClaudeModels.Cost(fable52, 0, 1_000_000, 0, 0)); + Assert.Equal(0.20m, ClaudeModels.Cost(fable52, 0, 0, 0, 1_000_000)); Assert.True(ClaudeModels.TryPrice("opus-5-5", 1000, out var opus55)); Assert.Equal(0.20m, ClaudeModels.Cost(opus55, 0, 0, 0, 1_000_000)); // 1-hour cache writes are twice the base input rate. diff --git a/ClaudeRevit/Services/ChatService.cs b/ClaudeRevit/Services/ChatService.cs index 4dc444b..7adcf93 100644 --- a/ClaudeRevit/Services/ChatService.cs +++ b/ClaudeRevit/Services/ChatService.cs @@ -863,8 +863,8 @@ private async Task SendCoreAsync( // invalidate the 1h prompt-cache prefix for the rest of the turn, and rebuilding // the tool list would let a Settings change yank tools out from under the model // mid-plan (plus ~130 tool schemas re-serialized per round for nothing). - // "Auto" model mode. DEFAULT (advisorMode): Sonnet 5 runs every round and consults Opus - // 4.8 via the advisor tool only when it needs a plan — Sonnet's prompt cache stays warm + // "Auto" model mode. DEFAULT (advisorMode): Sonnet 5.5 runs every round and consults Opus + // 5.5 via the advisor tool only when it needs a plan — Sonnet's prompt cache stays warm // all session, Opus is billed only for the short advice sub-inference. The legacy // alternative (Settings → AutoUseAdvisor off) switches the whole turn to Opus on a hard // task/error/deep-loop; see the escalation vars below. @@ -926,7 +926,7 @@ List ClaudeTools() // to a stronger one when the task looks hard up front, or once the fast model starts // erroring / the task turns into a deep multi-step loop. Two paths share this machinery: // • Alt path — fast alt model → the configured "reasoning" alt model. - // • Claude "Auto" — Sonnet 5 (cheap) → Opus 4.8 (strong). This is the big cost lever: + // • Claude "Auto" — Sonnet 5.5 (cheap) → Opus 5.5 (strong). This is the big cost lever: // Sonnet is $3/$15 vs Opus $5/$25 per 1M, so routine turns run at ~60% of the price // and Opus is reserved for the turns that actually need it. // Legacy model-switch path (alt reasoning model, or Auto with the advisor turned off). @@ -1005,11 +1005,11 @@ await ui.InvokeAsync(() => conversation.Add(new ChatMessage // the reasoning model still takes precedence, since that is a deliberate switch. var altModelForTurn = escalate ? reasoningModel : AltModelOverride(model); // Claude Auto: in advisor mode the executor is the configured cheap/fast model - // (Sonnet 5 default, or Haiku 4.5 for the cheapest tier); the advisor is reached + // (Sonnet 5.5 default, or Haiku 5.5 for the cheapest tier); the advisor is reached // via the tool, not by switching. In legacy mode: Sonnet until escalated to Opus. var claudeModelForTurn = advisorMode ? SettingsStore.AutoExecutorModel - : (claudeAuto ? (escalate ? "opus-4-8" : "sonnet-5") : model); + : (claudeAuto ? (escalate ? ClaudeModels.DefaultAdvisor : ClaudeModels.DefaultExecutor) : model); BackendTurn turn; try { @@ -1741,7 +1741,7 @@ private static BackendTurn ToBackendTurn(BetaMessage message) // The advisor tool definition for Auto mode: the executor consults the advisor model at most // once per request (a per-turn total cap is enforced by the caller), with the advisor's own // transcript cached for an hour so repeated consults in a session don't re-bill the whole - // prefix. Advisor model is user-configurable (Opus 4.8 default, or Fable 5 for hardest tasks). + // prefix. Advisor model is user-configurable (Opus 5.5 default, or Fable 5.2 for hardest tasks). private static BetaToolUnion AdvisorTool(string advisorModelId) => new BetaAdvisorTool20260301 { Model = advisorModelId, diff --git a/ClaudeRevit/Services/ClaudeCodeBackend.cs b/ClaudeRevit/Services/ClaudeCodeBackend.cs index 1d82a0e..cd02cd8 100644 --- a/ClaudeRevit/Services/ClaudeCodeBackend.cs +++ b/ClaudeRevit/Services/ClaudeCodeBackend.cs @@ -42,26 +42,17 @@ public sealed class Result public string? Subtype; } - // Map our internal model tag to what Claude Code accepts for `--model`. - // - // The older tags use the coarse family aliases ("opus"/"sonnet"/"haiku"), which resolve to - // whatever the CLI currently considers that family's model. That is fine for them, but it can't - // express "Opus 5 specifically" — the family alias would just pick the CLI's current Opus and - // make two dropdown entries behave identically. So the newer models pass their FULL model id, - // which Claude Code also accepts, and the pane's choice actually decides. + // Map our internal model tag to what Claude Code accepts for `--model`: the full model id, + // so the pane's choice decides exactly which model runs (a family alias such as "opus" + // would silently pick whatever the CLI currently considers that family's model). Tags come + // from the Models API catalog, so any model the account can use maps without a table here. // // Returns null for "auto" — there we let the CLI pick, because the advisor-escalation that - // "auto" means on the API has no equivalent inside Claude Code's own loop. Fable 5 used to - // map to null too, which silently ran the CLI's default model instead of the one chosen. - // A tag the CLI doesn't recognise surfaces as a CLI error rather than a silent fallback. - public static string? ModelAlias(string? tag) => tag switch - { - "opus-5-5" or "sonnet-5-5" or "haiku-5-5" or "opus-5" or "fable-5" or "fable-5-1" => ClaudeModels.ApiId(tag), - "opus-4-8" or "opus-4-7" or "opus-4-6" => "opus", - "sonnet-5" or "sonnet-4-6" => "sonnet", - "haiku-4-5" => "haiku", - _ => null - }; + // "auto" means on the API has no equivalent inside Claude Code's own loop. A tag the CLI + // doesn't recognise surfaces as a CLI error rather than a silent fallback. + public static string? ModelAlias(string? tag) => + string.IsNullOrWhiteSpace(tag) || tag == "auto" || tag!.StartsWith("alt", StringComparison.Ordinal) + ? null : ClaudeModels.ApiId(tag); public static async Task RunAsync( string exe, string prompt, string workDir, string mcpConfigPath, string? resumeSessionId, diff --git a/ClaudeRevit/Services/ClaudeModelCatalog.cs b/ClaudeRevit/Services/ClaudeModelCatalog.cs new file mode 100644 index 0000000..80e3984 --- /dev/null +++ b/ClaudeRevit/Services/ClaudeModelCatalog.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Anthropic; +using Anthropic.Core; +using Anthropic.Models.Models; + +namespace ClaudeRevit.Services; + +// The Claude models this account can use, read from the Models API (GET /v1/models) — the +// counterpart of CodexModelCatalog, so the pickers follow what Anthropic serves instead of a +// hand-kept list that goes stale with each release. Listing models is free: no inference runs. +// Without an API key (Claude Code on a subscription) the built-in current generation is used. +public static class ClaudeModelCatalog +{ + private static readonly SemaphoreSlim CacheGate = new(1, 1); + private static string? _cacheKey; + private static IReadOnlyList? _cached; + private static DateTime _expires; + + // True when the last answer came from the Models API rather than the built-in list. + public static bool LastWasLive { get; private set; } + + public static async Task> ReadAsync(CancellationToken ct, bool forceRefresh = false) + { + var apiKey = ApiKeyStore.Load(); + if (string.IsNullOrWhiteSpace(apiKey)) { LastWasLive = false; return ClaudeModels.Current; } + // The key itself is never kept; its hash only tells a changed key from the cached one. + var key = Convert.ToBase64String(System.Security.Cryptography.SHA256.Create() + .ComputeHash(System.Text.Encoding.UTF8.GetBytes(apiKey))); + await CacheGate.WaitAsync(ct); + try + { + ct.ThrowIfCancellationRequested(); + if (!forceRefresh && _cached != null && _cacheKey == key && DateTime.UtcNow < _expires) + { LastWasLive = true; return _cached; } + // A failed explicit refresh must not leave stale entries available to the next run. + _cached = null; + var models = await DiscoverAsync(apiKey!, ct); + if (models.Count == 0) throw new InvalidOperationException("The Models API returned no Claude models."); + _cacheKey = key; _cached = models; _expires = DateTime.UtcNow.AddMinutes(5); + LastWasLive = true; + return models; + } + finally { CacheGate.Release(); } + } + + private static async Task> DiscoverAsync(string apiKey, CancellationToken ct) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(30)); + var client = new AnthropicClient(new ClientOptions { ApiKey = apiKey }); + var served = new List<(string, string?)>(); + // Newest first, as the API orders them; bounded so a misbehaving cursor cannot loop. + IPage page = await client.Models.List(new ModelListParams { Limit = 100 }, timeout.Token); + for (var pages = 0; ; pages++) + { + foreach (var model in page.Items) served.Add((model.ID, model.DisplayName)); + if (pages >= 20 || !page.HasNext()) break; + page = await page.Next(timeout.Token); + } + return ClaudeModels.FromServed(served); + } +} diff --git a/ClaudeRevit/Services/ClaudeModels.cs b/ClaudeRevit/Services/ClaudeModels.cs index f15ede8..0ad00c2 100644 --- a/ClaudeRevit/Services/ClaudeModels.cs +++ b/ClaudeRevit/Services/ClaudeModels.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace ClaudeRevit.Services; @@ -7,54 +8,98 @@ namespace ClaudeRevit.Services; // the Claude Code backend all read them from here instead of keeping their own lists. public static class ClaudeModels { - public static string ApiId(string tag) => tag switch + // A tag is the API id without its "claude-" prefix ("opus-5-5" <-> "claude-opus-5-5"), so + // any model the Models API returns maps to a tag without a hand-kept table. A full id is + // accepted as-is; "auto" (and an empty tag) resolve to the current default executor. + public const string DefaultExecutor = "sonnet-5-5"; + public const string DefaultAdvisor = "opus-5-5"; + + public static string ApiId(string tag) + { + var t = (tag ?? "").Trim(); + if (t.Length == 0 || t == "auto") return "claude-" + DefaultExecutor; + return t.StartsWith("claude-", StringComparison.OrdinalIgnoreCase) ? t : "claude-" + t; + } + + public static string TagOf(string apiId) + { + var id = (apiId ?? "").Trim(); + return id.StartsWith("claude-", StringComparison.OrdinalIgnoreCase) ? id.Substring(7) : id; + } + + // Normalise a served model id (either "claude-opus-4-8", a dated "claude-opus-4-5-20251101" + // or an enum form like "ClaudeOpus4_8") to our rate tag: family, major, optional one-digit + // minor. A date suffix is not a minor version. Unknown ids default to the advisor. + public static string TagFromModel(string id) => Normalize(id) ?? DefaultAdvisor; + + // Family-major[-minor] of any Claude id or tag, or null when it names no Claude model. + private static string? Normalize(string? id) { - "opus-5-5" => "claude-opus-5-5", - "sonnet-5-5" => "claude-sonnet-5-5", - "haiku-5-5" => "claude-haiku-5-5", - "opus-5" => "claude-opus-5", - "fable-5-1" => "claude-fable-5-1", - "opus-4-8" => "claude-opus-4-8", - "fable-5" => "claude-fable-5", - "haiku-4-5" => "claude-haiku-4-5", - "sonnet-4-6" => "claude-sonnet-4-6", - "opus-4-7" => "claude-opus-4-7", - _ => "claude-sonnet-5" + var s = (id ?? "").ToLowerInvariant().Replace("_", "-"); + var m = System.Text.RegularExpressions.Regex.Match(s, @"(fable|mythos|opus|sonnet|haiku)-?(\d+)(?:-(\d)(?!\d))?"); + if (!m.Success) return null; + return m.Groups[1].Value + "-" + m.Groups[2].Value + (m.Groups[3].Success ? "-" + m.Groups[3].Value : ""); + } + + // Rules below are keyed by the normalised tag, so a dated id from the Models API + // ("opus-4-5-20251101") or a full id ("claude-opus-5-5") gets its family's rules. + private static string Key(string tag) => Normalize(tag) ?? tag; + + // Display name for a tag the Models API did not name: "opus-5-5" -> "Claude Opus 5.5". + public static string DisplayName(string tag) + { + var parts = TagOf(ApiId(tag)).Split('-'); + if (parts.Length < 2 || parts[0].Length == 0 || !char.IsDigit(parts[1][0])) return TagOf(ApiId(tag)); + var version = parts[1] + (parts.Length > 2 && parts[2].Length == 1 ? "." + parts[2] : ""); + return "Claude " + char.ToUpperInvariant(parts[0][0]) + parts[0].Substring(1) + " " + version; + } + + // Shown until the Models API answers (no API key, offline): the current generation only. + // Same order as the API: most capable/newest first. + public static readonly IReadOnlyList Current = new[] + { + new ClaudeModelInfo("fable-5-2", "Claude Fable 5.2"), + new ClaudeModelInfo("opus-5-5", "Claude Opus 5.5"), + new ClaudeModelInfo("sonnet-5-5", "Claude Sonnet 5.5"), + new ClaudeModelInfo("haiku-5-5", "Claude Haiku 5.5"), }; - // Normalise a served model id (either "claude-opus-4-8" or an enum form like "ClaudeOpus4_8") - // to our rate tag. Most specific first: "opus-5-5" must not fall into "opus-5", and - // "fable-5-1" must not collapse into "fable-5". Unknown ids default to opus-4-8, the advisor. - public static string TagFromModel(string id) + // The Models API answer as picker entries: Claude ids only, de-duplicated, order kept. + public static IReadOnlyList FromServed(IEnumerable<(string Id, string? Name)> served) { - var s = id.ToLowerInvariant().Replace("_", "-"); - if (s.Contains("opus-5-5") || s.Contains("opus5-5")) return "opus-5-5"; - if (s.Contains("sonnet-5-5") || s.Contains("sonnet5-5")) return "sonnet-5-5"; - if (s.Contains("haiku-5-5") || s.Contains("haiku5-5")) return "haiku-5-5"; - if (s.Contains("opus-5") || s.Contains("opus5")) return "opus-5"; - if (s.Contains("fable-5-1") || s.Contains("fable5-1")) return "fable-5-1"; - if (s.Contains("opus") && s.Contains("4-7")) return "opus-4-7"; - if (s.Contains("opus")) return "opus-4-8"; - if (s.Contains("sonnet") && s.Contains("4-6")) return "sonnet-4-6"; - if (s.Contains("sonnet")) return "sonnet-5"; - if (s.Contains("haiku")) return "haiku-4-5"; - if (s.Contains("fable")) return "fable-5"; - return "opus-4-8"; + var list = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (id, name) in served) + { + if (string.IsNullOrWhiteSpace(id) || !id.StartsWith("claude-", StringComparison.OrdinalIgnoreCase)) continue; + var tag = TagOf(id); + if (!seen.Add(tag)) continue; + list.Add(new ClaudeModelInfo(tag, string.IsNullOrWhiteSpace(name) ? DisplayName(tag) : name!.Trim())); + } + return list; } // Preserved thinking: these models bind each thinking block to the exact conversation before // it, and for accounts created from 31 August 2026 the API answers an edited history with a // 400. For them the history must be append-only: no tool-result aging, no client-side // compaction, no per-request text removed again, and system/tools kept stable. - public static bool AppendOnlyHistory(string tag) => tag is "fable-5-1" or "opus-5-5" or "sonnet-5-5" or "haiku-5-5"; + public static bool AppendOnlyHistory(string tag) => Key(tag) is "fable-5-2" or "mythos-5-2" or "fable-5-1" or "opus-5-5" or "sonnet-5-5" or "haiku-5-5"; // Models whose safety classifier can decline a request and that accept the server-side // fallback ("fallbacks": "default"). Haiku 5.5 declines too but has no server-side fallback. - public static bool SupportsServerFallback(string tag) => tag is "fable-5" or "fable-5-1" or "opus-5" or "opus-5-5" or "sonnet-5-5"; + public static bool SupportsServerFallback(string tag) => Key(tag) is "fable-5-2" or "mythos-5-2" or "fable-5" or "fable-5-1" or "opus-5" or "opus-5-5" or "sonnet-5-5"; - // Haiku 4.5 predates adaptive thinking and rejects the effort parameter; every other - // current model takes both. - public static bool SupportsAdaptiveThinking(string tag) => tag != "haiku-4-5"; + // Adaptive thinking and effort arrived with the 4.6 generation; Haiku 4.5 and anything older + // (which the Models API may still list) reject both. Fable and Mythos always take them. + public static bool SupportsAdaptiveThinking(string tag) + { + var t = Key(tag); + if (t.StartsWith("fable-") || t.StartsWith("mythos-")) return true; + var parts = t.Split('-'); + if (parts.Length < 2 || !int.TryParse(parts[1], out var major)) return true; + var minor = parts.Length > 2 && int.TryParse(parts[2], out var n) ? n : 0; + return major > 4 || (major == 4 && minor >= 6); + } // List prices per million tokens (USD) and the cache-read price as a fraction of base input. // Cache writes are always 1-hour here (2x base input). Haiku 5.5 is priced by prompt size: @@ -65,16 +110,17 @@ public static string TagFromModel(string id) public static bool TryPrice(string tag, long promptTokens, out Price price) { - price = tag switch + price = Key(tag) switch { "opus-5-5" => new(4.0m, 20.0m, 0.05m), "sonnet-5-5" => new(2.0m, 10.0m, 0.1m), "haiku-5-5" => promptTokens > 100_000 ? new(0.5m, 2.5m, 0.1m) : new(0.1m, 0.5m, 0.1m), + "fable-5-2" or "mythos-5-2" => new(8.0m, 40.0m, 0.025m), "fable-5-1" => new(10.0m, 50.0m, 0.025m), "fable-5" => new(10.0m, 50.0m, 0.1m), "opus-5" => new(5.0m, 25.0m, 0.1m), "sonnet-5" => new(2.0m, 10.0m, 0.1m), - "opus-4-8" or "opus-4-7" => new(5.0m, 25.0m, 0.1m), + "opus-4-8" or "opus-4-7" or "opus-4-6" => new(5.0m, 25.0m, 0.1m), "sonnet-4-6" => new(3.0m, 15.0m, 0.1m), "haiku-4-5" => new(1.0m, 5.0m, 0.1m), _ => default @@ -105,3 +151,6 @@ public static decimal Cost(Price price, long input, long output, long cacheCreat _ => null }; } + +// One entry of the Claude model picker: our tag ("opus-5-5") and the name to show. +public sealed record ClaudeModelInfo(string Tag, string Name); diff --git a/ClaudeRevit/Services/SettingsStore.cs b/ClaudeRevit/Services/SettingsStore.cs index 2642eea..56ab034 100644 --- a/ClaudeRevit/Services/SettingsStore.cs +++ b/ClaudeRevit/Services/SettingsStore.cs @@ -108,23 +108,36 @@ public static bool AutoUseAdvisor } // "Auto" executor model: the cheap/fast model that runs every round. - // "sonnet-5" (default) — best balance. "haiku-4-5" — cheapest/fastest, pairs with an - // advisor for the hard moments (Haiku executor + Opus/Fable advisor is a valid pattern). + // "sonnet-5-5" (default) — best balance. "haiku-5-5" — cheapest, pairs with an advisor + // for the hard moments. Values saved by older versions (Sonnet 5, Haiku 4.5) move to the + // current generation of the same tier. public static string AutoExecutorModel { - get => string.IsNullOrWhiteSpace(Current.AutoExecutorModel) ? "sonnet-5" : Current.AutoExecutorModel; + get => CurrentExecutor(Current.AutoExecutorModel); set { Current.AutoExecutorModel = value; Save(); } } // "Auto" advisor model consulted mid-turn. - // "opus-4-8" (default). "fable-5" — Anthropic's most capable, for the hardest tasks - // (2x Opus's price, but only the short advice sub-inference is billed at that rate). + // "opus-5-5" (default). "fable-5-2" — Anthropic's most capable, for the hardest tasks + // (only the short advice sub-inference is billed at its rate). Both are accepted as advisors + // by both executors; Opus 4.8 and older are rejected by a Sonnet 5.5 executor, so older + // saved values move to the current model of the same tier. public static string AutoAdvisorModel { - get => string.IsNullOrWhiteSpace(Current.AutoAdvisorModel) ? "opus-4-8" : Current.AutoAdvisorModel; + get => CurrentAdvisor(Current.AutoAdvisorModel); set { Current.AutoAdvisorModel = value; Save(); } } + public static string CurrentExecutor(string? saved) => + saved is "sonnet-5-5" or "haiku-5-5" ? saved + : saved != null && saved.StartsWith("haiku", StringComparison.Ordinal) ? "haiku-5-5" + : ClaudeModels.DefaultExecutor; + + public static string CurrentAdvisor(string? saved) => + saved is "opus-5-5" or "fable-5-2" ? saved + : saved != null && (saved.StartsWith("fable", StringComparison.Ordinal) || saved.StartsWith("mythos", StringComparison.Ordinal)) ? "fable-5-2" + : ClaudeModels.DefaultAdvisor; + // Show a per-task diagnostics line (elapsed time, tokens, rounds) after each answer, to // compare how efficiently different models solve the same task. On by default. public static bool ShowTaskDiagnostics @@ -337,8 +350,8 @@ private sealed class Settings public int AltContextK { get; set; } = 0; public bool AltCompactTools { get; set; } = true; public bool AutoUseAdvisor { get; set; } = true; - public string AutoExecutorModel { get; set; } = "sonnet-5"; - public string AutoAdvisorModel { get; set; } = "opus-4-8"; + public string AutoExecutorModel { get; set; } = ClaudeModels.DefaultExecutor; + public string AutoAdvisorModel { get; set; } = ClaudeModels.DefaultAdvisor; public bool ShowTaskDiagnostics { get; set; } = true; public bool McpEnabled { get; set; } public int McpPort { get; set; } = 8788; diff --git a/ClaudeRevit/UI/BenchmarkWindow.xaml b/ClaudeRevit/UI/BenchmarkWindow.xaml index 3d50cb1..c139104 100644 --- a/ClaudeRevit/UI/BenchmarkWindow.xaml +++ b/ClaudeRevit/UI/BenchmarkWindow.xaml @@ -73,9 +73,17 @@ - -