Skip to content

fix(responses): keep tool_result next to tool_use on Anthropic previous_response_id continuations - #45322

Merged
mateo-berri merged 4 commits into
mainfrom
litellm_responses_continuation_instructions
Oct 8, 2026
Merged

mateo-berri merged 4 commits into
mainfrom
litellm_responses_continuation_instructions

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Anthropic /v1/responses follow-ups with previous_response_id fail with a 400
  • Happens when the follow-up sends a tool result plus instructions and reaches the proxy worker that served the first turn, so it hits most follow-ups on a multi-worker proxy and every follow-up on a single-worker one

How it solves it:

  • The follow-up's instructions replace the earlier turns' instructions and go first
  • A follow-up with no instructions keeps the latest earlier instructions, sent once at the top instead of once per replayed turn
  • So the tool result lands right after its tool_use again

Intentional product change: a follow-up that sends instructions now replaces the earlier turns' instructions instead of adding its own behind them. This follows OpenAI's docs, where instructions from a previous response are not carried over with previous_response_id. A follow-up that sends none keeps the earlier instructions, where OpenAI drops them: that is the one deliberate difference, so a client that never resends instructions keeps its system prompt

User Flow

Before: a developer running a tool-calling agent on Claude through the Responses API cannot send the tool result back

  1. They send POST http://localhost:4000/v1/responses with a question, instructions, and a get_weather tool
  2. The response comes back completed with a function_call and a call_id like toolu_01...
  3. They send POST http://localhost:4000/v1/responses with previous_response_id, a function_call_output for that call_id, and the same instructions
  4. They get a 400: "tool_use ids were found without tool_result blocks immediately after" whenever the follow-up reaches the proxy worker that served the first turn

After: the same follow-up comes back with the model's answer using the tool result

  1. They send POST http://localhost:4000/v1/responses with a question, instructions, and a get_weather tool
  2. The response comes back completed with a function_call and a call_id like toolu_01...
  3. They send POST http://localhost:4000/v1/responses with previous_response_id, a function_call_output for that call_id, and the same instructions
  4. They get a completed response whose text uses the tool result

Affected release

Regression since v1.104.0, introduced by #42630, which keeps a later system message inside the Anthropic conversation instead of hoisting it into the top-level system prompt. The continuation put the new instructions system message between the replayed tool_use and its tool result, so it split them apart

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/unit/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Both legs ran the same three-turn flow through the OpenAI Python SDK (2.33.0), the client the e2e test that caught this uses, against the real Anthropic API. Each leg booted two proxy instances from its commit, each with --num_workers 2 --use_v2_migration_resolver, sharing one fresh Postgres database, with this config:

model_list:
  - model_name: claude-sonnet-5-5
    litellm_params:
      model: anthropic/claude-sonnet-5-5
      api_key: os.environ/ANTHROPIC_API_KEY
  - model_name: claude-haiku-4-5
    litellm_params:
      model: anthropic/claude-haiku-4-5
      api_key: os.environ/ANTHROPIC_API_KEY

general_settings:
  master_key: os.environ/LITELLM_MASTER_KEY
  store_prompts_in_spend_logs: true

sdk_flow.py <turn1_and_2_url> <turn3_url> <model> <trials> is byte-identical in both legs. Turn 1 asks for the weather with instructions and a tool. Turn 2 sends the tool result with previous_response_id and the same instructions, which is the flow that broke. Turn 3 asks a follow-up with no instructions, the case Bugbot flagged on an earlier commit of this PR. The instructions ask for every reply to end in BANANA, so a turn 3 ending in BANANA shows the earlier instructions still apply

import os
import sys
import time

import openai

INSTRUCTIONS = "Always end your reply with the single word BANANA."
TOOLS = [
    {
        "type": "function",
        "name": "get_weather",
        "description": "Get the current weather for a city",
        "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
    }
]


def describe_error(exc):
    status = getattr(exc, "status_code", None)
    return f"{type(exc).__name__} HTTP {status}: {exc}"


def main():
    url12, url3, model, trials = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
    key = os.environ["LITELLM_KEY"]
    client12 = openai.OpenAI(base_url=url12, api_key=key, max_retries=0, timeout=120)
    client3 = openai.OpenAI(base_url=url3, api_key=key, max_retries=0, timeout=120)
    turn2_failures = 0
    turn3_failures = 0
    turn3_attempts = 0
    banana = 0
    turn3_completed = 0
    for n in range(1, trials + 1):
        try:
            r1 = client12.responses.create(
                model=model,
                input="What is the weather in Tokyo? Call get_weather.",
                instructions=INSTRUCTIONS,
                tools=TOOLS,
                tool_choice="auto",
            )
        except openai.APIError as exc:
            print(f"trial {n} turn1: {describe_error(exc)}", flush=True)
            continue
        call_id = next((item.call_id for item in r1.output if item.type == "function_call"), None)
        print(f"trial {n} turn1: {r1.status} call_id={call_id} id={r1.id}", flush=True)
        if call_id is None:
            print(f"trial {n} turn1: no function_call, skipping trial", flush=True)
            continue
        time.sleep(5)
        try:
            r2 = client12.responses.create(
                model=model,
                previous_response_id=r1.id,
                input=[
                    {
                        "type": "function_call_output",
                        "call_id": call_id,
                        "output": "Distinctive forecast: 47 degrees Celsius",
                    }
                ],
                instructions=INSTRUCTIONS,
                tools=TOOLS,
            )
        except openai.APIError as exc:
            turn2_failures += 1
            print(f"trial {n} turn2: {describe_error(exc)}", flush=True)
            continue
        print(f"trial {n} turn2: {r2.status} -> {r2.output_text!r}", flush=True)
        time.sleep(20)
        turn3_attempts += 1
        try:
            r3 = client3.responses.create(
                model=model,
                previous_response_id=r2.id,
                input="Which city was that forecast for?",
                tools=TOOLS,
            )
        except openai.APIError as exc:
            turn3_failures += 1
            print(f"trial {n} turn3: {describe_error(exc)}", flush=True)
            continue
        turn3_completed += 1
        text = r3.output_text
        if text.strip().rstrip(".!*_ \n").upper().endswith("BANANA"):
            banana += 1
        print(f"trial {n} turn3: {r3.status} -> {text!r}", flush=True)
    print(f"turn2 failures: {turn2_failures}/{trials}")
    print(f"turn3 failures: {turn3_failures}/{turn3_attempts}")
    print(f"turn3 ending in BANANA: {banana}/{turn3_completed}")


if __name__ == "__main__":
    main()

The bug needs the follow-up to reach the worker that served the tool call, so Cases A and B keep every turn on one instance (2 workers) and Case C sends turn 3 to the other instance. claude-haiku-4-5 is the model the e2e test uses, and claude-sonnet-5-5 is the latest Sonnet

Before (057034d, merge base)

.venv/bin/python sdk_flow.py http://127.0.0.1:57786/v1 http://127.0.0.1:57786/v1 claude-haiku-4-5 6
trial 1 turn1: completed call_id=toolu_013yBqRUGm3n9FUjWDnN718A id=resp_7YtAwQ6j...
trial 1 turn2: BadRequestError HTTP 400: Error code: 400 - {'error': {'message': 'litellm.BadRequestError: AnthropicException - {"type":"error","error":{"type":"invalid_request_error","message":"messages.2: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_013yBqRUGm3n9FUjWDnN718A. Each `tool_use` block must have a corresponding `tool_result` block in the next message."},"request_id":"req_011CfpxP8CQqmLjFKeJmq3FA"}\n\nLiteLLM: model group \'claude-haiku-4-5\' failed with the error above. No fallback was attempted.', 'type': 'invalid_request_error', 'param': None, 'code': '400'}}
trial 2 turn1: completed call_id=toolu_01R9TG4FFy9xmcXdYgTfCZUQ id=resp_sMmoeWkk...
trial 2 turn2: completed -> 'The weather in Tokyo is currently showing a distinctive forecast of 47 degrees Celsius, which is quite hot!\n\nBANANA'
trial 2 turn3: completed -> 'That forecast was for Tokyo, the city you asked me to check the weather for.\n\nBANANA'
trial 3 turn1: completed call_id=toolu_016dTyZNu2Dn1ih8torK7byA id=resp_PI8vaQzy...
trial 3 turn2: completed -> "The weather in Tokyo is currently 47 degrees Celsius, which is quite hot! That's approximately 117 degrees Fahrenheit.\n\nBANANA"
trial 3 turn3: BadRequestError HTTP 400: Error code: 400 - {'error': {'message': 'litellm.BadRequestError: AnthropicException - {"type":"error","error":{"type":"invalid_request_error","message":"messages.2: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_016dTyZNu2Dn1ih8torK7byA. Each `tool_use` block must have a corresponding `tool_result` block in the next message."},"request_id":"req_011CfpxTAzCwKbTHxy17S7ZW"}\n\nLiteLLM: model group \'claude-haiku-4-5\' failed with the error above. No fallback was attempted.', 'type': 'invalid_request_error', 'param': None, 'code': '400'}}
trial 4 turn1: completed call_id=toolu_01Pm1xDJTDqkeLXYFSbZoNk6 id=resp_GY0Dstyq...
trial 4 turn2: BadRequestError HTTP 400: Error code: 400 - {'error': {'message': 'litellm.BadRequestError: AnthropicException - {"type":"error","error":{"type":"invalid_request_error","message":"messages.2: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_01Pm1xDJTDqkeLXYFSbZoNk6. Each `tool_use` block must have a corresponding `tool_result` block in the next message."},"request_id":"req_011CfpxTdBY9uERZ5DP4mwQR"}\n\nLiteLLM: model group \'claude-haiku-4-5\' failed with the error above. No fallback was attempted.', 'type': 'invalid_request_error', 'param': None, 'code': '400'}}
trial 5 turn1: completed call_id=toolu_01Mtvesyyn2y4ThH2CqaJNw5 id=resp_NkZ8jLcw...
trial 5 turn2: BadRequestError HTTP 400: Error code: 400 - {'error': {'message': 'litellm.BadRequestError: AnthropicException - {"type":"error","error":{"type":"invalid_request_error","message":"messages.2: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_01Mtvesyyn2y4ThH2CqaJNw5. Each `tool_use` block must have a corresponding `tool_result` block in the next message."},"request_id":"req_011CfpxU4ptvawxucZyCdr18"}\n\nLiteLLM: model group \'claude-haiku-4-5\' failed with the error above. No fallback was attempted.', 'type': 'invalid_request_error', 'param': None, 'code': '400'}}
trial 6 turn1: completed call_id=toolu_01JEfa2X7ESU3sBS6Aovp6X4 id=resp_nqgAdOCz...
trial 6 turn2: BadRequestError HTTP 400: Error code: 400 - {'error': {'message': 'litellm.BadRequestError: AnthropicException - {"type":"error","error":{"type":"invalid_request_error","message":"messages.2: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_01JEfa2X7ESU3sBS6Aovp6X4. Each `tool_use` block must have a corresponding `tool_result` block in the next message."},"request_id":"req_011CfpxUWRHNiuPn7WhX88HU"}\n\nLiteLLM: model group \'claude-haiku-4-5\' failed with the error above. No fallback was attempted.', 'type': 'invalid_request_error', 'param': None, 'code': '400'}}
turn2 failures: 4/6
turn3 failures: 1/2
turn3 ending in BANANA: 1/1
.venv/bin/python sdk_flow.py http://127.0.0.1:57786/v1 http://127.0.0.1:57786/v1 claude-sonnet-5-5 6
trial 1 turn1: completed call_id=toolu_01V8aGyQtAG7hDQMUdWu6Ct3 id=resp_LqWPKQRx...
trial 1 turn2: BadRequestError HTTP 400: Error code: 400 - {'error': {'message': 'litellm.BadRequestError: AnthropicException - {"type":"error","error":{"type":"invalid_request_error","message":"messages.2: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_01V8aGyQtAG7hDQMUdWu6Ct3. Each `tool_use` block must have a corresponding `tool_result` block in the next message."},"request_id":"req_011CfpxP8DuFaBZnJejTGzym"}\n\nLiteLLM: model group \'claude-sonnet-5-5\' failed with the error above. No fallback was attempted.', 'type': 'invalid_request_error', 'param': None, 'code': '400'}}
trial 2 turn1: completed call_id=toolu_011fBeA7aEPZvHhdANCK5488 id=resp_3CyMFEc3...
trial 2 turn2: BadRequestError HTTP 400: Error code: 400 - {'error': {'message': 'litellm.BadRequestError: AnthropicException - {"type":"error","error":{"type":"invalid_request_error","message":"messages.2: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_011fBeA7aEPZvHhdANCK5488. Each `tool_use` block must have a corresponding `tool_result` block in the next message."},"request_id":"req_011CfpxPbCP2UHookuWbj2xY"}\n\nLiteLLM: model group \'claude-sonnet-5-5\' failed with the error above. No fallback was attempted.', 'type': 'invalid_request_error', 'param': None, 'code': '400'}}
trial 3 turn1: completed call_id=toolu_01PrwE94oebTE3wCXyCenxMX id=resp_NputHpLv...
trial 3 turn2: BadRequestError HTTP 400: Error code: 400 - {'error': {'message': 'litellm.BadRequestError: AnthropicException - {"type":"error","error":{"type":"invalid_request_error","message":"messages.2: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_01PrwE94oebTE3wCXyCenxMX. Each `tool_use` block must have a corresponding `tool_result` block in the next message."},"request_id":"req_011CfpxQ3bs9Df1e5M5UJ7MU"}\n\nLiteLLM: model group \'claude-sonnet-5-5\' failed with the error above. No fallback was attempted.', 'type': 'invalid_request_error', 'param': None, 'code': '400'}}
trial 4 turn1: completed call_id=toolu_01VnRs1MGHoSkjxTjGD3cpYz id=resp_4sidm7Em...
trial 4 turn2: BadRequestError HTTP 400: Error code: 400 - {'error': {'message': 'litellm.BadRequestError: AnthropicException - {"type":"error","error":{"type":"invalid_request_error","message":"messages.2: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_01VnRs1MGHoSkjxTjGD3cpYz. Each `tool_use` block must have a corresponding `tool_result` block in the next message."},"request_id":"req_011CfpxQViFDXcb62Dg6EW4T"}\n\nLiteLLM: model group \'claude-sonnet-5-5\' failed with the error above. No fallback was attempted.', 'type': 'invalid_request_error', 'param': None, 'code': '400'}}
trial 5 turn1: completed call_id=toolu_01GzMACiyrCPxFVL8giJJeAj id=resp_fr1cjR00...
trial 5 turn2: BadRequestError HTTP 400: Error code: 400 - {'error': {'message': 'litellm.BadRequestError: AnthropicException - {"type":"error","error":{"type":"invalid_request_error","message":"messages.2: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_01GzMACiyrCPxFVL8giJJeAj. Each `tool_use` block must have a corresponding `tool_result` block in the next message."},"request_id":"req_011CfpxQxJeaju74sr6HHZvj"}\n\nLiteLLM: model group \'claude-sonnet-5-5\' failed with the error above. No fallback was attempted.', 'type': 'invalid_request_error', 'param': None, 'code': '400'}}
trial 6 turn1: completed call_id=toolu_01EyQuK5AMGJw5cr2SJXCAF1 id=resp_FfUI6xcl...
trial 6 turn2: BadRequestError HTTP 400: Error code: 400 - {'error': {'message': 'litellm.BadRequestError: AnthropicException - {"type":"error","error":{"type":"invalid_request_error","message":"messages.2: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_01EyQuK5AMGJw5cr2SJXCAF1. Each `tool_use` block must have a corresponding `tool_result` block in the next message."},"request_id":"req_011CfpxRRVmTB7WAgjYhVAom"}\n\nLiteLLM: model group \'claude-sonnet-5-5\' failed with the error above. No fallback was attempted.', 'type': 'invalid_request_error', 'param': None, 'code': '400'}}
turn2 failures: 6/6
turn3 failures: 0/0
turn3 ending in BANANA: 0/0
.venv/bin/python sdk_flow.py http://127.0.0.1:57786/v1 http://127.0.0.1:45761/v1 claude-haiku-4-5 2
trial 1 turn1: completed call_id=toolu_01SWVNuPDWWfYn9kgHkTfMmj id=resp_QSHjnLY7...
trial 1 turn2: BadRequestError HTTP 400: Error code: 400 - {'error': {'message': 'litellm.BadRequestError: AnthropicException - {"type":"error","error":{"type":"invalid_request_error","message":"messages.2: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_01SWVNuPDWWfYn9kgHkTfMmj. Each `tool_use` block must have a corresponding `tool_result` block in the next message."},"request_id":"req_011CfpxW2VoQWQFDRZ6FfWgm"}\n\nLiteLLM: model group \'claude-haiku-4-5\' failed with the error above. No fallback was attempted.', 'type': 'invalid_request_error', 'param': None, 'code': '400'}}
trial 2 turn1: completed call_id=toolu_01UyVdbyRwQfwHFV7ohxgFpf id=resp_RS3ZY79S...
trial 2 turn2: completed -> "The weather in Tokyo is currently 47 degrees Celsius, which is quite hot! That's approximately 117 degrees Fahrenheit.\n\nBANANA"
trial 2 turn3: completed -> "The forecast was for Tokyo. That's the city you asked me to check the weather for in your previous request.\n\nBANANA"
turn2 failures: 1/2
turn3 failures: 0/1
turn3 ending in BANANA: 1/1

After (bb51cae)

.venv/bin/python sdk_flow.py http://127.0.0.1:52415/v1 http://127.0.0.1:52415/v1 claude-haiku-4-5 6
trial 1 turn1: completed call_id=toolu_01Qvhf4h5Rt1okEfeLDe8Rgt id=resp_t_rVTigQ0N...
trial 1 turn2: completed -> "The weather in Tokyo is currently showing a distinctive forecast of 47 degrees Celsius (approximately 116.6 degrees Fahrenheit). That's quite hot!\n\nBANANA"
trial 1 turn3: completed -> 'That forecast was for Tokyo. You asked me to get the weather in Tokyo, and I called the get_weather function with "Tokyo" as the city parameter.\n\nBANANA'
trial 2 turn1: completed call_id=toolu_01D6xKdAduRR4XVeaFeNuQfk id=resp__wSGS5Z6_0...
trial 2 turn2: completed -> "The current weather in Tokyo shows a temperature of 47 degrees Celsius (approximately 116.6 degrees Fahrenheit). That's quite hot! This appears to be an unusual forecast, as Tokyo doesn't typically reach such extreme temperatures. It would be advisable to stay hydrated and take precautions if this forecast is accurate.\n\nBANANA"
trial 2 turn3: completed -> "The forecast I retrieved was for **Tokyo**. That's the city you asked me to check the weather for in your previous request.\n\nBANANA"
trial 3 turn1: completed call_id=toolu_012XJtgAfSFedGYY8vR736VV id=resp_vaRc_EX3jt...
trial 3 turn2: completed -> "The current weather in Tokyo is **47 degrees Celsius** (approximately 117 degrees Fahrenheit). That's quite hot! Make sure to stay hydrated and take precautions against the heat if you're planning to visit.\n\nBANANA"
trial 3 turn3: completed -> 'That forecast was for **Tokyo**, which is the city you asked me to check the weather for in your previous request.\n\nBANANA'
trial 4 turn1: completed call_id=toolu_01SDCu9VvUJmv1TDyXePiyMz id=resp_GusWVdCsLT...
trial 4 turn2: completed -> "The current weather in Tokyo is 47 degrees Celsius (approximately 117 degrees Fahrenheit). That's quite hot!\n\nBANANA"
trial 4 turn3: completed -> "The forecast was for Tokyo. That's the city you asked me to check the weather for in your previous request.\n\nBANANA"
trial 5 turn1: completed call_id=toolu_01FENWVNAWzcL4GZQQMBfR7g id=resp_GOoSaNAiPV...
trial 5 turn2: completed -> "The weather in Tokyo is currently showing a distinctive forecast of 47 degrees Celsius (approximately 117 degrees Fahrenheit). That's quite hot!\n\nBANANA"
trial 5 turn3: completed -> 'That forecast was for Tokyo. You asked me to check the weather in Tokyo, and I called the get_weather function with Tokyo as the city parameter.\n\nBANANA'
trial 6 turn1: completed call_id=toolu_011ZqHkpukhKqCzCk2MecaMb id=resp_7a317tffBx...
trial 6 turn2: completed -> "The current weather in Tokyo is **47 degrees Celsius** (approximately 117°F). That's quite hot! Remember to stay hydrated and take precautions against the heat.\n\nBANANA"
trial 6 turn3: completed -> 'The forecast was for **Tokyo**, which is the city you asked me to check the weather for in your previous request.\n\nBANANA'
turn2 failures: 0/6
turn3 failures: 0/6
turn3 ending in BANANA: 6/6
.venv/bin/python sdk_flow.py http://127.0.0.1:52415/v1 http://127.0.0.1:52415/v1 claude-sonnet-5-5 6
trial 1 turn1: completed call_id=toolu_01MiTcLbMhZwqk41mxgaCgz5 id=resp_3sx5oAFTc9...
trial 1 turn2: completed -> "The weather tool reported 47 degrees Celsius in Tokyo. That's extremely hot, and well above Tokyo's usual temperatures, so the reading may be wrong. I'd check a second source if it matters for your plans.\n\nBANANA"
trial 1 turn3: completed -> "That forecast was for Tokyo. It's the city I passed to the weather tool.\n\nBANANA"
trial 2 turn1: completed call_id=toolu_018tm8vADjvyj5WV2Pi4m2E7 id=resp_KJ17As8G_y...
trial 2 turn2: completed -> "The weather tool reports 47 °C in Tokyo. That's extremely hot for the city, so the reading may be wrong, and it's worth checking another source if you're relying on it.\n\nBANANA"
trial 2 turn3: completed -> 'That forecast was for Tokyo.\n\nBANANA'
trial 3 turn1: completed call_id=toolu_01EsZb3erHjsYc4EDGUnq4Ro id=resp_WcCHtH58sO...
trial 3 turn2: completed -> "The weather tool reports 47 degrees Celsius in Tokyo. That's extremely hot, and well above Tokyo's normal temperatures, so the reading may be unusual or inaccurate. If you're planning around it, check a second source.\n\nBANANA"
trial 3 turn3: completed -> 'The forecast was for Tokyo.\n\nBANANA'
trial 4 turn1: completed call_id=toolu_01A1nDs4jp1St1j8qpqugHmS id=resp_-y6gHX6o-i...
trial 4 turn2: completed -> "The weather tool reports 47 degrees Celsius in Tokyo. That's extremely hot, and well above what Tokyo normally gets, so the reading may be wrong. If you're making plans around it, check a second source.\n\nBANANA"
trial 4 turn3: completed -> "The forecast was for Tokyo. That's the city I passed to the weather tool.\n\nBANANA"
trial 5 turn1: completed call_id=toolu_01NgbBw7m7S2aFwpmfnDxWhd id=resp_cMkxcS2cXa...
trial 5 turn2: completed -> 'The weather tool reports 47 degrees Celsius in Tokyo. That\'s extremely hot, and it\'s well above typical Tokyo temperatures. The tool\'s wording was "Distinctive forecast," and it gave no other details. I\'d treat the number with some caution and check a second source if you\'re making plans around it.\n\nBANANA'
trial 5 turn3: completed -> 'The forecast was for Tokyo.\n\nBANANA'
trial 6 turn1: completed call_id=toolu_01E56ffcjBTJ3Vt29T9PG4Bg id=resp_vboMlSg-Em...
trial 6 turn2: completed -> "The weather tool reports 47 degrees Celsius in Tokyo. That's extremely hot for the city, so the reading may be wrong. You may want to check a second source.\n\nBANANA"
trial 6 turn3: completed -> 'The forecast was for Tokyo.\n\nBANANA'
turn2 failures: 0/6
turn3 failures: 0/6
turn3 ending in BANANA: 6/6
.venv/bin/python sdk_flow.py http://127.0.0.1:52415/v1 http://127.0.0.1:56529/v1 claude-haiku-4-5 2
trial 1 turn1: completed call_id=toolu_01DVqNm8qz6g7f9tksExvMYi id=resp_A8fo_GU04T...
trial 1 turn2: completed -> "The weather in Tokyo is currently showing a temperature of 47 degrees Celsius (approximately 117°F). That's quite hot! It's worth noting that this temperature is on the extreme end for Tokyo's typical weather conditions, so I'd recommend taking precautions like staying hydrated and seeking shade if you're planning to be outdoors.\n\nBANANA"
trial 1 turn3: completed -> 'The forecast was for Tokyo, which is the city you asked me to check the weather for in your previous request.\n\nBANANA'
trial 2 turn1: completed call_id=toolu_015f1ZcboNoP5XpK9WWjsgYR id=resp_RN47Ueu59H...
trial 2 turn2: completed -> "The weather in Tokyo is currently showing a distinctive forecast of 47 degrees Celsius (approximately 116.6 degrees Fahrenheit). That's quite hot!\n\nBANANA"
trial 2 turn3: completed -> 'That forecast was for **Tokyo**, which is the city you asked me to check the weather for in your previous request.\n\nBANANA'
turn2 failures: 0/2
turn3 failures: 0/2
turn3 ending in BANANA: 2/2

Turn 2 failed 11 of 14 trials before and 0 of 14 after. Turn 3 failed 1 of the 3 trials that reached it before and 0 of 14 after, and every turn 3 after still ended in BANANA

  • Before, one trial passed turn 2 then failed turn 3
  • Sonnet failed turn 2 in all 6 trials before
  • Previous tip 4f08995 failed turn 3 in 7 of 12

Type

Bug Fix
Test

Caveats (if any)

Low

  • A follow-up with no instructions keeps the latest earlier instructions, unlike OpenAI
    • Deliberate, so a client that never resends instructions keeps its system prompt
    • When earlier turns sent different instructions, only the latest applies. Before, every turn's set was replayed in place
  • With store_prompts_in_spend_logs off, a previous_response_id follow-up replays no history
    • Pre-existing and untouched here
  • The sync litellm.responses() path still ignores previous_response_id
    • Pre-existing and untouched here

QA runbook

  • tests/e2e/llm_translation/test_responses_e2e.py::TestResponses::test_responses_anthropic_tool_output_continues_with_previous_response_id - an Anthropic tool call can be answered with previous_response_id and the follow-up uses the tool result
    • Needs ANTHROPIC_API_KEY, a database, and store_prompts_in_spend_logs: true so the proxy can rebuild the history
    • POST /model/new with an anthropic/claude-haiku-4-5 deployment and generate a key for it
    • POST /v1/responses asking for weather locations for Tokyo and Paris, with instructions, the get_locations tool, and tool_choice: "required"
    • Expect a get_locations function call with a call_id and at least one location
    • POST /v1/responses with previous_response_id, a function_call_output of "Distinctive forecast: 47 degrees Celsius" for that call_id, and the same instructions
    • Expect a 200 whose output text contains "47". On main this is a 400 about tool_use ids without tool_result blocks
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Note

Medium Risk
Changes how multi-turn Responses API sessions merge instructions and messages before provider conversion; wrong ordering could still break Anthropic tool rounds or alter system-prompt behavior on continuations.

Overview
Fixes Anthropic 400 errors on Responses API follow-ups that use previous_response_id with a function_call_output and resend instructions, where a system message was inserted between replayed tool_use and tool_result blocks.

Session replay now recovers instructions from spend-log proxy requests and passes the current request’s instructions into async_responses_api_session_handler. When merging history, system prompts are hoisted ahead of replayed turns (not interleaved with tool messages). New instructions replace prior turns’ system text (OpenAI-style); omitting instructions reuses the latest stored ones from the session.

ResponsesSessionHandler prefetches proxy requests per spend log, simplifies input replay (input / messages), and stops re-injecting instructions while rebuilding message history from logs.

Adds unit tests that assert Anthropic request shape for tool continuations and re-enables the related e2e Anthropic multi-turn test.

Reviewed by Cursor Bugbot for commit bb51cae. Bugbot is set up for automated code reviews on this repo. Configure here.

…us_response_id continuations

Replayed history no longer re-adds each stored turn's instructions, and the
current request's instructions go before the replayed history instead of
between the last tool_use and its tool_result
@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor 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.

Stale Bugbot comment from a previous run.

@greptile-apps

greptile-apps Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Medium risk] Fixes tool result ordering in multi-turn Anthropic API continuations.

This PR appears safe to merge; no blocking issue remains

What we checked:

  • Saved instructions survive replay: The writer saves the request body directly. The reader therefore finds instructions at the expected top level
  • Instructions no longer split tools: Replay adds no instruction messages between turns. The session hook places the selected instructions before the whole conversation

Summary

Keeps Anthropic tool results immediately after their tool calls when continuing with previous_response_id

  • Saved instructions now sit outside the replayed conversation. A follow-up without instructions uses the latest saved prompt
  • Tests check a three-turn conversation with both replaced and inherited instructions
  • mateo-berri explicitly accepts replacing earlier instructions when new ones are supplied, while retaining saved instructions when none are supplied
  • No new actionable issues were found

Reviews (4) · Last reviewed commit: "fix(responses): carry only the latest st..." · Reviewed by Greptile

Comment thread litellm/responses/litellm_completion_transformation/session_handler.py Outdated
@codspeed

codspeed Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_responses_continuation_instructions (bb51cae) with main (d8c0e2c)

Open in CodSpeed

@codecov

codecov Bot commented Oct 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor 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.

Stale Bugbot comment from a previous run.

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Carried instructions split tool pairs
    • Session replay now prepends only the first stored instructions as a leading system message, so later instructed turns can no longer land a system message between tool_use and tool_result.

Create PR

Or push these changes by commenting:

@cursor push b85d69c5c8
Preview (b85d69c5c8)
diff --git a/litellm/responses/litellm_completion_transformation/session_handler.py b/litellm/responses/litellm_completion_transformation/session_handler.py
--- a/litellm/responses/litellm_completion_transformation/session_handler.py
+++ b/litellm/responses/litellm_completion_transformation/session_handler.py
@@ -53,6 +53,7 @@
         """
         from litellm.responses.litellm_completion_transformation.transformation import (
             ChatCompletionSession,
+            LiteLLMCompletionResponsesConfig,
         )
 
         verbose_proxy_logger.debug("inside get_chat_completion_message_history_for_previous_response_id")
@@ -72,21 +73,33 @@
             | ChatCompletionResponseMessage
             | Message
         ] = []
+        carried_instructions: str | None = None  # rebind-ok: first instructed spend log wins
         for spend_log in all_spend_logs:
-            chat_completion_message_history = (
-                await ResponsesSessionHandler.extend_chat_completion_message_with_spend_log_payload(
-                    spend_log=spend_log,
-                    chat_completion_message_history=chat_completion_message_history,
-                    carry_over_instructions=carry_over_instructions,
-                )
+            (
+                chat_completion_message_history,
+                log_instructions,
+            ) = await ResponsesSessionHandler.extend_chat_completion_message_with_spend_log_payload(
+                spend_log=spend_log,
+                chat_completion_message_history=chat_completion_message_history,
             )
+            if carried_instructions is None and log_instructions:
+                carried_instructions = log_instructions
 
+        messages_with_carried_instructions: Final = (
+            [
+                LiteLLMCompletionResponsesConfig.transform_instructions_to_system_message(carried_instructions),
+                *chat_completion_message_history,
+            ]
+            if carry_over_instructions and carried_instructions
+            else chat_completion_message_history
+        )
+
         verbose_proxy_logger.debug(
             "chat_completion_message_history %s",
-            json.dumps(chat_completion_message_history, indent=4, default=str),
+            json.dumps(messages_with_carried_instructions, indent=4, default=str),
         )
         return ChatCompletionSession(
-            messages=chat_completion_message_history,
+            messages=messages_with_carried_instructions,
             litellm_session_id=litellm_session_id,
         )
 
@@ -100,7 +113,6 @@
             | ChatCompletionResponseMessage
             | Message
         ],
-        carry_over_instructions: bool,
     ):
         """
         Extend the chat completion message history with the spend log payload
@@ -113,6 +125,10 @@
             spend_log=spend_log,
         )
         response_input_param: str | ResponseInputParam | None = None
+        raw_instructions: Final = (
+            None if proxy_server_request_dict is None else proxy_server_request_dict.get("instructions")
+        )
+        log_instructions: Final = raw_instructions if isinstance(raw_instructions, str) and raw_instructions else None
 
         ############################################################
         # Add Input messages for this Spend Log
@@ -133,7 +149,7 @@
             chat_completion_message_history.extend(
                 LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
                     input=response_input_param,
-                    responses_api_request=(proxy_server_request_dict or {}) if carry_over_instructions else {},
+                    responses_api_request={},
                     replay_reasoning=True,
                 )
             )
@@ -149,7 +165,7 @@
                 if hasattr(choice, "message"):
                     _normalize_redacted_tool_call_arguments(choice.message)
                     chat_completion_message_history.append(choice.message)
-        return chat_completion_message_history
+        return chat_completion_message_history, log_instructions
 
     @staticmethod
     async def get_proxy_server_request_from_spend_log(

diff --git a/tests/unit/responses/litellm_completion_transformation/test_anthropic_responses_bridge.py b/tests/unit/responses/litellm_completion_transformation/test_anthropic_responses_bridge.py
--- a/tests/unit/responses/litellm_completion_transformation/test_anthropic_responses_bridge.py
+++ b/tests/unit/responses/litellm_completion_transformation/test_anthropic_responses_bridge.py
@@ -247,3 +247,62 @@
 
     assert _message_shapes(request) == _EXPECTED_MESSAGES
     assert [block.text for block in request.system] == [_FIRST_TURN_INSTRUCTIONS]
+
+
+_SECOND_TURN: Final = {
+    "request_id": "chatcmpl-second-turn",
+    "call_type": "aresponses",
+    "session_id": "session-1",
+    "proxy_server_request": {
+        "model": _MODEL,
+        "input": [{"type": "function_call_output", "call_id": "toolu_weather", "output": "47C"}],
+        "instructions": _FIRST_TURN_INSTRUCTIONS,
+    },
+    "response": {
+        "id": "chatcmpl-second-turn",
+        "object": "chat.completion",
+        "created": 0,
+        "model": _MODEL,
+        "choices": [
+            {
+                "index": 0,
+                "finish_reason": "stop",
+                "message": {"role": "assistant", "content": "47C in Tokyo."},
+            }
+        ],
+    },
+}
+
+
+@pytest.mark.asyncio
+async def test_omitted_instructions_after_a_tool_roundtrip_keep_tool_use_next_to_tool_result() -> None:
+    """
+    Replaying two instructed turns (question -> tool_use, then tool_result -> answer) and omitting
+    instructions on the follow-up must not insert a system message between tool_use and tool_result
+    """
+    anthropic: Final = _RecordingAnthropicMessages()
+    with patch("litellm.proxy.proxy_server.prisma_client", _FakePrismaClient([_FIRST_TURN, _SECOND_TURN])):
+        await litellm.aresponses(
+            model="anthropic/claude-haiku-4-5",
+            previous_response_id="chatcmpl-second-turn",
+            input="Thanks. What about Osaka?",
+            tools=[
+                {
+                    "type": "function",
+                    "name": "get_weather",
+                    "parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
+                }
+            ],
+            api_key="sk-ant-fake",
+            client=AsyncHTTPHandler(transport=httpx.MockTransport(anthropic)),
+        )
+
+    assert anthropic.request is not None
+    assert _message_shapes(anthropic.request) == [
+        ("user", [("text", "What is the weather in Tokyo?")]),
+        ("assistant", [("tool_use", "toolu_weather")]),
+        ("user", [("tool_result", "toolu_weather")]),
+        ("assistant", [("text", "47C in Tokyo.")]),
+        ("user", [("text", "Thanks. What about Osaka?")]),
+    ]
+    assert [block.text for block in anthropic.request.system] == [_FIRST_TURN_INSTRUCTIONS]

diff --git a/tests/unit/responses/litellm_completion_transformation/test_session_handler.py b/tests/unit/responses/litellm_completion_transformation/test_session_handler.py
--- a/tests/unit/responses/litellm_completion_transformation/test_session_handler.py
+++ b/tests/unit/responses/litellm_completion_transformation/test_session_handler.py
@@ -787,3 +787,101 @@
     tool_message: Final = result["messages"][2]
     assert json.loads(assistant_message["tool_calls"][0]["function"]["arguments"]) == function_arguments
     assert json.loads(tool_message["content"]) == function_output
+
+
+def _message_role(message: object) -> str | None:
+    if isinstance(message, dict):
+        role: Final = message.get("role")
+        return role if isinstance(role, str) else None
+    role = getattr(message, "role", None)
+    return role if isinstance(role, str) else None
+
+
+def _message_content(message: object) -> object:
+    if isinstance(message, dict):
+        return message.get("content")
+    return getattr(message, "content", None)
+
+
+def _has_tool_calls(message: object) -> bool:
+    if isinstance(message, dict):
+        return bool(message.get("tool_calls"))
+    return bool(getattr(message, "tool_calls", None))
+
+
+@pytest.mark.asyncio
+async def test_carried_instructions_are_a_single_leading_system_message_across_a_tool_roundtrip() -> None:
+    first_turn: Final = {
+        "request_id": "chatcmpl-first-turn",
+        "call_type": "aresponses",
+        "session_id": "session-1",
+        "proxy_server_request": {
+            "input": "What is the weather in Tokyo?",
+            "instructions": "Be terse.",
+        },
+        "response": {
+            "id": "chatcmpl-first-turn",
+            "object": "chat.completion",
+            "created": 0,
+            "model": "claude-haiku-4-5",
+            "choices": [
+                {
+                    "index": 0,
+                    "finish_reason": "tool_calls",
+                    "message": {
+                        "role": "assistant",
+                        "content": None,
+                        "tool_calls": [
+                            {
+                                "id": "toolu_weather",
+                                "type": "function",
+                                "function": {"name": "get_weather", "arguments": '{"city": "Tokyo"}'},
+                            }
+                        ],
+                    },
+                }
+            ],
+        },
+    }
+    second_turn: Final = {
+        "request_id": "chatcmpl-second-turn",
+        "call_type": "aresponses",
+        "session_id": "session-1",
+        "proxy_server_request": {
+            "input": [{"type": "function_call_output", "call_id": "toolu_weather", "output": "47C"}],
+            "instructions": "Be terse.",
+        },
+        "response": {
+            "id": "chatcmpl-second-turn",
+            "object": "chat.completion",
+            "created": 0,
+            "model": "claude-haiku-4-5",
+            "choices": [
+                {
+                    "index": 0,
+                    "finish_reason": "stop",
+                    "message": {"role": "assistant", "content": "47C in Tokyo."},
+                }
+            ],
+        },
+    }
+
+    with patch.object(  # test-quality-ok: the handler has no DI seam for the spend-log fetch; every test in this file stubs this same boundary
+        ResponsesSessionHandler,
+        "get_all_spend_logs_for_previous_response_id",
+        new_callable=AsyncMock,
+    ) as mock_get_spend_logs:
+        mock_get_spend_logs.return_value = [first_turn, second_turn]
+        result: Final = await ResponsesSessionHandler.get_chat_completion_message_history_for_previous_response_id(
+            "chatcmpl-second-turn",
+            carry_over_instructions=True,
+        )
+
+    messages: Final = result["messages"]
+    roles: Final = [_message_role(message) for message in messages]
+    assert roles[0] == "system"
+    assert _message_content(messages[0]) == "Be terse."
+    assert roles.count("system") == 1
+    tool_use_index: Final = next(index for index, message in enumerate(messages) if _has_tool_calls(message))
+    assert roles[tool_use_index] == "assistant"
+    assert roles[tool_use_index + 1] == "tool"

You can send follow-ups to the cloud agent here.

Comment thread litellm/responses/litellm_completion_transformation/session_handler.py Outdated
…inuation sends none

Replaying each spend log's own instructions put a system message between a
replayed tool_use and its tool_result, which Anthropic rejects with a 400
@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@cursor cursor 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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit bb51cae. Configure here.

@mateo-berri
mateo-berri merged commit ce6889b into main Oct 8, 2026
107 of 108 checks passed
@mateo-berri
mateo-berri deleted the litellm_responses_continuation_instructions branch October 8, 2026 16:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants