463eca380d
CallStats gains hedges/generation_ms/hedge_won (all defaulted, appended after total_latency_ms); _CallContext counts them via record_generation (overwrite for chat/OCR, accumulate for embedding batches) and register_hedge, and snapshot carries them out. All three _attempt implementations time only the transport call itself on the same injected clock as total_latency_ms; the chat sink is recorded by the orchestrator so hedge winner attribution stays with T3. Hedge counters stay 0/False until the T3 orchestration lands. Red-green evidence: tests/outputs/137/t2/ (10 new tests AttributeError red, then green; full unit+contracts 1621 passed).
693 lines
27 KiB
Python
693 lines
27 KiB
Python
"""EmbeddingClient: 治理化 embedding 调用(M2 设计 §7,方案 G2)。
|
|
|
|
独立精简治理循环,**复用**库的算法件: `RateLimiter`/`ProviderGate` 端口与
|
|
两种后端、错误四分类、`backoff_delay` 退避公式、`SourceCooldownMemo`、
|
|
`TelemetryEmitter`(遥测单一 helper 铁律)。选源/等待循环与 RetryMW 同构
|
|
——这是设计 §7.1 已声明的有限重复(chat 循环含流式/结构化/缓存分支,
|
|
强行合一才是复制);行为口径(stall 双条件、记账降级、取消穿透)与 chat
|
|
完全一致。
|
|
|
|
对参考实现的已声明裁决(设计 §7.3): async httpx;返回 list[list[float]]
|
|
(核心不依赖 numpy);normalize 开关(VT 语义,防除零 max(norm,1e-12));
|
|
必填 batch_size 批间串行;GovDoc 自研退避与 on_usage 回调、VT 同步接口
|
|
均**有意放弃**。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import dataclasses
|
|
import math
|
|
import random
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from loguru import logger
|
|
|
|
from polygateway.client import _aclose_component, _telemetry_status_of
|
|
from polygateway.config import EmbeddingSettings
|
|
from polygateway.deadline import ensure_call_deadline, with_call_deadline
|
|
from polygateway.errors import (
|
|
AllSourcesExhausted,
|
|
GovernanceBackendError,
|
|
PolyGatewayError,
|
|
RequestRejectedError,
|
|
ResultInvalidError,
|
|
SourceDeadError,
|
|
SourceNotConfiguredError,
|
|
TransientError,
|
|
)
|
|
from polygateway.middleware.admission import SourceAdmission, settle_and_release
|
|
from polygateway.middleware.breaker import BreakerGate
|
|
from polygateway.middleware.ratelimit import QuotaGate
|
|
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
|
|
from polygateway.middleware.telemetry import TelemetryEmitter, emit_terminal_once
|
|
from polygateway.types import (
|
|
ChatRequest,
|
|
EmbeddingResponse,
|
|
LLMResponse,
|
|
TelemetryStatus,
|
|
_CallContext,
|
|
strip_unsupported_extra_body,
|
|
validate_caller_dimensions,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Awaitable, Callable, Mapping
|
|
|
|
from polygateway.ports import (
|
|
EmbeddingTransport,
|
|
GateDecision,
|
|
Permit,
|
|
ProviderGate,
|
|
RateLimiter,
|
|
SourceSelector,
|
|
TelemetryRecorder,
|
|
)
|
|
from polygateway.pricing import PricingTable
|
|
from polygateway.types import (
|
|
BackpressurePolicy,
|
|
EmbeddingTransportResult,
|
|
RetryPolicy,
|
|
SourceConfig,
|
|
)
|
|
|
|
_TELEMETRY_TEXT_CAP = 200 # 遥测行每条 text 截断长度(原文不整段入库,VT R12)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _FailedBatch:
|
|
exc: PolyGatewayError
|
|
immediate: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _BatchOutcome:
|
|
result: EmbeddingTransportResult
|
|
source: SourceConfig
|
|
call_id: str
|
|
latency_ms: int
|
|
|
|
|
|
class EmbeddingClient:
|
|
"""治理化 embedding 入口;与 GatewayClient 共享后端实例即共享全局闸。"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
scope: str,
|
|
sources: list[SourceConfig],
|
|
selector: SourceSelector,
|
|
limiter: RateLimiter,
|
|
breaker: ProviderGate,
|
|
transport: EmbeddingTransport,
|
|
retry: RetryPolicy,
|
|
backpressure: BackpressurePolicy,
|
|
quota_full: str = "wait",
|
|
circuit_open: str = "fail_fast",
|
|
telemetry: TelemetryRecorder | None = None,
|
|
pricing: PricingTable | None = None,
|
|
text_cap: int | None = None,
|
|
batch_size: int,
|
|
normalize: bool = False,
|
|
expected_dim: int | None = None,
|
|
call_deadline_s: float | None = None,
|
|
now: Callable[[], float] = time.monotonic,
|
|
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
rng: Callable[[], float] = random.random,
|
|
) -> None:
|
|
if batch_size < 1:
|
|
raise ValueError("batch_size 必须 ≥ 1")
|
|
if expected_dim is not None and expected_dim < 1:
|
|
raise ValueError("expected_dim 必须 ≥ 1")
|
|
# 入口即校: 装配错误当场报,不等到第一次调用才炸
|
|
self._call_deadline_s = ensure_call_deadline(
|
|
call_deadline_s, "EmbeddingClient(call_deadline_s=...)"
|
|
)
|
|
self._scope = scope
|
|
# embed payload 硬编码 {model, input},带 extra_body 的源必须先剥离,
|
|
# 否则遥测会记录一个从未发出的采样参数(issue #4 决策 G)
|
|
self._sources = strip_unsupported_extra_body(list(sources), path="embedding")
|
|
self._quota = QuotaGate(limiter, scope=self._scope)
|
|
self._breaker = BreakerGate(breaker, scope=self._scope)
|
|
self._transport = transport
|
|
self._retry = retry
|
|
self._emitter = (
|
|
TelemetryEmitter(telemetry, scope=self._scope, pricing=pricing, text_cap=text_cap)
|
|
if telemetry
|
|
else None
|
|
)
|
|
self._telemetry = telemetry
|
|
# 限流/熔断后端在此之外只以 QuotaGate/BreakerGate 的形态存在,自持一份
|
|
# 引用才关得到自建的 redis 客户端(设计 §3.4)
|
|
self._limiter_backend = limiter
|
|
self._breaker_backend = breaker
|
|
# 所有权默认"不拥有": `__init__` 是全量注入路径,只有工厂自建时才置 True
|
|
self._owns_transport = False
|
|
self._owns_telemetry = False
|
|
self._owns_limiter = False
|
|
self._owns_breaker = False
|
|
self._pricing = pricing
|
|
self._batch_size = batch_size
|
|
self._normalize = normalize
|
|
self._expected_dim = expected_dim
|
|
self._now = now
|
|
self._sleep = sleep
|
|
self._rng = rng
|
|
# 准入编排三条循环共用一份(issue #14);冷却备忘由它独占
|
|
self._admission = SourceAdmission(
|
|
scope=self._scope,
|
|
sources=self._sources,
|
|
selector=selector,
|
|
quota=self._quota,
|
|
breaker=self._breaker,
|
|
backpressure=backpressure,
|
|
quota_full=quota_full,
|
|
circuit_open=circuit_open,
|
|
now=now,
|
|
sleep=sleep,
|
|
rng=rng,
|
|
)
|
|
self._closed = False
|
|
|
|
async def embed(
|
|
self,
|
|
texts: list[str],
|
|
*,
|
|
session_id: str | None = None,
|
|
parent_call_id: str | None = None,
|
|
tenant_id: str | None = None,
|
|
meta: Mapping[str, Any] | None = None,
|
|
call_deadline_s: float | None = None,
|
|
) -> EmbeddingResponse:
|
|
"""一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。
|
|
|
|
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11);它们属于
|
|
本次调用而非某一批,故每批的遥测行都带同一份维度。
|
|
|
|
`call_deadline_s` 是本次调用的墙钟硬边界(issue #22): `None` = 继承装配值。
|
|
**整次调用共享一份**——N 批串行跑在同一条期限内,不按批数放大 N 倍。
|
|
`texts == []` 的早返回在期限之外(零尝试,无等待可治)。
|
|
"""
|
|
if not isinstance(texts, list) or any(not isinstance(t, str) for t in texts):
|
|
raise TypeError("texts 必须是 list[str](显式优于隐式,不收单条 str)")
|
|
# 必须在切批之前校验: 洋葱/链路内的一切失败都被遥测层降级成 warning
|
|
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验——非法维度
|
|
# 会变成静默丢失的遥测行,而调用照常发出(issue #11 §4.2)
|
|
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
|
tenant_id, meta, origin="embed(tenant_id=..., meta=...)"
|
|
)
|
|
# 期限取值与校验必须在创建 awaitable **之前**(否则遗留未 await 的协程)
|
|
deadline = (
|
|
self._call_deadline_s
|
|
if call_deadline_s is None
|
|
else ensure_call_deadline(call_deadline_s, "embed(call_deadline_s=...)")
|
|
)
|
|
# 校验均已通过 → 进入统计边界(设计 §3.5: `texts` 类型与调用方维度校验之后)
|
|
context = _CallContext(now=self._now)
|
|
if not texts:
|
|
# 合法零尝试: 返回真实统计(attempts=0),且**不写任何遥测行**
|
|
# ——与 cache_hit 不同,不要按"遥测必录"推断它有台账行(设计 §3 M2)
|
|
return EmbeddingResponse(
|
|
vectors=[],
|
|
dim=0,
|
|
model="",
|
|
provider="",
|
|
prompt_tokens=0,
|
|
usage_source="measured",
|
|
latency_ms=0,
|
|
call_id=str(uuid.uuid4()),
|
|
source_name="",
|
|
call_stats=context.snapshot(),
|
|
)
|
|
try:
|
|
return await with_call_deadline(
|
|
self._embed_all(
|
|
texts, session_id, parent_call_id, dimension_tenant_id, dimensions, context
|
|
),
|
|
deadline_s=deadline,
|
|
scope=self._scope,
|
|
)
|
|
except PolyGatewayError as exc:
|
|
await self._emit_terminal(
|
|
texts, session_id, parent_call_id, dimension_tenant_id, dimensions, context, exc
|
|
)
|
|
raise
|
|
except asyncio.CancelledError:
|
|
# 三条链路同一口径尽力写一条(允许 0 条);取消优先,不 shield
|
|
await self._emit_terminal(
|
|
texts,
|
|
session_id,
|
|
parent_call_id,
|
|
dimension_tenant_id,
|
|
dimensions,
|
|
context,
|
|
"cancelled",
|
|
)
|
|
raise
|
|
|
|
async def _embed_all(
|
|
self,
|
|
texts: list[str],
|
|
session_id: str | None,
|
|
parent_call_id: str | None,
|
|
tenant_id: str | None,
|
|
meta: dict[str, Any],
|
|
context: _CallContext,
|
|
) -> EmbeddingResponse:
|
|
"""切批串行执行并合并;无源的 raise 必须在本方法内——否则无源终态行写不出。"""
|
|
if not self._sources:
|
|
raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0)
|
|
outcomes = []
|
|
for start in range(0, len(texts), self._batch_size):
|
|
outcomes.append(
|
|
await self._embed_batch(
|
|
texts[start : start + self._batch_size],
|
|
session_id,
|
|
parent_call_id,
|
|
tenant_id,
|
|
meta,
|
|
context,
|
|
)
|
|
)
|
|
# 全批共享同一上下文,故分批是实现细节而非 N 次独立逻辑调用
|
|
return dataclasses.replace(self._merge(outcomes), call_stats=context.snapshot())
|
|
|
|
async def _emit_terminal(
|
|
self,
|
|
texts: list[str],
|
|
session_id: str | None,
|
|
parent_call_id: str | None,
|
|
tenant_id: str | None,
|
|
meta: dict[str, Any],
|
|
context: _CallContext,
|
|
error: PolyGatewayError | str,
|
|
) -> None:
|
|
"""终态行的请求摘要(设计 §6 M4): 计数占位 + 第一批截断文本。
|
|
|
|
描述的是**本次调用的整体输入**但不扩大单行正文预算: 失败批的具体文本由同
|
|
`logical_call_id` 的 attempt 行给出,终态行不保存全量原输入。
|
|
"""
|
|
batches = math.ceil(len(texts) / self._batch_size)
|
|
messages = [{"role": "user", "content": f"<embed texts={len(texts)} batches={batches}>"}]
|
|
# 与逐批行同款构造(至多 `batch_size` 条、每条 200 字符)
|
|
messages += [
|
|
{"role": "user", "content": t[:_TELEMETRY_TEXT_CAP]} for t in texts[: self._batch_size]
|
|
]
|
|
await emit_terminal_once(
|
|
self._emitter,
|
|
request=ChatRequest(
|
|
messages=messages,
|
|
session_id=session_id,
|
|
parent_call_id=parent_call_id,
|
|
tenant_id=tenant_id,
|
|
meta=meta,
|
|
call_context=context,
|
|
),
|
|
context=context,
|
|
error=error,
|
|
operation="embed",
|
|
)
|
|
|
|
# —— 治理循环(与 RetryMW 同构;设计 §7.1 已声明的有限重复)——
|
|
|
|
async def _embed_batch(
|
|
self,
|
|
batch: list[str],
|
|
session_id: str | None,
|
|
parent_call_id: str | None,
|
|
tenant_id: str | None,
|
|
meta: dict[str, Any],
|
|
context: _CallContext,
|
|
) -> _BatchOutcome:
|
|
fails = 0
|
|
reasons: dict[str, str] = {}
|
|
# 只计非生产性等待(issue #8): 真实尝试由重试预算治理,不重复烧 stall 预算
|
|
clock = StallClock(self._now)
|
|
while True:
|
|
picked, gate_rejections = await self._admission.pick(reasons, {})
|
|
if picked is None:
|
|
await self._admission.on_no_runnable(gate_rejections, reasons, clock)
|
|
continue
|
|
async with clock.attempting():
|
|
outcome = await self._attempt(
|
|
batch, *picked, reasons, session_id, parent_call_id, tenant_id, meta, context
|
|
)
|
|
if isinstance(outcome, _BatchOutcome):
|
|
return outcome
|
|
fails += 1
|
|
if fails >= self._retry.max_attempts:
|
|
raise AllSourcesExhausted(
|
|
scope=self._scope,
|
|
reason="retry_exhausted",
|
|
retry_after_s=self._retry.backoff_base_s,
|
|
per_source_reasons=reasons,
|
|
) from outcome.exc
|
|
if not outcome.immediate:
|
|
await self._sleep(backoff_delay(self._retry, fails, outcome.exc, self._rng))
|
|
|
|
async def _attempt(
|
|
self,
|
|
batch: list[str],
|
|
source: SourceConfig,
|
|
permit: Permit,
|
|
entry: GateDecision,
|
|
reasons: dict[str, str],
|
|
session_id: str | None,
|
|
parent_call_id: str | None,
|
|
tenant_id: str | None,
|
|
meta: dict[str, Any],
|
|
context: _CallContext,
|
|
) -> _BatchOutcome | _FailedBatch:
|
|
call_id = str(uuid.uuid4())
|
|
started = self._now()
|
|
actual = 0
|
|
# 局部阶段变量(与 RetryMW 同口径): 该刻库是否已算出确定结算。只服务于取消
|
|
# 分支的兜底取值,不进任何签名; 未分类异常逃逸时仍逐字走旧的全额退还。
|
|
settlement_known = False
|
|
# 登记在 transport 调用**之前**(同 RetryMW): 失败与取消的尝试也真的发出去了
|
|
context.register_attempt()
|
|
try:
|
|
# 裸生成时间(1.3.7 H8): 计时只包 transport 调用本身(同 RetryMW 口径),
|
|
# 与本链路 total_latency_ms 同一只注入钟;分批累加在成功分支登记
|
|
gen_started = self._now()
|
|
result = await self._transport.embed(texts=batch, source=source, call_id=call_id)
|
|
gen_ms = int((self._now() - gen_started) * 1000)
|
|
if self._expected_dim is not None and result.dim != self._expected_dim:
|
|
raise ResultInvalidError(
|
|
f"{source.name} 维度 {result.dim} 不符期望 {self._expected_dim}",
|
|
source_name=source.name,
|
|
operation="embedding",
|
|
)
|
|
if result.usage_source == "unavailable":
|
|
# 与 RetryMW 同口径: 用量不可得时按入场预扣量结算(设计 §3.2 #9)
|
|
actual = source.effective_est_tokens()
|
|
else:
|
|
actual = result.prompt_tokens
|
|
# 真实 usage 恰为 0 也是已知事实, 后续取消不得改写成 est
|
|
settlement_known = True
|
|
context.record_generation(gen_ms, accumulate=True)
|
|
await self._record_quietly(self._breaker.record_success(entry))
|
|
await self._record_quietly(self._quota.mark_progress())
|
|
latency_ms = int((self._now() - started) * 1000)
|
|
await self._emit(
|
|
batch,
|
|
source,
|
|
call_id,
|
|
started,
|
|
session_id,
|
|
parent_call_id,
|
|
tenant_id,
|
|
meta,
|
|
context,
|
|
result,
|
|
)
|
|
return _BatchOutcome(result, source, call_id, latency_ms)
|
|
except (RequestRejectedError, ResultInvalidError) as exc:
|
|
actual, settlement_known = 0, True # 逐字保住 1.3.5 口径(本版不改这一族记账)
|
|
await self._gate_on_terminal(exc, entry)
|
|
await self._emit(
|
|
batch,
|
|
source,
|
|
call_id,
|
|
started,
|
|
session_id,
|
|
parent_call_id,
|
|
tenant_id,
|
|
meta,
|
|
context,
|
|
error=exc,
|
|
)
|
|
raise
|
|
except asyncio.CancelledError:
|
|
if not settlement_known:
|
|
# 端口已开始、结算未定: 保守保留预扣(设计 §6.3 S7)
|
|
actual = source.effective_est_tokens()
|
|
if entry.is_probe:
|
|
await self._record_quietly(self._breaker.release_probe(entry))
|
|
await self._emit(
|
|
batch,
|
|
source,
|
|
call_id,
|
|
started,
|
|
session_id,
|
|
parent_call_id,
|
|
tenant_id,
|
|
meta,
|
|
context,
|
|
error="cancelled",
|
|
)
|
|
raise
|
|
except (SourceDeadError, TransientError) as exc:
|
|
dead = isinstance(exc, SourceDeadError)
|
|
reason = _failure_reason(exc)
|
|
reasons[source.name] = reason
|
|
# 结算决定定死在本分支第一个 await 之前: 同级 except CancelledError 接不住
|
|
# 落在本块 await 上的取消,它直穿 finally。值与 1.3.5 逐字相同,只是算得更早。
|
|
actual = 0 if dead else source.effective_est_tokens()
|
|
settlement_known = True
|
|
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
|
|
await self._emit(
|
|
batch,
|
|
source,
|
|
call_id,
|
|
started,
|
|
session_id,
|
|
parent_call_id,
|
|
tenant_id,
|
|
meta,
|
|
context,
|
|
error=exc,
|
|
)
|
|
return _FailedBatch(exc, immediate=dead)
|
|
finally:
|
|
await settle_and_release(permit, actual)
|
|
|
|
# —— 辅助 ——
|
|
|
|
async def _gate_on_terminal(self, exc: PolyGatewayError, entry: GateDecision) -> None:
|
|
"""终态异常的门控写回(与 RetryMW 同口径): 坏结果/网关健康拒绝 ≠ 坏服务
|
|
→ 记成功;网关没响应的拒绝若持探针则归还。"""
|
|
if isinstance(exc, ResultInvalidError) or exc.status_code is not None:
|
|
# M2.5 §3.1: 记成功但不计失败率窗口样本(坏结果/坏请求 ≠ 坏服务)
|
|
await self._record_quietly(self._breaker.record_success(entry, count_attempt=False))
|
|
elif entry.is_probe:
|
|
await self._record_quietly(self._breaker.release_probe(entry))
|
|
|
|
async def _record_quietly(self, write_back: Awaitable[object]) -> None:
|
|
"""记账侧写回降级(与 RetryMW._record_quietly 同口径,设计 §10)。"""
|
|
try:
|
|
await write_back
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except (GovernanceBackendError, SourceNotConfiguredError) as exc:
|
|
logger.warning("embedding 治理记账写回降级(不冒泡): {}", exc)
|
|
|
|
async def _emit(
|
|
self,
|
|
batch: list[str],
|
|
source: SourceConfig,
|
|
call_id: str,
|
|
started: float,
|
|
session_id: str | None,
|
|
parent_call_id: str | None,
|
|
tenant_id: str | None,
|
|
meta: dict[str, Any],
|
|
context: _CallContext,
|
|
result: EmbeddingTransportResult | None = None,
|
|
error: PolyGatewayError | str | None = None,
|
|
) -> None:
|
|
"""逐批遥测(经同一 Emitter): messages=截断 texts、向量绝不入库。"""
|
|
if self._emitter is None:
|
|
return
|
|
# 这个 ChatRequest 只为复用同一个 Emitter 而现场构造(embedding 不走 chat
|
|
# 洋葱),故调用方维度必须在这里显式填回,否则 embed 行的维度恒为空
|
|
request = ChatRequest(
|
|
messages=[{"role": "user", "content": t[:_TELEMETRY_TEXT_CAP]} for t in batch],
|
|
session_id=session_id,
|
|
parent_call_id=parent_call_id,
|
|
tenant_id=tenant_id,
|
|
meta=meta,
|
|
call_context=context,
|
|
)
|
|
response = None
|
|
if result is not None:
|
|
response = LLMResponse(
|
|
content=f"<vectors n={len(result.vectors)} dim={result.dim}>",
|
|
thinking="",
|
|
model=source.model,
|
|
provider=source.provider,
|
|
prompt_tokens=result.prompt_tokens,
|
|
completion_tokens=0,
|
|
latency_ms=int((self._now() - started) * 1000),
|
|
ttft_ms=None,
|
|
max_inter_token_ms=None,
|
|
cache_hit=False,
|
|
call_id=call_id,
|
|
source_name=source.name,
|
|
usage_source=result.usage_source,
|
|
)
|
|
await self._emitter.emit_attempt(
|
|
request=request,
|
|
source=source,
|
|
call_id=call_id,
|
|
latency_ms=int((self._now() - started) * 1000),
|
|
response=response,
|
|
# 异常对象原样下传: 状态码/底层异常类型/网关正文在 Emitter 内定型
|
|
error=error,
|
|
# embedding payload 硬编码 {model, input},从不带推理参数;源上即便
|
|
# 误配了 ENABLE_THINKING,记一个档也是替这次调用声称它没做过的事
|
|
reasoning_applies=False,
|
|
operation="embed",
|
|
)
|
|
|
|
def _merge(self, outcomes: list[_BatchOutcome]) -> EmbeddingResponse:
|
|
"""全批合并(设计 §7.3): vectors 拼接、tokens/latency 求和、保守 usage_source。"""
|
|
vectors = [v for o in outcomes for v in o.result.vectors]
|
|
if self._normalize:
|
|
vectors = [_l2_normalize(v) for v in vectors]
|
|
first = outcomes[0]
|
|
prompt_tokens = sum(o.result.prompt_tokens for o in outcomes)
|
|
# 三态合并优先级(解耦设计 §3.2 #10): 任一批不可得 → 整体不可得
|
|
sources = {o.result.usage_source for o in outcomes}
|
|
if "unavailable" in sources:
|
|
merged_source = "unavailable"
|
|
elif "estimated" in sources:
|
|
merged_source = "estimated"
|
|
else:
|
|
merged_source = "measured"
|
|
return EmbeddingResponse(
|
|
vectors=vectors,
|
|
dim=first.result.dim,
|
|
model=first.source.model,
|
|
provider=first.source.provider,
|
|
prompt_tokens=prompt_tokens,
|
|
usage_source=merged_source,
|
|
latency_ms=sum(o.latency_ms for o in outcomes),
|
|
call_id=first.call_id,
|
|
source_name=first.source.name,
|
|
cost=self._total_cost(outcomes),
|
|
)
|
|
|
|
def _total_cost(self, outcomes: list[_BatchOutcome]) -> float | None:
|
|
"""全批成本;任一批用量不可得则整体记 NULL(解耦设计 §3.2 #11)。
|
|
|
|
逐批求和会把不可得的批当 0 计入,给出一个偏低却看似有效的金额——
|
|
与"宁可算不出成本,也不算错成本"的不变式相悖。
|
|
"""
|
|
if self._pricing is None:
|
|
return None
|
|
if any(o.result.usage_source == "unavailable" for o in outcomes):
|
|
return None
|
|
costs = [self._pricing.cost(o.source.model, o.result.prompt_tokens, 0) for o in outcomes]
|
|
known = [c for c in costs if c is not None]
|
|
return sum(known) if known else None
|
|
|
|
@property
|
|
def telemetry_status(self) -> TelemetryStatus | None:
|
|
"""遥测后端的可写状态;无遥测或注入的 recorder 不提供状态时为 None。
|
|
|
|
判定收敛在 `_telemetry_status_of` 一处(不是三处各自探测): 三个 client
|
|
的 `aclose` 曾各持一份逐字复制,漂移的结果就是越权关闭(设计 §3.3/§3.4)。
|
|
"""
|
|
return _telemetry_status_of(self._telemetry)
|
|
|
|
async def aclose(self) -> None:
|
|
"""幂等释放**自建**资源(与 GatewayClient 对称);注入的组件一律不碰。"""
|
|
if self._closed:
|
|
return
|
|
self._closed = True
|
|
if self._owns_transport:
|
|
await _aclose_component(self._transport)
|
|
if self._owns_telemetry:
|
|
await _aclose_component(self._telemetry)
|
|
if self._owns_limiter:
|
|
await _aclose_component(self._limiter_backend)
|
|
if self._owns_breaker:
|
|
await _aclose_component(self._breaker_backend)
|
|
|
|
async def __aenter__(self) -> EmbeddingClient:
|
|
return self
|
|
|
|
async def __aexit__(self, *exc_info: object) -> None:
|
|
await self.aclose()
|
|
|
|
# —— 工厂(与 GatewayClient 对称)——
|
|
|
|
@classmethod
|
|
def from_settings(
|
|
cls,
|
|
settings: EmbeddingSettings,
|
|
*,
|
|
limiter: RateLimiter | None = None,
|
|
breaker: ProviderGate | None = None,
|
|
telemetry: TelemetryRecorder | None = None,
|
|
registry: Mapping[str, object] | None = None,
|
|
) -> EmbeddingClient:
|
|
"""按配置装配;显式传入的后端实例即共享(与 chat scope 共享全局闸)。"""
|
|
from polygateway.client import (
|
|
_build_breaker,
|
|
_build_limiter,
|
|
_build_selector,
|
|
_build_telemetry,
|
|
_mark_owned_components,
|
|
)
|
|
from polygateway.pricing import PricingTable
|
|
from polygateway.transports.openai_compat import OpenAICompatTransport
|
|
|
|
gw = settings.gateway
|
|
sources = list(gw.sources)
|
|
client = cls(
|
|
scope=gw.scope,
|
|
sources=sources,
|
|
selector=_build_selector(gw.selector),
|
|
limiter=limiter if limiter is not None else _build_limiter(gw, sources),
|
|
breaker=breaker if breaker is not None else _build_breaker(gw),
|
|
transport=OpenAICompatTransport(registry=registry),
|
|
retry=gw.retry,
|
|
backpressure=gw.backpressure,
|
|
quota_full=gw.quota_full,
|
|
circuit_open=gw.circuit_open,
|
|
telemetry=telemetry if telemetry is not None else _build_telemetry(gw),
|
|
pricing=PricingTable.from_file(gw.pricing_path)
|
|
if gw.pricing_path is not None
|
|
else None,
|
|
# embed 行与 chat 行写同一张 llm_calls;漏传这一条,同表内就一半受控
|
|
# 一半不受控(issue #12)
|
|
text_cap=gw.telemetry_text_cap,
|
|
batch_size=settings.batch_size,
|
|
normalize=settings.normalize,
|
|
expected_dim=settings.expected_dim,
|
|
call_deadline_s=gw.call_deadline_s,
|
|
)
|
|
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
|
|
return client
|
|
|
|
@classmethod
|
|
def from_env(
|
|
cls,
|
|
scope: str = "EMBED",
|
|
*,
|
|
limiter: RateLimiter | None = None,
|
|
breaker: ProviderGate | None = None,
|
|
telemetry: TelemetryRecorder | None = None,
|
|
registry: Mapping[str, object] | None = None,
|
|
env: Mapping[str, str] | None = None,
|
|
) -> EmbeddingClient:
|
|
"""从 .env/环境变量装配一个 embedding scope 的 client。"""
|
|
return cls.from_settings(
|
|
EmbeddingSettings.from_env(scope, env=env),
|
|
limiter=limiter,
|
|
breaker=breaker,
|
|
telemetry=telemetry,
|
|
registry=registry,
|
|
)
|
|
|
|
|
|
def _l2_normalize(vector: list[float]) -> list[float]:
|
|
"""L2 归一化;`max(norm, 1e-12)` 防除零(VT embedding.py:167-170 语义)。"""
|
|
norm = max(math.sqrt(sum(x * x for x in vector)), 1e-12)
|
|
return [x / norm for x in vector]
|