feat: wire the tier through the transport and keep each tier's warning distinct
The transport now hands back the tier it actually sent, and that tier rides TransportResult into LLMResponse. It is not the requested one: under EFFORT_FALLBACK=nearest a medium request goes out as low, and telemetry grouping by the requested tier would file the row under a tier that never left the process. Reconciliation judges the same tier instead of the old enable_thinking bool, and the warning throttle keys on it. Keyed on the bool, every tier of one model shared a single key, so the second contradiction was silenced for the lifetime of the transport. The predicate is an identity check against Effort.NONE on purpose -- the member's value is the non-empty string "none", so any truthiness test would send every strength tier down the "asked to disable" branch and invert the alarm.
This commit is contained in:
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
@@ -333,7 +334,7 @@ class OpenAICompatTransport:
|
||||
# 一个容器会让两种告警的生命周期纠缠在一起——将来任一侧想加清空/过期策略,
|
||||
# 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制;
|
||||
# 分开维护的理由是语义,不是碰撞)
|
||||
self._warned_mismatches: set[tuple[str, str, bool | None]] = set()
|
||||
self._warned_mismatches: set[tuple[str, str, Effort | None]] = set()
|
||||
self._client_factory = client_factory or _default_client_factory
|
||||
self._clients: dict[str, httpx.AsyncClient] = {}
|
||||
|
||||
@@ -353,7 +354,13 @@ class OpenAICompatTransport:
|
||||
stream: bool,
|
||||
overlay: dict[str, Any],
|
||||
reasoning_effort: Effort | None,
|
||||
) -> dict[str, Any]:
|
||||
) -> tuple[dict[str, Any], Effort | None]:
|
||||
"""组装请求体,并交回本次**实际**发出去的档位(`None` = 未表态,不注入)。
|
||||
|
||||
返回二元组而非只返回 payload: 实际档在 `nearest` 映射后与请求档分叉,而
|
||||
除本函数外没有第二处知道映射结果——不交出去,遥测就只能事后再算一遍,
|
||||
算出来必是请求档。
|
||||
"""
|
||||
payload: dict[str, Any] = {"model": source.model, "messages": messages, "stream": stream}
|
||||
if stream:
|
||||
payload["stream_options"] = {"include_usage": True} # 强制 usage 帧(三项目同款)
|
||||
@@ -364,25 +371,26 @@ class OpenAICompatTransport:
|
||||
self._warned_models.add(source.model)
|
||||
# 三层优先级在此汇合: 请求级 > 源级 > enable_thinking 语法糖(设计 §4.2)。
|
||||
# 判定与装配守卫共用同一个纯函数,两处分叉就会变成"装配期放行、运行期报错"
|
||||
payload.update(
|
||||
resolve_thinking(
|
||||
profile,
|
||||
capability,
|
||||
effective_effort(
|
||||
request_effort=reasoning_effort,
|
||||
source_effort=source.reasoning_effort,
|
||||
enable_thinking=source.enable_thinking,
|
||||
),
|
||||
model=source.model,
|
||||
fallback=source.effort_fallback,
|
||||
warn_unregistered=first_time,
|
||||
).payload
|
||||
resolution = resolve_thinking(
|
||||
profile,
|
||||
capability,
|
||||
effective_effort(
|
||||
request_effort=reasoning_effort,
|
||||
source_effort=source.reasoning_effort,
|
||||
enable_thinking=source.enable_thinking,
|
||||
),
|
||||
model=source.model,
|
||||
# 源级 `EFFORT_FALLBACK` 必须真的走到这里: 硬编码 "error" 会让人类明确
|
||||
# 要求实现的 `nearest` 在零告警下变成死代码(2026-09-05 独立验证查出)
|
||||
fallback=source.effort_fallback,
|
||||
warn_unregistered=first_time,
|
||||
)
|
||||
payload.update(resolution.payload)
|
||||
# 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级
|
||||
# overlay(含结构化注入)在后覆盖之。两行不可调换
|
||||
payload.update(source.extra_body)
|
||||
payload.update(overlay)
|
||||
return payload
|
||||
return payload, resolution.applied_effort
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
@@ -401,7 +409,7 @@ class OpenAICompatTransport:
|
||||
"""
|
||||
profile = get_provider(source.provider, registry=self._registry)
|
||||
try:
|
||||
payload = self._build_payload(
|
||||
payload, applied_effort = self._build_payload(
|
||||
messages=messages,
|
||||
source=source,
|
||||
profile=profile,
|
||||
@@ -432,27 +440,37 @@ class OpenAICompatTransport:
|
||||
except httpx.TransportError as exc:
|
||||
# VT 宽集: 覆盖断连/协议错误/读写失败(设计 §9 行 8)
|
||||
raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc
|
||||
# 此处是唯一同时握有请求方向与响应结果的地方,对账只能落在这里
|
||||
# 实际发出去的档只有 `_build_payload` 知道,而组装 TransportResult 的两条
|
||||
# 路径都在更深一层。在此唯一汇合点补齐,好过给两条路径各加一个参数——那正是
|
||||
# 遥测那边被明令禁止的"复制参数列表"形态,两条路径迟早只改一条
|
||||
result = replace(result, applied_effort=applied_effort)
|
||||
# 此处是唯一同时握有请求档位与响应结果的地方,对账只能落在这里
|
||||
self._warn_on_thinking_mismatch(source, result)
|
||||
return result
|
||||
|
||||
def _warn_on_thinking_mismatch(self, source: SourceConfig, result: TransportResult) -> None:
|
||||
"""声明与观测矛盾即 warning;按 (source, model, direction) 节流,同组合只喊一次。
|
||||
"""声明与观测矛盾即 warning;按 (source, model, 实际档位) 节流,同组合只喊一次。
|
||||
|
||||
三段缺一不可。**方向**: 同一模型的开、关两档是两个独立的矛盾。**源名**:
|
||||
多源多账号是本库的核心场景,同一 model 跨 N 个源是常态,而每个源背后是
|
||||
独立的账号/网关,一个源的行为不代表另一个——漏掉源名,5 个源里第一个出
|
||||
问题的喊完一次,其余四个永久静音。逐次调用刷屏会把告警变成噪声,噪声等于
|
||||
没有告警。
|
||||
三段缺一不可。**档位**: 同一模型的 low 与 max 是两个独立的矛盾,共用一个
|
||||
键会让第二个永久静音(旧版拿 `enable_thinking` 当第三段,而档位根本不经过
|
||||
那个字段,于是同一模型的所有档共用一个键)。**源名**: 多源多账号是本库的
|
||||
核心场景,同一 model 跨 N 个源是常态,而每个源背后是独立的账号/网关,一个
|
||||
源的行为不代表另一个——漏掉源名,5 个源里第一个出问题的喊完一次,其余四个
|
||||
永久静音。逐次调用刷屏会把告警变成噪声,噪声等于没有告警。
|
||||
|
||||
档位取 `result.applied_effort`(真正发出去的那一档)而非请求档: `nearest`
|
||||
映射后二者分叉,而对账问的是"我发出去的要求有没有被满足"——拿一个从未发出
|
||||
过的档去对账,文案与键都指向了一次不存在的请求。被映射到同一档的两个请求
|
||||
因此共用一个键,这正是它们该有的关系(同一条实际要求,同一个矛盾)。
|
||||
|
||||
**先判键再对账**: `reconcile_thinking` 会拼含完整 `evidence` 的长字符串,
|
||||
而非流式档每次调用都命中这一分支,节流后再拼是纯粹的热路径浪费。
|
||||
"""
|
||||
key = (source.name, source.model, source.enable_thinking)
|
||||
key = (source.name, source.model, result.applied_effort)
|
||||
if key in self._warned_mismatches:
|
||||
return
|
||||
message = reconcile_thinking(
|
||||
enable_thinking=source.enable_thinking,
|
||||
effort=result.applied_effort,
|
||||
observation=result.thinking_observation,
|
||||
capability=get_capability(source.model, table=self._capabilities),
|
||||
model=source.model,
|
||||
|
||||
Reference in New Issue
Block a user