Skip to content

Commit 3fdebd0

Browse files
committed
fix(providers): inject top-level instructions for openai-responses wire (#134)
Strict OpenAI-Responses gateways reject requests that carry a system/developer role inside input[] without a matching top-level `instructions` field. pi-ai's plain `openai-responses` wire emits the former but not the latter, so sub2api-style routers return 400. Mirror the codex wire's strict behavior via the `onPayload` hook: - set params.instructions from the aggregated systemPrompt - filter out role === "system" | "developer" entries from input[] Only wired when model.api === "openai-responses" AND systemPrompt is non-empty. Other wires (anthropic-messages, openai-completions, openai-codex-responses) are untouched. Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent 5727a09 commit 3fdebd0

2 files changed

Lines changed: 159 additions & 1 deletion

File tree

packages/providers/src/index.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,135 @@ describe('complete', () => {
198198
expect(result.content).toBe('ok');
199199
});
200200
});
201+
202+
describe('complete — openai-responses strict instructions', () => {
203+
it('injects top-level instructions and strips system/developer input items via onPayload', async () => {
204+
getModelMock.mockReturnValue({
205+
id: 'gpt-5.1',
206+
api: 'openai-responses',
207+
provider: 'openai',
208+
});
209+
210+
let capturedOnPayload:
211+
| ((payload: unknown) => unknown | Promise<unknown | undefined>)
212+
| undefined;
213+
214+
completeSimpleMock.mockImplementationOnce(async (_model, _context, opts) => {
215+
capturedOnPayload = opts.onPayload;
216+
return {
217+
role: 'assistant',
218+
content: [{ type: 'text', text: 'ok' }],
219+
api: 'openai-responses',
220+
provider: 'openai',
221+
model: 'gpt-5.1',
222+
usage: {
223+
input: 1,
224+
output: 1,
225+
cacheRead: 0,
226+
cacheWrite: 0,
227+
totalTokens: 2,
228+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
229+
},
230+
stopReason: 'stop',
231+
timestamp: Date.now(),
232+
};
233+
});
234+
235+
await complete(
236+
{ provider: 'openai', modelId: 'gpt-5.1' },
237+
[
238+
{ role: 'system', content: 'You are open-codesign.' },
239+
{ role: 'user', content: 'hi' },
240+
],
241+
{ apiKey: 'sk-test' },
242+
);
243+
244+
expect(capturedOnPayload).toBeDefined();
245+
246+
const params = {
247+
input: [
248+
{ role: 'system', content: 'ignored' },
249+
{ role: 'developer', content: 'ignored' },
250+
{ role: 'user', content: [{ type: 'input_text', text: 'hi' }] },
251+
],
252+
};
253+
const mutated = (await capturedOnPayload?.(params)) as {
254+
instructions?: string;
255+
input: Array<{ role: string }>;
256+
};
257+
258+
expect(mutated.instructions).toBe('You are open-codesign.');
259+
expect(mutated.input.map((entry) => entry.role)).toEqual(['user']);
260+
});
261+
262+
it('does not attach onPayload when systemPrompt is empty', async () => {
263+
getModelMock.mockReturnValue({
264+
id: 'gpt-5.1',
265+
api: 'openai-responses',
266+
provider: 'openai',
267+
});
268+
269+
completeSimpleMock.mockImplementationOnce(async (_model, _context, opts) => {
270+
expect(opts.onPayload).toBeUndefined();
271+
return {
272+
role: 'assistant',
273+
content: [{ type: 'text', text: 'ok' }],
274+
api: 'openai-responses',
275+
provider: 'openai',
276+
model: 'gpt-5.1',
277+
usage: {
278+
input: 1,
279+
output: 1,
280+
cacheRead: 0,
281+
cacheWrite: 0,
282+
totalTokens: 2,
283+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
284+
},
285+
stopReason: 'stop',
286+
timestamp: Date.now(),
287+
};
288+
});
289+
290+
await complete({ provider: 'openai', modelId: 'gpt-5.1' }, [{ role: 'user', content: 'hi' }], {
291+
apiKey: 'sk-test',
292+
});
293+
});
294+
295+
it('does not attach onPayload for anthropic-messages wire even with systemPrompt', async () => {
296+
getModelMock.mockReturnValue({
297+
id: 'claude-4.7-sonnet',
298+
api: 'anthropic-messages',
299+
provider: 'anthropic',
300+
});
301+
302+
completeSimpleMock.mockImplementationOnce(async (_model, _context, opts) => {
303+
expect(opts.onPayload).toBeUndefined();
304+
return {
305+
role: 'assistant',
306+
content: [{ type: 'text', text: 'ok' }],
307+
api: 'anthropic-messages',
308+
provider: 'anthropic',
309+
model: 'claude-4.7-sonnet',
310+
usage: {
311+
input: 1,
312+
output: 1,
313+
cacheRead: 0,
314+
cacheWrite: 0,
315+
totalTokens: 2,
316+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
317+
},
318+
stopReason: 'stop',
319+
timestamp: Date.now(),
320+
};
321+
});
322+
323+
await complete(
324+
{ provider: 'anthropic', modelId: 'claude-4.7-sonnet' },
325+
[
326+
{ role: 'system', content: 'You are open-codesign.' },
327+
{ role: 'user', content: 'hi' },
328+
],
329+
{ apiKey: 'sk-ant-test' },
330+
);
331+
});
332+
});

packages/providers/src/index.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ export async function complete(
223223
maxTokens?: number;
224224
reasoning?: ReasoningLevel;
225225
headers?: Record<string, string>;
226+
onPayload?: (payload: unknown) => unknown;
226227
},
227228
) => Promise<PiAssistantMessage>;
228229
};
@@ -241,13 +242,16 @@ export async function complete(
241242
}
242243
}
243244

245+
const piContext = toPiContext(messages, piModel);
246+
244247
const piOpts: {
245248
apiKey: string;
246249
baseUrl?: string;
247250
signal?: AbortSignal;
248251
maxTokens?: number;
249252
reasoning?: ReasoningLevel;
250253
headers?: Record<string, string>;
254+
onPayload?: (payload: unknown) => unknown;
251255
} = {
252256
apiKey,
253257
};
@@ -257,6 +261,28 @@ export async function complete(
257261
if (opts.reasoning !== undefined) piOpts.reasoning = opts.reasoning;
258262
if (opts.httpHeaders !== undefined) piOpts.headers = { ...opts.httpHeaders };
259263

264+
// Strict OpenAI-Responses gateways (e.g. sub2api-style routers) 400 when
265+
// they see BOTH a system/developer item in `input[]` AND no top-level
266+
// `instructions`. pi-ai's plain `openai-responses` wire injects the former
267+
// but not the latter, so we mirror the codex wire's strict behavior here:
268+
// set `instructions` and strip system/developer entries from `input[]`.
269+
if (piModel.api === 'openai-responses' && piContext.systemPrompt) {
270+
const systemPrompt = piContext.systemPrompt;
271+
piOpts.onPayload = (payload) => {
272+
const params = payload as {
273+
instructions?: string;
274+
input?: Array<{ role?: string }>;
275+
};
276+
params.instructions = systemPrompt;
277+
if (Array.isArray(params.input)) {
278+
params.input = params.input.filter(
279+
(entry) => entry.role !== 'system' && entry.role !== 'developer',
280+
);
281+
}
282+
return params;
283+
};
284+
}
285+
260286
// sub2api / claude2api gateways 403 requests without claude-cli identity
261287
// headers. pi-ai only injects those on OAuth tokens — paste a
262288
// sub2api-issued key and you hit the plain API-key branch. Force the
@@ -269,7 +295,7 @@ export async function complete(
269295
piOpts.headers = { ...claudeCodeIdentityHeaders(), ...(piOpts.headers ?? {}) };
270296
}
271297

272-
const result = await pi.completeSimple(piModel, toPiContext(messages, piModel), piOpts);
298+
const result = await pi.completeSimple(piModel, piContext, piOpts);
273299

274300
if (result.stopReason === 'error') {
275301
throw new CodesignError(

0 commit comments

Comments
 (0)