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