mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-26 07:55:14 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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. ` +
|
||||
|
||||
Reference in New Issue
Block a user