Skip to content

Commit efbed19

Browse files
andyfellerCopilot
andauthored
Expose Auto tier switching across all six SDKs (#2514)
* Expose Auto tier switching across all six SDKs The runtime now accepts an Auto routing preference change on a live session through `session.model.switchAutoTier`, and reports the authoritative committed, pending, and activating preferences through `session.model.getCurrent`. Without SDK support, integrators could only choose a tier at session creation or resume and had no way to change it or observe whether a change took effect. Each SDK gains two capabilities: * `setAutoTier` changes the routing preference without changing the selected model. * The model switch options gain an Auto tier field, which stages a tier atomically with selecting `auto`. The runtime distinguishes an explicit null tier, meaning "return to provider-default routing," from an absent one, meaning "leave the preference alone." Python, Go, .NET, Java, and Rust generated wrappers drop null properties, so those SDKs build the request payload directly where a null must survive; each bypass carries a comment explaining why. Node.js needed no workaround. The tri-state choice is expressed idiomatically per language: `undefined`/tier/`null` in Node.js, an `_UNSET` sentinel in Python, a `ClearAutoTier` flag validated as mutually exclusive in Go, .NET, and Java, and an `AutoTierPreference` enum in Rust. Tests cover the wire payloads for both methods, including the explicit null case, and decoding of the ephemeral `session.auto_tier_switch_failed` event across all four failure reasons plus the null requested-tier case. Documentation previously stated that a resident resume rejects a different tier. The authoritative schema now says the runtime requests a safe switch applied after the resume succeeds, which cannot change a turn already in flight. This corrects that text in all six SDKs and in the session persistence guide. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2aad36e9-e078-4b9c-afa1-0c7cd2c71f74 * Address pre-review findings on Auto tier switching Reviewing the change against the standards maintainers have applied to earlier SDK pull requests surfaced four issues worth fixing before opening the pull request. Python could not accept the enum it hands back. `set_auto_tier` and `set_model` were typed for the `AutoTier` string literal, but results and events carry the generated `AutoTier` enum. Feeding that value back raised `TypeError: Object of type AutoTier is not JSON serializable`, because the JSON-RPC encoder calls `json.dumps` with no enum support. Both methods now accept either representation and normalize to the wire value. Added a regression test. .NET and Go hand-copied request fields. Both built a second, partial copy of the model-switch payload so an explicit null tier would survive serialization. Each copy listed only a subset of the generated request's fields, so a new field on the schema would have been silently dropped on the clear path. Both now serialize the generated request and write the null back, matching what Rust, Java, and Python already did. This also removes two hand-written request types and their serializer registrations from the .NET SDK. Java allocated raw `ObjectMapper` instances instead of using the shared `MAPPER`, which is configured to match the JSON-RPC encoder. A differently configured mapper could produce a different wire shape. The .NET test compared the status enum through `ToString`; it now compares the enum directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2aad36e9-e078-4b9c-afa1-0c7cd2c71f74 * Mark the Auto tier surface experimental in every SDK The .NET and Java implementations carried [Experimental] and @CopilotExperimental, but the Node.js, Python, Go, and Rust equivalents carried no marker. That asymmetry is the exact gap maintainers have flagged before: C# gets the annotation and the other languages are left without the corresponding doc annotation. Auto tier routing is still moving — the `fast` tier is not yet exposed and the runtime contract may shift — so the surface is marked experimental consistently in all six SDKs, each using the convention already established in that language. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2aad36e9-e078-4b9c-afa1-0c7cd2c71f74 * Use one vocabulary for resetting the Auto tier The SDKs described the same idea three different ways: Rust called it `AutoTierPreference::ProviderDefault`, while Go, .NET, and Java called it `ClearAutoTier`. Cross-SDK consistency is the standard maintainers apply most often, and a reader comparing two SDKs had no way to tell these were the same operation. Everything now uses "reset": `ResetAutoTier` in Go and .NET, `setResetAutoTier` in Java, and `AutoTierPreference::Reset` with `with_reset_auto_tier` in Rust. The underlying shapes stay language-idiomatic. Node.js and Python express all three states natively, because `null` and `None` are distinguishable from an omitted argument. Rust uses a sum type. Go, .NET, and Java cannot make that distinction in a single value, so they keep a separate reset option. Forcing Rust's enum onto Go would mean exported constructor functions in place of the pointer-and-flag pattern Go already uses for "unset versus explicit zero", which trades a cross-language inconsistency for a within-language one. Documented the trade-off and added a per-SDK table for staging a tier on a model switch, so the difference is explained rather than discovered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2aad36e9-e078-4b9c-afa1-0c7cd2c71f74 * Add end-to-end coverage for Auto tier switching The unit tests for Auto tier switching only check the JSON-RPC payload the SDK builds. That proves the wire shape but not that the runtime interprets it the way the API documents, which is the part reviewers have asked us to demonstrate on earlier model-switching work. These tests run against a real Copilot runtime and read the staged state back through `model.getCurrent()`, so every assertion reflects recorded runtime behavior. Two scenarios, identical across all six SDKs: - Staging and resetting. A request is accepted as `pending`, a second request replaces the first and reports the tier it displaced, and a null tier returns the session to provider-default routing. - Omission semantics. Calling `setModel("auto")` without a tier preserves the staged preference, passing a tier replaces it, and asking for a reset clears it. These are three distinct outcomes, which is why the reset is expressed separately from the tier value in every language. The runtime only commits a preference on a later turn that uses the `auto` model, so it rejects `switchAutoTier` unless `auto` is selected. Both snapshots therefore list `auto` and record no conversation; covering the commit path through `session.model_change` and `session.auto_tier_switch_failed` needs a recorded conversation and is left as follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2aad36e9-e078-4b9c-afa1-0c7cd2c71f74 * Fix Rust CI: format E2E imports and skip the README fragment The Auto tier README example is a fragment that references an undefined 'session' binding, so rustdoc could not compile it as a doctest. Mark it 'rust,ignore' to match every other fragment in the file, and apply the rustfmt import merge the format job asked for. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2aad36e9-e078-4b9c-afa1-0c7cd2c71f74 * Address review feedback on the Auto tier surface Gate the experimental Auto tier options consistently. The .NET SetModelOptions.AutoTier and .ResetAutoTier properties, the Java Auto tier accessors, and the Python set_model auto_tier parameter now carry the same experimental marker their dedicated setAutoTier counterparts already had. Correct two documentation errors. The reset-state paragraph claimed four SDKs cannot express reset in a single value; Rust can, through AutoTierPreference::Reset, so only Go, .NET, and Java need a separate flag. The live-switching section now states that it needs Copilot CLI 1.0.83-4, which is newer than the 1.0.82-1 required to select a tier at create or resume. Fix the Java class Javadoc, which said every option is optional when setModel rejects a null model, and rewrite the Rust E2E doc comment to describe what it covers instead of pointing at the Node.js test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2aad36e9-e078-4b9c-afa1-0c7cd2c71f74 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2aad36e9-e078-4b9c-afa1-0c7cd2c71f74
1 parent 0921029 commit efbed19

42 files changed

Lines changed: 2640 additions & 75 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/features/session-persistence.md

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ When resuming a session, you can optionally reconfigure many settings. This is u
242242
| `availableTools` | Restrict which tools are available |
243243
| `excludedTools` | Disable specific tools |
244244
| `provider` | Re-provide BYOK credentials (required for BYOK sessions) |
245-
| `capi.autoTier` | Override the persisted Auto routing preference on cold resume only |
245+
| `capi.autoTier` | Override the persisted Auto routing preference |
246246
| `reasoningEffort` | Adjust reasoning effort level |
247247
| `streaming` | Enable/disable streaming responses |
248248
| `workingDirectory` | Change the working directory |
@@ -262,13 +262,55 @@ The runtime persists the selected tier, so applications do not need to resend it
262262

263263
* Omitting the tier when creating a session uses the runtime's default routing behavior.
264264
* A cold resume restores the persisted tier. Supplying an explicit tier overrides the restored value for the new activation.
265-
* When resuming a session already resident in the runtime, omitting the tier preserves the current selection, supplying the same tier is a no-op, and supplying a different tier is rejected.
265+
* When resuming a session already resident in the runtime, omitting the tier preserves the current selection and supplying the same tier is a no-op. Supplying a different tier requests a safe switch that the runtime applies after the resume succeeds; it cannot change a turn that is already in flight.
266266
* Older sessions without a persisted tier retain default routing behavior.
267267

268268
Tier selection is not a live model-switch operation. The SDK forwards the preference; the runtime owns persistence and validation.
269269

270270
The `session.start` and `session.resume` events expose the selected tier in their optional `data.autoTier` field (`data.auto_tier` in Python). When no tier is selected, the field is omitted.
271271

272+
### Changing the Auto tier during a session
273+
274+
Call `setAutoTier` to change the routing preference on a live session without changing the selected model. Pass `null` (Python `None`, Go `nil`) to return to the provider's default Auto routing. This requires Copilot CLI `1.0.83-4` or later, which is newer than the `1.0.82-1` needed to select a tier when creating or resuming a session.
275+
276+
```typescript
277+
const result = await session.setAutoTier("intelligence");
278+
if (result.status === "pending") {
279+
// Accepted, but not yet in effect.
280+
}
281+
```
282+
283+
The runtime does not apply the preference immediately. It records the request and commits it only when a later user turn using the `auto` model successfully obtains a usable model from the provider. A `pending` status therefore confirms that the request was accepted, not that it took effect. Only the most recent request survives: a new request replaces any earlier one that no turn has claimed yet.
284+
285+
Watch for the outcome through these events:
286+
287+
* `session.model_change` when the preference commits.
288+
* `session.auto_tier_switch_failed` when it does not. This event is ephemeral, so the runtime never persists or replays it on resume. Its `reason` field is one of `policy_rejected`, `request_failed`, `setup_failed`, or `unsupported`, and the previously effective preference stays active.
289+
290+
You can also read the authoritative state at any time through the session's `model.getCurrent` RPC method, which reports the committed `autoTier`, any unclaimed `pendingAutoTier`, and the `activatingAutoTier` currently claimed by an in-progress activation.
291+
292+
| SDK | Change the tier | Return to provider-default routing |
293+
|-----|-----------------|------------------------------------|
294+
| Node.js | `session.setAutoTier("balance")` | `session.setAutoTier(null)` |
295+
| Python | `session.set_auto_tier("balance")` | `session.set_auto_tier(None)` |
296+
| Go | `session.SetAutoTier(ctx, &tier)` | `session.SetAutoTier(ctx, nil)` |
297+
| .NET | `session.SetAutoTierAsync(AutoTier.Balance)` | `session.SetAutoTierAsync(null)` |
298+
| Rust | `session.set_auto_tier(Some(AutoTier::Balance))` | `session.set_auto_tier(None)` |
299+
| Java | `session.setAutoTier(AutoTier.BALANCE)` | `session.setAutoTier(null)` |
300+
301+
To select the `auto` model and its routing preference in a single call, stage the tier on the model switch instead. The runtime rejects this option when the model is anything other than `auto`.
302+
303+
| SDK | Stage a tier with the switch | Reset to provider-default routing |
304+
|-----|------------------------------|-----------------------------------|
305+
| Node.js | `setModel("auto", { autoTier: "balance" })` | `setModel("auto", { autoTier: null })` |
306+
| Python | `set_model("auto", auto_tier="balance")` | `set_model("auto", auto_tier=None)` |
307+
| Go | `SetModelOptions{AutoTier: &tier}` | `SetModelOptions{ResetAutoTier: true}` |
308+
| .NET | `new SetModelOptions { AutoTier = AutoTier.Balance }` | `new SetModelOptions { ResetAutoTier = true }` |
309+
| Rust | `SetModelOptions::default().with_auto_tier(AutoTier::Balance)` | `SetModelOptions::default().with_reset_auto_tier()` |
310+
| Java | `new SetModelOptions().setModel("auto").setAutoTier(AutoTier.BALANCE)` | `new SetModelOptions().setModel("auto").setResetAutoTier(true)` |
311+
312+
Node.js, Python, and Rust express all three states in a single value: Node.js and Python because `null`/`None` is distinguishable from an omitted argument, and Rust because `AutoTierPreference::Reset` is a distinct variant of the same option. Go, .NET, and Java have no way to distinguish "reset" from "unset" in one value, so they carry a separate reset flag. Omitting both always means "leave the current preference alone."
313+
272314
### Example: changing model on resume
273315

274316
```typescript

dotnet/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,27 @@ await session2.DisposeAsync();
284284

285285
---
286286

287+
## Auto routing tiers
288+
289+
Change the Auto routing preference without changing the selected model. The runtime does not apply the preference immediately: it records the request and commits it only when a later user turn using the `auto` model successfully obtains a usable model from the provider, so a `pending` status confirms acceptance rather than effect. Only the most recent request survives.
290+
291+
Watch for the outcome through the `session.model_change` event on success or the ephemeral `session.auto_tier_switch_failed` event on failure. Read the authoritative committed, pending, and activating preferences at any time through the session's `model.getCurrent` RPC method.
292+
293+
```csharp
294+
var result = await session.SetAutoTierAsync(AutoTier.Intelligence);
295+
if (result.Status == ModelSwitchAutoTierStatus.Pending)
296+
{
297+
// Accepted, but not yet in effect.
298+
}
299+
300+
// Return to the provider's default Auto routing.
301+
await session.SetAutoTierAsync(null);
302+
```
303+
304+
`SetModelAsync` accepts the same preference through `SetModelOptions.AutoTier`, which stages the tier atomically with selecting `auto`. Set `ResetAutoTier` instead to return to provider-default routing; the two options are mutually exclusive.
305+
306+
See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) for the full lifecycle rules.
307+
287308
## Event Types
288309

289310
Sessions emit various events during processing. Each event type is a class that inherits from `SessionEvent`:

dotnet/src/Session.cs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using System.Text.Json;
1212
using System.Text.Json.Nodes;
1313
using System.Text.Json.Serialization;
14+
using System.Text.Json.Serialization.Metadata;
1415
using System.Threading.Channels;
1516

1617
namespace GitHub.Copilot;
@@ -1927,8 +1928,35 @@ public async Task SetModelAsync(string model, SetModelOptions options, Cancellat
19271928
ArgumentNullException.ThrowIfNull(model);
19281929
ThrowIfDisposed();
19291930

1931+
if (options.AutoTier is not null && options.ResetAutoTier)
1932+
{
1933+
throw new ArgumentException(
1934+
$"{nameof(SetModelOptions.AutoTier)} and {nameof(SetModelOptions.ResetAutoTier)} are mutually exclusive.",
1935+
nameof(options));
1936+
}
1937+
1938+
if (options.ResetAutoTier)
1939+
{
1940+
var request = new ModelSwitchToRequest
1941+
{
1942+
SessionId = SessionId,
1943+
ModelId = model,
1944+
ReasoningEffort = options.ReasoningEffort,
1945+
ReasoningSummary = options.ReasoningSummary,
1946+
ModelCapabilities = options.ModelCapabilities,
1947+
ContextTier = options.ContextTier,
1948+
};
1949+
await CopilotClient.InvokeRpcAsync(
1950+
Rpc,
1951+
"session.model.switchTo",
1952+
[WithExplicitNullAutoTier(request, RpcJsonContext.Default.ModelSwitchToRequest)],
1953+
cancellationToken);
1954+
return;
1955+
}
1956+
19301957
await Rpc.Model.SwitchToAsync(
19311958
modelId: model,
1959+
autoTier: options.AutoTier,
19321960
reasoningEffort: options.ReasoningEffort,
19331961
reasoningSummary: options.ReasoningSummary,
19341962
verbosity: null,
@@ -1938,6 +1966,69 @@ await Rpc.Model.SwitchToAsync(
19381966
cancellationToken: cancellationToken);
19391967
}
19401968

1969+
/// <summary>
1970+
/// Changes the Auto routing preference without changing the selected model.
1971+
/// </summary>
1972+
/// <remarks>
1973+
/// <para>
1974+
/// The runtime does not apply the preference immediately. It records the request and
1975+
/// commits it only when a later user turn using the <c>auto</c> model successfully
1976+
/// obtains a usable model from the provider. A <c>pending</c> status therefore confirms
1977+
/// that the request was accepted, not that it took effect.
1978+
/// </para>
1979+
/// <para>
1980+
/// Watch for the outcome through the <c>session.model_change</c> event on success, or the
1981+
/// ephemeral <c>session.auto_tier_switch_failed</c> event on failure. You can also read
1982+
/// the current committed and in-flight state at any time with
1983+
/// <c>session.Rpc.Model.GetCurrentAsync</c>.
1984+
/// </para>
1985+
/// <para>
1986+
/// Only the most recent request survives: issuing a new request replaces any earlier one
1987+
/// that has not yet been claimed by a turn.
1988+
/// </para>
1989+
/// </remarks>
1990+
/// <param name="autoTier">Routing preference to activate, or <see langword="null"/> to return to the provider's default Auto routing.</param>
1991+
/// <param name="cancellationToken">Optional cancellation token.</param>
1992+
/// <returns>The runtime's immediate acknowledgement and Auto preference snapshot.</returns>
1993+
/// <example>
1994+
/// <code>
1995+
/// var result = await session.SetAutoTierAsync(AutoTier.Intelligence);
1996+
/// </code>
1997+
/// </example>
1998+
[Experimental(Diagnostics.Experimental)]
1999+
public async Task<ModelSwitchAutoTierResult> SetAutoTierAsync(AutoTier? autoTier, CancellationToken cancellationToken = default)
2000+
{
2001+
ThrowIfDisposed();
2002+
2003+
if (autoTier is not null)
2004+
{
2005+
return await Rpc.Model.SwitchAutoTierAsync(autoTier, cancellationToken: cancellationToken);
2006+
}
2007+
2008+
var request = new ModelSwitchAutoTierRequest { SessionId = SessionId };
2009+
return await CopilotClient.InvokeRpcAsync<ModelSwitchAutoTierResult>(
2010+
Rpc,
2011+
"session.model.switchAutoTier",
2012+
[WithExplicitNullAutoTier(request, RpcJsonContext.Default.ModelSwitchAutoTierRequest)],
2013+
cancellationToken);
2014+
}
2015+
2016+
/// <summary>
2017+
/// Serializes a generated request and restores the <c>autoTier</c> property as an explicit null.
2018+
/// </summary>
2019+
/// <remarks>
2020+
/// The generated request types omit <c>autoTier</c> when it is null. The runtime reads an
2021+
/// omitted tier as "leave the current preference alone" and an explicit null as "return to
2022+
/// provider-default Auto routing", so the null has to survive serialization. Serializing the
2023+
/// generated type keeps every other field on the request in sync with the schema.
2024+
/// </remarks>
2025+
private static JsonObject WithExplicitNullAutoTier<T>(T request, JsonTypeInfo<T> typeInfo)
2026+
{
2027+
var payload = JsonSerializer.SerializeToNode(request, typeInfo)!.AsObject();
2028+
payload["autoTier"] = null;
2029+
return payload;
2030+
}
2031+
19412032
/// <summary>
19422033
/// Changes the model for this session.
19432034
/// </summary>

dotnet/src/Types.cs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2447,9 +2447,11 @@ public sealed class CapiSessionOptions
24472447
/// </summary>
24482448
/// <remarks>
24492449
/// Requires a runtime that supports Auto tiers; it has no effect outside V2 Auto.
2450-
/// When omitted, the runtime uses its default on create and preserves the persisted or current
2451-
/// tier on resume. An explicit tier overrides the persisted tier on a cold resume; a conflicting
2452-
/// tier on a resident session resume is rejected by the runtime.
2450+
/// When omitted, the runtime uses its default on create and restores the last committed
2451+
/// tier on cold resume. On resident resume, a different tier requests a safe switch that
2452+
/// takes effect after resume succeeds and never disturbs a turn that is already running.
2453+
/// To change the preference on a live session, use
2454+
/// <see cref="CopilotSession.SetAutoTierAsync"/>.
24532455
/// </remarks>
24542456
[JsonPropertyName("autoTier")]
24552457
public AutoTier? AutoTier { get; set; }
@@ -3017,6 +3019,26 @@ public struct SetModelOptions
30173019

30183020
/// <summary>Per-property overrides for model capabilities, deep-merged over runtime defaults.</summary>
30193021
public ModelCapabilitiesOverride? ModelCapabilities { get; set; }
3022+
3023+
/// <summary>
3024+
/// Routing preference to stage atomically with selecting the <c>auto</c> model.
3025+
/// </summary>
3026+
/// <remarks>
3027+
/// Leave unset to leave the current preference alone. Set
3028+
/// <see cref="ResetAutoTier"/> instead to return to the provider's default Auto
3029+
/// routing. The runtime rejects this option when the model is anything other than
3030+
/// <c>auto</c>; use <see cref="CopilotSession.SetAutoTierAsync"/> to change the
3031+
/// preference without changing the selected model.
3032+
/// </remarks>
3033+
[Experimental(Diagnostics.Experimental)]
3034+
public AutoTier? AutoTier { get; set; }
3035+
3036+
/// <summary>
3037+
/// Returns to the provider's default Auto routing as part of this switch.
3038+
/// Mutually exclusive with <see cref="AutoTier"/>.
3039+
/// </summary>
3040+
[Experimental(Diagnostics.Experimental)]
3041+
public bool ResetAutoTier { get; set; }
30203042
}
30213043

30223044
/// <summary>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
using GitHub.Copilot.Rpc;
6+
using GitHub.Copilot.Test.Harness;
7+
using Xunit;
8+
using Xunit.Abstractions;
9+
10+
namespace GitHub.Copilot.Test.E2E;
11+
12+
/// <summary>
13+
/// Mirrors nodejs/test/e2e/auto_tier.e2e.test.ts (snapshot category "auto_tier").
14+
/// </summary>
15+
/// <remarks>
16+
/// The runtime stages an Auto routing preference instead of applying it immediately: a
17+
/// request stays unclaimed until a later turn using the <c>auto</c> model mints a usable
18+
/// model and token pair. These tests observe that staged state through
19+
/// <c>Model.GetCurrentAsync</c>, so they assert what the runtime actually recorded rather
20+
/// than what the SDK serialized.
21+
/// </remarks>
22+
public class AutoTierE2ETests(E2ETestFixture fixture, ITestOutputHelper output)
23+
: E2ETestBase(fixture, "auto_tier", output)
24+
{
25+
private static async Task AssertPendingAutoTierAsync(CopilotSession session, AutoTier? expected)
26+
{
27+
var current = await session.Rpc.Model.GetCurrentAsync();
28+
Assert.Equal(expected, current.PendingAutoTier);
29+
}
30+
31+
[Fact]
32+
public async Task Should_Stage_And_Reset_Auto_Tier_Preference()
33+
{
34+
await using var session = await CreateSessionAsync(new SessionConfig
35+
{
36+
Model = "auto",
37+
OnPermissionRequest = PermissionHandler.ApproveAll,
38+
});
39+
40+
await AssertPendingAutoTierAsync(session, null);
41+
42+
var staged = await session.SetAutoTierAsync(AutoTier.Efficiency);
43+
Assert.Equal(ModelSwitchAutoTierStatus.Pending, staged.Status);
44+
Assert.Equal(AutoTier.Efficiency, staged.PendingAutoTier);
45+
await AssertPendingAutoTierAsync(session, AutoTier.Efficiency);
46+
47+
// A second request replaces the first and reports the one it displaced.
48+
var superseded = await session.SetAutoTierAsync(AutoTier.Intelligence);
49+
Assert.Equal(ModelSwitchAutoTierStatus.Pending, superseded.Status);
50+
Assert.Equal(AutoTier.Intelligence, superseded.PendingAutoTier);
51+
Assert.Equal(AutoTier.Efficiency, superseded.SupersededAutoTier);
52+
await AssertPendingAutoTierAsync(session, AutoTier.Intelligence);
53+
54+
// A null tier returns the session to provider-default routing. The status is
55+
// Unchanged because provider-default was already the committed preference; the
56+
// request's effect is cancelling the staged one.
57+
var reset = await session.SetAutoTierAsync(null);
58+
Assert.Equal(ModelSwitchAutoTierStatus.Unchanged, reset.Status);
59+
Assert.Equal(AutoTier.Intelligence, reset.SupersededAutoTier);
60+
await AssertPendingAutoTierAsync(session, null);
61+
}
62+
63+
[Fact]
64+
public async Task Should_Preserve_Auto_Tier_When_Set_Model_Omits_It()
65+
{
66+
await using var session = await CreateSessionAsync(new SessionConfig
67+
{
68+
Model = "auto",
69+
OnPermissionRequest = PermissionHandler.ApproveAll,
70+
});
71+
72+
await session.SetAutoTierAsync(AutoTier.Balance);
73+
await AssertPendingAutoTierAsync(session, AutoTier.Balance);
74+
75+
// Leaving AutoTier unset without asking for a reset leaves the staged preference alone.
76+
await session.SetModelAsync("auto", new SetModelOptions());
77+
await AssertPendingAutoTierAsync(session, AutoTier.Balance);
78+
79+
// Supplying a tier replaces it.
80+
await session.SetModelAsync("auto", new SetModelOptions { AutoTier = AutoTier.Intelligence });
81+
await AssertPendingAutoTierAsync(session, AutoTier.Intelligence);
82+
83+
// ResetAutoTier clears it. Omission, a value, and a reset are three distinct
84+
// outcomes, which is why a single nullable property cannot express the request.
85+
await session.SetModelAsync("auto", new SetModelOptions { ResetAutoTier = true });
86+
await AssertPendingAutoTierAsync(session, null);
87+
}
88+
}

0 commit comments

Comments
 (0)