Skip to content

Commit cc1881a

Browse files
committed
refactor(providers): share payload patch helpers
1 parent 0273062 commit cc1881a

10 files changed

Lines changed: 446 additions & 241 deletions
Lines changed: 11 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,13 @@
11
import type { StreamFn } from "@mariozechner/pi-agent-core";
22
import { streamSimple } from "@mariozechner/pi-ai";
3-
import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream";
3+
import {
4+
applyAnthropicEphemeralCacheControlMarkers,
5+
buildCopilotDynamicHeaders,
6+
hasCopilotVisionInput,
7+
streamWithPayloadPatch,
8+
} from "openclaw/plugin-sdk/provider-stream";
49

510
type StreamContext = Parameters<StreamFn>[1];
6-
type StreamMessage = StreamContext["messages"][number];
7-
8-
function inferCopilotInitiator(messages: StreamContext["messages"]): "agent" | "user" {
9-
const last = messages[messages.length - 1];
10-
return last && last.role !== "user" ? "agent" : "user";
11-
}
12-
13-
function hasCopilotVisionInput(messages: StreamContext["messages"]): boolean {
14-
return messages.some((message: StreamMessage) => {
15-
if (message.role === "user" && Array.isArray(message.content)) {
16-
return message.content.some((item) => item.type === "image");
17-
}
18-
if (message.role === "toolResult" && Array.isArray(message.content)) {
19-
return message.content.some((item) => item.type === "image");
20-
}
21-
return false;
22-
});
23-
}
24-
25-
function buildCopilotDynamicHeaders(params: {
26-
messages: StreamContext["messages"];
27-
}): Record<string, string> {
28-
return {
29-
"X-Initiator": inferCopilotInitiator(params.messages),
30-
"Openai-Intent": "conversation-edits",
31-
...(hasCopilotVisionInput(params.messages) ? { "Copilot-Vision-Request": "true" } : {}),
32-
};
33-
}
34-
35-
function applyAnthropicPromptCacheMarkers(payloadObj: Record<string, unknown>): void {
36-
const messages = payloadObj.messages;
37-
if (!Array.isArray(messages)) {
38-
return;
39-
}
40-
41-
for (const message of messages as Array<{ role?: string; content?: unknown }>) {
42-
if (message.role === "system" || message.role === "developer") {
43-
if (typeof message.content === "string") {
44-
message.content = [
45-
{ type: "text", text: message.content, cache_control: { type: "ephemeral" } },
46-
];
47-
continue;
48-
}
49-
if (Array.isArray(message.content) && message.content.length > 0) {
50-
const last = message.content[message.content.length - 1];
51-
if (last && typeof last === "object") {
52-
const record = last as Record<string, unknown>;
53-
if (record.type !== "thinking" && record.type !== "redacted_thinking") {
54-
record.cache_control = { type: "ephemeral" };
55-
}
56-
}
57-
}
58-
continue;
59-
}
60-
61-
if (message.role === "assistant" && Array.isArray(message.content)) {
62-
for (const block of message.content) {
63-
if (!block || typeof block !== "object") {
64-
continue;
65-
}
66-
const record = block as Record<string, unknown>;
67-
if (record.type === "thinking" || record.type === "redacted_thinking") {
68-
delete record.cache_control;
69-
}
70-
}
71-
}
72-
}
73-
}
7411

7512
export function wrapCopilotAnthropicStream(baseStreamFn: StreamFn | undefined): StreamFn {
7613
const underlying = baseStreamFn ?? streamSimple;
@@ -86,11 +23,14 @@ export function wrapCopilotAnthropicStream(baseStreamFn: StreamFn | undefined):
8623
{
8724
...options,
8825
headers: {
89-
...buildCopilotDynamicHeaders({ messages: context.messages }),
26+
...buildCopilotDynamicHeaders({
27+
messages: context.messages as StreamContext["messages"],
28+
hasImages: hasCopilotVisionInput(context.messages as StreamContext["messages"]),
29+
}),
9030
...(options?.headers ?? {}),
9131
},
9232
},
93-
applyAnthropicPromptCacheMarkers,
33+
applyAnthropicEphemeralCacheControlMarkers,
9434
);
9535
};
9636
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { Model } from "@mariozechner/pi-ai";
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
applyOpenAIResponsesPayloadPolicy,
5+
resolveOpenAIResponsesPayloadPolicy,
6+
} from "./openai-responses-payload-policy.js";
7+
8+
describe("openai responses payload policy", () => {
9+
it("forces store for native OpenAI responses payloads but keeps disable mode for transport defaults", () => {
10+
const model = {
11+
id: "gpt-5.4",
12+
name: "GPT-5.4",
13+
api: "openai-responses",
14+
provider: "openai",
15+
baseUrl: "https://api.openai.com/v1",
16+
reasoning: true,
17+
input: ["text"],
18+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
19+
contextWindow: 200000,
20+
maxTokens: 8192,
21+
} satisfies Model<"openai-responses">;
22+
23+
expect(
24+
resolveOpenAIResponsesPayloadPolicy(model, { storeMode: "provider-policy" }),
25+
).toMatchObject({
26+
explicitStore: true,
27+
allowsServiceTier: true,
28+
});
29+
expect(resolveOpenAIResponsesPayloadPolicy(model, { storeMode: "disable" })).toMatchObject({
30+
explicitStore: false,
31+
allowsServiceTier: true,
32+
});
33+
});
34+
35+
it("strips store and prompt cache for proxy-like responses routes when requested", () => {
36+
const policy = resolveOpenAIResponsesPayloadPolicy(
37+
{
38+
api: "openai-responses",
39+
provider: "openai",
40+
baseUrl: "https://proxy.example.com/v1",
41+
compat: { supportsStore: false },
42+
},
43+
{
44+
enablePromptCacheStripping: true,
45+
storeMode: "provider-policy",
46+
},
47+
);
48+
const payload = {
49+
store: false,
50+
prompt_cache_key: "session-123",
51+
prompt_cache_retention: "24h",
52+
} satisfies Record<string, unknown>;
53+
54+
applyOpenAIResponsesPayloadPolicy(payload, policy);
55+
56+
expect(payload).not.toHaveProperty("store");
57+
expect(payload).not.toHaveProperty("prompt_cache_key");
58+
expect(payload).not.toHaveProperty("prompt_cache_retention");
59+
});
60+
61+
it("strips disabled reasoning payloads through the shared helper", () => {
62+
const payload = {
63+
reasoning: {
64+
effort: "none",
65+
},
66+
} satisfies Record<string, unknown>;
67+
68+
applyOpenAIResponsesPayloadPolicy(
69+
payload,
70+
resolveOpenAIResponsesPayloadPolicy(
71+
{
72+
api: "openai-responses",
73+
provider: "openai",
74+
baseUrl: "https://api.openai.com/v1",
75+
},
76+
{ storeMode: "disable" },
77+
),
78+
);
79+
80+
expect(payload).not.toHaveProperty("reasoning");
81+
});
82+
});
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { resolveProviderRequestPolicyConfig } from "./provider-request-config.js";
2+
3+
type OpenAIResponsesPayloadModel = {
4+
api?: unknown;
5+
baseUrl?: unknown;
6+
provider?: unknown;
7+
contextWindow?: unknown;
8+
compat?: { supportsStore?: boolean };
9+
};
10+
11+
type OpenAIResponsesPayloadPolicyOptions = {
12+
extraParams?: Record<string, unknown>;
13+
storeMode?: "provider-policy" | "disable" | "preserve";
14+
enablePromptCacheStripping?: boolean;
15+
enableServerCompaction?: boolean;
16+
};
17+
18+
export type OpenAIResponsesPayloadPolicy = {
19+
allowsServiceTier: boolean;
20+
compactThreshold: number;
21+
explicitStore: boolean | undefined;
22+
shouldStripDisabledReasoningPayload: boolean;
23+
shouldStripPromptCache: boolean;
24+
shouldStripStore: boolean;
25+
useServerCompaction: boolean;
26+
};
27+
28+
const OPENAI_RESPONSES_APIS = new Set([
29+
"openai-responses",
30+
"azure-openai-responses",
31+
"openai-codex-responses",
32+
]);
33+
34+
function parsePositiveInteger(value: unknown): number | undefined {
35+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
36+
return Math.floor(value);
37+
}
38+
if (typeof value === "string") {
39+
const parsed = Number.parseInt(value, 10);
40+
if (Number.isFinite(parsed) && parsed > 0) {
41+
return parsed;
42+
}
43+
}
44+
return undefined;
45+
}
46+
47+
function resolveOpenAIResponsesCompactThreshold(model: { contextWindow?: unknown }): number {
48+
const contextWindow = parsePositiveInteger(model.contextWindow);
49+
if (contextWindow) {
50+
return Math.max(1_000, Math.floor(contextWindow * 0.7));
51+
}
52+
return 80_000;
53+
}
54+
55+
function shouldEnableOpenAIResponsesServerCompaction(
56+
explicitStore: boolean | undefined,
57+
provider: unknown,
58+
extraParams: Record<string, unknown> | undefined,
59+
): boolean {
60+
const configured = extraParams?.responsesServerCompaction;
61+
if (configured === false) {
62+
return false;
63+
}
64+
if (explicitStore !== true) {
65+
return false;
66+
}
67+
if (configured === true) {
68+
return true;
69+
}
70+
return provider === "openai";
71+
}
72+
73+
function stripDisabledOpenAIReasoningPayload(payloadObj: Record<string, unknown>): void {
74+
const reasoning = payloadObj.reasoning;
75+
if (reasoning === "none") {
76+
delete payloadObj.reasoning;
77+
return;
78+
}
79+
if (!reasoning || typeof reasoning !== "object" || Array.isArray(reasoning)) {
80+
return;
81+
}
82+
83+
// GPT-5 models reject `reasoning.effort: "none"`. Treat the disabled effort
84+
// as "reasoning omitted" instead of forwarding an unsupported value.
85+
const reasoningObj = reasoning as Record<string, unknown>;
86+
if (reasoningObj.effort === "none") {
87+
delete payloadObj.reasoning;
88+
}
89+
}
90+
91+
export function resolveOpenAIResponsesPayloadPolicy(
92+
model: OpenAIResponsesPayloadModel,
93+
options: OpenAIResponsesPayloadPolicyOptions = {},
94+
): OpenAIResponsesPayloadPolicy {
95+
const capabilities = resolveProviderRequestPolicyConfig({
96+
provider: typeof model.provider === "string" ? model.provider : undefined,
97+
api: typeof model.api === "string" ? model.api : undefined,
98+
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
99+
compat: model.compat,
100+
capability: "llm",
101+
transport: "stream",
102+
}).capabilities;
103+
const storeMode = options.storeMode ?? "provider-policy";
104+
const explicitStore =
105+
storeMode === "preserve"
106+
? undefined
107+
: storeMode === "disable"
108+
? capabilities.supportsResponsesStoreField
109+
? false
110+
: undefined
111+
: capabilities.allowsResponsesStore
112+
? true
113+
: undefined;
114+
const isResponsesApi = typeof model.api === "string" && OPENAI_RESPONSES_APIS.has(model.api);
115+
116+
return {
117+
allowsServiceTier: capabilities.allowsOpenAIServiceTier,
118+
compactThreshold:
119+
parsePositiveInteger(options.extraParams?.responsesCompactThreshold) ??
120+
resolveOpenAIResponsesCompactThreshold(model),
121+
explicitStore,
122+
shouldStripDisabledReasoningPayload: capabilities.supportsOpenAIReasoningCompatPayload,
123+
shouldStripPromptCache:
124+
options.enablePromptCacheStripping === true && capabilities.shouldStripResponsesPromptCache,
125+
shouldStripStore:
126+
explicitStore !== true && model.compat?.supportsStore === false && isResponsesApi,
127+
useServerCompaction:
128+
options.enableServerCompaction === true &&
129+
shouldEnableOpenAIResponsesServerCompaction(
130+
explicitStore,
131+
model.provider,
132+
options.extraParams,
133+
),
134+
};
135+
}
136+
137+
export function applyOpenAIResponsesPayloadPolicy(
138+
payloadObj: Record<string, unknown>,
139+
policy: OpenAIResponsesPayloadPolicy,
140+
): void {
141+
if (policy.explicitStore !== undefined) {
142+
payloadObj.store = policy.explicitStore;
143+
}
144+
if (policy.shouldStripStore) {
145+
delete payloadObj.store;
146+
}
147+
if (policy.shouldStripPromptCache) {
148+
delete payloadObj.prompt_cache_key;
149+
delete payloadObj.prompt_cache_retention;
150+
}
151+
if (policy.useServerCompaction && payloadObj.context_management === undefined) {
152+
payloadObj.context_management = [
153+
{
154+
type: "compaction",
155+
compact_threshold: policy.compactThreshold,
156+
},
157+
];
158+
}
159+
if (policy.shouldStripDisabledReasoningPayload) {
160+
stripDisabledOpenAIReasoningPayload(payloadObj);
161+
}
162+
}

0 commit comments

Comments
 (0)