Skip to content

Commit c982358

Browse files
galinilievGalin Iliev
andauthored
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>
1 parent 18a514e commit c982358

3 files changed

Lines changed: 120 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212
### Fixes
1313

1414
- 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.
15+
- 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.
1516
- 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.
1617
- 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.
1718
- Codex: avoid duplicate dynamic tool terminal diagnostics while large diagnostic backlogs drain without blocking tool responses. (#82937) Thanks @galiniliev.

src/agents/openai-transport-stream.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3015,6 +3015,82 @@ describe("openai transport stream", () => {
30153015
expect(params.tools?.[0]?.strict).toBe(false);
30163016
});
30173017

3018+
it("deduplicates repeated OpenAI strict schema downgrade diagnostics", async () => {
3019+
const debug = vi.fn();
3020+
const logger = {
3021+
subsystem: "openai-transport",
3022+
isEnabled: vi.fn((level: string, target?: string) => level === "debug" && target === "any"),
3023+
trace: vi.fn(),
3024+
debug,
3025+
info: vi.fn(),
3026+
warn: vi.fn(),
3027+
error: vi.fn(),
3028+
fatal: vi.fn(),
3029+
raw: vi.fn(),
3030+
child: vi.fn(),
3031+
};
3032+
logger.child.mockReturnValue(logger);
3033+
3034+
vi.resetModules();
3035+
vi.doMock("../logging/subsystem.js", async (importOriginal) => ({
3036+
...(await importOriginal<typeof import("../logging/subsystem.js")>()),
3037+
createSubsystemLogger: vi.fn(() => logger),
3038+
}));
3039+
3040+
try {
3041+
const { buildOpenAIResponsesParams: isolatedBuildOpenAIResponsesParams } =
3042+
await import("./openai-transport-stream.js");
3043+
const model = {
3044+
id: "gpt-5.4",
3045+
name: "GPT-5.4",
3046+
api: "openai-responses",
3047+
provider: "openai",
3048+
baseUrl: "https://api.openai.com/v1",
3049+
reasoning: true,
3050+
input: ["text"],
3051+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3052+
contextWindow: 200000,
3053+
maxTokens: 8192,
3054+
} satisfies Model<"openai-responses">;
3055+
const context = {
3056+
systemPrompt: "system",
3057+
messages: [],
3058+
tools: [
3059+
{
3060+
name: "read",
3061+
description: "Read file",
3062+
parameters: {
3063+
type: "object",
3064+
additionalProperties: false,
3065+
properties: { path: { type: "string" } },
3066+
required: [],
3067+
},
3068+
},
3069+
],
3070+
} as never;
3071+
3072+
const first = isolatedBuildOpenAIResponsesParams(model, context, undefined) as {
3073+
tools?: Array<{ strict?: boolean }>;
3074+
};
3075+
const second = isolatedBuildOpenAIResponsesParams(model, context, undefined) as {
3076+
tools?: Array<{ strict?: boolean }>;
3077+
};
3078+
3079+
expect(first.tools?.[0]?.strict).toBe(false);
3080+
expect(second.tools?.[0]?.strict).toBe(false);
3081+
expect(
3082+
debug.mock.calls.filter(
3083+
([message]) =>
3084+
typeof message === "string" &&
3085+
message.includes("tool schema strict mode downgraded to strict=false"),
3086+
),
3087+
).toHaveLength(1);
3088+
} finally {
3089+
vi.doUnmock("../logging/subsystem.js");
3090+
vi.resetModules();
3091+
}
3092+
});
3093+
30183094
it("omits responses strict tool shaping for proxy-like OpenAI routes", () => {
30193095
const params = buildOpenAIResponsesParams(
30203096
{

src/agents/openai-transport-stream.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@ const AZURE_RESPONSES_FIRST_EVENT_TIMEOUT_MS = 30_000;
7878
const MODEL_STREAM_COOPERATIVE_YIELD_INTERVAL_MS = 12;
7979
const MODEL_STREAM_COOPERATIVE_YIELD_MAX_EVENTS = 64;
8080
const RESPONSE_FAILED_NO_DETAILS_MESSAGE = "Unknown error (no error details in response)";
81+
const MAX_OPENAI_STRICT_TOOL_DOWNGRADE_DIAGNOSTIC_KEYS = 256;
8182
const log = createSubsystemLogger("openai-transport");
83+
const loggedOpenAIStrictToolDowngradeDiagnosticKeys = new Set<string>();
8284

8385
type ReplayableResponseOutputMessage = Omit<ResponseOutputMessage, "id"> & { id?: string };
8486
type ReplayableResponseReasoningItem = Omit<ResponseReasoningItem, "id"> & { id?: string };
@@ -976,6 +978,9 @@ function resolveOpenAIStrictToolFlagWithDiagnostics(
976978
const strict = resolveOpenAIStrictToolFlagForInventory(tools, strictSetting);
977979
if (strictSetting === true && strict === false && log.isEnabled("debug", "any")) {
978980
const diagnostics = findOpenAIStrictToolSchemaDiagnostics(tools);
981+
if (!shouldLogOpenAIStrictToolDowngradeDiagnostic(diagnostics, context)) {
982+
return strict;
983+
}
979984
const sample = diagnostics.slice(0, 5).map((entry) => ({
980985
tool: entry.toolName ?? `tool[${entry.toolIndex}]`,
981986
violations: entry.violations.slice(0, 8),
@@ -996,6 +1001,44 @@ function resolveOpenAIStrictToolFlagWithDiagnostics(
9961001
return strict;
9971002
}
9981003

1004+
function buildOpenAIStrictToolDowngradeDiagnosticKey(
1005+
diagnostics: ReturnType<typeof findOpenAIStrictToolSchemaDiagnostics>,
1006+
context: { transport: "responses" | "completions"; model: OpenAIModeModel },
1007+
): string {
1008+
return createHash("sha256")
1009+
.update(
1010+
JSON.stringify({
1011+
transport: context.transport,
1012+
provider: context.model.provider ?? null,
1013+
model: context.model.id ?? null,
1014+
diagnostics: diagnostics.map((entry) => ({
1015+
toolIndex: entry.toolIndex,
1016+
toolName: entry.toolName ?? null,
1017+
violations: entry.violations,
1018+
})),
1019+
}),
1020+
)
1021+
.digest("hex");
1022+
}
1023+
1024+
function shouldLogOpenAIStrictToolDowngradeDiagnostic(
1025+
diagnostics: ReturnType<typeof findOpenAIStrictToolSchemaDiagnostics>,
1026+
context: { transport: "responses" | "completions"; model: OpenAIModeModel },
1027+
): boolean {
1028+
const key = buildOpenAIStrictToolDowngradeDiagnosticKey(diagnostics, context);
1029+
if (loggedOpenAIStrictToolDowngradeDiagnosticKeys.has(key)) {
1030+
return false;
1031+
}
1032+
if (
1033+
loggedOpenAIStrictToolDowngradeDiagnosticKeys.size >=
1034+
MAX_OPENAI_STRICT_TOOL_DOWNGRADE_DIAGNOSTIC_KEYS
1035+
) {
1036+
loggedOpenAIStrictToolDowngradeDiagnosticKeys.clear();
1037+
}
1038+
loggedOpenAIStrictToolDowngradeDiagnosticKeys.add(key);
1039+
return true;
1040+
}
1041+
9991042
function createResponsesFirstEventTimeoutError(model: Model<Api>, timeoutMs: number): Error {
10001043
return new Error(
10011044
`Azure OpenAI Responses stream did not deliver a first event within ${timeoutMs}ms after HTTP streaming headers. ` +

0 commit comments

Comments
 (0)