mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 04:59:30 +00:00
fix: preserve Foundry Responses reasoning replay ids
Preserve Microsoft Foundry encrypted reasoning replay item ids for Responses continuations while leaving chat-completions streams untouched. Fixes #91033.
This commit is contained in:
committed by
GitHub
parent
e64f2324b9
commit
248dfb22ec
@@ -1,4 +1,5 @@
|
||||
// Microsoft Foundry tests cover index plugin behavior.
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -781,6 +782,60 @@ describe("microsoft-foundry plugin", () => {
|
||||
expect(provider?.models[0]?.compat?.maxTokensField).toBe("max_completion_tokens");
|
||||
});
|
||||
|
||||
it("keeps replay item ids for Foundry encrypted reasoning continuations", async () => {
|
||||
const provider = registerProvider();
|
||||
let capturedReplayIds: boolean | undefined;
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
capturedReplayIds = (options as { replayResponsesItemIds?: boolean } | undefined)
|
||||
?.replayResponsesItemIds;
|
||||
return {} as never;
|
||||
};
|
||||
|
||||
const wrappedStreamFn = provider.wrapStreamFn?.({
|
||||
streamFn: baseStreamFn,
|
||||
modelId: "gpt-5.4",
|
||||
model: buildFoundryModel({
|
||||
reasoning: true,
|
||||
compat: { supportsStore: false },
|
||||
}),
|
||||
extraParams: {},
|
||||
config: {},
|
||||
agentDir: defaultFoundryAgentDir,
|
||||
} as never);
|
||||
|
||||
expect(wrappedStreamFn).toBeTypeOf("function");
|
||||
await wrappedStreamFn?.(
|
||||
buildFoundryModel({
|
||||
reasoning: true,
|
||||
compat: { supportsStore: false },
|
||||
}) as never,
|
||||
{ systemPrompt: "system", messages: [] } as never,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(capturedReplayIds).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves Foundry chat completions streams unwrapped by Responses defaults", () => {
|
||||
const provider = registerProvider();
|
||||
const baseStreamFn: StreamFn = () => ({}) as never;
|
||||
|
||||
expect(
|
||||
provider.wrapStreamFn?.({
|
||||
streamFn: baseStreamFn,
|
||||
modelId: "gpt-4o-mini",
|
||||
model: buildFoundryModel({
|
||||
id: "gpt-4o-mini",
|
||||
name: "gpt-4o-mini",
|
||||
api: "openai-completions",
|
||||
}),
|
||||
extraParams: {},
|
||||
config: {},
|
||||
agentDir: defaultFoundryAgentDir,
|
||||
} as never),
|
||||
).toBe(baseStreamFn);
|
||||
});
|
||||
|
||||
it("marks Foundry chat models as not supporting reasoning_effort", () => {
|
||||
const result = buildFoundryAuthResult({
|
||||
profileId: "microsoft-foundry:default",
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ModelProviderConfig,
|
||||
ProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { OPENAI_RESPONSES_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family";
|
||||
import { apiKeyAuthMethod, entraIdAuthMethod } from "./auth.js";
|
||||
import { prepareFoundryRuntimeAuth } from "./runtime.js";
|
||||
import {
|
||||
@@ -18,6 +19,40 @@ import {
|
||||
resolveFoundryTargetProfileId,
|
||||
} from "./shared.js";
|
||||
|
||||
type FoundryProviderHooks = Pick<ProviderPlugin, "wrapStreamFn">;
|
||||
|
||||
const wrapOpenAIResponsesStreamFn = OPENAI_RESPONSES_STREAM_HOOKS.wrapStreamFn;
|
||||
|
||||
const wrapMicrosoftFoundryStreamFn: NonNullable<FoundryProviderHooks["wrapStreamFn"]> = (ctx) => {
|
||||
if (ctx.model?.api !== "openai-responses") {
|
||||
return ctx.streamFn ?? null;
|
||||
}
|
||||
|
||||
const baseStreamFn = ctx.streamFn;
|
||||
if (!baseStreamFn) {
|
||||
return wrapOpenAIResponsesStreamFn?.(ctx) ?? null;
|
||||
}
|
||||
|
||||
const streamFnWithResponsesReplayIds: NonNullable<typeof ctx.streamFn> = (
|
||||
model,
|
||||
context,
|
||||
options,
|
||||
) =>
|
||||
baseStreamFn(model, context, {
|
||||
...options,
|
||||
// Foundry validates encrypted reasoning replay against the original item id,
|
||||
// even though its Responses endpoint does not support persisted `store`.
|
||||
replayResponsesItemIds: true,
|
||||
} as typeof options & { replayResponsesItemIds: true });
|
||||
|
||||
return (
|
||||
wrapOpenAIResponsesStreamFn?.({
|
||||
...ctx,
|
||||
streamFn: streamFnWithResponsesReplayIds,
|
||||
}) ?? streamFnWithResponsesReplayIds
|
||||
);
|
||||
};
|
||||
|
||||
export function buildMicrosoftFoundryProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: PROVIDER_ID,
|
||||
@@ -174,6 +209,7 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin {
|
||||
...(compat ? { compat } : {}),
|
||||
};
|
||||
},
|
||||
wrapStreamFn: wrapMicrosoftFoundryStreamFn,
|
||||
prepareRuntimeAuth: prepareFoundryRuntimeAuth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -354,6 +354,53 @@ describe("convertResponsesMessages", () => {
|
||||
});
|
||||
expect(functionCall).not.toHaveProperty("id");
|
||||
});
|
||||
|
||||
it("keeps encrypted reasoning replay item ids when requested", () => {
|
||||
const input = convertResponsesMessages(
|
||||
nativeOpenAIModel,
|
||||
{
|
||||
systemPrompt: "system",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
api: nativeOpenAIModel.api,
|
||||
provider: nativeOpenAIModel.provider,
|
||||
model: nativeOpenAIModel.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 1,
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "Need continuity.",
|
||||
thinkingSignature: JSON.stringify({
|
||||
type: "reasoning",
|
||||
id: "rs_foundry_prior",
|
||||
encrypted_content: "ciphertext",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies Context,
|
||||
allowedToolCallProviders,
|
||||
{ includeSystemPrompt: false, replayResponsesItemIds: true },
|
||||
) as unknown as Array<Record<string, unknown>>;
|
||||
|
||||
expect(input.find((item) => item.type === "reasoning")).toMatchObject({
|
||||
type: "reasoning",
|
||||
id: "rs_foundry_prior",
|
||||
encrypted_content: "ciphertext",
|
||||
summary: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("processResponsesStream", () => {
|
||||
|
||||
Reference in New Issue
Block a user