fix(openai): avoid mutating Responses tool schemas - #1951
rosetta-livekit-bot[bot] wants to merge 1 commit into
Conversation
🦋 Changeset detectedLatest commit: 3b94a14 The changes in this PR will be included in the next version bump. This PR includes changesets to release 35 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Close this PR. The bug it ports does not exist in this repo. The port is faithful to the source diff, CI is green, and the changed line is harmless. It also corrects nothing: the TypeScript Responses conversion never wrote into a tool schema, so the copy protects against a mutation that no code here performs. Blocking The Responses payload never writes into the tool schema. Python splits function tools into two kinds. For a agents-js/plugins/openai/src/tool_utils.ts Lines 14 to 30 in 1a00d8e The new test says the same thing. Run it against Evidence is in the details below: three tests that pass on both branches, and a live call to the Responses API that returns the raw schema unchanged. Non-blocking
Mechanism, tests, and the separate strict-schema bugHow the two repos differPython, after livekit/agents#6305: for tool in tool_ctx.flatten():
if isinstance(tool, llm.RawFunctionTool):
schema = {**tool.info.raw_schema, "type": "function"} # the payload IS the stored dict
schemas.append(schema)
elif isinstance(tool, llm.FunctionTool):
schema = llm.utils.build_legacy_openai_schema(tool, internally_tagged=True)TypeScript, on const oaiParams = {
type: 'function' as const, // outer object
name,
description: tool.description,
parameters: llm.toJsonSchema(...), // the stored schema, one level down
} as OpenAI.Responses.FunctionTool;
if (strictToolSchema) {
oaiParams.strict = true; // outer object again
}The one thing the two share is aliasing. The repo already guards the one place that does write. The copy in this PR is also one level deep. On the branch, The testsSave as // SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
import { llm } from '@livekit/agents';
import { describe, expect, it } from 'vitest';
import { toResponsesTools } from './tool_utils.js';
describe('toResponsesTools raw schema pollution (livekit/agents#6305)', () => {
const makeTool = () => {
const rawSchema = {
type: 'object' as const,
properties: { city: { type: 'string' as const } },
required: ['city'],
};
const snapshot = JSON.parse(JSON.stringify(rawSchema));
const fn = llm.tool({
name: 'lookup_weather',
description: 'Look up weather',
parameters: rawSchema,
execute: async () => 'sunny',
});
return { rawSchema, snapshot, fn };
};
it('leaves the stored raw schema untouched after a Responses conversion', () => {
const { rawSchema, snapshot, fn } = makeTool();
toResponsesTools(new llm.ToolContext([fn]), true);
// The Python bug: the stored schema gained a provider-format "type" key.
expect((rawSchema as Record<string, unknown>).type).toBe('object');
expect(JSON.parse(JSON.stringify(rawSchema))).toEqual(snapshot);
});
it('leaves a later chat-completions conversion clean', () => {
const { rawSchema, snapshot, fn } = makeTool();
toResponsesTools(new llm.ToolContext([fn]), true);
// What every other provider path reads afterwards.
expect(llm.toJsonSchema(fn.parameters!, true, false)).toEqual(snapshot);
expect(rawSchema).toEqual(snapshot);
});
it('nested subschemas are shared with the payload on both branches', () => {
const { rawSchema, fn } = makeTool();
const tools = toResponsesTools(new llm.ToolContext([fn]), true);
const parameters = (tools?.[0] as { parameters?: Record<string, unknown> }).parameters!;
// The shallow spread copies the top level only; `properties` is still the caller's object.
expect(parameters.properties).toBe(rawSchema.properties);
});
});The PR's own test against Line 54 is the mutation assertion. It passes. The separate strict-schema bug
Python does not have this. Its A playground that shows both observables is in the worktree at Checked and found fineSeven call sites read
If you want to keep somethingThe first test above is a real regression guard for the invariant the source PR describes. It can land on its own, without the copy and without a changeset. |
Summary
@livekit/agents-plugin-openaiTesting
pnpm buildpnpm test -- plugins/openai/src/tool_utils.test.tsPorted from livekit/agents#6305
Original PR description
This PR prevents the OpenAI Responses provider-format conversion from mutating a raw tool schema object that may be reused by later provider conversions.
What Problem This Solves
to_responses_fnc_ctx()currently handlesRawFunctionToolby assigning the stored schema directly:schemais the same dict object astool.info.raw_schema, so adding the Responses-specific"type": "function"field mutates the tool's stored raw schema in place.This is order-dependent. If the same raw tool is first converted for
openai.responsesand then reused for regular OpenAI Chat Completions, the later Chat Completions conversion sees a raw schema that has already been polluted with a provider-format field.Responses and Chat Completions use different tool payload shells:
The bug is not that Responses needs
"type": "function"; the bug is that the formatter writes that field onto the persistent raw schema instead of a temporary payload copy.Changes
Copy the raw schema before adding the Responses-specific type field:
This keeps the generated Responses payload unchanged while preserving
RawFunctionTool.info.raw_schemafor later conversions, caller inspection, and reuse.This PR only changes raw function tool handling in the OpenAI Responses provider-format path. It does not change normal
FunctionToolschema generation, provider tools, chat message conversion, or OpenAI Chat Completions formatting.Evidence
A focused regression test now covers a single raw function tool reused across provider formats:
Before this fix, the Responses conversion added
"type"toraw_tool_1.info.raw_schema. After this fix, the Responses payload still includes"type": "function", but the raw schema object remains unchanged.Possible call chain / impact
The affected surface is raw tool schema conversion for OpenAI Responses.
Sibling surfaces checked:
openai.to_fnc_ctx()wraps raw schemas for Chat Completions but does not write provider-specific keys into the stored raw schema.FunctionToolconversion builds fresh schemas through helper functions and is not affected.to_dict()and are outside this raw-schema mutation path.Validation
All commands passed locally. A broader
uv run --no-sync pytest --unit tests/test_tools.py -k responses_raw_schema -qattempt was blocked during unrelated unit-test collection because this local Windows environment does not have several provider test dependencies installed, such aslivekit.plugins,langchain_core, andgoogle.genai.