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
1 change: 1 addition & 0 deletions ClaudeRevit.PaneTests/ClaudeRevit.PaneTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<Compile Include="..\ClaudeRevit\UI\ChatMessage.cs" Link="ChatMessage.cs" />
<Compile Include="..\ClaudeRevit\Services\McpAgentSelection.cs" Link="McpAgentSelection.cs" />
<Compile Include="..\ClaudeRevit\Services\CodexModels.cs" Link="CodexModels.cs" />
<Compile Include="..\ClaudeRevit\Services\ClaudeModels.cs" Link="ClaudeModels.cs" />
<Compile Include="..\ClaudeRevit\Services\DocumentSessions.cs" Link="DocumentSessions.cs" />
</ItemGroup>
</Project>
8 changes: 4 additions & 4 deletions ClaudeRevit.PaneTests/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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();
Expand Down Expand Up @@ -117,15 +117,15 @@ 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);
Check(((ItemsControl)pane.FindName("AttachmentList")).Items.Count == 1, "Returning to A lost its pending attachment");
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";
Expand Down
6 changes: 6 additions & 0 deletions ClaudeRevit.PaneTests/Stubs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IReadOnlyList<ClaudeModelInfo>> ReadAsync(CancellationToken ct, bool forceRefresh = false) =>
Task.FromResult(ClaudeModels.Current);
}
public static class CodexModelCatalog
{
public static Task<IReadOnlyList<CodexModel>> ReadAsync(string exe, string workDir, CancellationToken ct, bool forceRefresh = false) =>
Expand Down
37 changes: 35 additions & 2 deletions ClaudeRevit.Tests/ClaudeModelsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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.
Expand Down
12 changes: 6 additions & 6 deletions ClaudeRevit/Services/ChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -863,8 +863,8 @@
// 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.
Expand Down Expand Up @@ -926,7 +926,7 @@
// 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).
Expand Down Expand Up @@ -1005,11 +1005,11 @@
// 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
{
Expand Down Expand Up @@ -1570,7 +1570,7 @@
ContextManagement = ClaudeModels.AppendOnlyHistory(model)
? new BetaContextManagementConfig { Edits = [new BetaCompact20260112Edit()] }
: null,
Fallbacks = fallback ? new Default() : null,

Check warning on line 1573 in ClaudeRevit/Services/ChatService.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'value' in 'BetaFallbacksParam.implicit operator BetaFallbacksParam(Default value)'.
Betas = BetasFor(model, useAdvisorBeta, fallback, binding)
};

Expand Down Expand Up @@ -1674,7 +1674,7 @@
{
turn.Blocks.Add(new ChatFallbackBlock(JsonSerializer.Serialize(fb)));
string? category = null;
try { category = fb.Trigger?.Category.Raw(); } catch { }

Check warning on line 1677 in ClaudeRevit/Services/ChatService.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
turn.FallbackNote = $"[Answered by {fb.To.Model.Raw()}: {fb.From.Model.Raw()} declined this request" +
(string.IsNullOrEmpty(category) ? "" : $" (classifier category: {category})") +
". Server-side fallback can be switched off in Settings.]";
Expand Down Expand Up @@ -1726,7 +1726,7 @@
adv.CacheCreationInputTokens, adv.CacheReadInputTokens));
else if (fellBack && iter.TryPickBetaMessageIterationUsage(out var declined))
turn.AdvisorUsages.Add(new AdvisorUsage(
ClaudeModels.TagFromModel(declined.Model.Raw() ?? ""),

Check warning on line 1729 in ClaudeRevit/Services/ChatService.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
declined.InputTokens, declined.OutputTokens,
declined.CacheCreationInputTokens, declined.CacheReadInputTokens));
}
Expand All @@ -1741,7 +1741,7 @@
// 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,
Expand Down
27 changes: 9 additions & 18 deletions ClaudeRevit/Services/ClaudeCodeBackend.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result> RunAsync(
string exe, string prompt, string workDir, string mcpConfigPath, string? resumeSessionId,
Expand Down
65 changes: 65 additions & 0 deletions ClaudeRevit/Services/ClaudeModelCatalog.cs
Original file line number Diff line number Diff line change
@@ -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<ClaudeModelInfo>? _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<IReadOnlyList<ClaudeModelInfo>> 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<IReadOnlyList<ClaudeModelInfo>> 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<ModelInfo> 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);
}
}
Loading
Loading