From 2e8e7407a41c1e4f9cf9c393e6ddcaece5965ad9 Mon Sep 17 00:00:00 2001 From: spencer Date: Wed, 3 Jun 2026 12:24:16 -0500 Subject: [PATCH] fix(ai,coding-agent): correct Copilot context window discovery and await it during startup GitHub Copilot's /models endpoint surfaces 1M-context Anthropic variants that aren't in the static registry. Three bugs made these effectively unusable: 1. Discovery hit api.individual.githubcopilot.com unconditionally, which returns HTTP 421 Misdirected Request for some accounts. Discovery silently no-op'd, the 1M ids were never merged, and resolution fell back to a smaller-window sibling. 2. When discovery did succeed, contextWindow was set to max_context_window_tokens (total input+output+cache budget), not max_prompt_tokens (real input cap). The compaction threshold (window - 16k reserve) never tripped, so the provider rejected oversized prompts before compaction could fire. 3. discoverAnthropicCapabilities() was fire-and-forget at session startup, racing initial model resolution. If the saved default model wasn't in the static registry, resolution lost the race and fell back to a sibling with a smaller window. Fixes: - discoverCopilot now tries the configured host first, then falls back through api.githubcopilot.com / .business / .enterprise. The host that responded is used as the registered model's baseUrl so chat calls hit the same proxy that served /models. - Both the capability cache and the discovered registry entry now use max_prompt_tokens (fall back to max_context_window_tokens only when missing). Compaction thresholds now match what the provider will actually accept. - createAgentSession and createAgentSessionServices now await discovery before resolving the initial model when the saved default isn't in the static snapshot. Adds at most one /models round-trip to startup in that case; fire-and-forget path is preserved when the saved default already resolves. --- packages/ai/CHANGELOG.md | 2 + .../ai/src/providers/anthropic-discovery.ts | 60 ++++++++++++++----- packages/coding-agent/CHANGELOG.md | 1 + .../src/core/agent-session-services.ts | 11 +++- packages/coding-agent/src/core/sdk.ts | 16 +++-- 5 files changed, 69 insertions(+), 21 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 42b5f6da5..154d30a93 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed - Fixed CI type-check failures from test files referencing model IDs no longer returned by upstream `/models` endpoints. `models.generated.ts` is regenerated each CI run, so tests hardcoding stale IDs (`gpt-4o` on github-copilot, `qwen-3-235b-a22b-instruct-2507` on cerebras, `google/gemini-2.0-flash-001` on openrouter) failed `getModel`'s typed lookup. Swapped to currently-served siblings (`gpt-4.1`, `llama3.1-8b`, `google/gemini-2.5-flash`). +- Fixed GitHub Copilot capability discovery silently failing on `api.individual.githubcopilot.com` (HTTP 421 Misdirected Request for some accounts): discovery now falls back through the canonical Copilot hosts (`api.githubcopilot.com`, `api.business.githubcopilot.com`, `api.enterprise.githubcopilot.com`) and uses the host that responded for the registered model's `baseUrl`, so 1M-context variants are actually surfaced. +- Fixed Copilot Anthropic model `contextWindow` being set to `max_context_window_tokens` (total input+output budget) instead of `max_prompt_tokens` (real input cap), causing the compaction threshold to never trigger for 1M-context variants until the provider rejected the request. Both the capability cache and the registered model entry now use `max_prompt_tokens`, so compaction fires at the real input ceiling. - Fixed Anthropic adaptive-thinking rejection on `claude-opus-4-7` (and the `opus-4-6` family more generally): the request builder now consults a per-(provider, model) capability table and emits the adaptive `thinking.type=adaptive` + `output_config.effort` shape for any model that requires it, instead of the legacy `thinking.type=enabled` + `budget_tokens` shape ([#11](https://github.com/JuliusBrussee/caveman-code/issues/11)) - Fixed Anthropic 1M context tier not being opted into for `claude-opus-4-5` on the direct Anthropic API and Bedrock: the provider now sends `anthropic-beta: context-1m-2025-08-07` for that family and the model registry overrides its `contextWindow` to 1_000_000 so the modeline / picker / compaction logic see the real ceiling. The opt-in is provider-scoped: the GitHub Copilot Anthropic relay rejects this beta header and exposes 1M-context Claude models as separate model ids instead, so the beta is not sent on Copilot. Discovery of those Copilot-only model ids is out of scope for this PR; a follow-up runtime-discovery layer will surface them ([#12](https://github.com/JuliusBrussee/caveman-code/issues/12)) - Fixed bare `readline` import to use `node:readline` prefix for Deno compatibility ([#2885](https://github.com/badlogic/pi-mono/issues/2885) by [@milosv-vtool](https://github.com/milosv-vtool)) diff --git a/packages/ai/src/providers/anthropic-discovery.ts b/packages/ai/src/providers/anthropic-discovery.ts index 16101e2e1..84cf849cc 100644 --- a/packages/ai/src/providers/anthropic-discovery.ts +++ b/packages/ai/src/providers/anthropic-discovery.ts @@ -129,19 +129,43 @@ interface CopilotModelEntry { }; } +// Copilot's /models endpoint is served by a different proxy than chat. Some +// accounts (individual/business/enterprise) get HTTP 421 "Misdirected +// Request" on the host that serves chat. Try the configured host first, +// then fall back to canonical hosts so discovery succeeds regardless of +// which Copilot tier the user is on. +const COPILOT_DISCOVERY_FALLBACK_HOSTS = [ + "https://api.githubcopilot.com", + "https://api.individual.githubcopilot.com", + "https://api.business.githubcopilot.com", + "https://api.enterprise.githubcopilot.com", +]; + async function discoverCopilot(baseUrl: string, apiKey: string, extraHeaders?: Record): Promise { - const res = await fetch(`${baseUrl}/models`, { - method: "GET", - headers: { - Authorization: `Bearer ${apiKey}`, - "User-Agent": "GitHubCopilotChat/0.35.0", - "Editor-Version": "vscode/1.107.0", - "Editor-Plugin-Version": "copilot-chat/0.35.0", - "Copilot-Integration-Id": "vscode-chat", - ...extraHeaders, - }, - }); - if (!res.ok) return; + const headers = { + Authorization: `Bearer ${apiKey}`, + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.107.0", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "vscode-chat", + ...extraHeaders, + }; + const hosts = [baseUrl, ...COPILOT_DISCOVERY_FALLBACK_HOSTS.filter((h) => h !== baseUrl)]; + let res: Response | undefined; + let workingHost = baseUrl; + for (const host of hosts) { + try { + const r = await fetch(`${host}/models`, { method: "GET", headers }); + if (r.ok) { + res = r; + workingHost = host; + break; + } + } catch { + // try next host + } + } + if (!res) return; const body = (await res.json()) as { data?: CopilotModelEntry[] }; const entries = body?.data ?? []; @@ -159,10 +183,16 @@ async function discoverCopilot(baseUrl: string, apiKey: string, extraHeaders?: R const efforts = sup.reasoning_effort ?? []; const xhighEffort = efforts.includes("max") || efforts.includes("xhigh"); + // Use max_prompt_tokens (real input cap) for contextWindow, not + // max_context_window_tokens (input+output+cache total). Compaction + // compares against the input budget and the provider rejects + // requests over max_prompt_tokens. + const promptCap = lim.max_prompt_tokens ?? lim.max_context_window_tokens; + setDiscoveredCapabilities("github-copilot", entry.id, { thinkingSchema: adaptive ? "adaptive" : "legacy", xhighEffort: xhighEffort || undefined, - contextWindow: lim.max_context_window_tokens, + contextWindow: promptCap, }); // Build a registry entry so previously-unknown ids (e.g. @@ -172,7 +202,7 @@ async function discoverCopilot(baseUrl: string, apiKey: string, extraHeaders?: R name: entry.name || entry.id, api: "anthropic-messages", provider: "github-copilot", - baseUrl, + baseUrl: workingHost, headers: { "User-Agent": "GitHubCopilotChat/0.35.0", "Editor-Version": "vscode/1.107.0", @@ -182,7 +212,7 @@ async function discoverCopilot(baseUrl: string, apiKey: string, extraHeaders?: R reasoning: adaptive, input: sup.vision ? ["text", "image"] : ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: lim.max_context_window_tokens ?? 128000, + contextWindow: promptCap ?? 128000, maxTokens: lim.max_output_tokens ?? 8192, }); } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a7fe14198..024849a9b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed initial model resolution falling back to a different model when the user's saved default isn't in the static registry (e.g. discovered-only Copilot ids). `createAgentSession` and the session-services factory now await Anthropic capability discovery before resolving the initial model if the saved default is missing from the snapshot, so the correct `contextWindow` is used from the first turn instead of silently degrading to a smaller-window sibling and over-compacting. - Added missing `@sinclair/typebox` runtime dependency. Fixes `ERR_MODULE_NOT_FOUND` on `caveman-code` startup when installed globally ([#6](https://github.com/JuliusBrussee/caveman-code/issues/6)). - Fixed broken extensions-migration links in the hooks-folder warning — now point to `JuliusBrussee/caveman-code` instead of the renamed-away `caveman-cli` repo ([#8](https://github.com/JuliusBrussee/caveman-code/issues/8)). - Self-update check now queries the correct GitHub repo (`caveman-code`) for releases. diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index ad333630b..d175d2f0d 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -134,8 +134,15 @@ export async function createAgentSessionServices( const authStorage = options.authStorage ?? AuthStorage.create(join(agentDir, "auth.json")); const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json")); - // See sdk.ts for rationale; fire-and-forget Anthropic capability discovery. - void modelRegistry.discoverAnthropicCapabilities().catch(() => {}); + // Anthropic capability discovery. If the user's saved default model isn't + // in the static registry, await discovery so resolution sees the freshly + // merged ids (e.g. github-copilot's claude-opus-4.7-1m-internal). + const discoveryPromise = modelRegistry.discoverAnthropicCapabilities().catch(() => {}); + const savedDefaultProvider = settingsManager.getDefaultProvider(); + const savedDefaultModelId = settingsManager.getDefaultModel(); + if (savedDefaultProvider && savedDefaultModelId && !modelRegistry.find(savedDefaultProvider, savedDefaultModelId)) { + await discoveryPromise; + } const resourceLoader = new DefaultResourceLoader({ ...(options.resourceLoaderOptions ?? {}), cwd, diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index c9def03b4..56087f09d 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -179,13 +179,21 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const authStorage = options.authStorage ?? AuthStorage.create(authPath); const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, modelsPath); + const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); + // Kick off per-account capability discovery for Anthropic-API providers // (e.g. GitHub Copilot's /models endpoint surfaces 1M variants like - // claude-opus-4.6-1m that aren't in the static registry). Fire-and-forget; - // onModelRegistryChange refreshes the local snapshot as results arrive. - void modelRegistry.discoverAnthropicCapabilities().catch(() => {}); + // claude-opus-4.6-1m that aren't in the static registry). + // If the user's saved default model isn't in the static registry, we MUST + // await discovery before resolving — otherwise resolution falls back to a + // different model with a smaller context window. Otherwise fire-and-forget. + const discoveryPromise = modelRegistry.discoverAnthropicCapabilities().catch(() => {}); + const savedDefaultProvider = settingsManager.getDefaultProvider(); + const savedDefaultModelId = settingsManager.getDefaultModel(); + if (savedDefaultProvider && savedDefaultModelId && !modelRegistry.find(savedDefaultProvider, savedDefaultModelId)) { + await discoveryPromise; + } - const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); const sessionManager = options.sessionManager ?? SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir)); if (!resourceLoader) {