From 57eff2fbe2f7d6750336b1038f70ff9609005dcd Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 7 Sep 2026 13:53:59 -0700 Subject: [PATCH] Fix Fable inference by omitting deprecated Anthropic sampling parameters --- .../src/agent/anthropic-provider.test.ts | 22 ++++- .../src/agent/anthropic-request.test.ts | 86 +++++++++++++++++++ apps/server/src/agent/anthropic-request.ts | 29 +++++++ apps/server/src/agent/client.ts | 2 + docs/hosted-agent.md | 11 ++- 5 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/agent/anthropic-request.test.ts create mode 100644 apps/server/src/agent/anthropic-request.ts diff --git a/apps/server/src/agent/anthropic-provider.test.ts b/apps/server/src/agent/anthropic-provider.test.ts index bd2aec56..fde6f8df 100644 --- a/apps/server/src/agent/anthropic-provider.test.ts +++ b/apps/server/src/agent/anthropic-provider.test.ts @@ -5,6 +5,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { AnthropicRequestHandler } from "./anthropic-request"; import { locate } from "./cli"; import { plannerConfiguration, RUNTIME_ENV, verifyModel, workerConfiguration } from "./client"; import { NAME } from "./planner"; @@ -65,11 +66,30 @@ async function runtime( respond: (request: Request) => Promise, run: (session: CopilotSession) => Promise, ) { - let server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: respond }); + let server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + let body = await request.clone().json() as Record; + for (let field of ["temperature", "top_p", "top_k"]) { + if (field in body) { + return Response.json({ + type: "error", + error: { + type: "invalid_request_error", + message: `${field} is deprecated for this model.`, + }, + }, { status: 400 }); + } + } + return respond(request); + }, + }); let directory = mkdtempSync(join(tmpdir(), "chopin-anthropic-test-")); let cli = locate(); if (!cli.ok) throw new Error(cli.reason); let client = new CopilotClient({ + requestHandler: new AnthropicRequestHandler(server.url.origin), mode: "empty", workingDirectory: directory, baseDirectory: directory, diff --git a/apps/server/src/agent/anthropic-request.test.ts b/apps/server/src/agent/anthropic-request.test.ts new file mode 100644 index 00000000..350ac995 --- /dev/null +++ b/apps/server/src/agent/anthropic-request.test.ts @@ -0,0 +1,86 @@ +import { expect, it, spyOn } from "bun:test"; + +import { AnthropicRequestHandler } from "./anthropic-request"; + +import type { CopilotRequestContext } from "@github/copilot-sdk"; + +class Handler extends AnthropicRequestHandler { + public override sendRequest(request: Request, context: CopilotRequestContext) { + return super.sendRequest(request, context); + } +} + +let context: CopilotRequestContext = { + requestId: "fixture", + transport: "http", + url: "https://api.anthropic.com/v1/messages", + headers: {}, + signal: new AbortController().signal, +}; + +it("preserves Anthropic messages, tools, credentials, streaming and cancellation", async () => { + let body = { + model: "claude-fable-5-1", + messages: [{ role: "user", content: "Read the document." }], + tools: [{ name: "read_plan", input_schema: { type: "object" } }], + max_tokens: 32000, + stream: true, + thinking: { type: "adaptive" }, + }; + let response = new Response("event: message_stop\ndata: {}\n\n", { + headers: { "content-type": "text/event-stream" }, + }); + let fetch = spyOn(globalThis, "fetch").mockImplementation( + (async (input, options) => { + let request = input as Request; + expect(await request.json()).toEqual(body); + expect(request.url).toBe(`${context.url}?beta=true`); + expect(request.headers.get("x-api-key")).toBe("fixture-key"); + expect(request.headers.get("anthropic-version")).toBe("2023-06-01"); + expect(request.headers.get("content-length")).toBeNull(); + expect(options?.signal).toBe(context.signal); + return response; + }) as typeof globalThis.fetch, + ); + try { + let request = new Request(`${context.url}?beta=true`, { + method: "POST", + headers: { + "x-api-key": "fixture-key", + "anthropic-version": "2023-06-01", + "content-type": "application/json", + "content-length": "9999", + }, + body: JSON.stringify({ ...body, temperature: 0, top_p: 0.9, top_k: 40 }), + }); + expect(await new Handler().sendRequest(request, context)).toBe(response); + expect(fetch).toHaveBeenCalledTimes(1); + } finally { + fetch.mockRestore(); + } +}); + +it("passes Copilot and unrelated requests through without rewriting them", async () => { + let requests = [ + new Request("https://api.githubcopilot.com/chat/completions", { + method: "POST", + body: '{ "temperature": 0.5 }', + }), + new Request("https://other.example/v1/messages", { method: "POST", body: "unchanged" }), + new Request("https://api.anthropic.com/v1/messages/count_tokens", { + method: "POST", + body: "unchanged", + }), + new Request("https://api.anthropic.com/v1/messages"), + ]; + let fetch = spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok")); + try { + for (let request of requests) { + await new Handler().sendRequest(request, context); + expect(fetch.mock.calls.at(-1)?.[0]).toBe(request); + expect(request.bodyUsed).toBe(false); + } + } finally { + fetch.mockRestore(); + } +}); diff --git a/apps/server/src/agent/anthropic-request.ts b/apps/server/src/agent/anthropic-request.ts new file mode 100644 index 00000000..538ca86a --- /dev/null +++ b/apps/server/src/agent/anthropic-request.ts @@ -0,0 +1,29 @@ +import { CopilotRequestHandler } from "@github/copilot-sdk"; + +import type { CopilotRequestContext } from "@github/copilot-sdk"; + +/** Use Anthropic's sampling defaults; Fable rejects the CLI's temperature. */ +export class AnthropicRequestHandler extends CopilotRequestHandler { + constructor(private origin = "https://api.anthropic.com") { + super(); + } + + protected override async sendRequest( + request: Request, + context: CopilotRequestContext, + ): Promise { + let url = new URL(request.url); + if ( + request.method === "POST" && url.origin === this.origin && url.pathname === "/v1/messages" + ) { + let body = await request.json() as Record; + delete body.temperature; + delete body.top_p; + delete body.top_k; + let headers = new Headers(request.headers); + headers.delete("content-length"); + request = new Request(request, { method: "POST", headers, body: JSON.stringify(body) }); + } + return super.sendRequest(request, context); + } +} diff --git a/apps/server/src/agent/client.ts b/apps/server/src/agent/client.ts index 2ad0b90e..d6472919 100644 --- a/apps/server/src/agent/client.ts +++ b/apps/server/src/agent/client.ts @@ -11,6 +11,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { AnthropicRequestHandler } from "./anthropic-request"; import { locate } from "./cli"; import { gate, @@ -253,6 +254,7 @@ function connect() { let home = mkdtempSync(join(tmpdir(), "chopin-copilot-")); try { let client = new CopilotClient({ + requestHandler: new AnthropicRequestHandler(), mode: "empty", workingDirectory: home, baseDirectory: home, diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index f73a9f70..4b4bad80 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -209,6 +209,11 @@ configured Anthropic model and refuses a mismatch. Runtime `assistant.usage` events log the model and token counts, without prompts or credentials. These logs provide evidence independent of an agent's self-description. +The SDK request handler removes `temperature`, `top_p`, and `top_k` from direct +Anthropic Messages requests. The pinned CLI adds a temperature that Fable 5.1 +rejects; these requests use Anthropic's sampling defaults. Other endpoints pass +through unchanged, and responses retain streaming and cancellation support. + Public research uses Anthropic's Messages API directly with only the disclosed query and the basic `web_search_20250305` server tool. It has no private document, repository, filesystem, or client tools. Search results and citations must agree; @@ -223,6 +228,6 @@ GitHub's web-search MCP configuration is used only in Copilot mode. Private analysis and synthesis stay in separate no-web SDK sessions in both modes. `bun run test:anthropic` exercises the pinned CLI against a local mock Anthropic -endpoint, including wire model/key routing, streaming, tool permission checks, -and terminal results. It requires local socket access but no API key or Copilot -login. It does not establish live model availability for a deployment's key. +endpoint, including rejection of deprecated sampling parameters, wire model/key +routing, streaming, tool permission checks, and terminal results. It requires +local socket access but no API key or Copilot login. It does not establish live model availability for a deployment's key.