diff --git a/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx b/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx index c0b6de2df9e..16a9be54292 100644 --- a/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useChatHistory } from "@/components/chat/useChatHistory"; import ChatConversationPage from "./page"; @@ -154,6 +154,44 @@ describe("/ui/chat request metrics", () => { ]); }); + it.each([false, true])("ignores late response IDs after model changes ($0)", async (switchBack) => { + vi.mocked(fetchAvailableModels).mockResolvedValue([ + { model_group: "gpt-5.4-mini", providers: ["openai"] }, + { model_group: "second-model", providers: ["openai"] }, + ]); + const firstCompletion = Promise.withResolvers(); + mockMakeOpenAIResponsesRequest.mockImplementationOnce(async (...args: unknown[]) => { + await firstCompletion.promise; + (args[1] as (role: string, delta: string) => void)("assistant", "First answer"); + (args[15] as (id: string) => void)("response-first"); + }); + mockMakeOpenAIResponsesRequest.mockResolvedValue(undefined); + + await sendOneMessage(); + fireEvent.click(screen.getByRole("button", { name: /gpt-5\.4-mini/ })); + fireEvent.click(await screen.findByRole("button", { name: "second-model" })); + if (switchBack) { + fireEvent.click(screen.getByRole("button", { name: "second-model" })); + fireEvent.click(await screen.findByRole("button", { name: "gpt-5.4-mini" })); + } + + await act(async () => firstCompletion.resolve()); + expect(await screen.findByText("First answer")).toBeVisible(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Follow up" } }); + await waitFor(() => expect(screen.getByRole("button", { name: "Send" })).toBeEnabled()); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + await waitFor(() => expect(mockMakeOpenAIResponsesRequest).toHaveBeenCalledTimes(2)); + + const second = mockMakeOpenAIResponsesRequest.mock.calls[1]; + expect(second[2]).toBe(switchBack ? "gpt-5.4-mini" : "second-model"); + expect(second[14]).toBeNull(); + expect(second[0]).toEqual([ + { role: "user", content: "How much did this cost?" }, + { role: "assistant", content: "First answer" }, + { role: "user", content: "Follow up" }, + ]); + }); + it("renders latency, TTFT, token counts and cost reported for the assistant turn", async () => { mockMakeOpenAIResponsesRequest.mockImplementation(async (...args: unknown[]) => { const updateTextUI = args[1] as (role: string, delta: string) => void; diff --git a/ui/litellm-dashboard/src/app/chat/page.tsx b/ui/litellm-dashboard/src/app/chat/page.tsx index 256031c2c41..def1223ec0c 100644 --- a/ui/litellm-dashboard/src/app/chat/page.tsx +++ b/ui/litellm-dashboard/src/app/chat/page.tsx @@ -77,6 +77,7 @@ export default function ChatConversationPage() { const [modelSearchText, setModelSearchText] = useState(""); const [responsesSessionId, setResponsesSessionId] = useState(null); + const responsesSessionGenerationRef = useRef(0); const [prevConversationIdForSessionReset, setPrevConversationIdForSessionReset] = useState(activeConversationId); const [isStreaming, setIsStreaming] = useState(false); const [inputText, setInputText] = useState(""); @@ -129,6 +130,7 @@ export default function ChatConversationPage() { } const selectModel = useCallback((model: string) => { + responsesSessionGenerationRef.current += 1; setSelectedModel(model); setResponsesSessionId(null); localStorage.setItem(LOCALSTORAGE_MODEL_KEY, model); @@ -141,6 +143,7 @@ export default function ChatConversationPage() { const trimmed = text.trim(); if (!trimmed || !selectedModel || isStreaming) return; const model = selectedModel; + const sessionGeneration = responsesSessionGenerationRef.current; setInputText(""); let convId = activeConversationId; @@ -213,7 +216,11 @@ export default function ChatConversationPage() { undefined, selectedMCPServers.length > 0 ? selectedMCPServers : undefined, previousResponseId, - (id: string) => setResponsesSessionId(id), + (id: string) => { + if (responsesSessionGenerationRef.current === sessionGeneration) { + setResponsesSessionId(id); + } + }, (event: MCPEvent) => { // Accumulate locally only — persisted once in finally to avoid // one full localStorage write per MCP event during streaming.