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; +}