From 40c456c168bed4d9c13f8eb3d3b6ca465601a248 Mon Sep 17 00:00:00 2001 From: Derek Legenzoff Date: Wed, 17 Jun 2026 12:25:38 -0700 Subject: [PATCH 1/2] Add capi.disableWebSocketResponses session option Add the capi.disableWebSocketResponses opt-out to session create/resume across all six SDK languages, so consumers in proxy/WebSocket-blocked environments can fall back to the HTTP Responses transport for the CAPI Responses API. SDK-side follow-up to github/copilot-agent-runtime#10551, which makes WebSocket transport the default for CAPI and adds this opt-out. The field is a hand-written pass-through mirroring the existing provider (BYOK) nested option, wired into session.create and session.resume. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 5 + dotnet/src/Types.cs | 27 ++++ dotnet/test/Unit/CloneTests.cs | 28 ++++ dotnet/test/Unit/SerializationTests.cs | 70 +++++++++ go/client.go | 2 + go/client_test.go | 125 +++++++++++++++ go/types.go | 21 +++ .../github/copilot/SessionRequestBuilder.java | 2 + .../copilot/rpc/CapiSessionOptions.java | 63 ++++++++ .../copilot/rpc/CreateSessionRequest.java | 13 ++ .../copilot/rpc/ResumeSessionConfig.java | 28 ++++ .../copilot/rpc/ResumeSessionRequest.java | 13 ++ .../com/github/copilot/rpc/SessionConfig.java | 28 ++++ .../copilot/CapiSessionOptionsTest.java | 131 ++++++++++++++++ .../copilot/JsonIncludeNonNullTest.java | 6 + nodejs/src/client.ts | 2 + nodejs/src/index.ts | 1 + nodejs/src/types.ts | 32 ++++ nodejs/test/client.test.ts | 32 ++++ python/copilot/__init__.py | 2 + python/copilot/client.py | 41 +++++ python/test_client.py | 41 +++++ rust/src/types.rs | 144 +++++++++++++++++- rust/src/wire.rs | 6 +- 24 files changed, 857 insertions(+), 6 deletions(-) create mode 100644 java/src/main/java/com/github/copilot/rpc/CapiSessionOptions.java create mode 100644 java/src/test/java/com/github/copilot/CapiSessionOptionsTest.java diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 85fb8bd34..06a139dd2 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -963,6 +963,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance toolFilter.AvailableTools, toolFilter.ExcludedTools, config.Provider, + config.Capi, config.EnableSessionTelemetry, config.OnPermissionRequest != null ? true : null, config.OnUserInputRequest != null ? true : null, @@ -1159,6 +1160,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes toolFilter.AvailableTools, toolFilter.ExcludedTools, config.Provider, + config.Capi, config.EnableSessionTelemetry, config.OnPermissionRequest != null ? true : null, config.OnUserInputRequest != null ? true : null, @@ -2355,6 +2357,7 @@ internal record CreateSessionRequest( IList? AvailableTools, IList? ExcludedTools, ProviderConfig? Provider, + CapiSessionOptions? Capi, bool? EnableSessionTelemetry, bool? RequestPermission, bool? RequestUserInput, @@ -2445,6 +2448,7 @@ internal record ResumeSessionRequest( IList? AvailableTools, IList? ExcludedTools, ProviderConfig? Provider, + CapiSessionOptions? Capi, bool? EnableSessionTelemetry, bool? RequestPermission, bool? RequestUserInput, @@ -2569,6 +2573,7 @@ internal record HooksInvokeResponse( [JsonSerializable(typeof(EmbeddingCacheStorageMode))] [JsonSerializable(typeof(ModelCapabilitiesOverride))] [JsonSerializable(typeof(ProviderConfig))] + [JsonSerializable(typeof(CapiSessionOptions))] [JsonSerializable(typeof(ResumeSessionRequest))] [JsonSerializable(typeof(ResumeSessionResponse))] [JsonSerializable(typeof(SessionCapabilities))] diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 706a1ec6b..abbdc97b8 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2058,6 +2058,26 @@ public sealed class ProviderConfig public int? MaxOutputTokens { get; set; } } +/// +/// Provider-scoped options for the CAPI (Copilot API) provider. +/// +public sealed class CapiSessionOptions +{ + /// + /// When , opts out of the WebSocket transport for the CAPI Responses API + /// and uses the HTTP Responses transport instead. + /// + /// + /// WebSocket transport is the default for CAPI Responses API requests when the model advertises + /// the ws:/responses endpoint. Set this option for users behind proxies where WebSockets + /// fail. This is equivalent to setting the COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES + /// environment variable. The option is scoped under the capi namespace because a single + /// session can host multiple providers, such as CAPI and BYOK, so transport choice is provider-level. + /// + [JsonPropertyName("disableWebSocketResponses")] + public bool? DisableWebSocketResponses { get; set; } +} + /// /// Azure OpenAI-specific provider options. /// @@ -2494,6 +2514,7 @@ protected SessionConfigBase(SessionConfigBase? other) OnPermissionRequest = other.OnPermissionRequest; OnUserInputRequest = other.OnUserInputRequest; Provider = other.Provider; + Capi = other.Capi; EnableSessionTelemetry = other.EnableSessionTelemetry; SkipCustomInstructions = other.SkipCustomInstructions; CustomAgentsLocalOnly = other.CustomAgentsLocalOnly; @@ -2649,6 +2670,11 @@ protected SessionConfigBase(SessionConfigBase? other) /// Custom model provider configuration for the session. public ProviderConfig? Provider { get; set; } + /// + /// CAPI (Copilot API) provider-scoped configuration for the session. + /// + public CapiSessionOptions? Capi { get; set; } + /// /// Enables or disables internal session telemetry for this session. /// When false, disables session telemetry. When null (the default) or true, @@ -3554,6 +3580,7 @@ public sealed class SystemMessageTransformRpcResponse [JsonSerializable(typeof(PingRequest))] [JsonSerializable(typeof(PingResponse))] [JsonSerializable(typeof(ProviderConfig))] +[JsonSerializable(typeof(CapiSessionOptions))] [JsonSerializable(typeof(SessionContext))] [JsonSerializable(typeof(SessionLifecycleEvent))] [JsonSerializable(typeof(SessionLifecycleEventMetadata))] diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index 2894952cb..1081ea0aa 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -82,6 +82,7 @@ public void SessionConfig_Clone_CopiesAllProperties() McpOAuthTokenStorage = McpOAuthTokenStorageMode.Persistent, CustomAgents = [new CustomAgentConfig { Name = "agent1", Model = "claude-haiku-4.5" }], Agent = "agent1", + Capi = new CapiSessionOptions { DisableWebSocketResponses = true }, Cloud = new CloudSessionOptions { Repository = new CloudSessionRepository @@ -123,6 +124,7 @@ public void SessionConfig_Clone_CopiesAllProperties() Assert.Equal(original.CustomAgents.Count, clone.CustomAgents!.Count); Assert.Equal(original.CustomAgents[0].Model, clone.CustomAgents[0].Model); Assert.Equal(original.Agent, clone.Agent); + Assert.Same(original.Capi, clone.Capi); Assert.Same(original.Cloud, clone.Cloud); Assert.Equal(original.DefaultAgent!.ExcludedTools, clone.DefaultAgent!.ExcludedTools); Assert.Equal(original.SkillDirectories, clone.SkillDirectories); @@ -515,4 +517,30 @@ public void ResumeSessionConfig_Clone_CopiesMcpOAuthTokenStorage() Assert.Equal(McpOAuthTokenStorageMode.Persistent, clone.McpOAuthTokenStorage); } + + [Fact] + public void SessionConfig_Clone_CopiesCapiOptions() + { + var original = new SessionConfig + { + Capi = new CapiSessionOptions { DisableWebSocketResponses = true }, + }; + + var clone = original.Clone(); + + Assert.Same(original.Capi, clone.Capi); + } + + [Fact] + public void ResumeSessionConfig_Clone_CopiesCapiOptions() + { + var original = new ResumeSessionConfig + { + Capi = new CapiSessionOptions { DisableWebSocketResponses = true }, + }; + + var clone = original.Clone(); + + Assert.Same(original.Capi, clone.Capi); + } } diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index f074a4d2f..4a6dc21e5 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -50,6 +50,25 @@ public void ProviderConfig_CanSerializeHeaders_WithSdkOptions() Assert.Equal(4096, deserialized.MaxOutputTokens); } + [Fact] + public void CapiSessionOptions_CanSerializeDisableWebSocketResponses_WithSdkOptions() + { + var options = GetSerializerOptions(); + var original = new CapiSessionOptions + { + DisableWebSocketResponses = true + }; + + var json = JsonSerializer.Serialize(original, options); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.True(root.GetProperty("disableWebSocketResponses").GetBoolean()); + + var deserialized = JsonSerializer.Deserialize(json, options); + Assert.NotNull(deserialized); + Assert.True(deserialized.DisableWebSocketResponses); + } + [Fact] public void ModelBilling_CanSerializeTokenPrices_WithSdkOptions() { @@ -221,6 +240,57 @@ public void ResumeSessionRequest_CanSerializeInstructionDirectories_WithSdkOptio Assert.Equal("C:\\resume-instructions", root.GetProperty("instructionDirectories")[0].GetString()); } + [Fact] + public void SessionRequests_CanSerializeCapiOptions_WithSdkOptions() + { + var options = GetSerializerOptions(); + var capi = new CapiSessionOptions { DisableWebSocketResponses = true }; + + var createRequestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest"); + var createRequest = CreateInternalRequest( + createRequestType, + ("SessionId", "session-id"), + ("Capi", capi)); + + var createJson = JsonSerializer.Serialize(createRequest, createRequestType, options); + using var createDocument = JsonDocument.Parse(createJson); + Assert.True(createDocument.RootElement.GetProperty("capi").GetProperty("disableWebSocketResponses").GetBoolean()); + + var resumeRequestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); + var resumeRequest = CreateInternalRequest( + resumeRequestType, + ("SessionId", "session-id"), + ("Capi", capi)); + + var resumeJson = JsonSerializer.Serialize(resumeRequest, resumeRequestType, options); + using var resumeDocument = JsonDocument.Parse(resumeJson); + Assert.True(resumeDocument.RootElement.GetProperty("capi").GetProperty("disableWebSocketResponses").GetBoolean()); + } + + [Fact] + public void SessionRequests_OmitCapiOptions_WhenUnset() + { + var options = GetSerializerOptions(); + + var createRequestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest"); + var createRequest = CreateInternalRequest( + createRequestType, + ("SessionId", "session-id")); + + var createJson = JsonSerializer.Serialize(createRequest, createRequestType, options); + using var createDocument = JsonDocument.Parse(createJson); + Assert.False(createDocument.RootElement.TryGetProperty("capi", out _)); + + var resumeRequestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); + var resumeRequest = CreateInternalRequest( + resumeRequestType, + ("SessionId", "session-id")); + + var resumeJson = JsonSerializer.Serialize(resumeRequest, resumeRequestType, options); + using var resumeDocument = JsonDocument.Parse(resumeJson); + Assert.False(resumeDocument.RootElement.TryGetProperty("capi", out _)); + } + [Fact] public void SessionRequests_CanSerializeReasoningSummary_WithSdkOptions() { diff --git a/go/client.go b/go/client.go index ad330e5a0..32cbc58d0 100644 --- a/go/client.go +++ b/go/client.go @@ -681,6 +681,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.ExcludedTools = excludedTools req.ToolFilterPrecedence = precedence req.Provider = config.Provider + req.Capi = config.Capi req.EnableSessionTelemetry = config.EnableSessionTelemetry req.SkipCustomInstructions = config.SkipCustomInstructions req.CustomAgentsLocalOnly = config.CustomAgentsLocalOnly @@ -976,6 +977,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.SystemMessage = wireSystemMessage req.Tools = config.Tools req.Provider = config.Provider + req.Capi = config.Capi req.EnableSessionTelemetry = config.EnableSessionTelemetry req.SkipCustomInstructions = config.SkipCustomInstructions req.CustomAgentsLocalOnly = config.CustomAgentsLocalOnly diff --git a/go/client_test.go b/go/client_test.go index e54689fa9..a6366c7d1 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -209,6 +209,76 @@ func newRuntimeShutdownRpcPair(t *testing.T) (*jsonrpc2.Client, *jsonrpc2.Client return rpcClient, server, shutdownCalled } +func TestClient_ForwardsCapiOptionsToSessionRequests(t *testing.T) { + rpcClient, server, _ := newRuntimeShutdownRpcPair(t) + t.Cleanup(server.Stop) + client := &Client{ + client: rpcClient, + RPC: rpc.NewServerRPC(rpcClient), + sessions: make(map[string]*Session), + } + + createParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + createParams <- append(json.RawMessage(nil), params...) + sessionID := sessionIDFromParams(t, params) + return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil + }) + + _, err := client.CreateSession(t.Context(), &SessionConfig{ + Capi: &CapiSessionOptions{DisableWebSocketResponses: Bool(true)}, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + assertCapiDisableWebSocketResponses(t, <-createParams) + + resumeParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + resumeParams <- append(json.RawMessage(nil), params...) + return []byte(`{"sessionId":"resumed-capi","workspacePath":"/workspace"}`), nil + }) + + _, err = client.ResumeSessionWithOptions(t.Context(), "resumed-capi", &ResumeSessionConfig{ + Capi: &CapiSessionOptions{DisableWebSocketResponses: Bool(true)}, + }) + if err != nil { + t.Fatalf("ResumeSessionWithOptions failed: %v", err) + } + assertCapiDisableWebSocketResponses(t, <-resumeParams) +} + +func assertCapiDisableWebSocketResponses(t *testing.T, params json.RawMessage) { + t.Helper() + + var decoded map[string]any + if err := json.Unmarshal(params, &decoded); err != nil { + t.Fatalf("failed to unmarshal request params: %v", err) + } + capi, ok := decoded["capi"].(map[string]any) + if !ok { + t.Fatalf("expected capi object in request params, got %T", decoded["capi"]) + } + if capi["disableWebSocketResponses"] != true { + t.Fatalf("expected capi.disableWebSocketResponses=true, got %v", capi["disableWebSocketResponses"]) + } +} + +func sessionIDFromParams(t *testing.T, params json.RawMessage) string { + t.Helper() + + var decoded struct { + SessionID string `json:"sessionId"` + } + if err := json.Unmarshal(params, &decoded); err != nil { + t.Fatalf("failed to unmarshal request params: %v", err) + } + if decoded.SessionID == "" { + t.Fatal("expected generated sessionId in request params") + } + return decoded.SessionID +} + func assertRuntimeShutdownNotCalled(t *testing.T, shutdownCalled <-chan struct{}) { t.Helper() select { @@ -1339,6 +1409,61 @@ func TestCreateSessionRequest_Cloud(t *testing.T) { }) } +func TestSessionRequests_Capi(t *testing.T) { + t.Run("forwards capi options in session.create RPC", func(t *testing.T) { + req := createSessionRequest{ + Capi: &CapiSessionOptions{DisableWebSocketResponses: Bool(true)}, + } + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + capi, ok := m["capi"].(map[string]any) + if !ok { + t.Fatalf("Expected capi to be an object, got %T", m["capi"]) + } + if capi["disableWebSocketResponses"] != true { + t.Errorf("Expected disableWebSocketResponses=true, got %v", capi["disableWebSocketResponses"]) + } + }) + + t.Run("forwards capi options in session.resume RPC", func(t *testing.T) { + req := resumeSessionRequest{ + SessionID: "s1", + Capi: &CapiSessionOptions{DisableWebSocketResponses: Bool(true)}, + } + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + capi, ok := m["capi"].(map[string]any) + if !ok { + t.Fatalf("Expected capi to be an object, got %T", m["capi"]) + } + if capi["disableWebSocketResponses"] != true { + t.Errorf("Expected disableWebSocketResponses=true, got %v", capi["disableWebSocketResponses"]) + } + }) + + t.Run("omits capi from JSON when unset", func(t *testing.T) { + req := createSessionRequest{} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["capi"]; ok { + t.Error("Expected capi to be omitted when unset") + } + }) +} + func TestResumeSessionRequest_Commands(t *testing.T) { t.Run("forwards commands in session.resume RPC", func(t *testing.T) { req := resumeSessionRequest{ diff --git a/go/types.go b/go/types.go index 5ed0b6931..c3210c447 100644 --- a/go/types.go +++ b/go/types.go @@ -983,6 +983,8 @@ type SessionConfig struct { IncludeSubAgentStreamingEvents *bool // Provider configures a custom model provider (BYOK) Provider *ProviderConfig + // Capi configures provider-scoped CAPI (Copilot API) session options. + Capi *CapiSessionOptions // EnableSessionTelemetry enables or disables internal session telemetry for this session. // When false, disables session telemetry. When nil (the default) or true, // telemetry is enabled for GitHub-authenticated sessions. When a custom @@ -1316,6 +1318,8 @@ type ResumeSessionConfig struct { ExcludedTools []string // Provider configures a custom model provider Provider *ProviderConfig + // Capi configures provider-scoped CAPI (Copilot API) session options. + Capi *CapiSessionOptions // EnableSessionTelemetry enables or disables internal session telemetry for this session. // When false, disables session telemetry. When nil (the default) or true, // telemetry is enabled for GitHub-authenticated sessions. When a custom @@ -1540,6 +1544,21 @@ type ProviderConfig struct { MaxOutputTokens int `json:"maxOutputTokens,omitempty"` } +// CapiSessionOptions configures provider-scoped CAPI (Copilot API) session behavior. +// +// WebSocket transport is the default for the CAPI Responses API whenever the +// model advertises the ws:/responses endpoint. Set DisableWebSocketResponses to +// Bool(true) to opt out to the HTTP Responses transport, which is useful behind +// proxies where WebSockets fail. This is equivalent to setting the +// COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES environment variable. These options +// are provider-scoped under the capi namespace because a single session can host +// multiple providers, such as CAPI and BYOK, so transport choice is provider-level. +type CapiSessionOptions struct { + // DisableWebSocketResponses opts out of the default WebSocket Responses + // transport and uses HTTP Responses transport when set to Bool(true). + DisableWebSocketResponses *bool `json:"disableWebSocketResponses,omitempty"` +} + // AzureProviderOptions contains Azure-specific provider configuration type AzureProviderOptions struct { // APIVersion is the Azure API version. Defaults to "2024-10-21". @@ -1721,6 +1740,7 @@ type createSessionRequest struct { ExcludedTools []string `json:"excludedTools,omitempty"` ToolFilterPrecedence *rpc.OptionsUpdateToolFilterPrecedence `json:"toolFilterPrecedence,omitempty"` Provider *ProviderConfig `json:"provider,omitempty"` + Capi *CapiSessionOptions `json:"capi,omitempty"` EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` CustomAgentsLocalOnly *bool `json:"customAgentsLocalOnly,omitempty"` @@ -1800,6 +1820,7 @@ type resumeSessionRequest struct { ExcludedTools []string `json:"excludedTools,omitempty"` ToolFilterPrecedence *rpc.OptionsUpdateToolFilterPrecedence `json:"toolFilterPrecedence,omitempty"` Provider *ProviderConfig `json:"provider,omitempty"` + Capi *CapiSessionOptions `json:"capi,omitempty"` EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` CustomAgentsLocalOnly *bool `json:"customAgentsLocalOnly,omitempty"` diff --git a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java index 5697c7060..f4979bba6 100644 --- a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -113,6 +113,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setAvailableTools(config.getAvailableTools()); request.setExcludedTools(config.getExcludedTools()); request.setProvider(config.getProvider()); + request.setCapi(config.getCapi()); config.getEnableSessionTelemetry().ifPresent(request::setEnableSessionTelemetry); if (config.getOnUserInputRequest() != null) { request.setRequestUserInput(true); @@ -225,6 +226,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setAvailableTools(config.getAvailableTools()); request.setExcludedTools(config.getExcludedTools()); request.setProvider(config.getProvider()); + request.setCapi(config.getCapi()); config.getEnableSessionTelemetry().ifPresent(request::setEnableSessionTelemetry); if (config.getOnUserInputRequest() != null) { request.setRequestUserInput(true); diff --git a/java/src/main/java/com/github/copilot/rpc/CapiSessionOptions.java b/java/src/main/java/com/github/copilot/rpc/CapiSessionOptions.java new file mode 100644 index 000000000..f63e65872 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/CapiSessionOptions.java @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Provider-scoped session options for the CAPI (Copilot API) provider. + *

+ * WebSocket transport is the default for the CAPI Responses API whenever the + * model advertises the {@code ws:/responses} endpoint. Setting + * {@link #setDisableWebSocketResponses(Boolean)} to {@code true} opts out to + * the HTTP Responses transport instead, which is useful for users behind + * proxies where WebSockets fail. This is equivalent to setting the + * {@code COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES} environment variable. + *

+ * These options are scoped under the {@code capi} namespace because a single + * session can host multiple providers (for example, CAPI and BYOK), so + * transport choice is provider-level rather than top-level session state. All + * setter methods return {@code this} for method chaining. + * + * @see SessionConfig#setCapi(CapiSessionOptions) + * @see ResumeSessionConfig#setCapi(CapiSessionOptions) + * @since 1.5.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CapiSessionOptions { + + @JsonProperty("disableWebSocketResponses") + private Boolean disableWebSocketResponses; + + /** + * Gets whether CAPI Responses API WebSocket transport is disabled. + * + * @return {@code true} to opt out of WebSocket Responses transport, + * {@code false} to explicitly allow it, or {@code null} to use the + * default behavior + */ + public Boolean getDisableWebSocketResponses() { + return disableWebSocketResponses; + } + + /** + * Sets whether to disable CAPI Responses API WebSocket transport. + *

+ * WebSocket transport is the default for the CAPI Responses API whenever the + * model advertises the {@code ws:/responses} endpoint. Set this to {@code true} + * to opt out to the HTTP Responses transport instead, which is useful for users + * behind proxies where WebSockets fail. This is equivalent to setting the + * {@code COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES} environment variable. + * + * @param disableWebSocketResponses + * {@code true} to opt out of WebSocket Responses transport + * @return this config for method chaining + */ + public CapiSessionOptions setDisableWebSocketResponses(Boolean disableWebSocketResponses) { + this.disableWebSocketResponses = disableWebSocketResponses; + return this; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 7211cc36c..a585e36b8 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -60,6 +60,9 @@ public final class CreateSessionRequest { @JsonProperty("provider") private ProviderConfig provider; + @JsonProperty("capi") + private CapiSessionOptions capi; + @JsonProperty("enableSessionTelemetry") private Boolean enableSessionTelemetry; @@ -313,6 +316,16 @@ public void setProvider(ProviderConfig provider) { this.provider = provider; } + /** Gets the CAPI session options. @return the CAPI session options */ + public CapiSessionOptions getCapi() { + return capi; + } + + /** Sets the CAPI session options. @param capi the CAPI session options */ + public void setCapi(CapiSessionOptions capi) { + this.capi = capi; + } + /** Gets enable session telemetry flag. @return the flag */ public Boolean getEnableSessionTelemetry() { return enableSessionTelemetry; diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index 680d337ec..5dcfb2249 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -45,6 +45,7 @@ public class ResumeSessionConfig { private List availableTools; private List excludedTools; private ProviderConfig provider; + private CapiSessionOptions capi; private Boolean enableSessionTelemetry; private Boolean skipCustomInstructions; private Boolean customAgentsLocalOnly; @@ -254,6 +255,32 @@ public ResumeSessionConfig setProvider(ProviderConfig provider) { return this; } + /** + * Gets the CAPI provider-scoped session options. + * + * @return the CAPI session options + */ + public CapiSessionOptions getCapi() { + return capi; + } + + /** + * Sets CAPI provider-scoped session options. + *

+ * Use {@link CapiSessionOptions#setDisableWebSocketResponses(Boolean)} to opt + * out of the default CAPI Responses API WebSocket transport and use HTTP + * Responses transport instead. + * + * @param capi + * the CAPI session options + * @return this config for method chaining + * @see CapiSessionOptions + */ + public ResumeSessionConfig setCapi(CapiSessionOptions capi) { + this.capi = capi; + return this; + } + /** * Enables or disables internal session telemetry for this session. When * {@code false}, disables session telemetry. When unset (the default) or @@ -1548,6 +1575,7 @@ public ResumeSessionConfig clone() { copy.availableTools = this.availableTools != null ? new ArrayList<>(this.availableTools) : null; copy.excludedTools = this.excludedTools != null ? new ArrayList<>(this.excludedTools) : null; copy.provider = this.provider; + copy.capi = this.capi; copy.enableSessionTelemetry = this.enableSessionTelemetry; copy.reasoningEffort = this.reasoningEffort; copy.reasoningSummary = this.reasoningSummary; diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index e88be7a9f..4dc5e775e 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -62,6 +62,9 @@ public final class ResumeSessionRequest { @JsonProperty("provider") private ProviderConfig provider; + @JsonProperty("capi") + private CapiSessionOptions capi; + @JsonProperty("enableSessionTelemetry") private Boolean enableSessionTelemetry; @@ -318,6 +321,16 @@ public void setProvider(ProviderConfig provider) { this.provider = provider; } + /** Gets the CAPI session options. @return the CAPI session options */ + public CapiSessionOptions getCapi() { + return capi; + } + + /** Sets the CAPI session options. @param capi the CAPI session options */ + public void setCapi(CapiSessionOptions capi) { + this.capi = capi; + } + /** Gets enable session telemetry flag. @return the flag */ public Boolean getEnableSessionTelemetry() { return enableSessionTelemetry; diff --git a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java index ded429867..be766ea7b 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -49,6 +49,7 @@ public class SessionConfig { private List availableTools; private List excludedTools; private ProviderConfig provider; + private CapiSessionOptions capi; private Boolean enableSessionTelemetry; private Boolean skipCustomInstructions; private Boolean customAgentsLocalOnly; @@ -355,6 +356,32 @@ public SessionConfig setProvider(ProviderConfig provider) { return this; } + /** + * Gets the CAPI provider-scoped session options. + * + * @return the CAPI session options + */ + public CapiSessionOptions getCapi() { + return capi; + } + + /** + * Sets CAPI provider-scoped session options. + *

+ * Use {@link CapiSessionOptions#setDisableWebSocketResponses(Boolean)} to opt + * out of the default CAPI Responses API WebSocket transport and use HTTP + * Responses transport instead. + * + * @param capi + * the CAPI session options + * @return this config instance for method chaining + * @see CapiSessionOptions + */ + public SessionConfig setCapi(CapiSessionOptions capi) { + this.capi = capi; + return this; + } + /** * Enables or disables internal session telemetry for this session. When * {@code false}, disables session telemetry. When unset (the default) or @@ -1671,6 +1698,7 @@ public SessionConfig clone() { copy.availableTools = this.availableTools != null ? new ArrayList<>(this.availableTools) : null; copy.excludedTools = this.excludedTools != null ? new ArrayList<>(this.excludedTools) : null; copy.provider = this.provider; + copy.capi = this.capi; copy.enableSessionTelemetry = this.enableSessionTelemetry; copy.skipCustomInstructions = this.skipCustomInstructions; copy.customAgentsLocalOnly = this.customAgentsLocalOnly; diff --git a/java/src/test/java/com/github/copilot/CapiSessionOptionsTest.java b/java/src/test/java/com/github/copilot/CapiSessionOptionsTest.java new file mode 100644 index 000000000..fb27a52dd --- /dev/null +++ b/java/src/test/java/com/github/copilot/CapiSessionOptionsTest.java @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * 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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; + +import com.github.copilot.rpc.CapiSessionOptions; +import com.github.copilot.rpc.ResumeSessionConfig; +import com.github.copilot.rpc.SessionConfig; + +/** + * Tests for CAPI provider-scoped session options. + */ +class CapiSessionOptionsTest { + + @Test + void defaultsAreNull() { + var capi = new CapiSessionOptions(); + + assertNull(capi.getDisableWebSocketResponses()); + } + + @Test + void fluentSetterReturnsSameInstance() { + var capi = new CapiSessionOptions(); + + assertSame(capi, capi.setDisableWebSocketResponses(true)); + assertEquals(Boolean.TRUE, capi.getDisableWebSocketResponses()); + } + + @Test + void serializesDisableWebSocketResponses() { + var capi = new CapiSessionOptions().setDisableWebSocketResponses(true); + + JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(capi); + + assertTrue(json.get("disableWebSocketResponses").asBoolean()); + } + + @Test + void omitsUnsetDisableWebSocketResponses() { + var capi = new CapiSessionOptions(); + + JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(capi); + + assertTrue(json.path("disableWebSocketResponses").isMissingNode()); + assertEquals(0, json.size()); + } + + @Test + void createRequestIncludesCapiWhenSet() { + var config = new SessionConfig().setCapi(new CapiSessionOptions().setDisableWebSocketResponses(true)); + + var request = SessionRequestBuilder.buildCreateRequest(config, "session-1"); + JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(request); + + assertNotNull(request.getCapi()); + assertTrue(json.get("capi").get("disableWebSocketResponses").asBoolean()); + } + + @Test + void createRequestOmitsCapiWhenUnset() { + var config = new SessionConfig(); + + var request = SessionRequestBuilder.buildCreateRequest(config, "session-1"); + JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(request); + + assertNull(request.getCapi()); + assertTrue(json.path("capi").isMissingNode()); + } + + @Test + void resumeRequestIncludesCapiWhenSet() { + var config = new ResumeSessionConfig().setCapi(new CapiSessionOptions().setDisableWebSocketResponses(true)); + + var request = SessionRequestBuilder.buildResumeRequest("session-1", config); + JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(request); + + assertNotNull(request.getCapi()); + assertTrue(json.get("capi").get("disableWebSocketResponses").asBoolean()); + } + + @Test + void resumeRequestOmitsCapiWhenUnset() { + var config = new ResumeSessionConfig(); + + var request = SessionRequestBuilder.buildResumeRequest("session-1", config); + JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(request); + + assertNull(request.getCapi()); + assertTrue(json.path("capi").isMissingNode()); + } + + @Test + void sessionConfigCloneCopiesCapiReference() { + var capi = new CapiSessionOptions().setDisableWebSocketResponses(true); + + var clone = new SessionConfig().setCapi(capi).clone(); + + assertSame(capi, clone.getCapi()); + } + + @Test + void resumeSessionConfigCloneCopiesCapiReference() { + var capi = new CapiSessionOptions().setDisableWebSocketResponses(true); + + var clone = new ResumeSessionConfig().setCapi(capi).clone(); + + assertSame(capi, clone.getCapi()); + } + + @Test + void falseValueIsSerializedWhenExplicitlySet() { + var capi = new CapiSessionOptions().setDisableWebSocketResponses(false); + + JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(capi); + + assertFalse(json.get("disableWebSocketResponses").asBoolean()); + } +} diff --git a/java/src/test/java/com/github/copilot/JsonIncludeNonNullTest.java b/java/src/test/java/com/github/copilot/JsonIncludeNonNullTest.java index b25f573c6..ec7ead567 100644 --- a/java/src/test/java/com/github/copilot/JsonIncludeNonNullTest.java +++ b/java/src/test/java/com/github/copilot/JsonIncludeNonNullTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test; +import com.github.copilot.rpc.CapiSessionOptions; import com.github.copilot.rpc.CopilotClientOptions; import com.github.copilot.rpc.CustomAgentConfig; import com.github.copilot.rpc.InfiniteSessionConfig; @@ -76,6 +77,11 @@ void providerConfigHasNonNullAnnotation() { assertHasNonNullInclude(ProviderConfig.class); } + @Test + void capiSessionOptionsHasNonNullAnnotation() { + assertHasNonNullInclude(CapiSessionOptions.class); + } + @Test void telemetryConfigHasNonNullAnnotation() { assertHasNonNullInclude(TelemetryConfig.class); diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 4deda08b4..5e3c829d5 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1221,6 +1221,7 @@ export class CopilotClient { excludedTools: toolFilterOptions.excludedTools, toolFilterPrecedence: toolFilterOptions.toolFilterPrecedence, provider: config.provider, + capi: config.capi, enableSessionTelemetry: config.enableSessionTelemetry, modelCapabilities: config.modelCapabilities, largeOutput: toWireLargeOutput(config.largeOutput), @@ -1405,6 +1406,7 @@ export class CopilotClient { description: cmd.description, })), provider: config.provider, + capi: config.capi, modelCapabilities: config.modelCapabilities, largeOutput: toWireLargeOutput(config.largeOutput), requestPermission: diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index a7ebbbde0..2ed96e7a0 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -84,6 +84,7 @@ export type { ModelBilling, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, + CapiSessionOptions, ModelCapabilities, ModelCapabilitiesOverride, ModelInfo, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index bad1c33ad..ad2a19b75 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1584,6 +1584,31 @@ export interface ExtensionInfo { name: string; } +/** + * Provider-scoped options for the Copilot API (CAPI). + * + * These settings apply to the built-in Copilot API provider only. They live + * under their own namespace because a single session can host multiple + * providers (CAPI alongside BYOK via {@link ProviderConfig}), so transport and + * provider-level choices are conceptually per-provider rather than global. + */ +export interface CapiSessionOptions { + /** + * Opt out of the WebSocket transport for the CAPI Responses API. + * + * WebSocket transport is enabled by default whenever the selected model + * advertises the `ws:/responses` endpoint. Set this to `true` to fall back + * to the HTTP Responses transport instead — useful for users behind proxies + * where WebSocket connections fail. + * + * Equivalent to setting the `COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES` + * environment variable. + * + * @default false + */ + disableWebSocketResponses?: boolean; +} + /** * Shared configuration fields used by both {@link SessionConfig} (for * creating a new session) and {@link ResumeSessionConfig} (for resuming @@ -1745,6 +1770,13 @@ export interface SessionConfigBase { */ provider?: ProviderConfig; + /** + * Provider-scoped options for the built-in Copilot API (CAPI), such as + * opting out of the WebSocket Responses transport. See + * {@link CapiSessionOptions}. + */ + capi?: CapiSessionOptions; + /** * Enables or disables internal session telemetry for this session. * When `false`, disables session telemetry. When omitted (the default) or `true`, diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 42c0ff18e..41fae7bee 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -171,6 +171,38 @@ describe("CopilotClient", () => { expect(resumePayload.contextTier).toBe("default"); }); + it("forwards capi options in session.create and session.resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + const session = await client.createSession({ + onPermissionRequest: approveAll, + capi: { disableWebSocketResponses: true }, + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + capi: { disableWebSocketResponses: true }, + }); + + const createPayload = spy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + const resumePayload = spy.mock.calls.find( + ([method]) => method === "session.resume" + )![1] as any; + expect(createPayload.capi).toEqual({ disableWebSocketResponses: true }); + expect(resumePayload.capi).toEqual({ disableWebSocketResponses: true }); + }); + it("forwards pluginDirectories and largeOutput in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index ff2562d68..fd21867ab 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -28,6 +28,7 @@ OpenCanvasInstance, ) from .client import ( + CapiSessionOptions, ChildProcessRuntimeConnection, CloudSessionOptions, CloudSessionRepository, @@ -176,6 +177,7 @@ "CanvasHostContext", "CanvasHostContextCapabilities", "CanvasJsonSchema", + "CapiSessionOptions", "ChildProcessRuntimeConnection", "CloudSessionOptions", "CloudSessionRepository", diff --git a/python/copilot/client.py b/python/copilot/client.py index 0dff0e5ab..39f8b1f67 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -133,6 +133,13 @@ class CloudSessionOptions: repository: CloudSessionRepository | None = None +class CapiSessionOptions(TypedDict, total=False): + """CAPI provider-scoped session options.""" + + disable_web_socket_responses: bool + """Opt out of WebSocket Responses transport and use HTTP Responses instead.""" + + def _cloud_session_options_to_dict(options: CloudSessionOptions) -> dict[str, Any]: result: dict[str, Any] = {} if options.repository is not None: @@ -146,6 +153,13 @@ def _cloud_session_options_to_dict(options: CloudSessionOptions) -> dict[str, An return result +def _capi_session_options_to_wire(options: CapiSessionOptions) -> dict[str, Any]: + wire: dict[str, Any] = {} + if "disable_web_socket_responses" in options: + wire["disableWebSocketResponses"] = options["disable_web_socket_responses"] + return wire + + def _validate_session_fs_config(config: SessionFsConfig) -> None: if not config.get("initial_working_directory"): raise ValueError("session_fs.initial_working_directory is required") @@ -1627,6 +1641,7 @@ async def create_session( hooks: SessionHooks | None = None, working_directory: str | None = None, provider: ProviderConfig | None = None, + capi: CapiSessionOptions | None = None, enable_session_telemetry: bool | None = None, skip_custom_instructions: bool | None = None, custom_agents_local_only: bool | None = None, @@ -1709,6 +1724,16 @@ async def create_session( hooks: Lifecycle hooks for the session. working_directory: Working directory for the session. provider: Provider configuration for Azure or custom endpoints. + capi: CAPI provider-scoped options. WebSocket transport is the + default for the CAPI Responses API whenever the model advertises + the ``ws:/responses`` endpoint. Set + ``disable_web_socket_responses=True`` to opt out to the HTTP + Responses transport, which is useful behind proxies where + WebSockets fail. This is equivalent to setting the + ``COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES`` environment + variable. The option is under the ``capi`` namespace because a + single session can host multiple providers (CAPI + BYOK), so + transport choice is provider-level. enable_session_telemetry: Enables or disables internal session telemetry for this session. When False, disables session telemetry. When omitted or True, telemetry is enabled for GitHub-authenticated sessions. When @@ -1916,6 +1941,9 @@ async def create_session( if provider: payload["provider"] = self._convert_provider_to_wire_format(provider) + if capi is not None: + payload["capi"] = _capi_session_options_to_wire(capi) + if enable_session_telemetry is not None: payload["enableSessionTelemetry"] = enable_session_telemetry @@ -2204,6 +2232,7 @@ async def resume_session( hooks: SessionHooks | None = None, working_directory: str | None = None, provider: ProviderConfig | None = None, + capi: CapiSessionOptions | None = None, enable_session_telemetry: bool | None = None, skip_custom_instructions: bool | None = None, custom_agents_local_only: bool | None = None, @@ -2287,6 +2316,16 @@ async def resume_session( hooks: Lifecycle hooks for the session. working_directory: Working directory for the session. provider: Provider configuration for Azure or custom endpoints. + capi: CAPI provider-scoped options. WebSocket transport is the + default for the CAPI Responses API whenever the model advertises + the ``ws:/responses`` endpoint. Set + ``disable_web_socket_responses=True`` to opt out to the HTTP + Responses transport, which is useful behind proxies where + WebSockets fail. This is equivalent to setting the + ``COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES`` environment + variable. The option is under the ``capi`` namespace because a + single session can host multiple providers (CAPI + BYOK), so + transport choice is provider-level. enable_session_telemetry: Enables or disables internal session telemetry for this session. When False, disables session telemetry. When omitted or True, telemetry is enabled for GitHub-authenticated sessions. When @@ -2437,6 +2476,8 @@ async def resume_session( payload["toolFilterPrecedence"] = "excluded" if provider: payload["provider"] = self._convert_provider_to_wire_format(provider) + if capi is not None: + payload["capi"] = _capi_session_options_to_wire(capi) if enable_session_telemetry is not None: payload["enableSessionTelemetry"] = enable_session_telemetry if model_capabilities: diff --git a/python/test_client.py b/python/test_client.py index 6af4450de..2b5e14984 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -10,6 +10,7 @@ import pytest from copilot import ( + CapiSessionOptions, CopilotClient, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, @@ -240,6 +241,46 @@ async def mock_request(method, params, **kwargs): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_create_and_resume_session_forward_capi_options(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method in ("session.create", "session.resume"): + result = {"sessionId": params.get("sessionId") or "session-1"} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + client._client.request = mock_request + create_capi: CapiSessionOptions = {"disable_web_socket_responses": True} + resume_capi: CapiSessionOptions = {"disable_web_socket_responses": False} + + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + capi=create_capi, + ) + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + capi=resume_capi, + ) + + assert captured["session.create"]["capi"] == { + "disableWebSocketResponses": True, + } + assert captured["session.resume"]["capi"] == { + "disableWebSocketResponses": False, + } + finally: + await client.force_stop() + @pytest.mark.asyncio async def test_create_and_resume_session_forward_plugin_directories_and_large_output(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) diff --git a/rust/src/types.rs b/rust/src/types.rs index c0643ec66..dc8a86312 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1147,6 +1147,44 @@ impl ProviderConfig { } } +/// Provider-scoped CAPI (Copilot API) session options. +/// +/// WebSocket transport is the default for the CAPI Responses API whenever +/// the model advertises the `ws:/responses` endpoint. Set +/// [`disable_web_socket_responses`](Self::disable_web_socket_responses) to +/// `true` to opt out to the HTTP Responses transport instead, which is useful +/// for users behind proxies where WebSockets fail. +/// +/// This is equivalent to setting the +/// `COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES` environment variable. The option +/// is scoped under the `capi` namespace because a single session can host +/// multiple providers, so transport choice is provider-level. +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct CapiSessionOptions { + /// Opt out of WebSocket transport for CAPI Responses API calls. + /// + /// When `Some(true)`, the runtime uses HTTP Responses transport even if + /// the selected model advertises `ws:/responses`. When unset, the runtime + /// default applies. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disable_web_socket_responses: Option, +} + +impl CapiSessionOptions { + /// Construct CAPI session options with all fields unset. + pub fn new() -> Self { + Self::default() + } + + /// Opt out of WebSocket transport for CAPI Responses API calls. + pub fn with_disable_web_socket_responses(mut self, disable: bool) -> Self { + self.disable_web_socket_responses = Some(disable); + self + } +} + /// Azure-specific provider options. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1341,6 +1379,12 @@ pub struct SessionConfig { /// requests through this provider instead of the default Copilot /// routing. pub provider: Option, + /// Provider-scoped CAPI session options. + /// + /// Use this to opt out of the default WebSocket transport for CAPI + /// Responses API calls, equivalent to setting + /// `COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES`. + pub capi: Option, /// Enables or disables internal session telemetry for this session. /// /// When `Some(false)`, disables session telemetry. When `None` or @@ -1494,6 +1538,7 @@ impl std::fmt::Debug for SessionConfig { .field("agent", &self.agent) .field("infinite_sessions", &self.infinite_sessions) .field("provider", &self.provider) + .field("capi", &self.capi) .field("enable_session_telemetry", &self.enable_session_telemetry) .field("model_capabilities", &self.model_capabilities) .field("memory", &self.memory) @@ -1594,6 +1639,7 @@ impl Default for SessionConfig { agent: None, infinite_sessions: None, provider: None, + capi: None, enable_session_telemetry: None, model_capabilities: None, memory: None, @@ -1737,6 +1783,7 @@ impl SessionConfig { agent: self.agent, infinite_sessions: self.infinite_sessions, provider: self.provider, + capi: self.capi, enable_session_telemetry: self.enable_session_telemetry, model_capabilities: self.model_capabilities, memory: self.memory, @@ -2154,6 +2201,12 @@ impl SessionConfig { self } + /// Configure provider-scoped CAPI session options. + pub fn with_capi(mut self, capi: CapiSessionOptions) -> Self { + self.capi = Some(capi); + self + } + /// Enable or disable internal session telemetry. /// /// See [`Self::enable_session_telemetry`] for default and BYOK behavior. @@ -2349,6 +2402,12 @@ pub struct ResumeSessionConfig { pub infinite_sessions: Option, /// Re-supply BYOK provider configuration on resume. pub provider: Option, + /// Re-supply provider-scoped CAPI session options on resume. + /// + /// Use this to opt out of the default WebSocket transport for CAPI + /// Responses API calls, equivalent to setting + /// `COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES`. + pub capi: Option, /// Enables or disables internal session telemetry for this session. /// /// When `Some(false)`, disables session telemetry. When `None` or @@ -2482,6 +2541,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("agent", &self.agent) .field("infinite_sessions", &self.infinite_sessions) .field("provider", &self.provider) + .field("capi", &self.capi) .field("enable_session_telemetry", &self.enable_session_telemetry) .field("model_capabilities", &self.model_capabilities) .field("memory", &self.memory) @@ -2626,6 +2686,7 @@ impl ResumeSessionConfig { agent: self.agent, infinite_sessions: self.infinite_sessions, provider: self.provider, + capi: self.capi, enable_session_telemetry: self.enable_session_telemetry, model_capabilities: self.model_capabilities, memory: self.memory, @@ -2703,6 +2764,7 @@ impl ResumeSessionConfig { agent: None, infinite_sessions: None, provider: None, + capi: None, enable_session_telemetry: None, model_capabilities: None, memory: None, @@ -3093,6 +3155,12 @@ impl ResumeSessionConfig { self } + /// Re-supply provider-scoped CAPI session options on resume. + pub fn with_capi(mut self, capi: CapiSessionOptions) -> Self { + self.capi = Some(capi); + self + } + /// Enable or disable internal session telemetry on resume. /// /// See [`Self::enable_session_telemetry`] for default and BYOK behavior. @@ -4356,11 +4424,12 @@ mod tests { use super::{ AgentMode, Attachment, AttachmentLineRange, AttachmentSelectionPosition, - AttachmentSelectionRange, ConnectionState, CustomAgentConfig, DeliveryMode, ExtensionInfo, - GitHubReferenceType, InfiniteSessionConfig, LargeToolOutputConfig, MemoryConfiguration, - ProviderConfig, ReasoningSummary, ResumeSessionConfig, SessionConfig, SessionEvent, - SessionId, SystemMessageConfig, Tool, ToolBinaryResult, ToolResult, ToolResultExpanded, - ToolResultResponse, ensure_attachment_display_names, + AttachmentSelectionRange, CapiSessionOptions, ConnectionState, CustomAgentConfig, + DeliveryMode, ExtensionInfo, GitHubReferenceType, InfiniteSessionConfig, + LargeToolOutputConfig, MemoryConfiguration, ProviderConfig, ReasoningSummary, + ResumeSessionConfig, SessionConfig, SessionEvent, SessionId, SystemMessageConfig, Tool, + ToolBinaryResult, ToolResult, ToolResultExpanded, ToolResultResponse, + ensure_attachment_display_names, }; use crate::generated::session_events::TypedSessionEvent; @@ -4781,6 +4850,7 @@ mod tests { .with_config_directory(PathBuf::from("/tmp/config")) .with_working_directory(PathBuf::from("/tmp/work")) .with_github_token("ghp_test") + .with_capi(CapiSessionOptions::new().with_disable_web_socket_responses(true)) .with_enable_session_telemetry(false) .with_include_sub_agent_streaming_events(false) .with_extension_info(ExtensionInfo::new("github-app", "counter")); @@ -4817,6 +4887,10 @@ mod tests { assert_eq!(cfg.config_directory, Some(PathBuf::from("/tmp/config"))); assert_eq!(cfg.working_directory, Some(PathBuf::from("/tmp/work"))); assert_eq!(cfg.github_token.as_deref(), Some("ghp_test")); + assert_eq!( + cfg.capi, + Some(CapiSessionOptions::new().with_disable_web_socket_responses(true)) + ); assert_eq!(cfg.enable_session_telemetry, Some(false)); assert_eq!(cfg.include_sub_agent_streaming_events, Some(false)); assert_eq!( @@ -4847,6 +4921,7 @@ mod tests { .with_config_directory(PathBuf::from("/tmp/config")) .with_working_directory(PathBuf::from("/tmp/work")) .with_github_token("ghp_test") + .with_capi(CapiSessionOptions::new().with_disable_web_socket_responses(true)) .with_enable_session_telemetry(false) .with_include_sub_agent_streaming_events(true) .with_suppress_resume_event(true) @@ -4883,6 +4958,10 @@ mod tests { assert_eq!(cfg.config_directory, Some(PathBuf::from("/tmp/config"))); assert_eq!(cfg.working_directory, Some(PathBuf::from("/tmp/work"))); assert_eq!(cfg.github_token.as_deref(), Some("ghp_test")); + assert_eq!( + cfg.capi, + Some(CapiSessionOptions::new().with_disable_web_socket_responses(true)) + ); assert_eq!(cfg.enable_session_telemetry, Some(false)); assert_eq!(cfg.include_sub_agent_streaming_events, Some(true)); assert_eq!(cfg.suppress_resume_event, Some(true)); @@ -5061,6 +5140,61 @@ mod tests { assert!(wire_unset.get("maxOutputTokens").is_none()); } + #[test] + fn capi_session_options_builder_composes_and_serializes() { + let cfg = CapiSessionOptions::new().with_disable_web_socket_responses(true); + + assert_eq!(cfg.disable_web_socket_responses, Some(true)); + + let wire = serde_json::to_value(&cfg).unwrap(); + assert_eq!( + wire, + serde_json::json!({ "disableWebSocketResponses": true }) + ); + + let unset = CapiSessionOptions::new(); + let wire_unset = serde_json::to_value(&unset).unwrap(); + assert!(wire_unset.get("disableWebSocketResponses").is_none()); + } + + #[test] + fn session_config_with_capi_serializes() { + let (wire, _) = SessionConfig::default() + .with_capi(CapiSessionOptions::new().with_disable_web_socket_responses(true)) + .into_wire(Some(SessionId::from("capi-create"))) + .expect("no duplicate handlers"); + let json = serde_json::to_value(&wire).unwrap(); + assert_eq!( + json["capi"], + serde_json::json!({ "disableWebSocketResponses": true }) + ); + + let (empty_wire, _) = SessionConfig::default() + .into_wire(Some(SessionId::from("capi-create-unset"))) + .expect("no duplicate handlers"); + let empty_json = serde_json::to_value(&empty_wire).unwrap(); + assert!(empty_json.get("capi").is_none()); + } + + #[test] + fn resume_session_config_with_capi_serializes() { + let (wire, _) = ResumeSessionConfig::new(SessionId::from("capi-resume")) + .with_capi(CapiSessionOptions::new().with_disable_web_socket_responses(true)) + .into_wire() + .expect("no duplicate handlers"); + let json = serde_json::to_value(&wire).unwrap(); + assert_eq!( + json["capi"], + serde_json::json!({ "disableWebSocketResponses": true }) + ); + + let (empty_wire, _) = ResumeSessionConfig::new(SessionId::from("capi-resume-unset")) + .into_wire() + .expect("no duplicate handlers"); + let empty_json = serde_json::to_value(&empty_wire).unwrap(); + assert!(empty_json.get("capi").is_none()); + } + #[test] fn system_message_config_builder_composes() { use std::collections::HashMap; diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 1b58abacd..bf908403a 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -24,7 +24,7 @@ use crate::generated::api_types::{ }; use crate::generated::session_events::ReasoningSummary; use crate::types::{ - CloudSessionOptions, CustomAgentConfig, DefaultAgentConfig, ExtensionInfo, + CapiSessionOptions, CloudSessionOptions, CustomAgentConfig, DefaultAgentConfig, ExtensionInfo, InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, MemoryConfiguration, ProviderConfig, SessionId, SystemMessageConfig, Tool, }; @@ -130,6 +130,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub provider: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub capi: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub enable_session_telemetry: Option, #[serde(skip_serializing_if = "Option::is_none")] pub model_capabilities: Option, @@ -239,6 +241,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub provider: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub capi: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub enable_session_telemetry: Option, #[serde(skip_serializing_if = "Option::is_none")] pub model_capabilities: Option, From 91ec4c8f92aca806e9f8e4d78571a2a742c4b2d1 Mon Sep 17 00:00:00 2001 From: Derek Legenzoff Date: Wed, 17 Jun 2026 22:31:17 -0700 Subject: [PATCH 2/2] Add provider.transport BYOK option Add the BYOK provider `transport` field ("http" | "websockets", default "http") to the hand-written ProviderConfig across all six SDK languages, so BYOK OpenAI-compatible providers can opt into delivering Responses API requests over a persistent WebSocket connection instead of HTTP. SDK-side follow-up to github/copilot-agent-runtime#9557, which adds the runtime `transport` option. The SDK's consumer-facing ProviderConfig is hand-written (not generated from the schema), so the field is added as a pass-through mirroring the existing `wireApi` field, flowing through the already-wired `provider` option on session create and resume. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Types.cs | 9 ++++++ dotnet/test/Unit/SerializationTests.cs | 5 +++- go/client_test.go | 27 ++++++++++++++++++ go/types.go | 4 +++ .../github/copilot/rpc/ProviderConfig.java | 28 +++++++++++++++++++ .../github/copilot/ProviderConfigTest.java | 22 +++++++++++++++ nodejs/src/types.ts | 11 ++++++++ nodejs/test/client.test.ts | 4 +++ python/copilot/client.py | 2 ++ python/copilot/session.py | 5 ++++ python/test_client.py | 2 ++ rust/src/types.rs | 15 ++++++++++ 12 files changed, 133 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index abbdc97b8..ed792def0 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1991,6 +1991,15 @@ public sealed class ProviderConfig [JsonPropertyName("wireApi")] public string? WireApi { get; set; } + ///

+ /// Transport for OpenAI Responses requests ("http" or "websockets"). Defaults to "http". + /// Set to "websockets" to deliver Responses API requests over a persistent WebSocket + /// connection instead of HTTP. Applies to OpenAI-compatible providers using + /// wireApi: "responses". + /// + [JsonPropertyName("transport")] + public string? Transport { get; set; } + /// /// Base URL of the provider's API endpoint. /// diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 4a6dc21e5..5dbc53acb 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -27,7 +27,8 @@ public void ProviderConfig_CanSerializeHeaders_WithSdkOptions() ModelId = "gpt-4o", WireModel = "my-finetune-v3", MaxPromptTokens = 100_000, - MaxOutputTokens = 4096 + MaxOutputTokens = 4096, + Transport = "websockets" }; var json = JsonSerializer.Serialize(original, options); @@ -39,6 +40,7 @@ public void ProviderConfig_CanSerializeHeaders_WithSdkOptions() Assert.Equal("my-finetune-v3", root.GetProperty("wireModel").GetString()); Assert.Equal(100_000, root.GetProperty("maxPromptTokens").GetInt32()); Assert.Equal(4096, root.GetProperty("maxOutputTokens").GetInt32()); + Assert.Equal("websockets", root.GetProperty("transport").GetString()); var deserialized = JsonSerializer.Deserialize(json, options); Assert.NotNull(deserialized); @@ -48,6 +50,7 @@ public void ProviderConfig_CanSerializeHeaders_WithSdkOptions() Assert.Equal("my-finetune-v3", deserialized.WireModel); Assert.Equal(100_000, deserialized.MaxPromptTokens); Assert.Equal(4096, deserialized.MaxOutputTokens); + Assert.Equal("websockets", deserialized.Transport); } [Fact] diff --git a/go/client_test.go b/go/client_test.go index a6366c7d1..d82ba36d1 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -1464,6 +1464,33 @@ func TestSessionRequests_Capi(t *testing.T) { }) } +func TestProviderConfig_Transport(t *testing.T) { + t.Run("serializes transport with camelCase key", func(t *testing.T) { + cfg := ProviderConfig{BaseURL: "https://example.com", Transport: "websockets"} + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["transport"] != "websockets" { + t.Errorf("Expected transport=websockets, got %v", m["transport"]) + } + }) + + t.Run("omits transport from JSON when unset", func(t *testing.T) { + cfg := ProviderConfig{BaseURL: "https://example.com"} + data, _ := json.Marshal(cfg) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["transport"]; ok { + t.Error("Expected transport to be omitted when unset") + } + }) +} + func TestResumeSessionRequest_Commands(t *testing.T) { t.Run("forwards commands in session.resume RPC", func(t *testing.T) { req := resumeSessionRequest{ diff --git a/go/types.go b/go/types.go index c3210c447..fb78cb031 100644 --- a/go/types.go +++ b/go/types.go @@ -1511,6 +1511,10 @@ type ProviderConfig struct { Type string `json:"type,omitempty"` // WireAPI is the API format (openai/azure only): "completions" or "responses". Defaults to "completions". WireAPI string `json:"wireApi,omitempty"` + // Transport for OpenAI Responses requests: "http" or "websockets". Defaults to "http". + // Set "websockets" to deliver Responses API requests over a persistent WebSocket + // connection instead of HTTP. Applies to OpenAI-compatible providers using WireAPI "responses". + Transport string `json:"transport,omitempty"` // BaseURL is the API endpoint URL BaseURL string `json:"baseUrl"` // APIKey is the API key. Optional for local providers like Ollama. diff --git a/java/src/main/java/com/github/copilot/rpc/ProviderConfig.java b/java/src/main/java/com/github/copilot/rpc/ProviderConfig.java index 6c9cf379f..8ba492ed9 100644 --- a/java/src/main/java/com/github/copilot/rpc/ProviderConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/ProviderConfig.java @@ -44,6 +44,9 @@ public class ProviderConfig { @JsonProperty("wireApi") private String wireApi; + @JsonProperty("transport") + private String transport; + @JsonProperty("baseUrl") private String baseUrl; @@ -122,6 +125,31 @@ public ProviderConfig setWireApi(String wireApi) { return this; } + /** + * Gets the transport for OpenAI Responses requests. + * + * @return the transport ("http" or "websockets") + */ + public String getTransport() { + return transport; + } + + /** + * Sets the transport for OpenAI Responses requests. + *

+ * Defaults to "http". Set to "websockets" to deliver Responses API requests + * over a persistent WebSocket connection instead of HTTP. Applies to + * OpenAI-compatible providers using {@code wireApi} "responses". + * + * @param transport + * the transport ("http" or "websockets") + * @return this config for method chaining + */ + public ProviderConfig setTransport(String transport) { + this.transport = transport; + return this; + } + /** * Gets the base URL for the API. * diff --git a/java/src/test/java/com/github/copilot/ProviderConfigTest.java b/java/src/test/java/com/github/copilot/ProviderConfigTest.java index 5c40230ec..effb36040 100644 --- a/java/src/test/java/com/github/copilot/ProviderConfigTest.java +++ b/java/src/test/java/com/github/copilot/ProviderConfigTest.java @@ -224,6 +224,28 @@ void testSerializeCustomWireApi() throws Exception { assertEquals("responses", json.get("wireApi").asText()); } + @Test + void testSerializeTransport() throws Exception { + var provider = new ProviderConfig().setType("openai").setBaseUrl("https://custom.example.com").setApiKey("key") + .setWireApi("responses").setTransport("websockets"); + + JsonNode json = MAPPER.valueToTree(provider); + + assertEquals("websockets", json.get("transport").asText()); + + ProviderConfig roundTrip = MAPPER.readValue(MAPPER.writeValueAsString(provider), ProviderConfig.class); + assertEquals("websockets", roundTrip.getTransport()); + } + + @Test + void testTransportOmittedWhenNull() throws Exception { + var provider = new ProviderConfig().setType("openai").setBaseUrl("https://custom.example.com"); + + JsonNode json = MAPPER.valueToTree(provider); + + assertTrue(json.path("transport").isMissingNode()); + } + // ========================================================================= // JSON serialization — all fields populated // ========================================================================= diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index ad2a19b75..dab56fc4e 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -2147,6 +2147,17 @@ export interface ProviderConfig { */ wireApi?: "completions" | "responses"; + /** + * Transport for OpenAI Responses requests. Defaults to "http". + * + * Set to "websockets" to deliver Responses API requests over a persistent + * WebSocket connection instead of HTTP. Useful for long-running, + * tool-call-heavy sessions that benefit from incremental + * `previous_response_id` continuations. Applies to OpenAI-compatible + * providers using `wireApi: "responses"`. + */ + transport?: "http" | "websockets"; + /** * API endpoint URL */ diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 41fae7bee..77f6da934 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -989,6 +989,7 @@ describe("CopilotClient", () => { wireModel: "my-finetune-v3", maxPromptTokens: 100_000, maxOutputTokens: 4096, + transport: "websockets", }, }); @@ -1001,6 +1002,7 @@ describe("CopilotClient", () => { wireModel: "my-finetune-v3", maxPromptTokens: 100_000, maxOutputTokens: 4096, + transport: "websockets", }) ); spy.mockRestore(); @@ -1028,6 +1030,7 @@ describe("CopilotClient", () => { wireModel: "my-finetune-v3", maxPromptTokens: 100_000, maxOutputTokens: 4096, + transport: "websockets", }, }); @@ -1040,6 +1043,7 @@ describe("CopilotClient", () => { wireModel: "my-finetune-v3", maxPromptTokens: 100_000, maxOutputTokens: 4096, + transport: "websockets", }) ); spy.mockRestore(); diff --git a/python/copilot/client.py b/python/copilot/client.py index 39f8b1f67..45d768ce6 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -3179,6 +3179,8 @@ def _convert_provider_to_wire_format( wire_provider["apiKey"] = provider["api_key"] if "wire_api" in provider: wire_provider["wireApi"] = provider["wire_api"] + if "transport" in provider: + wire_provider["transport"] = provider["transport"] if "bearer_token" in provider: wire_provider["bearerToken"] = provider["bearer_token"] if "headers" in provider: diff --git a/python/copilot/session.py b/python/copilot/session.py index 3720af05d..cc6d5e279 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -1071,6 +1071,11 @@ class ProviderConfig(TypedDict, total=False): type: Literal["openai", "azure", "anthropic"] wire_api: Literal["completions", "responses"] + # Transport for OpenAI Responses requests. Defaults to "http". Set + # "websockets" to deliver Responses API requests over a persistent WebSocket + # connection instead of HTTP. Applies to OpenAI-compatible providers using + # wire_api "responses". + transport: Literal["http", "websockets"] base_url: str api_key: str # Bearer token for authentication. Sets the Authorization header directly. diff --git a/python/test_client.py b/python/test_client.py index 2b5e14984..f5bfe9851 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -1125,6 +1125,7 @@ async def mock_request(method, params, **kwargs): "wire_model": "my-finetune-v3", "max_prompt_tokens": 100_000, "max_output_tokens": 4096, + "transport": "websockets", }, ) @@ -1135,6 +1136,7 @@ async def mock_request(method, params, **kwargs): assert provider["wireModel"] == "my-finetune-v3" assert provider["maxPromptTokens"] == 100_000 assert provider["maxOutputTokens"] == 4096 + assert provider["transport"] == "websockets" finally: await client.force_stop() diff --git a/rust/src/types.rs b/rust/src/types.rs index dc8a86312..59f8d617a 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1027,6 +1027,12 @@ pub struct ProviderConfig { /// Defaults to `"completions"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub wire_api: Option, + /// Transport for OpenAI Responses requests: `"http"` or `"websockets"`. + /// Defaults to `"http"`. Set `"websockets"` to deliver Responses API + /// requests over a persistent WebSocket connection instead of HTTP. + /// Applies to OpenAI-compatible providers using `wire_api` `"responses"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transport: Option, /// API endpoint URL. pub base_url: String, /// API key. Optional for local providers like Ollama. @@ -1090,6 +1096,13 @@ impl ProviderConfig { self } + /// Set the transport (`"http"` or `"websockets"`) for OpenAI Responses + /// requests. Defaults to `"http"`. + pub fn with_transport(mut self, transport: impl Into) -> Self { + self.transport = Some(transport.into()); + self + } + /// Set the API key. Optional for local providers like Ollama. pub fn with_api_key(mut self, api_key: impl Into) -> Self { self.api_key = Some(api_key.into()); @@ -5100,6 +5113,7 @@ mod tests { let cfg = ProviderConfig::new("https://api.example.com") .with_provider_type("openai") .with_wire_api("completions") + .with_transport("websockets") .with_api_key("sk-test") .with_bearer_token("bearer-test") .with_headers(headers) @@ -5111,6 +5125,7 @@ mod tests { assert_eq!(cfg.base_url, "https://api.example.com"); assert_eq!(cfg.provider_type.as_deref(), Some("openai")); assert_eq!(cfg.wire_api.as_deref(), Some("completions")); + assert_eq!(cfg.transport.as_deref(), Some("websockets")); assert_eq!(cfg.api_key.as_deref(), Some("sk-test")); assert_eq!(cfg.bearer_token.as_deref(), Some("bearer-test")); assert_eq!(