mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50:21 +08:00
feat: Antigravity 和 Codex 服务支持
- 新增 Antigravity 服务:签名缓存、URL 可用性检测、信封处理 - 新增 Codex 服务:信封处理、元数据收集器 - 重构 provider transport 支持新的服务架构 - 新增 stream_bridge 和 upstream_stream_bridge 处理流式响应 - 优化 OAuth 工具函数 - 添加相关测试用例
This commit is contained in:
10
README.md
10
README.md
@@ -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
|
|||||||
|
|
||||||
[](https://star-history.com/#fawney19/Aether&Date)
|
[](https://star-history.com/#fawney19/Aether&Date)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
81
docs/provider-quirks.md
Normal file
81
docs/provider-quirks.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Provider 特殊适配(Codex / Antigravity)
|
||||||
|
|
||||||
|
Aether 内部用两层标识来描述上游:
|
||||||
|
|
||||||
|
- **endpoint signature(family: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`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ProviderEnvelope(wire 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 == ...`)。
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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=False),data_count 由 _record_converted_chunks 更新
|
# 当需要格式转换时(record_chunk=False),data_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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
202
src/api/handlers/base/upstream_stream_bridge.py
Normal file
202
src/api/handlers/base/upstream_stream_bridge.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"""Upstream stream bridging helpers (handler layer).
|
||||||
|
|
||||||
|
This module provides small utilities used when handler-layer policies force an
|
||||||
|
upstream request to be streaming (SSE) even when the client asked for sync.
|
||||||
|
|
||||||
|
It intentionally stays lightweight and works with:
|
||||||
|
- standard SSE `data: {...}` lines (OpenAI/Claude/etc.)
|
||||||
|
- Gemini CLI JSON-array lines (best-effort)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import codecs
|
||||||
|
import json
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.api.handlers.base.response_parser import ResponseParser
|
||||||
|
from src.api.handlers.base.utils import get_format_converter_registry
|
||||||
|
from src.core.api_format.conversion.internal import InternalResponse
|
||||||
|
from src.core.api_format.conversion.stream_bridge import InternalStreamAggregator
|
||||||
|
from src.core.api_format.conversion.stream_state import StreamState
|
||||||
|
from src.core.exceptions import EmbeddedErrorException
|
||||||
|
from src.core.logger import logger
|
||||||
|
from src.services.provider.envelope import ProviderEnvelope
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sse_data_line(line: str) -> tuple[Any | None, str]:
|
||||||
|
"""Parse `data: {...}` as JSON."""
|
||||||
|
payload = line[5:].strip()
|
||||||
|
if not payload:
|
||||||
|
return None, "empty"
|
||||||
|
try:
|
||||||
|
return json.loads(payload), "ok"
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None, "invalid"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sse_event_data_line(line: str) -> tuple[Any | None, str]:
|
||||||
|
"""Parse `event: xxx data: {...}` as JSON (best-effort)."""
|
||||||
|
# Split only on the first " data:" occurrence.
|
||||||
|
try:
|
||||||
|
_, data_part = line.split(" data:", 1)
|
||||||
|
except ValueError:
|
||||||
|
return None, "invalid"
|
||||||
|
payload = data_part.strip()
|
||||||
|
if not payload:
|
||||||
|
return None, "empty"
|
||||||
|
try:
|
||||||
|
return json.loads(payload), "ok"
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None, "invalid"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_gemini_json_array_line(line: str) -> tuple[Any | None, str]:
|
||||||
|
"""Parse Gemini CLI JSON-array streaming line (best-effort).
|
||||||
|
|
||||||
|
Gemini CLI may stream objects in a JSON array form like:
|
||||||
|
- "[{...},"
|
||||||
|
- " {...},"
|
||||||
|
- " {...}]"
|
||||||
|
"""
|
||||||
|
stripped = (line or "").strip()
|
||||||
|
if not stripped:
|
||||||
|
return None, "empty"
|
||||||
|
|
||||||
|
# Quick filter: must contain a JSON object boundary.
|
||||||
|
if "{" not in stripped:
|
||||||
|
return None, "skip"
|
||||||
|
|
||||||
|
candidate = stripped.lstrip(",").rstrip(",").strip()
|
||||||
|
# Drop array brackets on edges.
|
||||||
|
if candidate.startswith("["):
|
||||||
|
candidate = candidate[1:].strip()
|
||||||
|
if candidate.endswith("]"):
|
||||||
|
candidate = candidate[:-1].strip()
|
||||||
|
candidate = candidate.lstrip(",").rstrip(",").strip()
|
||||||
|
|
||||||
|
if not candidate:
|
||||||
|
return None, "empty"
|
||||||
|
try:
|
||||||
|
return json.loads(candidate), "ok"
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.debug(f"Gemini JSON-array line skip: {stripped[:50]}")
|
||||||
|
return None, "invalid"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_provider_stream_line_to_json(
|
||||||
|
line: str,
|
||||||
|
provider_format: str,
|
||||||
|
) -> tuple[Any | None, str]:
|
||||||
|
"""Best-effort parse for upstream streaming lines (SSE or Gemini JSON-array)."""
|
||||||
|
|
||||||
|
if not line:
|
||||||
|
return None, "skip"
|
||||||
|
|
||||||
|
normalized = line.rstrip("\r").strip("\n")
|
||||||
|
if not normalized or normalized.strip() == "":
|
||||||
|
return None, "skip"
|
||||||
|
|
||||||
|
# Standard SSE data line.
|
||||||
|
if normalized.startswith("data:"):
|
||||||
|
# `data: [DONE]` is a sentinel.
|
||||||
|
if normalized[5:].strip() == "[DONE]":
|
||||||
|
return None, "skip"
|
||||||
|
return _parse_sse_data_line(normalized)
|
||||||
|
|
||||||
|
# event + data on same line.
|
||||||
|
if normalized.startswith("event:") and " data:" in normalized:
|
||||||
|
return _parse_sse_event_data_line(normalized)
|
||||||
|
|
||||||
|
# Other control lines.
|
||||||
|
if normalized.startswith(("event:", "id:", "retry:")):
|
||||||
|
return None, "skip"
|
||||||
|
|
||||||
|
# Gemini JSON-array/chunked streaming (no SSE prefix).
|
||||||
|
if str(provider_format or "").strip().lower().startswith("gemini"):
|
||||||
|
return _parse_gemini_json_array_line(normalized)
|
||||||
|
|
||||||
|
return None, "skip"
|
||||||
|
|
||||||
|
|
||||||
|
async def aggregate_upstream_stream_to_internal_response(
|
||||||
|
byte_iter: AsyncIterator[bytes],
|
||||||
|
*,
|
||||||
|
provider_api_format: str,
|
||||||
|
provider_name: str,
|
||||||
|
model: str,
|
||||||
|
request_id: str,
|
||||||
|
envelope: ProviderEnvelope | None = None,
|
||||||
|
provider_parser: ResponseParser | None = None,
|
||||||
|
) -> InternalResponse:
|
||||||
|
"""Aggregate upstream SSE/streaming bytes into an InternalResponse (best-effort)."""
|
||||||
|
|
||||||
|
registry = get_format_converter_registry()
|
||||||
|
src_norm = registry.get_normalizer(provider_api_format) if provider_api_format else None
|
||||||
|
if src_norm is None:
|
||||||
|
raise RuntimeError(f"未注册 Normalizer: {provider_api_format}")
|
||||||
|
if not getattr(src_norm, "capabilities", None) or not src_norm.capabilities.supports_stream:
|
||||||
|
raise RuntimeError(f"上游格式不支持流式: {provider_api_format}")
|
||||||
|
|
||||||
|
state = StreamState(model=str(model or ""), message_id=str(request_id or ""))
|
||||||
|
aggregator = InternalStreamAggregator(
|
||||||
|
fallback_id=str(request_id or "resp"),
|
||||||
|
fallback_model=str(model or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
buffer = b""
|
||||||
|
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
||||||
|
|
||||||
|
def _feed_line(normalized_line: str) -> None:
|
||||||
|
data_obj, st = parse_provider_stream_line_to_json(normalized_line, provider_api_format)
|
||||||
|
if st != "ok" or data_obj is None:
|
||||||
|
return
|
||||||
|
if not isinstance(data_obj, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
if envelope:
|
||||||
|
unwrapped = envelope.unwrap_response(data_obj)
|
||||||
|
if not isinstance(unwrapped, dict):
|
||||||
|
return
|
||||||
|
data_obj = unwrapped
|
||||||
|
envelope.postprocess_unwrapped_response(model=model, data=data_obj)
|
||||||
|
|
||||||
|
if provider_parser and provider_parser.is_error_response(data_obj):
|
||||||
|
parsed = provider_parser.parse_response(data_obj, 200)
|
||||||
|
raise EmbeddedErrorException(
|
||||||
|
provider_name=str(provider_name),
|
||||||
|
error_code=parsed.embedded_status_code,
|
||||||
|
error_message=parsed.error_message,
|
||||||
|
error_status=parsed.error_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal_events = src_norm.stream_chunk_to_internal(data_obj, state)
|
||||||
|
aggregator.feed(internal_events)
|
||||||
|
|
||||||
|
async for chunk in byte_iter:
|
||||||
|
buffer += chunk
|
||||||
|
while b"\n" in buffer:
|
||||||
|
line_bytes, buffer = buffer.split(b"\n", 1)
|
||||||
|
line = decoder.decode(line_bytes + b"\n", False).rstrip("\n")
|
||||||
|
normalized_line = line.rstrip("\r")
|
||||||
|
|
||||||
|
_feed_line(normalized_line)
|
||||||
|
|
||||||
|
# Flush remaining buffered bytes (in case upstream doesn't end with newline).
|
||||||
|
if buffer:
|
||||||
|
try:
|
||||||
|
tail = decoder.decode(buffer, True)
|
||||||
|
except Exception:
|
||||||
|
tail = ""
|
||||||
|
normalized_tail = (tail or "").rstrip("\r\n")
|
||||||
|
if normalized_tail:
|
||||||
|
_feed_line(normalized_tail)
|
||||||
|
|
||||||
|
return aggregator.build()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"aggregate_upstream_stream_to_internal_response",
|
||||||
|
"parse_provider_stream_line_to_json",
|
||||||
|
]
|
||||||
@@ -96,7 +96,12 @@ class GeminiCliMessageHandler(CliMessageHandlerBase):
|
|||||||
# 优先使用映射后的模型名,否则使用请求体中的
|
# 优先使用映射后的模型名,否则使用请求体中的
|
||||||
return mapped_model or request_body.get("model")
|
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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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]]:
|
||||||
|
|||||||
262
src/core/api_format/conversion/stream_bridge.py
Normal file
262
src/core/api_format/conversion/stream_bridge.py
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
"""Sync<->stream bridge helpers for the conversion layer.
|
||||||
|
|
||||||
|
We already have:
|
||||||
|
- streaming conversion: source stream chunk -> internal events -> target stream chunk
|
||||||
|
- sync conversion: source response -> internal response -> target response
|
||||||
|
|
||||||
|
This module fills the missing link:
|
||||||
|
- aggregate internal stream events into a single InternalResponse (stream -> sync)
|
||||||
|
- expand an InternalResponse into internal stream events (sync -> stream)
|
||||||
|
|
||||||
|
Used by handler-layer upstream policies that force upstream streaming mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Iterable, Iterator
|
||||||
|
|
||||||
|
from .internal import (
|
||||||
|
ContentType,
|
||||||
|
ImageBlock,
|
||||||
|
InternalResponse,
|
||||||
|
StopReason,
|
||||||
|
TextBlock,
|
||||||
|
ToolUseBlock,
|
||||||
|
UsageInfo,
|
||||||
|
)
|
||||||
|
from .stream_events import (
|
||||||
|
ContentBlockStartEvent,
|
||||||
|
ContentBlockStopEvent,
|
||||||
|
ContentDeltaEvent,
|
||||||
|
InternalStreamEvent,
|
||||||
|
MessageStartEvent,
|
||||||
|
MessageStopEvent,
|
||||||
|
ToolCallDeltaEvent,
|
||||||
|
UsageEvent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _BlockBuilder:
|
||||||
|
block_type: ContentType
|
||||||
|
text: str = ""
|
||||||
|
tool_id: str | None = None
|
||||||
|
tool_name: str | None = None
|
||||||
|
tool_args_json: str = ""
|
||||||
|
image_data: str | None = None
|
||||||
|
image_media_type: str | None = None
|
||||||
|
extra: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def finalize(self) -> Any:
|
||||||
|
if self.block_type == ContentType.TEXT:
|
||||||
|
return TextBlock(text=self.text, extra=self.extra)
|
||||||
|
|
||||||
|
if self.block_type == ContentType.TOOL_USE:
|
||||||
|
tool_input: dict[str, Any] = {}
|
||||||
|
raw = self.tool_args_json.strip()
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
tool_input = parsed
|
||||||
|
except Exception:
|
||||||
|
tool_input = {}
|
||||||
|
return ToolUseBlock(
|
||||||
|
tool_id=str(self.tool_id or ""),
|
||||||
|
tool_name=str(self.tool_name or ""),
|
||||||
|
tool_input=tool_input,
|
||||||
|
extra=self.extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.block_type == ContentType.IMAGE:
|
||||||
|
return ImageBlock(
|
||||||
|
data=self.image_data,
|
||||||
|
media_type=self.image_media_type,
|
||||||
|
url=None,
|
||||||
|
extra=self.extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unknown block type: best-effort drop.
|
||||||
|
return TextBlock(text=self.text, extra=self.extra)
|
||||||
|
|
||||||
|
|
||||||
|
class InternalStreamAggregator:
|
||||||
|
"""Aggregate internal stream events into a single InternalResponse (best-effort)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
fallback_id: str = "resp",
|
||||||
|
fallback_model: str = "",
|
||||||
|
) -> None:
|
||||||
|
self._fallback_id = fallback_id
|
||||||
|
self._fallback_model = fallback_model
|
||||||
|
|
||||||
|
self._id: str | None = None
|
||||||
|
self._model: str | None = None
|
||||||
|
self._stop_reason: StopReason | None = None
|
||||||
|
self._usage: UsageInfo | None = None
|
||||||
|
|
||||||
|
self._open: dict[int, _BlockBuilder] = {}
|
||||||
|
self._final: dict[int, Any] = {}
|
||||||
|
|
||||||
|
def feed(self, events: Iterable[InternalStreamEvent]) -> None:
|
||||||
|
for ev in events:
|
||||||
|
if isinstance(ev, MessageStartEvent):
|
||||||
|
if ev.message_id:
|
||||||
|
self._id = ev.message_id
|
||||||
|
if ev.model:
|
||||||
|
self._model = ev.model
|
||||||
|
if ev.usage:
|
||||||
|
self._usage = ev.usage
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(ev, UsageEvent):
|
||||||
|
if ev.usage:
|
||||||
|
self._usage = ev.usage
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(ev, ContentBlockStartEvent):
|
||||||
|
b = _BlockBuilder(block_type=ev.block_type, extra=dict(ev.extra or {}))
|
||||||
|
if ev.block_type == ContentType.TOOL_USE:
|
||||||
|
b.tool_id = ev.tool_id
|
||||||
|
b.tool_name = ev.tool_name
|
||||||
|
if ev.block_type == ContentType.IMAGE:
|
||||||
|
b.image_data = b.extra.get("image_data") or b.extra.get("data")
|
||||||
|
b.image_media_type = b.extra.get("image_media_type") or b.extra.get("mime_type")
|
||||||
|
self._open[int(ev.block_index)] = b
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(ev, ContentDeltaEvent):
|
||||||
|
idx = int(ev.block_index)
|
||||||
|
b = self._open.get(idx)
|
||||||
|
if b is None:
|
||||||
|
b = _BlockBuilder(block_type=ContentType.TEXT)
|
||||||
|
self._open[idx] = b
|
||||||
|
if ev.text_delta:
|
||||||
|
b.text += ev.text_delta
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(ev, ToolCallDeltaEvent):
|
||||||
|
idx = int(ev.block_index)
|
||||||
|
b = self._open.get(idx)
|
||||||
|
if b is None:
|
||||||
|
b = _BlockBuilder(block_type=ContentType.TOOL_USE)
|
||||||
|
self._open[idx] = b
|
||||||
|
if ev.input_delta:
|
||||||
|
b.tool_args_json += ev.input_delta
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(ev, ContentBlockStopEvent):
|
||||||
|
idx = int(ev.block_index)
|
||||||
|
b = self._open.pop(idx, None)
|
||||||
|
if b is not None:
|
||||||
|
self._final.setdefault(idx, b.finalize())
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(ev, MessageStopEvent):
|
||||||
|
self._stop_reason = ev.stop_reason
|
||||||
|
if ev.usage:
|
||||||
|
self._usage = ev.usage
|
||||||
|
# Flush remaining open blocks (best-effort).
|
||||||
|
for idx, b in list(self._open.items()):
|
||||||
|
self._final.setdefault(idx, b.finalize())
|
||||||
|
self._open.clear()
|
||||||
|
continue
|
||||||
|
|
||||||
|
def build(self) -> InternalResponse:
|
||||||
|
rid = self._id or self._fallback_id
|
||||||
|
model = self._model or self._fallback_model
|
||||||
|
content = [self._final[k] for k in sorted(self._final.keys())]
|
||||||
|
return InternalResponse(
|
||||||
|
id=str(rid or "resp"),
|
||||||
|
model=str(model or ""),
|
||||||
|
content=content,
|
||||||
|
stop_reason=self._stop_reason,
|
||||||
|
usage=self._usage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_internal_response_as_stream_events(
|
||||||
|
internal: InternalResponse,
|
||||||
|
*,
|
||||||
|
chunk_text: bool = False,
|
||||||
|
text_chunk_size: int = 200,
|
||||||
|
) -> Iterator[InternalStreamEvent]:
|
||||||
|
"""Expand an InternalResponse into internal stream events (best-effort).
|
||||||
|
|
||||||
|
This is used to simulate SSE when the upstream is forced to sync mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
msg_id = str(internal.id or "resp")
|
||||||
|
model = str(internal.model or "")
|
||||||
|
|
||||||
|
yield MessageStartEvent(message_id=msg_id, model=model)
|
||||||
|
|
||||||
|
block_index = 0
|
||||||
|
for block in internal.content or []:
|
||||||
|
# Text
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
yield ContentBlockStartEvent(block_index=block_index, block_type=ContentType.TEXT)
|
||||||
|
text = str(block.text or "")
|
||||||
|
if not chunk_text or text_chunk_size <= 0:
|
||||||
|
if text:
|
||||||
|
yield ContentDeltaEvent(block_index=block_index, text_delta=text)
|
||||||
|
else:
|
||||||
|
for i in range(0, len(text), text_chunk_size):
|
||||||
|
part = text[i : i + text_chunk_size]
|
||||||
|
if part:
|
||||||
|
yield ContentDeltaEvent(block_index=block_index, text_delta=part)
|
||||||
|
yield ContentBlockStopEvent(block_index=block_index)
|
||||||
|
block_index += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Tool use
|
||||||
|
if isinstance(block, ToolUseBlock):
|
||||||
|
tool_id = block.tool_id or f"tool_{block_index}"
|
||||||
|
yield ContentBlockStartEvent(
|
||||||
|
block_index=block_index,
|
||||||
|
block_type=ContentType.TOOL_USE,
|
||||||
|
tool_id=tool_id,
|
||||||
|
tool_name=block.tool_name or None,
|
||||||
|
)
|
||||||
|
payload = {}
|
||||||
|
if isinstance(block.tool_input, dict):
|
||||||
|
payload = block.tool_input
|
||||||
|
yield ToolCallDeltaEvent(
|
||||||
|
block_index=block_index,
|
||||||
|
tool_id=str(tool_id),
|
||||||
|
input_delta=json.dumps(payload, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
yield ContentBlockStopEvent(block_index=block_index)
|
||||||
|
block_index += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Image
|
||||||
|
if isinstance(block, ImageBlock):
|
||||||
|
yield ContentBlockStartEvent(
|
||||||
|
block_index=block_index,
|
||||||
|
block_type=ContentType.IMAGE,
|
||||||
|
extra={
|
||||||
|
"image_data": block.data,
|
||||||
|
"image_media_type": block.media_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
yield ContentBlockStopEvent(block_index=block_index)
|
||||||
|
block_index += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Unknown blocks: ignore.
|
||||||
|
block_index += 1
|
||||||
|
|
||||||
|
yield MessageStopEvent(
|
||||||
|
stop_reason=internal.stop_reason or StopReason.END_TURN, usage=internal.usage
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"InternalStreamAggregator",
|
||||||
|
"iter_internal_response_as_stream_events",
|
||||||
|
]
|
||||||
@@ -80,3 +80,36 @@ format_conversion_duration_seconds = Histogram(
|
|||||||
["direction", "source_format", "target_format"],
|
["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"],
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/services/antigravity/__init__.py
Normal file
1
src/services/antigravity/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Antigravity integration package."""
|
||||||
75
src/services/antigravity/client.py
Normal file
75
src/services/antigravity/client.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Antigravity API 客户端(最小封装)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.clients.http_client import HTTPClientPool
|
||||||
|
from src.services.antigravity.constants import (
|
||||||
|
DAILY_BASE_URL,
|
||||||
|
HTTP_USER_AGENT,
|
||||||
|
PROD_BASE_URL,
|
||||||
|
)
|
||||||
|
from src.services.antigravity.url_availability import url_availability
|
||||||
|
|
||||||
|
|
||||||
|
async def load_code_assist(
|
||||||
|
access_token: str,
|
||||||
|
proxy_config: dict[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = 10.0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""调用 /v1internal:loadCodeAssist 获取账户信息。
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- email 需通过 Google userinfo API 获取(由 enrich_auth_config 复用已有逻辑)
|
||||||
|
- 这里仅负责 project_id / tier 等信息
|
||||||
|
- 使用 url_availability 决定优先尝试的 URL
|
||||||
|
"""
|
||||||
|
if not access_token:
|
||||||
|
raise ValueError("missing access_token")
|
||||||
|
|
||||||
|
client = await HTTPClientPool.get_proxy_client(proxy_config)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"User-Agent": HTTP_USER_AGENT,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
body = {"metadata": {"ideType": "ANTIGRAVITY"}}
|
||||||
|
|
||||||
|
# 使用可用性排序(prod 优先,但会参考历史成功/失败记录)
|
||||||
|
urls = url_availability.get_ordered_urls(prefer_daily=False)
|
||||||
|
if not urls:
|
||||||
|
urls = [PROD_BASE_URL, DAILY_BASE_URL]
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
|
||||||
|
for base_url in urls:
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{base_url}/v1internal:loadCodeAssist",
|
||||||
|
json=body,
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
)
|
||||||
|
if 200 <= resp.status_code < 300:
|
||||||
|
url_availability.mark_success(base_url)
|
||||||
|
data = resp.json()
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
# 非 2xx:标记不可用并继续 fallback
|
||||||
|
if resp.status_code in (429, 500, 502, 503, 504):
|
||||||
|
url_availability.mark_unavailable(base_url)
|
||||||
|
last_exc = RuntimeError(
|
||||||
|
f"loadCodeAssist failed: status={resp.status_code} base_url={base_url}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
url_availability.mark_unavailable(base_url)
|
||||||
|
last_exc = e
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise last_exc or RuntimeError("loadCodeAssist failed")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["load_code_assist"]
|
||||||
40
src/services/antigravity/constants.py
Normal file
40
src/services/antigravity/constants.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Antigravity 全局常量定义。
|
||||||
|
|
||||||
|
注意:这里的 PROVIDER_TYPE 指的是 Provider.provider_type(用于路由与特判),
|
||||||
|
不是 endpoint signature(family:kind)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# ============== Provider 标识 ==============
|
||||||
|
PROVIDER_TYPE = "antigravity"
|
||||||
|
|
||||||
|
# ============== API 端点 ==============
|
||||||
|
PROD_BASE_URL = "https://cloudcode-pa.googleapis.com"
|
||||||
|
DAILY_BASE_URL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
||||||
|
|
||||||
|
# ============== User-Agent ==============
|
||||||
|
# HTTP Header
|
||||||
|
HTTP_USER_AGENT = "antigravity/1.15.8 windows/amd64"
|
||||||
|
# V1InternalRequest.userAgent 字段
|
||||||
|
REQUEST_USER_AGENT = "antigravity"
|
||||||
|
|
||||||
|
# ============== URL 可用性 ==============
|
||||||
|
URL_UNAVAILABLE_TTL_SECONDS = 300 # 5 分钟
|
||||||
|
|
||||||
|
# ============== Thinking Signature ==============
|
||||||
|
DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
|
||||||
|
|
||||||
|
# ============== v1internal 路径 ==============
|
||||||
|
V1INTERNAL_PATH_TEMPLATE = "/v1internal:{action}"
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DAILY_BASE_URL",
|
||||||
|
"DUMMY_THOUGHT_SIGNATURE",
|
||||||
|
"HTTP_USER_AGENT",
|
||||||
|
"PROD_BASE_URL",
|
||||||
|
"PROVIDER_TYPE",
|
||||||
|
"REQUEST_USER_AGENT",
|
||||||
|
"URL_UNAVAILABLE_TTL_SECONDS",
|
||||||
|
"V1INTERNAL_PATH_TEMPLATE",
|
||||||
|
]
|
||||||
177
src/services/antigravity/envelope.py
Normal file
177
src/services/antigravity/envelope.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""Antigravity v1internal request/response envelope helpers.
|
||||||
|
|
||||||
|
Antigravity reuses the `gemini:cli` endpoint signature but wraps the actual
|
||||||
|
wire format:
|
||||||
|
- Request: V1InternalRequest (top-level metadata + nested GeminiRequest)
|
||||||
|
- Response: V1InternalResponse (top-level responseId + nested GeminiResponse)
|
||||||
|
|
||||||
|
We keep this logic isolated so other providers can reuse the same envelope hook
|
||||||
|
pattern.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.services.antigravity.constants import HTTP_USER_AGENT as ANTIGRAVITY_HTTP_USER_AGENT
|
||||||
|
from src.services.antigravity.constants import REQUEST_USER_AGENT as ANTIGRAVITY_REQUEST_USER_AGENT
|
||||||
|
from src.services.antigravity.url_availability import url_availability
|
||||||
|
from src.services.provider.request_context import get_selected_base_url
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_v1internal_request(
|
||||||
|
gemini_request: dict[str, Any],
|
||||||
|
*,
|
||||||
|
project_id: str,
|
||||||
|
model: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Wrap a GeminiRequest into Antigravity V1InternalRequest.
|
||||||
|
|
||||||
|
Note: Antigravity expects `model` at top-level; the nested request must not
|
||||||
|
include `model` again.
|
||||||
|
"""
|
||||||
|
|
||||||
|
inner_request = dict(gemini_request)
|
||||||
|
inner_request.pop("model", None)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"project": project_id,
|
||||||
|
"requestId": str(uuid.uuid4()),
|
||||||
|
"userAgent": ANTIGRAVITY_REQUEST_USER_AGENT,
|
||||||
|
"requestType": "agent",
|
||||||
|
"model": model,
|
||||||
|
"request": inner_request,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def unwrap_v1internal_response(response: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Unwrap Antigravity V1InternalResponse into a GeminiResponse-like dict."""
|
||||||
|
|
||||||
|
inner = response.get("response")
|
||||||
|
if isinstance(inner, dict):
|
||||||
|
unwrapped = dict(inner)
|
||||||
|
resp_id = response.get("responseId")
|
||||||
|
if resp_id is not None:
|
||||||
|
unwrapped["_v1internal_response_id"] = resp_id
|
||||||
|
return unwrapped
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def cache_thought_signatures(model: str, response: dict[str, Any]) -> None:
|
||||||
|
"""Best-effort cache for Antigravity thought signatures."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.services.antigravity.signature_cache import signature_cache
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
candidates = response.get("candidates")
|
||||||
|
if not isinstance(candidates, list):
|
||||||
|
return
|
||||||
|
|
||||||
|
for cand in candidates:
|
||||||
|
if not isinstance(cand, dict):
|
||||||
|
continue
|
||||||
|
content = cand.get("content")
|
||||||
|
if not isinstance(content, dict):
|
||||||
|
continue
|
||||||
|
parts = content.get("parts")
|
||||||
|
if not isinstance(parts, list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
continue
|
||||||
|
text = part.get("text")
|
||||||
|
if not isinstance(text, str) or not text:
|
||||||
|
continue
|
||||||
|
sig = (
|
||||||
|
part.get("thoughtSignature")
|
||||||
|
or part.get("thought_signature")
|
||||||
|
or part.get("signature")
|
||||||
|
)
|
||||||
|
if not isinstance(sig, str) or not sig:
|
||||||
|
continue
|
||||||
|
signature_cache.cache(model, text, sig)
|
||||||
|
except Exception:
|
||||||
|
# Never fail request path due to cache issues.
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
class AntigravityV1InternalEnvelope:
|
||||||
|
"""Provider envelope hooks for Antigravity v1internal wrapper."""
|
||||||
|
|
||||||
|
name = "antigravity:v1internal"
|
||||||
|
|
||||||
|
def extra_headers(self) -> dict[str, str] | None:
|
||||||
|
return {"User-Agent": ANTIGRAVITY_HTTP_USER_AGENT}
|
||||||
|
|
||||||
|
def wrap_request(
|
||||||
|
self,
|
||||||
|
request_body: dict[str, Any],
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
url_model: str | None,
|
||||||
|
decrypted_auth_config: dict[str, Any] | None,
|
||||||
|
) -> tuple[dict[str, Any], str | None]:
|
||||||
|
project_id = (decrypted_auth_config or {}).get("project_id")
|
||||||
|
if not isinstance(project_id, str) or not project_id:
|
||||||
|
from src.core.exceptions import ProviderNotAvailableException
|
||||||
|
|
||||||
|
raise ProviderNotAvailableException(
|
||||||
|
"Antigravity OAuth 配置缺少 project_id,请重新授权",
|
||||||
|
provider_name="antigravity",
|
||||||
|
upstream_response="missing auth_config.project_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
wrapped = wrap_v1internal_request(
|
||||||
|
request_body,
|
||||||
|
project_id=project_id,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Antigravity's model lives in the request body, not the URL path.
|
||||||
|
return wrapped, None
|
||||||
|
|
||||||
|
def unwrap_response(self, data: Any) -> Any:
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return unwrap_v1internal_response(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def postprocess_unwrapped_response(self, *, model: str, data: Any) -> None:
|
||||||
|
if isinstance(data, dict):
|
||||||
|
cache_thought_signatures(model, data)
|
||||||
|
|
||||||
|
def capture_selected_base_url(self) -> str | None:
|
||||||
|
return get_selected_base_url()
|
||||||
|
|
||||||
|
def on_http_status(self, *, base_url: str | None, status_code: int) -> None:
|
||||||
|
if not base_url:
|
||||||
|
return
|
||||||
|
if status_code == 200:
|
||||||
|
url_availability.mark_success(base_url)
|
||||||
|
elif status_code in (429, 500, 502, 503, 504):
|
||||||
|
url_availability.mark_unavailable(base_url)
|
||||||
|
|
||||||
|
def on_connection_error(self, *, base_url: str | None, exc: Exception) -> None: # noqa: ARG002
|
||||||
|
if not base_url:
|
||||||
|
return
|
||||||
|
url_availability.mark_unavailable(base_url)
|
||||||
|
|
||||||
|
def force_stream_rewrite(self) -> bool:
|
||||||
|
# Streaming must be rewritten even when endpoint signature matches, because
|
||||||
|
# Antigravity wraps chunks in v1internal envelope.
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
antigravity_v1internal_envelope = AntigravityV1InternalEnvelope()
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AntigravityV1InternalEnvelope",
|
||||||
|
"antigravity_v1internal_envelope",
|
||||||
|
"cache_thought_signatures",
|
||||||
|
"unwrap_v1internal_response",
|
||||||
|
"wrap_v1internal_request",
|
||||||
|
]
|
||||||
58
src/services/antigravity/signature_cache.py
Normal file
58
src/services/antigravity/signature_cache.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""Antigravity thinking block signature cache (minimal)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from src.services.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
|
||||||
|
|
||||||
|
|
||||||
|
class ThinkingSignatureCache:
|
||||||
|
"""缓存 thinking block 签名。
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 优先使用缓存(比客户端透传更可靠)
|
||||||
|
- Gemini 模型允许使用 dummy signature 作为兜底(跳过验证)
|
||||||
|
- 线程安全
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, maxsize: int = 1000) -> None:
|
||||||
|
self._cache: dict[str, str] = {}
|
||||||
|
self._maxsize = maxsize
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def get_or_dummy(self, model: str, thinking_text: str) -> str | None:
|
||||||
|
key = self._key(model, thinking_text)
|
||||||
|
with self._lock:
|
||||||
|
cached = self._cache.get(key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
if str(model).startswith("gemini-"):
|
||||||
|
return DUMMY_THOUGHT_SIGNATURE
|
||||||
|
return None
|
||||||
|
|
||||||
|
def cache(self, model: str, thinking_text: str, signature: str) -> None:
|
||||||
|
key = self._key(model, thinking_text)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if key in self._cache:
|
||||||
|
self._cache[key] = signature
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(self._cache) >= self._maxsize:
|
||||||
|
# 简单 FIFO 清理(dict 保持插入顺序)
|
||||||
|
evict_n = max(1, self._maxsize // 4)
|
||||||
|
for k in list(self._cache.keys())[:evict_n]:
|
||||||
|
self._cache.pop(k, None)
|
||||||
|
|
||||||
|
self._cache[key] = signature
|
||||||
|
|
||||||
|
def _key(self, model: str, thinking_text: str) -> str:
|
||||||
|
content = f"{model}:{thinking_text}"
|
||||||
|
return hashlib.sha256(content.encode("utf-8")).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
|
signature_cache = ThinkingSignatureCache()
|
||||||
|
|
||||||
|
__all__ = ["ThinkingSignatureCache", "signature_cache"]
|
||||||
78
src/services/antigravity/url_availability.py
Normal file
78
src/services/antigravity/url_availability.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Antigravity URL 可用性管理(带 TTL 自动恢复)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from src.services.antigravity.constants import (
|
||||||
|
DAILY_BASE_URL,
|
||||||
|
PROD_BASE_URL,
|
||||||
|
URL_UNAVAILABLE_TTL_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class URLAvailability:
|
||||||
|
"""管理 Antigravity API 端点可用性(进程内)。"""
|
||||||
|
|
||||||
|
_instance: "URLAvailability | None" = None
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
def __new__(cls) -> "URLAvailability":
|
||||||
|
if cls._instance is None:
|
||||||
|
with cls._lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._init()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def _init(self) -> None:
|
||||||
|
self._unavailable: dict[str, float] = {} # url -> recover_at(ts)
|
||||||
|
self._last_success: str | None = None
|
||||||
|
self._mu = threading.RLock()
|
||||||
|
|
||||||
|
def _prune(self, now: float | None = None) -> None:
|
||||||
|
now_ts = time.time() if now is None else now
|
||||||
|
self._unavailable = {u: t for u, t in self._unavailable.items() if t > now_ts}
|
||||||
|
|
||||||
|
def is_available(self, url: str) -> bool:
|
||||||
|
with self._mu:
|
||||||
|
self._prune()
|
||||||
|
return url not in self._unavailable
|
||||||
|
|
||||||
|
def get_ordered_urls(self, *, prefer_daily: bool = True) -> list[str]:
|
||||||
|
"""返回优先级排序的可用 URL 列表。
|
||||||
|
|
||||||
|
- 默认 daily 优先(通常限流更宽松)
|
||||||
|
- 最近成功的 URL 会被提升到最前
|
||||||
|
- 若全部被标记不可用,则返回 base_order(允许继续尝试,等待 TTL 自动恢复)
|
||||||
|
"""
|
||||||
|
with self._mu:
|
||||||
|
self._prune()
|
||||||
|
|
||||||
|
base_order = (
|
||||||
|
[DAILY_BASE_URL, PROD_BASE_URL] if prefer_daily else [PROD_BASE_URL, DAILY_BASE_URL]
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._last_success and self._last_success in base_order:
|
||||||
|
base_order.remove(self._last_success)
|
||||||
|
base_order.insert(0, self._last_success)
|
||||||
|
|
||||||
|
available = [u for u in base_order if u not in self._unavailable]
|
||||||
|
return available if available else base_order
|
||||||
|
|
||||||
|
def mark_success(self, url: str) -> None:
|
||||||
|
with self._mu:
|
||||||
|
self._last_success = url
|
||||||
|
self._unavailable.pop(url, None)
|
||||||
|
|
||||||
|
def mark_unavailable(self, url: str) -> None:
|
||||||
|
with self._mu:
|
||||||
|
self._unavailable[url] = time.time() + URL_UNAVAILABLE_TTL_SECONDS
|
||||||
|
if self._last_success == url:
|
||||||
|
self._last_success = None
|
||||||
|
|
||||||
|
|
||||||
|
url_availability = URLAvailability()
|
||||||
|
|
||||||
|
__all__ = ["URLAvailability", "url_availability"]
|
||||||
3
src/services/codex/__init__.py
Normal file
3
src/services/codex/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Codex provider integration package."""
|
||||||
|
|
||||||
|
__all__ = []
|
||||||
78
src/services/codex/envelope.py
Normal file
78
src/services/codex/envelope.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Codex upstream envelope hooks.
|
||||||
|
|
||||||
|
Codex OAuth upstreams (e.g. `chatgpt.com/backend-api/codex`) behave like the OpenAI
|
||||||
|
Responses API (`openai:cli`) but may require additional transport-level headers
|
||||||
|
to avoid upstream blocks (Cloudflare, etc.).
|
||||||
|
|
||||||
|
Request/response shape quirks should live in the conversion layer as a same-format
|
||||||
|
variant (`target_variant="codex"` in the `openai:cli` normalizer). This envelope
|
||||||
|
only adds headers and keeps the rest as a no-op wrapper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.config.settings import config
|
||||||
|
from src.services.provider.request_context import get_selected_base_url
|
||||||
|
|
||||||
|
|
||||||
|
class CodexOAuthEnvelope:
|
||||||
|
"""Provider envelope hooks for Codex OAuth upstream."""
|
||||||
|
|
||||||
|
name = "codex:oauth"
|
||||||
|
|
||||||
|
def extra_headers(self) -> dict[str, str] | None:
|
||||||
|
# These headers are best-effort: Codex upstream is stricter than public OpenAI API.
|
||||||
|
# Keep them provider-scoped (via ProviderEnvelope) to avoid leaking to other upstreams.
|
||||||
|
headers: dict[str, str] = {
|
||||||
|
"x-oai-web-search-eligible": "true",
|
||||||
|
"session_id": str(uuid.uuid4()),
|
||||||
|
"originator": "codex_cli_rs",
|
||||||
|
# Ensure SSE is returned when upstream is forced to streaming mode.
|
||||||
|
"Accept": "text/event-stream",
|
||||||
|
}
|
||||||
|
|
||||||
|
ua = str(getattr(config, "internal_user_agent_openai_cli", "") or "").strip()
|
||||||
|
if ua:
|
||||||
|
headers["User-Agent"] = ua
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def wrap_request(
|
||||||
|
self,
|
||||||
|
request_body: dict[str, Any],
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
url_model: str | None,
|
||||||
|
decrypted_auth_config: dict[str, Any] | None,
|
||||||
|
) -> tuple[dict[str, Any], str | None]:
|
||||||
|
# No wire envelope for Codex; keep request body as-is.
|
||||||
|
_ = model, decrypted_auth_config
|
||||||
|
return request_body, url_model
|
||||||
|
|
||||||
|
def unwrap_response(self, data: Any) -> Any:
|
||||||
|
# No response envelope for Codex.
|
||||||
|
return data
|
||||||
|
|
||||||
|
def postprocess_unwrapped_response(self, *, model: str, data: Any) -> None: # noqa: ARG002
|
||||||
|
return
|
||||||
|
|
||||||
|
def capture_selected_base_url(self) -> str | None:
|
||||||
|
# Keep interface consistent with Antigravity. Transport currently doesn't set this for Codex.
|
||||||
|
return get_selected_base_url()
|
||||||
|
|
||||||
|
def on_http_status(self, *, base_url: str | None, status_code: int) -> None: # noqa: ARG002
|
||||||
|
return
|
||||||
|
|
||||||
|
def on_connection_error(self, *, base_url: str | None, exc: Exception) -> None: # noqa: ARG002
|
||||||
|
return
|
||||||
|
|
||||||
|
def force_stream_rewrite(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
codex_oauth_envelope = CodexOAuthEnvelope()
|
||||||
|
|
||||||
|
__all__ = ["CodexOAuthEnvelope", "codex_oauth_envelope"]
|
||||||
@@ -13,6 +13,7 @@ Thinking 整流器(Rectifier)
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
import 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]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
48
src/services/provider/behavior.py
Normal file
48
src/services/provider/behavior.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Provider behavior resolver.
|
||||||
|
|
||||||
|
Keep provider-specific quirks centralized so handler code stays generic.
|
||||||
|
|
||||||
|
Concepts:
|
||||||
|
- envelope: wire-level request/response wrappers and transport side-effects
|
||||||
|
- same_format_variant: subtle same-format differences (e.g. Codex)
|
||||||
|
- cross_format_variant: cross-format conversion tweaks (e.g. Antigravity thinking blocks)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from src.services.provider.envelope import ProviderEnvelope, get_provider_envelope
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ProviderBehavior:
|
||||||
|
provider_type: str
|
||||||
|
envelope: ProviderEnvelope | None
|
||||||
|
same_format_variant: str | None
|
||||||
|
cross_format_variant: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider_behavior(
|
||||||
|
*,
|
||||||
|
provider_type: str | None,
|
||||||
|
endpoint_sig: str | None,
|
||||||
|
) -> ProviderBehavior:
|
||||||
|
pt = str(provider_type or "").strip().lower()
|
||||||
|
envelope = get_provider_envelope(provider_type=pt, endpoint_sig=endpoint_sig)
|
||||||
|
|
||||||
|
# same-format variant: apply on top of passthrough (e.g. OpenAI Responses -> Codex quirks)
|
||||||
|
same_format_variant = pt if pt in {"codex"} else None
|
||||||
|
|
||||||
|
# cross-format variant: apply during format conversion (e.g. Claude thinking -> Gemini thought parts)
|
||||||
|
cross_format_variant = pt if pt in {"codex", "antigravity"} else None
|
||||||
|
|
||||||
|
return ProviderBehavior(
|
||||||
|
provider_type=pt,
|
||||||
|
envelope=envelope,
|
||||||
|
same_format_variant=same_format_variant,
|
||||||
|
cross_format_variant=cross_format_variant,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ProviderBehavior", "get_provider_behavior"]
|
||||||
81
src/services/provider/envelope.py
Normal file
81
src/services/provider/envelope.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Provider request/response envelope hooks.
|
||||||
|
|
||||||
|
Some upstreams expose an API that is *almost* compatible with an existing
|
||||||
|
endpoint signature (family:kind), but wrap the wire format in an extra envelope
|
||||||
|
or require small transport-level behaviors.
|
||||||
|
|
||||||
|
This module provides a small hook mechanism so handlers can stay generic while
|
||||||
|
provider-specific envelopes live in their own service modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderEnvelope(Protocol):
|
||||||
|
"""Provider-specific envelope transformation and side-effects."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def extra_headers(self) -> dict[str, str] | None:
|
||||||
|
"""Extra upstream request headers to merge into the RequestBuilder."""
|
||||||
|
|
||||||
|
def wrap_request(
|
||||||
|
self,
|
||||||
|
request_body: dict[str, Any],
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
url_model: str | None,
|
||||||
|
decrypted_auth_config: dict[str, Any] | None,
|
||||||
|
) -> tuple[dict[str, Any], str | None]:
|
||||||
|
"""Wrap request payload and optionally override url_model (e.g. move model into body)."""
|
||||||
|
|
||||||
|
def unwrap_response(self, data: Any) -> Any:
|
||||||
|
"""Unwrap upstream response payload (streaming chunk or full JSON)."""
|
||||||
|
|
||||||
|
def postprocess_unwrapped_response(self, *, model: str, data: Any) -> None:
|
||||||
|
"""Best-effort post processing after unwrap (e.g. cache signatures)."""
|
||||||
|
|
||||||
|
def capture_selected_base_url(self) -> str | None:
|
||||||
|
"""Capture the base_url selected by transport layer (if any)."""
|
||||||
|
|
||||||
|
def on_http_status(self, *, base_url: str | None, status_code: int) -> None:
|
||||||
|
"""Called after receiving upstream HTTP status code."""
|
||||||
|
|
||||||
|
def on_connection_error(self, *, base_url: str | None, exc: Exception) -> None:
|
||||||
|
"""Called when a connection-type exception happens."""
|
||||||
|
|
||||||
|
def force_stream_rewrite(self) -> bool:
|
||||||
|
"""Whether streaming should always go through the rewrite/conversion path."""
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider_envelope(
|
||||||
|
*,
|
||||||
|
provider_type: str | None,
|
||||||
|
endpoint_sig: str | None,
|
||||||
|
) -> ProviderEnvelope | None:
|
||||||
|
"""Return envelope hooks for the given provider_type + endpoint signature."""
|
||||||
|
|
||||||
|
pt = str(provider_type or "").strip().lower()
|
||||||
|
sig = str(endpoint_sig or "").strip().lower()
|
||||||
|
|
||||||
|
if not pt:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Antigravity wraps Gemini CLI responses in a v1internal envelope.
|
||||||
|
if pt == "antigravity" and (sig == "gemini:cli" or not sig):
|
||||||
|
from src.services.antigravity.envelope import antigravity_v1internal_envelope
|
||||||
|
|
||||||
|
return antigravity_v1internal_envelope
|
||||||
|
|
||||||
|
# Codex OAuth upstream requires a few fixed headers (SSE, session id, etc.).
|
||||||
|
if pt == "codex" and (sig == "openai:cli" or not sig):
|
||||||
|
from src.services.codex.envelope import codex_oauth_envelope
|
||||||
|
|
||||||
|
return codex_oauth_envelope
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ProviderEnvelope", "get_provider_envelope"]
|
||||||
28
src/services/provider/request_context.py
Normal file
28
src/services/provider/request_context.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Per-request context shared across layers.
|
||||||
|
|
||||||
|
We use `contextvars` so the transport layer (URL builder) can pass small bits of
|
||||||
|
state to the handler layer without changing existing return types.
|
||||||
|
|
||||||
|
This is intentionally minimal; only add fields that are safe and cheap to carry
|
||||||
|
per request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextvars
|
||||||
|
|
||||||
|
_selected_base_url: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
||||||
|
"provider_selected_base_url",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_selected_base_url(url: str | None) -> None:
|
||||||
|
_selected_base_url.set(url)
|
||||||
|
|
||||||
|
|
||||||
|
def get_selected_base_url() -> str | None:
|
||||||
|
return _selected_base_url.get()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["get_selected_base_url", "set_selected_base_url"]
|
||||||
139
src/services/provider/stream_policy.py
Normal file
139
src/services/provider/stream_policy.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""Upstream streaming execution policy (per endpoint).
|
||||||
|
|
||||||
|
This is about how we talk to the upstream provider, not what the client asked for.
|
||||||
|
|
||||||
|
Motivation:
|
||||||
|
- Some upstreams require streaming only (e.g. Codex Responses OAuth endpoint).
|
||||||
|
- Some upstreams do not support streaming (or are flaky with SSE).
|
||||||
|
|
||||||
|
We allow forcing upstream request mode per ProviderEndpoint, while the gateway still
|
||||||
|
returns what the client requested by doing internal sync<->stream bridging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.core.api_format.metadata import resolve_endpoint_definition
|
||||||
|
|
||||||
|
|
||||||
|
class UpstreamStreamPolicy(str, Enum):
|
||||||
|
AUTO = "auto" # follow client request
|
||||||
|
FORCE_STREAM = "force_stream"
|
||||||
|
FORCE_NON_STREAM = "force_non_stream"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_upstream_stream_policy(value: Any) -> UpstreamStreamPolicy:
|
||||||
|
if value is None:
|
||||||
|
return UpstreamStreamPolicy.AUTO
|
||||||
|
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return UpstreamStreamPolicy.FORCE_STREAM if value else UpstreamStreamPolicy.FORCE_NON_STREAM
|
||||||
|
|
||||||
|
raw = str(value).strip().lower()
|
||||||
|
if raw in {"", "auto", "follow", "client", "default"}:
|
||||||
|
return UpstreamStreamPolicy.AUTO
|
||||||
|
if raw in {"force_stream", "stream", "sse", "true", "1", "yes"}:
|
||||||
|
return UpstreamStreamPolicy.FORCE_STREAM
|
||||||
|
if raw in {"force_non_stream", "force_sync", "non_stream", "sync", "false", "0", "no"}:
|
||||||
|
return UpstreamStreamPolicy.FORCE_NON_STREAM
|
||||||
|
|
||||||
|
return UpstreamStreamPolicy.AUTO
|
||||||
|
|
||||||
|
|
||||||
|
def get_upstream_stream_policy(
|
||||||
|
endpoint: Any,
|
||||||
|
*,
|
||||||
|
provider_type: str | None = None,
|
||||||
|
endpoint_sig: str | None = None,
|
||||||
|
) -> UpstreamStreamPolicy:
|
||||||
|
"""Resolve policy for an endpoint.
|
||||||
|
|
||||||
|
Config source: endpoint.config["upstream_stream_policy"] (preferred).
|
||||||
|
|
||||||
|
Defaults:
|
||||||
|
- Codex + openai:cli: FORCE_STREAM (Codex upstream requires stream=true).
|
||||||
|
"""
|
||||||
|
|
||||||
|
provider_obj = getattr(endpoint, "provider", None)
|
||||||
|
pt = str(provider_type or getattr(provider_obj, "provider_type", "") or "").strip().lower()
|
||||||
|
sig = str(endpoint_sig or getattr(endpoint, "api_format", "") or "").strip().lower()
|
||||||
|
|
||||||
|
# Explicit config wins (unless upstream has a hard constraint).
|
||||||
|
cfg = getattr(endpoint, "config", None)
|
||||||
|
if isinstance(cfg, dict):
|
||||||
|
val = (
|
||||||
|
cfg.get("upstream_stream_policy")
|
||||||
|
or cfg.get("upstreamStreamPolicy")
|
||||||
|
or cfg.get("upstream_stream")
|
||||||
|
)
|
||||||
|
parsed = parse_upstream_stream_policy(val)
|
||||||
|
if parsed != UpstreamStreamPolicy.AUTO:
|
||||||
|
# Codex upstream requires streaming; do not allow forcing non-stream.
|
||||||
|
if (
|
||||||
|
pt == "codex"
|
||||||
|
and sig == "openai:cli"
|
||||||
|
and parsed == UpstreamStreamPolicy.FORCE_NON_STREAM
|
||||||
|
):
|
||||||
|
return UpstreamStreamPolicy.FORCE_STREAM
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
# Safe-by-default: Codex Responses OAuth behaves like SSE-only.
|
||||||
|
if pt == "codex" and sig == "openai:cli":
|
||||||
|
return UpstreamStreamPolicy.FORCE_STREAM
|
||||||
|
|
||||||
|
return UpstreamStreamPolicy.AUTO
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_upstream_is_stream(
|
||||||
|
*,
|
||||||
|
client_is_stream: bool,
|
||||||
|
policy: UpstreamStreamPolicy,
|
||||||
|
) -> bool:
|
||||||
|
if policy == UpstreamStreamPolicy.FORCE_STREAM:
|
||||||
|
return True
|
||||||
|
if policy == UpstreamStreamPolicy.FORCE_NON_STREAM:
|
||||||
|
return False
|
||||||
|
return bool(client_is_stream)
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_stream_mode_for_upstream(
|
||||||
|
request_body: dict[str, Any],
|
||||||
|
*,
|
||||||
|
provider_api_format: str,
|
||||||
|
upstream_is_stream: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Force upstream stream/sync mode in request body (best-effort).
|
||||||
|
|
||||||
|
Note: Some formats (Gemini) do not use a `stream` field in body; for those we
|
||||||
|
remove it to avoid leaking client intent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
meta = resolve_endpoint_definition(provider_api_format)
|
||||||
|
provider_uses_stream = meta.stream_in_body if meta is not None else True
|
||||||
|
|
||||||
|
if provider_uses_stream:
|
||||||
|
request_body["stream"] = bool(upstream_is_stream)
|
||||||
|
else:
|
||||||
|
request_body.pop("stream", None)
|
||||||
|
|
||||||
|
# OpenAI Chat Completions: request usage in streaming mode.
|
||||||
|
provider_fmt = str(provider_api_format or "").strip().lower()
|
||||||
|
if upstream_is_stream and provider_fmt == "openai:chat":
|
||||||
|
stream_options = request_body.get("stream_options")
|
||||||
|
if not isinstance(stream_options, dict):
|
||||||
|
stream_options = {}
|
||||||
|
stream_options["include_usage"] = True
|
||||||
|
request_body["stream_options"] = stream_options
|
||||||
|
|
||||||
|
return request_body
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"UpstreamStreamPolicy",
|
||||||
|
"enforce_stream_mode_for_upstream",
|
||||||
|
"get_upstream_stream_policy",
|
||||||
|
"parse_upstream_stream_policy",
|
||||||
|
"resolve_upstream_is_stream",
|
||||||
|
]
|
||||||
@@ -19,7 +19,16 @@ from src.core.api_format import (
|
|||||||
make_signature_key,
|
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` 返回 SSE(data: {...})。
|
||||||
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` 返回 SSE(data: {...})。
|
|
||||||
# 网关侧统一使用 SSE 输出,优先向上游请求 SSE 以减少解析分支;同时保留 JSON-array 兜底解析。
|
|
||||||
if is_stream:
|
|
||||||
effective_query_params.setdefault("alt", "sse")
|
|
||||||
|
|
||||||
# 添加查询参数
|
# 添加查询参数
|
||||||
if effective_query_params:
|
if effective_query_params:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
201
tests/api/handlers/base/test_antigravity_v1internal.py
Normal file
201
tests/api/handlers/base/test_antigravity_v1internal.py
Normal 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)
|
||||||
38
tests/core/test_provider_oauth_utils_antigravity.py
Normal file
38
tests/core/test_provider_oauth_utils_antigravity.py
Normal 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"
|
||||||
|
|
||||||
36
tests/services/antigravity/test_client.py
Normal file
36
tests/services/antigravity/test_client.py
Normal 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)
|
||||||
|
|
||||||
35
tests/services/antigravity/test_signature_cache.py
Normal file
35
tests/services/antigravity/test_signature_cache.py
Normal 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
|
||||||
|
|
||||||
118
tests/services/antigravity/test_thinking_signature_conversion.py
Normal file
118
tests/services/antigravity/test_thinking_signature_conversion.py
Normal 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"
|
||||||
|
|
||||||
58
tests/services/antigravity/test_url_availability.py
Normal file
58
tests/services/antigravity/test_url_availability.py
Normal 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]
|
||||||
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
56
tests/services/test_provider_transport_antigravity.py
Normal file
56
tests/services/test_provider_transport_antigravity.py
Normal 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
|
||||||
|
|
||||||
47
tests/services/test_provider_transport_codex.py
Normal file
47
tests/services/test_provider_transport_codex.py
Normal 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"
|
||||||
|
|
||||||
46
tests/services/test_request_builder_oauth.py
Normal file
46
tests/services/test_request_builder_oauth.py
Normal 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
|
||||||
|
|
||||||
63
tests/services/test_upstream_stream_policy.py
Normal file
63
tests/services/test_upstream_stream_policy.py
Normal 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"
|
||||||
|
|
||||||
Reference in New Issue
Block a user