"""Offline Dify attachment regression checks, including independent outlet failure.""" import asyncio import base64 import json from unittest import TestCase import httpx import model_gateway as gw def outlet(kind='dify', **changes): config = {'id': kind, 'name': kind, 'kind': kind, 'base_url': f'https://{kind}.invalid/v1', 'api_key': 'synthetic-key', 'model': 'audit', 'max_tokens': 300, 'temperature': .2, 'timeout_ms': 2000, 'max_inflight': 2} config.update(changes) return gw.Outlet(config=config, gate=asyncio.Semaphore(2)) def image(raw=b'audit-png', mime='image/png'): return {'type': 'image_url', 'image_url': {'url': 'data:' + mime + ';base64,' + base64.b64encode(raw).decode()}} def messages(*images): return [{'role': 'user', 'content': [{'type': 'text', 'text': '请描述这些图片。'}, *images]}] class GatewayFailureAudit(TestCase): def run_request(self, handler, content, **kwargs): async def run(): async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: return await gw.call_outlet(client, kwargs.pop('outlet', outlet()), content, **kwargs) return asyncio.run(run()) def test_plain_text_does_not_upload(self): paths = [] def handler(request): paths.append(request.url.path) self.assertNotIn('files', json.loads(request.content)) return httpx.Response(200, json={'answer': '纯文字回复。'}) result = self.run_request(handler, [{'role': 'user', 'content': '在吗?'}]) self.assertEqual(result['text'], '纯文字回复。') self.assertEqual(paths, ['/v1/chat-messages']) def test_exact_endpoint_is_used_for_upload_root_and_chat(self): paths = [] def handler(request): paths.append(request.url.path) return httpx.Response(200, json={'id': 'audit-upload'} if request.url.path.endswith('/files/upload') else {'answer': '成功。'}) result = self.run_request(handler, messages(image()), outlet=outlet(base_url='https://dify.invalid/custom/invoke', endpoint_mode='exact')) self.assertEqual(result['text'], '成功。') self.assertEqual(paths, ['/custom/invoke/files/upload', '/custom/invoke']) def test_multiple_images_preserve_order_mime_bytes_and_same_user(self): uploads, bodies = [], [] def handler(request): if request.url.path.endswith('/files/upload'): uploads.append(request.content) self.assertIn(b'name="user"\r\n\r\nwechat-rpa\r\n', request.content) return httpx.Response(200, json={'id': f'upload-{len(uploads)}'}) bodies.append(json.loads(request.content)) return httpx.Response(200, json={'answer': '两张图片已读取。'}) result = self.run_request(handler, messages(image(b'first-jpeg', 'image/jpeg'), image(b'second-webp', 'image/webp'))) self.assertEqual(result['error'], '') self.assertEqual(len(uploads), 2) self.assertIn(b'Content-Type: image/jpeg', uploads[0]) self.assertIn(b'first-jpeg', uploads[0]) self.assertIn(b'Content-Type: image/webp', uploads[1]) self.assertIn(b'second-webp', uploads[1]) self.assertEqual(bodies[0]['user'], 'wechat-rpa') self.assertEqual([item['upload_file_id'] for item in bodies[0]['files']], ['upload-1', 'upload-2']) self.assertNotIn('base64', bodies[0]['query']) def test_old_turn_attachment_is_not_uploaded_for_a_text_followup(self): seen = [] def handler(request): seen.append(request.url.path) return httpx.Response(200, json={'answer': '已说明。'}) content = [*messages(image()), {'role': 'assistant', 'content': '一张蓝色图片。'}, {'role': 'user', 'content': '谢谢'}] result = self.run_request(handler, content) self.assertEqual(result['error'], '') self.assertEqual(seen, ['/v1/chat-messages']) def test_same_inline_and_legacy_image_is_only_uploaded_once(self): seen = [] def handler(request): seen.append(request.url.path) return httpx.Response(200, json={'id': 'one'} if request.url.path.endswith('/files/upload') else {'answer': '一张。'}) self.run_request(handler, messages(image()), image_b64=base64.b64encode(b'audit-png').decode()) self.assertEqual(seen.count('/v1/files/upload'), 1) def _assert_upload_failure_is_local(self, upload_response): async def run(): def handler(request): if request.url.host == 'openai.invalid': return httpx.Response(200, json={'choices': [{'message': {'content': '另一出口仍然成功。'}}]}) self.assertTrue(request.url.path.endswith('/files/upload')) if isinstance(upload_response, type) and issubclass(upload_response, httpx.HTTPError): raise upload_response('synthetic upload failure', request=request) return upload_response async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: return await asyncio.gather(gw.call_outlet(client, outlet('dify'), messages(image())), gw.call_outlet(client, outlet('openai'), messages(image()))) results = asyncio.run(run()) self.assertEqual(results[0]['text'], '') self.assertTrue(results[0]['error']) self.assertEqual(results[1]['text'], '另一出口仍然成功。') self.assertEqual(results[1]['error'], '') def test_upload_invalid_json_is_local_to_dify_outlet(self): self._assert_upload_failure_is_local(httpx.Response(200, text='invalid-json')) def test_upload_non_object_json_is_local_to_dify_outlet(self): for payload in ([{'id': 'wrong'}], 'wrong-shape', 7, None): with self.subTest(payload=payload): self._assert_upload_failure_is_local(httpx.Response(200, json=payload)) def test_upload_transport_failure_is_local_to_dify_outlet(self): for error in (httpx.ReadTimeout, httpx.ConnectError): with self.subTest(error=error.__name__): self._assert_upload_failure_is_local(error) def test_upload_http_failure_is_local_to_dify_outlet(self): for status in (401, 413, 503): with self.subTest(status=status): self._assert_upload_failure_is_local(httpx.Response(status, text='synthetic failure')) def test_remote_image_uses_file_reference_without_local_fetch(self): requests = [] def handler(request): requests.append(request) body = json.loads(request.content) self.assertEqual(body['files'], [{'type': 'image', 'transfer_method': 'remote_url', 'url': 'https://images.invalid/audit.png'}]) return httpx.Response(200, json={'answer': '已引用。'}) self.run_request(handler, messages({'type': 'image_url', 'image_url': {'url': 'https://images.invalid/audit.png'}})) self.assertEqual(len(requests), 1)