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