Merge origin/master into feat/cloud-v2-subscriptions

Keep i18n'd requester error messages while adopting master's structured
RequesterError metadata.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
RockChinQ
2026-10-17 01:37:14 +08:00
co-authored by Claude Opus 5.5
32 changed files with 4563 additions and 410 deletions
+100
View File
@@ -0,0 +1,100 @@
# LLM request-layer benchmark (2026-10-15)
This controlled test did not reproduce a large LangBot slowdown. Warm request
preparation was sub-millisecond; most measured latency occurred upstream.
It did identify two local transport problems, now fixed in this PR.
## Method
One Windows host, Python 3.12.7, LiteLLM 1.88.1 and OpenCode 2.0.16 used the
same user-provided NewAPI gateway and requested `claude-haiku-4.5`. Each client
made one warmup and six measured streaming calls, alternating all six client
orders. The synthetic system prefix was about 6,000 tokens. Parameters were
`temperature=0`, `max_tokens=256`, `stream=true`, `include_usage=true`, with an
ephemeral cache marker on the system text. No reasoning budget was requested.
The measurement relay deliberately forwards identical serialized requests and
headers for all three clients. It replaces OpenCode's added agent/environment
instructions and normalizes SDK request differences. The canonical SHA-256 is
`7834a66a188e70b4eb1b6234b3a14be4c66b8c72dd8a2c06395fe2505c1c371b`.
OpenCode runs as an isolated resident server, using its
[versioned session API](https://github.com/anomalyco/opencode/blob/v2.0.16/packages/protocol/openapi.json).
Process startup and session creation are outside the warm dispatch measurement.
All clients share the relay's upstream connection pool. Therefore this experiment
isolates local request dispatch and upstream response timing; it does **not**
compare their independent direct-to-provider TLS/proxy behavior. It also excludes
the complete bot pipeline, Runner/plugin RPC and OpenCode UI rendering. It cannot
establish which application is faster in every production deployment.
## Results
Medians, milliseconds; six measured samples per client:
| Client | Dispatch to relay | First content | Complete upstream response |
| --- | ---: | ---: | ---: |
| Direct HTTPX | 3.952 | 1119.541 | 2199.413 |
| LangBot Core requester | 6.802 | 1089.092 | 2135.935 |
| OpenCode | 41.158 | 1104.020 | 2148.265 |
Dispatch includes client preparation and local HTTP dispatch. First content and
completion use the same relay boundary for all clients, measured from dispatch;
they are not UI latency. LangBot's separately instrumented preparation median
was **0.316 ms**, maximum **0.497 ms**. These small samples and upstream variation
do not justify interpreting the small TTFT differences as a speed ranking.
Every measured response reported 5,990 input tokens, 5,987 cache-read tokens and
109 output tokens. This demonstrates equal warm cache accounting under the
normalized upstream request, not native cache-marker forwarding by all clients.
No cache-miss latency comparison or independent provider-route verification was
performed. Missing cache accounting is represented as null, never assumed zero.
The gateway may perform its own routing, retries and cache translation.
Sanitized individual samples and summaries are in
[llm-latency-2026-10-15.json](llm-latency-2026-10-15.json).
## Bottlenecks fixed
1. The OpenAI SDK stops consuming SSE at `[DONE]` before HTTP framing reaches
EOF. The healthy connection was then discarded. In the baseline relay run,
LangBot reused zero of six measured client connections; after bounded terminal
draining it reused six of six. A real TCP regression test also reproduces this
with split terminal markers and late usage. OpenRouter's generic adapter
already consumed EOF. Early close and cancellation still close immediately.
2. On this host, constructing default HTTPX clients took 1,761 / 1,443 / 1,435 ms.
Loading one TLS context per requester pool and sharing it across that client's
proxy mounts reduced measured pool setup to 666 / 430 / 431 ms. Certificate
loading now runs off the event loop. These are local setup measurements, not
a claim of equivalent percentage improvement in LLM latency. Runtime lazy
initialization and process startup remain separate cold costs.
3. High-resolution monotonic timing replaces the low-resolution clock used on
Python 3.12 Windows. Preparation no longer misleadingly rounds to zero.
`transport_setup_ms` exposes pool initialization separately.
The relay socket observations concern client-to-relay connections. HTTPX and
OpenCode have different keepalive policies, so those counts alone are not a
cross-client pooling ranking. The shared relay also prevents quantifying the
provider-side handshake savings from the SSE fix in this run.
## Reproduce
From a development environment with LangBot dependencies installed, set
`LLM_BENCHMARK_API_KEY` securely in the environment, then run:
```sh
python scripts/llm_latency_probe.py --opencode /path/to/opencode \
--opencode-version 2.0.16 --base-url https://gateway.example/v1 \
--model claude-haiku-4.5 --long-prefix --cache-control --rounds 6 \
--output /path/outside/repository/latency.json
```
This makes 21 billable requests by default. `--mock` uses synthetic local SSE
instead; it still needs OpenCode. `--baseline-no-drain` disables terminal draining
for a transport regression comparison. The harness records only hashes and
message sizes, not prompt bodies or credentials, and terminates its own isolated
OpenCode process. Keep raw results outside the repository because they include
the configured gateway URL. Other OpenCode versions may need API adjustments.
The next diagnostic for a reported slow production session should capture the
actual pipeline/Runner timing, streaming mode, prompt size, reasoning settings,
cache usage and gateway routing alongside these requester-level measurements.
+108
View File
@@ -0,0 +1,108 @@
# Request-chain acceptance (2026-10-15)
The acceptance suite starts an isolated Core process, a real SDK Plugin Runtime
process, and a packaged Runner plugin. It installs the plugin and creates the
provider, model and Agent through the same authenticated HTTP APIs used by the
application. Agent Debug requests then traverse:
HTTP -> Core Agent orchestration -> Plugin Runtime -> plugin SDK -> Runtime ->
Core model manager/request policy -> LiteLLM/provider SDK -> HTTP/SSE provider.
Assertions cover plugin return values, captured provider requests, TCP disconnects
and persisted SQLite monitoring records. No requester, action transport, model
manager or monitoring implementation is replaced with a mock.
The remote provider is a deterministic local HTTP server. It injects failures and
delays reproducibly; these are real process/network integration tests, **not** a
claim that every external provider has been tested. See the separate
[latency benchmark](LLM_LATENCY_BENCHMARK.md) for the live NewAPI experiment.
## Matrix
Measured results on Windows / Python 3.12.7 / LiteLLM 1.88.1:
- Core with SDK companion commit `c25ff9b`: **31 passed**, strict stream closure,
including all four historical API cases (106.30 seconds).
- Core with the existing SDK 0.7.11rc5 environment: **27 passed, 4 XFAIL** for
the reproduced SDK cancellation defect (107.61 seconds).
- Published stable SDK 0.7.11 also reproduced the cancellation defect in the
preceding 23-case run. The old and stable wrappers were verified identical.
- Focused Core regressions: **371 passed** on Python 3.13.7, ResourceWarnings fatal.
- SDK API and stream flow-control regressions: **507 passed**.
- Existing real Runner discovery/orchestration/event-processor E2E: **3 passed**.
| Area | Cases and evidence |
| --- | --- |
| Public APIs | Runner and invocation-bound plugin APIs: message, message with usage, chunk stream and event stream; unchanged SDK entities and nested usage |
| Historical plugin APIs | Verbatim SDK 0.5.8 proxy and provider-message entities, all four APIs, ordinary calls without `run_id` |
| Request configuration | Persisted defaults, call overrides, OpenAI cache key/retention, OpenRouter numeric reasoning budget and Anthropic cache marker |
| Headers | Explicit application attribution and scoped conversation ID reach the provider |
| Multimodal/tools | Image data URL and function schema survive plugin serialization and provider conversion |
| Retry/failure | 503 then success, `num_retries=0`, `max_retries=0`, 401 without retry, first-event timeout, total timeout, idle timeout and connection loss after content |
| Recovery | Each fault followed by a successful request through the same running processes |
| Cancellation | Early close of Runner and plugin APIs, both chunk and event streams; provider socket closes and the next invocation succeeds |
| Reasoning | Reasoning content shown/hidden with the existing `remove_think` option; unsupported numeric budget rejected before HTTP |
| Monitoring | Full cache/reasoning usage, transport timing and structured errors persisted |
Historical API files come from SDK tag `0.5.8`, commit
`41e320b68e899a7b5e74cb48c5246f6ccbf9d73f`.
They use their own message serializers and the current Runtime transport. This
checks old plugin call/response compatibility; it is not a test of running the
entire old Runtime against the current Core.
## Defects exposed by the full chain
- The plugin bridge flattened model and call policy layers, allowing persisted
`request_policy.retries` to override a call's legacy zero-retry option. LLM
policy layers are now resolved separately; unrelated argument merging remains.
- Ordinary plugin calls without a Runner/query returned content but lost usage.
Core now creates a trusted, invocation-local query for accounting, using the
existing plugin-action query builder. No public payload/signature changes.
- The shipped OpenRouter requester uses OpenAI metadata. Numeric budgets selected
the wrong mapping, and LiteLLM's OpenAI chat adapter stripped cache markers.
OpenRouter chat now uses its own protocol adapter. Embedding/rerank routing is
unchanged, and a regression test reads the actual shipped requester YAML.
- Published SDK 0.7.11 leaves inner streaming generators unowned in several public
proxy wrappers. Core's direct TCP close test passes, but the complete plugin
chain does not promptly cancel its upstream request. The companion
[SDK PR #177](https://github.com/langbot-app/langbot-plugin-sdk/pull/177) adds
explicit generator ownership without changing signatures, permissions, action
payloads or error translation.
**Immediate remote cancellation requires SDK 0.7.12 or later.** Core now pins
published SDK 0.7.12, which contains the companion fix. Separately deployed
Runtime and plugin development environments also need the fixed SDK. The E2E
suite reports the known close failure as XFAIL only for SDK <=0.7.11. Set
`LLM_E2E_REQUIRE_STREAM_CLOSE=1` to require strict passing with the patched SDK;
newer SDK versions must pass without this override. Historical API checks are
explicitly skipped unless their released source files are supplied.
## Reproduction
```sh
python -m pytest tests/e2e/test_llm_request_chain.py -q --tb=short
```
For the historical API matrix, export the two unmodified files below from SDK tag
`0.5.8` into a temporary directory, named `legacy_api.py` and `legacy_message.py`,
then set `LLM_E2E_LEGACY_API_DIR` to its absolute path:
- `src/langbot_plugin/api/proxies/langbot_api.py`
- `src/langbot_plugin/api/entities/builtin/provider/message.py`
For strict cancellation acceptance, install the companion SDK into an isolated
test environment and set `LLM_E2E_REQUIRE_STREAM_CLOSE=1` before running the suite.
The existing CI E2E job discovers this test module automatically. Tests use random
localhost ports, test-only credentials and pytest temporary data directories;
they leave application data and running user instances untouched.
## Limits
The suite does not exercise IM delivery, browser rendering, external provider
authentication/billing, native Anthropic/Gemini network services, Codex OAuth, or
complete third-party Runner business logic. It validates multimodal/tool request
transport rather than image understanding or actual tool execution. Native Codex,
retry, reasoning, transport and plugin regressions also run in the focused Core
test suite. A Python 3.13 Windows full-app attempt timed out during startup before
any LLM request; full process acceptance therefore uses Python 3.12.7, while the
focused Core regressions run on Python 3.13.7.
+256
View File
@@ -0,0 +1,256 @@
# LLM request policy and diagnostics
The Core owns chat-call deadlines and retry decisions. The existing SDK
`Message`, `MessageChunk`, invoke-with-usage and stream-usage shapes are unchanged.
No SDK or Runner upgrade is required. This policy does not add automatic tool
replay, model fallback, key rotation, or embedding/rerank retries.
The public API remains compatible with existing plugins. Full-chain testing
identified a pre-existing SDK <=0.7.11 early-close defect: prompt remote
cancellation through public plugin/Runner wrappers requires SDK 0.7.12 or later,
which includes the [SDK fix](https://github.com/langbot-app/langbot-plugin-sdk/pull/177).
Core now pins 0.7.12; separately deployed Runtime and plugin development
environments must also install a version containing the fix.
See [E2E acceptance results](LLM_REQUEST_E2E.md) for tested versions and scope.
## Configuration
Instance defaults live in `data/config.yaml` under `llm_request_policy`; restart
Core after editing them. Persist model-specific overrides in the existing model
advanced parameters (`extra_args`). Runners can override them through their
existing call-level `extra_args` argument:
```json
{
"request_policy": {
"retries": 1,
"total_timeout": 180,
"first_event_timeout": 60,
"stream_idle_timeout": 45,
"connect_timeout": 10,
"read_timeout": 120,
"write_timeout": 30,
"pool_timeout": 10,
"backoff_base": 0.5,
"backoff_max": 8,
"attribution": true,
"conversation_header": null
}
}
```
Precedence is instance/requester defaults, model extra arguments, then call extra
arguments. Within each layer, `request_policy.retries` overrides legacy
`num_retries` / `max_retries`. Zero disables retries. Legacy numeric `timeout`
sets read, first-event and idle timeouts; it does not extend the total deadline.
Timeouts are positive finite seconds; retries must be an integer from 0 to 5.
Unknown policy keys are rejected. Policy keys are removed before calling upstream.
Default retries are **zero**, total timeout is 300 seconds, and first-event and
idle timeouts are 120 seconds. Retry backoff uses exponential full jitter;
`Retry-After` seconds or HTTP dates are respected only within the remaining total
budget. Quota, authentication and invalid-request failures are not retried.
Core disables both LiteLLM and provider-SDK retries for managed calls. A gateway
may still retry internally; configure its budget separately.
Retries stop after any raw upstream event (including reasoning/control/usage)
or, for legacy adapters without raw instrumentation, the first yielded chunk.
Cancellation is propagated and the owned stream is closed. Total deadlines also
include downstream consumption time, but Core does not cancel a consumer while
the generator is suspended at a yield; expiration is enforced on the next pull.
Transport connect/read/write/pool limits apply to LiteLLM and native Codex HTTP.
Legacy third-party requesters receive Host-level deadlines but remain responsible
for their internal transport configuration and internal retries.
## Diagnostics and usage
HTTP monitoring and `get_monitoring_records(kind="llm_calls")` return additive
`details` containing `timing`, complete provider `usage`, and structured `error`.
Older records have null details. Existing top-level token counters remain intact.
Migration `0038_llm_call_details` adds a nullable JSON column and preserves rows.
Timing offsets use a high-resolution monotonic clock, in milliseconds from invocation start:
| Field | Meaning |
| --- | --- |
| `request_prepared_ms` | First attempt finished building its request |
| `request_started_ms` | First adapter call into the network SDK; not a socket-level timestamp |
| `response_open_ms` | SDK returned a response/stream object |
| `first_event_ms` | First parsed upstream stream event, including reasoning/control |
| `first_reasoning_ms` | First upstream reasoning delta when supported |
| `first_content_ms` | First upstream content delta; inline think tags may still be content |
| `first_visible_chunk_ms` | First nonempty normalized chunk; may include displayed reasoning |
| `response_completed_ms` | Host finished consuming the response |
| `total_ms` | Invocation duration, excluding the subsequent monitoring write |
| `attempts`, `retry_wait_ms` | Host attempts and total backoff wait |
| `preparation_ms` | Cumulative LiteLLM request-building time across attempts |
| `transport_setup_ms` | Cumulative managed HTTP pool acquisition, including first-use TLS setup |
| `preparation_cache_hits` | Message preparation reused within a retrying invocation |
Missing fields mean not observed or not instrumented, not zero latency. The Core
does not label these measurements as network-only TTFT. Usage preserves nested
cache and reasoning counts, costs and provider-specific counters when supplied;
missing upstream accounting cannot be reconstructed. Partial usage received
before cancellation/failure is retained. No fabricated usage event is sent early.
`RequesterError` retains its legacy string representation and adds `code`,
`status_code`, `retryable`, `retry_after`, `request_id`, `phase`, and `as_dict()`.
Native Codex errors retain their existing ValueError/HTTP error contract.
Legacy SDK error transport remains unchanged; structured diagnostics are available
in Core monitoring. The metadata never copies upstream error bodies or headers
wholesale. Requester-specific raw error strings are not included by the LiteLLM
error translator.
## Application and conversation headers
Direct OpenRouter requests automatically receive:
```text
HTTP-Referer: https://langbot.app
X-OpenRouter-Title: LangBot
x-session-id: lb-<opaque scoped hash>
```
Existing headers in model/call `extra_headers` win, case-insensitively. Legacy
`X-Title` is respected. Set `attribution: false` to disable automatic application
attribution (explicit headers remain untouched). Detection uses the exact
`openrouter.ai` hostname, or an OpenRouter requester without an overridden base
URL; arbitrary gateway hosts do not inherit OpenRouter identity headers.
The conversation hash includes Workspace, bot, pipeline and session identity;
it is stable across turns and differs across those scopes. It is pseudonymous,
not a claim of cryptographic anonymization. Calls without conversation context
omit generated conversation headers. No prompt, raw user ID or group ID is sent.
`x-langbot-conversation-id` is a custom convention, **not a documented universal
provider standard**. OpenRouter documents `x-session-id` (or body `session_id`,
which takes precedence). LiteLLM Proxy documents `x-litellm-session-id` and
vendor session headers. Configure `conversation_header` explicitly for gateways:
```json
{
"request_policy": {"conversation_header": "x-litellm-session-id"},
"extra_headers": {
"HTTP-Referer": "https://langbot.app",
"X-OpenRouter-Title": "LangBot"
}
}
```
Supported generated header names are `x-session-id`, `x-litellm-session-id` and
`x-langbot-conversation-id`. The latter is intended for a gateway configured to
consume that custom header; do not expect automatic routing or caching benefits.
New API may supply its own OpenRouter attribution and may filter/override client
headers. Configure explicit channel forwarding/override rules and verify the
outgoing request. Sending a header to New API is not proof OpenRouter received it.
The local `langbot-new-api` snapshot inspected during implementation overwrites
attribution with New API; upstream main uses defaults when its outgoing headers
are empty. This change does not modify or deploy either gateway.
Sources checked on 2026-10-15:
- https://openrouter.ai/docs/app-attribution
- https://openrouter.ai/docs/guides/best-practices/prompt-caching
- https://docs.litellm.ai/docs/proxy/request_headers
- https://docs.litellm.ai/docs/routing
- https://github.com/QuantumNous/new-api/blob/main/relay/channel/openai/adaptor.go
- https://github.com/QuantumNous/new-api/blob/main/relay/channel/api_request.go
## Connection and preparation reuse
Initialized OpenAI-compatible, OpenRouter and native Codex requesters own a lazy
HTTP pool per runtime Provider. Keys, headers and timeout budgets stay request-local.
The pool holds at most 100 connections, with 20 idle keepalive connections and a
30-second idle expiry. Provider retirement rejects new leases and closes its pool
after existing streams finish or are cancelled. Native LiteLLM providers retain
their own transport handling; custom LiteLLM transport/TLS settings, explicit caller clients and redirected model routes
are preserved. Bare OpenAI names on OpenRouter retain LiteLLM's alternate routing;
use OpenRouter's fully qualified names such as `openai/gpt-4o`.
Each pool loads its TLS context once in a worker thread, preserving HTTPX
certificate and proxy environment settings. Concurrent initialization is serialized;
cancellation or retirement during certificate loading creates no HTTP client.
OpenAI-compatible streams drain HTTP framing after the SSE `[DONE]` record
(at most 50 ms / 64 KiB) so the SDK can return healthy connections to the pool.
Early close and cancellation never trigger this drain; late usage preceding
`[DONE]` remains available. OpenRouter already consumes EOF in its generic adapter.
Codex briefly drains the HTTP tail after a completion event (at most 50 ms) to
release a healthy connection for reuse. Incomplete/cancelled streams are closed.
Capability lookups use a bounded requester-local cache (128 entries, five minutes).
Message/tool conversion is reused only within an invocation that permits retries,
with independent copies for each attempt. No conversation content is cached across
invocations. Token counting runs in the application's executor instead of blocking
the event loop. These reduce local overhead; they do not guarantee faster upstream
inference or a cache hit.
## Unified thinking budgets
Existing effort levels and Runner/SDK signatures remain unchanged. Model
`reasoning_config` accepts an optional positive integer `budget_tokens`:
```json
{"reasoning_config": {"level": "enabled", "budget_tokens": 2048}}
```
Old SDKs can send the same object inside their existing call `extra_args`.
Precedence is persisted model configuration, model `extra_args.reasoning_config`,
call `extra_args.reasoning_config`, then the existing Runner override. Each object
replaces the previous one; a Runner effort override clears a lower-priority budget.
Omitting `level` with a budget means `enabled`. Budgets cannot be combined with an
effort level, `disabled`, or native thinking arguments. Unsupported budgets fail
before the network call; they are never silently converted into an effort level.
Numeric budgets map to Anthropic `thinking.budget_tokens`, Gemini 2.5 thinking
configuration, supported Qwen `thinking_budget`, or OpenRouter
`reasoning.max_tokens`. Direct OpenAI/Codex and adaptive-only Claude models keep
effort-based control. Query `reasoning_capabilities.budget_tokens` for the Core's
accepted bounds; upstream models/gateways may impose additional limits. OpenRouter
may translate numeric budgets to effort for models without native numeric support.
Direct Anthropic calls require an explicit `max_tokens` greater than the budget;
Core never silently raises the output limit. Existing UI effort selectors are
unchanged; numeric budgets can be configured through the model API or extra args.
## Prompt caching
Default behavior remains provider-controlled. `extra_args.prompt_caching` is a
Core-only object and is stripped before sending the request. Call-level objects
replace model-level objects. There is no local generated-response cache.
```json
{"prompt_caching": {"mode": "openai", "retention": "in_memory"}}
```
OpenAI mode adds an opaque conversation-and-model-scoped `prompt_cache_key` when
conversation context exists. `retention` is optional (`in_memory` or `24h`) and
must be supported by the destination model/gateway. Native explicit
`prompt_cache_key`, `prompt_cache_retention`, and newer `prompt_cache_options`
take precedence. Routing keys are hints, not cache-hit guarantees; current models
may route caching automatically or use newer native options instead. This mode
supports OpenAI-compatible/OpenRouter OpenAI models; forwarding through gateways
must be verified separately.
```json
{"prompt_caching": {"mode": "anthropic", "ttl": "5m"}}
```
Anthropic mode supports native Claude and OpenRouter Claude. It adds one ephemeral
breakpoint to the last text block in the leading system/developer prefix, without
reordering tools/history or marking user messages. TTL is `5m` (default) or `1h`.
If any native cache-control marker exists, Core preserves it without adding more.
With no eligible prefix, no marker is inserted. Provider minimum prefix lengths,
TTL pricing and eligibility still apply. Explicit `provider_default` adds no fields.
Codex's restricted Responses body does not accept this caching object; its existing
server-managed caching behavior is preserved. Gemini explicit cache resource
creation/lifecycle and general Responses migration are outside this batch.
References checked on 2026-10-15:
- https://developers.openai.com/api/docs/guides/prompt-caching
- https://platform.claude.com/docs/en/build-with-claude/prompt-caching
- https://platform.claude.com/docs/en/build-with-claude/extended-thinking
- https://ai.google.dev/gemini-api/docs/generate-content/thinking
- https://openrouter.ai/docs/guides/best-practices/reasoning-tokens
See [the latency benchmark](LLM_LATENCY_BENCHMARK.md) for measured results and limitations.
+411
View File
@@ -0,0 +1,411 @@
{
"model": "claude-haiku-4.5",
"litellm": "1.88.1",
"opencode": "2.0.16",
"canonical_sha256": "7834a66a188e70b4eb1b6234b3a14be4c66b8c72dd8a2c06395fe2505c1c371b",
"canonical_bytes": 31785,
"summary": {
"direct": {
"samples": 6,
"dispatch_to_wire_ms": {
"median": 3.952,
"max": 5.44
},
"upstream_ttft_ms": {
"median": 1115.744,
"max": 1158.609
},
"ttft_ms": {
"median": 1119.541,
"max": 1164.048
},
"total_ms": {
"median": 2199.413,
"max": 2219.7
},
"cache_read_tokens": [
5987,
5987,
5987,
5987,
5987,
5987
]
},
"langbot": {
"samples": 6,
"dispatch_to_wire_ms": {
"median": 6.802,
"max": 10.028
},
"upstream_ttft_ms": {
"median": 1079.658,
"max": 1113.394
},
"ttft_ms": {
"median": 1089.092,
"max": 1120.475
},
"total_ms": {
"median": 2135.935,
"max": 2175.882
},
"preparation_ms": {
"median": 0.316,
"max": 0.497
},
"cache_read_tokens": [
5987,
5987,
5987,
5987,
5987,
5987
]
},
"opencode": {
"samples": 6,
"dispatch_to_wire_ms": {
"median": 41.158,
"max": 50.283
},
"upstream_ttft_ms": {
"median": 1063.875,
"max": 1158.768
},
"ttft_ms": {
"median": 1104.02,
"max": 1201.731
},
"total_ms": {
"median": 2148.265,
"max": 2244.956
},
"cache_read_tokens": [
5987,
5987,
5987,
5987,
5987,
5987
]
}
},
"records": [
{
"client": "direct",
"round": -1,
"warmup": true,
"dispatch_to_wire_ms": 3.500899998471141,
"upstream_ttft_ms": 2094.1667999722995,
"ttft_ms": 2097.6676999707706,
"total_ms": 3145.2215999597684,
"client_socket_reused": false,
"consumer_first_content_ms": 2098.3635999727994,
"consumer_total_ms": 3146.0422999807633,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "langbot",
"round": -1,
"warmup": true,
"dispatch_to_wire_ms": 1452.2201999789104,
"upstream_ttft_ms": 1122.500299999956,
"ttft_ms": 2574.7204999788664,
"total_ms": 3640.2128000045195,
"preparation_ms": 0.373,
"transport_setup_ms": 441.243,
"client_socket_reused": false,
"consumer_first_content_ms": 2576.894700003322,
"consumer_total_ms": 3646.833000006154,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "opencode",
"round": -1,
"warmup": true,
"dispatch_to_wire_ms": 846.3009999832138,
"upstream_ttft_ms": 1082.7391999773681,
"ttft_ms": 1929.040199960582,
"total_ms": 3569.3477999884635,
"client_socket_reused": false,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "direct",
"round": 0,
"warmup": false,
"dispatch_to_wire_ms": 3.5812999703921378,
"upstream_ttft_ms": 1012.8218000172637,
"ttft_ms": 1016.4030999876559,
"total_ms": 2067.0370999723673,
"client_socket_reused": false,
"consumer_first_content_ms": 1017.0787000097334,
"consumer_total_ms": 2067.508200008888,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "langbot",
"round": 0,
"warmup": false,
"dispatch_to_wire_ms": 6.439700024202466,
"upstream_ttft_ms": 1067.0202999608591,
"ttft_ms": 1073.4599999850616,
"total_ms": 2151.4558999915607,
"preparation_ms": 0.281,
"transport_setup_ms": 0.017,
"client_socket_reused": true,
"consumer_first_content_ms": 1074.6371999848634,
"consumer_total_ms": 2156.441500002984,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "opencode",
"round": 0,
"warmup": false,
"dispatch_to_wire_ms": 50.28290004702285,
"upstream_ttft_ms": 1151.4483999926597,
"ttft_ms": 1201.7313000396825,
"total_ms": 2244.956100010313,
"client_socket_reused": false,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "direct",
"round": 1,
"warmup": false,
"dispatch_to_wire_ms": 3.269899985753,
"upstream_ttft_ms": 1149.7626000200398,
"ttft_ms": 1153.0325000057928,
"total_ms": 2198.293499997817,
"client_socket_reused": true,
"consumer_first_content_ms": 1153.3279999857768,
"consumer_total_ms": 2198.7193999812007,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "opencode",
"round": 1,
"warmup": false,
"dispatch_to_wire_ms": 40.65959999570623,
"upstream_ttft_ms": 1158.767800021451,
"ttft_ms": 1199.4274000171572,
"total_ms": 2242.273700016085,
"client_socket_reused": false,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "langbot",
"round": 1,
"warmup": false,
"dispatch_to_wire_ms": 6.523299962282181,
"upstream_ttft_ms": 1070.9955000202172,
"ttft_ms": 1077.5187999824993,
"total_ms": 2109.3437999952585,
"preparation_ms": 0.497,
"transport_setup_ms": 0.015,
"client_socket_reused": true,
"consumer_first_content_ms": 1081.5072999685071,
"consumer_total_ms": 2113.767499977257,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "langbot",
"round": 2,
"warmup": false,
"dispatch_to_wire_ms": 7.08060001488775,
"upstream_ttft_ms": 1113.394399988465,
"ttft_ms": 1120.4750000033528,
"total_ms": 2175.882300012745,
"preparation_ms": 0.221,
"transport_setup_ms": 0.009,
"client_socket_reused": true,
"consumer_first_content_ms": 1122.9100999771617,
"consumer_total_ms": 2187.183100031689,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "direct",
"round": 2,
"warmup": false,
"dispatch_to_wire_ms": 5.439500033389777,
"upstream_ttft_ms": 1158.6087999748997,
"ttft_ms": 1164.0483000082895,
"total_ms": 2219.7004000190645,
"client_socket_reused": false,
"consumer_first_content_ms": 1164.5996000152081,
"consumer_total_ms": 2220.197000016924,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "opencode",
"round": 2,
"warmup": false,
"dispatch_to_wire_ms": 41.65710002416745,
"upstream_ttft_ms": 1063.7803999707103,
"ttft_ms": 1105.4374999948777,
"total_ms": 2145.6969000282697,
"client_socket_reused": false,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "langbot",
"round": 3,
"warmup": false,
"dispatch_to_wire_ms": 10.027599986642599,
"upstream_ttft_ms": 1080.2179000456817,
"ttft_ms": 1090.2455000323243,
"total_ms": 2105.4020000156015,
"preparation_ms": 0.406,
"transport_setup_ms": 0.012,
"client_socket_reused": true,
"consumer_first_content_ms": 1092.152299999725,
"consumer_total_ms": 2109.886699996423,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "opencode",
"round": 3,
"warmup": false,
"dispatch_to_wire_ms": 38.63289998844266,
"upstream_ttft_ms": 1063.9694000128657,
"ttft_ms": 1102.6023000013083,
"total_ms": 2139.4490000093356,
"client_socket_reused": false,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "direct",
"round": 3,
"warmup": false,
"dispatch_to_wire_ms": 4.3889000080525875,
"upstream_ttft_ms": 1157.878800004255,
"ttft_ms": 1162.2677000123076,
"total_ms": 2214.4845000002533,
"client_socket_reused": false,
"consumer_first_content_ms": 1162.9892000346445,
"consumer_total_ms": 2214.923900028225,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "opencode",
"round": 4,
"warmup": false,
"dispatch_to_wire_ms": 34.960800025146455,
"upstream_ttft_ms": 1052.52319999272,
"ttft_ms": 1087.4840000178665,
"total_ms": 2137.3740999842994,
"client_socket_reused": true,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "direct",
"round": 4,
"warmup": false,
"dispatch_to_wire_ms": 2.0186000037938356,
"upstream_ttft_ms": 987.3963000136428,
"ttft_ms": 989.4149000174366,
"total_ms": 2200.532699993346,
"client_socket_reused": true,
"consumer_first_content_ms": 989.9038000148721,
"consumer_total_ms": 2201.0204999824055,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "langbot",
"round": 4,
"warmup": false,
"dispatch_to_wire_ms": 6.363800028339028,
"upstream_ttft_ms": 1112.4532999820076,
"ttft_ms": 1118.8171000103466,
"total_ms": 2150.362300046254,
"preparation_ms": 0.325,
"transport_setup_ms": 0.011,
"client_socket_reused": true,
"consumer_first_content_ms": 1120.592100021895,
"consumer_total_ms": 2154.264000011608,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "opencode",
"round": 5,
"warmup": false,
"dispatch_to_wire_ms": 41.722599999047816,
"upstream_ttft_ms": 1059.4015000388026,
"ttft_ms": 1101.1241000378504,
"total_ms": 2150.8331000222825,
"client_socket_reused": false,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "langbot",
"round": 5,
"warmup": false,
"dispatch_to_wire_ms": 8.840300026349723,
"upstream_ttft_ms": 1079.098400019575,
"ttft_ms": 1087.9387000459246,
"total_ms": 2121.5078000095673,
"preparation_ms": 0.307,
"transport_setup_ms": 0.021,
"client_socket_reused": true,
"consumer_first_content_ms": 1091.6245000553317,
"consumer_total_ms": 2126.561200013384,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
},
{
"client": "direct",
"round": 5,
"warmup": false,
"dispatch_to_wire_ms": 4.323699977248907,
"upstream_ttft_ms": 1081.7263000062667,
"ttft_ms": 1086.0499999835156,
"total_ms": 2126.9287999602966,
"client_socket_reused": false,
"consumer_first_content_ms": 1086.7821999709122,
"consumer_total_ms": 2127.6911000022665,
"input_tokens": 5990,
"output_tokens": 109,
"cache_read_tokens": 5987
}
]
}
+1 -1
View File
@@ -71,7 +71,7 @@ dependencies = [
"langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.7.11",
"langbot-plugin==0.7.12",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
+418
View File
@@ -0,0 +1,418 @@
"""Compare a direct stream, Core requester and isolated OpenCode 2.x server.
All three clients use a local measurement relay. The relay sends the same
synthetic request bytes and headers upstream, deliberately removing agent prompt
differences. This measures the request layer, not a production bot pipeline/UI.
Credentials are read from an environment variable or an untracked local file.
"""
import argparse
import asyncio
import contextlib
import hashlib
import itertools
import json
import os
from pathlib import Path
import socket
import statistics
import secrets
import subprocess
import tempfile
import time
from types import SimpleNamespace
import httpx
from aiohttp import web
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
class SSEMeter:
"""Incrementally parse SSE without assuming network chunk boundaries."""
def __init__(self):
self.buffer = b''
self.first_event = None
self.first_content = None
self.usage = None
self.content_chars = 0
self.done = False
def feed(self, data, now):
self.buffer += data
while b'\n' in self.buffer:
line, self.buffer = self.buffer.split(b'\n', 1)
if not line.startswith(b'data:'):
continue
raw = line[5:].strip()
if raw == b'[DONE]':
self.done = True
continue
item = json.loads(raw)
if self.first_event is None:
self.first_event = now
if item.get('usage'):
self.usage = item['usage']
for choice in item.get('choices', []):
content = (choice.get('delta') or {}).get('content')
if content:
if self.first_content is None:
self.first_content = now
self.content_chars += len(content)
def cache_tokens(usage):
if not isinstance(usage, dict):
return None
detail = usage.get('prompt_tokens_details') or usage.get('input_tokens_details') or {}
return detail.get('cached_tokens', usage.get('cache_read_input_tokens'))
def summary(records):
result = {}
for name in sorted({row['client'] for row in records}):
rows = [r for r in records if r['client'] == name and not r.get('warmup')]
stats = {'samples': len(rows)}
for key in ('dispatch_to_wire_ms', 'upstream_ttft_ms', 'ttft_ms', 'total_ms', 'preparation_ms'):
values = [r[key] for r in rows if r.get(key) is not None]
if values:
stats[key] = {'median': round(statistics.median(values), 3), 'max': round(max(values), 3)}
stats['cache_read_tokens'] = [cache_tokens(r.get('usage')) for r in rows]
result[name] = stats
return result
def free_port():
with socket.socket() as sock:
sock.bind(('127.0.0.1', 0))
return sock.getsockname()[1]
async def run(options):
# Import/initialization cost is reported separately from resident calls.
started = time.perf_counter()
from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester
from langbot.pkg.provider.modelmgr.request_policy import CallState, RequestPolicy, current_call
from langbot_plugin.api.entities.builtin.provider.message import Message
import importlib.metadata
import_ms = (time.perf_counter() - started) * 1000
credentials = json.loads(Path(options.credentials_file).read_text()) if options.credentials_file else {}
base_url = options.base_url or credentials.get('base_url')
key = os.environ.get(options.api_key_env) or credentials.get('api_key')
if not options.mock and not (base_url and key):
raise ValueError('A base URL and API key are required for live mode')
prefix = 'You are a concise technical assistant. Follow only the final question.\n'
if options.long_prefix:
prefix += ''.join(
f'Reference entry {i}: HTTP connection pooling reuses established transports. '
'Keep authentication and timeouts request scoped. Measure first content separately from reasoning.\n'
for i in range(180)
)
canonical = {
'model': options.model,
'stream': True,
'stream_options': {'include_usage': True},
'temperature': 0,
'max_tokens': 256,
'messages': [
{
'role': 'system',
'content': [
{
'type': 'text',
'text': prefix,
**({'cache_control': {'type': 'ephemeral'}} if options.cache_control else {}),
}
],
},
{'role': 'user', 'content': 'Explain HTTP connection pooling in exactly five short numbered sentences.'},
],
}
payload = json.dumps(canonical, separators=(',', ':')).encode()
records = []
active = None
upstream = httpx.AsyncClient(timeout=90)
relay_sockets = {}
async def relay(request):
nonlocal active
if active is None or active.get('wire_started') is not None:
return web.json_response({'error': 'Unexpected extra request'}, status=409)
row = active
finished = row['relay_finished']
row['wire_started'] = time.perf_counter()
incoming = await request.json()
row['received_body_hash'] = digest(incoming)
row['received_fields'] = sorted(incoming)
row['received_message_chars'] = len(json.dumps(incoming.get('messages', [])))
row['upstream_body_hash'] = digest(canonical)
row['dispatch_to_wire_ms'] = (row['wire_started'] - row['start']) * 1000
connection = id(request.transport)
row['client_socket_reused'] = connection in relay_sockets
relay_sockets[connection] = request.transport
meter = SSEMeter()
response = web.StreamResponse(headers={'Content-Type': 'text/event-stream'})
try:
if options.mock:
await response.prepare(request)
await asyncio.sleep(0.05)
for i in range(20):
event = {
'id': 'synthetic',
'object': 'chat.completion.chunk',
'created': 1,
'model': options.model,
'choices': [{'index': 0, 'delta': {'content': 'word '}, 'finish_reason': None}],
}
data = ('data: ' + json.dumps(event) + '\n\n').encode()
meter.feed(data, time.perf_counter())
await response.write(data)
await asyncio.sleep(0.03)
event['choices'] = [{'index': 0, 'delta': {}, 'finish_reason': 'stop'}]
event['usage'] = {'prompt_tokens': 50, 'completion_tokens': 20, 'total_tokens': 70}
data = ('data: ' + json.dumps(event) + '\n\ndata: [DONE]\n\n').encode()
meter.feed(data, time.perf_counter())
await response.write(data)
else:
async with upstream.stream(
'POST',
base_url.rstrip('/') + '/chat/completions',
content=payload,
headers={'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json'},
) as remote:
row['status'] = remote.status_code
if remote.status_code != 200:
await remote.aread()
# Do not log upstream response bodies, headers or credentials.
return web.json_response(
{'error': f'Upstream HTTP {remote.status_code}'}, status=remote.status_code
)
row['upstream_headers_ms'] = (time.perf_counter() - row['wire_started']) * 1000
await response.prepare(request)
async for chunk in remote.aiter_bytes():
meter.feed(chunk, time.perf_counter())
try:
await response.write(chunk)
except ConnectionResetError:
row['client_closed_before_http_eof'] = True
row['upstream_ttft_ms'] = (
(meter.first_content - row['wire_started']) * 1000 if meter.first_content is not None else None
)
row['ttft_ms'] = (meter.first_content - row['start']) * 1000 if meter.first_content is not None else None
row['usage'] = meter.usage
row['content_chars'] = meter.content_chars
row['upstream_complete_ms'] = (time.perf_counter() - row['wire_started']) * 1000
with contextlib.suppress(ConnectionResetError):
await response.write_eof()
return response
finally:
finished.set()
app = web.Application()
app.router.add_post('/v1/chat/completions', relay)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start()
local = f'http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}/v1'
adapter = LiteLLMRequester(
SimpleNamespace(),
{'custom_llm_provider': 'openai', 'base_url': local, 'requester_name': 'new-api-chat-completions'},
)
await adapter.initialize()
if options.baseline_no_drain:
adapter._http_pool.drain_sse = False
model = SimpleNamespace(
model_entity=SimpleNamespace(name=options.model, extra_args={}, reasoning_config=None, abilities=[]),
reasoning_config_override=None,
provider=SimpleNamespace(
token_mgr=SimpleNamespace(get_token=lambda: 'local-benchmark'),
provider_entity=SimpleNamespace(requester='new-api-chat-completions'),
),
)
messages = [Message(role=x['role'], content=x['content']) for x in canonical['messages']]
process = None
with tempfile.TemporaryDirectory(prefix='langbot-llm-benchmark-') as directory:
root = Path(directory)
config = {
'model': f'bench/{options.model}',
'small_model': f'bench/{options.model}',
'autoupdate': False,
'provider': {
'bench': {
'npm': '@ai-sdk/openai-compatible',
'name': 'Benchmark',
'options': {'baseURL': local, 'apiKey': 'local-benchmark'},
'models': {options.model: {'name': 'Benchmark', 'limit': {'context': 200000, 'output': 256}}},
}
},
'agent': {'bench': {'mode': 'primary', 'prompt': prefix, 'tools': {'*': False}, 'steps': 2}},
'permission': {'*': 'deny'},
}
config_path = root / 'opencode.json'
config_path.write_text(json.dumps(config))
env = os.environ.copy()
server_password = secrets.token_urlsafe(24)
env.update(
{
'OPENCODE_CONFIG': str(config_path),
'XDG_CONFIG_HOME': str(root / 'config'),
'XDG_DATA_HOME': str(root / 'data'),
'XDG_CACHE_HOME': str(root / 'cache'),
'NO_PROXY': os.environ.get('NO_PROXY', ''),
'OPENCODE_SERVER_PASSWORD': server_password,
}
)
port = free_port()
server = f'http://127.0.0.1:{port}'
log = (root / 'server.log').open('w')
try:
start_server = time.perf_counter()
process = await asyncio.create_subprocess_exec(
options.opencode,
'serve',
'--hostname',
'127.0.0.1',
'--port',
str(port),
cwd=root,
env=env,
stdout=log,
stderr=log,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0,
)
async with httpx.AsyncClient(trust_env=False, timeout=100, auth=('opencode', server_password)) as client:
for _ in range(120):
try:
response = await client.get(server + '/api/info')
if response.is_success:
break
except httpx.ConnectError:
pass
await asyncio.sleep(0.25)
else:
raise RuntimeError('Isolated OpenCode server did not start')
startup_ms = (time.perf_counter() - start_server) * 1000
orders = list(itertools.permutations(('direct', 'langbot', 'opencode')))
for round_index in range(-1, options.rounds):
for name in orders[max(0, round_index) % len(orders)]:
session_id = None
if name == 'opencode':
response = await client.post(
server + '/api/session',
json={
'title': 'LLM benchmark',
'agent': 'bench',
'model': {'id': options.model, 'providerID': 'bench'},
'location': {'directory': str(root)},
},
)
response.raise_for_status()
session_id = response.json()['data']['id']
row = {
'client': name,
'round': round_index,
'warmup': round_index < 0,
'start': time.perf_counter(),
'relay_finished': asyncio.Event(),
}
active = row
if name == 'direct':
meter = SSEMeter()
async with client.stream('POST', local + '/chat/completions', json=canonical) as response:
response.raise_for_status()
async for chunk in response.aiter_bytes():
meter.feed(chunk, time.perf_counter())
row['consumer_first_content_ms'] = (meter.first_content - row['start']) * 1000
elif name == 'langbot':
state = CallState(RequestPolicy(retries=0, total_timeout=90))
token = current_call.set(state)
try:
async with contextlib.aclosing(
adapter.invoke_llm_stream(
None, model, messages, extra_args={'temperature': 0, 'max_tokens': 256}
)
) as stream:
async for chunk in stream:
if chunk.content and 'consumer_first_content_ms' not in row:
row['consumer_first_content_ms'] = (
time.perf_counter() - row['start']
) * 1000
row['preparation_ms'] = state.metrics.get('preparation_ms')
row['transport_setup_ms'] = state.metrics.get('transport_setup_ms')
row['client_usage'] = state.usage
finally:
current_call.reset(token)
else:
response = await client.post(
server + f'/api/session/{session_id}/prompt',
json={'text': canonical['messages'][-1]['content']},
)
response.raise_for_status()
await asyncio.wait_for(row['relay_finished'].wait(), 100)
if name != 'opencode':
row['consumer_total_ms'] = (time.perf_counter() - row['start']) * 1000
await asyncio.wait_for(row['relay_finished'].wait(), 100)
if 'upstream_complete_ms' not in row:
raise RuntimeError(f'Incomplete benchmark response (HTTP {row.get("status", "unknown")})')
# Use one completion boundary for all clients. This
# relay cannot measure OpenCode's UI rendering latency.
row['total_ms'] = row['dispatch_to_wire_ms'] + row['upstream_complete_ms']
row.pop('relay_finished')
row.pop('start')
row.pop('wire_started', None)
records.append(row)
active = None
print(json.dumps(row), flush=True)
await asyncio.sleep(0.1)
report = {
'model': options.model,
'base_url': base_url,
'mock': options.mock,
'long_prefix': options.long_prefix,
'cache_control': options.cache_control,
'baseline_no_drain': options.baseline_no_drain,
'canonical_sha256': digest(canonical),
'canonical_bytes': len(payload),
'python_import_ms': import_ms,
'opencode_startup_ms': startup_ms,
'litellm': importlib.metadata.version('litellm'),
'opencode': options.opencode_version,
'records': records,
'summary': summary(records),
}
Path(options.output).write_text(json.dumps(report, indent=2), encoding='utf-8')
print('SUMMARY', json.dumps(report['summary']), flush=True)
finally:
if process and process.returncode is None:
process.terminate()
await process.wait()
log.close()
await adapter.aclose()
await upstream.aclose()
await runner.cleanup()
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--opencode', required=True)
parser.add_argument('--opencode-version', default='unknown')
parser.add_argument('--model', default='claude-haiku-4.5')
parser.add_argument('--base-url')
parser.add_argument('--credentials-file')
parser.add_argument('--api-key-env', default='LLM_BENCHMARK_API_KEY')
parser.add_argument('--rounds', type=int, default=6)
parser.add_argument('--mock', action='store_true')
parser.add_argument('--long-prefix', action='store_true')
parser.add_argument('--cache-control', action='store_true')
parser.add_argument('--baseline-no-drain', action='store_true', help='Reproduce the pre-fix stream transport')
parser.add_argument('--output', required=True)
args = parser.parse_args()
if not 1 <= args.rounds <= 12:
parser.error('--rounds must be between 1 and 12 (plus one warmup round)')
os.environ['NO_PROXY'] = ','.join(filter(None, [os.environ.get('NO_PROXY'), '127.0.0.1', 'localhost']))
asyncio.run(run(args))
+23
View File
@@ -242,3 +242,26 @@ Use `reset_session_context(bot_id, session_id)` only when a user asks to start a
## Cloud plan platform restrictions
Bot creation and enabling use the same Workspace entitlement policy as HTTP. A disabled platform is rejected even through MCP or plugin calls; do not retry using an Omni or legacy alias to bypass the restriction. The Workspace-scoped HTTP adapter catalog omits disabled platforms.
## LLM request diagnostics
`get_monitoring_records(kind="llm_calls")` includes nullable `details` on each call.
Read `details.timing`, `details.usage`, and `details.error` to distinguish provider
latency, retry waits, cached/reasoning tokens, and failures. Timing values are
milliseconds since invocation start; `first_event_ms` includes reasoning/control
frames, while `first_content_ms` measures the first upstream content delta.
`first_visible_chunk_ms` may contain displayed reasoning, not just answer text.
Old records have no details. Do not equate total duration with provider TTFT.
See `docs/LLM_REQUEST_POLICY.md` for policy precedence and gateway header behavior.
`get_llm_model` also exposes optional `reasoning_capabilities.budget_tokens`
min/max bounds. Numeric thinking budgets use `reasoning_config.budget_tokens`
with level `enabled`; do not combine budgets with effort levels or native thinking
arguments. Existing Runner reasoning overrides take precedence. Prompt caching
is opt-in through model/call `extra_args.prompt_caching`; use provider-specific
modes documented in `docs/LLM_REQUEST_POLICY.md`. Cache keys are routing hints,
not evidence of hits; read the actual usage counters. `preparation_ms` measures
request construction and `preparation_cache_hits` counts retry preparation reuse.
`transport_setup_ms` measures managed pool acquisition, including first-use TLS
initialization; it is not network TTFT. See `docs/LLM_LATENCY_BENCHMARK.md` for
controlled request-layer measurements and their scope.
@@ -608,6 +608,7 @@ class MonitoringService(ExecutionDetailsMixin):
cost: float | None = None,
error_message: str | None = None,
message_id: str | None = None,
details: dict | None = None,
) -> str:
"""Record an LLM call"""
workspace_uuid = self._require_write_context(context)
@@ -619,7 +620,7 @@ class MonitoringService(ExecutionDetailsMixin):
'model_name': model_name,
'input_tokens': input_tokens,
'output_tokens': output_tokens,
'total_tokens': input_tokens + output_tokens,
'total_tokens': ((details or {}).get('usage') or {}).get('total_tokens') or (input_tokens + output_tokens),
'duration': duration,
'cost': cost,
'status': status,
@@ -630,6 +631,7 @@ class MonitoringService(ExecutionDetailsMixin):
'session_id': session_id,
'error_message': error_message,
'message_id': message_id,
'details': details,
}
await self.ap.persistence_mgr.execute_async(
+8 -2
View File
@@ -182,7 +182,7 @@ class LangBotMCPServer:
return _dump(ap.box_service.get_recent_errors(context))
@mcp.tool(
description='Read bounded Workspace runtime records. Filters follow the monitoring service: bot_ids, pipeline_ids, session_ids, start_time, end_time, mode (all/real/debug), execution_statuses (owning execution status groups), knowledge_base_id, user_query, is_active as supported by the record kind.'
description='Read bounded Workspace runtime records. LLM calls include optional details (timing in ms, complete provider usage, structured error); older records have null details. Filters follow the monitoring service: bot_ids, pipeline_ids, session_ids, start_time, end_time, mode (all/real/debug), execution_statuses (owning execution status groups), knowledge_base_id, user_query, is_active as supported by the record kind.'
)
async def get_monitoring_records(
kind: typing.Literal['messages', 'llm_calls', 'tool_calls', 'embedding_calls', 'sessions', 'errors'],
@@ -530,7 +530,13 @@ class LangBotMCPServer:
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.llm_model_service.get_llm_models(context, include_secret=False))
@mcp.tool(description='Get a single LLM model by UUID.')
@mcp.tool(
description=(
'Get a single LLM model by UUID, including reasoning capabilities. '
'Optional budget_tokens min/max describes numeric thinking-budget support; '
'reasoning_config and extra_args retain the existing model API shape.'
)
)
async def get_llm_model(model_uuid: str) -> str:
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.llm_model_service.get_llm_model(context, model_uuid, include_secret=False))
@@ -70,6 +70,8 @@ class MonitoringLLMCall(Base):
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID
details = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
__table_args__ = (
sqlalchemy.Index('ix_monitoring_llm_calls_workspace_timestamp', 'workspace_uuid', 'timestamp'),
sqlalchemy.Index('ix_monitoring_llm_calls_workspace_session', 'workspace_uuid', 'session_id'),
@@ -0,0 +1,25 @@
"""Persist additive LLM timing, usage and structured failure diagnostics."""
from alembic import op
import sqlalchemy as sa
revision = '0038_llm_call_details'
down_revision = '0037_metadata_text'
branch_labels = None
depends_on = None
def upgrade():
inspector = sa.inspect(op.get_bind())
if inspector.has_table('monitoring_llm_calls'):
columns = {column['name'] for column in inspector.get_columns('monitoring_llm_calls')}
if 'details' not in columns:
op.add_column('monitoring_llm_calls', sa.Column('details', sa.JSON(), nullable=True))
def downgrade():
inspector = sa.inspect(op.get_bind())
if inspector.has_table('monitoring_llm_calls'):
columns = {column['name'] for column in inspector.get_columns('monitoring_llm_calls')}
if 'details' in columns:
op.drop_column('monitoring_llm_calls', 'details')
@@ -380,6 +380,7 @@ class MonitoringHelper:
cost: float | None = None,
error_message: str | None = None,
message_id: str | None = None,
details: dict | None = None,
):
"""Record LLM call"""
try:
@@ -400,6 +401,7 @@ class MonitoringHelper:
cost=cost,
error_message=error_message,
message_id=message_id,
details=details,
)
except Exception as e:
ap.logger.error(f'Failed to record LLM call: {e}')
+13 -3
View File
@@ -443,13 +443,19 @@ def _resolve_remove_think(data: dict[str, Any], query: Any | None) -> bool:
return False
def _merge_model_extra_args(model: Any, call_extra_args: Any) -> dict[str, Any]:
def _merge_model_extra_args(model: Any, call_extra_args: Any, *, llm_policy: bool = False) -> dict[str, Any]:
"""Merge persisted model extra_args with action-level overrides."""
merged: dict[str, Any] = {}
model_extra_args = getattr(getattr(model, 'model_entity', None), 'extra_args', None)
if isinstance(model_extra_args, dict):
merged.update(model_extra_args)
if llm_policy:
# RuntimeProvider resolves model and call policy layers independently.
# Flattening them here makes a model request_policy.retries override a
# caller's legacy num_retries=0 (and similarly hides timeout overrides).
for key in ('request_policy', 'num_retries', 'max_retries', 'timeout'):
merged.pop(key, None)
if isinstance(call_extra_args, dict):
merged.update(call_extra_args)
@@ -1484,7 +1490,9 @@ class RuntimeConnectionHandler(handler.Handler):
funcs_obj = [resource_tool.LLMTool.model_validate({**func, 'func': _placeholder_func}) for func in funcs]
query = _resolve_action_query(data, session, self.ap, action_context)
effective_extra_args = _merge_model_extra_args(llm_model, extra_args)
if query is None:
query = _build_plugin_action_query(action_context)
effective_extra_args = _merge_model_extra_args(llm_model, extra_args, llm_policy=True)
remove_think = _resolve_remove_think(data, query)
effective_funcs = funcs_obj if 'func_call' in (llm_model.model_entity.abilities or []) else []
@@ -1580,7 +1588,9 @@ class RuntimeConnectionHandler(handler.Handler):
funcs_obj = [resource_tool.LLMTool.model_validate({**func, 'func': _placeholder_func}) for func in funcs]
query = _resolve_action_query(data, session, self.ap, action_context)
effective_extra_args = _merge_model_extra_args(llm_model, extra_args)
if query is None:
query = _build_plugin_action_query(action_context)
effective_extra_args = _merge_model_extra_args(llm_model, extra_args, llm_policy=True)
remove_think = _resolve_remove_think(data, query)
effective_funcs = funcs_obj if 'func_call' in (llm_model.model_entity.abilities or []) else []
@@ -1,12 +1,20 @@
"""Explicitly safe Codex failures; never construct messages from upstream bodies."""
from ...i18n import t
from .errors import RequesterError
class CodexProviderError(ValueError):
"""A known provider failure safe to expose at the HTTP boundary."""
def __init__(self, message: str, status_code: int = 502, error_code: str = 'codex_upstream_failure'):
super().__init__(t(message))
ValueError.__init__(self, t(message))
self.code = error_code
self.retryable = status_code in (500, 502, 503, 504)
self.retry_after = None
self.request_id = None
self.phase = None
self.status_code = status_code
self.error_code = error_code
as_dict = RequesterError.as_dict
+24 -1
View File
@@ -4,5 +4,28 @@ from ...i18n import t
class RequesterError(Exception):
"""Base class for all Requester errors."""
def __init__(self, message: str):
def __init__(
self,
message: str,
*,
code: str = 'provider_error',
status_code: int | None = None,
retryable: bool = False,
retry_after: float | None = None,
request_id: str | None = None,
phase: str | None = None,
):
super().__init__(t('Model request failed') + ': ' + message)
self.code = code
self.status_code = status_code
self.retryable = retryable
self.retry_after = retry_after
self.request_id = request_id
self.phase = phase
def as_dict(self) -> dict:
"""Machine-readable diagnostics without upstream bodies or credentials."""
return {
key: getattr(self, key)
for key in ('code', 'status_code', 'retryable', 'retry_after', 'request_id', 'phase')
}
@@ -0,0 +1,114 @@
"""Requester-local connections, retired only after their active leases finish."""
from contextlib import asynccontextmanager
import asyncio
import re
import httpx
from .errors import RequesterError
class _CompletedSSEStream(httpx.AsyncByteStream):
"""Finish bounded HTTP framing before SDKs stop at the SSE DONE record."""
_done = re.compile(rb'\ndata: ?\[DONE\]\r?\n\r?\n')
def __init__(self, stream):
self.stream = stream
async def __aiter__(self):
iterator = self.stream.__aiter__()
tail = b'\n'
async for chunk in iterator:
probe = tail + chunk
if self._done.search(probe):
# SDK parsers stop at [DONE] before httpcore observes EOF. This
# otherwise discards the connection on every successful stream.
# Do not drain on cancellation, truncation, or merely a finish
# reason: providers can still send usage before the DONE record.
try:
async with asyncio.timeout(0.05):
drained = 0
async for extra in iterator:
drained += len(extra)
if drained >= 65536:
break
except (TimeoutError, httpx.HTTPError):
pass
yield chunk
return
tail = probe[-32:]
yield chunk
async def aclose(self):
await self.stream.aclose()
async def _finish_sse_body(response):
if response.headers.get('content-type', '').split(';')[0].strip() == 'text/event-stream':
response.stream = _CompletedSSEStream(response.stream)
class RequesterHTTPPool:
def __init__(self, *, follow_redirects=False, drain_sse=False):
self.client = None
self.active = 0
self.retired = False
self.follow_redirects = follow_redirects
self.drain_sse = drain_sse
self._creation_lock = asyncio.Lock()
@asynccontextmanager
async def lease(self):
if self.retired:
raise RequesterError('Provider has been retired', code='provider_retired')
if self.client is None:
async with self._creation_lock:
if self.retired:
raise RequesterError('Provider has been retired', code='provider_retired')
if self.client is None:
# HTTPX otherwise reloads the same CA bundle for its direct
# transport and each environment proxy mount. Load it once,
# off the event loop, preserving HTTPX's TLS/env semantics.
# Cancellation here leaves no open HTTP client or sockets.
ssl_context = await asyncio.to_thread(httpx.create_ssl_context)
if self.retired:
raise RequesterError('Provider has been retired', code='provider_retired')
# Auth and deadlines remain request-local. No pool or TLS
# context is shared across runtime providers.
self.client = httpx.AsyncClient(
verify=ssl_context,
follow_redirects=self.follow_redirects,
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20, keepalive_expiry=30),
event_hooks={'response': [_finish_sse_body]} if self.drain_sse else None,
)
self.active += 1
try:
yield self.client
finally:
self.active -= 1
if self.retired and not self.active:
await self._close()
async def _close(self):
client, self.client = self.client, None
if client is not None:
await client.aclose()
async def aclose(self):
self.retired = True
if not self.active:
await self._close()
@asynccontextmanager
async def requester_http_client(owner):
pool = getattr(owner, '_http_pool', None)
if pool is not None:
async with pool.lease() as client:
yield client
else:
# Preserve direct/uninitialized requester use by older extensions/tests.
async with httpx.AsyncClient(follow_redirects=False) as client:
yield client
@@ -743,6 +743,7 @@ class ModelManager:
config = {
'base_url': provider_entity.base_url,
'requester_name': provider_entity.requester,
'request_policy': self.ap.instance_config.data.get('llm_request_policy', {}),
}
if provider_entity.requester == 'openai-codex':
@@ -0,0 +1,82 @@
"""Opt-in upstream prompt caching; never cache generated answers locally."""
import copy
import hashlib
from .errors import RequesterError
def _has_breakpoint(value):
if isinstance(value, dict):
return 'cache_control' in value or any(_has_breakpoint(item) for item in value.values())
if isinstance(value, list):
return any(_has_breakpoint(item) for item in value)
return False
def apply_prompt_caching(args, state, *, provider, family):
config = args.pop('prompt_caching', None)
if config is None:
return
if not isinstance(config, dict) or set(config) - {'mode', 'retention', 'ttl'}:
raise RequesterError('Invalid prompt_caching fields', code='invalid_policy')
mode = config.get('mode', 'provider_default')
if mode not in ('provider_default', 'openai', 'anthropic'):
raise RequesterError('Unsupported prompt_caching mode', code='invalid_policy')
if mode == 'provider_default':
if set(config) - {'mode'}:
raise RequesterError('Cache settings require an explicit mode', code='invalid_policy')
return
if mode == 'openai':
if provider not in ('openai', 'openrouter') or family not in ('openai', 'openrouter'):
raise RequesterError('OpenAI caching requires an OpenAI-compatible model', code='invalid_policy')
if 'ttl' in config or config.get('retention') not in (None, 'in_memory', '24h'):
raise RequesterError('Invalid OpenAI cache retention', code='invalid_policy')
body = copy.deepcopy(args.get('extra_body') or {})
if not isinstance(body, dict):
raise RequesterError('extra_body must be an object', code='invalid_policy')
# Bypass LiteLLM's model-specific parameter filter for explicit opt-in
# caching fields as well as generated hints. The SDK's extra_body wins
# when the same native field appears in both locations.
for key in ('prompt_cache_key', 'prompt_cache_retention', 'prompt_cache_options'):
if key in args:
body.setdefault(key, args.pop(key))
# Explicit provider-native settings win. A key is a routing hint only;
# it neither enables caching nor guarantees a hit.
if (
state
and state.conversation_id
and not any(key in body for key in ('prompt_cache_key', 'prompt_cache_options'))
):
scope = f'{state.conversation_id}:{args["model"]}'
body['prompt_cache_key'] = 'lb-' + hashlib.sha256(scope.encode()).hexdigest()
if config.get('retention') and not any(
key in location for key in ('prompt_cache_retention', 'prompt_cache_options') for location in (args, body)
):
body['prompt_cache_retention'] = config['retention']
if body:
args['extra_body'] = body
return
if family != 'anthropic':
raise RequesterError('Anthropic caching requires an Anthropic model', code='invalid_policy')
if 'retention' in config or config.get('ttl', '5m') not in ('5m', '1h'):
raise RequesterError('Invalid Anthropic cache TTL', code='invalid_policy')
# Existing manual or automatic breakpoints are authoritative. Do not exceed
# a provider's breakpoint limit or change its TTL ordering.
if _has_breakpoint(args):
return
messages = copy.deepcopy(args['messages'])
boundary = None
for message in messages:
if message.get('role') not in ('system', 'developer'):
break
content = message.get('content')
if isinstance(content, str) and content:
message['content'] = [{'type': 'text', 'text': content}]
if isinstance(message.get('content'), list):
for part in message['content']:
if isinstance(part, dict) and part.get('type') == 'text' and part.get('text'):
boundary = part
if boundary is not None:
boundary['cache_control'] = {'type': 'ephemeral', 'ttl': config.get('ttl', '5m')}
args['messages'] = messages
+32 -5
View File
@@ -48,20 +48,27 @@ _CONFLICTING_EXTRA_BODY_ARGS = {
}
def normalize_reasoning_config(value: typing.Any) -> dict[str, str]:
def normalize_reasoning_config(value: typing.Any) -> dict[str, typing.Any]:
"""Return the canonical model reasoning configuration."""
if value is None:
return dict(DEFAULT_REASONING_CONFIG)
if not isinstance(value, dict):
raise ValueError('reasoning_config must be an object')
unknown_fields = set(value) - {'level'}
unknown_fields = set(value) - {'level', 'budget_tokens'}
if unknown_fields:
raise ValueError(f'Unsupported reasoning_config fields: {", ".join(sorted(unknown_fields))}')
level = value.get('level', 'provider_default')
budget = value.get('budget_tokens')
level = value.get('level', 'enabled' if budget is not None else 'provider_default')
if level not in REASONING_LEVELS:
raise ValueError(f'Unsupported reasoning level: {level}')
if budget is not None:
if type(budget) is not int or budget <= 0:
raise ValueError('budget_tokens must be a positive integer')
if level != 'enabled':
raise ValueError('budget_tokens requires level enabled; do not combine a token budget with an effort level')
return {'level': level, 'budget_tokens': budget}
return {'level': typing.cast(str, level)}
@@ -69,7 +76,7 @@ def validate_reasoning_config(
value: typing.Any,
abilities: typing.Iterable[str] | None,
extra_args: typing.Any,
) -> dict[str, str]:
) -> dict[str, typing.Any]:
"""Validate a model-facing reasoning config and conflicting raw arguments."""
config = normalize_reasoning_config(value)
if config['level'] == 'provider_default':
@@ -101,7 +108,13 @@ def validate_reasoning_capabilities(
model_name: str,
) -> None:
"""Ensure an explicit reasoning level can be honored by the requester."""
level = normalize_reasoning_config(config)['level']
config = normalize_reasoning_config(config)
level = config['level']
if 'budget_tokens' in config:
limits = capabilities.get('budget_tokens')
if not isinstance(limits, dict) or not limits['min'] <= config['budget_tokens'] <= limits['max']:
raise ValueError(f'Unsupported reasoning token budget for model {model_name}: {config["budget_tokens"]}')
return
if level == 'provider_default':
return
@@ -154,3 +167,17 @@ def model_with_reasoning_level(
scoped_model = copy.copy(model)
scoped_model.reasoning_config_override = copy.deepcopy(config)
return scoped_model
def resolve_reasoning_config(model, extra_args=None):
"""Runner policy wins; a call object replaces, rather than merges, a model policy."""
value = getattr(model.model_entity, 'reasoning_config', None)
if value is not None and not isinstance(value, (dict, str, int, float, bool, list, tuple)):
value = None
for args in (model.model_entity.extra_args, extra_args):
if isinstance(args, dict) and 'reasoning_config' in args:
value = args['reasoning_config']
override = getattr(model, 'reasoning_config_override', None)
if override is not None:
value = override
return normalize_reasoning_config(value)
@@ -0,0 +1,173 @@
"""Host-owned LLM deadlines, retries and additive diagnostics.
No SDK wire shape changes: request_policy is a reserved model/call extra argument.
Context-local state lets adapters report raw events without sharing mutable state.
"""
from __future__ import annotations
import asyncio
from contextvars import ContextVar
from dataclasses import dataclass, fields
from email.utils import parsedate_to_datetime
import hashlib
import json
import math
import random
import time
from .errors import RequesterError
from .codex_errors import CodexProviderError
STRUCTURED_ERRORS = (RequesterError, CodexProviderError)
@dataclass
class RequestPolicy:
retries: int = 0
total_timeout: float = 300
first_event_timeout: float = 120
stream_idle_timeout: float = 120
connect_timeout: float = 10
read_timeout: float = 120
write_timeout: float = 30
pool_timeout: float = 10
backoff_base: float = 0.5
backoff_max: float = 8
attribution: bool = True
conversation_header: str | None = None
@classmethod
def resolve(cls, *layers):
values = {}
names = {field.name for field in fields(cls)}
for layer in layers:
if not isinstance(layer, dict):
continue
# Preserve the legacy per-model/call retry knob, including zero.
if 'max_retries' in layer:
values['retries'] = layer['max_retries']
if 'num_retries' in layer:
values['retries'] = layer['num_retries']
if isinstance(layer.get('timeout'), (int, float)):
values['read_timeout'] = layer['timeout']
values['first_event_timeout'] = layer['timeout']
values['stream_idle_timeout'] = layer['timeout']
policy = layer.get('request_policy', {})
if not isinstance(policy, dict) or set(policy) - names:
raise RequesterError('Invalid request_policy fields', code='invalid_policy')
values.update(policy)
policy = cls(**values)
for field in fields(cls):
value = getattr(policy, field.name)
if field.name == 'conversation_header':
if value not in (None, 'x-session-id', 'x-litellm-session-id', 'x-langbot-conversation-id'):
raise RequesterError('Unsupported conversation header', code='invalid_policy')
elif field.name == 'attribution':
if not isinstance(value, bool):
raise RequesterError('attribution must be boolean', code='invalid_policy')
elif field.name == 'retries':
if type(value) is not int or not 0 <= value <= 5:
raise RequesterError('retries must be an integer from 0 to 5', code='invalid_policy')
elif (
isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0
):
raise RequesterError(f'{field.name} must be finite and positive', code='invalid_policy')
return policy
class CallState:
def __init__(self, policy, conversation_id=None):
self.policy = policy
self.conversation_id = conversation_id
self.start = time.perf_counter()
self.attempt_start = self.start
self.metrics = {'attempts': 0, 'retry_wait_ms': 0}
self.raw_started = False
self.error = None
self.usage = None
self.prepared = {}
def mark(self, name):
self.metrics.setdefault(name, round((time.perf_counter() - self.start) * 1000, 3))
async def wait(self, operation, phase, limit):
remaining = self.policy.total_timeout - (time.perf_counter() - self.start)
if phase == 'first_event':
limit = min(limit, self.policy.first_event_timeout - (time.perf_counter() - self.attempt_start))
token = current_call.set(self)
try:
# timeout(0) only cancels at a scheduling point. Buffered SDK
# responses can complete synchronously, so enforce expired budgets
# before starting another operation as well.
if min(limit, remaining) <= 0:
raise TimeoutError
async with asyncio.timeout(max(0, min(limit, remaining))):
return await operation()
except TimeoutError as exc:
raise RequesterError(
'LLM request timed out', code='timeout', retryable=True, phase='total' if remaining <= limit else phase
) from exc
finally:
current_call.reset(token)
async def retry(self, error):
self.error = error.as_dict() if isinstance(error, STRUCTURED_ERRORS) else {'code': 'provider_error'}
self.metrics.setdefault('attempt_errors', []).append(dict(self.error))
if not isinstance(error, STRUCTURED_ERRORS) or not error.retryable or self.raw_started:
return False
if self.metrics['attempts'] > self.policy.retries:
return False
delay = random.uniform(
0, min(self.policy.backoff_max, self.policy.backoff_base * 2 ** (self.metrics['attempts'] - 1))
)
delay = max(delay, error.retry_after or 0)
remaining = self.policy.total_timeout - (time.perf_counter() - self.start)
if delay >= remaining:
return False
started = time.perf_counter()
await asyncio.sleep(delay)
self.metrics['retry_wait_ms'] += round((time.perf_counter() - started) * 1000, 3)
return True
current_call: ContextVar[CallState | None] = ContextVar('llm_call', default=None)
def opaque_conversation_id(query, context):
if query is None:
return None
session = getattr(query, 'session', None)
session_id = getattr(session, 'session_id', None)
if not isinstance(session_id, (str, int)):
launcher = getattr(query, 'launcher_id', None)
if not isinstance(launcher, (str, int)):
return None
launcher_type = getattr(query, 'launcher_type', '')
session_id = [str(getattr(launcher_type, 'value', launcher_type)), str(launcher)]
scope = [context.workspace_uuid, query.bot_uuid, query.pipeline_uuid, session_id]
return 'lb-' + hashlib.sha256(json.dumps(scope, ensure_ascii=True).encode()).hexdigest()
def error_metadata(exc):
status = getattr(exc, 'status_code', None)
status = status if isinstance(status, int) else None
response = getattr(exc, 'response', None)
headers = getattr(response, 'headers', {})
headers = headers if hasattr(headers, 'get') else {}
retry_after = None
raw = headers.get('retry-after')
if raw:
try:
retry_after = float(raw)
except (ValueError, TypeError):
try:
retry_after = parsedate_to_datetime(raw).timestamp() - time.time()
except (ValueError, TypeError, OverflowError):
pass
if retry_after is not None:
retry_after = max(0, retry_after) if math.isfinite(retry_after) else None
request_id = headers.get('x-request-id') or headers.get('request-id')
if not isinstance(request_id, str):
request_id = None
return {'status_code': status, 'retry_after': retry_after, 'request_id': request_id[:256] if request_id else None}
+135 -144
View File
@@ -14,6 +14,7 @@ from ...workspace.errors import WorkspaceInvariantError
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from . import token
from . import reasoning
from . import request_policy
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
@@ -101,6 +102,48 @@ class RuntimeProvider:
raise WorkspaceInvariantError('LLM invocation requires an ExecutionContext when query is absent')
return execution_context
def _call_state(self, query, model, extra_args, context):
config = getattr(self.requester, 'requester_cfg', {})
policy = request_policy.RequestPolicy.resolve(config, model.model_entity.extra_args, extra_args)
if query is not None:
if query.variables is None:
query.variables = {}
query.variables.pop(LLM_USAGE_QUERY_VARIABLE, None)
query.variables.pop(STREAM_USAGE_QUERY_VARIABLE, None)
return request_policy.CallState(policy, request_policy.opaque_conversation_id(query, context))
async def _record_call(self, query, model, state, status, error):
state.mark('total_ms')
if query is None:
return
stream_usage = query.variables.pop(STREAM_USAGE_QUERY_VARIABLE, None)
usage = state.usage if state.usage is not None else stream_usage
if usage is not None:
_store_llm_usage(query, usage)
details = {'timing': dict(state.metrics), 'usage': usage, 'error': error}
query.variables['_llm_diagnostics'] = details
try:
from ...pipeline import monitoring_helper
await monitoring_helper.MonitoringHelper.record_llm_call(
ap=self.requester.ap,
query=query,
bot_id=query.bot_uuid or 'unknown',
bot_name=query.variables.get('_monitoring_bot_name', 'Unknown'),
pipeline_id=query.pipeline_uuid or 'unknown',
pipeline_name=query.variables.get('_monitoring_pipeline_name', 'Unknown'),
model_name=model.model_entity.name,
input_tokens=(usage or {}).get('prompt_tokens', 0) or 0,
output_tokens=(usage or {}).get('completion_tokens', 0) or 0,
duration_ms=int(state.metrics['total_ms']),
status=status,
error_message=error.get('message') if error else None,
message_id=query.variables.get('_monitoring_message_id'),
details=details,
)
except Exception as exc:
self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM call: {exc}')
async def invoke_llm(
self,
query: pipeline_query.Query | None,
@@ -111,79 +154,46 @@ class RuntimeProvider:
remove_think: bool = False,
execution_context: ExecutionContext | None = None,
) -> provider_message.Message:
"""Bridge method for invoking LLM with monitoring"""
invocation_context = self._resolve_llm_execution_context(query, execution_context)
self._validate_invocation(model, invocation_context)
# Start timing for monitoring
start_time = time.time()
input_tokens = 0
output_tokens = 0
status = 'success'
error_message = None
"""Preserve the legacy Message return while owning retries and deadlines."""
context = self._resolve_llm_execution_context(query, execution_context)
self._validate_invocation(model, context)
state = self._call_state(query, model, extra_args, context)
status, error = 'success', None
try:
# Call the underlying requester
result = await self.requester.invoke_llm(
query=query,
model=model,
messages=messages,
funcs=funcs,
extra_args=extra_args,
remove_think=remove_think,
)
# Try to extract token usage if the requester returns it
# For requesters that return tuple (message, usage_info)
if isinstance(result, tuple):
msg, usage_info = result
if usage_info:
_store_llm_usage(query, usage_info)
input_tokens = usage_info.get('prompt_tokens', 0)
output_tokens = usage_info.get('completion_tokens', 0)
return msg
else:
return result
except Exception as e:
while True:
state.metrics['attempts'] += 1
state.attempt_start = time.perf_counter()
try:
result = await state.wait(
lambda: self.requester.invoke_llm(
query=query,
model=model,
messages=messages,
funcs=funcs,
extra_args=extra_args,
remove_think=remove_think,
),
'response',
state.policy.total_timeout,
)
state.mark('response_completed_ms')
if isinstance(result, tuple):
message, state.usage = result
return message
return result
except Exception as exc:
if not await state.retry(exc):
raise
except (asyncio.CancelledError, GeneratorExit):
status, error = 'error', {'code': 'cancelled', 'message': 'LLM invocation cancelled'}
raise
except Exception as exc:
status = 'error'
error_message = str(e)
error = exc.as_dict() if isinstance(exc, request_policy.STRUCTURED_ERRORS) else {'code': 'provider_error'}
error['message'] = str(exc)
raise
finally:
# Record LLM call monitoring data (only if query is provided)
if query is not None:
duration_ms = int((time.time() - start_time) * 1000)
# Import monitoring helper
try:
from ...pipeline import monitoring_helper
# Get monitoring metadata from query variables
if query.variables:
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
message_id = query.variables.get('_monitoring_message_id')
else:
bot_name = 'Unknown'
pipeline_name = 'Unknown'
message_id = None
await monitoring_helper.MonitoringHelper.record_llm_call(
ap=self.requester.ap,
query=query,
bot_id=query.bot_uuid or 'unknown',
bot_name=bot_name,
pipeline_id=query.pipeline_uuid or 'unknown',
pipeline_name=pipeline_name,
model_name=model.model_entity.name,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration_ms=duration_ms,
status=status,
error_message=error_message,
message_id=message_id,
)
except Exception as monitor_err:
self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM call: {monitor_err}')
await self._record_call(query, model, state, status, error)
async def invoke_llm_stream(
self,
@@ -194,85 +204,66 @@ class RuntimeProvider:
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
execution_context: ExecutionContext | None = None,
) -> provider_message.MessageChunk:
"""Bridge method for invoking LLM stream with monitoring"""
invocation_context = self._resolve_llm_execution_context(query, execution_context)
self._validate_invocation(model, invocation_context)
# Start timing for monitoring
start_time = time.time()
status = 'success'
error_message = None
input_tokens = 0
output_tokens = 0
) -> typing.AsyncIterator[provider_message.MessageChunk]:
"""Never retry after a raw upstream event or a legacy adapter chunk."""
context = self._resolve_llm_execution_context(query, execution_context)
self._validate_invocation(model, context)
state = self._call_state(query, model, extra_args, context)
status, error = 'success', None
try:
# Stream the response
async with aclosing(
self.requester.invoke_llm_stream(
query=query,
model=model,
messages=messages,
funcs=funcs,
extra_args=extra_args,
remove_think=remove_think,
)
) as owned_stream:
async for chunk in owned_stream:
yield chunk
# Extract usage from stream if available (stored by LiteLLM requester)
if query:
if query.variables is None:
query.variables = {}
if STREAM_USAGE_QUERY_VARIABLE in query.variables:
usage_info = query.variables[STREAM_USAGE_QUERY_VARIABLE]
_store_llm_usage(query, usage_info)
input_tokens = usage_info.get('prompt_tokens', 0)
output_tokens = usage_info.get('completion_tokens', 0)
del query.variables[STREAM_USAGE_QUERY_VARIABLE]
while True:
state.metrics['attempts'] += 1
state.attempt_start = time.perf_counter()
try:
async with aclosing(
self.requester.invoke_llm_stream(
query=query,
model=model,
messages=messages,
funcs=funcs,
extra_args=extra_args,
remove_think=remove_think,
)
) as stream:
while True:
try:
chunk = await state.wait(
lambda: anext(stream),
'total'
if getattr(self.requester, 'reports_raw_timing', False) is True
else ('stream_idle' if state.raw_started else 'first_event'),
state.policy.total_timeout
if getattr(self.requester, 'reports_raw_timing', False) is True
else (
state.policy.stream_idle_timeout
if state.raw_started
else state.policy.first_event_timeout
),
)
except StopAsyncIteration:
break
if chunk is None:
continue
state.raw_started = True
state.mark('first_event_ms')
if chunk.content:
state.mark('first_visible_chunk_ms')
yield chunk
state.mark('response_completed_ms')
return
except Exception as exc:
if not await state.retry(exc):
raise
except (asyncio.CancelledError, GeneratorExit):
status = 'error'
error_message = 'Streaming generation cancelled by consumer'
status, error = 'error', {'code': 'cancelled', 'message': 'LLM stream cancelled'}
raise
except Exception as e:
except Exception as exc:
status = 'error'
error_message = str(e)
error = exc.as_dict() if isinstance(exc, request_policy.STRUCTURED_ERRORS) else {'code': 'provider_error'}
error['message'] = str(exc)
raise
finally:
# Record LLM call monitoring data (only if query is provided)
if query is not None:
duration_ms = int((time.time() - start_time) * 1000)
# Import monitoring helper
try:
from ...pipeline import monitoring_helper
# Get monitoring metadata from query variables
if query.variables:
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
message_id = query.variables.get('_monitoring_message_id')
else:
bot_name = 'Unknown'
pipeline_name = 'Unknown'
message_id = None
await monitoring_helper.MonitoringHelper.record_llm_call(
ap=self.requester.ap,
query=query,
bot_id=query.bot_uuid or 'unknown',
bot_name=bot_name,
pipeline_id=query.pipeline_uuid or 'unknown',
pipeline_name=pipeline_name,
model_name=model.model_entity.name,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration_ms=duration_ms,
status=status,
error_message=error_message,
message_id=message_id,
)
except Exception as monitor_err:
self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM stream call: {monitor_err}')
await self._record_call(query, model, state, status, error)
async def invoke_embedding(
self,
@@ -388,7 +379,7 @@ class RuntimeLLMModel:
provider: RuntimeProvider
"""提供商实例"""
reasoning_config_override: dict[str, str] | None
reasoning_config_override: dict[str, typing.Any] | None
"""Request-scoped reasoning policy supplied by the active pipeline."""
def __init__(
@@ -396,7 +387,7 @@ class RuntimeLLMModel:
execution_context: ExecutionContext,
model_entity: persistence_model.LLMModel,
provider: RuntimeProvider,
reasoning_config_override: dict[str, str] | None = None,
reasoning_config_override: dict[str, typing.Any] | None = None,
):
_ensure_same_execution_scope(provider.execution_context, execution_context, resource='LLM model')
if model_entity.workspace_uuid != execution_context.workspace_uuid:
@@ -7,12 +7,16 @@ import json
import secrets
import time
from collections import OrderedDict
from contextlib import AsyncExitStack, aclosing
import httpx
import langbot
import langbot_plugin.api.entities.builtin.provider.message as pm
from .. import requester, reasoning
from ..request_policy import current_call
from ..http_pool import RequesterHTTPPool, requester_http_client
from ..errors import RequesterError
from ..codex_auth import BASE_URL, CodexAuth, LOGIN_REQUIRED
from ..codex_errors import CodexProviderError
@@ -21,7 +25,8 @@ async def sse_events(response):
"""Decode SSE records, including CRLF, comments, and multiline data."""
data = []
size = 0
async for line in response.aiter_lines():
lines = response.aiter_lines()
async for line in lines:
if not line:
if data:
text = '\n'.join(data)
@@ -33,6 +38,18 @@ async def sse_events(response):
raise ValueError
except ValueError:
raise ValueError('Codex returned an invalid stream event') from None
if event.get('type') == 'response.completed':
# Finish the HTTP body so a healthy connection can return
# to the pool. Never wait indefinitely for a broken SSE
# server after it has already sent the terminal result.
try:
async with asyncio.timeout(0.05):
async for _ in lines:
pass
except (TimeoutError, httpx.HTTPError):
pass
yield event
return
yield event
data, size = [], 0
elif line.startswith('data:'):
@@ -85,6 +102,7 @@ def _tool(item):
def _usage(response):
usage = response.get('usage') or {}
return {
**usage,
'prompt_tokens': usage.get('input_tokens', 0),
'completion_tokens': usage.get('output_tokens', 0),
'total_tokens': usage.get('total_tokens', usage.get('input_tokens', 0) + usage.get('output_tokens', 0)),
@@ -94,7 +112,10 @@ def _usage(response):
class CodexRequester(requester.ProviderAPIRequester):
reports_raw_timing = True
async def initialize(self):
self._http_pool = RequesterHTTPPool()
self.auth = CodexAuth(self.ap)
self.workspace = self.requester_cfg['workspace_uuid']
self.provider = self.requester_cfg['provider_uuid']
@@ -104,6 +125,8 @@ class CodexRequester(requester.ProviderAPIRequester):
async def aclose(self):
self._replay.clear()
if getattr(self, '_http_pool', None) is not None:
await self._http_pool.aclose()
def get_reasoning_capabilities(self, model):
return {
@@ -184,6 +207,8 @@ class CodexRequester(requester.ProviderAPIRequester):
def _body(self, query, model, messages, funcs, extra_args, tokens):
args = {**(model.model_entity.extra_args or {}), **(extra_args or {})}
args.pop('request_policy', None)
args.pop('reasoning_config', None)
# Never permit credentials, transport overrides, store/history or arbitrary
# SDK kwargs to be smuggled through model advanced parameters.
allowed = {'reasoning', 'text', 'parallel_tool_calls', 'tool_choice'}
@@ -235,13 +260,11 @@ class CodexRequester(requester.ProviderAPIRequester):
'stream': True,
'include': ['reasoning.encrypted_content'],
}
reasoning_config = getattr(model, 'reasoning_config_override', None)
if reasoning_config is None:
reasoning_config = getattr(model.model_entity, 'reasoning_config', None)
level = reasoning.normalize_reasoning_config(reasoning_config)['level']
reasoning_config = reasoning.resolve_reasoning_config(model, extra_args)
level = reasoning_config['level']
if level != 'provider_default':
reasoning.validate_reasoning_capabilities(
{'level': level}, self.get_reasoning_capabilities(model), model.model_entity.name
reasoning_config, self.get_reasoning_capabilities(model), model.model_entity.name
)
body['reasoning'] = {'effort': level, 'summary': 'auto'}
if funcs:
@@ -259,13 +282,39 @@ class CodexRequester(requester.ProviderAPIRequester):
async def _events(self, query, model, messages, funcs, extra_args):
tokens = await self.auth.access(self.workspace, self.provider)
state = current_call.get()
timeout = 120
if state:
p = state.policy
timeout = httpx.Timeout(
connect=p.connect_timeout, read=p.read_timeout, write=p.write_timeout, pool=p.pool_timeout
)
try:
async with asyncio.timeout(300), httpx.AsyncClient(timeout=120, follow_redirects=False) as client:
async with (
asyncio.timeout(None if state else 300),
requester_http_client(self) as client,
):
for attempt in range(2):
body = self._body(query, model, messages, funcs, extra_args, tokens)
async with client.stream(
'POST', BASE_URL + '/responses', json=body, headers=self._headers(tokens, stream=True)
) as response:
if state:
state.mark('request_prepared_ms')
state.mark('request_started_ms')
async with AsyncExitStack() as stack:
response_context = client.stream(
'POST',
BASE_URL + '/responses',
json=body,
headers=self._headers(tokens, stream=True),
timeout=timeout,
)
if state:
response = await state.wait(
lambda: stack.enter_async_context(response_context),
'first_event',
state.policy.first_event_timeout,
)
else:
response = await stack.enter_async_context(response_context)
if response.status_code == 401 and attempt == 0:
tokens = await self.auth.access(
self.workspace, self.provider, rejected_token=tokens['access_token']
@@ -273,85 +322,117 @@ class CodexRequester(requester.ProviderAPIRequester):
continue
if response.status_code != 200:
raise await self._response_error(response)
async for event in sse_events(response):
if state:
state.mark('response_open_ms')
events = sse_events(response)
while True:
try:
if state:
event = await state.wait(
lambda: anext(events),
'stream_idle' if state.raw_started else 'first_event',
state.policy.stream_idle_timeout
if state.raw_started
else state.policy.first_event_timeout,
)
else:
event = await anext(events)
except StopAsyncIteration:
break
if state:
state.raw_started = True
state.mark('first_event_ms')
kind = event.get('type', '')
if kind == 'response.output_text.delta' and event.get('delta'):
state.mark('first_content_ms')
elif 'reasoning' in kind and event.get('delta'):
state.mark('first_reasoning_ms')
yield event, tokens
return
except (httpx.HTTPError, TimeoutError):
raise ValueError('ChatGPT Codex network error or timeout. Please retry.') from None
except (httpx.HTTPError, TimeoutError) as exc:
raise RequesterError(
'ChatGPT Codex network error or timeout. Please retry.',
code='timeout' if isinstance(exc, (httpx.TimeoutException, TimeoutError)) else 'connection',
retryable=True,
) from None
async def _chunks(self, query, model, messages, funcs, extra_args, remove_think, usage_out):
text = ''
seen_calls = set()
output_items = {}
response_id = None
async for event, tokens in self._events(query, model, messages, funcs, extra_args):
kind = event.get('type')
response = event.get('response') or {}
response_id = response.get('id') or response_id
if kind in ('error', 'response.failed', 'response.incomplete'):
raise CodexProviderError('ChatGPT Codex response failed or was incomplete. Please retry.')
if kind == 'response.output_text.delta':
delta = event.get('delta', '')
text += delta
yield pm.MessageChunk(role='assistant', content=delta, resp_message_id=response_id)
elif kind in ('response.reasoning_summary_text.delta', 'response.reasoning_text.delta'):
if not remove_think:
yield pm.MessageChunk(
role='assistant',
content='',
provider_specific_fields={'reasoning_content': event.get('delta', '')},
)
elif kind == 'response.output_item.done':
item = event.get('item') or {}
output_items[event.get('output_index', len(output_items))] = item
if item.get('type') == 'function_call' and item.get('call_id') not in seen_calls:
seen_calls.add(item.get('call_id'))
yield pm.MessageChunk(role='assistant', content='', tool_calls=[_tool(item)])
elif kind in ('response.completed', 'response.done'):
if response.get('status') not in (None, 'completed'):
raise CodexProviderError('ChatGPT Codex response was not completed')
output = response.get('output') or [output_items[k] for k in sorted(output_items)]
for item in output:
async with aclosing(self._events(query, model, messages, funcs, extra_args)) as events:
async for event, tokens in events:
kind = event.get('type')
response = event.get('response') or {}
response_id = response.get('id') or response_id
if kind in ('error', 'response.failed', 'response.incomplete'):
raise CodexProviderError('ChatGPT Codex response failed or was incomplete. Please retry.')
if kind == 'response.output_text.delta':
delta = event.get('delta', '')
text += delta
yield pm.MessageChunk(role='assistant', content=delta, resp_message_id=response_id)
elif kind in ('response.reasoning_summary_text.delta', 'response.reasoning_text.delta'):
if not remove_think:
yield pm.MessageChunk(
role='assistant',
content='',
provider_specific_fields={'reasoning_content': event.get('delta', '')},
)
elif kind == 'response.output_item.done':
item = event.get('item') or {}
output_items[event.get('output_index', len(output_items))] = item
if item.get('type') == 'function_call' and item.get('call_id') not in seen_calls:
seen_calls.add(item.get('call_id'))
yield pm.MessageChunk(role='assistant', content='', tool_calls=[_tool(item)])
# Some servers send only the terminal output, without text deltas.
final_text = ''.join(
p.get('text', '')
for item in output
if item.get('type') == 'message'
for p in item.get('content', [])
if p.get('type') == 'output_text'
)
if not text and final_text:
text = final_text
yield pm.MessageChunk(role='assistant', content=text, resp_message_id=response_id)
usage_out.update(_usage(response))
if query is not None:
if query.variables is None:
query.variables = {}
query.variables[requester.STREAM_USAGE_QUERY_VARIABLE] = dict(usage_out)
fields = None
if query is not None and output:
handle = secrets.token_urlsafe(24)
self._replay[handle] = (self._scope(query, model, tokens), time.time() + 3600, output)
while len(self._replay) > 64:
self._replay.popitem(last=False)
fields = {'codex_replay_id': handle}
yield pm.MessageChunk(
role='assistant',
content='',
all_content=text,
is_final=True,
resp_message_id=response_id,
provider_specific_fields=fields,
)
return
elif kind in ('response.completed', 'response.done'):
if response.get('status') not in (None, 'completed'):
raise CodexProviderError('ChatGPT Codex response was not completed')
output = response.get('output') or [output_items[k] for k in sorted(output_items)]
for item in output:
if item.get('type') == 'function_call' and item.get('call_id') not in seen_calls:
seen_calls.add(item.get('call_id'))
yield pm.MessageChunk(role='assistant', content='', tool_calls=[_tool(item)])
# Some servers send only the terminal output, without text deltas.
final_text = ''.join(
p.get('text', '')
for item in output
if item.get('type') == 'message'
for p in item.get('content', [])
if p.get('type') == 'output_text'
)
if not text and final_text:
text = final_text
yield pm.MessageChunk(role='assistant', content=text, resp_message_id=response_id)
usage_out.update(_usage(response))
if current_call.get():
current_call.get().usage = dict(usage_out)
if query is not None:
if query.variables is None:
query.variables = {}
query.variables[requester.STREAM_USAGE_QUERY_VARIABLE] = dict(usage_out)
fields = None
if query is not None and output:
handle = secrets.token_urlsafe(24)
self._replay[handle] = (self._scope(query, model, tokens), time.time() + 3600, output)
while len(self._replay) > 64:
self._replay.popitem(last=False)
fields = {'codex_replay_id': handle}
yield pm.MessageChunk(
role='assistant',
content='',
all_content=text,
is_final=True,
resp_message_id=response_id,
provider_specific_fields=fields,
)
return
raise CodexProviderError('ChatGPT Codex stream ended before completion. Please retry.')
async def invoke_llm_stream(self, query, model, messages, funcs=None, extra_args=None, remove_think=False):
async for chunk in self._chunks(query, model, messages, funcs, extra_args, remove_think, {}):
yield chunk
async with aclosing(self._chunks(query, model, messages, funcs, extra_args, remove_think, {})) as chunks:
async for chunk in chunks:
yield chunk
async def invoke_llm(self, query, model, messages, funcs=None, extra_args=None, remove_think=False):
usage = {}
@@ -376,11 +457,12 @@ class CodexRequester(requester.ProviderAPIRequester):
async def scan_models(self, api_key=None):
tokens = await self.auth.access(self.workspace, self.provider)
try:
async with asyncio.timeout(90), httpx.AsyncClient(timeout=30, follow_redirects=False) as client:
async with asyncio.timeout(90), requester_http_client(self) as client:
for attempt in range(2):
response = await client.get(
BASE_URL + '/models',
params={'client_version': langbot.__version__},
timeout=30,
headers=self._headers(tokens),
)
if response.status_code == 401 and attempt == 0:
@@ -5,10 +5,23 @@ from __future__ import annotations
from ....i18n import t
import typing
import copy
import time
import os
from collections import OrderedDict
from contextlib import asynccontextmanager
from openai import AsyncOpenAI
from ..http_pool import RequesterHTTPPool
from ..prompt_caching import apply_prompt_caching
import asyncio
import httpx
from urllib.parse import urlsplit
from ..request_policy import current_call, RequestPolicy, error_metadata
import litellm
from litellm import acompletion, aembedding, arerank
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.secret_managers.main import get_secret, get_secret_bool
from .. import errors, reasoning, requester
from ....utils import httpclient
@@ -17,6 +30,37 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
class _LeasedHTTPHandler(AsyncHTTPHandler):
"""Adapt a leased transport without LiteLLM's hidden connection-error retry."""
def __init__(self, client, timeout):
self.client = client
self.timeout = timeout
self.event_hooks = None
self.client_alias = 'langbot'
async def post(self, url, *, timeout=None, stream=False, logging_obj=None, **kwargs):
if isinstance(kwargs.get('data'), (str, bytes)):
kwargs['content'] = kwargs.pop('data')
request = self.client.build_request('POST', url, timeout=timeout or self.timeout, **kwargs)
response = await self.client.send(request, stream=stream)
try:
response.raise_for_status()
except BaseException:
await response.aclose()
raise
return response
async def close(self):
pass # The requester's lease owns the transport.
async def __aexit__(self, *args):
pass
def __del__(self):
pass
class _ThinkStripState:
"""Drop explicit think blocks, buffering only possible split tag prefixes.
@@ -133,6 +177,8 @@ class _ThinkStripState:
class LiteLLMRequester(requester.ProviderAPIRequester):
"""LiteLLM unified API requester supporting chat, embedding, and rerank."""
reports_raw_timing = True
_EMBEDDING_MODEL_HINTS = ('embedding', 'embed', 'bge-', 'e5-', 'm3e', 'gte-', 'text-embedding')
_RERANK_MODEL_HINTS = ('rerank', 're-rank', 're_rank')
_QWEN_DEDICATED_THINKING_MODELS = frozenset(
@@ -181,9 +227,83 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
async def initialize(self):
"""Initialize LiteLLM client settings."""
# LiteLLM doesn't require explicit client initialization
# Configuration is passed per-request via litellm params
pass
self._http_pool = RequesterHTTPPool(
follow_redirects=True, drain_sse=self._get_custom_llm_provider() == 'openai'
)
async def aclose(self):
if getattr(self, '_http_pool', None) is not None:
await self._http_pool.aclose()
if hasattr(self, '_metadata_cache'):
self._metadata_cache.clear()
@asynccontextmanager
async def _client_args(self, args):
pool = getattr(self, '_http_pool', None)
provider = args.get('custom_llm_provider') or self._get_custom_llm_provider()
if pool is None or provider not in ('openai', 'openrouter') or args.get('client') is not None:
yield args
return
# Preserve explicitly customized LiteLLM transports and TLS settings.
if (
any(args.get(key) is not None for key in ('shared_session', 'http_client', 'ssl_verify', 'ssl_certificate'))
or getattr(litellm, 'aclient_session', None) is not None
or getattr(litellm, 'ssl_verify', True) is not True
or getattr(litellm, 'ssl_certificate', None)
or getattr(litellm, 'force_ipv4', False)
or any(name in os.environ for name in ('SSL_VERIFY', 'SSL_CERTIFICATE'))
):
yield args
return
if not args['model'].startswith(f'{provider}/'):
yield args # A caller redirected the model; let LiteLLM resolve its transport.
return
model_name = args['model'].removeprefix(f'{provider}/')
if provider == 'openrouter' and model_name in litellm.open_ai_chat_completion_models:
yield args # Preserve LiteLLM's alternate bare-model routing.
return
transport_started = time.perf_counter()
async with pool.lease() as client:
state = current_call.get()
if state:
state.metrics['transport_setup_ms'] = round(
state.metrics.get('transport_setup_ms', 0) + (time.perf_counter() - transport_started) * 1000, 3
)
if provider == 'openrouter' or get_secret_bool('EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER'):
yield {**args, 'client': _LeasedHTTPHandler(client, args.get('timeout', 120))}
return
# A fresh lightweight SDK wrapper keeps rotating keys, organization
# and deadlines request-local; only its HTTP transport is reused.
sdk = AsyncOpenAI(
api_key=args.get('api_key') or litellm.api_key or litellm.openai_key or get_secret('OPENAI_API_KEY'),
base_url=(
args.get('api_base')
or litellm.api_base
or get_secret('OPENAI_BASE_URL')
or get_secret('OPENAI_API_BASE')
or 'https://api.openai.com/v1'
),
http_client=client,
organization=args.get('organization') or litellm.organization,
timeout=args.get('timeout', 120),
max_retries=0,
)
yield {**args, 'client': sdk}
def _cached_metadata(self, key, compute):
if not hasattr(self, '_metadata_cache'):
self._metadata_cache = OrderedDict()
cache = self._metadata_cache
cached = cache.get(key)
if cached is not None and time.monotonic() - cached[0] < 300:
cache.move_to_end(key)
return copy.deepcopy(cached[1])
value = compute()
cache[key] = (time.monotonic(), copy.deepcopy(value))
cache.move_to_end(key)
while len(cache) > 128:
cache.popitem(last=False)
return value
def _build_litellm_model_name(self, model_name: str, custom_llm_provider: str | None = None) -> str:
"""Build LiteLLM model name with provider prefix if needed."""
@@ -199,8 +319,21 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
def _get_custom_llm_provider(self) -> str | None:
return self.requester_cfg.get('custom_llm_provider') or None
def _uses_openrouter_protocol(self) -> bool:
# OpenRouter's shipped metadata also serves OpenAI-compatible non-chat
# operations. Select gateway chat semantics independently of it.
return (
self._get_custom_llm_provider() == 'openrouter'
or self.requester_cfg.get('requester_name') == 'openrouter-chat-completions'
)
def _safe_litellm_bool_helper(self, helper_name: str, model_name: str) -> bool:
"""Call a LiteLLM boolean capability helper without letting metadata gaps fail requests."""
return self._cached_metadata(
('bool', helper_name, model_name), lambda: self._uncached_litellm_bool_helper(helper_name, model_name)
)
def _uncached_litellm_bool_helper(self, helper_name, model_name):
helper = getattr(litellm, helper_name, None)
if not callable(helper):
return False
@@ -299,6 +432,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
return None
def _safe_context_length(self, model_name: str) -> int | None:
return self._cached_metadata(('model', model_name), lambda: self._uncached_model_info(model_name))
def _uncached_model_info(self, model_name):
helper = getattr(litellm, 'get_model_info', None)
if not callable(helper):
return self._known_context_length_fallback(model_name)
@@ -563,6 +699,20 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
}
if family == 'qwen' and 'disabled' in capabilities['levels'] and 'enabled' not in capabilities['levels']:
capabilities['legacy_levels'] = ['enabled']
if self._uses_openrouter_protocol():
capabilities['budget_tokens'] = {'min': 1, 'max': 32768}
elif family == 'anthropic' and not self._is_anthropic_adaptive_model(normalized_name):
if normalized_name.rsplit('/', 1)[-1].startswith(
('claude-3-7-', 'claude-sonnet-4', 'claude-opus-4', 'claude-haiku-4')
):
capabilities['budget_tokens'] = {'min': 1024, 'max': 32768}
elif family == 'qwen' and self._supports_qwen_thinking_budget(model_name):
capabilities['budget_tokens'] = {'min': 1, 'max': 32768}
elif family == 'gemini' and 'gemini-2.5' in normalized_name:
capabilities['budget_tokens'] = {
'min': 128 if 'pro' in normalized_name else (512 if 'flash-lite' in normalized_name else 1),
'max': 32768 if 'pro' in normalized_name else 24576,
}
return capabilities
def _build_reasoning_args(self, model: requester.RuntimeLLMModel) -> dict[str, typing.Any]:
@@ -570,14 +720,21 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
if level == 'provider_default':
return {}
config = {'level': level}
config = reasoning.normalize_reasoning_config(self._reasoning_config_value(model))
capabilities = self.get_reasoning_capabilities(model)
try:
reasoning.validate_reasoning_capabilities(config, capabilities, model.model_entity.name)
except ValueError as exc:
raise errors.RequesterError(str(exc)) from exc
raise errors.RequesterError(str(exc), code='invalid_policy') from exc
family = self._reasoning_family(model.model_entity.name, model)
budget = config.get('budget_tokens')
if budget is not None:
if self._uses_openrouter_protocol():
return {'extra_body': {'reasoning': {'max_tokens': budget}}}
if family == 'qwen':
return {'extra_body': {'enable_thinking': True, 'thinking_budget': budget}}
return {'thinking': {'type': 'enabled', 'budget_tokens': budget}}
if level == 'disabled':
if family in {'deepseek', 'kimi', 'mimo', 'doubao'}:
return {'extra_body': {'thinking': {'type': 'disabled'}}}
@@ -1029,7 +1186,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
if include_retry_params:
if self.requester_cfg.get('drop_params'):
args['drop_params'] = self.requester_cfg['drop_params']
if self.requester_cfg.get('num_retries'):
if self.requester_cfg.get('num_retries') is not None:
args['num_retries'] = self.requester_cfg['num_retries']
if self.requester_cfg.get('api_version'):
args['api_version'] = self.requester_cfg['api_version']
@@ -1037,24 +1194,102 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
def _handle_litellm_error(self, e: Exception) -> None:
"""Convert LiteLLM exceptions to RequesterError. Never returns, always raises."""
# Check more specific exceptions first (they inherit from base exceptions)
if isinstance(e, litellm.ContextWindowExceededError):
raise errors.RequesterError(t('Context length exceeded') + f': {e}')
if isinstance(e, litellm.BadRequestError):
raise errors.RequesterError(t('Invalid request parameters') + f': {e}')
if isinstance(e, litellm.AuthenticationError):
raise errors.RequesterError(t('Invalid API key') + f': {e}')
if isinstance(e, litellm.NotFoundError):
raise errors.RequesterError(t('Invalid model or endpoint') + f': {e}')
if isinstance(e, litellm.RateLimitError):
raise errors.RequesterError(t('Rate limit exceeded or insufficient balance') + f': {e}')
if isinstance(e, litellm.Timeout):
raise errors.RequesterError(t('Request timed out') + f': {e}')
if isinstance(e, litellm.APIConnectionError):
raise errors.RequesterError(t('Connection failed') + f': {e}')
if isinstance(e, litellm.APIError):
raise errors.RequesterError(t('API error') + f': {e}')
raise errors.RequesterError(t('Unknown error') + f': {e}')
if isinstance(e, errors.RequesterError):
raise e
metadata = error_metadata(e)
mappings = (
(litellm.ContextWindowExceededError, 'context_length', t('Context length exceeded'), False),
(litellm.AuthenticationError, 'authentication', t('Invalid API key'), False),
(litellm.NotFoundError, 'not_found', t('Invalid model or endpoint'), False),
(litellm.RateLimitError, 'rate_limit', t('Rate limit exceeded or insufficient balance'), True),
(litellm.Timeout, 'timeout', t('Request timed out'), True),
(litellm.APIConnectionError, 'connection', t('Connection failed'), True),
(litellm.BadRequestError, 'bad_request', t('Invalid request parameters'), False),
)
for exception_type, code, message, retryable in mappings:
if isinstance(e, exception_type):
body = getattr(e, 'body', None)
body = body if isinstance(body, dict) else {}
upstream_error = body.get('error', body)
upstream_code = upstream_error.get('code') if isinstance(upstream_error, dict) else None
if upstream_code in ('insufficient_quota', 'quota_exceeded', 'billing_hard_limit_reached'):
code, retryable = 'quota_exceeded', False
raise errors.RequesterError(message, code=code, retryable=retryable, **metadata) from e
status = metadata['status_code']
raise errors.RequesterError(
t('API error') if isinstance(e, litellm.APIError) else t('Unknown error'),
code='upstream_error',
retryable=status in (408, 409, 429, 500, 502, 503, 504),
**metadata,
) from e
def _apply_transport(self, args, model, extra_args):
state = current_call.get()
policy = (
state.policy
if state
else RequestPolicy.resolve(self.requester_cfg, model.model_entity.extra_args, extra_args)
)
args.pop('request_policy', None)
if state:
# The Host owns retries; do not multiply attempts in nested SDKs.
args['num_retries'] = 0
args['max_retries'] = 0
args['timeout'] = httpx.Timeout(
connect=policy.connect_timeout,
read=policy.read_timeout,
write=policy.write_timeout,
pool=policy.pool_timeout,
)
state.mark('request_prepared_ms')
base = args.get('api_base', '')
is_openrouter = urlsplit(base).hostname == 'openrouter.ai' or (
not base and self._get_custom_llm_provider() == 'openrouter'
)
headers = dict(args.get('extra_headers') or {})
existing = {key.lower() for key in headers}
if policy.attribution and is_openrouter:
if 'http-referer' not in existing:
headers['HTTP-Referer'] = 'https://langbot.app'
if not {'x-title', 'x-openrouter-title'} & existing:
headers['X-OpenRouter-Title'] = 'LangBot'
conversation_header = policy.conversation_header or ('x-session-id' if is_openrouter else None)
if state and state.conversation_id and conversation_header and conversation_header not in existing:
headers[conversation_header] = state.conversation_id
if headers:
args['extra_headers'] = headers
async def _completion(self, args):
state = current_call.get()
if state:
state.mark('request_started_ms')
result = await state.wait(
lambda: acompletion(**args),
'first_event' if args.get('stream') else 'response',
state.policy.first_event_timeout if args.get('stream') else state.policy.total_timeout,
)
state.mark('response_open_ms')
return result
return await acompletion(**args)
async def _raw_chunks(self, response):
state = current_call.get()
iterator = response.__aiter__()
while True:
try:
if state:
chunk = await state.wait(
lambda: anext(iterator),
'stream_idle' if state.raw_started else 'first_event',
state.policy.stream_idle_timeout if state.raw_started else state.policy.first_event_timeout,
)
state.raw_started = True
state.mark('first_event_ms')
else:
chunk = await anext(iterator)
except StopAsyncIteration:
return
yield chunk
async def _build_completion_args(
self,
@@ -1065,14 +1300,31 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
stream: bool = False,
) -> dict:
"""Build common completion arguments for invoke_llm and invoke_llm_stream."""
preparation_start = time.perf_counter()
state = current_call.get()
cache_preparation = state is not None and state.policy.retries > 0
scoped_model = copy.copy(model)
try:
scoped_model.reasoning_config_override = reasoning.resolve_reasoning_config(model, extra_args)
except ValueError as exc:
raise errors.RequesterError(str(exc), code='invalid_policy') from exc
model = scoped_model
reasoning_family = self._reasoning_family(model.model_entity.name, model)
reasoning_level = self._reasoning_level(model)
req_messages = self._convert_messages(
messages,
reasoning_family=reasoning_family,
include_reasoning_context=reasoning_level != 'disabled',
)
model_name = self._build_litellm_model_name(model.model_entity.name)
message_key = ('messages', id(messages), reasoning_family, reasoning_level != 'disabled')
if cache_preparation and message_key in state.prepared:
req_messages = copy.deepcopy(state.prepared[message_key])
state.metrics['preparation_cache_hits'] = state.metrics.get('preparation_cache_hits', 0) + 1
else:
req_messages = self._convert_messages(
messages, reasoning_family=reasoning_family, include_reasoning_context=reasoning_level != 'disabled'
)
if cache_preparation:
state.prepared[message_key] = copy.deepcopy(req_messages)
# OpenRouter shares requester metadata with OpenAI-compatible non-chat
# operations, but chat needs its own adapter to preserve cache markers.
chat_provider = 'openrouter' if self._uses_openrouter_protocol() else self._get_custom_llm_provider()
model_name = self._build_litellm_model_name(model.model_entity.name, chat_provider)
api_key = model.provider.token_mgr.get_token()
args = {
@@ -1085,10 +1337,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
args['stream_options'] = {'include_usage': True}
self._build_common_args(args)
if chat_provider == 'openrouter':
args['custom_llm_provider'] = chat_provider
# Apply model-level extra_args first, then call-level extra_args
if model.model_entity.extra_args:
args.update(model.model_entity.extra_args)
args.update(extra_args)
args.pop('reasoning_config', None)
reasoning_args = self._build_reasoning_args(model)
if reasoning_args:
@@ -1113,8 +1369,23 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
raise errors.RequesterError('allowed_openai_params must be an array')
args['allowed_openai_params'] = list(dict.fromkeys([*allowed_openai_params, 'reasoning_effort']))
budget = model.reasoning_config_override.get('budget_tokens')
if budget is not None and reasoning_family == 'anthropic' and not self._uses_openrouter_protocol():
output_limit = args.get('max_completion_tokens', args.get('max_tokens'))
if type(output_limit) is not int or output_limit <= budget:
raise errors.RequesterError(
'Anthropic budget_tokens requires explicit max_tokens greater than the budget',
code='invalid_policy',
)
if funcs:
tools = await self.ap.tool_mgr.generate_tools_for_openai(funcs)
tool_key = ('tools', id(funcs))
if cache_preparation and tool_key in state.prepared:
tools = copy.deepcopy(state.prepared[tool_key])
else:
tools = copy.deepcopy(await self.ap.tool_mgr.generate_tools_for_openai(funcs))
if cache_preparation:
state.prepared[tool_key] = copy.deepcopy(tools)
if tools:
args['tools'] = tools
args.setdefault('tool_choice', 'auto')
@@ -1123,6 +1394,15 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
# and imports proxy-only dependencies such as FastAPI.
args['_skip_mcp_handler'] = True
cache_family = reasoning_family
if cache_family == 'openrouter':
cache_family = self._infer_reasoning_family_from_model_name(model.model_entity.name) or cache_family
apply_prompt_caching(args, state, provider=self._get_custom_llm_provider(), family=cache_family)
self._apply_transport(args, model, extra_args)
if state:
state.metrics['preparation_ms'] = round(
state.metrics.get('preparation_ms', 0) + (time.perf_counter() - preparation_start) * 1000, 3
)
return args
async def count_tokens(
@@ -1144,7 +1424,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
count_args['tool_choice'] = args['tool_choice']
try:
tokens = litellm.token_counter(**count_args)
tokens = await asyncio.to_thread(litellm.token_counter, **count_args)
except Exception as e:
self._handle_litellm_error(e)
@@ -1164,38 +1444,41 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
"""Invoke LLM and return message with usage info."""
args = await self._build_completion_args(model, messages, funcs, extra_args, stream=False)
try:
response = await acompletion(**args)
async with self._client_args(args) as args:
try:
response = await self._completion(args)
message_data = response.choices[0].message.model_dump()
if 'role' not in message_data or message_data['role'] is None:
message_data['role'] = 'assistant'
message_data = response.choices[0].message.model_dump()
if 'role' not in message_data or message_data['role'] is None:
message_data['role'] = 'assistant'
content = message_data.get('content', '')
reasoning_content = message_data.get('reasoning_content', None)
thinking_blocks = message_data.get('thinking_blocks')
if reasoning_content or thinking_blocks:
provider_fields = dict(message_data.get('provider_specific_fields') or {})
if reasoning_content:
provider_fields['reasoning_content'] = reasoning_content
if thinking_blocks:
provider_fields['thinking_blocks'] = thinking_blocks
message_data['provider_specific_fields'] = provider_fields
display_reasoning = reasoning_content or self._thinking_blocks_text(thinking_blocks) or None
message_data['content'] = self._process_thinking_content(content, display_reasoning, remove_think)
content = message_data.get('content', '')
reasoning_content = message_data.get('reasoning_content', None)
thinking_blocks = message_data.get('thinking_blocks')
if reasoning_content or thinking_blocks:
provider_fields = dict(message_data.get('provider_specific_fields') or {})
if reasoning_content:
provider_fields['reasoning_content'] = reasoning_content
if thinking_blocks:
provider_fields['thinking_blocks'] = thinking_blocks
message_data['provider_specific_fields'] = provider_fields
display_reasoning = reasoning_content or self._thinking_blocks_text(thinking_blocks) or None
message_data['content'] = self._process_thinking_content(content, display_reasoning, remove_think)
if 'reasoning_content' in message_data:
del message_data['reasoning_content']
if 'thinking_blocks' in message_data:
del message_data['thinking_blocks']
if 'reasoning_content' in message_data:
del message_data['reasoning_content']
if 'thinking_blocks' in message_data:
del message_data['thinking_blocks']
message = provider_message.Message(**message_data)
usage_info = self._extract_usage(response)
message = provider_message.Message(**message_data)
usage_info = self._extract_usage(response)
if current_call.get():
current_call.get().usage = usage_info
return message, usage_info
return message, usage_info
except Exception as e:
self._handle_litellm_error(e)
except Exception as e:
self._handle_litellm_error(e)
async def invoke_llm_stream(
self,
@@ -1218,141 +1501,149 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
thinking_blocks_state: list[dict[str, typing.Any]] = []
response = None
try:
response = await acompletion(**args)
async for chunk in response:
# Capture usage whenever a chunk carries it.
#
# Important: many OpenAI-compatible gateways (e.g. new-api) and
# providers send the final usage payload in a chunk that STILL
# contains a (empty-delta) choice, not an empty `choices` list.
# The previous implementation only captured usage when `choices`
# was empty, so streamed calls always recorded 0 tokens.
# We therefore capture usage independently of `choices`, and then
# fall through to also process any content this chunk may carry.
if getattr(chunk, 'usage', None):
usage_info = self._normalize_usage(chunk.usage)
if query is not None:
if query.variables is None:
query.variables = {}
query.variables[requester.STREAM_USAGE_QUERY_VARIABLE] = usage_info
async with self._client_args(args) as args:
try:
response = await self._completion(args)
async for chunk in self._raw_chunks(response):
# Capture usage whenever a chunk carries it.
#
# Important: many OpenAI-compatible gateways (e.g. new-api) and
# providers send the final usage payload in a chunk that STILL
# contains a (empty-delta) choice, not an empty `choices` list.
# The previous implementation only captured usage when `choices`
# was empty, so streamed calls always recorded 0 tokens.
# We therefore capture usage independently of `choices`, and then
# fall through to also process any content this chunk may carry.
if getattr(chunk, 'usage', None):
if current_call.get():
current_call.get().usage = self._extract_usage(chunk)
usage_info = self._normalize_usage(chunk.usage)
if query is not None:
if query.variables is None:
query.variables = {}
query.variables[requester.STREAM_USAGE_QUERY_VARIABLE] = usage_info
if not hasattr(chunk, 'choices') or not chunk.choices:
continue
if not hasattr(chunk, 'choices') or not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}
finish_reason = getattr(choice, 'finish_reason', None)
choice = chunk.choices[0]
delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}
finish_reason = getattr(choice, 'finish_reason', None)
if 'role' in delta and delta['role']:
role = delta['role']
if 'role' in delta and delta['role']:
role = delta['role']
delta_content = delta.get('content') or ''
reasoning_content = delta.get('reasoning_content') or ''
provider_fields = dict(delta.get('provider_specific_fields') or {})
if finish_reason:
# Preserve an explicit provider stop even when the final delta is empty.
provider_fields['finish_reason'] = finish_reason
raw_thinking_blocks = delta.get('thinking_blocks')
if raw_thinking_blocks:
thinking_blocks_state = self._merge_thinking_blocks(thinking_blocks_state, raw_thinking_blocks)
provider_fields['thinking_blocks'] = thinking_blocks_state
thinking_blocks_text = self._thinking_blocks_text(raw_thinking_blocks)
display_reasoning_content = reasoning_content or thinking_blocks_text
state = current_call.get()
if state and delta.get('content'):
state.mark('first_content_ms')
if state and (delta.get('reasoning_content') or delta.get('thinking_blocks')):
state.mark('first_reasoning_ms')
delta_content = delta.get('content') or ''
reasoning_content = delta.get('reasoning_content') or ''
provider_fields = dict(delta.get('provider_specific_fields') or {})
if finish_reason:
# Preserve an explicit provider stop even when the final delta is empty.
provider_fields['finish_reason'] = finish_reason
raw_thinking_blocks = delta.get('thinking_blocks')
if raw_thinking_blocks:
thinking_blocks_state = self._merge_thinking_blocks(thinking_blocks_state, raw_thinking_blocks)
provider_fields['thinking_blocks'] = thinking_blocks_state
thinking_blocks_text = self._thinking_blocks_text(raw_thinking_blocks)
display_reasoning_content = reasoning_content or thinking_blocks_text
# Handle reasoning_content based on remove_think flag
if reasoning_content:
provider_fields['reasoning_content'] = reasoning_content
if remove_think:
delta_content = delta_content or None
else:
# Stream explicit markers so downstream adapters and
# the debug page see the same format as non-streaming
# responses.
if not reasoning_started:
delta_content = '<think>\n'
reasoning_started = True
# Handle reasoning_content based on remove_think flag
if reasoning_content:
provider_fields['reasoning_content'] = reasoning_content
if remove_think:
delta_content = delta_content or None
else:
delta_content = ''
delta_content += display_reasoning_content
if delta.get('content'):
delta_content += f'\n</think>\n{delta.get("content")}'
reasoning_closed = True
# Stream explicit markers so downstream adapters and
# the debug page see the same format as non-streaming
# responses.
if not reasoning_started:
delta_content = '<think>\n'
reasoning_started = True
else:
delta_content = ''
delta_content += display_reasoning_content
if delta.get('content'):
delta_content += f'\n</think>\n{delta.get("content")}'
reasoning_closed = True
elif display_reasoning_content:
if remove_think:
delta_content = delta_content or None
else:
if not reasoning_started:
delta_content = '<think>\n'
reasoning_started = True
elif display_reasoning_content:
if remove_think:
delta_content = delta_content or None
else:
delta_content = ''
delta_content += display_reasoning_content
if delta.get('content'):
delta_content += f'\n</think>\n{delta.get("content")}'
reasoning_closed = True
if not reasoning_started:
delta_content = '<think>\n'
reasoning_started = True
else:
delta_content = ''
delta_content += display_reasoning_content
if delta.get('content'):
delta_content += f'\n</think>\n{delta.get("content")}'
reasoning_closed = True
elif delta_content and not remove_think and reasoning_started and not reasoning_closed:
delta_content = f'\n</think>\n{delta_content}'
reasoning_closed = True
elif delta_content and not remove_think and reasoning_started and not reasoning_closed:
delta_content = f'\n</think>\n{delta_content}'
reasoning_closed = True
if finish_reason and not remove_think and reasoning_started and not reasoning_closed:
delta_content = f'{delta_content}\n</think>\n'
reasoning_closed = True
if finish_reason and not remove_think and reasoning_started and not reasoning_closed:
delta_content = f'{delta_content}\n</think>\n'
reasoning_closed = True
if think_state is not None and delta_content:
delta_content = think_state.feed(delta_content)
if think_state is not None and delta_content:
delta_content = think_state.feed(delta_content)
tool_calls = self._normalize_stream_tool_calls(delta.get('tool_calls'), tool_call_state)
tool_calls = self._normalize_stream_tool_calls(delta.get('tool_calls'), tool_call_state)
if not delta_content and not tool_calls and not provider_fields and not finish_reason:
if not delta_content and not tool_calls and not provider_fields and not finish_reason:
chunk_idx += 1
continue
chunk_data: dict[str, typing.Any] = {
'role': role,
'content': delta_content if delta_content else None,
'tool_calls': tool_calls,
'is_final': bool(finish_reason),
}
# Preserve provider_specific_fields from delta (e.g., Gemini thought_signatures)
if provider_fields:
chunk_data['provider_specific_fields'] = provider_fields
chunk_data = {k: v for k, v in chunk_data.items() if v is not None}
yield provider_message.MessageChunk(**chunk_data)
chunk_idx += 1
continue
chunk_data: dict[str, typing.Any] = {
'role': role,
'content': delta_content if delta_content else None,
'tool_calls': tool_calls,
'is_final': bool(finish_reason),
}
# Preserve provider_specific_fields from delta (e.g., Gemini thought_signatures)
if provider_fields:
chunk_data['provider_specific_fields'] = provider_fields
chunk_data = {k: v for k, v in chunk_data.items() if v is not None}
yield provider_message.MessageChunk(**chunk_data)
chunk_idx += 1
if reasoning_started and not reasoning_closed:
yield provider_message.MessageChunk(
role=role,
content='\n</think>\n',
is_final=True,
)
if think_state is not None:
pending_content = think_state.flush()
if pending_content:
if reasoning_started and not reasoning_closed:
yield provider_message.MessageChunk(
role=role,
content=pending_content,
content='\n</think>\n',
is_final=True,
)
except Exception as e:
self._handle_litellm_error(e)
if think_state is not None:
pending_content = think_state.flush()
if pending_content:
yield provider_message.MessageChunk(
role=role,
content=pending_content,
is_final=True,
)
finally:
# Closing a downstream async generator must release the provider's
# HTTP stream immediately, including cancellation while at yield.
close = getattr(response, 'aclose', None)
if callable(close):
try:
await asyncio.wait_for(close(), timeout=5.0)
except Exception as exc:
self.ap.logger.debug(f'Failed to close LLM stream: {type(exc).__name__}')
except Exception as e:
self._handle_litellm_error(e)
finally:
# Closing a downstream async generator must release the provider's
# HTTP stream immediately, including cancellation while at yield.
close = getattr(response, 'aclose', None)
if callable(close):
try:
await asyncio.wait_for(close(), timeout=5.0)
except Exception as exc:
self.ap.logger.debug(f'Failed to close LLM stream: {type(exc).__name__}')
async def invoke_embedding(
self,
+17
View File
@@ -458,3 +458,20 @@ space:
# Complete execution records are batched as {"records": [...]}; records are
# removed from the buffer only once the sender accepted them.
telemetry_flush_seconds: 180
# Host-owned LLM policy. Models/calls may override through extra_args.request_policy.
llm_request_policy:
retries: 0
total_timeout: 300
first_event_timeout: 120
stream_idle_timeout: 120
connect_timeout: 10
read_timeout: 120
write_timeout: 30
pool_timeout: 10
backoff_base: 0.5
backoff_max: 8
attribution: true
# Optional: x-session-id, x-litellm-session-id, x-langbot-conversation-id.
# OpenRouter uses x-session-id automatically; other providers default to none.
conversation_header: null
+442
View File
@@ -0,0 +1,442 @@
"""Real HTTP -> Core -> Plugin Runtime -> Runner -> Core -> HTTP provider tests.
Only the remote provider is deterministic. No requester, SDK action transport,
model manager, persistence or monitoring implementation is mocked.
"""
import json
import importlib.metadata
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import shutil
import select
import socket
import sqlite3
import threading
import time
import pytest
import yaml
import httpx
from packaging.version import Version
from tests.e2e import test_runner_plugin_runtime as runtime
# Reuse the real process lifecycle; override the package/config and port below.
runner_runtime_ports = runtime.runner_runtime_ports
runner_runtime_process = runtime.runner_runtime_process
runner_langbot_process = runtime.runner_langbot_process
runner_client = runtime.runner_client
pytestmark = pytest.mark.e2e
RUNNER = runtime.QA_RUNNER_ID
USAGE = {
'prompt_tokens': 24,
'completion_tokens': 4,
'total_tokens': 28,
'prompt_tokens_details': {'cached_tokens': 16},
'completion_tokens_details': {'reasoning_tokens': 2},
}
@pytest.fixture(scope='session')
def runner_e2e_tmpdir(tmp_path_factory):
# Retain logs/SQLite in pytest's bounded temp history for failure diagnosis.
return tmp_path_factory.mktemp('llm-chain')
@pytest.fixture(scope='session')
def runner_e2e_port():
return runtime._free_port()
@pytest.fixture(scope='session')
def runner_e2e_config_path(runner_e2e_tmpdir, runner_e2e_port, runner_runtime_ports):
path = runtime.runner_e2e_config_path.__wrapped__(runner_e2e_tmpdir, runner_e2e_port, runner_runtime_ports)
root = runner_e2e_tmpdir / 'agent-runner-qa-package'
manifest = root / 'components/runner/default.yaml'
data = yaml.safe_load(manifest.read_text(encoding='utf-8'))
data['spec']['permissions'] = {'models': ['invoke', 'stream', 'count_tokens']}
data['spec']['config'] = [
{'name': 'model', 'label': {'en_US': 'Model'}, 'type': 'llm-model-selector', 'required': True},
]
manifest.write_text(yaml.safe_dump(data), encoding='utf-8')
shutil.copyfile(Path(__file__).parent / 'utils/llm_probe_runner.py', root / 'components/runner/default.py')
if legacy_root := os.environ.get('LLM_E2E_LEGACY_API_DIR'):
for filename in ('legacy_api.py', 'legacy_message.py'):
shutil.copyfile(Path(legacy_root) / filename, root / filename)
shutil.make_archive(str(runner_e2e_tmpdir / 'agent-runner-qa'), 'zip', root_dir=root)
return path
@pytest.fixture(scope='module')
def provider_wire():
records = []
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def log_message(self, *args):
pass
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
scenario = self.headers.get('x-e2e-case', 'normal')
row = {'body': body, 'headers': dict(self.headers), 'case': scenario, 'closed': False}
records.append(row)
attempt = sum(x['case'] == scenario for x in records)
status = 503 if scenario.startswith('retry') and attempt == 1 else 200
if scenario == 'auth':
status = 401
if status != 200:
data = json.dumps({'error': {'message': 'injected failure', 'type': 'api_error'}}).encode()
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(data)))
self.send_header('Retry-After', '0')
self.end_headers()
self.wfile.write(data)
return
if scenario in ('first-timeout', 'total-timeout'):
time.sleep(1)
try:
if not body.get('stream'):
data = json.dumps(
{
'id': 'e2e',
'object': 'chat.completion',
'created': 1,
'model': body['model'],
'choices': [
{
'index': 0,
'message': {'role': 'assistant', 'content': 'hello world'},
'finish_reason': 'stop',
}
],
'usage': USAGE,
}
).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
return
self.send_response(200)
self.send_header('Content-Type', 'text/event-stream')
self.send_header('Transfer-Encoding', 'chunked')
self.end_headers()
def send(event):
data = ('data: ' + (event if isinstance(event, str) else json.dumps(event)) + '\n\n').encode()
self.wfile.write(f'{len(data):X}\r\n'.encode() + data + b'\r\n')
self.wfile.flush()
base = {'id': 'e2e', 'object': 'chat.completion.chunk', 'created': 1, 'model': body['model']}
if scenario.startswith('reasoning-'):
send(
{
**base,
'choices': [
{'index': 0, 'delta': {'reasoning_content': 'private thought'}, 'finish_reason': None}
],
}
)
for index, content in enumerate(['hello ', 'world']):
send({**base, 'choices': [{'index': 0, 'delta': {'content': content}, 'finish_reason': None}]})
if index == 0 and (scenario in ('idle-timeout', 'disconnect') or scenario.startswith('cancel-')):
if scenario == 'disconnect':
self.close_connection = True
return
deadline = time.monotonic() + 1
while time.monotonic() < deadline:
if select.select([self.connection], [], [], 0.02)[0]:
if not self.connection.recv(1, socket.MSG_PEEK):
row['closed'] = True
return
send({**base, 'choices': [{'index': 0, 'delta': {}, 'finish_reason': 'stop'}]})
send({**base, 'choices': [], 'usage': USAGE})
send('[DONE]')
self.wfile.write(b'0\r\n\r\n')
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
row['closed'] = True
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f'http://127.0.0.1:{server.server_port}/v1', records
server.shutdown()
server.server_close()
thread.join(timeout=5)
@pytest.fixture(scope='module')
def chain(request, provider_wire):
request.getfixturevalue('runner_langbot_process')
port = request.getfixturevalue('runner_e2e_port')
client = httpx.Client(base_url=f'http://127.0.0.1:{port}', timeout=90, trust_env=False)
request.addfinalizer(client.close)
tmpdir = request.getfixturevalue('runner_e2e_tmpdir')
token = runtime._init_and_auth(client)
runtime._ensure_qa_plugin(client, token, tmpdir / 'agent-runner-qa.zip')
client.headers['Authorization'] = f'Bearer {token}'
def post(path, body):
response = client.post('/api/v1/' + path, json=body)
assert response.status_code == 200, response.text
payload = response.json()
assert payload['code'] == 0, payload
return payload['data']
provider = post(
'provider/providers',
{
'name': 'LLM chain E2E',
'requester': 'openai-chat-completions',
'base_url': provider_wire[0],
'api_keys': ['e2e-not-a-secret'],
},
)['uuid']
model = post(
'provider/models/llm',
{
'name': 'gpt-4o',
'provider_uuid': provider,
'abilities': ['func_call', 'vision'],
'extra_args': {
'temperature': 0.1,
'request_policy': {'retries': 1, 'backoff_base': 0.01, 'backoff_max': 0.01},
},
},
)['uuid']
agent = post(
'agents',
{
'kind': 'agent',
'name': 'LLM chain E2E',
'config': {
'runner': {'id': RUNNER},
'runner_config': {RUNNER: {'model': model}},
},
},
)['uuid']
def invoke(mode, case='normal', extra=None, *, configuration=None, options=None):
selected_model, selected_agent = model, agent
if configuration:
configured_provider = post(
'provider/providers',
{
'name': case,
'requester': configuration.get('requester', 'openai-chat-completions'),
'base_url': provider_wire[0],
'api_keys': ['e2e-not-a-secret'],
},
)['uuid']
selected_model = post(
'provider/models/llm',
{
'name': configuration.get('name', 'gpt-4o'),
'provider_uuid': configured_provider,
'abilities': ['func_call', 'vision'],
'extra_args': configuration.get('extra_args', {}),
},
)['uuid']
selected_agent = post(
'agents',
{
'kind': 'agent',
'name': case,
'config': {
'runner': {'id': RUNNER},
'runner_config': {RUNNER: {'model': selected_model}},
},
},
)['uuid']
result = post(
f'agents/{selected_agent}/debug',
{
'text': json.dumps(
{'mode': mode, 'model': selected_model, 'case': case, 'extra': extra or {}, **(options or {})}
),
'conversation_id': 'e2e-' + case,
'event_type': 'message.received',
},
)
assert result.get('final_text'), result
return json.loads(result['final_text'])
return invoke, provider_wire[1], tmpdir
def test_multimodal_and_tool_definitions_survive_plugin_transport(chain):
invoke, records, _ = chain
image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII='
messages = [
{
'role': 'user',
'content': [
{'type': 'text', 'text': 'Describe this image'},
{'type': 'image_url', 'image_url': {'url': image}},
],
}
]
result = invoke('runner-events', 'multimodal', options={'messages': messages, 'tool': True})
assert result['text'] == 'hello world', result
wire = records[-1]['body']
assert wire['messages'][0]['content'][1]['image_url']['url'] == image
assert wire['tools'][0]['function']['name'] == 'e2e_lookup'
def test_openrouter_budget_cache_and_explicit_attribution(chain):
invoke, records, _ = chain
result = invoke(
'runner-events',
'openrouter-budget-cache',
configuration={
'requester': 'openrouter-chat-completions',
'name': 'anthropic/claude-sonnet-4',
'extra_args': {
'reasoning_config': {'budget_tokens': 2048},
'prompt_caching': {'mode': 'anthropic'},
'max_tokens': 4096,
},
},
extra={
'extra_headers': {'HTTP-Referer': 'https://langbot.app', 'X-OpenRouter-Title': 'LangBot'},
'request_policy': {'conversation_header': 'x-langbot-conversation-id'},
},
options={'messages': [{'role': 'system', 'content': 'Stable prefix'}, {'role': 'user', 'content': 'Hello'}]},
)
assert result['text'] == 'hello world', result
wire = records[-1]
assert wire['body']['reasoning']['max_tokens'] == 2048
assert wire['body']['messages'][0]['content'][0]['cache_control']['type'] == 'ephemeral'
headers = {k.lower(): v for k, v in wire['headers'].items()}
assert headers['http-referer'] == 'https://langbot.app'
assert headers['x-openrouter-title'] == 'LangBot'
assert headers['x-langbot-conversation-id'].startswith('lb-')
assert 'prompt_caching' not in wire['body'] and 'reasoning_config' not in wire['body']
def test_openai_cache_options_and_call_override(chain):
invoke, records, _ = chain
result = invoke(
'runner-usage',
'openai-cache',
configuration={
'extra_args': {'temperature': 0.1, 'prompt_caching': {'mode': 'openai', 'retention': 'in_memory'}},
},
extra={'temperature': 0.7, 'prompt_cache_key': 'explicit-test-cache'},
)
assert result['text'] == 'hello world', result
assert records[-1]['body']['temperature'] == 0.7
assert records[-1]['body']['prompt_cache_key'] == 'explicit-test-cache'
assert records[-1]['body']['prompt_cache_retention'] == 'in_memory'
@pytest.mark.parametrize(
'mode',
[
'runner-message',
'runner-usage',
'runner-stream',
'runner-events',
'plugin-message',
'plugin-usage',
'plugin-stream',
'plugin-events',
],
)
def test_public_api_shapes_and_usage(chain, mode):
invoke, records, _ = chain
result = invoke(mode, mode)
assert result['text'] == 'hello world', result
assert not result.get('error'), result
if mode.endswith(('usage', 'events')):
assert result['usage']['prompt_tokens_details']['cached_tokens'] == 16
assert result['usage']['completion_tokens_details']['reasoning_tokens'] == 2
wire = records[-1]['body']
assert wire['temperature'] == 0.1
assert 'request_policy' not in wire
@pytest.mark.parametrize(
'case,extra,attempts,error',
[
('retry-once', {}, 2, False),
('retry-disabled', {'num_retries': 0}, 1, True),
('retry-max-disabled', {'max_retries': 0}, 1, True),
('auth', {}, 1, True),
('first-timeout', {'request_policy': {'first_event_timeout': 0.2, 'retries': 0}}, 1, True),
('total-timeout', {'request_policy': {'total_timeout': 0.2, 'retries': 0}}, 1, True),
('idle-timeout', {'request_policy': {'stream_idle_timeout': 0.2}}, 1, True),
('disconnect', {}, 1, True),
],
)
def test_failures_retry_boundaries_and_recovery(chain, case, extra, attempts, error):
invoke, records, _ = chain
result = invoke('runner-events', case, extra)
assert bool(result.get('error')) == error, result
assert sum(row['case'] == case for row in records) == attempts
assert invoke('runner-events', 'recover-' + case)['text'] == 'hello world'
@pytest.mark.parametrize('surface', ['runner-events', 'runner-stream', 'plugin-events', 'plugin-stream'])
def test_cancellation_releases_upstream_and_next_request_works(chain, surface):
invoke, records, tmpdir = chain
case = 'cancel-' + surface
result = invoke(case, case)
assert result['text'] == 'hello ', result
time.sleep(1.5)
cancelled = [row for row in records if row['case'] == case]
with sqlite3.connect(tmpdir / 'data/langbot.db') as db:
diagnostics = db.execute('SELECT session_id, details FROM monitoring_llm_calls').fetchall()
assert len(cancelled) == 1
assert invoke('runner-events', 'after-' + case)['text'] == 'hello world'
if (
not cancelled[0]['closed']
and Version(importlib.metadata.version('langbot-plugin')) <= Version('0.7.11')
and not os.environ.get('LLM_E2E_REQUIRE_STREAM_CLOSE')
):
pytest.xfail('Published SDK <=0.7.11 omits aclosing in public proxy wrappers; requires companion SDK fix')
assert cancelled[0]['closed'], diagnostics[-2:]
@pytest.mark.parametrize('remove_think', [True, False])
def test_reasoning_visibility_through_runner(chain, remove_think):
result = chain[0]('runner-events', f'reasoning-{remove_think}', options={'remove_think': remove_think})
assert result['text'].endswith('hello world'), result
assert ('private thought' in result['text']) == (not remove_think)
def test_invalid_budget_fails_before_network_and_recovers(chain):
invoke, records, _ = chain
result = invoke('runner-events', 'invalid-budget', {'reasoning_config': {'budget_tokens': 1}})
assert result.get('error'), result
assert not any(row['case'] == 'invalid-budget' for row in records)
assert invoke('runner-events', 'after-invalid-budget')['text'] == 'hello world'
def test_monitoring_persists_complete_usage_and_structured_errors(chain):
invoke, _, tmpdir = chain
invoke('runner-events', 'monitoring')
with sqlite3.connect(tmpdir / 'data/langbot.db') as db:
rows = db.execute('SELECT details FROM monitoring_llm_calls').fetchall()
details = [json.loads(row[0]) for row in rows if row[0]]
assert any(x.get('usage', {}).get('prompt_tokens_details', {}).get('cached_tokens') == 16 for x in details)
assert any(x.get('timing', {}).get('transport_setup_ms') is not None for x in details)
assert any(x.get('error', {}).get('code') for x in details if x.get('error'))
@pytest.mark.skipif(not os.environ.get('LLM_E2E_LEGACY_API_DIR'), reason='Historical SDK sources not supplied')
@pytest.mark.parametrize('mode', ['legacy-message', 'legacy-usage', 'legacy-stream', 'legacy-events'])
def test_historical_sdk_proxy_and_message_entities(chain, mode):
result = chain[0](mode, mode)
assert result['text'] == 'hello world', result
assert not result.get('error'), result
if mode.endswith(('usage', 'events')):
assert result['usage']['prompt_tokens_details']['cached_tokens'] == 16
+93
View File
@@ -0,0 +1,93 @@
"""Packaged into the isolated E2E plugin; calls only public SDK LLM APIs."""
from contextlib import aclosing
import json
from langbot_plugin.api.definition.components.runner.runner import Runner
from langbot_plugin.api.entities.builtin.provider.message import Message
from langbot_plugin.api.entities.builtin.runner.result import RunnerResult
from langbot_plugin.api.proxies.langbot_api import LangBotAPIProxy
class DefaultRunner(Runner):
async def run(self, ctx):
options = json.loads(ctx.input.to_text())
mode = options['mode']
cancel = mode.startswith('cancel-')
if cancel:
mode = mode.removeprefix('cancel-')
api = LangBotAPIProxy(self._plugin_runtime_handler) if mode.startswith('plugin-') else ctx.api
message_type = Message
if mode.startswith('legacy-'):
# Historical files are supplied verbatim from a released SDK tag.
# Its proxy and serializers use the real current runtime transport.
import legacy_api
import legacy_message
legacy_api.provider_message = legacy_message
api = legacy_api.LangBotAPIProxy(self._plugin_runtime_handler)
message_type = legacy_message.Message
extra = {
**options['extra'],
'extra_headers': {
**options['extra'].get('extra_headers', {}),
'x-e2e-case': options['case'],
},
}
kwargs = {
'llm_model_uuid': options['model'],
'messages': [
message_type.model_validate(m)
for m in options.get(
'messages',
[
{'role': 'user', 'content': 'Hello'},
],
)
],
'extra_args': extra,
}
if options.get('tool'):
from langbot_plugin.api.entities.builtin.resource.tool import LLMTool
async def lookup(**kwargs):
return 'result'
kwargs['funcs'] = [
LLMTool(
name='e2e_lookup',
human_desc='Lookup',
description='Lookup',
parameters={'type': 'object', 'properties': {}},
func=lookup,
)
]
result = {'text': '', 'usage': None}
if 'remove_think' in options:
kwargs['remove_think'] = options['remove_think']
try:
if mode.endswith('message'):
message = await api.invoke_llm(**kwargs)
assert isinstance(message, message_type)
result['text'] = message.content
elif mode.endswith('usage'):
response = await api.invoke_llm_with_usage(**kwargs)
result.update(
text=response.message.content, usage=response.usage.model_dump() if response.usage else None
)
else:
events = mode.endswith('events')
method = api.invoke_llm_stream_events if events else api.invoke_llm_stream
async with aclosing(method(**kwargs)) as stream:
async for item in stream:
chunk = item.chunk if events else item
if chunk:
result['text'] += chunk.content or ''
if events and item.usage:
result['usage'] = item.usage.model_dump()
if cancel and result['text']:
break
except Exception as exc:
result['error'] = type(exc).__name__ + ': ' + str(exc)
yield RunnerResult.message_completed(ctx.run_id, Message(role='assistant', content=json.dumps(result)))
yield RunnerResult.run_completed(ctx.run_id, finish_reason='stop')
@@ -10,6 +10,27 @@ from __future__ import annotations
import pytest
from unittest.mock import Mock, AsyncMock
from importlib import import_module
from types import SimpleNamespace
@pytest.mark.parametrize(
'call_args',
[
{'num_retries': 0},
{'max_retries': 0},
{'timeout': 3},
{'request_policy': {'stream_idle_timeout': 2}},
],
)
def test_llm_bridge_keeps_model_and_call_policy_layers_separate(call_args):
from langbot.pkg.provider.modelmgr.request_policy import RequestPolicy
model_args = {'temperature': 0.2, 'request_policy': {'retries': 2, 'read_timeout': 90}}
model = SimpleNamespace(model_entity=SimpleNamespace(extra_args=model_args))
merged = get_handler_module()._merge_model_extra_args(model, call_args, llm_policy=True)
assert RequestPolicy.resolve(model_args, merged) == RequestPolicy.resolve(model_args, call_args)
assert merged['temperature'] == 0.2
assert model_args['request_policy'] == {'retries': 2, 'read_timeout': 90}
def get_handler_module():
+75
View File
@@ -1,6 +1,7 @@
"""Replay synthetic HTTP/SSE traffic through the real Codex requester."""
import json
import asyncio
from collections import OrderedDict
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -16,6 +17,32 @@ TOKENS = {'access_token': 'access-secret', 'account_id': 'account', 'connection_
MODEL = SimpleNamespace(model_entity=SimpleNamespace(name='codex-test', extra_args={}, reasoning_config=None))
@pytest.mark.asyncio
async def test_response_headers_obey_host_first_event_deadline(monkeypatch):
from langbot.pkg.provider.modelmgr.errors import RequesterError
from langbot.pkg.provider.modelmgr.request_policy import CallState, RequestPolicy, current_call
cancelled = asyncio.Event()
async def handler(request):
try:
await asyncio.sleep(10)
finally:
cancelled.set()
obj = requester(monkeypatch, handler)
state = CallState(RequestPolicy(first_event_timeout=0.02))
token = current_call.set(state)
try:
with pytest.raises(RequesterError) as caught:
await obj.invoke_llm(None, MODEL, [])
finally:
current_call.reset(token)
assert caught.value.code == 'timeout'
assert caught.value.phase == 'first_event'
assert cancelled.is_set()
def requester(monkeypatch, handler):
real_client = httpx.AsyncClient
monkeypatch.setattr(
@@ -198,3 +225,51 @@ def test_advanced_parameters_cannot_override_transport(monkeypatch, key):
obj = requester(monkeypatch, lambda request: httpx.Response(200))
with pytest.raises(ValueError, match='Unsupported Codex advanced'):
obj._body(None, MODEL, [], None, {key: 'secret'}, TOKENS)
@pytest.mark.asyncio
async def test_early_close_releases_nested_http_stream(monkeypatch):
closed = asyncio.Event()
class Body(httpx.AsyncByteStream):
async def __aiter__(self):
yield b'data: {"type":"response.output_text.delta","delta":"first"}\n\n'
await asyncio.Event().wait()
async def aclose(self):
closed.set()
obj = requester(monkeypatch, lambda request: httpx.Response(200, stream=Body()))
stream = obj.invoke_llm_stream(None, MODEL, [])
assert (await anext(stream)).content == 'first'
await stream.aclose()
assert closed.is_set()
@pytest.mark.asyncio
async def test_host_deadline_does_not_cancel_suspended_consumer(monkeypatch, runtime_provider, runtime_llm_model):
from langbot.pkg.provider.modelmgr.errors import RequesterError
from tests.unit_tests.provider.conftest import TEST_EXECUTION_CONTEXT
runtime_provider.requester = requester(
monkeypatch,
lambda request: stream(
[
{'type': 'response.output_text.delta', 'delta': 'first'},
{'type': 'response.completed', 'response': {'status': 'completed', 'output': []}},
]
),
)
runtime_llm_model.model_entity.extra_args = {}
result = runtime_provider.invoke_llm_stream(
None,
runtime_llm_model,
[],
extra_args={'request_policy': {'total_timeout': 0.05}},
execution_context=TEST_EXECUTION_CONTEXT,
)
assert (await anext(result)).content == 'first'
await asyncio.sleep(0.07)
with pytest.raises(RequesterError) as caught:
await anext(result)
assert caught.value.phase == 'total'
@@ -0,0 +1,285 @@
import asyncio
import json
import threading
from types import SimpleNamespace
from unittest.mock import Mock
import httpx
import pytest
from aiohttp import web
from langbot.pkg.provider.modelmgr.http_pool import _CompletedSSEStream, RequesterHTTPPool
from langbot.pkg.provider.modelmgr.errors import RequesterError
from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester
from langbot.pkg.provider.modelmgr.request_policy import CallState, RequestPolicy, current_call
from tests.unit_tests.provider.test_reasoning_control import _runtime_model
from scripts.llm_latency_probe import SSEMeter, cache_tokens
def test_request_timing_uses_high_resolution_monotonic_clock(monkeypatch):
from langbot.pkg.provider.modelmgr import request_policy
ticks = iter([100.0, 100.0004])
monkeypatch.setattr(request_policy.time, 'perf_counter', lambda: next(ticks))
state = CallState(RequestPolicy())
state.mark('request_prepared_ms')
assert state.metrics['request_prepared_ms'] == 0.4
def test_meter_accepts_split_utf8_and_usage_without_content():
meter = SSEMeter()
event = {'choices': [{'delta': {'content': '你好'}}]}
data = ('data: ' + json.dumps(event, ensure_ascii=False) + '\r\n\r\n').encode()
for byte in data:
meter.feed(bytes([byte]), 1)
meter.feed(b'data: {"choices":[],"usage":{"prompt_tokens_details":{"cached_tokens":12}}}\n\n', 2)
meter.feed(b'data: [DONE]\n\n', 3)
assert meter.first_content == 1 and meter.content_chars == 2
assert cache_tokens(meter.usage) == 12 and meter.done
assert cache_tokens(None) is None
@pytest.mark.asyncio
@pytest.mark.parametrize('provider,name', [('openai', 'gpt-4o'), ('openrouter', 'openai/gpt-4o')])
@pytest.mark.parametrize('drain', [False, True])
async def test_real_stream_reuses_socket_and_preserves_late_usage(monkeypatch, provider, name, drain):
monkeypatch.setenv('NO_PROXY', '127.0.0.1,localhost')
sockets = []
async def handle(request):
await request.read()
sockets.append(request.transport)
response = web.StreamResponse(headers={'Content-Type': 'text/event-stream'})
await response.prepare(request)
events = [
{'choices': [{'index': 0, 'delta': {'role': 'assistant', 'content': 'hello'}, 'finish_reason': None}]},
{'choices': [{'index': 0, 'delta': {}, 'finish_reason': 'stop'}]},
{
'choices': [],
'usage': {
'prompt_tokens': 14,
'completion_tokens': 1,
'total_tokens': 15,
'prompt_tokens_details': {'cached_tokens': 12},
},
},
]
for event in events:
await response.write(
(
'data: '
+ json.dumps(
{
'id': 'r',
'object': 'chat.completion.chunk',
'created': 1,
'model': name,
**event,
}
)
+ '\n\n'
).encode()
)
await asyncio.sleep(0.02)
await response.write(b'data: [DO')
await response.write(b'NE]\n\n')
await response.write_eof()
return response
app = web.Application()
app.router.add_post('/chat/completions', handle)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start()
adapter = LiteLLMRequester(
SimpleNamespace(logger=Mock()),
{
'custom_llm_provider': provider,
'base_url': f'http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}',
},
)
await adapter.initialize()
adapter._http_pool.drain_sse = drain
model = _runtime_model(adapter, name=name)
model.provider.token_mgr.get_token = lambda: 'test'
try:
for _ in range(2):
state = CallState(RequestPolicy())
token = current_call.set(state)
try:
chunks = [chunk async for chunk in adapter.invoke_llm_stream(None, model, [])]
assert ''.join(chunk.content or '' for chunk in chunks) == 'hello'
assert state.usage['prompt_tokens_details']['cached_tokens'] == 12
finally:
current_call.reset(token)
# OpenRouter's generic HTTP adapter already consumes EOF; the OpenAI
# SDK path is the regression that needs terminal draining.
assert (sockets[0] is sockets[1]) == (drain or provider == 'openrouter')
finally:
await adapter.aclose()
await runner.cleanup()
@pytest.mark.asyncio
async def test_terminal_drain_is_bounded_but_early_close_does_not_drain():
class Stream(httpx.AsyncByteStream):
def __init__(self, terminal):
self.terminal = terminal
self.tail_read = False
self.closed = False
async def __aiter__(self):
yield b'data: [DONE]\n\n' if self.terminal else b'data: {"choices":[]}\n\n'
self.tail_read = True
await asyncio.Event().wait()
async def aclose(self):
self.closed = True
for terminal in (False, True):
raw = Stream(terminal)
wrapped = _CompletedSSEStream(raw)
iterator = wrapped.__aiter__()
async with asyncio.timeout(1):
await anext(iterator)
await wrapped.aclose()
await iterator.aclose()
assert raw.closed and raw.tail_read == terminal
@pytest.mark.asyncio
async def test_cancellation_during_terminal_drain_is_not_swallowed():
reading = asyncio.Event()
class Stream(httpx.AsyncByteStream):
async def __aiter__(self):
yield b'data: [DONE]\n\n'
reading.set()
await asyncio.Event().wait()
async def aclose(self):
pass
wrapped = _CompletedSSEStream(Stream())
iterator = wrapped.__aiter__()
pending = asyncio.create_task(anext(iterator))
await reading.wait()
pending.cancel()
with pytest.raises(asyncio.CancelledError):
await pending
await iterator.aclose()
await wrapped.aclose()
@pytest.mark.asyncio
@pytest.mark.parametrize('retire', [False, True])
async def test_concurrent_pool_initialization_loads_tls_once_off_loop(monkeypatch, retire):
context = httpx.create_ssl_context()
entered, release = threading.Event(), threading.Event()
main_thread = threading.get_ident()
calls = []
def load():
assert threading.get_ident() != main_thread
calls.append(True)
entered.set()
assert release.wait(2)
return context
monkeypatch.setattr(httpx, 'create_ssl_context', load)
pool = RequesterHTTPPool()
async def lease():
async with pool.lease() as client:
return client
first, second = asyncio.create_task(lease()), asyncio.create_task(lease())
await asyncio.to_thread(entered.wait, 2)
if retire:
await pool.aclose()
release.set()
results = await asyncio.gather(first, second, return_exceptions=True)
assert len(calls) == 1
if retire:
assert all(isinstance(result, RequesterError) for result in results)
assert pool.client is None
else:
assert results[0] is results[1] is pool.client
assert not pool.client.is_closed
await pool.aclose()
@pytest.mark.asyncio
async def test_cancelling_tls_initialization_leaks_no_http_client(monkeypatch):
context = httpx.create_ssl_context()
entered, release = threading.Event(), threading.Event()
def load():
entered.set()
assert release.wait(2)
return context
monkeypatch.setattr(httpx, 'create_ssl_context', load)
pool = RequesterHTTPPool()
async def lease():
async with pool.lease():
pytest.fail('Cancelled initializer must not publish a client')
pending = asyncio.create_task(lease())
await asyncio.to_thread(entered.wait, 2)
pending.cancel()
try:
with pytest.raises(asyncio.CancelledError):
await pending
await pool.aclose()
assert pool.client is None and pool.active == 0
finally:
release.set()
@pytest.mark.asyncio
async def test_real_openai_stream_close_disconnects_upstream(monkeypatch):
monkeypatch.setenv('NO_PROXY', '127.0.0.1,localhost')
disconnected = asyncio.Event()
async def handle(request):
await request.read()
response = web.StreamResponse(headers={'Content-Type': 'text/event-stream'})
await response.prepare(request)
await response.write(
b'data: {"id":"r","object":"chat.completion.chunk","created":1,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":null}]}\n\n'
)
for _ in range(100):
if request.transport is None or request.transport.is_closing():
disconnected.set()
break
await asyncio.sleep(0.02)
return response
app = web.Application()
app.router.add_post('/chat/completions', handle)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start()
adapter = LiteLLMRequester(
SimpleNamespace(logger=Mock()),
{
'custom_llm_provider': 'openai',
'base_url': f'http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}',
},
)
await adapter.initialize()
model = _runtime_model(adapter, name='gpt-4o')
model.provider.token_mgr.get_token = lambda: 'test'
stream = adapter.invoke_llm_stream(None, model, [])
try:
assert (await anext(stream)).content == 'hello'
await stream.aclose()
await asyncio.wait_for(disconnected.wait(), 3)
finally:
await stream.aclose()
await adapter.aclose()
await runner.cleanup()
@@ -0,0 +1,516 @@
"""Connection ownership, preparation reuse and provider-specific request policy."""
import asyncio
import copy
import threading
import json
from pathlib import Path
from collections import OrderedDict
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
import httpx
import pytest
import yaml
from aiohttp import web
from langbot.pkg.provider.modelmgr import reasoning
from langbot.pkg.provider.modelmgr.errors import RequesterError
from langbot.pkg.provider.modelmgr.http_pool import RequesterHTTPPool
from langbot.pkg.provider.modelmgr.prompt_caching import apply_prompt_caching
from langbot.pkg.provider.modelmgr.request_policy import CallState, RequestPolicy, current_call
from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester
from langbot.pkg.provider.modelmgr.requesters.litellmchat import _LeasedHTTPHandler
from langbot.pkg.provider.modelmgr.requesters import codex
from langbot_plugin.api.entities.builtin.provider.message import Message
from tests.unit_tests.provider.test_reasoning_control import _runtime_model, _requester
@pytest.mark.asyncio
async def test_pool_retirement_waits_for_stream_lease():
pool = RequesterHTTPPool()
async with pool.lease() as first:
async with pool.lease() as second:
assert first is second
await pool.aclose()
assert not first.is_closed
with pytest.raises(RequesterError, match='retired'):
async with pool.lease():
pass
assert not first.is_closed
assert first.is_closed
await pool.aclose()
@pytest.mark.asyncio
@pytest.mark.parametrize('provider,name', [('openai', 'gpt-4o'), ('openrouter', 'openai/gpt-4o')])
async def test_real_http_reuses_socket_with_rotating_key_and_timeout(monkeypatch, provider, name):
monkeypatch.setenv('NO_PROXY', '127.0.0.1,localhost')
sockets, headers, bodies = [], [], []
async def handle(request):
sockets.append(request.transport)
headers.append(request.headers['Authorization'])
bodies.append(await request.json())
return web.json_response(
{
'id': 'r',
'object': 'chat.completion',
'created': 1,
'model': 'gpt-4o',
'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': 'ok'}, 'finish_reason': 'stop'}],
'usage': {'prompt_tokens': 1, 'completion_tokens': 1, 'total_tokens': 2},
}
)
app = web.Application()
app.router.add_post('/v1/chat/completions', handle)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
adapter = LiteLLMRequester(Mock(), {'custom_llm_provider': provider, 'base_url': f'http://127.0.0.1:{port}/v1'})
await adapter.initialize()
model = _runtime_model(adapter, name=name)
model.provider.token_mgr.get_token = Mock(side_effect=['first-key', 'second-key'])
try:
for seconds in (10, 20):
state = CallState(RequestPolicy(read_timeout=seconds), 'lb-scoped')
token = current_call.set(state)
try:
message, _ = await adapter.invoke_llm(
None,
model,
[Message(role='user', content='Hi')],
extra_args={'prompt_caching': {'mode': 'openai', 'retention': 'in_memory'}},
)
assert message.content == 'ok'
finally:
current_call.reset(token)
assert sockets[0] is sockets[1]
assert headers == ['Bearer first-key', 'Bearer second-key']
assert bodies[0]['prompt_cache_key'] == bodies[1]['prompt_cache_key']
assert bodies[0]['prompt_cache_retention'] == 'in_memory'
assert all('prompt_caching' not in body and 'reasoning_config' not in body for body in bodies)
finally:
await adapter.aclose()
await runner.cleanup()
@pytest.mark.asyncio
@pytest.mark.parametrize('cancel', [False, True])
async def test_stream_holds_pool_lease_until_consumer_close(cancel):
adapter = _requester('openai')
await adapter.initialize()
model = _runtime_model(adapter, name='gpt-4o')
model.provider.token_mgr.get_token = Mock(return_value='test')
closed = asyncio.Event()
async def raw():
try:
yield SimpleNamespace(
choices=[
SimpleNamespace(delta=SimpleNamespace(model_dump=lambda: {'content': 'hi'}), finish_reason=None)
]
)
await asyncio.Event().wait()
finally:
closed.set()
with patch('langbot.pkg.provider.modelmgr.requesters.litellmchat.acompletion', new=AsyncMock(return_value=raw())):
stream = adapter.invoke_llm_stream(None, model, [])
await anext(stream)
client = adapter._http_pool.client
await adapter.aclose()
assert not client.is_closed
if cancel:
pending = asyncio.create_task(anext(stream))
await asyncio.sleep(0)
pending.cancel()
with pytest.raises(asyncio.CancelledError):
await pending
else:
await stream.aclose()
assert closed.is_set() and client.is_closed
@pytest.mark.asyncio
async def test_preparation_reused_only_within_call_and_is_not_mutated():
adapter = _requester('openai')
adapter.ap.tool_mgr = SimpleNamespace(
generate_tools_for_openai=AsyncMock(
return_value=[{'type': 'function', 'function': {'name': 'tool', 'parameters': {'type': 'object'}}}]
)
)
model = _runtime_model(adapter, name='gpt-4o')
model.provider.token_mgr.get_token = Mock(side_effect=['key-1', 'key-2', 'key-3'])
messages, funcs = [Message(role='user', content='original')], [object()]
state = CallState(RequestPolicy(retries=1))
with patch.object(adapter, '_convert_messages', wraps=adapter._convert_messages) as convert:
token = current_call.set(state)
try:
first = await adapter._build_completion_args(model, messages, funcs)
first['messages'][0]['content'] = 'mutated'
first['tools'][0]['function']['name'] = 'mutated'
second = await adapter._build_completion_args(model, messages, funcs)
finally:
current_call.reset(token)
assert convert.call_count == 1
assert adapter.ap.tool_mgr.generate_tools_for_openai.await_count == 1
assert second['messages'][0]['content'] == 'original'
assert second['tools'][0]['function']['name'] == 'tool'
assert second['api_key'] == 'key-2'
await adapter._build_completion_args(model, messages, funcs)
assert convert.call_count == 2
assert messages[0].content == 'original'
@pytest.mark.asyncio
async def test_token_counter_runs_off_event_loop():
adapter = _requester('openai')
model = _runtime_model(adapter, name='gpt-4o')
model.provider.token_mgr.get_token = Mock(return_value='test')
main_thread = threading.get_ident()
def count(**kwargs):
assert threading.get_ident() != main_thread
return 12
with patch('litellm.token_counter', new=count):
assert await adapter.count_tokens(model, []) == 12
def test_metadata_cache_is_bounded_and_returns_copies():
adapter = _requester('openai')
compute = Mock(return_value={'levels': ['low']})
first = adapter._cached_metadata('model', compute)
first['levels'].append('bad')
assert adapter._cached_metadata('model', compute) == {'levels': ['low']}
assert compute.call_count == 1
for index in range(150):
adapter._cached_metadata(index, lambda: index)
assert len(adapter._metadata_cache) == 128
@pytest.mark.parametrize(
'config',
[
{'budget_tokens': True},
{'budget_tokens': 0},
{'budget_tokens': -1},
{'budget_tokens': 1.5},
{'level': 'high', 'budget_tokens': 1024},
{'level': 'disabled', 'budget_tokens': 1024},
],
)
def test_invalid_reasoning_budget(config):
with pytest.raises(ValueError):
reasoning.normalize_reasoning_config(config)
@pytest.mark.asyncio
@pytest.mark.parametrize(
'provider,name,budget,expected',
[
('anthropic', 'claude-sonnet-4-5', 2048, {'thinking': {'type': 'enabled', 'budget_tokens': 2048}}),
('openai', 'qwen3-32b', 2048, {'extra_body': {'enable_thinking': True, 'thinking_budget': 2048}}),
('gemini', 'gemini-2.5-flash', 2048, {'thinking': {'type': 'enabled', 'budget_tokens': 2048}}),
('openrouter', 'anthropic/claude-sonnet-4.5', 2048, {'extra_body': {'reasoning': {'max_tokens': 2048}}}),
],
)
async def test_budget_mapping_through_existing_extra_args(provider, name, budget, expected):
adapter = _requester(provider)
model = _runtime_model(adapter, name=name)
model.provider.token_mgr.get_token = Mock(return_value='test')
args = await adapter._build_completion_args(
model, [], extra_args={'reasoning_config': {'budget_tokens': budget}, 'max_tokens': 4096}
)
for key, value in expected.items():
assert args[key] == value
assert 'reasoning_config' not in args
assert model.reasoning_config_override is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
'provider,name,budget',
[
('openai', 'gpt-5', 2048),
('anthropic', 'claude-opus-4-7', 2048),
('anthropic', 'claude-sonnet-4-5', 512),
('gemini', 'gemini-2.5-flash', 30000),
('gemini', 'gemini-2.5-flash-lite', 128),
],
)
async def test_budget_not_silently_dropped_on_unsupported_model(provider, name, budget):
adapter = _requester(provider)
model = _runtime_model(adapter, name=name)
model.provider.token_mgr.get_token = Mock(return_value='test')
with pytest.raises(RequesterError, match='budget'):
await adapter._build_completion_args(model, [], extra_args={'reasoning_config': {'budget_tokens': budget}})
def test_runner_level_replaces_call_and_model_budget():
adapter = _requester('anthropic')
model = _runtime_model(adapter, name='claude-sonnet-4-5')
model.model_entity.reasoning_config = {'budget_tokens': 2048}
call = {'reasoning_config': {'budget_tokens': 4096}}
assert reasoning.resolve_reasoning_config(model, call)['budget_tokens'] == 4096
model.reasoning_config_override = {'level': 'low'}
assert reasoning.resolve_reasoning_config(model, call) == {'level': 'low'}
def test_anthropic_cache_boundary_stable_and_input_unchanged():
messages = [{'role': 'system', 'content': 'stable instructions'}, {'role': 'user', 'content': 'first'}]
original = copy.deepcopy(messages)
args = {'messages': messages, 'prompt_caching': {'mode': 'anthropic', 'ttl': '1h'}}
apply_prompt_caching(args, None, provider='anthropic', family='anthropic')
prefix = args['messages'][0]
assert prefix['content'][-1]['cache_control'] == {'type': 'ephemeral', 'ttl': '1h'}
assert messages == original
assert 'cache_control' not in args['messages'][1]
args['messages'].append({'role': 'user', 'content': 'second'})
args['prompt_caching'] = {'mode': 'anthropic'}
apply_prompt_caching(args, None, provider='anthropic', family='anthropic')
assert args['messages'][0] == prefix
def test_explicit_cache_settings_win_and_default_does_nothing():
args = {
'model': 'openai/gpt-4o',
'extra_body': {'prompt_cache_key': 'explicit', 'prompt_cache_retention': '24h'},
'prompt_caching': {'mode': 'openai', 'retention': 'in_memory'},
}
apply_prompt_caching(args, CallState(RequestPolicy(), 'lb-scoped'), provider='openai', family='openai')
assert 'prompt_cache_key' not in args and 'prompt_cache_retention' not in args
args = {'messages': [{'role': 'system', 'content': 'same'}], 'prompt_caching': {'mode': 'provider_default'}}
apply_prompt_caching(args, None, provider='anthropic', family='anthropic')
assert args == {'messages': [{'role': 'system', 'content': 'same'}]}
def test_native_cache_options_bypass_model_filter_without_legacy_hints():
args = {
'model': 'openai/gpt-test',
'prompt_cache_options': {'mode': 'implicit', 'ttl': '30m'},
'prompt_caching': {'mode': 'openai', 'retention': '24h'},
}
apply_prompt_caching(args, CallState(RequestPolicy(), 'lb-scoped'), provider='openai', family='openai')
assert args == {
'model': 'openai/gpt-test',
'extra_body': {'prompt_cache_options': {'mode': 'implicit', 'ttl': '30m'}},
}
@pytest.mark.parametrize(
'config',
[
{'mode': 'typo'},
{'mode': 'anthropic', 'ttl': '24h'},
{'mode': 'openai', 'retention': 'forever'},
{'mode': 'provider_default', 'ttl': '1h'},
],
)
def test_invalid_cache_config(config):
with pytest.raises(RequesterError):
apply_prompt_caching(
{'model': 'test', 'messages': [], 'prompt_caching': config}, None, provider='openai', family='openai'
)
@pytest.mark.asyncio
async def test_gateway_transport_never_retries_or_closes_borrowed_client():
calls = 0
async def fail(request):
nonlocal calls
calls += 1
raise httpx.ConnectError('unavailable', request=request)
async with httpx.AsyncClient(transport=httpx.MockTransport(fail)) as client:
handler = _LeasedHTTPHandler(client, 10)
with pytest.raises(httpx.ConnectError):
await handler.post('https://example.com', json={})
await handler.close()
assert calls == 1 and not client.is_closed
@pytest.mark.asyncio
@pytest.mark.parametrize('provider', ['anthropic', 'openrouter', 'openrouter-metadata'])
async def test_anthropic_cache_and_budget_reach_upstream(provider):
bodies = []
async def handle(request):
bodies.append(json.loads(request.content))
if provider == 'anthropic':
payload = {
'id': 'msg_test',
'type': 'message',
'role': 'assistant',
'model': 'claude-sonnet-4-5',
'content': [{'type': 'text', 'text': 'ok'}],
'stop_reason': 'end_turn',
'usage': {'input_tokens': 1, 'output_tokens': 1, 'cache_read_input_tokens': 50},
}
else:
payload = {
'id': 'r',
'object': 'chat.completion',
'created': 1,
'model': 'anthropic/claude-sonnet-4.5',
'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': 'ok'}, 'finish_reason': 'stop'}],
'usage': {
'prompt_tokens': 51,
'completion_tokens': 1,
'total_tokens': 52,
'prompt_tokens_details': {'cached_tokens': 50},
},
}
return httpx.Response(200, json=payload)
adapter = _requester('openai' if provider == 'openrouter-metadata' else provider)
if provider == 'openrouter-metadata':
metadata = yaml.safe_load(
(
Path(__file__).parents[3] / 'src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml'
).read_text(encoding='utf-8')
)
adapter.requester_cfg.update(
requester_name=metadata['metadata']['name'], custom_llm_provider=metadata['spec']['litellm_provider']
)
name = 'claude-sonnet-4-5' if provider == 'anthropic' else 'anthropic/claude-sonnet-4.5'
model = _runtime_model(adapter, name=name)
model.provider.token_mgr.get_token = Mock(return_value='test')
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
result, usage = await adapter.invoke_llm(
None,
model,
[
Message(role='system', content='stable'),
Message(role='user', content='hello'),
],
extra_args={
'client': _LeasedHTTPHandler(client, 10),
'reasoning_config': {'budget_tokens': 2048},
'max_tokens': 4096,
'prompt_caching': {'mode': 'anthropic', 'ttl': '1h'},
},
)
assert result.content == 'ok'
assert usage
body = bodies[0]
if provider == 'anthropic':
assert body['system'][-1]['cache_control'] == {'type': 'ephemeral', 'ttl': '1h'}
assert body['thinking']['budget_tokens'] == 2048
else:
assert body['messages'][0]['content'][-1]['cache_control'] == {'type': 'ephemeral', 'ttl': '1h'}
assert body['reasoning']['max_tokens'] == 2048
assert 'reasoning_config' not in body and 'prompt_caching' not in body
@pytest.mark.asyncio
async def test_codex_reuses_real_connection(monkeypatch):
monkeypatch.setenv('NO_PROXY', '127.0.0.1,localhost')
sockets = []
async def handle(request):
sockets.append(request.transport)
await request.read()
events = [
{'type': 'response.output_text.delta', 'delta': 'ok'},
{'type': 'response.completed', 'response': {'id': 'r', 'status': 'completed', 'output': []}},
]
return web.Response(
text=''.join('data: ' + json.dumps(event) + '\n\n' for event in events), content_type='text/event-stream'
)
app = web.Application()
app.router.add_post('/responses', handle)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start()
monkeypatch.setattr(codex, 'BASE_URL', f'http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}')
adapter = object.__new__(codex.CodexRequester)
adapter.workspace, adapter.provider = 'w', 'p'
adapter._replay = OrderedDict()
adapter._http_pool = RequesterHTTPPool()
adapter.auth = SimpleNamespace(
access=AsyncMock(
return_value={
'access_token': 'test',
'account_id': 'a',
'connection_id': 'c',
}
)
)
model = SimpleNamespace(model_entity=SimpleNamespace(name='test', extra_args={}, reasoning_config=None))
try:
for _ in range(2):
message, _ = await adapter.invoke_llm(None, model, [])
assert message.content == 'ok'
assert sockets[0] is sockets[1]
finally:
await adapter.aclose()
await runner.cleanup()
@pytest.mark.asyncio
async def test_explicit_client_and_redirected_provider_are_preserved():
adapter = _requester('openai')
await adapter.initialize()
explicit = object()
for args in ({'model': 'openai/gpt-4o', 'client': explicit}, {'model': 'anthropic/claude-sonnet-4-5'}):
async with adapter._client_args(args) as result:
assert result is args
assert adapter._http_pool.client is None
await adapter.aclose()
@pytest.mark.asyncio
async def test_codex_terminal_tail_is_bounded_and_releases_stream():
closed = asyncio.Event()
class HangingTail(httpx.AsyncByteStream):
async def __aiter__(self):
yield b'data: {"type":"response.completed"}\n\n'
await asyncio.Event().wait()
async def aclose(self):
closed.set()
async with httpx.AsyncClient(
transport=httpx.MockTransport(lambda request: httpx.Response(200, stream=HangingTail()))
) as client:
async with client.stream('GET', 'https://example.com') as response:
events = [event async for event in codex.sse_events(response)]
assert events == [{'type': 'response.completed'}]
assert closed.is_set()
@pytest.mark.asyncio
async def test_custom_tls_keeps_litellm_transport(monkeypatch):
import litellm
adapter = _requester('openai')
await adapter.initialize()
monkeypatch.setattr(litellm, 'ssl_verify', '/custom/ca.pem')
args = {'model': 'openai/gpt-4o'}
async with adapter._client_args(args) as result:
assert result is args
assert adapter._http_pool.client is None
await adapter.aclose()
@pytest.mark.asyncio
async def test_pooled_sdk_preserves_environment_fallbacks(monkeypatch):
monkeypatch.setenv('OPENAI_BASE_URL', 'https://gateway.example/v1')
monkeypatch.setenv('OPENAI_API_KEY', 'env-test-key')
adapter = _requester('openai')
await adapter.initialize()
try:
async with adapter._client_args({'model': 'openai/gpt-4o', 'api_key': ''}) as args:
assert str(args['client'].base_url) == 'https://gateway.example/v1/'
assert args['client'].api_key == 'env-test-key'
finally:
await adapter.aclose()
@@ -0,0 +1,549 @@
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
import httpx
import litellm
import pytest
from langbot.pkg.provider.modelmgr.errors import RequesterError
from langbot.pkg.provider.modelmgr.request_policy import (
CallState,
RequestPolicy,
current_call,
error_metadata,
opaque_conversation_id,
)
from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
from tests.unit_tests.provider.conftest import TEST_EXECUTION_CONTEXT
from tests.unit_tests.provider.test_litellmchat import MockRuntimeModel
def test_policy_precedence_zero_and_no_mutation():
model = {'num_retries': 3, 'request_policy': {'read_timeout': 42}}
policy = RequestPolicy.resolve({'request_policy': {'retries': 2}}, model, {'num_retries': 0})
assert policy.retries == 0
assert policy.read_timeout == 42
assert model['num_retries'] == 3
@pytest.mark.parametrize(
'policy',
[
{'retries': True},
{'retries': -1},
{'retries': 6},
{'read_timeout': 0},
{'total_timeout': float('nan')},
{'connect_timeout': float('inf')},
{'attribution': 'false'},
{'conversation_header': 'authorization'},
{'typo': 1},
],
)
def test_invalid_policy(policy):
with pytest.raises(RequesterError, match='Model request failed|模型请求失败'):
RequestPolicy.resolve({'request_policy': policy})
def test_retry_metadata_does_not_include_upstream_secrets():
response = httpx.Response(429, headers={'retry-after': '2', 'x-request-id': 'req-123'})
meta = error_metadata(SimpleNamespace(status_code=429, response=response))
assert meta == {'status_code': 429, 'retry_after': 2, 'request_id': 'req-123'}
adapter = LiteLLMRequester(Mock(), {})
exc = litellm.RateLimitError('secret-key-and-prompt', llm_provider='openai', model='test', response=response)
with pytest.raises(RequesterError) as caught:
adapter._handle_litellm_error(exc)
assert caught.value.retryable
assert caught.value.request_id == 'req-123'
assert 'secret-key' not in str(caught.value)
def test_quota_is_not_retryable():
adapter = LiteLLMRequester(Mock(), {})
exc = litellm.RateLimitError('quota', llm_provider='openai', model='test')
exc.body = {'error': {'code': 'insufficient_quota'}}
with pytest.raises(RequesterError) as caught:
adapter._handle_litellm_error(exc)
assert caught.value.code == 'quota_exceeded'
assert not caught.value.retryable
@pytest.mark.asyncio
async def test_headers_and_sdk_retry_ownership():
adapter = LiteLLMRequester(Mock(), {'base_url': 'https://openrouter.ai/api/v1'})
state = CallState(RequestPolicy(retries=2), 'lb-opaque')
token = current_call.set(state)
try:
args = await adapter._build_completion_args(
MockRuntimeModel(),
[],
extra_args={
'request_policy': {'retries': 2},
'num_retries': 2,
'extra_headers': {'x-openrouter-title': 'Custom app', 'x-other': 'keep'},
},
)
finally:
current_call.reset(token)
assert 'request_policy' not in args
assert args['num_retries'] == args['max_retries'] == 0
assert isinstance(args['timeout'], httpx.Timeout)
assert args['extra_headers'] == {
'x-openrouter-title': 'Custom app',
'x-other': 'keep',
'HTTP-Referer': 'https://langbot.app',
'x-session-id': 'lb-opaque',
}
@pytest.mark.asyncio
@pytest.mark.parametrize('url', ['https://gateway.example/v1', 'https://openrouter.ai.evil.example/v1'])
async def test_no_vendor_headers_on_unrelated_hosts(url):
adapter = LiteLLMRequester(Mock(), {'base_url': url})
args = await adapter._build_completion_args(MockRuntimeModel(), [])
assert not args.get('extra_headers')
assert args['num_retries'] == 0
def test_conversation_is_stable_and_scoped():
query = SimpleNamespace(session=None, launcher_type='person', launcher_id='123', bot_uuid='bot', pipeline_uuid='p')
first = opaque_conversation_id(query, TEST_EXECUTION_CONTEXT)
assert first == opaque_conversation_id(query, TEST_EXECUTION_CONTEXT)
query.bot_uuid = 'another-bot'
assert first != opaque_conversation_id(query, TEST_EXECUTION_CONTEXT)
assert opaque_conversation_id(None, TEST_EXECUTION_CONTEXT) is None
@pytest.mark.asyncio
async def test_runtime_retries_only_before_output(runtime_provider, runtime_llm_model):
provider = runtime_provider
attempts = 0
closed = []
async def stream(**kwargs):
nonlocal attempts
attempts += 1
try:
if attempts == 1:
raise RequesterError('transient', retryable=True)
yield MessageChunk(role='assistant', content='first')
raise RequesterError('after output', retryable=True)
finally:
closed.append(attempts)
provider.requester.invoke_llm_stream = stream
result = []
with pytest.raises(RequesterError, match='after output'):
async for chunk in provider.invoke_llm_stream(
None,
runtime_llm_model,
[],
extra_args={'request_policy': {'retries': 3, 'backoff_base': 0.001}},
execution_context=TEST_EXECUTION_CONTEXT,
):
result.append(chunk.content)
assert result == ['first']
assert attempts == 2
assert closed == [1, 2]
assert current_call.get() is None
@pytest.mark.asyncio
async def test_runtime_deadline_and_close(runtime_provider, runtime_llm_model):
closed = asyncio.Event()
async def stream(**kwargs):
try:
await asyncio.Event().wait()
yield MessageChunk(role='assistant', content='never')
finally:
closed.set()
runtime_provider.requester.invoke_llm_stream = stream
with pytest.raises(RequesterError) as caught:
async for _ in runtime_provider.invoke_llm_stream(
None,
runtime_llm_model,
[],
extra_args={'request_policy': {'first_event_timeout': 0.02}},
execution_context=TEST_EXECUTION_CONTEXT,
):
pass
assert caught.value.phase == 'first_event'
assert closed.is_set()
@pytest.mark.asyncio
async def test_retry_after_cannot_extend_total_budget():
state = CallState(RequestPolicy(retries=5, total_timeout=0.1))
state.metrics['attempts'] = 1
assert not await state.retry(RequesterError('busy', retryable=True, retry_after=30))
@pytest.mark.asyncio
async def test_cancellation_is_not_retried(runtime_provider, runtime_llm_model):
invoke = AsyncMock(side_effect=asyncio.CancelledError)
runtime_provider.requester.invoke_llm = invoke
with pytest.raises(asyncio.CancelledError):
await runtime_provider.invoke_llm(
None,
runtime_llm_model,
[],
extra_args={'request_policy': {'retries': 3}},
execution_context=TEST_EXECUTION_CONTEXT,
)
assert invoke.await_count == 1
@pytest.mark.asyncio
async def test_complete_usage_and_timings_persist(runtime_provider, runtime_llm_model):
from langbot_plugin.api.entities.builtin.pipeline.query import Query
query = Query.model_construct(
query_id=1, launcher_type='person', launcher_id=1, variables={}, bot_uuid='bot', pipeline_uuid='p', session=None
)
object.__setattr__(query, '_execution_context', TEST_EXECUTION_CONTEXT)
usage = {
'prompt_tokens': 20,
'completion_tokens': 10,
'total_tokens': 30,
'prompt_tokens_details': {'cached_tokens': 15},
'completion_tokens_details': {'reasoning_tokens': 8},
'cost': 0.01,
}
runtime_provider.requester.invoke_llm = AsyncMock(return_value=(Message(role='assistant', content='ok'), usage))
with patch(
'langbot.pkg.pipeline.monitoring_helper.MonitoringHelper.record_llm_call', new_callable=AsyncMock
) as record:
result = await runtime_provider.invoke_llm(query, runtime_llm_model, [])
assert result.content == 'ok'
details = record.call_args.kwargs['details']
assert details['usage'] == usage
assert details['timing']['attempts'] == 1
assert details['timing']['total_ms'] >= details['timing']['response_completed_ms']
assert query.variables['_llm_usage'] == usage
@pytest.mark.asyncio
async def test_hidden_reasoning_marks_raw_event_and_usage():
state = CallState(RequestPolicy())
adapter = LiteLLMRequester(Mock(), {})
async def raw():
yield SimpleNamespace(
choices=[
SimpleNamespace(
delta=SimpleNamespace(model_dump=lambda: {'reasoning_content': 'hidden'}), finish_reason=None
)
]
)
yield SimpleNamespace(choices=[], usage={'prompt_tokens': 3, 'completion_tokens': 2, 'total_tokens': 5})
token = current_call.set(state)
try:
with patch(
'langbot.pkg.provider.modelmgr.requesters.litellmchat.acompletion', new=AsyncMock(return_value=raw())
):
chunks = [c async for c in adapter.invoke_llm_stream(None, MockRuntimeModel(), [], remove_think=True)]
finally:
current_call.reset(token)
assert state.raw_started
assert 'first_event_ms' in state.metrics and 'first_reasoning_ms' in state.metrics
assert 'first_content_ms' not in state.metrics
assert state.usage['total_tokens'] == 5
assert not any(c.content for c in chunks)
@pytest.mark.asyncio
async def test_real_http_retry_count_and_request_body(runtime_provider, runtime_llm_model):
"""Exercise the pinned LiteLLM/OpenAI SDK against a local HTTP server."""
from aiohttp import web
received = []
async def handler(request):
received.append(await request.json())
if len(received) == 1:
return web.json_response({'error': {'message': 'busy', 'type': 'server_error'}}, status=503)
return web.json_response(
{
'id': 'chatcmpl-local',
'object': 'chat.completion',
'created': 1,
'model': 'gpt-4o',
'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': 'ok'}, 'finish_reason': 'stop'}],
'usage': {'prompt_tokens': 4, 'completion_tokens': 1, 'total_tokens': 5},
}
)
app = web.Application()
app.router.add_post('/v1/chat/completions', handler)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
runtime_provider.requester = LiteLLMRequester(
Mock(),
{
'base_url': f'http://127.0.0.1:{port}/v1',
'custom_llm_provider': 'openai',
},
)
runtime_llm_model.model_entity.name = 'gpt-4o'
runtime_llm_model.model_entity.extra_args = {}
try:
with pytest.raises(RequesterError):
await runtime_provider.invoke_llm(
None,
runtime_llm_model,
[Message(role='user', content='hello')],
extra_args={'num_retries': 0},
execution_context=TEST_EXECUTION_CONTEXT,
)
assert len(received) == 1
received.clear()
result = await runtime_provider.invoke_llm(
None,
runtime_llm_model,
[Message(role='user', content='hello')],
extra_args={'request_policy': {'retries': 1, 'backoff_base': 0.001}},
execution_context=TEST_EXECUTION_CONTEXT,
)
assert result.content == 'ok'
assert len(received) == 2
assert all('request_policy' not in body and 'num_retries' not in body for body in received)
finally:
await runner.cleanup()
@pytest.mark.asyncio
async def test_stream_idle_timeout_after_output(runtime_provider, runtime_llm_model):
async def stream(**kwargs):
yield MessageChunk(role='assistant', content='first')
await asyncio.Event().wait()
runtime_provider.requester.invoke_llm_stream = stream
with pytest.raises(RequesterError) as caught:
async for _ in runtime_provider.invoke_llm_stream(
None,
runtime_llm_model,
[],
extra_args={'request_policy': {'stream_idle_timeout': 0.02}},
execution_context=TEST_EXECUTION_CONTEXT,
):
pass
assert caught.value.phase == 'stream_idle'
def test_details_migration_preserves_existing_records_and_is_idempotent():
import importlib
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0038_llm_call_details')
engine = sa.create_engine('sqlite://')
try:
with engine.begin() as connection:
connection.exec_driver_sql('CREATE TABLE monitoring_llm_calls (id TEXT PRIMARY KEY)')
connection.exec_driver_sql("INSERT INTO monitoring_llm_calls (id) VALUES ('old')")
with Operations.context(MigrationContext.configure(connection)):
migration.upgrade()
migration.upgrade()
assert connection.exec_driver_sql('SELECT id, details FROM monitoring_llm_calls').one() == ('old', None)
table = sa.Table('monitoring_llm_calls', sa.MetaData(), autoload_with=connection)
payload = {'usage': {'completion_tokens_details': {'reasoning_tokens': 8}}, 'timing': {'first_event_ms': 1}}
connection.execute(table.insert().values(id='new', details=payload))
assert connection.execute(sa.select(table.c.details).where(table.c.id == 'new')).scalar() == payload
finally:
engine.dispose()
@pytest.mark.asyncio
async def test_hidden_raw_events_reset_idle_not_first_visible_deadline(runtime_provider, runtime_llm_model):
adapter = LiteLLMRequester(Mock(), {})
runtime_provider.requester = adapter
runtime_llm_model.model_entity.extra_args = {}
async def raw():
for _ in range(5):
await asyncio.sleep(0.3)
yield SimpleNamespace(
choices=[
SimpleNamespace(delta=SimpleNamespace(model_dump=lambda: {'role': 'assistant'}), finish_reason=None)
]
)
yield SimpleNamespace(
choices=[
SimpleNamespace(delta=SimpleNamespace(model_dump=lambda: {'content': 'answer'}), finish_reason='stop')
]
)
with patch('langbot.pkg.provider.modelmgr.requesters.litellmchat.acompletion', new=AsyncMock(return_value=raw())):
chunks = [
chunk
async for chunk in runtime_provider.invoke_llm_stream(
None,
runtime_llm_model,
[],
extra_args={'request_policy': {'first_event_timeout': 1, 'stream_idle_timeout': 2}},
execution_context=TEST_EXECUTION_CONTEXT,
)
]
assert ''.join(chunk.content or '' for chunk in chunks) == 'answer'
@pytest.mark.asyncio
async def test_monitoring_service_persists_full_details():
from langbot.pkg.api.http.service.monitoring import MonitoringService
persistence = SimpleNamespace(execute_async=AsyncMock())
service = MonitoringService(SimpleNamespace(persistence_mgr=persistence))
details = {
'usage': {
'prompt_tokens': 4,
'completion_tokens': 3,
'total_tokens': 9,
'cache_read_input_tokens': 2,
'provider_extra': {'cost': 0.01},
},
'timing': {'attempts': 2},
'error': None,
}
await service.record_llm_call(
TEST_EXECUTION_CONTEXT,
bot_id='b',
bot_name='b',
pipeline_id='p',
pipeline_name='p',
session_id='s',
model_name='m',
input_tokens=4,
output_tokens=3,
duration=10,
details=details,
)
values = persistence.execute_async.call_args.args[0].compile().params
assert values['details'] == details
assert values['total_tokens'] == 9
def test_codex_usage_preserves_provider_fields():
from langbot.pkg.provider.modelmgr.requesters.codex import _usage
raw = {
'input_tokens': 4,
'output_tokens': 2,
'cost': 0.02,
'output_tokens_details': {'reasoning_tokens': 1},
'future_counter': 7,
}
normalized = _usage({'usage': raw})
assert all(normalized[key] == value for key, value in raw.items())
assert normalized['total_tokens'] == 6
@pytest.mark.asyncio
async def test_nonstream_uses_response_budget_not_first_event():
adapter = LiteLLMRequester(Mock(), {})
state = CallState(RequestPolicy(first_event_timeout=0.001, total_timeout=1))
async def completion(**kwargs):
await asyncio.sleep(0.02)
return 'complete'
token = current_call.set(state)
try:
with patch('langbot.pkg.provider.modelmgr.requesters.litellmchat.acompletion', new=completion):
assert await adapter._completion({'stream': False}) == 'complete'
finally:
current_call.reset(token)
@pytest.mark.asyncio
@pytest.mark.parametrize('cancel_read', [False, True])
async def test_litellm_stream_close_and_cancel_release_upstream(runtime_provider, runtime_llm_model, cancel_read):
adapter = LiteLLMRequester(Mock(), {})
runtime_provider.requester = adapter
runtime_llm_model.model_entity.extra_args = {}
closed = asyncio.Event()
reading = asyncio.Event()
async def raw():
try:
yield SimpleNamespace(
choices=[
SimpleNamespace(delta=SimpleNamespace(model_dump=lambda: {'content': 'first'}), finish_reason=None)
]
)
reading.set()
await asyncio.Event().wait()
finally:
closed.set()
completion = AsyncMock(return_value=raw())
with patch('langbot.pkg.provider.modelmgr.requesters.litellmchat.acompletion', new=completion):
stream = runtime_provider.invoke_llm_stream(
None,
runtime_llm_model,
[],
extra_args={'request_policy': {'retries': 3}},
execution_context=TEST_EXECUTION_CONTEXT,
)
assert (await anext(stream)).content == 'first'
if cancel_read:
task = asyncio.create_task(anext(stream))
await reading.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
else:
await stream.aclose()
assert closed.is_set()
assert completion.await_count == 1
assert current_call.get() is None
@pytest.mark.asyncio
async def test_cancel_during_backoff_does_not_start_another_attempt(runtime_provider, runtime_llm_model):
runtime_provider.requester.invoke_llm = AsyncMock(side_effect=RequesterError('busy', retryable=True))
sleeping = asyncio.Event()
async def backoff(delay):
sleeping.set()
await asyncio.Event().wait()
with patch('langbot.pkg.provider.modelmgr.request_policy.asyncio.sleep', new=backoff):
task = asyncio.create_task(
runtime_provider.invoke_llm(
None,
runtime_llm_model,
[],
extra_args={'request_policy': {'retries': 3}},
execution_context=TEST_EXECUTION_CONTEXT,
)
)
await sleeping.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert runtime_provider.requester.invoke_llm.await_count == 1
@pytest.mark.asyncio
async def test_legacy_none_chunk_is_ignored(runtime_provider, runtime_llm_model):
async def stream(**kwargs):
yield None
yield MessageChunk(role='assistant', content='ok')
runtime_provider.requester.invoke_llm_stream = stream
chunks = [
chunk
async for chunk in runtime_provider.invoke_llm_stream(
None, runtime_llm_model, [], execution_context=TEST_EXECUTION_CONTEXT
)
]
assert [chunk.content for chunk in chunks] == ['ok']
Generated
+4 -4
View File
@@ -2185,7 +2185,7 @@ requires-dist = [
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
{ name = "langbot-plugin", specifier = "==0.7.11" },
{ name = "langbot-plugin", specifier = "==0.7.12" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2255,7 +2255,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.7.11"
version = "0.7.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -2276,9 +2276,9 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/c4/ed01dd73ab5047c75a0563dd38b88146d5d509b889197d5ec47fc27541e5/langbot_plugin-0.7.11.tar.gz", hash = "sha256:19f57a020d435fd414b02cbc477a70f880a462cba73e4ec767fb7dbbffb1bc56", size = 697187, upload-time = "2026-10-14T07:41:24.85Z" }
sdist = { url = "https://files.pythonhosted.org/packages/67/4c/0286247a6caa24a21328fe5dae32b295635143cb863d9eb879384bf613ce/langbot_plugin-0.7.12.tar.gz", hash = "sha256:36a661bfe25233a5c4d74e86568df6831e9055e1b6c88bfad8dee7c2e207766e", size = 697786, upload-time = "2026-10-15T12:01:17.213Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/5b/959ca0b62eac62a95f7740ba3f7c36dd34a1d7ae8499eff17e417b5d2706/langbot_plugin-0.7.11-py3-none-any.whl", hash = "sha256:234d31a8ed4df7e3d8c648735062e8ceba90ac81dfa51a6c678be5a872b505fe", size = 447857, upload-time = "2026-10-14T07:41:23.14Z" },
{ url = "https://files.pythonhosted.org/packages/5c/34/2e40056497e60411e851b30320087e1385865b6357185a05e40a75ef81ca/langbot_plugin-0.7.12-py3-none-any.whl", hash = "sha256:b0bbd6bbfedf15ed48377c3904c73af62c4ee88a676b06649b470fc1bad9416a", size = 448016, upload-time = "2026-10-15T12:01:15.542Z" },
]
[[package]]