From 5c7ac98d31cc3ee5a1e47235e716275bb37cfba0 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Thu, 3 Sep 2026 09:32:26 -0400 Subject: [PATCH 1/7] Expose Auto tier switching across all six SDKs The runtime now accepts an Auto routing preference change on a live session through `session.model.switchAutoTier`, and reports the authoritative committed, pending, and activating preferences through `session.model.getCurrent`. Without SDK support, integrators could only choose a tier at session creation or resume and had no way to change it or observe whether a change took effect. Each SDK gains two capabilities: * `setAutoTier` changes the routing preference without changing the selected model. * The model switch options gain an Auto tier field, which stages a tier atomically with selecting `auto`. The runtime distinguishes an explicit null tier, meaning "return to provider-default routing," from an absent one, meaning "leave the preference alone." Python, Go, .NET, Java, and Rust generated wrappers drop null properties, so those SDKs build the request payload directly where a null must survive; each bypass carries a comment explaining why. Node.js needed no workaround. The tri-state choice is expressed idiomatically per language: `undefined`/tier/`null` in Node.js, an `_UNSET` sentinel in Python, a `ClearAutoTier` flag validated as mutually exclusive in Go, .NET, and Java, and an `AutoTierPreference` enum in Rust. Tests cover the wire payloads for both methods, including the explicit null case, and decoding of the ephemeral `session.auto_tier_switch_failed` event across all four failure reasons plus the null requested-tier case. Documentation previously stated that a resident resume rejects a different tier. The authoritative schema now says the runtime requests a safe switch applied after the resume succeeds, which cannot change a turn already in flight. This corrects that text in all six SDKs and in the session persistence guide. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2aad36e9-e078-4b9c-afa1-0c7cd2c71f74 --- docs/features/session-persistence.md | 33 ++- dotnet/README.md | 21 ++ dotnet/src/Session.cs | 104 ++++++++ dotnet/src/Types.cs | 26 +- .../test/Unit/ClientSessionLifetimeTests.cs | 114 +++++++++ .../Unit/SessionEventSerializationTests.cs | 61 +++++ go/README.md | 24 ++ go/session.go | 69 ++++++ go/session_event_serialization_test.go | 67 +++++ go/session_test.go | 100 +++++++- go/types.go | 10 +- java/README.md | 24 +- .../com/github/copilot/CopilotSession.java | 145 +++++++++-- .../copilot/rpc/CapiSessionOptions.java | 5 +- .../github/copilot/rpc/SetModelOptions.java | 170 +++++++++++++ .../copilot/SessionAutoTierEventTest.java | 37 +++ .../copilot/SessionAutoTierSwitchTest.java | 228 ++++++++++++++++++ nodejs/README.md | 26 ++ nodejs/src/index.ts | 3 + nodejs/src/session.ts | 46 ++++ nodejs/src/types.ts | 15 +- nodejs/test/client.test.ts | 111 +++++++++ nodejs/test/session-event-types.test.ts | 43 ++++ python/README.md | 19 ++ python/copilot/__init__.py | 10 + python/copilot/client.py | 15 +- python/copilot/session.py | 97 +++++++- python/test_client.py | 99 ++++++++ python/test_event_forward_compatibility.py | 49 ++++ rust/README.md | 27 ++- rust/src/session.rs | 75 +++++- rust/src/types.rs | 47 +++- rust/tests/api_types_test.rs | 69 +++++- 33 files changed, 1914 insertions(+), 75 deletions(-) create mode 100644 java/sdk/src/main/java/com/github/copilot/rpc/SetModelOptions.java create mode 100644 java/sdk/src/test/java/com/github/copilot/SessionAutoTierSwitchTest.java diff --git a/docs/features/session-persistence.md b/docs/features/session-persistence.md index 9b0aa9434c..b166b92610 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,42 @@ 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. + +```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)` | + ### Example: changing model on resume ```typescript diff --git a/dotnet/README.md b/dotnet/README.md index 23a78030b4..e840ee0e6e 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 `ClearAutoTier` 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..12033a34e6 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -1927,8 +1927,34 @@ public async Task SetModelAsync(string model, SetModelOptions options, Cancellat ArgumentNullException.ThrowIfNull(model); ThrowIfDisposed(); + if (options.AutoTier is not null && options.ClearAutoTier) + { + throw new ArgumentException( + $"{nameof(SetModelOptions.AutoTier)} and {nameof(SetModelOptions.ClearAutoTier)} are mutually exclusive.", + nameof(options)); + } + + if (options.ClearAutoTier) + { + // The generated request omits a null tier, which the runtime reads as + // "leave the preference alone" rather than "use provider-default + // routing", so send a request that always writes the null. + var request = new ClearAutoTierModelSwitchToRequest + { + SessionId = SessionId, + ModelId = model, + ReasoningEffort = options.ReasoningEffort, + ReasoningSummary = options.ReasoningSummary, + ModelCapabilities = options.ModelCapabilities, + ContextTier = options.ContextTier, + }; + await CopilotClient.InvokeRpcAsync(Rpc, "session.model.switchTo", [request], cancellationToken); + return; + } + await Rpc.Model.SwitchToAsync( modelId: model, + autoTier: options.AutoTier, reasoningEffort: options.ReasoningEffort, reasoningSummary: options.ReasoningSummary, verbosity: null, @@ -1938,6 +1964,82 @@ 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(); + + // `autoTier` is a required field whose null value means "use provider-default + // routing", so this cannot go through the generated wrapper, which omits nulls. + var request = new SwitchAutoTierRequest { SessionId = SessionId, AutoTier = autoTier }; + return await CopilotClient.InvokeRpcAsync( + Rpc, "session.model.switchAutoTier", [request], cancellationToken); + } + + internal sealed class SwitchAutoTierRequest + { + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("autoTier")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public AutoTier? AutoTier { get; set; } + } + + internal sealed class ClearAutoTierModelSwitchToRequest + { + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("modelId")] + public string ModelId { get; set; } = string.Empty; + + [JsonPropertyName("autoTier")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public AutoTier? AutoTier { get; set; } + + [JsonPropertyName("reasoningEffort")] + public string? ReasoningEffort { get; set; } + + [JsonPropertyName("reasoningSummary")] + public ReasoningSummary? ReasoningSummary { get; set; } + + [JsonPropertyName("modelCapabilities")] + public ModelCapabilitiesOverride? ModelCapabilities { get; set; } + + [JsonPropertyName("contextTier")] + public ContextTier? ContextTier { get; set; } + } + /// /// Changes the model for this session. /// @@ -2130,6 +2232,8 @@ internal void ThrowIfDisposed() [JsonSerializable(typeof(SendMessageRequest))] [JsonSerializable(typeof(SendMessageResponse))] [JsonSerializable(typeof(SessionAbortRequest))] + [JsonSerializable(typeof(SwitchAutoTierRequest))] + [JsonSerializable(typeof(ClearAutoTierModelSwitchToRequest))] [JsonSerializable(typeof(SessionDestroyRequest))] [JsonSerializable(typeof(SessionEndHookInput))] [JsonSerializable(typeof(SessionEndHookOutput))] diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 3af7ea7f12..82f96efcd1 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,24 @@ 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. + /// + public AutoTier? AutoTier { get; set; } + + /// + /// Returns to the provider's default Auto routing as part of this switch. + /// Mutually exclusive with . + /// + public bool ClearAutoTier { get; set; } } /// diff --git a/dotnet/test/Unit/ClientSessionLifetimeTests.cs b/dotnet/test/Unit/ClientSessionLifetimeTests.cs index 204626a6f5..1ea367a683 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 { ClearAutoTier = 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, ClearAutoTier = 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("pending", result.Status.ToString()); + 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..8fe3d59a92 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 `ClearAutoTier` 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/session.go b/go/session.go index 4c31c01e8c..a61c735f4e 100644 --- a/go/session.go +++ b/go/session.go @@ -1841,6 +1841,16 @@ 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. + AutoTier *AutoTier + // ClearAutoTier returns to the provider's default Auto routing as part of + // this switch. It is mutually exclusive with AutoTier. + ClearAutoTier bool } // SetModel changes the model for this session. @@ -1857,10 +1867,37 @@ 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.ClearAutoTier { + return fmt.Errorf("failed to set model: AutoTier and ClearAutoTier are mutually exclusive") + } params.ReasoningEffort = opts.ReasoningEffort params.ReasoningSummary = opts.ReasoningSummary params.ContextTier = opts.ContextTier params.ModelCapabilities = opts.ModelCapabilities + params.AutoTier = opts.AutoTier + + if opts.ClearAutoTier { + // The generated request omits a nil AutoTier, which the runtime + // reads as "leave the preference alone" rather than "use default + // routing", so send an explicit null instead. + req := map[string]any{"sessionId": s.SessionID, "modelId": model, "autoTier": nil} + if opts.ReasoningEffort != nil { + req["reasoningEffort"] = *opts.ReasoningEffort + } + if opts.ReasoningSummary != nil { + req["reasoningSummary"] = *opts.ReasoningSummary + } + if opts.ContextTier != nil { + req["contextTier"] = *opts.ContextTier + } + if opts.ModelCapabilities != nil { + req["modelCapabilities"] = *opts.ModelCapabilities + } + if _, err := s.client.Request(ctx, "session.model.switchTo", req); err != nil { + return fmt.Errorf("failed to set model: %w", err) + } + return nil + } } _, err := s.RPC.Model.SwitchTo(ctx, params) if err != nil { @@ -1870,6 +1907,38 @@ 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. +// +// 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..ba837c3b30 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{ClearAutoTier: 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, + ClearAutoTier: true, + }) + if err == nil { + t.Fatal("expected an error when AutoTier and ClearAutoTier 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..0425a40b41 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 `setClearAutoTier(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..3055f9c5a7 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,132 @@ 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 = new ObjectMapper().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").setClearAutoTier(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.isClearAutoTier()) { + throw new IllegalArgumentException( + "setModel cannot combine an explicit autoTier with clearAutoTier; 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.isClearAutoTier()) { + 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 = new ObjectMapper().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 = new ObjectMapper().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..372ce0be4a --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/rpc/SetModelOptions.java @@ -0,0 +1,170 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +/** + * Optional settings for a model switch. + *

+ * All setter methods return {@code this} for method chaining. Every 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 clearAutoTier; + + /** + * 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 + */ + 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 #setClearAutoTier(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 + */ + 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 + */ + public boolean isClearAutoTier() { + return clearAutoTier; + } + + /** + * 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 clearAutoTier + * {@code true} to return to provider-default Auto routing + * @return this options object for method chaining + */ + public SetModelOptions setClearAutoTier(boolean clearAutoTier) { + this.clearAutoTier = clearAutoTier; + return this; + } +} 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..8d1022db57 --- /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").setClearAutoTier(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).setClearAutoTier(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..77e13676e5 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,52 @@ 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. + */ + 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 + * + * @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/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..ab38279d02 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -39,6 +39,7 @@ LogRequest, MCPOauthHandlePendingRequest, MCPOauthPendingRequestResponse, + ModelSwitchAutoTierResult, ModelSwitchToRequest, PermissionDecision, PermissionDecisionApproveOnce, @@ -174,9 +175,25 @@ 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() + + class SessionFsCapabilities(TypedDict, total=False): sqlite: bool @@ -3050,6 +3067,7 @@ async def set_model( reasoning_summary: ReasoningSummary | None = None, context_tier: ContextTier | None = None, model_capabilities: ModelCapabilitiesOverride | None = None, + auto_tier: AutoTier | None | _Unset = _UNSET, ) -> None: """ Change the model for this session. @@ -3067,6 +3085,12 @@ 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: 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 +3098,74 @@ 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 + params["sessionId"] = self.session_id + await self._client.request("session.model.switchTo", params) + + async def set_auto_tier(self, auto_tier: AutoTier | None) -> ModelSwitchAutoTierResult: + """ + 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.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}, ) ) diff --git a/python/test_client.py b/python/test_client.py index e62154e247..747fa186d1 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -21,6 +21,7 @@ ExtensionInfo, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, + ModelSwitchAutoTierStatus, RuntimeConnection, StdioRuntimeConnection, define_tool, @@ -38,6 +39,7 @@ ModelLimits, ModelSupports, ) +from copilot.generated.rpc import AutoTier as AutoTierEnum from copilot.session import PermissionHandler from copilot.session_events import ( McpOauthRequestReason, @@ -2585,6 +2587,103 @@ 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() 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..3bb2a74937 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 +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_provider_default_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..f0e1ef019c 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,62 @@ impl Session { source: None, verbosity: None, }; + + if matches!(auto_tier, Some(AutoTierPreference::ProviderDefault)) { + // 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. + /// + /// # 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..c97682b696 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,27 @@ 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. +/// +/// This is a three-state choice. Leaving [`SetModelOptions::auto_tier`] as +/// `None` leaves the current preference alone, which is different from +/// [`AutoTierPreference::ProviderDefault`], which actively clears it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AutoTierPreference { + /// Route using a specific tier. + Tier(AutoTier), + /// Return to the provider's default Auto routing. + ProviderDefault, } impl SetModelOptions { @@ -4820,6 +4846,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_provider_default_auto_tier(mut self) -> Self { + self.auto_tier = Some(AutoTierPreference::ProviderDefault); + 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..cc6bf2f30e 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_provider_default() { + 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_provider_default_auto_tier(); + assert_eq!(cleared.auto_tier, Some(AutoTierPreference::ProviderDefault)); +} From d7fea2c4a17a42de543e766a32d505908df3db6d Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Thu, 3 Sep 2026 11:56:53 -0400 Subject: [PATCH 2/7] Address pre-review findings on Auto tier switching Reviewing the change against the standards maintainers have applied to earlier SDK pull requests surfaced four issues worth fixing before opening the pull request. Python could not accept the enum it hands back. `set_auto_tier` and `set_model` were typed for the `AutoTier` string literal, but results and events carry the generated `AutoTier` enum. Feeding that value back raised `TypeError: Object of type AutoTier is not JSON serializable`, because the JSON-RPC encoder calls `json.dumps` with no enum support. Both methods now accept either representation and normalize to the wire value. Added a regression test. .NET and Go hand-copied request fields. Both built a second, partial copy of the model-switch payload so an explicit null tier would survive serialization. Each copy listed only a subset of the generated request's fields, so a new field on the schema would have been silently dropped on the clear path. Both now serialize the generated request and write the null back, matching what Rust, Java, and Python already did. This also removes two hand-written request types and their serializer registrations from the .NET SDK. Java allocated raw `ObjectMapper` instances instead of using the shared `MAPPER`, which is configured to match the JSON-RPC encoder. A differently configured mapper could produce a different wire shape. The .NET test compared the status enum through `ToString`; it now compares the enum directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2aad36e9-e078-4b9c-afa1-0c7cd2c71f74 --- dotnet/src/Session.cs | 73 ++++++++----------- .../test/Unit/ClientSessionLifetimeTests.cs | 2 +- go/session.go | 24 +++--- .../com/github/copilot/CopilotSession.java | 7 +- python/copilot/session.py | 26 ++++++- python/test_client.py | 33 +++++++++ 6 files changed, 100 insertions(+), 65 deletions(-) diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 12033a34e6..ec8b5dba17 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; @@ -1936,10 +1937,7 @@ public async Task SetModelAsync(string model, SetModelOptions options, Cancellat if (options.ClearAutoTier) { - // The generated request omits a null tier, which the runtime reads as - // "leave the preference alone" rather than "use provider-default - // routing", so send a request that always writes the null. - var request = new ClearAutoTierModelSwitchToRequest + var request = new ModelSwitchToRequest { SessionId = SessionId, ModelId = model, @@ -1948,7 +1946,11 @@ public async Task SetModelAsync(string model, SetModelOptions options, Cancellat ModelCapabilities = options.ModelCapabilities, ContextTier = options.ContextTier, }; - await CopilotClient.InvokeRpcAsync(Rpc, "session.model.switchTo", [request], cancellationToken); + await CopilotClient.InvokeRpcAsync( + Rpc, + "session.model.switchTo", + [WithExplicitNullAutoTier(request, RpcJsonContext.Default.ModelSwitchToRequest)], + cancellationToken); return; } @@ -1998,46 +2000,33 @@ public async Task SetAutoTierAsync(AutoTier? autoTier { ThrowIfDisposed(); - // `autoTier` is a required field whose null value means "use provider-default - // routing", so this cannot go through the generated wrapper, which omits nulls. - var request = new SwitchAutoTierRequest { SessionId = SessionId, AutoTier = autoTier }; - return await CopilotClient.InvokeRpcAsync( - Rpc, "session.model.switchAutoTier", [request], cancellationToken); - } - - internal sealed class SwitchAutoTierRequest - { - [JsonPropertyName("sessionId")] - public string SessionId { get; set; } = string.Empty; + if (autoTier is not null) + { + return await Rpc.Model.SwitchAutoTierAsync(autoTier, cancellationToken: cancellationToken); + } - [JsonPropertyName("autoTier")] - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public AutoTier? AutoTier { get; set; } + var request = new ModelSwitchAutoTierRequest { SessionId = SessionId }; + return await CopilotClient.InvokeRpcAsync( + Rpc, + "session.model.switchAutoTier", + [WithExplicitNullAutoTier(request, RpcJsonContext.Default.ModelSwitchAutoTierRequest)], + cancellationToken); } - internal sealed class ClearAutoTierModelSwitchToRequest + ///

+ /// 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) { - [JsonPropertyName("sessionId")] - public string SessionId { get; set; } = string.Empty; - - [JsonPropertyName("modelId")] - public string ModelId { get; set; } = string.Empty; - - [JsonPropertyName("autoTier")] - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public AutoTier? AutoTier { get; set; } - - [JsonPropertyName("reasoningEffort")] - public string? ReasoningEffort { get; set; } - - [JsonPropertyName("reasoningSummary")] - public ReasoningSummary? ReasoningSummary { get; set; } - - [JsonPropertyName("modelCapabilities")] - public ModelCapabilitiesOverride? ModelCapabilities { get; set; } - - [JsonPropertyName("contextTier")] - public ContextTier? ContextTier { get; set; } + var payload = JsonSerializer.SerializeToNode(request, typeInfo)!.AsObject(); + payload["autoTier"] = null; + return payload; } /// @@ -2232,8 +2221,6 @@ internal void ThrowIfDisposed() [JsonSerializable(typeof(SendMessageRequest))] [JsonSerializable(typeof(SendMessageResponse))] [JsonSerializable(typeof(SessionAbortRequest))] - [JsonSerializable(typeof(SwitchAutoTierRequest))] - [JsonSerializable(typeof(ClearAutoTierModelSwitchToRequest))] [JsonSerializable(typeof(SessionDestroyRequest))] [JsonSerializable(typeof(SessionEndHookInput))] [JsonSerializable(typeof(SessionEndHookOutput))] diff --git a/dotnet/test/Unit/ClientSessionLifetimeTests.cs b/dotnet/test/Unit/ClientSessionLifetimeTests.cs index 1ea367a683..62e7a99e82 100644 --- a/dotnet/test/Unit/ClientSessionLifetimeTests.cs +++ b/dotnet/test/Unit/ClientSessionLifetimeTests.cs @@ -596,7 +596,7 @@ public async Task SetAutoTierAsync_Serializes_Tier_And_Returns_Snapshot() var request = Assert.Single(server.Requests, request => request.Method == "session.model.switchAutoTier"); Assert.Equal("intelligence", request.Params.GetProperty("autoTier").GetString()); - Assert.Equal("pending", result.Status.ToString()); + Assert.Equal(ModelSwitchAutoTierStatus.Pending, result.Status); Assert.Equal(AutoTier.Balance, result.EffectiveAutoTier); } diff --git a/go/session.go b/go/session.go index a61c735f4e..9a69173892 100644 --- a/go/session.go +++ b/go/session.go @@ -4,6 +4,7 @@ package copilot import ( "context" "encoding/json" + "errors" "fmt" "log" "sync" @@ -1868,7 +1869,7 @@ func (s *Session) SetModel(ctx context.Context, model string, opts *SetModelOpti params := &rpc.ModelSwitchToRequest{ModelID: model} if opts != nil { if opts.AutoTier != nil && opts.ClearAutoTier { - return fmt.Errorf("failed to set model: AutoTier and ClearAutoTier are mutually exclusive") + return errors.New("failed to set model: AutoTier and ClearAutoTier are mutually exclusive") } params.ReasoningEffort = opts.ReasoningEffort params.ReasoningSummary = opts.ReasoningSummary @@ -1879,20 +1880,17 @@ func (s *Session) SetModel(ctx context.Context, model string, opts *SetModelOpti if opts.ClearAutoTier { // The generated request omits a nil AutoTier, which the runtime // reads as "leave the preference alone" rather than "use default - // routing", so send an explicit null instead. - req := map[string]any{"sessionId": s.SessionID, "modelId": model, "autoTier": nil} - if opts.ReasoningEffort != nil { - req["reasoningEffort"] = *opts.ReasoningEffort - } - if opts.ReasoningSummary != nil { - req["reasoningSummary"] = *opts.ReasoningSummary - } - if opts.ContextTier != nil { - req["contextTier"] = *opts.ContextTier + // routing", so serialize the request and write the null back. + raw, err := json.Marshal(params) + if err != nil { + return fmt.Errorf("failed to set model: %w", err) } - if opts.ModelCapabilities != nil { - req["modelCapabilities"] = *opts.ModelCapabilities + var req map[string]any + if err := json.Unmarshal(raw, &req); err != nil { + return fmt.Errorf("failed to set model: %w", err) } + req["sessionId"] = s.SessionID + req["autoTier"] = nil if _, err := s.client.Request(ctx, "session.model.switchTo", req); err != nil { return fmt.Errorf("failed to set model: %w", err) } 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 3055f9c5a7..99e1ebc027 100644 --- a/java/sdk/src/main/java/com/github/copilot/CopilotSession.java +++ b/java/sdk/src/main/java/com/github/copilot/CopilotSession.java @@ -2099,8 +2099,7 @@ private static ModelCapabilitiesOverride toGeneratedCapabilities( } ModelCapabilitiesOverrideLimits limits = null; if (modelCapabilities.getLimits() != null) { - limits = new ObjectMapper().convertValue(modelCapabilities.getLimits(), - ModelCapabilitiesOverrideLimits.class); + limits = MAPPER.convertValue(modelCapabilities.getLimits(), ModelCapabilitiesOverrideLimits.class); } return new ModelCapabilitiesOverride(supports, limits); } @@ -2159,7 +2158,7 @@ null, toGeneratedCapabilities(options.getModelCapabilities()), null, null, 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 = new ObjectMapper().valueToTree(params); + ObjectNode payload = MAPPER.valueToTree(params); payload.putNull("autoTier"); return rpc.invoke("session.model.switchTo", payload, Void.class); } @@ -2207,7 +2206,7 @@ public CompletableFuture setAutoTier(com.githu // 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 = new ObjectMapper().valueToTree(params); + ObjectNode payload = MAPPER.valueToTree(params); payload.putNull("autoTier"); return rpc.invoke("session.model.switchAutoTier", payload, SessionModelSwitchAutoTierResult.class); } diff --git a/python/copilot/session.py b/python/copilot/session.py index ab38279d02..39c17dc9e4 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, @@ -194,6 +198,18 @@ def __repr__(self) -> str: _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 @@ -3067,7 +3083,7 @@ async def set_model( reasoning_summary: ReasoningSummary | None = None, context_tier: ContextTier | None = None, model_capabilities: ModelCapabilitiesOverride | None = None, - auto_tier: AutoTier | None | _Unset = _UNSET, + auto_tier: AutoTier | _RpcAutoTier | None | _Unset = _UNSET, ) -> None: """ Change the model for this session. @@ -3122,11 +3138,13 @@ async def set_model( # 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 + 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 | None) -> ModelSwitchAutoTierResult: + async def set_auto_tier( + self, auto_tier: AutoTier | _RpcAutoTier | None + ) -> ModelSwitchAutoTierResult: """ Change the Auto routing preference without changing the selected model. @@ -3165,7 +3183,7 @@ async def set_auto_tier(self, auto_tier: AutoTier | None) -> ModelSwitchAutoTier return ModelSwitchAutoTierResult.from_dict( await self._client.request( "session.model.switchAutoTier", - {"sessionId": self.session_id, "autoTier": auto_tier}, + {"sessionId": self.session_id, "autoTier": _auto_tier_to_wire(auto_tier)}, ) ) diff --git a/python/test_client.py b/python/test_client.py index 747fa186d1..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 @@ -2687,6 +2688,38 @@ async def mock_request(method, params, **kwargs): 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() + class TestMcpOAuthTokenStorage: @pytest.mark.asyncio From 3ab56b1429c8dbd195468aada50efa4f6443d769 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Thu, 3 Sep 2026 12:04:34 -0400 Subject: [PATCH 3/7] Mark the Auto tier surface experimental in every SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .NET and Java implementations carried [Experimental] and @CopilotExperimental, but the Node.js, Python, Go, and Rust equivalents carried no marker. That asymmetry is the exact gap maintainers have flagged before: C# gets the annotation and the other languages are left without the corresponding doc annotation. Auto tier routing is still moving — the `fast` tier is not yet exposed and the runtime contract may shift — so the surface is marked experimental consistently in all six SDKs, each using the convention already established in that language. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2aad36e9-e078-4b9c-afa1-0c7cd2c71f74 --- go/session.go | 9 +++++++++ nodejs/src/session.ts | 6 ++++++ python/copilot/session.py | 3 +++ rust/src/session.rs | 3 +++ rust/src/types.rs | 3 +++ 5 files changed, 24 insertions(+) diff --git a/go/session.go b/go/session.go index 9a69173892..f89a0d3748 100644 --- a/go/session.go +++ b/go/session.go @@ -1848,9 +1848,15 @@ type SetModelOptions struct { // 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 // ClearAutoTier returns to the provider's default Auto routing as part of // this switch. It is mutually exclusive with AutoTier. + // + // Experimental: ClearAutoTier is part of an experimental Auto routing surface + // and may change or be removed. ClearAutoTier bool } @@ -1921,6 +1927,9 @@ func (s *Session) SetModel(ctx context.Context, model string, opts *SetModelOpti // // 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 diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 77e13676e5..0dfff88bc7 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -2086,6 +2086,9 @@ export class CopilotSession { * 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; } @@ -2113,6 +2116,9 @@ export class CopilotSession { * 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"); diff --git a/python/copilot/session.py b/python/copilot/session.py index 39c17dc9e4..964ed04c72 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -3148,6 +3148,9 @@ async def set_auto_tier( """ 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 diff --git a/rust/src/session.rs b/rust/src/session.rs index f0e1ef019c..2efc9ea6fd 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -606,6 +606,9 @@ impl Session { /// /// 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 diff --git a/rust/src/types.rs b/rust/src/types.rs index c97682b696..5e2cfab3e9 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -4808,6 +4808,9 @@ pub struct SetModelOptions { /// 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::ProviderDefault`], which actively clears it. From 8fb91e616acdba7d4c88a9c1aba039de92f994f5 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Thu, 3 Sep 2026 12:35:56 -0400 Subject: [PATCH 4/7] Use one vocabulary for resetting the Auto tier The SDKs described the same idea three different ways: Rust called it `AutoTierPreference::ProviderDefault`, while Go, .NET, and Java called it `ClearAutoTier`. Cross-SDK consistency is the standard maintainers apply most often, and a reader comparing two SDKs had no way to tell these were the same operation. Everything now uses "reset": `ResetAutoTier` in Go and .NET, `setResetAutoTier` in Java, and `AutoTierPreference::Reset` with `with_reset_auto_tier` in Rust. The underlying shapes stay language-idiomatic. Node.js and Python express all three states natively, because `null` and `None` are distinguishable from an omitted argument. Rust uses a sum type. Go, .NET, and Java cannot make that distinction in a single value, so they keep a separate reset option. Forcing Rust's enum onto Go would mean exported constructor functions in place of the pointer-and-flag pattern Go already uses for "unset versus explicit zero", which trades a cross-language inconsistency for a within-language one. Documented the trade-off and added a per-SDK table for staging a tier on a model switch, so the difference is explained rather than discovered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2aad36e9-e078-4b9c-afa1-0c7cd2c71f74 --- docs/features/session-persistence.md | 13 +++++++ dotnet/README.md | 2 +- dotnet/src/Session.cs | 6 +-- dotnet/src/Types.cs | 4 +- .../test/Unit/ClientSessionLifetimeTests.cs | 4 +- go/README.md | 2 +- go/session.go | 39 +++++++------------ go/session_test.go | 6 +-- java/README.md | 2 +- .../com/github/copilot/CopilotSession.java | 8 ++-- .../github/copilot/rpc/SetModelOptions.java | 14 +++---- .../copilot/SessionAutoTierSwitchTest.java | 4 +- rust/README.md | 2 +- rust/src/session.rs | 2 +- rust/src/types.rs | 8 ++-- rust/tests/api_types_test.rs | 6 +-- 16 files changed, 63 insertions(+), 59 deletions(-) diff --git a/docs/features/session-persistence.md b/docs/features/session-persistence.md index b166b92610..357d0cab0c 100644 --- a/docs/features/session-persistence.md +++ b/docs/features/session-persistence.md @@ -298,6 +298,19 @@ You can also read the authoritative state at any time through the session's `mod | 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 and Python express all three states natively, because `null`/`None` is distinguishable from an omitted argument. The other four SDKs cannot make that distinction in a single value, so they carry a separate reset option. 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 e840ee0e6e..5c058d19a9 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -301,7 +301,7 @@ if (result.Status == ModelSwitchAutoTierStatus.Pending) await session.SetAutoTierAsync(null); ``` -`SetModelAsync` accepts the same preference through `SetModelOptions.AutoTier`, which stages the tier atomically with selecting `auto`. Set `ClearAutoTier` instead to return to provider-default routing; the two options are mutually exclusive. +`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. diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index ec8b5dba17..42cbe3f9ce 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -1928,14 +1928,14 @@ public async Task SetModelAsync(string model, SetModelOptions options, Cancellat ArgumentNullException.ThrowIfNull(model); ThrowIfDisposed(); - if (options.AutoTier is not null && options.ClearAutoTier) + if (options.AutoTier is not null && options.ResetAutoTier) { throw new ArgumentException( - $"{nameof(SetModelOptions.AutoTier)} and {nameof(SetModelOptions.ClearAutoTier)} are mutually exclusive.", + $"{nameof(SetModelOptions.AutoTier)} and {nameof(SetModelOptions.ResetAutoTier)} are mutually exclusive.", nameof(options)); } - if (options.ClearAutoTier) + if (options.ResetAutoTier) { var request = new ModelSwitchToRequest { diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 82f96efcd1..2f6197b3df 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -3025,7 +3025,7 @@ public struct SetModelOptions /// /// /// Leave unset to leave the current preference alone. Set - /// instead to return to the provider's default Auto + /// 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. @@ -3036,7 +3036,7 @@ public struct SetModelOptions /// Returns to the provider's default Auto routing as part of this switch. /// Mutually exclusive with . ///
- public bool ClearAutoTier { get; set; } + public bool ResetAutoTier { get; set; } } /// diff --git a/dotnet/test/Unit/ClientSessionLifetimeTests.cs b/dotnet/test/Unit/ClientSessionLifetimeTests.cs index 62e7a99e82..20c25f4885 100644 --- a/dotnet/test/Unit/ClientSessionLifetimeTests.cs +++ b/dotnet/test/Unit/ClientSessionLifetimeTests.cs @@ -558,7 +558,7 @@ public async Task SetModelAsync_Writes_Null_AutoTier_WhenCleared() OnPermissionRequest = PermissionHandler.ApproveAll }); - await session.SetModelAsync("auto", new SetModelOptions { ClearAutoTier = true }); + 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". @@ -579,7 +579,7 @@ public async Task SetModelAsync_Rejects_Conflicting_AutoTier_Options() await Assert.ThrowsAsync(() => session.SetModelAsync( "auto", - new SetModelOptions { AutoTier = AutoTier.Balance, ClearAutoTier = true })); + new SetModelOptions { AutoTier = AutoTier.Balance, ResetAutoTier = true })); } [Fact] diff --git a/go/README.md b/go/README.md index 8fe3d59a92..4f31a0e0b9 100644 --- a/go/README.md +++ b/go/README.md @@ -352,7 +352,7 @@ if result.Status == rpc.ModelSwitchAutoTierStatusPending { _, err = session.SetAutoTier(ctx, nil) ``` -`SetModel` accepts the same preference through `SetModelOptions.AutoTier`, which stages the tier atomically with selecting `auto`. Set `ClearAutoTier` instead to return to provider-default routing; the two options are mutually exclusive. +`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. diff --git a/go/session.go b/go/session.go index f89a0d3748..058fb60a1e 100644 --- a/go/session.go +++ b/go/session.go @@ -1852,12 +1852,12 @@ type SetModelOptions struct { // Experimental: AutoTier is part of an experimental Auto routing surface and // may change or be removed. AutoTier *AutoTier - // ClearAutoTier returns to the provider's default Auto routing as part of + // ResetAutoTier returns to the provider's default Auto routing as part of // this switch. It is mutually exclusive with AutoTier. // - // Experimental: ClearAutoTier is part of an experimental Auto routing surface + // Experimental: ResetAutoTier is part of an experimental Auto routing surface // and may change or be removed. - ClearAutoTier bool + ResetAutoTier bool } // SetModel changes the model for this session. @@ -1874,33 +1874,24 @@ 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.ClearAutoTier { - return errors.New("failed to set model: AutoTier and ClearAutoTier are mutually exclusive") + 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 - params.AutoTier = opts.AutoTier - if opts.ClearAutoTier { - // The generated request omits a nil AutoTier, which the runtime - // reads as "leave the preference alone" rather than "use default - // routing", so serialize the request and write the null back. - raw, err := json.Marshal(params) - if err != nil { - return fmt.Errorf("failed to set model: %w", err) - } - var req map[string]any - if err := json.Unmarshal(raw, &req); err != nil { - return fmt.Errorf("failed to set model: %w", err) - } - req["sessionId"] = s.SessionID - req["autoTier"] = nil - if _, err := s.client.Request(ctx, "session.model.switchTo", req); err != nil { - return fmt.Errorf("failed to set model: %w", err) - } - return nil + // 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) diff --git a/go/session_test.go b/go/session_test.go index ba837c3b30..1547f82375 100644 --- a/go/session_test.go +++ b/go/session_test.go @@ -98,7 +98,7 @@ func TestSession_SetModelForwardsAutoTier(t *testing.T) { } func TestSession_SetModelSendsExplicitNullAutoTierWhenCleared(t *testing.T) { - params := captureSetModelRequestForModel(t, "auto", &SetModelOptions{ClearAutoTier: true}) + 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". @@ -117,10 +117,10 @@ func TestSession_SetModelRejectsConflictingAutoTierOptions(t *testing.T) { err := session.SetModel(context.Background(), "auto", &SetModelOptions{ AutoTier: &tier, - ClearAutoTier: true, + ResetAutoTier: true, }) if err == nil { - t.Fatal("expected an error when AutoTier and ClearAutoTier are both set") + t.Fatal("expected an error when AutoTier and ResetAutoTier are both set") } } diff --git a/java/README.md b/java/README.md index 0425a40b41..6a95da424e 100644 --- a/java/README.md +++ b/java/README.md @@ -385,7 +385,7 @@ if (result.status() == ModelSwitchAutoTierStatus.PENDING) { session.setAutoTier(null).get(); ``` -`setModel(SetModelOptions)` accepts the same preference through `SetModelOptions.setAutoTier(...)`, which stages the tier atomically with selecting `auto`. Call `setClearAutoTier(true)` instead to return to provider-default routing; the two options are mutually exclusive. +`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 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 99e1ebc027..156cb3495a 100644 --- a/java/sdk/src/main/java/com/github/copilot/CopilotSession.java +++ b/java/sdk/src/main/java/com/github/copilot/CopilotSession.java @@ -2119,7 +2119,7 @@ private static com.github.copilot.generated.rpc.AutoTier toGeneratedAutoTier( * *
{@code
      * session.setModel(new SetModelOptions().setModel("auto").setAutoTier(AutoTier.INTELLIGENCE)).get();
-     * session.setModel(new SetModelOptions().setModel("auto").setClearAutoTier(true)).get();
+     * session.setModel(new SetModelOptions().setModel("auto").setResetAutoTier(true)).get();
      * }
* * @param options @@ -2141,9 +2141,9 @@ public CompletableFuture setModel(com.github.copilot.rpc.SetModelOptions o if (options.getModel() == null) { throw new IllegalArgumentException("options must specify a model"); } - if (options.getAutoTier() != null && options.isClearAutoTier()) { + if (options.getAutoTier() != null && options.isResetAutoTier()) { throw new IllegalArgumentException( - "setModel cannot combine an explicit autoTier with clearAutoTier; choose one"); + "setModel cannot combine an explicit autoTier with resetAutoTier; choose one"); } var generatedReasoningSummary = options.getReasoningSummary() == null ? null @@ -2152,7 +2152,7 @@ public CompletableFuture setModel(com.github.copilot.rpc.SetModelOptions o toGeneratedAutoTier(options.getAutoTier()), options.getReasoningEffort(), generatedReasoningSummary, null, toGeneratedCapabilities(options.getModelCapabilities()), null, null, null, null, null, null, null, null, null); - if (!options.isClearAutoTier()) { + if (!options.isResetAutoTier()) { return getRpc().model.switchTo(params).thenApply(r -> null); } // The generated params record omits null properties, but returning to 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 index 372ce0be4a..dd8c268877 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/SetModelOptions.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/SetModelOptions.java @@ -28,7 +28,7 @@ public class SetModelOptions { private AutoTier autoTier; - private boolean clearAutoTier; + private boolean resetAutoTier; /** * Gets the target model ID. @@ -131,7 +131,7 @@ public AutoTier getAutoTier() { *

* 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 #setClearAutoTier(boolean)} to return to the provider's + * provider. Use {@link #setResetAutoTier(boolean)} to return to the provider's * default Auto routing instead. * * @param autoTier @@ -149,8 +149,8 @@ public SetModelOptions setAutoTier(AutoTier autoTier) { * * @return {@code true} when the request clears the Auto routing preference */ - public boolean isClearAutoTier() { - return clearAutoTier; + public boolean isResetAutoTier() { + return resetAutoTier; } /** @@ -159,12 +159,12 @@ public boolean isClearAutoTier() { * This differs from leaving {@link #setAutoTier(AutoTier)} unset, which keeps * the current preference. It cannot be combined with an explicit tier. * - * @param clearAutoTier + * @param resetAutoTier * {@code true} to return to provider-default Auto routing * @return this options object for method chaining */ - public SetModelOptions setClearAutoTier(boolean clearAutoTier) { - this.clearAutoTier = clearAutoTier; + public SetModelOptions setResetAutoTier(boolean resetAutoTier) { + this.resetAutoTier = resetAutoTier; return this; } } diff --git a/java/sdk/src/test/java/com/github/copilot/SessionAutoTierSwitchTest.java b/java/sdk/src/test/java/com/github/copilot/SessionAutoTierSwitchTest.java index 8d1022db57..adeeca2c6b 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionAutoTierSwitchTest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionAutoTierSwitchTest.java @@ -70,7 +70,7 @@ void setModel_sends_explicit_null_autoTier_when_clearing() throws Exception { var session = new CopilotSession("sess-3", sockets.client()); var stub = sockets.stubServer(); - session.setModel(new SetModelOptions().setModel("auto").setClearAutoTier(true)); + session.setModel(new SetModelOptions().setModel("auto").setResetAutoTier(true)); var sent = stub.readOneMessage(); assertEquals("session.model.switchTo", sent.get("method").asText()); @@ -86,7 +86,7 @@ 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).setClearAutoTier(true); + var options = new SetModelOptions().setModel("auto").setAutoTier(AutoTier.BALANCE).setResetAutoTier(true); assertThrows(IllegalArgumentException.class, () -> session.setModel(options)); } diff --git a/rust/README.md b/rust/README.md index 3bb2a74937..81bfc527d3 100644 --- a/rust/README.md +++ b/rust/README.md @@ -402,7 +402,7 @@ if result.status == ModelSwitchAutoTierStatus::Pending { 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_provider_default_auto_tier` instead to return to provider-default routing. +`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 2efc9ea6fd..7cf207d104 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -571,7 +571,7 @@ impl Session { verbosity: None, }; - if matches!(auto_tier, Some(AutoTierPreference::ProviderDefault)) { + 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. diff --git a/rust/src/types.rs b/rust/src/types.rs index 5e2cfab3e9..32b504e336 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -4813,13 +4813,13 @@ pub struct SetModelOptions { /// /// This is a three-state choice. Leaving [`SetModelOptions::auto_tier`] as /// `None` leaves the current preference alone, which is different from -/// [`AutoTierPreference::ProviderDefault`], which actively clears it. +/// [`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. - ProviderDefault, + Reset, } impl SetModelOptions { @@ -4858,8 +4858,8 @@ impl SetModelOptions { /// Set [`auto_tier`](Self::auto_tier) to return to the provider's default /// Auto routing. - pub fn with_provider_default_auto_tier(mut self) -> Self { - self.auto_tier = Some(AutoTierPreference::ProviderDefault); + pub fn with_reset_auto_tier(mut self) -> Self { + self.auto_tier = Some(AutoTierPreference::Reset); self } } diff --git a/rust/tests/api_types_test.rs b/rust/tests/api_types_test.rs index cc6bf2f30e..9ddd450e3f 100644 --- a/rust/tests/api_types_test.rs +++ b/rust/tests/api_types_test.rs @@ -247,7 +247,7 @@ fn switch_auto_tier_result_deserializes_full_snapshot() { } #[test] -fn set_model_options_distinguishes_unset_tier_from_provider_default() { +fn set_model_options_distinguishes_unset_tier_from_reset() { let untouched = SetModelOptions::default(); assert_eq!(untouched.auto_tier, None); @@ -257,6 +257,6 @@ fn set_model_options_distinguishes_unset_tier_from_provider_default() { Some(AutoTierPreference::Tier(AutoTier::Intelligence)) ); - let cleared = SetModelOptions::default().with_provider_default_auto_tier(); - assert_eq!(cleared.auto_tier, Some(AutoTierPreference::ProviderDefault)); + let cleared = SetModelOptions::default().with_reset_auto_tier(); + assert_eq!(cleared.auto_tier, Some(AutoTierPreference::Reset)); } From fee4bd7a8578f16751b4f87fd3338a4c3f2fae13 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Thu, 3 Sep 2026 16:03:48 -0400 Subject: [PATCH 5/7] Add end-to-end coverage for Auto tier switching The unit tests for Auto tier switching only check the JSON-RPC payload the SDK builds. That proves the wire shape but not that the runtime interprets it the way the API documents, which is the part reviewers have asked us to demonstrate on earlier model-switching work. These tests run against a real Copilot runtime and read the staged state back through `model.getCurrent()`, so every assertion reflects recorded runtime behavior. Two scenarios, identical across all six SDKs: - Staging and resetting. A request is accepted as `pending`, a second request replaces the first and reports the tier it displaced, and a null tier returns the session to provider-default routing. - Omission semantics. Calling `setModel("auto")` without a tier preserves the staged preference, passing a tier replaces it, and asking for a reset clears it. These are three distinct outcomes, which is why the reset is expressed separately from the tier value in every language. The runtime only commits a preference on a later turn that uses the `auto` model, so it rejects `switchAutoTier` unless `auto` is selected. Both snapshots therefore list `auto` and record no conversation; covering the commit path through `session.model_change` and `session.auto_tier_switch_failed` needs a recorded conversation and is left as follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2aad36e9-e078-4b9c-afa1-0c7cd2c71f74 --- dotnet/test/E2E/AutoTierE2ETests.cs | 88 +++++++++++ go/internal/e2e/auto_tier_e2e_test.go | 140 ++++++++++++++++++ .../java/com/github/copilot/AutoTierIT.java | 120 +++++++++++++++ nodejs/test/e2e/auto_tier.e2e.test.ts | 73 +++++++++ python/e2e/test_auto_tier_e2e.py | 81 ++++++++++ rust/tests/e2e.rs | 2 + rust/tests/e2e/auto_tier.rs | 139 +++++++++++++++++ ...rve_auto_tier_when_set_model_omits_it.yaml | 4 + ..._stage_and_reset_auto_tier_preference.yaml | 4 + 9 files changed, 651 insertions(+) create mode 100644 dotnet/test/E2E/AutoTierE2ETests.cs create mode 100644 go/internal/e2e/auto_tier_e2e_test.go create mode 100644 java/sdk/src/test/java/com/github/copilot/AutoTierIT.java create mode 100644 nodejs/test/e2e/auto_tier.e2e.test.ts create mode 100644 python/e2e/test_auto_tier_e2e.py create mode 100644 rust/tests/e2e/auto_tier.rs create mode 100644 test/snapshots/auto_tier/should_preserve_auto_tier_when_set_model_omits_it.yaml create mode 100644 test/snapshots/auto_tier/should_stage_and_reset_auto_tier_preference.yaml 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/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/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/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/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/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..eea4113756 --- /dev/null +++ b/rust/tests/e2e/auto_tier.rs @@ -0,0 +1,139 @@ +use github_copilot_sdk::rpc::ModelSwitchAutoTierStatus; +use github_copilot_sdk::session::Session; +use github_copilot_sdk::session_events::AutoTier; +use github_copilot_sdk::SetModelOptions; + +use super::support::with_dedicated_e2e_context; + +const MODEL_ID: &str = "auto"; + +/// 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().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: [] From 4ae0906f734407c4efe5c1945699797e65340c66 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Thu, 3 Sep 2026 23:25:36 -0400 Subject: [PATCH 6/7] Fix Rust CI: format E2E imports and skip the README fragment The Auto tier README example is a fragment that references an undefined 'session' binding, so rustdoc could not compile it as a doctest. Mark it 'rust,ignore' to match every other fragment in the file, and apply the rustfmt import merge the format job asked for. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2aad36e9-e078-4b9c-afa1-0c7cd2c71f74 --- rust/README.md | 2 +- rust/tests/e2e/auto_tier.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/README.md b/rust/README.md index 81bfc527d3..72f9571e70 100644 --- a/rust/README.md +++ b/rust/README.md @@ -390,7 +390,7 @@ Change the Auto routing preference without changing the selected model. The runt 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 +```rust,ignore use github_copilot_sdk::{AutoTier, ModelSwitchAutoTierStatus}; let result = session.set_auto_tier(Some(AutoTier::Intelligence)).await?; diff --git a/rust/tests/e2e/auto_tier.rs b/rust/tests/e2e/auto_tier.rs index eea4113756..7f169523ad 100644 --- a/rust/tests/e2e/auto_tier.rs +++ b/rust/tests/e2e/auto_tier.rs @@ -1,7 +1,7 @@ +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 github_copilot_sdk::SetModelOptions; use super::support::with_dedicated_e2e_context; From cf38b7b13ee4c2c8e3d0554cd0d57793c981b3dc Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Fri, 4 Sep 2026 00:02:54 -0400 Subject: [PATCH 7/7] Address review feedback on the Auto tier surface Gate the experimental Auto tier options consistently. The .NET SetModelOptions.AutoTier and .ResetAutoTier properties, the Java Auto tier accessors, and the Python set_model auto_tier parameter now carry the same experimental marker their dedicated setAutoTier counterparts already had. Correct two documentation errors. The reset-state paragraph claimed four SDKs cannot express reset in a single value; Rust can, through AutoTierPreference::Reset, so only Go, .NET, and Java need a separate flag. The live-switching section now states that it needs Copilot CLI 1.0.83-4, which is newer than the 1.0.82-1 required to select a tier at create or resume. Fix the Java class Javadoc, which said every option is optional when setModel rejects a null model, and rewrite the Rust E2E doc comment to describe what it covers instead of pointing at the Node.js test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2aad36e9-e078-4b9c-afa1-0c7cd2c71f74 --- docs/features/session-persistence.md | 4 ++-- dotnet/src/Types.cs | 2 ++ .../java/com/github/copilot/rpc/SetModelOptions.java | 11 +++++++++-- python/copilot/session.py | 4 +++- rust/tests/e2e/auto_tier.rs | 3 ++- 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/features/session-persistence.md b/docs/features/session-persistence.md index 357d0cab0c..869af9b9a4 100644 --- a/docs/features/session-persistence.md +++ b/docs/features/session-persistence.md @@ -271,7 +271,7 @@ The `session.start` and `session.resume` events expose the selected tier in thei ### 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. +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"); @@ -309,7 +309,7 @@ To select the `auto` model and its routing preference in a single call, stage th | 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 and Python express all three states natively, because `null`/`None` is distinguishable from an omitted argument. The other four SDKs cannot make that distinction in a single value, so they carry a separate reset option. Omitting both always means "leave the current preference alone." +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 diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 2f6197b3df..9129b118e8 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -3030,12 +3030,14 @@ public struct SetModelOptions /// 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/java/sdk/src/main/java/com/github/copilot/rpc/SetModelOptions.java b/java/sdk/src/main/java/com/github/copilot/rpc/SetModelOptions.java index dd8c268877..9ebfbf199b 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/SetModelOptions.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/SetModelOptions.java @@ -4,11 +4,14 @@ 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. Every option is - * optional; an unset option leaves the corresponding session state unchanged. + * 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();
@@ -122,6 +125,7 @@ public SetModelOptions setModelCapabilities(ModelCapabilitiesOverride modelCapab
      *
      * @return the requested tier, or {@code null} when no tier was requested
      */
+    @CopilotExperimental
     public AutoTier getAutoTier() {
         return autoTier;
     }
@@ -139,6 +143,7 @@ public AutoTier getAutoTier() {
      *            current preference unchanged
      * @return this options object for method chaining
      */
+    @CopilotExperimental
     public SetModelOptions setAutoTier(AutoTier autoTier) {
         this.autoTier = autoTier;
         return this;
@@ -149,6 +154,7 @@ public SetModelOptions setAutoTier(AutoTier autoTier) {
      *
      * @return {@code true} when the request clears the Auto routing preference
      */
+    @CopilotExperimental
     public boolean isResetAutoTier() {
         return resetAutoTier;
     }
@@ -163,6 +169,7 @@ public boolean isResetAutoTier() {
      *            {@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/python/copilot/session.py b/python/copilot/session.py
index 964ed04c72..d92fc7f34f 100644
--- a/python/copilot/session.py
+++ b/python/copilot/session.py
@@ -3101,7 +3101,9 @@ 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: Routing preference to apply when ``model`` is ``"auto"``.
+            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
diff --git a/rust/tests/e2e/auto_tier.rs b/rust/tests/e2e/auto_tier.rs
index 7f169523ad..85c70dd460 100644
--- a/rust/tests/e2e/auto_tier.rs
+++ b/rust/tests/e2e/auto_tier.rs
@@ -7,7 +7,8 @@ use super::support::with_dedicated_e2e_context;
 
 const MODEL_ID: &str = "auto";
 
-/// Mirrors nodejs/test/e2e/auto_tier.e2e.test.ts (snapshot category "auto_tier").
+/// 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