From 596500aa519a77f93459dee826ff334583621c40 Mon Sep 17 00:00:00 2001 From: john-mckillip Date: Wed, 4 Feb 2026 20:36:08 -0600 Subject: [PATCH 01/12] Add model validation to CreateSessionAsync Validate that SessionConfig.Model is a valid model name before creating a session. When an invalid model is specified, throw an ArgumentException with the invalid model name and list of available models. Fixes #250 --- dotnet/src/Client.cs | 13 +++++++++++++ dotnet/test/SessionTests.cs | 17 ++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 57cebc4b..c4419c52 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -343,6 +343,19 @@ private async Task CleanupConnectionAsync(List? errors) public async Task CreateSessionAsync(SessionConfig? config = null, CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); + Console.WriteLine("Creating new Copilot session"); + if (!string.IsNullOrEmpty(config?.Model)) + { + var availableModels = await ListModelsAsync(cancellationToken).ConfigureAwait(false); + var validModelIds = availableModels.Select(m => m.Id).ToList(); + + if (!validModelIds.Contains(config.Model, StringComparer.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"Invalid model '{config.Model}'. Available models: {string.Join(", ", validModelIds)}", + nameof(config)); + } + } var hasHooks = config?.Hooks != null && ( config.Hooks.OnPreToolUse != null || diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index 13b23522..1e716111 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -15,7 +15,7 @@ public class SessionTests(E2ETestFixture fixture, ITestOutputHelper output) : E2 [Fact] public async Task ShouldCreateAndDestroySessions() { - var session = await Client.CreateSessionAsync(new SessionConfig { Model = "fake-test-model" }); + var session = await Client.CreateSessionAsync(new SessionConfig { Model = "claude-sonnet-4.5" }); Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); @@ -395,4 +395,19 @@ public async Task Should_Create_Session_With_Custom_Config_Dir() Assert.NotNull(assistantMessage); Assert.Contains("2", assistantMessage!.Data.Content); } + + [Fact] + public async Task CreateSessionAsync_WithInvalidModel_ThrowsArgumentException() + { + var exception = await Assert.ThrowsAsync(async () => + { + await Client.CreateSessionAsync(new SessionConfig + { + Model = "INVALID_MODEL_THAT_DOES_NOT_EXIST" + }); + }); + + Assert.Contains("Invalid model", exception.Message); + Assert.Contains("INVALID_MODEL_THAT_DOES_NOT_EXIST", exception.Message); + } } From af07711f29c7d4616377cb96c8a6e9be7bc8af0c Mon Sep 17 00:00:00 2001 From: john-mckillip Date: Wed, 4 Feb 2026 20:38:18 -0600 Subject: [PATCH 02/12] Removed temp code I added for debugging. --- dotnet/src/Client.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index c4419c52..180f8451 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -343,7 +343,7 @@ private async Task CleanupConnectionAsync(List? errors) public async Task CreateSessionAsync(SessionConfig? config = null, CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); - Console.WriteLine("Creating new Copilot session"); + if (!string.IsNullOrEmpty(config?.Model)) { var availableModels = await ListModelsAsync(cancellationToken).ConfigureAwait(false); From 2a64e9dcdcd2ff61bb7d1164114f6b27987204ca Mon Sep 17 00:00:00 2001 From: john-mckillip Date: Wed, 4 Feb 2026 21:03:52 -0600 Subject: [PATCH 03/12] Optimize model validation with HashSet for O(1) lookup --- dotnet/src/Client.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 180f8451..ac706aa2 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -347,9 +347,9 @@ public async Task CreateSessionAsync(SessionConfig? config = nul if (!string.IsNullOrEmpty(config?.Model)) { var availableModels = await ListModelsAsync(cancellationToken).ConfigureAwait(false); - var validModelIds = availableModels.Select(m => m.Id).ToList(); + var validModelIds = new HashSet(availableModels.Select(m => m.Id), StringComparer.OrdinalIgnoreCase); - if (!validModelIds.Contains(config.Model, StringComparer.OrdinalIgnoreCase)) + if (!validModelIds.Contains(config.Model)) { throw new ArgumentException( $"Invalid model '{config.Model}'. Available models: {string.Join(", ", validModelIds)}", From e747e18a03efacf82edb5491ed6bda78e40b4a09 Mon Sep 17 00:00:00 2001 From: john-mckillip Date: Wed, 4 Feb 2026 21:05:20 -0600 Subject: [PATCH 04/12] Added a comment explaining that ListModelsAsync caches its results, clarifying the minimal performance overhead --- dotnet/src/Client.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index ac706aa2..aeb41ea7 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -346,6 +346,7 @@ public async Task CreateSessionAsync(SessionConfig? config = nul if (!string.IsNullOrEmpty(config?.Model)) { + // ListModelsAsync caches results after the first call, so this validation has minimal overhead var availableModels = await ListModelsAsync(cancellationToken).ConfigureAwait(false); var validModelIds = new HashSet(availableModels.Select(m => m.Id), StringComparer.OrdinalIgnoreCase); From 285e803e6b0d1b218a86a9d726869d18a3c6bbcf Mon Sep 17 00:00:00 2001 From: john-mckillip Date: Thu, 5 Feb 2026 18:02:37 -0600 Subject: [PATCH 05/12] Add model validation to create_session Validate that the model specified in SessionConfig exists in the list of available models before creating a session. Raises ValueError with the invalid model name and available alternatives. Uses list_models() which caches results after the first call, so validation adds minimal overhead. Fixes #250 --- python/copilot/client.py | 13 +++++++++++++ python/e2e/test_session.py | 11 +++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/python/copilot/client.py b/python/copilot/client.py index da1ba8c0..6313bba9 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -389,6 +389,19 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo cfg = config or {} + # Validate model if specified + model = cfg.get("model") + if model: + # list_models() caches results, so this has minimal overhead + available_models = await self.list_models() + model_lower = model.lower() + + if not any(m.id.lower() == model_lower for m in available_models): + valid_ids = [m.id for m in available_models] + raise ValueError( + f"Invalid model '{model}'. Available models: {', '.join(valid_ids)}" + ) + tool_defs = [] tools = cfg.get("tools") if tools: diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index f2e545ed..cb2fdcd0 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -14,14 +14,14 @@ class TestSessions: async def test_should_create_and_destroy_sessions(self, ctx: E2ETestContext): - session = await ctx.client.create_session({"model": "fake-test-model"}) + session = await ctx.client.create_session({"model": "claude-sonnet-4.5"}) assert session.session_id messages = await session.get_messages() assert len(messages) > 0 assert messages[0].type.value == "session.start" assert messages[0].data.session_id == session.session_id - assert messages[0].data.selected_model == "fake-test-model" + assert messages[0].data.selected_model == "claude-sonnet-4.5" await session.destroy() @@ -456,6 +456,13 @@ def on_event(event): assistant_message = await get_final_assistant_message(session) assert "300" in assistant_message.data.content + async def test_create_session_with_invalid_model_raises_value_error(self, ctx: E2ETestContext): + with pytest.raises(ValueError) as exc_info: + await ctx.client.create_session({"model": "INVALID_MODEL_THAT_DOES_NOT_EXIST"}) + + assert "Invalid model" in str(exc_info.value) + assert "INVALID_MODEL_THAT_DOES_NOT_EXIST" in str(exc_info.value) + async def test_should_create_session_with_custom_config_dir(self, ctx: E2ETestContext): import os From 8f49e1d54208bd32d8a82173f9515aba017f1203 Mon Sep 17 00:00:00 2001 From: john-mckillip Date: Thu, 5 Feb 2026 18:31:57 -0600 Subject: [PATCH 06/12] Add model validation to Go SDK CreateSession Validate that the specified model exists in the available models list when creating a new session. This brings the Go SDK in line with the Python and .NET implementations. Changes: - Add model validation in Client.CreateSession that checks against ListModels output using case-insensitive comparison - Return descriptive error listing available models on validation failure - Add TestClient_CreateSession_WithInvalidModel unit test - Refactor to use strings.ToLower and strings.Join directly for performance - Reuse cached model list from ListModels to minimize overhead The validation ensures users receive immediate feedback if they specify an invalid model name, rather than encountering errors later in the session lifecycle. --- go/client.go | 24 ++++++++++++++++++++++++ go/client_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/go/client.go b/go/client.go index d45d3447..3e055f63 100644 --- a/go/client.go +++ b/go/client.go @@ -470,6 +470,30 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses params := make(map[string]any) if config != nil { if config.Model != "" { + // Validate model if specified + // ListModels caches results after the first call, so this validation has minimal overhead + availableModels, err := c.ListModels(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list models: %w", err) + } + + modelLower := strings.ToLower(config.Model) + modelFound := false + for _, model := range availableModels { + if strings.ToLower(model.ID) == modelLower { + modelFound = true + break + } + } + + if !modelFound { + validIDs := make([]string, len(availableModels)) + for i, model := range availableModels { + validIDs[i] = model.ID + } + return nil, fmt.Errorf("invalid model '%s'. Available models: %s", config.Model, strings.Join(validIDs, ", ")) + } + params["model"] = config.Model } if config.SessionID != "" { diff --git a/go/client_test.go b/go/client_test.go index 185bb4cb..6a6cea1c 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -5,9 +5,15 @@ import ( "path/filepath" "reflect" "regexp" + "strings" "testing" ) +// contains checks if a string contains a substring (case-insensitive) +func contains(s, substr string) bool { + return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) +} + // This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.go instead func TestClient_HandleToolCallRequest(t *testing.T) { @@ -48,6 +54,38 @@ func TestClient_HandleToolCallRequest(t *testing.T) { }) } +func TestClient_CreateSession_WithInvalidModel(t *testing.T) { + cliPath := findCLIPathForTest() + if cliPath == "" { + t.Skip("CLI not found") + } + + client := NewClient(&ClientOptions{CLIPath: cliPath}) + t.Cleanup(func() { client.ForceStop() }) + + err := client.Start(t.Context()) + if err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + _, err = client.CreateSession(t.Context(), &SessionConfig{ + Model: "INVALID_MODEL_THAT_DOES_NOT_EXIST", + }) + + if err == nil { + t.Fatal("Expected error when creating session with invalid model, got nil") + } + + errorMsg := err.Error() + if !contains(errorMsg, "invalid model") { + t.Errorf("Expected error message to contain 'invalid model', got: %s", errorMsg) + } + + if !contains(errorMsg, "INVALID_MODEL_THAT_DOES_NOT_EXIST") { + t.Errorf("Expected error message to contain 'INVALID_MODEL_THAT_DOES_NOT_EXIST', got: %s", errorMsg) + } +} + func TestClient_URLParsing(t *testing.T) { t.Run("should parse port-only URL format", func(t *testing.T) { client := NewClient(&ClientOptions{ From 3031fec202ebb807cbbeded196e869a523e73ea2 Mon Sep 17 00:00:00 2001 From: john-mckillip Date: Thu, 5 Feb 2026 18:44:43 -0600 Subject: [PATCH 07/12] Add model validation to Node.js SDK createSession Validate that the specified model exists in the available models list when creating a new session. This brings the Node.js SDK in line with the Python, .NET, and Go implementations. Changes: - Add model validation in CopilotClient.createSession that checks against listModels output using case-insensitive comparison - Throw descriptive error listing available models on validation failure - Add unit test "throws error when creating session with invalid model" in client.test.ts - Optimize performance using Array.some() for short-circuit search and single lowercase conversion of input model - Reuse cached model list from listModels() to minimize overhead The validation ensures users receive immediate feedback if they specify an invalid model name, rather than encountering errors later in the session lifecycle. --- nodejs/src/client.ts | 14 ++++++++++++++ nodejs/test/client.test.ts | 14 ++++++++++++++ nodejs/test/e2e/session.test.ts | 4 ++-- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 20dc17f8..20fcc690 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -467,6 +467,20 @@ export class CopilotClient { } } + // Validate model if specified + if (config.model) { + // listModels() caches results, so this has minimal overhead + const availableModels = await this.listModels(); + const modelLower = config.model.toLowerCase(); + + if (!availableModels.some((m) => m.id.toLowerCase() === modelLower)) { + const validIds = availableModels.map((m) => m.id); + throw new Error( + `Invalid model '${config.model}'. Available models: ${validIds.join(", ")}` + ); + } + } + const response = await this.connection!.sendRequest("session.create", { model: config.model, sessionId: config.sessionId, diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 364ff382..e3bef517 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -28,6 +28,20 @@ describe("CopilotClient", () => { }); }); + it("throws error when creating session with invalid model", async () => { + const client = new CopilotClient({ cliPath: CLI_PATH }); + await client.start(); + onTestFinished(() => client.forceStop()); + + await expect( + client.createSession({ model: "INVALID_MODEL_THAT_DOES_NOT_EXIST" }) + ).rejects.toThrow(/Invalid model/); + + await expect( + client.createSession({ model: "INVALID_MODEL_THAT_DOES_NOT_EXIST" }) + ).rejects.toThrow(/INVALID_MODEL_THAT_DOES_NOT_EXIST/); + }); + describe("URL parsing", () => { it("should parse port-only URL format", () => { const client = new CopilotClient({ diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index b3fba475..28cb0122 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -8,13 +8,13 @@ describe("Sessions", async () => { const { copilotClient: client, openAiEndpoint, homeDir, env } = await createSdkTestContext(); it("should create and destroy sessions", async () => { - const session = await client.createSession({ model: "fake-test-model" }); + const session = await client.createSession({ model: "claude-sonnet-4.5" }); expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); expect(await session.getMessages()).toMatchObject([ { type: "session.start", - data: { sessionId: session.sessionId, selectedModel: "fake-test-model" }, + data: { sessionId: session.sessionId, selectedModel: "claude-sonnet-4.5" }, }, ]); From f11f1feceb42903cbb6b45ba31c23d332010afa6 Mon Sep 17 00:00:00 2001 From: john-mckillip Date: Thu, 5 Feb 2026 18:48:39 -0600 Subject: [PATCH 08/12] Fixed a broken test in the GO SDK. --- go/internal/e2e/session_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/go/internal/e2e/session_test.go b/go/internal/e2e/session_test.go index 62183286..730703ce 100644 --- a/go/internal/e2e/session_test.go +++ b/go/internal/e2e/session_test.go @@ -18,7 +18,7 @@ func TestSession(t *testing.T) { t.Run("should create and destroy sessions", func(t *testing.T) { ctx.ConfigureForTest(t) - session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{Model: "fake-test-model"}) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{Model: "claude-sonnet-4.5"}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -41,8 +41,8 @@ func TestSession(t *testing.T) { t.Errorf("Expected session.start sessionId to match") } - if messages[0].Data.SelectedModel == nil || *messages[0].Data.SelectedModel != "fake-test-model" { - t.Errorf("Expected selectedModel to be 'fake-test-model', got %v", messages[0].Data.SelectedModel) + if messages[0].Data.SelectedModel == nil || *messages[0].Data.SelectedModel != "claude-sonnet-4.5" { + t.Errorf("Expected selectedModel to be 'claude-sonnet-4.5', got %v", messages[0].Data.SelectedModel) } if err := session.Destroy(); err != nil { From 8ad3bbe83637e5db1483e47847ea08bacb10607c Mon Sep 17 00:00:00 2001 From: john-mckillip Date: Thu, 5 Feb 2026 19:03:30 -0600 Subject: [PATCH 09/12] A few updates based on Copilot PR review. --- go/client_test.go | 8 ++++---- nodejs/test/client.test.ts | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/go/client_test.go b/go/client_test.go index 6a6cea1c..34cc0cc6 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -9,8 +9,8 @@ import ( "testing" ) -// contains checks if a string contains a substring (case-insensitive) -func contains(s, substr string) bool { +// containsIgnoreCase checks if a string containsIgnoreCase a substring (case-insensitive) +func containsIgnoreCase(s, substr string) bool { return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) } @@ -77,11 +77,11 @@ func TestClient_CreateSession_WithInvalidModel(t *testing.T) { } errorMsg := err.Error() - if !contains(errorMsg, "invalid model") { + if !containsIgnoreCase(errorMsg, "invalid model") { t.Errorf("Expected error message to contain 'invalid model', got: %s", errorMsg) } - if !contains(errorMsg, "INVALID_MODEL_THAT_DOES_NOT_EXIST") { + if !containsIgnoreCase(errorMsg, "INVALID_MODEL_THAT_DOES_NOT_EXIST") { t.Errorf("Expected error message to contain 'INVALID_MODEL_THAT_DOES_NOT_EXIST', got: %s", errorMsg) } } diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index e3bef517..674de28e 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -33,13 +33,13 @@ describe("CopilotClient", () => { await client.start(); onTestFinished(() => client.forceStop()); - await expect( - client.createSession({ model: "INVALID_MODEL_THAT_DOES_NOT_EXIST" }) - ).rejects.toThrow(/Invalid model/); + const error = await client + .createSession({ model: "INVALID_MODEL_THAT_DOES_NOT_EXIST" }) + .catch((e) => e); - await expect( - client.createSession({ model: "INVALID_MODEL_THAT_DOES_NOT_EXIST" }) - ).rejects.toThrow(/INVALID_MODEL_THAT_DOES_NOT_EXIST/); + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain("Invalid model"); + expect(error.message).toContain("INVALID_MODEL_THAT_DOES_NOT_EXIST"); }); describe("URL parsing", () => { From a21e19b6d8d1adc82d4ad05dcb9eeb9b2747b000 Mon Sep 17 00:00:00 2001 From: john-mckillip Date: Thu, 5 Feb 2026 19:12:27 -0600 Subject: [PATCH 10/12] Fixed a comment I added in the Go client_test file. --- go/client_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/client_test.go b/go/client_test.go index 34cc0cc6..d4047f4b 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -9,7 +9,7 @@ import ( "testing" ) -// containsIgnoreCase checks if a string containsIgnoreCase a substring (case-insensitive) +// containsIgnoreCase checks if a string contains a substring (case-insensitive) func containsIgnoreCase(s, substr string) bool { return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) } From c1fed7dcc40689ec782ca8c7b1dffa02b0bb4d78 Mon Sep 17 00:00:00 2001 From: john-mckillip Date: Thu, 5 Feb 2026 19:20:05 -0600 Subject: [PATCH 11/12] Refactor: use LINQ Any() for model validation in CreateSessionAsync Replace HashSet-based model validation with a more idiomatic LINQ Any() expression using case-insensitive string comparison. This improves readability and aligns with other SDK implementations. - Remove intermediate HashSet creation and validModelIds variable - Use string.Equals with StringComparison.OrdinalIgnoreCase for consistency - Update error message to use availableModels.Select(m => m.Id) inline --- dotnet/src/Client.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index aeb41ea7..8dafef0a 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -348,12 +348,11 @@ public async Task CreateSessionAsync(SessionConfig? config = nul { // ListModelsAsync caches results after the first call, so this validation has minimal overhead var availableModels = await ListModelsAsync(cancellationToken).ConfigureAwait(false); - var validModelIds = new HashSet(availableModels.Select(m => m.Id), StringComparer.OrdinalIgnoreCase); - if (!validModelIds.Contains(config.Model)) + if (!availableModels.Any(m => string.Equals(m.Id, config.Model, StringComparison.OrdinalIgnoreCase))) { throw new ArgumentException( - $"Invalid model '{config.Model}'. Available models: {string.Join(", ", validModelIds)}", + $"Invalid model '{config.Model}'. Available models: {string.Join(", ", availableModels.Select(m => m.Id))}", nameof(config)); } } From 1d40907d9c1a277a3894059fa9c9eb10b60e53ce Mon Sep 17 00:00:00 2001 From: john-mckillip Date: Thu, 5 Feb 2026 19:39:50 -0600 Subject: [PATCH 12/12] Added the ArgumentException to the XML documentation for CreateSessionAsync in the dotnet SDK client. --- dotnet/src/Client.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 8dafef0a..5ed6c0e5 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -322,6 +322,7 @@ private async Task CleanupConnectionAsync(List? errors) /// A that can be used to cancel the operation. /// A task that resolves to provide the . /// Thrown when the client is not connected and AutoStart is disabled, or when a session with the same ID already exists. + /// Thrown when contains an invalid model name. /// /// Sessions maintain conversation state, handle events, and manage tool execution. /// If the client is not connected and is enabled (default),