diff --git a/docs/features/session-persistence.md b/docs/features/session-persistence.md index 3bfff10d0..9b0aa9434 100644 --- a/docs/features/session-persistence.md +++ b/docs/features/session-persistence.md @@ -242,6 +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 | | `reasoningEffort` | Adjust reasoning effort level | | `streaming` | Enable/disable streaming responses | | `workingDirectory` | Change the working directory | @@ -253,6 +254,21 @@ When resuming a session, you can optionally reconfigure many settings. This is u | `disabledSkills` | Skills to disable | | `infiniteSessions` | Configure infinite session behavior | +### Auto tier persistence + +With `model: "auto"`, the optional `capi.autoTier` setting selects an Auto routing preference: `efficiency`, `balance`, or `intelligence`. In Python, use `capi={"auto_tier": "balance"}`. This requires Copilot CLI `1.0.82-1` or later with V2 Auto routing; V1 Auto requests are unchanged. + +The runtime persists the selected tier, so applications do not need to resend it on every resume: + +* 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. +* 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. + ### Example: changing model on resume ```typescript diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index f3c1a57e6..80ad95471 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -10933,6 +10933,10 @@ public sealed class OptionsUpdateAdditionalContentExclusionPolicy [Experimental(Diagnostics.Experimental)] public sealed class CapiSessionOptions { + /// Routing preference used when the session model is `auto`. The runtime persists the preference across cold resume. When omitted, the default routing behavior is used. Resuming an already-resident session cannot change its preference. + [JsonPropertyName("autoTier")] + public AutoTier? AutoTier { get; set; } + /// Whether to use WebSocket transport for the CAPI Responses API. Enabled by default when the model advertises `ws:/responses` support; set to `false` to force the HTTP Responses transport in environments where WebSockets are blocked (e.g. behind a proxy). Setting this to `false` is equivalent to the `COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES` environment variable. [JsonPropertyName("enableWebSocketResponses")] public bool? EnableWebSocketResponses { get; set; } @@ -35646,6 +35650,7 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(GitHub.Copilot.AutoModeSwitchRequestedData), TypeInfoPropertyName = "SessionEventsAutoModeSwitchRequestedData")] [JsonSerializable(typeof(GitHub.Copilot.AutoModeSwitchRequestedEvent), TypeInfoPropertyName = "SessionEventsAutoModeSwitchRequestedEvent")] [JsonSerializable(typeof(GitHub.Copilot.AutoModeSwitchResponse), TypeInfoPropertyName = "SessionEventsAutoModeSwitchResponse")] +[JsonSerializable(typeof(GitHub.Copilot.AutoTier), TypeInfoPropertyName = "SessionEventsAutoTier")] [JsonSerializable(typeof(GitHub.Copilot.AutopilotObjectiveChangedOperation), TypeInfoPropertyName = "SessionEventsAutopilotObjectiveChangedOperation")] [JsonSerializable(typeof(GitHub.Copilot.AutopilotObjectiveChangedStatus), TypeInfoPropertyName = "SessionEventsAutopilotObjectiveChangedStatus")] [JsonSerializable(typeof(GitHub.Copilot.BinaryAssetReference), TypeInfoPropertyName = "SessionEventsBinaryAssetReference")] diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index b4351c8f2..d7c71fd24 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -1916,6 +1916,11 @@ public sealed partial class SessionStartData [JsonPropertyName("alreadyInUse")] public bool? AlreadyInUse { get; set; } + /// Auto routing preference selected at session creation time. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("autoTier")] + public AutoTier? AutoTier { get; set; } + /// Working directory and git context at session start. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("context")] @@ -1995,6 +2000,11 @@ public sealed partial class SessionResumeData [JsonPropertyName("alreadyInUse")] public bool? AlreadyInUse { get; set; } + /// Auto routing preference active at resume time. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("autoTier")] + public AutoTier? AutoTier { get; set; } + /// Updated working directory and git context at resume time. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("context")] @@ -10305,6 +10315,70 @@ public sealed partial class McpAppToolCallCompleteToolMeta public McpAppToolCallCompleteToolMetaUI? Ui { get; set; } } +/// Routing preference used when the session model is `auto`. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct AutoTier : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public AutoTier(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Optimize for efficiency. + public static AutoTier Efficiency { get; } = new("efficiency"); + + /// Balance efficiency and intelligence. + public static AutoTier Balance { get; } = new("balance"); + + /// Optimize for intelligence. + public static AutoTier Intelligence { get; } = new("intelligence"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(AutoTier left, AutoTier right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(AutoTier left, AutoTier right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is AutoTier other && Equals(other); + + /// + public bool Equals(AutoTier other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override AutoTier Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, AutoTier value, JsonSerializerOptions options) + { + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(AutoTier)); + } + } +} + /// Hosting platform type of the repository (github or ado). [JsonConverter(typeof(Converter))] [DebuggerDisplay("{Value,nq}")] diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 6ed05e306..e9b5f2c62 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2398,6 +2398,18 @@ public sealed class CapiSessionOptions /// [JsonPropertyName("enableWebSocketResponses")] public bool? EnableWebSocketResponses { get; set; } + + /// + /// Routing tier for model auto with V2 Auto. + /// + /// + /// 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. + /// + [JsonPropertyName("autoTier")] + public AutoTier? AutoTier { get; set; } } /// diff --git a/dotnet/test/Unit/ClientSessionLifetimeTests.cs b/dotnet/test/Unit/ClientSessionLifetimeTests.cs index b61546c65..e2eb50eed 100644 --- a/dotnet/test/Unit/ClientSessionLifetimeTests.cs +++ b/dotnet/test/Unit/ClientSessionLifetimeTests.cs @@ -464,6 +464,94 @@ public async Task CreateSessionAsync_Omits_CustomAgent_ReasoningEffort_When_Unse Assert.False(agent.TryGetProperty("reasoningEffort", out _)); } + public static TheoryData CapiAutoTiers => new() + { + { AutoTier.Efficiency, "efficiency", null }, + { AutoTier.Balance, "balance", null }, + { AutoTier.Intelligence, "intelligence", null }, + { AutoTier.Efficiency, "efficiency", false }, + { AutoTier.Balance, "balance", false }, + { AutoTier.Intelligence, "intelligence", false }, + }; + + [Theory] + [MemberData(nameof(CapiAutoTiers))] + public async Task SessionRequests_Serialize_CapiAutoTier(AutoTier tier, string expectedTier, bool? enableWebSocketResponses) + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + var capi = new CapiSessionOptions { AutoTier = tier, EnableWebSocketResponses = enableWebSocketResponses }; + + await using var created = await client.CreateSessionAsync(new SessionConfig + { + Model = "auto", + Capi = capi, + OnPermissionRequest = PermissionHandler.ApproveAll + }); + await using var resumed = await client.ResumeSessionAsync("resume-with-auto-tier", new ResumeSessionConfig + { + Model = "auto", + Capi = capi, + OnPermissionRequest = PermissionHandler.ApproveAll + }); + + foreach (var method in new[] { "session.create", "session.resume" }) + { + var request = Assert.Single(server.Requests, request => request.Method == method); + var serializedCapi = request.Params.GetProperty("capi"); + Assert.Equal(expectedTier, serializedCapi.GetProperty("autoTier").GetString()); + if (enableWebSocketResponses.HasValue) + { + Assert.Equal(enableWebSocketResponses.Value, serializedCapi.GetProperty("enableWebSocketResponses").GetBoolean()); + } + else + { + Assert.False(serializedCapi.TryGetProperty("enableWebSocketResponses", out _)); + } + } + } + + [Theory] + [InlineData(false, null)] + [InlineData(true, null)] + [InlineData(true, false)] + public async Task SessionRequests_Omit_CapiAutoTier_WhenUnset(bool includeCapi, bool? enableWebSocketResponses) + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + var capi = includeCapi ? new CapiSessionOptions { EnableWebSocketResponses = enableWebSocketResponses } : null; + + await using var created = await client.CreateSessionAsync(new SessionConfig + { + Model = "auto", + Capi = capi, + OnPermissionRequest = PermissionHandler.ApproveAll + }); + await using var resumed = await client.ResumeSessionAsync("resume-without-auto-tier", new ResumeSessionConfig + { + Capi = capi, + OnPermissionRequest = PermissionHandler.ApproveAll + }); + + foreach (var method in new[] { "session.create", "session.resume" }) + { + var request = Assert.Single(server.Requests, request => request.Method == method); + Assert.Equal(includeCapi, request.Params.TryGetProperty("capi", out var serializedCapi)); + if (includeCapi) + { + Assert.False(serializedCapi.TryGetProperty("autoTier", out _)); + if (enableWebSocketResponses.HasValue) + { + Assert.Equal(enableWebSocketResponses.Value, serializedCapi.GetProperty("enableWebSocketResponses").GetBoolean()); + } + else + { + Assert.Empty(serializedCapi.EnumerateObject()); + } + } + } + } + [Fact] public async Task SessionRequests_Serialize_AdditionalDirectories() { diff --git a/dotnet/test/Unit/SessionEventSerializationTests.cs b/dotnet/test/Unit/SessionEventSerializationTests.cs index 326ac3f3c..8aea50573 100644 --- a/dotnet/test/Unit/SessionEventSerializationTests.cs +++ b/dotnet/test/Unit/SessionEventSerializationTests.cs @@ -9,6 +9,45 @@ namespace GitHub.Copilot.Test.Unit; public class SessionEventSerializationTests { + public static TheoryData AutoTiers => new() + { + { AutoTier.Efficiency, "efficiency" }, + { AutoTier.Balance, "balance" }, + { AutoTier.Intelligence, "intelligence" }, + { null, null }, + }; + + [Theory] + [MemberData(nameof(AutoTiers))] + public void SessionEvent_Deserializes_AutoTier(AutoTier? expectedTier, string? wireTier) + { + foreach (var eventType in new[] { "session.start", "session.resume" }) + { + var autoTierProperty = wireTier is null ? "" : $""", "autoTier": "{wireTier}" """; + var json = $$""" + { + "id": "11111111-1111-1111-1111-111111111111", + "timestamp": "2026-08-28T00:00:00Z", + "parentId": null, + "type": "{{eventType}}", + "data": { + "sessionId": "test-session", "version": 1, + "producer": "copilot", "copilotVersion": "1.0.82-1", + "startTime": "2026-08-28T00:00:00Z", + "resumeTime": "2026-08-28T00:00:00Z", "eventCount": 1 + {{autoTierProperty}} + } + } + """; + + var sessionEvent = SessionEvent.FromJson(json); + var actualTier = eventType == "session.start" + ? Assert.IsType(sessionEvent).Data.AutoTier + : Assert.IsType(sessionEvent).Data.AutoTier; + Assert.Equal(expectedTier, actualTier); + } + } + public static TheoryData JsonElementBackedEvents => new() { { diff --git a/go/client_test.go b/go/client_test.go index c6ab0808c..25c86d54f 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -407,42 +407,63 @@ func newRuntimeShutdownRpcPair(t *testing.T) (*jsonrpc2.Client, *jsonrpc2.Client } func TestClient_ForwardsCapiOptionsToSessionRequests(t *testing.T) { - rpcClient, server, _ := newRuntimeShutdownRpcPair(t) - t.Cleanup(server.Stop) - client := &Client{ - client: rpcClient, - RPC: rpc.NewServerRPC(rpcClient), - sessions: make(map[string]*Session), - } + tests := []struct { + name string + capi *CapiSessionOptions + want map[string]any + }{ + {"omitted", nil, nil}, + {"empty", &CapiSessionOptions{}, map[string]any{}}, + {"websocket only", &CapiSessionOptions{EnableWebSocketResponses: Bool(false)}, map[string]any{"enableWebSocketResponses": false}}, + {"efficiency", &CapiSessionOptions{AutoTier: AutoTierEfficiency}, map[string]any{"autoTier": "efficiency"}}, + {"balance", &CapiSessionOptions{AutoTier: AutoTierBalance}, map[string]any{"autoTier": "balance"}}, + {"intelligence", &CapiSessionOptions{AutoTier: AutoTierIntelligence}, map[string]any{"autoTier": "intelligence"}}, + {"efficiency with websocket", &CapiSessionOptions{AutoTier: AutoTierEfficiency, EnableWebSocketResponses: Bool(false)}, map[string]any{"autoTier": "efficiency", "enableWebSocketResponses": false}}, + {"balance with websocket", &CapiSessionOptions{AutoTier: AutoTierBalance, EnableWebSocketResponses: Bool(false)}, map[string]any{"autoTier": "balance", "enableWebSocketResponses": false}}, + {"intelligence with websocket", &CapiSessionOptions{AutoTier: AutoTierIntelligence, EnableWebSocketResponses: Bool(false)}, map[string]any{"autoTier": "intelligence", "enableWebSocketResponses": false}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rpcClient, server, _ := newRuntimeShutdownRpcPair(t) + t.Cleanup(server.Stop) + client := &Client{ + client: rpcClient, + RPC: rpc.NewServerRPC(rpcClient), + sessions: make(map[string]*Session), + } - createParams := make(chan json.RawMessage, 1) - server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { - createParams <- append(json.RawMessage(nil), params...) - sessionID := sessionIDFromParams(t, params) - return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil - }) + createParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + createParams <- append(json.RawMessage(nil), params...) + sessionID := sessionIDFromParams(t, params) + return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil + }) - _, err := client.CreateSession(t.Context(), &SessionConfig{ - Capi: &CapiSessionOptions{EnableWebSocketResponses: Bool(false)}, - }) - if err != nil { - t.Fatalf("CreateSession failed: %v", err) - } - assertCapiEnableWebSocketResponses(t, <-createParams) + _, err := client.CreateSession(t.Context(), &SessionConfig{ + Model: "auto", + Capi: tt.capi, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + assertCapiOptions(t, <-createParams, tt.want) - resumeParams := make(chan json.RawMessage, 1) - server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { - resumeParams <- append(json.RawMessage(nil), params...) - return []byte(`{"sessionId":"resumed-capi","workspacePath":"/workspace"}`), nil - }) + resumeParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + resumeParams <- append(json.RawMessage(nil), params...) + return []byte(`{"sessionId":"resumed-capi","workspacePath":"/workspace"}`), nil + }) - _, err = client.ResumeSessionWithOptions(t.Context(), "resumed-capi", &ResumeSessionConfig{ - Capi: &CapiSessionOptions{EnableWebSocketResponses: Bool(false)}, - }) - if err != nil { - t.Fatalf("ResumeSessionWithOptions failed: %v", err) + _, err = client.ResumeSessionWithOptions(t.Context(), "resumed-capi", &ResumeSessionConfig{ + Model: "auto", + Capi: tt.capi, + }) + if err != nil { + t.Fatalf("ResumeSessionWithOptions failed: %v", err) + } + assertCapiOptions(t, <-resumeParams, tt.want) + }) } - assertCapiEnableWebSocketResponses(t, <-resumeParams) } func TestClient_ForwardsAdditionalDirectoriesToSessionRequests(t *testing.T) { @@ -620,7 +641,7 @@ func TestClient_ForwardsNewSessionOptionsToSessionRequests(t *testing.T) { assertNewSessionOptions(t, <-resumeParams, false, false, "task", 15) } -func assertCapiEnableWebSocketResponses(t *testing.T, params json.RawMessage) { +func assertCapiOptions(t *testing.T, params json.RawMessage, want map[string]any) { t.Helper() var decoded map[string]any @@ -628,12 +649,18 @@ func assertCapiEnableWebSocketResponses(t *testing.T, params json.RawMessage) { t.Fatalf("failed to unmarshal request params: %v", err) } + if want == nil { + if _, present := decoded["capi"]; present { + t.Fatalf("expected capi to be omitted, got %v", decoded["capi"]) + } + return + } capi, ok := decoded["capi"].(map[string]any) if !ok { t.Fatalf("expected capi object in request params, got %T", decoded["capi"]) } - if capi["enableWebSocketResponses"] != false { - t.Fatalf("expected capi.enableWebSocketResponses=false, got %v", capi["enableWebSocketResponses"]) + if !reflect.DeepEqual(capi, want) { + t.Fatalf("expected capi %v, got %v", want, capi) } } diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index edd5f925f..36fd07720 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -1361,6 +1361,10 @@ type CanvasSessionContext struct { // Experimental: CapiSessionOptions is part of an experimental API and may change or be // removed. type CapiSessionOptions struct { + // Routing preference used when the session model is `auto`. The runtime persists the + // preference across cold resume. When omitted, the default routing behavior is used. + // Resuming an already-resident session cannot change its preference. + AutoTier *AutoTier `json:"autoTier,omitempty"` // Whether to use WebSocket transport for the CAPI Responses API. Enabled by default when // the model advertises `ws:/responses` support; set to `false` to force the HTTP Responses // transport in environments where WebSockets are blocked (e.g. behind a proxy). Setting @@ -15858,6 +15862,19 @@ const ( AuthInfoTypeUser AuthInfoType = "user" ) +// Routing preference used when the session model is `auto`. +// Experimental: AutoTier is part of an experimental API and may change or be removed. +type AutoTier string + +const ( + // Balance efficiency and intelligence. + AutoTierBalance AutoTier = "balance" + // Optimize for efficiency. + AutoTierEfficiency AutoTier = "efficiency" + // Optimize for intelligence. + AutoTierIntelligence AutoTier = "intelligence" +) + // Custom input-format kind. // Experimental: BuiltinToolFormatType is part of an experimental API and may change or be // removed. diff --git a/go/rpc/zsession_events.go b/go/rpc/zsession_events.go index 7f24ab628..5efcf14bc 100644 --- a/go/rpc/zsession_events.go +++ b/go/rpc/zsession_events.go @@ -2128,6 +2128,8 @@ func (*SessionHandoffData) Type() SessionEventType { return SessionEventTypeSess type SessionStartData struct { // Whether the session was already in use by another client at start time AlreadyInUse *bool `json:"alreadyInUse,omitempty"` + // Auto routing preference selected at session creation time + AutoTier *AutoTier `json:"autoTier,omitempty"` // Working directory and git context at session start Context *WorkingDirectoryContext `json:"context,omitempty"` // Context tier selected at session creation time for models with tiered context pricing; null when no tier is selected (e.g., non-tiered model) @@ -2206,6 +2208,8 @@ func (*SessionSessionLimitsChangedData) Type() SessionEventType { type SessionResumeData struct { // Whether the session was already in use by another client at resume time AlreadyInUse *bool `json:"alreadyInUse,omitempty"` + // Auto routing preference active at resume time + AutoTier *AutoTier `json:"autoTier,omitempty"` // Updated working directory and git context at resume time Context *WorkingDirectoryContext `json:"context,omitempty"` // Context tier currently selected at resume time; null when no tier is active diff --git a/go/session_event_serialization_test.go b/go/session_event_serialization_test.go index ee9258b22..96bf53bb5 100644 --- a/go/session_event_serialization_test.go +++ b/go/session_event_serialization_test.go @@ -14,6 +14,50 @@ var _ SessionEventData = (*rpc.UserMessageData)(nil) var _ rpc.EmbeddedTextResourceContents = EmbeddedTextResourceContents{} var _ EmbeddedTextResourceContents = rpc.EmbeddedTextResourceContents{} +func TestSessionEventAutoTier(t *testing.T) { + for _, eventType := range []string{"session.start", "session.resume"} { + for _, tier := range []AutoTier{"", AutoTierEfficiency, AutoTierBalance, AutoTierIntelligence} { + t.Run(eventType+"/"+string(tier), func(t *testing.T) { + data := map[string]any{ + "sessionId": "test-session", "version": 1, + "producer": "copilot", "copilotVersion": "1.0.82-1", + "startTime": "2026-08-28T00:00:00Z", + "resumeTime": "2026-08-28T00:00:00Z", "eventCount": 1, + } + if tier != "" { + data["autoTier"] = tier + } + wire, err := json.Marshal(map[string]any{ + "id": "00000000-0000-0000-0000-000000000001", + "timestamp": "2026-08-28T00:00:00Z", "parentId": nil, + "type": eventType, "data": data, + }) + if err != nil { + t.Fatal(err) + } + var event SessionEvent + if err := json.Unmarshal(wire, &event); err != nil { + t.Fatal(err) + } + var actual *AutoTier + switch eventType { + case "session.start": + actual = event.Data.(*SessionStartData).AutoTier + case "session.resume": + actual = event.Data.(*SessionResumeData).AutoTier + } + if tier == "" { + if actual != nil { + t.Fatalf("expected omitted autoTier, got %v", *actual) + } + } else if actual == nil || *actual != tier { + t.Fatalf("expected autoTier %q, got %v", tier, actual) + } + }) + } + } +} + func TestSessionEventAgentIDRoundTripsKnownEvent(t *testing.T) { var event SessionEvent if err := json.Unmarshal([]byte(`{ diff --git a/go/types.go b/go/types.go index 1d98e0615..a936df74f 100644 --- a/go/types.go +++ b/go/types.go @@ -2205,6 +2205,18 @@ func (p ProviderConfig) MarshalJSON() ([]byte, error) { return json.Marshal(aux) } +// AutoTier selects the routing tier for model "auto" with V2 Auto. +type AutoTier = rpc.AutoTier + +const ( + // AutoTierEfficiency selects the efficiency routing tier. + AutoTierEfficiency = rpc.AutoTierEfficiency + // AutoTierBalance selects the balance routing tier. + AutoTierBalance = rpc.AutoTierBalance + // AutoTierIntelligence selects the intelligence routing tier. + AutoTierIntelligence = rpc.AutoTierIntelligence +) + // CapiSessionOptions configures provider-scoped Copilot API (CAPI) session behavior. // // WebSocket transport is the default for the CAPI Responses API whenever the @@ -2219,6 +2231,14 @@ type CapiSessionOptions struct { // WebSocket transport. Enabled by default when the model advertises // ws:/responses support; set to Bool(false) to force HTTP Responses transport. EnableWebSocketResponses *bool `json:"enableWebSocketResponses,omitempty"` + + // 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. + AutoTier AutoTier `json:"autoTier,omitempty"` } // AzureProviderOptions contains Azure-specific provider configuration diff --git a/java/README.md b/java/README.md index 2e1ba5181..1b70ef695 100644 --- a/java/README.md +++ b/java/README.md @@ -330,6 +330,32 @@ Chain fluent modifiers to set tool options: For design context and decision rationale, see [ADR-006](docs/adr/adr-006-tool-definition-inline.md). +## Auto routing tiers + +Use `CapiSessionOptions.setAutoTier(...)` to select `AutoTier.EFFICIENCY`, +`AutoTier.BALANCE`, or `AutoTier.INTELLIGENCE`. This option is meaningful only +with model `auto` (Auto mode V2). +It requires a runtime version that supports `capi.autoTier`. + +```java +import com.github.copilot.rpc.AutoTier; +import com.github.copilot.rpc.CapiSessionOptions; +import com.github.copilot.rpc.SessionConfig; + +var config = new SessionConfig() + .setModel("auto") + .setCapi(new CapiSessionOptions().setAutoTier(AutoTier.BALANCE)); +``` + +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. +See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) +for the lifecycle rules. + ## 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/pom.xml b/java/pom.xml index 3e5e1645a..933c65835 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -63,7 +63,7 @@ DO NOT EDIT MANUALLY. Updated by the update-copilot-dependency workflow. --> - ^1.0.82-0 + ^1.0.82-1 true diff --git a/java/scripts/codegen/package-lock.json b/java/scripts/codegen/package-lock.json index 985ae5d5d..fb7d80aad 100644 --- a/java/scripts/codegen/package-lock.json +++ b/java/scripts/codegen/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "copilot-sdk-java-codegen", "dependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.82-1", "json-schema": "^0.4.0", "tsx": "^4.23.12" } @@ -428,9 +428,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.82-0.tgz", - "integrity": "sha512-fSZVNAzFFYaS6btYD0+cKF7SrrtOklhpkPs/cIMZY7Fgxoa6rfZrlTWXlQNgNIdwLp51XxwTfxnZO+D9+MQ5yg==", + "version": "1.0.82-1", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.82-1.tgz", + "integrity": "sha512-YogYPdxH12MQDfTZr5jbwSEsxufgCixsKQwqLZbBjW02D1NBR2H4NgIIOiIQ7gs+8sS/wBq5Y/lL9+c0n321pg==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -439,20 +439,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.82-0", - "@github/copilot-darwin-x64": "1.0.82-0", - "@github/copilot-linux-arm64": "1.0.82-0", - "@github/copilot-linux-x64": "1.0.82-0", - "@github/copilot-linuxmusl-arm64": "1.0.82-0", - "@github/copilot-linuxmusl-x64": "1.0.82-0", - "@github/copilot-win32-arm64": "1.0.82-0", - "@github/copilot-win32-x64": "1.0.82-0" + "@github/copilot-darwin-arm64": "1.0.82-1", + "@github/copilot-darwin-x64": "1.0.82-1", + "@github/copilot-linux-arm64": "1.0.82-1", + "@github/copilot-linux-x64": "1.0.82-1", + "@github/copilot-linuxmusl-arm64": "1.0.82-1", + "@github/copilot-linuxmusl-x64": "1.0.82-1", + "@github/copilot-win32-arm64": "1.0.82-1", + "@github/copilot-win32-x64": "1.0.82-1" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.82-0.tgz", - "integrity": "sha512-TzBYfyvxcw3z9Mu7U8TsFo/Nq7m5XS6ahT71aPL+gx/YId0kmenI27b9daXsK6LA1D0gsFGwNBDKINddqntt1g==", + "version": "1.0.82-1", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.82-1.tgz", + "integrity": "sha512-Ny9JdK5o1XuJm+7vhlrxm8rhvoUbo6fAIOF8Fchdjknp6NR9dMk/dC/wSG30Kf2Fx44BuxuBYUNwH8CPwOaHbg==", "cpu": [ "arm64" ], @@ -466,9 +466,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.82-0.tgz", - "integrity": "sha512-Lm/U5Q8kN8yEeBTWKTfIxXAgXaT6zqdBzAAO7lA4DWHZ92AEeZZiVTs6jEWsQ2aWB2uMs6zbn0vi6t+/jIqCGw==", + "version": "1.0.82-1", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.82-1.tgz", + "integrity": "sha512-l7L5sY/cWyllj+t5vPmq67YlO3Ect5qtaMCn3SrdGpDkm+KXu9Z86ap1YTL6OB25q6XE5+S2z5/r0Z9RHfKzYA==", "cpu": [ "x64" ], @@ -482,9 +482,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.82-0.tgz", - "integrity": "sha512-YERVMC1Q4p6l6KQHL5rVOI52rWbvgp9IwyzUBaVSGrfFuqu5BEvZ9bgHPsxTYi3Npkt5KVOXEyPMU5rAY09qQQ==", + "version": "1.0.82-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.82-1.tgz", + "integrity": "sha512-eg3+W6g4HZMSb+WyX2wVVdLZkvFYgC7HgNZ8+29E0v4ukoDRebHnXFBFRssvQw1fhA8dM99kX+m5rBsE339glg==", "cpu": [ "arm64" ], @@ -498,9 +498,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.82-0.tgz", - "integrity": "sha512-z2hxMVjqt4+xDRFTZv3/0K3X+aqcJhd6zPO2JxCpOVTh5CNZFaWk+XIa2iXAPWxFqdKJsQ4muXMl3zInaAOkRw==", + "version": "1.0.82-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.82-1.tgz", + "integrity": "sha512-iEEpOIrzrTxs91wdGjcmoJA2zMlBB/0bVK159PE0hHTd/QNdE2o22BRscwFu6R+l4+KvIQIhoMoIvLCzOTxVzw==", "cpu": [ "x64" ], @@ -514,9 +514,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.82-0.tgz", - "integrity": "sha512-EcUCv2PKhBzCCvpTaS511VYTDWyhudyIRPvBpc9gFNO3hjlgiNDusf4k9vP6+E3/lHenwGYzMsyRHcYIOy4vcQ==", + "version": "1.0.82-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.82-1.tgz", + "integrity": "sha512-k64C/mVrtwCoVzQy0e29DDgZYI3oOSiCNrPet3/miQe+8kW1FGXRYakudPVnx6P2GdGR+VuRry2ATMp64WguEQ==", "cpu": [ "arm64" ], @@ -530,9 +530,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.82-0.tgz", - "integrity": "sha512-1fKVjUiZ1tdb0/d/re90EpFGXhlIPfjENp2Wo/2Kj592dWO3+IwM2qV/AOdMQ4pacW5iYHII7nibx1/EYq3LGQ==", + "version": "1.0.82-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.82-1.tgz", + "integrity": "sha512-tG3CNiaS2QiElJpFn+EKGxNtRXkzQJ/KJlK8Wbh/ffAFz1VvewhwsasjD3vmqowsX2wHMthds/B7l+DkDXjtxg==", "cpu": [ "x64" ], @@ -546,9 +546,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.82-0.tgz", - "integrity": "sha512-H341wuxQHhwe/yLmORHzwC3DGzFZgGzh+TpwfyK2zjeYVbwDZ3Bax8M+9CzIED9jIBdEfAmfGzxKUHFugpXTtQ==", + "version": "1.0.82-1", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.82-1.tgz", + "integrity": "sha512-dFWwEYODzFzw6aA03VuRIPk80Q8isu0x+8wXxlTAXzWrETs7bc8dtE9Wh2ZLFPCuidRiitMxwuD7Dt4KF9bQiQ==", "cpu": [ "arm64" ], @@ -562,9 +562,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.82-0.tgz", - "integrity": "sha512-f1ba3gG8NaoYWFHtHaHcLN4It7mclkWdCOXvwFPqPEwqCEIx/+Zh6VHiOeIcNWRS0elRP6QYDCKaTDy1TW27uQ==", + "version": "1.0.82-1", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.82-1.tgz", + "integrity": "sha512-w/ti4ipLbcZjoXa2WRV8dEbOXB0Av3WQe0Uua7Wbp5E2UgXhY6grg/hWErHlRS1fa/nnqfr8CyxLv3Mvm78qyQ==", "cpu": [ "x64" ], diff --git a/java/scripts/codegen/package.json b/java/scripts/codegen/package.json index f5d6bf66d..0bd11f34b 100644 --- a/java/scripts/codegen/package.json +++ b/java/scripts/codegen/package.json @@ -7,7 +7,7 @@ "generate:java": "tsx java.ts" }, "dependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.82-1", "json-schema": "^0.4.0", "tsx": "^4.23.12" } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/AutoTier.java b/java/sdk/src/generated/java/com/github/copilot/generated/AutoTier.java new file mode 100644 index 000000000..254543160 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/AutoTier.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +package com.github.copilot.generated; + +import javax.annotation.processing.Generated; + +/** + * Routing preference used when the session model is `auto`. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public enum AutoTier { + /** The {@code efficiency} variant. */ + EFFICIENCY("efficiency"), + /** The {@code balance} variant. */ + BALANCE("balance"), + /** The {@code intelligence} variant. */ + INTELLIGENCE("intelligence"); + + private final String value; + AutoTier(String value) { this.value = value; } + @com.fasterxml.jackson.annotation.JsonValue + public String getValue() { return value; } + @com.fasterxml.jackson.annotation.JsonCreator + public static AutoTier fromValue(String value) { + for (AutoTier v : values()) { + if (v.value.equals(value)) return v; + } + throw new IllegalArgumentException("Unknown AutoTier value: " + value); + } +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/SessionResumeEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/SessionResumeEvent.java index a3f39d769..de54f8a3c 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/SessionResumeEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/SessionResumeEvent.java @@ -51,6 +51,8 @@ public record SessionResumeEventData( @JsonProperty("verbosity") Verbosity verbosity, /** Context tier currently selected at resume time; null when no tier is active */ @JsonProperty("contextTier") ContextTier contextTier, + /** Auto routing preference active at resume time */ + @JsonProperty("autoTier") AutoTier autoTier, /** Session limits currently configured at resume time; null when no limits are active */ @JsonProperty("sessionLimits") SessionLimitsConfig sessionLimits, /** Updated working directory and git context at resume time */ diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/SessionStartEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/SessionStartEvent.java index bf8b4e91c..b977ae036 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/SessionStartEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/SessionStartEvent.java @@ -55,6 +55,8 @@ public record SessionStartEventData( @JsonProperty("verbosity") Verbosity verbosity, /** Context tier selected at session creation time for models with tiered context pricing; null when no tier is selected (e.g., non-tiered model) */ @JsonProperty("contextTier") ContextTier contextTier, + /** Auto routing preference selected at session creation time */ + @JsonProperty("autoTier") AutoTier autoTier, /** Session limits configured at session creation time, if any */ @JsonProperty("sessionLimits") SessionLimitsConfig sessionLimits, /** Working directory and git context at session start */ diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/AutoTier.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/AutoTier.java new file mode 100644 index 000000000..a4433e1ea --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/AutoTier.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import javax.annotation.processing.Generated; + +/** + * Routing preference used when the session model is `auto`. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public enum AutoTier { + /** The {@code efficiency} variant. */ + EFFICIENCY("efficiency"), + /** The {@code balance} variant. */ + BALANCE("balance"), + /** The {@code intelligence} variant. */ + INTELLIGENCE("intelligence"); + + private final String value; + AutoTier(String value) { this.value = value; } + @com.fasterxml.jackson.annotation.JsonValue + public String getValue() { return value; } + @com.fasterxml.jackson.annotation.JsonCreator + public static AutoTier fromValue(String value) { + for (AutoTier v : values()) { + if (v.value.equals(value)) return v; + } + throw new IllegalArgumentException("Unknown AutoTier value: " + value); + } +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CapiSessionOptions.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CapiSessionOptions.java index 27fd29128..e77117b2c 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CapiSessionOptions.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CapiSessionOptions.java @@ -21,6 +21,8 @@ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public record CapiSessionOptions( + /** Routing preference used when the session model is `auto`. The runtime persists the preference across cold resume. When omitted, the default routing behavior is used. Resuming an already-resident session cannot change its preference. */ + @JsonProperty("autoTier") AutoTier autoTier, /** Whether to use WebSocket transport for the CAPI Responses API. Enabled by default when the model advertises `ws:/responses` support; set to `false` to force the HTTP Responses transport in environments where WebSockets are blocked (e.g. behind a proxy). Setting this to `false` is equivalent to the `COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES` environment variable. */ @JsonProperty("enableWebSocketResponses") Boolean enableWebSocketResponses ) { diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/AutoTier.java b/java/sdk/src/main/java/com/github/copilot/rpc/AutoTier.java new file mode 100644 index 000000000..f9117abfb --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/rpc/AutoTier.java @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Routing tier for the {@code auto} model with Auto mode V2. + * + * @see CapiSessionOptions#setAutoTier(AutoTier) + */ +public enum AutoTier { + + /** Prioritize efficiency. */ + EFFICIENCY("efficiency"), + + /** Balance efficiency and intelligence. */ + BALANCE("balance"), + + /** Prioritize intelligence. */ + INTELLIGENCE("intelligence"); + + private final String value; + + AutoTier(String value) { + this.value = value; + } + + /** + * Returns the JSON value for this routing tier. + * + * @return the string value used in JSON serialization + */ + @JsonValue + public String getValue() { + return value; + } + + /** + * Deserializes a JSON string into its routing tier. + * + * @param value + * the JSON string value + * @return the matching tier, or {@code null} if value is {@code null} + * @throws IllegalArgumentException + * if the value does not match a known routing tier + */ + @JsonCreator + public static AutoTier fromValue(String value) { + if (value == null) { + return null; + } + for (AutoTier tier : values()) { + if (tier.value.equals(value)) { + return tier; + } + } + throw new IllegalArgumentException("Unknown AutoTier value: " + value); + } +} 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 d94d59f67..e40176230 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 @@ -29,9 +29,40 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public class CapiSessionOptions { + @JsonProperty("autoTier") + private AutoTier autoTier; + @JsonProperty("enableWebSocketResponses") private Boolean enableWebSocketResponses; + /** + * Gets the routing tier for the {@code auto} model (Auto mode V2). + * + * @return the explicit tier, or {@code null} to leave tier selection to the + * runtime + */ + public AutoTier getAutoTier() { + return autoTier; + } + + /** + * Sets the routing tier, meaningful only with model {@code auto} (Auto mode + * V2). Requires a runtime version that supports {@code 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. + * + * @param autoTier + * the routing tier, or {@code null} to omit it from the request + * @return this config for method chaining + */ + public CapiSessionOptions setAutoTier(AutoTier autoTier) { + this.autoTier = autoTier; + return this; + } + /** * Gets whether CAPI Responses API WebSocket transport is enabled. * diff --git a/java/sdk/src/test/java/com/github/copilot/CapiSessionOptionsTest.java b/java/sdk/src/test/java/com/github/copilot/CapiSessionOptionsTest.java index 17e8f131f..dccb4e9ad 100644 --- a/java/sdk/src/test/java/com/github/copilot/CapiSessionOptionsTest.java +++ b/java/sdk/src/test/java/com/github/copilot/CapiSessionOptionsTest.java @@ -9,12 +9,16 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import com.fasterxml.jackson.databind.JsonNode; +import com.github.copilot.rpc.AutoTier; import com.github.copilot.rpc.CapiSessionOptions; import com.github.copilot.rpc.ResumeSessionConfig; import com.github.copilot.rpc.SessionConfig; @@ -29,6 +33,7 @@ void defaultsAreNull() { var capi = new CapiSessionOptions(); assertNull(capi.getEnableWebSocketResponses()); + assertNull(capi.getAutoTier()); } @Test @@ -37,6 +42,8 @@ void fluentSetterReturnsSameInstance() { assertSame(capi, capi.setEnableWebSocketResponses(true)); assertEquals(Boolean.TRUE, capi.getEnableWebSocketResponses()); + assertSame(capi, capi.setAutoTier(AutoTier.BALANCE)); + assertEquals(AutoTier.BALANCE, capi.getAutoTier()); } @Test @@ -46,6 +53,7 @@ void serializesEnableWebSocketResponses() { JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(capi); assertTrue(json.get("enableWebSocketResponses").asBoolean()); + assertTrue(json.path("autoTier").isMissingNode()); } @Test @@ -55,6 +63,44 @@ void omitsUnsetEnableWebSocketResponses() { JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(capi); assertTrue(json.path("enableWebSocketResponses").isMissingNode()); + assertTrue(json.path("autoTier").isMissingNode()); + assertEquals(0, json.size()); + } + + @ParameterizedTest + @CsvSource({"EFFICIENCY,efficiency", "BALANCE,balance", "INTELLIGENCE,intelligence"}) + void autoTierCanonicalValuesRoundTripAndForward(AutoTier tier, String value) throws Exception { + var mapper = JsonRpcClient.getObjectMapper(); + var capi = new CapiSessionOptions().setAutoTier(tier); + JsonNode json = mapper.valueToTree(capi); + assertEquals(value, json.get("autoTier").asText()); + assertEquals(1, json.size()); + assertEquals(tier, mapper.treeToValue(json, CapiSessionOptions.class).getAutoTier()); + + capi.setEnableWebSocketResponses(false); + var create = SessionRequestBuilder.buildCreateRequest(new SessionConfig().setModel("auto").setCapi(capi), + "session-1"); + var resume = SessionRequestBuilder.buildResumeRequest("session-1", new ResumeSessionConfig().setCapi(capi)); + for (Object request : new Object[]{create, resume}) { + JsonNode requestJson = mapper.valueToTree(request); + assertEquals(value, requestJson.get("capi").get("autoTier").asText()); + assertFalse(requestJson.get("capi").get("enableWebSocketResponses").asBoolean()); + assertEquals(2, requestJson.get("capi").size()); + } + } + + @Test + void autoTierRejectsNoncanonicalValues() { + for (String value : new String[]{"balanced", "Balance", "unknown"}) { + assertThrows(IllegalArgumentException.class, () -> AutoTier.fromValue(value)); + } + assertNull(AutoTier.fromValue(null)); + } + + @Test + void clearingAutoTierOmitsIt() { + var capi = new CapiSessionOptions().setAutoTier(AutoTier.BALANCE).setAutoTier(null); + JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(capi); assertEquals(0, json.size()); } @@ -67,6 +113,7 @@ void createRequestIncludesCapiWhenSet() { assertNotNull(request.getCapi()); assertTrue(json.get("capi").get("enableWebSocketResponses").asBoolean()); + assertTrue(json.get("capi").path("autoTier").isMissingNode()); } @Test @@ -89,6 +136,7 @@ void resumeRequestIncludesCapiWhenSet() { assertNotNull(request.getCapi()); assertTrue(json.get("capi").get("enableWebSocketResponses").asBoolean()); + assertTrue(json.get("capi").path("autoTier").isMissingNode()); } @Test diff --git a/java/sdk/src/test/java/com/github/copilot/SessionAutoTierEventTest.java b/java/sdk/src/test/java/com/github/copilot/SessionAutoTierEventTest.java new file mode 100644 index 000000000..5213cdbb8 --- /dev/null +++ b/java/sdk/src/test/java/com/github/copilot/SessionAutoTierEventTest.java @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * 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.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import com.github.copilot.generated.AutoTier; +import com.github.copilot.generated.SessionEvent; +import com.github.copilot.generated.SessionResumeEvent; +import com.github.copilot.generated.SessionStartEvent; + +/** + * Verifies auto routing preferences on generated session lifecycle events. + */ +class SessionAutoTierEventTest { + + private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper(); + + @ParameterizedTest + @CsvSource({"session.start,EFFICIENCY,efficiency", "session.start,BALANCE,balance", + "session.start,INTELLIGENCE,intelligence", "session.resume,EFFICIENCY,efficiency", + "session.resume,BALANCE,balance", "session.resume,INTELLIGENCE,intelligence"}) + void canonicalAutoTierRoundTrips(String type, AutoTier tier, String value) throws Exception { + String json = """ + {"type":"%s","data":{"selectedModel":"auto","autoTier":"%s"}} + """.formatted(type, value); + + var event = MAPPER.readValue(json, SessionEvent.class); + assertEquals(tier, autoTier(event, type)); + String serialized = MAPPER.writeValueAsString(event); + assertEquals(value, MAPPER.readTree(serialized).path("data").path("autoTier").asText()); + assertEquals(tier, autoTier(MAPPER.readValue(serialized, SessionEvent.class), type)); + } + + @ParameterizedTest + @ValueSource(strings = {"session.start", "session.resume"}) + void missingOrNullAutoTierRemainsOptional(String type) throws Exception { + for (String data : new String[]{"{}", "{\"autoTier\":null}"}) { + String json = """ + {"type":"%s","data":%s} + """.formatted(type, data); + + var event = MAPPER.readValue(json, SessionEvent.class); + assertNull(autoTier(event, type)); + var serialized = MAPPER.readTree(MAPPER.writeValueAsString(event)); + assertFalse(serialized.path("data").has("autoTier")); + } + } + + private static AutoTier autoTier(SessionEvent event, String type) { + if ("session.start".equals(type)) { + return assertInstanceOf(SessionStartEvent.class, event).getData().autoTier(); + } + return assertInstanceOf(SessionResumeEvent.class, event).getData().autoTier(); + } +} diff --git a/java/sdk/src/test/java/com/github/copilot/SessionEventHandlingTest.java b/java/sdk/src/test/java/com/github/copilot/SessionEventHandlingTest.java index 529c42f2b..b75e71072 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionEventHandlingTest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionEventHandlingTest.java @@ -213,7 +213,7 @@ void testHandlerReceivesCorrectEventData() { SessionStartEvent startEvent = createSessionStartEvent(); startEvent.setData(new SessionStartEvent.SessionStartEventData("my-session-123", null, null, null, null, null, - null, null, null, null, null, null, null, null, null, null)); + null, null, null, null, null, null, null, null, null, null, null)); dispatchEvent(startEvent); AssistantMessageEvent msgEvent = createAssistantMessageEvent("Test content"); @@ -890,7 +890,7 @@ private SessionStartEvent createSessionStartEvent() { private SessionStartEvent createSessionStartEvent(String sessionId) { var event = new SessionStartEvent(); var data = new SessionStartEvent.SessionStartEventData(sessionId, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null); event.setData(data); return event; } diff --git a/nodejs/README.md b/nodejs/README.md index 93f9c3fa6..c8cdc4357 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -131,6 +131,7 @@ Create a new conversation session. - `sessionId?: string` - Custom session ID. - `model?: string` - Model to use ("gpt-5", "claude-sonnet-4.5", etc.). **Required when using custom provider.** +- `capi?: CapiSessionOptions` - Copilot API options. With `model: "auto"`, set `autoTier` to `"efficiency"`, `"balance"`, or `"intelligence"` to choose a routing preference. Requires a runtime with Auto tier support and V2 Auto routing. Omission preserves default behavior. See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) for resume semantics. - `reasoningEffort?: "low" | "medium" | "high" | "xhigh" | "max"` - Reasoning effort level for models that support it. Use `listModels()` to check which models support this option. - `tools?: Tool[]` - Custom tools exposed to the CLI. Tools without `handler` are declaration-only and must be resolved via pending tool-call RPCs. - `systemMessage?: SystemMessageConfig` - System message customization (see below) diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 3b8e6cb86..f62cc6764 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.82-1", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" @@ -658,8 +658,8 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.82-0", - "integrity": "sha512-fSZVNAzFFYaS6btYD0+cKF7SrrtOklhpkPs/cIMZY7Fgxoa6rfZrlTWXlQNgNIdwLp51XxwTfxnZO+D9+MQ5yg==", + "version": "1.0.82-1", + "integrity": "sha512-YogYPdxH12MQDfTZr5jbwSEsxufgCixsKQwqLZbBjW02D1NBR2H4NgIIOiIQ7gs+8sS/wBq5Y/lL9+c0n321pg==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -668,19 +668,19 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.82-0", - "@github/copilot-darwin-x64": "1.0.82-0", - "@github/copilot-linux-arm64": "1.0.82-0", - "@github/copilot-linux-x64": "1.0.82-0", - "@github/copilot-linuxmusl-arm64": "1.0.82-0", - "@github/copilot-linuxmusl-x64": "1.0.82-0", - "@github/copilot-win32-arm64": "1.0.82-0", - "@github/copilot-win32-x64": "1.0.82-0" + "@github/copilot-darwin-arm64": "1.0.82-1", + "@github/copilot-darwin-x64": "1.0.82-1", + "@github/copilot-linux-arm64": "1.0.82-1", + "@github/copilot-linux-x64": "1.0.82-1", + "@github/copilot-linuxmusl-arm64": "1.0.82-1", + "@github/copilot-linuxmusl-x64": "1.0.82-1", + "@github/copilot-win32-arm64": "1.0.82-1", + "@github/copilot-win32-x64": "1.0.82-1" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.82-0", - "integrity": "sha512-TzBYfyvxcw3z9Mu7U8TsFo/Nq7m5XS6ahT71aPL+gx/YId0kmenI27b9daXsK6LA1D0gsFGwNBDKINddqntt1g==", + "version": "1.0.82-1", + "integrity": "sha512-Ny9JdK5o1XuJm+7vhlrxm8rhvoUbo6fAIOF8Fchdjknp6NR9dMk/dC/wSG30Kf2Fx44BuxuBYUNwH8CPwOaHbg==", "cpu": [ "arm64" ], @@ -694,8 +694,8 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.82-0", - "integrity": "sha512-Lm/U5Q8kN8yEeBTWKTfIxXAgXaT6zqdBzAAO7lA4DWHZ92AEeZZiVTs6jEWsQ2aWB2uMs6zbn0vi6t+/jIqCGw==", + "version": "1.0.82-1", + "integrity": "sha512-l7L5sY/cWyllj+t5vPmq67YlO3Ect5qtaMCn3SrdGpDkm+KXu9Z86ap1YTL6OB25q6XE5+S2z5/r0Z9RHfKzYA==", "cpu": [ "x64" ], @@ -709,8 +709,8 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.82-0", - "integrity": "sha512-YERVMC1Q4p6l6KQHL5rVOI52rWbvgp9IwyzUBaVSGrfFuqu5BEvZ9bgHPsxTYi3Npkt5KVOXEyPMU5rAY09qQQ==", + "version": "1.0.82-1", + "integrity": "sha512-eg3+W6g4HZMSb+WyX2wVVdLZkvFYgC7HgNZ8+29E0v4ukoDRebHnXFBFRssvQw1fhA8dM99kX+m5rBsE339glg==", "cpu": [ "arm64" ], @@ -724,8 +724,8 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.82-0", - "integrity": "sha512-z2hxMVjqt4+xDRFTZv3/0K3X+aqcJhd6zPO2JxCpOVTh5CNZFaWk+XIa2iXAPWxFqdKJsQ4muXMl3zInaAOkRw==", + "version": "1.0.82-1", + "integrity": "sha512-iEEpOIrzrTxs91wdGjcmoJA2zMlBB/0bVK159PE0hHTd/QNdE2o22BRscwFu6R+l4+KvIQIhoMoIvLCzOTxVzw==", "cpu": [ "x64" ], @@ -739,8 +739,8 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.82-0", - "integrity": "sha512-EcUCv2PKhBzCCvpTaS511VYTDWyhudyIRPvBpc9gFNO3hjlgiNDusf4k9vP6+E3/lHenwGYzMsyRHcYIOy4vcQ==", + "version": "1.0.82-1", + "integrity": "sha512-k64C/mVrtwCoVzQy0e29DDgZYI3oOSiCNrPet3/miQe+8kW1FGXRYakudPVnx6P2GdGR+VuRry2ATMp64WguEQ==", "cpu": [ "arm64" ], @@ -754,8 +754,8 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.82-0", - "integrity": "sha512-1fKVjUiZ1tdb0/d/re90EpFGXhlIPfjENp2Wo/2Kj592dWO3+IwM2qV/AOdMQ4pacW5iYHII7nibx1/EYq3LGQ==", + "version": "1.0.82-1", + "integrity": "sha512-tG3CNiaS2QiElJpFn+EKGxNtRXkzQJ/KJlK8Wbh/ffAFz1VvewhwsasjD3vmqowsX2wHMthds/B7l+DkDXjtxg==", "cpu": [ "x64" ], @@ -769,8 +769,8 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.82-0", - "integrity": "sha512-H341wuxQHhwe/yLmORHzwC3DGzFZgGzh+TpwfyK2zjeYVbwDZ3Bax8M+9CzIED9jIBdEfAmfGzxKUHFugpXTtQ==", + "version": "1.0.82-1", + "integrity": "sha512-dFWwEYODzFzw6aA03VuRIPk80Q8isu0x+8wXxlTAXzWrETs7bc8dtE9Wh2ZLFPCuidRiitMxwuD7Dt4KF9bQiQ==", "cpu": [ "arm64" ], @@ -784,8 +784,8 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.82-0", - "integrity": "sha512-f1ba3gG8NaoYWFHtHaHcLN4It7mclkWdCOXvwFPqPEwqCEIx/+Zh6VHiOeIcNWRS0elRP6QYDCKaTDy1TW27uQ==", + "version": "1.0.82-1", + "integrity": "sha512-w/ti4ipLbcZjoXa2WRV8dEbOXB0Av3WQe0Uua7Wbp5E2UgXhY6grg/hWErHlRS1fa/nnqfr8CyxLv3Mvm78qyQ==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index 89863520e..c9be6b430 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,7 +56,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.82-1", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index ad675a88c..d67610860 100644 --- a/nodejs/samples/package-lock.json +++ b/nodejs/samples/package-lock.json @@ -18,7 +18,7 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.82-1", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index db0ea63dc..92e0e31dc 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -5,7 +5,7 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; -import type { AbortReason, Attachment, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, McpServerSource, McpServerStatus, ModelChangeSource, PermissionMode, PermissionPromptRequest, PermissionRule, ReasoningSummary, SessionEvent, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, TaskCompleteData, TaskCompletionOutcome, UserToolSessionApproval, Verbosity } from "./session-events.js"; +import type { AbortReason, Attachment, AutoTier, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, McpServerSource, McpServerStatus, ModelChangeSource, PermissionMode, PermissionPromptRequest, PermissionRule, ReasoningSummary, SessionEvent, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, TaskCompleteData, TaskCompletionOutcome, UserToolSessionApproval, Verbosity } from "./session-events.js"; /** A value that can be represented losslessly on the SDK JSON wire. */ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; @@ -5487,6 +5487,7 @@ export interface CanvasProviderUnregisterRequest { */ /** @experimental */ export interface CapiSessionOptions { + autoTier?: AutoTier; /** * Whether to use WebSocket transport for the CAPI Responses API. Enabled by default when the model advertises `ws:/responses` support; set to `false` to force the HTTP Responses transport in environments where WebSockets are blocked (e.g. behind a proxy). Setting this to `false` is equivalent to the `COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES` environment variable. */ diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 907537998..1c1c82e4d 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -135,6 +135,16 @@ export type SessionEvent = | CanvasRemovedEvent | ExtensionsAttachmentsPushedEvent | McpAppToolCallCompleteEvent; +/** + * Routing preference used when the session model is `auto`. + */ +export type AutoTier = + /** Optimize for efficiency. */ + | "efficiency" + /** Balance efficiency and intelligence. */ + | "balance" + /** Optimize for intelligence. */ + | "intelligence"; /** * Hosting platform type of the repository (github or ado) */ @@ -1106,6 +1116,7 @@ export interface StartData { * Whether the session was already in use by another client at start time */ alreadyInUse?: boolean; + autoTier?: AutoTier; context?: WorkingDirectoryContext; /** * Context tier selected at session creation time for models with tiered context pricing; null when no tier is selected (e.g., non-tiered model) @@ -1258,6 +1269,7 @@ export interface ResumeData { * Whether the session was already in use by another client at resume time */ alreadyInUse?: boolean; + autoTier?: AutoTier; context?: WorkingDirectoryContext; /** * Context tier currently selected at resume time; null when no tier is active diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 9d55ab1d1..a57997dd9 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -119,6 +119,7 @@ export type { ModelBilling, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, + AutoTier, CapiSessionOptions, ModelCapabilities, ModelCapabilitiesOverride, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 616e15a46..1687295bd 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -11,6 +11,7 @@ import type { Canvas } from "./canvas.js"; import type { SessionFsProvider } from "./sessionFsProvider.js"; import type { CopilotRequestHandler } from "./copilotRequestHandler.js"; import type { + AutoTier, PermissionRequest as GeneratedPermissionRequest, PermissionRequestedData as GeneratedPermissionRequestedData, PermissionRequestedEvent as GeneratedPermissionRequestedEvent, @@ -72,7 +73,7 @@ export type { export type SessionEvent = | Exclude | PermissionRequestedEvent; -export type { ReasoningSummary } from "./generated/session-events.js"; +export type { AutoTier, ReasoningSummary } from "./generated/session-events.js"; export type { SessionFsProvider } from "./sessionFsProvider.js"; export { createSessionFsAdapter } from "./sessionFsProvider.js"; export type { SessionFsFileInfo } from "./sessionFsProvider.js"; @@ -2129,6 +2130,17 @@ export interface FactoryMeta { * provider-level choices are conceptually per-provider rather than global. */ export interface CapiSessionOptions { + /** + * Routing preference used when the session model is `auto`. + * 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. + */ + autoTier?: AutoTier; + /** * Whether to use the WebSocket transport for the CAPI Responses API. * diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 3ffda2fa7..9c19112ff 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -12,6 +12,7 @@ import { createCanvas, DisableBypassPermissionsModes, RuntimeConnection, + type CapiSessionOptions, type GitHubTelemetryNotification, type ManagedSettings, type ModelInfo, @@ -1276,37 +1277,50 @@ describe("CopilotClient", () => { expect(resumePayload.expAssignments).toBeUndefined(); }); - it("forwards capi options in session.create and session.resume", async () => { - const client = new CopilotClient(); - await client.start(); - onTestFinished(() => stopClient(client)); + it.each([ + undefined, + {}, + { enableWebSocketResponses: false }, + { enableWebSocketResponses: true }, + { autoTier: "efficiency" }, + { autoTier: "balance" }, + { autoTier: "intelligence" }, + { autoTier: "balance", enableWebSocketResponses: false }, + ] satisfies (CapiSessionOptions | undefined)[])( + "forwards capi options %j in session.create and session.resume", + async (capi) => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); - const spy = vi - .spyOn((client as any).connection!, "sendRequest") - .mockImplementation(async (method: string, params: any) => { - if (method === "session.create") return { sessionId: params.sessionId }; - if (method === "session.resume") return { sessionId: params.sessionId }; - throw new Error(`Unexpected method: ${method}`); - }); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); - const session = await client.createSession({ - onPermissionRequest: approveAll, - capi: { enableWebSocketResponses: false }, - }); - await client.resumeSession(session.sessionId, { - onPermissionRequest: approveAll, - capi: { enableWebSocketResponses: false }, - }); + const session = await client.createSession({ + onPermissionRequest: approveAll, + model: "auto", + capi, + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + capi, + }); - const createPayload = spy.mock.calls.find( - ([method]) => method === "session.create" - )![1] as any; - const resumePayload = spy.mock.calls.find( - ([method]) => method === "session.resume" - )![1] as any; - expect(createPayload.capi).toEqual({ enableWebSocketResponses: false }); - expect(resumePayload.capi).toEqual({ enableWebSocketResponses: false }); - }); + const createPayload = spy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + const resumePayload = spy.mock.calls.find( + ([method]) => method === "session.resume" + )![1] as any; + expect(JSON.parse(JSON.stringify(createPayload)).capi).toEqual(capi); + expect(JSON.parse(JSON.stringify(resumePayload)).capi).toEqual(capi); + } + ); it("forwards pluginDirectories and largeOutput in session.create and session.resume", async () => { const client = new CopilotClient(); diff --git a/nodejs/test/session-event-types.test.ts b/nodejs/test/session-event-types.test.ts index 5c41f2216..93edebfc8 100644 --- a/nodejs/test/session-event-types.test.ts +++ b/nodejs/test/session-event-types.test.ts @@ -21,6 +21,8 @@ import type { FactoryAgentOptions as WireFactoryAgentOptions } from "../src/gene import type { // The aggregate union; must still resolve via the package root. SessionEvent, + AutoTier, + CapiSessionOptions, PermissionRequest, PermissionRequestedData, PermissionRequestedEvent, @@ -128,6 +130,32 @@ type _PermissionRequestedEventStaysAlignedWithSessionEventUnion = _AssertEqual< const _permissionRequestedEventAlignmentCheck: _PermissionRequestedEventStaysAlignedWithSessionEventUnion = true; describe("Session event type exports (#1156)", () => { + it.each(["efficiency", "balance", "intelligence", undefined] satisfies ( + | AutoTier + | undefined + )[])("exposes Auto tier %s on start and resume data", (autoTier) => { + const start: StartData = { + copilotVersion: "1.0.82-1", + producer: "copilot-agent", + sessionId: "session-1", + startTime: "2026-08-28T00:00:00Z", + version: 1, + autoTier, + }; + const resume: ResumeData = { + eventCount: 1, + resumeTime: "2026-08-28T00:01:00Z", + autoTier, + }; + const capi: CapiSessionOptions = { autoTier: start.autoTier }; + expect(capi.autoTier).toBe(autoTier); + expect(resume.autoTier).toBe(autoTier); + if (autoTier === undefined) { + expect(JSON.parse(JSON.stringify(start))).not.toHaveProperty("autoTier"); + expect(JSON.parse(JSON.stringify(resume))).not.toHaveProperty("autoTier"); + } + }); + 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 61608c16a..aaafd8dd1 100644 --- a/python/README.md +++ b/python/README.md @@ -272,6 +272,7 @@ finally: These are passed as keyword arguments to `create_session()`: - `model` (str): Model to use ("gpt-5", "claude-sonnet-4.5", etc.). **Required when using custom provider.** +- `capi` (CapiSessionOptions): Copilot API options. With `model="auto"`, set `auto_tier` to `"efficiency"`, `"balance"`, or `"intelligence"` to choose a routing preference. Requires a runtime with Auto tier support and V2 Auto routing. Omission preserves default behavior. See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) for resume semantics. - `reasoning_effort` (str): Reasoning effort level for models that support it ("low", "medium", "high", "xhigh", "max"). Use `list_models()` to check which models support this option. - `session_id` (str): Custom session ID - `tools` (list): Custom tools exposed to the CLI. Tools with `handler=None` are declaration-only and must be resolved via pending tool-call RPCs. diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 608dacf25..00ea25dce 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -29,6 +29,7 @@ OpenCanvasInstance, ) from .client import ( + AutoTier, CapiSessionOptions, ChildProcessRuntimeConnection, CloudSessionOptions, @@ -229,6 +230,7 @@ "AutoModeSwitchHandler", "AutoModeSwitchRequest", "AutoModeSwitchResponse", + "AutoTier", "BUILTIN_TOOLS_ISOLATED", "CanvasAction", "CanvasDeclaration", diff --git a/python/copilot/client.py b/python/copilot/client.py index 271fad626..506ca01d4 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -260,9 +260,23 @@ 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.""" + auto_tier: AutoTier + """Routing preference used when the session model is ``auto``. + + 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. + """ + enable_web_socket_responses: bool """Whether to use WebSocket transport for the CAPI Responses API. @@ -289,6 +303,8 @@ def _cloud_session_options_to_dict(options: CloudSessionOptions) -> dict[str, An def _capi_session_options_to_wire(options: CapiSessionOptions) -> dict[str, Any]: wire: dict[str, Any] = {} + if "auto_tier" in options: + wire["autoTier"] = options["auto_tier"] if "enable_web_socket_responses" in options: wire["enableWebSocketResponses"] = options["enable_web_socket_responses"] return wire @@ -2314,7 +2330,9 @@ async def create_session( hooks: Lifecycle hooks for the session. working_directory: Working directory for the session. provider: Provider configuration for Azure or custom endpoints. - capi: CAPI provider-scoped options. WebSocket transport is the + capi: CAPI provider-scoped options. Set ``auto_tier`` to ``efficiency``, + ``balance``, or ``intelligence`` to select an Auto routing preference + on a runtime with Auto tier support. WebSocket transport is the default for the CAPI Responses API whenever the model advertises the ``ws:/responses`` endpoint. Set ``enable_web_socket_responses=False`` to force the HTTP @@ -3076,7 +3094,10 @@ async def resume_session( hooks: Lifecycle hooks for the session. working_directory: Working directory for the session. provider: Provider configuration for Azure or custom endpoints. - capi: CAPI provider-scoped options. WebSocket transport is the + capi: CAPI provider-scoped options. Omit ``auto_tier`` to preserve the + current or persisted Auto routing preference. An explicit tier + overrides it on cold resume, but cannot change it on an + already-resident session. WebSocket transport is the default for the CAPI Responses API whenever the model advertises the ``ws:/responses`` endpoint. Set ``enable_web_socket_responses=False`` to force the HTTP diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 1d59a55f4..e4c29d3b7 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -6,7 +6,7 @@ from typing import ClassVar, TYPE_CHECKING -from .session_events import AbortReason, Attachment, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, McpServerSource, McpServerStatus, ModelChangeSource, PermissionMode, PermissionPromptRequest, PermissionRule, ReasoningSummary, SessionEvent, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, TaskCompletionOutcome, UserToolSessionApproval, Verbosity +from .session_events import AbortReason, Attachment, AutoTier, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, McpServerSource, McpServerStatus, ModelChangeSource, PermissionMode, PermissionPromptRequest, PermissionRule, ReasoningSummary, SessionEvent, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, TaskCompletionOutcome, UserToolSessionApproval, Verbosity if TYPE_CHECKING: from .._jsonrpc import JsonRpcClient @@ -1051,6 +1051,11 @@ def to_dict(self) -> dict: class CapiSessionOptions: """Options scoped to the built-in CAPI (Copilot API) provider.""" + auto_tier: AutoTier | None = None + """Routing preference used when the session model is `auto`. The runtime persists the + preference across cold resume. When omitted, the default routing behavior is used. + Resuming an already-resident session cannot change its preference. + """ enable_web_socket_responses: bool | None = None """Whether to use WebSocket transport for the CAPI Responses API. Enabled by default when the model advertises `ws:/responses` support; set to `false` to force the HTTP Responses @@ -1062,11 +1067,14 @@ class CapiSessionOptions: @staticmethod def from_dict(obj: Any) -> 'CapiSessionOptions': assert isinstance(obj, dict) + auto_tier = from_union([AutoTier, from_none], obj.get("autoTier")) enable_web_socket_responses = from_union([from_bool, from_none], obj.get("enableWebSocketResponses")) - return CapiSessionOptions(enable_web_socket_responses) + return CapiSessionOptions(auto_tier, enable_web_socket_responses) def to_dict(self) -> dict: result: dict = {} + if self.auto_tier is not None: + result["autoTier"] = from_union([lambda x: to_enum(AutoTier, x), from_none], self.auto_tier) if self.enable_web_socket_responses is not None: result["enableWebSocketResponses"] = from_union([from_bool, from_none], self.enable_web_socket_responses) return result diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index c22f9f24f..cf65473ab 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -8222,6 +8222,7 @@ class SessionResumeData: event_count: int resume_time: datetime already_in_use: bool | None = None + auto_tier: AutoTier | None = None context: WorkingDirectoryContext | None = None context_tier: ContextTier | None = None continue_pending_work: bool | None = None @@ -8240,6 +8241,7 @@ def from_dict(obj: Any) -> "SessionResumeData": event_count = from_int(obj.get("eventCount")) resume_time = from_datetime(obj.get("resumeTime")) already_in_use = from_union([from_none, from_bool], obj.get("alreadyInUse")) + auto_tier = from_union([from_none, lambda x: parse_enum(AutoTier, x)], obj.get("autoTier")) context = from_union([from_none, WorkingDirectoryContext.from_dict], obj.get("context")) context_tier = from_union([from_none, lambda x: parse_enum(ContextTier, x)], obj.get("contextTier")) continue_pending_work = from_union([from_none, from_bool], obj.get("continuePendingWork")) @@ -8255,6 +8257,7 @@ def from_dict(obj: Any) -> "SessionResumeData": event_count=event_count, resume_time=resume_time, already_in_use=already_in_use, + auto_tier=auto_tier, context=context, context_tier=context_tier, continue_pending_work=continue_pending_work, @@ -8274,6 +8277,8 @@ def to_dict(self) -> dict: result["resumeTime"] = to_datetime(self.resume_time) if self.already_in_use is not None: result["alreadyInUse"] = from_union([from_none, from_bool], self.already_in_use) + if self.auto_tier is not None: + result["autoTier"] = from_union([from_none, lambda x: to_enum(AutoTier, x)], self.auto_tier) if self.context is not None: result["context"] = from_union([from_none, lambda x: to_class(WorkingDirectoryContext, x)], self.context) if self.context_tier is not None: @@ -8566,6 +8571,7 @@ class SessionStartData: start_time: datetime version: int already_in_use: bool | None = None + auto_tier: AutoTier | None = None context: WorkingDirectoryContext | None = None context_tier: ContextTier | None = None detached_from_spawning_parent_session_id: str | None = None @@ -8586,6 +8592,7 @@ def from_dict(obj: Any) -> "SessionStartData": start_time = from_datetime(obj.get("startTime")) version = from_int(obj.get("version")) already_in_use = from_union([from_none, from_bool], obj.get("alreadyInUse")) + auto_tier = from_union([from_none, lambda x: parse_enum(AutoTier, x)], obj.get("autoTier")) context = from_union([from_none, WorkingDirectoryContext.from_dict], obj.get("context")) context_tier = from_union([from_none, lambda x: parse_enum(ContextTier, x)], obj.get("contextTier")) detached_from_spawning_parent_session_id = from_union([from_none, from_str], obj.get("detachedFromSpawningParentSessionId")) @@ -8603,6 +8610,7 @@ def from_dict(obj: Any) -> "SessionStartData": start_time=start_time, version=version, already_in_use=already_in_use, + auto_tier=auto_tier, context=context, context_tier=context_tier, detached_from_spawning_parent_session_id=detached_from_spawning_parent_session_id, @@ -8624,6 +8632,8 @@ def to_dict(self) -> dict: result["version"] = to_int(self.version) if self.already_in_use is not None: result["alreadyInUse"] = from_union([from_none, from_bool], self.already_in_use) + if self.auto_tier is not None: + result["autoTier"] = from_union([from_none, lambda x: to_enum(AutoTier, x)], self.auto_tier) if self.context is not None: result["context"] = from_union([from_none, lambda x: to_class(WorkingDirectoryContext, x)], self.context) if self.context_tier is not None: @@ -11695,6 +11705,16 @@ class AutoModeSwitchResponse(Enum): NO = "no" +class AutoTier(Enum): + "Routing preference used when the session model is `auto`." + # Optimize for efficiency. + EFFICIENCY = "efficiency" + # Balance efficiency and intelligence. + BALANCE = "balance" + # Optimize for intelligence. + INTELLIGENCE = "intelligence" + + class AutopilotObjectiveChangedOperation(Enum): "The type of operation performed on the autopilot objective state file" # Autopilot objective state file was created for a new objective. @@ -12530,6 +12550,7 @@ def session_event_to_dict(x: SessionEvent) -> Any: "AutoModeSwitchCompletedData", "AutoModeSwitchRequestedData", "AutoModeSwitchResponse", + "AutoTier", "AutopilotObjectiveChangedOperation", "AutopilotObjectiveChangedStatus", "BinaryAssetReference", diff --git a/python/test_client.py b/python/test_client.py index a33f0ecd6..e47abb40d 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -1071,7 +1071,25 @@ async def mock_request(method, params, **kwargs): await client.force_stop() @pytest.mark.asyncio - async def test_create_and_resume_session_forward_capi_options(self): + @pytest.mark.parametrize( + ("capi", "expected"), + [ + (None, None), + ({}, {}), + ({"enable_web_socket_responses": False}, {"enableWebSocketResponses": False}), + ({"enable_web_socket_responses": True}, {"enableWebSocketResponses": True}), + ({"auto_tier": "efficiency"}, {"autoTier": "efficiency"}), + ({"auto_tier": "balance"}, {"autoTier": "balance"}), + ({"auto_tier": "intelligence"}, {"autoTier": "intelligence"}), + ( + {"auto_tier": "balance", "enable_web_socket_responses": False}, + {"autoTier": "balance", "enableWebSocketResponses": False}, + ), + ], + ) + async def test_create_and_resume_session_forward_capi_options( + self, capi: CapiSessionOptions | None, expected: dict[str, object] | None + ): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) await client.start() try: @@ -1088,25 +1106,22 @@ async def mock_request(method, params, **kwargs): return {} client._client.request = mock_request - create_capi: CapiSessionOptions = {"enable_web_socket_responses": False} - resume_capi: CapiSessionOptions = {"enable_web_socket_responses": True} - session = await client.create_session( on_permission_request=PermissionHandler.approve_all, - capi=create_capi, + model="auto", + capi=capi, ) await client.resume_session( session.session_id, on_permission_request=PermissionHandler.approve_all, - capi=resume_capi, + capi=capi, ) - assert captured["session.create"]["capi"] == { - "enableWebSocketResponses": False, - } - assert captured["session.resume"]["capi"] == { - "enableWebSocketResponses": True, - } + for method in ("session.create", "session.resume"): + if capi is None: + assert "capi" not in captured[method] + else: + assert captured[method]["capi"] == expected finally: await client.force_stop() diff --git a/python/test_event_forward_compatibility.py b/python/test_event_forward_compatibility.py index 2e8015a97..00cad4321 100644 --- a/python/test_event_forward_compatibility.py +++ b/python/test_event_forward_compatibility.py @@ -14,6 +14,7 @@ from copilot.session_events import ( AttachmentGitHubReferenceType, + AutoTier, Data, ElicitationCompletedAction, ElicitationRequestedMode, @@ -24,6 +25,8 @@ PermissionRequestMemoryAction, SessionEventType, SessionManagedSettingsResolvedData, + SessionResumeData, + SessionStartData, SessionTaskCompleteData, UserMessageAgentMode, session_event_from_dict, @@ -34,6 +37,40 @@ class TestEventForwardCompatibility: """Test forward compatibility for unknown event types.""" + @pytest.mark.parametrize("event_type", ["session.start", "session.resume"]) + @pytest.mark.parametrize("tier", ["efficiency", "balance", "intelligence", None]) + def test_auto_tier_lifecycle_events_round_trip(self, event_type, tier): + timestamp = "2026-08-28T00:00:00Z" + data = ( + { + "copilotVersion": "1.0.82-1", + "producer": "copilot-agent", + "sessionId": str(uuid4()), + "startTime": timestamp, + "version": 1, + } + if event_type == "session.start" + else {"eventCount": 1, "resumeTime": timestamp} + ) + if tier is not None: + data["autoTier"] = tier + event = session_event_from_dict( + { + "id": str(uuid4()), + "timestamp": timestamp, + "parentId": None, + "type": event_type, + "data": data, + } + ) + assert isinstance(event.data, (SessionStartData, SessionResumeData)) + assert event.data.auto_tier == (AutoTier(tier) if tier is not None else None) + serialized = session_event_to_dict(event)["data"] + if tier is None: + assert "autoTier" not in serialized + else: + assert serialized["autoTier"] == tier + 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 323d525d3..4cdc2916f 100644 --- a/rust/README.md +++ b/rust/README.md @@ -302,6 +302,30 @@ provider errors, and invalid token responses reject that operation instead of falling back to ambient authentication. Idle sessions refresh only before their next credential-consuming operation; there is no background refresh timer. +### Auto routing tiers + +Use `CapiSessionOptions::with_auto_tier` to select `AutoTier::Efficiency`, +`AutoTier::Balance`, or `AutoTier::Intelligence`. This option is meaningful only +with model `auto` (Auto mode V2). +It requires a runtime version that supports `capi.autoTier`. + +```rust +use github_copilot_sdk::{AutoTier, CapiSessionOptions, SessionConfig}; + +let config = SessionConfig::default() + .with_model("auto") + .with_capi(CapiSessionOptions::new().with_auto_tier(AutoTier::Balance)); +``` + +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. +See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) +for the lifecycle rules. + ### Session Hooks Hooks intercept CLI behavior at lifecycle points — tool use, prompt submission, session start/end, and errors. Install a `SessionHooks` impl with [`SessionConfig::with_hooks`] — the SDK auto-enables `hooks` in `SessionConfig` when one is set. diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index e22c888f2..12cb010be 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -10,10 +10,11 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use super::session_events::{ - AbortReason, ContextTier, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, McpServerSource, - McpServerStatus, ModelChangeSource, OmittedBinaryOmittedReason, PermissionMode, - PermissionPromptRequest, PermissionRule, ReasoningSummary, SessionLimitsConfig, SessionMode, - ShutdownType, SkillSource, TaskCompletionOutcome, UserToolSessionApproval, Verbosity, + AbortReason, AutoTier, ContextTier, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, + McpServerSource, McpServerStatus, ModelChangeSource, OmittedBinaryOmittedReason, + PermissionMode, PermissionPromptRequest, PermissionRule, ReasoningSummary, SessionLimitsConfig, + SessionMode, ShutdownType, SkillSource, TaskCompletionOutcome, UserToolSessionApproval, + Verbosity, }; use crate::types::{RequestId, SessionEvent, SessionId}; @@ -3019,6 +3020,9 @@ pub struct CanvasProviderUnregisterRequest { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CapiSessionOptions { + /// Routing preference used when the session model is `auto`. The runtime persists the preference across cold resume. When omitted, the default routing behavior is used. Resuming an already-resident session cannot change its preference. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_tier: Option, /// Whether to use WebSocket transport for the CAPI Responses API. Enabled by default when the model advertises `ws:/responses` support; set to `false` to force the HTTP Responses transport in environments where WebSockets are blocked (e.g. behind a proxy). Setting this to `false` is equivalent to the `COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES` environment variable. #[serde(skip_serializing_if = "Option::is_none")] pub enable_web_socket_responses: Option, diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index 79284b167..47ac50481 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -939,6 +939,9 @@ pub struct SessionStartData { /// Whether the session was already in use by another client at start time #[serde(skip_serializing_if = "Option::is_none")] pub already_in_use: Option, + /// Auto routing preference selected at session creation time + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_tier: Option, /// Working directory and git context at session start #[serde(skip_serializing_if = "Option::is_none")] pub context: Option, @@ -988,6 +991,9 @@ pub struct SessionResumeData { /// Whether the session was already in use by another client at resume time #[serde(skip_serializing_if = "Option::is_none")] pub already_in_use: Option, + /// Auto routing preference active at resume time + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_tier: Option, /// Updated working directory and git context at resume time #[serde(skip_serializing_if = "Option::is_none")] pub context: Option, @@ -6321,6 +6327,24 @@ pub struct McpAppToolCallCompleteData { pub tool_name: String, } +/// Routing preference used when the session model is `auto`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AutoTier { + /// Optimize for efficiency. + #[serde(rename = "efficiency")] + Efficiency, + /// Balance efficiency and intelligence. + #[serde(rename = "balance")] + Balance, + /// Optimize for intelligence. + #[serde(rename = "intelligence")] + Intelligence, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + /// Hosting platform type of the repository (github or ado) #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum WorkingDirectoryContextHostType { diff --git a/rust/src/types.rs b/rust/src/types.rs index 6e451eb45..10993a749 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}; +/// Routing tier for the `auto` model with Auto mode V2. +pub use crate::generated::session_events::AutoTier; use crate::generated::session_events::ReasoningSummary; /// Context window tier for models that support tiered context windows. pub use crate::generated::session_events::{ContextTier, SessionLimitsConfig}; @@ -1401,6 +1403,8 @@ impl ProviderConfig { } } +impl Copy for AutoTier {} + /// Provider-scoped Copilot API (CAPI) session options. /// /// WebSocket transport is the default for the CAPI Responses API whenever @@ -1417,6 +1421,16 @@ impl ProviderConfig { #[serde(rename_all = "camelCase")] #[non_exhaustive] 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. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_tier: Option, + /// Whether to use WebSocket transport for CAPI Responses API calls. /// /// When `Some(false)`, the runtime uses HTTP Responses transport even if @@ -1432,6 +1446,12 @@ impl CapiSessionOptions { Self::default() } + /// Set the routing tier for the `auto` model (Auto mode V2). + pub fn with_auto_tier(mut self, auto_tier: AutoTier) -> Self { + self.auto_tier = Some(auto_tier); + self + } + /// Set whether to use WebSocket transport for CAPI Responses API calls. pub fn with_enable_web_socket_responses(mut self, enable: bool) -> Self { self.enable_web_socket_responses = Some(enable); @@ -5966,9 +5986,9 @@ mod tests { use super::{ AgentMode, Attachment, AttachmentLineRange, AttachmentSelectionPosition, - AttachmentSelectionRange, AzureProviderOptions, CapiSessionOptions, ConnectionState, - CopilotExpAssignmentResponse, CustomAgentConfig, DeliveryMode, ExpConfigEntry, - ExpFlagValue, ExtensionInfo, GitHubMcpToolConfig, GitHubReferenceType, + AttachmentSelectionRange, AutoTier, AzureProviderOptions, CapiSessionOptions, + ConnectionState, CopilotExpAssignmentResponse, CustomAgentConfig, DeliveryMode, + ExpConfigEntry, ExpFlagValue, ExtensionInfo, GitHubMcpToolConfig, GitHubReferenceType, InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, McpStdioServerConfig, MemoryConfiguration, NamedProviderConfig, PermissionResponseCapability, ProviderConfig, ProviderModelConfig, ReasoningSummary, ResumeSessionConfig, SessionConfig, SessionEvent, @@ -7210,6 +7230,56 @@ mod tests { let unset = CapiSessionOptions::new(); let wire_unset = serde_json::to_value(&unset).unwrap(); assert!(wire_unset.get("enableWebSocketResponses").is_none()); + assert!(wire_unset.get("autoTier").is_none()); + assert_eq!(wire_unset, json!({})); + } + + #[test] + fn capi_auto_tier_canonical_values_round_trip_and_forward() { + for (tier, value) in [ + (AutoTier::Efficiency, "efficiency"), + (AutoTier::Balance, "balance"), + (AutoTier::Intelligence, "intelligence"), + ] { + let exported: crate::AutoTier = tier; + let capi = CapiSessionOptions::new().with_auto_tier(exported); + assert_eq!(capi.auto_tier, Some(tier)); + assert_eq!( + serde_json::to_value(&capi).unwrap(), + json!({"autoTier": value}) + ); + assert_eq!( + serde_json::from_value::(json!({"autoTier": value})).unwrap(), + capi + ); + + let capi = capi.with_enable_web_socket_responses(false); + let expected = json!({"autoTier": value, "enableWebSocketResponses": false}); + let (create, _) = SessionConfig::default() + .with_model("auto") + .with_capi(capi.clone()) + .into_wire(Some(SessionId::from("capi-create"))) + .unwrap(); + assert_eq!(serde_json::to_value(create).unwrap()["capi"], expected); + + let (resume, _) = ResumeSessionConfig::new(SessionId::from("capi-resume")) + .with_capi(capi) + .into_wire() + .unwrap(); + assert_eq!(serde_json::to_value(resume).unwrap()["capi"], expected); + } + } + + #[test] + fn capi_auto_tier_accepts_unknown_values_for_forward_compatibility() { + for value in ["balanced", "Balance", "unknown"] { + assert_eq!( + serde_json::from_value::(json!(value)).unwrap(), + AutoTier::Unknown + ); + } + let capi: CapiSessionOptions = serde_json::from_value(json!({})).unwrap(); + assert_eq!(capi.auto_tier, None); } #[test] diff --git a/rust/tests/api_types_test.rs b/rust/tests/api_types_test.rs index 9b86b1367..8ed40e7c7 100644 --- a/rust/tests/api_types_test.rs +++ b/rust/tests/api_types_test.rs @@ -3,11 +3,53 @@ #![allow(clippy::unwrap_used)] +use github_copilot_sdk::AutoTier; use github_copilot_sdk::rpc::{ Extension, ExtensionList, ExtensionSource, ExtensionStatus, ExtensionsDisableRequest, ExtensionsEnableRequest, FleetStartRequest, FleetStartResult, TasksStartAgentRequest, }; -use github_copilot_sdk::session_events::{PermissionRequest, PermissionRequestedData}; +use github_copilot_sdk::session_events::{ + PermissionRequest, PermissionRequestedData, SessionEventData, TypedSessionEvent, +}; + +#[test] +fn session_events_deserialize_auto_tier() { + for event_type in ["session.start", "session.resume"] { + for (tier, wire_tier) in [ + (Some(AutoTier::Efficiency), Some("efficiency")), + (Some(AutoTier::Balance), Some("balance")), + (Some(AutoTier::Intelligence), Some("intelligence")), + (None, None), + ] { + let mut wire = serde_json::json!({ + "id": "11111111-1111-1111-1111-111111111111", + "timestamp": "2026-08-28T00:00:00Z", + "parentId": null, + "type": event_type, + "data": { + "sessionId": "test-session", "version": 1, + "producer": "copilot", "copilotVersion": "1.0.82-1", + "startTime": "2026-08-28T00:00:00Z", + "resumeTime": "2026-08-28T00:00:00Z", "eventCount": 1 + } + }); + if let Some(wire_tier) = wire_tier { + wire["data"]["autoTier"] = serde_json::json!(wire_tier); + } + let event: TypedSessionEvent = serde_json::from_value(wire).unwrap(); + let actual: Option = match event.payload { + SessionEventData::SessionStart(data) if event_type == "session.start" => { + data.auto_tier + } + SessionEventData::SessionResume(data) if event_type == "session.resume" => { + data.auto_tier + } + _ => panic!("expected {event_type}"), + }; + assert_eq!(actual, tier); + } + } +} #[test] fn extension_running_has_expected_status_and_source() { diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index 710d72531..44597b173 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.82-1", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", @@ -472,8 +472,8 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.82-0", - "integrity": "sha512-fSZVNAzFFYaS6btYD0+cKF7SrrtOklhpkPs/cIMZY7Fgxoa6rfZrlTWXlQNgNIdwLp51XxwTfxnZO+D9+MQ5yg==", + "version": "1.0.82-1", + "integrity": "sha512-YogYPdxH12MQDfTZr5jbwSEsxufgCixsKQwqLZbBjW02D1NBR2H4NgIIOiIQ7gs+8sS/wBq5Y/lL9+c0n321pg==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "dependencies": { @@ -483,19 +483,19 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.82-0", - "@github/copilot-darwin-x64": "1.0.82-0", - "@github/copilot-linux-arm64": "1.0.82-0", - "@github/copilot-linux-x64": "1.0.82-0", - "@github/copilot-linuxmusl-arm64": "1.0.82-0", - "@github/copilot-linuxmusl-x64": "1.0.82-0", - "@github/copilot-win32-arm64": "1.0.82-0", - "@github/copilot-win32-x64": "1.0.82-0" + "@github/copilot-darwin-arm64": "1.0.82-1", + "@github/copilot-darwin-x64": "1.0.82-1", + "@github/copilot-linux-arm64": "1.0.82-1", + "@github/copilot-linux-x64": "1.0.82-1", + "@github/copilot-linuxmusl-arm64": "1.0.82-1", + "@github/copilot-linuxmusl-x64": "1.0.82-1", + "@github/copilot-win32-arm64": "1.0.82-1", + "@github/copilot-win32-x64": "1.0.82-1" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.82-0", - "integrity": "sha512-TzBYfyvxcw3z9Mu7U8TsFo/Nq7m5XS6ahT71aPL+gx/YId0kmenI27b9daXsK6LA1D0gsFGwNBDKINddqntt1g==", + "version": "1.0.82-1", + "integrity": "sha512-Ny9JdK5o1XuJm+7vhlrxm8rhvoUbo6fAIOF8Fchdjknp6NR9dMk/dC/wSG30Kf2Fx44BuxuBYUNwH8CPwOaHbg==", "cpu": [ "arm64" ], @@ -510,8 +510,8 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.82-0", - "integrity": "sha512-Lm/U5Q8kN8yEeBTWKTfIxXAgXaT6zqdBzAAO7lA4DWHZ92AEeZZiVTs6jEWsQ2aWB2uMs6zbn0vi6t+/jIqCGw==", + "version": "1.0.82-1", + "integrity": "sha512-l7L5sY/cWyllj+t5vPmq67YlO3Ect5qtaMCn3SrdGpDkm+KXu9Z86ap1YTL6OB25q6XE5+S2z5/r0Z9RHfKzYA==", "cpu": [ "x64" ], @@ -526,8 +526,8 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.82-0", - "integrity": "sha512-YERVMC1Q4p6l6KQHL5rVOI52rWbvgp9IwyzUBaVSGrfFuqu5BEvZ9bgHPsxTYi3Npkt5KVOXEyPMU5rAY09qQQ==", + "version": "1.0.82-1", + "integrity": "sha512-eg3+W6g4HZMSb+WyX2wVVdLZkvFYgC7HgNZ8+29E0v4ukoDRebHnXFBFRssvQw1fhA8dM99kX+m5rBsE339glg==", "cpu": [ "arm64" ], @@ -542,8 +542,8 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.82-0", - "integrity": "sha512-z2hxMVjqt4+xDRFTZv3/0K3X+aqcJhd6zPO2JxCpOVTh5CNZFaWk+XIa2iXAPWxFqdKJsQ4muXMl3zInaAOkRw==", + "version": "1.0.82-1", + "integrity": "sha512-iEEpOIrzrTxs91wdGjcmoJA2zMlBB/0bVK159PE0hHTd/QNdE2o22BRscwFu6R+l4+KvIQIhoMoIvLCzOTxVzw==", "cpu": [ "x64" ], @@ -558,8 +558,8 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.82-0", - "integrity": "sha512-EcUCv2PKhBzCCvpTaS511VYTDWyhudyIRPvBpc9gFNO3hjlgiNDusf4k9vP6+E3/lHenwGYzMsyRHcYIOy4vcQ==", + "version": "1.0.82-1", + "integrity": "sha512-k64C/mVrtwCoVzQy0e29DDgZYI3oOSiCNrPet3/miQe+8kW1FGXRYakudPVnx6P2GdGR+VuRry2ATMp64WguEQ==", "cpu": [ "arm64" ], @@ -574,8 +574,8 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.82-0", - "integrity": "sha512-1fKVjUiZ1tdb0/d/re90EpFGXhlIPfjENp2Wo/2Kj592dWO3+IwM2qV/AOdMQ4pacW5iYHII7nibx1/EYq3LGQ==", + "version": "1.0.82-1", + "integrity": "sha512-tG3CNiaS2QiElJpFn+EKGxNtRXkzQJ/KJlK8Wbh/ffAFz1VvewhwsasjD3vmqowsX2wHMthds/B7l+DkDXjtxg==", "cpu": [ "x64" ], @@ -590,8 +590,8 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.82-0", - "integrity": "sha512-H341wuxQHhwe/yLmORHzwC3DGzFZgGzh+TpwfyK2zjeYVbwDZ3Bax8M+9CzIED9jIBdEfAmfGzxKUHFugpXTtQ==", + "version": "1.0.82-1", + "integrity": "sha512-dFWwEYODzFzw6aA03VuRIPk80Q8isu0x+8wXxlTAXzWrETs7bc8dtE9Wh2ZLFPCuidRiitMxwuD7Dt4KF9bQiQ==", "cpu": [ "arm64" ], @@ -606,8 +606,8 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.82-0", - "integrity": "sha512-f1ba3gG8NaoYWFHtHaHcLN4It7mclkWdCOXvwFPqPEwqCEIx/+Zh6VHiOeIcNWRS0elRP6QYDCKaTDy1TW27uQ==", + "version": "1.0.82-1", + "integrity": "sha512-w/ti4ipLbcZjoXa2WRV8dEbOXB0Av3WQe0Uua7Wbp5E2UgXhY6grg/hWErHlRS1fa/nnqfr8CyxLv3Mvm78qyQ==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index cceca0b95..aa05dda00 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -14,7 +14,7 @@ "node": "^20.19.0 || >=22.12.0" }, "devDependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.82-1", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14",