Skip to content

fix(openai): avoid mutating Responses tool schemas - #1951

Closed
rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
vilified-loin-smoky
Closed

rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
vilified-loin-smoky

Conversation

@rosetta-livekit-bot

@rosetta-livekit-bot rosetta-livekit-bot Bot commented Jul 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • copy JSON schema objects before including them in OpenAI Responses tool payloads
  • add coverage that raw JSON schemas are not reused or mutated during Responses tool conversion
  • add a patch changeset for @livekit/agents-plugin-openai

Testing

  • pnpm build
  • pnpm test -- plugins/openai/src/tool_utils.test.ts

Ported 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 handles RawFunctionTool by assigning the stored schema directly:

schema = tool.info.raw_schema
schema["type"] = "function"
schemas.append(schema)

schema is the same dict object as tool.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.responses and 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:

# Responses-style function tool
{
    "type": "function",
    "name": "...",
    "parameters": {...},
}

# Chat Completions-style tool
{
    "type": "function",
    "function": {
        "name": "...",
        "parameters": {...},
    },
}

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:

schema = {**tool.info.raw_schema, "type": "function"}

This keeps the generated Responses payload unchanged while preserving RawFunctionTool.info.raw_schema for 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 FunctionTool schema 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:

ctx = ToolContext([raw_tool_1])
raw_schema = raw_tool_1.info.raw_schema.copy()

responses_tools = ctx.parse_function_tools("openai.responses")

assert responses_tools[0]["type"] == "function"
assert raw_tool_1.info.raw_schema == raw_schema
assert "type" not in raw_tool_1.info.raw_schema

chat_tools = ctx.parse_function_tools("openai")
assert chat_tools[0] == {
    "type": "function",
    "function": raw_tool_1.info.raw_schema,
}
assert "type" not in chat_tools[0]["function"]

Before this fix, the Responses conversion added "type" to raw_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

User-defined @function_tool(raw_schema=...)
  -> ToolContext([...])
  -> OpenAI Responses request
  -> ToolContext.parse_function_tools("openai.responses")
  -> openai.to_responses_fnc_ctx()
  -> schema = tool.info.raw_schema
  -> schema["type"] = "function"
  -> same RawFunctionTool reused later
  -> ToolContext.parse_function_tools("openai")
  -> openai.to_fnc_ctx()
  -> {"type": "function", "function": polluted_raw_schema}

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.
  • FunctionTool conversion builds fresh schemas through helper functions and is not affected.
  • Provider tools are appended through to_dict() and are outside this raw-schema mutation path.

Validation

uv run --no-sync pytest tests/test_tools.py::TestToolContext::test_openai_responses_raw_schema_does_not_mutate_tool_schema -q
uv run --no-sync ruff check livekit-agents/livekit/agents/llm/_provider_format/openai.py tests/test_tools.py
uv run --no-sync ruff format --check livekit-agents/livekit/agents/llm/_provider_format/openai.py tests/test_tools.py
git diff --check

All commands passed locally. A broader uv run --no-sync pytest --unit tests/test_tools.py -k responses_raw_schema -q attempt was blocked during unrelated unit-test collection because this local Windows environment does not have several provider test dependencies installed, such as livekit.plugins, langchain_core, and google.genai.

@changeset-bot

changeset-bot Bot commented Jul 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3b94a14

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 35 packages
Name Type
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-xai Patch
@livekit/agents Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-hedra Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugins-test Patch

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

@rosetta-livekit-bot
rosetta-livekit-bot Bot requested a review from longcw July 3, 2026 08:32

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@longcw

longcw commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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 RawFunctionTool, the stored raw_schema dict is the whole Responses payload, so the source function added its "type": "function" key directly to the object the tool keeps. This repo has no RawFunctionTool. A raw JSON schema is only the parameters field of an ordinary function tool, and toResponsesTools nests it one level down. The keys type and strict land on the outer object literal, not on the schema.

const functionTools = llm.sortedToolEntries(toolCtx).map(([name, tool]) => {
const oaiParams = {
type: 'function' as const,
name,
description: tool.description,
parameters: llm.toJsonSchema(
tool.parameters,
true,
strictToolSchema,
) as unknown as OpenAI.Responses.FunctionTool['parameters'],
} as OpenAI.Responses.FunctionTool;
if (strictToolSchema) {
oaiParams.strict = true;
}
return oaiParams;

The new test says the same thing. Run it against main and one line fails, the identity assertion .not.toBe(rawSchema), where Vitest prints "Compared values have no visual difference". The mutation assertion expect(rawSchema).toEqual(originalSchema) passes on main already. The test pins object identity, which nothing reads, and not the invariant the source PR was about.

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

  • The changeset publishes a patch of all 35 packages with a changelog line for a fix that never applied.
  • Raw JSON schema plus strictToolSchema: true is rejected by OpenAI with a 400. This is pre-existing, it is not from this PR, and it deserves its own issue.
Mechanism, tests, and the separate strict-schema bug

How the two repos differ

Python, 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 main:

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. llm.toJsonSchema returns the caller's object when the schema is not a Zod schema, so the stored schema does travel into the payload by reference. Aliasing alone is not the bug. The bug was the write, and there is no write here.

The repo already guards the one place that does write. inference/llm.ts strips $schema by destructuring, and its comment gives the reason: "Destructure instead of delete so a caller-supplied raw JSON schema object isn't mutated." The OpenAI plugin's chat-completions path is that same class, so the cross-provider scenario in the source description is already covered.

The copy in this PR is also one level deep. On the branch, parameters.properties is still the caller's object, so a later write one level down reaches the tool definition exactly as before.

The tests

Save as plugins/openai/src/pr1951_diff.test.ts. All three pass on main and on this branch.

// 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 main:

FAIL plugins/openai/src/tool_utils.test.ts > toResponsesTools > does not mutate raw JSON schemas
AssertionError: expected { type: 'object', …(2) } not to be { type: 'object', …(2) } // Object.is equality

Compared values have no visual difference.

 ❯ plugins/openai/src/tool_utils.test.ts:53:82
      52|     expect(tools?.[0]).toMatchObject({ type: 'function', parameters: r…
      53|     expect((tools?.[0] as { parameters?: unknown } | undefined)?.param…
      54|     expect(rawSchema).toEqual(originalSchema);

Line 54 is the mutation assertion. It passes.

The separate strict-schema bug

llm.toJsonSchema applies the strict transform to Zod schemas only. A raw JSON schema returns unchanged, and toResponsesTools still sets strict: true on the payload. strictToolSchema defaults to true on both Responses transports, so a raw JSON schema tool fails out of the box:

APIStatusError: 400 Invalid schema for function 'lookup_weather':
  In context=(), 'additionalProperties' is required to be supplied and to be false.

Python does not have this. Its to_responses_fnc_ctx accepts a strict argument and never emits a strict field for function tools. The same gap exists in inference/llm.ts, where strictToolSchema defaults to false, so the Responses path is the exposed one.

A playground that shows both observables is in the worktree at examples/src/test_responses_raw_schema.ts. It needs OPENAI_API_KEY only.

strict: false       : tool call lookup_weather{"city":"Paris"}
raw schema before   : {"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}
raw schema after    : {"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}
unchanged           : true

now the Responses default, strictToolSchema: true —
APIStatusError: 400 Invalid schema for function 'lookup_weather' ...

Checked and found fine

Seven call sites read llm.toJsonSchema. None of them writes into the object it returns:

  • agents/src/inference/llm.ts destructures $schema away, with the comment quoted above.
  • plugins/google/src/utils.ts takes a deep copy, because the Google library mutates.
  • plugins/anthropic, plugins/mistralai, plugins/phonic, plugins/openai/src/realtime, and plugins/openai/src/tool_utils.ts embed the object in a new literal and write nothing.

zodSchemaToJsonSchema holds no cache and builds a new object per call, so a Zod tool has no shared object to protect.

If you want to keep something

The 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.

@longcw longcw closed this Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants