Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,32 @@ public async Task Probe_FiltersByCapabilityAndPickerEligibility()
Assert.DoesNotContain("hidden", ids);
}

[Fact]
public void ParseCopilotModelCapabilities_RetainsHttpEndpointsWithoutConfusingWebSocketResponses()
{
var capabilities = GitHubCopilotDescriptor.ParseCopilotModelCapabilities(
"""
{ "data": [
{ "id": "responses-only", "capabilities": { "type": "chat" }, "supported_endpoints": ["/responses", "ws:/responses"] },
{ "id": "chat-only", "capabilities": { "type": "chat" }, "supported_endpoints": ["/chat/completions"] },
{ "id": "dual", "capabilities": { "type": "chat" }, "supported_endpoints": ["/responses", "/chat/completions"] },
{ "id": "not-chat", "capabilities": { "type": "embeddings" }, "supported_endpoints": ["/responses"] },
{ "id": "hidden", "capabilities": { "type": "chat" }, "model_picker_enabled": false, "supported_endpoints": ["/responses"] }
] }
""");

Assert.Equal(3, capabilities.Count);
var responsesOnly = Assert.Single(capabilities, capability => capability.ModelId == "responses-only");
Assert.True(responsesOnly.SupportsResponses);
Assert.False(responsesOnly.SupportsChatCompletions);
Assert.Contains("ws:/responses", responsesOnly.SupportedEndpoints);
Assert.Equal(GitHubCopilotApiKind.Responses, responsesOnly.PreferredApi);
Assert.Equal(GitHubCopilotApiKind.ChatCompletions,
Assert.Single(capabilities, capability => capability.ModelId == "chat-only").PreferredApi);
Assert.Equal(GitHubCopilotApiKind.Responses,
Assert.Single(capabilities, capability => capability.ModelId == "dual").PreferredApi);
}

[Fact]
public async Task Probe_FallsBackToCuratedListWhenModelsEndpointFails()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// -----------------------------------------------------------------------
// <copyright file="GitHubCopilotModelCatalogTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using Microsoft.Extensions.AI;
using Netclaw.Providers.GitHubCopilot;
using Xunit;

namespace Netclaw.Daemon.Tests.Providers.GitHubCopilot;

public sealed class GitHubCopilotModelCatalogTests
{
[Fact]
public void Catalog_UsesEndpointAndCaseInsensitiveModelLookup()
{
var catalog = new GitHubCopilotModelCatalog();
var capability = new GitHubCopilotModelCapability(
"GPT-5.5", true, false, ["/responses"]);

catalog.Store(new Uri("https://api.tenant.ghe.com"), [capability]);

Assert.Same(capability,
catalog.Find(new Uri("https://api.tenant.ghe.com/"), "gpt-5.5"));
Assert.Null(catalog.Find(new Uri("https://api.githubcopilot.com"), "gpt-5.5"));
}

[Fact]
public async Task LazyClient_InitializesExactlyOnceForConcurrentFirstRequests()
{
var initializeCount = 0;
var client = new GitHubCopilotCapabilityResolvingChatClient(() =>
{
Interlocked.Increment(ref initializeCount);
return Task.FromResult<IChatClient>(new TestChatClient());
});

await Task.WhenAll(
client.GetResponseAsync([new ChatMessage(ChatRole.User, "one")],
cancellationToken: TestContext.Current.CancellationToken),
client.GetResponseAsync([new ChatMessage(ChatRole.User, "two")],
cancellationToken: TestContext.Current.CancellationToken));

Assert.Equal(1, initializeCount);
}

private sealed class TestChatClient : IChatClient
{
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages,
ChatOptions? options = null, CancellationToken cancellationToken = default) =>
Task.FromResult(new ChatResponse());

public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
yield break;
}

public object? GetService(Type serviceType, object? serviceKey = null) => null;
public void Dispose() { }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,31 @@ private static CopilotTokenExchanger ExchangerReturning(string copilotToken, str
private static GitHubCopilotProviderPlugin NewPlugin()
{
var exchanger = NewExchanger();
var descriptor = new GitHubCopilotDescriptor(new HttpClient(), exchanger);
var descriptor = DescriptorWithModels(exchanger, "/chat/completions");
return new GitHubCopilotProviderPlugin(descriptor, exchanger);
}

private static GitHubCopilotDescriptor DescriptorWithModels(
CopilotTokenExchanger exchanger, params string[] endpoints)
{
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(new
{
data = new[]
{
new
{
id = "gpt-4o",
capabilities = new { type = "chat" },
supported_endpoints = endpoints,
},
},
}), Encoding.UTF8, "application/json"),
});
return new GitHubCopilotDescriptor(new HttpClient(handler), exchanger);
}

[Fact]
public void CreateChatClient_DefaultEndpoint_ReturnsNonNullClient()
{
Expand Down Expand Up @@ -119,7 +140,7 @@ public async Task CreateChatClient_SendsExchangedTokenNotPlaceholder()
});

var exchanger = ExchangerReturning("copilot-real", apiBase: "https://api.githubcopilot.com");
var descriptor = new GitHubCopilotDescriptor(new HttpClient(), exchanger);
var descriptor = DescriptorWithModels(exchanger, "/chat/completions");
var plugin = new GitHubCopilotProviderPlugin(descriptor, exchanger)
{
TransportOverride = new HttpClientPipelineTransport(new HttpClient(captureHandler)),
Expand Down Expand Up @@ -161,7 +182,7 @@ public async Task CreateChatClient_RoutesChatToTokenApiHost()
});

var exchanger = ExchangerReturning("copilot-ghe", apiBase: "https://api.tenant.ghe.com");
var descriptor = new GitHubCopilotDescriptor(new HttpClient(), exchanger);
var descriptor = DescriptorWithModels(exchanger, "/chat/completions");
var plugin = new GitHubCopilotProviderPlugin(descriptor, exchanger)
{
TransportOverride = new HttpClientPipelineTransport(new HttpClient(captureHandler)),
Expand Down Expand Up @@ -202,7 +223,7 @@ public async Task CreateChatClient_CustomEndpoint_KeepsOperatorHostOverToken()
});

var exchanger = ExchangerReturning("copilot-ghe", apiBase: "https://api.tenant.ghe.com");
var descriptor = new GitHubCopilotDescriptor(new HttpClient(), exchanger);
var descriptor = DescriptorWithModels(exchanger, "/chat/completions");
var plugin = new GitHubCopilotProviderPlugin(descriptor, exchanger)
{
TransportOverride = new HttpClientPipelineTransport(new HttpClient(captureHandler)),
Expand All @@ -226,6 +247,80 @@ [new ChatMessage(ChatRole.User, "hi")],
Assert.Equal("copilot-proxy.example.com", sentUri!.Host);
}

[Fact]
public async Task CreateChatClient_ResponsesOnlyModel_UsesResponsesAtTokenHostWithCopilotHeaders()
{
Uri? sentUri = null;
HttpRequestMessage? sentRequest = null;
var captureHandler = new FakeHttpMessageHandler(req =>
{
sentUri = req.RequestUri;
sentRequest = req;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(MinimalResponseJson, Encoding.UTF8, "application/json"),
};
});

var exchanger = ExchangerReturning("copilot-ghe", apiBase: "https://api.tenant.ghe.com");
var descriptor = DescriptorWithModels(exchanger, "/responses", "ws:/responses");
var plugin = new GitHubCopilotProviderPlugin(descriptor, exchanger)
{
TransportOverride = new HttpClientPipelineTransport(new HttpClient(captureHandler)),
};
var entry = new ProviderEntry
{
Type = "github-copilot", AuthMethod = AuthMethod.OAuthDevice,
OAuthAccessToken = new SensitiveString("oauth-1"),
};

var client = plugin.CreateChatClient(
entry, new ModelReference { Provider = "my-copilot", ModelId = "gpt-4o" });
await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")],
cancellationToken: TestContext.Current.CancellationToken);

Assert.NotNull(sentUri);
Assert.Equal("api.tenant.ghe.com", sentUri!.Host);
Assert.Equal("/responses", sentUri.AbsolutePath);
Assert.Equal("Bearer copilot-ghe", sentRequest!.Headers.Authorization!.ToString());
Assert.Equal("vscode-chat", sentRequest.Headers.GetValues("copilot-integration-id").Single());
Assert.True(sentRequest.Headers.Contains("editor-version"));
Assert.Equal("conversation-agent", sentRequest.Headers.GetValues("openai-intent").Single());
var userAgent = sentRequest.Headers.UserAgent.ToString();
Assert.StartsWith(NetclawUserAgent.Value, userAgent);
Assert.Equal(1, userAgent.Split(NetclawUserAgent.Value, StringSplitOptions.None).Length - 1);
}

[Fact]
public async Task CreateChatClient_ModelWithoutHttpEndpoint_FailsBeforeInference()
{
var inferenceCalls = 0;
var exchanger = ExchangerReturning("copilot-ghe", apiBase: "https://api.tenant.ghe.com");
var descriptor = DescriptorWithModels(exchanger, "ws:/responses");
var plugin = new GitHubCopilotProviderPlugin(descriptor, exchanger)
{
TransportOverride = new HttpClientPipelineTransport(new HttpClient(
new FakeHttpMessageHandler(_ =>
{
Interlocked.Increment(ref inferenceCalls);
return new HttpResponseMessage(HttpStatusCode.OK);
}))),
};
var entry = new ProviderEntry
{
Type = "github-copilot", AuthMethod = AuthMethod.OAuthDevice,
OAuthAccessToken = new SensitiveString("oauth-1"),
};
var client = plugin.CreateChatClient(
entry, new ModelReference { Provider = "my-copilot", ModelId = "gpt-4o" });

var error = await Assert.ThrowsAsync<InvalidOperationException>(() => client.GetResponseAsync(
[new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken));

Assert.Contains("does not advertise a supported HTTP inference endpoint", error.Message);
Assert.Equal(0, inferenceCalls);
}

private const string MinimalChatCompletionJson =
"""
{
Expand All @@ -243,4 +338,24 @@ [new ChatMessage(ChatRole.User, "hi")],
"usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 }
}
""";

private const string MinimalResponseJson =
"""
{
"id": "resp-test",
"object": "response",
"created_at": 0,
"status": "completed",
"model": "gpt-4o",
"output": [
{
"id": "msg-test",
"type": "message",
"status": "completed",
"role": "assistant",
"content": [ { "type": "output_text", "text": "ok", "annotations": [] } ]
}
]
}
""";
}
Original file line number Diff line number Diff line change
Expand Up @@ -128,5 +128,6 @@ private static void ApplyCopilotHeaders(PipelineMessage message)
headers.Set("copilot-integration-id", "vscode-chat");
headers.Set("editor-version", "Netclaw/1.0");
headers.Set("openai-intent", "conversation-agent");
headers.Set("user-agent", NetclawUserAgent.Value);
}
}
Loading
Loading