Skip to content

Commit e38ea2d

Browse files
authored
fix(providers): inject top-level instructions for openai-responses wire (#134) (#160)
## Summary - Strict OpenAI-Responses gateways (sub2api-style routers) return 400 when `input[]` carries a `system`/`developer` role without a matching top-level `instructions` field. pi-ai's plain `openai-responses` wire emits the former but not the latter. - Mirror the `openai-codex-responses` wire's strict behavior via pi-ai's `onPayload` hook: set `params.instructions` from the aggregated systemPrompt, and 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. Fixes #134. ### Four principles - Compatibility: green — no schema or IPC change; only the wire payload for openai-responses is adjusted; other wires unchanged. - Upgradeability: green — the filter/inject is local to `complete()`; if pi-ai later emits `instructions` natively we can delete the hook in one place. - No bloat: green — ~20 lines, no new deps, reuses existing `toPiContext` output (no helper refactor). - Elegance: green — contract mirrors how `openai-codex-responses` already behaves upstream. ## Test plan - [x] `pnpm --filter @open-codesign/providers test -- --run` (10 files, 134 tests, incl. 3 new cases: payload mutation, no-op when systemPrompt empty, no-op for anthropic wire) - [x] `pnpm typecheck` (10/10 tasks successful) - [x] `pnpm lint` (biome clean) - [ ] Manual: call an openai-responses gateway that requires `instructions` and confirm 200 with system prompt honored Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent a245761 commit e38ea2d

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
@@ -304,3 +304,135 @@ describe('complete', () => {
304304
).rejects.toMatchObject({ code: 'ATTACHMENT_TOO_LARGE' });
305305
});
306306
});
307+
308+
describe('complete — openai-responses strict instructions', () => {
309+
it('injects top-level instructions and strips system/developer input items via onPayload', async () => {
310+
getModelMock.mockReturnValue({
311+
id: 'gpt-5.1',
312+
api: 'openai-responses',
313+
provider: 'openai',
314+
});
315+
316+
let capturedOnPayload:
317+
| ((payload: unknown) => unknown | Promise<unknown | undefined>)
318+
| undefined;
319+
320+
completeSimpleMock.mockImplementationOnce(async (_model, _context, opts) => {
321+
capturedOnPayload = opts.onPayload;
322+
return {
323+
role: 'assistant',
324+
content: [{ type: 'text', text: 'ok' }],
325+
api: 'openai-responses',
326+
provider: 'openai',
327+
model: 'gpt-5.1',
328+
usage: {
329+
input: 1,
330+
output: 1,
331+
cacheRead: 0,
332+
cacheWrite: 0,
333+
totalTokens: 2,
334+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
335+
},
336+
stopReason: 'stop',
337+
timestamp: Date.now(),
338+
};
339+
});
340+
341+
await complete(
342+
{ provider: 'openai', modelId: 'gpt-5.1' },
343+
[
344+
{ role: 'system', content: 'You are open-codesign.' },
345+
{ role: 'user', content: 'hi' },
346+
],
347+
{ apiKey: 'sk-test' },
348+
);
349+
350+
expect(capturedOnPayload).toBeDefined();
351+
352+
const params = {
353+
input: [
354+
{ role: 'system', content: 'ignored' },
355+
{ role: 'developer', content: 'ignored' },
356+
{ role: 'user', content: [{ type: 'input_text', text: 'hi' }] },
357+
],
358+
};
359+
const mutated = (await capturedOnPayload?.(params)) as {
360+
instructions?: string;
361+
input: Array<{ role: string }>;
362+
};
363+
364+
expect(mutated.instructions).toBe('You are open-codesign.');
365+
expect(mutated.input.map((entry) => entry.role)).toEqual(['user']);
366+
});
367+
368+
it('does not attach onPayload when systemPrompt is empty', async () => {
369+
getModelMock.mockReturnValue({
370+
id: 'gpt-5.1',
371+
api: 'openai-responses',
372+
provider: 'openai',
373+
});
374+
375+
completeSimpleMock.mockImplementationOnce(async (_model, _context, opts) => {
376+
expect(opts.onPayload).toBeUndefined();
377+
return {
378+
role: 'assistant',
379+
content: [{ type: 'text', text: 'ok' }],
380+
api: 'openai-responses',
381+
provider: 'openai',
382+
model: 'gpt-5.1',
383+
usage: {
384+
input: 1,
385+
output: 1,
386+
cacheRead: 0,
387+
cacheWrite: 0,
388+
totalTokens: 2,
389+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
390+
},
391+
stopReason: 'stop',
392+
timestamp: Date.now(),
393+
};
394+
});
395+
396+
await complete({ provider: 'openai', modelId: 'gpt-5.1' }, [{ role: 'user', content: 'hi' }], {
397+
apiKey: 'sk-test',
398+
});
399+
});
400+
401+
it('does not attach onPayload for anthropic-messages wire even with systemPrompt', async () => {
402+
getModelMock.mockReturnValue({
403+
id: 'claude-4.7-sonnet',
404+
api: 'anthropic-messages',
405+
provider: 'anthropic',
406+
});
407+
408+
completeSimpleMock.mockImplementationOnce(async (_model, _context, opts) => {
409+
expect(opts.onPayload).toBeUndefined();
410+
return {
411+
role: 'assistant',
412+
content: [{ type: 'text', text: 'ok' }],
413+
api: 'anthropic-messages',
414+
provider: 'anthropic',
415+
model: 'claude-4.7-sonnet',
416+
usage: {
417+
input: 1,
418+
output: 1,
419+
cacheRead: 0,
420+
cacheWrite: 0,
421+
totalTokens: 2,
422+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
423+
},
424+
stopReason: 'stop',
425+
timestamp: Date.now(),
426+
};
427+
});
428+
429+
await complete(
430+
{ provider: 'anthropic', modelId: 'claude-4.7-sonnet' },
431+
[
432+
{ role: 'system', content: 'You are open-codesign.' },
433+
{ role: 'user', content: 'hi' },
434+
],
435+
{ apiKey: 'sk-ant-test' },
436+
);
437+
});
438+
});

packages/providers/src/index.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ export async function complete(
233233
maxTokens?: number;
234234
reasoning?: ReasoningLevel;
235235
headers?: Record<string, string>;
236+
onPayload?: (payload: unknown) => unknown;
236237
},
237238
) => Promise<PiAssistantMessage>;
238239
};
@@ -251,13 +252,16 @@ export async function complete(
251252
}
252253
}
253254

255+
const piContext = toPiContext(messages, piModel, opts);
256+
254257
const piOpts: {
255258
apiKey: string;
256259
baseUrl?: string;
257260
signal?: AbortSignal;
258261
maxTokens?: number;
259262
reasoning?: ReasoningLevel;
260263
headers?: Record<string, string>;
264+
onPayload?: (payload: unknown) => unknown;
261265
} = {
262266
apiKey,
263267
};
@@ -267,6 +271,28 @@ export async function complete(
267271
if (opts.reasoning !== undefined) piOpts.reasoning = opts.reasoning;
268272
if (opts.httpHeaders !== undefined) piOpts.headers = { ...opts.httpHeaders };
269273

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

282308
validateCodexImageInputs(opts);
283-
const result = await pi.completeSimple(piModel, toPiContext(messages, piModel, opts), piOpts);
309+
const result = await pi.completeSimple(piModel, piContext, piOpts);
284310

285311
if (result.stopReason === 'error') {
286312
throw new CodesignError(

0 commit comments

Comments
 (0)