diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts index 3d84a50095b..03869c7677c 100644 --- a/extensions/microsoft-foundry/index.test.ts +++ b/extensions/microsoft-foundry/index.test.ts @@ -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", diff --git a/extensions/microsoft-foundry/provider.ts b/extensions/microsoft-foundry/provider.ts index 727c8d63d6e..dfb0ceaf1bc 100644 --- a/extensions/microsoft-foundry/provider.ts +++ b/extensions/microsoft-foundry/provider.ts @@ -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; + +const wrapOpenAIResponsesStreamFn = OPENAI_RESPONSES_STREAM_HOOKS.wrapStreamFn; + +const wrapMicrosoftFoundryStreamFn: NonNullable = (ctx) => { + if (ctx.model?.api !== "openai-responses") { + return ctx.streamFn ?? null; + } + + const baseStreamFn = ctx.streamFn; + if (!baseStreamFn) { + return wrapOpenAIResponsesStreamFn?.(ctx) ?? null; + } + + const streamFnWithResponsesReplayIds: NonNullable = ( + 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, }; } diff --git a/src/llm/providers/openai-responses-shared.test.ts b/src/llm/providers/openai-responses-shared.test.ts index 747a0cf243b..4795c922c3f 100644 --- a/src/llm/providers/openai-responses-shared.test.ts +++ b/src/llm/providers/openai-responses-shared.test.ts @@ -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>; + + expect(input.find((item) => item.type === "reasoning")).toMatchObject({ + type: "reasoning", + id: "rs_foundry_prior", + encrypted_content: "ciphertext", + summary: [], + }); + }); }); describe("processResponsesStream", () => {