fix(llm): request_id 条件透传贯通网关日志(CR-H4,守 §9.3)
This commit is contained in:
@@ -29,6 +29,10 @@
|
|||||||
- `OpenAICompatAdapter` 不变(新增瞬时错误包装,参与回退链)。
|
- `OpenAICompatAdapter` 不变(新增瞬时错误包装,参与回退链)。
|
||||||
- **依赖**:`packages/llm_gateway/pyproject.toml` 加 `tenacity>=8.2`(已 `uv sync`;原为 instructor 传递依赖)。**`anthropic`/`google-genai` SDK 已由 @devops 加(已 `uv sync`)**——适配器仍懒导入/注入客户端,单测零依赖。
|
- **依赖**:`packages/llm_gateway/pyproject.toml` 加 `tenacity>=8.2`(已 `uv sync`;原为 instructor 传递依赖)。**`anthropic`/`google-genai` SDK 已由 @devops 加(已 `uv sync`)**——适配器仍懒导入/注入客户端,单测零依赖。
|
||||||
|
|
||||||
|
#### C1 扩(CR-H4, 2026-07-08)· `LlmRequest` 加可选 `request_id` owner @llm 状态: 稳定
|
||||||
|
- **`LlmRequest` 加字段**(C1 形变,**仅加可选、默认 `None`,最后一位,向后兼容**):`request_id: str | None = None`。调用方(apps/api/编排器)设置后,网关把它**条件透传**到每条 `llm_call` / `llm_provider_failed` 日志(`req.request_id is not None` 才发——避免 None 覆盖 `merge_contextvars` 在 sync/SSE 路径供的 id),贯通端到端追踪(ARCH §9.3)。
|
||||||
|
- **无 OpenAPI 形变、无需 gen:api**:`LlmRequest` 是网关内部类型,不出现在 `packages/shared` / `schema.d.ts`;前端无感。旧调用点全部无 request_id → 默认 `None`,行为不变。
|
||||||
|
|
||||||
#### C1 扩 follow-up #2(2026-06-19)· `build_adapter` provider→适配器工厂 owner @llm 状态: 稳定
|
#### C1 扩 follow-up #2(2026-06-19)· `build_adapter` provider→适配器工厂 owner @llm 状态: 稳定
|
||||||
- 位置:`packages/llm_gateway/ww_llm_gateway/factory.py`,经包 `__init__` 导出 `build_adapter`。
|
- 位置:`packages/llm_gateway/ww_llm_gateway/factory.py`,经包 `__init__` 导出 `build_adapter`。
|
||||||
- **签名(稳定,apps/api `build_gateway_for_tier` 据此逐 provider 建适配器进 `adapters` dict)**:
|
- **签名(稳定,apps/api `build_gateway_for_tier` 据此逐 provider 建适配器进 `adapters` dict)**:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import structlog
|
||||||
from fakes import FakeAdapter, FakeLedger, fake_route
|
from fakes import FakeAdapter, FakeLedger, fake_route
|
||||||
from ww_llm_gateway.gateway import Gateway
|
from ww_llm_gateway.gateway import Gateway
|
||||||
from ww_llm_gateway.routing import resolve_route
|
from ww_llm_gateway.routing import resolve_route
|
||||||
@@ -60,6 +61,33 @@ async def test_stream_yields_deltas_and_records_once(req: LlmRequest) -> None:
|
|||||||
assert ledger.records[0].output_tokens == 50
|
assert ledger.records[0].output_tokens == 50
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_logs_request_id_from_request(scope: Scope) -> None:
|
||||||
|
# CR-H4:调用方设了 request_id 时,网关 llm_call 日志必须带上它(贯通 §9.3 追踪)。
|
||||||
|
ledger = FakeLedger()
|
||||||
|
gw = Gateway({"deepseek": FakeAdapter()}, ledger, resolver=fake_route)
|
||||||
|
req = LlmRequest(tier="writer", input="x", scope=scope, request_id="rid-xyz")
|
||||||
|
|
||||||
|
with structlog.testing.capture_logs() as logs:
|
||||||
|
await gw.run(req)
|
||||||
|
|
||||||
|
call = next(entry for entry in logs if entry["event"] == "llm_call")
|
||||||
|
assert call["request_id"] == "rid-xyz"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_omits_request_id_when_unset(scope: Scope) -> None:
|
||||||
|
# 未设 request_id 时**不得**发出该键——否则 None 会覆盖 merge_contextvars 供的 id,
|
||||||
|
# 反倒回退了 sync/SSE 路径的追踪(条件透传锁定)。
|
||||||
|
ledger = FakeLedger()
|
||||||
|
gw = Gateway({"deepseek": FakeAdapter()}, ledger, resolver=fake_route)
|
||||||
|
req = LlmRequest(tier="writer", input="x", scope=scope)
|
||||||
|
|
||||||
|
with structlog.testing.capture_logs() as logs:
|
||||||
|
await gw.run(req)
|
||||||
|
|
||||||
|
call = next(entry for entry in logs if entry["event"] == "llm_call")
|
||||||
|
assert "request_id" not in call
|
||||||
|
|
||||||
|
|
||||||
async def test_unknown_provider_raises_llm_unavailable(req: LlmRequest) -> None:
|
async def test_unknown_provider_raises_llm_unavailable(req: LlmRequest) -> None:
|
||||||
gw = Gateway({}, FakeLedger(), resolver=fake_route)
|
gw = Gateway({}, FakeLedger(), resolver=fake_route)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ def test_llm_request_has_no_thinking_field() -> None:
|
|||||||
"output_schema",
|
"output_schema",
|
||||||
"max_tokens",
|
"max_tokens",
|
||||||
"scope",
|
"scope",
|
||||||
|
"request_id", # CR-H4:可选端到端追踪字段(additive/optional)。
|
||||||
}
|
}
|
||||||
# 同名但另一处 live 字段 `Capabilities.thinking`(适配器能力矩阵)不受影响。
|
# 同名但另一处 live 字段 `Capabilities.thinking`(适配器能力矩阵)不受影响。
|
||||||
assert "thinking" in Capabilities.model_fields
|
assert "thinking" in Capabilities.model_fields
|
||||||
|
|||||||
@@ -183,22 +183,26 @@ class Gateway:
|
|||||||
def _log_call(
|
def _log_call(
|
||||||
self, req: LlmRequest, usage: Usage, served_by: ServedBy, *, stream: bool
|
self, req: LlmRequest, usage: Usage, served_by: ServedBy, *, stream: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
log.info(
|
fields: dict[str, object] = {
|
||||||
"llm_call",
|
"provider": usage.provider,
|
||||||
provider=usage.provider,
|
"model": usage.model,
|
||||||
model=usage.model,
|
"tier": req.tier,
|
||||||
tier=req.tier,
|
"input_tokens": usage.input_tokens,
|
||||||
input_tokens=usage.input_tokens,
|
"output_tokens": usage.output_tokens,
|
||||||
output_tokens=usage.output_tokens,
|
"cache_read_tokens": usage.cache_read_tokens,
|
||||||
cache_read_tokens=usage.cache_read_tokens,
|
"cost_minor": usage.cost_minor,
|
||||||
cost_minor=usage.cost_minor,
|
"currency": usage.currency,
|
||||||
currency=usage.currency,
|
"stream": stream,
|
||||||
stream=stream,
|
"fell_back": served_by.fell_back,
|
||||||
fell_back=served_by.fell_back,
|
"degraded": served_by.degraded,
|
||||||
degraded=served_by.degraded,
|
"input_chars": _input_len(req),
|
||||||
input_chars=_input_len(req),
|
"project_id": str(req.scope.project_id) if req.scope.project_id else None,
|
||||||
project_id=str(req.scope.project_id) if req.scope.project_id else None,
|
}
|
||||||
)
|
# 仅在调用方显式设了 request_id 时才发——否则 None 会覆盖 merge_contextvars
|
||||||
|
# 供的 id,反倒回退 sync/SSE 路径的追踪(§9.3)。
|
||||||
|
if req.request_id is not None:
|
||||||
|
fields["request_id"] = req.request_id
|
||||||
|
log.info("llm_call", **fields)
|
||||||
|
|
||||||
def _retrying(self) -> AsyncRetrying:
|
def _retrying(self) -> AsyncRetrying:
|
||||||
return AsyncRetrying(
|
return AsyncRetrying(
|
||||||
@@ -237,6 +241,7 @@ class Gateway:
|
|||||||
provider=route.provider,
|
provider=route.provider,
|
||||||
tier=req.tier,
|
tier=req.tier,
|
||||||
error=type(exc).__name__,
|
error=type(exc).__name__,
|
||||||
|
**({"request_id": req.request_id} if req.request_id is not None else {}),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
self._breaker.record_success(route.provider)
|
self._breaker.record_success(route.provider)
|
||||||
@@ -297,6 +302,7 @@ class Gateway:
|
|||||||
tier=req.tier,
|
tier=req.tier,
|
||||||
error=type(exc).__name__,
|
error=type(exc).__name__,
|
||||||
stream=True,
|
stream=True,
|
||||||
|
**({"request_id": req.request_id} if req.request_id is not None else {}),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
self._breaker.record_success(route.provider)
|
self._breaker.record_success(route.provider)
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ class Scope(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class LlmRequest(BaseModel):
|
class LlmRequest(BaseModel):
|
||||||
"""统一请求。`system` 稳定块在前(断点前),`input` 易变内容在后。"""
|
"""统一请求。`system` 稳定块在前(断点前),`input` 易变内容在后。
|
||||||
|
|
||||||
|
`request_id`(可选)贯通端到端追踪:调用方设置后,网关把它落到每条 LLM 调用日志,
|
||||||
|
使一次「写一章」的 assemble→write→审→验收全链路可 grep(ARCH §9.3)。
|
||||||
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
@@ -40,6 +44,7 @@ class LlmRequest(BaseModel):
|
|||||||
output_schema: type[BaseModel] | None = None
|
output_schema: type[BaseModel] | None = None
|
||||||
max_tokens: int | None = None
|
max_tokens: int | None = None
|
||||||
scope: Scope
|
scope: Scope
|
||||||
|
request_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class Usage(BaseModel):
|
class Usage(BaseModel):
|
||||||
|
|||||||
Reference in New Issue
Block a user