Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion apps/server/src/agent/anthropic-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -65,11 +66,30 @@ async function runtime(
respond: (request: Request) => Promise<Response>,
run: (session: CopilotSession) => Promise<void>,
) {
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<string, unknown>;
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,
Expand Down
86 changes: 86 additions & 0 deletions apps/server/src/agent/anthropic-request.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
29 changes: 29 additions & 0 deletions apps/server/src/agent/anthropic-request.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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<string, unknown>;
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);
}
}
2 changes: 2 additions & 0 deletions apps/server/src/agent/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 8 additions & 3 deletions docs/hosted-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Loading