diff --git a/docs.json b/docs.json index 999eafc6..8cf555b5 100644 --- a/docs.json +++ b/docs.json @@ -718,6 +718,7 @@ "pages": [ "en/plugin/dev/apis/agent-run", "en/plugin/dev/apis/common", + "en/plugin/dev/apis/llm-requests", "en/plugin/dev/apis/platform", "en/plugin/dev/apis/pipeline-events", "en/plugin/dev/apis/messages", @@ -1034,6 +1035,7 @@ "pages": [ "zh/plugin/dev/apis/agent-run", "zh/plugin/dev/apis/common", + "zh/plugin/dev/apis/llm-requests", "zh/plugin/dev/apis/platform", "zh/plugin/dev/apis/pipeline-events", "zh/plugin/dev/apis/messages", @@ -1353,6 +1355,7 @@ "pages": [ "ja/plugin/dev/apis/agent-run", "ja/plugin/dev/apis/common", + "ja/plugin/dev/apis/llm-requests", "ja/plugin/dev/apis/platform", "ja/plugin/dev/apis/pipeline-events", "ja/plugin/dev/apis/messages", diff --git a/en/plugin/dev/apis/agent-run.mdx b/en/plugin/dev/apis/agent-run.mdx index 58bcf069..38c019c2 100644 --- a/en/plugin/dev/apis/agent-run.mdx +++ b/en/plugin/dev/apis/agent-run.mdx @@ -19,6 +19,8 @@ This page covers the data and methods available through these contexts. Use `sel `EventContext`, `ExecuteContext`, and `Session` also carry `instance_uuid`, `workspace_uuid`, and `placement_generation`. These fields identify the current instance and workspace for execution; plugins must not treat them as authorization data. +See [LLM request configuration](/en/plugin/dev/apis/llm-requests) for retries, timeouts, numeric reasoning budgets, prompt caching, headers and stream cancellation. + ## EventListener EventListener handles Pipeline events. Its event handler receives an `EventContext`: diff --git a/en/plugin/dev/apis/common.mdx b/en/plugin/dev/apis/common.mdx index e1196cd9..a335ef01 100644 --- a/en/plugin/dev/apis/common.mdx +++ b/en/plugin/dev/apis/common.mdx @@ -4,6 +4,8 @@ title: "LangBot API" Use `self.plugin` for models, tools, knowledge bases, and storage. For the current event, replies, and conversation state, see [Context APIs](/en/plugin/dev/apis/agent-run). +See [LLM request configuration](/en/plugin/dev/apis/llm-requests) for retries, timeouts, numeric reasoning budgets, prompt caching, headers and stream cancellation. + ## LangBot API These APIs can be called in any plugin component. Access methods: diff --git a/en/plugin/dev/apis/llm-requests.mdx b/en/plugin/dev/apis/llm-requests.mdx new file mode 100644 index 00000000..800f8610 --- /dev/null +++ b/en/plugin/dev/apis/llm-requests.mdx @@ -0,0 +1,134 @@ +--- +title: "LLM request configuration" +description: "Retries, deadlines, reasoning budgets, caching and stream cancellation for plugins and Runners" +--- + +These options require a Core build containing [Core #2661](https://github.com/langbot-app/LangBot/pull/2661). A merged PR does not imply a published release. Pass them through the existing `extra_args`; plugin and Runner signatures, message types and authorization remain unchanged. See [LangBot API](/en/plugin/dev/apis/common) and [Context APIs](/en/plugin/dev/apis/agent-run). + +## Retries and timeouts + +The following non-streaming example configures the request and reads complete usage: + +```python +from langbot_plugin.api.entities.builtin.provider.message import Message + +result = await self.plugin.invoke_llm_with_usage( + llm_model_uuid=model_uuid, + messages=[Message(role="user", content="Hello!")], + timeout=110, + extra_args={ + "request_policy": { + "retries": 1, + "total_timeout": 90, + "first_event_timeout": 30, + "stream_idle_timeout": 30, + "connect_timeout": 10, + "read_timeout": 60, + }, + }, +) +print(result.message.content) +if result.usage is not None: + print(result.usage.model_dump()) +``` + +Runner `ctx.api.invoke_llm*` accepts the same `extra_args`, subject to run authorization and remaining time. Calls through `self.plugin` also inherit these restrictions inside a run context. + +`timeout=` is the SDK/RPC waiting limit (120 seconds by default for non-streaming calls). Core's `request_policy` controls the inner request. Keep its total budget within the enclosing RPC/Runner deadline; raising it cannot extend that deadline. Streaming methods have no `timeout=` argument and remain bounded by SDK/Runner waiting limits. + +| `request_policy` field | Default | Meaning | +| --- | --- | --- | +| `retries` | `0` | Additional attempts after the first, integer 0–5 | +| `total_timeout` | `300` | Whole invocation budget, including backoff | +| `first_event_timeout` | `120` | Wait for the first upstream event, not the first visible text | +| `stream_idle_timeout` | `120` | Idle time between stream events | +| `connect_timeout` | `10` | Connection timeout | +| `read_timeout` | `120` | Transport read timeout | +| `write_timeout` | `30` | Transport write timeout | +| `pool_timeout` | `10` | Connection pool acquisition timeout | +| `backoff_base` / `backoff_max` | `0.5` / `8` | Exponential backoff base and cap, with full jitter | +| `attribution` | `True` | Automatic application attribution headers | +| `conversation_header` | `None` | Generated session header name, described below | + +Times are seconds; timeouts must be finite and positive. Unknown policy fields are rejected. Precedence: instance `llm_request_policy`, model `extra_args`, then call `extra_args`. Within one layer, `request_policy.retries` wins over legacy `num_retries` / `max_retries`. A call-level legacy value still overrides model policy; `0` disables retries. Legacy numeric `extra_args["timeout"]` sets read, first-event and stream-idle limits without extending the total budget. + +Authentication, invalid-request and exhausted-quota failures are not retried. Retries stop after any upstream event, including reasoning or usage. `Retry-After` is bounded by the remaining budget. Core disables LiteLLM and provider SDK retries for managed calls; gateways may independently retry. This adds no tool replay, model fallback or embedding/rerank retries. Legacy third-party requesters still own their transport configuration. + +## Reasoning budgets + +Existing `reasoning_level` behavior is unchanged. For a numeric budget, pass this object as `extra_args` or save it in model advanced parameters. Omitting `level` means `enabled`: + +```json +{ + "reasoning_config": {"budget_tokens": 2048}, + "max_tokens": 4096 +} +``` + +Do not combine a budget with an effort level such as `medium`, `disabled`, or native reasoning parameters. Direct Anthropic calls require explicit `max_tokens` greater than the budget; Core never raises that limit automatically. + +Precedence: persisted model `reasoning_config`, model `extra_args.reasoning_config`, call `extra_args.reasoning_config`, then an explicit Runner level override. Each object replaces the previous one; an effort override clears a lower-priority budget. + +Numeric budgets map to native Anthropic, Gemini 2.5, supported Qwen models and OpenRouter routes. Direct OpenAI/Codex and adaptive-only Claude models retain effort control. Check the model's `reasoning_capabilities.budget_tokens` for support and bounds. Unsupported budgets fail before HTTP. Providers/gateways may impose stricter limits; OpenRouter may translate a numeric budget into effort. + +## Prompt caching + +`extra_args.prompt_caching` is consumed by Core, not forwarded unchanged. Call configuration replaces model configuration. The default `{"mode": "provider_default"}` adds no fields. This is not a local response cache. + +| Configuration | Behavior | +| --- | --- | +| `{"mode": "openai", "retention": "in_memory"}` | Generates a model-and-conversation-scoped `prompt_cache_key` when conversation context exists; optional `retention` also accepts `24h` if supported by the target | +| `{"mode": "anthropic", "ttl": "5m"}` | Adds an ephemeral breakpoint to the last text block of the leading system/developer prefix; `ttl` accepts `5m` or `1h` | + +OpenAI mode applies to compatible routes and OpenRouter OpenAI models supporting these options. Explicit native `prompt_cache_key`, `prompt_cache_retention` and `prompt_cache_options` win. Anthropic mode supports native Claude and OpenRouter Claude. Existing native cache markers are preserved without adding duplicates. Core does not reorder tools/history or mark user messages, and inserts nothing without an eligible prefix. + +Hints do not guarantee hits. Inspect `usage.model_dump()` for provider fields such as `prompt_tokens_details.cached_tokens` or `cache_read_input_tokens`; missing counts are not zero. Minimum prefix lengths, retention eligibility and pricing are provider-controlled. Codex's restricted Responses body does not accept this configuration object; existing server caching remains. General Responses migration and Gemini explicit cache-resource management are outside this change. + +## Application and session headers + +Direct OpenRouter calls receive `HTTP-Referer: https://langbot.app`, `X-OpenRouter-Title: LangBot`, and, with conversation context, `x-session-id: lb-`. Custom gateway addresses do not automatically inherit these headers. `request_policy.attribution=False` disables only automatic application attribution. Explicit `extra_headers` remain and take precedence case-insensitively. + +A gateway can use an explicitly configured session header: + +```json +{ + "request_policy": {"conversation_header": "x-litellm-session-id"}, + "extra_headers": { + "HTTP-Referer": "https://langbot.app", + "X-OpenRouter-Title": "LangBot" + } +} +``` + +Supported names: `x-session-id`, `x-litellm-session-id`, `x-langbot-conversation-id`. The last is a custom convention, not a universal provider standard, and provides no automatic routing or caching benefit. Generated identifiers are scoped by workspace/bot/pipeline/session and contain no raw session ID or prompt. They are pseudonymous, not a guarantee of anonymization, and are omitted without context. New API and other gateways may filter or rewrite headers; verify forwarding rules and actual outbound requests. + +## Stream cancellation and compatibility + +Use `aclosing` when leaving a stream early. Allow task cancellation exceptions to propagate: + +```python +from contextlib import aclosing +from langbot_plugin.api.entities.builtin.provider.message import Message + +async with aclosing(self.plugin.invoke_llm_stream_events( + llm_model_uuid=model_uuid, + messages=[Message(role="user", content="Hello!")], + extra_args={"request_policy": {"retries": 0, "total_timeout": 90}}, +)) as events: + async for event in events: + if event.usage is not None: + print(event.usage.model_dump()) + if event.chunk is not None and event.chunk.content: + print(event.chunk.content) + break +``` + +This example intentionally stops after the first nonempty content chunk and may miss final usage. For a complete response, remove `break`; a final event can have usage without a chunk. `invoke_llm` still returns `Message`, and `invoke_llm_stream` still yields `MessageChunk`. Choose `invoke_llm_with_usage` or `invoke_llm_stream_events` for accounting. Usage may be `None`; nested cache/reasoning counts and provider-specific fields are preserved. + +SDK ≤0.7.11 has a proxy-layer defect that delays upstream cancellation on early close. Deterministic close requires **langbot-plugin ≥0.7.12**, which publishes the fix from [SDK #177](https://github.com/langbot-app/langbot-plugin-sdk/pull/177). A Core dependency pin does not automatically upgrade a separately deployed Runtime or plugin development environment; upgrade those executing environments too. Ordinary signatures, payloads and return types remain compatible. Total deadlines include consumer time, but cannot interrupt a consumer while the generator is suspended at `yield`; expiration is enforced on the next pull. + +## Diagnostics + +Core monitoring records expose `details.timing`, complete `usage` and structured `error`; legacy SDK exception transport is unchanged. Structured errors include `code`, `status_code`, `retryable`, `retry_after`, `request_id` and `phase`. Do not assume these fields exist on legacy exceptions received by plugins. + +Millisecond timings include `preparation_ms`, `transport_setup_ms`, `first_event_ms`, `first_reasoning_ms`, `first_content_ms`, `first_visible_chunk_ms`, `total_ms`, plus `attempts` and `retry_wait_ms`. The first event may be reasoning/control, and the first visible chunk may include reasoning. `request_started_ms` marks the SDK call, not a packet leaving the socket. Missing fields mean unobserved, not zero. Monitoring retains partial usage received before cancellation but cannot reconstruct missing upstream accounting. diff --git a/ja/plugin/dev/apis/agent-run.mdx b/ja/plugin/dev/apis/agent-run.mdx index bd49ed18..4fa4c189 100644 --- a/ja/plugin/dev/apis/agent-run.mdx +++ b/ja/plugin/dev/apis/agent-run.mdx @@ -19,6 +19,8 @@ title: "コンテキスト API" `EventContext`、`ExecuteContext`、`Session` には `instance_uuid`、`workspace_uuid`、`placement_generation` も含まれます。これらは現在の実行に対応するインスタンスとワークスペースを示すためのもので、プラグイン側の認可情報として使用しないでください。 +リトライ、タイムアウト、数値思考予算、prompt caching、ヘッダー、ストリーム終了は [LLM リクエスト設定](/ja/plugin/dev/apis/llm-requests)を参照してください。 + ## EventListener EventListener はパイプラインイベントを処理します。イベントハンドラーは `EventContext` を受け取ります。 diff --git a/ja/plugin/dev/apis/common.mdx b/ja/plugin/dev/apis/common.mdx index 5b828483..c80087da 100644 --- a/ja/plugin/dev/apis/common.mdx +++ b/ja/plugin/dev/apis/common.mdx @@ -4,6 +4,8 @@ title: "LangBot API" モデル、ツール、ナレッジベース、ストレージには `self.plugin` を使用します。現在のイベント、返信、会話の状態については[コンテキスト API](/ja/plugin/dev/apis/agent-run) を参照してください。 +リトライ、タイムアウト、数値思考予算、prompt caching、ヘッダー、ストリーム終了は [LLM リクエスト設定](/ja/plugin/dev/apis/llm-requests)を参照してください。 + ## LangBot API これらのAPIは、通常のプラグインコンポーネントで呼び出すことができます。アクセス方法: diff --git a/ja/plugin/dev/apis/llm-requests.mdx b/ja/plugin/dev/apis/llm-requests.mdx new file mode 100644 index 00000000..dcf2cd7e --- /dev/null +++ b/ja/plugin/dev/apis/llm-requests.mdx @@ -0,0 +1,134 @@ +--- +title: "LLM リクエスト設定" +description: "プラグインと Runner のリトライ、タイムアウト、思考予算、キャッシュ、ストリーム終了" +--- + +本ページの設定には [Core #2661](https://github.com/langbot-app/LangBot/pull/2661) を含む Core が必要です。マージとリリースは別です。既存の `extra_args` で指定し、プラグインと Runner のシグネチャ、メッセージ型、権限確認は変わりません。[LangBot API](/ja/plugin/dev/apis/common) と[コンテキスト API](/ja/plugin/dev/apis/agent-run)も参照してください。 + +## リトライとタイムアウト + +非ストリーミング呼び出しでリクエスト設定と完全な使用量を扱う例です: + +```python +from langbot_plugin.api.entities.builtin.provider.message import Message + +result = await self.plugin.invoke_llm_with_usage( + llm_model_uuid=model_uuid, + messages=[Message(role="user", content="Hello!")], + timeout=110, + extra_args={ + "request_policy": { + "retries": 1, + "total_timeout": 90, + "first_event_timeout": 30, + "stream_idle_timeout": 30, + "connect_timeout": 10, + "read_timeout": 60, + }, + }, +) +print(result.message.content) +if result.usage is not None: + print(result.usage.model_dump()) +``` + +Runner の `ctx.api.invoke_llm*` でも同じ `extra_args` を使えますが、実行の権限と残り時間に制限されます。実行コンテキスト内の `self.plugin` 呼び出しにも同じ制限が適用されます。 + +`timeout=` は SDK/RPC の待機期限(非ストリーミングでは既定 120 秒)です。`request_policy` は Core 内部のリクエストを制御します。総予算は外側の RPC/Runner の期限内に収めてください。Core の予算を増やしても外側の期限は延長されません。ストリーミングメソッドには `timeout=` 引数がなく、SDK/Runner の待機期限が適用されます。 + +| `request_policy` フィールド | 既定値 | 意味 | +| --- | --- | --- | +| `retries` | `0` | 初回以外の追加試行数、整数 0–5 | +| `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` / `backoff_max` | `0.5` / `8` | full jitter を使う指数バックオフの基数と上限 | +| `attribution` | `True` | アプリ識別ヘッダーの自動付与 | +| `conversation_header` | `None` | 生成するセッションヘッダー名(後述) | + +時間は秒単位で、タイムアウトは有限の正数です。未知のフィールドは拒否されます。優先順位はインスタンスの `llm_request_policy`、モデルの `extra_args`、呼び出しの `extra_args` の順です。同じ階層では `request_policy.retries` が旧 `num_retries` / `max_retries` より優先されます。呼び出し側の旧パラメーターでもモデル設定を上書きでき、`0` でリトライを無効化できます。旧数値 `extra_args["timeout"]` は読み取り、最初のイベント、ストリーム待機を設定し、総予算は延長しません。 + +認証失敗、不正なリクエスト、クォータ不足はリトライしません。思考や usage を含む上流イベントを受信した後も再試行しません。`Retry-After` は残りの総予算内に制限されます。Core は管理対象の LiteLLM/プロバイダー SDK 内蔵リトライを無効化しますが、中継サービスは独自に再試行する場合があります。ツール再実行、モデル切り替え、embedding/rerank のリトライは追加しません。旧サードパーティ requester の内部通信設定はその実装が管理します。 + +## 思考予算 + +既存の `reasoning_level` は変わりません。数値予算は以下を `extra_args` またはモデルの詳細パラメーターに指定します。`level` を省略すると `enabled` です: + +```json +{ + "reasoning_config": {"budget_tokens": 2048}, + "max_tokens": 4096 +} +``` + +予算と effort レベル(例:`medium`)、`disabled`、プロバイダー独自の思考パラメーターを併用しないでください。Anthropic 直結では予算より大きい `max_tokens` を明示する必要があり、Core は出力上限を自動で増やしません。 + +優先順位は保存済みモデル `reasoning_config`、モデル `extra_args.reasoning_config`、呼び出し `extra_args.reasoning_config`、明示的な Runner レベル指定です。各オブジェクトが前の設定を全置換し、effort 指定は低優先度の数値予算を削除します。 + +数値予算はネイティブ Anthropic、Gemini 2.5、対応 Qwen、OpenRouter 経由に変換されます。OpenAI/Codex 直結と adaptive のみの Claude は effort を使います。モデルの `reasoning_capabilities.budget_tokens` で対応と範囲を確認してください。未対応なら HTTP 送信前にエラーになります。上流の制限がより厳しい場合や、OpenRouter が予算を effort に変換する場合もあります。 + +## Prompt caching + +`extra_args.prompt_caching` は Core 専用で、そのまま上流には送信されません。呼び出し設定がモデル設定を全置換します。既定の `{"mode": "provider_default"}` はフィールドを追加しません。ローカルの回答キャッシュではありません。 + +| 設定 | 動作 | +| --- | --- | +| `{"mode": "openai", "retention": "in_memory"}` | 会話コンテキストがあるとモデルと会話別の `prompt_cache_key` を生成。`retention` は省略可能で、対応モデルでは `24h` も指定可能 | +| `{"mode": "anthropic", "ttl": "5m"}` | 先頭の system/developer 接頭辞の最後のテキストブロックに一時キャッシュマーカーを追加。`ttl` は `5m` または `1h` | + +OpenAI モードは対応する互換ルートおよび OpenRouter の OpenAI モデル向けです。明示したネイティブ `prompt_cache_key`、`prompt_cache_retention`、`prompt_cache_options` が優先されます。Anthropic モードはネイティブ Claude と OpenRouter Claude に対応します。既存マーカーがあれば追加せず、ツール/履歴の並べ替えやユーザーメッセージへの追加もしません。適切な接頭辞がなければ何も追加しません。 + +キャッシュヒントはヒットを保証しません。`usage.model_dump()` の `prompt_tokens_details.cached_tokens`、`cache_read_input_tokens` などで確認します。未提供はゼロではありません。最小接頭辞長、保持期間の対応、料金はプロバイダー次第です。Codex の制限付き Responses ボディはこの設定を受け付けず、既存のサーバー側キャッシュを維持します。汎用 Responses 移行と Gemini 明示的キャッシュリソース管理は今回の対象外です。 + +## アプリとセッションのヘッダー + +OpenRouter 直結では `HTTP-Referer: https://langbot.app`、`X-OpenRouter-Title: LangBot`、会話コンテキストがあれば `x-session-id: lb-` を自動付与します。独自の中継 URL には自動付与しません。`request_policy.attribution=False` は自動アプリ識別のみを無効化します。明示した `extra_headers` は保持され、大文字小文字を区別せず優先されます。 + +中継サービスに合わせてセッションヘッダーを指定できます: + +```json +{ + "request_policy": {"conversation_header": "x-litellm-session-id"}, + "extra_headers": { + "HTTP-Referer": "https://langbot.app", + "X-OpenRouter-Title": "LangBot" + } +} +``` + +対応名は `x-session-id`、`x-litellm-session-id`、`x-langbot-conversation-id` です。最後は独自規約であり共通標準ではなく、自動的なルーティングやキャッシュ効果はありません。生成値は workspace/bot/pipeline/session ごとに分離され、生の ID やプロンプトを含みませんが、仮名化であり匿名化保証ではありません。コンテキストがなければ生成しません。New API などはヘッダーを削除・上書きする可能性があるため、転送設定と実際の出力を確認してください。 + +## ストリーム終了と互換性 + +途中でループを抜けるときは `aclosing` で生成器を確実に閉じ、タスクのキャンセル例外は伝播させてください: + +```python +from contextlib import aclosing +from langbot_plugin.api.entities.builtin.provider.message import Message + +async with aclosing(self.plugin.invoke_llm_stream_events( + llm_model_uuid=model_uuid, + messages=[Message(role="user", content="Hello!")], + extra_args={"request_policy": {"retries": 0, "total_timeout": 90}}, +)) as events: + async for event in events: + if event.usage is not None: + print(event.usage.model_dump()) + if event.chunk is not None and event.chunk.content: + print(event.chunk.content) + break +``` + +この例は最初の空でない内容で停止するため、最後の usage を受け取れない場合があります。全文取得では `break` を除きます。最後のイベントが chunk なしの usage のみの場合もあります。`invoke_llm` は引き続き `Message`、`invoke_llm_stream` は `MessageChunk` を返します。使用量には `invoke_llm_with_usage` または `invoke_llm_stream_events` を使います。usage は `None` の場合があり、ネストしたキャッシュ/思考使用量や上流独自フィールドは保持されます。 + +SDK ≤0.7.11 には、プロキシの早期終了が上流へ即時伝播しない問題があります。確実な終了には **langbot-plugin ≥0.7.12** が必要です。この公開版は [SDK #177](https://github.com/langbot-app/langbot-plugin-sdk/pull/177) の修正を含みます。Core の依存固定だけでは別配置の Runtime やプラグイン開発環境は更新されないため、実行する各環境も更新してください。通常のシグネチャ、ペイロード、戻り値は互換です。総期限は消費時間も含みますが、生成器が `yield` で停止中に消費側を中断せず、次の読み取りで期限切れを確認します。 + +## 診断 + +Core の監視レコード `details` に `timing`、完全な `usage`、構造化 `error` を記録します。旧 SDK の例外伝送形式は変更しません。構造化エラーは `code`、`status_code`、`retryable`、`retry_after`、`request_id`、`phase` を含みますが、プラグインが受け取る旧例外にこれらがあるとは限りません。 + +時間はミリ秒で、`preparation_ms`、`transport_setup_ms`、`first_event_ms`、`first_reasoning_ms`、`first_content_ms`、`first_visible_chunk_ms`、`total_ms`、`retry_wait_ms`、および試行数 `attempts` を記録します。最初のイベントは思考や制御の場合があり、最初の表示チャンクも思考を含む場合があります。`request_started_ms` は SDK 呼び出し時刻で、パケット送信時刻ではありません。欠落は未観測でありゼロではありません。キャンセル前の部分 usage は監視に保持されますが、未提供の使用量は復元できません。 diff --git a/scripts/verify-plugin-api-examples.py b/scripts/verify-plugin-api-examples.py index 1f984550..c7002da5 100644 --- a/scripts/verify-plugin-api-examples.py +++ b/scripts/verify-plugin-api-examples.py @@ -7,6 +7,7 @@ from __future__ import annotations import asyncio +import argparse import ast import inspect import re @@ -34,6 +35,7 @@ class Host: def __init__(self): self.calls = [] + self.closed_streams = 0 async def call_action(self, action, data, timeout=15): name = action.value @@ -74,9 +76,13 @@ async def call_action(self, action, data, timeout=15): async def call_action_generator(self, action, data, timeout=15): assert action.value == "invoke_llm_stream" - yield {"chunk": {"role": "assistant", "content": "Hello", "is_final": False}} - yield {"chunk": {"role": "assistant", "content": "!", "is_final": True}} - yield {"usage": {"total_tokens": 8}} + self.calls.append((action.value, data)) + try: + yield {"chunk": {"role": "assistant", "content": "Hello", "is_final": False}} + yield {"chunk": {"role": "assistant", "content": "!", "is_final": True}} + yield {"usage": {"total_tokens": 8}} + finally: + self.closed_streams += 1 def context(): @@ -111,7 +117,7 @@ def context(): def snippets(locale, page): - source = (ROOT / locale / f"plugin/dev/apis/{page}.mdx").read_text() + source = (ROOT / locale / f"plugin/dev/apis/{page}.mdx").read_text(encoding="utf-8") if page == "common": headings = { "zh": ("### 模型流式输出与用量", "## 其他资源 API"), @@ -125,7 +131,7 @@ def snippets(locale, page): def verify_context_reference(): - source = (ROOT / "zh/plugin/dev/apis/agent-run.mdx").read_text() + source = (ROOT / "zh/plugin/dev/apis/agent-run.mdx").read_text(encoding="utf-8") query_methods = { "reply", "get_bot_uuid", @@ -217,10 +223,10 @@ async def execute(block, namespace): await namespace["example"]() -async def main(): +async def main(pages): verify_context_reference() total = 0 - for page in ("common", "agent-run", "platform"): + for page in pages: blocks = snippets("zh", page) for locale in ("en", "ja"): assert blocks == snippets(locale, page), f"{page}: {locale} code differs" @@ -260,6 +266,13 @@ async def main(): } with bind_invocation(host, api): await execute(block, namespace) + if page == "llm-requests": + expected = 0 if "aclosing" in block else 1 + llm_calls = [data for name, data in host.calls if name.startswith("invoke_llm")] + assert len(llm_calls) == 1 + assert llm_calls[0]["extra_args"]["request_policy"]["retries"] == expected + if "aclosing" in block: + assert host.closed_streams == 1, "Early close did not reach the Host stream" for name, data in host.calls: if name in {"reply_message", "set_query_var"}: expected_query = 42 if "event_context" in block else 43 @@ -297,4 +310,11 @@ async def main(): if __name__ == "__main__": - asyncio.run(main()) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--page", action="append", + choices=("common", "agent-run", "platform", "llm-requests"), + help="Verify selected pages (repeatable); defaults to all pages.", + ) + args = parser.parse_args() + asyncio.run(main(args.page or ("common", "agent-run", "platform", "llm-requests"))) diff --git a/sitemap-alternates.xml b/sitemap-alternates.xml index 328ef6d0..48c87445 100644 --- a/sitemap-alternates.xml +++ b/sitemap-alternates.xml @@ -2079,6 +2079,27 @@ + + https://langbot.app/docs/en/plugin/dev/apis/llm-requests + + + + + + + https://langbot.app/docs/zh/plugin/dev/apis/llm-requests + + + + + + + https://langbot.app/docs/ja/plugin/dev/apis/llm-requests + + + + + https://langbot.app/docs/en/plugin/dev/apis/messages diff --git a/tests/agent-static-build.test.mjs b/tests/agent-static-build.test.mjs index 052a30b2..a03bf5ee 100644 --- a/tests/agent-static-build.test.mjs +++ b/tests/agent-static-build.test.mjs @@ -23,7 +23,7 @@ function links(node, result = []) { } test("every sitemap page has meaningful canonical Markdown and HTML discovery", async () => { - assert.equal(urls.length, 506 + zhOnlyArticleRoutes.length); + assert.equal(urls.length, 509 + zhOnlyArticleRoutes.length); for (const url of urls) { const route = relative(url); const text = await read(`${route}.md`); diff --git a/tests/fumapress-migration.test.mjs b/tests/fumapress-migration.test.mjs index 50f918c8..e10cbb51 100644 --- a/tests/fumapress-migration.test.mjs +++ b/tests/fumapress-migration.test.mjs @@ -170,8 +170,8 @@ test("MDX compatibility normalization removes internal .html route suffixes", () test("canonical MDX and nested OpenAPI sources are discovered", async () => { const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8")); const pages = await collectMdxDocuments(root); - assert.equal(pages.length, 332); - assert.deepEqual(Object.fromEntries([...pages.reduce((acc, page) => acc.set(page.split("/", 1)[0], (acc.get(page.split("/", 1)[0]) ?? 0) + 1), new Map()).entries()]), { en: 105, zh: 122, ja: 105 }); + assert.equal(pages.length, 335); + assert.deepEqual(Object.fromEntries([...pages.reduce((acc, page) => acc.set(page.split("/", 1)[0], (acc.get(page.split("/", 1)[0]) ?? 0) + 1), new Map()).entries()]), { en: 106, zh: 123, ja: 106 }); assert.deepEqual(collectOpenApiSources(docs), [ { locale: "en", source: "openapi/service-api-en.json", directory: "en/api-reference" }, { locale: "zh", source: "openapi/service-api-zh.json", directory: "zh/api-reference" }, @@ -216,7 +216,7 @@ test("prebuild is deterministic and preserves local assets and SEO files", async const first = await prepareFumapress({ root, outRoot: temp }); const second = await prepareFumapress({ root, outRoot: temp }); assert.deepEqual(first, second); - assert.equal(first.documents, 332); + assert.equal(first.documents, 335); assert.equal(first.fallbackDefaults, 0); assert.equal(first.localeOnlyDocuments, 17); assert.equal(first.redirects, 103); diff --git a/tests/fumapress-static-build.test.mjs b/tests/fumapress-static-build.test.mjs index 2fc4bb4e..b1060887 100644 --- a/tests/fumapress-static-build.test.mjs +++ b/tests/fumapress-static-build.test.mjs @@ -46,7 +46,7 @@ function staticHtmlForUrlPath(urlPath) { test("all canonical localized documents have static HTML", async () => { const documents = await collectMdxDocuments(root); - assert.equal(documents.length, 332); + assert.equal(documents.length, 335); const missing = []; for (const document of documents) { try { diff --git a/tests/locale-static-build.test.mjs b/tests/locale-static-build.test.mjs index 8f0d7910..91ebc66d 100644 --- a/tests/locale-static-build.test.mjs +++ b/tests/locale-static-build.test.mjs @@ -36,5 +36,5 @@ test("every localized HTML payload includes the preference client provider", asy } for (const locale of ["en", "zh", "ja"]) await visit(path.join(root, locale)); // 509 canonical content pages plus one framework 404 page per locale. - assert.equal(count, 512); + assert.equal(count, 515); }); diff --git a/zh/plugin/dev/apis/agent-run.mdx b/zh/plugin/dev/apis/agent-run.mdx index e04d76f9..98c069c8 100644 --- a/zh/plugin/dev/apis/agent-run.mdx +++ b/zh/plugin/dev/apis/agent-run.mdx @@ -19,6 +19,8 @@ title: "上下文 API" `EventContext`、`ExecuteContext` 和 `Session` 还会携带 `instance_uuid`、`workspace_uuid` 和 `placement_generation`。这些字段用于标识当前执行所在的实例和工作区,不应作为插件自行鉴权的依据。 +重试、超时、数字思考预算、prompt caching、请求头和流式取消见 [LLM 请求配置](/zh/plugin/dev/apis/llm-requests)。 + ## EventListener EventListener 处理流水线事件。事件处理函数接收 `EventContext`: diff --git a/zh/plugin/dev/apis/common.mdx b/zh/plugin/dev/apis/common.mdx index 950558c4..4084355b 100644 --- a/zh/plugin/dev/apis/common.mdx +++ b/zh/plugin/dev/apis/common.mdx @@ -4,6 +4,8 @@ title: "LangBot API" 模型、工具、知识库和存储等能力通过 `self.plugin` 访问。当前事件、回复和会话状态的访问方式见[上下文 API](/zh/plugin/dev/apis/agent-run)。 +重试、超时、数字思考预算、prompt caching、请求头和流式取消见 [LLM 请求配置](/zh/plugin/dev/apis/llm-requests)。 + ## LangBot API 这些 API 可在所有组件中通过 `self.plugin` 调用。访问方式如下: diff --git a/zh/plugin/dev/apis/llm-requests.mdx b/zh/plugin/dev/apis/llm-requests.mdx new file mode 100644 index 00000000..07f0e062 --- /dev/null +++ b/zh/plugin/dev/apis/llm-requests.mdx @@ -0,0 +1,134 @@ +--- +title: "LLM 请求配置" +description: "插件与 Runner 的重试、超时、思考预算、缓存和流式取消" +--- + +本页对应 [Core #2661](https://github.com/langbot-app/LangBot/pull/2661) 的请求层能力。部署的 Core 必须包含该改动;合并不代表已发布。新配置通过已有的 `extra_args` 传入,不改变插件或 Runner 的方法签名、消息返回类型和权限检查。方法列表见 [LangBot API](/zh/plugin/dev/apis/common),运行上下文见[上下文 API](/zh/plugin/dev/apis/agent-run)。 + +## 重试与超时 + +以下示例在一次非流式调用中设置请求策略,并读取完整用量: + +```python +from langbot_plugin.api.entities.builtin.provider.message import Message + +result = await self.plugin.invoke_llm_with_usage( + llm_model_uuid=model_uuid, + messages=[Message(role="user", content="Hello!")], + timeout=110, + extra_args={ + "request_policy": { + "retries": 1, + "total_timeout": 90, + "first_event_timeout": 30, + "stream_idle_timeout": 30, + "connect_timeout": 10, + "read_timeout": 60, + }, + }, +) +print(result.message.content) +if result.usage is not None: + print(result.usage.model_dump()) +``` + +Runner 的 `ctx.api.invoke_llm*` 同样接受 `extra_args`,并受当前运行的授权与剩余时间限制。`self.plugin` 在运行上下文内调用也会绑定这些限制。 + +`timeout=` 是 SDK/RPC 的等待期限,非流式默认 120 秒;`request_policy` 控制 Core 内部请求。总预算应小于外层 RPC 和 Runner 的剩余期限,增加 Core 预算不会延长外层期限。流式方法没有 `timeout=` 参数,仍受 SDK/Runner 的等待期限约束。 + +| `request_policy` 字段 | 默认值 | 含义 | +| --- | --- | --- | +| `retries` | `0` | 首次尝试之外的重试次数,整数 0–5 | +| `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` / `backoff_max` | `0.5` / `8` | 指数退避基数与上限,使用 full jitter | +| `attribution` | `True` | 自动添加应用身份头 | +| `conversation_header` | `None` | 指定生成的会话头名称,见下文 | + +时间单位为秒,超时必须是有限正数,未知策略字段会被拒绝。优先级为实例 `llm_request_policy`、模型 `extra_args`、调用 `extra_args`。同层 `request_policy.retries` 优先于旧的 `num_retries` / `max_retries`;调用层旧参数仍可覆盖模型层策略,`0` 禁用重试。旧的数值 `extra_args["timeout"]` 同时设置读取、首事件和流式空闲超时,不延长总预算。 + +鉴权、非法请求和额度耗尽不重试;收到任何上游事件后(包括思考或 usage)不再重试。`Retry-After` 受剩余总预算约束。Core 关闭托管调用的 LiteLLM 和供应商 SDK 内置重试,中转站仍可能自行重试。这里不增加工具重放、模型回退或 embedding/rerank 重试。旧第三方 requester 仍负责自己的传输配置。 + +## 思考预算 + +已有的 `reasoning_level` 保持不变。需要数字预算时,通过 `extra_args.reasoning_config` 设置,省略 `level` 表示 `enabled`: + +```json +{ + "reasoning_config": {"budget_tokens": 2048}, + "max_tokens": 4096 +} +``` + +将此对象作为调用的 `extra_args`,或保存到模型高级参数。数字预算不能与 effort 等级(如 `medium`)、`disabled` 或供应商原生思考参数混用。直接调用 Anthropic 时必须显式设置大于预算的 `max_tokens`,Core 不会自动提高输出上限。 + +配置依次来自模型 `reasoning_config`、模型 `extra_args.reasoning_config`、调用 `extra_args.reasoning_config`、显式 Runner 等级覆盖。每个对象整体替换前一个,后设置的 effort 等级会清除低优先级的预算。 + +数字预算适用于原生 Anthropic、Gemini 2.5、支持该能力的 Qwen 和 OpenRouter 路由;直接 OpenAI/Codex 及仅支持 adaptive 的 Claude 仍使用 effort。根据模型的 `reasoning_capabilities.budget_tokens` 判断支持情况与上下限,不支持时在发请求前报错。供应商或中转站可能有更严格的限制,OpenRouter 也可能将预算转换为 effort。 + +## Prompt caching + +`extra_args.prompt_caching` 是 Core 配置,不会原样发给供应商。调用配置整体替换模型配置,默认 `{"mode": "provider_default"}` 不添加字段。这不是本地回答缓存。 + +| 配置 | 行为 | +| --- | --- | +| `{"mode": "openai", "retention": "in_memory"}` | 有会话上下文时生成按模型和会话隔离的 `prompt_cache_key`;`retention` 可省略,也可为 `24h`,需要目标模型支持 | +| `{"mode": "anthropic", "ttl": "5m"}` | 在开头 system/developer 前缀最后一个文本块添加临时缓存标记;`ttl` 可为 `5m` 或 `1h` | + +OpenAI 模式用于支持相关参数的 OpenAI 兼容路由和 OpenRouter OpenAI 模型。显式原生 `prompt_cache_key`、`prompt_cache_retention`、`prompt_cache_options` 优先。Anthropic 模式支持原生 Claude 和 OpenRouter Claude;已有原生缓存标记时不重复添加,不重排工具/历史、不标记用户消息,没有合适的前缀则不添加。 + +缓存提示不保证命中。使用 `usage.model_dump()` 检查供应商返回的 `prompt_tokens_details.cached_tokens`、`cache_read_input_tokens` 等字段;缺失不等于零。前缀最小长度、保留时间支持与计费由供应商决定。Codex 的受限 Responses 请求不接受此配置对象,保留现有服务端缓存。此改动不包含通用 Responses 迁移或 Gemini 显式缓存资源管理。 + +## 应用与会话请求头 + +直连 OpenRouter 时自动添加 `HTTP-Referer: https://langbot.app`、`X-OpenRouter-Title: LangBot`,有会话上下文时还会添加 `x-session-id: lb-`。自定义中转地址不会自动继承 OpenRouter 身份头。`request_policy.attribution=False` 仅关闭自动应用身份头;显式 `extra_headers` 仍保留,并以不区分大小写的方式优先。 + +中转站可按其配置显式选择会话头: + +```json +{ + "request_policy": {"conversation_header": "x-litellm-session-id"}, + "extra_headers": { + "HTTP-Referer": "https://langbot.app", + "X-OpenRouter-Title": "LangBot" + } +} +``` + +支持 `x-session-id`、`x-litellm-session-id`、`x-langbot-conversation-id`。最后一个是自定义约定,不是通用供应商标准,不会自动带来路由或缓存收益。生成的值按 workspace/bot/pipeline/session 隔离,不发送原始会话标识或提示词;它是化名标识,不是强匿名保证。没有上下文时不生成。New API 等中转站可能过滤或改写头,需核对其转发配置与实际出站请求。 + +## 流式取消与兼容性 + +提前退出异步流时,使用 `aclosing` 确定性关闭生成器,任务取消时也应让取消异常继续传播: + +```python +from contextlib import aclosing +from langbot_plugin.api.entities.builtin.provider.message import Message + +async with aclosing(self.plugin.invoke_llm_stream_events( + llm_model_uuid=model_uuid, + messages=[Message(role="user", content="Hello!")], + extra_args={"request_policy": {"retries": 0, "total_timeout": 90}}, +)) as events: + async for event in events: + if event.usage is not None: + print(event.usage.model_dump()) + if event.chunk is not None and event.chunk.content: + print(event.chunk.content) + break +``` + +此例刻意只消费第一个非空内容块,提前结束可能收不到最终 usage。完整读取时不要 `break`,末尾可能只有 usage 而没有 chunk。`invoke_llm` 仍返回 `Message`,`invoke_llm_stream` 仍返回 `MessageChunk`;需要用量时选择 `invoke_llm_with_usage` 或 `invoke_llm_stream_events`,`usage` 可为 `None`,嵌套缓存、思考和供应商附加字段会保留。 + +SDK ≤0.7.11 存在代理层提前关闭不及时向上游传播的问题。确定性取消需要实际运行环境使用 **langbot-plugin ≥0.7.12**,该版本已发布 [SDK #177](https://github.com/langbot-app/langbot-plugin-sdk/pull/177) 的修复。Core 固定 SDK 依赖不会自动升级独立部署的 Runtime 或插件自己的开发环境;这些环境也需升级到包含修复的版本。普通调用的签名、参数载荷和返回结构保持兼容。总超时包含消费时间,但生成器停在 `yield` 时不会主动中断消费者,下一次拉取时才检查过期。 + +## 诊断 + +Core 监控记录的 `details` 提供 `timing`、完整 `usage` 与结构化 `error`,旧 SDK 的异常传输格式保持不变。结构化错误包含 `code`、`status_code`、`retryable`、`retry_after`、`request_id`、`phase`,不要假定插件收到的旧异常对象具有这些字段。 + +计时单位为毫秒,包括 `preparation_ms`、`transport_setup_ms`、`first_event_ms`、`first_reasoning_ms`、`first_content_ms`、`first_visible_chunk_ms`、`total_ms`、`attempts` 和 `retry_wait_ms`。首事件可能是思考或控制事件,首个可见块也可能包含思考;`request_started_ms` 是 SDK 调用时间,不是发出网络包的时间。缺失字段代表未观测到,不是零。取消前已收到的部分用量会留在监控中,但无法补出供应商未返回的用量。