fix(openai-responses): normalize Copilot response item IDs (#69362) (thanks @Menci)

This commit is contained in:
Peter Steinberger
2026-04-22 22:21:07 +01:00
parent 4f9169c6dd
commit 2e13f224d6
5 changed files with 194 additions and 3 deletions

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
rewriteCopilotConnectionBoundResponseIds,
rewriteCopilotResponsePayloadConnectionBoundIds,
} from "./connection-bound-ids.js";
describe("github-copilot connection-bound response IDs", () => {
it("rewrites opaque response item IDs deterministically", () => {
const originalId = Buffer.from(`reasoning-${"x".repeat(24)}`).toString("base64");
const first = [{ id: originalId, type: "reasoning" }];
const second = [{ id: originalId, type: "reasoning" }];
expect(rewriteCopilotConnectionBoundResponseIds(first)).toBe(true);
expect(rewriteCopilotConnectionBoundResponseIds(second)).toBe(true);
expect(first[0]?.id).toMatch(/^rs_[a-f0-9]{16}$/);
expect(first[0]?.id).toBe(second[0]?.id);
});
it("uses response item type prefixes and preserves local IDs", () => {
const functionCallId = Buffer.from(`function-call-${"y".repeat(20)}`).toString("base64");
const messageId = Buffer.from(`message-${"z".repeat(24)}`).toString("base64");
const input = [
{ id: "rs_existing", type: "reasoning" },
{ id: "msg_existing", type: "message" },
{ id: "fc_existing", type: "function_call" },
{ id: functionCallId, type: "function_call" },
{ id: messageId, type: "message" },
];
expect(rewriteCopilotConnectionBoundResponseIds(input)).toBe(true);
expect(input[0]?.id).toBe("rs_existing");
expect(input[1]?.id).toBe("msg_existing");
expect(input[2]?.id).toBe("fc_existing");
expect(input[3]?.id).toMatch(/^fc_[a-f0-9]{16}$/);
expect(input[4]?.id).toMatch(/^msg_[a-f0-9]{16}$/);
});
it("patches response payload input arrays only", () => {
const messageId = Buffer.from(`message-${"m".repeat(24)}`).toString("base64");
const payload = { input: [{ id: messageId, type: "message" }] };
expect(rewriteCopilotResponsePayloadConnectionBoundIds(payload)).toBe(true);
expect(payload.input[0]?.id).toMatch(/^msg_[a-f0-9]{16}$/);
expect(rewriteCopilotResponsePayloadConnectionBoundIds(undefined)).toBe(false);
expect(rewriteCopilotResponsePayloadConnectionBoundIds({ input: "text" })).toBe(false);
});
});

View File

@@ -0,0 +1,51 @@
import { createHash } from "node:crypto";
// Copilot's OpenAI-compatible `/responses` endpoint can emit replay item IDs
// that encode upstream connection state. Those IDs are rejected after the
// connection changes, so normalize them at the provider boundary before send.
function looksLikeConnectionBoundId(id: string): boolean {
if (id.length < 24) {
return false;
}
if (/^(?:rs|msg|fc)_[A-Za-z0-9_-]+$/.test(id)) {
return false;
}
if (!/^[A-Za-z0-9+/_-]+=*$/.test(id)) {
return false;
}
return Buffer.from(id, "base64").length >= 16;
}
function deriveReplacementId(type: string | undefined, originalId: string): string {
const prefix = type === "reasoning" ? "rs" : type === "function_call" ? "fc" : "msg";
const hex = createHash("sha256").update(originalId).digest("hex").slice(0, 16);
return `${prefix}_${hex}`;
}
type InputItem = Record<string, unknown> & { id?: unknown; type?: unknown };
export function rewriteCopilotConnectionBoundResponseIds(input: unknown): boolean {
if (!Array.isArray(input)) {
return false;
}
let rewrote = false;
for (const item of input as InputItem[]) {
const id = item.id;
if (typeof id !== "string" || id.length === 0) {
continue;
}
if (looksLikeConnectionBoundId(id)) {
item.id = deriveReplacementId(typeof item.type === "string" ? item.type : undefined, id);
rewrote = true;
}
}
return rewrote;
}
export function rewriteCopilotResponsePayloadConnectionBoundIds(payload: unknown): boolean {
if (!payload || typeof payload !== "object") {
return false;
}
return rewriteCopilotConnectionBoundResponseIds((payload as { input?: unknown }).input);
}

View File

@@ -1,6 +1,10 @@
import { buildCopilotDynamicHeaders } from "openclaw/plugin-sdk/provider-stream-shared";
import { describe, expect, it, vi } from "vitest";
import { wrapCopilotAnthropicStream, wrapCopilotProviderStream } from "./stream.js";
import {
wrapCopilotAnthropicStream,
wrapCopilotOpenAIResponsesStream,
wrapCopilotProviderStream,
} from "./stream.js";
describe("wrapCopilotAnthropicStream", () => {
it("adds Copilot headers and Anthropic cache markers for Claude payloads", async () => {
@@ -89,6 +93,62 @@ describe("wrapCopilotAnthropicStream", () => {
expect(baseStreamFn).toHaveBeenCalledWith(expect.anything(), expect.anything(), options);
});
it("rewrites Copilot 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) => {
const payload = { input: [{ id: connectionBoundId, type: "reasoning" }] };
options?.onPayload?.(payload, _model);
payloads.push(payload);
return {
async *[Symbol.asyncIterator]() {},
} as never;
});
const wrapped = wrapCopilotOpenAIResponsesStream(baseStreamFn);
void wrapped(
{
provider: "github-copilot",
api: "openai-responses",
id: "gpt-5.4",
} as never,
{ messages: [{ role: "user", content: "hi" }] } as never,
{},
);
expect(payloads[0]?.input[0]?.id).toMatch(/^rs_[a-f0-9]{16}$/);
});
it("rewrites Copilot Responses IDs returned by an existing payload hook", async () => {
const connectionBoundId = Buffer.from(`message-${"y".repeat(24)}`).toString("base64");
let returnedPayload: unknown;
const baseStreamFn = vi.fn(async (_model, _context, options) => {
returnedPayload = await options?.onPayload?.({ input: [] }, _model);
return {
async *[Symbol.asyncIterator]() {},
} as never;
});
const wrapped = wrapCopilotOpenAIResponsesStream(baseStreamFn);
await wrapped(
{
provider: "github-copilot",
api: "openai-responses",
id: "gpt-5.4",
} as never,
{ messages: [{ role: "user", content: "hi" }] } as never,
{
onPayload: () => ({ input: [{ id: connectionBoundId, type: "message" }] }),
} as never,
);
expect((returnedPayload as { input: Array<Record<string, unknown>> }).input[0]?.id).toMatch(
/^msg_[a-f0-9]{16}$/,
);
});
it("adapts provider stream context without changing wrapper behavior", () => {
const baseStreamFn = vi.fn(() => ({ async *[Symbol.asyncIterator]() {} }) as never);

View File

@@ -7,8 +7,21 @@ import {
hasCopilotVisionInput,
streamWithPayloadPatch,
} from "openclaw/plugin-sdk/provider-stream-shared";
import { rewriteCopilotResponsePayloadConnectionBoundIds } from "./connection-bound-ids.js";
type _StreamContext = Parameters<StreamFn>[1];
type StreamOptions = Parameters<StreamFn>[2];
function patchOnPayloadResult(result: unknown): unknown {
if (result && typeof result === "object" && "then" in result) {
return Promise.resolve(result).then((next) => {
rewriteCopilotResponsePayloadConnectionBoundIds(next);
return next;
});
}
rewriteCopilotResponsePayloadConnectionBoundIds(result);
return result;
}
export function wrapCopilotAnthropicStream(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
@@ -36,6 +49,25 @@ export function wrapCopilotAnthropicStream(baseStreamFn: StreamFn | undefined):
};
}
export function wrapCopilotProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn {
return wrapCopilotAnthropicStream(ctx.streamFn);
export function wrapCopilotOpenAIResponsesStream(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
if (model.provider !== "github-copilot" || model.api !== "openai-responses") {
return underlying(model, context, options);
}
const originalOnPayload = options?.onPayload;
const wrappedOptions: StreamOptions = {
...options,
onPayload: (payload, payloadModel) => {
rewriteCopilotResponsePayloadConnectionBoundIds(payload);
return patchOnPayloadResult(originalOnPayload?.(payload, payloadModel));
},
};
return underlying(model, context, wrappedOptions);
};
}
export function wrapCopilotProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn {
return wrapCopilotOpenAIResponsesStream(wrapCopilotAnthropicStream(ctx.streamFn));
}