Skip to content

fix(provider): route GitHub Copilot chat to the token's endpoints.api host (GHE data residency)#1555

Merged
Aaronontheweb merged 3 commits into
netclaw-dev:devfrom
Aaronontheweb:issue-1550-ghe-copilot-endpoint
Jul 2, 2026
Merged

fix(provider): route GitHub Copilot chat to the token's endpoints.api host (GHE data residency)#1555
Aaronontheweb merged 3 commits into
netclaw-dev:devfrom
Aaronontheweb:issue-1550-ghe-copilot-endpoint

Conversation

@Aaronontheweb

Copy link
Copy Markdown
Collaborator

Fixes #1550.

Root cause

GitHub Enterprise Cloud data residency mints Copilot tokens that are scoped to the enterprise's region and are only valid at a tenant-specific host (api.<subdomain>.ghe.com). GitHub reports that host in the endpoints.api field of the /copilot_internal/v2/token response — and every real Copilot client (litellm, the VS Code extension) uses it as the chat base URL.

Netclaw ignored endpoints.api and sent chat/completions to the hardcoded public api.githubcopilot.com. A GHE-scoped token at the public host is rejected with HTTP 400 — exactly the split in the issue: the token exchange (against the correct tenant host) returned 200, then the chat call (against the wrong public host) returned 400.

Secondary problem: the setup probe hit the same wrong host, failed, and was silently masked by the curated fallback model list — so provider add reported healthy for a provider that couldn't chat.

What changed

  • CopilotTokenExchanger parses endpoints.api and returns it alongside the bearer as a new CopilotToken. The bearer is now a SensitiveString (redacted ToString), and a non-HTTPS endpoints.api is ignored so authenticated traffic can never be redirected off HTTPS.
  • CopilotRequestPolicy rebases the outgoing chat request onto that host (origin + any base path), preserving the SDK-built /chat/completions path and query. This is the actual 400 fix.
  • GitHubCopilotProviderPlugin wires the routing decision through.
  • GitHubCopilotDescriptor probes /models at the token's host and no longer masks auth/connection failures behind the curated fallback — a broken GHE provider now surfaces at provider add instead of on the first chat.
  • ProbeHelpers / ProviderProbeResult gain a Transient classifier so only reachable-server retryable failures (timeout / 5xx / 429 / 408) are masked; connection failures and auth/4xx errors surface.

Operator override is respected

--github-host / --github-api-base still only configure the OAuth and token-exchange hosts. If an operator deliberately points the entry at a custom endpoint (e.g. a corporate proxy via --endpoint), that override always wins over the token's host on both the chat and probe paths — traffic is never silently rerouted.

Testing

  • 14 focused tests across the exchanger, request policy, plugin, and descriptor, including two end-to-end tests that drive the real OpenAI SDK pipeline through a capturing transport and assert the URL that reaches the wire (GHE token → tenant host; custom endpoint → proxy host).
  • Suites green: provider/probe/copilot 209, CLI provider/model/probe 626, Configuration 426.
  • dotnet slopwatch analyze: 0 issues. Add-FileHeaders.ps1 -Verify: clean. Full solution builds warning-free.

An xhigh multi-agent code review was run on the diff; findings on probe-failure classification (connection failures were masked), chat/probe URL-path consistency, and secret hygiene (SensitiveString) are folded into this PR.

Notes

No config-schema, system-skill, or eval changes are required: the provider add CLI surface, config format, and operator guidance are unchanged, and ProviderProbeResult is a runtime type (not a persisted *Config).

@Aaronontheweb Aaronontheweb added the providers Provider integrations and capability detection across OpenAI-compatible backends. label Jul 2, 2026
… host

GitHub Enterprise Cloud data residency mints Copilot tokens that are only
valid at a tenant-specific host (api.<subdomain>.ghe.com), reported in the
token exchange's endpoints.api field. Netclaw ignored that field and sent
chat/completions to the hardcoded public api.githubcopilot.com, so a GHE
token was rejected with HTTP 400 while the token exchange itself succeeded
(issue netclaw-dev#1550).

- CopilotTokenExchanger parses endpoints.api and returns it alongside the
  bearer (now a SensitiveString) as CopilotToken; a non-HTTPS value is
  ignored so authenticated traffic can't be redirected off HTTPS.
- CopilotRequestPolicy rebases the outgoing chat request onto that host
  (origin + any base path), unless the operator deliberately configured a
  custom endpoint (e.g. a corporate proxy), which is always respected.
- GitHubCopilotDescriptor probes /models at the same host and no longer
  masks auth/connection failures behind the curated fallback, so a broken
  GHE provider surfaces at `provider add` instead of on the first chat.
- ProbeHelpers/ProviderProbeResult gain a Transient classifier so only
  reachable-server retryable failures (timeout/5xx/429/408) are masked.

Fixes netclaw-dev#1550
The optional apiBase=null default silently selected the no-endpoints.api
(standard-account) token shape, so a new routing test could exercise the
wrong shape without ever deciding to — the silent-fallback trap this fix is
otherwise about. Make it mandatory so every call site states whether it
models a standard or a GHE-data-residency token (apiBase: null vs a host).
@Aaronontheweb
Aaronontheweb force-pushed the issue-1550-ghe-copilot-endpoint branch from 1e1d804 to c89cd0c Compare July 2, 2026 20:49
@Aaronontheweb

Copy link
Copy Markdown
Collaborator Author

Found some issues that I've had Claude fix while doing an earlier review pass (silent fallbacks etc, the sort of design issue that led to this problem in the first place.)

@Aaronontheweb

Copy link
Copy Markdown
Collaborator Author

Live-tested against a real GitHub Copilot account

I built this branch, published the CLI and daemon, and swapped them into a running install to test against an actual GitHub Copilot account (a standard github.com one, not GHE). Two paths mattered here.

First, model discovery, which runs the probe. model discover copilot came back with the full catalog, 21 models, and it did not hard-fail. That's the part I cared about: the token exchange response included endpoints.api, so the new "no API host" guard stayed quiet. It confirms a real response actually carries the host, so standard accounts won't trip the hard failure I added.

Then the chat itself, which runs the routing. I pointed the main model at a Copilot model and cleared the fallback first, so nothing could quietly answer in Copilot's place. A one-word prompt came back with a clean response and real token usage, meaning the request routed to the host from endpoints.api and returned 200.

The session log opens the same way as the bug report, with the token exchange returning 200. The difference is the next step: the chat completes instead of failing with 400.

One thing this run doesn't cover is the GHE data-residency case, where endpoints.api points at a tenant host rather than the public one. I don't have a .ghe.com tenant to hit, so that redirect is still only exercised by the unit tests. Everything up to "parse the host, require it, route to it" ran for real.

I restored the original config afterward.

Removes the last silent fallback in the endpoints.api routing path. When we
follow the token's host (no operator override) but the exchange returns no
usable endpoints.api, the code silently kept the configured/default host —
the same guess that sent a GHE token to the public host in netclaw-dev#1550. Refuse to
guess: the chat policy throws with a clear message and the setup probe fails
loudly instead of probing a guessed host. A deliberate operator endpoint
override is still honored — that's an explicit choice, not a guess.

Test tokens now include a realistic endpoints.api (GitHub always reports it;
litellm's Copilot provider likewise uses it with no fallback), and a null
host is exercised only to prove the loud failure.
@Aaronontheweb
Aaronontheweb force-pushed the issue-1550-ghe-copilot-endpoint branch from c89cd0c to 31ec86e Compare July 2, 2026 21:01

@Aaronontheweb Aaronontheweb left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

/// list or must surface as a hard failure. Defaults to false so an
/// unclassified failure is never silently masked.
/// </summary>
public bool Transient { get; init; }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

}

[Fact]
public async Task ProcessAsync_RoutesRequestToTokenApiHost()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly just testing to make sure the request gets routed to the correct place.

// Request-timeout, 5xx, and rate limiting are worth a retry; auth and
// other 4xx errors (incl. 404 "not found" at the token's own API host)
// signal a real misconfiguration the operator has to fix.
var transient = statusCode is HttpStatusCode.RequestTimeout

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an improvement that should help with other cloud-hosted providers too

@Aaronontheweb
Aaronontheweb enabled auto-merge (squash) July 2, 2026 21:08
@Aaronontheweb
Aaronontheweb merged commit 022adbc into netclaw-dev:dev Jul 2, 2026
15 checks passed
@Aaronontheweb Aaronontheweb mentioned this pull request Jul 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

providers Provider integrations and capability detection across OpenAI-compatible backends.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(provider) - GHE provider in Netclaw 0.24.3-beta.3 endpoint error

1 participant