mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 20:40:43 +00:00
fix: align github copilot request headers
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}$/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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" } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user