mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat: Antigravity 和 Codex 服务支持
- 新增 Antigravity 服务:签名缓存、URL 可用性检测、信封处理 - 新增 Codex 服务:信封处理、元数据收集器 - 重构 provider transport 支持新的服务架构 - 新增 stream_bridge 和 upstream_stream_bridge 处理流式响应 - 优化 OAuth 工具函数 - 添加相关测试用例
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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=False),data_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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
202
src/api/handlers/base/upstream_stream_bridge.py
Normal file
202
src/api/handlers/base/upstream_stream_bridge.py
Normal 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",
|
||||
]
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
262
src/core/api_format/conversion/stream_bridge.py
Normal file
262
src/core/api_format/conversion/stream_bridge.py
Normal 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",
|
||||
]
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
1
src/services/antigravity/__init__.py
Normal file
1
src/services/antigravity/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Antigravity integration package."""
|
||||
75
src/services/antigravity/client.py
Normal file
75
src/services/antigravity/client.py
Normal 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"]
|
||||
40
src/services/antigravity/constants.py
Normal file
40
src/services/antigravity/constants.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Antigravity 全局常量定义。
|
||||
|
||||
注意:这里的 PROVIDER_TYPE 指的是 Provider.provider_type(用于路由与特判),
|
||||
不是 endpoint signature(family: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",
|
||||
]
|
||||
177
src/services/antigravity/envelope.py
Normal file
177
src/services/antigravity/envelope.py
Normal 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",
|
||||
]
|
||||
58
src/services/antigravity/signature_cache.py
Normal file
58
src/services/antigravity/signature_cache.py
Normal 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"]
|
||||
78
src/services/antigravity/url_availability.py
Normal file
78
src/services/antigravity/url_availability.py
Normal 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"]
|
||||
3
src/services/codex/__init__.py
Normal file
3
src/services/codex/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Codex provider integration package."""
|
||||
|
||||
__all__ = []
|
||||
78
src/services/codex/envelope.py
Normal file
78
src/services/codex/envelope.py
Normal 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"]
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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]:
|
||||
|
||||
48
src/services/provider/behavior.py
Normal file
48
src/services/provider/behavior.py
Normal 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"]
|
||||
81
src/services/provider/envelope.py
Normal file
81
src/services/provider/envelope.py
Normal 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"]
|
||||
28
src/services/provider/request_context.py
Normal file
28
src/services/provider/request_context.py
Normal 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"]
|
||||
139
src/services/provider/stream_policy.py
Normal file
139
src/services/provider/stream_policy.py
Normal 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",
|
||||
]
|
||||
@@ -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` 返回 SSE(data: {...})。
|
||||
# 网关侧统一使用 SSE 输出,优先向上游请求 SSE 以减少解析分支;同时保留 JSON-array 兜底解析。
|
||||
if is_stream:
|
||||
effective_query_params.setdefault("alt", "sse")
|
||||
# Gemini streamGenerateContent 官方支持 `?alt=sse` 返回 SSE(data: {...})。
|
||||
# 网关侧统一使用 SSE 输出,优先向上游请求 SSE 以减少解析分支;同时保留 JSON-array 兜底解析。
|
||||
if endpoint_sig.startswith("gemini:") and is_stream:
|
||||
effective_query_params.setdefault("alt", "sse")
|
||||
|
||||
# 添加查询参数
|
||||
if effective_query_params:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user