refactor: 拆分 Rust gateway/executor 大文件为模块目录结构,拆分 Python gateway.py 为子模块
Rust 侧:
- executor.rs 拆分为 executor/ 目录 (plan_builders, stream, sync, submission)
- 新增 kiro_stream/, local_finalize/, local_stream/ 模块目录
- 新增 video_tasks.rs
- 测试文件 ai_execute/files/video 拆分为子目录
- handlers/headers/control/constants 扩展支持新模块
Python 侧:
- gateway.py 拆分为 24 个子模块 (routes, shared, contract, chat, cli, video, files, finalize, reporting 等)
- 新增 antigravity/gemini_cli/kiro 的 rust_http 适配层
- upstream_fetcher 增加 Rust sidecar 支持
- executor_plan/candidate/pipeline 适配调整
测试:
- 对应拆分 test_internal_gateway_routes 为子目录
- 新增 rust_http 相关测试
2026-03-23 17:19:15 +08:00
|
|
|
"""Gateway internal decision/plan builder tests."""
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import base64
|
|
|
|
|
import json
|
|
|
|
|
import time
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
from typing import Any
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from fastapi import BackgroundTasks, FastAPI
|
|
|
|
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
from sqlalchemy import create_engine
|
|
|
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
|
|
|
|
|
|
from src.api.internal.gateway import (
|
|
|
|
|
CONTROL_ACTION_HEADER,
|
|
|
|
|
CONTROL_ACTION_PROXY_PUBLIC,
|
|
|
|
|
CONTROL_EXECUTED_HEADER,
|
|
|
|
|
GatewayAuthContext,
|
|
|
|
|
GatewayExecutionDecisionResponse,
|
|
|
|
|
GatewayExecuteRequest,
|
|
|
|
|
GatewayExecutionPlanResponse,
|
|
|
|
|
GatewayResolveRequest,
|
|
|
|
|
GatewayStreamReportRequest,
|
|
|
|
|
GatewaySyncReportRequest,
|
|
|
|
|
_dispatch_gateway_sync_telemetry,
|
|
|
|
|
_build_gateway_sync_error_payload,
|
|
|
|
|
_build_gateway_sync_telemetry_writer,
|
|
|
|
|
_run_gateway_stream_report_background,
|
|
|
|
|
_run_gateway_sync_report_background,
|
|
|
|
|
_stream_executor_requires_python_rewrite,
|
|
|
|
|
_build_claude_chat_sync_decision,
|
|
|
|
|
_build_claude_chat_stream_decision,
|
|
|
|
|
_build_claude_cli_sync_decision,
|
|
|
|
|
_build_claude_cli_stream_decision,
|
|
|
|
|
_build_gemini_files_download_stream_decision,
|
|
|
|
|
_build_gemini_files_proxy_sync_decision,
|
|
|
|
|
_build_gemini_chat_sync_decision,
|
|
|
|
|
_build_gemini_chat_stream_decision,
|
|
|
|
|
_build_gemini_cli_sync_decision,
|
|
|
|
|
_build_gemini_cli_stream_decision,
|
|
|
|
|
_build_openai_chat_sync_decision,
|
|
|
|
|
_build_openai_chat_stream_decision,
|
|
|
|
|
_build_openai_cli_stream_decision,
|
|
|
|
|
_build_openai_video_content_stream_decision,
|
|
|
|
|
_extract_gateway_sync_error_message,
|
|
|
|
|
_record_gateway_direct_candidate_graph,
|
|
|
|
|
_resolve_gateway_sync_error_status_code,
|
|
|
|
|
_build_openai_chat_sync_plan,
|
|
|
|
|
_build_openai_cli_sync_decision,
|
|
|
|
|
_build_openai_cli_stream_plan,
|
|
|
|
|
_build_openai_cli_sync_plan,
|
|
|
|
|
_is_streaming_sync_payload,
|
|
|
|
|
_resolve_auth_context,
|
|
|
|
|
_resolve_gateway_sync_adapter,
|
|
|
|
|
classify_gateway_route,
|
|
|
|
|
router,
|
|
|
|
|
)
|
|
|
|
|
from src.database import get_db
|
|
|
|
|
from src.models.database import Base, RequestCandidate
|
|
|
|
|
from src.services.orchestration.candidate_resolver import CandidateResolver
|
|
|
|
|
from src.services.scheduling.schemas import PoolCandidate, ProviderCandidate
|
2026-04-03 14:59:58 +08:00
|
|
|
from src.services.request.execution_runtime_plan import ExecutionPlan, ExecutionPlanBody, PreparedExecutionPlan
|
refactor: 拆分 Rust gateway/executor 大文件为模块目录结构,拆分 Python gateway.py 为子模块
Rust 侧:
- executor.rs 拆分为 executor/ 目录 (plan_builders, stream, sync, submission)
- 新增 kiro_stream/, local_finalize/, local_stream/ 模块目录
- 新增 video_tasks.rs
- 测试文件 ai_execute/files/video 拆分为子目录
- handlers/headers/control/constants 扩展支持新模块
Python 侧:
- gateway.py 拆分为 24 个子模块 (routes, shared, contract, chat, cli, video, files, finalize, reporting 等)
- 新增 antigravity/gemini_cli/kiro 的 rust_http 适配层
- upstream_fetcher 增加 Rust sidecar 支持
- executor_plan/candidate/pipeline 适配调整
测试:
- 对应拆分 test_internal_gateway_routes 为子目录
- 新增 rust_http 相关测试
2026-03-23 17:19:15 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _wait_until(predicate: Any, *, timeout: float = 1.0, interval: float = 0.01) -> None:
|
|
|
|
|
deadline = time.time() + timeout
|
|
|
|
|
while time.time() < deadline:
|
|
|
|
|
if predicate():
|
|
|
|
|
return
|
|
|
|
|
time.sleep(interval)
|
|
|
|
|
assert predicate()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_build_openai_chat_sync_plan_uses_finalize_for_cross_format_candidate(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
from src.api.handlers.openai import OpenAIChatAdapter
|
|
|
|
|
|
|
|
|
|
adapter = OpenAIChatAdapter()
|
|
|
|
|
fake_handler = SimpleNamespace(
|
|
|
|
|
allowed_api_formats=["openai:chat"],
|
|
|
|
|
_convert_request=AsyncMock(return_value={"model": "gpt-5", "messages": []}),
|
|
|
|
|
extract_model_from_request=lambda body, path_params: "gpt-5",
|
|
|
|
|
_resolve_capability_requirements=lambda **kwargs: None,
|
|
|
|
|
_resolve_preferred_key_ids=AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
fake_context = SimpleNamespace(
|
|
|
|
|
path_params={},
|
|
|
|
|
request_id="req-chat-finalize-123",
|
|
|
|
|
client_ip="127.0.0.1",
|
|
|
|
|
user_agent="pytest",
|
|
|
|
|
start_time=0.0,
|
|
|
|
|
original_headers={"content-type": "application/json", "authorization": "Bearer test-key"},
|
|
|
|
|
query_params={},
|
|
|
|
|
client_content_encoding=None,
|
|
|
|
|
)
|
|
|
|
|
fake_context.ensure_json_body_async = AsyncMock(
|
|
|
|
|
return_value={"model": "gpt-5", "messages": []}
|
|
|
|
|
)
|
|
|
|
|
fake_candidate = SimpleNamespace(
|
|
|
|
|
provider=SimpleNamespace(id="provider-chat-finalize-123", name="gemini"),
|
|
|
|
|
endpoint=SimpleNamespace(id="endpoint-chat-finalize-123", api_format="gemini:chat"),
|
|
|
|
|
key=SimpleNamespace(id="key-chat-finalize-123"),
|
|
|
|
|
mapping_matched_model="gemini-2.5-pro",
|
|
|
|
|
request_candidate_id="cand-chat-finalize-123",
|
|
|
|
|
)
|
|
|
|
|
prepared_plan = PreparedExecutionPlan(
|
|
|
|
|
contract=ExecutionPlan(
|
|
|
|
|
request_id="req-chat-finalize-123",
|
|
|
|
|
candidate_id="cand-chat-finalize-123",
|
|
|
|
|
provider_name="gemini",
|
|
|
|
|
provider_id="provider-chat-finalize-123",
|
|
|
|
|
endpoint_id="endpoint-chat-finalize-123",
|
|
|
|
|
key_id="key-chat-finalize-123",
|
|
|
|
|
method="POST",
|
|
|
|
|
url="https://api.gemini.example/v1beta/models/gemini-2.5-pro:generateContent",
|
|
|
|
|
headers={"content-type": "application/json"},
|
|
|
|
|
body=ExecutionPlanBody(json_body={"contents": []}),
|
|
|
|
|
stream=False,
|
|
|
|
|
provider_api_format="gemini:chat",
|
|
|
|
|
client_api_format="openai:chat",
|
|
|
|
|
model_name="gpt-5",
|
|
|
|
|
),
|
|
|
|
|
payload={"contents": []},
|
|
|
|
|
headers={"content-type": "application/json"},
|
|
|
|
|
upstream_is_stream=False,
|
|
|
|
|
needs_conversion=True,
|
|
|
|
|
provider_type="gemini",
|
|
|
|
|
request_timeout=30.0,
|
|
|
|
|
envelope=object(),
|
|
|
|
|
proxy_info={"mode": "direct"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class FakeChatSyncExecutor:
|
|
|
|
|
def __init__(self, handler: object) -> None:
|
|
|
|
|
self._ctx = SimpleNamespace(mapped_model_result="gemini-2.5-pro")
|
|
|
|
|
|
|
|
|
|
async def _build_sync_execution_plan(self, *args: object, **kwargs: object) -> PreparedExecutionPlan:
|
|
|
|
|
return prepared_plan
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(adapter, "authorize", lambda context: None)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter,
|
|
|
|
|
"_create_handler",
|
|
|
|
|
lambda **kwargs: fake_handler,
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter,
|
|
|
|
|
"_validate_request_body",
|
|
|
|
|
lambda body, path_params: body,
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter,
|
|
|
|
|
"_merge_path_params",
|
|
|
|
|
lambda body, path_params: body,
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._resolve_gateway_sync_adapter",
|
|
|
|
|
lambda decision, path: (adapter, {}),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._load_gateway_auth_models",
|
|
|
|
|
lambda db, auth_context: (
|
|
|
|
|
SimpleNamespace(id="user-chat-finalize-123"),
|
|
|
|
|
SimpleNamespace(id="api-key-chat-finalize-123"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway.get_pipeline",
|
|
|
|
|
lambda: SimpleNamespace(_check_user_rate_limit=AsyncMock(return_value=None)),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._build_gateway_request_context",
|
|
|
|
|
lambda **kwargs: fake_context,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._select_gateway_direct_candidate",
|
|
|
|
|
AsyncMock(return_value=fake_candidate),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.handlers.base.chat_sync_executor.ChatSyncExecutor",
|
|
|
|
|
FakeChatSyncExecutor,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
decision = classify_gateway_route("POST", "/v1/chat/completions")
|
|
|
|
|
auth_context = GatewayAuthContext(
|
|
|
|
|
user_id="user-chat-finalize-123",
|
|
|
|
|
api_key_id="api-key-chat-finalize-123",
|
|
|
|
|
access_allowed=True,
|
|
|
|
|
)
|
|
|
|
|
payload = GatewayExecuteRequest(
|
|
|
|
|
method="POST",
|
|
|
|
|
path="/v1/chat/completions",
|
|
|
|
|
headers={"content-type": "application/json", "authorization": "Bearer test-key"},
|
|
|
|
|
body_json={"model": "gpt-5", "messages": []},
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = await _build_openai_chat_sync_plan(
|
|
|
|
|
request=SimpleNamespace(),
|
|
|
|
|
payload=payload,
|
|
|
|
|
db=object(),
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
decision=decision,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert result.report_kind == "openai_chat_sync_finalize"
|
|
|
|
|
assert result.report_context["provider_api_format"] == "gemini:chat"
|
|
|
|
|
assert result.report_context["client_api_format"] == "openai:chat"
|
|
|
|
|
assert result.report_context["mapped_model"] == "gemini-2.5-pro"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_build_openai_chat_sync_decision_allows_upstream_stream_finalize(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
from src.api.handlers.openai import OpenAIChatAdapter
|
|
|
|
|
|
|
|
|
|
adapter = OpenAIChatAdapter()
|
|
|
|
|
fake_context = SimpleNamespace(
|
|
|
|
|
path_params={},
|
|
|
|
|
request_id="req-chat-upstream-stream-decision-123",
|
|
|
|
|
client_ip="127.0.0.1",
|
|
|
|
|
user_agent="pytest",
|
|
|
|
|
start_time=0.0,
|
|
|
|
|
original_headers={"content-type": "application/json", "authorization": "Bearer test-key"},
|
|
|
|
|
query_params={},
|
|
|
|
|
client_content_encoding=None,
|
|
|
|
|
extra={},
|
|
|
|
|
)
|
|
|
|
|
fake_context.ensure_json_body_async = AsyncMock(
|
|
|
|
|
return_value={"model": "gpt-5", "messages": []}
|
|
|
|
|
)
|
|
|
|
|
fake_candidate = SimpleNamespace(
|
|
|
|
|
provider=SimpleNamespace(
|
|
|
|
|
id="provider-chat-upstream-stream-decision-123",
|
|
|
|
|
name="openai",
|
|
|
|
|
provider_type="openai",
|
|
|
|
|
proxy=None,
|
|
|
|
|
request_timeout=30.0,
|
|
|
|
|
),
|
|
|
|
|
endpoint=SimpleNamespace(id="endpoint-chat-upstream-stream-decision-123", api_format="openai:chat"),
|
|
|
|
|
key=SimpleNamespace(id="key-chat-upstream-stream-decision-123", proxy=None),
|
|
|
|
|
mapping_matched_model="gpt-5",
|
|
|
|
|
request_candidate_id="cand-chat-upstream-stream-decision-123",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(adapter, "authorize", lambda context: None)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter,
|
|
|
|
|
"_create_handler",
|
|
|
|
|
lambda **kwargs: SimpleNamespace(
|
|
|
|
|
allowed_api_formats=["openai:chat"],
|
|
|
|
|
extract_model_from_request=lambda body, path_params: "gpt-5",
|
|
|
|
|
apply_mapped_model=lambda body, mapped_model: {**body, "model": mapped_model},
|
|
|
|
|
_resolve_capability_requirements=lambda **kwargs: None,
|
|
|
|
|
_resolve_preferred_key_ids=AsyncMock(return_value=None),
|
|
|
|
|
_get_mapped_model=AsyncMock(return_value="gpt-5"),
|
|
|
|
|
),
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter,
|
|
|
|
|
"_validate_request_body",
|
|
|
|
|
lambda body, path_params: body,
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter,
|
|
|
|
|
"_merge_path_params",
|
|
|
|
|
lambda body, path_params: body,
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._resolve_gateway_sync_adapter",
|
|
|
|
|
lambda decision, path: (adapter, {}),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._load_gateway_auth_models",
|
|
|
|
|
lambda db, auth_context: (
|
|
|
|
|
SimpleNamespace(id="user-chat-upstream-stream-decision-123"),
|
|
|
|
|
SimpleNamespace(id="api-key-chat-upstream-stream-decision-123"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway.get_pipeline",
|
|
|
|
|
lambda: SimpleNamespace(_check_user_rate_limit=AsyncMock(return_value=None)),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._build_gateway_request_context",
|
|
|
|
|
lambda **kwargs: fake_context,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._select_gateway_direct_candidate",
|
|
|
|
|
AsyncMock(return_value=fake_candidate),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.provider.behavior.get_provider_behavior",
|
|
|
|
|
lambda **kwargs: SimpleNamespace(
|
|
|
|
|
envelope=None,
|
|
|
|
|
same_format_variant=None,
|
|
|
|
|
cross_format_variant=None,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.provider.stream_policy.resolve_upstream_is_stream",
|
|
|
|
|
lambda **kwargs: True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.provider.prompt_cache.maybe_patch_request_with_prompt_cache_key",
|
|
|
|
|
lambda body, **kwargs: {**body, "prompt_cache_key": "cache-key-123"},
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.provider.auth.get_provider_auth",
|
|
|
|
|
AsyncMock(
|
|
|
|
|
return_value=SimpleNamespace(
|
|
|
|
|
as_tuple=lambda: ("authorization", "Bearer upstream-key"),
|
|
|
|
|
decrypted_auth_config=None,
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.provider.upstream_headers.build_upstream_extra_headers",
|
|
|
|
|
lambda **kwargs: {"x-extra-upstream": "1"},
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.resolve_proxy_info_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.get_system_proxy_config_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.resolve_delegate_config_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.build_proxy_url_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
decision = classify_gateway_route("POST", "/v1/chat/completions")
|
|
|
|
|
auth_context = GatewayAuthContext(
|
|
|
|
|
user_id="user-chat-upstream-stream-decision-123",
|
|
|
|
|
api_key_id="api-key-chat-upstream-stream-decision-123",
|
|
|
|
|
access_allowed=True,
|
|
|
|
|
)
|
|
|
|
|
payload = GatewayExecuteRequest(
|
|
|
|
|
method="POST",
|
|
|
|
|
path="/v1/chat/completions",
|
|
|
|
|
headers={"content-type": "application/json", "authorization": "Bearer test-key"},
|
|
|
|
|
body_json={"model": "gpt-5", "messages": []},
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = await _build_openai_chat_sync_decision(
|
|
|
|
|
request=SimpleNamespace(),
|
|
|
|
|
payload=payload,
|
|
|
|
|
db=object(),
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
decision=decision,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert result.upstream_is_stream is True
|
|
|
|
|
assert result.report_kind == "openai_chat_sync_finalize"
|
|
|
|
|
assert result.mapped_model == "gpt-5"
|
|
|
|
|
assert result.prompt_cache_key == "cache-key-123"
|
|
|
|
|
assert result.extra_headers == {"x-extra-upstream": "1"}
|
|
|
|
|
assert result.provider_request_headers is None
|
|
|
|
|
assert result.provider_request_body is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_build_claude_chat_sync_decision_uses_finalize_for_cross_format_candidate(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
from src.api.handlers.claude import ClaudeChatAdapter
|
|
|
|
|
|
|
|
|
|
adapter = ClaudeChatAdapter()
|
|
|
|
|
fake_handler = SimpleNamespace(
|
|
|
|
|
allowed_api_formats=["claude:chat"],
|
|
|
|
|
_convert_request=AsyncMock(return_value={"model": "claude-sonnet-4-5", "messages": []}),
|
|
|
|
|
extract_model_from_request=lambda body, path_params: "claude-sonnet-4-5",
|
|
|
|
|
_resolve_capability_requirements=lambda **kwargs: None,
|
|
|
|
|
_resolve_preferred_key_ids=AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
fake_context = SimpleNamespace(
|
|
|
|
|
path_params={},
|
|
|
|
|
request_id="req-claude-chat-decision-finalize-123",
|
|
|
|
|
client_ip="127.0.0.1",
|
|
|
|
|
user_agent="pytest",
|
|
|
|
|
start_time=0.0,
|
|
|
|
|
original_headers={"content-type": "application/json", "x-api-key": "client-key"},
|
|
|
|
|
query_params={},
|
|
|
|
|
client_content_encoding=None,
|
|
|
|
|
)
|
|
|
|
|
fake_context.ensure_json_body_async = AsyncMock(
|
|
|
|
|
return_value={"model": "claude-sonnet-4-5", "messages": []}
|
|
|
|
|
)
|
|
|
|
|
fake_candidate = SimpleNamespace(
|
|
|
|
|
provider=SimpleNamespace(id="provider-claude-chat-decision-finalize-123", name="gemini"),
|
|
|
|
|
endpoint=SimpleNamespace(
|
|
|
|
|
id="endpoint-claude-chat-decision-finalize-123",
|
|
|
|
|
base_url="https://api.gemini.example",
|
|
|
|
|
),
|
|
|
|
|
key=SimpleNamespace(id="key-claude-chat-decision-finalize-123", api_key="enc-key"),
|
|
|
|
|
request_candidate_id="cand-claude-chat-decision-finalize-123",
|
|
|
|
|
)
|
|
|
|
|
prepared_plan = PreparedExecutionPlan(
|
|
|
|
|
contract=ExecutionPlan(
|
|
|
|
|
request_id="req-claude-chat-decision-finalize-123",
|
|
|
|
|
candidate_id="cand-claude-chat-decision-finalize-123",
|
|
|
|
|
provider_name="gemini",
|
|
|
|
|
provider_id="provider-claude-chat-decision-finalize-123",
|
|
|
|
|
endpoint_id="endpoint-claude-chat-decision-finalize-123",
|
|
|
|
|
key_id="key-claude-chat-decision-finalize-123",
|
|
|
|
|
method="POST",
|
|
|
|
|
url="https://api.gemini.example/v1beta/models/gemini-2.5-pro:generateContent",
|
|
|
|
|
headers={
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"x-goog-api-key": "upstream-key",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
},
|
|
|
|
|
body=ExecutionPlanBody(
|
|
|
|
|
json_body={"contents": [{"role": "user", "parts": [{"text": "hello"}]}]}
|
|
|
|
|
),
|
|
|
|
|
stream=False,
|
|
|
|
|
provider_api_format="gemini:chat",
|
|
|
|
|
client_api_format="claude:chat",
|
|
|
|
|
model_name="claude-sonnet-4-5",
|
|
|
|
|
tls_profile="custom-profile",
|
|
|
|
|
),
|
|
|
|
|
payload={"contents": [{"role": "user", "parts": [{"text": "hello"}]}]},
|
|
|
|
|
headers={
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"x-goog-api-key": "upstream-key",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
},
|
|
|
|
|
upstream_is_stream=False,
|
|
|
|
|
needs_conversion=True,
|
|
|
|
|
provider_type="gemini",
|
|
|
|
|
request_timeout=30.0,
|
|
|
|
|
envelope=object(),
|
|
|
|
|
proxy_info={"mode": "direct"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class FakeChatSyncExecutor:
|
|
|
|
|
def __init__(self, handler: object) -> None:
|
|
|
|
|
self._ctx = SimpleNamespace(mapped_model_result="gemini-2.5-pro")
|
|
|
|
|
|
|
|
|
|
async def _build_sync_execution_plan(
|
|
|
|
|
self, *args: object, **kwargs: object
|
|
|
|
|
) -> PreparedExecutionPlan:
|
|
|
|
|
return prepared_plan
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(adapter, "authorize", lambda context: None)
|
|
|
|
|
monkeypatch.setattr(adapter, "_create_handler", lambda **kwargs: fake_handler, raising=False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter, "_validate_request_body", lambda body, path_params: body, raising=False
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter, "_merge_path_params", lambda body, path_params: body, raising=False
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._resolve_gateway_sync_adapter",
|
|
|
|
|
lambda decision, path: (adapter, {}),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._load_gateway_auth_models",
|
|
|
|
|
lambda db, auth_context: (
|
|
|
|
|
SimpleNamespace(id="user-claude-chat-decision-finalize-123"),
|
|
|
|
|
SimpleNamespace(id="api-key-claude-chat-decision-finalize-123"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway.get_pipeline",
|
|
|
|
|
lambda: SimpleNamespace(_check_user_rate_limit=AsyncMock(return_value=None)),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._build_gateway_request_context",
|
|
|
|
|
lambda **kwargs: fake_context,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._select_gateway_direct_candidate",
|
|
|
|
|
AsyncMock(return_value=fake_candidate),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.handlers.base.chat_sync_executor.ChatSyncExecutor",
|
|
|
|
|
FakeChatSyncExecutor,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
decision = classify_gateway_route("POST", "/v1/messages", {"x-api-key": "client-key"})
|
|
|
|
|
auth_context = GatewayAuthContext(
|
|
|
|
|
user_id="user-claude-chat-decision-finalize-123",
|
|
|
|
|
api_key_id="api-key-claude-chat-decision-finalize-123",
|
|
|
|
|
access_allowed=True,
|
|
|
|
|
)
|
|
|
|
|
payload = GatewayExecuteRequest(
|
|
|
|
|
method="POST",
|
|
|
|
|
path="/v1/messages",
|
|
|
|
|
headers={"content-type": "application/json", "x-api-key": "client-key"},
|
|
|
|
|
body_json={"model": "claude-sonnet-4-5", "messages": []},
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = await _build_claude_chat_sync_decision(
|
|
|
|
|
request=SimpleNamespace(),
|
|
|
|
|
payload=payload,
|
|
|
|
|
db=object(),
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
decision=decision,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert result.report_kind == "claude_chat_sync_finalize"
|
|
|
|
|
assert result.provider_api_format == "gemini:chat"
|
|
|
|
|
assert result.client_api_format == "claude:chat"
|
|
|
|
|
assert result.mapped_model == "gemini-2.5-pro"
|
|
|
|
|
assert (
|
|
|
|
|
result.upstream_url
|
|
|
|
|
== "https://api.gemini.example/v1beta/models/gemini-2.5-pro:generateContent"
|
|
|
|
|
)
|
|
|
|
|
assert result.tls_profile == "custom-profile"
|
|
|
|
|
assert result.provider_request_headers == {
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"x-goog-api-key": "upstream-key",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
}
|
|
|
|
|
assert result.provider_request_body == {
|
|
|
|
|
"contents": [{"role": "user", "parts": [{"text": "hello"}]}]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_build_gemini_chat_sync_decision_uses_finalize_for_cross_format_candidate(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
from src.api.handlers.gemini import GeminiChatAdapter
|
|
|
|
|
|
|
|
|
|
adapter = GeminiChatAdapter()
|
|
|
|
|
fake_handler = SimpleNamespace(
|
|
|
|
|
allowed_api_formats=["gemini:chat"],
|
|
|
|
|
_convert_request=AsyncMock(return_value={"contents": []}),
|
|
|
|
|
extract_model_from_request=lambda body, path_params: "gemini-2.5-pro",
|
|
|
|
|
_resolve_capability_requirements=lambda **kwargs: None,
|
|
|
|
|
_resolve_preferred_key_ids=AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
fake_context = SimpleNamespace(
|
|
|
|
|
path_params={},
|
|
|
|
|
request_id="req-gemini-chat-decision-finalize-123",
|
|
|
|
|
client_ip="127.0.0.1",
|
|
|
|
|
user_agent="pytest",
|
|
|
|
|
start_time=0.0,
|
|
|
|
|
original_headers={"content-type": "application/json", "x-goog-api-key": "client-key"},
|
|
|
|
|
query_params={},
|
|
|
|
|
client_content_encoding=None,
|
|
|
|
|
)
|
|
|
|
|
fake_context.ensure_json_body_async = AsyncMock(return_value={"contents": []})
|
|
|
|
|
fake_candidate = SimpleNamespace(
|
|
|
|
|
provider=SimpleNamespace(id="provider-gemini-chat-decision-finalize-123", name="openai"),
|
|
|
|
|
endpoint=SimpleNamespace(
|
|
|
|
|
id="endpoint-gemini-chat-decision-finalize-123",
|
|
|
|
|
base_url="https://api.openai.example",
|
|
|
|
|
),
|
|
|
|
|
key=SimpleNamespace(id="key-gemini-chat-decision-finalize-123", api_key="enc-key"),
|
|
|
|
|
request_candidate_id="cand-gemini-chat-decision-finalize-123",
|
|
|
|
|
)
|
|
|
|
|
prepared_plan = PreparedExecutionPlan(
|
|
|
|
|
contract=ExecutionPlan(
|
|
|
|
|
request_id="req-gemini-chat-decision-finalize-123",
|
|
|
|
|
candidate_id="cand-gemini-chat-decision-finalize-123",
|
|
|
|
|
provider_name="openai",
|
|
|
|
|
provider_id="provider-gemini-chat-decision-finalize-123",
|
|
|
|
|
endpoint_id="endpoint-gemini-chat-decision-finalize-123",
|
|
|
|
|
key_id="key-gemini-chat-decision-finalize-123",
|
|
|
|
|
method="POST",
|
|
|
|
|
url="https://api.openai.example/v1/chat/completions",
|
|
|
|
|
headers={
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"authorization": "Bearer upstream-key",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
},
|
|
|
|
|
body=ExecutionPlanBody(
|
|
|
|
|
json_body={"model": "gpt-5.1", "messages": [{"role": "user", "content": "hello"}]}
|
|
|
|
|
),
|
|
|
|
|
stream=False,
|
|
|
|
|
provider_api_format="openai:chat",
|
|
|
|
|
client_api_format="gemini:chat",
|
|
|
|
|
model_name="gemini-2.5-pro",
|
|
|
|
|
tls_profile="custom-profile",
|
|
|
|
|
),
|
|
|
|
|
payload={"model": "gpt-5.1", "messages": [{"role": "user", "content": "hello"}]},
|
|
|
|
|
headers={
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"authorization": "Bearer upstream-key",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
},
|
|
|
|
|
upstream_is_stream=False,
|
|
|
|
|
needs_conversion=True,
|
|
|
|
|
provider_type="openai",
|
|
|
|
|
request_timeout=30.0,
|
|
|
|
|
envelope=object(),
|
|
|
|
|
proxy_info={"mode": "direct"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class FakeChatSyncExecutor:
|
|
|
|
|
def __init__(self, handler: object) -> None:
|
|
|
|
|
self._ctx = SimpleNamespace(mapped_model_result="gpt-5.1")
|
|
|
|
|
|
|
|
|
|
async def _build_sync_execution_plan(
|
|
|
|
|
self, *args: object, **kwargs: object
|
|
|
|
|
) -> PreparedExecutionPlan:
|
|
|
|
|
return prepared_plan
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(adapter, "authorize", lambda context: None)
|
|
|
|
|
monkeypatch.setattr(adapter, "_create_handler", lambda **kwargs: fake_handler, raising=False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter, "_validate_request_body", lambda body, path_params: body, raising=False
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter, "_merge_path_params", lambda body, path_params: body, raising=False
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._resolve_gateway_sync_adapter",
|
|
|
|
|
lambda decision, path: (adapter, {}),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._load_gateway_auth_models",
|
|
|
|
|
lambda db, auth_context: (
|
|
|
|
|
SimpleNamespace(id="user-gemini-chat-decision-finalize-123"),
|
|
|
|
|
SimpleNamespace(id="api-key-gemini-chat-decision-finalize-123"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway.get_pipeline",
|
|
|
|
|
lambda: SimpleNamespace(_check_user_rate_limit=AsyncMock(return_value=None)),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._build_gateway_request_context",
|
|
|
|
|
lambda **kwargs: fake_context,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._select_gateway_direct_candidate",
|
|
|
|
|
AsyncMock(return_value=fake_candidate),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.handlers.base.chat_sync_executor.ChatSyncExecutor",
|
|
|
|
|
FakeChatSyncExecutor,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
decision = classify_gateway_route(
|
|
|
|
|
"POST",
|
|
|
|
|
"/v1beta/models/gemini-2.5-pro:generateContent",
|
|
|
|
|
{"x-goog-api-key": "client-key"},
|
|
|
|
|
)
|
|
|
|
|
auth_context = GatewayAuthContext(
|
|
|
|
|
user_id="user-gemini-chat-decision-finalize-123",
|
|
|
|
|
api_key_id="api-key-gemini-chat-decision-finalize-123",
|
|
|
|
|
access_allowed=True,
|
|
|
|
|
)
|
|
|
|
|
payload = GatewayExecuteRequest(
|
|
|
|
|
method="POST",
|
|
|
|
|
path="/v1beta/models/gemini-2.5-pro:generateContent",
|
|
|
|
|
headers={"content-type": "application/json", "x-goog-api-key": "client-key"},
|
|
|
|
|
body_json={"contents": []},
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = await _build_gemini_chat_sync_decision(
|
|
|
|
|
request=SimpleNamespace(),
|
|
|
|
|
payload=payload,
|
|
|
|
|
db=object(),
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
decision=decision,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert result.report_kind == "gemini_chat_sync_finalize"
|
|
|
|
|
assert result.provider_api_format == "openai:chat"
|
|
|
|
|
assert result.client_api_format == "gemini:chat"
|
|
|
|
|
assert result.mapped_model == "gpt-5.1"
|
|
|
|
|
assert result.upstream_url == "https://api.openai.example/v1/chat/completions"
|
|
|
|
|
assert result.tls_profile == "custom-profile"
|
|
|
|
|
assert result.provider_request_headers == {
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"authorization": "Bearer upstream-key",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
}
|
|
|
|
|
assert result.provider_request_body == {
|
|
|
|
|
"model": "gpt-5.1",
|
|
|
|
|
"messages": [{"role": "user", "content": "hello"}],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_stream_executor_requires_python_rewrite_allows_claude_to_openai_chat_conversion() -> None:
|
|
|
|
|
assert (
|
|
|
|
|
_stream_executor_requires_python_rewrite(
|
|
|
|
|
envelope=None,
|
|
|
|
|
needs_conversion=True,
|
|
|
|
|
provider_api_format="claude:chat",
|
|
|
|
|
client_api_format="openai:chat",
|
|
|
|
|
)
|
|
|
|
|
is False
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
_stream_executor_requires_python_rewrite(
|
|
|
|
|
envelope=None,
|
|
|
|
|
needs_conversion=True,
|
|
|
|
|
provider_api_format="gemini:chat",
|
|
|
|
|
client_api_format="openai:chat",
|
|
|
|
|
)
|
|
|
|
|
is False
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
_stream_executor_requires_python_rewrite(
|
|
|
|
|
envelope=None,
|
|
|
|
|
needs_conversion=True,
|
|
|
|
|
provider_api_format="claude:cli",
|
|
|
|
|
client_api_format="openai:cli",
|
|
|
|
|
)
|
|
|
|
|
is False
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
_stream_executor_requires_python_rewrite(
|
|
|
|
|
envelope=None,
|
|
|
|
|
needs_conversion=True,
|
|
|
|
|
provider_api_format="gemini:cli",
|
|
|
|
|
client_api_format="openai:cli",
|
|
|
|
|
)
|
|
|
|
|
is False
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_build_openai_chat_stream_decision_preserves_exact_provider_request_for_same_format(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
from src.api.handlers.openai import OpenAIChatAdapter
|
|
|
|
|
|
|
|
|
|
adapter = OpenAIChatAdapter()
|
|
|
|
|
fake_context = SimpleNamespace(
|
|
|
|
|
path_params={},
|
|
|
|
|
request_id="req-openai-chat-stream-exact-123",
|
|
|
|
|
client_ip="127.0.0.1",
|
|
|
|
|
user_agent="pytest",
|
|
|
|
|
start_time=0.0,
|
|
|
|
|
original_headers={"content-type": "application/json", "authorization": "Bearer test-key"},
|
|
|
|
|
query_params={},
|
|
|
|
|
client_content_encoding=None,
|
|
|
|
|
extra={},
|
|
|
|
|
)
|
|
|
|
|
fake_context.ensure_json_body_async = AsyncMock(
|
|
|
|
|
return_value={"model": "gpt-5", "messages": [], "stream": True}
|
|
|
|
|
)
|
|
|
|
|
fake_candidate = SimpleNamespace(
|
|
|
|
|
provider=SimpleNamespace(
|
|
|
|
|
id="provider-openai-chat-stream-exact-123",
|
|
|
|
|
name="openai",
|
|
|
|
|
provider_type="openai",
|
|
|
|
|
proxy=None,
|
|
|
|
|
),
|
|
|
|
|
endpoint=SimpleNamespace(
|
|
|
|
|
id="endpoint-openai-chat-stream-exact-123",
|
|
|
|
|
api_format="openai:chat",
|
|
|
|
|
base_url="https://api.openai.example",
|
|
|
|
|
custom_path=None,
|
|
|
|
|
),
|
|
|
|
|
key=SimpleNamespace(id="key-openai-chat-stream-exact-123", proxy=None, api_key="enc-key"),
|
|
|
|
|
mapping_matched_model="gpt-5-upstream",
|
|
|
|
|
request_candidate_id="cand-openai-chat-stream-exact-123",
|
|
|
|
|
)
|
|
|
|
|
fake_prep = SimpleNamespace(
|
|
|
|
|
request_body={"model": "gpt-5-upstream", "messages": [], "stream": True},
|
|
|
|
|
url_model="gpt-5-upstream",
|
|
|
|
|
mapped_model="gpt-5-upstream",
|
|
|
|
|
envelope=None,
|
|
|
|
|
extra_headers={"x-provider-extra": "1"},
|
|
|
|
|
upstream_is_stream=True,
|
|
|
|
|
needs_conversion=False,
|
|
|
|
|
provider_api_format="openai:chat",
|
|
|
|
|
client_api_format="openai:chat",
|
|
|
|
|
auth_info=SimpleNamespace(
|
|
|
|
|
as_tuple=lambda: ("authorization", "Bearer upstream-key"),
|
|
|
|
|
decrypted_auth_config=None,
|
|
|
|
|
),
|
|
|
|
|
tls_profile="custom-profile",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class FakeChatHandler:
|
|
|
|
|
allowed_api_formats = ["openai:chat"]
|
|
|
|
|
|
|
|
|
|
def __init__(self, **kwargs: object) -> None:
|
|
|
|
|
self._request_builder = SimpleNamespace(
|
|
|
|
|
build=lambda *args, **kwargs: (
|
|
|
|
|
{
|
|
|
|
|
"model": "gpt-5-upstream",
|
|
|
|
|
"messages": [],
|
|
|
|
|
"stream": True,
|
|
|
|
|
"metadata": {"decision": "exact"},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"authorization": "Bearer upstream-key",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _convert_request(self, request_obj: object) -> object:
|
|
|
|
|
return SimpleNamespace(model="gpt-5")
|
|
|
|
|
|
|
|
|
|
def extract_model_from_request(
|
|
|
|
|
self, body: dict[str, object], path_params: dict[str, str]
|
|
|
|
|
) -> str:
|
|
|
|
|
return "gpt-5"
|
|
|
|
|
|
|
|
|
|
def _resolve_capability_requirements(self, **kwargs: object) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def _resolve_preferred_key_ids(self, **kwargs: object) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def _prepare_provider_request(self, **kwargs: object) -> SimpleNamespace:
|
|
|
|
|
return fake_prep
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(adapter, "authorize", lambda context: None)
|
|
|
|
|
monkeypatch.setattr(adapter, "_create_handler", lambda **kwargs: FakeChatHandler(), raising=False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter, "_validate_request_body", lambda body, path_params: body, raising=False
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter, "_merge_path_params", lambda body, path_params: body, raising=False
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._resolve_gateway_sync_adapter",
|
|
|
|
|
lambda decision, path: (adapter, {}),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._load_gateway_auth_models",
|
|
|
|
|
lambda db, auth_context: (
|
|
|
|
|
SimpleNamespace(id="user-openai-chat-stream-exact-123"),
|
|
|
|
|
SimpleNamespace(id="api-key-openai-chat-stream-exact-123"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway.get_pipeline",
|
|
|
|
|
lambda: SimpleNamespace(_check_user_rate_limit=AsyncMock(return_value=None)),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._build_gateway_request_context",
|
|
|
|
|
lambda **kwargs: fake_context,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._select_gateway_direct_candidate",
|
|
|
|
|
AsyncMock(return_value=fake_candidate),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.resolve_proxy_info_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.get_system_proxy_config_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.resolve_delegate_config_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.build_proxy_url_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
decision = classify_gateway_route("POST", "/v1/chat/completions")
|
|
|
|
|
auth_context = GatewayAuthContext(
|
|
|
|
|
user_id="user-openai-chat-stream-exact-123",
|
|
|
|
|
api_key_id="api-key-openai-chat-stream-exact-123",
|
|
|
|
|
access_allowed=True,
|
|
|
|
|
)
|
|
|
|
|
payload = GatewayExecuteRequest(
|
|
|
|
|
method="POST",
|
|
|
|
|
path="/v1/chat/completions",
|
|
|
|
|
headers={"content-type": "application/json", "authorization": "Bearer test-key"},
|
|
|
|
|
body_json={"model": "gpt-5", "messages": [], "stream": True},
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = await _build_openai_chat_stream_decision(
|
|
|
|
|
request=SimpleNamespace(),
|
|
|
|
|
payload=payload,
|
|
|
|
|
db=object(),
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
decision=decision,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert result.report_kind == "openai_chat_stream_success"
|
|
|
|
|
assert result.provider_api_format == "openai:chat"
|
|
|
|
|
assert result.client_api_format == "openai:chat"
|
|
|
|
|
assert result.mapped_model == "gpt-5-upstream"
|
|
|
|
|
assert result.upstream_url == "https://api.openai.example/v1/chat/completions"
|
|
|
|
|
assert result.tls_profile == "custom-profile"
|
|
|
|
|
assert result.extra_headers == {}
|
|
|
|
|
assert result.provider_request_headers == {
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"authorization": "Bearer upstream-key",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
"accept": "text/event-stream",
|
|
|
|
|
}
|
|
|
|
|
assert result.provider_request_body == {
|
|
|
|
|
"model": "gpt-5-upstream",
|
|
|
|
|
"messages": [],
|
|
|
|
|
"stream": True,
|
|
|
|
|
"metadata": {"decision": "exact"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_build_openai_chat_stream_decision_allows_claude_cross_format_exact_provider_request(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
from src.api.handlers.openai import OpenAIChatAdapter
|
|
|
|
|
|
|
|
|
|
adapter = OpenAIChatAdapter()
|
|
|
|
|
fake_context = SimpleNamespace(
|
|
|
|
|
path_params={},
|
|
|
|
|
request_id="req-openai-chat-stream-claude-xfmt-123",
|
|
|
|
|
client_ip="127.0.0.1",
|
|
|
|
|
user_agent="pytest",
|
|
|
|
|
start_time=0.0,
|
|
|
|
|
original_headers={"content-type": "application/json", "authorization": "Bearer test-key"},
|
|
|
|
|
query_params={},
|
|
|
|
|
client_content_encoding=None,
|
|
|
|
|
extra={},
|
|
|
|
|
)
|
|
|
|
|
fake_context.ensure_json_body_async = AsyncMock(
|
|
|
|
|
return_value={"model": "gpt-5", "messages": [], "stream": True}
|
|
|
|
|
)
|
|
|
|
|
fake_candidate = SimpleNamespace(
|
|
|
|
|
provider=SimpleNamespace(
|
|
|
|
|
id="provider-openai-chat-stream-claude-xfmt-123",
|
|
|
|
|
name="claude",
|
|
|
|
|
provider_type="anthropic",
|
|
|
|
|
proxy=None,
|
|
|
|
|
),
|
|
|
|
|
endpoint=SimpleNamespace(
|
|
|
|
|
id="endpoint-openai-chat-stream-claude-xfmt-123",
|
|
|
|
|
api_format="claude:chat",
|
|
|
|
|
base_url="https://api.claude.example",
|
|
|
|
|
custom_path=None,
|
|
|
|
|
),
|
|
|
|
|
key=SimpleNamespace(id="key-openai-chat-stream-claude-xfmt-123", proxy=None, api_key="enc-key"),
|
|
|
|
|
mapping_matched_model="claude-sonnet-4-5",
|
|
|
|
|
request_candidate_id="cand-openai-chat-stream-claude-xfmt-123",
|
|
|
|
|
)
|
|
|
|
|
fake_prep = SimpleNamespace(
|
|
|
|
|
request_body={"model": "claude-sonnet-4-5", "messages": [], "stream": True},
|
|
|
|
|
url_model="claude-sonnet-4-5",
|
|
|
|
|
mapped_model="claude-sonnet-4-5",
|
|
|
|
|
envelope=None,
|
|
|
|
|
extra_headers={"x-provider-extra": "1"},
|
|
|
|
|
upstream_is_stream=True,
|
|
|
|
|
needs_conversion=True,
|
|
|
|
|
provider_api_format="claude:chat",
|
|
|
|
|
client_api_format="openai:chat",
|
|
|
|
|
auth_info=SimpleNamespace(
|
|
|
|
|
as_tuple=lambda: ("authorization", "Bearer upstream-key"),
|
|
|
|
|
decrypted_auth_config=None,
|
|
|
|
|
),
|
|
|
|
|
tls_profile="custom-profile",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class FakeChatHandler:
|
|
|
|
|
allowed_api_formats = ["openai:chat"]
|
|
|
|
|
|
|
|
|
|
def __init__(self, **kwargs: object) -> None:
|
|
|
|
|
self._request_builder = SimpleNamespace(
|
|
|
|
|
build=lambda *args, **kwargs: (
|
|
|
|
|
{
|
|
|
|
|
"model": "claude-sonnet-4-5",
|
|
|
|
|
"messages": [],
|
|
|
|
|
"stream": True,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"authorization": "Bearer upstream-key",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _convert_request(self, request_obj: object) -> object:
|
|
|
|
|
return SimpleNamespace(model="gpt-5")
|
|
|
|
|
|
|
|
|
|
def extract_model_from_request(
|
|
|
|
|
self, body: dict[str, object], path_params: dict[str, str]
|
|
|
|
|
) -> str:
|
|
|
|
|
return "gpt-5"
|
|
|
|
|
|
|
|
|
|
def _resolve_capability_requirements(self, **kwargs: object) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def _resolve_preferred_key_ids(self, **kwargs: object) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def _prepare_provider_request(self, **kwargs: object) -> SimpleNamespace:
|
|
|
|
|
return fake_prep
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(adapter, "authorize", lambda context: None)
|
|
|
|
|
monkeypatch.setattr(adapter, "_create_handler", lambda **kwargs: FakeChatHandler(), raising=False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter, "_validate_request_body", lambda body, path_params: body, raising=False
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter, "_merge_path_params", lambda body, path_params: body, raising=False
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._resolve_gateway_sync_adapter",
|
|
|
|
|
lambda decision, path: (adapter, {}),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._load_gateway_auth_models",
|
|
|
|
|
lambda db, auth_context: (
|
|
|
|
|
SimpleNamespace(id="user-openai-chat-stream-claude-xfmt-123"),
|
|
|
|
|
SimpleNamespace(id="api-key-openai-chat-stream-claude-xfmt-123"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway.get_pipeline",
|
|
|
|
|
lambda: SimpleNamespace(_check_user_rate_limit=AsyncMock(return_value=None)),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._build_gateway_request_context",
|
|
|
|
|
lambda **kwargs: fake_context,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._select_gateway_direct_candidate",
|
|
|
|
|
AsyncMock(return_value=fake_candidate),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.resolve_proxy_info_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.get_system_proxy_config_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.resolve_delegate_config_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.build_proxy_url_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
decision = classify_gateway_route("POST", "/v1/chat/completions")
|
|
|
|
|
auth_context = GatewayAuthContext(
|
|
|
|
|
user_id="user-openai-chat-stream-claude-xfmt-123",
|
|
|
|
|
api_key_id="api-key-openai-chat-stream-claude-xfmt-123",
|
|
|
|
|
access_allowed=True,
|
|
|
|
|
)
|
|
|
|
|
payload = GatewayExecuteRequest(
|
|
|
|
|
method="POST",
|
|
|
|
|
path="/v1/chat/completions",
|
|
|
|
|
headers={"content-type": "application/json", "authorization": "Bearer test-key"},
|
|
|
|
|
body_json={"model": "gpt-5", "messages": [], "stream": True},
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = await _build_openai_chat_stream_decision(
|
|
|
|
|
request=SimpleNamespace(),
|
|
|
|
|
payload=payload,
|
|
|
|
|
db=object(),
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
decision=decision,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert result.report_kind == "openai_chat_stream_success"
|
|
|
|
|
assert result.provider_api_format == "claude:chat"
|
|
|
|
|
assert result.client_api_format == "openai:chat"
|
|
|
|
|
assert result.mapped_model == "claude-sonnet-4-5"
|
|
|
|
|
assert result.upstream_url == "https://api.claude.example/v1/messages"
|
|
|
|
|
assert result.tls_profile == "custom-profile"
|
|
|
|
|
assert result.provider_request_headers == {
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"authorization": "Bearer upstream-key",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
"accept": "text/event-stream",
|
|
|
|
|
}
|
|
|
|
|
assert result.provider_request_body == {
|
|
|
|
|
"model": "claude-sonnet-4-5",
|
|
|
|
|
"messages": [],
|
|
|
|
|
"stream": True,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_build_openai_chat_stream_decision_allows_gemini_cross_format_exact_provider_request(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
from src.api.handlers.openai import OpenAIChatAdapter
|
|
|
|
|
|
|
|
|
|
adapter = OpenAIChatAdapter()
|
|
|
|
|
fake_context = SimpleNamespace(
|
|
|
|
|
path_params={},
|
|
|
|
|
request_id="req-openai-chat-stream-gemini-xfmt-123",
|
|
|
|
|
client_ip="127.0.0.1",
|
|
|
|
|
user_agent="pytest",
|
|
|
|
|
start_time=0.0,
|
|
|
|
|
original_headers={"content-type": "application/json", "authorization": "Bearer test-key"},
|
|
|
|
|
query_params={},
|
|
|
|
|
client_content_encoding=None,
|
|
|
|
|
extra={},
|
|
|
|
|
)
|
|
|
|
|
fake_context.ensure_json_body_async = AsyncMock(
|
|
|
|
|
return_value={"model": "gpt-5", "messages": [], "stream": True}
|
|
|
|
|
)
|
|
|
|
|
fake_candidate = SimpleNamespace(
|
|
|
|
|
provider=SimpleNamespace(
|
|
|
|
|
id="provider-openai-chat-stream-gemini-xfmt-123",
|
|
|
|
|
name="gemini",
|
|
|
|
|
provider_type="google",
|
|
|
|
|
proxy=None,
|
|
|
|
|
),
|
|
|
|
|
endpoint=SimpleNamespace(
|
|
|
|
|
id="endpoint-openai-chat-stream-gemini-xfmt-123",
|
|
|
|
|
api_format="gemini:chat",
|
|
|
|
|
base_url="https://generativelanguage.googleapis.com",
|
|
|
|
|
custom_path=None,
|
|
|
|
|
),
|
|
|
|
|
key=SimpleNamespace(id="key-openai-chat-stream-gemini-xfmt-123", proxy=None, api_key="enc-key"),
|
|
|
|
|
mapping_matched_model="gemini-2.5-pro-upstream",
|
|
|
|
|
request_candidate_id="cand-openai-chat-stream-gemini-xfmt-123",
|
|
|
|
|
)
|
|
|
|
|
fake_prep = SimpleNamespace(
|
|
|
|
|
request_body={"contents": [], "stream": True},
|
|
|
|
|
url_model="gemini-2.5-pro-upstream",
|
|
|
|
|
mapped_model="gemini-2.5-pro-upstream",
|
|
|
|
|
envelope=None,
|
|
|
|
|
extra_headers={"x-provider-extra": "1"},
|
|
|
|
|
upstream_is_stream=True,
|
|
|
|
|
needs_conversion=True,
|
|
|
|
|
provider_api_format="gemini:chat",
|
|
|
|
|
client_api_format="openai:chat",
|
|
|
|
|
auth_info=SimpleNamespace(
|
|
|
|
|
as_tuple=lambda: ("x-goog-api-key", "upstream-key"),
|
|
|
|
|
decrypted_auth_config=None,
|
|
|
|
|
),
|
|
|
|
|
tls_profile="custom-profile",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class FakeRequestBuilder:
|
|
|
|
|
def build(self, *args: object, **kwargs: object) -> tuple[dict[str, object], dict[str, str]]:
|
|
|
|
|
return (
|
|
|
|
|
{
|
|
|
|
|
"contents": [],
|
|
|
|
|
"generationConfig": {"temperature": 0.2},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"x-goog-api-key": "upstream-key",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class FakeChatHandler:
|
|
|
|
|
allowed_api_formats = ["openai:chat"]
|
|
|
|
|
|
|
|
|
|
def __init__(self, **kwargs: object) -> None:
|
|
|
|
|
self._request_builder = FakeRequestBuilder()
|
|
|
|
|
|
|
|
|
|
async def _convert_request(self, request_obj: object) -> object:
|
|
|
|
|
return SimpleNamespace(model="gpt-5")
|
|
|
|
|
|
|
|
|
|
def extract_model_from_request(
|
|
|
|
|
self, body: dict[str, object], path_params: dict[str, str]
|
|
|
|
|
) -> str:
|
|
|
|
|
return "gpt-5"
|
|
|
|
|
|
|
|
|
|
def _resolve_capability_requirements(self, **kwargs: object) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def _resolve_preferred_key_ids(self, **kwargs: object) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def _prepare_provider_request(self, **kwargs: object) -> SimpleNamespace:
|
|
|
|
|
return fake_prep
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(adapter, "authorize", lambda context: None)
|
|
|
|
|
monkeypatch.setattr(adapter, "_create_handler", lambda **kwargs: FakeChatHandler(), raising=False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter, "_validate_request_body", lambda body, path_params: body, raising=False
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter, "_merge_path_params", lambda body, path_params: body, raising=False
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._resolve_gateway_sync_adapter",
|
|
|
|
|
lambda decision, path: (adapter, {}),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._load_gateway_auth_models",
|
|
|
|
|
lambda db, auth_context: (
|
|
|
|
|
SimpleNamespace(id="user-openai-chat-stream-gemini-xfmt-123"),
|
|
|
|
|
SimpleNamespace(id="api-key-openai-chat-stream-gemini-xfmt-123"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway.get_pipeline",
|
|
|
|
|
lambda: SimpleNamespace(_check_user_rate_limit=AsyncMock(return_value=None)),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._build_gateway_request_context",
|
|
|
|
|
lambda **kwargs: fake_context,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._select_gateway_direct_candidate",
|
|
|
|
|
AsyncMock(return_value=fake_candidate),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.resolve_proxy_info_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.get_system_proxy_config_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.resolve_delegate_config_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.build_proxy_url_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
decision = classify_gateway_route("POST", "/v1/chat/completions")
|
|
|
|
|
auth_context = GatewayAuthContext(
|
|
|
|
|
user_id="user-openai-chat-stream-gemini-xfmt-123",
|
|
|
|
|
api_key_id="api-key-openai-chat-stream-gemini-xfmt-123",
|
|
|
|
|
access_allowed=True,
|
|
|
|
|
)
|
|
|
|
|
payload = GatewayExecuteRequest(
|
|
|
|
|
method="POST",
|
|
|
|
|
path="/v1/chat/completions",
|
|
|
|
|
headers={"content-type": "application/json", "authorization": "Bearer test-key"},
|
|
|
|
|
body_json={"model": "gpt-5", "messages": [], "stream": True},
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = await _build_openai_chat_stream_decision(
|
|
|
|
|
request=SimpleNamespace(),
|
|
|
|
|
payload=payload,
|
|
|
|
|
db=object(),
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
decision=decision,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert result.report_kind == "openai_chat_stream_success"
|
|
|
|
|
assert result.provider_api_format == "gemini:chat"
|
|
|
|
|
assert result.client_api_format == "openai:chat"
|
|
|
|
|
assert result.mapped_model == "gemini-2.5-pro-upstream"
|
|
|
|
|
assert result.upstream_url == (
|
|
|
|
|
"https://generativelanguage.googleapis.com/"
|
|
|
|
|
"v1beta/models/gemini-2.5-pro-upstream:streamGenerateContent?alt=sse"
|
|
|
|
|
)
|
|
|
|
|
assert result.tls_profile == "custom-profile"
|
|
|
|
|
assert result.provider_request_headers == {
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"x-goog-api-key": "upstream-key",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
"accept": "text/event-stream",
|
|
|
|
|
}
|
|
|
|
|
assert result.provider_request_body == {
|
|
|
|
|
"contents": [],
|
|
|
|
|
"generationConfig": {"temperature": 0.2},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_build_claude_chat_stream_decision_preserves_exact_provider_request_for_same_format(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
from src.api.handlers.claude import ClaudeChatAdapter
|
|
|
|
|
|
|
|
|
|
adapter = ClaudeChatAdapter()
|
|
|
|
|
fake_context = SimpleNamespace(
|
|
|
|
|
path_params={},
|
|
|
|
|
request_id="req-claude-chat-stream-exact-123",
|
|
|
|
|
client_ip="127.0.0.1",
|
|
|
|
|
user_agent="pytest",
|
|
|
|
|
start_time=0.0,
|
|
|
|
|
original_headers={"content-type": "application/json", "x-api-key": "client-key"},
|
|
|
|
|
query_params={},
|
|
|
|
|
client_content_encoding=None,
|
|
|
|
|
extra={},
|
|
|
|
|
)
|
|
|
|
|
fake_context.ensure_json_body_async = AsyncMock(
|
|
|
|
|
return_value={"model": "claude-sonnet-4", "messages": [], "stream": True}
|
|
|
|
|
)
|
|
|
|
|
fake_candidate = SimpleNamespace(
|
|
|
|
|
provider=SimpleNamespace(
|
|
|
|
|
id="provider-claude-chat-stream-exact-123",
|
|
|
|
|
name="claude",
|
|
|
|
|
provider_type="claude",
|
|
|
|
|
proxy=None,
|
|
|
|
|
),
|
|
|
|
|
endpoint=SimpleNamespace(
|
|
|
|
|
id="endpoint-claude-chat-stream-exact-123",
|
|
|
|
|
api_format="claude:chat",
|
|
|
|
|
base_url="https://api.anthropic.example",
|
|
|
|
|
custom_path=None,
|
|
|
|
|
),
|
|
|
|
|
key=SimpleNamespace(id="key-claude-chat-stream-exact-123", proxy=None, api_key="enc-key"),
|
|
|
|
|
mapping_matched_model="claude-sonnet-4-upstream",
|
|
|
|
|
request_candidate_id="cand-claude-chat-stream-exact-123",
|
|
|
|
|
)
|
|
|
|
|
fake_prep = SimpleNamespace(
|
|
|
|
|
request_body={"model": "claude-sonnet-4-upstream", "messages": [], "stream": True},
|
|
|
|
|
url_model="claude-sonnet-4-upstream",
|
|
|
|
|
mapped_model="claude-sonnet-4-upstream",
|
|
|
|
|
envelope=None,
|
|
|
|
|
extra_headers={"x-provider-extra": "1"},
|
|
|
|
|
upstream_is_stream=True,
|
|
|
|
|
needs_conversion=False,
|
|
|
|
|
provider_api_format="claude:chat",
|
|
|
|
|
client_api_format="claude:chat",
|
|
|
|
|
auth_info=SimpleNamespace(
|
|
|
|
|
as_tuple=lambda: ("x-api-key", "upstream-secret"),
|
|
|
|
|
decrypted_auth_config=None,
|
|
|
|
|
),
|
|
|
|
|
tls_profile="custom-profile",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class FakeChatHandler:
|
|
|
|
|
allowed_api_formats = ["claude:chat"]
|
|
|
|
|
|
|
|
|
|
def __init__(self, **kwargs: object) -> None:
|
|
|
|
|
self._request_builder = SimpleNamespace(
|
|
|
|
|
build=lambda *args, **kwargs: (
|
|
|
|
|
{
|
|
|
|
|
"model": "claude-sonnet-4-upstream",
|
|
|
|
|
"messages": [],
|
|
|
|
|
"stream": True,
|
|
|
|
|
"metadata": {"decision": "exact"},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"x-api-key": "upstream-secret",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _convert_request(self, request_obj: object) -> object:
|
|
|
|
|
return SimpleNamespace(model="claude-sonnet-4")
|
|
|
|
|
|
|
|
|
|
def extract_model_from_request(
|
|
|
|
|
self, body: dict[str, object], path_params: dict[str, str]
|
|
|
|
|
) -> str:
|
|
|
|
|
return "claude-sonnet-4"
|
|
|
|
|
|
|
|
|
|
def _resolve_capability_requirements(self, **kwargs: object) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def _resolve_preferred_key_ids(self, **kwargs: object) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def _prepare_provider_request(self, **kwargs: object) -> SimpleNamespace:
|
|
|
|
|
return fake_prep
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(adapter, "authorize", lambda context: None)
|
|
|
|
|
monkeypatch.setattr(adapter, "_create_handler", lambda **kwargs: FakeChatHandler(), raising=False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter, "_validate_request_body", lambda body, path_params: body, raising=False
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter, "_merge_path_params", lambda body, path_params: body, raising=False
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._resolve_gateway_sync_adapter",
|
|
|
|
|
lambda decision, path: (adapter, {}),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._load_gateway_auth_models",
|
|
|
|
|
lambda db, auth_context: (
|
|
|
|
|
SimpleNamespace(id="user-claude-chat-stream-exact-123"),
|
|
|
|
|
SimpleNamespace(id="api-key-claude-chat-stream-exact-123"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway.get_pipeline",
|
|
|
|
|
lambda: SimpleNamespace(_check_user_rate_limit=AsyncMock(return_value=None)),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._build_gateway_request_context",
|
|
|
|
|
lambda **kwargs: fake_context,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._select_gateway_direct_candidate",
|
|
|
|
|
AsyncMock(return_value=fake_candidate),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.resolve_proxy_info_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.get_system_proxy_config_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.resolve_delegate_config_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.build_proxy_url_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
decision = classify_gateway_route(
|
|
|
|
|
"POST",
|
|
|
|
|
"/v1/messages",
|
|
|
|
|
{"content-type": "application/json", "x-api-key": "client-key"},
|
|
|
|
|
)
|
|
|
|
|
auth_context = GatewayAuthContext(
|
|
|
|
|
user_id="user-claude-chat-stream-exact-123",
|
|
|
|
|
api_key_id="api-key-claude-chat-stream-exact-123",
|
|
|
|
|
access_allowed=True,
|
|
|
|
|
)
|
|
|
|
|
payload = GatewayExecuteRequest(
|
|
|
|
|
method="POST",
|
|
|
|
|
path="/v1/messages",
|
|
|
|
|
headers={"content-type": "application/json", "x-api-key": "client-key"},
|
|
|
|
|
body_json={"model": "claude-sonnet-4", "messages": [], "stream": True},
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = await _build_claude_chat_stream_decision(
|
|
|
|
|
request=SimpleNamespace(),
|
|
|
|
|
payload=payload,
|
|
|
|
|
db=object(),
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
decision=decision,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert result.report_kind == "claude_chat_stream_success"
|
|
|
|
|
assert result.provider_api_format == "claude:chat"
|
|
|
|
|
assert result.client_api_format == "claude:chat"
|
|
|
|
|
assert result.mapped_model == "claude-sonnet-4-upstream"
|
|
|
|
|
assert result.upstream_url == "https://api.anthropic.example/v1/messages"
|
|
|
|
|
assert result.tls_profile == "custom-profile"
|
|
|
|
|
assert result.extra_headers == {}
|
|
|
|
|
assert result.provider_request_headers == {
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"x-api-key": "upstream-secret",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
"accept": "text/event-stream",
|
|
|
|
|
}
|
|
|
|
|
assert result.provider_request_body == {
|
|
|
|
|
"model": "claude-sonnet-4-upstream",
|
|
|
|
|
"messages": [],
|
|
|
|
|
"stream": True,
|
|
|
|
|
"metadata": {"decision": "exact"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_build_gemini_chat_stream_decision_preserves_exact_provider_request_for_same_format(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
from src.api.handlers.gemini import GeminiChatAdapter
|
|
|
|
|
|
|
|
|
|
adapter = GeminiChatAdapter()
|
|
|
|
|
fake_context = SimpleNamespace(
|
|
|
|
|
path_params={"model": "gemini-2.5-pro", "stream": True},
|
|
|
|
|
request_id="req-gemini-chat-stream-exact-123",
|
|
|
|
|
client_ip="127.0.0.1",
|
|
|
|
|
user_agent="pytest",
|
|
|
|
|
start_time=0.0,
|
|
|
|
|
original_headers={"content-type": "application/json", "x-goog-api-key": "client-key"},
|
|
|
|
|
query_params={},
|
|
|
|
|
client_content_encoding=None,
|
|
|
|
|
extra={},
|
|
|
|
|
)
|
|
|
|
|
fake_context.ensure_json_body_async = AsyncMock(
|
|
|
|
|
return_value={"contents": [{"role": "user", "parts": [{"text": "hello"}]}]}
|
|
|
|
|
)
|
|
|
|
|
fake_candidate = SimpleNamespace(
|
|
|
|
|
provider=SimpleNamespace(
|
|
|
|
|
id="provider-gemini-chat-stream-exact-123",
|
|
|
|
|
name="gemini",
|
|
|
|
|
provider_type="gemini",
|
|
|
|
|
proxy=None,
|
|
|
|
|
),
|
|
|
|
|
endpoint=SimpleNamespace(
|
|
|
|
|
id="endpoint-gemini-chat-stream-exact-123",
|
|
|
|
|
api_format="gemini:chat",
|
|
|
|
|
base_url="https://generativelanguage.googleapis.com",
|
|
|
|
|
custom_path=None,
|
|
|
|
|
),
|
|
|
|
|
key=SimpleNamespace(id="key-gemini-chat-stream-exact-123", proxy=None, api_key="enc-key"),
|
|
|
|
|
mapping_matched_model="gemini-2.5-pro-upstream",
|
|
|
|
|
request_candidate_id="cand-gemini-chat-stream-exact-123",
|
|
|
|
|
)
|
|
|
|
|
fake_prep = SimpleNamespace(
|
|
|
|
|
request_body={"contents": [{"role": "user", "parts": [{"text": "hello"}]}]},
|
|
|
|
|
url_model="gemini-2.5-pro-upstream",
|
|
|
|
|
mapped_model="gemini-2.5-pro-upstream",
|
|
|
|
|
envelope=None,
|
|
|
|
|
extra_headers={"x-provider-extra": "1"},
|
|
|
|
|
upstream_is_stream=True,
|
|
|
|
|
needs_conversion=False,
|
|
|
|
|
provider_api_format="gemini:chat",
|
|
|
|
|
client_api_format="gemini:chat",
|
|
|
|
|
auth_info=SimpleNamespace(
|
|
|
|
|
as_tuple=lambda: ("x-goog-api-key", "upstream-key"),
|
|
|
|
|
decrypted_auth_config=None,
|
|
|
|
|
),
|
|
|
|
|
tls_profile="custom-profile",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class FakeChatHandler:
|
|
|
|
|
allowed_api_formats = ["gemini:chat"]
|
|
|
|
|
|
|
|
|
|
def __init__(self, **kwargs: object) -> None:
|
|
|
|
|
self._request_builder = SimpleNamespace(
|
|
|
|
|
build=lambda *args, **kwargs: (
|
|
|
|
|
{
|
|
|
|
|
"contents": [{"role": "user", "parts": [{"text": "hello"}]}],
|
|
|
|
|
"generationConfig": {"temperature": 0.2},
|
|
|
|
|
"prompt_cache_key": "cache-key-123",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"x-goog-api-key": "upstream-key",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _convert_request(self, request_obj: object) -> object:
|
|
|
|
|
return SimpleNamespace(model="gemini-2.5-pro")
|
|
|
|
|
|
|
|
|
|
def extract_model_from_request(
|
|
|
|
|
self, body: dict[str, object], path_params: dict[str, str]
|
|
|
|
|
) -> str:
|
|
|
|
|
return "gemini-2.5-pro"
|
|
|
|
|
|
|
|
|
|
def _resolve_capability_requirements(self, **kwargs: object) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def _resolve_preferred_key_ids(self, **kwargs: object) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def _prepare_provider_request(self, **kwargs: object) -> SimpleNamespace:
|
|
|
|
|
return fake_prep
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(adapter, "authorize", lambda context: None)
|
|
|
|
|
monkeypatch.setattr(adapter, "_create_handler", lambda **kwargs: FakeChatHandler(), raising=False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter,
|
|
|
|
|
"_validate_request_body",
|
|
|
|
|
lambda body, path_params: body,
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter,
|
|
|
|
|
"_merge_path_params",
|
|
|
|
|
lambda body, path_params: body,
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._resolve_gateway_sync_adapter",
|
|
|
|
|
lambda decision, path: (adapter, {"model": "gemini-2.5-pro", "stream": True}),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._load_gateway_auth_models",
|
|
|
|
|
lambda db, auth_context: (
|
|
|
|
|
SimpleNamespace(id="user-gemini-chat-stream-exact-123"),
|
|
|
|
|
SimpleNamespace(id="api-key-gemini-chat-stream-exact-123"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway.get_pipeline",
|
|
|
|
|
lambda: SimpleNamespace(_check_user_rate_limit=AsyncMock(return_value=None)),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._build_gateway_request_context",
|
|
|
|
|
lambda **kwargs: fake_context,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._select_gateway_direct_candidate",
|
|
|
|
|
AsyncMock(return_value=fake_candidate),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.resolve_proxy_info_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.get_system_proxy_config_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.resolve_delegate_config_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.build_proxy_url_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
decision = classify_gateway_route(
|
|
|
|
|
"POST",
|
|
|
|
|
"/v1beta/models/gemini-2.5-pro:streamGenerateContent",
|
|
|
|
|
{"content-type": "application/json", "x-goog-api-key": "client-key"},
|
|
|
|
|
)
|
|
|
|
|
auth_context = GatewayAuthContext(
|
|
|
|
|
user_id="user-gemini-chat-stream-exact-123",
|
|
|
|
|
api_key_id="api-key-gemini-chat-stream-exact-123",
|
|
|
|
|
access_allowed=True,
|
|
|
|
|
)
|
|
|
|
|
payload = GatewayExecuteRequest(
|
|
|
|
|
method="POST",
|
|
|
|
|
path="/v1beta/models/gemini-2.5-pro:streamGenerateContent",
|
|
|
|
|
headers={"content-type": "application/json", "x-goog-api-key": "client-key"},
|
|
|
|
|
body_json={"contents": [{"role": "user", "parts": [{"text": "hello"}]}]},
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = await _build_gemini_chat_stream_decision(
|
|
|
|
|
request=SimpleNamespace(),
|
|
|
|
|
payload=payload,
|
|
|
|
|
db=object(),
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
decision=decision,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert result.report_kind == "gemini_chat_stream_success"
|
|
|
|
|
assert result.provider_api_format == "gemini:chat"
|
|
|
|
|
assert result.client_api_format == "gemini:chat"
|
|
|
|
|
assert result.mapped_model == "gemini-2.5-pro-upstream"
|
|
|
|
|
assert result.upstream_url == (
|
|
|
|
|
"https://generativelanguage.googleapis.com/"
|
|
|
|
|
"v1beta/models/gemini-2.5-pro-upstream:streamGenerateContent?alt=sse"
|
|
|
|
|
)
|
|
|
|
|
assert result.tls_profile == "custom-profile"
|
|
|
|
|
assert result.extra_headers == {}
|
|
|
|
|
assert result.provider_request_headers == {
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"x-goog-api-key": "upstream-key",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
"accept": "text/event-stream",
|
|
|
|
|
}
|
|
|
|
|
assert result.provider_request_body == {
|
|
|
|
|
"contents": [{"role": "user", "parts": [{"text": "hello"}]}],
|
|
|
|
|
"generationConfig": {"temperature": 0.2},
|
|
|
|
|
"prompt_cache_key": "cache-key-123",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_build_gemini_chat_stream_decision_allows_antigravity_force_rewrite_envelope(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
from src.api.handlers.gemini import GeminiChatAdapter
|
|
|
|
|
|
|
|
|
|
class AntigravityEnvelope:
|
|
|
|
|
name = "antigravity:v1internal"
|
|
|
|
|
|
|
|
|
|
def force_stream_rewrite(self) -> bool:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
adapter = GeminiChatAdapter()
|
|
|
|
|
fake_context = SimpleNamespace(
|
|
|
|
|
path_params={"model": "gemini-2.5-pro"},
|
|
|
|
|
request_id="req-antigravity-gemini-chat-stream-exact-123",
|
|
|
|
|
client_ip="127.0.0.1",
|
|
|
|
|
user_agent="pytest",
|
|
|
|
|
start_time=0.0,
|
|
|
|
|
original_headers={
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"authorization": "Bearer client-key",
|
|
|
|
|
},
|
|
|
|
|
query_params={},
|
|
|
|
|
client_content_encoding=None,
|
|
|
|
|
extra={},
|
|
|
|
|
)
|
|
|
|
|
fake_context.ensure_json_body_async = AsyncMock(
|
|
|
|
|
return_value={
|
|
|
|
|
"contents": [{"role": "user", "parts": [{"text": "hello"}]}],
|
|
|
|
|
"stream": True,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
fake_candidate = SimpleNamespace(
|
|
|
|
|
provider=SimpleNamespace(
|
|
|
|
|
id="provider-antigravity-gemini-chat-stream-exact-123",
|
|
|
|
|
name="antigravity",
|
|
|
|
|
provider_type="antigravity",
|
|
|
|
|
proxy=None,
|
|
|
|
|
),
|
|
|
|
|
endpoint=SimpleNamespace(
|
|
|
|
|
id="endpoint-antigravity-gemini-chat-stream-exact-123",
|
|
|
|
|
api_format="gemini:chat",
|
|
|
|
|
base_url="https://generativelanguage.googleapis.com",
|
|
|
|
|
custom_path=None,
|
|
|
|
|
),
|
|
|
|
|
key=SimpleNamespace(
|
|
|
|
|
id="key-antigravity-gemini-chat-stream-exact-123",
|
|
|
|
|
proxy=None,
|
|
|
|
|
api_key="enc-key",
|
|
|
|
|
),
|
|
|
|
|
mapping_matched_model="claude-sonnet-4-5",
|
|
|
|
|
request_candidate_id="cand-antigravity-gemini-chat-stream-exact-123",
|
|
|
|
|
)
|
|
|
|
|
fake_prep = SimpleNamespace(
|
|
|
|
|
request_body={"contents": [{"role": "user", "parts": [{"text": "hello"}]}]},
|
|
|
|
|
url_model="claude-sonnet-4-5",
|
|
|
|
|
mapped_model="claude-sonnet-4-5",
|
|
|
|
|
envelope=AntigravityEnvelope(),
|
|
|
|
|
extra_headers={"x-provider-extra": "1"},
|
|
|
|
|
upstream_is_stream=True,
|
|
|
|
|
needs_conversion=False,
|
|
|
|
|
provider_api_format="gemini:chat",
|
|
|
|
|
client_api_format="gemini:chat",
|
|
|
|
|
auth_info=SimpleNamespace(
|
|
|
|
|
as_tuple=lambda: ("authorization", "Bearer upstream-secret"),
|
|
|
|
|
decrypted_auth_config=None,
|
|
|
|
|
),
|
|
|
|
|
tls_profile="custom-profile",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class FakeChatHandler:
|
|
|
|
|
allowed_api_formats = ["gemini:chat"]
|
|
|
|
|
|
|
|
|
|
def __init__(self, **kwargs: object) -> None:
|
|
|
|
|
self._request_builder = SimpleNamespace(
|
|
|
|
|
build=lambda *args, **kwargs: (
|
|
|
|
|
{
|
|
|
|
|
"contents": [{"role": "user", "parts": [{"text": "hello"}]}],
|
|
|
|
|
"generationConfig": {"temperature": 0.2},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"authorization": "Bearer upstream-secret",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _convert_request(self, request_obj: object) -> object:
|
|
|
|
|
return SimpleNamespace(model="gemini-2.5-pro")
|
|
|
|
|
|
|
|
|
|
def extract_model_from_request(
|
|
|
|
|
self, body: dict[str, object], path_params: dict[str, str]
|
|
|
|
|
) -> str:
|
|
|
|
|
return "gemini-2.5-pro"
|
|
|
|
|
|
|
|
|
|
def _resolve_capability_requirements(self, **kwargs: object) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def _resolve_preferred_key_ids(self, **kwargs: object) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def _prepare_provider_request(self, **kwargs: object) -> SimpleNamespace:
|
|
|
|
|
return fake_prep
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(adapter, "authorize", lambda context: None)
|
|
|
|
|
monkeypatch.setattr(adapter, "_create_handler", lambda **kwargs: FakeChatHandler(), raising=False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter, "_validate_request_body", lambda body, path_params: body, raising=False
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter, "_merge_path_params", lambda body, path_params: body, raising=False
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._resolve_gateway_sync_adapter",
|
|
|
|
|
lambda decision, path: (adapter, {"model": "gemini-2.5-pro"}),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._load_gateway_auth_models",
|
|
|
|
|
lambda db, auth_context: (
|
|
|
|
|
SimpleNamespace(id="user-antigravity-gemini-chat-stream-exact-123"),
|
|
|
|
|
SimpleNamespace(id="api-key-antigravity-gemini-chat-stream-exact-123"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway.get_pipeline",
|
|
|
|
|
lambda: SimpleNamespace(_check_user_rate_limit=AsyncMock(return_value=None)),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._build_gateway_request_context",
|
|
|
|
|
lambda **kwargs: fake_context,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._select_gateway_direct_candidate",
|
|
|
|
|
AsyncMock(return_value=fake_candidate),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.resolve_proxy_info_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.get_system_proxy_config_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.resolve_delegate_config_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.services.proxy_node.resolver.build_proxy_url_async",
|
|
|
|
|
AsyncMock(return_value=None),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
decision = classify_gateway_route(
|
|
|
|
|
"POST",
|
|
|
|
|
"/v1beta/models/gemini-2.5-pro:streamGenerateContent",
|
|
|
|
|
{"content-type": "application/json", "authorization": "Bearer client-key"},
|
|
|
|
|
)
|
|
|
|
|
auth_context = GatewayAuthContext(
|
|
|
|
|
user_id="user-antigravity-gemini-chat-stream-exact-123",
|
|
|
|
|
api_key_id="api-key-antigravity-gemini-chat-stream-exact-123",
|
|
|
|
|
access_allowed=True,
|
|
|
|
|
)
|
|
|
|
|
payload = GatewayExecuteRequest(
|
|
|
|
|
method="POST",
|
|
|
|
|
path="/v1beta/models/gemini-2.5-pro:streamGenerateContent",
|
|
|
|
|
headers={"content-type": "application/json", "authorization": "Bearer client-key"},
|
|
|
|
|
body_json={
|
|
|
|
|
"contents": [{"role": "user", "parts": [{"text": "hello"}]}],
|
|
|
|
|
"stream": True,
|
|
|
|
|
},
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = await _build_gemini_chat_stream_decision(
|
|
|
|
|
request=SimpleNamespace(),
|
|
|
|
|
payload=payload,
|
|
|
|
|
db=object(),
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
decision=decision,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert result.report_kind == "gemini_chat_stream_success"
|
|
|
|
|
assert result.provider_api_format == "gemini:chat"
|
|
|
|
|
assert result.client_api_format == "gemini:chat"
|
|
|
|
|
assert result.mapped_model == "claude-sonnet-4-5"
|
|
|
|
|
assert result.upstream_url == (
|
|
|
|
|
"https://generativelanguage.googleapis.com/"
|
|
|
|
|
"v1beta/models/claude-sonnet-4-5:streamGenerateContent?alt=sse"
|
|
|
|
|
)
|
|
|
|
|
assert result.provider_request_headers == {
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"authorization": "Bearer upstream-secret",
|
|
|
|
|
"x-provider-extra": "1",
|
|
|
|
|
"accept": "text/event-stream",
|
|
|
|
|
}
|
|
|
|
|
assert result.provider_request_body == {
|
|
|
|
|
"contents": [{"role": "user", "parts": [{"text": "hello"}]}],
|
|
|
|
|
"generationConfig": {"temperature": 0.2},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_build_openai_chat_sync_plan_allows_upstream_stream_finalize(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
from src.api.handlers.openai import OpenAIChatAdapter
|
|
|
|
|
|
|
|
|
|
adapter = OpenAIChatAdapter()
|
|
|
|
|
fake_context = SimpleNamespace(
|
|
|
|
|
path_params={},
|
|
|
|
|
request_id="req-chat-upstream-stream-123",
|
|
|
|
|
client_ip="127.0.0.1",
|
|
|
|
|
user_agent="pytest",
|
|
|
|
|
start_time=0.0,
|
|
|
|
|
original_headers={"content-type": "application/json", "authorization": "Bearer test-key"},
|
|
|
|
|
query_params={},
|
|
|
|
|
client_content_encoding=None,
|
|
|
|
|
extra={},
|
|
|
|
|
)
|
|
|
|
|
fake_context.ensure_json_body_async = AsyncMock(
|
|
|
|
|
return_value={"model": "gpt-5", "messages": []}
|
|
|
|
|
)
|
|
|
|
|
fake_candidate = SimpleNamespace(
|
|
|
|
|
provider=SimpleNamespace(
|
|
|
|
|
id="provider-chat-upstream-stream-123",
|
|
|
|
|
name="openai",
|
|
|
|
|
provider_type="openai",
|
|
|
|
|
proxy=None,
|
|
|
|
|
request_timeout=30.0,
|
|
|
|
|
),
|
|
|
|
|
endpoint=SimpleNamespace(id="endpoint-chat-upstream-stream-123", api_format="openai:chat"),
|
|
|
|
|
key=SimpleNamespace(id="key-chat-upstream-stream-123", proxy=None),
|
|
|
|
|
mapping_matched_model="gpt-5",
|
|
|
|
|
needs_conversion=False,
|
|
|
|
|
output_limit=None,
|
|
|
|
|
request_candidate_id="cand-chat-upstream-stream-123",
|
|
|
|
|
)
|
|
|
|
|
prepared_plan = PreparedExecutionPlan(
|
|
|
|
|
contract=ExecutionPlan(
|
|
|
|
|
request_id="plan-chat-upstream-stream-123",
|
|
|
|
|
candidate_id="cand-chat-upstream-stream-123",
|
|
|
|
|
provider_name="openai",
|
|
|
|
|
provider_id="provider-chat-upstream-stream-123",
|
|
|
|
|
endpoint_id="endpoint-chat-upstream-stream-123",
|
|
|
|
|
key_id="key-chat-upstream-stream-123",
|
|
|
|
|
method="POST",
|
|
|
|
|
url="https://api.openai.example/v1/chat/completions",
|
|
|
|
|
headers={"content-type": "application/json"},
|
|
|
|
|
body=ExecutionPlanBody(json_body={"model": "gpt-5", "messages": []}),
|
|
|
|
|
stream=True,
|
|
|
|
|
provider_api_format="openai:chat",
|
|
|
|
|
client_api_format="openai:chat",
|
|
|
|
|
model_name="gpt-5",
|
|
|
|
|
),
|
|
|
|
|
payload={"model": "gpt-5", "messages": []},
|
|
|
|
|
headers={"content-type": "application/json"},
|
|
|
|
|
upstream_is_stream=True,
|
|
|
|
|
needs_conversion=False,
|
|
|
|
|
provider_type="openai",
|
|
|
|
|
request_timeout=30.0,
|
|
|
|
|
envelope=None,
|
|
|
|
|
proxy_info={"mode": "direct"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class FakeChatSyncExecutor:
|
|
|
|
|
def __init__(self, handler: object) -> None:
|
|
|
|
|
self._ctx = SimpleNamespace(mapped_model_result="gpt-5")
|
|
|
|
|
|
|
|
|
|
async def _build_sync_execution_plan(self, *args: object, **kwargs: object) -> PreparedExecutionPlan:
|
|
|
|
|
return prepared_plan
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(adapter, "authorize", lambda context: None)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter,
|
|
|
|
|
"_create_handler",
|
|
|
|
|
lambda **kwargs: SimpleNamespace(
|
|
|
|
|
allowed_api_formats=["openai:chat"],
|
|
|
|
|
_convert_request=AsyncMock(return_value=SimpleNamespace(model="gpt-5")),
|
|
|
|
|
extract_model_from_request=lambda body, path_params: "gpt-5",
|
|
|
|
|
_resolve_capability_requirements=lambda **kwargs: None,
|
|
|
|
|
_resolve_preferred_key_ids=AsyncMock(return_value=None),
|
|
|
|
|
),
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter,
|
|
|
|
|
"_validate_request_body",
|
|
|
|
|
lambda body, path_params: body,
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
adapter,
|
|
|
|
|
"_merge_path_params",
|
|
|
|
|
lambda body, path_params: body,
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._resolve_gateway_sync_adapter",
|
|
|
|
|
lambda decision, path: (adapter, {}),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._load_gateway_auth_models",
|
|
|
|
|
lambda db, auth_context: (
|
|
|
|
|
SimpleNamespace(id="user-chat-upstream-stream-123"),
|
|
|
|
|
SimpleNamespace(id="api-key-chat-upstream-stream-123"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway.get_pipeline",
|
|
|
|
|
lambda: SimpleNamespace(_check_user_rate_limit=AsyncMock(return_value=None)),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._build_gateway_request_context",
|
|
|
|
|
lambda **kwargs: fake_context,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.internal.gateway._select_gateway_direct_candidate",
|
|
|
|
|
AsyncMock(return_value=fake_candidate),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"src.api.handlers.base.chat_sync_executor.ChatSyncExecutor",
|
|
|
|
|
FakeChatSyncExecutor,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
decision = classify_gateway_route("POST", "/v1/chat/completions")
|
|
|
|
|
auth_context = GatewayAuthContext(
|
|
|
|
|
user_id="user-chat-upstream-stream-123",
|
|
|
|
|
api_key_id="api-key-chat-upstream-stream-123",
|
|
|
|
|
access_allowed=True,
|
|
|
|
|
)
|
|
|
|
|
payload = GatewayExecuteRequest(
|
|
|
|
|
method="POST",
|
|
|
|
|
path="/v1/chat/completions",
|
|
|
|
|
headers={"content-type": "application/json", "authorization": "Bearer test-key"},
|
|
|
|
|
body_json={"model": "gpt-5", "messages": []},
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = await _build_openai_chat_sync_plan(
|
|
|
|
|
request=SimpleNamespace(),
|
|
|
|
|
payload=payload,
|
|
|
|
|
db=object(),
|
|
|
|
|
auth_context=auth_context,
|
|
|
|
|
decision=decision,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert result.report_kind == "openai_chat_sync_finalize"
|
|
|
|
|
assert result.plan["stream"] is True
|