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:
fawney19
2026-02-09 01:05:48 +08:00
parent e324ffdcd6
commit b34dd12863
82 changed files with 6498 additions and 840 deletions

View File

@@ -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,

View File

@@ -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

View 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",
]

View File

@@ -83,6 +83,25 @@ FIXED_PROVIDERS: dict[ProviderType, FixedProviderTemplate] = {
use_pkce=True,
),
),
ProviderType.KIRO: FixedProviderTemplate(
provider_type=ProviderType.KIRO,
display_name="Kiro",
# Region is resolved from per-key auth_config (imported credentials).
# Keep a templated base_url so endpoints remain fixed/locked.
api_base_url="https://q.{region}.amazonaws.com",
endpoint_signatures=["claude:cli"],
# Kiro does not support Aether's OAuth flow; credentials are imported.
# Keep a placeholder OAuth config so the fixed-provider template shape stays stable.
oauth=FixedProviderOAuth(
authorize_url="",
token_url="",
client_id="",
client_secret="",
scopes=[],
redirect_uri="",
use_pkce=False,
),
),
ProviderType.GEMINI_CLI: FixedProviderTemplate(
provider_type=ProviderType.GEMINI_CLI,
display_name="GeminiCli",

View File

@@ -17,6 +17,7 @@ class ProviderType(str, Enum):
CUSTOM = "custom"
CLAUDE_CODE = "claude_code"
KIRO = "kiro"
CODEX = "codex"
GEMINI_CLI = "gemini_cli"
ANTIGRAVITY = "antigravity"