diff --git a/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/GitHubCopilotDescriptorTests.cs b/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/GitHubCopilotDescriptorTests.cs
index e16ae98d4..51180a3ea 100644
--- a/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/GitHubCopilotDescriptorTests.cs
+++ b/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/GitHubCopilotDescriptorTests.cs
@@ -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()
{
diff --git a/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/GitHubCopilotModelCatalogTests.cs b/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/GitHubCopilotModelCatalogTests.cs
new file mode 100644
index 000000000..005d762ee
--- /dev/null
+++ b/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/GitHubCopilotModelCatalogTests.cs
@@ -0,0 +1,64 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+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(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 GetResponseAsync(IEnumerable messages,
+ ChatOptions? options = null, CancellationToken cancellationToken = default) =>
+ Task.FromResult(new ChatResponse());
+
+ public async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable 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() { }
+ }
+}
diff --git a/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/GitHubCopilotProviderPluginTests.cs b/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/GitHubCopilotProviderPluginTests.cs
index ea5414654..5199cf168 100644
--- a/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/GitHubCopilotProviderPluginTests.cs
+++ b/src/Netclaw.Daemon.Tests/Providers/GitHubCopilot/GitHubCopilotProviderPluginTests.cs
@@ -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()
{
@@ -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)),
@@ -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)),
@@ -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)),
@@ -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(() => 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 =
"""
{
@@ -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": [] } ]
+ }
+ ]
+ }
+ """;
}
diff --git a/src/Netclaw.Providers/GitHubCopilot/CopilotRequestPolicy.cs b/src/Netclaw.Providers/GitHubCopilot/CopilotRequestPolicy.cs
index 82b88fb3a..deb8550c5 100644
--- a/src/Netclaw.Providers/GitHubCopilot/CopilotRequestPolicy.cs
+++ b/src/Netclaw.Providers/GitHubCopilot/CopilotRequestPolicy.cs
@@ -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);
}
}
diff --git a/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotDescriptor.cs b/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotDescriptor.cs
index 6da68d400..4236250f8 100644
--- a/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotDescriptor.cs
+++ b/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotDescriptor.cs
@@ -18,6 +18,7 @@ namespace Netclaw.Providers.GitHubCopilot;
public sealed class GitHubCopilotDescriptor(
HttpClient httpClient, CopilotTokenExchanger tokenExchanger) : IProviderDescriptor
{
+ private readonly GitHubCopilotModelCatalog _modelCatalog = new();
// The public Copilot API host. Standard/individual tokens are valid here;
// org and GHE-data-residency tokens are valid only at the host reported in
@@ -93,83 +94,104 @@ public async Task ProbeAsync(
[]);
}
+ var discovery = await DiscoverModelCatalogAsync(entry, ct).ConfigureAwait(false);
+ var modelsResult = discovery.Result;
+
+ if (!modelsResult.Success)
+ {
+ // A transient /models hiccup (timeout, 5xx, rate limit) shouldn't
+ // leave the model picker empty, so fall back to the curated list. But
+ // auth/client errors (revoked token, wrong tenant, bad request) are
+ // real misconfigurations: surface them here so the operator fixes
+ // setup instead of hitting the failure on the first chat (issue #1550).
+ return modelsResult.Transient
+ ? new ProviderProbeResult(true,
+ $"GitHub Copilot /models unreachable ({modelsResult.ErrorMessage}); "
+ + "using curated fallback list.",
+ CuratedModels)
+ : modelsResult;
+ }
+
+ return modelsResult;
+ }
+
+ internal async Task ResolveModelCapabilityAsync(
+ ProviderEntry entry, string modelId)
+ {
+ var discovery = await DiscoverModelCatalogAsync(entry, CancellationToken.None).ConfigureAwait(false);
+ if (!discovery.Result.Success || discovery.ApiEndpoint is null)
+ {
+ throw new InvalidOperationException(
+ $"GitHub Copilot model capability discovery failed: {discovery.Result.ErrorMessage}");
+ }
+
+ var capability = _modelCatalog.Find(discovery.ApiEndpoint, modelId);
+ if (capability is not null)
+ return capability;
+
+ var availableModels = discovery.Capabilities
+ .Select(capability => capability.ModelId)
+ .Order(StringComparer.OrdinalIgnoreCase)
+ .Select(id => $"- {id}");
+ throw new InvalidOperationException(
+ $"GitHub Copilot model '{modelId}' is not available to the authenticated account.\n\n"
+ + "Available models:\n" + string.Join('\n', availableModels));
+ }
+
+ private async Task DiscoverModelCatalogAsync(
+ ProviderEntry entry, CancellationToken ct)
+ {
CopilotToken copilot;
try
{
- copilot = await tokenExchanger.GetTokenAsync(entry, ct);
+ copilot = await tokenExchanger.GetTokenAsync(entry, ct).ConfigureAwait(false);
}
catch (CopilotAuthExpiredException)
{
- return new ProviderProbeResult(false,
+ return ModelCatalogDiscovery.Fail(
"GitHub Copilot authorization expired. Re-authenticate by running "
+ "'netclaw provider remove ' then "
- + "'netclaw provider add github-copilot --auth oauth-device'.",
- []);
+ + "'netclaw provider add github-copilot --auth oauth-device'.");
}
catch (HttpRequestException ex)
{
- return new ProviderProbeResult(false,
- $"GitHub Copilot token exchange failed: {ex.Message}",
- []);
+ return ModelCatalogDiscovery.Fail($"GitHub Copilot token exchange failed: {ex.Message}");
}
catch (InvalidOperationException ex)
{
- return new ProviderProbeResult(false,
- $"GitHub Copilot token exchange failed: {ex.Message}",
- []);
+ return ModelCatalogDiscovery.Fail($"GitHub Copilot token exchange failed: {ex.Message}");
}
- // Probe /models at the host the token is valid at (endpoints.api). For
- // GHE data residency this is the tenant host, not api.githubcopilot.com;
- // probing the wrong host is what let a broken GHE provider report healthy
- // at setup (issue #1550). A deliberate custom endpoint override wins. If
- // we are meant to follow the token's host but the exchange reported none,
- // surface that — never silently probe a guessed default host.
- string probeEndpoint;
- if (HasCustomEndpointOverride(entry.Endpoint))
+ Uri? apiEndpoint = HasCustomEndpointOverride(entry.Endpoint)
+ ? new Uri(entry.Endpoint!)
+ : copilot.ApiBase;
+ if (apiEndpoint is null)
{
- probeEndpoint = entry.Endpoint!;
- }
- else if (copilot.ApiBase is { } apiBase)
- {
- probeEndpoint = apiBase.ToString();
- }
- else
- {
- return new ProviderProbeResult(false,
+ return ModelCatalogDiscovery.Fail(
"GitHub Copilot token exchange did not return an API host "
+ "(endpoints.api); cannot determine where to reach the Copilot API. "
- + "Re-authenticate the provider, or set an explicit endpoint to "
- + "override the host.",
- []);
+ + "Re-authenticate the provider, or set an explicit endpoint to override the host.");
}
- var modelsResult = await ProbeHelpers.ExecuteProbeAsync(
+ IReadOnlyList? capabilities = null;
+ var result = await ProbeHelpers.ExecuteProbeAsync(
httpClient,
"GitHub Copilot",
DefaultEndpoint,
ModelListingPath,
- probeEndpoint,
+ apiEndpoint.ToString(),
ApplyCopilotRequestHeaders(copilot.Token.Value),
- ParseCopilotModels,
- ct);
+ json =>
+ {
+ capabilities = ParseCopilotModelCapabilities(json);
+ return ProjectProbeResult(capabilities);
+ },
+ ct).ConfigureAwait(false);
- if (!modelsResult.Success)
- {
- // A transient /models hiccup (timeout, 5xx, rate limit) shouldn't
- // leave the model picker empty, so fall back to the curated list. But
- // auth/client errors (revoked token, wrong tenant, bad request) are
- // real misconfigurations: surface them here so the operator fixes
- // setup instead of hitting the failure on the first chat (issue #1550).
- return modelsResult.Transient
- ? new ProviderProbeResult(true,
- $"GitHub Copilot /models unreachable ({modelsResult.ErrorMessage}); "
- + "using curated fallback list.",
- CuratedModels)
- : modelsResult;
- }
+ if (result.Success && capabilities is not null)
+ _modelCatalog.Store(apiEndpoint, capabilities);
- return modelsResult;
+ return new ModelCatalogDiscovery(result, apiEndpoint, capabilities ?? []);
}
private static Action ApplyCopilotRequestHeaders(string copilotToken) =>
@@ -181,12 +203,17 @@ private static Action ApplyCopilotRequestHeaders(string copi
request.Headers.TryAddWithoutValidation("openai-intent", "conversation-agent");
};
- // Filters the Copilot /models payload to chat-capable entries the
- // server marks as picker-eligible. Falls back to the curated list when
- // every entry is filtered out so callers never see a zero-model success.
+ // One parser feeds both the picker projection and the provider-local runtime
+ // catalog. capabilities.type filters non-chat models but is not treated as
+ // proof that /chat/completions is supported.
internal static ProviderProbeResult ParseCopilotModels(string json)
{
- var models = new List();
+ return ProjectProbeResult(ParseCopilotModelCapabilities(json));
+ }
+
+ internal static IReadOnlyList ParseCopilotModelCapabilities(string json)
+ {
+ var models = new List();
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("data", out var data)
@@ -213,15 +240,45 @@ internal static ProviderProbeResult ParseCopilotModels(string json)
continue;
}
- models.Add(new DiscoveredModel { ModelId = new(id) });
+ var endpoints = entry.TryGetProperty("supported_endpoints", out var endpointsProp)
+ && endpointsProp.ValueKind == JsonValueKind.Array
+ ? endpointsProp.EnumerateArray()
+ .Where(endpoint => endpoint.ValueKind == JsonValueKind.String)
+ .Select(endpoint => endpoint.GetString())
+ .Where(endpoint => !string.IsNullOrWhiteSpace(endpoint))
+ .Select(endpoint => endpoint!)
+ .ToArray()
+ : [];
+
+ models.Add(new GitHubCopilotModelCapability(
+ id,
+ endpoints.Any(endpoint => string.Equals(endpoint, "/responses", StringComparison.OrdinalIgnoreCase)),
+ endpoints.Any(endpoint => string.Equals(endpoint, "/chat/completions", StringComparison.OrdinalIgnoreCase)),
+ endpoints));
}
}
+ return models;
+ }
+
+ private static ProviderProbeResult ProjectProbeResult(
+ IReadOnlyList models)
+ {
return models.Count == 0
? new ProviderProbeResult(true,
"GitHub Copilot /models returned no chat-capable entries; "
+ "using curated fallback.",
CuratedModels)
- : new ProviderProbeResult(true, null, models);
+ : new ProviderProbeResult(true, null,
+ models.Select(model => new DiscoveredModel { ModelId = new(model.ModelId) }).ToArray());
+ }
+
+ private sealed record ModelCatalogDiscovery(
+ ProviderProbeResult Result,
+ Uri? ApiEndpoint,
+ IReadOnlyList Capabilities)
+ {
+ public static ModelCatalogDiscovery Fail(string error) =>
+ new(new ProviderProbeResult(false, error, []), null, []);
}
}
diff --git a/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotModelCatalog.cs b/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotModelCatalog.cs
new file mode 100644
index 000000000..309ec84ea
--- /dev/null
+++ b/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotModelCatalog.cs
@@ -0,0 +1,108 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+using System.Collections.Concurrent;
+using System.Runtime.CompilerServices;
+using Microsoft.Extensions.AI;
+
+namespace Netclaw.Providers.GitHubCopilot;
+
+internal enum GitHubCopilotApiKind
+{
+ Responses,
+ ChatCompletions,
+}
+
+/// Provider-local endpoint capability advertised by GitHub Copilot /models.
+internal sealed record GitHubCopilotModelCapability(
+ string ModelId,
+ bool SupportsResponses,
+ bool SupportsChatCompletions,
+ IReadOnlyList SupportedEndpoints)
+{
+ public GitHubCopilotApiKind? PreferredApi => SupportsResponses
+ ? GitHubCopilotApiKind.Responses
+ : SupportsChatCompletions ? GitHubCopilotApiKind.ChatCompletions : null;
+}
+
+///
+/// Caches authenticated Copilot model capabilities by the effective API endpoint
+/// and the canonical server model id. Tokens are intentionally never retained.
+///
+internal sealed class GitHubCopilotModelCatalog
+{
+ private readonly ConcurrentDictionary _entries = new();
+
+ public void Store(Uri apiEndpoint, IEnumerable capabilities)
+ {
+ var endpoint = NormalizeEndpoint(apiEndpoint);
+ foreach (var capability in capabilities)
+ _entries[new GitHubCopilotCatalogKey(endpoint, NormalizeModelId(capability.ModelId))] = capability;
+ }
+
+ public GitHubCopilotModelCapability? Find(Uri apiEndpoint, string modelId)
+ {
+ var endpoint = NormalizeEndpoint(apiEndpoint);
+ return _entries.TryGetValue(
+ new GitHubCopilotCatalogKey(endpoint, NormalizeModelId(modelId)), out var capability)
+ ? capability
+ : null;
+ }
+
+ private static string NormalizeEndpoint(Uri endpoint) => endpoint.AbsoluteUri.TrimEnd('/');
+ private static string NormalizeModelId(string modelId) => modelId.ToUpperInvariant();
+
+ private readonly record struct GitHubCopilotCatalogKey(string Endpoint, string ModelId);
+}
+
+/// Minimal lazy delegate that selects the Copilot API exactly once.
+internal sealed class GitHubCopilotCapabilityResolvingChatClient(
+ Func> initialize) : IChatClient
+{
+ private readonly object _initializationLock = new();
+ private Task? _innerTask;
+
+ public async Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ var inner = await GetInnerAsync(cancellationToken).ConfigureAwait(false);
+ return await inner.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var inner = await GetInnerAsync(cancellationToken).ConfigureAwait(false);
+ await foreach (var update in inner.GetStreamingResponseAsync(messages, options, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ yield return update;
+ }
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null) =>
+ _innerTask is { IsCompletedSuccessfully: true }
+ ? _innerTask.Result.GetService(serviceType, serviceKey)
+ : null;
+
+ public void Dispose()
+ {
+ if (_innerTask is { IsCompletedSuccessfully: true })
+ _innerTask.Result.Dispose();
+ }
+
+ private Task GetInnerAsync(CancellationToken cancellationToken)
+ {
+ Task task;
+ lock (_initializationLock)
+ task = _innerTask ??= initialize();
+
+ return task.WaitAsync(cancellationToken);
+ }
+}
diff --git a/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotProviderPlugin.cs b/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotProviderPlugin.cs
index 311dbcc31..ce4909b6b 100644
--- a/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotProviderPlugin.cs
+++ b/src/Netclaw.Providers/GitHubCopilot/GitHubCopilotProviderPlugin.cs
@@ -8,6 +8,7 @@
using Microsoft.Extensions.AI;
using Netclaw.Configuration;
using OpenAI;
+using OpenAI.Responses;
namespace Netclaw.Providers.GitHubCopilot;
@@ -31,37 +32,60 @@ public sealed class GitHubCopilotProviderPlugin(
internal PipelineTransport? TransportOverride { get; init; }
public override IChatClient CreateChatClient(ProviderEntry entry, ModelReference model)
+ {
+ return new GitHubCopilotCapabilityResolvingChatClient(async () =>
+ {
+ var capability = await Descriptor.ResolveModelCapabilityAsync(entry, model.ModelId)
+ .ConfigureAwait(false);
+ return CreateSdkClient(entry, model.Provider, capability);
+ });
+ }
+
+ private IChatClient CreateSdkClient(
+ ProviderEntry entry, string? providerName, GitHubCopilotModelCapability capability)
{
var endpoint = string.IsNullOrWhiteSpace(entry.Endpoint)
? Descriptor.DefaultEndpoint
: entry.Endpoint.TrimEnd('/');
+ var credential = new ApiKeyCredential("placeholder");
+ var oauth = GitHubCopilotDescriptor.CreateOAuthAuth(entry);
+ var followTokenHost = !GitHubCopilotDescriptor.HasCustomEndpointOverride(entry.Endpoint);
- var options = new OpenAIClientOptions
+ if (capability.PreferredApi is null)
{
- Endpoint = new Uri(endpoint),
- };
+ var advertised = capability.SupportedEndpoints.Count == 0
+ ? "(none)"
+ : string.Join(", ", capability.SupportedEndpoints);
+ throw new InvalidOperationException(
+ $"GitHub Copilot model '{capability.ModelId}' does not advertise a supported HTTP inference endpoint. "
+ + $"Advertised endpoints: {advertised}.");
+ }
- if (TransportOverride is not null)
- options.Transport = TransportOverride;
+ return capability.PreferredApi == GitHubCopilotApiKind.Responses
+ ? CreateResponsesClient()
+ : CreateChatCompletionsClient();
- // The SDK owns the Authorization header: its key-credential auth policy
- // runs after any policy we register and writes "Bearer {key}" from this
- // credential at send time. So we hand it a mutable credential and let
- // CopilotRequestPolicy refresh its value (to a fresh short-lived Copilot
- // token) on every call. The "placeholder" is overwritten before the
- // first request goes out.
- var credential = new ApiKeyCredential("placeholder");
- var oauth = GitHubCopilotDescriptor.CreateOAuthAuth(entry);
+ IChatClient CreateResponsesClient()
+ {
+ var options = new ResponsesClientOptions { Endpoint = new Uri(endpoint) };
+ if (TransportOverride is not null)
+ options.Transport = TransportOverride;
+ options.AddPolicy(CreateRequestPolicy(), PipelinePosition.PerCall);
+ return new ResponsesClient(credential, options).AsIChatClient(capability.ModelId);
+ }
- // When the operator left the endpoint on the public default, let the chat
- // host follow the token's endpoints.api (required for GHE data residency,
- // issue #1550). A deliberate custom endpoint (e.g. a proxy) is respected.
- var followTokenHost = !GitHubCopilotDescriptor.HasCustomEndpointOverride(entry.Endpoint);
- options.AddPolicy(
- new CopilotRequestPolicy(tokenExchanger, entry, credential, followTokenHost, model.Provider, oauth),
- PipelinePosition.PerCall);
+ IChatClient CreateChatCompletionsClient()
+ {
+ var options = new OpenAIClientOptions { Endpoint = new Uri(endpoint) };
+ if (TransportOverride is not null)
+ options.Transport = TransportOverride;
+ options.AddPolicy(CreateRequestPolicy(), PipelinePosition.PerCall);
+ return new OpenAIClient(credential, options)
+ .GetChatClient(capability.ModelId)
+ .AsIChatClient();
+ }
- var client = new OpenAIClient(credential, options);
- return client.GetChatClient(model.ModelId).AsIChatClient();
+ CopilotRequestPolicy CreateRequestPolicy() =>
+ new(tokenExchanger, entry, credential, followTokenHost, providerName, oauth);
}
}