Merge pull request #5109 from any-victor/fix/xai-oauth-replay-shapes
fix(ai): adapted xai responses replay shapes
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed xAI OAuth Responses continuations replaying OpenAI-only `custom_tool_call`/`custom_tool_call_output` history and `input_image.detail: "original"` frames; replay now downgrades those to xAI-compatible function calls and `detail: "auto"`. ([#5002](https://github.com/can1357/oh-my-pi/issues/5002))
|
||||
|
||||
## [16.4.1] - 2026-07-10
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1357,6 +1357,65 @@ export function convertResponsesInputContent(
|
||||
return normalizedContent.length > 0 ? normalizedContent : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map freeform custom-tool wire names back to the internal tool name for
|
||||
* providers that only accept function_call / function_call_output.
|
||||
* Built once per request; `apply_patch` → `edit` is the OMP default.
|
||||
*/
|
||||
function buildCustomToolWireNameMap(tools: readonly Tool[] | undefined): ReadonlyMap<string, string> | undefined {
|
||||
if (!tools?.length) return undefined;
|
||||
const map = new Map<string, string>();
|
||||
for (const tool of tools) {
|
||||
if (tool.customWireName) map.set(tool.customWireName, tool.name);
|
||||
}
|
||||
return map.size > 0 ? map : undefined;
|
||||
}
|
||||
|
||||
function resolveReplayCustomToolName(wireName: string, wireNameMap: ReadonlyMap<string, string> | undefined): string {
|
||||
return wireNameMap?.get(wireName) ?? (wireName === "apply_patch" ? "edit" : wireName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downgrade OpenAI-only custom tool items when the target model does not
|
||||
* advertise freeform custom tools (`applyPatchToolType === "freeform"`).
|
||||
* No-op (returns the same array reference) when freeform is supported.
|
||||
*/
|
||||
function adaptResponsesReplayItemsForModel(
|
||||
input: ResponseInput,
|
||||
supportsCustomToolCalls: boolean,
|
||||
wireNameMap: ReadonlyMap<string, string> | undefined,
|
||||
): ResponseInput {
|
||||
if (supportsCustomToolCalls) return input;
|
||||
|
||||
let changed = false;
|
||||
const adapted: ResponseInput = [];
|
||||
for (const item of input) {
|
||||
if (item.type === "custom_tool_call") {
|
||||
changed = true;
|
||||
adapted.push({
|
||||
type: "function_call",
|
||||
...(item.id ? { id: item.id } : {}),
|
||||
call_id: item.call_id,
|
||||
name: resolveReplayCustomToolName(item.name, wireNameMap),
|
||||
arguments: JSON.stringify({ input: item.input }),
|
||||
...(item.namespace ? { namespace: item.namespace } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (item.type === "custom_tool_call_output") {
|
||||
changed = true;
|
||||
adapted.push({
|
||||
type: "function_call_output",
|
||||
call_id: item.call_id,
|
||||
output: item.output,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
adapted.push(item);
|
||||
}
|
||||
return changed ? adapted : input;
|
||||
}
|
||||
|
||||
export interface BuildResponsesInputOptions<TApi extends Api> {
|
||||
model: Model<TApi>;
|
||||
context: Context;
|
||||
@@ -1381,6 +1440,15 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
|
||||
messages.push({ role: options.systemRole as "system" | "developer", content: systemPrompt });
|
||||
}
|
||||
|
||||
// Compat is resolved by the catalog (e.g. Copilot / xai-oauth reject
|
||||
// `detail: "original"`). Do not re-branch on provider id here.
|
||||
const supportsImageDetailOriginal = options.supportsImageDetailOriginal;
|
||||
// Freeform custom tools (`custom_tool_call`) only when the catalog says so;
|
||||
// same gate as tool conversion (`applyPatchToolType === "freeform"`).
|
||||
const supportsCustomToolCalls = options.model.applyPatchToolType === "freeform";
|
||||
const customToolWireNameMap = supportsCustomToolCalls
|
||||
? undefined
|
||||
: buildCustomToolWireNameMap(options.context.tools);
|
||||
let knownCallIds = new Set<string>();
|
||||
const customCallIds = new Set<string>();
|
||||
const transformedMessages = transformMessages(
|
||||
@@ -1408,7 +1476,12 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
|
||||
}) ??
|
||||
false);
|
||||
if (historyItems && shouldReplayPayloadItems) {
|
||||
messages.push(...sanitizeOpenAIResponsesHistoryItemsForReplay(filterReasoning(historyItems)));
|
||||
const sanitizedItems = sanitizeOpenAIResponsesHistoryItemsForReplay(filterReasoning(historyItems), {
|
||||
supportsImageDetailOriginal,
|
||||
});
|
||||
messages.push(
|
||||
...adaptResponsesReplayItemsForModel(sanitizedItems, supportsCustomToolCalls, customToolWireNameMap),
|
||||
);
|
||||
knownCallIds = collectKnownCallIds(messages);
|
||||
for (const id of collectCustomCallIds(messages)) customCallIds.add(id);
|
||||
msgIndex++;
|
||||
@@ -1417,7 +1490,7 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
|
||||
const content = convertResponsesInputContent(
|
||||
msg.content,
|
||||
options.model.input.includes("image"),
|
||||
options.supportsImageDetailOriginal,
|
||||
supportsImageDetailOriginal,
|
||||
);
|
||||
if (!content) continue;
|
||||
messages.push({
|
||||
@@ -1445,9 +1518,17 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
|
||||
const historyItems = providerPayload?.items;
|
||||
let suppressHiddenEmptyFallback = false;
|
||||
if (historyItems) {
|
||||
const sanitizedHistoryItems = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(
|
||||
const rawSanitizedHistoryItems = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(
|
||||
filterReasoning(historyItems),
|
||||
{ supportsImageDetailOriginal },
|
||||
);
|
||||
const sanitizedHistoryItems = rawSanitizedHistoryItems
|
||||
? adaptResponsesReplayItemsForModel(
|
||||
rawSanitizedHistoryItems,
|
||||
supportsCustomToolCalls,
|
||||
customToolWireNameMap,
|
||||
)
|
||||
: undefined;
|
||||
if (nativeReplayEnabled && sanitizedHistoryItems) {
|
||||
if (providerPayload?.dt) {
|
||||
messages.push(...sanitizedHistoryItems);
|
||||
@@ -1470,6 +1551,8 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
|
||||
suppressHiddenEmptyFallback ? false : includeThinkingSignatures,
|
||||
customCallIds,
|
||||
options.preserveAssistantMessageIds,
|
||||
supportsCustomToolCalls,
|
||||
customToolWireNameMap,
|
||||
);
|
||||
const outputItems = suppressHiddenEmptyFallback
|
||||
? sanitizeOpenAIResponsesAssistantFallbackItemsForReplay(convertedOutputItems)
|
||||
@@ -1482,9 +1565,10 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
|
||||
msg,
|
||||
options.model,
|
||||
options.strictResponsesPairing,
|
||||
options.supportsImageDetailOriginal,
|
||||
supportsImageDetailOriginal,
|
||||
knownCallIds,
|
||||
customCallIds,
|
||||
supportsCustomToolCalls,
|
||||
);
|
||||
}
|
||||
msgIndex++;
|
||||
@@ -1517,6 +1601,8 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
|
||||
includeThinkingSignatures = true,
|
||||
customCallIds?: Set<string>,
|
||||
preserveMessageIds = false,
|
||||
supportsCustomToolCalls = true,
|
||||
customToolWireNameMap?: ReadonlyMap<string, string>,
|
||||
): ResponseInput {
|
||||
const outputItems: ResponseInput = [];
|
||||
let unsignedTextBlocks = 0;
|
||||
@@ -1588,7 +1674,7 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
|
||||
itemId = undefined;
|
||||
}
|
||||
knownCallIds.add(normalized.callId);
|
||||
if (block.customWireName) {
|
||||
if (block.customWireName && supportsCustomToolCalls) {
|
||||
const rawInput = typeof block.arguments?.input === "string" ? block.arguments.input : "";
|
||||
customCallIds?.add(normalized.callId);
|
||||
outputItems.push({
|
||||
@@ -1600,11 +1686,15 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
|
||||
} as ResponseInput[number]);
|
||||
continue;
|
||||
}
|
||||
const functionName =
|
||||
block.customWireName && !supportsCustomToolCalls
|
||||
? resolveReplayCustomToolName(block.customWireName, customToolWireNameMap)
|
||||
: block.name;
|
||||
outputItems.push({
|
||||
type: "function_call",
|
||||
...(itemId ? { id: itemId } : {}),
|
||||
call_id: normalized.callId,
|
||||
name: block.name,
|
||||
name: functionName,
|
||||
arguments: stringifyJson(block.arguments) ?? "null",
|
||||
});
|
||||
}
|
||||
@@ -1620,6 +1710,7 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
|
||||
supportsImageDetailOriginal: boolean,
|
||||
knownCallIds: ReadonlySet<string>,
|
||||
customCallIds?: ReadonlySet<string>,
|
||||
supportsCustomToolCalls = true,
|
||||
): void {
|
||||
const supportsImages = model.input.includes("image");
|
||||
const textResult = toolResult.content
|
||||
@@ -1649,7 +1740,7 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
|
||||
} as ResponseInput[number]);
|
||||
return;
|
||||
}
|
||||
if (customCallIds?.has(normalized.callId)) {
|
||||
if (supportsCustomToolCalls && customCallIds?.has(normalized.callId)) {
|
||||
messages.push({
|
||||
type: "custom_tool_call_output",
|
||||
call_id: normalized.callId,
|
||||
|
||||
@@ -65,10 +65,50 @@ export function truncateResponseItemId(id: string, prefix: string): string {
|
||||
return `${prefix}_${Bun.hash(id).toString(36)}`;
|
||||
}
|
||||
|
||||
export function sanitizeOpenAIResponsesHistoryItemsForReplay(items: Array<Record<string, unknown>>): ResponseInput {
|
||||
interface OpenAIResponsesReplaySanitizeOptions {
|
||||
supportsImageDetailOriginal?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp `detail: "original"` only where Responses input_image parts live —
|
||||
* top-level items and `message.content[]`. Avoids a deep tree walk/clone of
|
||||
* every history node on providers that reject native-resolution images.
|
||||
*/
|
||||
function clampReplayItemImageDetail(
|
||||
item: Record<string, unknown>,
|
||||
supportsImageDetailOriginal: boolean,
|
||||
): Record<string, unknown> {
|
||||
if (supportsImageDetailOriginal) return item;
|
||||
|
||||
if (item.type === "input_image" && item.detail === "original") {
|
||||
return { ...item, detail: "auto" };
|
||||
}
|
||||
|
||||
if (item.type !== "message" || !Array.isArray(item.content)) return item;
|
||||
|
||||
let changed = false;
|
||||
const content = item.content.map(part => {
|
||||
if (!part || typeof part !== "object" || Array.isArray(part)) return part;
|
||||
const record = part as Record<string, unknown>;
|
||||
if (record.type !== "input_image" || record.detail !== "original") return part;
|
||||
changed = true;
|
||||
return { ...record, detail: "auto" };
|
||||
});
|
||||
return changed ? { ...item, content } : item;
|
||||
}
|
||||
|
||||
export function sanitizeOpenAIResponsesHistoryItemsForReplay(
|
||||
items: Array<Record<string, unknown>>,
|
||||
options: OpenAIResponsesReplaySanitizeOptions = {},
|
||||
): ResponseInput {
|
||||
const normalizedCallIds = new Map<string, string>();
|
||||
const supportsImageDetailOriginal = options.supportsImageDetailOriginal !== false;
|
||||
return items.flatMap(item => {
|
||||
const sanitized = sanitizeOpenAIResponsesHistoryItemForReplay(item, normalizedCallIds);
|
||||
const sanitized = sanitizeOpenAIResponsesHistoryItemForReplay(
|
||||
item,
|
||||
normalizedCallIds,
|
||||
supportsImageDetailOriginal,
|
||||
);
|
||||
return sanitized ? [sanitized] : [];
|
||||
});
|
||||
}
|
||||
@@ -82,8 +122,9 @@ export function sanitizeOpenAIResponsesHistoryItemsForReplay(items: Array<Record
|
||||
*/
|
||||
export function sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(
|
||||
items: Array<Record<string, unknown>>,
|
||||
options: OpenAIResponsesReplaySanitizeOptions = {},
|
||||
): ResponseInput | undefined {
|
||||
const sanitized = sanitizeOpenAIResponsesHistoryItemsForReplay(items);
|
||||
const sanitized = sanitizeOpenAIResponsesHistoryItemsForReplay(items, options);
|
||||
let hasReplayableAssistantOutput = false;
|
||||
|
||||
for (const item of sanitized) {
|
||||
@@ -153,6 +194,7 @@ export function sanitizeOpenAIResponsesAssistantFallbackItemsForReplay(items: Re
|
||||
function sanitizeOpenAIResponsesHistoryItemForReplay(
|
||||
item: Record<string, unknown>,
|
||||
normalizedCallIds: Map<string, string>,
|
||||
supportsImageDetailOriginal: boolean,
|
||||
): OpenAIResponsesReplayItem | undefined {
|
||||
if (item.type === "item_reference") return undefined;
|
||||
if (item.type === "image_generation_call") return sanitizeOpenAIResponsesImageGenerationCallForReplay(item);
|
||||
@@ -164,7 +206,10 @@ function sanitizeOpenAIResponsesHistoryItemForReplay(
|
||||
sanitizedItem.call_id = normalizeReplayedResponsesHistoryCallId(item.call_id, normalizedCallIds);
|
||||
}
|
||||
|
||||
return sanitizedItem as unknown as OpenAIResponsesReplayItem;
|
||||
return clampReplayItemImageDetail(
|
||||
sanitizedItem,
|
||||
supportsImageDetailOriginal,
|
||||
) as unknown as OpenAIResponsesReplayItem;
|
||||
}
|
||||
|
||||
function sanitizeOpenAIResponsesReasoningItemForReplay(item: Record<string, unknown>): OpenAIResponsesReplayItem {
|
||||
|
||||
@@ -5,11 +5,12 @@ import {
|
||||
} from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
|
||||
import { type OpenAIResponsesOptions, streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses";
|
||||
import { buildResponsesInput } from "@oh-my-pi/pi-ai/providers/openai-shared";
|
||||
import type { Context, Model, ModelSpec, ProviderSessionState } from "@oh-my-pi/pi-ai/types";
|
||||
import type { Context, Model, ModelSpec, ProviderSessionState, Tool } from "@oh-my-pi/pi-ai/types";
|
||||
import { createOpenAIResponsesHistoryPayload, truncateResponseItemId } from "@oh-my-pi/pi-ai/utils";
|
||||
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
||||
import { type GeneratedProvider, getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
||||
import * as piUtils from "@oh-my-pi/pi-utils";
|
||||
import { type } from "arktype";
|
||||
|
||||
const TEST_INSTALLATION_ID = "00000000-0000-4000-8000-000000000001";
|
||||
|
||||
@@ -35,13 +36,42 @@ function createCodexToken(accountId: string): string {
|
||||
return `${header}.${payload}.signature`;
|
||||
}
|
||||
|
||||
function getOpenAIReasoningModel(
|
||||
provider: Parameters<typeof getBundledModel>[0],
|
||||
id: string,
|
||||
): Model<"openai-responses"> {
|
||||
return getBundledModel(provider, id) as Model<"openai-responses">;
|
||||
function getOpenAIReasoningModel(provider: GeneratedProvider, id: string): Model<"openai-responses"> {
|
||||
const model = getBundledModel<"openai-responses">(provider, id);
|
||||
return model;
|
||||
}
|
||||
|
||||
const ISSUE_5002_PATCH = "*** Begin Patch\n*** End Patch\n";
|
||||
const ISSUE_5002_TOOL_OUTPUT = "patch applied";
|
||||
const issue5002XaiOAuthModel = buildModel({
|
||||
id: "grok-build",
|
||||
name: "Grok Build",
|
||||
api: "openai-responses",
|
||||
provider: "xai-oauth",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 256000,
|
||||
maxTokens: 64000,
|
||||
} satisfies ModelSpec<"openai-responses">);
|
||||
|
||||
const issue5002ZeroUsage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
const issue5002EditTool: Tool = {
|
||||
name: "edit",
|
||||
customWireName: "apply_patch",
|
||||
description: "Apply a hashline patch",
|
||||
parameters: type({ input: "string" }),
|
||||
customFormat: { syntax: "lark", definition: 'start: "*** Begin Patch" LF\nLF: /\\n/' },
|
||||
};
|
||||
|
||||
const preservedHistoryItems = [
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "Preserved user" }] },
|
||||
{ type: "compaction", encrypted_content: "enc_123" },
|
||||
@@ -298,6 +328,38 @@ function findResponsesInputItem(input: unknown[] | undefined, type: string): Rec
|
||||
}) as Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
function isIssue5002Record(value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function findResponsesInputItemByCallId(
|
||||
input: unknown[],
|
||||
type: string,
|
||||
callId: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
for (const item of input) {
|
||||
if (!isIssue5002Record(item)) continue;
|
||||
if (item.type === type && item.call_id === callId) return item;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectResponsesInputImageDetails(input: unknown): string[] {
|
||||
const details: string[] = [];
|
||||
const visit = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) visit(child);
|
||||
return;
|
||||
}
|
||||
if (!isIssue5002Record(node)) return;
|
||||
if (node.type === "input_image" && typeof node.detail === "string") details.push(node.detail);
|
||||
for (const key in node) visit(node[key]);
|
||||
};
|
||||
visit(input);
|
||||
return details;
|
||||
}
|
||||
|
||||
function containsUserInputText(input: unknown[] | undefined, text: string): boolean {
|
||||
return (input ?? []).some(item => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
@@ -366,11 +428,188 @@ describe("OpenAI responses history payload", () => {
|
||||
});
|
||||
assertWireOrder(openaiItems);
|
||||
|
||||
const codexModel = getBundledModel("openai-codex", "gpt-5.2-codex") as Model<"openai-codex-responses">;
|
||||
const codexModel = getBundledModel<"openai-codex-responses">("openai-codex", "gpt-5.2-codex");
|
||||
const codexItems = convertCodexResponsesMessages(codexModel, makeContext("openai-codex"));
|
||||
assertWireOrder(codexItems);
|
||||
});
|
||||
|
||||
it("adapts reconstructed apply_patch replay for xai-oauth while preserving OpenAI custom replay", () => {
|
||||
const context: Context = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "previous frame" },
|
||||
{ type: "image", mimeType: "image/png", data: "ZmFrZQ==", detail: "original" },
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_apply",
|
||||
name: "apply_patch",
|
||||
arguments: { input: ISSUE_5002_PATCH },
|
||||
customWireName: "apply_patch",
|
||||
},
|
||||
],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5-mini",
|
||||
usage: issue5002ZeroUsage,
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_apply",
|
||||
toolName: "edit",
|
||||
content: [{ type: "text", text: ISSUE_5002_TOOL_OUTPUT }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
tools: [issue5002EditTool],
|
||||
};
|
||||
|
||||
const xaiInput = buildResponsesInput({
|
||||
model: issue5002XaiOAuthModel,
|
||||
context,
|
||||
strictResponsesPairing: false,
|
||||
supportsImageDetailOriginal: issue5002XaiOAuthModel.compat.supportsImageDetailOriginal,
|
||||
nativeHistory: { replay: true, filterReasoning: issue5002XaiOAuthModel.compat.filterReasoningHistory },
|
||||
});
|
||||
expect(findResponsesInputItemByCallId(xaiInput, "function_call", "call_apply")).toEqual({
|
||||
type: "function_call",
|
||||
call_id: "call_apply",
|
||||
name: "edit",
|
||||
arguments: JSON.stringify({ input: ISSUE_5002_PATCH }),
|
||||
});
|
||||
expect(findResponsesInputItemByCallId(xaiInput, "function_call_output", "call_apply")).toEqual({
|
||||
type: "function_call_output",
|
||||
call_id: "call_apply",
|
||||
output: ISSUE_5002_TOOL_OUTPUT,
|
||||
});
|
||||
expect(JSON.stringify(xaiInput)).not.toContain("custom_tool_call");
|
||||
expect(collectResponsesInputImageDetails(xaiInput)).toEqual(["auto"]);
|
||||
|
||||
const openaiModel = getOpenAIReasoningModel("openai", "gpt-5-mini");
|
||||
const openaiInput = buildResponsesInput({
|
||||
model: openaiModel,
|
||||
context,
|
||||
strictResponsesPairing: false,
|
||||
supportsImageDetailOriginal: openaiModel.compat.supportsImageDetailOriginal,
|
||||
nativeHistory: { replay: true, filterReasoning: openaiModel.compat.filterReasoningHistory },
|
||||
});
|
||||
expect(findResponsesInputItemByCallId(openaiInput, "custom_tool_call", "call_apply")).toEqual({
|
||||
type: "custom_tool_call",
|
||||
call_id: "call_apply",
|
||||
name: "apply_patch",
|
||||
input: ISSUE_5002_PATCH,
|
||||
});
|
||||
expect(findResponsesInputItemByCallId(openaiInput, "custom_tool_call_output", "call_apply")).toEqual({
|
||||
type: "custom_tool_call_output",
|
||||
call_id: "call_apply",
|
||||
output: ISSUE_5002_TOOL_OUTPUT,
|
||||
});
|
||||
expect(collectResponsesInputImageDetails(openaiInput)).toEqual(["original"]);
|
||||
});
|
||||
|
||||
it("adapts persisted native apply_patch Responses items for xai-oauth continuations", () => {
|
||||
const nativeHistoryItems = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: "previous native frame" },
|
||||
{ type: "input_image", detail: "original", image_url: "data:image/png;base64,ZmFrZQ==" },
|
||||
],
|
||||
},
|
||||
{ type: "custom_tool_call", call_id: "call_native_apply", name: "apply_patch", input: ISSUE_5002_PATCH },
|
||||
{
|
||||
type: "custom_tool_call_output",
|
||||
call_id: "call_native_apply",
|
||||
output: ISSUE_5002_TOOL_OUTPUT,
|
||||
},
|
||||
];
|
||||
const xaiContext: Context = {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "fallback should not be replayed" }],
|
||||
api: "openai-responses",
|
||||
provider: "xai-oauth",
|
||||
model: issue5002XaiOAuthModel.id,
|
||||
usage: issue5002ZeroUsage,
|
||||
stopReason: "stop",
|
||||
providerPayload: createOpenAIResponsesHistoryPayload("xai-oauth", nativeHistoryItems),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{ role: "user", content: "continue", timestamp: Date.now() },
|
||||
],
|
||||
};
|
||||
|
||||
const xaiInput = buildResponsesInput({
|
||||
model: issue5002XaiOAuthModel,
|
||||
context: xaiContext,
|
||||
strictResponsesPairing: false,
|
||||
supportsImageDetailOriginal: issue5002XaiOAuthModel.compat.supportsImageDetailOriginal,
|
||||
nativeHistory: { replay: true, filterReasoning: issue5002XaiOAuthModel.compat.filterReasoningHistory },
|
||||
});
|
||||
expect(findResponsesInputItemByCallId(xaiInput, "function_call", "call_native_apply")).toEqual({
|
||||
type: "function_call",
|
||||
call_id: "call_native_apply",
|
||||
name: "edit",
|
||||
arguments: JSON.stringify({ input: ISSUE_5002_PATCH }),
|
||||
});
|
||||
expect(findResponsesInputItemByCallId(xaiInput, "function_call_output", "call_native_apply")).toEqual({
|
||||
type: "function_call_output",
|
||||
call_id: "call_native_apply",
|
||||
output: ISSUE_5002_TOOL_OUTPUT,
|
||||
});
|
||||
expect(JSON.stringify(xaiInput)).not.toContain("custom_tool_call");
|
||||
expect(collectResponsesInputImageDetails(xaiInput)).toEqual(["auto"]);
|
||||
|
||||
const openaiModel = getOpenAIReasoningModel("openai", "gpt-5-mini");
|
||||
const openaiContext: Context = {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "fallback should not be replayed" }],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: openaiModel.id,
|
||||
usage: issue5002ZeroUsage,
|
||||
stopReason: "stop",
|
||||
providerPayload: createOpenAIResponsesHistoryPayload("openai", nativeHistoryItems),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{ role: "user", content: "continue", timestamp: Date.now() },
|
||||
],
|
||||
};
|
||||
const openaiInput = buildResponsesInput({
|
||||
model: openaiModel,
|
||||
context: openaiContext,
|
||||
strictResponsesPairing: false,
|
||||
supportsImageDetailOriginal: openaiModel.compat.supportsImageDetailOriginal,
|
||||
nativeHistory: { replay: true, filterReasoning: openaiModel.compat.filterReasoningHistory },
|
||||
});
|
||||
expect(findResponsesInputItemByCallId(openaiInput, "custom_tool_call", "call_native_apply")).toEqual({
|
||||
type: "custom_tool_call",
|
||||
call_id: "call_native_apply",
|
||||
name: "apply_patch",
|
||||
input: ISSUE_5002_PATCH,
|
||||
});
|
||||
expect(findResponsesInputItemByCallId(openaiInput, "custom_tool_call_output", "call_native_apply")).toEqual({
|
||||
type: "custom_tool_call_output",
|
||||
call_id: "call_native_apply",
|
||||
output: ISSUE_5002_TOOL_OUTPUT,
|
||||
});
|
||||
expect(collectResponsesInputImageDetails(openaiInput)).toEqual(["original"]);
|
||||
});
|
||||
|
||||
it("prepends multiple OpenAI developer instructions in order without changing prompt cache key routing", async () => {
|
||||
const model = getOpenAIReasoningModel("openai", "gpt-5-mini");
|
||||
const payload = (await captureResponsesPayload(
|
||||
@@ -1063,7 +1302,7 @@ describe("OpenAI responses history payload", () => {
|
||||
{ role: "user", content: "Resume", timestamp: Date.now() },
|
||||
],
|
||||
};
|
||||
const model = getBundledModel("openai-codex", "gpt-5.2-codex") as Model<"openai-codex-responses">;
|
||||
const model = getBundledModel<"openai-codex-responses">("openai-codex", "gpt-5.2-codex");
|
||||
const payload = (await captureCodexPayload(model, context)) as { input?: unknown[] };
|
||||
const functionCallItem = findResponsesInputItem(payload.input, "function_call");
|
||||
const functionCallOutputItem = findResponsesInputItem(payload.input, "function_call_output");
|
||||
|
||||
@@ -595,12 +595,13 @@ export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): Resol
|
||||
// Azure OpenAI and GitHub Copilot Responses paths require tool results
|
||||
// to strictly match prior tool calls when building Responses inputs.
|
||||
strictResponsesPairing: isAzure || spec.provider === "github-copilot",
|
||||
// GitHub Copilot's Responses endpoint rejects the `detail: "original"`
|
||||
// image hint with a 400; every other host preserves native-resolution
|
||||
// frames (snapcompact relies on `original`). Detect Copilot by provider id
|
||||
// or base-URL host (mirroring the Anthropic compat builder) so a model
|
||||
// pointed at the Copilot host under a different provider id still clamps.
|
||||
supportsImageDetailOriginal: !modelMatchesHost({ provider: spec.provider, baseUrl }, "githubCopilot"),
|
||||
// GitHub Copilot and xAI OAuth reject `detail: "original"` (400 / 422).
|
||||
// Every other host preserves native-resolution frames (snapcompact relies
|
||||
// on `original`). Detect Copilot by provider id or base-URL host so a
|
||||
// model pointed at the Copilot host under a different provider id still
|
||||
// clamps; xai-oauth is provider-id only (same host family as paid `xai`).
|
||||
supportsImageDetailOriginal:
|
||||
spec.provider !== "xai-oauth" && !modelMatchesHost({ provider: spec.provider, baseUrl }, "githubCopilot"),
|
||||
reasoningEffortMap: {},
|
||||
supportsReasoningParams: true,
|
||||
thinkingFormat,
|
||||
|
||||
@@ -87250,7 +87250,8 @@
|
||||
"includeEncryptedReasoning": false,
|
||||
"filterReasoningHistory": true,
|
||||
"omitReasoningEffort": true,
|
||||
"supportsReasoningEffort": false
|
||||
"supportsReasoningEffort": false,
|
||||
"supportsImageDetailOriginal": false
|
||||
}
|
||||
},
|
||||
"grok-4.20-0309-reasoning": {
|
||||
@@ -87279,7 +87280,8 @@
|
||||
"includeEncryptedReasoning": false,
|
||||
"filterReasoningHistory": true,
|
||||
"omitReasoningEffort": true,
|
||||
"supportsReasoningEffort": false
|
||||
"supportsReasoningEffort": false,
|
||||
"supportsImageDetailOriginal": false
|
||||
}
|
||||
},
|
||||
"grok-4.20-multi-agent-0309": {
|
||||
@@ -87320,7 +87322,8 @@
|
||||
"includeEncryptedReasoning": false,
|
||||
"filterReasoningHistory": true,
|
||||
"omitReasoningEffort": false,
|
||||
"supportsReasoningEffort": true
|
||||
"supportsReasoningEffort": true,
|
||||
"supportsImageDetailOriginal": false
|
||||
}
|
||||
},
|
||||
"grok-4.3": {
|
||||
@@ -87362,7 +87365,8 @@
|
||||
"includeEncryptedReasoning": false,
|
||||
"filterReasoningHistory": true,
|
||||
"omitReasoningEffort": false,
|
||||
"supportsReasoningEffort": true
|
||||
"supportsReasoningEffort": true,
|
||||
"supportsImageDetailOriginal": false
|
||||
}
|
||||
},
|
||||
"grok-4.5": {
|
||||
@@ -87404,7 +87408,8 @@
|
||||
"includeEncryptedReasoning": false,
|
||||
"filterReasoningHistory": true,
|
||||
"omitReasoningEffort": false,
|
||||
"supportsReasoningEffort": true
|
||||
"supportsReasoningEffort": true,
|
||||
"supportsImageDetailOriginal": false
|
||||
}
|
||||
},
|
||||
"grok-build": {
|
||||
@@ -87433,7 +87438,8 @@
|
||||
"includeEncryptedReasoning": false,
|
||||
"filterReasoningHistory": true,
|
||||
"omitReasoningEffort": true,
|
||||
"supportsReasoningEffort": false
|
||||
"supportsReasoningEffort": false,
|
||||
"supportsImageDetailOriginal": false
|
||||
}
|
||||
},
|
||||
"grok-build-0.1": {
|
||||
@@ -87462,7 +87468,8 @@
|
||||
"includeEncryptedReasoning": false,
|
||||
"filterReasoningHistory": true,
|
||||
"omitReasoningEffort": true,
|
||||
"supportsReasoningEffort": false
|
||||
"supportsReasoningEffort": false,
|
||||
"supportsImageDetailOriginal": false
|
||||
}
|
||||
},
|
||||
"grok-composer-2.5-fast": {
|
||||
@@ -87490,7 +87497,8 @@
|
||||
"includeEncryptedReasoning": false,
|
||||
"filterReasoningHistory": true,
|
||||
"omitReasoningEffort": true,
|
||||
"supportsReasoningEffort": false
|
||||
"supportsReasoningEffort": false,
|
||||
"supportsImageDetailOriginal": false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1176,6 +1176,7 @@ function withXaiOAuthCompatDefaults(model: ModelSpec<"openai-responses">): Model
|
||||
...(model.compat ?? {}),
|
||||
includeEncryptedReasoning: model.compat?.includeEncryptedReasoning ?? false,
|
||||
filterReasoningHistory: model.compat?.filterReasoningHistory ?? true,
|
||||
supportsImageDetailOriginal: model.compat?.supportsImageDetailOriginal ?? false,
|
||||
omitReasoningEffort: model.compat?.omitReasoningEffort ?? !isGrokReasoningEffortCapable(model.id),
|
||||
};
|
||||
return { ...model, compat };
|
||||
@@ -1218,6 +1219,7 @@ function mergeCuratedIntoModel(
|
||||
reasoningEffortMap: { ...XAI_REASONING_EFFORT_MAP, ...(base.compat?.reasoningEffortMap ?? {}) },
|
||||
includeEncryptedReasoning: base.compat?.includeEncryptedReasoning ?? false,
|
||||
filterReasoningHistory: base.compat?.filterReasoningHistory ?? true,
|
||||
supportsImageDetailOriginal: base.compat?.supportsImageDetailOriginal ?? false,
|
||||
omitReasoningEffort: !effortCapable,
|
||||
supportsReasoningEffort: effortCapable,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user