feat: Antigravity 和 Codex 服务支持

- 新增 Antigravity 服务:签名缓存、URL 可用性检测、信封处理
- 新增 Codex 服务:信封处理、元数据收集器
- 重构 provider transport 支持新的服务架构
- 新增 stream_bridge 和 upstream_stream_bridge 处理流式响应
- 优化 OAuth 工具函数
- 添加相关测试用例
This commit is contained in:
fawney19
2026-02-05 15:57:52 +08:00
parent ed2ff5c1d7
commit 440721368f
44 changed files with 3498 additions and 134 deletions

View File

@@ -43,12 +43,18 @@ from src.api.handlers.base.response_parser import ResponseParser
from src.api.handlers.base.stream_context import StreamContext
from src.api.handlers.base.stream_processor import StreamProcessor
from src.api.handlers.base.stream_telemetry import StreamTelemetryRecorder
from src.api.handlers.base.upstream_stream_bridge import (
aggregate_upstream_stream_to_internal_response,
)
from src.api.handlers.base.utils import (
build_sse_headers,
filter_proxy_response_headers,
get_format_converter_registry,
)
from src.config.settings import config
from src.core.api_format.conversion.stream_bridge import (
iter_internal_response_as_stream_events,
)
from src.core.error_utils import extract_client_error_message
from src.core.exceptions import (
EmbeddedErrorException,
@@ -68,6 +74,12 @@ from src.models.database import (
User,
)
from src.services.cache.aware_scheduler import ProviderCandidate
from src.services.provider.behavior import get_provider_behavior
from src.services.provider.stream_policy import (
enforce_stream_mode_for_upstream,
get_upstream_stream_policy,
resolve_upstream_is_stream,
)
from src.services.provider.transport import (
build_provider_url,
get_vertex_ai_effective_format,
@@ -760,9 +772,25 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
else:
request_body = dict(original_request_body)
# 确定目标变体(用于 Codex 等需要特殊处理的上游)
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
target_variant = provider_type if provider_type == "codex" else None
behavior = get_provider_behavior(
provider_type=provider_type,
endpoint_sig=provider_api_format,
)
envelope = behavior.envelope
same_format_variant = behavior.same_format_variant
cross_format_variant = behavior.cross_format_variant
# Upstream streaming policy (per-endpoint): may force upstream to sync/stream mode.
upstream_policy = get_upstream_stream_policy(
endpoint,
provider_type=provider_type,
endpoint_sig=str(provider_api_format),
)
upstream_is_stream = resolve_upstream_is_stream(
client_is_stream=True,
policy=upstream_policy,
)
# 跨格式:先做请求体转换(失败触发 failover
registry = get_format_converter_registry()
@@ -771,7 +799,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
request_body,
str(client_api_format),
str(provider_api_format),
target_variant=target_variant,
target_variant=cross_format_variant,
)
# 格式转换后,为需要 model 字段的格式设置模型名
self._set_model_after_conversion(
@@ -785,50 +813,223 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
request_body,
str(client_api_format),
str(provider_api_format),
is_stream=True,
is_stream=upstream_is_stream,
)
else:
# 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段)
request_body = self.prepare_provider_request_body(request_body)
# 同格式时也需要应用 target_variant 转换(如 Codex
if target_variant:
if same_format_variant:
request_body = registry.convert_request(
request_body,
str(provider_api_format),
str(provider_api_format),
target_variant=target_variant,
target_variant=same_format_variant,
)
# Force upstream stream/sync mode in request body (best-effort).
if provider_api_format:
enforce_stream_mode_for_upstream(
request_body,
provider_api_format=str(provider_api_format),
upstream_is_stream=upstream_is_stream,
)
# 获取 URL 模型名
url_model = self.get_model_for_url(request_body, mapped_model) or ctx.model
# Provider envelope: wrap request after auth is available and before RequestBuilder.build().
if envelope:
request_body, url_model = envelope.wrap_request(
request_body,
model=url_model or ctx.model or "",
url_model=url_model,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
)
# Provider envelope: extra upstream headers (e.g. dedicated User-Agent).
extra_headers: dict[str, str] = {}
if envelope:
extra_headers.update(envelope.extra_headers() or {})
# 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式)
provider_payload, provider_headers = self._request_builder.build(
request_body,
original_headers,
endpoint,
key,
is_stream=True,
is_stream=upstream_is_stream,
extra_headers=extra_headers if extra_headers else None,
pre_computed_auth=auth_info.as_tuple() if auth_info else None,
)
if upstream_is_stream:
# Ensure upstream returns SSE payload when in streaming mode.
provider_headers["Accept"] = "text/event-stream"
ctx.provider_request_headers = provider_headers
ctx.provider_request_body = provider_payload
# 获取 URL 模型名
url_model = self.get_model_for_url(request_body, mapped_model) or ctx.model
url = build_provider_url(
endpoint,
query_params=query_params,
path_params={"model": url_model},
is_stream=True,
is_stream=upstream_is_stream,
key=key,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
)
# Capture the selected base_url from transport (used by some envelopes for failover).
ctx.selected_base_url = envelope.capture_selected_base_url() if envelope else None
logger.debug(
f" [{self.request_id}] 发送流式请求: Provider={provider.name}, "
f"模型={ctx.model} -> {mapped_model or '无映射'}"
)
# If upstream is forced to non-stream mode, we execute a sync request and then
# simulate streaming to the client (sync -> stream bridge).
if not upstream_is_stream:
from src.clients.http_client import HTTPClientPool
request_timeout_sync = provider.request_timeout or config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client(
proxy_config=provider.proxy,
)
try:
resp = await http_client.post(
url,
json=provider_payload,
headers=provider_headers,
timeout=httpx.Timeout(request_timeout_sync),
)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
if ctx.selected_base_url:
logger.warning(
f"[{envelope.name}] Connection error: {ctx.selected_base_url} ({e})"
)
raise
ctx.status_code = resp.status_code
ctx.response_headers = dict(resp.headers)
if envelope:
envelope.on_http_status(base_url=ctx.selected_base_url, status_code=ctx.status_code)
# Reuse HTTPStatusError classification path (handled by TaskService/error_classifier).
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
error_body = ""
try:
error_body = resp.text[:4000] if resp.text else ""
except Exception:
error_body = ""
e.upstream_response = error_body # type: ignore[attr-defined]
raise
# Safe JSON parsing.
try:
response_json = resp.json()
except (UnicodeDecodeError, json.JSONDecodeError) as e:
raw_content = ""
try:
raw_content = resp.text[:500] if resp.text else "(empty)"
except Exception:
try:
raw_content = repr(resp.content[:500]) if resp.content else "(empty)"
except Exception:
raw_content = "(unable to read)"
raise ProviderNotAvailableException(
"上游服务返回了无效的响应",
provider_name=str(provider.name),
upstream_status=resp.status_code,
upstream_response=f"json_decode_error={type(e).__name__}: {raw_content}",
)
if envelope:
response_json = envelope.unwrap_response(response_json)
envelope.postprocess_unwrapped_response(model=ctx.model, data=response_json)
# Embedded error detection (HTTP 200 but error body).
if isinstance(response_json, dict):
parser = get_parser_for_format(provider_api_format)
if parser.is_error_response(response_json):
parsed = parser.parse_response(response_json, 200)
raise EmbeddedErrorException(
provider_name=str(provider.name),
error_code=parsed.embedded_status_code,
error_message=parsed.error_message,
error_status=parsed.error_type,
)
# Convert sync JSON -> InternalResponse, then InternalResponse -> client stream events.
src_norm = (
registry.get_normalizer(str(provider_api_format)) if provider_api_format else None
)
if src_norm is None:
raise RuntimeError(f"未注册 Normalizer: {provider_api_format}")
internal_resp = src_norm.response_to_internal(
response_json if isinstance(response_json, dict) else {}
)
internal_resp.model = str(ctx.model or internal_resp.model or "")
if internal_resp.id:
ctx.response_id = internal_resp.id
if internal_resp.usage:
ctx.input_tokens = int(internal_resp.usage.input_tokens or 0)
ctx.output_tokens = int(internal_resp.usage.output_tokens or 0)
ctx.cached_tokens = int(internal_resp.usage.cache_read_tokens or 0)
ctx.cache_creation_tokens = int(internal_resp.usage.cache_write_tokens or 0)
from src.core.api_format.conversion.stream_state import StreamState
tgt_norm = (
registry.get_normalizer(str(client_api_format)) if client_api_format else None
)
if tgt_norm is None:
raise RuntimeError(f"未注册 Normalizer: {client_api_format}")
state = StreamState(
model=str(ctx.model or ""),
message_id=str(ctx.response_id or ctx.request_id or self.request_id or ""),
)
output_state = {"started": False}
async def _streamified() -> AsyncGenerator[bytes]:
for ev in iter_internal_response_as_stream_events(internal_resp):
converted_events = tgt_norm.stream_event_from_internal(ev, state)
if not converted_events:
continue
for evt in converted_events:
if isinstance(evt, dict):
ctx.data_count += 1
if ctx.record_parsed_chunks:
ctx.parsed_chunks.append(evt)
payload = json.dumps(evt, ensure_ascii=False)
ctx.chunk_count += 1
if not output_state["started"]:
ctx.record_first_byte_time(self.start_time)
if stream_processor.on_streaming_start:
stream_processor.on_streaming_start()
output_state["started"] = True
yield f"data: {payload}\n\n".encode("utf-8")
# OpenAI chat clients expect a final [DONE] marker.
if str(client_api_format or "").strip().lower() == "openai:chat":
if not output_state["started"]:
ctx.record_first_byte_time(self.start_time)
if stream_processor.on_streaming_start:
stream_processor.on_streaming_start()
output_state["started"] = True
ctx.chunk_count += 1
yield b"data: [DONE]\n\n"
ctx.has_completion = True
return _streamified()
# 配置 HTTP 超时
# 注意read timeout 用于检测连接断开,不是整体请求超时
# 整体请求超时由 asyncio.wait_for 控制,使用全局配置
@@ -866,6 +1067,11 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
ctx.status_code = stream_response.status_code
ctx.response_headers = dict(stream_response.headers)
if envelope:
envelope.on_http_status(
base_url=ctx.selected_base_url,
status_code=ctx.status_code,
)
stream_response.raise_for_status()
@@ -925,6 +1131,22 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
timeout=int(request_timeout),
)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
# 连接/读写超时:清理可能已建立的连接上下文
if response_ctx is not None:
try:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
if ctx.selected_base_url:
logger.warning(
f"[{envelope.name}] Connection error: {ctx.selected_base_url} ({e})"
)
raise
except httpx.HTTPStatusError as e:
error_text = await self._extract_error_text(e)
logger.error(f"Provider 返回错误: {e.response.status_code}\n Response: {error_text}")
@@ -1099,9 +1321,25 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
else:
request_body = dict(request_body_ref["body"])
# 确定目标变体(用于 Codex 等需要特殊处理的上游)
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
target_variant = provider_type if provider_type == "codex" else None
behavior = get_provider_behavior(
provider_type=provider_type,
endpoint_sig=provider_api_format,
)
envelope = behavior.envelope
same_format_variant = behavior.same_format_variant
cross_format_variant = behavior.cross_format_variant
# Upstream streaming policy (per-endpoint).
upstream_policy = get_upstream_stream_policy(
endpoint,
provider_type=provider_type,
endpoint_sig=str(provider_api_format),
)
upstream_is_stream = resolve_upstream_is_stream(
client_is_stream=False,
policy=upstream_policy,
)
# 跨格式:先做请求体转换(失败触发 failover
registry = get_format_converter_registry()
@@ -1110,7 +1348,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
request_body,
client_api_format,
provider_api_format,
target_variant=target_variant,
target_variant=cross_format_variant,
)
# 格式转换后,为需要 model 字段的格式设置模型名
self._set_model_after_conversion(
@@ -1124,48 +1362,76 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
request_body,
client_api_format,
provider_api_format,
is_stream=False,
is_stream=upstream_is_stream,
)
else:
# 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段)
request_body = self.prepare_provider_request_body(request_body)
# 同格式时也需要应用 target_variant 转换(如 Codex
if target_variant:
if same_format_variant:
request_body = registry.convert_request(
request_body,
provider_api_format,
provider_api_format,
target_variant=target_variant,
target_variant=same_format_variant,
)
# Force upstream stream/sync mode in request body (best-effort).
if provider_api_format:
enforce_stream_mode_for_upstream(
request_body,
provider_api_format=str(provider_api_format),
upstream_is_stream=upstream_is_stream,
)
# 获取 URL 模型名(兜底使用外层的 model确保 Gemini 等格式能正确构建 URL
url_model = self.get_model_for_url(request_body, mapped_model) or model
# Provider envelope: wrap request after auth is available and before RequestBuilder.build().
if envelope:
request_body, url_model = envelope.wrap_request(
request_body,
model=url_model or model or "",
url_model=url_model,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
)
# Provider envelope: extra upstream headers (e.g. dedicated User-Agent).
extra_headers: dict[str, str] = {}
if envelope:
extra_headers.update(envelope.extra_headers() or {})
# 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式)
provider_payload, provider_hdrs = self._request_builder.build(
request_body,
original_headers,
endpoint,
key,
is_stream=False,
is_stream=upstream_is_stream,
extra_headers=extra_headers if extra_headers else None,
pre_computed_auth=auth_info.as_tuple() if auth_info else None,
)
if upstream_is_stream:
# Ensure upstream returns SSE payload when forced to streaming mode.
provider_hdrs["Accept"] = "text/event-stream"
provider_request_headers = provider_hdrs
provider_request_body = provider_payload
# 获取 URL 模型名(兜底使用外层的 model确保 Gemini 等格式能正确构建 URL
url_model = self.get_model_for_url(request_body, mapped_model) or model
url = build_provider_url(
endpoint,
query_params=query_params,
path_params={"model": url_model},
is_stream=False,
is_stream=upstream_is_stream, # sync handler may still force upstream streaming
key=key,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
)
# 非流式:必须在 build_provider_url 调用后立即缓存(避免 contextvar 被后续调用覆盖)
selected_base_url_cached = envelope.capture_selected_base_url() if envelope else None
logger.info(
f" [{self.request_id}] 发送非流式请求: Provider={provider.name}, "
f"模型={model} -> {mapped_model or '无映射'}"
f" [{self.request_id}] 发送{'上游流式(聚合)' if upstream_is_stream else '非流式'}请求: "
f"Provider={provider.name}, 模型={model} -> {mapped_model or '无映射'}"
)
logger.debug(f" [{self.request_id}] 请求URL: {redact_url_for_log(url)}")
@@ -1182,16 +1448,93 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 注意:不使用 async with因为复用的客户端不应该被关闭
# 超时通过 timeout 参数控制
resp = await http_client.post(
url,
json=provider_payload,
headers=provider_hdrs,
timeout=httpx.Timeout(request_timeout),
)
resp: httpx.Response | None = None
if not upstream_is_stream:
try:
resp = await http_client.post(
url,
json=provider_payload,
headers=provider_hdrs,
timeout=httpx.Timeout(request_timeout),
)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope:
envelope.on_connection_error(base_url=selected_base_url_cached, exc=e)
if selected_base_url_cached:
logger.warning(
f"[{envelope.name}] Connection error: {selected_base_url_cached} ({e})"
)
raise
else:
# Forced upstream streaming: aggregate SSE to a sync JSON response.
provider_parser = (
get_parser_for_format(provider_api_format) if provider_api_format else None
)
try:
async with http_client.stream(
"POST",
url,
json=provider_payload,
headers=provider_hdrs,
timeout=httpx.Timeout(request_timeout),
) as stream_resp:
resp = stream_resp
status_code = stream_resp.status_code
response_headers = dict(stream_resp.headers)
if envelope:
envelope.on_http_status(
base_url=selected_base_url_cached,
status_code=status_code,
)
stream_resp.raise_for_status()
internal_resp = await aggregate_upstream_stream_to_internal_response(
stream_resp.aiter_bytes(),
provider_api_format=provider_api_format,
provider_name=str(provider.name),
model=str(model or ""),
request_id=str(self.request_id or ""),
envelope=envelope,
provider_parser=provider_parser,
)
tgt_norm = (
registry.get_normalizer(client_api_format)
if client_api_format
else None
)
if tgt_norm is None:
raise RuntimeError(f"未注册 Normalizer: {client_api_format}")
response_json = tgt_norm.response_from_internal(
internal_resp,
requested_model=model,
)
response_json = response_json if isinstance(response_json, dict) else {}
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope:
envelope.on_connection_error(base_url=selected_base_url_cached, exc=e)
if selected_base_url_cached:
logger.warning(
f"[{envelope.name}] Connection error: {selected_base_url_cached} ({e})"
)
raise
status_code = resp.status_code
response_headers = dict(resp.headers)
if envelope:
envelope.on_http_status(base_url=selected_base_url_cached, status_code=status_code)
# Forced upstream streaming already built response_json via aggregator.
if upstream_is_stream:
return response_json if isinstance(response_json, dict) else {}
# 统一使用 HTTPStatusError让 TaskService/error_classifier 负责分类(客户端错误/兼容性错误/限流等)
try:
resp.raise_for_status()
@@ -1233,6 +1576,10 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
upstream_response=raw_content,
)
if envelope:
response_json = envelope.unwrap_response(response_json)
envelope.postprocess_unwrapped_response(model=model, data=response_json)
# 检查响应体中的嵌套错误HTTP 200 但响应体包含错误)
if isinstance(response_json, dict):
parser = get_parser_for_format(provider_api_format)

View File

@@ -44,6 +44,9 @@ from src.api.handlers.base.response_parser import (
ResponseParser,
)
from src.api.handlers.base.stream_context import StreamContext
from src.api.handlers.base.upstream_stream_bridge import (
aggregate_upstream_stream_to_internal_response,
)
from src.api.handlers.base.utils import (
build_sse_headers,
check_html_response,
@@ -53,6 +56,9 @@ from src.api.handlers.base.utils import (
)
from src.config.constants import StreamDefaults
from src.config.settings import config
from src.core.api_format.conversion.stream_bridge import (
iter_internal_response_as_stream_events,
)
from src.core.error_utils import extract_client_error_message
from src.core.exceptions import (
EmbeddedErrorException,
@@ -72,6 +78,12 @@ from src.models.database import (
User,
)
from src.services.cache.aware_scheduler import ProviderCandidate
from src.services.provider.behavior import get_provider_behavior
from src.services.provider.stream_policy import (
enforce_stream_mode_for_upstream,
get_upstream_stream_policy,
resolve_upstream_is_stream,
)
from src.services.provider.transport import build_provider_url
from src.services.system.config import SystemConfigService
from src.utils.sse_parser import SSEEventParser
@@ -702,6 +714,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
ctx.final_response = None
ctx.response_id = None
ctx.response_metadata = {} # 重置 Provider 响应元数据
ctx.selected_base_url = None # 重置本次请求选用的 base_url重试时避免污染
# 记录 Provider 信息
ctx.provider_name = str(provider.name)
@@ -740,9 +753,26 @@ class CliMessageHandlerBase(BaseMessageHandler):
)
ctx.needs_conversion = needs_conversion
# 确定目标变体(用于 Codex 等需要特殊处理的上游)
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
target_variant = provider_type if provider_type == "codex" else None
behavior = get_provider_behavior(
provider_type=provider_type,
endpoint_sig=provider_api_format,
)
envelope = behavior.envelope
target_variant = behavior.same_format_variant
# 跨格式转换也允许变体Antigravity 需要保留/翻译 Claude thinking 块)
conversion_variant = behavior.cross_format_variant
# Upstream streaming policy (per-endpoint): may force upstream to sync/stream mode.
upstream_policy = get_upstream_stream_policy(
endpoint,
provider_type=provider_type,
endpoint_sig=provider_api_format,
)
upstream_is_stream = resolve_upstream_is_stream(
client_is_stream=True,
policy=upstream_policy,
)
# 跨格式:先做请求体转换(失败触发 failover
if needs_conversion and provider_api_format:
@@ -752,8 +782,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
provider_api_format,
mapped_model,
ctx.model,
is_stream=True,
target_variant=target_variant,
is_stream=upstream_is_stream,
target_variant=conversion_variant,
)
else:
# 同格式:按原逻辑做轻量清理(子类可覆盖)
@@ -762,7 +792,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
self.get_model_for_url(request_body, mapped_model) or mapped_model or ctx.model
)
# 同格式时也需要应用 target_variant 转换(如 Codex
if target_variant:
if target_variant and provider_api_format:
registry = get_format_converter_registry()
request_body = registry.convert_request(
request_body,
@@ -771,9 +801,31 @@ class CliMessageHandlerBase(BaseMessageHandler):
target_variant=target_variant,
)
# Force upstream stream/sync mode in request body (best-effort).
if provider_api_format:
enforce_stream_mode_for_upstream(
request_body,
provider_api_format=provider_api_format,
upstream_is_stream=upstream_is_stream,
)
# 获取认证信息(处理 Service Account 等异步认证场景)
auth_info = await get_provider_auth(endpoint, key)
# Provider envelope: wrap request after auth is available and before RequestBuilder.build().
if envelope:
request_body, url_model = envelope.wrap_request(
request_body,
model=url_model or ctx.model or "",
url_model=url_model,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
)
# Provider envelope: extra upstream headers (e.g. dedicated User-Agent).
extra_headers: dict[str, str] = {}
if envelope:
extra_headers.update(envelope.extra_headers() or {})
# 使用 RequestBuilder 构建请求体和请求头
# 注意mapped_model 已经应用到 request_body这里不再传递
# 上游始终使用 header 认证,不跟随客户端的 query 方式
@@ -782,9 +834,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
original_headers,
endpoint,
key,
is_stream=True,
is_stream=upstream_is_stream,
extra_headers=extra_headers if extra_headers else None,
pre_computed_auth=auth_info.as_tuple() if auth_info else None,
)
if upstream_is_stream:
# Ensure upstream returns SSE payload when in streaming mode.
provider_headers["Accept"] = "text/event-stream"
# 保存发送给 Provider 的请求信息(用于调试和统计)
ctx.provider_request_headers = provider_headers
@@ -794,10 +850,146 @@ class CliMessageHandlerBase(BaseMessageHandler):
endpoint,
query_params=query_params,
path_params={"model": url_model},
is_stream=True, # CLI handler 处理流式请求
is_stream=upstream_is_stream,
key=key,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
)
# Capture the selected base_url from transport (used by some envelopes for failover).
ctx.selected_base_url = envelope.capture_selected_base_url() if envelope else None
# If upstream is forced to non-stream mode, we execute a sync request and then
# simulate streaming to the client (sync -> stream bridge).
if not upstream_is_stream:
from src.clients.http_client import HTTPClientPool
request_timeout_sync = provider.request_timeout or config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client(
proxy_config=provider.proxy,
)
try:
resp = await http_client.post(
url,
json=provider_payload,
headers=provider_headers,
timeout=httpx.Timeout(request_timeout_sync),
)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
if ctx.selected_base_url:
logger.warning(
f"[{envelope.name}] Connection error: {ctx.selected_base_url} ({e})"
)
raise
ctx.status_code = resp.status_code
ctx.response_headers = dict(resp.headers)
if envelope:
envelope.on_http_status(base_url=ctx.selected_base_url, status_code=ctx.status_code)
# Reuse HTTPStatusError classification path (handled by TaskService/error_classifier).
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
error_body = ""
try:
error_body = resp.text[:4000] if resp.text else ""
except Exception:
error_body = ""
e.upstream_response = error_body # type: ignore[attr-defined]
raise
# Safe JSON parsing.
try:
response_json = resp.json()
except (UnicodeDecodeError, json.JSONDecodeError) as e:
raw_content = ""
try:
raw_content = resp.text[:500] if resp.text else "(empty)"
except Exception:
raw_content = "(unable to read)"
raise ProviderNotAvailableException(
"上游服务返回了无效的响应",
provider_name=str(provider.name),
upstream_status=resp.status_code,
upstream_response=f"json_decode_error={type(e).__name__}: {raw_content}",
)
if envelope:
response_json = envelope.unwrap_response(response_json)
envelope.postprocess_unwrapped_response(model=ctx.model, data=response_json)
# Embedded error detection (HTTP 200 but error body).
if isinstance(response_json, dict) and provider_api_format:
parser = get_parser_for_format(provider_api_format)
if parser.is_error_response(response_json):
parsed = parser.parse_response(response_json, 200)
raise EmbeddedErrorException(
provider_name=str(provider.name),
error_code=parsed.embedded_status_code,
error_message=parsed.error_message,
error_status=parsed.error_type,
)
# Extract Provider response metadata (best-effort).
if isinstance(response_json, dict):
ctx.response_metadata = self._extract_response_metadata(response_json)
# Convert sync JSON -> InternalResponse, then InternalResponse -> client stream events.
registry = get_format_converter_registry()
src_norm = registry.get_normalizer(provider_api_format) if provider_api_format else None
if src_norm is None:
raise RuntimeError(f"未注册 Normalizer: {provider_api_format}")
internal_resp = src_norm.response_to_internal(
response_json if isinstance(response_json, dict) else {}
)
internal_resp.model = str(ctx.model or internal_resp.model or "")
if internal_resp.id:
ctx.response_id = internal_resp.id
if internal_resp.usage:
ctx.input_tokens = int(internal_resp.usage.input_tokens or 0)
ctx.output_tokens = int(internal_resp.usage.output_tokens or 0)
ctx.cached_tokens = int(internal_resp.usage.cache_read_tokens or 0)
ctx.cache_creation_tokens = int(internal_resp.usage.cache_write_tokens or 0)
from src.core.api_format.conversion.stream_state import StreamState
tgt_norm = registry.get_normalizer(client_api_format) if client_api_format else None
if tgt_norm is None:
raise RuntimeError(f"未注册 Normalizer: {client_api_format}")
state = StreamState(
model=str(ctx.model or ""),
message_id=str(ctx.response_id or ctx.request_id or self.request_id or ""),
)
output_state = {"first_yield": True, "streaming_updated": False}
async def _streamified() -> AsyncGenerator[bytes]:
for ev in iter_internal_response_as_stream_events(internal_resp):
converted_events = tgt_norm.stream_event_from_internal(ev, state)
if not converted_events:
continue
self._record_converted_chunks(ctx, converted_events)
for sse_line in _format_converted_events_to_sse(
converted_events, client_api_format
):
if not sse_line:
continue
ctx.chunk_count += 1
self._mark_first_output(ctx, output_state)
yield (sse_line + "\n").encode("utf-8")
# OpenAI chat clients expect a final [DONE] marker.
if str(client_api_format or "").strip().lower() == "openai:chat":
ctx.chunk_count += 1
self._mark_first_output(ctx, output_state)
yield b"data: [DONE]\n\n"
ctx.has_completion = True
return _streamified()
# 配置 HTTP 超时
# 注意read timeout 用于检测连接断开,不是整体请求超时
@@ -847,6 +1039,12 @@ class CliMessageHandlerBase(BaseMessageHandler):
logger.debug(f" └─ 收到响应: status={stream_response.status_code}")
if envelope:
envelope.on_http_status(
base_url=ctx.selected_base_url,
status_code=ctx.status_code,
)
stream_response.raise_for_status()
# 使用字节流迭代器(避免 aiter_lines 的性能问题, aiter_bytes 会自动解压 gzip/deflate
@@ -871,7 +1069,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
else:
await asyncio.wait_for(_connect_and_prefetch(), timeout=request_timeout)
except TimeoutError:
except TimeoutError as e:
# 整体请求超时(建立连接 + 获取首字节)
# 清理可能已建立的连接上下文
if response_ctx is not None:
@@ -879,6 +1077,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
await http_client.aclose()
logger.warning(
f" [{self.request_id}] 请求超时: Provider={provider.name}, timeout={request_timeout}s"
@@ -901,6 +1101,16 @@ class CliMessageHandlerBase(BaseMessageHandler):
ctx.error_message = "client_disconnected_during_prefetch"
raise
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
if ctx.selected_base_url:
logger.warning(
f"[{envelope.name}] Connection error: {ctx.selected_base_url} ({e})"
)
await http_client.aclose()
raise
except httpx.HTTPStatusError as e:
error_text = await self._extract_error_text(e)
logger.error(
@@ -958,6 +1168,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 使用已设置的 ctx.needs_conversion由候选筛选阶段根据端点配置判断
# 不再调用 _needs_format_conversion它只检查格式差异不检查端点配置
needs_conversion = ctx.needs_conversion
behavior = get_provider_behavior(
provider_type=ctx.provider_type,
endpoint_sig=ctx.provider_api_format,
)
envelope = behavior.envelope
if envelope and envelope.force_stream_rewrite():
needs_conversion = True
ctx.needs_conversion = True
async for chunk in stream_response.aiter_bytes():
buffer += chunk
@@ -1321,6 +1539,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 使用已设置的 ctx.needs_conversion由候选筛选阶段根据端点配置判断
# 不再调用 _needs_format_conversion它只检查格式差异不检查端点配置
needs_conversion = ctx.needs_conversion
behavior = get_provider_behavior(
provider_type=ctx.provider_type,
endpoint_sig=ctx.provider_api_format,
)
envelope = behavior.envelope
if envelope and envelope.force_stream_rewrite():
needs_conversion = True
ctx.needs_conversion = True
# 先处理预读的字节块
for chunk in prefetched_chunks:
@@ -1568,18 +1794,31 @@ class CliMessageHandlerBase(BaseMessageHandler):
except json.JSONDecodeError:
return
if not isinstance(data, dict):
return
behavior = get_provider_behavior(
provider_type=ctx.provider_type,
endpoint_sig=ctx.provider_api_format,
)
envelope = behavior.envelope
if envelope:
data = envelope.unwrap_response(data)
if not isinstance(data, dict):
return
# 当不需要格式转换时,更新 data_count需要记录时再写入 parsed_chunks。
# 当需要格式转换时record_chunk=Falsedata_count 由 _record_converted_chunks 更新
if record_chunk and isinstance(data, dict):
if record_chunk:
ctx.data_count += 1
if ctx.record_parsed_chunks:
ctx.parsed_chunks.append(data)
if not isinstance(data, dict):
return
event_type = event_name or data.get("type", "")
if envelope:
envelope.postprocess_unwrapped_response(model=ctx.model, data=data)
# 调用格式特定的处理逻辑
# 注意跨格式转换时_process_event_data 会自动选择正确的 Provider 解析器
self._process_event_data(ctx, event_type, data)
@@ -1928,6 +2167,17 @@ class CliMessageHandlerBase(BaseMessageHandler):
logger.warning(f"[{ctx.request_id}] 流式请求失败,未选中提供商")
return
behavior = get_provider_behavior(
provider_type=ctx.provider_type,
endpoint_sig=ctx.provider_api_format,
)
envelope = behavior.envelope
if envelope:
envelope.on_http_status(
base_url=ctx.selected_base_url,
status_code=ctx.status_code,
)
# 获取新的 DB session
db_gen = get_db()
bg_db = next(db_gen)
@@ -2326,9 +2576,26 @@ class CliMessageHandlerBase(BaseMessageHandler):
)
needs_conversion = bool(getattr(candidate, "needs_conversion", False))
# 确定目标变体(用于 Codex 等需要特殊处理的上游)
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
target_variant = provider_type if provider_type == "codex" else None
behavior = get_provider_behavior(
provider_type=provider_type,
endpoint_sig=provider_api_format,
)
envelope = behavior.envelope
target_variant = behavior.same_format_variant
# 跨格式转换也允许变体Antigravity 需要保留/翻译 Claude thinking 块)
conversion_variant = behavior.cross_format_variant
# Upstream streaming policy (per-endpoint).
upstream_policy = get_upstream_stream_policy(
endpoint,
provider_type=provider_type,
endpoint_sig=provider_api_format,
)
upstream_is_stream = resolve_upstream_is_stream(
client_is_stream=False,
policy=upstream_policy,
)
# 跨格式:先做请求体转换(失败触发 failover
if needs_conversion and provider_api_format:
@@ -2338,8 +2605,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
provider_api_format,
mapped_model,
model,
is_stream=False,
target_variant=target_variant,
is_stream=upstream_is_stream,
target_variant=conversion_variant,
)
else:
# 同格式:按原逻辑做轻量清理(子类可覆盖)
@@ -2348,7 +2615,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
self.get_model_for_url(request_body, mapped_model) or mapped_model or model
)
# 同格式时也需要应用 target_variant 转换(如 Codex
if target_variant:
if target_variant and provider_api_format:
registry = get_format_converter_registry()
request_body = registry.convert_request(
request_body,
@@ -2357,9 +2624,31 @@ class CliMessageHandlerBase(BaseMessageHandler):
target_variant=target_variant,
)
# Force upstream stream/sync mode in request body (best-effort).
if provider_api_format:
enforce_stream_mode_for_upstream(
request_body,
provider_api_format=provider_api_format,
upstream_is_stream=upstream_is_stream,
)
# 获取认证信息(处理 Service Account 等异步认证场景)
auth_info = await get_provider_auth(endpoint, key)
# Provider envelope: wrap request after auth is available and before RequestBuilder.build().
if envelope:
request_body, url_model = envelope.wrap_request(
request_body,
model=url_model or model or "",
url_model=url_model,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
)
# Provider envelope: extra upstream headers (e.g. dedicated User-Agent).
extra_headers: dict[str, str] = {}
if envelope:
extra_headers.update(envelope.extra_headers() or {})
# 使用 RequestBuilder 构建请求体和请求头
# 注意mapped_model 已经应用到 request_body这里不再传递
# 上游始终使用 header 认证,不跟随客户端的 query 方式
@@ -2368,9 +2657,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
original_headers,
endpoint,
key,
is_stream=False,
is_stream=upstream_is_stream,
extra_headers=extra_headers if extra_headers else None,
pre_computed_auth=auth_info.as_tuple() if auth_info else None,
)
if upstream_is_stream:
# Ensure upstream returns SSE payload when forced to streaming mode.
provider_headers["Accept"] = "text/event-stream"
# 保存发送给 Provider 的请求信息(用于调试和统计)
provider_request_headers = provider_headers
@@ -2380,13 +2673,15 @@ class CliMessageHandlerBase(BaseMessageHandler):
endpoint,
query_params=query_params,
path_params={"model": url_model},
is_stream=False, # 非流式请求
is_stream=upstream_is_stream, # sync handler may still force upstream streaming
key=key,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
)
# 非流式:必须在 build_provider_url 调用后立即缓存(避免 contextvar 被后续调用覆盖)
selected_base_url_cached = envelope.capture_selected_base_url() if envelope else None
logger.info(
f" └─ [{self.request_id}] 发送非流式请求: "
f" └─ [{self.request_id}] 发送{'上游流式(聚合)' if upstream_is_stream else '非流式'}请求: "
f"Provider={provider.name}, Endpoint={endpoint.id[:8] if endpoint.id else 'N/A'}..., "
f"Key=***{key.api_key[-4:] if key.api_key else 'N/A'}, "
f"原始模型={model}, 映射后={mapped_model or '无映射'}, URL模型={url_model}"
@@ -2405,49 +2700,106 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 注意:不使用 async with因为复用的客户端不应该被关闭
# 超时通过 timeout 参数控制
resp = await http_client.post(
url,
json=provider_payload,
headers=provider_headers,
timeout=httpx.Timeout(request_timeout),
)
resp: httpx.Response | None = None
if not upstream_is_stream:
try:
resp = await http_client.post(
url,
json=provider_payload,
headers=provider_headers,
timeout=httpx.Timeout(request_timeout),
)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope:
envelope.on_connection_error(base_url=selected_base_url_cached, exc=e)
if selected_base_url_cached:
logger.warning(
f"[{envelope.name}] Connection error: {selected_base_url_cached} ({e})"
)
raise
else:
# Forced upstream streaming: aggregate SSE to a sync JSON response.
registry = get_format_converter_registry()
provider_parser = (
get_parser_for_format(provider_api_format) if provider_api_format else None
)
try:
async with http_client.stream(
"POST",
url,
json=provider_payload,
headers=provider_headers,
timeout=httpx.Timeout(request_timeout),
) as stream_resp:
resp = stream_resp
status_code = stream_resp.status_code
response_headers = dict(stream_resp.headers)
if envelope:
envelope.on_http_status(
base_url=selected_base_url_cached,
status_code=status_code,
)
stream_resp.raise_for_status()
internal_resp = await aggregate_upstream_stream_to_internal_response(
stream_resp.aiter_bytes(),
provider_api_format=provider_api_format,
provider_name=str(provider.name),
model=str(model or ""),
request_id=str(self.request_id or ""),
envelope=envelope,
provider_parser=provider_parser,
)
tgt_norm = (
registry.get_normalizer(client_api_format)
if client_api_format
else None
)
if tgt_norm is None:
raise RuntimeError(f"未注册 Normalizer: {client_api_format}")
response_json = tgt_norm.response_from_internal(
internal_resp,
requested_model=model,
)
response_json = response_json if isinstance(response_json, dict) else {}
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope:
envelope.on_connection_error(base_url=selected_base_url_cached, exc=e)
if selected_base_url_cached:
logger.warning(
f"[{envelope.name}] Connection error: {selected_base_url_cached} ({e})"
)
raise
status_code = resp.status_code
response_headers = dict(resp.headers)
if resp.status_code == 401:
raise ProviderAuthException(str(provider.name))
elif resp.status_code == 429:
raise ProviderRateLimitException(
"请求过于频繁,请稍后重试",
provider_name=str(provider.name),
response_headers=response_headers,
retry_after=int(resp.headers.get("retry-after", 0)) or None,
)
elif resp.status_code >= 500:
error_text = resp.text
raise ProviderNotAvailableException(
f"上游服务暂时不可用 (HTTP {resp.status_code})",
provider_name=str(provider.name),
upstream_status=resp.status_code,
upstream_response=error_text,
)
elif 300 <= resp.status_code < 400:
redirect_url = resp.headers.get("location", "unknown")
raise ProviderNotAvailableException(
"上游服务返回重定向响应",
provider_name=str(provider.name),
upstream_status=resp.status_code,
upstream_response=f"重定向 {resp.status_code} -> {redirect_url}",
)
elif resp.status_code != 200:
error_text = resp.text
raise ProviderNotAvailableException(
f"上游服务返回错误 (HTTP {resp.status_code})",
provider_name=str(provider.name),
upstream_status=resp.status_code,
upstream_response=error_text,
)
if envelope:
envelope.on_http_status(base_url=selected_base_url_cached, status_code=status_code)
# Forced upstream streaming already built response_json via aggregator.
if upstream_is_stream:
response_metadata_result = self._extract_response_metadata(response_json or {})
return response_json if isinstance(response_json, dict) else {}
# Reuse HTTPStatusError classification path (handled by TaskService/error_classifier).
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
error_body = ""
try:
error_body = resp.text[:4000] if resp.text else ""
except Exception:
error_body = ""
e.upstream_response = error_body # type: ignore[attr-defined]
raise
# 安全解析 JSON 响应,处理可能的编码错误
try:
@@ -2483,6 +2835,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
upstream_response=raw_content,
)
if envelope:
response_json = envelope.unwrap_response(response_json)
envelope.postprocess_unwrapped_response(model=model, data=response_json)
# 提取 Provider 响应元数据(子类可覆盖)
response_metadata_result = self._extract_response_metadata(response_json)
@@ -2531,12 +2887,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
if response_json is None:
response_json = {}
# 检查是否需要格式转换(同族格式无需转换,如 CLAUDE 和 CLAUDE_CLI
from src.core.api_format.utils import get_base_format
provider_base = get_base_format(provider_api_format) if provider_api_format else None
client_base = get_base_format(api_format) if api_format else None
if provider_base and client_base and provider_base != client_base:
# 跨格式:响应转换回 client_format失败不触发 failover保守回退为原始响应
if (
needs_conversion
and provider_api_format
and api_format
and isinstance(response_json, dict)
):
try:
registry = get_format_converter_registry()
response_json = registry.convert_response(
@@ -2835,6 +3192,15 @@ class CliMessageHandlerBase(BaseMessageHandler):
if status == "invalid" or status == "passthrough":
return [line], []
behavior = get_provider_behavior(
provider_type=ctx.provider_type,
endpoint_sig=ctx.provider_api_format,
)
envelope = behavior.envelope
if envelope:
data_obj = envelope.unwrap_response(data_obj)
envelope.postprocess_unwrapped_response(model=ctx.model, data=data_obj)
# 初始化流式转换状态
if ctx.stream_conversion_state is None:
from src.core.api_format.conversion.stream_state import StreamState

View File

@@ -586,7 +586,16 @@ async def get_provider_auth(
pass
decrypted_key = crypto_service.decrypt(key.api_key)
return ProviderAuthInfo(auth_header="Authorization", auth_value=f"Bearer {decrypted_key}")
decrypted_auth_config: dict[str, Any] | None = None
if isinstance(token_meta, dict) and token_meta:
decrypted_auth_config = token_meta
return ProviderAuthInfo(
auth_header="Authorization",
auth_value=f"Bearer {decrypted_key}",
decrypted_auth_config=decrypted_auth_config,
)
if auth_type == "vertex_ai":
from src.core.vertex_auth import VertexAuthError, VertexAuthService

View File

@@ -40,6 +40,8 @@ class StreamContext:
provider_name: str | None = None
provider_id: str | None = None
provider_type: str | None = None # Provider 类型(如 codex用于元数据采集
# Transport 层选中的 base_url用于 URL 可用性更新/故障转移等场景)
selected_base_url: str | None = None
endpoint_id: str | None = None
key_id: str | None = None
attempt_id: str | None = None
@@ -130,6 +132,7 @@ class StreamContext:
self.final_response = None
self.stream_conversion_state = None
self.needs_conversion = False
self.selected_base_url = None
@property
def collected_text(self) -> str:

View File

@@ -44,6 +44,7 @@ from src.core.exceptions import (
)
from src.core.logger import logger
from src.models.database import Provider, ProviderEndpoint
from src.services.provider.behavior import get_provider_behavior
from src.utils.perf import PerfRecorder
from src.utils.sse_parser import SSEEventParser
from src.utils.timeout import read_first_chunk_with_ttfb_timeout
@@ -213,6 +214,11 @@ class StreamProcessor:
"""
prefetched_chunks: list = []
parser = self.get_parser_for_provider(ctx)
behavior = get_provider_behavior(
provider_type=str(getattr(ctx, "provider_type", "") or ""),
endpoint_sig=str(getattr(ctx, "provider_api_format", "") or ""),
)
envelope = behavior.envelope
buffer = b""
line_count = 0
should_stop = False
@@ -291,6 +297,14 @@ class StreamProcessor:
break
continue
# Provider envelope: unwrap SSE data chunk before error detection / trial conversion.
if envelope and isinstance(data, dict):
data = envelope.unwrap_response(data)
envelope.postprocess_unwrapped_response(
model=str(ctx.model or ""),
data=data,
)
# 使用解析器检查是否为错误响应
if isinstance(data, dict) and parser.is_error_response(data):
parsed = parser.parse_response(data, 200)
@@ -431,6 +445,14 @@ class StreamProcessor:
) or "unknown"
# 使用 handler 层预计算的 needs_conversion由 candidate 决定)
needs_conversion = ctx.needs_conversion
behavior = get_provider_behavior(
provider_type=str(getattr(ctx, "provider_type", "") or ""),
endpoint_sig=str(getattr(ctx, "provider_api_format", "") or ""),
)
envelope = behavior.envelope
if envelope and envelope.force_stream_rewrite():
needs_conversion = True
ctx.needs_conversion = True
# 安全检查needs_conversion 为 True 时provider_format 必须有值
if needs_conversion and not provider_format:

View File

@@ -0,0 +1,202 @@
"""Upstream stream bridging helpers (handler layer).
This module provides small utilities used when handler-layer policies force an
upstream request to be streaming (SSE) even when the client asked for sync.
It intentionally stays lightweight and works with:
- standard SSE `data: {...}` lines (OpenAI/Claude/etc.)
- Gemini CLI JSON-array lines (best-effort)
"""
from __future__ import annotations
import codecs
import json
from collections.abc import AsyncIterator
from typing import Any
from src.api.handlers.base.response_parser import ResponseParser
from src.api.handlers.base.utils import get_format_converter_registry
from src.core.api_format.conversion.internal import InternalResponse
from src.core.api_format.conversion.stream_bridge import InternalStreamAggregator
from src.core.api_format.conversion.stream_state import StreamState
from src.core.exceptions import EmbeddedErrorException
from src.core.logger import logger
from src.services.provider.envelope import ProviderEnvelope
def _parse_sse_data_line(line: str) -> tuple[Any | None, str]:
"""Parse `data: {...}` as JSON."""
payload = line[5:].strip()
if not payload:
return None, "empty"
try:
return json.loads(payload), "ok"
except json.JSONDecodeError:
return None, "invalid"
def _parse_sse_event_data_line(line: str) -> tuple[Any | None, str]:
"""Parse `event: xxx data: {...}` as JSON (best-effort)."""
# Split only on the first " data:" occurrence.
try:
_, data_part = line.split(" data:", 1)
except ValueError:
return None, "invalid"
payload = data_part.strip()
if not payload:
return None, "empty"
try:
return json.loads(payload), "ok"
except json.JSONDecodeError:
return None, "invalid"
def _parse_gemini_json_array_line(line: str) -> tuple[Any | None, str]:
"""Parse Gemini CLI JSON-array streaming line (best-effort).
Gemini CLI may stream objects in a JSON array form like:
- "[{...},"
- " {...},"
- " {...}]"
"""
stripped = (line or "").strip()
if not stripped:
return None, "empty"
# Quick filter: must contain a JSON object boundary.
if "{" not in stripped:
return None, "skip"
candidate = stripped.lstrip(",").rstrip(",").strip()
# Drop array brackets on edges.
if candidate.startswith("["):
candidate = candidate[1:].strip()
if candidate.endswith("]"):
candidate = candidate[:-1].strip()
candidate = candidate.lstrip(",").rstrip(",").strip()
if not candidate:
return None, "empty"
try:
return json.loads(candidate), "ok"
except json.JSONDecodeError:
logger.debug(f"Gemini JSON-array line skip: {stripped[:50]}")
return None, "invalid"
def parse_provider_stream_line_to_json(
line: str,
provider_format: str,
) -> tuple[Any | None, str]:
"""Best-effort parse for upstream streaming lines (SSE or Gemini JSON-array)."""
if not line:
return None, "skip"
normalized = line.rstrip("\r").strip("\n")
if not normalized or normalized.strip() == "":
return None, "skip"
# Standard SSE data line.
if normalized.startswith("data:"):
# `data: [DONE]` is a sentinel.
if normalized[5:].strip() == "[DONE]":
return None, "skip"
return _parse_sse_data_line(normalized)
# event + data on same line.
if normalized.startswith("event:") and " data:" in normalized:
return _parse_sse_event_data_line(normalized)
# Other control lines.
if normalized.startswith(("event:", "id:", "retry:")):
return None, "skip"
# Gemini JSON-array/chunked streaming (no SSE prefix).
if str(provider_format or "").strip().lower().startswith("gemini"):
return _parse_gemini_json_array_line(normalized)
return None, "skip"
async def aggregate_upstream_stream_to_internal_response(
byte_iter: AsyncIterator[bytes],
*,
provider_api_format: str,
provider_name: str,
model: str,
request_id: str,
envelope: ProviderEnvelope | None = None,
provider_parser: ResponseParser | None = None,
) -> InternalResponse:
"""Aggregate upstream SSE/streaming bytes into an InternalResponse (best-effort)."""
registry = get_format_converter_registry()
src_norm = registry.get_normalizer(provider_api_format) if provider_api_format else None
if src_norm is None:
raise RuntimeError(f"未注册 Normalizer: {provider_api_format}")
if not getattr(src_norm, "capabilities", None) or not src_norm.capabilities.supports_stream:
raise RuntimeError(f"上游格式不支持流式: {provider_api_format}")
state = StreamState(model=str(model or ""), message_id=str(request_id or ""))
aggregator = InternalStreamAggregator(
fallback_id=str(request_id or "resp"),
fallback_model=str(model or ""),
)
buffer = b""
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
def _feed_line(normalized_line: str) -> None:
data_obj, st = parse_provider_stream_line_to_json(normalized_line, provider_api_format)
if st != "ok" or data_obj is None:
return
if not isinstance(data_obj, dict):
return
if envelope:
unwrapped = envelope.unwrap_response(data_obj)
if not isinstance(unwrapped, dict):
return
data_obj = unwrapped
envelope.postprocess_unwrapped_response(model=model, data=data_obj)
if provider_parser and provider_parser.is_error_response(data_obj):
parsed = provider_parser.parse_response(data_obj, 200)
raise EmbeddedErrorException(
provider_name=str(provider_name),
error_code=parsed.embedded_status_code,
error_message=parsed.error_message,
error_status=parsed.error_type,
)
internal_events = src_norm.stream_chunk_to_internal(data_obj, state)
aggregator.feed(internal_events)
async for chunk in byte_iter:
buffer += chunk
while b"\n" in buffer:
line_bytes, buffer = buffer.split(b"\n", 1)
line = decoder.decode(line_bytes + b"\n", False).rstrip("\n")
normalized_line = line.rstrip("\r")
_feed_line(normalized_line)
# Flush remaining buffered bytes (in case upstream doesn't end with newline).
if buffer:
try:
tail = decoder.decode(buffer, True)
except Exception:
tail = ""
normalized_tail = (tail or "").rstrip("\r\n")
if normalized_tail:
_feed_line(normalized_tail)
return aggregator.build()
__all__ = [
"aggregate_upstream_stream_to_internal_response",
"parse_provider_stream_line_to_json",
]

View File

@@ -96,7 +96,12 @@ class GeminiCliMessageHandler(CliMessageHandlerBase):
# 优先使用映射后的模型名,否则使用请求体中的
return mapped_model or request_body.get("model")
def _extract_usage_from_event(self, event: dict[str, Any]) -> dict[str, int]:
def _extract_usage_from_event(
self,
event: dict[str, Any],
*,
provider_type: str | None = None,
) -> dict[str, int]:
"""
从 Gemini 事件中提取 token 使用情况
@@ -104,10 +109,14 @@ class GeminiCliMessageHandler(CliMessageHandlerBase):
Args:
event: Gemini 流式响应事件
provider_type: Provider 类型(用于 Antigravity 特判)
Returns:
包含 input_tokens, output_tokens, cached_tokens 的字典
"""
if str(provider_type or "").lower() == "antigravity":
return self._extract_antigravity_usage(event)
from src.api.handlers.gemini.stream_parser import GeminiStreamParser
usage = GeminiStreamParser().extract_usage(event)
@@ -125,6 +134,35 @@ class GeminiCliMessageHandler(CliMessageHandlerBase):
"cached_tokens": usage.get("cached_tokens", 0),
}
def _extract_antigravity_usage(self, event: dict[str, Any]) -> dict[str, int]:
"""Antigravity 专用 usage 提取(宽松 + 边界保护)。
Antigravity 的 usageMetadata 可能缺少 totalTokenCount因此不能依赖
GeminiStreamParser.extract_usage 的“totalTokenCount 必须存在”的严格判断。
"""
usage_metadata = event.get("usageMetadata", {})
if not isinstance(usage_metadata, dict) or not usage_metadata:
return {"input_tokens": 0, "output_tokens": 0, "cached_tokens": 0}
def _as_int(v: Any) -> int:
try:
return int(v or 0)
except Exception:
return 0
prompt = _as_int(usage_metadata.get("promptTokenCount"))
cached = _as_int(usage_metadata.get("cachedContentTokenCount"))
candidates = _as_int(usage_metadata.get("candidatesTokenCount"))
thoughts = _as_int(usage_metadata.get("thoughtsTokenCount"))
return {
# 注意:计费层会根据 api_family(GEMINI) 扣除 cache_read_tokens
# 因此这里保持 Gemini 口径input_tokens=promptTokenCount含缓存
"input_tokens": max(0, prompt),
"output_tokens": max(0, candidates + thoughts),
"cached_tokens": max(0, cached),
}
def _process_event_data(
self,
ctx: StreamContext,
@@ -172,7 +210,7 @@ class GeminiCliMessageHandler(CliMessageHandlerBase):
ctx.final_response = data
# 提取使用量信息(复用 GeminiStreamParser.extract_usage
usage = self._extract_usage_from_event(data)
usage = self._extract_usage_from_event(data, provider_type=ctx.provider_type)
if usage["input_tokens"] > 0 or usage["output_tokens"] > 0:
ctx.input_tokens = usage["input_tokens"]
ctx.output_tokens = usage["output_tokens"]

View File

@@ -6,7 +6,6 @@ OpenAI CLI Adapter - 基于通用 CLI Adapter 基类的简化实现
from __future__ import annotations
import uuid
from typing import Any
import httpx
@@ -126,10 +125,10 @@ class OpenAICliAdapter(CliAdapterBase):
# 仅 Codex 端点添加特定头部
if base_url and is_codex_url(base_url):
headers["x-oai-web-search-eligible"] = "true"
headers["session_id"] = str(uuid.uuid4())
headers["accept"] = "text/event-stream"
headers["originator"] = "codex_cli_rs"
# 与运行时路径保持一致:使用 Codex envelope 的 best-effort headers。
from src.services.codex.envelope import codex_oauth_envelope
headers.update(codex_oauth_envelope.extra_headers() or {})
return headers

View File

@@ -197,6 +197,15 @@ class GeminiNormalizer(FormatNormalizer):
) -> dict[str, Any]:
system_text = internal.system or self._join_instructions(internal.instructions)
target_variant_norm = str(target_variant or "").strip().lower()
is_antigravity = target_variant_norm == "antigravity"
allow_dummy_thought = bool(
is_antigravity and str(internal.model or "").startswith("gemini-")
)
thinking_enabled = (
self._is_antigravity_thinking_enabled(internal) if is_antigravity else False
)
# tools/tool_choice
tools = None
if internal.tools:
@@ -278,8 +287,45 @@ class GeminiNormalizer(FormatNormalizer):
generation_config["thinkingConfig"] = orig_gc["thinking_config"]
contents: list[dict[str, Any]] = []
for msg in internal.messages:
contents.append(self._internal_message_to_content(msg))
last_idx = len(internal.messages) - 1
for idx, msg in enumerate(internal.messages):
content = self._internal_message_to_content(
msg,
target_variant=target_variant_norm,
model=internal.model,
)
# Antigravity: Gemini models allow a dummy thought signature as a workaround
# for strict thought signature validation. Only apply to the last assistant
# turn (prefill scenario) when thinking is enabled and no thought part exists.
if (
allow_dummy_thought
and thinking_enabled
and idx == last_idx
and content.get("role") == "model"
):
parts = content.get("parts")
if isinstance(parts, list) and parts:
has_thought = any(
isinstance(p, dict) and p.get("thought") is True for p in parts
)
if not has_thought:
try:
from src.services.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
dummy_sig = DUMMY_THOUGHT_SIGNATURE
except Exception:
dummy_sig = "skip_thought_signature_validator"
dummy_part: dict[str, Any] = {
"text": "Thinking...",
"thought": True,
"thoughtSignature": dummy_sig,
}
content = dict(content)
content["parts"] = [dummy_part, *parts]
contents.append(content)
result: dict[str, Any] = {
"contents": contents,
@@ -1120,12 +1166,22 @@ class GeminiNormalizer(FormatNormalizer):
return blocks, dropped
def _internal_message_to_content(self, msg: InternalMessage) -> dict[str, Any]:
def _internal_message_to_content(
self,
msg: InternalMessage,
*,
target_variant: str | None = None,
model: str | None = None,
) -> dict[str, Any]:
role = "model" if msg.role == Role.ASSISTANT else "user"
parts: list[dict[str, Any]] = []
for b in msg.content:
if isinstance(b, UnknownBlock):
if str(target_variant or "").strip().lower() == "antigravity":
part = self._unknown_block_to_antigravity_part(b, model=str(model or ""))
if part is not None:
parts.append(part)
continue
if isinstance(b, TextBlock):
@@ -1166,6 +1222,94 @@ class GeminiNormalizer(FormatNormalizer):
return {"role": role, "parts": parts}
def _is_antigravity_thinking_enabled(self, internal: InternalRequest) -> bool:
"""Best-effort detection of Claude-style `thinking` flag for Antigravity conversions."""
try:
extra = internal.extra if isinstance(internal.extra, dict) else {}
claude_extra = extra.get("claude")
if not isinstance(claude_extra, dict):
return False
thinking = claude_extra.get("thinking")
if thinking is True:
return True
if isinstance(thinking, dict):
ttype = thinking.get("type")
if isinstance(ttype, str) and ttype.strip().lower() == "enabled":
return True
enabled = thinking.get("enabled")
if enabled is True:
return True
return False
except Exception:
return False
def _unknown_block_to_antigravity_part(
self,
block: UnknownBlock,
*,
model: str,
) -> dict[str, Any] | None:
"""Translate Claude thinking blocks into Gemini thought parts for Antigravity.
The internal representation stores Claude `thinking`/`redacted_thinking` as UnknownBlock.
Antigravity expects them as Gemini parts with `thought=true` and `thoughtSignature`.
"""
raw_type = str(getattr(block, "raw_type", "") or "").strip().lower()
if raw_type not in {"thinking", "redacted_thinking"}:
return None
payload = block.payload if isinstance(block.payload, dict) else {}
# Claude: thinking -> {type:"thinking", thinking:"...", signature?: "..."}
# Claude: redacted_thinking -> {type:"redacted_thinking", data:"..."}
if raw_type == "thinking":
text_val = payload.get("thinking")
else:
text_val = payload.get("data")
if text_val is None:
text_val = payload.get("text")
if not isinstance(text_val, str) or not text_val:
return None
payload_sig = (
payload.get("signature")
or payload.get("thoughtSignature")
or payload.get("thought_signature")
)
if not isinstance(payload_sig, str) or not payload_sig:
payload_sig = None
signature: str | None = None
try:
from src.services.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
from src.services.antigravity.signature_cache import signature_cache
cached_or_dummy = signature_cache.get_or_dummy(model, text_val)
# Prefer cached real signature > client-provided signature > dummy signature.
if (
isinstance(cached_or_dummy, str)
and cached_or_dummy
and cached_or_dummy != DUMMY_THOUGHT_SIGNATURE
):
signature = cached_or_dummy
elif payload_sig:
signature = payload_sig
elif isinstance(cached_or_dummy, str) and cached_or_dummy:
signature = cached_or_dummy
except Exception:
signature = payload_sig
# For non-gemini models, missing signature is likely to fail upstream validation.
if not signature:
return None
return {"text": text_val, "thought": True, "thoughtSignature": signature}
def _collapse_system_instruction(
self, system_instruction: Any
) -> tuple[str | None, dict[str, int]]:

View File

@@ -0,0 +1,262 @@
"""Sync<->stream bridge helpers for the conversion layer.
We already have:
- streaming conversion: source stream chunk -> internal events -> target stream chunk
- sync conversion: source response -> internal response -> target response
This module fills the missing link:
- aggregate internal stream events into a single InternalResponse (stream -> sync)
- expand an InternalResponse into internal stream events (sync -> stream)
Used by handler-layer upstream policies that force upstream streaming mode.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Iterable, Iterator
from .internal import (
ContentType,
ImageBlock,
InternalResponse,
StopReason,
TextBlock,
ToolUseBlock,
UsageInfo,
)
from .stream_events import (
ContentBlockStartEvent,
ContentBlockStopEvent,
ContentDeltaEvent,
InternalStreamEvent,
MessageStartEvent,
MessageStopEvent,
ToolCallDeltaEvent,
UsageEvent,
)
@dataclass
class _BlockBuilder:
block_type: ContentType
text: str = ""
tool_id: str | None = None
tool_name: str | None = None
tool_args_json: str = ""
image_data: str | None = None
image_media_type: str | None = None
extra: dict[str, Any] = field(default_factory=dict)
def finalize(self) -> Any:
if self.block_type == ContentType.TEXT:
return TextBlock(text=self.text, extra=self.extra)
if self.block_type == ContentType.TOOL_USE:
tool_input: dict[str, Any] = {}
raw = self.tool_args_json.strip()
if raw:
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
tool_input = parsed
except Exception:
tool_input = {}
return ToolUseBlock(
tool_id=str(self.tool_id or ""),
tool_name=str(self.tool_name or ""),
tool_input=tool_input,
extra=self.extra,
)
if self.block_type == ContentType.IMAGE:
return ImageBlock(
data=self.image_data,
media_type=self.image_media_type,
url=None,
extra=self.extra,
)
# Unknown block type: best-effort drop.
return TextBlock(text=self.text, extra=self.extra)
class InternalStreamAggregator:
"""Aggregate internal stream events into a single InternalResponse (best-effort)."""
def __init__(
self,
*,
fallback_id: str = "resp",
fallback_model: str = "",
) -> None:
self._fallback_id = fallback_id
self._fallback_model = fallback_model
self._id: str | None = None
self._model: str | None = None
self._stop_reason: StopReason | None = None
self._usage: UsageInfo | None = None
self._open: dict[int, _BlockBuilder] = {}
self._final: dict[int, Any] = {}
def feed(self, events: Iterable[InternalStreamEvent]) -> None:
for ev in events:
if isinstance(ev, MessageStartEvent):
if ev.message_id:
self._id = ev.message_id
if ev.model:
self._model = ev.model
if ev.usage:
self._usage = ev.usage
continue
if isinstance(ev, UsageEvent):
if ev.usage:
self._usage = ev.usage
continue
if isinstance(ev, ContentBlockStartEvent):
b = _BlockBuilder(block_type=ev.block_type, extra=dict(ev.extra or {}))
if ev.block_type == ContentType.TOOL_USE:
b.tool_id = ev.tool_id
b.tool_name = ev.tool_name
if ev.block_type == ContentType.IMAGE:
b.image_data = b.extra.get("image_data") or b.extra.get("data")
b.image_media_type = b.extra.get("image_media_type") or b.extra.get("mime_type")
self._open[int(ev.block_index)] = b
continue
if isinstance(ev, ContentDeltaEvent):
idx = int(ev.block_index)
b = self._open.get(idx)
if b is None:
b = _BlockBuilder(block_type=ContentType.TEXT)
self._open[idx] = b
if ev.text_delta:
b.text += ev.text_delta
continue
if isinstance(ev, ToolCallDeltaEvent):
idx = int(ev.block_index)
b = self._open.get(idx)
if b is None:
b = _BlockBuilder(block_type=ContentType.TOOL_USE)
self._open[idx] = b
if ev.input_delta:
b.tool_args_json += ev.input_delta
continue
if isinstance(ev, ContentBlockStopEvent):
idx = int(ev.block_index)
b = self._open.pop(idx, None)
if b is not None:
self._final.setdefault(idx, b.finalize())
continue
if isinstance(ev, MessageStopEvent):
self._stop_reason = ev.stop_reason
if ev.usage:
self._usage = ev.usage
# Flush remaining open blocks (best-effort).
for idx, b in list(self._open.items()):
self._final.setdefault(idx, b.finalize())
self._open.clear()
continue
def build(self) -> InternalResponse:
rid = self._id or self._fallback_id
model = self._model or self._fallback_model
content = [self._final[k] for k in sorted(self._final.keys())]
return InternalResponse(
id=str(rid or "resp"),
model=str(model or ""),
content=content,
stop_reason=self._stop_reason,
usage=self._usage,
)
def iter_internal_response_as_stream_events(
internal: InternalResponse,
*,
chunk_text: bool = False,
text_chunk_size: int = 200,
) -> Iterator[InternalStreamEvent]:
"""Expand an InternalResponse into internal stream events (best-effort).
This is used to simulate SSE when the upstream is forced to sync mode.
"""
msg_id = str(internal.id or "resp")
model = str(internal.model or "")
yield MessageStartEvent(message_id=msg_id, model=model)
block_index = 0
for block in internal.content or []:
# Text
if isinstance(block, TextBlock):
yield ContentBlockStartEvent(block_index=block_index, block_type=ContentType.TEXT)
text = str(block.text or "")
if not chunk_text or text_chunk_size <= 0:
if text:
yield ContentDeltaEvent(block_index=block_index, text_delta=text)
else:
for i in range(0, len(text), text_chunk_size):
part = text[i : i + text_chunk_size]
if part:
yield ContentDeltaEvent(block_index=block_index, text_delta=part)
yield ContentBlockStopEvent(block_index=block_index)
block_index += 1
continue
# Tool use
if isinstance(block, ToolUseBlock):
tool_id = block.tool_id or f"tool_{block_index}"
yield ContentBlockStartEvent(
block_index=block_index,
block_type=ContentType.TOOL_USE,
tool_id=tool_id,
tool_name=block.tool_name or None,
)
payload = {}
if isinstance(block.tool_input, dict):
payload = block.tool_input
yield ToolCallDeltaEvent(
block_index=block_index,
tool_id=str(tool_id),
input_delta=json.dumps(payload, ensure_ascii=False),
)
yield ContentBlockStopEvent(block_index=block_index)
block_index += 1
continue
# Image
if isinstance(block, ImageBlock):
yield ContentBlockStartEvent(
block_index=block_index,
block_type=ContentType.IMAGE,
extra={
"image_data": block.data,
"image_media_type": block.media_type,
},
)
yield ContentBlockStopEvent(block_index=block_index)
block_index += 1
continue
# Unknown blocks: ignore.
block_index += 1
yield MessageStopEvent(
stop_reason=internal.stop_reason or StopReason.END_TURN, usage=internal.usage
)
__all__ = [
"InternalStreamAggregator",
"iter_internal_response_as_stream_events",
]

View File

@@ -80,3 +80,36 @@ format_conversion_duration_seconds = Histogram(
["direction", "source_format", "target_format"],
buckets=[0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0],
)
# ==================== Billing migration / shadow billing ====================
billing_requests_total = Counter(
"billing_requests_total",
"Total number of billing calculations",
["engine_mode", "truth_engine"], # low-cardinality labels
)
billing_fallback_total = Counter(
"billing_fallback_total",
"Total number of billing fallbacks to legacy engine",
)
billing_diff_exceeds_threshold_total = Counter(
"billing_diff_exceeds_threshold_total",
"Total number of shadow billing diffs exceeding threshold",
["engine_mode"],
)
billing_invariant_violation_total = Counter(
"billing_invariant_violation_total",
"Total number of billing invariant violations (sum(breakdown)!=total)",
["engine_mode", "truth_engine"],
)
# ==================== Antigravity ====================
antigravity_degradation_total = Counter(
"aether_antigravity_degradation_total",
"Count of Antigravity signature degradation (rectification) events",
["stage", "model"],
)

View File

@@ -348,6 +348,25 @@ async def enrich_auth_config(
)
if email:
auth_config["email"] = email
# Antigravity: project_id 需要通过 /v1internal:loadCodeAssist 获取
if provider_type == "antigravity":
if not auth_config.get("project_id"):
try:
from src.services.antigravity.client import load_code_assist
code_assist = await load_code_assist(access_token, proxy_config=proxy_config)
project_id = code_assist.get("cloudaicompanionProject")
if isinstance(project_id, str) and project_id:
auth_config["project_id"] = project_id
tier_obj = code_assist.get("currentTier")
if isinstance(tier_obj, dict):
tier_type = tier_obj.get("tierType")
if isinstance(tier_type, str) and tier_type:
auth_config["tier"] = tier_type
except Exception as e:
logger.warning(f"Failed to load code assist: {e}")
return auth_config
return auth_config

View File

@@ -15,6 +15,7 @@ from __future__ import annotations
from dataclasses import dataclass
from src.core.provider_templates.types import ProviderType
from src.services.antigravity.constants import PROD_BASE_URL as ANTIGRAVITY_PROD_URL
@dataclass(frozen=True, slots=True)
@@ -102,7 +103,7 @@ FIXED_PROVIDERS: dict[ProviderType, FixedProviderTemplate] = {
ProviderType.ANTIGRAVITY: FixedProviderTemplate(
provider_type=ProviderType.ANTIGRAVITY,
display_name="Antigravity",
api_base_url="https://cloudcode-pa.googleapis.com",
api_base_url=ANTIGRAVITY_PROD_URL,
endpoint_signatures=["gemini:cli"],
oauth=FixedProviderOAuth(
authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
@@ -117,7 +118,7 @@ FIXED_PROVIDERS: dict[ProviderType, FixedProviderTemplate] = {
"https://www.googleapis.com/auth/experimentsandconfigs",
],
redirect_uri="http://localhost:51121/oauth2callback",
use_pkce=False,
use_pkce=True,
),
),
}

View File

@@ -0,0 +1 @@
"""Antigravity integration package."""

View File

@@ -0,0 +1,75 @@
"""Antigravity API 客户端(最小封装)。"""
from __future__ import annotations
from typing import Any
from src.clients.http_client import HTTPClientPool
from src.services.antigravity.constants import (
DAILY_BASE_URL,
HTTP_USER_AGENT,
PROD_BASE_URL,
)
from src.services.antigravity.url_availability import url_availability
async def load_code_assist(
access_token: str,
proxy_config: dict[str, Any] | None = None,
*,
timeout_seconds: float = 10.0,
) -> dict[str, Any]:
"""调用 /v1internal:loadCodeAssist 获取账户信息。
注意:
- email 需通过 Google userinfo API 获取(由 enrich_auth_config 复用已有逻辑)
- 这里仅负责 project_id / tier 等信息
- 使用 url_availability 决定优先尝试的 URL
"""
if not access_token:
raise ValueError("missing access_token")
client = await HTTPClientPool.get_proxy_client(proxy_config)
headers = {
"Authorization": f"Bearer {access_token}",
"User-Agent": HTTP_USER_AGENT,
"Content-Type": "application/json",
"Accept": "application/json",
}
body = {"metadata": {"ideType": "ANTIGRAVITY"}}
# 使用可用性排序prod 优先,但会参考历史成功/失败记录)
urls = url_availability.get_ordered_urls(prefer_daily=False)
if not urls:
urls = [PROD_BASE_URL, DAILY_BASE_URL]
last_exc: Exception | None = None
for base_url in urls:
try:
resp = await client.post(
f"{base_url}/v1internal:loadCodeAssist",
json=body,
headers=headers,
timeout=timeout_seconds,
)
if 200 <= resp.status_code < 300:
url_availability.mark_success(base_url)
data = resp.json()
return data if isinstance(data, dict) else {}
# 非 2xx标记不可用并继续 fallback
if resp.status_code in (429, 500, 502, 503, 504):
url_availability.mark_unavailable(base_url)
last_exc = RuntimeError(
f"loadCodeAssist failed: status={resp.status_code} base_url={base_url}"
)
except Exception as e:
url_availability.mark_unavailable(base_url)
last_exc = e
continue
raise last_exc or RuntimeError("loadCodeAssist failed")
__all__ = ["load_code_assist"]

View File

@@ -0,0 +1,40 @@
"""Antigravity 全局常量定义。
注意:这里的 PROVIDER_TYPE 指的是 Provider.provider_type用于路由与特判
不是 endpoint signaturefamily:kind
"""
from __future__ import annotations
# ============== Provider 标识 ==============
PROVIDER_TYPE = "antigravity"
# ============== API 端点 ==============
PROD_BASE_URL = "https://cloudcode-pa.googleapis.com"
DAILY_BASE_URL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
# ============== User-Agent ==============
# HTTP Header
HTTP_USER_AGENT = "antigravity/1.15.8 windows/amd64"
# V1InternalRequest.userAgent 字段
REQUEST_USER_AGENT = "antigravity"
# ============== URL 可用性 ==============
URL_UNAVAILABLE_TTL_SECONDS = 300 # 5 分钟
# ============== Thinking Signature ==============
DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
# ============== v1internal 路径 ==============
V1INTERNAL_PATH_TEMPLATE = "/v1internal:{action}"
__all__ = [
"DAILY_BASE_URL",
"DUMMY_THOUGHT_SIGNATURE",
"HTTP_USER_AGENT",
"PROD_BASE_URL",
"PROVIDER_TYPE",
"REQUEST_USER_AGENT",
"URL_UNAVAILABLE_TTL_SECONDS",
"V1INTERNAL_PATH_TEMPLATE",
]

View File

@@ -0,0 +1,177 @@
"""Antigravity v1internal request/response envelope helpers.
Antigravity reuses the `gemini:cli` endpoint signature but wraps the actual
wire format:
- Request: V1InternalRequest (top-level metadata + nested GeminiRequest)
- Response: V1InternalResponse (top-level responseId + nested GeminiResponse)
We keep this logic isolated so other providers can reuse the same envelope hook
pattern.
"""
from __future__ import annotations
import uuid
from typing import Any
from src.services.antigravity.constants import HTTP_USER_AGENT as ANTIGRAVITY_HTTP_USER_AGENT
from src.services.antigravity.constants import REQUEST_USER_AGENT as ANTIGRAVITY_REQUEST_USER_AGENT
from src.services.antigravity.url_availability import url_availability
from src.services.provider.request_context import get_selected_base_url
def wrap_v1internal_request(
gemini_request: dict[str, Any],
*,
project_id: str,
model: str,
) -> dict[str, Any]:
"""Wrap a GeminiRequest into Antigravity V1InternalRequest.
Note: Antigravity expects `model` at top-level; the nested request must not
include `model` again.
"""
inner_request = dict(gemini_request)
inner_request.pop("model", None)
return {
"project": project_id,
"requestId": str(uuid.uuid4()),
"userAgent": ANTIGRAVITY_REQUEST_USER_AGENT,
"requestType": "agent",
"model": model,
"request": inner_request,
}
def unwrap_v1internal_response(response: dict[str, Any]) -> dict[str, Any]:
"""Unwrap Antigravity V1InternalResponse into a GeminiResponse-like dict."""
inner = response.get("response")
if isinstance(inner, dict):
unwrapped = dict(inner)
resp_id = response.get("responseId")
if resp_id is not None:
unwrapped["_v1internal_response_id"] = resp_id
return unwrapped
return response
def cache_thought_signatures(model: str, response: dict[str, Any]) -> None:
"""Best-effort cache for Antigravity thought signatures."""
try:
from src.services.antigravity.signature_cache import signature_cache
except Exception:
return
try:
candidates = response.get("candidates")
if not isinstance(candidates, list):
return
for cand in candidates:
if not isinstance(cand, dict):
continue
content = cand.get("content")
if not isinstance(content, dict):
continue
parts = content.get("parts")
if not isinstance(parts, list):
continue
for part in parts:
if not isinstance(part, dict):
continue
text = part.get("text")
if not isinstance(text, str) or not text:
continue
sig = (
part.get("thoughtSignature")
or part.get("thought_signature")
or part.get("signature")
)
if not isinstance(sig, str) or not sig:
continue
signature_cache.cache(model, text, sig)
except Exception:
# Never fail request path due to cache issues.
return
class AntigravityV1InternalEnvelope:
"""Provider envelope hooks for Antigravity v1internal wrapper."""
name = "antigravity:v1internal"
def extra_headers(self) -> dict[str, str] | None:
return {"User-Agent": ANTIGRAVITY_HTTP_USER_AGENT}
def wrap_request(
self,
request_body: dict[str, Any],
*,
model: str,
url_model: str | None,
decrypted_auth_config: dict[str, Any] | None,
) -> tuple[dict[str, Any], str | None]:
project_id = (decrypted_auth_config or {}).get("project_id")
if not isinstance(project_id, str) or not project_id:
from src.core.exceptions import ProviderNotAvailableException
raise ProviderNotAvailableException(
"Antigravity OAuth 配置缺少 project_id请重新授权",
provider_name="antigravity",
upstream_response="missing auth_config.project_id",
)
wrapped = wrap_v1internal_request(
request_body,
project_id=project_id,
model=model,
)
# Antigravity's model lives in the request body, not the URL path.
return wrapped, None
def unwrap_response(self, data: Any) -> Any:
if isinstance(data, dict):
return unwrap_v1internal_response(data)
return data
def postprocess_unwrapped_response(self, *, model: str, data: Any) -> None:
if isinstance(data, dict):
cache_thought_signatures(model, data)
def capture_selected_base_url(self) -> str | None:
return get_selected_base_url()
def on_http_status(self, *, base_url: str | None, status_code: int) -> None:
if not base_url:
return
if status_code == 200:
url_availability.mark_success(base_url)
elif status_code in (429, 500, 502, 503, 504):
url_availability.mark_unavailable(base_url)
def on_connection_error(self, *, base_url: str | None, exc: Exception) -> None: # noqa: ARG002
if not base_url:
return
url_availability.mark_unavailable(base_url)
def force_stream_rewrite(self) -> bool:
# Streaming must be rewritten even when endpoint signature matches, because
# Antigravity wraps chunks in v1internal envelope.
return True
antigravity_v1internal_envelope = AntigravityV1InternalEnvelope()
__all__ = [
"AntigravityV1InternalEnvelope",
"antigravity_v1internal_envelope",
"cache_thought_signatures",
"unwrap_v1internal_response",
"wrap_v1internal_request",
]

View File

@@ -0,0 +1,58 @@
"""Antigravity thinking block signature cache (minimal)."""
from __future__ import annotations
import hashlib
import threading
from src.services.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
class ThinkingSignatureCache:
"""缓存 thinking block 签名。
说明:
- 优先使用缓存(比客户端透传更可靠)
- Gemini 模型允许使用 dummy signature 作为兜底(跳过验证)
- 线程安全
"""
def __init__(self, maxsize: int = 1000) -> None:
self._cache: dict[str, str] = {}
self._maxsize = maxsize
self._lock = threading.Lock()
def get_or_dummy(self, model: str, thinking_text: str) -> str | None:
key = self._key(model, thinking_text)
with self._lock:
cached = self._cache.get(key)
if cached:
return cached
if str(model).startswith("gemini-"):
return DUMMY_THOUGHT_SIGNATURE
return None
def cache(self, model: str, thinking_text: str, signature: str) -> None:
key = self._key(model, thinking_text)
with self._lock:
if key in self._cache:
self._cache[key] = signature
return
if len(self._cache) >= self._maxsize:
# 简单 FIFO 清理dict 保持插入顺序)
evict_n = max(1, self._maxsize // 4)
for k in list(self._cache.keys())[:evict_n]:
self._cache.pop(k, None)
self._cache[key] = signature
def _key(self, model: str, thinking_text: str) -> str:
content = f"{model}:{thinking_text}"
return hashlib.sha256(content.encode("utf-8")).hexdigest()[:32]
signature_cache = ThinkingSignatureCache()
__all__ = ["ThinkingSignatureCache", "signature_cache"]

View File

@@ -0,0 +1,78 @@
"""Antigravity URL 可用性管理(带 TTL 自动恢复)。"""
from __future__ import annotations
import threading
import time
from src.services.antigravity.constants import (
DAILY_BASE_URL,
PROD_BASE_URL,
URL_UNAVAILABLE_TTL_SECONDS,
)
class URLAvailability:
"""管理 Antigravity API 端点可用性(进程内)。"""
_instance: "URLAvailability | None" = None
_lock = threading.Lock()
def __new__(cls) -> "URLAvailability":
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._init()
return cls._instance
def _init(self) -> None:
self._unavailable: dict[str, float] = {} # url -> recover_at(ts)
self._last_success: str | None = None
self._mu = threading.RLock()
def _prune(self, now: float | None = None) -> None:
now_ts = time.time() if now is None else now
self._unavailable = {u: t for u, t in self._unavailable.items() if t > now_ts}
def is_available(self, url: str) -> bool:
with self._mu:
self._prune()
return url not in self._unavailable
def get_ordered_urls(self, *, prefer_daily: bool = True) -> list[str]:
"""返回优先级排序的可用 URL 列表。
- 默认 daily 优先(通常限流更宽松)
- 最近成功的 URL 会被提升到最前
- 若全部被标记不可用,则返回 base_order允许继续尝试等待 TTL 自动恢复)
"""
with self._mu:
self._prune()
base_order = (
[DAILY_BASE_URL, PROD_BASE_URL] if prefer_daily else [PROD_BASE_URL, DAILY_BASE_URL]
)
if self._last_success and self._last_success in base_order:
base_order.remove(self._last_success)
base_order.insert(0, self._last_success)
available = [u for u in base_order if u not in self._unavailable]
return available if available else base_order
def mark_success(self, url: str) -> None:
with self._mu:
self._last_success = url
self._unavailable.pop(url, None)
def mark_unavailable(self, url: str) -> None:
with self._mu:
self._unavailable[url] = time.time() + URL_UNAVAILABLE_TTL_SECONDS
if self._last_success == url:
self._last_success = None
url_availability = URLAvailability()
__all__ = ["URLAvailability", "url_availability"]

View File

@@ -0,0 +1,3 @@
"""Codex provider integration package."""
__all__ = []

View File

@@ -0,0 +1,78 @@
"""Codex upstream envelope hooks.
Codex OAuth upstreams (e.g. `chatgpt.com/backend-api/codex`) behave like the OpenAI
Responses API (`openai:cli`) but may require additional transport-level headers
to avoid upstream blocks (Cloudflare, etc.).
Request/response shape quirks should live in the conversion layer as a same-format
variant (`target_variant="codex"` in the `openai:cli` normalizer). This envelope
only adds headers and keeps the rest as a no-op wrapper.
"""
from __future__ import annotations
import uuid
from typing import Any
from src.config.settings import config
from src.services.provider.request_context import get_selected_base_url
class CodexOAuthEnvelope:
"""Provider envelope hooks for Codex OAuth upstream."""
name = "codex:oauth"
def extra_headers(self) -> dict[str, str] | None:
# These headers are best-effort: Codex upstream is stricter than public OpenAI API.
# Keep them provider-scoped (via ProviderEnvelope) to avoid leaking to other upstreams.
headers: dict[str, str] = {
"x-oai-web-search-eligible": "true",
"session_id": str(uuid.uuid4()),
"originator": "codex_cli_rs",
# Ensure SSE is returned when upstream is forced to streaming mode.
"Accept": "text/event-stream",
}
ua = str(getattr(config, "internal_user_agent_openai_cli", "") or "").strip()
if ua:
headers["User-Agent"] = ua
return headers
def wrap_request(
self,
request_body: dict[str, Any],
*,
model: str,
url_model: str | None,
decrypted_auth_config: dict[str, Any] | None,
) -> tuple[dict[str, Any], str | None]:
# No wire envelope for Codex; keep request body as-is.
_ = model, decrypted_auth_config
return request_body, url_model
def unwrap_response(self, data: Any) -> Any:
# No response envelope for Codex.
return data
def postprocess_unwrapped_response(self, *, model: str, data: Any) -> None: # noqa: ARG002
return
def capture_selected_base_url(self) -> str | None:
# Keep interface consistent with Antigravity. Transport currently doesn't set this for Codex.
return get_selected_base_url()
def on_http_status(self, *, base_url: str | None, status_code: int) -> None: # noqa: ARG002
return
def on_connection_error(self, *, base_url: str | None, exc: Exception) -> None: # noqa: ARG002
return
def force_stream_rewrite(self) -> bool:
return False
codex_oauth_envelope = CodexOAuthEnvelope()
__all__ = ["CodexOAuthEnvelope", "codex_oauth_envelope"]

View File

@@ -13,6 +13,7 @@ Thinking 整流器Rectifier
"""
import copy
import json
from typing import Any
from src.core.logger import logger
@@ -67,6 +68,103 @@ class ThinkingRectifier:
return rectified_body, modified
@staticmethod
def rectify_signature_sensitive_blocks(
request_body: dict[str, Any],
) -> tuple[dict[str, Any], bool]:
"""Second-stage rectification for signature-related failures.
This is a more aggressive fallback than `rectify()`:
- Removes all thinking/redacted_thinking blocks
- Removes signature fields on remaining blocks
- Degrades tool_use/tool_result blocks into plain text blocks
- Disables top-level `thinking` when enabled
"""
if not request_body:
return request_body, False
rectified_body = copy.deepcopy(request_body)
modified = False
messages = rectified_body.get("messages", [])
if isinstance(messages, list) and messages:
new_messages: list[Any] = []
for message in messages:
if not isinstance(message, dict):
new_messages.append(message)
continue
new_message = dict(message)
content = message.get("content")
if isinstance(content, list):
new_content: list[Any] = []
for block in content:
if not isinstance(block, dict):
new_content.append(block)
continue
block_type = block.get("type")
if block_type in ("thinking", "redacted_thinking"):
modified = True
continue
if block_type == "tool_use":
# Degrade into text to avoid strict structure/signature validation.
name = block.get("name")
inp = block.get("input")
try:
inp_text = json.dumps(inp, ensure_ascii=False)
except Exception:
inp_text = str(inp)
new_content.append(
{
"type": "text",
"text": f"[tool_use] name={name} input={inp_text}",
}
)
modified = True
continue
if block_type == "tool_result":
raw = block.get("content")
try:
raw_text = json.dumps(raw, ensure_ascii=False)
except Exception:
raw_text = str(raw)
new_content.append(
{
"type": "text",
"text": f"[tool_result] {raw_text}",
}
)
modified = True
continue
# Remove signature field (for any non-thinking block).
if "signature" in block:
new_block = {k: v for k, v in block.items() if k != "signature"}
new_content.append(new_block)
modified = True
continue
new_content.append(block)
new_message["content"] = new_content
new_messages.append(new_message)
rectified_body["messages"] = new_messages
# Stage-2: disable top-level thinking unconditionally when enabled.
thinking_param = rectified_body.get("thinking")
if isinstance(thinking_param, dict) and thinking_param.get("type") == "enabled":
del rectified_body["thinking"]
modified = True
logger.info("ThinkingRectifier(stage2): 已移除顶层 thinking 参数")
return rectified_body, modified
@staticmethod
def _rectify_messages(messages: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], bool]:
"""

View File

@@ -176,6 +176,9 @@ class ErrorClassifier:
"expected `thinking`, found", # 带反引号变体
"expected redacted_thinking, found",
"expected `redacted_thinking`, found",
# Antigravity / Gemini-internal: thought signature validation
"thoughtsignature",
"thought_signature",
)
def _parse_error_response(self, error_text: str | None) -> dict[str, Any]:

View File

@@ -0,0 +1,48 @@
"""Provider behavior resolver.
Keep provider-specific quirks centralized so handler code stays generic.
Concepts:
- envelope: wire-level request/response wrappers and transport side-effects
- same_format_variant: subtle same-format differences (e.g. Codex)
- cross_format_variant: cross-format conversion tweaks (e.g. Antigravity thinking blocks)
"""
from __future__ import annotations
from dataclasses import dataclass
from src.services.provider.envelope import ProviderEnvelope, get_provider_envelope
@dataclass(frozen=True, slots=True)
class ProviderBehavior:
provider_type: str
envelope: ProviderEnvelope | None
same_format_variant: str | None
cross_format_variant: str | None
def get_provider_behavior(
*,
provider_type: str | None,
endpoint_sig: str | None,
) -> ProviderBehavior:
pt = str(provider_type or "").strip().lower()
envelope = get_provider_envelope(provider_type=pt, endpoint_sig=endpoint_sig)
# same-format variant: apply on top of passthrough (e.g. OpenAI Responses -> Codex quirks)
same_format_variant = pt if pt in {"codex"} else None
# cross-format variant: apply during format conversion (e.g. Claude thinking -> Gemini thought parts)
cross_format_variant = pt if pt in {"codex", "antigravity"} else None
return ProviderBehavior(
provider_type=pt,
envelope=envelope,
same_format_variant=same_format_variant,
cross_format_variant=cross_format_variant,
)
__all__ = ["ProviderBehavior", "get_provider_behavior"]

View File

@@ -0,0 +1,81 @@
"""Provider request/response envelope hooks.
Some upstreams expose an API that is *almost* compatible with an existing
endpoint signature (family:kind), but wrap the wire format in an extra envelope
or require small transport-level behaviors.
This module provides a small hook mechanism so handlers can stay generic while
provider-specific envelopes live in their own service modules.
"""
from __future__ import annotations
from typing import Any, Protocol
class ProviderEnvelope(Protocol):
"""Provider-specific envelope transformation and side-effects."""
name: str
def extra_headers(self) -> dict[str, str] | None:
"""Extra upstream request headers to merge into the RequestBuilder."""
def wrap_request(
self,
request_body: dict[str, Any],
*,
model: str,
url_model: str | None,
decrypted_auth_config: dict[str, Any] | None,
) -> tuple[dict[str, Any], str | None]:
"""Wrap request payload and optionally override url_model (e.g. move model into body)."""
def unwrap_response(self, data: Any) -> Any:
"""Unwrap upstream response payload (streaming chunk or full JSON)."""
def postprocess_unwrapped_response(self, *, model: str, data: Any) -> None:
"""Best-effort post processing after unwrap (e.g. cache signatures)."""
def capture_selected_base_url(self) -> str | None:
"""Capture the base_url selected by transport layer (if any)."""
def on_http_status(self, *, base_url: str | None, status_code: int) -> None:
"""Called after receiving upstream HTTP status code."""
def on_connection_error(self, *, base_url: str | None, exc: Exception) -> None:
"""Called when a connection-type exception happens."""
def force_stream_rewrite(self) -> bool:
"""Whether streaming should always go through the rewrite/conversion path."""
def get_provider_envelope(
*,
provider_type: str | None,
endpoint_sig: str | None,
) -> ProviderEnvelope | None:
"""Return envelope hooks for the given provider_type + endpoint signature."""
pt = str(provider_type or "").strip().lower()
sig = str(endpoint_sig or "").strip().lower()
if not pt:
return None
# Antigravity wraps Gemini CLI responses in a v1internal envelope.
if pt == "antigravity" and (sig == "gemini:cli" or not sig):
from src.services.antigravity.envelope import antigravity_v1internal_envelope
return antigravity_v1internal_envelope
# Codex OAuth upstream requires a few fixed headers (SSE, session id, etc.).
if pt == "codex" and (sig == "openai:cli" or not sig):
from src.services.codex.envelope import codex_oauth_envelope
return codex_oauth_envelope
return None
__all__ = ["ProviderEnvelope", "get_provider_envelope"]

View File

@@ -0,0 +1,28 @@
"""Per-request context shared across layers.
We use `contextvars` so the transport layer (URL builder) can pass small bits of
state to the handler layer without changing existing return types.
This is intentionally minimal; only add fields that are safe and cheap to carry
per request.
"""
from __future__ import annotations
import contextvars
_selected_base_url: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"provider_selected_base_url",
default=None,
)
def set_selected_base_url(url: str | None) -> None:
_selected_base_url.set(url)
def get_selected_base_url() -> str | None:
return _selected_base_url.get()
__all__ = ["get_selected_base_url", "set_selected_base_url"]

View File

@@ -0,0 +1,139 @@
"""Upstream streaming execution policy (per endpoint).
This is about how we talk to the upstream provider, not what the client asked for.
Motivation:
- Some upstreams require streaming only (e.g. Codex Responses OAuth endpoint).
- Some upstreams do not support streaming (or are flaky with SSE).
We allow forcing upstream request mode per ProviderEndpoint, while the gateway still
returns what the client requested by doing internal sync<->stream bridging.
"""
from __future__ import annotations
from enum import Enum
from typing import Any
from src.core.api_format.metadata import resolve_endpoint_definition
class UpstreamStreamPolicy(str, Enum):
AUTO = "auto" # follow client request
FORCE_STREAM = "force_stream"
FORCE_NON_STREAM = "force_non_stream"
def parse_upstream_stream_policy(value: Any) -> UpstreamStreamPolicy:
if value is None:
return UpstreamStreamPolicy.AUTO
if isinstance(value, bool):
return UpstreamStreamPolicy.FORCE_STREAM if value else UpstreamStreamPolicy.FORCE_NON_STREAM
raw = str(value).strip().lower()
if raw in {"", "auto", "follow", "client", "default"}:
return UpstreamStreamPolicy.AUTO
if raw in {"force_stream", "stream", "sse", "true", "1", "yes"}:
return UpstreamStreamPolicy.FORCE_STREAM
if raw in {"force_non_stream", "force_sync", "non_stream", "sync", "false", "0", "no"}:
return UpstreamStreamPolicy.FORCE_NON_STREAM
return UpstreamStreamPolicy.AUTO
def get_upstream_stream_policy(
endpoint: Any,
*,
provider_type: str | None = None,
endpoint_sig: str | None = None,
) -> UpstreamStreamPolicy:
"""Resolve policy for an endpoint.
Config source: endpoint.config["upstream_stream_policy"] (preferred).
Defaults:
- Codex + openai:cli: FORCE_STREAM (Codex upstream requires stream=true).
"""
provider_obj = getattr(endpoint, "provider", None)
pt = str(provider_type or getattr(provider_obj, "provider_type", "") or "").strip().lower()
sig = str(endpoint_sig or getattr(endpoint, "api_format", "") or "").strip().lower()
# Explicit config wins (unless upstream has a hard constraint).
cfg = getattr(endpoint, "config", None)
if isinstance(cfg, dict):
val = (
cfg.get("upstream_stream_policy")
or cfg.get("upstreamStreamPolicy")
or cfg.get("upstream_stream")
)
parsed = parse_upstream_stream_policy(val)
if parsed != UpstreamStreamPolicy.AUTO:
# Codex upstream requires streaming; do not allow forcing non-stream.
if (
pt == "codex"
and sig == "openai:cli"
and parsed == UpstreamStreamPolicy.FORCE_NON_STREAM
):
return UpstreamStreamPolicy.FORCE_STREAM
return parsed
# Safe-by-default: Codex Responses OAuth behaves like SSE-only.
if pt == "codex" and sig == "openai:cli":
return UpstreamStreamPolicy.FORCE_STREAM
return UpstreamStreamPolicy.AUTO
def resolve_upstream_is_stream(
*,
client_is_stream: bool,
policy: UpstreamStreamPolicy,
) -> bool:
if policy == UpstreamStreamPolicy.FORCE_STREAM:
return True
if policy == UpstreamStreamPolicy.FORCE_NON_STREAM:
return False
return bool(client_is_stream)
def enforce_stream_mode_for_upstream(
request_body: dict[str, Any],
*,
provider_api_format: str,
upstream_is_stream: bool,
) -> dict[str, Any]:
"""Force upstream stream/sync mode in request body (best-effort).
Note: Some formats (Gemini) do not use a `stream` field in body; for those we
remove it to avoid leaking client intent.
"""
meta = resolve_endpoint_definition(provider_api_format)
provider_uses_stream = meta.stream_in_body if meta is not None else True
if provider_uses_stream:
request_body["stream"] = bool(upstream_is_stream)
else:
request_body.pop("stream", None)
# OpenAI Chat Completions: request usage in streaming mode.
provider_fmt = str(provider_api_format or "").strip().lower()
if upstream_is_stream and provider_fmt == "openai:chat":
stream_options = request_body.get("stream_options")
if not isinstance(stream_options, dict):
stream_options = {}
stream_options["include_usage"] = True
request_body["stream_options"] = stream_options
return request_body
__all__ = [
"UpstreamStreamPolicy",
"enforce_stream_mode_for_upstream",
"get_upstream_stream_policy",
"parse_upstream_stream_policy",
"resolve_upstream_is_stream",
]

View File

@@ -19,7 +19,16 @@ from src.core.api_format import (
make_signature_key,
)
from src.core.logger import logger
from src.services.antigravity.constants import PROVIDER_TYPE as ANTIGRAVITY_PROVIDER_TYPE
from src.services.antigravity.constants import (
V1INTERNAL_PATH_TEMPLATE,
)
from src.services.antigravity.url_availability import url_availability
from src.services.provider.format import normalize_endpoint_signature
from src.services.provider.request_context import (
get_selected_base_url,
set_selected_base_url,
)
from src.utils.url_utils import is_codex_url
if TYPE_CHECKING:
@@ -72,6 +81,35 @@ def _normalize_base_url(base_url: str, path: str) -> str:
return base
def get_antigravity_base_url() -> str | None:
"""Backward-compat alias for `get_selected_base_url()`."""
return get_selected_base_url()
def _get_provider_type(endpoint: Any, key: "ProviderAPIKey" | None = None) -> str | None:
"""尽力获取 Provider.provider_type用于 Antigravity 等 Provider 特判)。"""
try:
provider = getattr(endpoint, "provider", None)
if provider is not None:
pt = getattr(provider, "provider_type", None)
if pt:
return str(pt).lower()
except Exception:
pass
try:
if key is not None:
provider = getattr(key, "provider", None)
if provider is not None:
pt = getattr(provider, "provider_type", None)
if pt:
return str(pt).lower()
except Exception:
pass
return None
def build_provider_url(
endpoint: ProviderEndpoint,
*,
@@ -97,6 +135,9 @@ def build_provider_url(
key: Provider API Key用于 Vertex AI 等需要从密钥配置读取信息的场景)
decrypted_auth_config: 已解密的认证配置(避免重复解密,由 get_provider_auth 提供)
"""
# 默认清理,避免上一次请求的 selected_base_url 泄漏到其他请求
set_selected_base_url(None)
# 检查是否为 Vertex AI 认证类型
auth_type = getattr(key, "auth_type", "api_key") if key else "api_key"
if auth_type == "vertex_ai":
@@ -123,6 +164,55 @@ def build_provider_url(
# endpoint_sig 为空时保持为空(更安全:默认路径回退到 "/",避免误判为 claude:chat
endpoint_sig = normalize_endpoint_signature(endpoint_sig) if endpoint_sig else ""
provider_type = _get_provider_type(endpoint, key)
# 合并查询参数(部分逻辑需要先拿到 query_params
effective_query_params = dict(query_params) if query_params else {}
# Gemini family 下清除可能存在的 key 参数(避免客户端传入的认证信息泄露到上游)
# 上游认证始终使用 header 方式,不使用 URL 参数
if endpoint_sig.startswith("gemini:"):
effective_query_params.pop("key", None)
# Antigravity 特殊处理:复用 gemini:cli endpoint signature但走 v1internal 端点
if provider_type == ANTIGRAVITY_PROVIDER_TYPE and endpoint_sig == "gemini:cli":
ordered_urls = url_availability.get_ordered_urls(prefer_daily=True)
base_url = ordered_urls[0] if ordered_urls else endpoint.base_url # type: ignore[arg-type]
# 存入 contextvars供后续 Handler 层获取)
set_selected_base_url(str(base_url) if base_url is not None else None)
action = "streamGenerateContent" if is_stream else "generateContent"
path = V1INTERNAL_PATH_TEMPLATE.format(action=action)
# v1internal 流式请求同样支持 `?alt=sse`
if is_stream:
effective_query_params.setdefault("alt", "sse")
url = f"{str(base_url).rstrip('/')}{path}"
if effective_query_params:
query_string = urlencode(effective_query_params, doseq=True)
if query_string:
url = f"{url}?{query_string}"
return url
# Codex OAuth upstream (chatgpt.com/backend-api/codex) uses `/responses` instead of `/v1/responses`.
# We special-case this at transport layer so fixed providers work without requiring custom_path.
if provider_type == "codex" and endpoint_sig == "openai:cli" and not endpoint.custom_path:
base = str(endpoint.base_url).rstrip("/")
path = "/responses"
# If user already included the final path in base_url, don't duplicate it.
url = base if base.endswith(path) else f"{base}{path}"
if effective_query_params:
query_string = urlencode(effective_query_params, doseq=True)
if query_string:
url = f"{url}?{query_string}"
return url
# 非 Antigravity清除 contextvar避免跨请求污染
set_selected_base_url(None)
# 准备路径参数Gemini chat/cli 需要 action
effective_path_params = dict(path_params) if path_params else {}
if endpoint_sig.startswith("gemini:"):
@@ -166,17 +256,10 @@ def build_provider_url(
base = _normalize_base_url(endpoint.base_url, path) # type: ignore[arg-type]
url = f"{base}{path}"
# 合并查询参数
effective_query_params = dict(query_params) if query_params else {}
# Gemini family 下清除可能存在的 key 参数(避免客户端传入的认证信息泄露到上游)
# 上游认证始终使用 header 方式,不使用 URL 参数
if endpoint_sig.startswith("gemini:"):
effective_query_params.pop("key", None)
# Gemini streamGenerateContent 官方支持 `?alt=sse` 返回 SSEdata: {...})。
# 网关侧统一使用 SSE 输出,优先向上游请求 SSE 以减少解析分支;同时保留 JSON-array 兜底解析。
if is_stream:
effective_query_params.setdefault("alt", "sse")
# Gemini streamGenerateContent 官方支持 `?alt=sse` 返回 SSEdata: {...})。
# 网关侧统一使用 SSE 输出,优先向上游请求 SSE 以减少解析分支;同时保留 JSON-array 兜底解析。
if endpoint_sig.startswith("gemini:") and is_stream:
effective_query_params.setdefault("alt", "sse")
# 添加查询参数
if effective_query_params:

View File

@@ -549,6 +549,8 @@ class TaskService:
self,
*,
converted_error: Any,
provider_type: str | None,
model_name: str | None,
request_id: str | None,
candidate_record_id: str,
elapsed_ms: int,
@@ -585,32 +587,77 @@ class TaskService:
)
raise converted_error
if request_body_ref.get("_rectified", False):
provider_type_norm = str(provider_type or "").lower()
# Rectification may have multiple stages (Antigravity only).
stage_raw = request_body_ref.get("_rectify_stage", 0)
try:
stage = int(stage_raw or 0)
except Exception:
stage = 0
if stage <= 0 and request_body_ref.get("_rectified", False):
stage = 1
if stage >= 2 or (stage >= 1 and provider_type_norm != "antigravity"):
logger.warning(" [{}] Thinking 错误:已整流仍失败,终止重试", request_id)
self._mark_thinking_error_failed(
candidate_record_id,
converted_error,
elapsed_ms,
captured_key_concurrent,
{**serializable_extra_data, "rectified": True},
{**serializable_extra_data, "rectified": True, "rectify_stage": stage},
)
raise converted_error
request_body = request_body_ref.get("body", {})
rectified_body, modified = ThinkingRectifier.rectify(request_body)
stage_label = "thinking_only"
next_stage = 1
if stage == 0:
rectified_body, modified = ThinkingRectifier.rectify(request_body)
stage_label = "thinking_only"
next_stage = 1
else:
# Stage 2 only applies to Antigravity.
rectified_body, modified = ThinkingRectifier.rectify_signature_sensitive_blocks(
request_body
)
stage_label = "thinking_and_tools"
next_stage = 2
if modified:
request_body_ref["body"] = rectified_body
request_body_ref["_rectified"] = True
request_body_ref["_rectified_this_turn"] = True
request_body_ref["_rectify_stage"] = next_stage
logger.info(" [{}] 请求已整流,在当前候选上重试", request_id)
if provider_type_norm == "antigravity":
try:
from src.core.metrics import antigravity_degradation_total
antigravity_degradation_total.labels(
stage=stage_label,
model=str(model_name or "unknown"),
).inc()
except Exception:
pass
logger.info(
" [{}] 请求已整流(stage={}),在当前候选上重试",
request_id,
next_stage,
)
self._mark_thinking_error_failed(
candidate_record_id,
converted_error,
elapsed_ms,
captured_key_concurrent,
{**serializable_extra_data, "rectified": True},
{
**serializable_extra_data,
"rectified": True,
"rectify_stage": next_stage,
"rectify_stage_label": stage_label,
},
)
return "continue"
@@ -770,6 +817,8 @@ class TaskService:
if isinstance(converted_error, ThinkingSignatureException):
action = self._handle_thinking_signature_error(
converted_error=converted_error,
provider_type=str(getattr(provider, "provider_type", "") or "").lower(),
model_name=str(global_model_id or ""),
request_id=request_id,
candidate_record_id=candidate_record_id,
elapsed_ms=elapsed_ms,