fix: align github copilot request headers

This commit is contained in:
Peter Steinberger
2026-04-24 23:40:37 +01:00
parent 304126ad79
commit 719d6df156
6 changed files with 129 additions and 8 deletions

View File

@@ -98,6 +98,7 @@ Docs: https://docs.openclaw.ai
- Models/fallback: resolve bare fallback model provider ids before model switching, so configured fallback chains keep working when a fallback is named without an explicit provider prefix. Thanks @steipete.
- Voice-call/Telnyx: preserve inbound/outbound callback metadata and read transcription text from Telnyx's current `transcription_data` payload. Thanks @steipete.
- Providers/DeepSeek: wire V4 thinking controls and OpenAI-compatible replay policy so follow-up turns preserve DeepSeek `reasoning_content`, while the None/off thinking path strips replayed reasoning fields. Fixes #70931. Thanks @lsdsjy.
- Providers/GitHub Copilot: align Copilot request headers across Anthropic and Responses transports, including tool-result and image follow-up turns, without enabling unverified Responses continuation. Thanks @steipete.
- Codex harness: send verbose tool progress to chat channels for native app-server runs, matching the Pi harness `/verbose on` and `/verbose full` behavior. (#70966) Thanks @jalehman.
- Codex models: fetch paginated Codex app-server model catalogs, mark truncated `/codex models` output, and keep ChatGPT OAuth defaults on the `openai-codex/gpt-5.5` route instead of the OpenAI API-key route. Thanks @steipete.
- Codex status: report Codex CLI OAuth as `oauth (codex-cli)` for native `codex/*` sessions instead of showing unknown auth. Fixes #70688. Thanks @jb510.

View File

@@ -90,6 +90,13 @@ openclaw models auth login --provider github-copilot --method device --set-defau
selects the correct transport based on the model ref.
</Accordion>
<Accordion title="Request compatibility">
OpenClaw sends Copilot IDE-style request headers on Copilot transports,
including tool-result and image follow-up turns. It does not enable
provider-level Responses continuation for Copilot unless that behavior has
been verified against Copilot's API.
</Accordion>
<Accordion title="Environment variable resolution order">
OpenClaw resolves Copilot auth from environment variables in the following
priority order:

View File

@@ -93,7 +93,7 @@ describe("wrapCopilotAnthropicStream", () => {
expect(baseStreamFn).toHaveBeenCalledWith(expect.anything(), expect.anything(), options);
});
it("rewrites Copilot Responses connection-bound IDs before payload send", () => {
it("adds Copilot headers and rewrites Responses connection-bound IDs before payload send", () => {
const connectionBoundId = Buffer.from(`reasoning-${"x".repeat(24)}`).toString("base64");
const payloads: Array<{ input: Array<Record<string, unknown>> }> = [];
const baseStreamFn = vi.fn((_model, _context, options) => {
@@ -106,6 +106,19 @@ describe("wrapCopilotAnthropicStream", () => {
});
const wrapped = wrapCopilotOpenAIResponsesStream(baseStreamFn);
const messages = [
{
role: "toolResult",
content: [
{ type: "text", text: "look" },
{ type: "image", image: "data:image/png;base64,abc" },
],
},
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
messages,
hasImages: true,
});
void wrapped(
{
@@ -113,10 +126,16 @@ describe("wrapCopilotAnthropicStream", () => {
api: "openai-responses",
id: "gpt-5.4",
} as never,
{ messages: [{ role: "user", content: "hi" }] } as never,
{},
{ messages } as never,
{ headers: { "X-Test": "1" } },
);
expect(baseStreamFn.mock.calls[0]?.[2]).toMatchObject({
headers: {
...expectedCopilotHeaders,
"X-Test": "1",
},
});
expect(payloads[0]?.input[0]?.id).toMatch(/^rs_[a-f0-9]{16}$/);
});

View File

@@ -59,6 +59,13 @@ export function wrapCopilotOpenAIResponsesStream(baseStreamFn: StreamFn | undefi
const originalOnPayload = options?.onPayload;
const wrappedOptions: StreamOptions = {
...options,
headers: {
...buildCopilotDynamicHeaders({
messages: context.messages,
hasImages: hasCopilotVisionInput(context.messages),
}),
...options?.headers,
},
onPayload: (payload, payloadModel) => {
rewriteCopilotResponsePayloadConnectionBoundIds(payload);
return patchOnPayloadResult(originalOnPayload?.(payload, payloadModel));

View File

@@ -2,20 +2,38 @@ import type { Context } from "@mariozechner/pi-ai";
export const COPILOT_EDITOR_VERSION = "vscode/1.96.2";
export const COPILOT_USER_AGENT = "GitHubCopilotChat/0.26.7";
export const COPILOT_EDITOR_PLUGIN_VERSION = "copilot-chat/0.35.0";
export const COPILOT_GITHUB_API_VERSION = "2025-04-01";
function inferCopilotInitiator(messages: Context["messages"]): "agent" | "user" {
const last = messages[messages.length - 1];
return last && last.role !== "user" ? "agent" : "user";
if (!last) {
return "user";
}
if (last.role === "user" && containsCopilotContentType(last.content, "tool_result")) {
return "agent";
}
return last.role === "user" ? "user" : "agent";
}
function containsCopilotContentType(value: unknown, type: string): boolean {
if (Array.isArray(value)) {
return value.some((item) => containsCopilotContentType(item, type));
}
if (!value || typeof value !== "object") {
return false;
}
const entry = value as { type?: unknown; content?: unknown };
return entry.type === type || containsCopilotContentType(entry.content, type);
}
export function hasCopilotVisionInput(messages: Context["messages"]): boolean {
return messages.some((message) => {
if (message.role === "user" && Array.isArray(message.content)) {
return message.content.some((item) => item.type === "image");
return message.content.some((item) => containsCopilotContentType(item, "image"));
}
if (message.role === "toolResult" && Array.isArray(message.content)) {
return message.content.some((item) => item.type === "image");
return message.content.some((item) => containsCopilotContentType(item, "image"));
}
return false;
});
@@ -28,6 +46,7 @@ export function buildCopilotIdeHeaders(
): Record<string, string> {
return {
"Editor-Version": COPILOT_EDITOR_VERSION,
"Editor-Plugin-Version": COPILOT_EDITOR_PLUGIN_VERSION,
"User-Agent": COPILOT_USER_AGENT,
...(params.includeApiVersion ? { "X-Github-Api-Version": COPILOT_GITHUB_API_VERSION } : {}),
};
@@ -39,8 +58,9 @@ export function buildCopilotDynamicHeaders(params: {
}): Record<string, string> {
return {
...buildCopilotIdeHeaders(),
"X-Initiator": inferCopilotInitiator(params.messages),
"Openai-Intent": "conversation-edits",
"Copilot-Integration-Id": "vscode-chat",
"Openai-Organization": "github-copilot",
"x-initiator": inferCopilotInitiator(params.messages),
...(params.hasImages ? { "Copilot-Vision-Request": "true" } : {}),
};
}

View File

@@ -1,9 +1,11 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import {
buildCopilotDynamicHeaders,
createHtmlEntityToolCallArgumentDecodingWrapper,
defaultToolStreamExtraParams,
decodeHtmlEntitiesInObject,
hasCopilotVisionInput,
} from "./provider-stream-shared.js";
type FakeWrappedStream = {
@@ -61,6 +63,71 @@ describe("defaultToolStreamExtraParams", () => {
});
});
describe("buildCopilotDynamicHeaders", () => {
it("matches Copilot IDE-style request headers without the legacy Openai-Intent", () => {
expect(
buildCopilotDynamicHeaders({
messages: [{ role: "user", content: "hi", timestamp: 1 }],
hasImages: false,
}),
).toMatchObject({
"Copilot-Integration-Id": "vscode-chat",
"Editor-Plugin-Version": "copilot-chat/0.35.0",
"Openai-Organization": "github-copilot",
"x-initiator": "user",
});
expect(
buildCopilotDynamicHeaders({
messages: [{ role: "user", content: "hi", timestamp: 1 }],
hasImages: false,
}),
).not.toHaveProperty("Openai-Intent");
});
it("marks tool-result follow-up turns as agent initiated and vision-capable", () => {
expect(
buildCopilotDynamicHeaders({
messages: [
{ role: "user", content: "hi", timestamp: 1 },
{
role: "toolResult",
content: [{ type: "image", data: "abc", mimeType: "image/png" }],
timestamp: 2,
toolCallId: "call_1",
toolName: "view_image",
isError: false,
},
],
hasImages: true,
}),
).toMatchObject({
"Copilot-Vision-Request": "true",
"x-initiator": "agent",
});
});
it("detects nested tool-result image blocks in user-shaped provider payloads", () => {
const messages = [
{
role: "user",
content: [
{
type: "tool_result",
content: [{ type: "image", source: { data: "abc", media_type: "image/png" } }],
},
],
timestamp: 1,
},
] as unknown as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
expect(hasCopilotVisionInput(messages)).toBe(true);
expect(buildCopilotDynamicHeaders({ messages, hasImages: true })).toMatchObject({
"Copilot-Vision-Request": "true",
"x-initiator": "agent",
});
});
});
describe("createHtmlEntityToolCallArgumentDecodingWrapper", () => {
it("decodes tool call arguments in final and streaming messages", async () => {
const resultMessage = {