diff --git a/docs/features/session-persistence.md b/docs/features/session-persistence.md index 9b0aa9434c..869af9b9a4 100644 --- a/docs/features/session-persistence.md +++ b/docs/features/session-persistence.md @@ -242,7 +242,7 @@ When resuming a session, you can optionally reconfigure many settings. This is u | `availableTools` | Restrict which tools are available | | `excludedTools` | Disable specific tools | | `provider` | Re-provide BYOK credentials (required for BYOK sessions) | -| `capi.autoTier` | Override the persisted Auto routing preference on cold resume only | +| `capi.autoTier` | Override the persisted Auto routing preference | | `reasoningEffort` | Adjust reasoning effort level | | `streaming` | Enable/disable streaming responses | | `workingDirectory` | Change the working directory | @@ -262,13 +262,55 @@ The runtime persists the selected tier, so applications do not need to resend it * Omitting the tier when creating a session uses the runtime's default routing behavior. * A cold resume restores the persisted tier. Supplying an explicit tier overrides the restored value for the new activation. -* 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. +* 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. * Older sessions without a persisted tier retain default routing behavior. Tier selection is not a live model-switch operation. The SDK forwards the preference; the runtime owns persistence and validation. 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. +### Changing the Auto tier during a session + +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. + +```typescript +const result = await session.setAutoTier("intelligence"); +if (result.status === "pending") { + // Accepted, but not yet in effect. +} +``` + +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. + +Watch for the outcome through these events: + +* `session.model_change` when the preference commits. +* `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. + +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. + +| SDK | Change the tier | Return to provider-default routing | +|-----|-----------------|------------------------------------| +| Node.js | `session.setAutoTier("balance")` | `session.setAutoTier(null)` | +| Python | `session.set_auto_tier("balance")` | `session.set_auto_tier(None)` | +| Go | `session.SetAutoTier(ctx, &tier)` | `session.SetAutoTier(ctx, nil)` | +| .NET | `session.SetAutoTierAsync(AutoTier.Balance)` | `session.SetAutoTierAsync(null)` | +| Rust | `session.set_auto_tier(Some(AutoTier::Balance))` | `session.set_auto_tier(None)` | +| Java | `session.setAutoTier(AutoTier.BALANCE)` | `session.setAutoTier(null)` | + +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`. + +| SDK | Stage a tier with the switch | Reset to provider-default routing | +|-----|------------------------------|-----------------------------------| +| Node.js | `setModel("auto", { autoTier: "balance" })` | `setModel("auto", { autoTier: null })` | +| Python | `set_model("auto", auto_tier="balance")` | `set_model("auto", auto_tier=None)` | +| Go | `SetModelOptions{AutoTier: &tier}` | `SetModelOptions{ResetAutoTier: true}` | +| .NET | `new SetModelOptions { AutoTier = AutoTier.Balance }` | `new SetModelOptions { ResetAutoTier = true }` | +| Rust | `SetModelOptions::default().with_auto_tier(AutoTier::Balance)` | `SetModelOptions::default().with_reset_auto_tier()` | +| Java | `new SetModelOptions().setModel("auto").setAutoTier(AutoTier.BALANCE)` | `new SetModelOptions().setModel("auto").setResetAutoTier(true)` | + +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." + ### Example: changing model on resume ```typescript diff --git a/dotnet/README.md b/dotnet/README.md index 23a78030b4..5c058d19a9 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -284,6 +284,27 @@ await session2.DisposeAsync(); --- +## Auto routing tiers + +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. + +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. + +```csharp +var result = await session.SetAutoTierAsync(AutoTier.Intelligence); +if (result.Status == ModelSwitchAutoTierStatus.Pending) +{ + // Accepted, but not yet in effect. +} + +// Return to the provider's default Auto routing. +await session.SetAutoTierAsync(null); +``` + +`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. + +See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) for the full lifecycle rules. + ## Event Types Sessions emit various events during processing. Each event type is a class that inherits from `SessionEvent`: diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index d531238435..42cbe3f9ce 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -11,6 +11,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using System.Threading.Channels; namespace GitHub.Copilot; @@ -1927,8 +1928,35 @@ public async Task SetModelAsync(string model, SetModelOptions options, Cancellat ArgumentNullException.ThrowIfNull(model); ThrowIfDisposed(); + if (options.AutoTier is not null && options.ResetAutoTier) + { + throw new ArgumentException( + $"{nameof(SetModelOptions.AutoTier)} and {nameof(SetModelOptions.ResetAutoTier)} are mutually exclusive.", + nameof(options)); + } + + if (options.ResetAutoTier) + { + var request = new ModelSwitchToRequest + { + SessionId = SessionId, + ModelId = model, + ReasoningEffort = options.ReasoningEffort, + ReasoningSummary = options.ReasoningSummary, + ModelCapabilities = options.ModelCapabilities, + ContextTier = options.ContextTier, + }; + await CopilotClient.InvokeRpcAsync( + Rpc, + "session.model.switchTo", + [WithExplicitNullAutoTier(request, RpcJsonContext.Default.ModelSwitchToRequest)], + cancellationToken); + return; + } + await Rpc.Model.SwitchToAsync( modelId: model, + autoTier: options.AutoTier, reasoningEffort: options.ReasoningEffort, reasoningSummary: options.ReasoningSummary, verbosity: null, @@ -1938,6 +1966,69 @@ await Rpc.Model.SwitchToAsync( cancellationToken: cancellationToken); } + /// + /// Changes 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. A pending status therefore confirms + /// that the request was accepted, not that it took effect. + /// + /// + /// Watch for the outcome through the session.model_change event on success, or the + /// ephemeral session.auto_tier_switch_failed event on failure. You can also read + /// the current committed and in-flight state at any time with + /// session.Rpc.Model.GetCurrentAsync. + /// + /// + /// Only the most recent request survives: issuing a new request replaces any earlier one + /// that has not yet been claimed by a turn. + /// + /// + /// Routing preference to activate, or to return to the provider's default Auto routing. + /// Optional cancellation token. + /// The runtime's immediate acknowledgement and Auto preference snapshot. + /// + /// + /// var result = await session.SetAutoTierAsync(AutoTier.Intelligence); + /// + /// + [Experimental(Diagnostics.Experimental)] + public async Task SetAutoTierAsync(AutoTier? autoTier, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + if (autoTier is not null) + { + return await Rpc.Model.SwitchAutoTierAsync(autoTier, cancellationToken: cancellationToken); + } + + var request = new ModelSwitchAutoTierRequest { SessionId = SessionId }; + return await CopilotClient.InvokeRpcAsync( + Rpc, + "session.model.switchAutoTier", + [WithExplicitNullAutoTier(request, RpcJsonContext.Default.ModelSwitchAutoTierRequest)], + cancellationToken); + } + + /// + /// Serializes a generated request and restores the autoTier property as an explicit null. + /// + /// + /// The generated request types omit autoTier when it is null. The runtime reads an + /// omitted tier as "leave the current preference alone" and an explicit null as "return to + /// provider-default Auto routing", so the null has to survive serialization. Serializing the + /// generated type keeps every other field on the request in sync with the schema. + /// + private static JsonObject WithExplicitNullAutoTier(T request, JsonTypeInfo typeInfo) + { + var payload = JsonSerializer.SerializeToNode(request, typeInfo)!.AsObject(); + payload["autoTier"] = null; + return payload; + } + /// /// Changes the model for this session. /// diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 3af7ea7f12..9129b118e8 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2447,9 +2447,11 @@ public sealed class CapiSessionOptions /// /// /// Requires a runtime that supports Auto tiers; it has no effect outside V2 Auto. - /// When omitted, the runtime uses its default on create and preserves the persisted or current - /// tier on resume. An explicit tier overrides the persisted tier on a cold resume; a conflicting - /// tier on a resident session resume is rejected by the runtime. + /// When omitted, the runtime uses its default on create and restores the last committed + /// tier on cold resume. On resident resume, a different tier requests a safe switch that + /// takes effect after resume succeeds and never disturbs a turn that is already running. + /// To change the preference on a live session, use + /// . /// [JsonPropertyName("autoTier")] public AutoTier? AutoTier { get; set; } @@ -3017,6 +3019,26 @@ public struct SetModelOptions /// Per-property overrides for model capabilities, deep-merged over runtime defaults. public ModelCapabilitiesOverride? ModelCapabilities { get; set; } + + /// + /// Routing preference to stage atomically with selecting the auto model. + /// + /// + /// Leave unset to leave the current preference alone. Set + /// instead to return to the provider's default Auto + /// routing. The runtime rejects this option when the model is anything other than + /// auto; use to change the + /// preference without changing the selected model. + /// + [Experimental(Diagnostics.Experimental)] + public AutoTier? AutoTier { get; set; } + + /// + /// Returns to the provider's default Auto routing as part of this switch. + /// Mutually exclusive with . + /// + [Experimental(Diagnostics.Experimental)] + public bool ResetAutoTier { get; set; } } /// diff --git a/dotnet/test/E2E/AutoTierE2ETests.cs b/dotnet/test/E2E/AutoTierE2ETests.cs new file mode 100644 index 0000000000..3758eee0f5 --- /dev/null +++ b/dotnet/test/E2E/AutoTierE2ETests.cs @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.Rpc; +using GitHub.Copilot.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.Test.E2E; + +/// +/// Mirrors nodejs/test/e2e/auto_tier.e2e.test.ts (snapshot category "auto_tier"). +/// +/// +/// The runtime stages an Auto routing preference instead of applying it immediately: a +/// request stays unclaimed until a later turn using the auto model mints a usable +/// model and token pair. These tests observe that staged state through +/// Model.GetCurrentAsync, so they assert what the runtime actually recorded rather +/// than what the SDK serialized. +/// +public class AutoTierE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "auto_tier", output) +{ + private static async Task AssertPendingAutoTierAsync(CopilotSession session, AutoTier? expected) + { + var current = await session.Rpc.Model.GetCurrentAsync(); + Assert.Equal(expected, current.PendingAutoTier); + } + + [Fact] + public async Task Should_Stage_And_Reset_Auto_Tier_Preference() + { + await using var session = await CreateSessionAsync(new SessionConfig + { + Model = "auto", + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + await AssertPendingAutoTierAsync(session, null); + + var staged = await session.SetAutoTierAsync(AutoTier.Efficiency); + Assert.Equal(ModelSwitchAutoTierStatus.Pending, staged.Status); + Assert.Equal(AutoTier.Efficiency, staged.PendingAutoTier); + await AssertPendingAutoTierAsync(session, AutoTier.Efficiency); + + // A second request replaces the first and reports the one it displaced. + var superseded = await session.SetAutoTierAsync(AutoTier.Intelligence); + Assert.Equal(ModelSwitchAutoTierStatus.Pending, superseded.Status); + Assert.Equal(AutoTier.Intelligence, superseded.PendingAutoTier); + Assert.Equal(AutoTier.Efficiency, superseded.SupersededAutoTier); + await AssertPendingAutoTierAsync(session, AutoTier.Intelligence); + + // A null tier returns the session to provider-default routing. The status is + // Unchanged because provider-default was already the committed preference; the + // request's effect is cancelling the staged one. + var reset = await session.SetAutoTierAsync(null); + Assert.Equal(ModelSwitchAutoTierStatus.Unchanged, reset.Status); + Assert.Equal(AutoTier.Intelligence, reset.SupersededAutoTier); + await AssertPendingAutoTierAsync(session, null); + } + + [Fact] + public async Task Should_Preserve_Auto_Tier_When_Set_Model_Omits_It() + { + await using var session = await CreateSessionAsync(new SessionConfig + { + Model = "auto", + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + await session.SetAutoTierAsync(AutoTier.Balance); + await AssertPendingAutoTierAsync(session, AutoTier.Balance); + + // Leaving AutoTier unset without asking for a reset leaves the staged preference alone. + await session.SetModelAsync("auto", new SetModelOptions()); + await AssertPendingAutoTierAsync(session, AutoTier.Balance); + + // Supplying a tier replaces it. + await session.SetModelAsync("auto", new SetModelOptions { AutoTier = AutoTier.Intelligence }); + await AssertPendingAutoTierAsync(session, AutoTier.Intelligence); + + // ResetAutoTier clears it. Omission, a value, and a reset are three distinct + // outcomes, which is why a single nullable property cannot express the request. + await session.SetModelAsync("auto", new SetModelOptions { ResetAutoTier = true }); + await AssertPendingAutoTierAsync(session, null); + } +} diff --git a/dotnet/test/Unit/ClientSessionLifetimeTests.cs b/dotnet/test/Unit/ClientSessionLifetimeTests.cs index 204626a6f5..20c25f4885 100644 --- a/dotnet/test/Unit/ClientSessionLifetimeTests.cs +++ b/dotnet/test/Unit/ClientSessionLifetimeTests.cs @@ -512,6 +512,111 @@ public async Task SessionRequests_Serialize_CapiAutoTier(AutoTier tier, string e } } + [Theory] + [InlineData("efficiency")] + [InlineData("balance")] + [InlineData("intelligence")] + public async Task SetModelAsync_Serializes_AutoTier(string expectedTier) + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll + }); + + await session.SetModelAsync("auto", new SetModelOptions { AutoTier = new AutoTier(expectedTier) }); + + var request = Assert.Single(server.Requests, request => request.Method == "session.model.switchTo"); + Assert.Equal("auto", request.Params.GetProperty("modelId").GetString()); + Assert.Equal(expectedTier, request.Params.GetProperty("autoTier").GetString()); + } + + [Fact] + public async Task SetModelAsync_Omits_AutoTier_WhenUnset() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll + }); + + await session.SetModelAsync("gpt-5.4"); + + var request = Assert.Single(server.Requests, request => request.Method == "session.model.switchTo"); + Assert.False(request.Params.TryGetProperty("autoTier", out _)); + } + + [Fact] + public async Task SetModelAsync_Writes_Null_AutoTier_WhenCleared() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll + }); + + await session.SetModelAsync("auto", new SetModelOptions { ResetAutoTier = true }); + + // An explicit null must survive to the wire. Omitting it would mean "leave the + // preference alone" rather than "use provider-default routing". + var request = Assert.Single(server.Requests, request => request.Method == "session.model.switchTo"); + Assert.True(request.Params.TryGetProperty("autoTier", out var autoTier)); + Assert.Equal(JsonValueKind.Null, autoTier.ValueKind); + } + + [Fact] + public async Task SetModelAsync_Rejects_Conflicting_AutoTier_Options() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll + }); + + await Assert.ThrowsAsync(() => session.SetModelAsync( + "auto", + new SetModelOptions { AutoTier = AutoTier.Balance, ResetAutoTier = true })); + } + + [Fact] + public async Task SetAutoTierAsync_Serializes_Tier_And_Returns_Snapshot() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll + }); + + var result = await session.SetAutoTierAsync(AutoTier.Intelligence); + + var request = Assert.Single(server.Requests, request => request.Method == "session.model.switchAutoTier"); + Assert.Equal("intelligence", request.Params.GetProperty("autoTier").GetString()); + Assert.Equal(ModelSwitchAutoTierStatus.Pending, result.Status); + Assert.Equal(AutoTier.Balance, result.EffectiveAutoTier); + } + + [Fact] + public async Task SetAutoTierAsync_Writes_Null_Tier_ForDefaultRouting() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll + }); + + await session.SetAutoTierAsync(null); + + var request = Assert.Single(server.Requests, request => request.Method == "session.model.switchAutoTier"); + Assert.True(request.Params.TryGetProperty("autoTier", out var autoTier)); + Assert.Equal(JsonValueKind.Null, autoTier.ValueKind); + } + [Theory] [InlineData(false, null)] [InlineData(true, null)] @@ -1832,6 +1937,15 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel { ["success"] = true }, + "session.model.switchTo" => new Dictionary + { + ["modelId"] = "auto" + }, + "session.model.switchAutoTier" => new Dictionary + { + ["status"] = "pending", + ["effectiveAutoTier"] = "balance" + }, "session.delete" => new Dictionary { ["success"] = true diff --git a/dotnet/test/Unit/SessionEventSerializationTests.cs b/dotnet/test/Unit/SessionEventSerializationTests.cs index 25c56838bc..405ffb379f 100644 --- a/dotnet/test/Unit/SessionEventSerializationTests.cs +++ b/dotnet/test/Unit/SessionEventSerializationTests.cs @@ -82,6 +82,67 @@ public void UserMessageEvent_MessageId_UsesCamelCaseAndIsOptional(string? messag } } + public static TheoryData AutoTierSwitchFailureReasons => + [ + "policy_rejected", + "request_failed", + "setup_failed", + "unsupported", + ]; + + [Theory] + [MemberData(nameof(AutoTierSwitchFailureReasons))] + public void SessionEvent_Deserializes_AutoTierSwitchFailed(string wireReason) + { + var json = $$""" + { + "id": "11111111-1111-1111-1111-111111111111", + "timestamp": "2026-08-28T00:00:00Z", + "parentId": null, + "type": "session.auto_tier_switch_failed", + "data": { + "effectiveAutoTier": "balance", + "requestedAutoTier": "intelligence", + "reason": "{{wireReason}}" + } + } + """; + + var sessionEvent = SessionEvent.FromJson(json); + + var data = Assert.IsType(sessionEvent).Data; + Assert.Equal(new AutoTierSwitchFailureReason(wireReason), data.Reason); + Assert.Equal(AutoTier.Balance, data.EffectiveAutoTier); + Assert.Equal(AutoTier.Intelligence, data.RequestedAutoTier); + } + + [Fact] + public void SessionEvent_Deserializes_AutoTierSwitchFailed_WithNullRequestedTier() + { + // A null requested tier means the attempt to return to provider-default + // Auto routing is what failed. + var json = """ + { + "id": "11111111-1111-1111-1111-111111111111", + "timestamp": "2026-08-28T00:00:00Z", + "parentId": null, + "type": "session.auto_tier_switch_failed", + "data": { + "effectiveAutoTier": "efficiency", + "requestedAutoTier": null, + "reason": "unsupported" + } + } + """; + + var sessionEvent = SessionEvent.FromJson(json); + + var data = Assert.IsType(sessionEvent).Data; + Assert.Null(data.RequestedAutoTier); + Assert.Equal(AutoTier.Efficiency, data.EffectiveAutoTier); + Assert.Equal(AutoTierSwitchFailureReason.Unsupported, data.Reason); + } + public static TheoryData JsonElementBackedEvents => new() { { diff --git a/go/README.md b/go/README.md index 801ad8556c..4f31a0e0b9 100644 --- a/go/README.md +++ b/go/README.md @@ -332,6 +332,30 @@ Each section override supports five actions: Unknown section IDs are handled gracefully: content from `replace`/`append`/`prepend` overrides is appended to additional instructions, and `remove` overrides are silently ignored. +## Auto routing tiers + +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. + +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. + +```go +tier := copilot.AutoTierIntelligence +result, err := session.SetAutoTier(ctx, &tier) +if err != nil { + return err +} +if result.Status == rpc.ModelSwitchAutoTierStatusPending { + // Accepted, but not yet in effect. +} + +// Return to the provider's default Auto routing. +_, err = session.SetAutoTier(ctx, nil) +``` + +`SetModel` 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. + +See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) for the full lifecycle rules. + ## Image Support The SDK supports image attachments via the `Attachments` field in `MessageOptions`. You can attach images by providing their file path, or by passing base64-encoded data directly using a blob attachment: diff --git a/go/internal/e2e/auto_tier_e2e_test.go b/go/internal/e2e/auto_tier_e2e_test.go new file mode 100644 index 0000000000..e974f95927 --- /dev/null +++ b/go/internal/e2e/auto_tier_e2e_test.go @@ -0,0 +1,140 @@ +package e2e + +import ( + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +// Mirrors nodejs/test/e2e/auto_tier.e2e.test.ts (snapshot category "auto_tier"). +// +// The runtime stages an Auto routing preference instead of applying it immediately: a +// request stays unclaimed until a later turn using the "auto" model mints a usable +// model and token pair. These tests observe that staged state through Model.GetCurrent, +// so they assert what the runtime actually recorded rather than what the SDK serialized. +func TestAutoTierE2E(t *testing.T) { + autoTier := func(tier copilot.AutoTier) *copilot.AutoTier { return &tier } + + pendingTier := func(t *testing.T, session *copilot.Session) *rpc.AutoTier { + t.Helper() + current, err := session.RPC.Model.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Model.GetCurrent failed: %v", err) + } + return current.PendingAutoTier + } + + assertPending := func(t *testing.T, session *copilot.Session, want rpc.AutoTier) { + t.Helper() + got := pendingTier(t, session) + if got == nil || *got != want { + t.Fatalf("Expected pending auto tier %q, got %v", want, got) + } + } + + assertNoPending := func(t *testing.T, session *copilot.Session) { + t.Helper() + if got := pendingTier(t, session); got != nil { + t.Fatalf("Expected no pending auto tier, got %q", *got) + } + } + + newAutoSession := func(t *testing.T) *copilot.Session { + t.Helper() + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + Model: "auto", + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + return session + } + + t.Run("should stage and reset auto tier preference", func(t *testing.T) { + session := newAutoSession(t) + assertNoPending(t, session) + + staged, err := session.SetAutoTier(t.Context(), autoTier(copilot.AutoTierEfficiency)) + if err != nil { + t.Fatalf("SetAutoTier(efficiency) failed: %v", err) + } + if staged.Status != rpc.ModelSwitchAutoTierStatusPending { + t.Fatalf("Expected status pending, got %q", staged.Status) + } + if staged.PendingAutoTier == nil || *staged.PendingAutoTier != rpc.AutoTierEfficiency { + t.Fatalf("Expected pending efficiency in result, got %+v", staged) + } + assertPending(t, session, rpc.AutoTierEfficiency) + + // A second request replaces the first and reports the one it displaced. + superseded, err := session.SetAutoTier(t.Context(), autoTier(copilot.AutoTierIntelligence)) + if err != nil { + t.Fatalf("SetAutoTier(intelligence) failed: %v", err) + } + if superseded.Status != rpc.ModelSwitchAutoTierStatusPending { + t.Fatalf("Expected status pending, got %q", superseded.Status) + } + if superseded.SupersededAutoTier == nil || *superseded.SupersededAutoTier != rpc.AutoTierEfficiency { + t.Fatalf("Expected superseded efficiency, got %+v", superseded) + } + assertPending(t, session, rpc.AutoTierIntelligence) + + // A nil tier returns the session to provider-default routing. The status is + // unchanged because provider-default was already the committed preference; + // the request's effect is cancelling the staged one. + reset, err := session.SetAutoTier(t.Context(), nil) + if err != nil { + t.Fatalf("SetAutoTier(nil) failed: %v", err) + } + if reset.Status != rpc.ModelSwitchAutoTierStatusUnchanged { + t.Fatalf("Expected status unchanged, got %q", reset.Status) + } + if reset.SupersededAutoTier == nil || *reset.SupersededAutoTier != rpc.AutoTierIntelligence { + t.Fatalf("Expected superseded intelligence, got %+v", reset) + } + assertNoPending(t, session) + }) + + t.Run("should preserve auto tier when set model omits it", func(t *testing.T) { + session := newAutoSession(t) + + if _, err := session.SetAutoTier(t.Context(), autoTier(copilot.AutoTierBalance)); err != nil { + t.Fatalf("SetAutoTier(balance) failed: %v", err) + } + assertPending(t, session, rpc.AutoTierBalance) + + // Leaving AutoTier nil without asking for a reset leaves the staged preference alone. + if err := session.SetModel(t.Context(), "auto", nil); err != nil { + t.Fatalf("SetModel without options failed: %v", err) + } + assertPending(t, session, rpc.AutoTierBalance) + + // Supplying a tier replaces it. + if err := session.SetModel(t.Context(), "auto", &copilot.SetModelOptions{ + AutoTier: autoTier(copilot.AutoTierIntelligence), + }); err != nil { + t.Fatalf("SetModel with AutoTier failed: %v", err) + } + assertPending(t, session, rpc.AutoTierIntelligence) + + // ResetAutoTier clears it. Omission, a value, and a reset are three distinct + // outcomes, which is why a single nillable field cannot express the request. + if err := session.SetModel(t.Context(), "auto", &copilot.SetModelOptions{ + ResetAutoTier: true, + }); err != nil { + t.Fatalf("SetModel with ResetAutoTier failed: %v", err) + } + assertNoPending(t, session) + }) +} diff --git a/go/session.go b/go/session.go index 4c31c01e8c..058fb60a1e 100644 --- a/go/session.go +++ b/go/session.go @@ -4,6 +4,7 @@ package copilot import ( "context" "encoding/json" + "errors" "fmt" "log" "sync" @@ -1841,6 +1842,22 @@ type SetModelOptions struct { // ModelCapabilities overrides individual model capabilities resolved by the runtime. // Only non-nil fields are applied over the runtime-resolved capabilities. ModelCapabilities *rpc.ModelCapabilitiesOverride + // AutoTier stages an Auto routing preference atomically with selecting the + // "auto" model. Leave nil to leave the current preference alone. + // + // The runtime rejects this option when the model is anything other than + // "auto". Use [Session.SetAutoTier] to change the preference without + // changing the selected model. + // + // Experimental: AutoTier is part of an experimental Auto routing surface and + // may change or be removed. + AutoTier *AutoTier + // ResetAutoTier returns to the provider's default Auto routing as part of + // this switch. It is mutually exclusive with AutoTier. + // + // Experimental: ResetAutoTier is part of an experimental Auto routing surface + // and may change or be removed. + ResetAutoTier bool } // SetModel changes the model for this session. @@ -1857,10 +1874,25 @@ type SetModelOptions struct { func (s *Session) SetModel(ctx context.Context, model string, opts *SetModelOptions) error { params := &rpc.ModelSwitchToRequest{ModelID: model} if opts != nil { + if opts.AutoTier != nil && opts.ResetAutoTier { + return errors.New("failed to set model: AutoTier and ResetAutoTier are mutually exclusive") + } params.ReasoningEffort = opts.ReasoningEffort params.ReasoningSummary = opts.ReasoningSummary params.ContextTier = opts.ContextTier params.ModelCapabilities = opts.ModelCapabilities + + // The generated field is a double pointer so the three cases stay + // distinct on the wire: a nil outer pointer omits the field and leaves + // any staged preference alone, while a non-nil outer pointer sends the + // inner value, including an explicit null. + switch { + case opts.AutoTier != nil: + params.AutoTier = &opts.AutoTier + case opts.ResetAutoTier: + var providerDefault *AutoTier + params.AutoTier = &providerDefault + } } _, err := s.RPC.Model.SwitchTo(ctx, params) if err != nil { @@ -1870,6 +1902,41 @@ func (s *Session) SetModel(ctx context.Context, model string, opts *SetModelOpti return nil } +// SetAutoTier changes 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. A [rpc.ModelSwitchAutoTierStatusPending] status +// therefore confirms that the request was accepted, not that it took effect. +// +// Watch for the outcome through the session.model_change event on success, or the +// ephemeral session.auto_tier_switch_failed event on failure. You can also read the +// current committed and in-flight state at any time with session.RPC.Model.GetCurrent. +// +// Only the most recent request survives: issuing a new request replaces any earlier one +// that has not yet been claimed by a turn. +// +// Pass nil to return to the provider's default Auto routing. +// +// Experimental: SetAutoTier is part of an experimental Auto routing surface and +// may change or be removed. +// +// Example: +// +// tier := copilot.AutoTierIntelligence +// result, err := session.SetAutoTier(context.Background(), &tier) +// if err != nil { +// log.Printf("Failed to set auto tier: %v", err) +// } +func (s *Session) SetAutoTier(ctx context.Context, autoTier *AutoTier) (*rpc.ModelSwitchAutoTierResult, error) { + result, err := s.RPC.Model.SwitchAutoTier(ctx, &rpc.ModelSwitchAutoTierRequest{AutoTier: autoTier}) + if err != nil { + return nil, fmt.Errorf("failed to set auto tier: %w", err) + } + + return result, nil +} + type LogOptions struct { // Level sets the log severity. Valid values are [rpc.SessionLogLevelInfo] (default), // [rpc.SessionLogLevelWarning], and [rpc.SessionLogLevelError]. diff --git a/go/session_event_serialization_test.go b/go/session_event_serialization_test.go index 96bf53bb5e..c64b6ce598 100644 --- a/go/session_event_serialization_test.go +++ b/go/session_event_serialization_test.go @@ -299,3 +299,70 @@ func TestManagedSettingsResolvedProvenanceRoundTrips(t *testing.T) { t.Fatalf("expected absent clientManaged to be omitted, got %v", serialized) } } + +// The failure event is ephemeral: the runtime emits it when an Auto preference +// switch cannot mint a usable model, and never persists or replays it. +func TestSessionAutoTierSwitchFailedEvent(t *testing.T) { + reasons := []AutoTierSwitchFailureReason{ + AutoTierSwitchFailureReasonPolicyRejected, + AutoTierSwitchFailureReasonRequestFailed, + AutoTierSwitchFailureReasonSetupFailed, + AutoTierSwitchFailureReasonUnsupported, + } + for _, reason := range reasons { + t.Run(string(reason), func(t *testing.T) { + wire, err := json.Marshal(map[string]any{ + "id": "00000000-0000-0000-0000-000000000001", + "timestamp": "2026-08-28T00:00:00Z", "parentId": nil, + "type": "session.auto_tier_switch_failed", + "data": map[string]any{ + "effectiveAutoTier": AutoTierBalance, + "requestedAutoTier": AutoTierIntelligence, + "reason": reason, + }, + }) + if err != nil { + t.Fatal(err) + } + var event SessionEvent + if err := json.Unmarshal(wire, &event); err != nil { + t.Fatal(err) + } + data, ok := event.Data.(*SessionAutoTierSwitchFailedData) + if !ok { + t.Fatalf("expected *SessionAutoTierSwitchFailedData, got %T", event.Data) + } + if data.Reason != reason { + t.Fatalf("expected reason %q, got %q", reason, data.Reason) + } + if data.EffectiveAutoTier == nil || *data.EffectiveAutoTier != AutoTierBalance { + t.Fatalf("expected effective tier %q, got %v", AutoTierBalance, data.EffectiveAutoTier) + } + if data.RequestedAutoTier == nil || *data.RequestedAutoTier != AutoTierIntelligence { + t.Fatalf("expected requested tier %q, got %v", AutoTierIntelligence, data.RequestedAutoTier) + } + }) + } +} + +// A null requested tier means the attempt to return to provider-default Auto +// routing is what failed. +func TestSessionAutoTierSwitchFailedEventNullRequestedTier(t *testing.T) { + wire := []byte(`{"id":"00000000-0000-0000-0000-000000000001","timestamp":"2026-08-28T00:00:00Z",` + + `"parentId":null,"type":"session.auto_tier_switch_failed","data":{"effectiveAutoTier":"efficiency",` + + `"requestedAutoTier":null,"reason":"unsupported"}}`) + var event SessionEvent + if err := json.Unmarshal(wire, &event); err != nil { + t.Fatal(err) + } + data, ok := event.Data.(*SessionAutoTierSwitchFailedData) + if !ok { + t.Fatalf("expected *SessionAutoTierSwitchFailedData, got %T", event.Data) + } + if data.RequestedAutoTier != nil { + t.Fatalf("expected nil requested tier, got %v", *data.RequestedAutoTier) + } + if data.EffectiveAutoTier == nil || *data.EffectiveAutoTier != AutoTierEfficiency { + t.Fatalf("expected effective tier %q, got %v", AutoTierEfficiency, data.EffectiveAutoTier) + } +} diff --git a/go/session_test.go b/go/session_test.go index 8d29cfc88f..1547f82375 100644 --- a/go/session_test.go +++ b/go/session_test.go @@ -80,6 +80,72 @@ func TestSession_SetModelOmitsContextTierWhenUnset(t *testing.T) { if _, ok := params["contextTier"]; ok { t.Fatalf("expected contextTier to be omitted, got %v", params["contextTier"]) } + if _, ok := params["autoTier"]; ok { + t.Fatalf("expected autoTier to be omitted, got %v", params["autoTier"]) + } +} + +func TestSession_SetModelForwardsAutoTier(t *testing.T) { + tier := AutoTierIntelligence + params := captureSetModelRequestForModel(t, "auto", &SetModelOptions{AutoTier: &tier}) + + if params["modelId"] != "auto" { + t.Fatalf("expected modelId auto, got %v", params["modelId"]) + } + if params["autoTier"] != "intelligence" { + t.Fatalf("expected autoTier intelligence, got %v", params["autoTier"]) + } +} + +func TestSession_SetModelSendsExplicitNullAutoTierWhenCleared(t *testing.T) { + params := captureSetModelRequestForModel(t, "auto", &SetModelOptions{ResetAutoTier: true}) + + // An explicit null must survive to the wire. Omitting it would mean "leave + // the preference alone" rather than "use provider-default routing". + value, ok := params["autoTier"] + if !ok { + t.Fatal("expected autoTier to be present") + } + if value != nil { + t.Fatalf("expected autoTier to be null, got %v", value) + } +} + +func TestSession_SetModelRejectsConflictingAutoTierOptions(t *testing.T) { + tier := AutoTierBalance + session := &Session{SessionID: "session-1"} + + err := session.SetModel(context.Background(), "auto", &SetModelOptions{ + AutoTier: &tier, + ResetAutoTier: true, + }) + if err == nil { + t.Fatal("expected an error when AutoTier and ResetAutoTier are both set") + } +} + +func TestSession_SetAutoTierForwardsTier(t *testing.T) { + tier := AutoTierEfficiency + params := captureSetAutoTierRequest(t, &tier) + + if params["sessionId"] != "session-1" { + t.Fatalf("expected sessionId session-1, got %v", params["sessionId"]) + } + if params["autoTier"] != "efficiency" { + t.Fatalf("expected autoTier efficiency, got %v", params["autoTier"]) + } +} + +func TestSession_SetAutoTierSendsExplicitNull(t *testing.T) { + params := captureSetAutoTierRequest(t, nil) + + value, ok := params["autoTier"] + if !ok { + t.Fatal("expected autoTier to be present") + } + if value != nil { + t.Fatalf("expected autoTier to be null, got %v", value) + } } func TestSession_MCPAuthRequestSendsHostToken(t *testing.T) { @@ -283,6 +349,28 @@ func TestMCPOauthRequiredDataAllowsOptionalMetadata(t *testing.T) { func captureSetModelRequest(t *testing.T, opts *SetModelOptions) map[string]any { t.Helper() + return captureModelRequest(t, "session.model.switchTo", func(session *Session) error { + return session.SetModel(context.Background(), "gpt-4.1", opts) + }) +} + +func captureSetModelRequestForModel(t *testing.T, model string, opts *SetModelOptions) map[string]any { + t.Helper() + return captureModelRequest(t, "session.model.switchTo", func(session *Session) error { + return session.SetModel(context.Background(), model, opts) + }) +} + +func captureSetAutoTierRequest(t *testing.T, autoTier *AutoTier) map[string]any { + t.Helper() + return captureModelRequest(t, "session.model.switchAutoTier", func(session *Session) error { + _, err := session.SetAutoTier(context.Background(), autoTier) + return err + }) +} + +func captureModelRequest(t *testing.T, method string, invoke func(*Session) error) map[string]any { + t.Helper() stdinR, stdinW := io.Pipe() stdoutR, stdoutW := io.Pipe() @@ -314,8 +402,8 @@ func captureSetModelRequest(t *testing.T, opts *SetModelOptions) map[string]any errCh <- err return } - if request.Method != "session.model.switchTo" { - errCh <- fmt.Errorf("expected session.model.switchTo, got %s", request.Method) + if request.Method != method { + errCh <- fmt.Errorf("expected %s, got %s", method, request.Method) return } @@ -324,7 +412,7 @@ func captureSetModelRequest(t *testing.T, opts *SetModelOptions) map[string]any response := map[string]any{ "jsonrpc": "2.0", "id": json.RawMessage(request.ID), - "result": map[string]any{}, + "result": map[string]any{"status": "pending"}, } data, err := json.Marshal(response) if err != nil { @@ -342,8 +430,8 @@ func captureSetModelRequest(t *testing.T, opts *SetModelOptions) map[string]any client: client, RPC: rpc.NewSessionRPC(client, "session-1"), } - if err := session.SetModel(context.Background(), "gpt-4.1", opts); err != nil { - t.Fatalf("SetModel failed: %v", err) + if err := invoke(session); err != nil { + t.Fatalf("model request failed: %v", err) } select { @@ -352,7 +440,7 @@ func captureSetModelRequest(t *testing.T, opts *SetModelOptions) map[string]any case err := <-errCh: t.Fatal(err) case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for session.model.switchTo request") + t.Fatalf("timed out waiting for %s request", method) } return nil } diff --git a/go/types.go b/go/types.go index 02ca391338..b4a34f2561 100644 --- a/go/types.go +++ b/go/types.go @@ -2315,10 +2315,12 @@ type CapiSessionOptions struct { // AutoTier selects the routing tier for model "auto" with V2 Auto. // Requires a runtime that supports Auto tiers; it has no effect outside V2 Auto. - // When unset, the runtime uses its default on create and preserves the - // persisted or current tier on resume. An explicit tier overrides the - // persisted tier on a cold resume; a conflicting tier on a resident - // session resume is rejected by the runtime. + // When unset, the runtime uses its default on create and restores the last + // committed tier on cold resume. On resident resume, a different tier + // requests a safe switch that takes effect after resume succeeds and never + // disturbs a turn that is already running. + // + // To change the preference on a live session, use [Session.SetAutoTier]. AutoTier AutoTier `json:"autoTier,omitempty"` } diff --git a/java/README.md b/java/README.md index bb71d7ab86..6a95da424e 100644 --- a/java/README.md +++ b/java/README.md @@ -363,12 +363,30 @@ var config = new SessionConfig() The same options work with `ResumeSessionConfig.setCapi(...)` and can be combined with `setEnableWebSocketResponses(false)`. The SDK omits an unset (`null`) tier: the runtime chooses its default on create and preserves the persisted/current -tier on resume. An explicit tier overrides the persisted tier on cold resume; -the runtime rejects a conflicting tier when the session is already resident -in memory. The SDK does not choose a default or manage tier persistence. +tier on resume. An explicit tier overrides the persisted tier on cold resume. On +resident resume, a different tier requests a safe switch applied after the +resume succeeds; it cannot change a turn that is already in flight. The SDK does not choose a default or manage tier persistence. See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) for the lifecycle rules. +### Changing the Auto tier during a session + +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. + +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. + +```java +var result = session.setAutoTier(AutoTier.INTELLIGENCE).get(); +if (result.status() == ModelSwitchAutoTierStatus.PENDING) { + // Accepted, but not yet in effect. +} + +// Return to the provider's default Auto routing. +session.setAutoTier(null).get(); +``` + +`setModel(SetModelOptions)` accepts the same preference through `SetModelOptions.setAutoTier(...)`, which stages the tier atomically with selecting `auto`. Call `setResetAutoTier(true)` instead to return to provider-default routing; the two options are mutually exclusive. + ## Session Store `enableSessionStore` on `SessionConfig` enables the cross-session store for search and retrieval across sessions. When unset in the default `CopilotClientMode.COPILOT_CLI` mode, the runtime default applies (enabled). In `CopilotClientMode.EMPTY` mode, defaults to disabled. diff --git a/java/sdk/src/main/java/com/github/copilot/CopilotSession.java b/java/sdk/src/main/java/com/github/copilot/CopilotSession.java index c53fea3f4b..156cb3495a 100644 --- a/java/sdk/src/main/java/com/github/copilot/CopilotSession.java +++ b/java/sdk/src/main/java/com/github/copilot/CopilotSession.java @@ -29,6 +29,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.github.copilot.generated.AssistantMessageEvent; import com.github.copilot.generated.rpc.SessionCommandsHandlePendingCommandParams; import com.github.copilot.generated.rpc.SessionLogParams; @@ -37,6 +38,8 @@ import com.github.copilot.generated.rpc.ModelCapabilitiesOverride; import com.github.copilot.generated.rpc.ModelCapabilitiesOverrideLimits; import com.github.copilot.generated.rpc.ModelCapabilitiesOverrideSupports; +import com.github.copilot.generated.rpc.SessionModelSwitchAutoTierParams; +import com.github.copilot.generated.rpc.SessionModelSwitchAutoTierResult; import com.github.copilot.generated.rpc.SessionModelSwitchToParams; import com.github.copilot.generated.rpc.SessionPermissionsHandlePendingPermissionRequestParams; import com.github.copilot.generated.rpc.SessionRpc; @@ -2073,21 +2076,7 @@ public CompletableFuture setModel(String model, String reasoningEffort, public CompletableFuture setModel(String model, String reasoningEffort, String reasoningSummary, com.github.copilot.rpc.ModelCapabilitiesOverride modelCapabilities) { ensureNotTerminated(); - ModelCapabilitiesOverride generatedCapabilities = null; - if (modelCapabilities != null) { - ModelCapabilitiesOverrideSupports supports = null; - if (modelCapabilities.getSupports() != null) { - var s = modelCapabilities.getSupports(); - supports = new ModelCapabilitiesOverrideSupports(s.getVision().orElse(null), - s.getReasoningEffort().orElse(null), null); - } - ModelCapabilitiesOverrideLimits limits = null; - if (modelCapabilities.getLimits() != null) { - limits = new ObjectMapper().convertValue(modelCapabilities.getLimits(), - ModelCapabilitiesOverrideLimits.class); - } - generatedCapabilities = new ModelCapabilitiesOverride(supports, limits); - } + ModelCapabilitiesOverride generatedCapabilities = toGeneratedCapabilities(modelCapabilities); var generatedReasoningSummary = reasoningSummary == null ? null : com.github.copilot.generated.rpc.ReasoningSummary.fromValue(reasoningSummary); @@ -2097,6 +2086,131 @@ public CompletableFuture setModel(String model, String reasoningEffort, St .thenApply(r -> null); } + private static ModelCapabilitiesOverride toGeneratedCapabilities( + com.github.copilot.rpc.ModelCapabilitiesOverride modelCapabilities) { + if (modelCapabilities == null) { + return null; + } + ModelCapabilitiesOverrideSupports supports = null; + if (modelCapabilities.getSupports() != null) { + var s = modelCapabilities.getSupports(); + supports = new ModelCapabilitiesOverrideSupports(s.getVision().orElse(null), + s.getReasoningEffort().orElse(null), null); + } + ModelCapabilitiesOverrideLimits limits = null; + if (modelCapabilities.getLimits() != null) { + limits = MAPPER.convertValue(modelCapabilities.getLimits(), ModelCapabilitiesOverrideLimits.class); + } + return new ModelCapabilitiesOverride(supports, limits); + } + + private static com.github.copilot.generated.rpc.AutoTier toGeneratedAutoTier( + com.github.copilot.rpc.AutoTier autoTier) { + return autoTier == null ? null : com.github.copilot.generated.rpc.AutoTier.fromValue(autoTier.getValue()); + } + + /** + * Changes the model for this session using an options object. + *

+ * The new model takes effect for the next message. Conversation history is + * preserved. Use {@link com.github.copilot.rpc.SetModelOptions#setAutoTier} to + * request an Auto routing preference at the same time, which the runtime + * accepts only when the model is {@code "auto"}. + * + *

{@code
+     * session.setModel(new SetModelOptions().setModel("auto").setAutoTier(AutoTier.INTELLIGENCE)).get();
+     * session.setModel(new SetModelOptions().setModel("auto").setResetAutoTier(true)).get();
+     * }
+ * + * @param options + * the switch settings; the model ID is required + * @return a future that completes when the model switch is acknowledged + * @throws IllegalArgumentException + * if {@code options} is {@code null}, if it carries no model ID, or + * if it requests both an explicit Auto tier and a return to + * provider-default Auto routing + * @throws IllegalStateException + * if this session has been terminated + * @since 1.6.0 + */ + public CompletableFuture setModel(com.github.copilot.rpc.SetModelOptions options) { + ensureNotTerminated(); + if (options == null) { + throw new IllegalArgumentException("options must not be null"); + } + if (options.getModel() == null) { + throw new IllegalArgumentException("options must specify a model"); + } + if (options.getAutoTier() != null && options.isResetAutoTier()) { + throw new IllegalArgumentException( + "setModel cannot combine an explicit autoTier with resetAutoTier; choose one"); + } + var generatedReasoningSummary = options.getReasoningSummary() == null + ? null + : com.github.copilot.generated.rpc.ReasoningSummary.fromValue(options.getReasoningSummary()); + var params = new SessionModelSwitchToParams(sessionId, options.getModel(), + toGeneratedAutoTier(options.getAutoTier()), options.getReasoningEffort(), generatedReasoningSummary, + null, toGeneratedCapabilities(options.getModelCapabilities()), null, null, null, null, null, null, null, + null, null); + if (!options.isResetAutoTier()) { + return getRpc().model.switchTo(params).thenApply(r -> null); + } + // The generated params record omits null properties, but returning to + // provider-default Auto routing requires sending an explicit null tier, so + // build the payload directly and reinstate the null. + ObjectNode payload = MAPPER.valueToTree(params); + payload.putNull("autoTier"); + return rpc.invoke("session.model.switchTo", payload, Void.class); + } + + /** + * Changes 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 {@code auto} model + * successfully obtains a usable model from the provider. A {@code pending} + * status therefore confirms that the request was accepted, not that it took + * effect. + *

+ * Watch for the outcome through the {@code session.model_change} event on + * success, or the ephemeral {@code session.auto_tier_switch_failed} event on + * failure. You can also read the committed and in-flight state at any time with + * {@code session.getRpc().model.getCurrent()}. + *

+ * Only the most recent request survives: a new request replaces any earlier one + * that no turn has claimed yet. + * + *

{@code
+     * var result = session.setAutoTier(AutoTier.INTELLIGENCE).get();
+     * if (result.status() == ModelSwitchAutoTierStatus.PENDING) {
+     * 	// Takes effect on a later turn that uses the `auto` model.
+     * }
+     * }
+ * + * @param autoTier + * the routing preference to activate, or {@code null} to return to + * the provider's default Auto routing + * @return a future completing with the runtime's immediate acknowledgement and + * Auto preference snapshot + * @throws IllegalStateException + * if this session has been terminated + * @since 1.6.0 + */ + @CopilotExperimental + public CompletableFuture setAutoTier(com.github.copilot.rpc.AutoTier autoTier) { + ensureNotTerminated(); + var params = new SessionModelSwitchAutoTierParams(sessionId, toGeneratedAutoTier(autoTier), null); + if (autoTier != null) { + return getRpc().model.switchAutoTier(params); + } + // The generated params record omits null properties, but the runtime + // distinguishes an explicit null tier (return to provider-default routing) + // from an absent one, so build the payload directly and reinstate the null. + ObjectNode payload = MAPPER.valueToTree(params); + payload.putNull("autoTier"); + return rpc.invoke("session.model.switchAutoTier", payload, SessionModelSwitchAutoTierResult.class); + } + /** * Changes the model for this session. *

diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/CapiSessionOptions.java b/java/sdk/src/main/java/com/github/copilot/rpc/CapiSessionOptions.java index e401762302..1743f572ed 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/CapiSessionOptions.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/CapiSessionOptions.java @@ -51,8 +51,9 @@ public AutoTier getAutoTier() { *

* When omitted, the runtime chooses its default on create and preserves the * persisted or current tier on resume. An explicit tier overrides the persisted - * tier on cold resume; the runtime rejects a conflicting tier when resuming a - * session already resident in memory. + * tier on cold resume. On resident resume, 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. * * @param autoTier * the routing tier, or {@code null} to omit it from the request diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/SetModelOptions.java b/java/sdk/src/main/java/com/github/copilot/rpc/SetModelOptions.java new file mode 100644 index 0000000000..9ebfbf199b --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/rpc/SetModelOptions.java @@ -0,0 +1,177 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.github.copilot.CopilotExperimental; + +/** + * Optional settings for a model switch. + *

+ * All setter methods return {@code this} for method chaining. {@code model} is + * required. Every other option is optional; an unset option leaves the + * corresponding session state unchanged. + * + *

{@code
+ * session.setModel(new SetModelOptions().setModel("auto").setAutoTier(AutoTier.INTELLIGENCE)).get();
+ * }
+ * + * @since 1.6.0 + */ +public class SetModelOptions { + + private String model; + + private String reasoningEffort; + + private String reasoningSummary; + + private ModelCapabilitiesOverride modelCapabilities; + + private AutoTier autoTier; + + private boolean resetAutoTier; + + /** + * Gets the target model ID. + * + * @return the model ID, or {@code null} when none has been set + */ + public String getModel() { + return model; + } + + /** + * Sets the model to switch to. This option is required. + * + * @param model + * the model ID (e.g., {@code "gpt-5.4"} or {@code "auto"}) + * @return this options object for method chaining + */ + public SetModelOptions setModel(String model) { + this.model = model; + return this; + } + + /** + * Gets the reasoning effort level. + * + * @return the reasoning effort level, or {@code null} to use the default + */ + public String getReasoningEffort() { + return reasoningEffort; + } + + /** + * Sets the reasoning effort level. + * + * @param reasoningEffort + * reasoning effort level (e.g., {@code "low"}, {@code "medium"}, + * {@code "high"}, {@code "xhigh"}, {@code "max"}); {@code null} to + * use the default + * @return this options object for method chaining + */ + public SetModelOptions setReasoningEffort(String reasoningEffort) { + this.reasoningEffort = reasoningEffort; + return this; + } + + /** + * Gets the reasoning summary mode. + * + * @return the reasoning summary mode, or {@code null} to use the default + */ + public String getReasoningSummary() { + return reasoningSummary; + } + + /** + * Sets the reasoning summary mode. + * + * @param reasoningSummary + * reasoning summary mode ({@code "none"}, {@code "concise"}, or + * {@code "detailed"}); {@code null} to use the default + * @return this options object for method chaining + */ + public SetModelOptions setReasoningSummary(String reasoningSummary) { + this.reasoningSummary = reasoningSummary; + return this; + } + + /** + * Gets the model capability overrides. + * + * @return the capability overrides, or {@code null} to use runtime defaults + */ + public ModelCapabilitiesOverride getModelCapabilities() { + return modelCapabilities; + } + + /** + * Sets per-property overrides for model capabilities. + * + * @param modelCapabilities + * the capability overrides; {@code null} to use runtime defaults + * @return this options object for method chaining + */ + public SetModelOptions setModelCapabilities(ModelCapabilitiesOverride modelCapabilities) { + this.modelCapabilities = modelCapabilities; + return this; + } + + /** + * Gets the requested Auto routing preference. + * + * @return the requested tier, or {@code null} when no tier was requested + */ + @CopilotExperimental + public AutoTier getAutoTier() { + return autoTier; + } + + /** + * Requests an Auto routing preference alongside the model switch. + *

+ * The runtime records the request and commits it only when a later user turn + * using the {@code auto} model successfully obtains a usable model from the + * provider. Use {@link #setResetAutoTier(boolean)} to return to the provider's + * default Auto routing instead. + * + * @param autoTier + * the routing preference to request; {@code null} to leave the + * current preference unchanged + * @return this options object for method chaining + */ + @CopilotExperimental + public SetModelOptions setAutoTier(AutoTier autoTier) { + this.autoTier = autoTier; + return this; + } + + /** + * Gets whether the request returns to provider-default Auto routing. + * + * @return {@code true} when the request clears the Auto routing preference + */ + @CopilotExperimental + public boolean isResetAutoTier() { + return resetAutoTier; + } + + /** + * Requests a return to the provider's default Auto routing. + *

+ * This differs from leaving {@link #setAutoTier(AutoTier)} unset, which keeps + * the current preference. It cannot be combined with an explicit tier. + * + * @param resetAutoTier + * {@code true} to return to provider-default Auto routing + * @return this options object for method chaining + */ + @CopilotExperimental + public SetModelOptions setResetAutoTier(boolean resetAutoTier) { + this.resetAutoTier = resetAutoTier; + return this; + } +} diff --git a/java/sdk/src/test/java/com/github/copilot/AutoTierIT.java b/java/sdk/src/test/java/com/github/copilot/AutoTierIT.java new file mode 100644 index 0000000000..d486ce1c2c --- /dev/null +++ b/java/sdk/src/test/java/com/github/copilot/AutoTierIT.java @@ -0,0 +1,120 @@ +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.github.copilot.generated.rpc.ModelSwitchAutoTierStatus; +import com.github.copilot.rpc.AutoTier; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.SessionConfig; +import com.github.copilot.rpc.SetModelOptions; + +/** + * End-to-end coverage for Auto tier switching, mirroring + * {@code nodejs/test/e2e/auto_tier.e2e.test.ts}. + *

+ * The runtime stages an Auto routing preference rather than applying it + * immediately: a request stays unclaimed until a later turn using the + * {@code auto} model mints a usable model and token pair. These tests read the + * staged state back through {@code model.getCurrent()}, so they assert what the + * runtime actually recorded rather than what the SDK serialized. + */ +class AutoTierIT { + + private static final String MODEL_ID = "auto"; + + private static E2ETestContext ctx; + + @BeforeAll + static void setUp() throws Exception { + ctx = E2ETestContext.create(); + } + + @AfterAll + static void tearDown() throws Exception { + if (ctx != null) { + ctx.close(); + } + } + + private static com.github.copilot.generated.rpc.AutoTier pendingAutoTier(CopilotSession session) throws Exception { + return session.getRpc().model.getCurrent().get(30, TimeUnit.SECONDS).pendingAutoTier(); + } + + private static CopilotSession createAutoSession(CopilotClient client) throws Exception { + return client + .createSession( + new SessionConfig().setModel(MODEL_ID).setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(30, TimeUnit.SECONDS); + } + + @Test + void shouldStageAndResetAutoTierPreference() throws Exception { + ctx.configureForTest("auto_tier", "should_stage_and_reset_auto_tier_preference"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = createAutoSession(client); + try { + assertNull(pendingAutoTier(session)); + + var staged = session.setAutoTier(AutoTier.EFFICIENCY).get(30, TimeUnit.SECONDS); + assertEquals(ModelSwitchAutoTierStatus.PENDING, staged.status()); + assertEquals(com.github.copilot.generated.rpc.AutoTier.EFFICIENCY, staged.pendingAutoTier()); + assertEquals(com.github.copilot.generated.rpc.AutoTier.EFFICIENCY, pendingAutoTier(session)); + + // A second request replaces the first and reports the one it displaced. + var superseded = session.setAutoTier(AutoTier.INTELLIGENCE).get(30, TimeUnit.SECONDS); + assertEquals(ModelSwitchAutoTierStatus.PENDING, superseded.status()); + assertEquals(com.github.copilot.generated.rpc.AutoTier.INTELLIGENCE, superseded.pendingAutoTier()); + assertEquals(com.github.copilot.generated.rpc.AutoTier.EFFICIENCY, superseded.supersededAutoTier()); + assertEquals(com.github.copilot.generated.rpc.AutoTier.INTELLIGENCE, pendingAutoTier(session)); + + // A null tier returns the session to provider-default routing. The status + // is `unchanged` because provider-default was already the committed + // preference; the request's effect is cancelling the staged one. + var reset = session.setAutoTier(null).get(30, TimeUnit.SECONDS); + assertEquals(ModelSwitchAutoTierStatus.UNCHANGED, reset.status()); + assertEquals(com.github.copilot.generated.rpc.AutoTier.INTELLIGENCE, reset.supersededAutoTier()); + assertNull(pendingAutoTier(session)); + } finally { + session.close(); + } + } + } + + @Test + void shouldPreserveAutoTierWhenSetModelOmitsIt() throws Exception { + ctx.configureForTest("auto_tier", "should_preserve_auto_tier_when_set_model_omits_it"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = createAutoSession(client); + try { + session.setAutoTier(AutoTier.BALANCE).get(30, TimeUnit.SECONDS); + assertEquals(com.github.copilot.generated.rpc.AutoTier.BALANCE, pendingAutoTier(session)); + + // Omitting the preference leaves the staged one alone. + session.setModel(new SetModelOptions().setModel(MODEL_ID)).get(30, TimeUnit.SECONDS); + assertEquals(com.github.copilot.generated.rpc.AutoTier.BALANCE, pendingAutoTier(session)); + + // Supplying a tier replaces it. + session.setModel(new SetModelOptions().setModel(MODEL_ID).setAutoTier(AutoTier.INTELLIGENCE)).get(30, + TimeUnit.SECONDS); + assertEquals(com.github.copilot.generated.rpc.AutoTier.INTELLIGENCE, pendingAutoTier(session)); + + // Requesting a reset clears it. Omission, an explicit tier, and a reset + // are three distinct outcomes. + session.setModel(new SetModelOptions().setModel(MODEL_ID).setResetAutoTier(true)).get(30, + TimeUnit.SECONDS); + assertNull(pendingAutoTier(session)); + } finally { + session.close(); + } + } + } +} diff --git a/java/sdk/src/test/java/com/github/copilot/SessionAutoTierEventTest.java b/java/sdk/src/test/java/com/github/copilot/SessionAutoTierEventTest.java index 5213cdbb8d..25e356a8d1 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionAutoTierEventTest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionAutoTierEventTest.java @@ -16,6 +16,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.github.copilot.generated.AutoTier; +import com.github.copilot.generated.AutoTierSwitchFailureReason; +import com.github.copilot.generated.SessionAutoTierSwitchFailedEvent; import com.github.copilot.generated.SessionEvent; import com.github.copilot.generated.SessionResumeEvent; import com.github.copilot.generated.SessionStartEvent; @@ -64,4 +66,39 @@ private static AutoTier autoTier(SessionEvent event, String type) { } return assertInstanceOf(SessionResumeEvent.class, event).getData().autoTier(); } + + @ParameterizedTest + @CsvSource({"policy_rejected,POLICY_REJECTED", "request_failed,REQUEST_FAILED", "setup_failed,SETUP_FAILED", + "unsupported,UNSUPPORTED"}) + void autoTierSwitchFailedEventDecodesEveryReason(String value, AutoTierSwitchFailureReason reason) + throws Exception { + String json = """ + {"type":"session.auto_tier_switch_failed","data":{"effectiveAutoTier":"balance", + "requestedAutoTier":"intelligence","reason":"%s"}} + """.formatted(value); + + var event = MAPPER.readValue(json, SessionEvent.class); + + var data = assertInstanceOf(SessionAutoTierSwitchFailedEvent.class, event).getData(); + assertEquals(AutoTier.BALANCE, data.effectiveAutoTier()); + assertEquals(AutoTier.INTELLIGENCE, data.requestedAutoTier()); + assertEquals(reason, data.reason()); + } + + @org.junit.jupiter.api.Test + void autoTierSwitchFailedEventAllowsNullRequestedTier() throws Exception { + // A null requested tier means the attempt to return to provider-default + // Auto routing is what failed. + String json = """ + {"type":"session.auto_tier_switch_failed","data":{"effectiveAutoTier":"efficiency", + "requestedAutoTier":null,"reason":"unsupported"}} + """; + + var event = MAPPER.readValue(json, SessionEvent.class); + + var data = assertInstanceOf(SessionAutoTierSwitchFailedEvent.class, event).getData(); + assertEquals(AutoTier.EFFICIENCY, data.effectiveAutoTier()); + assertNull(data.requestedAutoTier()); + assertEquals(AutoTierSwitchFailureReason.UNSUPPORTED, data.reason()); + } } diff --git a/java/sdk/src/test/java/com/github/copilot/SessionAutoTierSwitchTest.java b/java/sdk/src/test/java/com/github/copilot/SessionAutoTierSwitchTest.java new file mode 100644 index 0000000000..adeeca2c6b --- /dev/null +++ b/java/sdk/src/test/java/com/github/copilot/SessionAutoTierSwitchTest.java @@ -0,0 +1,228 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.generated.rpc.ModelSwitchAutoTierStatus; +import com.github.copilot.generated.rpc.SessionModelSwitchAutoTierResult; +import com.github.copilot.rpc.AutoTier; +import com.github.copilot.rpc.SetModelOptions; +import java.io.InputStream; +import java.net.ServerSocket; +import java.net.Socket; +import org.junit.jupiter.api.Test; + +/** + * Verifies the wire payloads produced by Auto routing preference switches. + *

+ * The runtime treats an explicit {@code null} {@code autoTier} (return to + * provider-default routing) differently from an absent one (leave the + * preference unchanged), so these tests assert on the raw JSON rather than on + * the generated params records, which drop null properties. + */ +@AllowCopilotExperimental +class SessionAutoTierSwitchTest { + + @Test + void setModel_omits_autoTier_when_no_preference_is_requested() throws Exception { + try (var sockets = new SocketPair()) { + var session = new CopilotSession("sess-1", sockets.client()); + var stub = sockets.stubServer(); + + session.setModel(new SetModelOptions().setModel("auto")); + + var params = stub.readOneMessage().get("params"); + assertEquals("auto", params.get("modelId").asText()); + assertFalse(params.has("autoTier"), "an unset preference must not appear on the wire"); + } + } + + @Test + void setModel_sends_requested_autoTier() throws Exception { + try (var sockets = new SocketPair()) { + var session = new CopilotSession("sess-2", sockets.client()); + var stub = sockets.stubServer(); + + session.setModel(new SetModelOptions().setModel("auto").setAutoTier(AutoTier.INTELLIGENCE) + .setReasoningEffort("high")); + + var sent = stub.readOneMessage(); + assertEquals("session.model.switchTo", sent.get("method").asText()); + var params = sent.get("params"); + assertEquals("intelligence", params.get("autoTier").asText()); + assertEquals("high", params.get("reasoningEffort").asText()); + assertEquals("sess-2", params.get("sessionId").asText()); + } + } + + @Test + void setModel_sends_explicit_null_autoTier_when_clearing() throws Exception { + try (var sockets = new SocketPair()) { + var session = new CopilotSession("sess-3", sockets.client()); + var stub = sockets.stubServer(); + + session.setModel(new SetModelOptions().setModel("auto").setResetAutoTier(true)); + + var sent = stub.readOneMessage(); + assertEquals("session.model.switchTo", sent.get("method").asText()); + var params = sent.get("params"); + assertTrue(params.has("autoTier"), "clearing must send the property"); + assertTrue(params.get("autoTier").isNull(), "clearing must send an explicit null"); + assertEquals("sess-3", params.get("sessionId").asText()); + } + } + + @Test + void setModel_rejects_a_tier_combined_with_clearing() throws Exception { + try (var sockets = new SocketPair()) { + var session = new CopilotSession("sess-4", sockets.client()); + + var options = new SetModelOptions().setModel("auto").setAutoTier(AutoTier.BALANCE).setResetAutoTier(true); + + assertThrows(IllegalArgumentException.class, () -> session.setModel(options)); + } + } + + @Test + void setModel_requires_a_model() throws Exception { + try (var sockets = new SocketPair()) { + var session = new CopilotSession("sess-5", sockets.client()); + + assertThrows(IllegalArgumentException.class, () -> session.setModel(new SetModelOptions())); + assertThrows(IllegalArgumentException.class, () -> session.setModel((SetModelOptions) null)); + } + } + + @Test + void setAutoTier_sends_the_requested_tier() throws Exception { + try (var sockets = new SocketPair()) { + var session = new CopilotSession("sess-6", sockets.client()); + var stub = sockets.stubServer(); + + session.setAutoTier(AutoTier.EFFICIENCY); + + var sent = stub.readOneMessage(); + assertEquals("session.model.switchAutoTier", sent.get("method").asText()); + var params = sent.get("params"); + assertEquals("efficiency", params.get("autoTier").asText()); + assertEquals("sess-6", params.get("sessionId").asText()); + } + } + + @Test + void setAutoTier_sends_explicit_null_for_provider_default_routing() throws Exception { + try (var sockets = new SocketPair()) { + var session = new CopilotSession("sess-7", sockets.client()); + var stub = sockets.stubServer(); + + session.setAutoTier(null); + + var sent = stub.readOneMessage(); + assertEquals("session.model.switchAutoTier", sent.get("method").asText()); + var params = sent.get("params"); + assertTrue(params.has("autoTier"), "returning to provider-default routing must send the property"); + assertTrue(params.get("autoTier").isNull(), "returning to provider-default routing must send null"); + assertEquals("sess-7", params.get("sessionId").asText()); + } + } + + @Test + void switchAutoTier_result_deserializes_every_field() throws Exception { + var json = """ + { + "status": "pending", + "effectiveAutoTier": "balance", + "pendingAutoTier": "intelligence", + "activatingAutoTier": null, + "supersededAutoTier": "efficiency" + } + """; + + var result = new ObjectMapper().readValue(json, SessionModelSwitchAutoTierResult.class); + + assertEquals(ModelSwitchAutoTierStatus.PENDING, result.status()); + assertEquals(com.github.copilot.generated.rpc.AutoTier.BALANCE, result.effectiveAutoTier()); + assertEquals(com.github.copilot.generated.rpc.AutoTier.INTELLIGENCE, result.pendingAutoTier()); + assertNull(result.activatingAutoTier()); + assertEquals(com.github.copilot.generated.rpc.AutoTier.EFFICIENCY, result.supersededAutoTier()); + } + + /** + * Loopback socket pair; the client side backs a real {@link JsonRpcClient} and + * the server side exposes the raw outbound messages. + */ + private static final class SocketPair implements AutoCloseable { + + private final Socket clientSocket; + private final Socket serverSocket; + private final JsonRpcClient rpcClient; + + SocketPair() throws Exception { + try (var ss = new ServerSocket(0)) { + clientSocket = new Socket("localhost", ss.getLocalPort()); + serverSocket = ss.accept(); + } + serverSocket.setSoTimeout(3000); + rpcClient = JsonRpcClient.fromSocket(clientSocket); + } + + JsonRpcClient client() { + return rpcClient; + } + + StubServer stubServer() { + return new StubServer(serverSocket); + } + + @Override + public void close() throws Exception { + rpcClient.close(); + clientSocket.close(); + serverSocket.close(); + } + } + + /** Reads Content-Length framed JSON-RPC messages from the server socket. */ + private static final class StubServer { + + private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper(); + + private final InputStream in; + + StubServer(Socket socket) { + try { + this.in = socket.getInputStream(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + JsonNode readOneMessage() throws Exception { + var header = new StringBuilder(); + int b; + while ((b = in.read()) != -1) { + if (b == '\n' && header.toString().endsWith("\r")) { + break; + } + header.append((char) b); + } + in.read(); + in.read(); + + String hdr = header.toString().trim(); + int colon = hdr.indexOf(':'); + int len = Integer.parseInt(hdr.substring(colon + 1).trim()); + byte[] body = in.readNBytes(len); + return MAPPER.readTree(body); + } + } +} diff --git a/nodejs/README.md b/nodejs/README.md index 3a2a536e65..e3d76ba6e4 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -319,6 +319,32 @@ const unsubscribe = session.on((event) => { unsubscribe(); ``` +##### `setModel(model: string, options?): Promise` + +Change the model for this session. The new model takes effect for the next message; conversation history is preserved. + +**Options:** + +- `reasoningEffort?: string` - Reasoning effort level +- `autoTier?: AutoTier | null` - Auto routing preference to stage together with selecting `auto`. Pass `null` to return to the provider's default Auto routing; omit it to leave the current preference unchanged. + +##### `setAutoTier(autoTier: AutoTier | null): Promise` + +Change the Auto routing preference without changing the selected model. Pass `null` to return to the provider's default Auto routing. + +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. + +Watch for the outcome through the `session.model_change` event on success or the ephemeral `session.auto_tier_switch_failed` event on failure, and read the authoritative state at any time with `session.rpc.model.getCurrent()`. + +```typescript +const result = await session.setAutoTier("intelligence"); +if (result.status === "pending") { + // Accepted, but not yet in effect. +} +``` + +See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) for the full lifecycle rules. + ##### `abort(): Promise` Abort the currently processing message in this session. diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index bf6f2195f9..2007679d61 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -123,6 +123,9 @@ export type { ModelBillingTokenPricesLongContext, AutoTier, CapiSessionOptions, + CurrentModel, + ModelSwitchAutoTierResult, + ModelSwitchAutoTierStatus, ModelCapabilities, ModelCapabilitiesOverride, ModelInfo, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index eb4c6b7561..0dfff88bc7 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -18,6 +18,7 @@ import type { McpOauthPendingRequestResponse, FactoryLogLine, FactoryRunResult as WireFactoryRunResult, + ModelSwitchAutoTierResult, } from "./generated/rpc.js"; import { type Canvas, CanvasError } from "./canvas.js"; import type { OpenCanvasInstance } from "./generated/rpc.js"; @@ -46,6 +47,7 @@ import type { ContextTier, ReasoningEffort, ReasoningSummary, + AutoTier, ModelCapabilitiesOverride, SectionTransformFn, SessionCapabilities, @@ -2065,6 +2067,9 @@ export class CopilotSession { * ```typescript * await session.setModel("gpt-5.4"); * await session.setModel("claude-sonnet-4.6", { reasoningEffort: "high" }); + * + * // Select the Auto model and its routing preference in one call. + * await session.setModel("auto", { autoTier: "intelligence" }); * ``` */ async setModel( @@ -2074,11 +2079,58 @@ export class CopilotSession { reasoningSummary?: ReasoningSummary; contextTier?: ContextTier; modelCapabilities?: ModelCapabilitiesOverride; + /** + * Routing preference to apply when `model` is `auto`. + * + * Pass `null` to return to the provider's default Auto routing. The + * runtime rejects this option when `model` is anything other than + * `auto`; use {@link setAutoTier} to change the preference without + * changing the selected model. + * + * @experimental Part of an experimental Auto routing surface and may + * change or be removed in a future release. + */ + autoTier?: AutoTier | null; } ): Promise { await this.rpc.model.switchTo({ modelId: model, ...options }); } + /** + * 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. A `pending` status + * therefore confirms that the request was accepted, not that it took effect. + * + * Watch for the outcome through the `session.model_change` event on success, + * or the ephemeral `session.auto_tier_switch_failed` event on failure. You + * can also read the current committed and in-flight state at any time with + * `session.rpc.model.getCurrent()`. + * + * Only the most recent request survives: issuing a new request replaces any + * earlier one that has not yet been claimed by a turn. + * + * @param autoTier - Routing preference to activate, or `null` to return to + * the provider's default Auto routing + * @returns The runtime's immediate acknowledgement and Auto preference snapshot + * + * @experimental Part of an experimental Auto routing surface and may change + * or be removed in a future release. + * + * @example + * ```typescript + * const result = await session.setAutoTier("intelligence"); + * if (result.status === "pending") { + * // Takes effect on a later turn that uses the `auto` model. + * } + * ``` + */ + async setAutoTier(autoTier: AutoTier | null): Promise { + return await this.rpc.model.switchAutoTier({ autoTier }); + } + /** * Log a message to the session timeline. * The message appears in the session event stream and is visible to SDK consumers diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 128ced3d68..82c6fe0e53 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -74,6 +74,11 @@ export type SessionEvent = | Exclude | PermissionRequestedEvent; export type { AutoTier, ReasoningSummary } from "./generated/session-events.js"; +export type { + CurrentModel, + ModelSwitchAutoTierResult, + ModelSwitchAutoTierStatus, +} from "./generated/rpc.js"; export type { SessionFsProvider } from "./sessionFsProvider.js"; export { createSessionFsAdapter } from "./sessionFsProvider.js"; export type { SessionFsFileInfo } from "./sessionFsProvider.js"; @@ -2176,9 +2181,13 @@ export interface CapiSessionOptions { * Requires a runtime with Auto tier support and V2 Auto routing. * * When omitted on create, the runtime uses its default routing behavior. - * The runtime persists this preference across cold resume; an explicit tier - * on cold resume overrides the persisted value. For an already-resident - * session, omission preserves the current tier and a different tier is rejected. + * The runtime persists this preference across cold resume; when omitted on + * cold resume, it restores the last committed preference. On resident + * resume, a different tier requests a safe switch that takes effect after + * resume succeeds, and never disturbs a turn that is already running. + * + * To change the preference on a live session, call + * {@link CopilotSession.setAutoTier} instead. */ autoTier?: AutoTier; diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 098d9eac54..ba96292ba0 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -2604,6 +2604,117 @@ describe("CopilotClient", () => { spy.mockRestore(); }); + it("sends the auto tier with session.model.switchTo when selecting the auto model", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, _params: any) => { + if (method === "session.model.switchTo") return {}; + throw new Error(`Unexpected method: ${method}`); + }); + + await session.setModel("auto", { autoTier: "intelligence" }); + + expect(spy).toHaveBeenCalledWith("session.model.switchTo", { + sessionId: session.sessionId, + modelId: "auto", + autoTier: "intelligence", + }); + + spy.mockRestore(); + }); + + it("sends a null auto tier with session.model.switchTo to restore default routing", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, _params: any) => { + if (method === "session.model.switchTo") return {}; + throw new Error(`Unexpected method: ${method}`); + }); + + await session.setModel("auto", { autoTier: null }); + + expect(spy).toHaveBeenCalledWith("session.model.switchTo", { + sessionId: session.sessionId, + modelId: "auto", + autoTier: null, + }); + + spy.mockRestore(); + }); + + it("sends session.model.switchAutoTier RPC and returns the runtime snapshot", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, _params: any) => { + if (method === "session.model.switchAutoTier") { + return { + status: "pending", + effectiveAutoTier: "balance", + pendingAutoTier: "intelligence", + activatingAutoTier: null, + supersededAutoTier: null, + }; + } + throw new Error(`Unexpected method: ${method}`); + }); + + const result = await session.setAutoTier("intelligence"); + + expect(spy).toHaveBeenCalledWith("session.model.switchAutoTier", { + sessionId: session.sessionId, + autoTier: "intelligence", + }); + expect(result.status).toBe("pending"); + expect(result.effectiveAutoTier).toBe("balance"); + expect(result.pendingAutoTier).toBe("intelligence"); + expect(result.activatingAutoTier).toBeNull(); + + spy.mockRestore(); + }); + + it("sends a null auto tier with session.model.switchAutoTier to restore default routing", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, _params: any) => { + if (method === "session.model.switchAutoTier") return { status: "unchanged" }; + throw new Error(`Unexpected method: ${method}`); + }); + + const result = await session.setAutoTier(null); + + expect(spy).toHaveBeenCalledWith("session.model.switchAutoTier", { + sessionId: session.sessionId, + autoTier: null, + }); + expect(result.status).toBe("unchanged"); + + spy.mockRestore(); + }); + describe("URL parsing", () => { it("should parse port-only URL format", () => { const client = new CopilotClient({ diff --git a/nodejs/test/e2e/auto_tier.e2e.test.ts b/nodejs/test/e2e/auto_tier.e2e.test.ts new file mode 100644 index 0000000000..0cb2a1a266 --- /dev/null +++ b/nodejs/test/e2e/auto_tier.e2e.test.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +/** + * The runtime stages an Auto routing preference instead of applying it immediately: a + * request is "unclaimed" until a later turn using the `auto` model mints a usable model + * and token pair. These tests observe that staged state through `model.getCurrent`, so + * they assert what the runtime actually recorded rather than what the SDK serialized. + */ +describe("Auto tier switching", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should stage and reset auto tier preference", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + model: "auto", + }); + + expect((await session.rpc.model.getCurrent()).pendingAutoTier).toBeUndefined(); + + const staged = await session.setAutoTier("efficiency"); + expect(staged.status).toBe("pending"); + expect(staged.pendingAutoTier).toBe("efficiency"); + expect((await session.rpc.model.getCurrent()).pendingAutoTier).toBe("efficiency"); + + // A second request replaces the first and reports the one it displaced. + const superseded = await session.setAutoTier("intelligence"); + expect(superseded.status).toBe("pending"); + expect(superseded.pendingAutoTier).toBe("intelligence"); + expect(superseded.supersededAutoTier).toBe("efficiency"); + expect((await session.rpc.model.getCurrent()).pendingAutoTier).toBe("intelligence"); + + // Passing null returns the session to provider-default routing. The status is + // `unchanged` because provider-default was already the committed preference; + // the request's effect is cancelling the staged one. + const reset = await session.setAutoTier(null); + expect(reset.status).toBe("unchanged"); + expect(reset.supersededAutoTier).toBe("intelligence"); + expect((await session.rpc.model.getCurrent()).pendingAutoTier).toBeUndefined(); + + await session.disconnect(); + }); + + it("should preserve auto tier when set model omits it", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + model: "auto", + }); + + await session.setAutoTier("balance"); + expect((await session.rpc.model.getCurrent()).pendingAutoTier).toBe("balance"); + + // Omitting the option leaves the staged preference alone. + await session.setModel("auto"); + expect((await session.rpc.model.getCurrent()).pendingAutoTier).toBe("balance"); + + // Supplying a tier replaces it. + await session.setModel("auto", { autoTier: "intelligence" }); + expect((await session.rpc.model.getCurrent()).pendingAutoTier).toBe("intelligence"); + + // Supplying null clears it. Omission, a value, and null are three distinct + // outcomes, which is why the option cannot collapse to a plain optional field. + await session.setModel("auto", { autoTier: null }); + expect((await session.rpc.model.getCurrent()).pendingAutoTier).toBeUndefined(); + + await session.disconnect(); + }); +}); diff --git a/nodejs/test/session-event-types.test.ts b/nodejs/test/session-event-types.test.ts index 93edebfc80..d20f3caaf6 100644 --- a/nodejs/test/session-event-types.test.ts +++ b/nodejs/test/session-event-types.test.ts @@ -22,6 +22,9 @@ import type { // The aggregate union; must still resolve via the package root. SessionEvent, AutoTier, + AutoTierSwitchFailedData, + AutoTierSwitchFailedEvent, + AutoTierSwitchFailureReason, CapiSessionOptions, PermissionRequest, PermissionRequestedData, @@ -156,6 +159,46 @@ describe("Session event type exports (#1156)", () => { } }); + it.each([ + "policy_rejected", + "request_failed", + "setup_failed", + "unsupported", + ] satisfies AutoTierSwitchFailureReason[])( + "exposes the Auto tier switch failure event with reason %s", + (reason) => { + const data: AutoTierSwitchFailedData = { + reason, + requestedAutoTier: "intelligence", + effectiveAutoTier: "balance", + }; + const event: AutoTierSwitchFailedEvent = { + type: "session.auto_tier_switch_failed", + id: "event-1", + parentId: null, + timestamp: "2026-09-02T00:00:00Z", + ephemeral: true, + data, + }; + + // The failure event must be reachable through the aggregate union so + // consumers can narrow on it in a single event handler. + const asSessionEvent: SessionEvent = event; + expect(asSessionEvent.type).toBe("session.auto_tier_switch_failed"); + expect(data.reason).toBe(reason); + expect(data.requestedAutoTier).toBe("intelligence"); + } + ); + + it("allows a null requested Auto tier when returning to default routing fails", () => { + const data: AutoTierSwitchFailedData = { + reason: "unsupported", + requestedAutoTier: null, + }; + expect(data.requestedAutoTier).toBeNull(); + expect(data.effectiveAutoTier).toBeUndefined(); + }); + it("exposes the headline ToolExecutionStartData type with a usable shape", () => { // This is the specific type called out in issue #1156. The annotation // is the compile-time API-surface check; these assertions only validate diff --git a/python/README.md b/python/README.md index 359026df41..6a7cb8a206 100644 --- a/python/README.md +++ b/python/README.md @@ -460,6 +460,25 @@ async def lookup_issue(params: LookupParams) -> str: # your logic ``` +## Auto routing tiers + +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. + +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. + +```python +result = await session.set_auto_tier("intelligence") +if result.status == ModelSwitchAutoTierStatus.PENDING: + ... # Accepted, but not yet in effect. + +# Return to the provider's default Auto routing. +await session.set_auto_tier(None) +``` + +`set_model()` accepts the same preference through its `auto_tier` argument, which stages the tier atomically with selecting `auto`. Pass `None` to return to provider-default routing, or omit the argument to leave the current preference unchanged. + +See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) for the full lifecycle rules. + ## Image Support The SDK supports image attachments via the `attachments` parameter. You can attach images by providing their file path, or by passing base64-encoded data directly using a blob attachment: diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 5d9e3f9500..8e14887a4f 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -90,6 +90,7 @@ LlmInferenceHeaders, ) from .generated.rpc import ( + CurrentModel, CurrentToolMetadata, GitHubTelemetryClientInfo, GitHubTelemetryEvent, @@ -99,6 +100,8 @@ GitHubTokenAcquireResultKind, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, + ModelSwitchAutoTierResult, + ModelSwitchAutoTierStatus, PermissionDecisionContext, PermissionDecisionOutcome, PermissionDecisionSource, @@ -106,7 +109,9 @@ PermissionResponseCapability, ) from .generated.session_events import ( + AutoTierSwitchFailureReason, PermissionRequest, + SessionAutoTierSwitchFailedData, SessionEvent, SessionEventType, ) @@ -234,6 +239,11 @@ "AutoModeSwitchResponse", "AskUserVariant", "AutoTier", + "SessionAutoTierSwitchFailedData", + "AutoTierSwitchFailureReason", + "CurrentModel", + "ModelSwitchAutoTierResult", + "ModelSwitchAutoTierStatus", "BUILTIN_TOOLS_ISOLATED", "CanvasAction", "CanvasDeclaration", diff --git a/python/copilot/client.py b/python/copilot/client.py index ab3410a56c..2df2db80a5 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -92,6 +92,7 @@ ) from .session import ( AutoModeSwitchHandler, + AutoTier, BearerTokenProvider, CommandDefinition, ContextTier, @@ -264,10 +265,6 @@ def _exp_assignment_response_to_dict( return wire -AutoTier = Literal["efficiency", "balance", "intelligence"] -"""Routing preference used when the session model is ``auto``.""" - - class CapiSessionOptions(TypedDict, total=False): """Provider-scoped Copilot API (CAPI) session options.""" @@ -276,9 +273,13 @@ class CapiSessionOptions(TypedDict, total=False): Requires a runtime with Auto tier support and V2 Auto routing. When omitted on create, the runtime uses its default routing behavior. The runtime persists - this preference across cold resume; an explicit tier on cold resume overrides - the persisted value. For an already-resident session, omission preserves the - current tier and a different tier is rejected. + this preference across cold resume; when omitted on cold resume, it restores + the last committed preference. On resident resume, a different tier requests a + safe switch that takes effect after resume succeeds and never disturbs a turn + that is already running. + + To change the preference on a live session, call + :meth:`CopilotSession.set_auto_tier` instead. """ enable_web_socket_responses: bool diff --git a/python/copilot/session.py b/python/copilot/session.py index 3c6d3d54a4..d92fc7f34f 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -19,6 +19,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import UTC, datetime +from enum import Enum from types import TracebackType from typing import TYPE_CHECKING, Any, Literal, NotRequired, Required, TypedDict, cast @@ -26,6 +27,9 @@ from ._jsonrpc import JsonRpcError, ProcessExitedError from ._telemetry import get_trace_context, trace_context from .canvas import CanvasError, CanvasHandler, OpenCanvasInstance +from .generated.rpc import ( + AutoTier as _RpcAutoTier, +) from .generated.rpc import ( BuiltinToolInputSchemaType, CanvasProviderCloseRequest, @@ -39,6 +43,7 @@ LogRequest, MCPOauthHandlePendingRequest, MCPOauthPendingRequestResponse, + ModelSwitchAutoTierResult, ModelSwitchToRequest, PermissionDecision, PermissionDecisionApproveOnce, @@ -174,9 +179,37 @@ def _capabilities_to_dict(caps: ModelCapabilitiesOverride) -> dict: ReasoningEffort = Literal["low", "medium", "high", "xhigh", "max"] ReasoningSummary = Literal["none", "concise", "detailed"] ContextTier = Literal["default", "long_context"] +AutoTier = Literal["efficiency", "balance", "intelligence"] SessionFsConventions = Literal["posix", "windows"] +class _Unset: + """Sentinel distinguishing an omitted argument from an explicit ``None``. + + Auto routing treats ``None`` as a meaningful value: it means "return to the + provider's default routing". Omitting the argument instead means "leave the + current preference alone", so the two cases cannot share a default. + """ + + def __repr__(self) -> str: + return "UNSET" + + +_UNSET = _Unset() + + +def _auto_tier_to_wire(auto_tier: AutoTier | _RpcAutoTier | None) -> str | None: + """Normalize an Auto tier to its wire value. + + Callers may pass either the ``AutoTier`` string literal or the generated + ``AutoTier`` enum, which is the type the SDK hands back on results and + events. The JSON-RPC encoder only understands plain strings. + """ + if isinstance(auto_tier, Enum): + return str(auto_tier.value) + return auto_tier + + class SessionFsCapabilities(TypedDict, total=False): sqlite: bool @@ -3050,6 +3083,7 @@ async def set_model( reasoning_summary: ReasoningSummary | None = None, context_tier: ContextTier | None = None, model_capabilities: ModelCapabilitiesOverride | None = None, + auto_tier: AutoTier | _RpcAutoTier | None | _Unset = _UNSET, ) -> None: """ Change the model for this session. @@ -3067,6 +3101,14 @@ async def set_model( context_tier: Optional context window tier for supported models. Omit to use normal model behavior with no explicit tier. model_capabilities: Override individual model capabilities resolved by the runtime. + auto_tier: **Experimental.** Part of an experimental Auto routing + surface and may change or be removed in a future release. + Routing preference to apply when ``model`` is ``"auto"``. + Pass ``None`` to return to the provider's default Auto routing. + Omit the argument to leave the current preference alone. The + runtime rejects this option when ``model`` is anything other than + ``"auto"``; use :meth:`set_auto_tier` to change the preference + without changing the selected model. Raises: Exception: If the session has been destroyed or the connection fails. @@ -3074,23 +3116,79 @@ async def set_model( Example: >>> await session.set_model("gpt-5.4") >>> await session.set_model("claude-sonnet-4.6", reasoning_effort="high") + >>> await session.set_model("auto", auto_tier="intelligence") """ rpc_caps = None if model_capabilities is not None: rpc_caps = _RpcModelCapabilitiesOverride.from_dict( _capabilities_to_dict(model_capabilities) ) - await self.rpc.model.switch_to( - ModelSwitchToRequest( - model_id=model, - reasoning_effort=reasoning_effort, - reasoning_summary=( - _RpcReasoningSummary(reasoning_summary) - if reasoning_summary is not None - else None - ), - context_tier=(_RpcContextTier(context_tier) if context_tier is not None else None), - model_capabilities=rpc_caps, + request = ModelSwitchToRequest( + model_id=model, + reasoning_effort=reasoning_effort, + reasoning_summary=( + _RpcReasoningSummary(reasoning_summary) if reasoning_summary is not None else None + ), + context_tier=(_RpcContextTier(context_tier) if context_tier is not None else None), + model_capabilities=rpc_caps, + ) + if isinstance(auto_tier, _Unset): + await self.rpc.model.switch_to(request) + return + + # The generated wrapper drops null fields, which would silently turn a + # request for default Auto routing into "leave the preference alone", so + # send the payload directly to preserve an explicit null. + params = {k: v for k, v in request.to_dict().items() if v is not None} + params["autoTier"] = _auto_tier_to_wire(auto_tier) + params["sessionId"] = self.session_id + await self._client.request("session.model.switchTo", params) + + async def set_auto_tier( + self, auto_tier: AutoTier | _RpcAutoTier | None + ) -> ModelSwitchAutoTierResult: + """ + Change the Auto routing preference without changing the selected model. + + **Experimental.** Part of an experimental Auto routing surface and may + change or be removed in a future release. + + 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. + + Watch for the outcome through the ``session.model_change`` event on + success, or the ephemeral ``session.auto_tier_switch_failed`` event on + failure. You can also read the current committed and in-flight state at + any time with ``session.rpc.model.get_current()``. + + Only the most recent request survives: issuing a new request replaces any + earlier one that has not yet been claimed by a turn. + + Args: + auto_tier: Routing preference to activate, or ``None`` to return to + the provider's default Auto routing. + + Returns: + The runtime's immediate acknowledgement and Auto preference snapshot. + + Raises: + Exception: If the session has been destroyed or the connection fails. + + Example: + >>> result = await session.set_auto_tier("intelligence") + >>> if result.status == ModelSwitchAutoTierStatus.PENDING: + ... pass # Takes effect on a later turn that uses the `auto` model. + """ + # `autoTier` is a required field whose null value means "use provider + # default routing", so this cannot go through the generated wrapper, + # which omits null fields. + return ModelSwitchAutoTierResult.from_dict( + await self._client.request( + "session.model.switchAutoTier", + {"sessionId": self.session_id, "autoTier": _auto_tier_to_wire(auto_tier)}, ) ) diff --git a/python/e2e/test_auto_tier_e2e.py b/python/e2e/test_auto_tier_e2e.py new file mode 100644 index 0000000000..5a878c4655 --- /dev/null +++ b/python/e2e/test_auto_tier_e2e.py @@ -0,0 +1,81 @@ +""" +E2E coverage for Auto routing tier switching (snapshot category ``auto_tier``). + +The runtime stages an Auto routing preference instead of applying it immediately: a +request stays "unclaimed" until a later turn using the ``auto`` model mints a usable +model and token pair. These tests observe that staged state through +``model.get_current``, so they assert what the runtime actually recorded rather than +what the SDK serialized. +""" + +from __future__ import annotations + +import pytest + +from copilot.rpc import ModelSwitchAutoTierStatus +from copilot.session import PermissionHandler +from copilot.session_events import AutoTier + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +async def pending_auto_tier(session) -> AutoTier | None: + return (await session.rpc.model.get_current()).pending_auto_tier + + +class TestAutoTier: + async def test_should_stage_and_reset_auto_tier_preference(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + model="auto", + ) + try: + assert await pending_auto_tier(session) is None + + staged = await session.set_auto_tier("efficiency") + assert staged.status == ModelSwitchAutoTierStatus.PENDING + assert staged.pending_auto_tier == AutoTier.EFFICIENCY + assert await pending_auto_tier(session) == AutoTier.EFFICIENCY + + # A second request replaces the first and reports the one it displaced. + superseded = await session.set_auto_tier("intelligence") + assert superseded.status == ModelSwitchAutoTierStatus.PENDING + assert superseded.pending_auto_tier == AutoTier.INTELLIGENCE + assert superseded.superseded_auto_tier == AutoTier.EFFICIENCY + assert await pending_auto_tier(session) == AutoTier.INTELLIGENCE + + # Passing None returns the session to provider-default routing. The status is + # "unchanged" because provider-default was already the committed preference; + # the request's effect is cancelling the staged one. + reset = await session.set_auto_tier(None) + assert reset.status == ModelSwitchAutoTierStatus.UNCHANGED + assert reset.superseded_auto_tier == AutoTier.INTELLIGENCE + assert await pending_auto_tier(session) is None + finally: + await session.disconnect() + + async def test_should_preserve_auto_tier_when_set_model_omits_it(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + model="auto", + ) + try: + await session.set_auto_tier("balance") + assert await pending_auto_tier(session) == AutoTier.BALANCE + + # Omitting the argument leaves the staged preference alone. + await session.set_model("auto") + assert await pending_auto_tier(session) == AutoTier.BALANCE + + # Supplying a tier replaces it. + await session.set_model("auto", auto_tier="intelligence") + assert await pending_auto_tier(session) == AutoTier.INTELLIGENCE + + # Supplying None clears it. Omission, a value, and None are three distinct + # outcomes, which is why the argument cannot collapse to a plain optional. + await session.set_model("auto", auto_tier=None) + assert await pending_auto_tier(session) is None + finally: + await session.disconnect() diff --git a/python/test_client.py b/python/test_client.py index e62154e247..47a1aa1f7c 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -6,6 +6,7 @@ import asyncio import inspect +import json import os from datetime import UTC, datetime from tempfile import TemporaryDirectory @@ -21,6 +22,7 @@ ExtensionInfo, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, + ModelSwitchAutoTierStatus, RuntimeConnection, StdioRuntimeConnection, define_tool, @@ -38,6 +40,7 @@ ModelLimits, ModelSupports, ) +from copilot.generated.rpc import AutoTier as AutoTierEnum from copilot.session import PermissionHandler from copilot.session_events import ( McpOauthRequestReason, @@ -2585,6 +2588,135 @@ async def mock_request(method, params, **kwargs): assert captured["session.model.switchTo"]["modelId"] == "gpt-4.1" assert captured["session.model.switchTo"]["reasoningSummary"] == "detailed" assert captured["session.model.switchTo"]["contextTier"] == "long_context" + assert "autoTier" not in captured["session.model.switchTo"] + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_set_model_sends_auto_tier(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + + try: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all + ) + + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method == "session.model.switchTo": + return {} + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await session.set_model("auto", auto_tier="intelligence") + assert captured["session.model.switchTo"]["sessionId"] == session.session_id + assert captured["session.model.switchTo"]["modelId"] == "auto" + assert captured["session.model.switchTo"]["autoTier"] == "intelligence" + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_set_model_sends_explicit_null_auto_tier(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + + try: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all + ) + + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method == "session.model.switchTo": + return {} + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await session.set_model("auto", auto_tier=None) + # An explicit null must survive to the wire; omitting it would mean + # "leave the preference alone" rather than "use default routing". + assert "autoTier" in captured["session.model.switchTo"] + assert captured["session.model.switchTo"]["autoTier"] is None + finally: + await client.force_stop() + + +class TestSetAutoTier: + @pytest.mark.asyncio + @pytest.mark.parametrize("auto_tier", ["efficiency", "balance", "intelligence", None]) + async def test_set_auto_tier_sends_correct_rpc(self, auto_tier): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + + try: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all + ) + + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method == "session.model.switchAutoTier": + return { + "status": "pending", + "effectiveAutoTier": "balance", + "pendingAutoTier": auto_tier, + "activatingAutoTier": None, + } + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + result = await session.set_auto_tier(auto_tier) + + params = captured["session.model.switchAutoTier"] + assert params["sessionId"] == session.session_id + assert "autoTier" in params + assert params["autoTier"] == auto_tier + + assert result.status == ModelSwitchAutoTierStatus.PENDING + assert result.effective_auto_tier == AutoTierEnum.BALANCE + assert result.activating_auto_tier is None + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_set_auto_tier_accepts_the_enum_it_returns(self): + """The tier on a result or event is an enum, so it has to be valid input too.""" + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + + try: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all + ) + + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method == "session.model.switchAutoTier": + return {"status": "pending", "effectiveAutoTier": "intelligence"} + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await session.set_auto_tier(AutoTierEnum.INTELLIGENCE) + + params = captured["session.model.switchAutoTier"] + # The value must be a plain string; the JSON-RPC encoder cannot + # serialize an enum. + assert params["autoTier"] == "intelligence" + assert isinstance(params["autoTier"], str) + json.dumps(params) finally: await client.force_stop() diff --git a/python/test_event_forward_compatibility.py b/python/test_event_forward_compatibility.py index a42b9994fc..65e39a80ba 100644 --- a/python/test_event_forward_compatibility.py +++ b/python/test_event_forward_compatibility.py @@ -15,6 +15,7 @@ from copilot.session_events import ( AttachmentGitHubReferenceType, AutoTier, + AutoTierSwitchFailureReason, Data, ElicitationCompletedAction, ElicitationRequestedMode, @@ -23,6 +24,7 @@ PermissionPromptRequestMemory, PermissionRequestMemory, PermissionRequestMemoryAction, + SessionAutoTierSwitchFailedData, SessionEventType, SessionManagedSettingsResolvedData, SessionResumeData, @@ -71,6 +73,53 @@ def test_auto_tier_lifecycle_events_round_trip(self, event_type, tier): else: assert serialized["autoTier"] == tier + @pytest.mark.parametrize( + "reason", + ["policy_rejected", "request_failed", "setup_failed", "unsupported"], + ) + def test_auto_tier_switch_failed_event_decodes_every_reason(self, reason): + timestamp = "2026-08-28T00:00:00Z" + event = session_event_from_dict( + { + "id": str(uuid4()), + "timestamp": timestamp, + "parentId": None, + "type": "session.auto_tier_switch_failed", + "data": { + "effectiveAutoTier": "balance", + "requestedAutoTier": "intelligence", + "reason": reason, + }, + } + ) + assert isinstance(event.data, SessionAutoTierSwitchFailedData) + assert event.data.reason == AutoTierSwitchFailureReason(reason) + assert event.data.effective_auto_tier == AutoTier.BALANCE + assert event.data.requested_auto_tier == AutoTier.INTELLIGENCE + + def test_auto_tier_switch_failed_event_allows_null_requested_tier(self): + # A null requested tier means the attempt to return to provider-default + # Auto routing is what failed. + timestamp = "2026-08-28T00:00:00Z" + event = session_event_from_dict( + { + "id": str(uuid4()), + "timestamp": timestamp, + "parentId": None, + "type": "session.auto_tier_switch_failed", + "data": { + "effectiveAutoTier": "efficiency", + "requestedAutoTier": None, + "reason": "unsupported", + }, + } + ) + assert isinstance(event.data, SessionAutoTierSwitchFailedData) + assert event.data.requested_auto_tier is None + assert event.data.effective_auto_tier == AutoTier.EFFICIENCY + serialized = session_event_to_dict(event)["data"] + assert serialized["requestedAutoTier"] is None + def test_session_usage_info_is_recognized(self): """The session.usage_info event type should be in the enum.""" assert SessionEventType.SESSION_USAGE_INFO.value == "session.usage_info" diff --git a/rust/README.md b/rust/README.md index a561e6da09..72f9571e70 100644 --- a/rust/README.md +++ b/rust/README.md @@ -380,9 +380,30 @@ let config = SessionConfig::default() The same options work with `ResumeSessionConfig::with_capi` and can be combined with `with_enable_web_socket_responses(false)`. The SDK omits an unset tier: the runtime chooses its default on create and preserves the persisted/current -tier on resume. An explicit tier overrides the persisted tier on cold resume; -the runtime rejects a conflicting tier when the session is already resident -in memory. The SDK does not choose a default or manage tier persistence. +tier on resume. An explicit tier overrides the persisted tier on cold resume. On +resident resume, a different tier requests a safe switch applied after the +resume succeeds; it cannot change a turn that is already in flight. The SDK does not choose a default or manage tier persistence. + +### Changing the Auto tier during a session + +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. + +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. + +```rust,ignore +use github_copilot_sdk::{AutoTier, ModelSwitchAutoTierStatus}; + +let result = session.set_auto_tier(Some(AutoTier::Intelligence)).await?; +if result.status == ModelSwitchAutoTierStatus::Pending { + // Accepted, but not yet in effect. +} + +// Return to the provider's default Auto routing. +session.set_auto_tier(None).await?; +``` + +`set_model` accepts the same preference through `SetModelOptions::with_auto_tier`, which stages the tier atomically with selecting `auto`. Use `with_reset_auto_tier` instead to return to provider-default routing. + See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) for the lifecycle rules. diff --git a/rust/src/session.rs b/rust/src/session.rs index 029e640948..7cf207d104 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -14,8 +14,9 @@ use tracing::{Instrument, error, warn}; use crate::canvas::CanvasHandler; use crate::generated::api_types::{ - LogRequest, ModelSwitchToRequest, OpenCanvasInstance, PermissionDecisionRequest, - RegisterEventInterestParams, ToolsGetCurrentMetadataResult, rpc_methods, + LogRequest, ModelSwitchAutoTierRequest, ModelSwitchAutoTierResult, ModelSwitchToRequest, + OpenCanvasInstance, PermissionDecisionRequest, RegisterEventInterestParams, + ToolsGetCurrentMetadataResult, rpc_methods, }; use crate::generated::session_events::{ CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData, McpOauthRequiredData, @@ -32,12 +33,12 @@ use crate::session_fs::SessionFsProvider; use crate::trace_context::inject_trace_context; use crate::transforms::SystemMessageTransform; use crate::types::{ - CommandContext, CommandDefinition, CommandHandler, CreateSessionResult, ElicitationRequest, - ElicitationResult, ExitPlanModeData, GetMessagesResponse, MessageOptions, - PermissionRequestData, RequestId, ResumeSessionConfig, ResumeSessionResult, SectionOverride, - SessionCapabilities, SessionConfig, SessionEvent, SessionId, SetModelOptions, - SystemMessageConfig, ToolInvocation, ToolResult, ToolResultExpanded, TraceContext, - UiInputOptions, ensure_attachment_display_names, + AutoTier, AutoTierPreference, CommandContext, CommandDefinition, CommandHandler, + CreateSessionResult, ElicitationRequest, ElicitationResult, ExitPlanModeData, + GetMessagesResponse, MessageOptions, PermissionRequestData, RequestId, ResumeSessionConfig, + ResumeSessionResult, SectionOverride, SessionCapabilities, SessionConfig, SessionEvent, + SessionId, SetModelOptions, SystemMessageConfig, ToolInvocation, ToolResult, + ToolResultExpanded, TraceContext, UiInputOptions, ensure_attachment_display_names, }; use crate::{ Client, Error, ErrorKind, JsonRpcResponse, SessionErrorKind, SessionEventNotification, @@ -548,8 +549,12 @@ impl Session { /// Pass `None` for `opts` if no extra configuration is needed. pub async fn set_model(&self, model: &str, opts: Option) -> Result<(), Error> { let opts = opts.unwrap_or_default(); + let auto_tier = opts.auto_tier.clone(); let request = ModelSwitchToRequest { - auto_tier: None, + auto_tier: match &auto_tier { + Some(AutoTierPreference::Tier(tier)) => Some(tier.clone()), + _ => None, + }, compaction_decision: None, context_tier: opts.context_tier, defer_if_model_change_queued: None, @@ -565,10 +570,65 @@ impl Session { source: None, verbosity: None, }; + + if matches!(auto_tier, Some(AutoTierPreference::Reset)) { + // The generated request skips a `None` tier, which the runtime reads + // as "leave the preference alone" rather than "use provider-default + // routing", so send an explicit null instead. + let mut wire_params = serde_json::to_value(request)?; + wire_params["sessionId"] = serde_json::Value::String(self.id.to_string()); + wire_params["autoTier"] = serde_json::Value::Null; + self.client + .call("session.model.switchTo", Some(wire_params)) + .await?; + return Ok(()); + } + self.rpc().model().switch_to(request).await?; Ok(()) } + /// 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. A + /// [`ModelSwitchAutoTierStatus::Pending`] status therefore confirms that the + /// request was accepted, not that it took effect. + /// + /// Watch for the outcome through the `session.model_change` event on + /// success, or the ephemeral `session.auto_tier_switch_failed` event on + /// failure. You can also read the current committed and in-flight state at + /// any time through `session.rpc().model().get_current()`. + /// + /// Only the most recent request survives: issuing a new request replaces any + /// earlier one that has not yet been claimed by a turn. + /// + /// Pass `None` to return to the provider's default Auto routing. + /// + /// **Experimental.** Part of an experimental Auto routing surface and may + /// change or be removed in a future release. + /// + /// # Cancel safety + /// + /// **Cancel-safe.** Single `session.model.switchAutoTier` RPC; the + /// underlying [`Client::call`](crate::Client::call) is cancel-safe via the + /// writer-actor. + /// + /// [`ModelSwitchAutoTierStatus::Pending`]: crate::generated::api_types::ModelSwitchAutoTierStatus::Pending + pub async fn set_auto_tier( + &self, + auto_tier: Option, + ) -> Result { + self.rpc() + .model() + .switch_auto_tier(ModelSwitchAutoTierRequest { + auto_tier, + source: None, + }) + .await + } + /// Disconnect this session from the CLI. /// /// Sends the `session.destroy` RPC, stops the event loop, and unregisters diff --git a/rust/src/types.rs b/rust/src/types.rs index ee3ac3df26..32b504e336 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -21,6 +21,8 @@ pub use crate::copilot_request_handler::{ CopilotWebSocketResponse, WebSocketTransform, forward_http, }; use crate::generated::api_types::{CurrentToolMetadata, OpenCanvasInstance}; +/// Acknowledgement and Auto preference snapshot returned by an Auto tier switch. +pub use crate::generated::api_types::{ModelSwitchAutoTierResult, ModelSwitchAutoTierStatus}; /// Routing tier for the `auto` model with Auto mode V2. pub use crate::generated::session_events::AutoTier; use crate::generated::session_events::ReasoningSummary; @@ -1422,10 +1424,13 @@ pub struct CapiSessionOptions { /// Routing tier, meaningful only with model `auto` (Auto mode V2). /// Requires a runtime version that supports `capi.autoTier`. /// - /// When omitted, the runtime chooses its default on create and preserves - /// the persisted or current tier on resume. An explicit tier overrides the - /// persisted tier on cold resume; the runtime rejects a conflicting tier - /// when resuming a session already resident in memory. + /// When omitted, the runtime chooses its default on create and restores + /// the last committed tier on cold resume. On resident resume, a different + /// tier requests a safe switch that takes effect after resume succeeds and + /// never disturbs a turn that is already running. + /// + /// To change the preference on a live session, use + /// [`Session::set_auto_tier`](crate::session::Session::set_auto_tier). #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_tier: Option, @@ -4791,6 +4796,30 @@ pub struct SetModelOptions { /// fields set on the override are applied; the rest fall back to the /// runtime-resolved values for the model. pub model_capabilities: Option, + /// Auto routing preference to stage atomically with selecting the `auto` + /// model. + /// + /// Leave as `None` to leave the current preference alone. The runtime + /// rejects this option when the model is anything other than `auto`; use + /// [`Session::set_auto_tier`](crate::session::Session::set_auto_tier) to + /// change the preference without changing the selected model. + pub auto_tier: Option, +} + +/// Auto routing preference requested alongside a model switch. +/// +/// **Experimental.** Part of an experimental Auto routing surface and may change +/// or be removed in a future release. +/// +/// This is a three-state choice. Leaving [`SetModelOptions::auto_tier`] as +/// `None` leaves the current preference alone, which is different from +/// [`AutoTierPreference::Reset`], which actively resets it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AutoTierPreference { + /// Route using a specific tier. + Tier(AutoTier), + /// Return to the provider's default Auto routing. + Reset, } impl SetModelOptions { @@ -4820,6 +4849,19 @@ impl SetModelOptions { self.model_capabilities = Some(caps); self } + + /// Set [`auto_tier`](Self::auto_tier) to a specific routing tier. + pub fn with_auto_tier(mut self, tier: AutoTier) -> Self { + self.auto_tier = Some(AutoTierPreference::Tier(tier)); + self + } + + /// Set [`auto_tier`](Self::auto_tier) to return to the provider's default + /// Auto routing. + pub fn with_reset_auto_tier(mut self) -> Self { + self.auto_tier = Some(AutoTierPreference::Reset); + self + } } /// Response from the top-level `ping` RPC. diff --git a/rust/tests/api_types_test.rs b/rust/tests/api_types_test.rs index 942c5dab5b..9ddd450e3f 100644 --- a/rust/tests/api_types_test.rs +++ b/rust/tests/api_types_test.rs @@ -3,15 +3,16 @@ #![allow(clippy::unwrap_used)] -use github_copilot_sdk::AutoTier; use github_copilot_sdk::rpc::{ Extension, ExtensionList, ExtensionSource, ExtensionStatus, ExtensionsDisableRequest, - ExtensionsEnableRequest, FleetStartRequest, FleetStartResult, QueuePendingItems, - QueuePendingItemsKind, SendAgentMode, TasksStartAgentRequest, + ExtensionsEnableRequest, FleetStartRequest, FleetStartResult, ModelSwitchAutoTierRequest, + ModelSwitchAutoTierResult, ModelSwitchAutoTierStatus, QueuePendingItems, QueuePendingItemsKind, + SendAgentMode, TasksStartAgentRequest, }; use github_copilot_sdk::session_events::{ PermissionRequest, PermissionRequestedData, SessionEventData, TypedSessionEvent, }; +use github_copilot_sdk::{AutoTier, AutoTierPreference, SetModelOptions}; #[test] fn session_events_deserialize_auto_tier() { @@ -197,3 +198,65 @@ fn running_extension(id: &str, name: &str) -> Extension { status: ExtensionStatus::Running, } } + +#[test] +fn switch_auto_tier_request_serializes_explicit_null_tier() { + // `autoTier` is a required field whose null value means "use provider-default + // routing", so it must survive serialization rather than being skipped. + let request = ModelSwitchAutoTierRequest { + auto_tier: None, + source: None, + }; + let wire = serde_json::to_value(&request).unwrap(); + + assert_eq!(wire.get("autoTier"), Some(&serde_json::Value::Null)); + assert!(wire.get("source").is_none()); +} + +#[test] +fn switch_auto_tier_request_serializes_each_tier() { + for (tier, expected) in [ + (AutoTier::Efficiency, "efficiency"), + (AutoTier::Balance, "balance"), + (AutoTier::Intelligence, "intelligence"), + ] { + let request = ModelSwitchAutoTierRequest { + auto_tier: Some(tier), + source: None, + }; + let wire = serde_json::to_value(&request).unwrap(); + assert_eq!(wire["autoTier"], serde_json::json!(expected)); + } +} + +#[test] +fn switch_auto_tier_result_deserializes_full_snapshot() { + let result: ModelSwitchAutoTierResult = serde_json::from_value(serde_json::json!({ + "status": "pending", + "effectiveAutoTier": "balance", + "pendingAutoTier": "intelligence", + "activatingAutoTier": null, + "supersededAutoTier": null + })) + .unwrap(); + + assert_eq!(result.status, ModelSwitchAutoTierStatus::Pending); + assert_eq!(result.effective_auto_tier, Some(AutoTier::Balance)); + assert_eq!(result.pending_auto_tier, Some(AutoTier::Intelligence)); + assert_eq!(result.activating_auto_tier, None); +} + +#[test] +fn set_model_options_distinguishes_unset_tier_from_reset() { + let untouched = SetModelOptions::default(); + assert_eq!(untouched.auto_tier, None); + + let explicit = SetModelOptions::default().with_auto_tier(AutoTier::Intelligence); + assert_eq!( + explicit.auto_tier, + Some(AutoTierPreference::Tier(AutoTier::Intelligence)) + ); + + let cleared = SetModelOptions::default().with_reset_auto_tier(); + assert_eq!(cleared.auto_tier, Some(AutoTierPreference::Reset)); +} diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index 03723dfb1b..eb4e750990 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -5,6 +5,8 @@ mod abort; #[path = "e2e/ask_user.rs"] mod ask_user; +#[path = "e2e/auto_tier.rs"] +mod auto_tier; #[path = "e2e/builtin_tools.rs"] mod builtin_tools; #[path = "e2e/byok_bearer_token_provider.rs"] diff --git a/rust/tests/e2e/auto_tier.rs b/rust/tests/e2e/auto_tier.rs new file mode 100644 index 0000000000..85c70dd460 --- /dev/null +++ b/rust/tests/e2e/auto_tier.rs @@ -0,0 +1,140 @@ +use github_copilot_sdk::SetModelOptions; +use github_copilot_sdk::rpc::ModelSwitchAutoTierStatus; +use github_copilot_sdk::session::Session; +use github_copilot_sdk::session_events::AutoTier; + +use super::support::with_dedicated_e2e_context; + +const MODEL_ID: &str = "auto"; + +/// End-to-end coverage for staging and resetting an Auto routing preference +/// (snapshot category "auto_tier"). +/// +/// The runtime stages an Auto routing preference instead of applying it immediately: a +/// request stays unclaimed until a later turn using the `auto` model mints a usable model +/// and token pair. These tests observe that staged state through `model().get_current()`, +/// so they assert what the runtime actually recorded rather than what the SDK serialized. +async fn pending_auto_tier(session: &Session) -> Option { + session + .rpc() + .model() + .get_current() + .await + .expect("get current model") + .pending_auto_tier +} + +#[tokio::test] +async fn should_stage_and_reset_auto_tier_preference() { + with_dedicated_e2e_context( + "auto_tier", + "should_stage_and_reset_auto_tier_preference", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_model(MODEL_ID)) + .await + .expect("create session"); + + assert_eq!(pending_auto_tier(&session).await, None); + + let staged = session + .set_auto_tier(Some(AutoTier::Efficiency)) + .await + .expect("stage efficiency"); + assert_eq!(staged.status, ModelSwitchAutoTierStatus::Pending); + assert_eq!(staged.pending_auto_tier, Some(AutoTier::Efficiency)); + assert_eq!( + pending_auto_tier(&session).await, + Some(AutoTier::Efficiency) + ); + + // A second request replaces the first and reports the one it displaced. + let superseded = session + .set_auto_tier(Some(AutoTier::Intelligence)) + .await + .expect("stage intelligence"); + assert_eq!(superseded.status, ModelSwitchAutoTierStatus::Pending); + assert_eq!(superseded.pending_auto_tier, Some(AutoTier::Intelligence)); + assert_eq!(superseded.superseded_auto_tier, Some(AutoTier::Efficiency)); + assert_eq!( + pending_auto_tier(&session).await, + Some(AutoTier::Intelligence) + ); + + // `None` returns the session to provider-default routing. The status is + // `Unchanged` because provider-default was already the committed + // preference; the request's effect is cancelling the staged one. + let reset = session.set_auto_tier(None).await.expect("reset tier"); + assert_eq!(reset.status, ModelSwitchAutoTierStatus::Unchanged); + assert_eq!(reset.superseded_auto_tier, Some(AutoTier::Intelligence)); + assert_eq!(pending_auto_tier(&session).await, None); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_preserve_auto_tier_when_set_model_omits_it() { + with_dedicated_e2e_context( + "auto_tier", + "should_preserve_auto_tier_when_set_model_omits_it", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_model(MODEL_ID)) + .await + .expect("create session"); + + session + .set_auto_tier(Some(AutoTier::Balance)) + .await + .expect("stage balance"); + assert_eq!(pending_auto_tier(&session).await, Some(AutoTier::Balance)); + + // Omitting the preference leaves the staged one alone. + session + .set_model(MODEL_ID, None) + .await + .expect("set model without a tier"); + assert_eq!(pending_auto_tier(&session).await, Some(AutoTier::Balance)); + + // Supplying a tier replaces it. + session + .set_model( + MODEL_ID, + Some(SetModelOptions::default().with_auto_tier(AutoTier::Intelligence)), + ) + .await + .expect("set model with a tier"); + assert_eq!( + pending_auto_tier(&session).await, + Some(AutoTier::Intelligence) + ); + + // Requesting a reset clears it. Omission, a tier, and a reset are three + // distinct outcomes, which `AutoTierPreference` makes explicit. + session + .set_model( + MODEL_ID, + Some(SetModelOptions::default().with_reset_auto_tier()), + ) + .await + .expect("set model with a reset"); + assert_eq!(pending_auto_tier(&session).await, None); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/test/snapshots/auto_tier/should_preserve_auto_tier_when_set_model_omits_it.yaml b/test/snapshots/auto_tier/should_preserve_auto_tier_when_set_model_omits_it.yaml new file mode 100644 index 0000000000..b287603f41 --- /dev/null +++ b/test/snapshots/auto_tier/should_preserve_auto_tier_when_set_model_omits_it.yaml @@ -0,0 +1,4 @@ +models: + - auto + - claude-sonnet-5 +conversations: [] diff --git a/test/snapshots/auto_tier/should_stage_and_reset_auto_tier_preference.yaml b/test/snapshots/auto_tier/should_stage_and_reset_auto_tier_preference.yaml new file mode 100644 index 0000000000..b287603f41 --- /dev/null +++ b/test/snapshots/auto_tier/should_stage_and_reset_auto_tier_preference.yaml @@ -0,0 +1,4 @@ +models: + - auto + - claude-sonnet-5 +conversations: []