diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 40ec12dd9e..d364660b02 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -252,7 +252,7 @@ from open_webui.utils.oauth import ( from open_webui.utils.plugin import install_tool_and_function_dependencies from open_webui.utils.redis import get_redis_client from open_webui.utils.security_headers import SecurityHeadersMiddleware -from open_webui.utils.session_pool import get_session +from open_webui.utils.session_pool import cleanup_response, get_session, stream_wrapper from open_webui.utils.tools import set_terminal_servers, set_tool_servers if SAFE_MODE: @@ -1779,6 +1779,7 @@ app.state.CHAT_COMPLETION_HANDLER = chat_completion from open_webui.utils.anthropic import ( convert_anthropic_to_openai_payload, convert_openai_to_anthropic_response, + is_anthropic_messages_passthrough, openai_stream_to_anthropic_stream, ) @@ -1793,6 +1794,65 @@ async def count_message_tokens( return {'input_tokens': await openai.count_anthropic_tokens(request, form_data, user)} +async def passthrough_anthropic_messages(request: Request, form_data: dict, user) -> Response | dict: + requested_model, payload, url, key, headers, cookies = await openai.get_anthropic_token_count_target( + request, form_data, user + ) + request_url = f'{url.rstrip("/")}/messages' + response = None + streaming = False + + try: + session = await get_session() + response = await session.request( + method='POST', + url=request_url, + data=json.dumps(payload), + headers=headers, + cookies=cookies, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + timeout=aiohttp.ClientTimeout(total=openai.AIOHTTP_CLIENT_TIMEOUT), + ) + + if 'text/event-stream' in response.headers.get('Content-Type', ''): + streaming = True + return StreamingResponse( + stream_wrapper(response), + status_code=response.status, + headers=openai._clean_proxy_headers(response.headers), + ) + + try: + response_data = await response.json() + except Exception: + response_data = await response.text() + + if response.status >= 400: + await openai.publish_model_provider_request_failed( + request, + actor=user, + provider='openai-compatible', + base_url=url, + api_key=key, + status=response.status, + requested_model=requested_model, + upstream_error=response_data, + ) + if isinstance(response_data, (dict, list)): + return JSONResponse(status_code=response.status, content=response_data) + return Response(status_code=response.status, content=response_data) + + return response_data + except HTTPException: + raise + except Exception: + log.exception('Failed to passthrough Anthropic Messages request for model %s', requested_model) + raise HTTPException(status_code=502, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR) + finally: + if not streaming: + await cleanup_response(response) + + @app.post('/api/message') @app.post('/api/v1/messages') # Anthropic Messages API compatible endpoint async def generate_messages( @@ -1833,7 +1893,9 @@ async def generate_messages( models = request.app.state.OPENAI_MODELS model = models.get(model_id) if model: - _, _, api_config = await openai.get_openai_connection(model['urlIdx']) + url, _, api_config = await openai.get_openai_connection(model['urlIdx']) + if is_anthropic_messages_passthrough(url, api_config): + return await passthrough_anthropic_messages(request, form_data, user) passthrough_params = api_config.get('passthrough_params') or [] # Convert Anthropic payload to OpenAI format diff --git a/backend/open_webui/models/config.py b/backend/open_webui/models/config.py index 00fcfc2e00..6081214b4c 100644 --- a/backend/open_webui/models/config.py +++ b/backend/open_webui/models/config.py @@ -46,6 +46,7 @@ API_CONFIG_FIELDS = ( 'auth_type', 'headers', 'azure', + 'api_type', 'api_version', 'extra_params', 'passthrough_params', diff --git a/backend/open_webui/utils/anthropic.py b/backend/open_webui/utils/anthropic.py index 52dd2ea36a..3c8d5ce9cd 100644 --- a/backend/open_webui/utils/anthropic.py +++ b/backend/open_webui/utils/anthropic.py @@ -126,6 +126,13 @@ def _finalize_openai_content(blocks: list) -> str | list: return blocks +def is_anthropic_messages_passthrough(url: str, api_config: dict | None = None) -> bool: + api_config = api_config or {} + provider = str(api_config.get('provider', '')).lower() + + return is_anthropic_url(url or '') or provider == 'litellm' + + def convert_anthropic_to_openai_payload( anthropic_payload: dict, passthrough_params: list[str] | str | None = None ) -> dict: @@ -192,6 +199,8 @@ def convert_anthropic_to_openai_payload( }, ) ) + elif block_type in ('thinking', 'redacted_thinking'): + openai_content.append(_copy_cache_control(block, dict(block))) elif block_type == 'image': source = block.get('source', {}) if source.get('type') == 'base64': @@ -492,6 +501,11 @@ def convert_openai_to_anthropic_response( if not isinstance(block, dict): continue + if block.get('type') == 'redacted_thinking': + content.append({k: v for k, v in block.items() if k in {'type', 'data'}}) + has_thinking = True + continue + thinking = block.get('thinking') or block.get('content') or block.get('text') if not thinking: continue diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index 6715c80b1a..3a3549a40c 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -597,6 +597,7 @@ +