diff --git a/CHANGELOG.md b/CHANGELOG.md index ddbd8d17674..89c35758f17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/providers/github-copilot.md b/docs/providers/github-copilot.md index cb5c353ea63..33f95c3b57b 100644 --- a/docs/providers/github-copilot.md +++ b/docs/providers/github-copilot.md @@ -90,6 +90,13 @@ openclaw models auth login --provider github-copilot --method device --set-defau selects the correct transport based on the model ref. + + 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. + + OpenClaw resolves Copilot auth from environment variables in the following priority order: diff --git a/extensions/github-copilot/stream.test.ts b/extensions/github-copilot/stream.test.ts index b0b345fb084..14d870e9eaa 100644 --- a/extensions/github-copilot/stream.test.ts +++ b/extensions/github-copilot/stream.test.ts @@ -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> }> = []; 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[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}$/); }); diff --git a/extensions/github-copilot/stream.ts b/extensions/github-copilot/stream.ts index b2dc00a54df..2da6502a692 100644 --- a/extensions/github-copilot/stream.ts +++ b/extensions/github-copilot/stream.ts @@ -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)); diff --git a/src/agents/copilot-dynamic-headers.ts b/src/agents/copilot-dynamic-headers.ts index 647198b5f51..58a6151a663 100644 --- a/src/agents/copilot-dynamic-headers.ts +++ b/src/agents/copilot-dynamic-headers.ts @@ -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 { 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 { 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" } : {}), }; } diff --git a/src/plugin-sdk/provider-stream-shared.test.ts b/src/plugin-sdk/provider-stream-shared.test.ts index 10e05ade4f5..d7f83b27638 100644 --- a/src/plugin-sdk/provider-stream-shared.test.ts +++ b/src/plugin-sdk/provider-stream-shared.test.ts @@ -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[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 = {