Skip to content

Commit 8910e54

Browse files
kargnaslidge-jun
andauthored
fix(openai-responses): strip hosted image_generation that conflicts with declared image_gen tool (#90)
* release: v2.7.5 * fix(openai-responses): strip hosted image_generation that conflicts with declared image_gen tool Codex declares a client tool for the imagegen app skill (function `image_gen.imagegen` or an `image_gen` namespace tool) AND attaches the hosted `image_generation` tool in the same request. The ChatGPT backend ("forward" mode) tolerates the pair, but a platform `/v1/responses` endpoint rejects it with HTTP 400: `Invalid Value: 'tools'. Function 'image_gen.imagegen' conflicts with a hosted tool in the same request.` Routed catalog entries drop `tool_mode` (native slugs run `code_mode_only`, embedding tools in the exec doc), so the dot-named skill function is sent as a real tools[] entry and collides with the hosted tool on keyed platforms. Add `stripConflictingHostedTools`, applied only on the API-key path, to drop the hosted entry when a conflicting declared tool is present. This mirrors the existing `UNSUPPORTED_HOSTED_TOOLS` precedent. Forward mode is left untouched so native imagegen keeps working. --------- Co-authored-by: bitkyc08-arch <bitkyc08@gmail.com>
1 parent cec073b commit 8910e54

2 files changed

Lines changed: 146 additions & 0 deletions

File tree

src/adapters/openai-responses.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,46 @@ function stripPreviousResponseId(body: unknown, strip: boolean): unknown {
175175
return rest;
176176
}
177177

178+
/**
179+
* Hosted tool types whose server-side function names collide with the client tools Codex
180+
* declares for the matching app skill. Codex sends BOTH (e.g. hosted `image_generation` plus a
181+
* declared `image_gen.imagegen` function/namespace tool for the imagegen skill). The ChatGPT
182+
* backend tolerates the pair, but the platform `/v1/responses` rejects it:
183+
* `Invalid Value: 'tools'. Function 'image_gen.imagegen' conflicts with a hosted tool in the
184+
* same request.` Keyed hosted-type → conflicting client tool-name prefix; the hosted entry is
185+
* dropped (the declared tool wins — Codex executes the skill client-side either way).
186+
*/
187+
const HOSTED_TOOL_NAME_CONFLICTS: ReadonlyArray<{ hostedType: string; namePrefix: string }> = [
188+
{ hostedType: "image_generation", namePrefix: "image_gen" },
189+
];
190+
191+
/**
192+
* Drop hosted tools whose names collide with declared function/namespace tools (see
193+
* HOSTED_TOOL_NAME_CONFLICTS). Only applies on the API-key platform path: the ChatGPT backend
194+
* ("forward" mode) accepts the pair, and stripping there would disable native imagegen. No-op
195+
* (returns the original reference) when nothing matches.
196+
*/
197+
function stripConflictingHostedTools(body: unknown): unknown {
198+
if (!isPlainObject(body) || !Array.isArray(body.tools)) return body;
199+
const allTools = body.tools;
200+
201+
const conflicting = HOSTED_TOOL_NAME_CONFLICTS.filter(c =>
202+
allTools.some(t => {
203+
if (!isPlainObject(t) || typeof t.name !== "string") return false;
204+
if (t.type === "namespace") return t.name === c.namePrefix;
205+
return t.name === c.namePrefix || t.name.startsWith(`${c.namePrefix}.`);
206+
}),
207+
);
208+
if (conflicting.length === 0) return body;
209+
210+
const tools = allTools.filter(t => {
211+
const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined;
212+
if (!type) return true;
213+
return !conflicting.some(c => c.hostedType === type);
214+
});
215+
return tools.length === allTools.length ? body : { ...body, tools };
216+
}
217+
178218
/**
179219
* Remove hosted tool entries the target native slug rejects, so the OAuth-passthrough body never
180220
* carries a tool the upstream model 400s on. No-op (returns the original reference) when nothing
@@ -237,6 +277,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
237277
forward || parsed._previousResponseInputExpanded === true,
238278
);
239279
if (forward) outBody = repairOrphanedInputItems(outBody, unexpandedMiss);
280+
else outBody = stripConflictingHostedTools(outBody);
240281
return {
241282
url,
242283
method: "POST",

tests/openai-responses-passthrough.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,108 @@ describe("OpenAI Responses passthrough sanitization", () => {
265265
expect(body.input[0]).toMatchObject({ type: "reasoning", id: "rs_1" });
266266
});
267267
});
268+
269+
describe("OpenAI Responses hosted-tool name conflicts", () => {
270+
const keyedProvider = {
271+
adapter: "openai-responses",
272+
baseUrl: "https://api.openai.example/v1",
273+
authMode: "key" as const,
274+
apiKey: "sk-test",
275+
};
276+
const meta = { headers: new Headers({ authorization: "Bearer token" }) };
277+
278+
test("keyed platform strips hosted image_generation that collides with a declared image_gen.imagegen tool", () => {
279+
const adapter = createResponsesPassthroughAdapter(keyedProvider);
280+
const request = adapter.buildRequest({
281+
modelId: "gpt-5.6-sol",
282+
context: { messages: [] },
283+
stream: true,
284+
options: {},
285+
_rawBody: {
286+
model: "gpt-5.6-sol",
287+
input: [],
288+
tools: [
289+
{ type: "function", name: "image_gen.imagegen", parameters: {} },
290+
{ type: "image_generation" },
291+
{ type: "web_search" },
292+
],
293+
},
294+
}, meta);
295+
const body = JSON.parse(request.body) as { tools: { type: string; name?: string }[] };
296+
297+
// Hosted image_generation dropped; the declared client tool wins and unrelated hosted tools stay.
298+
expect(body.tools).toHaveLength(2);
299+
expect(body.tools.some(t => t.type === "image_generation")).toBe(false);
300+
expect(body.tools.some(t => t.type === "function" && t.name === "image_gen.imagegen")).toBe(true);
301+
expect(body.tools.some(t => t.type === "web_search")).toBe(true);
302+
});
303+
304+
test("keyed platform strips hosted image_generation when the skill is declared as an image_gen namespace tool", () => {
305+
const adapter = createResponsesPassthroughAdapter(keyedProvider);
306+
const request = adapter.buildRequest({
307+
modelId: "gpt-5.6-sol",
308+
context: { messages: [] },
309+
stream: true,
310+
options: {},
311+
_rawBody: {
312+
model: "gpt-5.6-sol",
313+
input: [],
314+
tools: [
315+
{ type: "namespace", name: "image_gen" },
316+
{ type: "image_generation" },
317+
],
318+
},
319+
}, meta);
320+
const body = JSON.parse(request.body) as { tools: { type: string; name?: string }[] };
321+
322+
expect(body.tools).toHaveLength(1);
323+
expect(body.tools[0]).toMatchObject({ type: "namespace", name: "image_gen" });
324+
expect(body.tools.some(t => t.type === "image_generation")).toBe(false);
325+
});
326+
327+
test("keyed platform keeps hosted image_generation when no conflicting tool is declared", () => {
328+
const adapter = createResponsesPassthroughAdapter(keyedProvider);
329+
const request = adapter.buildRequest({
330+
modelId: "gpt-5.6-sol",
331+
context: { messages: [] },
332+
stream: true,
333+
options: {},
334+
_rawBody: {
335+
model: "gpt-5.6-sol",
336+
input: [],
337+
tools: [
338+
{ type: "function", name: "shell", parameters: {} },
339+
{ type: "image_generation" },
340+
],
341+
},
342+
}, meta);
343+
const body = JSON.parse(request.body) as { tools: { type: string }[] };
344+
345+
expect(body.tools).toHaveLength(2);
346+
expect(body.tools.some(t => t.type === "image_generation")).toBe(true);
347+
});
348+
349+
test("forward backend preserves the image_generation + image_gen.imagegen pair", () => {
350+
// The ChatGPT backend tolerates the pair; stripping there would disable native imagegen.
351+
const adapter = createResponsesPassthroughAdapter(provider);
352+
const request = adapter.buildRequest({
353+
modelId: "gpt-5.5",
354+
context: { messages: [] },
355+
stream: true,
356+
options: {},
357+
_rawBody: {
358+
model: "gpt-5.5",
359+
input: [],
360+
tools: [
361+
{ type: "function", name: "image_gen.imagegen", parameters: {} },
362+
{ type: "image_generation" },
363+
],
364+
},
365+
}, meta);
366+
const body = JSON.parse(request.body) as { tools: { type: string; name?: string }[] };
367+
368+
expect(body.tools).toHaveLength(2);
369+
expect(body.tools.some(t => t.type === "image_generation")).toBe(true);
370+
expect(body.tools.some(t => t.type === "function" && t.name === "image_gen.imagegen")).toBe(true);
371+
});
372+
});

0 commit comments

Comments
 (0)