From 1643d90b39185b67acc66911aed35c4a1d8132ba Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 15 Jun 2026 11:36:59 +0100 Subject: [PATCH] Add cross-language E2E coverage for session.provider.getEndpoint Adds E2E tests in nodejs, .NET, Go, Python, Rust, and Java verifying that session.provider.getEndpoint returns the configured BYOK provider endpoint and the resolved CAPI credentials. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/E2E/ProviderEndpointE2ETests.cs | 119 ++++++++++ go/internal/e2e/provider_endpoint_e2e_test.go | 147 ++++++++++++ .../copilot/ProviderEndpointE2ETest.java | 155 +++++++++++++ nodejs/test/e2e/provider_endpoint.e2e.test.ts | 92 ++++++++ python/e2e/test_provider_endpoint_e2e.py | 117 ++++++++++ rust/tests/e2e.rs | 2 + rust/tests/e2e/provider_endpoint.rs | 215 ++++++++++++++++++ 7 files changed, 847 insertions(+) create mode 100644 dotnet/test/E2E/ProviderEndpointE2ETests.cs create mode 100644 go/internal/e2e/provider_endpoint_e2e_test.go create mode 100644 java/src/test/java/com/github/copilot/ProviderEndpointE2ETest.java create mode 100644 nodejs/test/e2e/provider_endpoint.e2e.test.ts create mode 100644 python/e2e/test_provider_endpoint_e2e.py create mode 100644 rust/tests/e2e/provider_endpoint.rs diff --git a/dotnet/test/E2E/ProviderEndpointE2ETests.cs b/dotnet/test/E2E/ProviderEndpointE2ETests.cs new file mode 100644 index 000000000..f7e9a7885 --- /dev/null +++ b/dotnet/test/E2E/ProviderEndpointE2ETests.cs @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.Rpc; +using GitHub.Copilot.Test.Harness; +using System.Text.RegularExpressions; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.Test.E2E; + +public class ProviderEndpointE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "provider-endpoint", output) +{ + /// + /// Creates a client with the provider-endpoint API opt-in env var + /// (COPILOT_ALLOW_GET_PROVIDER_ENDPOINT) set on the CLI subprocess. + /// + private CopilotClient CreateProviderEndpointClient() + { + var env = new Dictionary(Ctx.GetEnvironment()) + { + ["COPILOT_ALLOW_GET_PROVIDER_ENDPOINT"] = "true", + }; + return Ctx.CreateClient(options: new CopilotClientOptions { Environment = env }); + } + + [Fact] + public async Task ShouldReturnByokProviderEndpointWhenCustomProviderIsConfigured() + { + var client = CreateProviderEndpointClient(); + + var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Provider = new ProviderConfig + { + Type = "openai", + WireApi = "completions", + BaseUrl = "https://api.example.test/v1", + ApiKey = "byok-secret", + Headers = new Dictionary { ["X-Custom-Header"] = "byok-yes" }, + }, + }); + + try + { + var endpoint = await session.Rpc.Provider.GetEndpointAsync(); + + Assert.Equal(ProviderEndpointType.Openai, endpoint.Type); + Assert.Equal(ProviderEndpointWireApi.Completions, endpoint.WireApi); + Assert.Equal("https://api.example.test/v1", endpoint.BaseUrl); + Assert.Equal("byok-secret", endpoint.ApiKey); + Assert.Equal("byok-yes", endpoint.Headers["X-Custom-Header"]); + // BYOK sessions never issue a CAPI session token. + Assert.Null(endpoint.SessionToken); + } + finally + { + try { await session.DisposeAsync(); } + catch { /* disconnect may fail since the BYOK provider URL is fake */ } + } + } + + [Fact] + public async Task ShouldReturnCapiProviderEndpointForOAuthAuthenticatedSession() + { + var client = CreateProviderEndpointClient(); + + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + var endpoint = await session.Rpc.Provider.GetEndpointAsync(); + + Assert.True( + endpoint.Type == ProviderEndpointType.Openai + || endpoint.Type == ProviderEndpointType.Azure + || endpoint.Type == ProviderEndpointType.Anthropic, + $"unexpected endpoint.Type {endpoint.Type}"); + // wireApi is omitted for anthropic; otherwise one of the OpenAI shapes. + if (endpoint.Type != ProviderEndpointType.Anthropic) + { + Assert.True( + endpoint.WireApi == ProviderEndpointWireApi.Completions + || endpoint.WireApi == ProviderEndpointWireApi.Responses, + $"unexpected endpoint.WireApi {endpoint.WireApi}"); + } + + // CAPI baseUrl is the (proxy) Copilot API URL injected by the harness. + Assert.Matches(@"^https?://", endpoint.BaseUrl); + + // For CAPI OAuth sessions the apiKey is the resolved GitHub bearer. + Assert.False(string.IsNullOrEmpty(endpoint.ApiKey)); + + // Standard CAPI headers should be present, and Authorization is + // surfaced as the runtime sends it (`Bearer `). + Assert.False(string.IsNullOrEmpty(endpoint.Headers["Copilot-Integration-Id"])); + Assert.Matches(new Regex("Copilot", RegexOptions.IgnoreCase), endpoint.Headers["User-Agent"]); + Assert.False(string.IsNullOrEmpty(endpoint.Headers["X-GitHub-Api-Version"])); + Assert.Matches(@"[0-9a-f-]{8,}", endpoint.Headers["X-Interaction-Id"]); + Assert.Equal($"Bearer {endpoint.ApiKey}", endpoint.Headers["Authorization"]); + + // When the omit-modelId path returned an auto-mode session token, it + // must use the documented header name. The harness may have a non-auto + // model selected, in which case the field is simply omitted. + if (endpoint.SessionToken != null) + { + Assert.Equal("Copilot-Session-Token", endpoint.SessionToken.Header); + Assert.False(string.IsNullOrEmpty(endpoint.SessionToken.Token)); + if (endpoint.SessionToken.ExpiresAt.HasValue) + { + Assert.True(endpoint.SessionToken.ExpiresAt.Value > DateTimeOffset.MinValue); + } + } + } +} diff --git a/go/internal/e2e/provider_endpoint_e2e_test.go b/go/internal/e2e/provider_endpoint_e2e_test.go new file mode 100644 index 000000000..aad02ca2b --- /dev/null +++ b/go/internal/e2e/provider_endpoint_e2e_test.go @@ -0,0 +1,147 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package e2e + +import ( + "regexp" + "strings" + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +// session.provider.getEndpoint is gated behind COPILOT_ALLOW_GET_PROVIDER_ENDPOINT; +// the harness env passed to the CLI subprocess opts in for this test file. +func TestProviderEndpointE2E(t *testing.T) { + ctx := testharness.NewTestContext(t) + + client := ctx.NewClient(func(opts *copilot.ClientOptions) { + opts.Env = append(opts.Env, "COPILOT_ALLOW_GET_PROVIDER_ENDPOINT=true") + }) + t.Cleanup(func() { client.ForceStop() }) + + t.Run("returns the BYOK provider endpoint when a custom provider is configured", func(t *testing.T) { + ctx.ConfigureForTest(t) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Provider: &copilot.ProviderConfig{ + Type: "openai", + WireAPI: "completions", + BaseURL: "https://api.example.test/v1", + APIKey: "byok-secret", + Headers: map[string]string{"X-Custom-Header": "byok-yes"}, + }, + }) + if err != nil { + t.Fatalf("create session: %v", err) + } + // disconnect may fail since the BYOK provider URL is fake. + defer func() { _ = session.Disconnect() }() + + endpoint, err := session.RPC.Provider.GetEndpoint(t.Context()) + if err != nil { + t.Fatalf("getEndpoint: %v", err) + } + + if endpoint.Type != rpc.ProviderEndpointTypeOpenai { + t.Errorf("Type: want %q, got %q", rpc.ProviderEndpointTypeOpenai, endpoint.Type) + } + if endpoint.WireAPI == nil || *endpoint.WireAPI != rpc.ProviderEndpointWireAPICompletions { + t.Errorf("WireAPI: want %q, got %v", rpc.ProviderEndpointWireAPICompletions, endpoint.WireAPI) + } + if endpoint.BaseURL != "https://api.example.test/v1" { + t.Errorf("BaseURL: got %q", endpoint.BaseURL) + } + if endpoint.APIKey == nil || *endpoint.APIKey != "byok-secret" { + t.Errorf("APIKey: got %v", endpoint.APIKey) + } + if got := endpoint.Headers["X-Custom-Header"]; got != "byok-yes" { + t.Errorf("X-Custom-Header: got %q", got) + } + // BYOK sessions never issue a CAPI session token. + if endpoint.SessionToken != nil { + t.Errorf("SessionToken: expected nil, got %+v", endpoint.SessionToken) + } + }) + + t.Run("returns the CAPI provider endpoint for an OAuth-authenticated session", func(t *testing.T) { + ctx.ConfigureForTest(t) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("create session: %v", err) + } + defer func() { + if err := session.Disconnect(); err != nil { + t.Errorf("disconnect: %v", err) + } + }() + + endpoint, err := session.RPC.Provider.GetEndpoint(t.Context()) + if err != nil { + t.Fatalf("getEndpoint: %v", err) + } + + switch endpoint.Type { + case rpc.ProviderEndpointTypeOpenai, rpc.ProviderEndpointTypeAzure, rpc.ProviderEndpointTypeAnthropic: + default: + t.Errorf("unexpected Type %q", endpoint.Type) + } + // wireApi is omitted for anthropic; otherwise one of the OpenAI shapes. + if endpoint.Type != rpc.ProviderEndpointTypeAnthropic { + if endpoint.WireAPI == nil || + (*endpoint.WireAPI != rpc.ProviderEndpointWireAPICompletions && + *endpoint.WireAPI != rpc.ProviderEndpointWireAPIResponses) { + t.Errorf("unexpected WireAPI %v for type %q", endpoint.WireAPI, endpoint.Type) + } + } + + // CAPI baseUrl is the (proxy) Copilot API URL injected by the harness. + if !strings.HasPrefix(endpoint.BaseURL, "http://") && !strings.HasPrefix(endpoint.BaseURL, "https://") { + t.Errorf("BaseURL not an http(s) URL: %q", endpoint.BaseURL) + } + + // For CAPI OAuth sessions the apiKey is the resolved GitHub bearer. + if endpoint.APIKey == nil || len(*endpoint.APIKey) == 0 { + t.Fatalf("APIKey should be a non-empty string, got %v", endpoint.APIKey) + } + + // Standard CAPI headers must be present, and Authorization is surfaced + // as the runtime sends it (`Bearer `). + if endpoint.Headers["Copilot-Integration-Id"] == "" { + t.Errorf("Copilot-Integration-Id header missing") + } + if ua := endpoint.Headers["User-Agent"]; !regexp.MustCompile(`(?i)Copilot`).MatchString(ua) { + t.Errorf("User-Agent should mention Copilot, got %q", ua) + } + if endpoint.Headers["X-GitHub-Api-Version"] == "" { + t.Errorf("X-GitHub-Api-Version header missing") + } + if !regexp.MustCompile(`[0-9a-f-]{8,}`).MatchString(endpoint.Headers["X-Interaction-Id"]) { + t.Errorf("X-Interaction-Id should match interaction-id format, got %q", endpoint.Headers["X-Interaction-Id"]) + } + if want, got := "Bearer "+*endpoint.APIKey, endpoint.Headers["Authorization"]; want != got { + t.Errorf("Authorization: want %q, got %q", want, got) + } + + // When the omit-modelId path returned an auto-mode session token, it + // must use the documented header name. The harness may have a non-auto + // model selected, in which case the field is simply omitted. + if endpoint.SessionToken != nil { + if endpoint.SessionToken.Header != "Copilot-Session-Token" { + t.Errorf("SessionToken.Header: got %q", endpoint.SessionToken.Header) + } + if endpoint.SessionToken.Token == "" { + t.Errorf("SessionToken.Token should be non-empty") + } + if endpoint.SessionToken.ExpiresAt != nil && endpoint.SessionToken.ExpiresAt.IsZero() { + t.Errorf("SessionToken.ExpiresAt should be a valid time when present") + } + } + }) +} diff --git a/java/src/test/java/com/github/copilot/ProviderEndpointE2ETest.java b/java/src/test/java/com/github/copilot/ProviderEndpointE2ETest.java new file mode 100644 index 000000000..1e302982e --- /dev/null +++ b/java/src/test/java/com/github/copilot/ProviderEndpointE2ETest.java @@ -0,0 +1,155 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.github.copilot.generated.rpc.ProviderEndpointType; +import com.github.copilot.generated.rpc.ProviderEndpointWireApi; +import com.github.copilot.generated.rpc.ProviderSessionToken; +import com.github.copilot.generated.rpc.SessionProviderGetEndpointResult; +import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.ProviderConfig; +import com.github.copilot.rpc.SessionConfig; + +/** + * Tests for the {@code session.provider.getEndpoint} RPC, which surfaces the + * resolved provider endpoint and credentials for either a BYOK or CAPI session. + */ +public class ProviderEndpointE2ETest { + + private static E2ETestContext ctx; + + @BeforeAll + static void setup() throws Exception { + ctx = E2ETestContext.create(); + } + + @AfterAll + static void teardown() throws Exception { + if (ctx != null) { + ctx.close(); + } + } + + // session.provider.getEndpoint is gated behind + // COPILOT_ALLOW_GET_PROVIDER_ENDPOINT; + // the harness env passed to the CLI subprocess opts in for these tests. + private CopilotClient createProviderEndpointClient() { + Map env = new HashMap<>(ctx.getEnvironment()); + env.put("COPILOT_ALLOW_GET_PROVIDER_ENDPOINT", "true"); + return ctx.createClient(new CopilotClientOptions().setEnvironment(env)); + } + + @Test + void shouldReturnByokProviderEndpointWhenCustomProviderConfigured() throws Exception { + try (CopilotClient client = createProviderEndpointClient()) { + Map customHeaders = new HashMap<>(); + customHeaders.put("X-Custom-Header", "byok-yes"); + + ProviderConfig provider = new ProviderConfig().setType("openai").setWireApi("completions") + .setBaseUrl("https://api.example.test/v1").setApiKey("byok-secret").setHeaders(customHeaders); + + CopilotSession session = client.createSession( + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setProvider(provider)) + .get(); + + try { + SessionProviderGetEndpointResult endpoint = session.getRpc().provider.getEndpoint().get(); + + assertEquals(ProviderEndpointType.OPENAI, endpoint.type()); + assertEquals(ProviderEndpointWireApi.COMPLETIONS, endpoint.wireApi()); + assertEquals("https://api.example.test/v1", endpoint.baseUrl()); + assertEquals("byok-secret", endpoint.apiKey()); + assertEquals("byok-yes", endpoint.headers().get("X-Custom-Header")); + // BYOK sessions never issue a CAPI session token. + assertNull(endpoint.sessionToken(), "BYOK session should not have a session token"); + } finally { + try { + session.close(); + } catch (Exception ignored) { + // disconnect may fail since the BYOK provider URL is fake + } + } + } + } + + @Test + void shouldReturnCapiProviderEndpointForOAuthAuthenticatedSession() throws Exception { + ctx.initializeProxy(); + ctx.setCopilotUserByToken("fake-token-for-e2e-tests", "e2e-user", "individual_pro", ctx.getProxyUrl(), + "https://localhost:1/telemetry", "e2e-tracking-id"); + + try (CopilotClient client = createProviderEndpointClient()) { + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + try { + SessionProviderGetEndpointResult endpoint = session.getRpc().provider.getEndpoint().get(); + + assertNotNull(endpoint.type(), "CAPI endpoint should have a provider type"); + assertTrue( + endpoint.type() == ProviderEndpointType.OPENAI || endpoint.type() == ProviderEndpointType.AZURE + || endpoint.type() == ProviderEndpointType.ANTHROPIC, + "expected type in {openai, azure, anthropic}, got " + endpoint.type()); + // wireApi is omitted for anthropic; otherwise one of the OpenAI shapes. + if (endpoint.type() != ProviderEndpointType.ANTHROPIC) { + assertTrue( + endpoint.wireApi() == ProviderEndpointWireApi.COMPLETIONS + || endpoint.wireApi() == ProviderEndpointWireApi.RESPONSES, + "expected wireApi in {completions, responses}, got " + endpoint.wireApi()); + } + + // CAPI baseUrl is the (proxy) Copilot API URL injected by the harness. + assertTrue(endpoint.baseUrl().startsWith("http://") || endpoint.baseUrl().startsWith("https://"), + "expected http(s) baseUrl, got " + endpoint.baseUrl()); + + // For CAPI OAuth sessions the apiKey is the resolved GitHub bearer. + assertNotNull(endpoint.apiKey(), "CAPI OAuth session must surface apiKey"); + assertFalse(endpoint.apiKey().isEmpty(), "apiKey must be non-empty"); + + Map headers = endpoint.headers(); + String integrationId = headers.get("Copilot-Integration-Id"); + assertNotNull(integrationId, "Copilot-Integration-Id header must be present"); + assertFalse(integrationId.isEmpty(), "Copilot-Integration-Id must be non-empty"); + + String userAgent = headers.get("User-Agent"); + assertNotNull(userAgent, "User-Agent header must be present"); + assertTrue(userAgent.toLowerCase().contains("copilot"), + "expected User-Agent to mention Copilot, got " + userAgent); + + String apiVersion = headers.get("X-GitHub-Api-Version"); + assertNotNull(apiVersion, "X-GitHub-Api-Version header must be present"); + assertFalse(apiVersion.isEmpty(), "X-GitHub-Api-Version must be non-empty"); + + String interactionId = headers.get("X-Interaction-Id"); + assertNotNull(interactionId, "X-Interaction-Id header must be present"); + assertTrue(interactionId.matches(".*[0-9a-f-]{8,}.*"), + "expected X-Interaction-Id to look like a hex/uuid value, got " + interactionId); + + String authorization = headers.get("Authorization"); + assertEquals("Bearer " + endpoint.apiKey(), authorization); + + ProviderSessionToken sessionToken = endpoint.sessionToken(); + if (sessionToken != null) { + assertEquals("Copilot-Session-Token", sessionToken.header()); + assertFalse(sessionToken.token().isEmpty(), "session token must be non-empty"); + // expiresAt is optional; when present it parses as OffsetDateTime so no + // additional validation is needed. + } + } finally { + session.close(); + } + } + } +} diff --git a/nodejs/test/e2e/provider_endpoint.e2e.test.ts b/nodejs/test/e2e/provider_endpoint.e2e.test.ts new file mode 100644 index 000000000..1bac76253 --- /dev/null +++ b/nodejs/test/e2e/provider_endpoint.e2e.test.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe("session.provider.getEndpoint RPC", async () => { + const { copilotClient: client, env } = await createSdkTestContext(); + + // The provider endpoint API is gated behind an opt-in env var; the harness + // env object is the same one passed to the CLI subprocess, so mutating it + // here enables the API for this test file's client. + env.COPILOT_ALLOW_GET_PROVIDER_ENDPOINT = "true"; + + it("returns the BYOK provider endpoint when a custom provider is configured", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + provider: { + type: "openai", + wireApi: "completions", + baseUrl: "https://api.example.test/v1", + apiKey: "byok-secret", + headers: { "X-Custom-Header": "byok-yes" }, + }, + }); + + try { + const endpoint = await session.rpc.provider.getEndpoint({}); + + expect(endpoint.type).toBe("openai"); + expect(endpoint.wireApi).toBe("completions"); + expect(endpoint.baseUrl).toBe("https://api.example.test/v1"); + expect(endpoint.apiKey).toBe("byok-secret"); + expect(endpoint.headers).toMatchObject({ "X-Custom-Header": "byok-yes" }); + // BYOK sessions never issue a CAPI session token. + expect(endpoint.sessionToken).toBeUndefined(); + } finally { + try { + await session.disconnect(); + } catch { + // disconnect may fail since the BYOK provider URL is fake + } + } + }); + + it("returns the CAPI provider endpoint for an OAuth-authenticated session", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + }); + + try { + const endpoint = await session.rpc.provider.getEndpoint({}); + + expect(["openai", "azure", "anthropic"]).toContain(endpoint.type); + // wireApi is omitted for anthropic; otherwise one of the OpenAI shapes. + if (endpoint.type !== "anthropic") { + expect(["completions", "responses"]).toContain(endpoint.wireApi); + } + + // CAPI baseUrl is the (proxy) Copilot API URL injected by the harness. + expect(endpoint.baseUrl).toMatch(/^https?:\/\//); + + // For CAPI OAuth sessions the apiKey is the resolved GitHub bearer. + expect(endpoint.apiKey).toBeTypeOf("string"); + expect(endpoint.apiKey!.length).toBeGreaterThan(0); + + // Standard CAPI headers should be present, and Authorization is + // surfaced as the runtime sends it (`Bearer `). + expect(endpoint.headers["Copilot-Integration-Id"]).toBeTypeOf("string"); + expect(endpoint.headers["User-Agent"]).toMatch(/Copilot/i); + expect(endpoint.headers["X-GitHub-Api-Version"]).toBeTypeOf("string"); + expect(endpoint.headers["X-Interaction-Id"]).toMatch(/[0-9a-f-]{8,}/); + expect(endpoint.headers.Authorization).toBe(`Bearer ${endpoint.apiKey}`); + + // When the omit-modelId path returned an auto-mode session token, it + // must use the documented header name and an ISO 8601 expiry. The + // harness may have a non-auto model selected, in which case the + // field is simply omitted. + if (endpoint.sessionToken) { + expect(endpoint.sessionToken.header).toBe("Copilot-Session-Token"); + expect(endpoint.sessionToken.token.length).toBeGreaterThan(0); + if (endpoint.sessionToken.expiresAt !== undefined) { + expect(Date.parse(endpoint.sessionToken.expiresAt)).not.toBeNaN(); + } + } + } finally { + await session.disconnect(); + } + }); +}); diff --git a/python/e2e/test_provider_endpoint_e2e.py b/python/e2e/test_provider_endpoint_e2e.py new file mode 100644 index 000000000..875a95b91 --- /dev/null +++ b/python/e2e/test_provider_endpoint_e2e.py @@ -0,0 +1,117 @@ +"""E2E tests for session.provider.getEndpoint.""" + +# session.provider.getEndpoint is gated behind COPILOT_ALLOW_GET_PROVIDER_ENDPOINT; +# the harness env passed to the CLI subprocess opts in for this test file. + +import re + +import pytest + +from copilot.client import CopilotClient, RuntimeConnection +from copilot.generated.rpc import ProviderEndpointType, ProviderEndpointWireApi +from copilot.session import PermissionHandler + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +@pytest.fixture(scope="module") +async def provider_ctx(ctx: E2ETestContext): + env = {**ctx.get_env(), "COPILOT_ALLOW_GET_PROVIDER_ENDPOINT": "true"} + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=ctx.cli_path), + working_directory=ctx.work_dir, + env=env, + github_token=env["GITHUB_TOKEN"], + ) + try: + yield ctx, client + finally: + await client.stop() + + +class TestProviderEndpoint: + async def test_returns_byok_provider_endpoint_when_custom_provider_is_configured( + self, provider_ctx: tuple[E2ETestContext, CopilotClient] + ): + _, client = provider_ctx + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + provider={ + "type": "openai", + "wire_api": "completions", + "base_url": "https://api.example.test/v1", + "api_key": "byok-secret", + "headers": {"X-Custom-Header": "byok-yes"}, + }, + ) + + try: + endpoint = await session.rpc.provider.get_endpoint() + + assert endpoint.type == ProviderEndpointType.OPENAI + assert endpoint.wire_api == ProviderEndpointWireApi.COMPLETIONS + assert endpoint.base_url == "https://api.example.test/v1" + assert endpoint.api_key == "byok-secret" + assert endpoint.headers["X-Custom-Header"] == "byok-yes" + # BYOK sessions never issue a CAPI session token. + assert endpoint.session_token is None + finally: + try: + await session.disconnect() + except Exception: + pass # disconnect may fail since the BYOK provider URL is fake + + async def test_returns_capi_provider_endpoint_for_oauth_authenticated_session( + self, provider_ctx: tuple[E2ETestContext, CopilotClient] + ): + _, client = provider_ctx + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + + try: + endpoint = await session.rpc.provider.get_endpoint() + + assert endpoint.type in ( + ProviderEndpointType.OPENAI, + ProviderEndpointType.AZURE, + ProviderEndpointType.ANTHROPIC, + ) + # wire_api is omitted for anthropic; otherwise one of the OpenAI shapes. + if endpoint.type != ProviderEndpointType.ANTHROPIC: + assert endpoint.wire_api in ( + ProviderEndpointWireApi.COMPLETIONS, + ProviderEndpointWireApi.RESPONSES, + ) + + # CAPI baseUrl is the (proxy) Copilot API URL injected by the harness. + assert re.match(r"^https?://", endpoint.base_url) + + # For CAPI OAuth sessions the api_key is the resolved GitHub bearer. + assert isinstance(endpoint.api_key, str) + assert len(endpoint.api_key) > 0 + + # Standard CAPI headers must be present, and Authorization is + # surfaced as the runtime sends it (`Bearer `). + assert isinstance(endpoint.headers["Copilot-Integration-Id"], str) + assert re.search(r"Copilot", endpoint.headers["User-Agent"], re.IGNORECASE) + assert isinstance(endpoint.headers["X-GitHub-Api-Version"], str) + assert re.search(r"[0-9a-f-]{8,}", endpoint.headers["X-Interaction-Id"]) + assert endpoint.headers["Authorization"] == f"Bearer {endpoint.api_key}" + + # When the omit-model_id path returned an auto-mode session token, + # it must use the documented header name. The harness may have a + # non-auto model selected, in which case the field is simply + # omitted. + if endpoint.session_token is not None: + assert endpoint.session_token.header == "Copilot-Session-Token" + assert len(endpoint.session_token.token) > 0 + # When provided, expires_at should be a parseable ISO timestamp. + if endpoint.session_token.expires_at is not None: + from datetime import datetime + + datetime.fromisoformat(endpoint.session_token.expires_at.replace("Z", "+00:00")) + finally: + await session.disconnect() diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index 40bb5adb7..29137d13d 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -51,6 +51,8 @@ mod per_session_auth; mod permissions; #[path = "e2e/pre_mcp_tool_call_hook.rs"] mod pre_mcp_tool_call_hook; +#[path = "e2e/provider_endpoint.rs"] +mod provider_endpoint; #[path = "e2e/rpc_additional_edge_cases.rs"] mod rpc_additional_edge_cases; #[path = "e2e/rpc_agent.rs"] diff --git a/rust/tests/e2e/provider_endpoint.rs b/rust/tests/e2e/provider_endpoint.rs new file mode 100644 index 000000000..df6d5941d --- /dev/null +++ b/rust/tests/e2e/provider_endpoint.rs @@ -0,0 +1,215 @@ +use std::collections::HashMap; +use std::ffi::OsString; +use std::sync::Arc; + +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::rpc::{ProviderEndpointType, ProviderEndpointWireApi}; +use github_copilot_sdk::{ProviderConfig, SessionConfig}; + +use super::support::{DEFAULT_TEST_TOKEN, with_e2e_context}; + +// session.provider.getEndpoint is gated behind COPILOT_ALLOW_GET_PROVIDER_ENDPOINT; +// the harness env passed to the CLI subprocess opts in for these tests. +fn opt_in_env() -> (OsString, OsString) { + ("COPILOT_ALLOW_GET_PROVIDER_ENDPOINT".into(), "true".into()) +} + +#[tokio::test] +async fn byok_provider_endpoint_returns_configured_endpoint() { + with_e2e_context( + "provider-endpoint", + "byok_provider_endpoint_returns_configured_endpoint", + |ctx| { + Box::pin(async move { + let mut options = ctx.client_options(); + options.env.push(opt_in_env()); + let client = github_copilot_sdk::Client::start(options) + .await + .expect("start client"); + + let mut headers = HashMap::new(); + headers.insert("X-Custom-Header".to_string(), "byok-yes".to_string()); + + let session = client + .create_session( + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_provider( + ProviderConfig::new("https://api.example.test/v1") + .with_provider_type("openai") + .with_wire_api("completions") + .with_api_key("byok-secret") + .with_headers(headers), + ), + ) + .await + .expect("create session"); + + let endpoint = session + .rpc() + .provider() + .get_endpoint() + .await + .expect("get_endpoint"); + + assert!( + matches!(endpoint.r#type, ProviderEndpointType::Openai), + "expected type=openai, got {:?}", + endpoint.r#type, + ); + assert!( + matches!( + endpoint.wire_api, + Some(ProviderEndpointWireApi::Completions) + ), + "expected wireApi=completions, got {:?}", + endpoint.wire_api, + ); + assert_eq!(endpoint.base_url, "https://api.example.test/v1"); + assert_eq!(endpoint.api_key.as_deref(), Some("byok-secret")); + assert_eq!( + endpoint.headers.get("X-Custom-Header").map(String::as_str), + Some("byok-yes"), + ); + assert!( + endpoint.session_token.is_none(), + "BYOK sessions never issue a CAPI session token", + ); + + // disconnect may fail since the BYOK provider URL is fake + let _ = session.disconnect().await; + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn capi_provider_endpoint_returns_resolved_credentials() { + with_e2e_context( + "provider-endpoint", + "capi_provider_endpoint_returns_resolved_credentials", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let mut options = ctx.client_options().with_github_token(DEFAULT_TEST_TOKEN); + options.env.push(opt_in_env()); + let client = github_copilot_sdk::Client::start(options) + .await + .expect("start client"); + + let session = client + .create_session( + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)), + ) + .await + .expect("create session"); + + let endpoint = session + .rpc() + .provider() + .get_endpoint() + .await + .expect("get_endpoint"); + + assert!( + matches!( + endpoint.r#type, + ProviderEndpointType::Openai + | ProviderEndpointType::Azure + | ProviderEndpointType::Anthropic + ), + "expected type in {{openai, azure, anthropic}}, got {:?}", + endpoint.r#type, + ); + if !matches!(endpoint.r#type, ProviderEndpointType::Anthropic) { + assert!( + matches!( + endpoint.wire_api, + Some(ProviderEndpointWireApi::Completions) + | Some(ProviderEndpointWireApi::Responses) + ), + "expected wireApi in {{completions, responses}}, got {:?}", + endpoint.wire_api, + ); + } + + assert!( + endpoint.base_url.starts_with("http://") + || endpoint.base_url.starts_with("https://"), + "expected http(s) baseUrl, got {}", + endpoint.base_url, + ); + + let api_key = endpoint + .api_key + .as_deref() + .expect("CAPI OAuth session must surface apiKey"); + assert!(!api_key.is_empty(), "apiKey must be non-empty"); + + let integration_id = endpoint + .headers + .get("Copilot-Integration-Id") + .expect("Copilot-Integration-Id header"); + assert!( + !integration_id.is_empty(), + "Copilot-Integration-Id must be non-empty", + ); + + let user_agent = endpoint + .headers + .get("User-Agent") + .expect("User-Agent header"); + assert!( + user_agent.to_ascii_lowercase().contains("copilot"), + "expected User-Agent to mention Copilot, got {user_agent}", + ); + + let api_version = endpoint + .headers + .get("X-GitHub-Api-Version") + .expect("X-GitHub-Api-Version header"); + assert!( + !api_version.is_empty(), + "X-GitHub-Api-Version must be non-empty", + ); + + let interaction_id = endpoint + .headers + .get("X-Interaction-Id") + .expect("X-Interaction-Id header"); + let hex_count = interaction_id + .chars() + .filter(|c| c.is_ascii_hexdigit() || *c == '-') + .count(); + assert!( + hex_count >= 8, + "expected X-Interaction-Id to look like a hex/uuid value, got {interaction_id}", + ); + + let authorization = endpoint + .headers + .get("Authorization") + .expect("Authorization header"); + assert_eq!(authorization, &format!("Bearer {api_key}")); + + if let Some(session_token) = endpoint.session_token.as_ref() { + assert_eq!(session_token.header, "Copilot-Session-Token"); + assert!( + !session_token.token.is_empty(), + "session token must be non-empty", + ); + if let Some(expires_at) = session_token.expires_at.as_deref() { + assert!(!expires_at.is_empty(), "expected non-empty expiresAt",); + } + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +}