fix: dedupe OpenAI strict schema downgrade diagnostics (#82933)

* fix: dedupe openai strict schema downgrade logs

* test: align openai transport helper export

* test: cover openai downgrade log behavior

* docs: note openai downgrade diagnostic dedupe

---------

Co-authored-by: Galin Iliev <Galin.Iliev@microsoft.com>
This commit is contained in:
Galin Iliev
2026-05-19 20:48:26 -07:00
committed by GitHub
parent 18a514e39e
commit c982358753
3 changed files with 120 additions and 0 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents: filter silent heartbeat response-tool transcript artifacts out of embedded context snapshots so later user turns are not polluted by heartbeat no-op messages. (#83477) Thanks @fuller-stack-dev.
- Agents/OpenAI: log repeated strict tool-schema downgrade diagnostics once per provider/model/tool signature, reducing duplicate debug noise while preserving `strict=false` fallback behavior. Fixes #82930. (#82933) Thanks @galiniliev.
- Agents/code mode: spell out the `exec` tool's JavaScript/TypeScript, no Node module, and catalog-bridge constraints in model-visible schema text so agents can use enabled tools without trial-and-error. (#84269) Thanks @Kaspre.
- Codex: give `image_generate` dynamic-tool calls a 120s default watchdog when no per-call or configured image timeout is set, so image generation no longer falls back to the generic 30s bridge timeout. (#84254) Thanks @moritzmmayerhofer.
- Codex: avoid duplicate dynamic tool terminal diagnostics while large diagnostic backlogs drain without blocking tool responses. (#82937) Thanks @galiniliev.

View File

@@ -3015,6 +3015,82 @@ describe("openai transport stream", () => {
expect(params.tools?.[0]?.strict).toBe(false);
});
it("deduplicates repeated OpenAI strict schema downgrade diagnostics", async () => {
const debug = vi.fn();
const logger = {
subsystem: "openai-transport",
isEnabled: vi.fn((level: string, target?: string) => level === "debug" && target === "any"),
trace: vi.fn(),
debug,
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
raw: vi.fn(),
child: vi.fn(),
};
logger.child.mockReturnValue(logger);
vi.resetModules();
vi.doMock("../logging/subsystem.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../logging/subsystem.js")>()),
createSubsystemLogger: vi.fn(() => logger),
}));
try {
const { buildOpenAIResponsesParams: isolatedBuildOpenAIResponsesParams } =
await import("./openai-transport-stream.js");
const model = {
id: "gpt-5.4",
name: "GPT-5.4",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
} satisfies Model<"openai-responses">;
const context = {
systemPrompt: "system",
messages: [],
tools: [
{
name: "read",
description: "Read file",
parameters: {
type: "object",
additionalProperties: false,
properties: { path: { type: "string" } },
required: [],
},
},
],
} as never;
const first = isolatedBuildOpenAIResponsesParams(model, context, undefined) as {
tools?: Array<{ strict?: boolean }>;
};
const second = isolatedBuildOpenAIResponsesParams(model, context, undefined) as {
tools?: Array<{ strict?: boolean }>;
};
expect(first.tools?.[0]?.strict).toBe(false);
expect(second.tools?.[0]?.strict).toBe(false);
expect(
debug.mock.calls.filter(
([message]) =>
typeof message === "string" &&
message.includes("tool schema strict mode downgraded to strict=false"),
),
).toHaveLength(1);
} finally {
vi.doUnmock("../logging/subsystem.js");
vi.resetModules();
}
});
it("omits responses strict tool shaping for proxy-like OpenAI routes", () => {
const params = buildOpenAIResponsesParams(
{

View File

@@ -78,7 +78,9 @@ const AZURE_RESPONSES_FIRST_EVENT_TIMEOUT_MS = 30_000;
const MODEL_STREAM_COOPERATIVE_YIELD_INTERVAL_MS = 12;
const MODEL_STREAM_COOPERATIVE_YIELD_MAX_EVENTS = 64;
const RESPONSE_FAILED_NO_DETAILS_MESSAGE = "Unknown error (no error details in response)";
const MAX_OPENAI_STRICT_TOOL_DOWNGRADE_DIAGNOSTIC_KEYS = 256;
const log = createSubsystemLogger("openai-transport");
const loggedOpenAIStrictToolDowngradeDiagnosticKeys = new Set<string>();
type ReplayableResponseOutputMessage = Omit<ResponseOutputMessage, "id"> & { id?: string };
type ReplayableResponseReasoningItem = Omit<ResponseReasoningItem, "id"> & { id?: string };
@@ -976,6 +978,9 @@ function resolveOpenAIStrictToolFlagWithDiagnostics(
const strict = resolveOpenAIStrictToolFlagForInventory(tools, strictSetting);
if (strictSetting === true && strict === false && log.isEnabled("debug", "any")) {
const diagnostics = findOpenAIStrictToolSchemaDiagnostics(tools);
if (!shouldLogOpenAIStrictToolDowngradeDiagnostic(diagnostics, context)) {
return strict;
}
const sample = diagnostics.slice(0, 5).map((entry) => ({
tool: entry.toolName ?? `tool[${entry.toolIndex}]`,
violations: entry.violations.slice(0, 8),
@@ -996,6 +1001,44 @@ function resolveOpenAIStrictToolFlagWithDiagnostics(
return strict;
}
function buildOpenAIStrictToolDowngradeDiagnosticKey(
diagnostics: ReturnType<typeof findOpenAIStrictToolSchemaDiagnostics>,
context: { transport: "responses" | "completions"; model: OpenAIModeModel },
): string {
return createHash("sha256")
.update(
JSON.stringify({
transport: context.transport,
provider: context.model.provider ?? null,
model: context.model.id ?? null,
diagnostics: diagnostics.map((entry) => ({
toolIndex: entry.toolIndex,
toolName: entry.toolName ?? null,
violations: entry.violations,
})),
}),
)
.digest("hex");
}
function shouldLogOpenAIStrictToolDowngradeDiagnostic(
diagnostics: ReturnType<typeof findOpenAIStrictToolSchemaDiagnostics>,
context: { transport: "responses" | "completions"; model: OpenAIModeModel },
): boolean {
const key = buildOpenAIStrictToolDowngradeDiagnosticKey(diagnostics, context);
if (loggedOpenAIStrictToolDowngradeDiagnosticKeys.has(key)) {
return false;
}
if (
loggedOpenAIStrictToolDowngradeDiagnosticKeys.size >=
MAX_OPENAI_STRICT_TOOL_DOWNGRADE_DIAGNOSTIC_KEYS
) {
loggedOpenAIStrictToolDowngradeDiagnosticKeys.clear();
}
loggedOpenAIStrictToolDowngradeDiagnosticKeys.add(key);
return true;
}
function createResponsesFirstEventTimeoutError(model: Model<Api>, timeoutMs: number): Error {
return new Error(
`Azure OpenAI Responses stream did not deliver a first event within ${timeoutMs}ms after HTTP streaming headers. ` +