mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: 新增 Kiro 适配器、OAuth 改进与多项功能增强
- 新增 Kiro provider 适配器(EventStream 协议解析、令牌管理、用量追踪) - 重构 OAuth 账户管理与统一配额机制 - 重构 Handler 基类(CLI adapter/handler、请求构建器、流处理器) - 增强缓存监控后端 API 与前端可视化 - 改进 Gemini 格式标准化器与请求头处理 - Antigravity/Codex 适配器更新,移除旧 metadata_collector - 新增数据库迁移:proxy provider API keys - 前端 UI 多项优化 Co-Authored-By: AAEE86 <ppk0227@hotmail.com>
This commit is contained in:
@@ -60,6 +60,74 @@ from src.core.api_format.conversion.stream_events import (
|
||||
ToolCallDeltaEvent,
|
||||
)
|
||||
from src.core.api_format.conversion.stream_state import StreamState
|
||||
from src.core.api_format.schema_utils import clean_gemini_schema as _clean_gemini_schema
|
||||
|
||||
# Valid Gemini Part data-oneof field names (camelCase + snake_case).
|
||||
_VALID_PART_DATA_FIELDS = frozenset(
|
||||
{
|
||||
"text",
|
||||
"inlineData",
|
||||
"inline_data",
|
||||
"functionCall",
|
||||
"function_call",
|
||||
"functionResponse",
|
||||
"function_response",
|
||||
"fileData",
|
||||
"file_data",
|
||||
"executableCode",
|
||||
"executable_code",
|
||||
"codeExecutionResult",
|
||||
"code_execution_result",
|
||||
"videoMetadata",
|
||||
"video_metadata",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_valid_gemini_part(part: Any) -> bool:
|
||||
"""Return True if *part* has at least one recognised Gemini data field."""
|
||||
if not isinstance(part, dict) or not part:
|
||||
return False
|
||||
return bool(part.keys() & _VALID_PART_DATA_FIELDS)
|
||||
|
||||
|
||||
def compact_gemini_contents(contents: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Strip invalid parts, drop empty contents, merge consecutive same-role.
|
||||
|
||||
Gemini requires every content to have at least one valid part (with a
|
||||
recognised data-oneof field), and strictly alternating user/model roles.
|
||||
Cross-format conversions (e.g. Responses API reasoning items) may produce
|
||||
contents with no Gemini-compatible parts, or consecutive same-role entries
|
||||
after filtering.
|
||||
"""
|
||||
# 1. Strip invalid parts, then drop contents with no valid parts remaining.
|
||||
# Use shallow copy to avoid mutating the caller's original dicts.
|
||||
non_empty: list[dict[str, Any]] = []
|
||||
for c in contents:
|
||||
parts = c.get("parts")
|
||||
if not isinstance(parts, list):
|
||||
continue
|
||||
valid_parts = [p for p in parts if _is_valid_gemini_part(p)]
|
||||
if valid_parts:
|
||||
c = {**c, "parts": valid_parts}
|
||||
non_empty.append(c)
|
||||
|
||||
# 2. Merge consecutive same-role entries.
|
||||
if not non_empty:
|
||||
return non_empty
|
||||
|
||||
merged: list[dict[str, Any]] = [non_empty[0]]
|
||||
for c in non_empty[1:]:
|
||||
if c.get("role") == merged[-1].get("role"):
|
||||
prev_parts = merged[-1].get("parts")
|
||||
if isinstance(prev_parts, list):
|
||||
prev_parts.extend(c.get("parts") or [])
|
||||
else:
|
||||
merged[-1]["parts"] = list(c.get("parts") or [])
|
||||
else:
|
||||
merged.append(c)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
class GeminiNormalizer(FormatNormalizer):
|
||||
@@ -206,22 +274,22 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
self._is_antigravity_thinking_enabled(internal) if is_antigravity else False
|
||||
)
|
||||
|
||||
# tools/tool_choice
|
||||
# tools/tool_choice — clean unsupported JSON Schema fields from parameters
|
||||
tools = None
|
||||
if internal.tools:
|
||||
tools = [
|
||||
{
|
||||
"function_declarations": [
|
||||
{
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"parameters": t.parameters or {},
|
||||
**(t.extra.get("gemini_function_declaration") or {}),
|
||||
}
|
||||
for t in internal.tools
|
||||
]
|
||||
func_decls: list[dict[str, Any]] = []
|
||||
for t in internal.tools:
|
||||
params = dict(t.parameters) if t.parameters else {}
|
||||
if params:
|
||||
_clean_gemini_schema(params)
|
||||
decl: dict[str, Any] = {
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"parameters": params,
|
||||
**(t.extra.get("gemini_function_declaration") or {}),
|
||||
}
|
||||
]
|
||||
func_decls.append(decl)
|
||||
tools = [{"function_declarations": func_decls}]
|
||||
|
||||
tool_config = None
|
||||
if internal.tool_choice:
|
||||
@@ -286,7 +354,7 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
if "thinking_config" in orig_gc and "thinkingConfig" not in generation_config:
|
||||
generation_config["thinkingConfig"] = orig_gc["thinking_config"]
|
||||
|
||||
contents: list[dict[str, Any]] = []
|
||||
raw_contents: list[dict[str, Any]] = []
|
||||
last_idx = len(internal.messages) - 1
|
||||
for idx, msg in enumerate(internal.messages):
|
||||
content = self._internal_message_to_content(
|
||||
@@ -327,7 +395,12 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
content = dict(content)
|
||||
content["parts"] = [dummy_part, *parts]
|
||||
|
||||
contents.append(content)
|
||||
raw_contents.append(content)
|
||||
|
||||
# Drop contents with empty parts (e.g. reasoning-only messages that have
|
||||
# no Gemini-compatible representation) and merge consecutive same-role
|
||||
# contents — Gemini requires strictly alternating user/model turns.
|
||||
contents = compact_gemini_contents(raw_contents)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"contents": contents,
|
||||
|
||||
@@ -21,12 +21,13 @@ from src.core.api_format.metadata import (
|
||||
get_protected_keys_for_endpoint,
|
||||
)
|
||||
from src.core.api_format.signature import EndpointSignature, parse_signature_key
|
||||
from src.core.logger import logger
|
||||
|
||||
# =============================================================================
|
||||
# 头部常量定义
|
||||
# =============================================================================
|
||||
|
||||
# 转发给上游时需要剔除的头部(系统管理 + 认证替换)
|
||||
# 转发给上游时需要剔除的头部(系统管理 + 认证替换 + 客户端/代理元数据)
|
||||
UPSTREAM_DROP_HEADERS: frozenset[str] = frozenset(
|
||||
{
|
||||
# 认证头 - 会被替换为 Provider 的认证
|
||||
@@ -40,6 +41,13 @@ UPSTREAM_DROP_HEADERS: frozenset[str] = frozenset(
|
||||
"connection",
|
||||
# 编码头 - 避免客户端请求 brotli/zstd 但 httpx 不支持
|
||||
"accept-encoding",
|
||||
# 反向代理 / 网关注入的头部 - 属于本站基础设施,不应泄露给上游
|
||||
"x-real-ip",
|
||||
"x-real-proto",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-proto",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-port",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -324,8 +332,23 @@ class HeaderBuilder:
|
||||
return self
|
||||
|
||||
def build(self) -> dict[str, str]:
|
||||
"""构建最终的头部字典"""
|
||||
return {original_key: value for original_key, value in self._headers.values()}
|
||||
"""构建最终的头部字典
|
||||
|
||||
Safety net: 跳过值中包含非 ASCII 字符的头部并记录警告,
|
||||
防止 httpx 发送时抛出 ``UnicodeEncodeError``。
|
||||
"""
|
||||
result: dict[str, str] = {}
|
||||
for original_key, value in self._headers.values():
|
||||
try:
|
||||
value.encode("ascii")
|
||||
except (UnicodeEncodeError, UnicodeDecodeError):
|
||||
logger.warning(
|
||||
"Dropping non-ASCII header before upstream request: {}",
|
||||
original_key,
|
||||
)
|
||||
continue
|
||||
result[original_key] = value
|
||||
return result
|
||||
|
||||
|
||||
def build_upstream_headers_for_endpoint(
|
||||
@@ -565,3 +588,18 @@ def get_extra_headers_from_endpoint(endpoint: Any) -> dict[str, str] | None:
|
||||
"""
|
||||
header_rules = getattr(endpoint, "header_rules", None)
|
||||
return extract_set_headers_from_rules(header_rules)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 请求头辅助工具
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def set_accept_if_absent(headers: dict[str, str], value: str = "text/event-stream") -> None:
|
||||
"""Set the ``Accept`` header only if not already present (case-insensitive check).
|
||||
|
||||
Used by stream handlers to request SSE format from upstream without overriding
|
||||
provider-specific Accept headers (e.g. Kiro's ``application/vnd.amazon.eventstream``).
|
||||
"""
|
||||
if not any(k.lower() == "accept" for k in headers):
|
||||
headers["Accept"] = value
|
||||
|
||||
60
src/core/api_format/schema_utils.py
Normal file
60
src/core/api_format/schema_utils.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""JSON Schema cleaning utilities shared across Gemini-compatible providers.
|
||||
|
||||
Google Gemini's function declaration API does not support certain JSON Schema
|
||||
fields. These must be stripped recursively from tool parameter schemas before
|
||||
forwarding to any Gemini-based upstream (native Gemini, Antigravity, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# JSON Schema fields unsupported by Google Gemini's function declaration API.
|
||||
GEMINI_FORBIDDEN_SCHEMA_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"$schema",
|
||||
"additionalProperties",
|
||||
"const",
|
||||
"contentEncoding",
|
||||
"contentMediaType",
|
||||
"default",
|
||||
"exclusiveMaximum",
|
||||
"exclusiveMinimum",
|
||||
"multipleOf",
|
||||
"patternProperties",
|
||||
"propertyNames",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def clean_gemini_schema(schema: dict[str, Any]) -> None:
|
||||
"""Recursively strip JSON Schema fields unsupported by Gemini.
|
||||
|
||||
Modifies *schema* in-place. Recurses into ``properties``, ``items``,
|
||||
and ``anyOf`` / ``oneOf`` / ``allOf`` sub-schemas.
|
||||
"""
|
||||
for field in GEMINI_FORBIDDEN_SCHEMA_FIELDS:
|
||||
schema.pop(field, None)
|
||||
|
||||
props = schema.get("properties")
|
||||
if isinstance(props, dict):
|
||||
for prop_schema in props.values():
|
||||
if isinstance(prop_schema, dict):
|
||||
clean_gemini_schema(prop_schema)
|
||||
|
||||
items = schema.get("items")
|
||||
if isinstance(items, dict):
|
||||
clean_gemini_schema(items)
|
||||
|
||||
for combo_key in ("anyOf", "oneOf", "allOf"):
|
||||
combo = schema.get(combo_key)
|
||||
if isinstance(combo, list):
|
||||
for sub in combo:
|
||||
if isinstance(sub, dict):
|
||||
clean_gemini_schema(sub)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GEMINI_FORBIDDEN_SCHEMA_FIELDS",
|
||||
"clean_gemini_schema",
|
||||
]
|
||||
Reference in New Issue
Block a user