fix(chat): preserve team access with document uploads

This commit is contained in:
ishaan-j-berri 2026-10-23 18:34:41 -07:00
commit e1b88fabc0
39 changed files with 1108 additions and 171 deletions

View file

@ -121,8 +121,8 @@ RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh && \
FROM $LITELLM_BUILD_IMAGE AS liteadmin-builder
COPY --from=uvbin /uv /usr/local/bin/uv
RUN apk add --no-cache python-3.13
ADD --checksum=sha256:2f7ae5cdd9d91731c0990e74a58239dc3e3fd2bf28dab23b55eafcdc47aaf87e \
https://github.com/BerriAI/litellm-admin-agent/archive/ef501e94bc9fbacb9233b922abf71427f030408c.tar.gz /tmp/liteadmin.tar.gz
ADD --checksum=sha256:4fb1847e28e2be30ba76dac94f02c4903291326516a0f4ba772f6ffb8b25d05c \
https://github.com/BerriAI/litellm-admin-agent/archive/5661c805ce479a0cf11b842ea133cf8d22cd7e34.tar.gz /tmp/liteadmin.tar.gz
RUN mkdir /tmp/liteadmin && tar xzf /tmp/liteadmin.tar.gz --strip-components=1 -C /tmp/liteadmin && \
uv venv /opt/liteadmin --python python3.13 && \
uv pip install --python /opt/liteadmin/bin/python --require-hashes -r /tmp/liteadmin/requirements.txt && \

View file

@ -972,3 +972,9 @@ def _get_openai_compatible_provider_info(
if dynamic_api_key is None and api_key is not None:
dynamic_api_key = api_key
return model, custom_llm_provider, dynamic_api_key, api_base
def get_provider_affinity_header(api_base: str | None) -> str | None:
from litellm.llms.opencode.transformation import OpenCodeProviderAdapter
return OpenCodeProviderAdapter().get_provider_affinity_header(api_base)

View file

@ -44,10 +44,10 @@ def _get_value(value: object, key: str) -> object | None:
return getattr(value, key, None)
def _get_provider_affinity_header_name(litellm_params: object | None) -> str | None:
def _get_provider_affinity_header_name(litellm_params: object | None, default_header: str | None = None) -> str | None:
header: Final = _get_value(litellm_params, "provider_affinity_header") if litellm_params is not None else None
if header is None:
return None
return default_header
if not isinstance(header, str):
raise TypeError("provider_affinity_header must be a string")
return validate_provider_affinity_header_name(header)
@ -84,9 +84,9 @@ def get_stable_session_id(litellm_params: object | None) -> str | None:
def add_provider_affinity_header(
headers: Mapping[str, object], litellm_params: object | None
headers: Mapping[str, object], litellm_params: object | None, default_header: str | None = None
) -> dict[str, object]: # mutable-ok: downstream handlers add auth and signing headers
header_name: Final = _get_provider_affinity_header_name(litellm_params)
header_name: Final = _get_provider_affinity_header_name(litellm_params, default_header)
if header_name is None or any(key.lower() == header_name.lower() for key in headers):
return dict(headers)

View file

@ -178,7 +178,7 @@ class AWSPollyTextToSpeechConfig(BaseTextToSpeechConfig, BaseAWSLLM):
else:
mapped_params["output_format"] = self.DEFAULT_OUTPUT_FORMAT
# Extract engine from model name (e.g., "aws_polly/neural" -> "neural")
# model arrives without the "aws_polly/" prefix, e.g. "standard"
engine: Final = self._extract_engine_from_model(model)
mapped_params["engine"] = engine
@ -196,18 +196,17 @@ class AWSPollyTextToSpeechConfig(BaseTextToSpeechConfig, BaseAWSLLM):
"""
Extract engine from model name.
speech() strips the "aws_polly/" prefix before the config is called,
so the bare engine name is what normally arrives here.
Examples:
- aws_polly/neural -> neural
- aws_polly/standard -> standard
- standard -> standard
- aws_polly/long-form -> long-form
- aws_polly -> neural (default)
"""
if "/" in model:
parts: Final = model.split("/")
if len(parts) >= 2:
engine: Final = parts[1].lower()
if engine in self.VALID_ENGINES:
return engine
engine: Final = model.rsplit("/", 1)[-1].lower()
if engine in self.VALID_ENGINES:
return engine
return self.DEFAULT_ENGINE
def validate_environment(

View file

@ -52,7 +52,9 @@ from litellm.litellm_core_utils.audio_utils.subtitle_utils import (
from litellm.litellm_core_utils.core_helpers import set_provider_response_headers_in_hidden_params
from litellm.litellm_core_utils.dot_notation_indexing import delete_nested_value
from litellm.litellm_core_utils.get_litellm_params import AWS_CREDENTIAL_KWARGS_KEYS
from litellm.litellm_core_utils.get_llm_provider_logic import get_provider_affinity_header
from litellm.litellm_core_utils.llm_request_utils import serialize_multipart_form_fields
from litellm.litellm_core_utils.provider_affinity import add_provider_affinity_header
from litellm.litellm_core_utils.realtime_errors import (
close_after_upstream_handshake_refusal,
realtime_error_event,
@ -60,6 +62,7 @@ from litellm.litellm_core_utils.realtime_errors import (
)
from litellm.litellm_core_utils.realtime_streaming import RealTimeStreaming
from litellm.litellm_core_utils.url_utils import encode_url_path_segment, join_api_base
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
from litellm.llms.base_llm.anthropic_messages.transformation import (
BaseAnthropicMessagesConfig,
)
@ -2435,13 +2438,13 @@ class BaseLLMHTTPHandler:
# Also check for extra_headers in kwargs (from config or direct calls)
extra_headers_from_kwargs: Final = kwargs.get("extra_headers", None)
# Merge all header sources: forwarded < extra_headers < provider_specific
merged_headers: Final = {}
if forwarded_headers:
merged_headers.update(forwarded_headers)
if extra_headers_from_kwargs:
merged_headers.update(extra_headers_from_kwargs)
if provider_specific_headers:
merged_headers.update(provider_specific_headers)
merged_headers: Final[Mapping[str, object]] = MappingProxyType(
{
**(forwarded_headers or {}),
**(extra_headers_from_kwargs or {}),
**(provider_specific_headers or {}),
}
)
request_params: Final = reduce(
delete_nested_value,
litellm_params.get("additional_drop_params") or (),
@ -2452,11 +2455,19 @@ class BaseLLMHTTPHandler:
and anthropic_messages_provider_config.should_filter_anthropic_beta_headers()
):
await aload_beta_headers_config()
affinity_api_base: Final = (
AnthropicModelInfo.get_api_base(api_base) if custom_llm_provider == "anthropic" else api_base
)
default_affinity_header: Final = get_provider_affinity_header(affinity_api_base)
(
headers,
api_base,
) = await anthropic_messages_provider_config.avalidate_anthropic_messages_environment(
headers=merged_headers or {},
headers=(
add_provider_affinity_header(merged_headers, litellm_params, default_affinity_header)
if default_affinity_header is not None
else dict(merged_headers)
),
model=model,
messages=messages,
optional_params=request_params,

View file

@ -0,0 +1,10 @@
import re
def is_opencode_api_base(api_base: str | None) -> bool:
return bool(
api_base
and re.fullmatch(
r"https://opencode\.ai/(?:zen(?:/go)?|inference/(?:go/)?(?:openai|anthropic))(?:/v1)?/?", api_base
)
)

View file

@ -0,0 +1,6 @@
from litellm.llms.opencode.common_utils import is_opencode_api_base
class OpenCodeProviderAdapter:
def get_provider_affinity_header(self, api_base: str | None) -> str | None:
return "x-opencode-session" if is_opencode_api_base(api_base) else None

View file

@ -89,6 +89,7 @@ from litellm.litellm_core_utils.get_litellm_params import (
with_control_options,
)
from litellm.litellm_core_utils.get_llm_provider_logic import (
get_provider_affinity_header,
invalid_provider_routing_hints_message,
)
from litellm.litellm_core_utils.get_provider_specific_headers import (
@ -130,6 +131,7 @@ from litellm.llms.bedrock_mantle.chat.claude_transformation import bedrock_mantl
from litellm.llms.cohere.common_utils import CohereModelInfo
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler, http2_enabled
from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config
from litellm.llms.openai.common_utils import get_openai_credentials
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
from litellm.llms.vertex_ai.common_utils import (
VertexAIModelRoute,
@ -5990,19 +5992,28 @@ def completion(
},
)
litellm_params: Final = with_control_options(requested_litellm_params, control_options)
if litellm_params.get("provider_affinity_header") is not None:
affinity_api_base: Final = (
str(client.base_url)
if isinstance(client, openai.OpenAI | openai.AsyncOpenAI)
else get_openai_credentials(api_base=api_base).api_base
if custom_llm_provider == "openai"
else api_base
)
default_affinity_header: Final = get_provider_affinity_header(affinity_api_base)
if litellm_params.get("provider_affinity_header") is not None or default_affinity_header is not None:
try:
headers = add_provider_affinity_header(
headers=headers or litellm.headers or MappingProxyType({}),
litellm_params=MappingProxyType(
{
"provider_affinity_header": litellm_params["provider_affinity_header"],
"provider_affinity_header": litellm_params.get("provider_affinity_header"),
"litellm_session_id": kwargs.get("litellm_session_id"),
"session_id": kwargs.get("session_id"),
"metadata": metadata,
"litellm_metadata": kwargs.get("litellm_metadata"),
}
),
default_header=default_affinity_header,
)
except ValueError as affinity_error:
raise litellm.BadRequestError(

View file

@ -103,6 +103,7 @@ def _target(request: NativeCall, model: str, provider: str | None) -> Target:
_FEATURES: Final[detectors.Detectors] = MappingProxyType(
{
**detectors.COMMON,
Feature.PROVIDER_AFFINITY: route_detectors.provider_affinity,
Feature.SAFEGUARDS_CLASSIFIER: route_detectors.safeguards_classifier,
Feature.RESPONSE_CACHE: detectors.response_cache,
Feature.MODIFY_PARAMS: detectors.modify_params,

View file

@ -30,6 +30,7 @@ from litellm.integrations.anthropic_cache_control_hook import (
from litellm.litellm_core_utils.asyncify import run_async_function
from litellm.litellm_core_utils.completion_timeout import CompletionTimeout, StreamKind
from litellm.litellm_core_utils.core_helpers import normalize_drop_params
from litellm.litellm_core_utils.get_llm_provider_logic import get_provider_affinity_header
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
from litellm.litellm_core_utils.prompt_templates.common_utils import (
@ -39,6 +40,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
from litellm.litellm_core_utils.provider_affinity import add_provider_affinity_header
from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
from litellm.llms.openai.common_utils import get_openai_credentials
from litellm.llms.openai_like.responses.transformation import OpenAILikeResponsesConfig
from litellm.responses.litellm_completion_transformation.compaction_items import (
with_compaction_items_as_summaries,
@ -1466,6 +1468,12 @@ def responses(
# get llm provider logic
litellm_params: Final = GenericLiteLLMParams(**kwargs)
affinity_api_base: Final = (
get_openai_credentials(api_base=litellm_params.api_base).api_base
if custom_llm_provider == "openai"
else litellm_params.api_base
)
default_affinity_header: Final = get_provider_affinity_header(affinity_api_base)
try:
effective_extra_headers: Final = (
add_provider_affinity_header(
@ -1479,8 +1487,9 @@ def responses(
"litellm_metadata": kwargs.get("litellm_metadata"),
}
),
default_header=default_affinity_header,
)
if litellm_params.provider_affinity_header is not None
if litellm_params.provider_affinity_header is not None or default_affinity_header is not None
else extra_headers
)
except ValueError as affinity_error:

View file

@ -165,6 +165,7 @@ CHAT_PYTHON_FEATURES: Final = COMMON_PYTHON_FEATURES | {
}
MESSAGES_PYTHON_FEATURES: Final = COMMON_PYTHON_FEATURES | {
Feature.PROVIDER_AFFINITY,
Feature.SAFEGUARDS_CLASSIFIER,
Feature.PRE_REQUEST_HOOK,
Feature.WEBSEARCH_INTERCEPTION,

View file

@ -5,10 +5,15 @@ from __future__ import annotations
from typing import Final
import litellm
from litellm.rust_bridge.detectors import overridden, present, streaming
from litellm.llms.opencode.common_utils import is_opencode_api_base
from litellm.rust_bridge.detectors import api_base, overridden, present, streaming
from litellm.rust_bridge.public_call import NativeCall, optional_mapping, optional_sequence, optional_str
def provider_affinity(request: NativeCall) -> bool:
return is_opencode_api_base(api_base(request))
def anthropic_workload_identity(request: NativeCall) -> bool:
"""Python mints an Anthropic federation token when no static credential is configured."""
from litellm.llms.anthropic.common_utils import AnthropicModelInfo

View file

@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Final, cast # noqa: TID251 # tool lists stay
from pydantic import TypeAdapter
import litellm
from litellm.llms.opencode.common_utils import is_opencode_api_base
from litellm.rust_bridge.detectors import api_base, call_callbacks, overridden, present
from litellm.rust_bridge.public_call import NativeCall, optional_mapping, optional_sequence, optional_str
@ -25,7 +26,7 @@ def client_headers(request: NativeCall) -> bool:
def provider_affinity(request: NativeCall) -> bool:
"""Python validates and fills the affinity header from the session ID."""
return present(request, "provider_affinity_header")
return present(request, "provider_affinity_header") or is_opencode_api_base(api_base(request))
def text_format(request: NativeCall) -> bool:

View file

@ -8,6 +8,7 @@ from litellm import CustomLLM
from litellm.litellm_core_utils import get_llm_provider_logic
from litellm.litellm_core_utils.get_llm_provider_logic import (
get_llm_provider,
get_provider_affinity_header,
inferred_provider,
is_registered_custom_provider,
)
@ -706,3 +707,11 @@ class TestClaudeModelPatternMatching:
set_fallback_generalizations([])
with pytest.raises(litellm.BadRequestError):
litellm.get_llm_provider(model="claude-opus-4-9")
@pytest.mark.parametrize("prefix", ["openai", "anthropic", "custom_openai", "openai_like"])
def test_opencode_affinity_resolution_preserves_existing_transport(prefix: str) -> None:
api_base: Final = "https://opencode.ai/zen/go/v1"
model, provider, _, destination = get_llm_provider(model=f"{prefix}/test-model", api_base=api_base)
assert (model, provider, destination) == ("test-model", prefix, api_base)
assert get_provider_affinity_header(destination) == "x-opencode-session"

View file

@ -1,6 +1,10 @@
import litellm
import json
from unittest.mock import MagicMock, patch
import httpx
import pytest
from unittest.mock import MagicMock
import litellm
@pytest.mark.asyncio
@ -134,6 +138,34 @@ async def test_aws_polly_tts_with_native_voice():
assert request_body.get("TextType", "text") == "text"
@pytest.mark.asyncio
@pytest.mark.usefixtures("fake_provider_credentials")
@pytest.mark.parametrize("engine", ["standard", "neural", "long-form", "generative"])
async def test_aws_polly_tts_sends_engine_from_model(engine: str) -> None:
"""
The engine in the model name must reach Polly. speech() strips the
provider prefix before the config reads it, which used to turn every
engine into the neural default.
"""
mock_httpx_response = MagicMock(spec=httpx.Response)
mock_httpx_response.content = b"fake_audio_data"
mock_httpx_response.status_code = 200
mock_httpx_response.headers = {"content-type": "audio/mpeg"}
with patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post") as mock_post:
mock_post.return_value = mock_httpx_response
await litellm.aspeech(
model=f"aws_polly/{engine}",
voice="Joanna",
input="Hello",
aws_region_name="us-east-1",
)
request_body = json.loads(mock_post.call_args.kwargs["data"])
assert request_body["Engine"] == engine
@pytest.mark.asyncio
@pytest.mark.usefixtures("fake_provider_credentials")
async def test_aws_polly_tts_with_openai_voice_mapping():

View file

@ -1,6 +1,8 @@
import json
from typing import Final
import pytest
from litellm.llms.aws_polly.text_to_speech.transformation import AWSPollyTextToSpeechConfig
@ -17,3 +19,17 @@ def test_installed_botocore_signs_the_speech_request() -> None:
authorization: Final = headers["Authorization"]
assert "/us-west-2/polly/aws4_request" in authorization
assert json.loads(body) == {"Text": "ping", "VoiceId": "Joanna"}
@pytest.mark.parametrize(
("model", "expected"),
[
("standard", "standard"),
("Long-Form", "long-form"),
("aws_polly/generative", "generative"),
("aws_polly", "neural"),
("unknown", "neural"),
],
)
def test_extract_engine_from_model(model: str, expected: str) -> None:
assert AWSPollyTextToSpeechConfig()._extract_engine_from_model(model) == expected

View file

@ -5542,3 +5542,85 @@ async def test_anthropic_messages_dropped_safeguards_send_neither_the_field_nor_
sent: Final = received[0]
assert "safeguards" not in json.loads(sent.content)
assert "dangerous-tool-use-2026-09-03" not in sent.headers.get("anthropic-beta", ""), dict(sent.headers)
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [False, True])
@pytest.mark.parametrize("environment_base", [None, "ANTHROPIC_API_BASE", "ANTHROPIC_BASE_URL"])
@pytest.mark.parametrize(
("api_base", "explicit_header", "expected_session"),
[
("https://opencode.ai/inference/go/anthropic", None, "conversation-123"),
("https://opencode.ai/inference/go/anthropic", "caller-override", "caller-override"),
("https://other.example", None, None),
],
)
async def test_messages_opencode_session_header(
stream: bool,
environment_base: str | None,
api_base: str,
explicit_header: str | None,
expected_session: str | None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("ANTHROPIC_API_BASE", raising=False)
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
if environment_base:
monkeypatch.setenv(environment_base, "https://opencode.ai/inference/go/anthropic")
received: Final[asyncio.Queue[httpx.Request]] = asyncio.Queue()
payload: Final = {
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": "claude-haiku-5-5",
"content": [{"type": "text", "text": "OK"}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 1, "output_tokens": 1},
}
def respond(request: httpx.Request) -> httpx.Response:
received.put_nowait(request)
return (
httpx.Response(
200,
headers={"content-type": "text/event-stream"},
text=f"event: message_start\ndata: {json.dumps({'type': 'message_start', 'message': payload})}\n\n"
'event: message_stop\ndata: {"type":"message_stop"}\n\n',
)
if stream
else httpx.Response(200, json=payload)
)
upstream: Final = AsyncHTTPHandler(transport=httpx.MockTransport(respond))
try:
result: Final = await litellm.anthropic.messages.acreate(
model="anthropic/claude-haiku-5-5",
messages=[{"role": "user", "content": "hello"}],
max_tokens=128000,
api_base=None if environment_base and api_base == "https://opencode.ai/inference/go/anthropic" else api_base,
api_key="test-key",
stream=stream,
litellm_session_id="conversation-123",
client=upstream,
extra_headers={"X-OpenCode-Session": explicit_header} if explicit_header else {"x-existing": "unchanged"},
)
if stream:
chunks: Final = b"".join([chunk async for chunk in result])
assert b'"text": "OK"' in chunks
assert b"message_stop" in chunks
else:
assert result["content"] == [{"type": "text", "text": "OK"}]
finally:
await upstream.close()
sent: Final = received.get_nowait()
assert str(sent.url) == f"{api_base}/v1/messages"
assert sent.headers.get("x-opencode-session") == expected_session
assert sent.headers["x-api-key"] == "test-key"
assert json.loads(sent.content) == {
"model": "claude-haiku-5-5",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 128000,
"stream": stream,
}
assert received.empty()

View file

@ -0,0 +1,90 @@
from typing import Final
import pytest
from litellm.constants import SESSION_ID_GENERATED_METADATA_KEY
from litellm.litellm_core_utils.provider_affinity import add_provider_affinity_header
from litellm.llms.opencode.transformation import OpenCodeProviderAdapter
@pytest.mark.parametrize(
"api_base",
[
"https://opencode.ai/zen/v1",
"https://opencode.ai/zen/go/v1/",
"https://opencode.ai/inference/openai/v1",
"https://opencode.ai/inference/go/openai/v1",
"https://opencode.ai/inference/go/anthropic/v1",
"https://opencode.ai/inference/anthropic/v1",
],
)
def test_opencode_defaults_affinity_header_to_the_callers_session(api_base: str) -> None:
headers: Final = {"Authorization": "Bearer test-key"}
params: Final = {"metadata": {"session_id": "conversation-123"}}
assert add_provider_affinity_header(
headers, params, OpenCodeProviderAdapter().get_provider_affinity_header(api_base)
) == {
**headers,
"x-opencode-session": "conversation-123",
}
assert headers == {"Authorization": "Bearer test-key"}
assert params == {"metadata": {"session_id": "conversation-123"}}
@pytest.mark.parametrize(
"api_base",
[
None,
"https://api.openai.com/v1",
"https://opencode.ai.example/zen/go/v1",
"https://opencode.ai@example.com/zen/go/v1",
"https://example.com/opencode.ai/zen/go/v1",
"https://example.com/v1?upstream=https://opencode.ai/zen/go/v1",
"https://opencode.ai/console/v1",
"http://opencode.ai/zen/go/v1",
],
)
def test_other_destinations_do_not_receive_an_automatic_session_header(api_base: str | None) -> None:
headers: Final = {"Authorization": "Bearer test-key", "x-existing": "unchanged"}
assert (
add_provider_affinity_header(
headers,
{"litellm_session_id": "conversation-123"},
OpenCodeProviderAdapter().get_provider_affinity_header(api_base),
)
== headers
)
@pytest.mark.parametrize(
"params",
[
{},
{"litellm_trace_id": "request-123"},
{"metadata": {"trace_id": "request-123"}},
{
"litellm_session_id": "request-123",
"litellm_metadata": {"session_id": "request-123", SESSION_ID_GENERATED_METADATA_KEY: True},
},
],
)
def test_opencode_does_not_invent_a_conversation_id(params: dict[str, object]) -> None:
assert (
add_provider_affinity_header(
{}, params, OpenCodeProviderAdapter().get_provider_affinity_header("https://opencode.ai/zen/go/v1")
)
== {}
)
def test_opencode_preserves_explicit_header_and_affinity_configuration() -> None:
assert add_provider_affinity_header(
{"X-OpenCode-Session": "explicit"},
{"litellm_session_id": "conversation-123"},
OpenCodeProviderAdapter().get_provider_affinity_header("https://opencode.ai/zen/go/v1"),
) == {"X-OpenCode-Session": "explicit"}
assert add_provider_affinity_header(
{},
{"litellm_session_id": "conversation-123", "provider_affinity_header": "x-custom-session"},
OpenCodeProviderAdapter().get_provider_affinity_header("https://opencode.ai/zen/go/v1"),
) == {"x-custom-session": "conversation-123"}

View file

@ -4,6 +4,7 @@ over the wire and surface provider errors correctly. Expected JSON bodies are st
in expected_responses_api_request/.
"""
import asyncio
import copy
import json
import logging
@ -1231,3 +1232,110 @@ def test_faked_stream_accepts_a_text_timeout(monkeypatch: pytest.MonkeyPatch) ->
assert [event.response.output[0].content[0].text for event in response if event.type == "response.completed"] == [
"Done."
]
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("stream", [False, True])
@pytest.mark.parametrize(
("api_base", "explicit_header", "expected_session"),
[
("https://opencode.ai/inference/go/openai/v1", None, "conversation-123"),
("https://opencode.ai/inference/go/openai/v1", "caller-override", "caller-override"),
("https://other.example/v1", None, None),
],
)
@pytest.mark.asyncio
async def test_responses_opencode_session_header(
asynchronous: bool, stream: bool, api_base: str, explicit_header: str | None, expected_session: str | None
) -> None:
received: Final[asyncio.Queue[httpx.Request]] = asyncio.Queue()
payload: Final = _minimal_responses_api_payload("resp_test", "gpt-6-luna")
def respond(request: httpx.Request) -> httpx.Response:
received.put_nowait(request)
return (
httpx.Response(
200,
headers={"content-type": "text/event-stream"},
text="".join(
f"event: {kind}\ndata: {json.dumps({'type': kind, 'response': payload})}\n\n"
for kind in ("response.created", "response.completed")
),
)
if stream
else httpx.Response(200, json=payload)
)
args: Final = {
"model": "openai/gpt-6-luna",
"input": "hello",
"api_base": api_base,
"api_key": "test-key",
"litellm_session_id": "conversation-123",
"stream": stream,
"extra_headers": {"X-OpenCode-Session": explicit_header} if explicit_header else {"x-existing": "unchanged"},
}
if asynchronous:
async_client: Final = AsyncHTTPHandler(transport=httpx.MockTransport(respond))
try:
response: Final = await litellm.aresponses(**args, client=async_client)
if stream:
events: Final = tuple([event.type async for event in response])
assert "response.completed" in events
else:
assert response.output[0].content[0].text == "Done."
finally:
await async_client.close()
else:
sync_client: Final = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(respond)))
try:
result: Final = litellm.responses(**args, client=sync_client)
if stream:
assert "response.completed" in tuple(event.type for event in result)
else:
assert result.output[0].content[0].text == "Done."
finally:
sync_client.close()
sent: Final = received.get_nowait()
assert str(sent.url) == f"{api_base}/responses"
assert sent.headers.get("x-opencode-session") == expected_session
assert sent.headers["authorization"] == "Bearer test-key"
assert json.loads(sent.content) == {"model": "gpt-6-luna", "input": "hello", "stream": stream}
assert received.empty()
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("source", ["OPENAI_BASE_URL", "OPENAI_API_BASE", "litellm.api_base"])
@pytest.mark.parametrize("explicit_base", [None, "https://other.example/v1"])
@pytest.mark.asyncio
async def test_responses_opencode_session_uses_effective_destination(
monkeypatch: pytest.MonkeyPatch,
respx_mock: respx.MockRouter,
asynchronous: bool,
source: str,
explicit_base: str | None,
) -> None:
default_base: Final = "https://opencode.ai/inference/go/openai/v1"
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
monkeypatch.setattr(litellm, "api_base", default_base if source == "litellm.api_base" else None)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
if source != "litellm.api_base":
monkeypatch.setenv(source, default_base)
endpoint: Final = respx_mock.post(f"{explicit_base or default_base}/responses").respond(
json=_minimal_responses_api_payload("resp_test", "gpt-6-luna")
)
args: Final = {
"model": "openai/gpt-6-luna",
"input": "hello",
"api_base": explicit_base,
"api_key": "test-key",
"litellm_session_id": "conversation-123",
}
response: Final = await litellm.aresponses(**args) if asynchronous else litellm.responses(**args)
assert response.output[0].content[0].text == "Done."
assert endpoint.call_count == 1
sent: Final = endpoint.calls[0].request
assert sent.headers.get("x-opencode-session") == (None if explicit_base else "conversation-123")
assert sent.headers["authorization"] == "Bearer test-key"
assert json.loads(sent.content) == {"model": "gpt-6-luna", "input": "hello"}

View file

@ -116,6 +116,9 @@ def _wrap_nested_level(value: object, depth: int) -> object:
(responses_detectors.provider_affinity, {"provider_affinity_header": ""}, True),
(responses_detectors.provider_affinity, {"provider_affinity_header": None, "session_id": "session-1"}, False),
(responses_detectors.provider_affinity, {}, False),
(responses_detectors.provider_affinity, {"api_base": "https://opencode.ai/inference/go/openai/v1"}, True),
(messages_detectors.provider_affinity, {"api_base": "https://opencode.ai/inference/go/anthropic/v1"}, True),
(messages_detectors.provider_affinity, {"api_base": "https://other.example/v1"}, False),
(detectors.streaming, {"stream": True}, True),
(detectors.streaming, {"stream": False}, False),
(detectors.timeout_variants, {"request_timeout": 5}, True),

View file

@ -20,7 +20,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
import respx
from openai import APITimeoutError, AsyncOpenAI
from openai import APITimeoutError, AsyncOpenAI, OpenAI
from openai._legacy_response import LegacyAPIResponse
from openai.types.chat.chat_completion import ChatCompletion
from pydantic import TypeAdapter
@ -95,6 +95,142 @@ def preserve_litellm_completion_state(monkeypatch: pytest.MonkeyPatch) -> None:
WHITE_PNG: Final = (Path(__file__).parents[1] / "white_100x100.png").read_bytes()
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("stream", [False, True])
@pytest.mark.parametrize(
("client_base", "explicit_header", "expected_session"),
[
("https://opencode.ai/zen/go/v1", None, "conversation-123"),
("https://opencode.ai/zen/go/v1", "caller-override", "caller-override"),
("https://other.example/v1", None, None),
],
)
@pytest.mark.asyncio
async def test_completion_sends_session_only_to_the_actual_opencode_destination(
asynchronous: bool,
stream: bool,
client_base: str,
explicit_header: str | None,
expected_session: str | None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("OPENAI_BASE_URL", "https://opencode.ai/zen/go/v1")
monkeypatch.setenv("OPENAI_API_BASE", "https://opencode.ai/zen/go/v1")
monkeypatch.setattr(litellm, "api_base", "https://opencode.ai/zen/go/v1")
requests: Final[asyncio.Queue[httpx.Request]] = asyncio.Queue()
def respond(request: httpx.Request) -> httpx.Response:
requests.put_nowait(request)
payload: Final = {
"id": "chatcmpl-test",
"object": "chat.completion.chunk" if stream else "chat.completion",
"created": 1,
"model": "session-test",
"choices": [
{
"index": 0,
"delta" if stream else "message": {"role": "assistant", "content": "OK"},
"finish_reason": "stop",
}
],
}
return (
httpx.Response(
200,
headers={"content-type": "text/event-stream"},
text=f"data: {json.dumps(payload)}\n\ndata: [DONE]\n\n",
)
if stream
else httpx.Response(200, json=payload)
)
args: Final = {
"model": "openai/session-test",
"api_base": "https://opencode.ai/zen/go/v1",
"messages": [{"role": "user", "content": "hello"}],
"litellm_session_id": "conversation-123",
"extra_headers": {"X-OpenCode-Session": explicit_header} if explicit_header else {"x-existing": "unchanged"},
"stream": stream,
}
if asynchronous:
async with AsyncOpenAI(
api_key="test-key",
base_url=client_base,
http_client=httpx.AsyncClient(transport=httpx.MockTransport(respond)),
) as async_client:
response: Final = await acompletion(**args, client=async_client)
content: Final = (
"".join([chunk.choices[0].delta.content or "" async for chunk in response])
if stream
else response.choices[0].message.content
)
assert content == "OK"
else:
with OpenAI(
api_key="test-key", base_url=client_base, http_client=httpx.Client(transport=httpx.MockTransport(respond))
) as sync_client:
result: Final = completion(**args, client=sync_client)
text: Final = (
"".join(chunk.choices[0].delta.content or "" for chunk in result)
if stream
else result.choices[0].message.content
)
assert text == "OK"
sent: Final = requests.get_nowait()
assert str(sent.url) == f"{client_base}/chat/completions"
assert sent.headers.get("x-opencode-session") == expected_session
assert sent.headers["authorization"] == "Bearer test-key"
assert json.loads(sent.content) == {
"model": "session-test",
"messages": args["messages"],
**({"stream": True} if stream else {}),
}
assert requests.empty()
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("source", ["OPENAI_BASE_URL", "OPENAI_API_BASE", "litellm.api_base"])
@pytest.mark.parametrize("explicit_base", [None, "https://other.example/v1"])
@pytest.mark.asyncio
async def test_completion_opencode_session_uses_effective_destination(
monkeypatch: pytest.MonkeyPatch,
respx_mock: respx.MockRouter,
asynchronous: bool,
source: str,
explicit_base: str | None,
) -> None:
default_base: Final = "https://opencode.ai/inference/go/openai/v1"
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
monkeypatch.setattr(litellm, "api_base", default_base if source == "litellm.api_base" else None)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
if source != "litellm.api_base":
monkeypatch.setenv(source, default_base)
endpoint: Final = respx_mock.post(f"{explicit_base or default_base}/chat/completions").respond(
json={
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1,
"model": "session-test",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "OK"}, "finish_reason": "stop"}],
}
)
args: Final = {
"model": "openai/session-test",
"messages": [{"role": "user", "content": "hello"}],
"api_base": explicit_base,
"api_key": "test-key",
"litellm_session_id": "conversation-123",
}
response: Final = await litellm.acompletion(**args) if asynchronous else litellm.completion(**args)
assert response.choices[0].message.content == "OK"
assert endpoint.call_count == 1
sent: Final = endpoint.calls[0].request
assert sent.headers.get("x-opencode-session") == (None if explicit_base else "conversation-123")
assert sent.headers["authorization"] == "Bearer test-key"
assert json.loads(sent.content) == {"model": "session-test", "messages": args["messages"]}
@pytest.fixture
def openai_api_response():
mock_response_data = {

View file

@ -56,7 +56,7 @@ function renderChat() {
async function uploadImage(user: ReturnType<typeof userEvent.setup>, name = "diagram.png") {
const bytes = Uint8Array.from(atob(pixel), (character) => character.charCodeAt(0));
await user.upload(screen.getByLabelText("Upload images"), new File([bytes], name, { type: "image/png" }));
await user.upload(screen.getByLabelText("Upload files"), new File([bytes], name, { type: "image/png" }));
await screen.findByRole("img", { name });
}
@ -117,13 +117,67 @@ describe("chat image workflows", () => {
it("should render the permitted models and image controls for a research team member", async () => {
renderChat();
expect(await screen.findByRole("button", { name: "research-chat" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Attach images" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Attach files" })).toBeInTheDocument();
await userEvent.setup().click(screen.getByRole("button", { name: "research-chat", exact: true }));
expect(await screen.findByRole("button", { name: /^studio-image/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(recordChatQuery).not.toHaveBeenCalled();
});
it("should upload a document as a file part, preserve it on edit, and keep contents out of metrics", async () => {
const user = userEvent.setup();
renderChat();
await screen.findByRole("button", { name: "research-chat" });
await user.upload(
screen.getByLabelText("Upload files"),
new File(["private fixture"], "brief.docx", {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}),
);
expect(await screen.findByText("brief.docx")).toBeInTheDocument();
expect(screen.queryByRole("img", { name: "brief.docx" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Send" }));
await screen.findByText("I can see the uploaded image.");
const filePart = {
type: "input_file",
filename: "brief.docx",
file_data:
"data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,cHJpdmF0ZSBmaXh0dXJl",
};
expect(requests[0].body).toMatchObject({ input: [{ role: "user", content: [filePart] }] });
expect(recordChatQuery).toHaveBeenCalledExactlyOnceWith("media-test-user");
await user.click(screen.getByRole("button", { name: "Edit message" }));
act(() =>
fireEvent.change(screen.getByRole("textbox", { name: "Edit message" }), {
target: { value: "Summarize the document" },
}),
);
await user.click(screen.getByRole("button", { name: "Save & Send" }));
await waitFor(() => expect(requests).toHaveLength(2));
expect(requests[1].body).toMatchObject({
input: [{ role: "user", content: [{ type: "input_text", text: "Summarize the document" }, filePart] }],
});
expect(requests[1].body).not.toHaveProperty("previous_response_id");
});
it("should enforce the shared file limit and remove a document before submission", async () => {
const user = userEvent.setup();
renderChat();
await screen.findByRole("button", { name: "research-chat" });
const files = Array.from(
{ length: 5 },
(_, index) => new File(["text"], `note-${index}.txt`, { type: "text/plain" }),
);
await user.upload(screen.getByLabelText("Upload files"), files);
expect(await screen.findByRole("alert")).toHaveTextContent("Attach up to 4 files per message.");
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
await user.upload(screen.getByLabelText("Upload files"), files[0]);
await user.click(await screen.findByRole("button", { name: "Remove note-0.txt" }));
expect(screen.queryByText("note-0.txt")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(recordChatQuery).not.toHaveBeenCalled();
});
it("should restore the selected chat model after switching modes and reopening the page", async () => {
const user = userEvent.setup();
const view = renderChat();

View file

@ -214,7 +214,7 @@ describe("/ui/chat storage banner", () => {
it("keeps the dismiss control amber on hover instead of the ghost variant's foreground", async () => {
renderWithProviders(<ChatConversationPage />);
const banner = await screen.findByText("Some chat history or images could not be saved or restored.");
const banner = await screen.findByText("Some chat history or attachments could not be saved or restored.");
const dismiss = within(banner.parentElement!).getByRole("button");
expect(dismiss).toHaveClass("hover:text-warning/80");
expect(dismiss).not.toHaveClass("hover:text-foreground");

View file

@ -4,10 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import { ChatShellProvider } from "@/contexts/ChatShellContext";
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
import { recordChatQuery } from "@/lib/telemetry/chat";
import ChatConversationPage from "./page";
vi.mock("@/lib/telemetry/chat", () => ({ recordChatQuery: vi.fn(async () => {}) }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }));
const teams = [
@ -40,7 +38,6 @@ function enterPrompt(text = "Hello") {
beforeEach(() => {
localStorage.clear();
testQueryClient.clear();
vi.mocked(recordChatQuery).mockClear();
requests.length = 0;
models = [{ model_group: "shared-chat", mode: "chat", providers: ["openai"], available_teams: teams }];
vi.stubGlobal(
@ -85,7 +82,6 @@ describe("Chat session team selection", () => {
enterPrompt();
expect(screen.getByRole("button", { name: "Send", exact: true })).toBeDisabled();
expect(requests).toHaveLength(0);
expect(recordChatQuery).not.toHaveBeenCalled();
});
it.each([
@ -122,7 +118,6 @@ describe("Chat session team selection", () => {
expect(requests[1].body.input).toEqual(
expect.arrayContaining([expect.objectContaining({ role: "user", content: "First question" })]),
);
expect(recordChatQuery).toHaveBeenCalledTimes(2);
});
it("should send image generation to the chosen team and clear the choice when changing models", async () => {
@ -145,4 +140,38 @@ describe("Chat session team selection", () => {
body: { model: "studio-image", prompt: "Draw a circle" },
});
});
it("should retain uploaded documents and replay them under the newly selected team", async () => {
const user = userEvent.setup();
renderChat();
await screen.findByRole("button", { name: "shared-chat" });
const file = new File(["team document"], "brief.txt", { type: "text/plain" });
await user.upload(screen.getByLabelText("Upload files"), file);
await screen.findByRole("button", { name: "Remove brief.txt" });
enterPrompt("Read the file");
expect(screen.getByRole("button", { name: "Send", exact: true })).toBeDisabled();
await user.click(screen.getByRole("combobox", { name: "Team paying for this request" }));
await user.click(await screen.findByRole("option", { name: "Research" }));
await user.click(screen.getByRole("button", { name: "Send", exact: true }));
await screen.findByText("Reply 1");
await user.click(screen.getByRole("combobox", { name: "Team paying for this request" }));
await user.click(await screen.findByRole("option", { name: "Engineering" }));
enterPrompt("Use that file again");
await user.click(screen.getByRole("button", { name: "Send", exact: true }));
await screen.findByText("Reply 2");
const document = {
type: "input_file",
filename: "brief.txt",
file_data: "data:text/plain;base64,dGVhbSBkb2N1bWVudA==",
};
const originalMessage = expect.objectContaining({
role: "user",
content: expect.arrayContaining([document]),
});
expect(requests.map((request) => request.teamId)).toEqual(["research", "engineering"]);
expect(requests[0].body.input).toEqual(expect.arrayContaining([originalMessage]));
expect(requests[1].body).not.toHaveProperty("previous_response_id");
expect(requests[1].body.input).toEqual(expect.arrayContaining([originalMessage]));
});
});

View file

@ -11,7 +11,7 @@ import { getChatRoutes } from "@/components/chat/ChatShell";
import ChatMessages from "@/components/chat/ChatMessages";
import MCPConnectPicker from "@/components/chat/MCPConnectPicker";
import { fetchAvailableModels, type ModelGroup } from "@/components/llm_calls/fetch_models";
import type { AssistantMessageUpdate, ChatImageAttachment, ChatMessage } from "@/components/chat/types";
import type { AssistantMessageUpdate, ChatAttachment, ChatMessage } from "@/components/chat/types";
import ChatModelPicker, {
ChatTeamPicker,
chatTeamSelection,
@ -22,7 +22,8 @@ import ChatModelPicker, {
import MoyaiChatComposer from "@/components/chat/moyai/MoyaiChatComposer";
import MoyaiChatHome from "@/components/chat/moyai/MoyaiChatHome";
import { runChatRequest } from "@/components/chat/runChatRequest";
import { readChatImage, MAX_CHAT_IMAGES } from "@/components/chat/imageHelpers";
import { readChatAttachment, MAX_CHAT_ATTACHMENTS, CHAT_ATTACHMENT_ACCEPT } from "@/components/chat/attachmentHelpers";
import ChatFileChip from "@/components/chat/ChatFileChip";
import { useQueryState } from "nuqs";
import { recordChatQuery } from "@/lib/telemetry/chat";
@ -30,10 +31,51 @@ const LOCALSTORAGE_MODEL_KEY = "litellm_chat_selected_model";
type ReplayMessage = Pick<ChatMessage, "role" | "content" | "attachments" | "generatedImages" | "mcpEvents">;
function hasMessageContent(text: string, images: ChatImageAttachment[], mode: ChatMode): boolean {
function hasMessageContent(text: string, images: ChatAttachment[], mode: ChatMode): boolean {
return Boolean(text.trim()) || (mode === "chat" && images.length > 0);
}
function AttachmentPreview({
attachments,
disabled,
onRemove,
}: {
attachments: ChatAttachment[];
disabled: boolean;
onRemove: (id: string) => void;
}) {
if (!attachments.length) return null;
return (
<div className="flex w-full flex-wrap gap-3 px-4 pt-4" aria-label="Attached files">
{attachments.map((attachment) => (
<div key={attachment.id} className={attachment.kind === "file" ? "relative max-w-64" : "relative w-20"}>
{attachment.kind === "file" ? (
<ChatFileChip file={attachment} />
) : (
<>
{/* User attachments use local data URLs, not the image optimizer. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={attachment.url} alt={attachment.name} className="h-20 w-20 rounded-xl border object-cover" />
<p className="mt-1 truncate text-[11px] text-muted-foreground">{attachment.name}</p>
</>
)}
<Button
type="button"
variant="secondary"
size="icon-xs"
aria-label={`Remove ${attachment.name}`}
className="absolute -right-2 -top-2 rounded-full"
disabled={disabled}
onClick={() => onRemove(attachment.id)}
>
<X className="size-3" />
</Button>
</div>
))}
</div>
);
}
export default function ChatConversationPage() {
const [, setConversationIdInUrl] = useQueryState("id", { history: "push" });
const router = useRouter();
@ -57,11 +99,11 @@ export default function ChatConversationPage() {
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [models, setModels] = useState<ModelGroup[]>([]);
const [mode, setMode] = useState<ChatMode>("chat");
const [attachments, setAttachments] = useState<ChatImageAttachment[]>([]);
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [isReadingImages, setIsReadingImages] = useState(false);
const imageInputRef = useRef<HTMLInputElement>(null);
const readImagesRef = useRef(false);
const [isReadingAttachments, setIsReadingAttachments] = useState(false);
const attachmentInputRef = useRef<HTMLInputElement>(null);
const readAttachmentsRef = useRef(false);
const draftVersionRef = useRef(0);
const [isLoadingModels, setIsLoadingModels] = useState(true);
@ -141,7 +183,7 @@ export default function ChatConversationPage() {
const selectModel = useCallback(
(model: ModelGroup) => {
if (modelChatMode(model) === "image" && attachments.length > 0) {
setAttachmentError("Remove the attached images before switching to image generation.");
setAttachmentError("Remove the attached files before switching to image generation.");
return;
}
responsesSessionGenerationRef.current += 1;
@ -159,33 +201,33 @@ export default function ChatConversationPage() {
[attachments.length],
);
const addImages = async (files: File[]) => {
const addAttachments = async (files: File[]) => {
if (mode !== "chat" || files.length === 0) return;
if (isStreaming || readImagesRef.current) return;
if (attachments.length + files.length > MAX_CHAT_IMAGES) {
setAttachmentError(`Attach up to ${MAX_CHAT_IMAGES} images per message.`);
if (isStreaming || readAttachmentsRef.current) return;
if (attachments.length + files.length > MAX_CHAT_ATTACHMENTS) {
setAttachmentError(`Attach up to ${MAX_CHAT_ATTACHMENTS} files per message.`);
return;
}
const draftVersion = draftVersionRef.current;
readImagesRef.current = true;
setIsReadingImages(true);
readAttachmentsRef.current = true;
setIsReadingAttachments(true);
setAttachmentError(null);
try {
const images = await Promise.all(files.map(readChatImage));
const images = await Promise.all(files.map(readChatAttachment));
if (draftVersion === draftVersionRef.current) setAttachments((current) => [...current, ...images]);
} catch (error) {
if (draftVersion === draftVersionRef.current)
setAttachmentError(error instanceof Error ? error.message : "Could not read these images.");
setAttachmentError(error instanceof Error ? error.message : "Could not read these files.");
} finally {
readImagesRef.current = false;
setIsReadingImages(false);
readAttachmentsRef.current = false;
setIsReadingAttachments(false);
}
};
const handleImageInput = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleAttachmentInput = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
event.target.value = "";
void addImages(files);
void addAttachments(files);
};
useEffect(
@ -200,7 +242,7 @@ export default function ChatConversationPage() {
async (
text: string,
historyOverride?: ReplayMessage[],
attachmentOverride?: ChatImageAttachment[],
attachmentOverride?: ChatAttachment[],
generationModel?: string,
) => {
const trimmed = text.trim();
@ -208,7 +250,7 @@ export default function ChatConversationPage() {
const requestMode = generationModel ? "image" : mode;
const model = generationModel ?? selectedModel;
if (!hasMessageContent(trimmed, images, requestMode) || !model) return;
if (isStreaming || abortControllerRef.current || readImagesRef.current) return;
if (isStreaming || abortControllerRef.current || readAttachmentsRef.current) return;
const teamSelection = chatTeamSelection(
models.find((item) => item.model_group === model),
selectedTeamId,
@ -318,7 +360,7 @@ export default function ChatConversationPage() {
const handleEditAndResend = useCallback(
(messageId: string, newContent: string) => {
if (!activeConversationId || isStreaming) return;
if (abortControllerRef.current || readImagesRef.current) return;
if (abortControllerRef.current || readAttachmentsRef.current) return;
// Compute the truncated history synchronously before the async state update lands,
// so handleSend receives the correct pre-edit context rather than the stale closure value.
const msgs = activeConversation?.messages ?? [];
@ -411,10 +453,10 @@ export default function ChatConversationPage() {
setAttachmentError(null);
};
const isModelSelectionDisabled = isStreaming || isReadingImages;
const isModelSelectionDisabled = isStreaming || isReadingAttachments;
const inputBar = (inConversation: boolean) => {
const preparing = isLoadingModels || isReadingImages;
const preparing = isLoadingModels || isReadingAttachments;
const model = models.find((item) => item.model_group === selectedModel);
const teamSelection = chatTeamSelection(model, selectedTeamId);
const canSubmit = hasMessageContent(inputText, attachments, mode) && selectedModel;
@ -427,7 +469,7 @@ export default function ChatConversationPage() {
onDrop={(event) => {
if (mode === "chat" && event.dataTransfer.files.length) {
event.preventDefault();
void addImages(Array.from(event.dataTransfer.files));
void addAttachments(Array.from(event.dataTransfer.files));
}
}}
>
@ -451,31 +493,15 @@ export default function ChatConversationPage() {
const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/"));
if (mode === "chat" && images.length > 0) {
event.preventDefault();
void addImages(images);
void addAttachments(images);
}
}}
attachmentPreview={
attachments.length > 0 ? (
<div className="flex w-full flex-wrap gap-3 px-4 pt-4" aria-label="Attached images">
{attachments.map((image) => (
<div key={image.id} className="relative w-20">
<img src={image.url} alt={image.name} className="h-20 w-20 rounded-xl border object-cover" />
<Button
type="button"
variant="secondary"
size="icon-xs"
aria-label={`Remove ${image.name}`}
className="absolute -right-2 -top-2 rounded-full"
disabled={isStreaming}
onClick={() => setAttachments((current) => current.filter((item) => item.id !== image.id))}
>
<X className="size-3" />
</Button>
<p className="mt-1 truncate text-[11px] text-muted-foreground">{image.name}</p>
</div>
))}
</div>
) : undefined
<AttachmentPreview
attachments={attachments}
disabled={isStreaming}
onRemove={(id) => setAttachments((current) => current.filter((item) => item.id !== id))}
/>
}
modelPicker={
<div className="flex min-w-0 flex-wrap items-center justify-end gap-1">
@ -498,23 +524,23 @@ export default function ChatConversationPage() {
mode === "chat" ? (
<>
<input
ref={imageInputRef}
ref={attachmentInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
accept={CHAT_ATTACHMENT_ACCEPT}
multiple
aria-label="Upload images"
aria-label="Upload files"
className="sr-only"
disabled={isStreaming || isReadingImages}
onChange={handleImageInput}
disabled={isStreaming || isReadingAttachments}
onChange={handleAttachmentInput}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Attach images"
title="Attach images"
disabled={isStreaming || isReadingImages || attachments.length >= MAX_CHAT_IMAGES}
onClick={() => imageInputRef.current?.click()}
aria-label="Attach files"
title="Attach images, PDF, Word (.docx), Excel (.xlsx), text, or code files (up to 4 files, 10 MB each)"
disabled={isStreaming || isReadingAttachments || attachments.length >= MAX_CHAT_ATTACHMENTS}
onClick={() => attachmentInputRef.current?.click()}
>
<Paperclip className="size-4" />
</Button>
@ -553,11 +579,14 @@ export default function ChatConversationPage() {
)
}
/>
{isReadingImages && (
{isReadingAttachments && (
<p role="status" className="mt-2 text-xs text-muted-foreground">
Preparing images…
Preparing files…
</p>
)}
{attachments.some((attachment) => attachment.kind === "file") && (
<p className="mt-2 text-xs text-muted-foreground">File support depends on the selected model and provider.</p>
)}
{attachmentError && (
<p role="alert" className="mt-2 text-sm text-destructive">
{attachmentError}
@ -581,7 +610,7 @@ export default function ChatConversationPage() {
<>
{storageUnavailable && !storageBannerDismissed && (
<div className="bg-warning/10 border-b border-warning/20 px-5 py-1.5 text-[13px] text-warning flex justify-between items-center">
<span>Some chat history or images could not be saved or restored.</span>
<span>Some chat history or attachments could not be saved or restored.</span>
<Button
variant="ghost"
size="icon-xs"

View file

@ -0,0 +1,18 @@
import { FileText } from "lucide-react";
import type { ChatFileAttachment } from "./types";
export default function ChatFileChip({ file }: { file: ChatFileAttachment }) {
return (
<div className="flex min-w-0 items-center gap-2 rounded-lg border bg-muted px-3 py-2 text-sm">
<FileText className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0">
<p className="truncate" title={file.name}>
{file.name}
</p>
<p className="text-xs text-muted-foreground">
{file.url ? "Attached file" : "File unavailable — start a new chat and attach it again"}
</p>
</div>
</div>
);
}

View file

@ -18,7 +18,8 @@ import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import ReasoningContent from "@/components/chat_ui/ReasoningContent";
import MCPEventsDisplay from "@/components/chat_ui/MCPEventsDisplay";
import ResponseMetrics from "@/components/chat_ui/ResponseMetrics";
import { ChatImageAttachment, ChatMessage } from "./types";
import { ChatAttachment, ChatImageAttachment, ChatMessage } from "./types";
import ChatFileChip from "./ChatFileChip";
import { isChatImageUrl } from "./imageHelpers";
const REDACTED_KEY_PATTERNS = /token|key|secret|password|auth/i;
@ -127,13 +128,17 @@ function ChatImage({ image, allowImages }: { image: ChatImageAttachment; allowIm
);
}
function ChatImages({ images, allowImages }: { images?: ChatImageAttachment[]; allowImages: boolean }) {
function ChatImages({ images, allowImages }: { images?: ChatAttachment[]; allowImages: boolean }) {
if (!images?.length) return null;
return (
<div className={`grid gap-3 ${images.length > 1 ? "grid-cols-2" : "grid-cols-1"}`}>
{images.map((image) => (
<ChatImage key={image.id} image={image} allowImages={allowImages} />
))}
{images.map((image) =>
image.kind === "file" ? (
<ChatFileChip key={image.id} file={image} />
) : (
<ChatImage key={image.id} image={image} allowImages={allowImages} />
),
)}
</div>
);
}

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { readChatAttachment, toResponsesMessages } from "./attachmentHelpers";
import { MAX_CHAT_IMAGE_BYTES } from "./imageHelpers";
import type { ChatFileAttachment, MCPEvent } from "./types";
const file: ChatFileAttachment = {
id: "document-1",
kind: "file",
name: "brief.pdf",
mimeType: "application/pdf",
url: "data:application/pdf;base64,cGRm",
};
describe("chat attachments", () => {
it.each([
["brief.pdf", "application/pdf"],
["brief.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
["orders.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
["notes.txt", "text/plain"],
["example.py", "text/plain"],
])("should read %s with its canonical MIME type even when the browser omits it", async (name, mimeType) => {
const attachment = await readChatAttachment(new File(["fixture"], name));
const expected = { kind: "file", name, mimeType, url: `data:${mimeType};base64,Zml4dHVyZQ==` };
expect(attachment).toMatchObject(expected);
});
it("should reject unsupported, empty, and oversized documents", async () => {
await expect(readChatAttachment(new File(["binary"], "installer.exe"))).rejects.toThrow("Choose an image, PDF");
await expect(readChatAttachment(new File(["binary"], "file.constructor"))).rejects.toThrow("Choose an image, PDF");
await expect(readChatAttachment(new File(["legacy"], "legacy.xls"))).rejects.toThrow("Excel (.xlsx)");
await expect(readChatAttachment(new File([], "empty.pdf"))).rejects.toThrow("empty");
const large = new File(["pdf"], "large.pdf");
Object.defineProperty(large, "size", { value: MAX_CHAT_IMAGE_BYTES + 1 });
await expect(readChatAttachment(large)).rejects.toThrow("10 MB");
});
it("should preserve mixed files, images, and tool-only assistant history for Responses", () => {
const image = { id: "image-1", name: "diagram.png", url: "data:image/png;base64,aW1hZ2U=" };
const mcpEvents: MCPEvent[] = [
{
type: "response.output_item.done",
item: { type: "mcp_call", id: "lookup-1", name: "lookup", arguments: "{}", output: "Found it" },
},
];
expect(
toResponsesMessages([
{ role: "user", content: "Compare", attachments: [image, file] },
{ role: "assistant", content: "I read the file.", mcpEvents },
{ role: "assistant", content: "", mcpEvents },
]),
).toEqual([
{
role: "user",
content: [
{ type: "input_text", text: "Compare" },
{ type: "input_image", image_url: image.url, detail: "auto" },
{ type: "input_file", filename: file.name, file_data: file.url },
],
},
{ role: "assistant", content: "I read the file.", mcpEvents },
{ role: "assistant", content: "", mcpEvents },
]);
});
it("should fail clearly instead of silently dropping unavailable document contents", () => {
for (const url of ["", "javascript:alert(1)", "https://example.com/private.pdf", "data:text/html;base64,cGRm"]) {
expect(() => toResponsesMessages([{ role: "user", content: "Read", attachments: [{ ...file, url }] }])).toThrow(
"A saved file (brief.pdf) is unavailable",
);
}
});
});

View file

@ -0,0 +1,112 @@
import type { MultimodalContent } from "@/components/chat_ui/types";
import type { ResponsesMessage } from "@/components/llm_calls/responses_api";
import { v4 as uuid } from "uuid";
import { CHAT_IMAGE_ACCEPT, MAX_CHAT_IMAGE_BYTES, isChatImageUrl, readChatImage } from "./imageHelpers";
import type { ChatAttachment, ChatFileAttachment, ChatMessage } from "./types";
export const MAX_CHAT_ATTACHMENTS = 4;
const DOCUMENT_TYPES: Record<string, string> = {
pdf: "application/pdf",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
csv: "text/csv",
tsv: "text/tab-separated-values",
txt: "text/plain",
md: "text/markdown",
json: "application/json",
html: "text/html",
xml: "application/xml",
};
const CODE_EXTENSIONS = [
"c",
"cpp",
"css",
"go",
"h",
"java",
"js",
"jsx",
"log",
"py",
"rb",
"rs",
"sh",
"sql",
"ts",
"tsx",
"yaml",
"yml",
];
for (const extension of CODE_EXTENSIONS) DOCUMENT_TYPES[extension] = "text/plain";
export const CHAT_ATTACHMENT_ACCEPT = [
CHAT_IMAGE_ACCEPT,
...Object.keys(DOCUMENT_TYPES).map((extension) => `.${extension}`),
].join(",");
export function isChatFileData(file: ChatFileAttachment): boolean {
return Boolean(
Object.values(DOCUMENT_TYPES).includes(file.mimeType) &&
file.url.startsWith(`data:${file.mimeType};base64,`) &&
/^[A-Za-z0-9+/]+={0,2}$/.test(file.url.split(",")[1] ?? ""),
);
}
export async function readChatAttachment(file: File): Promise<ChatAttachment> {
if (file.type.startsWith("image/")) return readChatImage(file);
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
const mimeType = Object.hasOwn(DOCUMENT_TYPES, extension) ? DOCUMENT_TYPES[extension] : undefined;
if (!mimeType) throw new Error("Choose an image, PDF, Word (.docx), Excel (.xlsx), text, or code file.");
if (file.size === 0) throw new Error(`${file.name} is empty. Choose another file.`);
if (file.size > MAX_CHAT_IMAGE_BYTES) throw new Error(`${file.name} is too large. Files must be 10 MB or smaller.`);
const url = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") resolve(reader.result);
else reject(new Error(`Could not read ${file.name}. Choose the file again.`));
};
reader.onerror = () => reject(new Error(`Could not read ${file.name}. Choose the file again.`));
reader.onabort = () => reject(new Error(`Reading ${file.name} was canceled.`));
reader.readAsDataURL(new Blob([file], { type: mimeType }));
});
return { id: uuid(), kind: "file", name: file.name, url, mimeType };
}
export function toResponsesMessages(
messages: Pick<
ChatMessage,
"role" | "content" | "attachments" | "generatedImages" | "imageGeneration" | "mcpEvents"
>[],
): ResponsesMessage[] {
return messages.flatMap((message): ResponsesMessage[] => {
if (message.role === "tool") return [];
if (message.role === "assistant") {
return message.content.trim() || message.mcpEvents?.length
? [
{
role: "assistant",
content: message.content,
...(message.mcpEvents?.length ? { mcpEvents: message.mcpEvents } : {}),
},
]
: [];
}
if (!message.attachments?.length) return [{ role: "user", content: message.content }];
const content: MultimodalContent[] = [];
if (message.content.trim()) content.push({ type: "input_text", text: message.content });
for (const attachment of message.attachments) {
if (attachment.kind === "file") {
if (!isChatFileData(attachment)) {
throw new Error(`A saved file (${attachment.name}) is unavailable. Start a new chat and attach it again.`);
}
content.push({ type: "input_file", filename: attachment.name, file_data: attachment.url });
continue;
}
if (!isChatImageUrl(attachment.url)) {
throw new Error(`A saved image (${attachment.name}) is unavailable. Start a new chat and attach it again.`);
}
content.push({ type: "input_image", image_url: attachment.url, detail: "auto" });
}
return [{ role: "user", content }];
});
}

View file

@ -1,4 +1,4 @@
import type { ChatImageAttachment, Conversation } from "./types";
import type { ChatAttachment, Conversation } from "./types";
const DATABASE_NAME = "litellm_chat_media_v1";
const STORE_NAME = "images";
@ -21,8 +21,8 @@ function openDatabase(): Promise<IDBDatabase> {
};
resolve(database);
};
request.onerror = () => reject(request.error ?? new Error("Image storage is unavailable."));
request.onblocked = () => reject(new Error("Image storage is blocked by another browser tab."));
request.onerror = () => reject(request.error ?? new Error("Attachment storage is unavailable."));
request.onblocked = () => reject(new Error("Attachment storage is blocked by another browser tab."));
}).catch((error: unknown) => {
databasePromise = undefined;
throw error;
@ -31,13 +31,13 @@ function openDatabase(): Promise<IDBDatabase> {
return databasePromise;
}
export function getConversationImages(conversations: Conversation[]): ChatImageAttachment[] {
export function getConversationImages(conversations: Conversation[]): ChatAttachment[] {
return conversations.flatMap((conversation) =>
conversation.messages.flatMap((message) => [...(message.attachments ?? []), ...(message.generatedImages ?? [])]),
);
}
export function persistChatImages(userId: string, images: ChatImageAttachment[]): Promise<void> {
export function persistChatImages(userId: string, images: ChatAttachment[]): Promise<void> {
return Promise.all(
images
.filter((image) => image.url.startsWith("data:"))
@ -52,8 +52,9 @@ export function persistChatImages(userId: string, images: ChatImageAttachment[])
const transaction = database.transaction(STORE_NAME, "readwrite");
transaction.objectStore(STORE_NAME).put({ userId, id: image.id, url: image.url });
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error ?? new Error("Could not save the image."));
transaction.onabort = () => reject(transaction.error ?? new Error("Saving the image was canceled."));
transaction.onerror = () => reject(transaction.error ?? new Error("Could not save the attachment."));
transaction.onabort = () =>
reject(transaction.error ?? new Error("Saving the attachment was canceled."));
}),
)
.catch((error: unknown) => {
@ -80,8 +81,8 @@ export async function loadChatImages(userId: string, ids: string[]): Promise<Map
};
}
transaction.oncomplete = () => resolve(loaded);
transaction.onerror = () => reject(transaction.error ?? new Error("Could not restore saved images."));
transaction.onabort = () => reject(transaction.error ?? new Error("Restoring saved images was canceled."));
transaction.onerror = () => reject(transaction.error ?? new Error("Could not restore saved attachments."));
transaction.onabort = () => reject(transaction.error ?? new Error("Restoring saved attachments was canceled."));
});
}
@ -93,13 +94,13 @@ export async function deleteChatImages(userId: string, ids: string[]): Promise<v
const transaction = database.transaction(STORE_NAME, "readwrite");
for (const id of ids) transaction.objectStore(STORE_NAME).delete([userId, id]);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error ?? new Error("Could not delete saved images."));
transaction.onabort = () => reject(transaction.error ?? new Error("Deleting saved images was canceled."));
transaction.onerror = () => reject(transaction.error ?? new Error("Could not delete saved attachments."));
transaction.onabort = () => reject(transaction.error ?? new Error("Deleting saved attachments was canceled."));
});
}
export function withoutImageData(conversations: Conversation[]): Conversation[] {
const stripData = (images: ChatImageAttachment[] | undefined) =>
const stripData = <T extends ChatAttachment>(images: T[] | undefined) =>
images?.map((image) => (image.url.startsWith("data:") ? { ...image, url: "" } : image));
return conversations.map((conversation) => ({
...conversation,
@ -112,7 +113,7 @@ export function withoutImageData(conversations: Conversation[]): Conversation[]
}
export function withRestoredImages(conversations: Conversation[], images: Map<string, string>): Conversation[] {
const restore = (attachments: ChatImageAttachment[]) =>
const restore = <T extends ChatAttachment>(attachments: T[]) =>
attachments.map((image) => (!image.url && images.has(image.id) ? { ...image, url: images.get(image.id)! } : image));
return conversations.map((conversation) => ({
...conversation,

View file

@ -202,7 +202,7 @@ If you need "shrink to fit content, cap at N px" rather than "always N px," you
### Input / Textarea
Use `Input` and `Textarea` from `@/components/ui`. Standalone Chat uses `MoyaiChatComposer` for the source editor and toolbar arrangement, preserving submission, cancellation, image attachments and IME handling. Other chat surfaces continue using the shared `ChatComposer` / `InputGroupTextarea`; this visual port is scoped to standalone Chat.
Use `Input` and `Textarea` from `@/components/ui`. Standalone Chat uses `MoyaiChatComposer` for the source editor and toolbar arrangement, preserving submission, cancellation, attachments and IME handling. Uploads share a limit of four files per message and 10 MB per file. Images retain previews; PDF, DOCX, XLSX, text and code files use named file chips and native Responses `input_file` parts. File support depends on the selected model and provider. Attachment bytes stay in the existing user-scoped IndexedDB store; history metadata stays small. Legacy DOC/XLS files are not accepted. Other chat surfaces continue using the shared `ChatComposer` / `InputGroupTextarea`; this visual port is scoped to standalone Chat.
```tsx
import { Input } from "@/components/ui/input";

View file

@ -1,12 +1,7 @@
import { toResponsesMessages } from "./attachmentHelpers";
import { beforeEach, describe, expect, it, vi } from "vitest";
import OpenAI from "openai";
import {
generateChatImages,
isChatImageUrl,
MAX_CHAT_IMAGE_BYTES,
readChatImage,
toResponsesMessages,
} from "./imageHelpers";
import { generateChatImages, isChatImageUrl, MAX_CHAT_IMAGE_BYTES, readChatImage } from "./imageHelpers";
const { generate } = vi.hoisted(() => ({ generate: vi.fn() }));
vi.mock("openai", () => ({

View file

@ -1,10 +1,8 @@
import OpenAI from "openai";
import { v4 as uuid } from "uuid";
import { getGatewayBaseUrl } from "@/components/networking";
import type { MultimodalContent } from "@/components/chat_ui/types";
import type { ResponsesMessage } from "@/components/llm_calls/responses_api";
import type { ChatImageAttachment, ChatMessage } from "./types";
import type { ChatImageAttachment } from "./types";
export const MAX_CHAT_IMAGES = 4;
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
export const CHAT_IMAGE_ACCEPT = "image/png,image/jpeg,image/webp,image/gif";
const IMAGE_TYPES = new Set(CHAT_IMAGE_ACCEPT.split(","));
@ -36,39 +34,7 @@ export async function readChatImage(file: File): Promise<ChatImageAttachment> {
reader.onabort = () => reject(new Error(`Reading ${file.name} was canceled.`));
reader.readAsDataURL(file);
});
return { id: crypto.randomUUID(), name: file.name, url, mimeType: file.type };
}
export function toResponsesMessages(
messages: Pick<
ChatMessage,
"role" | "content" | "attachments" | "generatedImages" | "imageGeneration" | "mcpEvents"
>[],
): ResponsesMessage[] {
return messages.flatMap((message): ResponsesMessage[] => {
if (message.role === "tool") return [];
if (message.role === "assistant") {
return message.content.trim() || message.mcpEvents?.length
? [
{
role: "assistant",
content: message.content,
...(message.mcpEvents?.length ? { mcpEvents: message.mcpEvents } : {}),
},
]
: [];
}
if (!message.attachments?.length) return [{ role: "user", content: message.content }];
const content: MultimodalContent[] = [];
if (message.content.trim()) content.push({ type: "input_text", text: message.content });
for (const attachment of message.attachments) {
if (!isChatImageUrl(attachment.url)) {
throw new Error(`A saved image (${attachment.name}) is unavailable. Start a new chat and attach it again.`);
}
content.push({ type: "input_image", image_url: attachment.url, detail: "auto" });
}
return [{ role: "user", content }];
});
return { id: uuid(), name: file.name, url, mimeType: file.type };
}
export async function generateChatImages({
@ -95,7 +61,7 @@ export async function generateChatImages({
const images = (response.data ?? []).flatMap((image, index): ChatImageAttachment[] => {
const url = image.b64_json ? `data:${mimeType};base64,${image.b64_json}` : image.url;
if (!url || !isChatImageUrl(url)) return [];
return [{ id: crypto.randomUUID(), name: `Generated image ${index + 1}`, url }];
return [{ id: uuid(), name: `Generated image ${index + 1}`, url }];
});
if (!images.length) throw new Error("The model did not return an image. Try another prompt or image model.");
return images;

View file

@ -1,12 +1,16 @@
import OpenAI from "openai";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api";
import { generateChatImages, toResponsesMessages } from "./imageHelpers";
import { generateChatImages } from "./imageHelpers";
import { toResponsesMessages } from "./attachmentHelpers";
import { runChatRequest } from "./runChatRequest";
import type { MCPEvent } from "./types";
vi.mock("@/components/llm_calls/responses_api", () => ({ makeOpenAIResponsesRequest: vi.fn() }));
vi.mock("./imageHelpers", () => ({
generateChatImages: vi.fn(),
}));
vi.mock("./attachmentHelpers", () => ({
toResponsesMessages: vi.fn((messages) => messages),
}));
@ -105,6 +109,35 @@ describe("runChatRequest", () => {
expect(options.onResponseId).toHaveBeenCalledWith(null);
});
it.each([400, 422, 401, 429, 500])(
"should suggest a supported model only when a file request is rejected with %i",
async (status) => {
const options = requestOptions({
messages: [
{
role: "user",
content: "Read the report",
attachments: [
{
id: "report",
kind: "file",
name: "report.pdf",
mimeType: "application/pdf",
url: "data:application/pdf;base64,JVBERg==",
},
],
},
],
});
const error = new OpenAI.APIError(status, { message: "Provider detail" }, "Provider detail", new Headers());
vi.mocked(makeOpenAIResponsesRequest).mockRejectedValue(error);
await runChatRequest(options);
const hint =
status === 400 || status === 422 ? " Try a model that supports file inputs, or paste the relevant text." : "";
expect(options.onUpdate).toHaveBeenLastCalledWith({ content: error.message + hint });
},
);
it("should return generated images through the same message update callback", async () => {
const options = requestOptions({ mode: "image", model: "image-model" });
const images = [{ id: "image-1", name: "Generated image 1", url: "https://images.example/image.png" }];
@ -121,6 +154,33 @@ describe("runChatRequest", () => {
expect(generateChatImages).toHaveBeenCalledWith(expectedRequest);
});
it("should not suggest file inputs for an image-generation rejection after a file message", async () => {
const options = requestOptions({
mode: "image",
messages: [
{
role: "user",
content: "Read the report",
attachments: [
{
id: "report",
kind: "file",
name: "report.pdf",
mimeType: "application/pdf",
url: "data:application/pdf;base64,JVBERg==",
},
],
},
],
});
const error = new OpenAI.APIError(400, { message: "Invalid image size" }, "Invalid image size", new Headers());
vi.mocked(generateChatImages).mockRejectedValue(error);
await runChatRequest(options);
expect(options.onUpdate).toHaveBeenLastCalledWith({ content: error.message });
});
it("should discard image results that arrive after the request was stopped", async () => {
const controller = new AbortController();
const options = requestOptions({ mode: "image", signal: controller.signal });

View file

@ -1,5 +1,7 @@
import OpenAI from "openai";
import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api";
import { generateChatImages, toResponsesMessages } from "./imageHelpers";
import { generateChatImages } from "./imageHelpers";
import { toResponsesMessages } from "./attachmentHelpers";
import type { AssistantMessageUpdate, ChatMessage, MCPEvent } from "./types";
interface ChatRequestOptions {
@ -19,6 +21,21 @@ interface ChatRequestOptions {
onUpdate: (update: AssistantMessageUpdate) => void;
}
function requestErrorMessage(
error: unknown,
messages: ChatRequestOptions["messages"],
mode: ChatRequestOptions["mode"],
): string {
const message = error instanceof Error ? error.message : "Something went wrong. Please try again.";
const hasFile =
mode === "chat" && messages.some((item) => item.attachments?.some((attachment) => attachment.kind === "file"));
const fileRejectionStatus = error instanceof OpenAI.APIError && (error.status === 400 || error.status === 422);
if (hasFile && fileRejectionStatus) {
return message + " Try a model that supports file inputs, or paste the relevant text.";
}
return message;
}
export async function runChatRequest({
model,
mode,
@ -85,7 +102,7 @@ export async function runChatRequest({
} catch (error: unknown) {
onResponseId(null);
const stopped = signal.aborted || (error instanceof Error && error.name === "AbortError");
const errorMessage = error instanceof Error ? error.message : "Something went wrong. Please try again.";
const errorMessage = requestErrorMessage(error, messages, mode);
onUpdate({ content: stopped ? content + " [stopped]" : content || errorMessage });
} finally {
if (mcpEvents.length) onUpdate({ mcpEvents });

View file

@ -2,13 +2,24 @@ import type { MCPEvent } from "../mcp_tools/types";
import type { TokenUsage } from "../chat_ui/ResponseMetrics";
export type { MCPEvent };
export interface ChatImageAttachment {
interface AttachmentData {
id: string;
name: string;
url: string;
mimeType?: string;
}
export interface ChatImageAttachment extends AttachmentData {
kind?: "image";
}
export interface ChatFileAttachment extends AttachmentData {
kind: "file";
mimeType: string;
}
export type ChatAttachment = ChatImageAttachment | ChatFileAttachment;
export interface ChatImageGeneration {
model: string;
prompt: string;
@ -18,7 +29,7 @@ export interface ChatMessage {
id: string;
role: "user" | "assistant" | "tool";
content: string;
attachments?: ChatImageAttachment[];
attachments?: ChatAttachment[];
generatedImages?: ChatImageAttachment[];
imageGeneration?: ChatImageGeneration;
reasoningContent?: string;

View file

@ -247,4 +247,33 @@ describe("useChatHistory", () => {
expect(second.result.current.conversations).toEqual([]);
expect(deleteChatImages).toHaveBeenCalledWith("alice", [image.id]);
});
it("should save, restore, and delete document bytes with the existing user-scoped media store", async () => {
const file = {
id: "file-1",
kind: "file" as const,
name: "brief.docx",
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
url: "data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,ZmlsZQ==",
};
const first = renderHook(() => useChatHistory(null, "alice"));
let id!: string;
act(() => {
id = first.result.current.createConversation("file-model");
});
act(() => {
first.result.current.appendMessage(id, { role: "user", content: "Read this", attachments: [file] });
});
await waitFor(() => expect(persistChatImages).toHaveBeenCalledWith("alice", [file]));
await waitFor(() => expect(localStorage.getItem(aliceStorageKey)).toContain("brief.docx"));
expect(localStorage.getItem(aliceStorageKey)).not.toContain(file.url);
first.unmount();
vi.mocked(loadChatImages).mockResolvedValue(new Map([[file.id, file.url]]));
const restored = renderHook(() => useChatHistory(id, "alice"));
await waitFor(() => expect(restored.result.current.activeConversation?.messages[0].attachments).toEqual([file]));
act(() => {
restored.result.current.deleteConversation(id);
});
await waitFor(() => expect(deleteChatImages).toHaveBeenCalledWith("alice", [file.id]));
expect(JSON.parse(localStorage.getItem(aliceStorageKey)!)).toEqual([]);
});
});

View file

@ -1,3 +1,4 @@
import { v4 as uuid } from "uuid";
import { useCallback, useEffect, useState } from "react";
import { AssistantMessageUpdate, ChatMessage, Conversation } from "./types";
import {
@ -174,7 +175,7 @@ export function useChatHistory(
}, [conversations, userId, historyLoadFailed]);
const createConversation = useCallback((model: string): string => {
const id = crypto.randomUUID();
const id = uuid();
const now = Date.now();
const newConversation: Conversation = {
id,
@ -193,7 +194,7 @@ export function useChatHistory(
const appendMessage = useCallback((conversationId: string, message: Omit<ChatMessage, "id" | "timestamp">) => {
const newMessage: ChatMessage = {
...message,
id: crypto.randomUUID(),
id: uuid(),
timestamp: Date.now(),
};

View file

@ -71,7 +71,9 @@ export interface MessageType {
}
export interface MultimodalContent {
type: "input_text" | "input_image";
type: "input_text" | "input_image" | "input_file";
filename?: string;
file_data?: string;
text?: string;
image_url?: string;
detail?: "low" | "high" | "auto";