mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:00:42 +00:00
fix(openai-responses): normalize Copilot response item IDs (#69362) (thanks @Menci)
This commit is contained in:
@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Config/gateway: recover configs accidentally prefixed with non-JSON output during gateway startup or `openclaw doctor --fix`, preserving the clobbered file as a backup while leaving normal config reads read-only.
|
||||
- Agents/GitHub Copilot: normalize connection-bound Responses item IDs in the Copilot provider wrapper so replayed histories no longer fail after the upstream connection changes. (#69362) Thanks @Menci.
|
||||
- Pi embedded runs: pass real built-in tools into Pi session creation and then narrow active tool names after custom tool registration, so the runner and compaction paths compile cleanly and keep OpenClaw-managed custom tool allowlists without feeding string arrays into `createAgentSession`. Thanks @vincentkoc.
|
||||
- Agents/OpenAI websocket: route native OpenAI websocket metadata and session-header decisions through the shared endpoint classifier so local mocks and custom `models.providers.openai.baseUrl` endpoints stay out of the native OpenAI path consistently across embedded-runner and websocket transport code. Thanks @vincentkoc.
|
||||
- Cron/MCP: retire bundled MCP runtimes through one shared cleanup path for isolated cron run ends, persistent cron session rollover, and direct cron `deleteAfterRun` fallback cleanup. Fixes #69145, #68623, and #68827.
|
||||
|
||||
47
extensions/github-copilot/connection-bound-ids.test.ts
Normal file
47
extensions/github-copilot/connection-bound-ids.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
51
extensions/github-copilot/connection-bound-ids.ts
Normal file
51
extensions/github-copilot/connection-bound-ids.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user