fix(skill): pin revisions per run and preserve shared Box state

This commit is contained in:
huanghuoguoguo
2026-10-17 09:32:41 +08:00
572 changed files with 49841 additions and 16188 deletions
+39
View File
@@ -360,3 +360,42 @@ curl -X POST \
- API-key-enabled endpoints use the same resource shapes as the web UI
- No need to learn different API paths - use the existing API documentation with API key authentication
- API keys never select a Workspace from a request header; their persisted binding is authoritative
## Workspace assistant browser session
The Workspace assistant endpoints under `/api/v1/assistant` are account-session
features, not API-key/MCP automation tools. They require a Workspace-scoped user
token and `runtime.operate` for recommendation and turns. Conversation reads
also enforce account ownership.
- `GET /recommended-model` resolves and validates the recommended tool-capable
model when the panel opens. An explicit `model_uuid` in a turn always takes
precedence; it never silently falls back if unavailable.
- `POST /conversations/{id}/turn/stream` accepts the same revision, text/model,
or approval decision as `/turn`, and returns NDJSON. `snapshot` frames contain
the public conversation; `text` frames contain the cumulative text for the
current model invocation; `phase` frames identify the model round or running
tool. A snapshot replaces transient text/tool progress. `completed` contains
the final conversation (including approval or failed states); `error` is a
transport/admission failure. Blank lines are heartbeats.
- `GET /conversations` lists the latest 100 conversations owned by the current
account in this Workspace. `GET /conversations/{id}` includes transient
`progress` (cumulative text, round and running tool) while execution is active.
- Disconnecting a streaming response only detaches the browser. The backend owns
the task until completion, approval, timeout or explicit stop. Slow or absent
consumers never block execution; reconnect using the conversation snapshot.
- `POST /conversations/{id}/stop` accepts `{ "revision": N }` for the active turn.
It enforces ownership and revision, cancels execution and records `stopped`.
Stopping cannot roll back tool effects and clients must never replay a write
automatically. Writes still require confirmation of saved arguments.
- Background execution lives in the current backend process, with at most four
simultaneous turns and the existing 120-second turn limit. This is not a durable
job queue: backend restarts do not resume interrupted tasks.
The original JSON `/turn` endpoint remains compatible for existing callers.
The assistant recommendation uses Space `/api/v1/models/assistant/recommendation`,
which selects only from the assistant pool using fresh successful health checks.
An unavailable recommendation returns 503 without falling back to general chat rankings.
The browser checks Workspace model providers before mounting the assistant; an empty
provider list shows Account sign-in and model settings actions instead of a composer.
+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.
+68
View File
@@ -0,0 +1,68 @@
# Backend message language
Workspace settings expose one **Workspace language**, independent of each member's
WebUI language. It selects LangBot-owned API business errors, model error prefixes,
Pipeline default notices and international platform replies. Supported locales are
`en_US`, `zh_Hans`, `zh_Hant`, `ja_JP`, `es_ES`, `ru_RU`, `th_TH` and `vi_VN`.
Workspaces without a setting default to English. Existing custom messages and
historical records are not rewritten.
## Storage and boundaries
`pkg/i18n/workspace.py` stores `message_language` in existing `WorkspaceMetadata`;
no database migration or instance-wide configuration is needed. Reads and writes
use tenant-scoped units of work. GET/PUT `/api/v1/workspaces/current/language`
require `workspace.view` / `workspace.update`; matching MCP tools are
`get_workspace_message_language` and `set_workspace_message_language`.
An authenticated Workspace takes precedence over the browser's
`X-LangBot-Language` header. The header supplies presentation language only for
account-only or unauthenticated responses and never selects a tenant. HTTP, MCP,
Pipeline and adapter boundaries set a ContextVar so concurrent Workspaces do not
share a language. Adapter SDK thread callbacks use their owning bot's cached
Workspace language, never the calling request's language.
The per-application cache holds at most 256 Workspaces for 30 seconds. Local
writes update the cache immediately; other processes refresh at the next async
message/request boundary after expiry. A running operation keeps its selected
language. Synchronous SDK callbacks use the most recently loaded value.
## Pipeline overrides
The output `misc` section accepts these optional string settings:
| Setting | Default purpose | Placeholder |
| --- | --- | --- |
| `failure-hint` | Request/runner execution failure | none |
| `rate-limit-hint` | Rate limit reached | none |
| `content-filter-hint` | Message rejected by content filter | none |
| `content-filter-error-hint` | Content check failed | none |
| `tool-call-hint` | Tool call notification | `{tools}` |
| `runner-not-found-hint` | Configured runner missing | `{runner}` |
| `runner-not-authorized-hint` | Runner authorization failure | none |
| `runner-unavailable-hint` | Runner temporarily unavailable | none |
Blank values inherit the Workspace translation. Custom text is emitted literally
with only the documented placeholder replaced. The previous shipped
`failure-hint` value `Request failed.` also inherits language; this avoids a data
migration. Existing output switches (including tool-call visibility and exception
handling) retain their behavior.
## Adding messages
Use `pkg.i18n.t()` with a stable English template and named parameters. Add the
same key and placeholders to all eight JSON catalogs. The catalog parity test
checks completeness and formatting. `pipeline_notice()` handles configurable
notices. Exact aliases at the API response boundary cover legacy business wording;
do not add substring replacement of arbitrary error text.
China-only adapters, field validation diagnostics, raw provider/plugin errors,
tracebacks, protocol values and machine-readable status codes are outside this
translation layer. Their values remain useful for diagnostics and interoperability.
Model errors translate LangBot's category/prefix while retaining upstream detail.
Plugin-authored content must use the plugin's own localization. System prompts and
user-authored content are not translated.
Locale JSON files are included in Python package data. Focused coverage lives in
`tests/unit_tests/test_backend_i18n.py`, the Workspace HTTP integration tests and
`web/tests/e2e/workspace-language.spec.ts`.
+48 -30
View File
@@ -10,14 +10,17 @@ ZIP digest without extracting the payload.
Core retains the artifact SHA-256, normalized digest
(`normalized_zip_digest()`), verification state, declared shared-runtime
profile, key ID, selected admission profile, and stable admission code in the
profile, `stateless-v1` component model, key ID, selected admission profile, and stable admission code in the
durable plugin `install_info._certification` record. The record belongs to the
installation row; no schema migration is needed for this additive JSON
metadata.
## Trusted issuer configuration
Configure the non-secret Ed25519 public-key ring in `data/config.yaml`:
Hosted Cloud provisions the non-secret Ed25519 issuer public-key ring out of
band. Key IDs must match the SDK envelope; values are base64 public keys, never
private signing keys. Invalid configuration fails closed; keep old issuer keys
through rotation while their signed archives remain installed.
```yaml
plugin:
@@ -39,50 +42,56 @@ PLUGIN__CERTIFICATION__TRUSTED_PUBLIC_KEYS_JSON='{"ed25519:issuer":"<base64>"}'
```
An **empty** ring is a supported state, not a misconfiguration. OSS defaults to
it, so a self-hosted instance that has not provisioned any issuer key still
installs packages (see the admission matrix below). Configure the ring to grant
the shared-runtime profile; leave it empty to keep every package on the
dedicated profile.
it and supports one Workspace; configuring certification keys on OSS is not a
supported way to enable cross-tenant sharing. Cloud provisions the ring to grant
shared placement only to eligible v2 artifacts. Certification means eligibility
for Cloud cross-tenant use of the same Worker and the same plugin/component
singleton, never a dedicated certificate or intermediate trust tier.
## Admission matrix
| Deployment | SDK verification | Explicit `administrator_force` | Result |
| --- | --- | --- | --- |
| Cloud | valid envelope declaring `shared-runtime-v1` | any | admitted to the shared profile |
| Cloud | absent | any | reject before storage with `CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_REQUIRED` |
| Cloud | malformed, untrusted, invalid, or non-shared | any | reject before storage with `CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_INVALID` |
| Cloud | valid envelope declaring `shared-runtime-v1` + `stateless-v1` | any | admitted to the shared singleton profile |
| Cloud | no signature | any | install on dedicated worker, without shared eligibility |
| Cloud | malformed, untrusted, invalid, or non-shared declaration | any | reject before storage with `CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_INVALID`; do not treat a broken signature as unsigned |
| OSS | absent legacy envelope | any | admitted to the dedicated profile |
| OSS | valid envelope declaring `shared-runtime-v1` | any | selected shared profile |
| OSS | declaration signed by a **key this instance resolves** | false | reject with `CERTIFIED_PLUGIN_OSS_FORCE_REQUIRED` |
| OSS | declaration signed by a **key this instance resolves** | true | admitted to the dedicated profile |
| OSS | valid envelope declaring `shared-runtime-v1` + `stateless-v1` | any | dedicated only; OSS has no cross-tenant shared placement |
| OSS | invalid declaration referencing a **key this instance resolves** | false | reject with `CERTIFIED_PLUGIN_OSS_FORCE_REQUIRED` |
| OSS | invalid declaration referencing a **key this instance resolves** | true | admitted to the dedicated profile |
| OSS | declaration this instance **cannot resolve** (empty ring) | any | admitted to the dedicated profile |
The OSS row that matters for availability is the last one. Marketplace
packages are signed by the marketplace issuer and declare
`shared-runtime-v1`, while OSS ships an empty key ring by default. Treating that
as a rejection made every certified marketplace package uninstallable with
`CERTIFIED_PLUGIN_OSS_FORCE_REQUIRED` before artifact storage. Because the
certificate is signed by an issuer the instance does not declare trusted, no
shared-runtime privilege may be granted, so admission degrades the install to
the existing `oss_dev` dedicated profile and records
`CERTIFIED_PLUGIN_OSS_UNTRUSTED_DEDICATED`. This is not an escalation: it
withholds the shared profile rather than granting it.
There is no dedicated certification, dedicated signature, or intermediate
certification trust tier. Advisory review is an internal prerequisite, not an
installation trust status. An `issued` marketplace badge, source candidate,
matching version, or shared artifact/dependency tree alone does not prove Cloud
admission or Worker sharing. Compare the downloaded archive's ZIP comment,
normalized digest and signed claims with the public version record and deployed
Core trust-key ring. Confirm two Workspace bindings share one Worker PID, one
plugin object, and one object for each declared component before claiming live
cross-tenant sharing.
A declaration is "resolvable" only when its `key_id` is present in the
configured ring. When the ring is configured and the declaration still fails
(malformed, `signature_invalid`, `digest_mismatch`, `unsupported_schema`, ...),
admission stays explicit and requires `administrator_force`.
configured ring. A parseable declaration from a resolved key that fails
signature, digest, identity, profile, or component-model validation requires
`administrator_force` in OSS. Malformed or unsupported envelopes without a
resolved certificate identity are treated as untrusted and stay dedicated.
`administrator_force` is deliberately strict: it is recognized only when the
install request carries boolean `true`. The local upload endpoint accepts the
multipart field `administrator_force=true`; GitHub and marketplace install
payloads carry the same field. The existing resource-manage authorization fence
protects those endpoints. A force never creates a Cloud dedicated fallback.
protects those endpoints. A force never creates a Cloud dedicated fallback or
certifies an invalid signature. A legacy v1 certificate may verify
cryptographically, but without a signed stateless component claim it is
rejected in Cloud; an old marketplace `issued` badge alone grants no placement.
Unsigned Cloud archives are the dedicated fallback.
## Runtime and logs
SDK 0.6.2 carries an installation-level execution mode in both apply and
authoritative reconcile payloads. Core selects `shared-runtime-v1` only when
The first stateless-compatible SDK release will carry the v2 certificate and
singleton component contract; its final version is assigned only at release.
Core must pin that release before sending or selecting the new contract. Core selects `shared-runtime-v1` only when
the persisted certification record says verification was valid, both the
certificate and admission profiles are `shared-runtime-v1`, the admission code
is shared-eligible, and the record's artifact SHA-256 exactly matches the
@@ -90,6 +99,13 @@ installation row. Missing, malformed, stale, invalid, or dedicated admission
facts select `dedicated`. Install, upgrade, configuration revision, restart,
and reconnect all use this same persisted-fact derivation.
The certificate must also bind `component_model=stateless-v1`. This profile
creates one `BasePlugin` and one instance of each component per digest Worker.
Installation slots contain immutable config snapshots and authority only;
task-local invocation context selects the active slot. Older certificates that
do not bind the component model are not eligible for shared placement. Their
source packages remain compatible on dedicated Workers under OSS policy.
The existing public plugin-log boundary already applies the immutable
installation binding (including workspace UUID) through
`RuntimeConnectionHandler.installation_scope()` before requesting logs. This is
@@ -100,5 +116,7 @@ same existing installation scope.
## SDK versioning
Core pins `langbot-plugin==0.6.2`, the first published SDK release carrying the
canonical installation execution-mode contract.
Ship in dependency order: SDK v2 certificate support, then Core exact pin and
lockfile, then Space signing, then Runtime/Core rollout. Legacy v1 envelopes
remain verifiable but do not carry `stateless-v1`, so they never select shared
singleton placement without re-review and v2 re-signing.
+37
View File
@@ -0,0 +1,37 @@
## Cloud plan comparison
`EntitlementSnapshot.plan_revision` and `monthly_bonus_credits` are optional
signed display metadata. The Space adapter opts into these fields with
`plan_metadata=2`. `/api/v1/workspaces/current` exposes them as `current_plan`,
alongside the snapshot's effective limits and feature switches. The existing
Workspace permission check and entitlement resolver remain authoritative.
Admission and quotas do not use plan names or display metadata.
The comparison dialog shows the purchased revision even when the catalog has
no matching plan. It compares resource limits, retention, credits, platform
restrictions and feature switches; identical benefits use one column even if
the revision changed. Missing signed metadata is an explicit unavailable state.
Purchase links for existing paid plans go to Space subscription management,
where permissions, cancellation and revision eligibility are checked again.
This adds display information to an existing authenticated response, not an
agent operation. There is no new MCP operation or billing mutation in Core.
Roll out the matching Core snapshot model before the updated Space adapter.
Verification:
```sh
uv run pytest tests/unit_tests/cloud/test_entitlements.py tests/integration/api/test_workspaces.py -q
cd web
node --test tests/unit/cloud-plans.test.mjs tests/unit/plan-revisions.test.mjs
pnpm build
node tests/revisions/verify.mjs
```
The browser runner expects sibling `space` and `core` checkouts and uses isolated
fixtures and mocked catalog responses. It never authenticates to real Space or
initiates a real payment, and shuts down its own servers after the tests.
Purchased benefits are a card in the existing horizontally scrollable plan card area, immediately before the matching latest plan. There is no separate revision comparison panel, and historical prices are not inferred from the latest catalog.
Customer presentation uses subscription dates and “Latest quotas for this plan”, never revision numbers. Unrelated plans and current latest subscriptions have no comparison markers. Core reuses its normal Benefits card content without diff rows or highlighting. Internal entitlement feature keys are not rendered as product benefits. The optional signed subscription_started_at is the current subscription period start, returned only for a matching entitlement revision; it is not snapshot issuance time. Requests with plan_metadata=1 retain their previous response shape.
-30
View File
@@ -1,30 +0,0 @@
# Beta 适配器诊断开发说明
此诊断用于 4.11 适配器事件与 API 的 Beta 验收。仅受支持的 Beta 版本启用;稳定版、Alpha、RC、开发/本地标记版本以及关闭遥测或 Beta 诊断的实例不采集、不上传。
## 采集位置
- `pkg/platform/botmgr.py`:完成适配器事件监听器注册后产生能力快照。`listener_registered` 表示注册完成,不代表网络连接可用。
- 各适配器的 `_dispatch_eba_event`:调用 `diagnostics.adapter_event_received(self, event)`,记录一次事件已收到。必须位于调用监听器之前,避免路由、插件或模型失败改变接收事实。
- `pkg/telemetry/adapter_diagnostics.py`:识别适配器 API 最外层调用、平台专用 API 名、聊天与媒体类型。通过内部上下文标记抑制嵌套 API 重复计数。
- 转换失败无法可靠确定事件种类时,保留未知事件失败;转换成功本身不重复计算事件接收。
新增适配器或原生事件入口时,应检查事件是否经过统一分发入口。只在转换器上添加埋点不足以覆盖直接构造事件及交互回调。
## 数据约束
`adapter_evidence` 标记可用于验收的边界记录;`listener_registered` 标记注册事实;`chat_type`、`content_type` 仅允许有限类别。消息正文、API 参数、用户/群 ID、异常正文、媒体地址不进入这些字段。内部嵌套调用标记不得序列化。
平台专用动作优先匹配清单,名称为 `platform_api.<动作名>`;标准交互 API 保持其标准名称。未知动作不能借通用 `call_platform_api` 包装方法被计为已验收功能。清单读取显式使用 UTF-8,兼容 Windows 默认编码环境。
## 联动 Space
适配器清单或诊断操作变更后,同步生成 Space 的 `internal/diagnostics/catalog_v1.json`,并运行其目录一致性测试。Space 从清单生成完整矩阵,旧诊断记录不作为新增验收证据。部署时应先更新 Space 接收端与数据库迁移,再更新 Core Beta。
Core 回归命令:
```sh
uv run pytest tests/unit_tests/telemetry -q
```
重点测试在 `test_adapter_acceptance.py`:版本与开关、隐私、嵌套计数、专用/交互 API、分发入口覆盖、监听器失败。Space 仓库的 `docs/beta-adapter-acceptance.md` 说明验收界面、统计口径及联调方式。
+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
}
]
}
+28 -12
View File
@@ -18,7 +18,7 @@
- Dify/Coze/N8n 迁移显式选择 `user-id-source=legacy-session`,Tbox 选择 `legacy-bot`;新建插件配置仍默认 `sender`。旧身份来自 Host 的可信会话/事件/机器人上下文,缺失时失败,不猜测为发送者。
- Dify/N8n 的旧身份使用原 `query.session`,Coze 使用原 `query`;不能在群聊、不同会话策略或恢复交互时把这两者混同。
- Coze、Langflow 等现代持久化远端会话机制保留;旧远端会话 ID 不跨账户、端点、Workspace 或 Runner 导入。
- DashScope 引用字段别名、Langflow 输入输出字段别名、Coze 历史开关别名分别归一化;冲突值阻断,而非靠字段出现顺序决定。
- DashScope 引用字段别名、Langflow 输入输出字段别名、Coze 历史开关别名分别归一化;冲突时固定采用当前规范字段的值并给出提示,不靠字段出现顺序决定。
- N8n 的 ignore 响应模式、认证和保留身份字段要维持明确语义。普通业务变量保留支持的 JSON 值,内部/权限/敏感变量不无条件转发。
- `remove-think` 保留原输出策略,并与支持该字段的 Runner 参数同步;仅用户实际修改才传播,不把表单挂载默认值视为修改。
- 空字符串、缺失和显式 null 区分处理。WeKnora 的 agent-id 不得在打开/保存其他字段时被默认值替换;未知字段/类型不能靠 truthiness 静默接受。
@@ -26,21 +26,37 @@
## 插件版本要求
以下是迁移器要求的已发布 Certified Plugin 版本。旧版本即使名称相同,也不能作为当前 Cloud 迁移能力的证明。
以下是迁移器要求的插件最低版本,不是认证版本清单。已发布的专属运行版本不等于已认证可跨工作区共享;旧版本即使名称相同,也不能作为当前 Cloud 迁移能力的证明。认证须由精确制品的有效签名及 `shared-runtime-v1`、`stateless-v1` 声明证明。
- `local-agent` → `LocalAgent` **0.1.10**。
- `dify-service-api` → `DifyAgent` **0.1.10**。
- `coze-api` → `CozeAgent` **0.1.10**。
- `dashscope-app-api` → `DashScopeAgent` **0.1.10**。
- `n8n-service-api` → `N8nAgent` **0.1.10**。
- `langflow-api` → `LangflowAgent` **0.1.10**。
- `deerflow-api` → `DeerFlowAgent` **0.1.10**。
- `tbox-app-api` → `TboxAgent` **0.1.8**。
- `weknora-api` → `WeKnoraAgent` **0.1.10**。
- `local-agent` → `LocalAgent` **0.2.1**。
- `dify-service-api` → `DifyAgent` **0.2.3**。
- `coze-api` → `CozeAgent` **0.2.0**。
- `dashscope-app-api` → `DashScopeAgent` **0.2.2**。
- `n8n-service-api` → `N8nAgent` **0.2.0**。
- `langflow-api` → `LangflowAgent` **0.2.2**。
- `deerflow-api` → `DeerFlowAgent` **0.2.1**。
- `tbox-app-api` → `TboxAgent` **0.2.1**。
- `weknora-api` → `WeKnoraAgent` **0.2.1**。
版本号是经过审阅的最低版本:安装时优先安装该版本;若扩展市场已下架该版本则回退安装最新版。已安装的更高版本不会被降级,迁移执行前仍以运行时 Runner schema 校验迁移结果。
## 宽松迁移规则(planner v5)
迁移器尽量自动修复,只在无法安全运行时阻断:
- 仍然阻断:配置不是对象/不是合法 JSON;必填凭据或端点缺失/为空(api-key、base-url、webhook-url、app-id、bot-id、flow-id、LocalAgent 缺少主模型);n8n header 名称非法或 basic 认证凭据不是 latin-1;Coze api-base 含凭据、查询串或片段。
- 自动修复并提示:
- 字符串形式的布尔/数字(如 `"true"`、`"30"`)按字段类型转换;无法转换时使用默认值(`migration.value_coerced` / `migration.invalid_value_defaulted`)。
- 缺失字段按 4.10 界面默认值补齐(`migration.missing_default_filled`)。
- 未知字段与残留的其他 Runner 配置段从活动配置移除,原值保存在迁移备份(`migration.unknown_field_dropped` / `migration.unknown_section_archived`)。
- 别名冲突固定取规范字段(`migration.alias_conflict_resolved`);同时存在 `runner.id` 与旧 `runner.runner` 时采用可用的一项(`migration.mixed_runner_selection`)。
- 扩展设置非法时忽略;仅启用部分插件时自动加入目标 Runner 插件(`extensions.runner_added`)。
- 旧版自定义 Runner(非内置 9 类)标记为 `unsupported_runner`:不迁移、不算失败,需改写为插件 Runner 后再绑定,参见 https://langbot.app/docs/zh/plugin/dev/tutor 。
- 诊断只包含代码和字段路径,不包含任何值或凭据。
## 逐字段完整清单
共 9 类 Runner、104 条字段条目;按消费者展开共享字段。未知部署自定义键不冒充已穷举字段,遇到时阻断并保留原值。
共 9 类 Runner、104 条字段条目;按消费者展开共享字段。未知部署自定义键不冒充已穷举字段,遇到时从活动配置移除、原值保留在迁移备份中并给出提示。
目标路径缩写 `R` 表示 `config.ai.runner_config[当前插件 Runner ID]`;`extensions_preferences` 是流水线的 Host 扩展策略,不是插件全局配置。
+72
View File
@@ -0,0 +1,72 @@
# Telegram 4.11 adapter acceptance
Validated on 2026-10-11 using the Telegram Web client and LangBot WebUI through computer use. Bot registration, bot configuration, processor bindings, group membership changes, and test messages were performed through the UI. No direct Bot API calls or database mutations were used to simulate acceptance traffic.
## Environment
- Core 4.11.0rc2, SDK 0.7.11rc2 on their main branches.
- OSS development mode, standalone Plugin Runtime and standalone Docker-backed Box Runtime.
- Dedicated new Telegram bot and dedicated test group; no unrelated conversations used.
- Pipeline and Agent: LocalAgent with gemini-3-flash-preview. Plugin processors: RunnerDemo community and observer.
## Real message coverage
| Path | Verification |
| --- | --- |
| Pipeline private text | TG-P01 received an actual reply. |
| Pipeline group text and streaming | TG-G04 and TG-STREAM05; observed partial output growing from 12 to 22 to 60 lines, ending with STREAM-END. |
| Pipeline private streaming | TG-PRIVATE-STREAM10; observed 8 partial lines progressing to 80 complete lines and STREAM-DONE. |
| Pipeline incoming image | TG-IMG01 correctly identified the red left half and blue right half of a synthetic image. |
| Pipeline incoming file | TG-FILE01 read marker TG-FILE-7319 and calculated 17 + 25 + 38 = 80. |
| Pipeline outgoing media | TG-FILE07 delivered a real green PNG and verified.txt as Telegram attachments. |
| Invalid image fallback | TG-FALLBACK08 delivered a-check.txt once and an intentionally invalid nine-byte PNG as a document once, without replaying the preceding file. |
| Agent private and group | TG-A01/A02/A03 and TG-COEXIST01 exercised event_reply and real delivery. Historical conversation wording influenced early replies; explicit current instructions were used for subsequent media tests. |
| Agent group image input | TG-AGENT-GIMG02 correctly identified left red/right blue. |
| Agent group file input/output | TG-AGENT-GFILE01 read the marker and sum and delivered result.txt (27 bytes); run completed in 10.72 seconds. Downloaded the Telegram attachment and verified its contents: Code: TG-FILE-7319 and Sum: 80. |
| Agent group image output | TG-AGENT-GOUT01 generated a real 64x64 PNG using Box, delivered it as a photo, and completed in 12.96 seconds. |
| Plugin private/group text | TG-PLUGIN-P02 and TG-REACTION-02 echo replies were delivered with completed runs after fixing MessageResult. |
| Plugin attachment events | Community processor independently recorded Image and File components, with completed runs. It intentionally sends no reply without its command prefix. |
| Independent subscriptions | TG-COEXIST01 produced both the plugin reply and Agent reply. |
Agent event_reply is a complete send, not token streaming. RunnerDemo community provides progress logs but no streaming or generated-media command; those outputs were tested through Pipeline and Agent instead. This is not a claim that every processor implements every output mode.
## Real event coverage
| Event | UI action and observed result |
| --- | --- |
| message.received | Sent private/group text and attachments. |
| message.edited | Edited TG-EDIT01; Agent reported the actual edited event. |
| message.reaction | Added then removed thumbs-up; observer payloads retained the emoji with is_add true/false. |
| group.member_joined | Added a dedicated existing test bot; observer recorded the event and community sent a welcome. |
| group.member_left | Removed that test bot; event had is_kicked true and community sent a departure notice. |
| group.member_banned | Restricted that test bot's sending permission; observer recorded the restriction event. |
| bot.invited_to_group | Re-added the QA bot; observer completed at 10:04:12. |
| bot.removed_from_group | Removed the QA bot while restricted; observer completed at 10:03:55. |
| bot.muted | Disabled sending permission; indefinite duration was null after the fix. |
| bot.unmuted | Restored sending while another restriction remained; observer completed at 10:02:29. |
| platform.specific | Changed administrator status; observer recorded my_chat_member_updated. |
This covers each declared event type, not every possible Telegram platform-specific subtype. Administrator-only updates were tested with a minimal administrator role in the dedicated group. Replying while muted/removed was not treated as an expected successful action.
## Fixes driven by acceptance
1. Normalize numeric conversation IDs before building the Agent protocol context.
2. Return MessageResult from Telegram send/reply so successful delivery does not subsequently fail plugin action decoding.
3. Deliver terminal stream attachments and remove an empty Thinking placeholder for attachment-only completions.
4. Fall back to document delivery for known Telegram photo encoding/dimension rejections, preserving earlier deliveries.
5. Register MessageReactionHandler with the correct keyword argument instead of accidentally filtering by chat ID.
6. Preserve the removed emoji and distinguish additions from removals.
7. Interpret Telegram's Unix-epoch restriction expiry as indefinite; calculate finite duration from event time.
8. Recognize partial unmute and removal from a restricted state.
## Automated and documentation validation
- 134 focused tests passed across Telegram legacy/new adapters, native stream completion, Omni parity, and Agent event-first protocol.
- Ruff and git diff whitespace checks passed.
- Chinese, English, and Japanese Telegram guides updated with processor selection, privacy mode, permissions, streaming/media boundaries, event semantics, and troubleshooting.
- Sanitized real group screenshot included in all three guides; rendered image verified in the local built documentation.
- Documentation tests and production static build passed (38 Node tests; 19 Python tests with one skip; 31 static-build tests).
After synchronizing the documentation main branch, a stale DingTalk screenshot assertion in the migration test was updated to match the current guide; the full documentation suite passed again.
Provider-specific errors observed with an earlier model were separated from adapter failures; successful acceptance uses the model stated above. This validation does not assert compatibility with every model provider, Telegram update subtype, or plugin implementation.