Files
writer-work-flow/packages/llm_gateway/ww_llm_gateway/adapters/gemini.py
Yaojia Wang 765dbdfbd4 feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。
M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变);
  网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则;
  前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。
K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径;
  coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。
本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400);
  GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化;
  字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。
门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:39:58 +02:00

128 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Google Gemini 适配器ARCH §4.2/§4.4)。
- 能力:原生结构化输出(`response_mime_type=application/json` + `response_schema`)、思考。
前缀缓存走 Gemini 隐式/显式缓存,机制差异大,原型保守标 `prefix_cache=False`。
- 客户端注入(`GeminiClient` Protocol`google.genai.Client().aio` 满足)——测试注入替身,
绝不联网;真实 SDK`google-genai`)仅在 apps/api 构造客户端时需要,本模块不硬 import。
- 瞬时故障翻译为 `TransientProviderError`,交网关退避/回退。
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any, Protocol
from ..errors import TransientProviderError
from ..types import LlmRequest
from .base import Capabilities, ProviderResult, ProviderUsage, StreamChunk
_TRANSIENT_NAMES = frozenset(
{
"ResourceExhausted",
"ServiceUnavailable",
"DeadlineExceeded",
"InternalServerError",
"ServerError",
"APIError",
}
)
class GeminiModels(Protocol):
async def generate_content(self, **kwargs: Any) -> Any: ...
def generate_content_stream(self, **kwargs: Any) -> Any: ...
class GeminiAio(Protocol):
models: GeminiModels
class GeminiClient(Protocol):
"""`google.genai.Client()` 满足此 Protocol用其 `.aio.models`)。"""
aio: GeminiAio
def _is_transient(exc: Exception) -> bool:
name = type(exc).__name__
if name in _TRANSIENT_NAMES:
return True
status = getattr(exc, "code", None) or getattr(exc, "status_code", None)
return isinstance(status, int) and (status == 429 or status >= 500)
def _contents(req: LlmRequest) -> str:
if isinstance(req.input, str):
return req.input
return "\n\n".join(b.text for b in req.input)
def _system_text(req: LlmRequest) -> str:
return "\n\n".join(b.text for b in req.system)
def _usage_from(meta: Any) -> ProviderUsage:
if meta is None:
return ProviderUsage(input_tokens=0, output_tokens=0)
return ProviderUsage(
input_tokens=int(getattr(meta, "prompt_token_count", 0) or 0),
output_tokens=int(getattr(meta, "candidates_token_count", 0) or 0),
cache_read_tokens=int(getattr(meta, "cached_content_token_count", 0) or 0),
)
def _config(req: LlmRequest) -> dict[str, Any]:
cfg: dict[str, Any] = {}
system = _system_text(req)
if system:
cfg["system_instruction"] = system
if req.max_tokens is not None:
cfg["max_output_tokens"] = req.max_tokens
if req.output_schema is not None:
cfg["response_mime_type"] = "application/json"
cfg["response_schema"] = req.output_schema
return cfg
class GeminiAdapter:
def __init__(self, provider: str, client: GeminiClient) -> None:
self.provider = provider
self._client = client
def capabilities(self) -> Capabilities:
return Capabilities(structured_output=True, prefix_cache=False, thinking=True)
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
try:
resp = await self._client.aio.models.generate_content(
model=model, contents=_contents(req), config=_config(req)
)
except Exception as exc:
if _is_transient(exc):
raise TransientProviderError(str(exc), provider=self.provider) from exc
raise
text = getattr(resp, "text", "") or ""
usage = _usage_from(getattr(resp, "usage_metadata", None))
if req.output_schema is not None:
parsed = req.output_schema.model_validate_json(text)
return ProviderResult(text=text, usage=usage, parsed=parsed)
return ProviderResult(text=text, usage=usage)
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
try:
stream = await self._client.aio.models.generate_content_stream(
model=model, contents=_contents(req), config=_config(req)
)
async for chunk in stream:
text = getattr(chunk, "text", "") or ""
if text:
yield StreamChunk(text=text)
meta = getattr(chunk, "usage_metadata", None)
if meta is not None:
yield StreamChunk(usage=_usage_from(meta))
except Exception as exc:
if _is_transient(exc):
raise TransientProviderError(str(exc), provider=self.provider) from exc
raise