diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 5a5d34dcd..eaf9859bc 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, @@ -1161,6 +1162,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, @@ -2359,6 +2361,7 @@ internal record CreateSessionRequest( IList? AvailableTools, IList? ExcludedTools, ProviderConfig? Provider, + CapiSessionOptions? Capi, bool? EnableSessionTelemetry, bool? RequestPermission, bool? RequestUserInput, @@ -2451,6 +2454,7 @@ internal record ResumeSessionRequest( IList? AvailableTools, IList? ExcludedTools, ProviderConfig? Provider, + CapiSessionOptions? Capi, bool? EnableSessionTelemetry, bool? RequestPermission, bool? RequestUserInput, @@ -2577,6 +2581,7 @@ internal record HooksInvokeResponse( [JsonSerializable(typeof(EmbeddingCacheStorageMode))] [JsonSerializable(typeof(ModelCapabilitiesOverride))] [JsonSerializable(typeof(ProviderConfig))] + [JsonSerializable(typeof(CapiSessionOptions))] [JsonSerializable(typeof(NamedProviderConfig))] [JsonSerializable(typeof(ProviderModelConfig))] [JsonSerializable(typeof(ResumeSessionRequest))] diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index d7b326afb..06878a727 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. /// @@ -2058,6 +2067,27 @@ public sealed class ProviderConfig public int? MaxOutputTokens { get; set; } } +/// +/// Provider-scoped options for the Copilot API (CAPI) provider. +/// +public sealed class CapiSessionOptions +{ + /// + /// When , forces the HTTP Responses transport for the CAPI Responses API + /// instead of the default WebSocket transport. + /// + /// + /// WebSocket transport is the default for CAPI Responses API requests when the model advertises + /// the ws:/responses endpoint. Set this to for users behind proxies + /// where WebSockets fail. Setting it to 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("enableWebSocketResponses")] + public bool? EnableWebSocketResponses { get; set; } +} + /// /// Azure OpenAI-specific provider options. /// @@ -2623,6 +2653,7 @@ protected SessionConfigBase(SessionConfigBase? other) OnPermissionRequest = other.OnPermissionRequest; OnUserInputRequest = other.OnUserInputRequest; Provider = other.Provider; + Capi = other.Capi; Providers = other.Providers is not null ? [.. other.Providers] : null; Models = other.Models is not null ? [.. other.Models] : null; EnableSessionTelemetry = other.EnableSessionTelemetry; @@ -2780,6 +2811,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; } + /// /// Named BYOK provider connections (transport + credentials). Additive to Copilot /// API authentication (unlike ); combine with . @@ -3700,6 +3736,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..9df9a200c 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 { EnableWebSocketResponses = false }, 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 { EnableWebSocketResponses = false }, + }; + + var clone = original.Clone(); + + Assert.Same(original.Capi, clone.Capi); + } + + [Fact] + public void ResumeSessionConfig_Clone_CopiesCapiOptions() + { + var original = new ResumeSessionConfig + { + Capi = new CapiSessionOptions { EnableWebSocketResponses = false }, + }; + + 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..baf71c299 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,26 @@ 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] + public void CapiSessionOptions_CanSerializeEnableWebSocketResponses_WithSdkOptions() + { + var options = GetSerializerOptions(); + var original = new CapiSessionOptions + { + EnableWebSocketResponses = false + }; + + var json = JsonSerializer.Serialize(original, options); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.False(root.GetProperty("enableWebSocketResponses").GetBoolean()); + + var deserialized = JsonSerializer.Deserialize(json, options); + Assert.NotNull(deserialized); + Assert.False(deserialized.EnableWebSocketResponses); } [Fact] @@ -221,6 +243,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 { EnableWebSocketResponses = false }; + + 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.False(createDocument.RootElement.GetProperty("capi").GetProperty("enableWebSocketResponses").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.False(resumeDocument.RootElement.GetProperty("capi").GetProperty("enableWebSocketResponses").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 af9044ad9..a824648b4 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.Providers = config.Providers req.Models = config.Models req.EnableSessionTelemetry = config.EnableSessionTelemetry @@ -978,6 +979,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.Providers = config.Providers req.Models = config.Models req.EnableSessionTelemetry = config.EnableSessionTelemetry diff --git a/go/client_test.go b/go/client_test.go index a3051f881..383ee315d 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{EnableWebSocketResponses: Bool(false)}, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + assertCapiEnableWebSocketResponses(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{EnableWebSocketResponses: Bool(false)}, + }) + if err != nil { + t.Fatalf("ResumeSessionWithOptions failed: %v", err) + } + assertCapiEnableWebSocketResponses(t, <-resumeParams) +} + +func assertCapiEnableWebSocketResponses(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["enableWebSocketResponses"] != false { + t.Fatalf("expected capi.enableWebSocketResponses=false, got %v", capi["enableWebSocketResponses"]) + } +} + +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 { @@ -1337,6 +1407,88 @@ 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{EnableWebSocketResponses: Bool(false)}, + } + 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["enableWebSocketResponses"] != false { + t.Errorf("Expected enableWebSocketResponses=false, got %v", capi["enableWebSocketResponses"]) + } + }) + + t.Run("forwards capi options in session.resume RPC", func(t *testing.T) { + req := resumeSessionRequest{ + SessionID: "s1", + Capi: &CapiSessionOptions{EnableWebSocketResponses: Bool(false)}, + } + 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["enableWebSocketResponses"] != false { + t.Errorf("Expected enableWebSocketResponses=false, got %v", capi["enableWebSocketResponses"]) + } + }) + + 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 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 ba83c6b6d..0219ce3cd 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 // Providers configures named BYOK provider connections. Additive to Copilot // API auth (unlike Provider); combine with Models. Cannot be combined with Provider. // @@ -1328,6 +1330,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 // Providers configures named BYOK provider connections. Additive to Copilot // API auth (unlike Provider); combine with Models. Cannot be combined with Provider. // @@ -1531,6 +1535,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. @@ -1564,6 +1572,22 @@ type ProviderConfig struct { MaxOutputTokens int `json:"maxOutputTokens,omitempty"` } +// CapiSessionOptions configures provider-scoped Copilot API (CAPI) session behavior. +// +// WebSocket transport is the default for the CAPI Responses API whenever the +// model advertises the ws:/responses endpoint. Set EnableWebSocketResponses to +// Bool(false) to force 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 { + // EnableWebSocketResponses controls whether the CAPI Responses API uses + // WebSocket transport. Enabled by default when the model advertises + // ws:/responses support; set to Bool(false) to force HTTP Responses transport. + EnableWebSocketResponses *bool `json:"enableWebSocketResponses,omitempty"` +} + // AzureProviderOptions contains Azure-specific provider configuration type AzureProviderOptions struct { // APIVersion is the Azure API version. Defaults to "2024-10-21". @@ -1804,6 +1828,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"` Providers []NamedProviderConfig `json:"providers,omitempty"` Models []ProviderModelConfig `json:"models,omitempty"` EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` @@ -1885,6 +1910,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"` Providers []NamedProviderConfig `json:"providers,omitempty"` Models []ProviderModelConfig `json:"models,omitempty"` EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` diff --git a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java index 66d3e4344..c26548a2f 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()); request.setProviders(config.getProviders()); request.setModels(config.getModels()); config.getEnableSessionTelemetry().ifPresent(request::setEnableSessionTelemetry); @@ -227,6 +228,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setAvailableTools(config.getAvailableTools()); request.setExcludedTools(config.getExcludedTools()); request.setProvider(config.getProvider()); + request.setCapi(config.getCapi()); request.setProviders(config.getProviders()); request.setModels(config.getModels()); config.getEnableSessionTelemetry().ifPresent(request::setEnableSessionTelemetry); 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..d94d59f67 --- /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 Copilot API (CAPI) provider. + *

+ * WebSocket transport is the default for the CAPI Responses API whenever the + * model advertises the {@code ws:/responses} endpoint. Setting + * {@link #setEnableWebSocketResponses(Boolean)} to {@code false} forces 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("enableWebSocketResponses") + private Boolean enableWebSocketResponses; + + /** + * Gets whether CAPI Responses API WebSocket transport is enabled. + * + * @return {@code false} to force the HTTP Responses transport, {@code true} to + * explicitly use WebSocket transport, or {@code null} to use the + * default behavior + */ + public Boolean getEnableWebSocketResponses() { + return enableWebSocketResponses; + } + + /** + * Sets whether to use 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 false} to force 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 enableWebSocketResponses + * {@code false} to force the HTTP Responses transport + * @return this config for method chaining + */ + public CapiSessionOptions setEnableWebSocketResponses(Boolean enableWebSocketResponses) { + this.enableWebSocketResponses = enableWebSocketResponses; + 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 42a431f49..773e97ce7 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -62,6 +62,8 @@ public final class CreateSessionRequest { @JsonProperty("provider") private ProviderConfig provider; + @JsonProperty("capi") + private CapiSessionOptions capi; @JsonProperty("providers") private List providers; @@ -321,6 +323,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 the named provider connections. @return the named providers */ @CopilotExperimental public List getProviders() { 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/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index fa900aceb..df600b0af 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -46,6 +46,7 @@ public class ResumeSessionConfig { private List availableTools; private List excludedTools; private ProviderConfig provider; + private CapiSessionOptions capi; private List providers; private List models; private Boolean enableSessionTelemetry; @@ -257,6 +258,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#setEnableWebSocketResponses(Boolean)} with + * {@code false} to force the HTTP Responses transport instead of the default + * CAPI Responses API WebSocket transport. + * + * @param capi + * the CAPI session options + * @return this config for method chaining + * @see CapiSessionOptions + */ + public ResumeSessionConfig setCapi(CapiSessionOptions capi) { + this.capi = capi; + return this; + } + /** * Gets the named BYOK provider connections. * @@ -1603,6 +1630,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.providers = this.providers != null ? new ArrayList<>(this.providers) : null; copy.models = this.models != null ? new ArrayList<>(this.models) : null; copy.enableSessionTelemetry = this.enableSessionTelemetry; 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 2067a291c..490fbd618 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -64,6 +64,8 @@ public final class ResumeSessionRequest { @JsonProperty("provider") private ProviderConfig provider; + @JsonProperty("capi") + private CapiSessionOptions capi; @JsonProperty("providers") private List providers; @@ -326,6 +328,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 the named provider connections. @return the named providers */ @CopilotExperimental public List getProviders() { 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 ef483c410..5567d32ac 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -50,6 +50,7 @@ public class SessionConfig { private List availableTools; private List excludedTools; private ProviderConfig provider; + private CapiSessionOptions capi; private List providers; private List models; private Boolean enableSessionTelemetry; @@ -358,6 +359,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#setEnableWebSocketResponses(Boolean)} with + * {@code false} to force the HTTP Responses transport instead of the default + * CAPI Responses API WebSocket transport. + * + * @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; + } + /** * Gets the named BYOK provider connections. * @@ -1727,6 +1754,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.providers = this.providers != null ? new ArrayList<>(this.providers) : null; copy.models = this.models != null ? new ArrayList<>(this.models) : null; copy.enableSessionTelemetry = this.enableSessionTelemetry; 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..17e8f131f --- /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.getEnableWebSocketResponses()); + } + + @Test + void fluentSetterReturnsSameInstance() { + var capi = new CapiSessionOptions(); + + assertSame(capi, capi.setEnableWebSocketResponses(true)); + assertEquals(Boolean.TRUE, capi.getEnableWebSocketResponses()); + } + + @Test + void serializesEnableWebSocketResponses() { + var capi = new CapiSessionOptions().setEnableWebSocketResponses(true); + + JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(capi); + + assertTrue(json.get("enableWebSocketResponses").asBoolean()); + } + + @Test + void omitsUnsetEnableWebSocketResponses() { + var capi = new CapiSessionOptions(); + + JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(capi); + + assertTrue(json.path("enableWebSocketResponses").isMissingNode()); + assertEquals(0, json.size()); + } + + @Test + void createRequestIncludesCapiWhenSet() { + var config = new SessionConfig().setCapi(new CapiSessionOptions().setEnableWebSocketResponses(true)); + + var request = SessionRequestBuilder.buildCreateRequest(config, "session-1"); + JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(request); + + assertNotNull(request.getCapi()); + assertTrue(json.get("capi").get("enableWebSocketResponses").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().setEnableWebSocketResponses(true)); + + var request = SessionRequestBuilder.buildResumeRequest("session-1", config); + JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(request); + + assertNotNull(request.getCapi()); + assertTrue(json.get("capi").get("enableWebSocketResponses").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().setEnableWebSocketResponses(true); + + var clone = new SessionConfig().setCapi(capi).clone(); + + assertSame(capi, clone.getCapi()); + } + + @Test + void resumeSessionConfigCloneCopiesCapiReference() { + var capi = new CapiSessionOptions().setEnableWebSocketResponses(true); + + var clone = new ResumeSessionConfig().setCapi(capi).clone(); + + assertSame(capi, clone.getCapi()); + } + + @Test + void falseValueIsSerializedWhenExplicitlySet() { + var capi = new CapiSessionOptions().setEnableWebSocketResponses(false); + + JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(capi); + + assertFalse(json.get("enableWebSocketResponses").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/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/client.ts b/nodejs/src/client.ts index a6efb061a..473479ac1 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1250,6 +1250,7 @@ export class CopilotClient { excludedTools: toolFilterOptions.excludedTools, toolFilterPrecedence: toolFilterOptions.toolFilterPrecedence, provider: config.provider, + capi: config.capi, providers: config.providers, models: config.models, enableSessionTelemetry: config.enableSessionTelemetry, @@ -1436,6 +1437,7 @@ export class CopilotClient { description: cmd.description, })), provider: config.provider, + capi: config.capi, providers: config.providers, models: config.models, modelCapabilities: config.modelCapabilities, diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 9b266fc9c..4182b7432 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 f198a88b3..452a47517 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 { + /** + * Whether to use 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 `false` to fall back + * to the HTTP Responses transport instead — useful for users behind proxies + * where WebSocket connections fail. + * + * Setting this to `false` is equivalent to setting the + * `COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES` environment variable. + * + * @default true + */ + enableWebSocketResponses?: 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; + /** * Named BYOK provider connections (transport + credentials), referenced by * {@link models} entries via {@link NamedProviderConfig.name}. @@ -2141,6 +2173,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 42c0ff18e..ef804095f 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: { enableWebSocketResponses: false }, + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + capi: { enableWebSocketResponses: false }, + }); + + const createPayload = spy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + const resumePayload = spy.mock.calls.find( + ([method]) => method === "session.resume" + )![1] as any; + expect(createPayload.capi).toEqual({ enableWebSocketResponses: false }); + expect(resumePayload.capi).toEqual({ enableWebSocketResponses: false }); + }); + it("forwards pluginDirectories and largeOutput in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); @@ -957,6 +989,7 @@ describe("CopilotClient", () => { wireModel: "my-finetune-v3", maxPromptTokens: 100_000, maxOutputTokens: 4096, + transport: "websockets", }, }); @@ -969,6 +1002,7 @@ describe("CopilotClient", () => { wireModel: "my-finetune-v3", maxPromptTokens: 100_000, maxOutputTokens: 4096, + transport: "websockets", }) ); spy.mockRestore(); @@ -996,6 +1030,7 @@ describe("CopilotClient", () => { wireModel: "my-finetune-v3", maxPromptTokens: 100_000, maxOutputTokens: 4096, + transport: "websockets", }, }); @@ -1008,6 +1043,7 @@ describe("CopilotClient", () => { wireModel: "my-finetune-v3", maxPromptTokens: 100_000, maxOutputTokens: 4096, + transport: "websockets", }) ); spy.mockRestore(); diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 1bda91072..60d3546bd 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -28,6 +28,7 @@ OpenCanvasInstance, ) from .client import ( + CapiSessionOptions, ChildProcessRuntimeConnection, CloudSessionOptions, CloudSessionRepository, @@ -178,6 +179,7 @@ "CanvasHostContext", "CanvasHostContextCapabilities", "CanvasJsonSchema", + "CapiSessionOptions", "ChildProcessRuntimeConnection", "CloudSessionOptions", "CloudSessionRepository", diff --git a/python/copilot/client.py b/python/copilot/client.py index 2c407149c..8ba350adc 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -135,6 +135,20 @@ class CloudSessionOptions: repository: CloudSessionRepository | None = None +class CapiSessionOptions(TypedDict, total=False): + """Provider-scoped Copilot API (CAPI) session options.""" + + enable_web_socket_responses: bool + """Whether to use WebSocket transport for the CAPI Responses API. + + Enabled by default when the model advertises ``ws:/responses`` support. Set + to ``False`` to force the HTTP Responses transport instead, which is + equivalent to the ``COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES`` environment + variable and useful in environments where WebSockets are blocked (e.g. + behind a proxy). + """ + + def _cloud_session_options_to_dict(options: CloudSessionOptions) -> dict[str, Any]: result: dict[str, Any] = {} if options.repository is not None: @@ -148,6 +162,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 "enable_web_socket_responses" in options: + wire["enableWebSocketResponses"] = options["enable_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") @@ -1629,6 +1650,7 @@ async def create_session( hooks: SessionHooks | None = None, working_directory: str | None = None, provider: ProviderConfig | None = None, + capi: CapiSessionOptions | None = None, providers: list[NamedProviderConfig] | None = None, models: list[ProviderModelConfig] | None = None, enable_session_telemetry: bool | None = None, @@ -1713,6 +1735,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 + ``enable_web_socket_responses=False`` to force 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. providers: Named BYOK provider connections. Additive to Copilot API auth (unlike `provider`); combine with `models`. Cannot be combined with `provider`. @@ -1925,6 +1957,8 @@ 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) # Add additive BYOK provider/model registry if provided if providers: payload["providers"] = [ @@ -2221,6 +2255,7 @@ async def resume_session( hooks: SessionHooks | None = None, working_directory: str | None = None, provider: ProviderConfig | None = None, + capi: CapiSessionOptions | None = None, providers: list[NamedProviderConfig] | None = None, models: list[ProviderModelConfig] | None = None, enable_session_telemetry: bool | None = None, @@ -2306,6 +2341,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 + ``enable_web_socket_responses=False`` to force 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. providers: Named BYOK provider connections. Additive to Copilot API auth (unlike `provider`); combine with `models`. Cannot be combined with `provider`. @@ -2461,6 +2506,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 providers: payload["providers"] = [ self._convert_named_provider_to_wire_format(p) for p in providers @@ -3168,6 +3215,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 139376ca8..f15d6c7d3 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 6af4450de..a3b7e85f0 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 = {"enable_web_socket_responses": False} + resume_capi: CapiSessionOptions = {"enable_web_socket_responses": True} + + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + capi=create_capi, + ) + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + capi=resume_capi, + ) + + assert captured["session.create"]["capi"] == { + "enableWebSocketResponses": False, + } + assert captured["session.resume"]["capi"] == { + "enableWebSocketResponses": True, + } + 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)) @@ -1084,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", }, ) @@ -1094,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 5c1c0ddf3..01b18eb52 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()); @@ -1147,6 +1160,44 @@ impl ProviderConfig { } } +/// Provider-scoped Copilot API (CAPI) session options. +/// +/// WebSocket transport is the default for the CAPI Responses API whenever +/// the model advertises the `ws:/responses` endpoint. Set +/// [`enable_web_socket_responses`](Self::enable_web_socket_responses) to +/// `false` to force the HTTP Responses transport instead, which is useful +/// for users behind proxies where WebSockets fail. +/// +/// Setting it to `false` 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 { + /// Whether to use WebSocket transport for CAPI Responses API calls. + /// + /// When `Some(false)`, the runtime uses HTTP Responses transport even if + /// the selected model advertises `ws:/responses`. When unset, the runtime + /// default applies (WebSocket transport when advertised). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_web_socket_responses: Option, +} + +impl CapiSessionOptions { + /// Construct CAPI session options with all fields unset. + pub fn new() -> Self { + Self::default() + } + + /// Set whether to use WebSocket transport for CAPI Responses API calls. + pub fn with_enable_web_socket_responses(mut self, enable: bool) -> Self { + self.enable_web_socket_responses = Some(enable); + self + } +} + /// Azure-specific provider options. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1532,6 +1583,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, /// **Experimental.** This field is part of an experimental multi-provider /// BYOK surface and may change or be removed in a future release. /// @@ -1698,6 +1755,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) @@ -1798,6 +1856,7 @@ impl Default for SessionConfig { agent: None, infinite_sessions: None, provider: None, + capi: None, providers: None, models: None, enable_session_telemetry: None, @@ -1943,6 +2002,7 @@ impl SessionConfig { agent: self.agent, infinite_sessions: self.infinite_sessions, provider: self.provider, + capi: self.capi, providers: self.providers, models: self.models, enable_session_telemetry: self.enable_session_telemetry, @@ -2362,6 +2422,12 @@ impl SessionConfig { self } + /// Configure provider-scoped CAPI session options. + pub fn with_capi(mut self, capi: CapiSessionOptions) -> Self { + self.capi = Some(capi); + self + } + /// **Experimental.** This method is part of an experimental multi-provider /// BYOK surface and may change or be removed in a future release. /// @@ -2577,6 +2643,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, /// **Experimental.** This field is part of an experimental multi-provider /// BYOK surface and may change or be removed in a future release. /// @@ -2722,6 +2794,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) @@ -2866,6 +2939,7 @@ impl ResumeSessionConfig { agent: self.agent, infinite_sessions: self.infinite_sessions, provider: self.provider, + capi: self.capi, providers: self.providers, models: self.models, enable_session_telemetry: self.enable_session_telemetry, @@ -2945,6 +3019,7 @@ impl ResumeSessionConfig { agent: None, infinite_sessions: None, provider: None, + capi: None, providers: None, models: None, enable_session_telemetry: None, @@ -3337,6 +3412,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 + } + /// **Experimental.** This method is part of an experimental multi-provider /// BYOK surface and may change or be removed in a future release. /// @@ -4620,8 +4701,8 @@ mod tests { use super::{ AgentMode, Attachment, AttachmentLineRange, AttachmentSelectionPosition, - AttachmentSelectionRange, AzureProviderOptions, ConnectionState, CustomAgentConfig, - DeliveryMode, ExtensionInfo, GitHubReferenceType, InfiniteSessionConfig, + AttachmentSelectionRange, AzureProviderOptions, CapiSessionOptions, ConnectionState, + CustomAgentConfig, DeliveryMode, ExtensionInfo, GitHubReferenceType, InfiniteSessionConfig, LargeToolOutputConfig, MemoryConfiguration, NamedProviderConfig, ProviderConfig, ProviderModelConfig, ReasoningSummary, ResumeSessionConfig, SessionConfig, SessionEvent, SessionId, SystemMessageConfig, Tool, ToolBinaryResult, ToolResult, ToolResultExpanded, @@ -5120,6 +5201,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_enable_web_socket_responses(false)) .with_enable_session_telemetry(false) .with_include_sub_agent_streaming_events(false) .with_extension_info(ExtensionInfo::new("github-app", "counter")); @@ -5156,6 +5238,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_enable_web_socket_responses(false)) + ); assert_eq!(cfg.enable_session_telemetry, Some(false)); assert_eq!(cfg.include_sub_agent_streaming_events, Some(false)); assert_eq!( @@ -5186,6 +5272,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_enable_web_socket_responses(false)) .with_enable_session_telemetry(false) .with_include_sub_agent_streaming_events(true) .with_suppress_resume_event(true) @@ -5222,6 +5309,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_enable_web_socket_responses(false)) + ); 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)); @@ -5360,6 +5451,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) @@ -5371,6 +5463,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!( @@ -5400,6 +5493,61 @@ mod tests { assert!(wire_unset.get("maxOutputTokens").is_none()); } + #[test] + fn capi_session_options_builder_composes_and_serializes() { + let cfg = CapiSessionOptions::new().with_enable_web_socket_responses(false); + + assert_eq!(cfg.enable_web_socket_responses, Some(false)); + + let wire = serde_json::to_value(&cfg).unwrap(); + assert_eq!( + wire, + serde_json::json!({ "enableWebSocketResponses": false }) + ); + + let unset = CapiSessionOptions::new(); + let wire_unset = serde_json::to_value(&unset).unwrap(); + assert!(wire_unset.get("enableWebSocketResponses").is_none()); + } + + #[test] + fn session_config_with_capi_serializes() { + let (wire, _) = SessionConfig::default() + .with_capi(CapiSessionOptions::new().with_enable_web_socket_responses(false)) + .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!({ "enableWebSocketResponses": false }) + ); + + 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_enable_web_socket_responses(false)) + .into_wire() + .expect("no duplicate handlers"); + let json = serde_json::to_value(&wire).unwrap(); + assert_eq!( + json["capi"], + serde_json::json!({ "enableWebSocketResponses": false }) + ); + + 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 cc9968100..e2a87bc4a 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, NamedProviderConfig, ProviderConfig, ProviderModelConfig, 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 providers: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub models: Option>, @@ -243,6 +245,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 providers: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub models: Option>,