diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index e5e81fe6d328..467df3fcdb6d 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -166,7 +166,8 @@ def __len__(self) -> int: _CORS_HEADERS = { "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Authorization, Content-Type", + # Include Idempotency-Key so browser clients can use request deduplication safely. + "Access-Control-Allow-Headers": "Authorization, Content-Type, Idempotency-Key", } @@ -185,10 +186,14 @@ async def cors_middleware(request, handler): if request.method == "OPTIONS": if cors_headers is None: return web.Response(status=403) + # Ensure downstream caches do not serve one origin's CORS response to another. + cors_headers.setdefault("Vary", "Origin") return web.Response(status=200, headers=cors_headers) response = await handler(request) if cors_headers is not None: + # Ensure downstream caches do not serve one origin's CORS response to another. + cors_headers.setdefault("Vary", "Origin") response.headers.update(cors_headers) return response else: diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 2ee9284842ee..57ea860f8da1 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -1301,6 +1301,34 @@ async def test_cors_headers_present_for_allowed_origin(self): assert "DELETE" in resp.headers.get("Access-Control-Allow-Methods", "") @pytest.mark.asyncio + + + @pytest.mark.asyncio + async def test_cors_allows_idempotency_key_header(self): + adapter = _make_adapter(cors_origins=["http://localhost:3000"]) + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.options( + "/v1/chat/completions", + headers={ + "Origin": "http://localhost:3000", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Idempotency-Key", + }, + ) + assert resp.status == 200 + assert "Idempotency-Key" in resp.headers.get("Access-Control-Allow-Headers", "") + + @pytest.mark.asyncio + async def test_cors_sets_vary_origin_header(self): + adapter = _make_adapter(cors_origins=["http://localhost:3000"]) + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/health", headers={"Origin": "http://localhost:3000"}) + assert resp.status == 200 + assert resp.headers.get("Vary") == "Origin" + + @pytest.mark.asyncio async def test_cors_options_preflight_allowed_for_configured_origin(self): """Configured origins can complete browser preflight.""" adapter = _make_adapter(cors_origins=["http://localhost:3000"])