fix(computer): gate native Responses transport

This commit is contained in:
usr-bin-roygbiv
2026-07-24 05:00:39 +00:00
parent 40937af750
commit c12c282aa5
10 changed files with 290 additions and 37 deletions
+5 -3
View File
@@ -74,11 +74,13 @@ Models with native OpenAI GA computer-use support receive the wire declaration `
OMP marks a model natively capable when either:
- its catalog metadata explicitly sets `supportsComputerUse: true`, or
- it uses `openai-responses`, `openai-codex-responses`, or `azure-openai-responses` and resolves to an OpenAI/OpenAI Codex or Azure model ID matching `gpt-5.4` or later in the `gpt-5.x` family.
- it uses a direct OpenAI Responses or Azure OpenAI Responses endpoint and resolves to a model ID matching `gpt-5.4` or later in the `gpt-5.x` family.
An explicit `supportsComputerUse: false` disables automatic derivation and routes the model through the function-tool form.
Codex subscription endpoints and custom or proxy routes do not infer native support from the model ID. They receive the regular `computer` function tool unless catalog metadata explicitly opts into the GA contract. An explicit `supportsComputerUse: false` also disables automatic derivation.
Natively capable OpenAI Responses routes may receive a forced `{ "type": "computer" }` choice. Function-tool fallback forcing is provider-specific: OpenAI/Ollama use a named function, Anthropic/Bedrock use a named tool, Google uses required-tool mode, and adapters without a forcing form keep provider-default selection. When native computer history is replayed to a non-native OpenAI Responses-family model, that adapter converts prior `computer_call` and `computer_call_output` items into stable text notes rather than sending invalid native items. Other provider adapters serialize the generic call and result through their ordinary tool format.
Natively capable OpenAI Responses routes may receive a forced `{ "type": "computer" }` choice. Function-tool fallback forcing is provider-specific: OpenAI/Ollama use a named function, Anthropic/Bedrock use a named tool, Google uses required-tool mode, and adapters without a forcing form keep provider-default selection. Responses Lite moves tools into `additional_tools`; for an explicitly forced computer declaration it sends only that declaration and uses `tool_choice: "required"`, preserving both selection and forcing without an invalid object choice that refers to removed top-level tools.
When a session switches from a native-capable API route to a subscription or proxy route, prior native computer history is converted to a representation the target accepts. Codex subscription requests replay it as named `computer` function calls and results, then declare the next computer call as the same named function. Other non-native OpenAI Responses-family targets may use stable assistant text notes; other provider adapters use their ordinary tool format.
If the tool never appears:
+2 -2
View File
@@ -26,8 +26,8 @@ User setup, safety guidance, platform permissions, and verified limitations: [Na
- Concurrency: `exclusive`.
- Native descriptor: `{ type: "computer" }`.
- Providers serialize the native descriptor only when `model.supportsComputerUse === true`; every other function-calling model receives `computer` as a regular function tool with the typed action schema below.
- Automatic capability derivation covers GA `gpt-5.4+` IDs on OpenAI Responses, OpenAI Codex Responses, and Azure OpenAI Responses; explicit model metadata overrides derivation.
- When OpenAI Responses-family history is replayed to a model without native support, native call/output items become stable assistant text notes. Other provider adapters serialize the generic call/result in their ordinary tool format.
- Automatic capability derivation covers GA `gpt-5.4+` IDs only on direct OpenAI Responses and Azure OpenAI Responses endpoints. Codex subscription and custom or proxy routes fall back unless explicit model metadata opts in.
- When a session switches from a native-capable API route to a non-native route, native history is adapted instead of being replayed as invalid GA items. Codex subscription requests use named function calls/results and the next declaration is a named function; other OpenAI Responses-family targets may use stable assistant text notes. Responses Lite preserves an explicit computer choice by placing only the selected declaration in `additional_tools` and using `tool_choice: "required"`. Other provider adapters use their ordinary tool format.
Unlike `browser`, `computer` operates the entire visible host session. It can act in IDEs, terminals, native applications, browser windows, and system dialogs, but has no structured application/DOM inspection.
@@ -1146,7 +1146,8 @@ export function normalizeCodexToolChoice(
};
if (choice.type === "computer") {
const computer = tools.find(tool => tool.native?.type === "computer");
return computer ? { type: "function", name: computer.name } : undefined;
if (!computer) return undefined;
return model?.supportsComputerUse === true ? { type: "computer" } : { type: "function", name: computer.name };
}
if (choice.type === "function") {
if ("function" in choice && choice.function?.name) {
@@ -4135,10 +4136,10 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
| undefined;
if (historyItems) {
const redactedHistoryItems = redactSensitiveInObject(historyItems).result as Array<ResponseInput[number]>;
const replayItems = unrollCodexComputerItems(
redactedHistoryItems,
model.compat.supportsImageDetailOriginal,
);
const replayItems =
model.supportsComputerUse === true
? redactedHistoryItems
: unrollCodexComputerItems(redactedHistoryItems, model.compat.supportsImageDetailOriginal);
for (const item of replayItems) {
if (item.type === "custom_tool_call") {
customCallIds.add(item.call_id);
@@ -4173,10 +4174,10 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
if (historyItems) {
const sanitizedHistoryItems = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(historyItems);
if (sanitizedHistoryItems) {
const replayItems = unrollCodexComputerItems(
sanitizedHistoryItems,
model.compat.supportsImageDetailOriginal,
);
const replayItems =
model.supportsComputerUse === true
? sanitizedHistoryItems
: unrollCodexComputerItems(sanitizedHistoryItems, model.compat.supportsImageDetailOriginal);
for (const item of replayItems) {
if (item.type === "custom_tool_call") {
customCallIds.add(item.call_id);
@@ -4198,7 +4199,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
}
const convertedOutputItems = convertResponsesAssistantMessage(
unrollCodexComputerAssistantMessage(msg as AssistantMessage),
model.supportsComputerUse === true ? assistantMsg : unrollCodexComputerAssistantMessage(assistantMsg),
model,
msgIndex,
knownCallIds,
@@ -4220,7 +4221,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
if (msg.role === "toolResult") {
appendResponsesToolResultMessages(
messages,
unrollCodexComputerToolResult(msg),
model.supportsComputerUse === true ? msg : unrollCodexComputerToolResult(msg),
model,
false,
model.compat.supportsImageDetailOriginal,
@@ -4255,6 +4256,7 @@ function normalizeInputMessageContent(
export { convertMessages as convertCodexResponsesMessages };
type CodexToolPayload =
| { type: "computer"; name?: never }
| {
type: "function";
name: string;
@@ -4277,8 +4279,12 @@ export function convertOpenAICodexResponsesTools(
const allowFreeform = model.applyPatchToolType === "freeform";
const payloads: CodexToolPayload[] = [];
for (const tool of tools) {
// The ChatGPT Codex endpoints reject the native `{ type: "computer" }`
// shape, so both standard and Lite transports expose it as a function.
// Subscription models default to the function fallback. Explicit metadata
// remains authoritative for future Codex endpoints that implement GA computer use.
if (tool.native?.type === "computer" && model.supportsComputerUse === true) {
payloads.push({ type: "computer" });
continue;
}
if (allowFreeform && tool.customFormat) {
payloads.push({
type: "custom",
@@ -312,18 +312,38 @@ export interface CodexLiteShapedBody {
* rewrite removes top-level `tools`, a forced hosted-tool choice (e.g.
* `{ type: "web_search" }`) would leave the backend unable to validate the
* choice against a tools collection and it rejects the request with HTTP 400
* (#5771). Such choices must fall back to `"auto"`; explicit string constraints
* such as `"none"` and `"required"` remain valid. Shared by normal turns and
* both remote-compaction paths — codex-rs routes `/responses/compact` through
* the same builder.
* (#5771). Native computer and named-function choices preserve exact forcing
* by isolating the selected declaration and using `"required"`; other hosted
* choices fall back to `"auto"`. Explicit string constraints such as `"none"`
* and `"required"` remain valid. Shared by normal turns and both remote-compaction
* paths — codex-rs routes `/responses/compact` through the same builder.
*/
export function applyCodexResponsesLiteShape(body: CodexLiteShapedBody): void {
const input = Array.isArray(body.input) ? body.input : [];
stripImageDetails(input);
body.parallel_tool_calls = false;
const prefix: InputItem[] = [
{ type: "additional_tools", role: "developer", tools: Array.isArray(body.tools) ? body.tools : [] },
];
const declaredTools = Array.isArray(body.tools) ? body.tools : [];
let additionalTools = declaredTools;
if (body.tool_choice && typeof body.tool_choice === "object" && "type" in body.tool_choice) {
const choice = body.tool_choice;
const selected = declaredTools.find(tool => {
if (tool === null || typeof tool !== "object" || !("type" in tool)) return false;
if (choice.type === "computer") return tool.type === "computer";
return (
choice.type === "function" &&
tool.type === "function" &&
"name" in choice &&
typeof choice.name === "string" &&
"name" in tool &&
tool.name === choice.name
);
});
if (selected) {
additionalTools = [selected];
body.tool_choice = "required";
}
}
const prefix: InputItem[] = [{ type: "additional_tools", role: "developer", tools: additionalTools }];
if (typeof body.instructions === "string" && body.instructions.length > 0) {
prefix.push({
type: "message",
@@ -1196,10 +1196,15 @@ export function buildParams(
const emittedNames = new Set(
params.tools.map(t => (t as { name?: string }).name).filter((n): n is string => n !== undefined),
);
const emittedComputer = params.tools.some(tool => tool.type === "computer");
const survivingTools =
params.tools.length === context.tools.length
? context.tools
: context.tools.filter(t => emittedNames.has(t.customWireName ?? t.name));
: context.tools.filter(
t =>
emittedNames.has(t.customWireName ?? t.name) ||
(t.native?.type === "computer" && emittedComputer),
);
const toolChoice = mapOpenAIResponsesToolChoiceForTools(options.toolChoice, survivingTools, model);
if (toolChoice !== undefined && params.tools.length > 0) {
params.tool_choice = toolChoice;
@@ -365,6 +365,22 @@ describe("openai-codex Responses Lite input shaping", () => {
expect(disabled.tools).toBeUndefined();
});
it.each(["gpt-5.3-codex-spark", "gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"])(
"preserves a forced computer function through Lite for %s",
async modelId => {
const model = createCodexModel(modelId);
const computer = { type: "function", name: "computer", parameters: { type: "object" } };
const other = { type: "function", name: "read", parameters: { type: "object" } };
const body = await transformRequestBody(
{ model: model.id, tools: [computer, other], tool_choice: { type: "function", name: "computer" } },
model,
{ responsesLite: true },
);
expect(body.input?.[0]).toEqual({ type: "additional_tools", role: "developer", tools: [computer] });
expect(body.tool_choice).toBe("required");
},
);
it("moves instructions and tools into input items under lite", async () => {
const model = createCodexModel("gpt-5.6-terra");
const tools = [{ type: "function", name: "shot", parameters: { type: "object" } }];
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import {
buildTransformedCodexRequestBody,
convertCodexResponsesMessages,
convertOpenAICodexResponsesTools,
normalizeCodexToolChoice,
@@ -16,7 +17,7 @@ import {
convertResponsesAssistantMessage,
processResponsesStream,
} from "@oh-my-pi/pi-ai/providers/openai-shared";
import type { AssistantMessage, Model, ModelSpec, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types";
import type { AssistantMessage, Context, Model, ModelSpec, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types";
import { sanitizeOpenAIResponsesHistoryItemsForReplay } from "@oh-my-pi/pi-ai/utils";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { type } from "arktype";
@@ -126,6 +127,66 @@ describe("OpenAI GA computer contract", () => {
});
});
test("uses the function fallback for every tested subscription model in regular and Lite requests", async () => {
const otherTool: Tool = { name: "read", description: "read", parameters: type({ path: "string" }) };
for (const id of ["gpt-5.3-codex-spark", "gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"]) {
const subscription = model("openai-codex-responses", id);
const context: Context = {
messages: [{ role: "user", content: "capture the screen", timestamp: 1 }],
tools: [computerTool, otherTool],
};
expect(subscription.supportsComputerUse).toBe(false);
const regular = await buildTransformedCodexRequestBody(subscription, context, {
toolChoice: { type: "computer" },
responsesLite: false,
});
expect(regular.tools).toMatchObject([
{ type: "function", name: "computer" },
{ type: "function", name: "read" },
]);
expect(regular.tool_choice).toEqual({ type: "function", name: "computer" });
const lite = await buildTransformedCodexRequestBody(subscription, context, {
toolChoice: { type: "computer" },
responsesLite: true,
});
expect(lite.tools).toBeUndefined();
expect(lite.input?.[0]).toMatchObject({
type: "additional_tools",
tools: [{ type: "function", name: "computer" }],
});
expect(lite.tool_choice).toBe("required");
}
});
test("preserves an explicit future Codex native opt-in through regular and Lite requests", async () => {
const optedIn = buildModel({
...model("openai-codex-responses", "gpt-5.6-terra"),
supportsComputerUse: true,
} as ModelSpec<"openai-codex-responses">);
const context: Context = {
messages: [{ role: "user", content: "capture", timestamp: 1 }],
tools: [computerTool],
};
expect(convertOpenAICodexResponsesTools([computerTool], optedIn)).toEqual([{ type: "computer" }]);
expect(normalizeCodexToolChoice({ type: "computer" }, [computerTool], optedIn)).toEqual({ type: "computer" });
const regular = await buildTransformedCodexRequestBody(optedIn, context, {
toolChoice: { type: "computer" },
responsesLite: false,
});
expect(regular.tools).toEqual([{ type: "computer" }]);
expect(regular.tool_choice).toEqual({ type: "computer" });
const lite = await buildTransformedCodexRequestBody(optedIn, context, {
toolChoice: { type: "computer" },
responsesLite: true,
});
expect(lite.tools).toBeUndefined();
expect(lite.input?.[0]).toEqual({ type: "additional_tools", role: "developer", tools: [{ type: "computer" }] });
expect(lite.tool_choice).toBe("required");
});
test("parses batched streamed actions, stable item id, and safety checks", async () => {
const output = assistant([]);
const emitted: unknown[] = [];
@@ -503,4 +564,70 @@ describe("OpenAI GA computer contract", () => {
).toBe(true);
expect(JSON.stringify(replay)).toContain("data:image/png;base64,cG5n");
});
test("unrolls direct API computer history after switching to a subscription model", async () => {
const current = model("openai-codex-responses", "gpt-5.6-terra");
const call = assistant([
{
type: "toolCall",
id: "call_direct_computer|item_direct_computer",
name: "computer",
arguments: {},
providerMetadata: {
type: "computer",
providerItemId: "item_direct_computer",
actions: [{ type: "screenshot" }],
pendingSafetyChecks: [],
},
},
]);
const result: ToolResultMessage = {
role: "toolResult",
toolCallId: "call_direct_computer|item_direct_computer",
toolName: "computer",
content: [{ type: "image", data: "cG5n", mimeType: "image/png", detail: "original" }],
isError: false,
timestamp: 2,
providerMetadata: {
type: "computer",
screenshot: { type: "computer_screenshot", image_url: "data:image/png;base64,cG5n" },
acknowledgedSafetyChecks: [],
},
};
const replay = convertCodexResponsesMessages(current, { messages: [call, result] });
expect(replay.some(item => item.type === "computer_call" || item.type === "computer_call_output")).toBe(false);
expect(replay).toContainEqual(
expect.objectContaining({ type: "function_call", name: "computer", call_id: "call_direct_computer" }),
);
expect(replay).toContainEqual(
expect.objectContaining({ type: "function_call_output", call_id: "call_direct_computer" }),
);
const context: Context = {
messages: [call, result, { role: "user", content: "continue", timestamp: 3 }],
tools: [computerTool],
};
for (const responsesLite of [false, true]) {
const body = await buildTransformedCodexRequestBody(current, context, {
toolChoice: { type: "computer" },
responsesLite,
});
const serialized = JSON.stringify(body);
expect(serialized).not.toContain('"type":"computer_call"');
expect(serialized).not.toContain('"type":"computer_call_output"');
expect(serialized).toContain('"type":"function_call"');
expect(serialized).toContain('"type":"function_call_output"');
if (responsesLite) {
expect(body.input?.[0]).toMatchObject({
type: "additional_tools",
tools: [{ type: "function", name: "computer" }],
});
expect(body.tool_choice).toBe("required");
} else {
expect(body.tools).toMatchObject([{ type: "function", name: "computer" }]);
expect(body.tool_choice).toEqual({ type: "function", name: "computer" });
}
}
});
});
@@ -71,6 +71,12 @@ const goodTool: Tool = {
description: "read a file",
parameters: type({ path: "string" }),
};
const computerTool: Tool = {
name: "computer",
description: "control the desktop",
parameters: type({}),
native: { type: "computer" },
};
describe("convertTools quarantine (#2652)", () => {
test("drops only the tool with the provider-rejecting schema, keeping the rest", () => {
@@ -123,4 +129,16 @@ describe("buildParams tool_choice reconciliation (#2652)", () => {
);
expect(params.tool_choice).toEqual({ type: "function", name: "read_file" });
});
test("keeps a forced native computer choice when only a sibling tool is quarantined", () => {
const nativeModel = { ...makeModel(), supportsComputerUse: true };
const { params } = buildParams(
nativeModel,
ctx([computerTool, badTool]),
{ toolChoice: { type: "computer" } },
undefined,
);
expect(params.tools).toEqual([{ type: "computer" }]);
expect(params.tool_choice).toEqual({ type: "computer" });
});
});
+27 -10
View File
@@ -20,18 +20,35 @@ import { cleanModelName } from "./utils";
const OPENAI_GA_COMPUTER_MODEL_RE = /^gpt-5\.(?:[4-9]|[1-9]\d)(?:[.-]|$)/i;
function isDirectOpenAIResponsesEndpoint(spec: ModelSpec<Api>): boolean {
if (spec.api === "openai-responses") {
if (spec.provider !== "openai") return false;
if (!spec.baseUrl) return true;
try {
const url = new URL(spec.baseUrl);
return url.protocol === "https:" && url.hostname === "api.openai.com";
} catch {
return false;
}
}
if (spec.api !== "azure-openai-responses" || (spec.provider !== "azure" && spec.provider !== "azure-openai")) {
return false;
}
if (!spec.baseUrl) return spec.provider === "azure";
try {
const url = new URL(spec.baseUrl);
return (
url.protocol === "https:" &&
(url.hostname.endsWith(".openai.azure.com") || url.hostname === "models.inference.ai.azure.com")
);
} catch {
return false;
}
}
function supportsOpenAIGAComputerUse(spec: ModelSpec<Api>): boolean {
if (spec.supportsComputerUse !== undefined) return spec.supportsComputerUse;
if (
spec.api !== "openai-responses" &&
spec.api !== "openai-codex-responses" &&
spec.api !== "azure-openai-responses"
) {
return false;
}
if (spec.api !== "azure-openai-responses" && spec.provider !== "openai" && spec.provider !== "openai-codex") {
return false;
}
if (!isDirectOpenAIResponsesEndpoint(spec)) return false;
return OPENAI_GA_COMPUTER_MODEL_RE.test(spec.requestModelId ?? spec.id);
}
+42
View File
@@ -144,6 +144,48 @@ describe("buildModel", () => {
expect(buildModel(completionsSpec({ name })).name).toBe(name);
}
});
it("limits inferred GA computer capability to first-party Responses transports", () => {
const common = {
id: "gpt-5.6-terra",
name: "GPT-5.6 Terra",
reasoning: true,
input: ["text", "image"] as Array<"text" | "image">,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400_000,
maxTokens: 128_000,
};
const direct = {
...common,
api: "openai-responses" as const,
provider: "openai",
baseUrl: "https://api.openai.com/v1",
} satisfies ModelSpec<"openai-responses">;
expect(buildModel(direct).supportsComputerUse).toBe(true);
expect(buildModel({ ...direct, baseUrl: "https://gateway.example/v1" }).supportsComputerUse).toBe(false);
expect(buildModel({ ...direct, provider: "gpt-proxy" }).supportsComputerUse).toBe(false);
const subscription = {
...common,
api: "openai-codex-responses" as const,
provider: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api",
} satisfies ModelSpec<"openai-codex-responses">;
for (const id of ["gpt-5.3-codex-spark", "gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"]) {
expect(buildModel({ ...subscription, id, name: id }).supportsComputerUse).toBe(false);
}
expect(buildModel({ ...subscription, supportsComputerUse: true }).supportsComputerUse).toBe(true);
const azure = {
...common,
api: "azure-openai-responses" as const,
provider: "azure",
baseUrl: "",
} satisfies ModelSpec<"azure-openai-responses">;
expect(buildModel(azure).supportsComputerUse).toBe(true);
expect(buildModel({ ...azure, provider: "custom-azure-proxy" }).supportsComputerUse).toBe(false);
expect(buildModel({ ...azure, baseUrl: "https://gateway.example/openai/v1" }).supportsComputerUse).toBe(false);
});
});
describe("xAI-OAuth Responses reasoning-effort suppression", () => {