feat: Antigravity 和 Codex 服务支持

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

View File

@@ -22,6 +22,15 @@
Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户管理、智能负载均衡、成本配额控制和健康监控能力。通过统一的 API 入口,可以无缝对接 Claude、OpenAI、Gemini 等主流 AI 服务及其 CLI 工具。 Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户管理、智能负载均衡、成本配额控制和健康监控能力。通过统一的 API 入口,可以无缝对接 Claude、OpenAI、Gemini 等主流 AI 服务及其 CLI 工具。
### 内置反代 Provider
- ClaudeCode`provider_type=claude_code`
- Codex`provider_type=codex`
- GeminiCli`provider_type=gemini_cli`
- Antigravity`provider_type=antigravity`
实现细节与差异说明见:[`docs/provider-quirks.md`](docs/provider-quirks.md)
### 页面预览 ### 页面预览
| 首页 | 仪表盘 | | 首页 | 仪表盘 |
@@ -168,4 +177,3 @@ cd frontend && npm install && npm run dev
[![Star History Chart](https://api.star-history.com/svg?repos=fawney19/Aether&type=Date)](https://star-history.com/#fawney19/Aether&Date) [![Star History Chart](https://api.star-history.com/svg?repos=fawney19/Aether&type=Date)](https://star-history.com/#fawney19/Aether&Date)

81
docs/provider-quirks.md Normal file
View File

@@ -0,0 +1,81 @@
# Provider 特殊适配Codex / Antigravity
Aether 内部用两层标识来描述上游:
- **endpoint signaturefamily:kind**:如 `openai:cli``gemini:cli`。决定走哪套协议的解析/格式转换。
- **provider_type**:如 `codex``antigravity`。描述“同格式但上游有细微差异”的变体,或需要额外的 wire envelope / transport 行为。
这两层分离的好处是:**格式体系保持稳定**(不轻易增加新的 family同时又能对特定上游做最小侵入的兼容。
---
## target_variant格式变体
`client_format == provider_format` 但上游存在细微差异时,格式转换注册表支持 `target_variant`
- **Codex**:在 `openai:cli` 请求上做上游兼容修补(例如强制 `stream=true`、补齐 `instructions` 等),见 `src/core/api_format/conversion/normalizers/openai_cli.py`
跨格式转换时也可以携带变体(例如 `claude:chat -> gemini:cli` 且目标上游是 Antigravity用于 thinking block 翻译等,见 `src/core/api_format/conversion/normalizers/gemini.py`
---
## ProviderEnvelopewire envelope / transport hooks
某些上游会在真实 wire format 外再包一层 envelope或需要 transport 层 side-effects例如记录本次选用的 base_url、按状态码更新可用性
Aether 通过 `ProviderEnvelope` 提供最小 hook让 handler 基类保持通用逻辑:
- 入口:`src/services/provider/behavior.py`统一解析行为envelope + variant
- envelope 路由:`src/services/provider/envelope.py`
相关 handler 接入点:
- CLI`src/api/handlers/base/cli_handler_base.py`
- Chat`src/api/handlers/base/chat_handler_base.py` + `src/api/handlers/base/stream_processor.py`
### Antigravity`provider_type=antigravity`
- **endpoint signature**:仍复用 `gemini:cli`
- **URL 路径**transport 层切到 `/v1internal:{action}`,见 `src/services/provider/transport.py`
- **Request/Response**v1internal 包装/解包,见 `src/services/antigravity/envelope.py`
- **base_url**prod/daily 可用性排序 + TTL 自动恢复,见 `src/services/antigravity/url_availability.py`
- **OAuth 元数据**:需要补齐 `project_id`(通过 `/v1internal:loadCodeAssist`),见 `src/services/antigravity/client.py``src/core/provider_oauth_utils.py`
- **thinking**Claude -> Gemini 转换时,把 thinking UnknownBlock 翻译成 Gemini thought part + signature 缓存/降级,见 `src/core/api_format/conversion/normalizers/gemini.py`
### Codex`provider_type=codex`
- 通常与 `openai:cli`Responses API配套
- **upstream URL**`chatgpt.com/backend-api/codex` 需要走 `/responses`(而不是 `/v1/responses`transport 层已做特判,见 `src/services/provider/transport.py`
- **强制 `stream=true`**Codex 上游按当前适配视为 *SSE-only*,默认强制上游 streaming`src/services/provider/stream_policy.py`);同时 `openai:cli` normalizer 的 `target_variant="codex"` 也会做请求修补(强制 `stream=true`、补齐 `instructions`、剔除不兼容字段等),见 `src/core/api_format/conversion/normalizers/openai_cli.py`
- **额外请求头**:运行时通过 Codex envelope 注入SSE Accept、session_id、originator 等),见 `src/services/codex/envelope.py``check_endpoint` 测试请求仍在 adapter 层做同样的 best-effort 注入)
---
## upstream_stream_policy上游流式策略
这是一个**按 Endpoint** 生效的通用开关控制“我们怎么请求上游”stream 还是 sync而不是控制客户端要不要 stream。
配置位置:`ProviderEndpoint.config.upstream_stream_policy`
取值:
- `auto`:跟随客户端(默认)
- `force_stream`:强制上游走 SSE如果客户端是 sync则网关会在内部**聚合 SSE -> sync JSON** 后再返回
- `force_non_stream`:强制上游走 sync如果客户端是 stream则网关会在内部**sync JSON -> streamify** 后再返回
实现位置:
- 策略解析与约束:`src/services/provider/stream_policy.py`
- stream<->sync 桥接InternalResponse 聚合/展开):`src/core/api_format/conversion/stream_bridge.py``src/api/handlers/base/upstream_stream_bridge.py`
注意:
- Codex `provider_type=codex + openai:cli` 被视为上游硬约束 **只能 streaming**,即使显式配置 `force_non_stream` 也会被忽略(返回 `force_stream`)。
## 新增类似上游的建议流程
如果以后还要接入“复用现有 signature但 wire/行为不同”的上游:
1. 先判断是否能用 `target_variant` 解决(仅请求/响应字段差异)。
2. 如果需要 wire envelope 或 transport side-effects新增一个 `ProviderEnvelope` 实现并注册到 `src/services/provider/envelope.py`
3. 如需统一变体策略,在 `src/services/provider/behavior.py` 增加映射(避免 handler 到处写 `if provider_type == ...`)。

View File

@@ -81,7 +81,7 @@
<div class="p-4 space-y-4"> <div class="p-4 space-y-4">
<!-- URL 配置区 --> <!-- URL 配置区 -->
<div class="flex items-end gap-3"> <div class="flex items-end gap-3">
<div class="flex-1 min-w-0 grid grid-cols-3 gap-3"> <div class="flex-1 min-w-0 grid grid-cols-4 gap-3">
<div class="col-span-2 space-y-1.5"> <div class="col-span-2 space-y-1.5">
<Label class="text-xs text-muted-foreground">Base URL</Label> <Label class="text-xs text-muted-foreground">Base URL</Label>
<Input <Input
@@ -100,10 +100,32 @@
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)" @update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
/> />
</div> </div>
<div class="space-y-1.5">
<Label class="text-xs text-muted-foreground">上游流式</Label>
<Select
:model-value="getEndpointEditState(endpoint.id)?.upstreamStreamPolicy ?? getEndpointUpstreamStreamPolicy(endpoint)"
@update:model-value="(v) => updateEndpointStreamPolicy(endpoint.id, v as string)"
>
<SelectTrigger class="h-9 text-xs">
<SelectValue placeholder="跟随客户端" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
跟随客户端
</SelectItem>
<SelectItem value="force_stream">
强制流式
</SelectItem>
<SelectItem value="force_non_stream">
强制非流式
</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
<!-- 保存/撤销按钮URL/路径有修改时显示) --> <!-- 保存/撤销按钮URL/路径有修改时显示) -->
<div <div
v-if="!isFixedProvider && hasUrlChanges(endpoint)" v-if="hasUrlChanges(endpoint)"
class="flex items-center gap-1 shrink-0" class="flex items-center gap-1 shrink-0"
> >
<Button <Button
@@ -521,6 +543,7 @@ interface EditableBodyRule {
interface EndpointEditState { interface EndpointEditState {
url: string url: string
path: string path: string
upstreamStreamPolicy: string
rules: EditableRule[] rules: EditableRule[]
bodyRules: EditableBodyRule[] bodyRules: EditableBodyRule[]
} }
@@ -689,6 +712,19 @@ function isCodexUrl(baseUrl: string): boolean {
return url.includes('/backend-api/codex') || url.endsWith('/codex') return url.includes('/backend-api/codex') || url.endsWith('/codex')
} }
// 读取端点的上游流式策略endpoint.config.upstream_stream_policy
function getEndpointUpstreamStreamPolicy(endpoint: ProviderEndpoint): string {
const cfg = endpoint.config || {}
const raw = (cfg.upstream_stream_policy ?? cfg.upstreamStreamPolicy ?? cfg.upstream_stream) as any
if (raw === null || raw === undefined) return 'auto'
if (typeof raw === 'boolean') return raw ? 'force_stream' : 'force_non_stream'
const s = String(raw).trim().toLowerCase()
if (!s || s === 'auto' || s === 'follow' || s === 'client' || s === 'default') return 'auto'
if (s === 'force_stream' || s === 'stream' || s === 'sse' || s === 'true' || s === '1') return 'force_stream'
if (s === 'force_non_stream' || s === 'force_sync' || s === 'non_stream' || s === 'sync' || s === 'false' || s === '0') return 'force_non_stream'
return 'auto'
}
// 初始化端点的编辑状态 // 初始化端点的编辑状态
function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState { function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState {
const rules: EditableRule[] = [] const rules: EditableRule[] = []
@@ -720,6 +756,7 @@ function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState {
return { return {
url: endpoint.base_url, url: endpoint.base_url,
path: endpoint.custom_path || '', path: endpoint.custom_path || '',
upstreamStreamPolicy: getEndpointUpstreamStreamPolicy(endpoint),
rules, rules,
bodyRules, bodyRules,
} }
@@ -743,6 +780,18 @@ function updateEndpointField(endpointId: string, field: 'url' | 'path', value: s
} }
} }
function updateEndpointStreamPolicy(endpointId: string, value: string) {
if (!endpointEditStates.value[endpointId]) {
const endpoint = localEndpoints.value.find(e => e.id === endpointId)
if (endpoint) {
endpointEditStates.value[endpointId] = initEndpointEditState(endpoint)
}
}
if (endpointEditStates.value[endpointId]) {
endpointEditStates.value[endpointId].upstreamStreamPolicy = value
}
}
// 获取端点的编辑规则 // 获取端点的编辑规则
function getEndpointEditRules(endpointId: string): EditableRule[] { function getEndpointEditRules(endpointId: string): EditableRule[] {
const state = endpointEditStates.value[endpointId] const state = endpointEditStates.value[endpointId]
@@ -1118,6 +1167,7 @@ function hasUrlChanges(endpoint: ProviderEndpoint): boolean {
if (!state) return false if (!state) return false
if (state.url !== endpoint.base_url) return true if (state.url !== endpoint.base_url) return true
if (state.path !== (endpoint.custom_path || '')) return true if (state.path !== (endpoint.custom_path || '')) return true
if (state.upstreamStreamPolicy !== getEndpointUpstreamStreamPolicy(endpoint)) return true
return false return false
} }
@@ -1264,12 +1314,34 @@ async function saveEndpoint(endpoint: ProviderEndpoint) {
savingEndpointId.value = endpoint.id savingEndpointId.value = endpoint.id
try { try {
await updateEndpoint(endpoint.id, { // 仅提交变更字段,避免 fixed provider 因 base_url/custom_path 被锁定而更新失败
base_url: state.url, const payload: Record<string, any> = {}
custom_path: state.path || null,
header_rules: rulesToHeaderRules(state.rules), if (!isFixedProvider.value) {
body_rules: rulesToBodyRules(state.bodyRules), if (state.url !== endpoint.base_url) payload.base_url = state.url
}) if (state.path !== (endpoint.custom_path || '')) payload.custom_path = state.path || null
}
if (hasRulesChanges(endpoint)) payload.header_rules = rulesToHeaderRules(state.rules)
if (hasBodyRulesChanges(endpoint)) payload.body_rules = rulesToBodyRules(state.bodyRules)
// endpoint.config.upstream_stream_policy
if (state.upstreamStreamPolicy !== getEndpointUpstreamStreamPolicy(endpoint)) {
const merged: Record<string, any> = { ...(endpoint.config || {}) }
// Normalize config keys: keep only canonical `upstream_stream_policy`
delete merged.upstream_stream_policy
delete merged.upstreamStreamPolicy
delete merged.upstream_stream
if (state.upstreamStreamPolicy !== 'auto') {
merged.upstream_stream_policy = state.upstreamStreamPolicy
}
payload.config = Object.keys(merged).length > 0 ? merged : null
}
if (Object.keys(payload).length === 0) return
await updateEndpoint(endpoint.id, payload)
success('端点已更新') success('端点已更新')
emit('endpointUpdated') emit('endpointUpdated')
} catch (error: any) { } catch (error: any) {

View File

@@ -43,12 +43,18 @@ from src.api.handlers.base.response_parser import ResponseParser
from src.api.handlers.base.stream_context import StreamContext from src.api.handlers.base.stream_context import StreamContext
from src.api.handlers.base.stream_processor import StreamProcessor from src.api.handlers.base.stream_processor import StreamProcessor
from src.api.handlers.base.stream_telemetry import StreamTelemetryRecorder 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 ( from src.api.handlers.base.utils import (
build_sse_headers, build_sse_headers,
filter_proxy_response_headers, filter_proxy_response_headers,
get_format_converter_registry, get_format_converter_registry,
) )
from src.config.settings import config 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.error_utils import extract_client_error_message
from src.core.exceptions import ( from src.core.exceptions import (
EmbeddedErrorException, EmbeddedErrorException,
@@ -68,6 +74,12 @@ from src.models.database import (
User, User,
) )
from src.services.cache.aware_scheduler import ProviderCandidate 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 ( from src.services.provider.transport import (
build_provider_url, build_provider_url,
get_vertex_ai_effective_format, get_vertex_ai_effective_format,
@@ -760,9 +772,25 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
else: else:
request_body = dict(original_request_body) request_body = dict(original_request_body)
# 确定目标变体(用于 Codex 等需要特殊处理的上游)
provider_type = str(getattr(provider, "provider_type", "") or "").lower() 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 # 跨格式:先做请求体转换(失败触发 failover
registry = get_format_converter_registry() registry = get_format_converter_registry()
@@ -771,7 +799,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
request_body, request_body,
str(client_api_format), str(client_api_format),
str(provider_api_format), str(provider_api_format),
target_variant=target_variant, target_variant=cross_format_variant,
) )
# 格式转换后,为需要 model 字段的格式设置模型名 # 格式转换后,为需要 model 字段的格式设置模型名
self._set_model_after_conversion( self._set_model_after_conversion(
@@ -785,50 +813,223 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
request_body, request_body,
str(client_api_format), str(client_api_format),
str(provider_api_format), str(provider_api_format),
is_stream=True, is_stream=upstream_is_stream,
) )
else: else:
# 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段) # 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段)
request_body = self.prepare_provider_request_body(request_body) request_body = self.prepare_provider_request_body(request_body)
# 同格式时也需要应用 target_variant 转换(如 Codex # 同格式时也需要应用 target_variant 转换(如 Codex
if target_variant: if same_format_variant:
request_body = registry.convert_request( request_body = registry.convert_request(
request_body, request_body,
str(provider_api_format), str(provider_api_format),
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 方式) # 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式)
provider_payload, provider_headers = self._request_builder.build( provider_payload, provider_headers = self._request_builder.build(
request_body, request_body,
original_headers, original_headers,
endpoint, endpoint,
key, 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, 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_headers = provider_headers
ctx.provider_request_body = provider_payload 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( url = build_provider_url(
endpoint, endpoint,
query_params=query_params, query_params=query_params,
path_params={"model": url_model}, path_params={"model": url_model},
is_stream=True, is_stream=upstream_is_stream,
key=key, key=key,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None, 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( logger.debug(
f" [{self.request_id}] 发送流式请求: Provider={provider.name}, " f" [{self.request_id}] 发送流式请求: Provider={provider.name}, "
f"模型={ctx.model} -> {mapped_model or '无映射'}" 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 超时 # 配置 HTTP 超时
# 注意read timeout 用于检测连接断开,不是整体请求超时 # 注意read timeout 用于检测连接断开,不是整体请求超时
# 整体请求超时由 asyncio.wait_for 控制,使用全局配置 # 整体请求超时由 asyncio.wait_for 控制,使用全局配置
@@ -866,6 +1067,11 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
ctx.status_code = stream_response.status_code ctx.status_code = stream_response.status_code
ctx.response_headers = dict(stream_response.headers) 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() stream_response.raise_for_status()
@@ -925,6 +1131,22 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
timeout=int(request_timeout), 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: except httpx.HTTPStatusError as e:
error_text = await self._extract_error_text(e) error_text = await self._extract_error_text(e)
logger.error(f"Provider 返回错误: {e.response.status_code}\n Response: {error_text}") logger.error(f"Provider 返回错误: {e.response.status_code}\n Response: {error_text}")
@@ -1099,9 +1321,25 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
else: else:
request_body = dict(request_body_ref["body"]) request_body = dict(request_body_ref["body"])
# 确定目标变体(用于 Codex 等需要特殊处理的上游)
provider_type = str(getattr(provider, "provider_type", "") or "").lower() 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 # 跨格式:先做请求体转换(失败触发 failover
registry = get_format_converter_registry() registry = get_format_converter_registry()
@@ -1110,7 +1348,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
request_body, request_body,
client_api_format, client_api_format,
provider_api_format, provider_api_format,
target_variant=target_variant, target_variant=cross_format_variant,
) )
# 格式转换后,为需要 model 字段的格式设置模型名 # 格式转换后,为需要 model 字段的格式设置模型名
self._set_model_after_conversion( self._set_model_after_conversion(
@@ -1124,48 +1362,76 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
request_body, request_body,
client_api_format, client_api_format,
provider_api_format, provider_api_format,
is_stream=False, is_stream=upstream_is_stream,
) )
else: else:
# 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段) # 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段)
request_body = self.prepare_provider_request_body(request_body) request_body = self.prepare_provider_request_body(request_body)
# 同格式时也需要应用 target_variant 转换(如 Codex # 同格式时也需要应用 target_variant 转换(如 Codex
if target_variant: if same_format_variant:
request_body = registry.convert_request( request_body = registry.convert_request(
request_body, request_body,
provider_api_format, provider_api_format,
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 方式) # 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式)
provider_payload, provider_hdrs = self._request_builder.build( provider_payload, provider_hdrs = self._request_builder.build(
request_body, request_body,
original_headers, original_headers,
endpoint, endpoint,
key, 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, 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_headers = provider_hdrs
provider_request_body = provider_payload 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( url = build_provider_url(
endpoint, endpoint,
query_params=query_params, query_params=query_params,
path_params={"model": url_model}, path_params={"model": url_model},
is_stream=False, is_stream=upstream_is_stream, # sync handler may still force upstream streaming
key=key, key=key,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None, 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( logger.info(
f" [{self.request_id}] 发送非流式请求: Provider={provider.name}, " f" [{self.request_id}] 发送{'上游流式(聚合)' if upstream_is_stream else '非流式'}请求: "
f"模型={model} -> {mapped_model or '无映射'}" f"Provider={provider.name}, 模型={model} -> {mapped_model or '无映射'}"
) )
logger.debug(f" [{self.request_id}] 请求URL: {redact_url_for_log(url)}") logger.debug(f" [{self.request_id}] 请求URL: {redact_url_for_log(url)}")
@@ -1182,16 +1448,93 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 注意:不使用 async with因为复用的客户端不应该被关闭 # 注意:不使用 async with因为复用的客户端不应该被关闭
# 超时通过 timeout 参数控制 # 超时通过 timeout 参数控制
resp = await http_client.post( resp: httpx.Response | None = None
url, if not upstream_is_stream:
json=provider_payload, try:
headers=provider_hdrs, resp = await http_client.post(
timeout=httpx.Timeout(request_timeout), 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 status_code = resp.status_code
response_headers = dict(resp.headers) 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 负责分类(客户端错误/兼容性错误/限流等) # 统一使用 HTTPStatusError让 TaskService/error_classifier 负责分类(客户端错误/兼容性错误/限流等)
try: try:
resp.raise_for_status() resp.raise_for_status()
@@ -1233,6 +1576,10 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
upstream_response=raw_content, upstream_response=raw_content,
) )
if envelope:
response_json = envelope.unwrap_response(response_json)
envelope.postprocess_unwrapped_response(model=model, data=response_json)
# 检查响应体中的嵌套错误HTTP 200 但响应体包含错误) # 检查响应体中的嵌套错误HTTP 200 但响应体包含错误)
if isinstance(response_json, dict): if isinstance(response_json, dict):
parser = get_parser_for_format(provider_api_format) parser = get_parser_for_format(provider_api_format)

View File

@@ -44,6 +44,9 @@ from src.api.handlers.base.response_parser import (
ResponseParser, ResponseParser,
) )
from src.api.handlers.base.stream_context import StreamContext 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 ( from src.api.handlers.base.utils import (
build_sse_headers, build_sse_headers,
check_html_response, check_html_response,
@@ -53,6 +56,9 @@ from src.api.handlers.base.utils import (
) )
from src.config.constants import StreamDefaults from src.config.constants import StreamDefaults
from src.config.settings import config 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.error_utils import extract_client_error_message
from src.core.exceptions import ( from src.core.exceptions import (
EmbeddedErrorException, EmbeddedErrorException,
@@ -72,6 +78,12 @@ from src.models.database import (
User, User,
) )
from src.services.cache.aware_scheduler import ProviderCandidate 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.provider.transport import build_provider_url
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
from src.utils.sse_parser import SSEEventParser from src.utils.sse_parser import SSEEventParser
@@ -702,6 +714,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
ctx.final_response = None ctx.final_response = None
ctx.response_id = None ctx.response_id = None
ctx.response_metadata = {} # 重置 Provider 响应元数据 ctx.response_metadata = {} # 重置 Provider 响应元数据
ctx.selected_base_url = None # 重置本次请求选用的 base_url重试时避免污染
# 记录 Provider 信息 # 记录 Provider 信息
ctx.provider_name = str(provider.name) ctx.provider_name = str(provider.name)
@@ -740,9 +753,26 @@ class CliMessageHandlerBase(BaseMessageHandler):
) )
ctx.needs_conversion = needs_conversion ctx.needs_conversion = needs_conversion
# 确定目标变体(用于 Codex 等需要特殊处理的上游)
provider_type = str(getattr(provider, "provider_type", "") or "").lower() 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 # 跨格式:先做请求体转换(失败触发 failover
if needs_conversion and provider_api_format: if needs_conversion and provider_api_format:
@@ -752,8 +782,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
provider_api_format, provider_api_format,
mapped_model, mapped_model,
ctx.model, ctx.model,
is_stream=True, is_stream=upstream_is_stream,
target_variant=target_variant, target_variant=conversion_variant,
) )
else: else:
# 同格式:按原逻辑做轻量清理(子类可覆盖) # 同格式:按原逻辑做轻量清理(子类可覆盖)
@@ -762,7 +792,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
self.get_model_for_url(request_body, mapped_model) or mapped_model or ctx.model self.get_model_for_url(request_body, mapped_model) or mapped_model or ctx.model
) )
# 同格式时也需要应用 target_variant 转换(如 Codex # 同格式时也需要应用 target_variant 转换(如 Codex
if target_variant: if target_variant and provider_api_format:
registry = get_format_converter_registry() registry = get_format_converter_registry()
request_body = registry.convert_request( request_body = registry.convert_request(
request_body, request_body,
@@ -771,9 +801,31 @@ class CliMessageHandlerBase(BaseMessageHandler):
target_variant=target_variant, 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 等异步认证场景) # 获取认证信息(处理 Service Account 等异步认证场景)
auth_info = await get_provider_auth(endpoint, key) 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 构建请求体和请求头 # 使用 RequestBuilder 构建请求体和请求头
# 注意mapped_model 已经应用到 request_body这里不再传递 # 注意mapped_model 已经应用到 request_body这里不再传递
# 上游始终使用 header 认证,不跟随客户端的 query 方式 # 上游始终使用 header 认证,不跟随客户端的 query 方式
@@ -782,9 +834,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
original_headers, original_headers,
endpoint, endpoint,
key, 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, 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 的请求信息(用于调试和统计) # 保存发送给 Provider 的请求信息(用于调试和统计)
ctx.provider_request_headers = provider_headers ctx.provider_request_headers = provider_headers
@@ -794,10 +850,146 @@ class CliMessageHandlerBase(BaseMessageHandler):
endpoint, endpoint,
query_params=query_params, query_params=query_params,
path_params={"model": url_model}, path_params={"model": url_model},
is_stream=True, # CLI handler 处理流式请求 is_stream=upstream_is_stream,
key=key, key=key,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None, 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 超时 # 配置 HTTP 超时
# 注意read timeout 用于检测连接断开,不是整体请求超时 # 注意read timeout 用于检测连接断开,不是整体请求超时
@@ -847,6 +1039,12 @@ class CliMessageHandlerBase(BaseMessageHandler):
logger.debug(f" └─ 收到响应: status={stream_response.status_code}") 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() stream_response.raise_for_status()
# 使用字节流迭代器(避免 aiter_lines 的性能问题, aiter_bytes 会自动解压 gzip/deflate # 使用字节流迭代器(避免 aiter_lines 的性能问题, aiter_bytes 会自动解压 gzip/deflate
@@ -871,7 +1069,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
else: else:
await asyncio.wait_for(_connect_and_prefetch(), timeout=request_timeout) await asyncio.wait_for(_connect_and_prefetch(), timeout=request_timeout)
except TimeoutError: except TimeoutError as e:
# 整体请求超时(建立连接 + 获取首字节) # 整体请求超时(建立连接 + 获取首字节)
# 清理可能已建立的连接上下文 # 清理可能已建立的连接上下文
if response_ctx is not None: if response_ctx is not None:
@@ -879,6 +1077,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
await response_ctx.__aexit__(None, None, None) await response_ctx.__aexit__(None, None, None)
except Exception: except Exception:
pass pass
if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
await http_client.aclose() await http_client.aclose()
logger.warning( logger.warning(
f" [{self.request_id}] 请求超时: Provider={provider.name}, timeout={request_timeout}s" 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" ctx.error_message = "client_disconnected_during_prefetch"
raise 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: except httpx.HTTPStatusError as e:
error_text = await self._extract_error_text(e) error_text = await self._extract_error_text(e)
logger.error( logger.error(
@@ -958,6 +1168,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 使用已设置的 ctx.needs_conversion由候选筛选阶段根据端点配置判断 # 使用已设置的 ctx.needs_conversion由候选筛选阶段根据端点配置判断
# 不再调用 _needs_format_conversion它只检查格式差异不检查端点配置 # 不再调用 _needs_format_conversion它只检查格式差异不检查端点配置
needs_conversion = ctx.needs_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(): async for chunk in stream_response.aiter_bytes():
buffer += chunk buffer += chunk
@@ -1321,6 +1539,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 使用已设置的 ctx.needs_conversion由候选筛选阶段根据端点配置判断 # 使用已设置的 ctx.needs_conversion由候选筛选阶段根据端点配置判断
# 不再调用 _needs_format_conversion它只检查格式差异不检查端点配置 # 不再调用 _needs_format_conversion它只检查格式差异不检查端点配置
needs_conversion = ctx.needs_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: for chunk in prefetched_chunks:
@@ -1568,18 +1794,31 @@ class CliMessageHandlerBase(BaseMessageHandler):
except json.JSONDecodeError: except json.JSONDecodeError:
return 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。 # 当不需要格式转换时,更新 data_count需要记录时再写入 parsed_chunks。
# 当需要格式转换时record_chunk=Falsedata_count 由 _record_converted_chunks 更新 # 当需要格式转换时record_chunk=Falsedata_count 由 _record_converted_chunks 更新
if record_chunk and isinstance(data, dict): if record_chunk:
ctx.data_count += 1 ctx.data_count += 1
if ctx.record_parsed_chunks: if ctx.record_parsed_chunks:
ctx.parsed_chunks.append(data) ctx.parsed_chunks.append(data)
if not isinstance(data, dict):
return
event_type = event_name or data.get("type", "") event_type = event_name or data.get("type", "")
if envelope:
envelope.postprocess_unwrapped_response(model=ctx.model, data=data)
# 调用格式特定的处理逻辑 # 调用格式特定的处理逻辑
# 注意跨格式转换时_process_event_data 会自动选择正确的 Provider 解析器 # 注意跨格式转换时_process_event_data 会自动选择正确的 Provider 解析器
self._process_event_data(ctx, event_type, data) self._process_event_data(ctx, event_type, data)
@@ -1928,6 +2167,17 @@ class CliMessageHandlerBase(BaseMessageHandler):
logger.warning(f"[{ctx.request_id}] 流式请求失败,未选中提供商") logger.warning(f"[{ctx.request_id}] 流式请求失败,未选中提供商")
return 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 session
db_gen = get_db() db_gen = get_db()
bg_db = next(db_gen) bg_db = next(db_gen)
@@ -2326,9 +2576,26 @@ class CliMessageHandlerBase(BaseMessageHandler):
) )
needs_conversion = bool(getattr(candidate, "needs_conversion", False)) needs_conversion = bool(getattr(candidate, "needs_conversion", False))
# 确定目标变体(用于 Codex 等需要特殊处理的上游)
provider_type = str(getattr(provider, "provider_type", "") or "").lower() 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 # 跨格式:先做请求体转换(失败触发 failover
if needs_conversion and provider_api_format: if needs_conversion and provider_api_format:
@@ -2338,8 +2605,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
provider_api_format, provider_api_format,
mapped_model, mapped_model,
model, model,
is_stream=False, is_stream=upstream_is_stream,
target_variant=target_variant, target_variant=conversion_variant,
) )
else: else:
# 同格式:按原逻辑做轻量清理(子类可覆盖) # 同格式:按原逻辑做轻量清理(子类可覆盖)
@@ -2348,7 +2615,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
self.get_model_for_url(request_body, mapped_model) or mapped_model or model self.get_model_for_url(request_body, mapped_model) or mapped_model or model
) )
# 同格式时也需要应用 target_variant 转换(如 Codex # 同格式时也需要应用 target_variant 转换(如 Codex
if target_variant: if target_variant and provider_api_format:
registry = get_format_converter_registry() registry = get_format_converter_registry()
request_body = registry.convert_request( request_body = registry.convert_request(
request_body, request_body,
@@ -2357,9 +2624,31 @@ class CliMessageHandlerBase(BaseMessageHandler):
target_variant=target_variant, 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 等异步认证场景) # 获取认证信息(处理 Service Account 等异步认证场景)
auth_info = await get_provider_auth(endpoint, key) 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 构建请求体和请求头 # 使用 RequestBuilder 构建请求体和请求头
# 注意mapped_model 已经应用到 request_body这里不再传递 # 注意mapped_model 已经应用到 request_body这里不再传递
# 上游始终使用 header 认证,不跟随客户端的 query 方式 # 上游始终使用 header 认证,不跟随客户端的 query 方式
@@ -2368,9 +2657,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
original_headers, original_headers,
endpoint, endpoint,
key, 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, 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 的请求信息(用于调试和统计)
provider_request_headers = provider_headers provider_request_headers = provider_headers
@@ -2380,13 +2673,15 @@ class CliMessageHandlerBase(BaseMessageHandler):
endpoint, endpoint,
query_params=query_params, query_params=query_params,
path_params={"model": url_model}, path_params={"model": url_model},
is_stream=False, # 非流式请求 is_stream=upstream_is_stream, # sync handler may still force upstream streaming
key=key, key=key,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None, 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( 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"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"Key=***{key.api_key[-4:] if key.api_key else 'N/A'}, "
f"原始模型={model}, 映射后={mapped_model or '无映射'}, URL模型={url_model}" f"原始模型={model}, 映射后={mapped_model or '无映射'}, URL模型={url_model}"
@@ -2405,49 +2700,106 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 注意:不使用 async with因为复用的客户端不应该被关闭 # 注意:不使用 async with因为复用的客户端不应该被关闭
# 超时通过 timeout 参数控制 # 超时通过 timeout 参数控制
resp = await http_client.post( resp: httpx.Response | None = None
url, if not upstream_is_stream:
json=provider_payload, try:
headers=provider_headers, resp = await http_client.post(
timeout=httpx.Timeout(request_timeout), 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 status_code = resp.status_code
response_headers = dict(resp.headers) response_headers = dict(resp.headers)
if resp.status_code == 401: if envelope:
raise ProviderAuthException(str(provider.name)) envelope.on_http_status(base_url=selected_base_url_cached, status_code=status_code)
elif resp.status_code == 429:
raise ProviderRateLimitException( # Forced upstream streaming already built response_json via aggregator.
"请求过于频繁,请稍后重试", if upstream_is_stream:
provider_name=str(provider.name), response_metadata_result = self._extract_response_metadata(response_json or {})
response_headers=response_headers, return response_json if isinstance(response_json, dict) else {}
retry_after=int(resp.headers.get("retry-after", 0)) or None,
) # Reuse HTTPStatusError classification path (handled by TaskService/error_classifier).
elif resp.status_code >= 500: try:
error_text = resp.text resp.raise_for_status()
raise ProviderNotAvailableException( except httpx.HTTPStatusError as e:
f"上游服务暂时不可用 (HTTP {resp.status_code})", error_body = ""
provider_name=str(provider.name), try:
upstream_status=resp.status_code, error_body = resp.text[:4000] if resp.text else ""
upstream_response=error_text, except Exception:
) error_body = ""
elif 300 <= resp.status_code < 400: e.upstream_response = error_body # type: ignore[attr-defined]
redirect_url = resp.headers.get("location", "unknown") raise
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,
)
# 安全解析 JSON 响应,处理可能的编码错误 # 安全解析 JSON 响应,处理可能的编码错误
try: try:
@@ -2483,6 +2835,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
upstream_response=raw_content, upstream_response=raw_content,
) )
if envelope:
response_json = envelope.unwrap_response(response_json)
envelope.postprocess_unwrapped_response(model=model, data=response_json)
# 提取 Provider 响应元数据(子类可覆盖) # 提取 Provider 响应元数据(子类可覆盖)
response_metadata_result = self._extract_response_metadata(response_json) response_metadata_result = self._extract_response_metadata(response_json)
@@ -2531,12 +2887,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
if response_json is None: if response_json is None:
response_json = {} response_json = {}
# 检查是否需要格式转换(同族格式无需转换,如 CLAUDE 和 CLAUDE_CLI # 跨格式:响应转换回 client_format失败不触发 failover保守回退为原始响应
from src.core.api_format.utils import get_base_format if (
needs_conversion
provider_base = get_base_format(provider_api_format) if provider_api_format else None and provider_api_format
client_base = get_base_format(api_format) if api_format else None and api_format
if provider_base and client_base and provider_base != client_base: and isinstance(response_json, dict)
):
try: try:
registry = get_format_converter_registry() registry = get_format_converter_registry()
response_json = registry.convert_response( response_json = registry.convert_response(
@@ -2835,6 +3192,15 @@ class CliMessageHandlerBase(BaseMessageHandler):
if status == "invalid" or status == "passthrough": if status == "invalid" or status == "passthrough":
return [line], [] 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: if ctx.stream_conversion_state is None:
from src.core.api_format.conversion.stream_state import StreamState from src.core.api_format.conversion.stream_state import StreamState

View File

@@ -586,7 +586,16 @@ async def get_provider_auth(
pass pass
decrypted_key = crypto_service.decrypt(key.api_key) 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": if auth_type == "vertex_ai":
from src.core.vertex_auth import VertexAuthError, VertexAuthService from src.core.vertex_auth import VertexAuthError, VertexAuthService

View File

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

View File

@@ -44,6 +44,7 @@ from src.core.exceptions import (
) )
from src.core.logger import logger from src.core.logger import logger
from src.models.database import Provider, ProviderEndpoint 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.perf import PerfRecorder
from src.utils.sse_parser import SSEEventParser from src.utils.sse_parser import SSEEventParser
from src.utils.timeout import read_first_chunk_with_ttfb_timeout from src.utils.timeout import read_first_chunk_with_ttfb_timeout
@@ -213,6 +214,11 @@ class StreamProcessor:
""" """
prefetched_chunks: list = [] prefetched_chunks: list = []
parser = self.get_parser_for_provider(ctx) 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"" buffer = b""
line_count = 0 line_count = 0
should_stop = False should_stop = False
@@ -291,6 +297,14 @@ class StreamProcessor:
break break
continue 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): if isinstance(data, dict) and parser.is_error_response(data):
parsed = parser.parse_response(data, 200) parsed = parser.parse_response(data, 200)
@@ -431,6 +445,14 @@ class StreamProcessor:
) or "unknown" ) or "unknown"
# 使用 handler 层预计算的 needs_conversion由 candidate 决定) # 使用 handler 层预计算的 needs_conversion由 candidate 决定)
needs_conversion = ctx.needs_conversion 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 必须有值 # 安全检查needs_conversion 为 True 时provider_format 必须有值
if needs_conversion and not provider_format: if needs_conversion and not provider_format:

View File

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

View File

@@ -96,7 +96,12 @@ class GeminiCliMessageHandler(CliMessageHandlerBase):
# 优先使用映射后的模型名,否则使用请求体中的 # 优先使用映射后的模型名,否则使用请求体中的
return mapped_model or request_body.get("model") 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 使用情况 从 Gemini 事件中提取 token 使用情况
@@ -104,10 +109,14 @@ class GeminiCliMessageHandler(CliMessageHandlerBase):
Args: Args:
event: Gemini 流式响应事件 event: Gemini 流式响应事件
provider_type: Provider 类型(用于 Antigravity 特判)
Returns: Returns:
包含 input_tokens, output_tokens, cached_tokens 的字典 包含 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 from src.api.handlers.gemini.stream_parser import GeminiStreamParser
usage = GeminiStreamParser().extract_usage(event) usage = GeminiStreamParser().extract_usage(event)
@@ -125,6 +134,35 @@ class GeminiCliMessageHandler(CliMessageHandlerBase):
"cached_tokens": usage.get("cached_tokens", 0), "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( def _process_event_data(
self, self,
ctx: StreamContext, ctx: StreamContext,
@@ -172,7 +210,7 @@ class GeminiCliMessageHandler(CliMessageHandlerBase):
ctx.final_response = data ctx.final_response = data
# 提取使用量信息(复用 GeminiStreamParser.extract_usage # 提取使用量信息(复用 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: if usage["input_tokens"] > 0 or usage["output_tokens"] > 0:
ctx.input_tokens = usage["input_tokens"] ctx.input_tokens = usage["input_tokens"]
ctx.output_tokens = usage["output_tokens"] ctx.output_tokens = usage["output_tokens"]

View File

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

View File

@@ -197,6 +197,15 @@ class GeminiNormalizer(FormatNormalizer):
) -> dict[str, Any]: ) -> dict[str, Any]:
system_text = internal.system or self._join_instructions(internal.instructions) 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/tool_choice
tools = None tools = None
if internal.tools: if internal.tools:
@@ -278,8 +287,45 @@ class GeminiNormalizer(FormatNormalizer):
generation_config["thinkingConfig"] = orig_gc["thinking_config"] generation_config["thinkingConfig"] = orig_gc["thinking_config"]
contents: list[dict[str, Any]] = [] contents: list[dict[str, Any]] = []
for msg in internal.messages: last_idx = len(internal.messages) - 1
contents.append(self._internal_message_to_content(msg)) 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] = { result: dict[str, Any] = {
"contents": contents, "contents": contents,
@@ -1120,12 +1166,22 @@ class GeminiNormalizer(FormatNormalizer):
return blocks, dropped 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" role = "model" if msg.role == Role.ASSISTANT else "user"
parts: list[dict[str, Any]] = [] parts: list[dict[str, Any]] = []
for b in msg.content: for b in msg.content:
if isinstance(b, UnknownBlock): 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 continue
if isinstance(b, TextBlock): if isinstance(b, TextBlock):
@@ -1166,6 +1222,94 @@ class GeminiNormalizer(FormatNormalizer):
return {"role": role, "parts": parts} 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( def _collapse_system_instruction(
self, system_instruction: Any self, system_instruction: Any
) -> tuple[str | None, dict[str, int]]: ) -> tuple[str | None, dict[str, int]]:

View File

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

View File

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

View File

@@ -348,6 +348,25 @@ async def enrich_auth_config(
) )
if email: if email:
auth_config["email"] = 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
return auth_config return auth_config

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ Thinking 整流器Rectifier
""" """
import copy import copy
import json
from typing import Any from typing import Any
from src.core.logger import logger from src.core.logger import logger
@@ -67,6 +68,103 @@ class ThinkingRectifier:
return rectified_body, modified 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 @staticmethod
def _rectify_messages(messages: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], bool]: def _rectify_messages(messages: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], bool]:
""" """

View File

@@ -176,6 +176,9 @@ class ErrorClassifier:
"expected `thinking`, found", # 带反引号变体 "expected `thinking`, found", # 带反引号变体
"expected redacted_thinking, found", "expected redacted_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]: def _parse_error_response(self, error_text: str | None) -> dict[str, Any]:

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,16 @@ from src.core.api_format import (
make_signature_key, make_signature_key,
) )
from src.core.logger import logger 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.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 from src.utils.url_utils import is_codex_url
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -72,6 +81,35 @@ def _normalize_base_url(base_url: str, path: str) -> str:
return base 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( def build_provider_url(
endpoint: ProviderEndpoint, endpoint: ProviderEndpoint,
*, *,
@@ -97,6 +135,9 @@ def build_provider_url(
key: Provider API Key用于 Vertex AI 等需要从密钥配置读取信息的场景) key: Provider API Key用于 Vertex AI 等需要从密钥配置读取信息的场景)
decrypted_auth_config: 已解密的认证配置(避免重复解密,由 get_provider_auth 提供) decrypted_auth_config: 已解密的认证配置(避免重复解密,由 get_provider_auth 提供)
""" """
# 默认清理,避免上一次请求的 selected_base_url 泄漏到其他请求
set_selected_base_url(None)
# 检查是否为 Vertex AI 认证类型 # 检查是否为 Vertex AI 认证类型
auth_type = getattr(key, "auth_type", "api_key") if key else "api_key" auth_type = getattr(key, "auth_type", "api_key") if key else "api_key"
if auth_type == "vertex_ai": if auth_type == "vertex_ai":
@@ -123,6 +164,55 @@ def build_provider_url(
# endpoint_sig 为空时保持为空(更安全:默认路径回退到 "/",避免误判为 claude:chat # endpoint_sig 为空时保持为空(更安全:默认路径回退到 "/",避免误判为 claude:chat
endpoint_sig = normalize_endpoint_signature(endpoint_sig) if endpoint_sig else "" 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 # 准备路径参数Gemini chat/cli 需要 action
effective_path_params = dict(path_params) if path_params else {} effective_path_params = dict(path_params) if path_params else {}
if endpoint_sig.startswith("gemini:"): 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] base = _normalize_base_url(endpoint.base_url, path) # type: ignore[arg-type]
url = f"{base}{path}" url = f"{base}{path}"
# 合并查询参数 # Gemini streamGenerateContent 官方支持 `?alt=sse` 返回 SSEdata: {...})。
effective_query_params = dict(query_params) if query_params else {} # 网关侧统一使用 SSE 输出,优先向上游请求 SSE 以减少解析分支;同时保留 JSON-array 兜底解析。
if endpoint_sig.startswith("gemini:") and is_stream:
# Gemini family 下清除可能存在的 key 参数(避免客户端传入的认证信息泄露到上游) effective_query_params.setdefault("alt", "sse")
# 上游认证始终使用 header 方式,不使用 URL 参数
if endpoint_sig.startswith("gemini:"):
effective_query_params.pop("key", None)
# Gemini streamGenerateContent 官方支持 `?alt=sse` 返回 SSEdata: {...})。
# 网关侧统一使用 SSE 输出,优先向上游请求 SSE 以减少解析分支;同时保留 JSON-array 兜底解析。
if is_stream:
effective_query_params.setdefault("alt", "sse")
# 添加查询参数 # 添加查询参数
if effective_query_params: if effective_query_params:

View File

@@ -549,6 +549,8 @@ class TaskService:
self, self,
*, *,
converted_error: Any, converted_error: Any,
provider_type: str | None,
model_name: str | None,
request_id: str | None, request_id: str | None,
candidate_record_id: str, candidate_record_id: str,
elapsed_ms: int, elapsed_ms: int,
@@ -585,32 +587,77 @@ class TaskService:
) )
raise converted_error 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) logger.warning(" [{}] Thinking 错误:已整流仍失败,终止重试", request_id)
self._mark_thinking_error_failed( self._mark_thinking_error_failed(
candidate_record_id, candidate_record_id,
converted_error, converted_error,
elapsed_ms, elapsed_ms,
captured_key_concurrent, captured_key_concurrent,
{**serializable_extra_data, "rectified": True}, {**serializable_extra_data, "rectified": True, "rectify_stage": stage},
) )
raise converted_error raise converted_error
request_body = request_body_ref.get("body", {}) 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: if modified:
request_body_ref["body"] = rectified_body request_body_ref["body"] = rectified_body
request_body_ref["_rectified"] = True request_body_ref["_rectified"] = True
request_body_ref["_rectified_this_turn"] = 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( self._mark_thinking_error_failed(
candidate_record_id, candidate_record_id,
converted_error, converted_error,
elapsed_ms, elapsed_ms,
captured_key_concurrent, captured_key_concurrent,
{**serializable_extra_data, "rectified": True}, {
**serializable_extra_data,
"rectified": True,
"rectify_stage": next_stage,
"rectify_stage_label": stage_label,
},
) )
return "continue" return "continue"
@@ -770,6 +817,8 @@ class TaskService:
if isinstance(converted_error, ThinkingSignatureException): if isinstance(converted_error, ThinkingSignatureException):
action = self._handle_thinking_signature_error( action = self._handle_thinking_signature_error(
converted_error=converted_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, request_id=request_id,
candidate_record_id=candidate_record_id, candidate_record_id=candidate_record_id,
elapsed_ms=elapsed_ms, elapsed_ms=elapsed_ms,

View File

@@ -0,0 +1,201 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.services.antigravity.envelope import (
unwrap_v1internal_response,
wrap_v1internal_request,
)
from src.api.handlers.base.stream_context import StreamContext
from src.api.handlers.gemini_cli.handler import GeminiCliMessageHandler
def _make_handler() -> GeminiCliMessageHandler:
return GeminiCliMessageHandler(
db=MagicMock(),
user=SimpleNamespace(id=1),
api_key=SimpleNamespace(id=1),
request_id="req_1",
client_ip="127.0.0.1",
user_agent="pytest",
start_time=0.0,
)
def test_wrap_v1internal_request_removes_inner_model() -> None:
gemini_request = {
"model": "gemini-2.0-flash",
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
}
wrapped = wrap_v1internal_request(
gemini_request,
project_id="project-123",
model="gemini-2.0-flash",
)
assert wrapped["project"] == "project-123"
assert wrapped["model"] == "gemini-2.0-flash"
assert "request" in wrapped
assert "model" not in wrapped["request"]
def test_unwrap_v1internal_response() -> None:
v1_resp = {
"response": {"candidates": [{"content": {"parts": [{"text": "Hello"}]}}]},
"responseId": "resp-123",
}
unwrapped = unwrap_v1internal_response(v1_resp)
assert "response" not in unwrapped
assert "candidates" in unwrapped
assert unwrapped["_v1internal_response_id"] == "resp-123"
def test_convert_sse_line_unwraps_before_convert_stream_chunk() -> None:
handler = _make_handler()
ctx = StreamContext(model="gemini-2.0-flash", api_format="gemini:cli")
ctx.provider_type = "antigravity"
ctx.provider_api_format = "gemini:cli"
ctx.client_api_format = "gemini:cli"
v1_line = (
'data: {"response": {"candidates": [{"content": {"parts": [{"text": "Hi"}]}}]},'
' "responseId": "123"}'
)
seen: dict[str, object] = {}
class _DummyRegistry:
def convert_stream_chunk(self, data_obj, *_args, **_kwargs): # noqa: ANN001
seen["data_obj"] = data_obj
return []
with patch(
"src.api.handlers.base.cli_handler_base.get_format_converter_registry",
return_value=_DummyRegistry(),
):
_lines, _events = handler._convert_sse_line(ctx, v1_line, [])
assert isinstance(seen.get("data_obj"), dict)
assert "response" not in seen["data_obj"] # 已解包
assert "_v1internal_response_id" in seen["data_obj"]
def test_handle_sse_event_unwraps_for_antigravity() -> None:
handler = _make_handler()
ctx = StreamContext(model="gemini-2.0-flash", api_format="gemini:cli")
ctx.provider_type = "antigravity"
v1_data = {
"response": {
"candidates": [
{
"content": {"parts": [{"text": "Hello"}], "role": "model"},
"finishReason": "STOP",
}
],
"modelVersion": "gemini-2.0-flash-001",
},
"responseId": "123",
}
with patch.object(handler, "_process_event_data") as mock_process:
handler._handle_sse_event(ctx, None, json.dumps(v1_data), record_chunk=False)
assert mock_process.call_count == 1
passed_data = mock_process.call_args[0][2]
assert isinstance(passed_data, dict)
assert "response" not in passed_data
assert "candidates" in passed_data
def test_handle_sse_event_caches_thought_signature_for_antigravity() -> None:
from src.services.antigravity.signature_cache import signature_cache
signature_cache._cache.clear() # type: ignore[attr-defined]
handler = _make_handler()
ctx = StreamContext(model="claude-sonnet-4-5", api_format="gemini:cli")
ctx.provider_type = "antigravity"
payload = {
"candidates": [
{
"content": {
"parts": [
{
"text": "t1",
"thought": True,
"thoughtSignature": "sig-abc",
}
]
}
}
]
}
with patch.object(handler, "_process_event_data") as _mock_process:
handler._handle_sse_event(ctx, None, json.dumps(payload), record_chunk=False)
assert signature_cache.get_or_dummy("claude-sonnet-4-5", "t1") == "sig-abc"
def test_provider_type_drives_antigravity_usage_path() -> None:
handler = _make_handler()
event = {"usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 2}}
usage = handler._extract_usage_from_event(event, provider_type="antigravity")
assert usage["input_tokens"] == 10
assert usage["output_tokens"] == 2
@pytest.mark.asyncio
async def test_antigravity_forces_conversion_path_in_stream_with_prefetch() -> None:
handler = _make_handler()
ctx = StreamContext(model="gemini-2.0-flash", api_format="gemini:cli")
ctx.provider_type = "antigravity"
ctx.needs_conversion = False # same-format case normally would passthrough
class _AsyncIter:
def __init__(self, items): # noqa: ANN001
self._it = iter(items)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self._it)
except StopIteration as e:
raise StopAsyncIteration from e
prefetched = [
b'data: {"response": {"candidates": []}, "responseId": "1"}\n',
]
byte_iter = _AsyncIter([]) # no more bytes after prefetch
response_ctx = SimpleNamespace(__aexit__=AsyncMock(return_value=None))
http_client = SimpleNamespace(aclose=AsyncMock(return_value=None))
with patch.object(
handler,
"_convert_sse_line",
return_value=(["data: {}"], []),
) as mock_convert:
out = []
async for chunk in handler._create_response_stream_with_prefetch(
ctx, byte_iter, response_ctx, http_client, prefetched
):
out.append(chunk)
assert mock_convert.call_count >= 1
assert any(b"data: {}" in c for c in out)

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from src.core.provider_oauth_utils import enrich_auth_config
@pytest.mark.asyncio
async def test_enrich_auth_config_antigravity_adds_project_id_and_email() -> None:
auth_config: dict[str, object] = {}
token_response: dict[str, object] = {}
with (
patch("src.core.provider_oauth_utils.fetch_google_email", AsyncMock(return_value="u@example.com")),
patch(
"src.services.antigravity.client.load_code_assist",
AsyncMock(
return_value={
"cloudaicompanionProject": "project-1",
"currentTier": {"tierType": "PAID"},
}
),
),
):
out = await enrich_auth_config(
provider_type="antigravity",
auth_config=auth_config, # in-place
token_response=token_response,
access_token="tok",
proxy_config=None,
)
assert out["email"] == "u@example.com"
assert out["project_id"] == "project-1"
assert out["tier"] == "PAID"

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from src.services.antigravity.client import load_code_assist
from src.services.antigravity.constants import DAILY_BASE_URL, PROD_BASE_URL
@pytest.mark.asyncio
async def test_load_code_assist_falls_back_to_daily() -> None:
resp1 = httpx.Response(500, json={"error": {"message": "boom"}})
resp2 = httpx.Response(200, json={"cloudaicompanionProject": "project-1"})
client = SimpleNamespace(post=AsyncMock(side_effect=[resp1, resp2]))
with patch(
"src.clients.http_client.HTTPClientPool.get_proxy_client",
AsyncMock(return_value=client),
):
data = await load_code_assist("tok", proxy_config=None, timeout_seconds=1.0)
assert data["cloudaicompanionProject"] == "project-1"
assert client.post.await_count == 2
assert client.post.call_args_list[0].args[0] == f"{PROD_BASE_URL}/v1internal:loadCodeAssist"
assert client.post.call_args_list[1].args[0] == f"{DAILY_BASE_URL}/v1internal:loadCodeAssist"
@pytest.mark.asyncio
async def test_load_code_assist_requires_token() -> None:
with pytest.raises(ValueError):
await load_code_assist("", proxy_config=None)

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from src.services.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
from src.services.antigravity.signature_cache import ThinkingSignatureCache
def test_get_or_dummy_returns_dummy_for_gemini_models() -> None:
cache = ThinkingSignatureCache(maxsize=10)
assert cache.get_or_dummy("gemini-3-pro", "thinking...") == DUMMY_THOUGHT_SIGNATURE
def test_get_or_dummy_returns_none_for_non_gemini_models() -> None:
cache = ThinkingSignatureCache(maxsize=10)
assert cache.get_or_dummy("claude-sonnet", "thinking...") is None
def test_cached_signature_preferred() -> None:
cache = ThinkingSignatureCache(maxsize=10)
cache.cache("gemini-3-pro", "t", "sig-1")
assert cache.get_or_dummy("gemini-3-pro", "t") == "sig-1"
def test_eviction_fifo() -> None:
cache = ThinkingSignatureCache(maxsize=4)
cache.cache("gemini-3-pro", "t1", "s1")
cache.cache("gemini-3-pro", "t2", "s2")
cache.cache("gemini-3-pro", "t3", "s3")
cache.cache("gemini-3-pro", "t4", "s4")
# Trigger eviction (evict 1 key when maxsize=4)
cache.cache("gemini-3-pro", "t5", "s5")
# Oldest key should be gone
assert cache.get_or_dummy("gemini-3-pro", "t1") == DUMMY_THOUGHT_SIGNATURE

View File

@@ -0,0 +1,118 @@
from __future__ import annotations
from src.api.handlers.base.utils import get_format_converter_registry
from src.services.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
from src.services.antigravity.signature_cache import signature_cache
def _reset_sig_cache() -> None:
# Module-global cache; tests must isolate state.
signature_cache._cache.clear() # type: ignore[attr-defined]
def test_antigravity_converts_claude_thinking_block_to_gemini_thought_part_prefers_payload_sig() -> None:
_reset_sig_cache()
req = {
"model": "gemini-3-pro",
"max_tokens": 64,
"thinking": {"type": "enabled", "budget_tokens": 1000},
"messages": [
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "t1", "signature": "sig-1"},
{"type": "text", "text": "ok"},
],
},
],
}
registry = get_format_converter_registry()
out = registry.convert_request(req, "claude:chat", "gemini:cli", target_variant="antigravity")
assert isinstance(out.get("contents"), list)
model_turn = out["contents"][1]
assert model_turn["role"] == "model"
parts = model_turn["parts"]
assert parts[0]["thought"] is True
assert parts[0]["text"] == "t1"
assert parts[0]["thoughtSignature"] == "sig-1"
def test_antigravity_uses_dummy_signature_for_gemini_when_missing() -> None:
_reset_sig_cache()
req = {
"model": "gemini-3-pro",
"max_tokens": 64,
"thinking": {"type": "enabled", "budget_tokens": 1000},
"messages": [
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "t2"},
{"type": "text", "text": "ok"},
],
},
],
}
registry = get_format_converter_registry()
out = registry.convert_request(req, "claude:chat", "gemini:cli", target_variant="antigravity")
parts = out["contents"][1]["parts"]
assert parts[0]["thought"] is True
assert parts[0]["text"] == "t2"
assert parts[0]["thoughtSignature"] == DUMMY_THOUGHT_SIGNATURE
def test_antigravity_drops_unsigned_thinking_for_non_gemini_models() -> None:
_reset_sig_cache()
req = {
"model": "claude-sonnet-4-5",
"max_tokens": 64,
"thinking": {"type": "enabled", "budget_tokens": 1000},
"messages": [
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "t3"},
{"type": "text", "text": "ok"},
],
},
],
}
registry = get_format_converter_registry()
out = registry.convert_request(req, "claude:chat", "gemini:cli", target_variant="antigravity")
parts = out["contents"][1]["parts"]
assert all(p.get("thought") is not True for p in parts)
def test_antigravity_inserts_dummy_thought_for_last_assistant_when_thinking_enabled() -> None:
_reset_sig_cache()
req = {
"model": "gemini-3-pro",
"max_tokens": 64,
"thinking": {"type": "enabled", "budget_tokens": 1000},
"messages": [
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
{"role": "assistant", "content": [{"type": "text", "text": "prefill"}]},
],
}
registry = get_format_converter_registry()
out = registry.convert_request(req, "claude:chat", "gemini:cli", target_variant="antigravity")
parts = out["contents"][1]["parts"]
assert parts[0]["thought"] is True
assert parts[0]["thoughtSignature"] == DUMMY_THOUGHT_SIGNATURE
assert parts[1]["text"] == "prefill"

View File

@@ -0,0 +1,58 @@
from __future__ import annotations
import src.services.antigravity.url_availability as ua_mod
from src.services.antigravity.constants import (
DAILY_BASE_URL,
PROD_BASE_URL,
URL_UNAVAILABLE_TTL_SECONDS,
)
from src.services.antigravity.url_availability import url_availability
def _reset_state() -> None:
# Singleton: tests need to reset global state
with url_availability._mu: # type: ignore[attr-defined]
url_availability._unavailable.clear() # type: ignore[attr-defined]
url_availability._last_success = None # type: ignore[attr-defined]
def test_mark_success_priority() -> None:
_reset_state()
url_availability.mark_success(PROD_BASE_URL)
ordered = url_availability.get_ordered_urls(prefer_daily=True)
assert ordered[0] == PROD_BASE_URL
def test_mark_unavailable_filters_url() -> None:
_reset_state()
url_availability.mark_unavailable(DAILY_BASE_URL)
ordered = url_availability.get_ordered_urls(prefer_daily=True)
assert ordered[0] == PROD_BASE_URL
assert url_availability.is_available(DAILY_BASE_URL) is False
def test_ttl_recovery(monkeypatch) -> None:
_reset_state()
t0 = 1000.0
monkeypatch.setattr(ua_mod.time, "time", lambda: t0)
url_availability.mark_unavailable(PROD_BASE_URL)
assert url_availability.is_available(PROD_BASE_URL) is False
monkeypatch.setattr(ua_mod.time, "time", lambda: t0 + URL_UNAVAILABLE_TTL_SECONDS + 1)
assert url_availability.is_available(PROD_BASE_URL) is True
def test_all_unavailable_fallback_returns_base_order() -> None:
_reset_state()
url_availability.mark_unavailable(PROD_BASE_URL)
url_availability.mark_unavailable(DAILY_BASE_URL)
ordered = url_availability.get_ordered_urls(prefer_daily=True)
assert ordered == [DAILY_BASE_URL, PROD_BASE_URL]

View File

@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
from src.services.provider.codex import maybe_patch_request_for_codex, patch_openai_cli_request_for_codex from src.services.provider.codex import (
maybe_patch_request_for_codex,
patch_openai_cli_request_for_codex,
)
def test_patch_openai_cli_request_for_codex_sets_store_and_instructions() -> None: def test_patch_openai_cli_request_for_codex_sets_store_and_instructions() -> None:
@@ -101,3 +104,13 @@ def test_maybe_patch_request_for_codex_patches_for_codex_openai_cli() -> None:
assert out["store"] is False assert out["store"] is False
assert "instructions" in out assert "instructions" in out
def test_codex_envelope_extra_headers_includes_sse_accept_and_session() -> None:
from src.services.codex.envelope import codex_oauth_envelope
headers = codex_oauth_envelope.extra_headers() or {}
assert headers.get("Accept") == "text/event-stream"
assert headers.get("x-oai-web-search-eligible") == "true"
assert headers.get("originator") == "codex_cli_rs"
assert isinstance(headers.get("session_id"), str)
assert headers.get("session_id")

View File

@@ -48,6 +48,14 @@ class TestThinkingErrorPatterns:
error = '{"error": {"message": "signature verification failed"}}' error = '{"error": {"message": "signature verification failed"}}'
assert classifier._is_thinking_error(error) is True assert classifier._is_thinking_error(error) is True
def test_detect_thought_signature_patterns(self, classifier: ErrorClassifier) -> None:
"""检测 Antigravity/Gemini thoughtSignature 相关错误"""
error = '{"error": {"message": "invalid thoughtSignature in thought part"}}'
assert classifier._is_thinking_error(error) is True
error2 = '{"error": {"message": "thought_signature verification failed"}}'
assert classifier._is_thinking_error(error2) is True
# === 结构错误测试 === # === 结构错误测试 ===
def test_detect_must_start_with_thinking_block(self, classifier: ErrorClassifier) -> None: def test_detect_must_start_with_thinking_block(self, classifier: ErrorClassifier) -> None:

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from unittest.mock import patch
from src.services.antigravity.constants import PROD_BASE_URL
from src.services.provider.transport import build_provider_url, get_antigravity_base_url
@dataclass
class _DummyEndpoint:
base_url: str
api_format: str
custom_path: str | None = None
provider: object | None = None
def test_antigravity_uses_v1internal_path_and_sets_contextvar() -> None:
endpoint = _DummyEndpoint(
base_url="https://ignored.example.com",
api_format="gemini:cli",
provider=SimpleNamespace(provider_type="antigravity"),
)
with patch(
"src.services.provider.transport.url_availability.get_ordered_urls",
return_value=[PROD_BASE_URL],
):
url = build_provider_url(
endpoint, # type: ignore[arg-type] - test stub
path_params={"model": "gemini-2.0-flash"},
is_stream=True,
)
assert url.startswith(f"{PROD_BASE_URL}/v1internal:streamGenerateContent")
assert "alt=sse" in url
assert get_antigravity_base_url() == PROD_BASE_URL
def test_gemini_cli_non_antigravity_uses_v1beta_path_and_clears_contextvar() -> None:
endpoint = _DummyEndpoint(
base_url="https://generativelanguage.googleapis.com",
api_format="gemini:cli",
provider=SimpleNamespace(provider_type="gemini_cli"),
)
url = build_provider_url(
endpoint, # type: ignore[arg-type] - test stub
path_params={"model": "gemini-2.0-flash"},
is_stream=False,
)
assert "/v1beta/models/gemini-2.0-flash:generateContent" in url
assert get_antigravity_base_url() is None

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from src.services.provider.transport import build_provider_url
@dataclass
class _DummyEndpoint:
base_url: str
api_format: str
custom_path: str | None = None
provider: object | None = None
def test_codex_openai_cli_uses_responses_path_without_v1_prefix() -> None:
endpoint = _DummyEndpoint(
base_url="https://chatgpt.com/backend-api/codex",
api_format="openai:cli",
provider=SimpleNamespace(provider_type="codex"),
)
url = build_provider_url(
endpoint, # type: ignore[arg-type] - test stub
path_params={"model": "ignored"},
is_stream=True,
)
assert url == "https://chatgpt.com/backend-api/codex/responses"
def test_codex_openai_cli_does_not_duplicate_responses_suffix() -> None:
endpoint = _DummyEndpoint(
base_url="https://chatgpt.com/backend-api/codex/responses",
api_format="openai:cli",
provider=SimpleNamespace(provider_type="codex"),
)
url = build_provider_url(
endpoint, # type: ignore[arg-type] - test stub
path_params={"model": "ignored"},
is_stream=False,
)
assert url == "https://chatgpt.com/backend-api/codex/responses"

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
import json
import time
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from src.api.handlers.base.request_builder import get_provider_auth
@pytest.mark.asyncio
async def test_get_provider_auth_oauth_returns_decrypted_auth_config() -> None:
now = int(time.time())
token_meta = {
"provider_type": "antigravity",
"expires_at": now + 3600,
"refresh_token": "rt-1",
"project_id": "project-1",
}
endpoint = SimpleNamespace(api_format="gemini:cli")
key = SimpleNamespace(
id="k1",
auth_type="oauth",
api_key="enc_access",
auth_config="enc_cfg",
provider=None,
)
def _decrypt(v): # noqa: ANN001
if v == "enc_access":
return "access-token"
if v == "enc_cfg":
return json.dumps(token_meta)
return ""
with patch("src.api.handlers.base.request_builder.crypto_service.decrypt", side_effect=_decrypt):
auth = await get_provider_auth(endpoint, key) # type: ignore[arg-type]
assert auth is not None
assert auth.auth_header == "Authorization"
assert auth.auth_value == "Bearer access-token"
assert auth.decrypted_auth_config == token_meta

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from src.services.provider.stream_policy import (
UpstreamStreamPolicy,
enforce_stream_mode_for_upstream,
get_upstream_stream_policy,
)
@dataclass
class _DummyEndpoint:
api_format: str
config: dict | None = None
provider: object | None = None
def test_get_upstream_stream_policy_defaults_to_auto() -> None:
ep = _DummyEndpoint(api_format="openai:chat", config=None, provider=SimpleNamespace(provider_type="custom"))
assert get_upstream_stream_policy(ep) == UpstreamStreamPolicy.AUTO
def test_get_upstream_stream_policy_codex_openai_cli_forces_stream() -> None:
ep = _DummyEndpoint(
api_format="openai:cli",
config=None,
provider=SimpleNamespace(provider_type="codex"),
)
assert get_upstream_stream_policy(ep) == UpstreamStreamPolicy.FORCE_STREAM
def test_get_upstream_stream_policy_codex_ignores_force_non_stream_config() -> None:
ep = _DummyEndpoint(
api_format="openai:cli",
config={"upstream_stream_policy": "force_non_stream"},
provider=SimpleNamespace(provider_type="codex"),
)
assert get_upstream_stream_policy(ep) == UpstreamStreamPolicy.FORCE_STREAM
def test_enforce_stream_mode_for_upstream_openai_chat_sets_stream_options_usage() -> None:
body = {"stream": False}
out = enforce_stream_mode_for_upstream(
body,
provider_api_format="openai:chat",
upstream_is_stream=True,
)
assert out["stream"] is True
assert out["stream_options"]["include_usage"] is True
def test_enforce_stream_mode_for_upstream_gemini_drops_stream_field() -> None:
body = {"stream": True, "foo": "bar"}
out = enforce_stream_mode_for_upstream(
body,
provider_api_format="gemini:chat",
upstream_is_stream=False,
)
assert "stream" not in out
assert out["foo"] == "bar"